/* - Date: 2026-06-29 Spec: plans/2026-06-29-claude-code-raw-replay-harness-plan.md Relationship: Automatic raw fixture capture entrypoint. It drives real Codex app-server and Claude Code SDK sessions against the prepared fixture playground and lets the existing live recorder write provider-native NDJSON. - Date: 2026-07-02 Spec: plans/2026-07-02-claude-plan-mode-observability-v2-implementation-plan.md Relationship: Adds the claude-plan-mode capture lane - a real Claude Code session started in permissionMode 'plan' with hook events on, auto-approving the ExitPlanMode plan review so the fixture records the full observable plan-mode lifecycle (status permissionMode frames, ExitPlanMode call/result). */ import { mkdtemp, cp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { query as claudeQuery, type PermissionMode, } from '@anthropic-ai/claude-agent-sdk'; import { connectCodexAppServer, startCodexAppServer, } from '../src/codexAppServer'; import { recordLiveAgentReplay } from '../src/agentReplayLiveRecorder'; import { type JsonObject, type JsonValue } from '../src/protocol'; import { preflightGstackClaudeSmokeSetup } from '../src/gstackReplayPreflight'; type Provider = 'codex' | 'claude' | 'gstack-claude-smoke' | 'claude-plan-mode'; type CaptureOptions = { readonly providers: readonly Provider[]; readonly packageRoot: string; readonly playgroundPath: string; readonly outDir: string; readonly exactRaw: boolean; readonly keepWorkdirs: boolean; readonly codexBin: string; readonly codexApprovalPolicy: string | undefined; readonly codexSandbox: string | undefined; readonly claudePermissionMode: PermissionMode | undefined; readonly claudeModel: string | undefined; readonly gstackSkillDir: string | undefined; readonly gstackCli: string | undefined; readonly preflightOnly: boolean; readonly preflightJson: boolean; }; type StartedCodex = Awaited>; type CodexClient = Awaited>; type PreflightResult = | { readonly type: 'none' } | { readonly type: 'gstack'; readonly setup: Exclude< Awaited>, { readonly type: 'error' } >; }; const thisFile = fileURLToPath(import.meta.url); const packageRoot = resolve(dirname(thisFile), '..'); async function main(): Promise { const argv = process.argv.slice(2); if (argv.includes('--help')) { printHelp(); return; } const options = parseArgs(argv); await mkdir(options.outDir, { recursive: true }); for (const provider of options.providers) { const runDir = await prepareRunDirectory(options, provider); let preflight: PreflightResult = { type: 'none' }; try { preflight = await preflightCaptureProvider(options, provider, runDir); } catch (error) { if (!options.keepWorkdirs) { await rm(dirname(runDir), { recursive: true, force: true }); } throw error; } if (options.preflightOnly) { writePreflightOk(options, provider, runDir, preflight); switch (options.keepWorkdirs) { case true: process.stdout.write(`${provider}: workdir ${runDir}\n`); break; case false: await rm(dirname(runDir), { recursive: true, force: true }); break; } continue; } const fixturePath = join(options.outDir, `${provider}.ndjson`); await writeFile(fixturePath, '', 'utf8'); process.env.LINZUMI_AGENT_REPLAY_RECORD_FILE = fixturePath; process.env.LINZUMI_AGENT_REPLAY_EXACT_RAW = options.exactRaw ? '1' : '0'; const prompt = await fixturePrompt(options.playgroundPath, provider); recordLiveAgentReplay({ provider: provider === 'codex' ? 'codex-app-server' : 'claude-code-sdk', captureKind: 'session-marker', rawValue: { type: 'capture_started', provider, cwd: runDir, sourcePlayground: options.playgroundPath, }, }); switch (provider) { case 'codex': await runCodexCapture(options, runDir, prompt); break; case 'claude': await runClaudeCapture(options, runDir, prompt, 'generic'); break; case 'gstack-claude-smoke': await runClaudeCapture(options, runDir, prompt, 'gstack'); break; case 'claude-plan-mode': await runClaudeCapture(options, runDir, prompt, 'plan-mode'); break; } recordLiveAgentReplay({ provider: provider === 'codex' ? 'codex-app-server' : 'claude-code-sdk', captureKind: 'session-marker', rawValue: { type: 'capture_completed', provider, cwd: runDir, keptWorkdir: options.keepWorkdirs, }, }); process.stdout.write(`${provider}: wrote ${fixturePath}\n`); switch (options.keepWorkdirs) { case true: process.stdout.write(`${provider}: workdir ${runDir}\n`); break; case false: await rm(dirname(runDir), { recursive: true, force: true }); break; } } } function printHelp(): void { process.stdout.write( `Usage: pnpm run record:agent-fixtures -- [options]\n\nOptions:\n --provider codex|claude|gstack-claude-smoke|both\n Providers to run. Default: both\n --out-dir Directory for NDJSON fixtures. Default: /tmp/linzumi-agent-replay-*\n --playground Prepared playground to copy. Default: checked-in fixture playground\n --exact-raw Preserve raw provider payloads except data omitted by the provider itself\n --keep-workdirs Print and leave copied playground workdirs for inspection\n --preflight-only Check provider-specific local setup without starting an agent session\n --preflight-json Emit machine-readable JSON for preflight-only success/failure\n --codex-bin Codex executable. Default: codex\n --codex-approval-policy Forwarded to Codex app-server thread/turn start\n --codex-sandbox Forwarded to Codex app-server thread/turn start\n --claude-permission-mode Forwarded to Claude SDK query options\n --claude-model Forwarded to Claude SDK query options. Can also use LINZUMI_AGENT_REPLAY_CLAUDE_MODEL\n --gstack-skill-dir Explicit gstack Claude skill directory. Can also use LINZUMI_AGENT_REPLAY_GSTACK_SKILL_DIR\n --gstack-cli Explicit gstack CLI path/name. Can also use LINZUMI_AGENT_REPLAY_GSTACK_CLI\n` ); } function parseArgs(args: readonly string[]): CaptureOptions { const rawProvider = argValue(args, '--provider') ?? 'both'; const providers = providersFromArg(rawProvider); const outDir = resolve( argValue(args, '--out-dir') ?? join(tmpdir(), `linzumi-agent-replay-${Date.now()}`) ); const playgroundPath = resolve( argValue(args, '--playground') ?? join(packageRoot, 'test/fixtures/agent-fixture-playground') ); return { providers, packageRoot, playgroundPath, outDir, exactRaw: flag(args, '--exact-raw'), keepWorkdirs: flag(args, '--keep-workdirs'), preflightOnly: flag(args, '--preflight-only'), preflightJson: flag(args, '--preflight-json'), codexBin: argValue(args, '--codex-bin') ?? process.env.LINZUMI_AGENT_REPLAY_CODEX_BIN ?? 'codex', codexApprovalPolicy: argValue(args, '--codex-approval-policy') ?? process.env.LINZUMI_AGENT_REPLAY_CODEX_APPROVAL_POLICY, codexSandbox: argValue(args, '--codex-sandbox') ?? process.env.LINZUMI_AGENT_REPLAY_CODEX_SANDBOX, claudePermissionMode: claudePermissionModeFromArg( argValue(args, '--claude-permission-mode') ?? process.env.LINZUMI_AGENT_REPLAY_CLAUDE_PERMISSION_MODE ), claudeModel: argValue(args, '--claude-model') ?? process.env.LINZUMI_AGENT_REPLAY_CLAUDE_MODEL, gstackSkillDir: argValue(args, '--gstack-skill-dir') ?? process.env.LINZUMI_AGENT_REPLAY_GSTACK_SKILL_DIR, gstackCli: argValue(args, '--gstack-cli') ?? process.env.LINZUMI_AGENT_REPLAY_GSTACK_CLI, }; } function providersFromArg(value: string): readonly Provider[] { switch (value) { case 'both': return ['codex', 'claude']; case 'codex': return ['codex']; case 'claude': return ['claude']; case 'gstack-claude-smoke': return ['gstack-claude-smoke']; case 'claude-plan-mode': return ['claude-plan-mode']; default: throw new Error(`unsupported --provider value: ${value}`); } } function claudePermissionModeFromArg( value: string | undefined ): PermissionMode | undefined { switch (value) { case undefined: return undefined; case 'default': case 'acceptEdits': case 'bypassPermissions': case 'plan': case 'dontAsk': case 'auto': return value; default: throw new Error(`unsupported Claude permission mode: ${value}`); } } function flag(args: readonly string[], name: string): boolean { return args.includes(name); } function argValue(args: readonly string[], name: string): string | undefined { const index = args.indexOf(name); if (index === -1) { return undefined; } const value = args[index + 1]; if (value === undefined || value.startsWith('--')) { throw new Error(`${name} requires a value`); } return value; } async function prepareRunDirectory( options: CaptureOptions, provider: Provider ): Promise { const base = await mkdtemp(join(tmpdir(), `linzumi-${provider}-fixture-`)); const runDir = join(base, 'agent-fixture-playground'); await cp(options.playgroundPath, runDir, { recursive: true }); return runDir; } function fixturePromptHeading(provider: Provider): string { switch (provider) { case 'codex': return 'Codex Prompt'; case 'claude': return 'Claude Code Prompt'; case 'gstack-claude-smoke': return 'Gstack Claude Smoke Prompt'; case 'claude-plan-mode': return 'Claude Plan Mode Prompt'; } } async function preflightCaptureProvider( options: CaptureOptions, provider: Provider, cwd: string ): Promise { switch (provider) { case 'codex': case 'claude': case 'claude-plan-mode': return { type: 'none' }; case 'gstack-claude-smoke': return { type: 'gstack', setup: await preflightGstackClaudeSmoke(options, cwd), }; } } async function preflightGstackClaudeSmoke( options: CaptureOptions, cwd: string ): Promise< Exclude< Awaited>, { readonly type: 'error' } > > { const result = await preflightGstackClaudeSmokeSetup({ cwd, env: { ...process.env, ...(options.gstackSkillDir === undefined ? {} : { LINZUMI_AGENT_REPLAY_GSTACK_SKILL_DIR: options.gstackSkillDir }), ...(options.gstackCli === undefined ? {} : { LINZUMI_AGENT_REPLAY_GSTACK_CLI: options.gstackCli }), }, }); switch (result.type) { case 'ok': return result; case 'error': writePreflightError(options, 'gstack-claude-smoke', cwd, result); throw new Error(result.message); } } function writePreflightOk( options: CaptureOptions, provider: Provider, cwd: string, preflight: PreflightResult ): void { if (options.preflightJson) { process.stdout.write( `${JSON.stringify(preflightOkJson(provider, cwd, preflight), null, 2)}\n` ); return; } process.stdout.write(`${provider}: preflight ok (${cwd})\n`); } function preflightOkJson( provider: Provider, cwd: string, preflight: PreflightResult ): JsonObject { switch (preflight.type) { case 'none': return { provider, status: 'ok', cwd }; case 'gstack': return { provider, status: 'ok', cwd, setup: { skillDir: preflight.setup.skillDir, cliPath: preflight.setup.cliPath, checkedSkillDirs: preflight.setup.checkedSkillDirs, checkedCliNames: preflight.setup.checkedCliNames, runtimeProfile: preflight.setup.runtimeProfile, includeHookEvents: preflight.setup.includeHookEvents, preservesClaudeTasks: preflight.setup.preservesClaudeTasks, }, }; } } function writePreflightError( options: CaptureOptions, provider: Provider, cwd: string, result: Exclude< Awaited>, { readonly type: 'ok' } > ): void { if (!options.preflightJson) { return; } process.stdout.write( `${JSON.stringify( { provider, status: 'failed', cwd, reason: result.message, missing: result.missing, checkedSkillDirs: result.checkedSkillDirs, checkedCliNames: result.checkedCliNames, cliPath: result.cliPath ?? null, }, null, 2 )}\n` ); } async function fixturePrompt( path: string, provider: Provider ): Promise { const markdown = await readFile(join(path, 'FIXTURE_PROMPTS.md'), 'utf8'); const heading = fixturePromptHeading(provider); const sectionStart = markdown.indexOf(`## ${heading}`); if (sectionStart === -1) { throw new Error(`could not find ${heading} in FIXTURE_PROMPTS.md`); } const fenceStartMarker = '```text\n'; const fenceEndMarker = '\n```'; const codeFenceStart = markdown.indexOf(fenceStartMarker, sectionStart); if (codeFenceStart === -1) { throw new Error( `could not find text fence for ${heading} in FIXTURE_PROMPTS.md` ); } const bodyStart = codeFenceStart + fenceStartMarker.length; const bodyEnd = markdown.indexOf(fenceEndMarker, bodyStart); if (bodyEnd === -1) { throw new Error( `could not find closing text fence for ${heading} in FIXTURE_PROMPTS.md` ); } return markdown.slice(bodyStart, bodyEnd); } async function runCodexCapture( options: CaptureOptions, cwd: string, prompt: string ): Promise { let started: StartedCodex | undefined; let client: CodexClient | undefined; try { started = await startCodexAppServer(options.codexBin, cwd); client = await connectCodexAppServer(started.url); const completion = codexTurnCompletion(client); const threadResponse = await client.request('thread/start', { cwd, serviceName: 'kandan-local-runner', personality: 'pragmatic', ...(options.codexApprovalPolicy === undefined ? {} : { approvalPolicy: options.codexApprovalPolicy }), ...(options.codexSandbox === undefined ? {} : { sandbox: options.codexSandbox }), }); const threadId = responseId(threadResponse, ['thread', 'id']); if (threadId === undefined) { throw new Error('thread/start response did not include thread.id'); } const turnResponse = await client.request('turn/start', { threadId, input: [{ type: 'text', text: prompt }], ...(options.codexApprovalPolicy === undefined ? {} : { approvalPolicy: options.codexApprovalPolicy }), ...(options.codexSandbox === undefined ? {} : { sandbox: options.codexSandbox }), }); const turnId = responseId(turnResponse, ['turn', 'id']); if (turnId === undefined) { throw new Error('turn/start response did not include turn.id'); } await completion; } finally { client?.close(); started?.stop(); } } function codexTurnCompletion(client: CodexClient): Promise { return new Promise((resolve, reject) => { client.onNotification((message) => { switch (message.method) { case 'turn/completed': case 'turn/failed': case 'turn/aborted': resolve(); break; default: break; } }); client.onClose((error) => reject(error)); }); } function responseId( response: JsonValue, path: readonly string[] ): string | undefined { if (!isJsonObject(response) || !isJsonObject(response.result)) { return undefined; } const first = response.result[path[0]]; if (!isJsonObject(first)) { return undefined; } const value = first[path[1]]; return typeof value === 'string' ? value : undefined; } async function runClaudeCapture( options: CaptureOptions, cwd: string, prompt: string, profile: 'generic' | 'gstack' | 'plan-mode' ): Promise { const stream = claudeQuery({ prompt, options: { cwd, persistSession: true, tools: { type: 'preset', preset: 'claude_code' }, includePartialMessages: true, ...(options.claudeModel === undefined ? {} : { model: options.claudeModel }), ...(profile === 'gstack' ? { includeHookEvents: true, env: gstackClaudeCaptureEnv(process.env) } : {}), // Plan-mode lane: start IN plan mode so the fixture records the status // permissionMode frames and the ExitPlanMode call/result lifecycle. The // canUseTool bridge auto-approves everything (the playground is a // throwaway temp copy and plan mode already blocks writes), so the plan // review resolves deterministically without a human. ...(profile === 'plan-mode' ? { permissionMode: 'plan' as PermissionMode, includeHookEvents: true, canUseTool: async ( _toolName: string, input: Record ) => ({ behavior: 'allow' as const, updatedInput: input }), } : {}), settingSources: ['user', 'project', 'local'], systemPrompt: { type: 'preset', preset: 'claude_code' }, ...(options.claudePermissionMode === undefined || profile === 'plan-mode' ? {} : { permissionMode: options.claudePermissionMode, ...(options.claudePermissionMode === 'bypassPermissions' ? { allowDangerouslySkipPermissions: true } : {}), }), }, }); for await (const message of stream) { recordLiveAgentReplay({ provider: 'claude-code-sdk', captureKind: 'sdk-message', rawValue: message as JsonValue, }); } } function gstackClaudeCaptureEnv( env: NodeJS.ProcessEnv ): Record { return Object.fromEntries( Object.entries(env).filter( (entry): entry is [string, string] => entry[1] !== undefined && entry[0] !== 'CLAUDE_CODE_ENABLE_TASKS' ) ); } function isJsonObject(value: JsonValue | undefined): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); } main().catch((error) => { process.stderr.write( `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n` ); process.exitCode = 1; });