From bd0a8142dfc97a9ddafdf563a8972ce7db268f45 Mon Sep 17 00:00:00 2001 From: pandec Date: Thu, 27 Aug 2026 13:16:27 +0200 Subject: [PATCH 1/8] chore: normalize sync-upstream ledger formatting --- .agents/skills/sync-upstream/LEDGER.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/sync-upstream/LEDGER.md b/.agents/skills/sync-upstream/LEDGER.md index 5fa700ac263f..2338c59a303c 100644 --- a/.agents/skills/sync-upstream/LEDGER.md +++ b/.agents/skills/sync-upstream/LEDGER.md @@ -110,7 +110,7 @@ Self-cleaning rules (apply during every sync's ledger update): - **`ChatMarkdown` resolves its environment with no active-environment fallback** (2026-08-26). The fork's split-view fix was `threadRef?.environmentId ?? activeEnvironmentId`; #7140 replaced it with `threadRef?.environmentId ?? explicitEnvironmentId ?? null` plus an explicit `environmentId` prop, and upstream's own review guidance now forbids a shared renderer falling back to the active environment. The fork's line was dropped by user decision because upstream's is a strict superset: every in-pane call site passes `threadRef`, and the thread-less surfaces (pull request panels) pass explicit scope. `null` there means "no environment", which correctly disables the file chip's open/reveal actions instead of aiming them at another machine. The fork's thread-scoped `claimWorkspaceBasenameLookup(key)` is separate and stays. Revisit only if upstream reintroduces an active-environment fallback. - **`unsettledAt` and `movedToTopAt` are separate anchors, composed by max** (2026-08-26). Upstream's #8231 `unsettledAt` is automatic (set on `thread.unsettled`, cleared on settle) and the fork's `movedToTopAt` is an explicit user bump; different triggers, same ordering axis, both worth keeping. The fork's composed sorters take `Math.max(base, unsettledAt, movedToTopAt)` where `base` is the latest-user-message-or-creation chain. **Never compose with upstream's `activeThreadAnchorTimestampMs` there** — it folds `createdAt` in unconditionally, which floors the base chain and makes an imported thread (fresh `createdAt`, old messages, see `SessionImportService`) sort as brand new. Both clients carry a regression test named "does not floor the latest-user-message key with creation time". Upstream's own `sortThreadsForSidebar` fast path may keep using the helper: there `base` already is `createdAt`. Revisit if upstream gives its anchor a manual-bump concept of its own. -- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs *before* the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment. +- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs _before_ the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment. ## Watchpoints From 19c0733456401c2d6e6149a537975b7507f8b282 Mon Sep 17 00:00:00 2001 From: pandec Date: Thu, 27 Aug 2026 13:16:34 +0200 Subject: [PATCH 2/8] fix(server): fork Claude threads in packaged desktop builds The fork driver spawned a node subprocess that resolved @anthropic-ai/claude-agent-sdk by name. The packaged desktop server is a single bundle with the SDK inlined, so the resolve threw 'Cannot find module' and every Claude fork failed with 'Conversation fork failed'. Call the statically imported forkSession in-process instead. The SDK reads CLAUDE_CONFIG_DIR from process.env at call time, so the driver swaps the variable to the instance's resolved config dir for the duration of the fork, serialized through a queue so concurrent forks against different config dirs cannot interleave. --- .../Drivers/ClaudeSessionFork.test.ts | 146 ++++++++++++------ .../src/provider/Drivers/ClaudeSessionFork.ts | 128 ++++++--------- .../src/provider/Layers/ClaudeAdapter.ts | 12 +- 3 files changed, 155 insertions(+), 131 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts b/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts index 82a40add52d3..fb2a83c9081a 100644 --- a/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts @@ -3,54 +3,104 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -import { forkClaudePersistedSession } from "./ClaudeSessionFork.ts"; - -it.layer(NodeServices.layer)("ClaudeSessionFork", (it) => { - it.effect("forks a real SDK transcript inside the configured Claude HOME", () => - Effect.acquireUseRelease( - Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))), - (homePath) => - Effect.gen(function* () { - const sourceSessionId = "11111111-1111-4111-8111-111111111111"; - const projectDirectory = NodePath.join( - homePath, - ".claude", - "projects", - "fixture-project", - ); - NodeFS.mkdirSync(projectDirectory, { recursive: true }); - NodeFS.writeFileSync( - NodePath.join(projectDirectory, `${sourceSessionId}.jsonl`), - [ - `{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${sourceSessionId}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`, - `{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${sourceSessionId}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`, - "", - ].join("\n"), - ); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const originalHome = process.env.HOME; - - const result = yield* forkClaudePersistedSession({ - sessionId: sourceSessionId, - environment: { ...process.env, HOME: homePath }, - spawner, - }); - - expect(result.sessionId).not.toBe(sourceSessionId); - expect( - NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`)), - ).toBe(true); - expect(process.env.HOME).toBe(originalHome); - }), - (homePath) => - Effect.sync(() => { - NodeFS.rmSync(homePath, { recursive: true, force: true }); - }), - ), + +import { ClaudeSessionForkError, forkClaudePersistedSession } from "./ClaudeSessionFork.ts"; + +const SOURCE_SESSION_ID = "11111111-1111-4111-8111-111111111111"; + +const withTempConfigDir = ( + use: (configDirPath: string) => Effect.Effect, +): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))), + use, + (configDirPath) => + Effect.sync(() => { + NodeFS.rmSync(configDirPath, { recursive: true, force: true }); + }), ); -}); + +const writeSourceTranscript = (configDirPath: string) => { + const projectDirectory = NodePath.join(configDirPath, "projects", "fixture-project"); + NodeFS.mkdirSync(projectDirectory, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(projectDirectory, `${SOURCE_SESSION_ID}.jsonl`), + [ + `{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${SOURCE_SESSION_ID}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`, + `{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${SOURCE_SESSION_ID}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`, + "", + ].join("\n"), + ); + return projectDirectory; +}; + +it.effect("forks a real SDK transcript inside the configured Claude config dir", () => + withTempConfigDir((configDirPath) => + Effect.gen(function* () { + const projectDirectory = writeSourceTranscript(configDirPath); + const originalConfigDir = process.env.CLAUDE_CONFIG_DIR; + + const result = yield* forkClaudePersistedSession({ + sessionId: SOURCE_SESSION_ID, + configDirPath, + }); + + expect(result.sessionId).not.toBe(SOURCE_SESSION_ID); + expect(NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`))).toBe( + true, + ); + expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir); + }), + ), +); + +it.effect("fails with ClaudeSessionForkError and restores the env for unknown sessions", () => + withTempConfigDir((configDirPath) => + Effect.gen(function* () { + const originalConfigDir = process.env.CLAUDE_CONFIG_DIR; + + const result = yield* forkClaudePersistedSession({ + sessionId: "99999999-9999-4999-8999-999999999999", + configDirPath, + }).pipe(Effect.flip); + + expect(result).toBeInstanceOf(ClaudeSessionForkError); + expect(result.sessionId).toBe("99999999-9999-4999-8999-999999999999"); + expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir); + }), + ), +); + +it.effect("serializes concurrent forks so each targets its own config dir", () => + withTempConfigDir((firstConfigDir) => + withTempConfigDir((secondConfigDir) => + Effect.gen(function* () { + const firstProject = writeSourceTranscript(firstConfigDir); + const secondProject = writeSourceTranscript(secondConfigDir); + + const [first, second] = yield* Effect.all( + [ + forkClaudePersistedSession({ + sessionId: SOURCE_SESSION_ID, + configDirPath: firstConfigDir, + }), + forkClaudePersistedSession({ + sessionId: SOURCE_SESSION_ID, + configDirPath: secondConfigDir, + }), + ], + { concurrency: "unbounded" }, + ); + + expect(NodeFS.existsSync(NodePath.join(firstProject, `${first.sessionId}.jsonl`))).toBe( + true, + ); + expect(NodeFS.existsSync(NodePath.join(secondProject, `${second.sessionId}.jsonl`))).toBe( + true, + ); + }), + ), + ), +); diff --git a/apps/server/src/provider/Drivers/ClaudeSessionFork.ts b/apps/server/src/provider/Drivers/ClaudeSessionFork.ts index 2c17f5bc23f7..ddbd7c10ef48 100644 --- a/apps/server/src/provider/Drivers/ClaudeSessionFork.ts +++ b/apps/server/src/provider/Drivers/ClaudeSessionFork.ts @@ -1,11 +1,6 @@ -import * as NodeModule from "node:module"; -import * as NodeURL from "node:url"; - +import { forkSession } from "@anthropic-ai/claude-agent-sdk"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts"; export class ClaudeSessionForkError extends Schema.TaggedErrorClass()( "ClaudeSessionForkError", @@ -16,85 +11,60 @@ export class ClaudeSessionForkError extends Schema.TaggedErrorClass = Promise.resolve(); -export const forkClaudePersistedSession = Effect.fn("forkClaudePersistedSession")(function (input: { - readonly sessionId: string; - readonly dir?: string; - readonly environment: NodeJS.ProcessEnv; - readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; -}) { - return Effect.gen(function* () { - const script = ` -const { forkSession } = await import(process.argv[1]); -const result = await forkSession(process.argv[2], process.argv[3] ? { dir: process.argv[3] } : undefined); -process.stdout.write(JSON.stringify(result)); -`; - const sdkModuleUrl = yield* Effect.try({ +const runWithClaudeConfigDir = (configDirPath: string, run: () => Promise): Promise => { + const task = forkQueue.then(async () => { + const previous = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDirPath; + try { + return await run(); + } finally { + if (previous === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previous; + } + } + }); + forkQueue = task.then( + () => undefined, + () => undefined, + ); + return task; +}; + +export const forkClaudePersistedSession = Effect.fn("forkClaudePersistedSession")( + function* (input: { + readonly sessionId: string; + readonly dir?: string; + readonly configDirPath: string; + }) { + const result = yield* Effect.tryPromise({ try: () => - NodeURL.pathToFileURL( - NodeModule.createRequire(import.meta.url).resolve("@anthropic-ai/claude-agent-sdk"), - ).href, + runWithClaudeConfigDir(input.configDirPath, () => + forkSession(input.sessionId, input.dir ? { dir: input.dir } : undefined), + ), catch: (cause) => new ClaudeSessionForkError({ sessionId: input.sessionId, - detail: "Unable to resolve the installed Claude Agent SDK module.", + detail: + cause instanceof Error && cause.message.length > 0 + ? cause.message + : "The Claude SDK failed to fork the session.", cause, }), }); - const child = yield* input.spawner - .spawn( - ChildProcess.make( - process.execPath, - ["--input-type=module", "--eval", script, sdkModuleUrl, input.sessionId, input.dir ?? ""], - { env: input.environment, extendEnv: false }, - ), - ) - .pipe( - Effect.mapError( - (cause) => - new ClaudeSessionForkError({ - sessionId: input.sessionId, - detail: "Unable to start the Claude SDK fork process.", - cause, - }), - ), - ); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectUint8StreamText({ stream: child.stdout }), - collectUint8StreamText({ stream: child.stderr }), - child.exitCode, - ], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError( - (cause) => - new ClaudeSessionForkError({ - sessionId: input.sessionId, - detail: "Unable to read the Claude SDK fork process result.", - cause, - }), - ), - ); - if (exitCode !== 0) { - return yield* new ClaudeSessionForkError({ - sessionId: input.sessionId, - detail: stderr.text.trim() || `Claude SDK fork process exited with code ${exitCode}.`, - }); - } - const result = yield* decodeClaudeForkProcessResult(stdout.text).pipe( - Effect.mapError( - (cause) => - new ClaudeSessionForkError({ - sessionId: input.sessionId, - detail: "Claude SDK fork process returned an invalid result.", - cause, - }), - ), - ); if (result.sessionId.length === 0) { return yield* new ClaudeSessionForkError({ sessionId: input.sessionId, @@ -102,5 +72,5 @@ process.stdout.write(JSON.stringify(result)); }); } return result; - }).pipe(Effect.scoped); -}); + }, +); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 5fdcd9140898..47766330b07e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -81,7 +81,6 @@ import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { ChildProcessSpawner } from "effect/unstable/process"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -1816,7 +1815,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const path = yield* Path.Path; const serverConfig = yield* ServerConfig; const crypto = yield* Crypto.Crypto; - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( Effect.provideService(Path.Path, path), ); @@ -4993,6 +4991,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const sourceSessionId = resumeState.resume; const forkOptions = input.cwd ? { dir: input.cwd } : undefined; + // Same pinning as session start: a relative CLAUDE_CONFIG_DIR/HOME + // resolves against the cwd the transcript lives under. + const forkConfigDirPath = yield* resolveClaudeConfigDirPath( + claudeSettings, + claudeEnvironment, + input.cwd, + ).pipe(Effect.provideService(Path.Path, path)); const forked = forkPersistedSession ? yield* Effect.tryPromise({ try: () => forkPersistedSession(sourceSessionId, forkOptions, claudeEnvironment), @@ -5007,8 +5012,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( : yield* forkClaudePersistedSession({ sessionId: sourceSessionId, ...(forkOptions?.dir ? { dir: forkOptions.dir } : {}), - environment: claudeEnvironment, - spawner: childProcessSpawner, + configDirPath: forkConfigDirPath, }).pipe( Effect.mapError( (cause) => From 8f8bd7660f8ffcdf7a07f64dbabeab4b5cf7cd4c Mon Sep 17 00:00:00 2001 From: pandec Date: Thu, 27 Aug 2026 13:23:07 +0200 Subject: [PATCH 3/8] style(server): infer fork queue promise types --- apps/server/src/provider/Drivers/ClaudeSessionFork.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeSessionFork.ts b/apps/server/src/provider/Drivers/ClaudeSessionFork.ts index ddbd7c10ef48..a67d7183657d 100644 --- a/apps/server/src/provider/Drivers/ClaudeSessionFork.ts +++ b/apps/server/src/provider/Drivers/ClaudeSessionFork.ts @@ -21,9 +21,9 @@ export class ClaudeSessionForkError extends Schema.TaggedErrorClass = Promise.resolve(); +let forkQueue = Promise.resolve(); -const runWithClaudeConfigDir = (configDirPath: string, run: () => Promise): Promise => { +const runWithClaudeConfigDir = (configDirPath: string, run: () => Promise) => { const task = forkQueue.then(async () => { const previous = process.env.CLAUDE_CONFIG_DIR; process.env.CLAUDE_CONFIG_DIR = configDirPath; From 505fceea9b2f7f77123311b97f1bb4ee747a9fc8 Mon Sep 17 00:00:00 2001 From: pandec Date: Thu, 27 Aug 2026 13:24:41 +0200 Subject: [PATCH 4/8] docs(server): note the fork serialization tradeoff --- apps/server/src/provider/Drivers/ClaudeSessionFork.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Drivers/ClaudeSessionFork.ts b/apps/server/src/provider/Drivers/ClaudeSessionFork.ts index a67d7183657d..c9a9a87eb9c2 100644 --- a/apps/server/src/provider/Drivers/ClaudeSessionFork.ts +++ b/apps/server/src/provider/Drivers/ClaudeSessionFork.ts @@ -19,7 +19,10 @@ export class ClaudeSessionForkError extends Schema.TaggedErrorClass Date: Thu, 27 Aug 2026 13:31:50 +0200 Subject: [PATCH 5/8] refactor(server): harden the in-process Claude fork after review Serialize forks with an Effect semaphore instead of a promise queue so a canceled waiter never runs its fork, and skip the env swap entirely when CLAUDE_CONFIG_DIR already matches. Snapshot makeClaudeEnvironment's base env instead of returning process.env by reference, so a session starting mid-fork cannot observe the temporary override. Unify the injected and production fork seams on one input shape so adapter tests assert the resolved config dir production uses, and validate the SDK result before trusting its session id. --- .../src/provider/Drivers/ClaudeHome.test.ts | 8 +- .../server/src/provider/Drivers/ClaudeHome.ts | 6 +- .../Drivers/ClaudeSessionFork.test.ts | 34 +++++++ .../src/provider/Drivers/ClaudeSessionFork.ts | 99 ++++++++++--------- .../src/provider/Layers/ClaudeAdapter.test.ts | 27 +++-- .../src/provider/Layers/ClaudeAdapter.ts | 66 +++++++------ 6 files changed, 143 insertions(+), 97 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index 334ebd29c5d5..f66daea519fe 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -24,9 +24,11 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { expect(yield* resolveClaudeConfigDirPath({ homePath: "" })).toBe( path.join(resolved, ".claude"), ); - expect(yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" })).toBe( - process.env, - ); + // A snapshot, never `process.env` by reference: a live reference + // would observe the fork driver's temporary CLAUDE_CONFIG_DIR swap. + const environment = yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" }); + expect(environment).not.toBe(process.env); + expect(environment).toEqual({ ...process.env }); }), ); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 1dd9d4e8be26..fdb8bea23066 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -79,7 +79,11 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function }; } const homePath = config.homePath.trim(); - if (homePath.length === 0) return resolvedBaseEnv; + // Copy instead of returning the base env by reference: when the base is + // `process.env`, a by-reference environment would observe the fork driver's + // temporary CLAUDE_CONFIG_DIR override (see ClaudeSessionFork.ts) at + // whatever moment a session start happens to snapshot it. + if (homePath.length === 0) return { ...resolvedBaseEnv }; const resolvedHomePath = yield* resolveClaudeHomePath(config); return { ...resolvedBaseEnv, diff --git a/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts b/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts index fb2a83c9081a..d20462b34ef3 100644 --- a/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts @@ -56,6 +56,40 @@ it.effect("forks a real SDK transcript inside the configured Claude config dir", ), ); +it.effect("forks without touching the env when CLAUDE_CONFIG_DIR already matches", () => + withTempConfigDir((configDirPath) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDirPath; + return previous; + }), + () => + Effect.gen(function* () { + const projectDirectory = writeSourceTranscript(configDirPath); + + const result = yield* forkClaudePersistedSession({ + sessionId: SOURCE_SESSION_ID, + configDirPath, + }); + + expect( + NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`)), + ).toBe(true); + expect(process.env.CLAUDE_CONFIG_DIR).toBe(configDirPath); + }), + (previous) => + Effect.sync(() => { + if (previous === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previous; + } + }), + ), + ), +); + it.effect("fails with ClaudeSessionForkError and restores the env for unknown sessions", () => withTempConfigDir((configDirPath) => Effect.gen(function* () { diff --git a/apps/server/src/provider/Drivers/ClaudeSessionFork.ts b/apps/server/src/provider/Drivers/ClaudeSessionFork.ts index c9a9a87eb9c2..b46ffc2bd0ba 100644 --- a/apps/server/src/provider/Drivers/ClaudeSessionFork.ts +++ b/apps/server/src/provider/Drivers/ClaudeSessionFork.ts @@ -1,6 +1,7 @@ import { forkSession } from "@anthropic-ai/claude-agent-sdk"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; export class ClaudeSessionForkError extends Schema.TaggedErrorClass()( "ClaudeSessionForkError", @@ -14,38 +15,20 @@ export class ClaudeSessionForkError extends Schema.TaggedErrorClass(configDirPath: string, run: () => Promise) => { - const task = forkQueue.then(async () => { - const previous = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = configDirPath; - try { - return await run(); - } finally { - if (previous === undefined) { - delete process.env.CLAUDE_CONFIG_DIR; - } else { - process.env.CLAUDE_CONFIG_DIR = previous; - } - } - }); - forkQueue = task.then( - () => undefined, - () => undefined, - ); - return task; -}; +const forkPermit = Semaphore.makeUnsafe(1); export const forkClaudePersistedSession = Effect.fn("forkClaudePersistedSession")( function* (input: { @@ -53,27 +36,49 @@ export const forkClaudePersistedSession = Effect.fn("forkClaudePersistedSession" readonly dir?: string; readonly configDirPath: string; }) { - const result = yield* Effect.tryPromise({ - try: () => - runWithClaudeConfigDir(input.configDirPath, () => - forkSession(input.sessionId, input.dir ? { dir: input.dir } : undefined), - ), - catch: (cause) => - new ClaudeSessionForkError({ - sessionId: input.sessionId, - detail: - cause instanceof Error && cause.message.length > 0 - ? cause.message - : "The Claude SDK failed to fork the session.", - cause, + const result = yield* forkPermit.withPermits(1)( + Effect.acquireUseRelease( + Effect.sync(() => { + if (process.env.CLAUDE_CONFIG_DIR === input.configDirPath) { + return { swapped: false as const, previous: undefined }; + } + const previous = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = input.configDirPath; + return { swapped: true as const, previous }; }), - }); - if (result.sessionId.length === 0) { + () => + Effect.uninterruptible( + Effect.tryPromise({ + try: () => forkSession(input.sessionId, input.dir ? { dir: input.dir } : undefined), + catch: (cause) => + new ClaudeSessionForkError({ + sessionId: input.sessionId, + detail: + cause instanceof Error && cause.message.length > 0 + ? cause.message + : "The Claude SDK failed to fork the session.", + cause, + }), + }), + ), + (swap) => + Effect.sync(() => { + if (!swap.swapped) return; + if (swap.previous === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = swap.previous; + } + }), + ), + ); + const forkedSessionId: unknown = (result as { sessionId?: unknown } | undefined)?.sessionId; + if (typeof forkedSessionId !== "string" || forkedSessionId.length === 0) { return yield* new ClaudeSessionForkError({ sessionId: input.sessionId, - detail: "Claude SDK returned an empty forked session id.", + detail: "Claude SDK returned an invalid forked session id.", }); } - return result; + return { sessionId: forkedSessionId }; }, ); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index d36cdbf29c67..dcfabb1cf544 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1313,15 +1313,13 @@ describe("ClaudeAdapterLive", () => { }); it.effect("forks a persisted Claude session without starting a live query", () => { - const forkCalls: Array< - readonly [string, { readonly dir?: string } | undefined, NodeJS.ProcessEnv | undefined] - > = []; - const forkSession: NonNullable = async ( - sessionId, - options, - environment, - ) => { - forkCalls.push([sessionId, options, environment]); + const forkCalls: Array<{ + readonly sessionId: string; + readonly dir?: string; + readonly configDirPath: string; + }> = []; + const forkSession: NonNullable = async (input) => { + forkCalls.push(input); return { sessionId: "22222222-2222-4222-8222-222222222222" }; }; const harness = makeHarness({ @@ -1342,12 +1340,11 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "full-access", }); - assert.equal(forkCalls[0]?.[0], "11111111-1111-4111-8111-111111111111"); - assert.deepEqual(forkCalls[0]?.[1], { dir: "/tmp/project" }); - assert.equal( - forkCalls[0]?.[2]?.CLAUDE_CONFIG_DIR, - NodePath.join(NodeOS.homedir(), ".claude-fork-work"), - ); + assert.deepEqual(forkCalls[0], { + sessionId: "11111111-1111-4111-8111-111111111111", + dir: "/tmp/project", + configDirPath: NodePath.join(NodeOS.homedir(), ".claude-fork-work"), + }); assert.deepEqual(result.resumeCursor, { threadId: "destination-thread", resume: "22222222-2222-4222-8222-222222222222", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 47766330b07e..697bfa07c29b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -91,7 +91,10 @@ import { listClaudeSessionTranscripts, readClaudeSessionTranscript, } from "../Drivers/ClaudeSessionImport.ts"; -import { forkClaudePersistedSession } from "../Drivers/ClaudeSessionFork.ts"; +import { + ClaudeSessionForkError, + forkClaudePersistedSession, +} from "../Drivers/ClaudeSessionFork.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; import { @@ -390,11 +393,11 @@ export interface ClaudeAdapterLiveOptions { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; }) => ClaudeQueryRuntime; - readonly forkSession?: ( - sessionId: string, - options?: { readonly dir?: string }, - environment?: NodeJS.ProcessEnv, - ) => Promise<{ readonly sessionId: string }>; + readonly forkSession?: (input: { + readonly sessionId: string; + readonly dir?: string; + readonly configDirPath: string; + }) => Promise<{ readonly sessionId: string }>; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } @@ -4990,7 +4993,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } const sourceSessionId = resumeState.resume; - const forkOptions = input.cwd ? { dir: input.cwd } : undefined; // Same pinning as session start: a relative CLAUDE_CONFIG_DIR/HOME // resolves against the cwd the transcript lives under. const forkConfigDirPath = yield* resolveClaudeConfigDirPath( @@ -4998,32 +5000,34 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( claudeEnvironment, input.cwd, ).pipe(Effect.provideService(Path.Path, path)); - const forked = forkPersistedSession - ? yield* Effect.tryPromise({ - try: () => forkPersistedSession(sourceSessionId, forkOptions, claudeEnvironment), - catch: (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/fork", - detail: `Failed to fork Claude session '${sourceSessionId}'.`, - cause, - }), - }) - : yield* forkClaudePersistedSession({ - sessionId: sourceSessionId, - ...(forkOptions?.dir ? { dir: forkOptions.dir } : {}), - configDirPath: forkConfigDirPath, - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/fork", - detail: `Failed to fork Claude session '${sourceSessionId}'.`, + const forkInput = { + sessionId: sourceSessionId, + ...(input.cwd ? { dir: input.cwd } : {}), + configDirPath: forkConfigDirPath, + }; + const forked = yield* ( + forkPersistedSession + ? Effect.tryPromise({ + try: () => forkPersistedSession(forkInput), + catch: (cause) => + new ClaudeSessionForkError({ + sessionId: sourceSessionId, + detail: "The injected fork dependency failed.", cause, }), - ), - ); + }) + : forkClaudePersistedSession(forkInput) + ).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/fork", + detail: `Failed to fork Claude session '${sourceSessionId}'.`, + cause, + }), + ), + ); return { resumeCursor: { threadId: input.destinationThreadId, From a8e4b0954fc5f04dc2af9fa81711fd784c1cac79 Mon Sep 17 00:00:00 2001 From: pandec Date: Thu, 27 Aug 2026 13:34:47 +0200 Subject: [PATCH 6/8] refactor(server): simplify fork env swap and validate via schema Drop the matching-env fast path (assigning an identical string is unobservable on a single thread), decode the SDK fork result with a schema instead of a predicate, share one ClaudeSessionForkInput type between the driver and the injectable seam, and centralize makeClaudeEnvironment's snapshot so every branch returns a copy. --- .../server/src/provider/Drivers/ClaudeHome.ts | 26 ++-- .../Drivers/ClaudeSessionFork.test.ts | 34 ----- .../src/provider/Drivers/ClaudeSessionFork.ts | 124 +++++++++--------- .../src/provider/Layers/ClaudeAdapter.ts | 7 +- 4 files changed, 76 insertions(+), 115 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index fdb8bea23066..8c4a19bedf82 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -61,7 +61,11 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function config: Pick, baseEnv?: NodeJS.ProcessEnv, ): Effect.fn.Return { - const resolvedBaseEnv = baseEnv ?? process.env; + // Always a copy, never the base env by reference: when the base is + // `process.env`, a by-reference environment would observe the fork driver's + // temporary CLAUDE_CONFIG_DIR override (see ClaudeSessionFork.ts) at + // whatever moment a session start happens to snapshot it. + const environment = { ...(baseEnv ?? process.env) }; // Isolate this instance's config via CLAUDE_CONFIG_DIR rather than HOME. // Overriding HOME also relocates the macOS login keychain lookup // ($HOME/Library/Keychains), so the spawned CLI can't find its stored @@ -73,22 +77,14 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function // The shadow dir wins over homePath: the CLI must read this account's // credentials, while shared state reaches the homePath dir through the // materialized symlinks. - return { - ...resolvedBaseEnv, - CLAUDE_CONFIG_DIR: shadowConfigDirPath, - }; + environment.CLAUDE_CONFIG_DIR = shadowConfigDirPath; + return environment; } const homePath = config.homePath.trim(); - // Copy instead of returning the base env by reference: when the base is - // `process.env`, a by-reference environment would observe the fork driver's - // temporary CLAUDE_CONFIG_DIR override (see ClaudeSessionFork.ts) at - // whatever moment a session start happens to snapshot it. - if (homePath.length === 0) return { ...resolvedBaseEnv }; - const resolvedHomePath = yield* resolveClaudeHomePath(config); - return { - ...resolvedBaseEnv, - CLAUDE_CONFIG_DIR: resolvedHomePath, - }; + if (homePath.length > 0) { + environment.CLAUDE_CONFIG_DIR = yield* resolveClaudeHomePath(config); + } + return environment; }); // The continuation key deliberately ignores `shadowHomePath`: a shadow diff --git a/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts b/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts index d20462b34ef3..fb2a83c9081a 100644 --- a/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts @@ -56,40 +56,6 @@ it.effect("forks a real SDK transcript inside the configured Claude config dir", ), ); -it.effect("forks without touching the env when CLAUDE_CONFIG_DIR already matches", () => - withTempConfigDir((configDirPath) => - Effect.acquireUseRelease( - Effect.sync(() => { - const previous = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = configDirPath; - return previous; - }), - () => - Effect.gen(function* () { - const projectDirectory = writeSourceTranscript(configDirPath); - - const result = yield* forkClaudePersistedSession({ - sessionId: SOURCE_SESSION_ID, - configDirPath, - }); - - expect( - NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`)), - ).toBe(true); - expect(process.env.CLAUDE_CONFIG_DIR).toBe(configDirPath); - }), - (previous) => - Effect.sync(() => { - if (previous === undefined) { - delete process.env.CLAUDE_CONFIG_DIR; - } else { - process.env.CLAUDE_CONFIG_DIR = previous; - } - }), - ), - ), -); - it.effect("fails with ClaudeSessionForkError and restores the env for unknown sessions", () => withTempConfigDir((configDirPath) => Effect.gen(function* () { diff --git a/apps/server/src/provider/Drivers/ClaudeSessionFork.ts b/apps/server/src/provider/Drivers/ClaudeSessionFork.ts index b46ffc2bd0ba..016704c9d99a 100644 --- a/apps/server/src/provider/Drivers/ClaudeSessionFork.ts +++ b/apps/server/src/provider/Drivers/ClaudeSessionFork.ts @@ -12,73 +12,75 @@ export class ClaudeSessionForkError extends Schema.TaggedErrorClass { + const previous = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = input.configDirPath; + return previous; + }), + () => + Effect.uninterruptible( + Effect.tryPromise({ + try: () => forkSession(input.sessionId, input.dir ? { dir: input.dir } : undefined), + catch: (cause) => + new ClaudeSessionForkError({ + sessionId: input.sessionId, + detail: + cause instanceof Error && cause.message.length > 0 + ? cause.message + : "The Claude SDK failed to fork the session.", + cause, + }), + }), + ), + (previous) => Effect.sync(() => { - if (process.env.CLAUDE_CONFIG_DIR === input.configDirPath) { - return { swapped: false as const, previous: undefined }; + if (previous === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previous; } - const previous = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = input.configDirPath; - return { swapped: true as const, previous }; }), - () => - Effect.uninterruptible( - Effect.tryPromise({ - try: () => forkSession(input.sessionId, input.dir ? { dir: input.dir } : undefined), - catch: (cause) => - new ClaudeSessionForkError({ - sessionId: input.sessionId, - detail: - cause instanceof Error && cause.message.length > 0 - ? cause.message - : "The Claude SDK failed to fork the session.", - cause, - }), - }), - ), - (swap) => - Effect.sync(() => { - if (!swap.swapped) return; - if (swap.previous === undefined) { - delete process.env.CLAUDE_CONFIG_DIR; - } else { - process.env.CLAUDE_CONFIG_DIR = swap.previous; - } - }), - ), - ); - const forkedSessionId: unknown = (result as { sessionId?: unknown } | undefined)?.sessionId; - if (typeof forkedSessionId !== "string" || forkedSessionId.length === 0) { - return yield* new ClaudeSessionForkError({ - sessionId: input.sessionId, - detail: "Claude SDK returned an invalid forked session id.", - }); - } - return { sessionId: forkedSessionId }; - }, -); + ), + ); + return yield* decodeForkedSession(result).pipe( + Effect.mapError( + (cause) => + new ClaudeSessionForkError({ + sessionId: input.sessionId, + detail: "Claude SDK returned an invalid forked session id.", + cause, + }), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 697bfa07c29b..e17b7565e2d1 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -93,6 +93,7 @@ import { } from "../Drivers/ClaudeSessionImport.ts"; import { ClaudeSessionForkError, + type ClaudeSessionForkInput, forkClaudePersistedSession, } from "../Drivers/ClaudeSessionFork.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; @@ -393,11 +394,7 @@ export interface ClaudeAdapterLiveOptions { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; }) => ClaudeQueryRuntime; - readonly forkSession?: (input: { - readonly sessionId: string; - readonly dir?: string; - readonly configDirPath: string; - }) => Promise<{ readonly sessionId: string }>; + readonly forkSession?: (input: ClaudeSessionForkInput) => Promise<{ readonly sessionId: string }>; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } From 44c83e83c342f31f7ecee69741f0a62471ad21bb Mon Sep 17 00:00:00 2001 From: pandec Date: Thu, 27 Aug 2026 13:49:14 +0200 Subject: [PATCH 7/8] docs(server): note accepted fork race windows --- apps/server/src/provider/Drivers/ClaudeSessionFork.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/server/src/provider/Drivers/ClaudeSessionFork.ts b/apps/server/src/provider/Drivers/ClaudeSessionFork.ts index 016704c9d99a..4b6196cf4b50 100644 --- a/apps/server/src/provider/Drivers/ClaudeSessionFork.ts +++ b/apps/server/src/provider/Drivers/ClaudeSessionFork.ts @@ -35,6 +35,13 @@ export interface ClaudeSessionForkInput { * Serializing every fork is a deliberate tradeoff: forks are rare, * user-initiated, and finish in milliseconds even for megabyte transcripts, * and the env var is process-global regardless of config dir. + * + * Two narrow windows are accepted rather than engineered away: a provider + * instance constructed during the swap can snapshot the override into its + * environment (requires a settings reload racing a custom-config-dir fork; + * the next reload rebinds it), and a shutdown-time interruption can leave a + * forked transcript on disk with no thread bound to it, where it simply + * becomes an importable session candidate. */ const forkPermit = Semaphore.makeUnsafe(1); From 203b5be97c1a6195dcb00be4f32bdb1aa2dad7e3 Mon Sep 17 00:00:00 2001 From: pandec Date: Thu, 27 Aug 2026 13:56:53 +0200 Subject: [PATCH 8/8] fix(server): address sol review of the in-process fork Prefer the live source session's pinned configDirPath so a relative CLAUDE_CONFIG_DIR/HOME is not re-resolved against a cwd that moved into a worktree; stopped sessions keep the restart-equivalent resolution. Make mergeProviderInstanceEnvironment always return a copy so no driver retains live process.env across the fork driver's temporary override. Make the fork concurrency test actually prove serialization by passing dir (the SDK awaits realpath before reading CLAUDE_CONFIG_DIR) and assert env restoration. --- .../Drivers/ClaudeSessionFork.test.ts | 38 +++++++++++++------ .../src/provider/Layers/ClaudeAdapter.ts | 17 +++++---- .../provider/ProviderInstanceEnvironment.ts | 10 ++--- 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts b/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts index fb2a83c9081a..da3ba05f3a13 100644 --- a/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts @@ -22,8 +22,8 @@ const withTempConfigDir = ( }), ); -const writeSourceTranscript = (configDirPath: string) => { - const projectDirectory = NodePath.join(configDirPath, "projects", "fixture-project"); +const writeSourceTranscript = (configDirPath: string, projectKey = "fixture-project") => { + const projectDirectory = NodePath.join(configDirPath, "projects", projectKey); NodeFS.mkdirSync(projectDirectory, { recursive: true }); NodeFS.writeFileSync( NodePath.join(projectDirectory, `${SOURCE_SESSION_ID}.jsonl`), @@ -77,29 +77,45 @@ it.effect("serializes concurrent forks so each targets its own config dir", () = withTempConfigDir((firstConfigDir) => withTempConfigDir((secondConfigDir) => Effect.gen(function* () { - const firstProject = writeSourceTranscript(firstConfigDir); - const secondProject = writeSourceTranscript(secondConfigDir); + const originalConfigDir = process.env.CLAUDE_CONFIG_DIR; + // Passing `dir` makes the SDK resolve it (awaited realpath) before it + // reads CLAUDE_CONFIG_DIR, so an unserialized implementation would + // read the other fork's override and fail to find its transcript. + const makeWorkspace = (configDirPath: string) => { + const workspace = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(configDirPath, "ws-")), + ); + const projectKey = workspace.replace(/[^a-zA-Z0-9]/g, "-"); + return { workspace, projectDirectory: writeSourceTranscript(configDirPath, projectKey) }; + }; + const first = makeWorkspace(firstConfigDir); + const second = makeWorkspace(secondConfigDir); - const [first, second] = yield* Effect.all( + const [firstFork, secondFork] = yield* Effect.all( [ forkClaudePersistedSession({ sessionId: SOURCE_SESSION_ID, + dir: first.workspace, configDirPath: firstConfigDir, }), forkClaudePersistedSession({ sessionId: SOURCE_SESSION_ID, + dir: second.workspace, configDirPath: secondConfigDir, }), ], { concurrency: "unbounded" }, ); - expect(NodeFS.existsSync(NodePath.join(firstProject, `${first.sessionId}.jsonl`))).toBe( - true, - ); - expect(NodeFS.existsSync(NodePath.join(secondProject, `${second.sessionId}.jsonl`))).toBe( - true, - ); + expect( + NodeFS.existsSync(NodePath.join(first.projectDirectory, `${firstFork.sessionId}.jsonl`)), + ).toBe(true); + expect( + NodeFS.existsSync( + NodePath.join(second.projectDirectory, `${secondFork.sessionId}.jsonl`), + ), + ).toBe(true); + expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir); }), ), ), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index e17b7565e2d1..0cbed51d43a9 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4990,13 +4990,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } const sourceSessionId = resumeState.resume; - // Same pinning as session start: a relative CLAUDE_CONFIG_DIR/HOME - // resolves against the cwd the transcript lives under. - const forkConfigDirPath = yield* resolveClaudeConfigDirPath( - claudeSettings, - claudeEnvironment, - input.cwd, - ).pipe(Effect.provideService(Path.Path, path)); + // Prefer the config dir the live source session pinned at start: a + // relative CLAUDE_CONFIG_DIR/HOME resolves against the cwd, and the + // session's cwd may have moved (worktrees) since the transcript was + // rooted. Stopped sessions re-resolve against the thread's cwd, the same + // resolution a restart of that session would perform. + const forkConfigDirPath = + sessions.get(input.sourceThreadId)?.configDirPath ?? + (yield* resolveClaudeConfigDirPath(claudeSettings, claudeEnvironment, input.cwd).pipe( + Effect.provideService(Path.Path, path), + )); const forkInput = { sessionId: sourceSessionId, ...(input.cwd ? { dir: input.cwd } : {}), diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts index e469253604e6..93bacc1d81f0 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -4,12 +4,12 @@ export function mergeProviderInstanceEnvironment( environment: ProviderInstanceEnvironment | undefined, baseEnv: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { - if (!environment || environment.length === 0) { - return baseEnv; - } - + // Always a copy, even without instance variables: drivers retain the result + // for the instance's lifetime, and a retained live `process.env` reference + // would observe the Claude fork driver's temporary CLAUDE_CONFIG_DIR swap + // (see ClaudeSessionFork.ts) at every later read. const next: NodeJS.ProcessEnv = { ...baseEnv }; - for (const variable of environment) { + for (const variable of environment ?? []) { next[variable.name] = variable.value; } return next;