import * as plugins from './plugins.js'; import type { TControllerTerminalAgentKind } from '../ts_interfaces/index.js'; /** * The only module that knows how Claude Code stores conversations on disk or how its CLI is * invoked. Everything else addresses a Claude chat through {@link IControllerTerminalAgentLaunch}. * * Claude Code has no idempotent attach-or-create-by-session-id flag: `--session-id` refuses an id * it already knows and `--resume` refuses one it does not, so the launch flag must be chosen per * start. That choice is a consumer-side workaround for the missing upstream capability and is * deliberately confined to this file. */ const sessionIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; /** Bounds the fallback transcript scan so a large projects directory cannot stall a start. */ const maxScannedProjectDirectories = 512; const agentListingTimeoutMs = 5_000; const maxAgentListingBytes = 4 * 1024 * 1024; export const isControllerTerminalAgentSessionId = (valueArg: unknown): valueArg is string => typeof valueArg === 'string' && sessionIdPattern.test(valueArg); export const newControllerTerminalAgentSessionId = (): string => plugins.crypto.randomUUID(); /** A live Claude Code session as reported by `claude agents --json`. */ export interface IControllerTerminalAgentSession { sessionId: string; kind: 'interactive' | 'background'; cwd: string; /** Present for interactive sessions; background sessions are daemon-owned. */ pid?: number; /** Short daemon id, present for background sessions. */ id?: string; name?: string; } export interface IControllerTerminalAgentLaunch { command: string; args: string[]; /** Which upstream flag was chosen, for surfacing in the terminal header. */ mode: 'resume' | 'new'; } const executableCandidates = (environmentArg: NodeJS.ProcessEnv): string[] => { const explicit = environmentArg.HARNESS_CONTROLLER_CLAUDE_EXECUTABLE; const home = environmentArg.HOME; return [ ...(explicit && plugins.path.isAbsolute(explicit) ? [explicit] : []), ...pathLookupCandidates('claude', environmentArg), ...(home ? [plugins.path.join(home, '.local', 'bin', 'claude')] : []), ]; }; const pathLookupCandidates = ( binaryArg: string, environmentArg: NodeJS.ProcessEnv, ): string[] => { const searchPath = environmentArg.PATH; if (!searchPath) return []; return searchPath .split(plugins.path.delimiter) .filter((entry) => entry.length > 0 && plugins.path.isAbsolute(entry)) .map((entry) => plugins.path.join(entry, binaryArg)); }; /** * Validates a candidate without resolving symlinks. The launcher at `~/.local/bin/claude` is * repointed by `claude install`, so persisting the realpath would pin a resource to a version * directory that a later update deletes. */ const validateAgentExecutable = async (candidateArg: string): Promise => { if (!plugins.path.isAbsolute(candidateArg)) { throw new Error('The terminal agent executable must be an absolute path.'); } const stats = await plugins.fs.promises.stat(candidateArg); if (!stats.isFile()) throw new Error('The terminal agent executable is not a file.'); if (process.platform !== 'win32') { await plugins.fs.promises.access(candidateArg, plugins.fs.constants.X_OK); } return candidateArg; }; export const resolveControllerTerminalAgentExecutable = async ( kindArg: TControllerTerminalAgentKind, environmentArg: NodeJS.ProcessEnv = process.env, ): Promise => { if (kindArg !== 'claude') throw new Error('Unsupported terminal agent.'); for (const candidate of [...new Set(executableCandidates(environmentArg))]) { try { return await validateAgentExecutable(candidate); } catch { // Try the next candidate before failing closed. } } throw new Error('No trusted Claude Code executable is available.'); }; const claudeProjectsDirectory = (environmentArg: NodeJS.ProcessEnv): string | undefined => { const configured = environmentArg.CLAUDE_CONFIG_DIR; if (configured && plugins.path.isAbsolute(configured)) { return plugins.path.join(configured, 'projects'); } const home = environmentArg.HOME; return home ? plugins.path.join(home, '.claude', 'projects') : undefined; }; /** * Claude derives a per-directory slug from the working directory, but the slug follows the live * cwd rather than the session, so it is a fast path only. The transcript filename is the session * id itself, which makes a bounded scan a reliable fallback. */ const claudeProjectSlug = (cwdArg: string): string => cwdArg.replace(/[^a-zA-Z0-9]/gu, '-'); const isReadableFile = async (pathArg: string): Promise => { try { const stats = await plugins.fs.promises.stat(pathArg); return stats.isFile(); } catch { return false; } }; /** * Answers "has this conversation ever had a turn", which decides `--resume` versus `--session-id`. * It never opens the transcript, so the unstable JSONL schema is irrelevant here. */ export const controllerTerminalAgentTranscriptExists = async ( sessionIdArg: string, cwdArg: string, environmentArg: NodeJS.ProcessEnv = process.env, ): Promise => { if (!isControllerTerminalAgentSessionId(sessionIdArg)) return false; const projectsDirectory = claudeProjectsDirectory(environmentArg); if (!projectsDirectory) return false; const transcriptName = `${sessionIdArg}.jsonl`; if (await isReadableFile( plugins.path.join(projectsDirectory, claudeProjectSlug(cwdArg), transcriptName), )) return true; let entries: plugins.fs.Dirent[]; try { entries = await plugins.fs.promises.readdir(projectsDirectory, { withFileTypes: true }); } catch { return false; } let scanned = 0; for (const entry of entries) { if (!entry.isDirectory()) continue; if (scanned >= maxScannedProjectDirectories) break; scanned += 1; if (await isReadableFile(plugins.path.join(projectsDirectory, entry.name, transcriptName))) { return true; } } return false; }; const parseAgentSessions = (payloadArg: string): IControllerTerminalAgentSession[] => { const parsed: unknown = JSON.parse(payloadArg); if (!Array.isArray(parsed)) throw new Error('The agent listing is not an array.'); const sessions: IControllerTerminalAgentSession[] = []; for (const entry of parsed) { if (typeof entry !== 'object' || entry === null) continue; const record = entry as Record; if (!isControllerTerminalAgentSessionId(record.sessionId)) continue; if (record.kind !== 'interactive' && record.kind !== 'background') continue; if (typeof record.cwd !== 'string') continue; sessions.push({ sessionId: record.sessionId, kind: record.kind, cwd: record.cwd, ...(Number.isSafeInteger(record.pid) ? { pid: record.pid as number } : {}), ...(typeof record.id === 'string' ? { id: record.id } : {}), ...(typeof record.name === 'string' ? { name: record.name } : {}), }); } return sessions; }; /** * Lists the conversations Claude Code currently considers live. `claude agents --json` is the * supported scripting interface for this and explicitly does not require a TTY. */ export const listControllerTerminalAgentSessions = async ( executableArg: string, environmentArg: NodeJS.ProcessEnv = process.env, ): Promise => { const payload = await new Promise((resolve, reject) => { plugins.childProcess.execFile( executableArg, ['agents', '--json'], { env: { ...environmentArg }, timeout: agentListingTimeoutMs, maxBuffer: maxAgentListingBytes, windowsHide: true, }, (errorArg, stdoutArg) => { if (errorArg) reject(errorArg); else resolve(stdoutArg); }, ); }); return parseAgentSessions(payload); }; /** * Fails closed. A conversation that is already open somewhere else must not be resumed: concurrent * resumes of one session id both succeed, branch the transcript into a DAG, and a later resume * follows a single leaf — silently discarding the other branch. An unreadable listing is treated * as unproven rather than free, matching how OpenCode orphan recovery refuses an occupant it * cannot positively verify. */ export const assertControllerTerminalAgentSessionIsFree = async ( executableArg: string, sessionIdArg: string, environmentArg: NodeJS.ProcessEnv = process.env, ): Promise => { let sessions: IControllerTerminalAgentSession[]; try { sessions = await listControllerTerminalAgentSessions(executableArg, environmentArg); } catch (errorArg) { throw new Error( 'The Claude session liveness listing is unavailable, so the conversation cannot be proven free.', { cause: errorArg }, ); } const holder = sessions.find((session) => session.sessionId === sessionIdArg); if (!holder) return; const location = holder.pid !== undefined ? `pid ${holder.pid}` : `background session ${holder.id ?? 'unknown'}`; throw new Error(`The Claude conversation is already open in another process (${location}).`); }; /** * Resolves the argv for one start. Callers must have proven the session free first. */ export const resolveControllerTerminalAgentLaunch = async ( kindArg: TControllerTerminalAgentKind, sessionIdArg: string, cwdArg: string, environmentArg: NodeJS.ProcessEnv = process.env, ): Promise => { const command = await resolveControllerTerminalAgentExecutable(kindArg, environmentArg); const resumable = await controllerTerminalAgentTranscriptExists(sessionIdArg, cwdArg, environmentArg); return { command, args: resumable ? ['--resume', sessionIdArg] : ['--session-id', sessionIdArg], mode: resumable ? 'resume' : 'new', }; };