From 2a4797575b67468bbbe96f3af062bd4efbe0c241 Mon Sep 17 00:00:00 2001 From: only21mil Date: Sat, 31 Oct 2026 19:36:04 +0000 Subject: [PATCH 1/4] fix(claude): Claude text generation stops saving throwaway transcripts Titles, commit messages, branch names, PR text and review guides ran claude -p without --no-session-persistence, so each run saved a resumable session to the user's Claude projects folder. Pass the flag, as the capability probe (persistSession: false) and Codex text generation (--ephemeral) already do. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/textGeneration/ClaudeTextGeneration.ts | 1 + .../textGeneration/testing/ClaudeTextGeneration.fixture.mjs | 3 +++ 2 files changed, 4 insertions(+) diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 8fa9ccb1c8094..0685ea0ae75f8 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -217,6 +217,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ] : ["--tools", ""]), "--disable-slash-commands", + "--no-session-persistence", // managed-mcp.json already isolates servers and makes Claude reject this flag. ...(managedMcpConfig ? [] : ["--strict-mcp-config"]), "--permission-mode", diff --git a/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs b/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs index d5ca2f185eb8d..13211c151d3c2 100644 --- a/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs +++ b/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs @@ -25,6 +25,9 @@ if (!argv.includes("--disable-slash-commands")) { if (!argv.includes("--strict-mcp-config")) { fail("text generation must not load configured MCP servers", 9); } +if (!argv.includes("--no-session-persistence")) { + fail("text generation must not save a resumable session", 16); +} const settingsIndex = argv.indexOf("--settings"); if (settingsIndex === -1 || JSON.parse(argv[settingsIndex + 1]).disableAllHooks !== true) { fail("text generation must disable hooks", 10); From 53f4b61acd6ba22c9836a9201a736b55d5e95138 Mon Sep 17 00:00:00 2001 From: only21mil Date: Sat, 31 Oct 2026 22:11:21 +0000 Subject: [PATCH 2/4] fix(claude): pass --no-session-persistence only to Claude Code 2.0.63+ Claude Code 2.0.60-2.0.62 run text generation but reject --no-session-persistence as an unknown option. Read the version the provider probe already detects and pass the flag only when it is 2.0.63 or newer; an unknown version omits it. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/Drivers/ClaudeDriver.ts | 1 + .../ClaudeTextGeneration.test.ts | 49 +++++++++++++++++++ .../textGeneration/ClaudeTextGeneration.ts | 10 +++- .../testing/ClaudeTextGeneration.fixture.mjs | 6 ++- 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e1fbcd30080fd..370d63592666f 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -193,6 +193,7 @@ export const ClaudeDriver: ProviderDriver snapshot.getSnapshot).pipe(Effect.map((current) => current.version)), processEnv, modelCatalog, ); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 3ea0502f83b33..5032db2ceb9e1 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -57,6 +57,8 @@ function makeFakeClaudeBinary(dir: string, lifecyclePort?: number) { function withFakeClaudeEnv( input: { output: string; + cliVersion?: string | null; + rejectsNoSessionPersistence?: boolean; lifecyclePort?: number; exitCode?: number; stderr?: string; @@ -75,6 +77,8 @@ function withFakeClaudeEnv( const binDir = yield* makeFakeClaudeBinary(tempDir, input.lifecyclePort); const pathDelimiter = (yield* HostProcess.isWindows) ? ";" : ":"; const previousPath = process.env.PATH; + const previousRejectsNoSessionPersistence = + process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE; const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE; const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR; @@ -89,6 +93,12 @@ function withFakeClaudeEnv( process.env.PATH = `${binDir}${pathDelimiter}${previousPath ?? ""}`; process.env.T3_FAKE_CLAUDE_OUTPUT = input.output; + if (input.rejectsNoSessionPersistence) { + process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE = "1"; + } else { + delete process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE; + } + if (input.exitCode !== undefined) { process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode); } else { @@ -135,6 +145,13 @@ function withFakeClaudeEnv( Effect.sync(() => { process.env.PATH = previousPath; + if (previousRejectsNoSessionPersistence === undefined) { + delete process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE; + } else { + process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE = + previousRejectsNoSessionPersistence; + } + if (previousOutput === undefined) { delete process.env.T3_FAKE_CLAUDE_OUTPUT; } else { @@ -188,6 +205,7 @@ function withFakeClaudeEnv( const config = decodeClaudeSettings(input.claudeConfig ?? {}); const textGeneration = yield* makeClaudeTextGeneration( config, + Effect.succeed(input.cliVersion === undefined ? "2.1.316" : input.cliVersion), undefined, Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), ); @@ -320,6 +338,37 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { }), ); + it.effect.each([ + { cliVersion: "2.0.63", rejectsNoSessionPersistence: false, label: "passes it to 2.0.63" }, + { cliVersion: "2.0.62", rejectsNoSessionPersistence: true, label: "omits it for 2.0.62" }, + { + cliVersion: null, + rejectsNoSessionPersistence: true, + label: "omits it for an unknown version", + }, + ])("--no-session-persistence $label", ({ cliVersion, rejectsNoSessionPersistence }) => + withFakeClaudeEnv( + { + output: JSON.stringify({ structured_output: { title: "Reconnect failures" } }), + cliVersion, + rejectsNoSessionPersistence, + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Investigate reconnect failures", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, + }, + }); + + expect(generated.title).toBe("Reconnect failures"); + }), + ), + ); + it.effect.each([ { verbose: false, label: "normal" }, { verbose: true, label: "verbose" }, diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 0685ea0ae75f8..20bab8b756ced 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -18,6 +18,7 @@ import { getModelSelectionStringOptionValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, @@ -49,6 +50,9 @@ const CLAUDE_INVESTIGATION_ALLOWED = [ // Denies override allows; block git --output/-o because allowed trailing args can otherwise write anywhere. const CLAUDE_INVESTIGATION_DENIED = ["Bash(git * --output*)", "Bash(git * -o *)"].join(","); +// Claude Code before 2.0.63 rejects --no-session-persistence as an unknown option. +const NO_SESSION_PERSISTENCE_MIN_VERSION = "2.0.63"; + const ClaudeOutputEnvelope = Schema.Struct({ structured_output: Schema.Unknown, }); @@ -79,6 +83,7 @@ function claudeErrorResult(stdout: string): string | undefined { export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( claudeSettings: ClaudeSettings, + cliVersion: Effect.Effect, environment?: NodeJS.ProcessEnv, modelCatalog: Effect.Effect = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), ) { @@ -193,6 +198,9 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ) : cwd; const managedMcpConfig = yield* hasClaudeManagedMcpConfig(); + const version = yield* cliVersion; + const supportsNoSessionPersistence = + version !== null && compareSemverVersions(version, NO_SESSION_PERSISTENCE_MIN_VERSION) >= 0; const spawnCommand = yield* resolveSpawnCommand( claudeSettings.binaryPath || "claude", [ @@ -217,7 +225,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ] : ["--tools", ""]), "--disable-slash-commands", - "--no-session-persistence", + ...(supportsNoSessionPersistence ? ["--no-session-persistence"] : []), // managed-mcp.json already isolates servers and makes Claude reject this flag. ...(managedMcpConfig ? [] : ["--strict-mcp-config"]), "--permission-mode", diff --git a/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs b/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs index 13211c151d3c2..22d406a4dcba0 100644 --- a/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs +++ b/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs @@ -25,7 +25,11 @@ if (!argv.includes("--disable-slash-commands")) { if (!argv.includes("--strict-mcp-config")) { fail("text generation must not load configured MCP servers", 9); } -if (!argv.includes("--no-session-persistence")) { +if (process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE === "1") { + if (argv.includes("--no-session-persistence")) { + fail("error: unknown option '--no-session-persistence'", 1); + } +} else if (!argv.includes("--no-session-persistence")) { fail("text generation must not save a resumable session", 16); } const settingsIndex = argv.indexOf("--settings"); From 7e0bde50f57b25a125459bb6a55c4c0a320d14fb Mon Sep 17 00:00:00 2001 From: only21mil Date: Sat, 31 Oct 2026 23:11:49 +0000 Subject: [PATCH 3/4] fix(claude): retry text generation without --no-session-persistence when the CLI rejects it The version gate read the provider probe's snapshot, which is null until the first probe finishes and comes from the state directory, while generation runs in a temp or project directory where a cwd-sensitive wrapper can launch a different Claude Code. Always pass the flag and, only when the CLI rejects it as an unknown option (Claude Code before 2.0.63), run once more without it. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/Drivers/ClaudeDriver.ts | 1 - .../ClaudeTextGeneration.test.ts | 15 ++------------ .../textGeneration/ClaudeTextGeneration.ts | 20 +++++++++---------- 3 files changed, 12 insertions(+), 24 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 370d63592666f..e1fbcd30080fd 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -193,7 +193,6 @@ export const ClaudeDriver: ProviderDriver snapshot.getSnapshot).pipe(Effect.map((current) => current.version)), processEnv, modelCatalog, ); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 5032db2ceb9e1..796ec5847e3ec 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -57,7 +57,6 @@ function makeFakeClaudeBinary(dir: string, lifecyclePort?: number) { function withFakeClaudeEnv( input: { output: string; - cliVersion?: string | null; rejectsNoSessionPersistence?: boolean; lifecyclePort?: number; exitCode?: number; @@ -205,7 +204,6 @@ function withFakeClaudeEnv( const config = decodeClaudeSettings(input.claudeConfig ?? {}); const textGeneration = yield* makeClaudeTextGeneration( config, - Effect.succeed(input.cliVersion === undefined ? "2.1.316" : input.cliVersion), undefined, Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), ); @@ -338,20 +336,11 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { }), ); - it.effect.each([ - { cliVersion: "2.0.63", rejectsNoSessionPersistence: false, label: "passes it to 2.0.63" }, - { cliVersion: "2.0.62", rejectsNoSessionPersistence: true, label: "omits it for 2.0.62" }, - { - cliVersion: null, - rejectsNoSessionPersistence: true, - label: "omits it for an unknown version", - }, - ])("--no-session-persistence $label", ({ cliVersion, rejectsNoSessionPersistence }) => + it.effect("retries without --no-session-persistence when the Claude CLI rejects it", () => withFakeClaudeEnv( { output: JSON.stringify({ structured_output: { title: "Reconnect failures" } }), - cliVersion, - rejectsNoSessionPersistence, + rejectsNoSessionPersistence: true, }, (textGeneration) => Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 20bab8b756ced..fcd2deb206bef 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -18,7 +18,6 @@ import { getModelSelectionStringOptionValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; -import { compareSemverVersions } from "@t3tools/shared/semver"; import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, @@ -50,8 +49,10 @@ const CLAUDE_INVESTIGATION_ALLOWED = [ // Denies override allows; block git --output/-o because allowed trailing args can otherwise write anywhere. const CLAUDE_INVESTIGATION_DENIED = ["Bash(git * --output*)", "Bash(git * -o *)"].join(","); -// Claude Code before 2.0.63 rejects --no-session-persistence as an unknown option. -const NO_SESSION_PERSISTENCE_MIN_VERSION = "2.0.63"; +const NO_SESSION_PERSISTENCE = "--no-session-persistence"; +// Claude Code before 2.0.63 rejects the flag; those runs retry without it. +const rejectsNoSessionPersistence = (error: TextGenerationError) => + error.detail.includes(`unknown option '${NO_SESSION_PERSISTENCE}'`); const ClaudeOutputEnvelope = Schema.Struct({ structured_output: Schema.Unknown, @@ -83,7 +84,6 @@ function claudeErrorResult(stdout: string): string | undefined { export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( claudeSettings: ClaudeSettings, - cliVersion: Effect.Effect, environment?: NodeJS.ProcessEnv, modelCatalog: Effect.Effect = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), ) { @@ -186,7 +186,9 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu "Failed to encode Claude CLI settings.", ); - const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { + const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* ( + noSessionPersistence: boolean, + ) { const workingDirectory = operation === "generateThreadTitle" ? yield* fileSystem @@ -198,9 +200,6 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ) : cwd; const managedMcpConfig = yield* hasClaudeManagedMcpConfig(); - const version = yield* cliVersion; - const supportsNoSessionPersistence = - version !== null && compareSemverVersions(version, NO_SESSION_PERSISTENCE_MIN_VERSION) >= 0; const spawnCommand = yield* resolveSpawnCommand( claudeSettings.binaryPath || "claude", [ @@ -225,7 +224,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ] : ["--tools", ""]), "--disable-slash-commands", - ...(supportsNoSessionPersistence ? ["--no-session-persistence"] : []), + ...(noSessionPersistence ? [NO_SESSION_PERSISTENCE] : []), // managed-mcp.json already isolates servers and makes Claude reject this flag. ...(managedMcpConfig ? [] : ["--strict-mcp-config"]), "--permission-mode", @@ -282,7 +281,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu return stdout; }); - const rawStdout = yield* runClaudeCommand().pipe( + const rawStdout = yield* runClaudeCommand(true).pipe( + Effect.catchIf(rejectsNoSessionPersistence, () => runClaudeCommand(false)), Effect.scoped, Effect.timeoutOption(investigate?.timeoutMs ?? CLAUDE_TIMEOUT_MS), Effect.flatMap( From 940b8a5b631c6525b40da971bfb866a534f02fa3 Mon Sep 17 00:00:00 2001 From: only21mil Date: Sat, 31 Oct 2026 23:48:51 +0000 Subject: [PATCH 4/4] fix(claude): retry without --no-session-persistence only on the CLI's stderr rejection The retry matched the combined error detail, which prefers a stdout error result, so an API or model error mentioning the option could trigger it. Decide from the exit code and stderr instead, and build the reported error after that decision. Note in the Usage guide that Claude Code and Codex text generation is not in the totals. Co-Authored-By: Claude Opus 5.5 --- .../ClaudeTextGeneration.test.ts | 8 ++++ .../textGeneration/ClaudeTextGeneration.ts | 41 +++++++++++-------- docs/user/usage.md | 2 + 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 796ec5847e3ec..197d412d93405 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -468,6 +468,14 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { apiError, ], ["stderr when stdout has no error", "not json", wrapperNotice], + [ + "a stdout error naming --no-session-persistence without retrying", + JSON.stringify({ + is_error: true, + result: "error: unknown option '--no-session-persistence'", + }), + "error: unknown option '--no-session-persistence'", + ], ] as const)("reports %s when the Claude CLI fails", ([, output, expected]) => withFakeClaudeEnv({ output, stderr: wrapperNotice, exitCode: 1 }, (textGeneration) => Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index fcd2deb206bef..51acced2745e5 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -50,9 +50,8 @@ const CLAUDE_INVESTIGATION_ALLOWED = [ const CLAUDE_INVESTIGATION_DENIED = ["Bash(git * --output*)", "Bash(git * -o *)"].join(","); const NO_SESSION_PERSISTENCE = "--no-session-persistence"; -// Claude Code before 2.0.63 rejects the flag; those runs retry without it. -const rejectsNoSessionPersistence = (error: TextGenerationError) => - error.detail.includes(`unknown option '${NO_SESSION_PERSISTENCE}'`); +// Claude Code before 2.0.63 exits with this on stderr; those runs retry without the flag. +const NO_SESSION_PERSISTENCE_REJECTED = `error: unknown option '${NO_SESSION_PERSISTENCE}'`; const ClaudeOutputEnvelope = Schema.Struct({ structured_output: Schema.Unknown, @@ -263,26 +262,32 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu { concurrency: "unbounded" }, ); - if (exitCode !== 0) { + return { stdout, stderr, exitCode }; + }); + + const rawStdout = yield* runClaudeCommand(true).pipe( + Effect.filterOrElse( + (result) => + result.exitCode === 0 || !result.stderr.includes(NO_SESSION_PERSISTENCE_REJECTED), + () => runClaudeCommand(false), + ), + Effect.flatMap(({ stdout, stderr, exitCode }) => { + if (exitCode === 0) return Effect.succeed(stdout); const stderrDetail = stderr.trim(); const stdoutDetail = stdout.trim(); const detail = claudeErrorResult(stdoutDetail) ?? (stderrDetail.length > 0 ? stderrDetail : stdoutDetail); - return yield* new TextGenerationError({ - operation, - detail: - detail.length > 0 - ? `Claude CLI command failed: ${detail}` - : `Claude CLI command failed with code ${exitCode}.`, - }); - } - - return stdout; - }); - - const rawStdout = yield* runClaudeCommand(true).pipe( - Effect.catchIf(rejectsNoSessionPersistence, () => runClaudeCommand(false)), + return Effect.fail( + new TextGenerationError({ + operation, + detail: + detail.length > 0 + ? `Claude CLI command failed: ${detail}` + : `Claude CLI command failed with code ${exitCode}.`, + }), + ); + }), Effect.scoped, Effect.timeoutOption(investigate?.timeoutMs ?? CLAUDE_TIMEOUT_MS), Effect.flatMap( diff --git a/docs/user/usage.md b/docs/user/usage.md index 447924a072eff..0289defe8bc71 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -17,6 +17,8 @@ Select a model under **Breakdown** to see its trend, cache hit rate, and cost pe Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. +Text generation (thread titles, commit messages, PR text, branch names and review guides) is not in +the totals for Codex or Claude Code 2.0.63 and newer, because those runs save no session history. OpenCode reads its SQLite database and older JSON history. Antigravity reads local conversation databases, including T3-managed profiles. Set `OPENCODE_DATA_DIR` or `ANTIGRAVITY_DATA_DIR` on the