import { mkdir, readFile, stat, unlink, writeFile } from 'fs/promises' import { dirname, join } from 'path' import { z } from 'zod/v4' import { logForDebugging } from '../utils/debug.js' import { isENOENT } from '../utils/errors.js' import { getWorktreePathsPortable } from '../utils/getWorktreePathsPortable.js' import { lazySchema } from '../utils/lazySchema.js' import { getProjectsDir, sanitizePath, } from '../utils/sessionStoragePortable.js' import { jsonParse, jsonStringify } from '../utils/slowOperations.js' /** * Upper bound on worktree fanout. git worktree list is naturally bounded * (50 is a LOT), but this caps the parallel stat() burst and guards against * pathological setups. Above this, --continue falls back to current-dir-only. */ const MAX_WORKTREE_FANOUT = 50 /** * Crash-recovery pointer for Remote Control sessions. * * Written immediately after a bridge session is created, periodically * refreshed during the session, and cleared on clean shutdown. If the * process dies unclean (crash, kill -9, terminal closed), the pointer * persists. On next startup, `claude remote-control` detects it and offers * to resume via the --session-id flow from #20460. * * Staleness is checked against the file's mtime (not an embedded timestamp) * so that a periodic re-write with the same content serves as a refresh — * matches the backend's rolling BRIDGE_LAST_POLL_TTL (4h) semantics. A * bridge that's been polling for 5+ hours and then crashes still has a * fresh pointer as long as the refresh ran within the window. * * Scoped per working directory (alongside transcript JSONL files) so two * concurrent bridges in different repos don't clobber each other. */ export const BRIDGE_POINTER_TTL_MS = 4 * 60 * 60 * 1000 const BridgePointerSchema = lazySchema(() => z.object({ sessionId: z.string(), environmentId: z.string(), source: z.enum(['standalone', 'repl']), }), ) export type BridgePointer = z.infer> export function getBridgePointerPath(dir: string): string { return join(getProjectsDir(), sanitizePath(dir), 'bridge-pointer.json') } /** * Write the pointer. Also used to refresh mtime during long sessions — * calling with the same IDs is a cheap no-content-change write that bumps * the staleness clock. Best-effort — a crash-recovery file must never * itself cause a crash. Logs and swallows on error. */ export async function writeBridgePointer( dir: string, pointer: BridgePointer, ): Promise { const path = getBridgePointerPath(dir) try { await mkdir(dirname(path), { recursive: true }) await writeFile(path, jsonStringify(pointer), 'utf8') logForDebugging(`[bridge:pointer] wrote ${path}`) } catch (err: unknown) { logForDebugging(`[bridge:pointer] write failed: ${err}`, { level: 'warn' }) } } /** * Read the pointer and its age (ms since last write). Operates directly * and handles errors — no existence check (CLAUDE.md TOCTOU rule). Returns * null on any failure: missing file, corrupted JSON, schema mismatch, or * stale (mtime > 4h ago). Stale/invalid pointers are deleted so they don't * keep re-prompting after the backend has already GC'd the env. */ export async function readBridgePointer( dir: string, ): Promise<(BridgePointer & { ageMs: number }) | null> { const path = getBridgePointerPath(dir) let raw: string let mtimeMs: number try { // stat for mtime (staleness anchor), then read. Two syscalls, but both // are needed — mtime IS the data we return, not a TOCTOU guard. mtimeMs = (await stat(path)).mtimeMs raw = await readFile(path, 'utf8') } catch { return null } const parsed = BridgePointerSchema().safeParse(safeJsonParse(raw)) if (!parsed.success) { logForDebugging(`[bridge:pointer] invalid schema, clearing: ${path}`) await clearBridgePointer(dir) return null } const ageMs = Math.max(0, Date.now() - mtimeMs) if (ageMs > BRIDGE_POINTER_TTL_MS) { logForDebugging(`[bridge:pointer] stale (>4h mtime), clearing: ${path}`) await clearBridgePointer(dir) return null } return { ...parsed.data, ageMs } } /** * Worktree-aware read for `--continue`. The REPL bridge writes its pointer * to `getOriginalCwd()` which EnterWorktreeTool/activeWorktreeSession can * mutate to a worktree path — but `claude remote-control --continue` runs * with `resolve('.')` = shell CWD. This fans out across git worktree * siblings to find the freshest pointer, matching /resume's semantics. * * Fast path: checks `dir` first. Only shells out to `git worktree list` if * that misses — the common case (pointer in launch dir) is one stat, zero * exec. Fanout reads run in parallel; capped at MAX_WORKTREE_FANOUT. * * Returns the pointer AND the dir it was found in, so the caller can clear * the right file on resume failure. */ export async function readBridgePointerAcrossWorktrees( dir: string, ): Promise<{ pointer: BridgePointer & { ageMs: number }; dir: string } | null> { // Fast path: current dir. Covers standalone bridge (always matches) and // REPL bridge when no worktree mutation happened. const here = await readBridgePointer(dir) if (here) { return { pointer: here, dir } } // Fanout: scan worktree siblings. getWorktreePathsPortable has a 5s // timeout and returns [] on any error (not a git repo, git not installed). const worktrees = await getWorktreePathsPortable(dir) if (worktrees.length <= 1) return null if (worktrees.length > MAX_WORKTREE_FANOUT) { logForDebugging( `[bridge:pointer] ${worktrees.length} worktrees exceeds fanout cap ${MAX_WORKTREE_FANOUT}, skipping`, ) return null } // Dedupe against `dir` so we don't re-stat it. sanitizePath normalizes // case/separators so worktree-list output matches our fast-path key even // on Windows where git may emit C:/ vs stored c:/. const dirKey = sanitizePath(dir) const candidates = worktrees.filter(wt => sanitizePath(wt) !== dirKey) // Parallel stat+read. Each readBridgePointer is a stat() that ENOENTs // for worktrees with no pointer (cheap) plus a ~100-byte read for the // rare ones that have one. Promise.all → latency ≈ slowest single stat. const results = await Promise.all( candidates.map(async wt => { const p = await readBridgePointer(wt) return p ? { pointer: p, dir: wt } : null }), ) // Pick freshest (lowest ageMs). The pointer stores environmentId so // resume reconnects to the right env regardless of which worktree // --continue was invoked from. let freshest: { pointer: BridgePointer & { ageMs: number } dir: string } | null = null for (const r of results) { if (r && (!freshest || r.pointer.ageMs < freshest.pointer.ageMs)) { freshest = r } } if (freshest) { logForDebugging( `[bridge:pointer] fanout found pointer in worktree ${freshest.dir} (ageMs=${freshest.pointer.ageMs})`, ) } return freshest } /** * Delete the pointer. Idempotent — ENOENT is expected when the process * shut down clean previously. */ export async function clearBridgePointer(dir: string): Promise { const path = getBridgePointerPath(dir) try { await unlink(path) logForDebugging(`[bridge:pointer] cleared ${path}`) } catch (err: unknown) { if (!isENOENT(err)) { logForDebugging(`[bridge:pointer] clear failed: ${err}`, { level: 'warn', }) } } } function safeJsonParse(raw: string): unknown { try { return jsonParse(raw) } catch { return null } }