From 5e17955604f7044c5e8f55d43592cae52ac11d81 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 3 Oct 2026 02:34:54 -0300 Subject: [PATCH 1/2] fix(server): Claude V2 turns start on Windows with the default binary path (#15021) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> (cherry picked from commit 8283b481a34f5cc6f984f290e7cb93cc35b4dc56) --- .../Adapters/ClaudeAdapterV2.test.ts | 140 +++++++++++------- .../Adapters/ClaudeAdapterV2.ts | 7 +- 2 files changed, 90 insertions(+), 57 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index a4164c936fdf..f14cbd3e8724 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -45,6 +45,8 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { Tool } from "effect/unstable/ai"; import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; @@ -55,6 +57,7 @@ import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; +import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; import { ProviderAdapterV2RuntimePolicy, @@ -1064,72 +1067,97 @@ describe("ClaudeAdapterV2 approval cancellation", () => { ); }); +// Opens a session with the given configured binary path, runs one turn, and +// returns the executable paths the SDK was asked to spawn. +const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(function* ( + binaryPath: string, +) { + const executablePaths: Array = []; + const adapter = yield* ClaudeAdapterV2.createClaudeAdapterV2( + { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + displayName: undefined, + environment: [], + enabled: true, + config: { ...DEFAULT_CLAUDE_SETTINGS, binaryPath }, + }, + {}, + ).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-claude-binary-path-", + }), + ), + Effect.provideService(ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, { + allocateSessionId: Effect.succeed("native-thread-claude-binary-path"), + open: (input) => + Effect.sync(() => { + executablePaths.push(input.options.pathToClaudeCodeExecutable); + return { + messages: Stream.never, + offer: () => Effect.void, + setModel: () => Effect.void, + interrupt: Effect.void, + close: Effect.void, + }; + }), + forkSession: () => Effect.die("unused"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }), + ); + const threadId = ThreadId.make("thread-claude-binary-path"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-binary-path"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-binary-path"), + text: "hello", + attachments: [], + }), + ); + return executablePaths; +}); + describe("ClaudeAdapterV2 executable path", () => { it.effect("expands ~ in the configured binary path for the SDK", () => Effect.scoped( Effect.gen(function* () { const path = yield* Path.Path; - const executablePaths: Array = []; - const adapter = yield* ClaudeAdapterV2.createClaudeAdapterV2( - { - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - displayName: undefined, - environment: [], - enabled: true, - config: { ...DEFAULT_CLAUDE_SETTINGS, binaryPath: "~/bin/claude" }, - }, - {}, - ).pipe( - Effect.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-claude-binary-home-", - }), - ), - Effect.provideService(ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, { - allocateSessionId: Effect.succeed("native-thread-claude-binary-home"), - open: (input) => - Effect.sync(() => { - executablePaths.push(input.options.pathToClaudeCodeExecutable); - return { - messages: Stream.never, - offer: () => Effect.void, - setModel: () => Effect.void, - interrupt: Effect.void, - close: Effect.void, - }; - }), - forkSession: () => Effect.die("unused"), - subagentLaunchToolUseId: () => Effect.succeed(null), - assertComplete: Effect.void, - }), - ); - const threadId = ThreadId.make("thread-claude-binary-home"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-binary-home"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-binary-home"), - text: "hello", - attachments: [], - }), - ); + const executablePaths = yield* captureSdkExecutablePaths("~/bin/claude"); assert.deepEqual(executablePaths, [path.join(NodeOS.homedir(), "bin", "claude")]); }), ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ); + + it.effect("follows a bare claude on Windows to the npm package executable", () => + Effect.scoped( + Effect.gen(function* () { + const npmDir = "C:\\Users\\dev\\AppData\\Roaming\\npm"; + const packageExe = `${npmDir}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; + const executablePaths = yield* captureSdkExecutablePaths("claude").pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(SpawnExecutableResolution, () => `${npmDir}\\claude.cmd`), + Effect.provideService(ClaudeExecutableFileCheck, (filePath) => filePath === packageExe), + ); + + assert.deepEqual(executablePaths, [packageExe]); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); }); describe("ClaudeAdapterV2 resume compaction", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 2cf91c619594..6e61738ec1f0 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -87,6 +87,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { resolveClaudeSdkExecutablePath } from "../../provider/Drivers/ClaudeExecutable.ts"; import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts"; import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; @@ -7666,9 +7667,13 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( const baseEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment); const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment); const path = yield* Path.Path; + const binaryPath = yield* resolveClaudeSdkExecutablePath( + expandHomePath(config.binaryPath), + claudeEnvironment, + ); return makeClaudeAdapterV2({ instanceId, - settings: { ...config, enabled, binaryPath: expandHomePath(config.binaryPath) }, + settings: { ...config, enabled, binaryPath }, environment: claudeEnvironment, attachmentsDir: serverConfig.attachmentsDir, fileSystem, From 30f4566f6c950ff7c93a563dc2cb8fe586ee6bb9 Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+oldbettie@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:18:21 +0700 Subject: [PATCH 2/2] fix(server): Claude threads recover after turns that never reached the CLI A Claude thread counted any earlier provider turn as proof that its native session existed, and reopened it with `resume`. Turns whose CLI never started (a Windows binary path the SDK cannot spawn) still count, as do ids minted by the fresh-session fallback (#15103). Once Claude could start again, every turn on those threads failed with "No conversation found with session ID" and the thread never recovered. The adapter now asks the CLI's session storage whether the transcript exists. It searches every project directory for `.jsonl` instead of re-deriving the CLI's cwd encoding, which differs on Windows. A missing transcript fails `resumeThread`, so the existing fallback opens a fresh session with a summary of the thread's history, and a new id is created with `sessionId` rather than resumed. When storage cannot be read, the old assumption stands. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/ClaudeAdapterV2.test.ts | 66 ++++++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 48 +++++++++++++- .../Drivers/ClaudeSessionTranscript.test.ts | 59 +++++++++++++++++ .../Drivers/ClaudeSessionTranscript.ts | 47 +++++++++++++ 4 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/provider/Drivers/ClaudeSessionTranscript.test.ts create mode 100644 apps/server/src/provider/Drivers/ClaudeSessionTranscript.ts diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index f14cbd3e8724..f02b3abbaa0f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1777,7 +1777,10 @@ describe("ClaudeAdapterV2 native fork", () => { }); describe("ClaudeAdapterV2 native session identity", () => { - const openTurnWithOrdinal = (providerTurnOrdinal: number) => + const openTurnWithOrdinal = ( + providerTurnOrdinal: number, + sessionTranscriptExists?: ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerShape["sessionTranscriptExists"], + ) => Effect.scoped( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1795,6 +1798,7 @@ describe("ClaudeAdapterV2 native session identity", () => { path: yield* Path.Path, idAllocator, queryRunner: { + ...(sessionTranscriptExists === undefined ? {} : { sessionTranscriptExists }), allocateSessionId: Effect.succeed("native-session-identity"), open: (input) => Effect.sync(() => { @@ -1860,6 +1864,66 @@ describe("ClaudeAdapterV2 native session identity", () => { assert.equal(openedQueries[0]?.options.sessionId, undefined); }), ); + + it.effect("resumes when the CLI has a transcript for the native session", () => + Effect.gen(function* () { + const openedQueries = yield* openTurnWithOrdinal(2, () => Effect.succeed(true)); + assert.equal(openedQueries[0]?.options.resume, "native-session-identity"); + }), + ); + + // Earlier turns whose CLI never spawned (a wrong binary path) still count + // as provider turns. Resuming then fails with "No conversation found". + it.effect("creates the native session when earlier turns never produced a transcript", () => + Effect.gen(function* () { + const openedQueries = yield* openTurnWithOrdinal(2, () => Effect.succeed(false)); + assert.equal(openedQueries.length, 1); + assert.equal(openedQueries[0]?.options.sessionId, "native-session-identity"); + assert.equal(openedQueries[0]?.options.resume, undefined); + }), + ); + + it.effect("fails resume when the native session has no transcript", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir: yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-missing-session-", + }), + fileSystem, + path: yield* Path.Path, + idAllocator: yield* IdAllocator.IdAllocatorV2, + queryRunner: { + sessionTranscriptExists: ({ sessionId }) => + Effect.succeed(sessionId === "native-session-identity" ? false : undefined), + allocateSessionId: Effect.succeed("native-session-identity"), + open: () => Effect.die("unused open"), + forkSession: () => Effect.die("unused forkSession"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-missing-session"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-missing"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const resumed = yield* Effect.result(runtime.resumeThread({ providerThread })); + assert.equal(resumed._tag, "Failure"); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); }); const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 6e61738ec1f0..2363f175b75b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -96,7 +96,9 @@ import { expandHomePath } from "../../pathExpansion.ts"; import { claudeSignedOutMessage, makeClaudeEnvironment, + resolveClaudeHomePath, } from "../../provider/Drivers/ClaudeHome.ts"; +import { claudeSessionTranscriptExists } from "../../provider/Drivers/ClaudeSessionTranscript.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, resolveClaudeCatalogContextWindow, @@ -366,6 +368,15 @@ export interface ClaudeAgentSdkQueryRunnerShape { readonly subagentLaunchToolUseId: ( input: ClaudeAgentSdkSubagentLookupInput, ) => Effect.Effect; + /** + * Whether the CLI's session storage under `configDir` has a transcript for + * `sessionId`, so a reopen can `resume` it. `undefined`, or a runner without + * this lookup, means unknown. + */ + readonly sessionTranscriptExists?: (input: { + readonly configDir: string; + readonly sessionId: string; + }) => Effect.Effect; readonly assertComplete: Effect.Effect; } @@ -768,6 +779,7 @@ export const claudeAgentSdkQueryRunnerLiveLayer: Layer.Layer< return toolUseId; }, ), + sessionTranscriptExists: claudeSessionTranscriptExists, assertComplete: Effect.void, }); }), @@ -2948,6 +2960,22 @@ export function makeClaudeAdapterV2( const steeredTurns = yield* Ref.make(new Set()); const queryContext = yield* Ref.make(null); const openedNativeThreads = yield* Ref.make(new Set()); + // A native id the CLI never created or whose transcript is gone. + // Resuming it fails with "No conversation found" on every turn. An + // earlier open does not prove the session exists: the SDK opens + // before it spawns, so a CLI that never started still counts as opened. + const isNativeSessionMissing = Effect.fnUntraced(function* (nativeThreadId: string) { + if (queryRunner.sessionTranscriptExists === undefined) return false; + const configDir = yield* resolveClaudeHomePath( + adapterOptions.settings, + adapterOptions.environment, + ).pipe(Effect.provideService(Path.Path, adapterOptions.path)); + const exists = yield* queryRunner.sessionTranscriptExists({ + configDir, + sessionId: nativeThreadId, + }); + return exists === false; + }); const latestPlanByKind = yield* Ref.make(new Map()); const planIdsByNativeItem = yield* Ref.make( new Map(), @@ -6869,12 +6897,17 @@ export function makeClaudeAdapterV2( const openedWithResume = (yield* Ref.get(openedNativeThreads)).has(nativeThreadId); // openedNativeThreads is per session instance and is lost when the // provider session is idle-released. A prior persisted provider turn - // proves the native session already exists, so the query must resume + // usually means the native session exists, so the query must resume // it; reopening with a fixed session id makes the CLI fail fast with - // "Session ID ... is already in use". + // "Session ID ... is already in use". Earlier turns do not prove it, + // though: turns whose CLI never spawned, and ids minted by a + // fresh-session fallback, have no transcript, and resuming them fails + // with "No conversation found" forever. const hasPersistedProviderTurn = turnInput.providerTurnOrdinal > 1; const shouldResume = - resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; + resumeSessionAt !== undefined || + ((openedWithResume || hasPersistedProviderTurn) && + !(yield* isNativeSessionMissing(nativeThreadId))); const querySession = yield* queryRunner .open({ threadId: turnInput.threadId, @@ -7413,6 +7446,15 @@ export function makeClaudeAdapterV2( ), resumeThread: Effect.fn("ClaudeAdapterV2.resumeThread")( function* (threadInput: { readonly providerThread: OrchestrationV2ProviderThread }) { + // Failing here sends the turn through the fresh-session fallback, + // which carries the thread's history over as a summary. + const nativeThreadId = yield* getNativeThreadId(threadInput.providerThread); + if (yield* isNativeSessionMissing(nativeThreadId)) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: `Claude has no transcript for session ${nativeThreadId}; it was never created or has been deleted.`, + }); + } const updatedAt = yield* DateTime.now; return { ...threadInput.providerThread, diff --git a/apps/server/src/provider/Drivers/ClaudeSessionTranscript.test.ts b/apps/server/src/provider/Drivers/ClaudeSessionTranscript.test.ts new file mode 100644 index 000000000000..0c4dd787bc71 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSessionTranscript.test.ts @@ -0,0 +1,59 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { claudeSessionTranscriptExists } from "./ClaudeSessionTranscript.ts"; + +const SESSION_ID = "9b7e437e-6b0e-4d18-bdf4-dca34e6572b0"; + +describe("claudeSessionTranscriptExists", () => { + it.effect("finds a transcript under any encoded project directory", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-home-" }); + // A Windows cwd as the CLI encodes it. + const project = path.join(configDir, "projects", "C--Users-me-code-app"); + yield* fileSystem.makeDirectory(project, { recursive: true }); + yield* fileSystem.makeDirectory(path.join(configDir, "projects", "-tmp-other")); + yield* fileSystem.writeFileString(path.join(project, `${SESSION_ID}.jsonl`), "{}\n"); + + assert.equal( + yield* claudeSessionTranscriptExists({ configDir, sessionId: SESSION_ID }), + true, + ); + assert.equal( + yield* claudeSessionTranscriptExists({ configDir, sessionId: "13aeed5e-0000" }), + false, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("reports no session for a config dir that never stored one", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const configDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-home-" }); + assert.equal( + yield* claudeSessionTranscriptExists({ configDir, sessionId: SESSION_ID }), + false, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("cannot tell when the config dir is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-home-" }); + assert.equal( + yield* claudeSessionTranscriptExists({ + configDir: path.join(parent, "missing"), + sessionId: SESSION_ID, + }), + undefined, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeSessionTranscript.ts b/apps/server/src/provider/Drivers/ClaudeSessionTranscript.ts new file mode 100644 index 000000000000..6504deb0ee22 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSessionTranscript.ts @@ -0,0 +1,47 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import * as Effect from "effect/Effect"; + +const pathKind = (target: string): Promise<"directory" | "file" | "missing" | "unknown"> => + NodeFSP.stat(target).then( + (stats) => (stats.isDirectory() ? "directory" : "file"), + (cause: NodeJS.ErrnoException) => + cause.code === "ENOENT" || cause.code === "ENOTDIR" ? "missing" : "unknown", + ); + +/** + * Whether Claude's session storage under `configDir` holds a transcript for + * `sessionId`, which is what `--resume ` needs to succeed. + * + * The CLI files each transcript as `projects//.jsonl`. The + * cwd encoding differs by platform and CLI version (drive letters, Windows + * separators, long-path hashing), so every project directory is searched + * rather than re-deriving it. Session ids are UUIDs, so a hit in any project + * is this session. + * + * Returns `undefined` when the answer is unknown (no config dir, or storage + * that cannot be read), so callers keep their previous assumption instead of + * discarding a session they simply cannot see. + */ +export const claudeSessionTranscriptExists = (input: { + readonly configDir: string; + readonly sessionId: string; +}): Effect.Effect => + Effect.promise(async () => { + if (input.sessionId.length === 0 || /[\\/]|\.\./.test(input.sessionId)) return undefined; + if ((await pathKind(input.configDir)) !== "directory") return undefined; + const projectsDir = NodePath.join(input.configDir, "projects"); + const projectsKind = await pathKind(projectsDir); + // A readable config dir without projects has never stored a session. + if (projectsKind === "missing") return false; + if (projectsKind !== "directory") return undefined; + const projects = await NodeFSP.readdir(projectsDir).catch(() => undefined); + if (projects === undefined) return undefined; + const fileName = `${input.sessionId}.jsonl`; + const kinds = await Promise.all( + projects.map((project) => pathKind(NodePath.join(projectsDir, project, fileName))), + ); + return kinds.includes("file"); + });