Files
Codeman/src/utils/claude-transcript.ts
T
Codeman maintainer 42d1c75a19 fix(story-mode): the rest of the PR review: lookups, polling, frame budget, linear links
From review round 7 (PR review):

- With no hook path (normal after a server restart), the transcript was
  looked up by conversation id only; it now also tries the Codeman id and
  a recovered session's `restored-<fragment>` transcript, as
  last-response does, and keeps what it found briefly (a lookup reads
  every project folder; a miss is kept only 4 s).
- A session Story Mode cannot draw is not polled, and a page with nothing
  drawn yet is polled like a finished one (every 15 s), not every 4 s.
- Helper steps: one request per helper is drawn, the newest; a failed
  refetch never writes over steps already shown.
- The Director's cut toggle, a skin change and the fonts arriving redraw
  within the frame budget, not every panel in one task.
- A bare name under an `ls` header finds its folder in one pass over the
  lines; a search per link over the text before it was quadratic (about
  1.5 s for 700 links on agent output).
- A page of many questions still keeps to 12 panels: questions fold last.
- Opening Story Mode while it is open never starts a second runtime; the
  status announcer carries data-i18n-skip; the turn key in the ETag is
  clipped.
- The crafted-input timing test runs at sizes where the old code took 6
  to 29 s and the new one under 10 ms, with a 1.5 s bound.
- Docs: `older`, `origin` (`scheduled`), which route answers a 500;
  mobile.css's stale comment; an orphaned doc comment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 14:44:37 +02:00

139 lines
5.6 KiB
TypeScript

/**
* @fileoverview Does a Claude conversation transcript exist on this host?
*
* Claude writes one `<conversation-id>.jsonl` per conversation under
* `<config dir>/projects/<mangled cwd>/`. Two launch decisions turn on whether
* such a file exists: `--resume <id>` needs one, and `--session-id <id>` is
* REFUSED when one exists (`Error: Session ID ... is already in use.`).
*
* The project directory name is derived from the working directory, and a case
* that has been moved or renamed leaves its transcript under the OLD name, so
* the search is across every project directory rather than the one that matches
* the pane's cwd today.
*
* ⚠️ Existence is the whole question here, with no size floor. The create route
* additionally requires ~4 KB before it will resume, which is a "is this
* conversation worth resuming" judgement; for a relaunch the question is the
* opposite one — a one-line transcript still makes `--session-id` collide.
*
* ⚠️ A false answer is not the conservative one. Skipping a resume leaves the
* relaunch on `--session-id <id>`, which is safe only when no transcript backs
* that id either, so a lookup that misses the real config dir turns a
* recoverable pane into the collision this module exists to prevent.
*
* @dependencies none
* @consumedby session (relaunch resume pinning), web/routes/story-routes (Story Mode transcript lookup)
*
* @module utils/claude-transcript
*/
import { readdir, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
/**
* `<config dir>/projects`, honouring a session's relocated `CLAUDE_CONFIG_DIR`
* (#255) and, failing that, the server process's own.
*
* ⚠️ The process env is not optional here. A pane inherits the server's
* environment through tmux, so on an install that exports `CLAUDE_CONFIG_DIR`
* the CLI writes its transcripts there and a lookup under `~/.claude` answers
* "no transcript" for every conversation on the host. `claudeCredentialsPath()`
* (claude-credentials.ts) and `realClaudeConfigDir()`
* (custom-model-injection-apply.ts) resolve the same directory the same way.
*/
export function claudeProjectsDir(configDir?: string): string {
const fromEnv = typeof process.env.CLAUDE_CONFIG_DIR === 'string' && process.env.CLAUDE_CONFIG_DIR.trim();
return join(configDir || fromEnv || join(homedir(), '.claude'), 'projects');
}
/**
* The one top-level transcript a recovered session (`restored-<first 8+ hex of its uuid>`, how
* reconcileSessions() names a session whose mux record was lost) belongs to, or null when none or
* more than one matches. The same recovery `last-response` makes (session-routes.ts).
*/
export async function findRestoredClaudeTranscriptPath(
codemanSessionId: string,
configDir?: string
): Promise<string | null> {
const restored = /^restored-([a-f0-9]{8,})$/i.exec(codemanSessionId);
if (!restored) return null;
const fragment = restored[1].toLowerCase();
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
const projectsDir = claudeProjectsDir(configDir);
let projectDirs: string[];
try {
projectDirs = await readdir(projectsDir);
} catch {
return null;
}
const found = new Map<string, string>();
for (const projectDir of projectDirs) {
let files: string[];
try {
files = await readdir(join(projectsDir, projectDir));
} catch {
continue;
}
for (const file of files) {
const id = file.endsWith('.jsonl') ? file.slice(0, -'.jsonl'.length) : '';
if (!id.toLowerCase().startsWith(fragment) || !uuid.test(id)) continue;
const path = join(projectsDir, projectDir, file);
if ((await stat(path).catch(() => null))?.isFile()) found.set(id, path);
}
}
return found.size === 1 ? [...found.values()][0] : null;
}
/**
* Path of the transcript for `conversationId` under any project directory, or null. Unlike
* {@link claudeTranscriptExists} it answers with the file (Story Mode reads it), so the id is
* checked against a strict charset before it is joined into a path, and only a regular file counts.
*/
export async function findClaudeTranscriptPath(conversationId: string, configDir?: string): Promise<string | null> {
if (!/^[a-zA-Z0-9._-]+$/.test(conversationId) || conversationId.startsWith('.')) return null;
const projectsDir = claudeProjectsDir(configDir);
let projectDirs: string[];
try {
projectDirs = await readdir(projectsDir);
} catch {
return null;
}
for (const projectDir of projectDirs) {
const candidate = join(projectsDir, projectDir, `${conversationId}.jsonl`);
try {
if ((await stat(candidate)).isFile()) return candidate;
} catch {
// Not in this project directory; keep looking.
}
}
return null;
}
/**
* True when a transcript for `conversationId` exists under any project
* directory. Returns false for a missing projects dir or an unreadable one,
* which leaves the caller unpinned: safe where nothing else can collide with
* the bare `--session-id`, and the reason the caller walks its candidates down
* to the session's own id rather than treating one false answer as final.
*/
export async function claudeTranscriptExists(conversationId: string, configDir?: string): Promise<boolean> {
if (!conversationId) return false;
const projectsDir = claudeProjectsDir(configDir);
let projectDirs: string[];
try {
projectDirs = await readdir(projectsDir);
} catch {
return false;
}
for (const projectDir of projectDirs) {
try {
await stat(join(projectsDir, projectDir, `${conversationId}.jsonl`));
return true;
} catch {
// Not in this project directory; keep looking.
}
}
return false;
}