feat(adapter): claude local chrome flag and max-turns session handling

Add --chrome flag support for Claude adapter. Detect max-turns
exhaustion (via subtype, stop_reason, or result text) and clear
the session to prevent stale session re-use. Add unit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-26 16:33:10 -06:00
parent b0f3f04ac6
commit 9e89ca4a9e
9 changed files with 72 additions and 2 deletions

View File

@@ -160,6 +160,7 @@ export interface CreateConfigValues {
promptTemplate: string;
model: string;
thinkingEffort: string;
chrome: boolean;
dangerouslySkipPermissions: boolean;
search: boolean;
dangerouslyBypassSandbox: boolean;

View File

@@ -15,6 +15,7 @@ Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process
- model (string, optional): Claude model id
- effort (string, optional): reasoning effort passed via --effort (low|medium|high)
- chrome (boolean, optional): pass --chrome when running Claude
- promptTemplate (string, optional): run prompt template
- bootstrapPromptTemplate (string, optional): first-run prompt template
- maxTurnsPerRun (number, optional): max turns for one run

View File

@@ -23,6 +23,7 @@ import {
parseClaudeStreamJson,
describeClaudeFailure,
detectClaudeLoginRequired,
isClaudeMaxTurnsResult,
isClaudeUnknownSessionError,
} from "./parse.js";
@@ -263,6 +264,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const bootstrapTemplate = asString(config.bootstrapPromptTemplate, promptTemplate);
const model = asString(config.model, "");
const effort = asString(config.effort, "");
const chrome = asBoolean(config.chrome, false);
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
@@ -315,6 +317,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
if (resumeSessionId) args.push("--resume", resumeSessionId);
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
if (chrome) args.push("--chrome");
if (model) args.push("--model", model);
if (effort) args.push("--effort", effort);
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
@@ -439,6 +442,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
} as Record<string, unknown>)
: null;
const clearSessionForMaxTurns = isClaudeMaxTurnsResult(parsed);
return {
exitCode: proc.exitCode,
@@ -460,7 +464,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
resultJson: parsed,
summary: parsedStream.summary || asString(parsed.result, ""),
clearSession: Boolean(opts.clearSessionOnMissingSession && !resolvedSessionId),
clearSession: clearSessionForMaxTurns || Boolean(opts.clearSessionOnMissingSession && !resolvedSessionId),
};
};

View File

@@ -1,6 +1,11 @@
export { execute, runClaudeLogin } from "./execute.js";
export { testEnvironment } from "./test.js";
export { parseClaudeStreamJson, describeClaudeFailure, isClaudeUnknownSessionError } from "./parse.js";
export {
parseClaudeStreamJson,
describeClaudeFailure,
isClaudeMaxTurnsResult,
isClaudeUnknownSessionError,
} from "./parse.js";
import type { AdapterSessionCodec } from "@paperclip/adapter-utils";
function readNonEmptyString(value: unknown): string | null {

View File

@@ -154,6 +154,19 @@ export function describeClaudeFailure(parsed: Record<string, unknown>): string |
return parts.length > 1 ? parts.join(": ") : null;
}
export function isClaudeMaxTurnsResult(parsed: Record<string, unknown> | null | undefined): boolean {
if (!parsed) return false;
const subtype = asString(parsed.subtype, "").trim().toLowerCase();
if (subtype === "error_max_turns") return true;
const stopReason = asString(parsed.stop_reason, "").trim().toLowerCase();
if (stopReason === "max_turns") return true;
const resultText = asString(parsed.result, "").trim();
return /max(?:imum)?\s+turns?/i.test(resultText);
}
export function isClaudeUnknownSessionError(parsed: Record<string, unknown>): boolean {
const resultText = asString(parsed.result, "").trim();
const allMessages = [resultText, ...extractClaudeErrorMessages(parsed)]

View File

@@ -57,6 +57,7 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.effort = v.thinkingEffort;
if (v.chrome) ac.chrome = true;
ac.timeoutSec = 0;
ac.graceSec = 15;
const env = parseEnvBindings(v.envBindings);

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { isClaudeMaxTurnsResult } from "@paperclip/adapter-claude-local/server";
describe("claude_local max-turn detection", () => {
it("detects max-turn exhaustion by subtype", () => {
expect(
isClaudeMaxTurnsResult({
subtype: "error_max_turns",
result: "Reached max turns",
}),
).toBe(true);
});
it("detects max-turn exhaustion by stop_reason", () => {
expect(
isClaudeMaxTurnsResult({
stop_reason: "max_turns",
}),
).toBe(true);
});
it("returns false for non-max-turn results", () => {
expect(
isClaudeMaxTurnsResult({
subtype: "success",
stop_reason: "end_turn",
}),
).toBe(false);
});
});

View File

@@ -24,6 +24,20 @@ export function ClaudeLocalAdvancedFields({
}: AdapterConfigFieldsProps) {
return (
<>
<ToggleField
label="Enable Chrome"
hint={help.chrome}
checked={
isCreate
? values!.chrome
: eff("adapterConfig", "chrome", config.chrome === true)
}
onChange={(v) =>
isCreate
? set!({ chrome: v })
: mark("adapterConfig", "chrome", v)
}
/>
<ToggleField
label="Skip permissions"
hint={help.dangerouslySkipPermissions}

View File

@@ -6,6 +6,7 @@ export const defaultCreateValues: CreateConfigValues = {
promptTemplate: "",
model: "",
thinkingEffort: "",
chrome: false,
dangerouslySkipPermissions: false,
search: false,
dangerouslyBypassSandbox: false,