@linzumi/cli 1.0.18 → 1.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,580 @@
1
+ /*
2
+ - Date: 2026-06-29
3
+ Spec: plans/2026-06-29-claude-code-raw-replay-harness-plan.md
4
+ Relationship: Automatic raw fixture capture entrypoint. It drives real Codex
5
+ app-server and Claude Code SDK sessions against the prepared fixture
6
+ playground and lets the existing live recorder write provider-native NDJSON.
7
+ */
8
+ import { mkdtemp, cp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
9
+ import { tmpdir } from 'node:os';
10
+ import { join, resolve, dirname } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import {
13
+ query as claudeQuery,
14
+ type PermissionMode,
15
+ } from '@anthropic-ai/claude-agent-sdk';
16
+ import {
17
+ connectCodexAppServer,
18
+ startCodexAppServer,
19
+ } from '../src/codexAppServer';
20
+ import { recordLiveAgentReplay } from '../src/agentReplayLiveRecorder';
21
+ import { type JsonObject, type JsonValue } from '../src/protocol';
22
+ import { preflightGstackClaudeSmokeSetup } from '../src/gstackReplayPreflight';
23
+
24
+ type Provider = 'codex' | 'claude' | 'gstack-claude-smoke';
25
+
26
+ type CaptureOptions = {
27
+ readonly providers: readonly Provider[];
28
+ readonly packageRoot: string;
29
+ readonly playgroundPath: string;
30
+ readonly outDir: string;
31
+ readonly exactRaw: boolean;
32
+ readonly keepWorkdirs: boolean;
33
+ readonly codexBin: string;
34
+ readonly codexApprovalPolicy: string | undefined;
35
+ readonly codexSandbox: string | undefined;
36
+ readonly claudePermissionMode: PermissionMode | undefined;
37
+ readonly claudeModel: string | undefined;
38
+ readonly gstackSkillDir: string | undefined;
39
+ readonly gstackCli: string | undefined;
40
+ readonly preflightOnly: boolean;
41
+ readonly preflightJson: boolean;
42
+ };
43
+
44
+ type StartedCodex = Awaited<ReturnType<typeof startCodexAppServer>>;
45
+
46
+ type CodexClient = Awaited<ReturnType<typeof connectCodexAppServer>>;
47
+
48
+ type PreflightResult =
49
+ | { readonly type: 'none' }
50
+ | {
51
+ readonly type: 'gstack';
52
+ readonly setup: Exclude<
53
+ Awaited<ReturnType<typeof preflightGstackClaudeSmokeSetup>>,
54
+ { readonly type: 'error' }
55
+ >;
56
+ };
57
+
58
+ const thisFile = fileURLToPath(import.meta.url);
59
+ const packageRoot = resolve(dirname(thisFile), '..');
60
+
61
+ async function main(): Promise<void> {
62
+ const argv = process.argv.slice(2);
63
+ if (argv.includes('--help')) {
64
+ printHelp();
65
+ return;
66
+ }
67
+
68
+ const options = parseArgs(argv);
69
+ await mkdir(options.outDir, { recursive: true });
70
+
71
+ for (const provider of options.providers) {
72
+ const runDir = await prepareRunDirectory(options, provider);
73
+ let preflight: PreflightResult = { type: 'none' };
74
+ try {
75
+ preflight = await preflightCaptureProvider(options, provider, runDir);
76
+ } catch (error) {
77
+ if (!options.keepWorkdirs) {
78
+ await rm(dirname(runDir), { recursive: true, force: true });
79
+ }
80
+
81
+ throw error;
82
+ }
83
+
84
+ if (options.preflightOnly) {
85
+ writePreflightOk(options, provider, runDir, preflight);
86
+
87
+ switch (options.keepWorkdirs) {
88
+ case true:
89
+ process.stdout.write(`${provider}: workdir ${runDir}\n`);
90
+ break;
91
+ case false:
92
+ await rm(dirname(runDir), { recursive: true, force: true });
93
+ break;
94
+ }
95
+
96
+ continue;
97
+ }
98
+ const fixturePath = join(options.outDir, `${provider}.ndjson`);
99
+ await writeFile(fixturePath, '', 'utf8');
100
+ process.env.LINZUMI_AGENT_REPLAY_RECORD_FILE = fixturePath;
101
+ process.env.LINZUMI_AGENT_REPLAY_EXACT_RAW = options.exactRaw ? '1' : '0';
102
+
103
+ const prompt = await fixturePrompt(options.playgroundPath, provider);
104
+ recordLiveAgentReplay({
105
+ provider: provider === 'codex' ? 'codex-app-server' : 'claude-code-sdk',
106
+ captureKind: 'session-marker',
107
+ rawValue: {
108
+ type: 'capture_started',
109
+ provider,
110
+ cwd: runDir,
111
+ sourcePlayground: options.playgroundPath,
112
+ },
113
+ });
114
+
115
+ switch (provider) {
116
+ case 'codex':
117
+ await runCodexCapture(options, runDir, prompt);
118
+ break;
119
+ case 'claude':
120
+ await runClaudeCapture(options, runDir, prompt, 'generic');
121
+ break;
122
+ case 'gstack-claude-smoke':
123
+ await runClaudeCapture(options, runDir, prompt, 'gstack');
124
+ break;
125
+ }
126
+
127
+ recordLiveAgentReplay({
128
+ provider: provider === 'codex' ? 'codex-app-server' : 'claude-code-sdk',
129
+ captureKind: 'session-marker',
130
+ rawValue: {
131
+ type: 'capture_completed',
132
+ provider,
133
+ cwd: runDir,
134
+ keptWorkdir: options.keepWorkdirs,
135
+ },
136
+ });
137
+
138
+ process.stdout.write(`${provider}: wrote ${fixturePath}\n`);
139
+
140
+ switch (options.keepWorkdirs) {
141
+ case true:
142
+ process.stdout.write(`${provider}: workdir ${runDir}\n`);
143
+ break;
144
+ case false:
145
+ await rm(dirname(runDir), { recursive: true, force: true });
146
+ break;
147
+ }
148
+ }
149
+ }
150
+
151
+ function printHelp(): void {
152
+ process.stdout.write(
153
+ `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 <path> Directory for NDJSON fixtures. Default: /tmp/linzumi-agent-replay-*\n --playground <path> 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 <path> Codex executable. Default: codex\n --codex-approval-policy <value> Forwarded to Codex app-server thread/turn start\n --codex-sandbox <value> Forwarded to Codex app-server thread/turn start\n --claude-permission-mode <value> Forwarded to Claude SDK query options\n --claude-model <model> Forwarded to Claude SDK query options. Can also use LINZUMI_AGENT_REPLAY_CLAUDE_MODEL\n --gstack-skill-dir <path> Explicit gstack Claude skill directory. Can also use LINZUMI_AGENT_REPLAY_GSTACK_SKILL_DIR\n --gstack-cli <path> Explicit gstack CLI path/name. Can also use LINZUMI_AGENT_REPLAY_GSTACK_CLI\n`
154
+ );
155
+ }
156
+
157
+ function parseArgs(args: readonly string[]): CaptureOptions {
158
+ const rawProvider = argValue(args, '--provider') ?? 'both';
159
+ const providers = providersFromArg(rawProvider);
160
+ const outDir = resolve(
161
+ argValue(args, '--out-dir') ??
162
+ join(tmpdir(), `linzumi-agent-replay-${Date.now()}`)
163
+ );
164
+ const playgroundPath = resolve(
165
+ argValue(args, '--playground') ??
166
+ join(packageRoot, 'test/fixtures/agent-fixture-playground')
167
+ );
168
+
169
+ return {
170
+ providers,
171
+ packageRoot,
172
+ playgroundPath,
173
+ outDir,
174
+ exactRaw: flag(args, '--exact-raw'),
175
+ keepWorkdirs: flag(args, '--keep-workdirs'),
176
+ preflightOnly: flag(args, '--preflight-only'),
177
+ preflightJson: flag(args, '--preflight-json'),
178
+ codexBin:
179
+ argValue(args, '--codex-bin') ??
180
+ process.env.LINZUMI_AGENT_REPLAY_CODEX_BIN ??
181
+ 'codex',
182
+ codexApprovalPolicy:
183
+ argValue(args, '--codex-approval-policy') ??
184
+ process.env.LINZUMI_AGENT_REPLAY_CODEX_APPROVAL_POLICY,
185
+ codexSandbox:
186
+ argValue(args, '--codex-sandbox') ??
187
+ process.env.LINZUMI_AGENT_REPLAY_CODEX_SANDBOX,
188
+ claudePermissionMode: claudePermissionModeFromArg(
189
+ argValue(args, '--claude-permission-mode') ??
190
+ process.env.LINZUMI_AGENT_REPLAY_CLAUDE_PERMISSION_MODE
191
+ ),
192
+ claudeModel:
193
+ argValue(args, '--claude-model') ??
194
+ process.env.LINZUMI_AGENT_REPLAY_CLAUDE_MODEL,
195
+ gstackSkillDir:
196
+ argValue(args, '--gstack-skill-dir') ??
197
+ process.env.LINZUMI_AGENT_REPLAY_GSTACK_SKILL_DIR,
198
+ gstackCli:
199
+ argValue(args, '--gstack-cli') ??
200
+ process.env.LINZUMI_AGENT_REPLAY_GSTACK_CLI,
201
+ };
202
+ }
203
+
204
+ function providersFromArg(value: string): readonly Provider[] {
205
+ switch (value) {
206
+ case 'both':
207
+ return ['codex', 'claude'];
208
+ case 'codex':
209
+ return ['codex'];
210
+ case 'claude':
211
+ return ['claude'];
212
+ case 'gstack-claude-smoke':
213
+ return ['gstack-claude-smoke'];
214
+ default:
215
+ throw new Error(`unsupported --provider value: ${value}`);
216
+ }
217
+ }
218
+
219
+ function claudePermissionModeFromArg(
220
+ value: string | undefined
221
+ ): PermissionMode | undefined {
222
+ switch (value) {
223
+ case undefined:
224
+ return undefined;
225
+ case 'default':
226
+ case 'acceptEdits':
227
+ case 'bypassPermissions':
228
+ case 'plan':
229
+ case 'dontAsk':
230
+ case 'auto':
231
+ return value;
232
+ default:
233
+ throw new Error(`unsupported Claude permission mode: ${value}`);
234
+ }
235
+ }
236
+
237
+ function flag(args: readonly string[], name: string): boolean {
238
+ return args.includes(name);
239
+ }
240
+
241
+ function argValue(args: readonly string[], name: string): string | undefined {
242
+ const index = args.indexOf(name);
243
+
244
+ if (index === -1) {
245
+ return undefined;
246
+ }
247
+
248
+ const value = args[index + 1];
249
+
250
+ if (value === undefined || value.startsWith('--')) {
251
+ throw new Error(`${name} requires a value`);
252
+ }
253
+
254
+ return value;
255
+ }
256
+
257
+ async function prepareRunDirectory(
258
+ options: CaptureOptions,
259
+ provider: Provider
260
+ ): Promise<string> {
261
+ const base = await mkdtemp(join(tmpdir(), `linzumi-${provider}-fixture-`));
262
+ const runDir = join(base, 'agent-fixture-playground');
263
+ await cp(options.playgroundPath, runDir, { recursive: true });
264
+ return runDir;
265
+ }
266
+
267
+ function fixturePromptHeading(provider: Provider): string {
268
+ switch (provider) {
269
+ case 'codex':
270
+ return 'Codex Prompt';
271
+ case 'claude':
272
+ return 'Claude Code Prompt';
273
+ case 'gstack-claude-smoke':
274
+ return 'Gstack Claude Smoke Prompt';
275
+ }
276
+ }
277
+
278
+ async function preflightCaptureProvider(
279
+ options: CaptureOptions,
280
+ provider: Provider,
281
+ cwd: string
282
+ ): Promise<PreflightResult> {
283
+ switch (provider) {
284
+ case 'codex':
285
+ case 'claude':
286
+ return { type: 'none' };
287
+ case 'gstack-claude-smoke':
288
+ return {
289
+ type: 'gstack',
290
+ setup: await preflightGstackClaudeSmoke(options, cwd),
291
+ };
292
+ }
293
+ }
294
+
295
+ async function preflightGstackClaudeSmoke(
296
+ options: CaptureOptions,
297
+ cwd: string
298
+ ): Promise<
299
+ Exclude<
300
+ Awaited<ReturnType<typeof preflightGstackClaudeSmokeSetup>>,
301
+ { readonly type: 'error' }
302
+ >
303
+ > {
304
+ const result = await preflightGstackClaudeSmokeSetup({
305
+ cwd,
306
+ env: {
307
+ ...process.env,
308
+ ...(options.gstackSkillDir === undefined
309
+ ? {}
310
+ : { LINZUMI_AGENT_REPLAY_GSTACK_SKILL_DIR: options.gstackSkillDir }),
311
+ ...(options.gstackCli === undefined
312
+ ? {}
313
+ : { LINZUMI_AGENT_REPLAY_GSTACK_CLI: options.gstackCli }),
314
+ },
315
+ });
316
+
317
+ switch (result.type) {
318
+ case 'ok':
319
+ return result;
320
+ case 'error':
321
+ writePreflightError(options, 'gstack-claude-smoke', cwd, result);
322
+ throw new Error(result.message);
323
+ }
324
+ }
325
+
326
+ function writePreflightOk(
327
+ options: CaptureOptions,
328
+ provider: Provider,
329
+ cwd: string,
330
+ preflight: PreflightResult
331
+ ): void {
332
+ if (options.preflightJson) {
333
+ process.stdout.write(
334
+ `${JSON.stringify(preflightOkJson(provider, cwd, preflight), null, 2)}\n`
335
+ );
336
+ return;
337
+ }
338
+
339
+ process.stdout.write(`${provider}: preflight ok (${cwd})\n`);
340
+ }
341
+
342
+ function preflightOkJson(
343
+ provider: Provider,
344
+ cwd: string,
345
+ preflight: PreflightResult
346
+ ): JsonObject {
347
+ switch (preflight.type) {
348
+ case 'none':
349
+ return { provider, status: 'ok', cwd };
350
+ case 'gstack':
351
+ return {
352
+ provider,
353
+ status: 'ok',
354
+ cwd,
355
+ setup: {
356
+ skillDir: preflight.setup.skillDir,
357
+ cliPath: preflight.setup.cliPath,
358
+ checkedSkillDirs: preflight.setup.checkedSkillDirs,
359
+ checkedCliNames: preflight.setup.checkedCliNames,
360
+ runtimeProfile: preflight.setup.runtimeProfile,
361
+ includeHookEvents: preflight.setup.includeHookEvents,
362
+ preservesClaudeTasks: preflight.setup.preservesClaudeTasks,
363
+ },
364
+ };
365
+ }
366
+ }
367
+
368
+ function writePreflightError(
369
+ options: CaptureOptions,
370
+ provider: Provider,
371
+ cwd: string,
372
+ result: Exclude<
373
+ Awaited<ReturnType<typeof preflightGstackClaudeSmokeSetup>>,
374
+ { readonly type: 'ok' }
375
+ >
376
+ ): void {
377
+ if (!options.preflightJson) {
378
+ return;
379
+ }
380
+
381
+ process.stdout.write(
382
+ `${JSON.stringify(
383
+ {
384
+ provider,
385
+ status: 'failed',
386
+ cwd,
387
+ reason: result.message,
388
+ missing: result.missing,
389
+ checkedSkillDirs: result.checkedSkillDirs,
390
+ checkedCliNames: result.checkedCliNames,
391
+ cliPath: result.cliPath ?? null,
392
+ },
393
+ null,
394
+ 2
395
+ )}\n`
396
+ );
397
+ }
398
+
399
+ async function fixturePrompt(
400
+ path: string,
401
+ provider: Provider
402
+ ): Promise<string> {
403
+ const markdown = await readFile(join(path, 'FIXTURE_PROMPTS.md'), 'utf8');
404
+ const heading = fixturePromptHeading(provider);
405
+ const sectionStart = markdown.indexOf(`## ${heading}`);
406
+
407
+ if (sectionStart === -1) {
408
+ throw new Error(`could not find ${heading} in FIXTURE_PROMPTS.md`);
409
+ }
410
+
411
+ const fenceStartMarker = '```text\n';
412
+ const fenceEndMarker = '\n```';
413
+ const codeFenceStart = markdown.indexOf(fenceStartMarker, sectionStart);
414
+
415
+ if (codeFenceStart === -1) {
416
+ throw new Error(
417
+ `could not find text fence for ${heading} in FIXTURE_PROMPTS.md`
418
+ );
419
+ }
420
+
421
+ const bodyStart = codeFenceStart + fenceStartMarker.length;
422
+ const bodyEnd = markdown.indexOf(fenceEndMarker, bodyStart);
423
+
424
+ if (bodyEnd === -1) {
425
+ throw new Error(
426
+ `could not find closing text fence for ${heading} in FIXTURE_PROMPTS.md`
427
+ );
428
+ }
429
+
430
+ return markdown.slice(bodyStart, bodyEnd);
431
+ }
432
+
433
+ async function runCodexCapture(
434
+ options: CaptureOptions,
435
+ cwd: string,
436
+ prompt: string
437
+ ): Promise<void> {
438
+ let started: StartedCodex | undefined;
439
+ let client: CodexClient | undefined;
440
+
441
+ try {
442
+ started = await startCodexAppServer(options.codexBin, cwd);
443
+ client = await connectCodexAppServer(started.url);
444
+ const completion = codexTurnCompletion(client);
445
+ const threadResponse = await client.request('thread/start', {
446
+ cwd,
447
+ serviceName: 'kandan-local-runner',
448
+ personality: 'pragmatic',
449
+ ...(options.codexApprovalPolicy === undefined
450
+ ? {}
451
+ : { approvalPolicy: options.codexApprovalPolicy }),
452
+ ...(options.codexSandbox === undefined
453
+ ? {}
454
+ : { sandbox: options.codexSandbox }),
455
+ });
456
+ const threadId = responseId(threadResponse, ['thread', 'id']);
457
+
458
+ if (threadId === undefined) {
459
+ throw new Error('thread/start response did not include thread.id');
460
+ }
461
+
462
+ const turnResponse = await client.request('turn/start', {
463
+ threadId,
464
+ input: [{ type: 'text', text: prompt }],
465
+ ...(options.codexApprovalPolicy === undefined
466
+ ? {}
467
+ : { approvalPolicy: options.codexApprovalPolicy }),
468
+ ...(options.codexSandbox === undefined
469
+ ? {}
470
+ : { sandbox: options.codexSandbox }),
471
+ });
472
+ const turnId = responseId(turnResponse, ['turn', 'id']);
473
+
474
+ if (turnId === undefined) {
475
+ throw new Error('turn/start response did not include turn.id');
476
+ }
477
+
478
+ await completion;
479
+ } finally {
480
+ client?.close();
481
+ started?.stop();
482
+ }
483
+ }
484
+
485
+ function codexTurnCompletion(client: CodexClient): Promise<void> {
486
+ return new Promise((resolve, reject) => {
487
+ client.onNotification((message) => {
488
+ switch (message.method) {
489
+ case 'turn/completed':
490
+ case 'turn/failed':
491
+ case 'turn/aborted':
492
+ resolve();
493
+ break;
494
+ default:
495
+ break;
496
+ }
497
+ });
498
+ client.onClose((error) => reject(error));
499
+ });
500
+ }
501
+
502
+ function responseId(
503
+ response: JsonValue,
504
+ path: readonly string[]
505
+ ): string | undefined {
506
+ if (!isJsonObject(response) || !isJsonObject(response.result)) {
507
+ return undefined;
508
+ }
509
+
510
+ const first = response.result[path[0]];
511
+ if (!isJsonObject(first)) {
512
+ return undefined;
513
+ }
514
+
515
+ const value = first[path[1]];
516
+ return typeof value === 'string' ? value : undefined;
517
+ }
518
+
519
+ async function runClaudeCapture(
520
+ options: CaptureOptions,
521
+ cwd: string,
522
+ prompt: string,
523
+ profile: 'generic' | 'gstack'
524
+ ): Promise<void> {
525
+ const stream = claudeQuery({
526
+ prompt,
527
+ options: {
528
+ cwd,
529
+ persistSession: true,
530
+ tools: { type: 'preset', preset: 'claude_code' },
531
+ includePartialMessages: true,
532
+ ...(options.claudeModel === undefined
533
+ ? {}
534
+ : { model: options.claudeModel }),
535
+ ...(profile === 'gstack'
536
+ ? { includeHookEvents: true, env: gstackClaudeCaptureEnv(process.env) }
537
+ : {}),
538
+ settingSources: ['user', 'project', 'local'],
539
+ systemPrompt: { type: 'preset', preset: 'claude_code' },
540
+ ...(options.claudePermissionMode === undefined
541
+ ? {}
542
+ : {
543
+ permissionMode: options.claudePermissionMode,
544
+ ...(options.claudePermissionMode === 'bypassPermissions'
545
+ ? { allowDangerouslySkipPermissions: true }
546
+ : {}),
547
+ }),
548
+ },
549
+ });
550
+
551
+ for await (const message of stream) {
552
+ recordLiveAgentReplay({
553
+ provider: 'claude-code-sdk',
554
+ captureKind: 'sdk-message',
555
+ rawValue: message as JsonValue,
556
+ });
557
+ }
558
+ }
559
+
560
+ function gstackClaudeCaptureEnv(
561
+ env: NodeJS.ProcessEnv
562
+ ): Record<string, string> {
563
+ return Object.fromEntries(
564
+ Object.entries(env).filter(
565
+ (entry): entry is [string, string] =>
566
+ entry[1] !== undefined && entry[0] !== 'CLAUDE_CODE_ENABLE_TASKS'
567
+ )
568
+ );
569
+ }
570
+
571
+ function isJsonObject(value: JsonValue | undefined): value is JsonObject {
572
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
573
+ }
574
+
575
+ main().catch((error) => {
576
+ process.stderr.write(
577
+ `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`
578
+ );
579
+ process.exitCode = 1;
580
+ });