diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index f76f397e..391fab58 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs'; +import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs'; import { spawn } from 'node:child_process'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -543,6 +543,19 @@ function createSession( const cleanEnv = envWithoutParentSession(); const resumeId = getClaudeSession(sessionKey, params.userId) ?? params.resumeSessionId; + /* + Where that transcript actually lives, because `--resume` is scoped to the WORKING DIRECTORY. + + Claude Code files transcripts per cwd (`~/.claude/projects//.jsonl`), so resuming + from a different directory fails with "No conversation found with session ID" — exit 1, before any + output. Conversations created before the New Chat directory picker carry no cwd, fall back to + HOST_HOME, and could therefore NEVER resume: every message died the same way, and the exit line said + `stderr=` while the CLI had printed the reason. + + The transcript knows its own directory — every line carries `cwd` — so it is read from the file + rather than guessed. An explicit cwd from the caller still wins; this only fills the gap. + */ + const resumeCwd = resumeId && !params.cwd ? cwdOfTranscript(resumeId) : undefined; const subModel = params.model?.split('/')[1]; session.resumeId = resumeId; session.model = params.model; @@ -559,7 +572,7 @@ function createSession( * disagree, and only this line can show that they have. */ console.log( - `[claude:spawn:${sessionKey}] resume=${resumeId ?? 'none'} cwd=${params.cwd ?? 'default'} ` + + `[claude:spawn:${sessionKey}] resume=${resumeId ?? 'none'} cwd=${params.cwd ?? resumeCwd ?? 'default'} ` + `as=${params.member?.osUser ?? 'owner'} model=${params.model ?? 'default'}`, ); @@ -572,7 +585,7 @@ function createSession( options: { // HOST_HOME is this process's home — the owner's. Defaulting a member's turn to it would start them in // a directory they cannot read, and the failure would look like a broken agent rather than a wrong cwd. - cwd: params.cwd ?? params.member?.home ?? HOST_HOME, + cwd: params.cwd ?? resumeCwd ?? params.member?.home ?? HOST_HOME, // `bypassPermissions` unless the conversation opened in plan mode. CLAUDE.md is explicit that the // unsandboxed default is deliberate — this is the owner's own machine — so plan mode is the only // thing that may ever move it, and it moves back the moment a plan is approved. @@ -904,6 +917,49 @@ const QUESTION_ANSWER_TIMEOUT_MS = 20 * 60 * 1000; * returned here is the harness's permission answer, so every path out of it must resolve — a throw or a * forgotten branch is a wedged turn, and the symptom would be a chat that shows a plan and then nothing. */ +/** + * The working directory a transcript belongs to, read from the transcript itself. + * + * `--resume` only finds a session when the process starts in the directory it was recorded in, and the + * id alone does not say which that is. Every line of a transcript carries its own `cwd`, so the file + * answers the question — no path-slug decoding, which is lossy the moment a directory name contains a + * dash. + * + * Only the FIRST line is read. These files reach 100 MB (one on this machine already has), and the + * answer is in the first few hundred bytes. + * + * The same id can exist under two project directories when a checkout has moved — `…/officerdev/platform` + * and `…/officer-dev/platform` both hold one here. The newest wins, and a directory that no longer + * exists is skipped, because resuming into a path that is gone fails exactly the way this is fixing. + */ +function cwdOfTranscript(sessionId: string): string | undefined { + try { + const root = join(homedir(), '.claude', 'projects'); + const candidates = readdirSync(root) + .map((dir) => join(root, dir, `${sessionId}.jsonl`)) + .filter((file) => existsSync(file)) + .map((file) => ({ file, at: statSync(file).mtimeMs })) + .sort((a, b) => b.at - a.at); + + for (const { file } of candidates) { + const fd = openSync(file, 'r'); + try { + const buf = Buffer.alloc(8192); + const read = readSync(fd, buf, 0, buf.length, 0); + const line = buf.subarray(0, read).toString('utf8').split('\n')[0]; + const cwd: unknown = line ? (JSON.parse(line) as { cwd?: unknown }).cwd : undefined; + if (typeof cwd === 'string' && cwd && existsSync(cwd)) return cwd; + } finally { + closeSync(fd); + } + } + } catch { + // A missing projects dir, an unreadable file, a first line that is not JSON — all mean "no answer", + // and the caller falls back to the default cwd exactly as it did before this existed. + } + return undefined; +} + async function planGate( session: PersistentSession, toolInput: Record,