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 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..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,18 +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(); - 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 82a40add52d3..da3ba05f3a13 100644 --- a/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts @@ -3,54 +3,120 @@ 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"), + +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, projectKey = "fixture-project") => { + const projectDirectory = NodePath.join(configDirPath, "projects", projectKey); + 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 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 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 }); - }), + const projectKey = workspace.replace(/[^a-zA-Z0-9]/g, "-"); + return { workspace, projectDirectory: writeSourceTranscript(configDirPath, projectKey) }; + }; + const first = makeWorkspace(firstConfigDir); + const second = makeWorkspace(secondConfigDir); + + 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(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/Drivers/ClaudeSessionFork.ts b/apps/server/src/provider/Drivers/ClaudeSessionFork.ts index 2c17f5bc23f7..4b6196cf4b50 100644 --- a/apps/server/src/provider/Drivers/ClaudeSessionFork.ts +++ b/apps/server/src/provider/Drivers/ClaudeSessionFork.ts @@ -1,11 +1,7 @@ -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"; +import * as Semaphore from "effect/Semaphore"; export class ClaudeSessionForkError extends Schema.TaggedErrorClass()( "ClaudeSessionForkError", @@ -16,91 +12,82 @@ export class ClaudeSessionForkError extends Schema.TaggedErrorClass - NodeURL.pathToFileURL( - NodeModule.createRequire(import.meta.url).resolve("@anthropic-ai/claude-agent-sdk"), - ).href, - catch: (cause) => + readonly configDirPath: string; +} + +/** + * The SDK resolves its config directory from `process.env.CLAUDE_CONFIG_DIR` + * at call time, so the fork briefly swaps that variable to the instance's + * config dir and restores it afterwards. The semaphore serializes forks so + * concurrent instances with different config dirs never observe each other's + * override; waiting for the permit stays interruptible, while the swap, SDK + * call, and restore run as one uninterruptible section. The fork runs + * in-process on the statically imported SDK: the packaged desktop server is a + * single bundle with no resolvable `@anthropic-ai/claude-agent-sdk` on disk, + * so spawning a subprocess that imports the SDK by name cannot work there. + * 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); + +export const forkClaudePersistedSession = Effect.fn("forkClaudePersistedSession")(function* ( + input: ClaudeSessionForkInput, +) { + const result = yield* forkPermit.withPermits(1)( + Effect.acquireUseRelease( + Effect.sync(() => { + 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 (previous === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previous; + } + }), + ), + ); + return yield* decodeForkedSession(result).pipe( + Effect.mapError( + (cause) => new ClaudeSessionForkError({ sessionId: input.sessionId, - detail: "Unable to resolve the installed Claude Agent SDK module.", + detail: "Claude SDK returned an invalid forked session id.", 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, - detail: "Claude SDK returned an empty forked session id.", - }); - } - return result; - }).pipe(Effect.scoped); + ), + ); }); 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 5fdcd9140898..0cbed51d43a9 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"; @@ -92,7 +91,11 @@ import { listClaudeSessionTranscripts, readClaudeSessionTranscript, } from "../Drivers/ClaudeSessionImport.ts"; -import { forkClaudePersistedSession } from "../Drivers/ClaudeSessionFork.ts"; +import { + ClaudeSessionForkError, + type ClaudeSessionForkInput, + forkClaudePersistedSession, +} from "../Drivers/ClaudeSessionFork.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; import { @@ -391,11 +394,7 @@ 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: ClaudeSessionForkInput) => Promise<{ readonly sessionId: string }>; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } @@ -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), ); @@ -4992,34 +4990,44 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } const sourceSessionId = resumeState.resume; - const forkOptions = input.cwd ? { dir: input.cwd } : undefined; - 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 } : {}), - environment: claudeEnvironment, - spawner: childProcessSpawner, - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/fork", - detail: `Failed to fork Claude session '${sourceSessionId}'.`, + // 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 } : {}), + 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, 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;