fix(chat): resume in the directory the transcript belongs to

--resume is scoped to the working directory: Claude Code files transcripts under
~/.claude/projects/<slugged cwd>/<id>.jsonl, so resuming from anywhere else 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, the banner said only 'exited with code 1', and the exit line claimed
stderr=<silent> while the CLI had printed the reason plainly.

The transcript knows its own directory — every line carries cwd — so it is read
from the file rather than guessed, first line only, since these reach 100 MB. An
explicit cwd from the caller still wins. When the same id exists under two project
dirs (a checkout that moved: officerdev/platform and officer-dev/platform both
hold one here) the newest wins and a vanished directory is skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 23:10:45 +01:00
co-authored by Claude Opus 5
parent 46d621c91b
commit 989c15e437
+59 -3
View File
@@ -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/<slugged path>/<id>.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=<silent>` 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<string, unknown>,