imprint-mcp 0.6.0 → 0.6.2

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.
Files changed (43) hide show
  1. package/README.md +2 -0
  2. package/package.json +4 -2
  3. package/prompts/audit-agent.md +4 -2
  4. package/prompts/auth-compile-agent.md +149 -158
  5. package/prompts/build-planning.md +14 -9
  6. package/prompts/compile-agent.md +46 -23
  7. package/prompts/live-verifier-agent.md +101 -0
  8. package/prompts/playbook-compilation.md +8 -1
  9. package/prompts/tool-candidate-detection.md +9 -29
  10. package/src/cli.ts +83 -1
  11. package/src/imprint/audit.ts +219 -20
  12. package/src/imprint/auth-compile-agent.ts +215 -155
  13. package/src/imprint/auth-compile-tools.ts +300 -118
  14. package/src/imprint/auth-verifier.ts +167 -180
  15. package/src/imprint/backend-cache.ts +18 -11
  16. package/src/imprint/backend-ladder.ts +225 -177
  17. package/src/imprint/build-plan.ts +3 -8
  18. package/src/imprint/cdp-browser-fetch.ts +930 -83
  19. package/src/imprint/claude-cli-compile.ts +46 -20
  20. package/src/imprint/codex-cli-compile.ts +436 -139
  21. package/src/imprint/compile-agent-types.ts +15 -10
  22. package/src/imprint/compile-agent.ts +88 -28
  23. package/src/imprint/compile-tools.ts +228 -39
  24. package/src/imprint/compile-verification.ts +187 -0
  25. package/src/imprint/compile.ts +71 -2
  26. package/src/imprint/credential-store.ts +57 -44
  27. package/src/imprint/emit.ts +3 -1
  28. package/src/imprint/freeform-redact.ts +27 -6
  29. package/src/imprint/live-verifier.ts +1665 -0
  30. package/src/imprint/llm.ts +22 -3
  31. package/src/imprint/login.ts +107 -25
  32. package/src/imprint/mcp-compile-server.ts +447 -114
  33. package/src/imprint/mcp-live-verifier-server.ts +359 -0
  34. package/src/imprint/mcp-server.ts +116 -35
  35. package/src/imprint/probe-backends.ts +88 -7
  36. package/src/imprint/recording-request.ts +36 -0
  37. package/src/imprint/request-capture.ts +40 -17
  38. package/src/imprint/runtime.ts +490 -337
  39. package/src/imprint/teach-state.ts +36 -2
  40. package/src/imprint/teach.ts +302 -146
  41. package/src/imprint/tool-candidates.ts +8 -10
  42. package/src/imprint/tool-loader.ts +12 -4
  43. package/src/imprint/types.ts +137 -68
@@ -7,17 +7,21 @@
7
7
  * success only after the MCP done() tool writes the verified sentinel.
8
8
  */
9
9
 
10
- import { type ChildProcess, spawn } from 'node:child_process';
10
+ import { type ChildProcess, spawn, spawnSync } from 'node:child_process';
11
11
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
12
12
  import { isAbsolute as pathIsAbsolute, join as pathJoin } from 'node:path';
13
- import { type Span, context as otelContext } from '@opentelemetry/api';
13
+ import type { Span } from '@opentelemetry/api';
14
14
  import type { AuthCliCompileMode } from './auth-compile-tools.ts';
15
15
  import { type SharedModuleManifestEntry, resolvePlanSliceFromFile } from './build-plan.ts';
16
- import type { CompileAgentProgress, CompileAgentResult } from './compile-agent-types.ts';
16
+ import type {
17
+ AuthCheckpoint,
18
+ CompileAgentProgress,
19
+ CompileAgentResult,
20
+ } from './compile-agent-types.ts';
17
21
  import { formatCandidateContext, formatToolPlan } from './compile-agent-types.ts';
18
22
  import { preferredAgentModel } from './llm.ts';
19
23
  import { createLog } from './log.ts';
20
- import { COMPILE_SENTINELS } from './mcp-compile-server.ts';
24
+ import { COMPILE_SENTINELS, compileDeadlineAfterVerification } from './mcp-compile-server.ts';
21
25
  import type { SharedCompileContext, ToolCandidate } from './tool-candidates.ts';
22
26
  import {
23
27
  endTraceSpan,
@@ -39,9 +43,14 @@ const REPO_ROOT = pathJoin(import.meta.dir, '..', '..');
39
43
  const CLI_PATH = pathJoin(REPO_ROOT, 'src', 'cli.ts');
40
44
  const MCP_SERVER_NAME = 'imprint-compile';
41
45
  const MAX_VERIFICATION_CYCLES = 5;
42
- const MIN_MCP_TOOL_TIMEOUT_SEC = 300;
43
46
  const MAX_MCP_TOOL_TIMEOUT_SEC = 1800;
44
47
 
48
+ function formatRevisionMode(enabled: boolean | undefined): string {
49
+ return enabled
50
+ ? 'REVISION MODE: inspect read_session_summary.revisionContext and the listed existing artifacts/diagnostics first. Preserve proven behavior; repair or honestly narrow only what evidence contradicts.'
51
+ : '';
52
+ }
53
+
45
54
  interface CompileViaCodexCliOptions {
46
55
  session: Session;
47
56
  absoluteToolDir: string;
@@ -59,13 +68,21 @@ interface CompileViaCodexCliOptions {
59
68
  sharedModules?: SharedModuleManifestEntry[];
60
69
  /** Per-tool implementation plan injected into the agent's initial message. */
61
70
  toolPlan?: string;
71
+ /** Revise existing generated artifacts from durable verification feedback. */
72
+ revisionMode?: boolean;
62
73
  /** Present → drive an auth compile rather than a data compile. */
63
74
  authMode?: AuthCliCompileMode;
75
+ /** Auth segments only: resume the same non-interactive Codex session. */
76
+ resume?: { sessionId: string; message: string };
77
+ /** Explicit model selected by the caller. Defaults to the provider preference. */
78
+ model?: string;
64
79
  }
65
80
 
66
81
  interface CodexJsonEvent {
67
82
  type: string;
68
83
  thread_id?: string;
84
+ response_item?: unknown;
85
+ event_msg?: unknown;
69
86
  item?: {
70
87
  id?: string;
71
88
  type?: string;
@@ -106,7 +123,7 @@ export async function compileViaCodexCli(
106
123
  'imprint.tool_name': opts.candidate?.toolName,
107
124
  'imprint.session_path': opts.sessionPath,
108
125
  'imprint.tool_dir': opts.absoluteToolDir,
109
- 'imprint.model': preferredAgentModel('codex-cli'),
126
+ 'imprint.model': opts.model ?? preferredAgentModel('codex-cli'),
110
127
  },
111
128
  async (span) => {
112
129
  const result = await compileViaCodexCliImpl(opts, span);
@@ -129,7 +146,12 @@ async function compileViaCodexCliImpl(
129
146
  traceSpan?: Span,
130
147
  ): Promise<CompileAgentResult> {
131
148
  mkdirSync(opts.absoluteToolDir, { recursive: true });
132
- for (const name of [COMPILE_SENTINELS.done, COMPILE_SENTINELS.giveUp]) {
149
+ for (const name of [
150
+ COMPILE_SENTINELS.done,
151
+ COMPILE_SENTINELS.giveUp,
152
+ COMPILE_SENTINELS.checkpoint,
153
+ COMPILE_SENTINELS.verificationState,
154
+ ]) {
133
155
  const p = pathJoin(opts.absoluteToolDir, name);
134
156
  if (existsSync(p)) {
135
157
  try {
@@ -153,7 +175,9 @@ async function compileViaCodexCliImpl(
153
175
  }
154
176
 
155
177
  // Auth and data compiles share the spawn + JSONL driver below; only the MCP
156
- // server args and the initial prompt body differ.
178
+ // server args and the initial prompt body differ. Auth compiles are resumable
179
+ // segments; resumed segments get only the orchestrator result as the next
180
+ // user message because Codex restores the prior conversation by session id.
157
181
  let mcpArgs: string[];
158
182
  let initialPrompt: string;
159
183
 
@@ -171,13 +195,9 @@ async function compileViaCodexCliImpl(
171
195
  '--auth-plan-json',
172
196
  opts.authMode.authPlanJson,
173
197
  ];
174
- initialPrompt = `<system_instructions>
175
- ${systemPrompt}
176
- </system_instructions>
177
-
178
- ${opts.authMode.initialPrompt}
179
-
180
- Use the imprint-compile MCP tools to inspect the session, write workflow.json, test it live, and call done() when the auth works.`;
198
+ initialPrompt = opts.resume
199
+ ? opts.resume.message
200
+ : buildAuthCodexInitialPrompt(systemPrompt, opts.authMode.initialPrompt);
181
201
  } else {
182
202
  mcpArgs = [
183
203
  'run',
@@ -187,10 +207,13 @@ Use the imprint-compile MCP tools to inspect the session, write workflow.json, t
187
207
  sessionPathAbs,
188
208
  '--tool-dir',
189
209
  opts.absoluteToolDir,
210
+ '--provider',
211
+ 'codex-cli',
190
212
  ...(opts.candidate ? ['--candidate-json', JSON.stringify(opts.candidate)] : []),
191
213
  ...(opts.sharedContext ? ['--shared-context-json', JSON.stringify(opts.sharedContext)] : []),
192
214
  ...(opts.buildPlanPath ? ['--build-plan-path', opts.buildPlanPath] : []),
193
215
  ...(opts.sharedModules ? ['--shared-modules-json', JSON.stringify(opts.sharedModules)] : []),
216
+ ...(opts.revisionMode ? ['--revision-mode'] : []),
194
217
  ];
195
218
  const { assignedSharedModules } = resolvePlanSliceFromFile(
196
219
  opts.buildPlanPath,
@@ -208,14 +231,17 @@ Tool directory: ${opts.absoluteToolDir}
208
231
  You will write artifacts into the tool directory.
209
232
  ${formatCandidateContext(opts.candidate, opts.sharedContext, assignedSharedModules)}
210
233
  ${formatToolPlan(opts.toolPlan)}
234
+ ${formatRevisionMode(opts.revisionMode)}
211
235
 
212
236
  Use the imprint-compile MCP tools to inspect the session, write artifacts, run tests, and call done(). Begin by calling read_session_summary, then proceed per the system instructions.`;
213
237
  }
214
238
 
215
- const model = preferredAgentModel('codex-cli');
239
+ const model = opts.model ?? preferredAgentModel('codex-cli');
216
240
  const initialTokenCount = resolveTraceTokenCount(null, initialPrompt);
217
241
  const captureLlmIo = traceLlmIoEnabled();
218
- const mcpToolTimeoutSec = resolveMcpToolTimeoutSec(opts.deadlineMs);
242
+ // done() owns a separately bounded live-verification phase, so its transport
243
+ // timeout cannot be derived from the compiler's remaining reasoning budget.
244
+ const mcpToolTimeoutSec = MAX_MCP_TOOL_TIMEOUT_SEC;
219
245
  setSpanAttributes(traceSpan, {
220
246
  ...llmSpanAttributes({
221
247
  provider: 'codex-cli',
@@ -237,15 +263,18 @@ Use the imprint-compile MCP tools to inspect the session, write artifacts, run t
237
263
  'imprint.compile.initial_prompt_chars': initialPrompt.length,
238
264
  });
239
265
 
266
+ const execArgs = opts.resume
267
+ ? ['exec', 'resume']
268
+ : [
269
+ 'exec',
270
+ // Data compiles are single-shot. Auth compiles need persisted sessions
271
+ // because run_verification checkpoints resume through `codex exec resume`.
272
+ ...(!opts.authMode ? ['--ephemeral'] : []),
273
+ ];
274
+
240
275
  const args = [
241
276
  '-a',
242
277
  'never',
243
- 'exec',
244
- '--json',
245
- '--ephemeral',
246
- '--ignore-user-config',
247
- '--ignore-rules',
248
- '--skip-git-repo-check',
249
278
  '-C',
250
279
  REPO_ROOT,
251
280
  '-s',
@@ -261,11 +290,26 @@ Use the imprint-compile MCP tools to inspect the session, write artifacts, run t
261
290
  '-c',
262
291
  `mcp_servers.${MCP_SERVER_NAME}.tool_timeout_sec=${mcpToolTimeoutSec}`,
263
292
  '-c',
293
+ `mcp_servers.${MCP_SERVER_NAME}.required=true`,
294
+ '-c',
264
295
  'shell_environment_policy.inherit=all',
296
+ ...execArgs,
297
+ // Compiler subprocesses need only the explicitly configured imprint MCP
298
+ // server. Desktop plugins add unrelated tools/instructions and can delay the
299
+ // mandatory first auth MCP call long enough to trip the progress watchdog.
300
+ '--disable',
301
+ 'plugins',
302
+ '--json',
303
+ '--ignore-user-config',
304
+ '--ignore-rules',
305
+ '--skip-git-repo-check',
306
+ ...(opts.resume ? [opts.resume.sessionId] : []),
265
307
  '-',
266
308
  ];
267
309
 
268
- log(`spawning codex (mcp-server=${MCP_SERVER_NAME})`);
310
+ log(
311
+ `spawning codex (mcp-server=${MCP_SERVER_NAME}${opts.resume ? `, resume=${opts.resume.sessionId.slice(0, 8)}` : ''})`,
312
+ );
269
313
 
270
314
  let child: ChildProcess;
271
315
  try {
@@ -273,6 +317,11 @@ Use the imprint-compile MCP tools to inspect the session, write artifacts, run t
273
317
  cwd: REPO_ROOT,
274
318
  env: process.env,
275
319
  stdio: ['pipe', 'pipe', 'pipe'],
320
+ // The npm `codex` launcher is a Node wrapper around the native binary.
321
+ // Give the wrapper and its descendants a dedicated process group so a
322
+ // watchdog can terminate the whole tree, including descendants that keep
323
+ // the inherited stdout pipe open after the wrapper exits.
324
+ detached: true,
276
325
  });
277
326
  } catch (err) {
278
327
  return finalErrorResult(opts, `failed to spawn codex-cli: ${errMsg(err)}`);
@@ -282,14 +331,27 @@ Use the imprint-compile MCP tools to inspect the session, write artifacts, run t
282
331
  child.stdin?.end(initialPrompt);
283
332
  } catch (err) {
284
333
  try {
285
- child.kill('SIGTERM');
334
+ signalCodexProcessTree(child, 'SIGTERM');
286
335
  } catch {
287
336
  // already gone
288
337
  }
289
338
  return finalErrorResult(opts, `failed to send prompt to codex-cli: ${errMsg(err)}`);
290
339
  }
291
340
 
292
- const result = await driveJsonl(child, opts, traceSpan);
341
+ const terminateOnParentExit = (): void => {
342
+ try {
343
+ signalCodexProcessTree(child, 'SIGTERM');
344
+ } catch {
345
+ // Parent shutdown is best-effort; the OS may already have reaped the child.
346
+ }
347
+ };
348
+ process.once('exit', terminateOnParentExit);
349
+ let result: CompileAgentResult;
350
+ try {
351
+ result = await driveJsonl(child, opts, traceSpan);
352
+ } finally {
353
+ process.removeListener('exit', terminateOnParentExit);
354
+ }
293
355
  const hasActualUsage = result.inputTokens > 0 || result.outputTokens > 0;
294
356
  const inputTokenCount = resolveTraceTokenCount(
295
357
  hasActualUsage ? result.inputTokens : null,
@@ -315,12 +377,14 @@ Use the imprint-compile MCP tools to inspect the session, write artifacts, run t
315
377
  return result;
316
378
  }
317
379
 
318
- function resolveMcpToolTimeoutSec(deadlineMs: number, now = Date.now()): number {
319
- const remainingSec = Math.ceil(Math.max(0, deadlineMs - now) / 1000);
320
- return Math.max(
321
- MIN_MCP_TOOL_TIMEOUT_SEC,
322
- Math.min(MAX_MCP_TOOL_TIMEOUT_SEC, remainingSec || MIN_MCP_TOOL_TIMEOUT_SEC),
323
- );
380
+ function buildAuthCodexInitialPrompt(systemPrompt: string, initialPrompt: string): string {
381
+ return `<system_instructions>
382
+ ${systemPrompt}
383
+ </system_instructions>
384
+
385
+ ${initialPrompt}
386
+
387
+ Codex provider framing: use only the imprint-compile MCP tools exposed for this task. Your first response must be the requested read_session_summary tool call, without preceding prose.`;
324
388
  }
325
389
 
326
390
  async function driveJsonl(
@@ -328,25 +392,33 @@ async function driveJsonl(
328
392
  opts: CompileViaCodexCliOptions,
329
393
  traceSpan?: Span,
330
394
  ): Promise<CompileAgentResult> {
331
- // Capture OTel context so child-process event handlers can parent spans
332
- // under the current compile.codex_cli_agent span. Bun's event emitters
333
- // don't propagate AsyncLocalStorage, so without this the agent.turn.*
334
- // spans appear as orphaned root traces in Phoenix.
335
- const parentCtx = otelContext.active();
336
-
337
395
  const conversationLog: unknown[] = [];
338
396
  const conversationLogPath = pathJoin(opts.absoluteToolDir, '.compile-log.json');
397
+ const rawStdoutPath = pathJoin(opts.absoluteToolDir, '.codex-stdout.jsonl');
398
+ const rawStderrPath = pathJoin(opts.absoluteToolDir, '.codex-stderr.log');
339
399
  const flushLog = (): void => {
340
400
  try {
341
401
  writeFileSync(conversationLogPath, JSON.stringify(conversationLog, null, 2), 'utf8');
342
402
  } catch {}
343
403
  };
404
+ let flushLogTimer: ReturnType<typeof setTimeout> | undefined;
405
+ const scheduleLogFlush = (): void => {
406
+ if (flushLogTimer) return;
407
+ flushLogTimer = setTimeout(() => {
408
+ flushLogTimer = undefined;
409
+ flushLog();
410
+ }, 50);
411
+ flushLogTimer.unref?.();
412
+ };
413
+ const rawStdoutChunks: string[] = [];
414
+ const rawStderrChunks: string[] = [];
344
415
  let inputTokens = 0;
345
416
  let outputTokens = 0;
346
417
  let turn = 0;
347
418
  let lastErrorMessage = '';
348
419
  let stderrBuf = '';
349
420
  let agentMessageCount = 0;
421
+ let capturedSessionId: string | undefined;
350
422
  const toolSpans = new Map<string, Span>();
351
423
  let currentTurnSpan: Span | null = null;
352
424
 
@@ -361,139 +433,202 @@ async function driveJsonl(
361
433
  inputTokens,
362
434
  outputTokens,
363
435
  verificationCycle: 1,
364
- maxVerificationCycles: MAX_VERIFICATION_CYCLES,
436
+ maxVerificationCycles: opts.authMode ? undefined : MAX_VERIFICATION_CYCLES,
365
437
  });
366
438
  };
367
439
 
368
440
  const doneSentinel = pathJoin(opts.absoluteToolDir, COMPILE_SENTINELS.done);
369
441
  const giveUpSentinel = pathJoin(opts.absoluteToolDir, COMPILE_SENTINELS.giveUp);
370
-
371
- const sentinelTimer = setInterval(() => {
372
- if (!existsSync(doneSentinel) && !existsSync(giveUpSentinel)) return;
442
+ const checkpointSentinel = pathJoin(opts.absoluteToolDir, COMPILE_SENTINELS.checkpoint);
443
+ const workflowPath = pathJoin(opts.absoluteToolDir, 'workflow.json');
444
+ const parserPath = pathJoin(opts.absoluteToolDir, 'parser.ts');
445
+ const parserTestPath = pathJoin(opts.absoluteToolDir, 'parser.test.ts');
446
+ const terminateChild = (graceMs: number): void => {
373
447
  try {
374
- child.kill('SIGTERM');
448
+ signalCodexProcessTree(child, 'SIGTERM');
375
449
  } catch {
376
450
  // already gone
377
451
  }
378
- }, 500);
379
-
380
- const deadlineTimer = setTimeout(
381
- () => {
382
- log('wall-clock deadline exceeded, terminating codex');
452
+ const forceTimer = setTimeout(() => {
383
453
  try {
384
- child.kill('SIGTERM');
385
- setTimeout(() => {
386
- if (!child.killed) child.kill('SIGKILL');
387
- }, 5000);
454
+ signalCodexProcessTree(child, 'SIGKILL');
388
455
  } catch {
389
456
  // already gone
390
457
  }
391
- },
392
- Math.max(0, opts.deadlineMs - Date.now()),
393
- );
458
+ }, graceMs);
459
+ forceTimer.unref?.();
460
+ };
461
+ let childExited = false;
462
+ const scheduleDeadlineCheck = (): ReturnType<typeof setTimeout> => {
463
+ const effectiveDeadlineMs = compileDeadlineAfterVerification(
464
+ opts.absoluteToolDir,
465
+ opts.deadlineMs,
466
+ );
467
+ return setTimeout(
468
+ () => {
469
+ if (childExited) return;
470
+ if (Date.now() < compileDeadlineAfterVerification(opts.absoluteToolDir, opts.deadlineMs)) {
471
+ deadlineTimer = scheduleDeadlineCheck();
472
+ return;
473
+ }
474
+ log('wall-clock deadline exceeded, terminating codex');
475
+ terminateChild(5000);
476
+ },
477
+ Math.max(0, effectiveDeadlineMs - Date.now()),
478
+ );
479
+ };
480
+ let deadlineTimer = scheduleDeadlineCheck();
394
481
 
395
482
  let stdoutBuf = '';
396
483
  child.stdout?.on('data', (chunk: Buffer) => {
397
- otelContext.with(parentCtx, () => {
398
- stdoutBuf += chunk.toString('utf8');
399
- while (true) {
400
- const nl = stdoutBuf.indexOf('\n');
401
- if (nl < 0) break;
402
- const line = stdoutBuf.slice(0, nl).trim();
403
- stdoutBuf = stdoutBuf.slice(nl + 1);
404
- if (!line) continue;
405
-
406
- let evt: CodexJsonEvent;
407
- try {
408
- evt = JSON.parse(line) as CodexJsonEvent;
409
- } catch (err) {
410
- log(`unparseable jsonl line: ${errMsg(err)}`);
411
- continue;
412
- }
484
+ const chunkText = chunk.toString('utf8');
485
+ rawStdoutChunks.push(chunkText);
486
+ stdoutBuf += chunkText;
487
+ while (true) {
488
+ const nl = stdoutBuf.indexOf('\n');
489
+ if (nl < 0) break;
490
+ const line = stdoutBuf.slice(0, nl).trim();
491
+ stdoutBuf = stdoutBuf.slice(nl + 1);
492
+ if (!line) continue;
413
493
 
414
- conversationLog.push(evt);
494
+ let evt: CodexJsonEvent;
495
+ try {
496
+ evt = JSON.parse(line) as CodexJsonEvent;
497
+ } catch (err) {
498
+ log(`unparseable jsonl line: ${errMsg(err)}`);
499
+ continue;
500
+ }
415
501
 
416
- if (evt.type === 'thread.started') {
417
- log(`thread_id=${evt.thread_id ?? '(none)'}`);
418
- setSpanAttributes(traceSpan, { 'codex.thread_id': evt.thread_id });
419
- continue;
420
- }
502
+ conversationLog.push(evt);
503
+ // Keep the human-readable compile log current during a long agent turn.
504
+ // Rewriting is throttled so streamed bursts do not cause one full-file
505
+ // write per JSONL event, while a stalled/crashed turn still leaves the
506
+ // latest tool activity visible on disk.
507
+ scheduleLogFlush();
421
508
 
422
- if (evt.type === 'turn.started') {
423
- if (currentTurnSpan) endTraceSpan(currentTurnSpan);
424
- flushLog();
425
- turn++;
426
- currentTurnSpan = startTraceSpan(`agent.turn.${turn}`, 'CHAIN', {
427
- 'imprint.agent.turn': turn,
428
- 'imprint.agent.cumulative_input_tokens': inputTokens,
429
- 'imprint.agent.cumulative_output_tokens': outputTokens,
430
- });
431
- fireProgress('thinking');
432
- continue;
433
- }
509
+ if (evt.type === 'thread.started') {
510
+ log(`thread_id=${evt.thread_id ?? '(none)'}`);
511
+ setSpanAttributes(traceSpan, { 'codex.thread_id': evt.thread_id });
512
+ if (evt.thread_id) capturedSessionId = evt.thread_id;
513
+ continue;
514
+ }
434
515
 
435
- if ((evt.type === 'item.started' || evt.type === 'item.completed') && evt.item) {
436
- const agentMessage = codexAgentMessageText(evt.item);
437
- if (agentMessage && evt.type === 'item.completed') {
438
- agentMessageCount++;
439
- setSpanAttributes(traceSpan, {
440
- 'imprint.codex.agent_messages': agentMessageCount,
441
- 'imprint.codex.last_agent_message_chars': agentMessage.length,
442
- ...(traceLlmIoEnabled()
443
- ? llmSpanAttributes({
444
- provider: 'codex-cli',
445
- model: preferredAgentModel('codex-cli'),
446
- outputMessages: traceLlmMessages([
447
- { role: 'assistant', content: agentMessage },
448
- ]),
449
- outputValue: agentMessage,
450
- })
451
- : {}),
452
- });
453
- continue;
454
- }
455
- const toolName = codexToolName(evt.item);
456
- if (toolName) {
457
- traceCodexToolEvent(toolSpans, evt.type, evt.item, toolName);
458
- fireProgress(evt.type === 'item.started' ? 'tool' : 'thinking', toolName);
459
- }
460
- continue;
516
+ if (evt.type === 'turn.started') {
517
+ if (currentTurnSpan) endTraceSpan(currentTurnSpan);
518
+ flushLog();
519
+ turn++;
520
+ currentTurnSpan = startTraceSpan(`agent.turn.${turn}`, 'CHAIN', {
521
+ 'imprint.agent.turn': turn,
522
+ 'imprint.agent.cumulative_input_tokens': inputTokens,
523
+ 'imprint.agent.cumulative_output_tokens': outputTokens,
524
+ });
525
+ fireProgress('thinking');
526
+ continue;
527
+ }
528
+
529
+ const normalizedToolEvt = normalizeCodexToolEvent(evt);
530
+ if (normalizedToolEvt) {
531
+ const { eventType, item } = normalizedToolEvt;
532
+ const toolName = codexToolName(item);
533
+ if (toolName) {
534
+ traceCodexToolEvent(toolSpans, eventType, item, toolName);
535
+ fireProgress(eventType === 'item.started' ? 'tool' : 'thinking', toolName);
461
536
  }
537
+ continue;
538
+ }
462
539
 
463
- if (evt.type === 'turn.completed') {
464
- const turnInput = evt.usage?.input_tokens ?? 0;
465
- const turnOutput = evt.usage?.output_tokens ?? 0;
466
- inputTokens += turnInput;
467
- outputTokens += turnOutput;
468
- if (currentTurnSpan) {
469
- setSpanAttributes(currentTurnSpan, {
470
- 'imprint.agent.turn_input_tokens': turnInput,
471
- 'imprint.agent.turn_output_tokens': turnOutput,
472
- });
473
- endTraceSpan(currentTurnSpan);
474
- currentTurnSpan = null;
475
- }
540
+ if ((evt.type === 'item.started' || evt.type === 'item.completed') && evt.item) {
541
+ const agentMessage = codexAgentMessageText(evt.item);
542
+ if (agentMessage && evt.type === 'item.completed') {
543
+ agentMessageCount++;
544
+ setSpanAttributes(traceSpan, {
545
+ 'imprint.codex.agent_messages': agentMessageCount,
546
+ 'imprint.codex.last_agent_message_chars': agentMessage.length,
547
+ ...(traceLlmIoEnabled()
548
+ ? llmSpanAttributes({
549
+ provider: 'codex-cli',
550
+ model: opts.model ?? preferredAgentModel('codex-cli'),
551
+ outputMessages: traceLlmMessages([{ role: 'assistant', content: agentMessage }]),
552
+ outputValue: agentMessage,
553
+ })
554
+ : {}),
555
+ });
476
556
  continue;
477
557
  }
558
+ continue;
559
+ }
478
560
 
479
- if (evt.type === 'error' || evt.type === 'turn.failed') {
480
- lastErrorMessage = evt.message ?? evt.error?.message ?? JSON.stringify(evt);
561
+ if (evt.type === 'turn.completed') {
562
+ const turnInput = evt.usage?.input_tokens ?? 0;
563
+ const turnOutput = evt.usage?.output_tokens ?? 0;
564
+ inputTokens += turnInput;
565
+ outputTokens += turnOutput;
566
+ if (currentTurnSpan) {
567
+ setSpanAttributes(currentTurnSpan, {
568
+ 'imprint.agent.turn_input_tokens': turnInput,
569
+ 'imprint.agent.turn_output_tokens': turnOutput,
570
+ });
571
+ endTraceSpan(currentTurnSpan);
572
+ currentTurnSpan = null;
481
573
  }
574
+ continue;
482
575
  }
483
- });
576
+
577
+ if (evt.type === 'error' || evt.type === 'turn.failed') {
578
+ lastErrorMessage = evt.message ?? evt.error?.message ?? JSON.stringify(evt);
579
+ }
580
+ }
484
581
  });
485
582
 
486
583
  child.stderr?.on('data', (chunk: Buffer) => {
487
584
  const s = chunk.toString('utf8');
585
+ rawStderrChunks.push(s);
488
586
  stderrBuf += s;
489
587
  log(`[codex stderr] ${s.trim()}`);
490
588
  });
491
589
 
492
590
  const exitCode: number = await new Promise((resolve) => {
493
- child.once('exit', (code) => resolve(code ?? -1));
494
- child.once('error', () => resolve(-1));
591
+ let resolved = false;
592
+ let forcedKillTimer: ReturnType<typeof setTimeout> | undefined;
593
+ const finish = (code: number): void => {
594
+ if (resolved) return;
595
+ resolved = true;
596
+ if (forcedKillTimer) clearTimeout(forcedKillTimer);
597
+ clearInterval(sentinelTimer);
598
+ resolve(code);
599
+ };
600
+ const terminateForSentinel = (): void => {
601
+ try {
602
+ signalCodexProcessTree(child, 'SIGTERM');
603
+ } catch {
604
+ // already gone
605
+ }
606
+ forcedKillTimer = setTimeout(() => {
607
+ if (resolved) return;
608
+ try {
609
+ signalCodexProcessTree(child, 'SIGKILL');
610
+ } catch {
611
+ // already gone
612
+ }
613
+ // A checkpoint sentinel is enough to let the auth orchestrator proceed;
614
+ // don't wait forever for Codex to acknowledge SIGTERM.
615
+ finish(-1);
616
+ }, 2000);
617
+ forcedKillTimer.unref?.();
618
+ };
619
+ const sentinelTimer = setInterval(() => {
620
+ const checkpointReached = opts.authMode && existsSync(checkpointSentinel);
621
+ if (!existsSync(doneSentinel) && !existsSync(giveUpSentinel) && !checkpointReached) return;
622
+ terminateForSentinel();
623
+ }, 500);
624
+ child.once('close', (code) => {
625
+ finish(code ?? -1);
626
+ });
627
+ child.once('error', () => {
628
+ finish(-1);
629
+ });
495
630
  });
496
- clearInterval(sentinelTimer);
631
+ childExited = true;
497
632
  clearTimeout(deadlineTimer);
498
633
  if (currentTurnSpan) endTraceSpan(currentTurnSpan);
499
634
  for (const span of toolSpans.values()) endTraceSpan(span);
@@ -503,12 +638,15 @@ async function driveJsonl(
503
638
  log(`unflushed stdout tail (${stdoutBuf.length} bytes) discarded`);
504
639
  }
505
640
 
641
+ try {
642
+ writeFileSync(rawStdoutPath, rawStdoutChunks.join(''), 'utf8');
643
+ writeFileSync(rawStderrPath, rawStderrChunks.join(''), 'utf8');
644
+ } catch {
645
+ // best effort diagnostics
646
+ }
647
+ if (flushLogTimer) clearTimeout(flushLogTimer);
506
648
  flushLog();
507
649
 
508
- const workflowPath = pathJoin(opts.absoluteToolDir, 'workflow.json');
509
- const parserPath = pathJoin(opts.absoluteToolDir, 'parser.ts');
510
- const parserTestPath = pathJoin(opts.absoluteToolDir, 'parser.test.ts');
511
-
512
650
  const verifiedOk =
513
651
  existsSync(doneSentinel) &&
514
652
  (() => {
@@ -539,6 +677,7 @@ async function driveJsonl(
539
677
  | 'outputTokens'
540
678
  | 'cacheReadInputTokens'
541
679
  | 'cacheCreationInputTokens'
680
+ | 'sessionId'
542
681
  > = {
543
682
  workflowPath: existsSync(workflowPath) ? workflowPath : undefined,
544
683
  parserPath: existsSync(parserPath) ? parserPath : undefined,
@@ -550,8 +689,32 @@ async function driveJsonl(
550
689
  outputTokens,
551
690
  cacheReadInputTokens: 0,
552
691
  cacheCreationInputTokens: 0,
692
+ sessionId: capturedSessionId,
553
693
  };
554
694
 
695
+ // Auth segment: the agent paused at a checkpoint for the orchestrator to act.
696
+ // The auth orchestrator resumes the same Codex session with the checkpoint
697
+ // result after running live verification.
698
+ if (opts.authMode && existsSync(checkpointSentinel)) {
699
+ let cp: AuthCheckpoint | undefined;
700
+ try {
701
+ const raw = readFileSync(checkpointSentinel, 'utf8').trim();
702
+ const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : undefined;
703
+ if (parsed && typeof parsed.kind === 'string') cp = parsed as unknown as AuthCheckpoint;
704
+ } catch (err) {
705
+ log(`failed to parse checkpoint sentinel: ${errMsg(err)}`);
706
+ }
707
+ if (cp) {
708
+ return {
709
+ success: false,
710
+ outcome: 'checkpoint',
711
+ checkpoint: cp,
712
+ message: `checkpoint:${cp.kind}`,
713
+ ...baseResult,
714
+ };
715
+ }
716
+ }
717
+
555
718
  if (existsSync(doneSentinel)) {
556
719
  let payload: {
557
720
  summary?: string;
@@ -597,7 +760,7 @@ async function driveJsonl(
597
760
  };
598
761
  }
599
762
 
600
- if (Date.now() > opts.deadlineMs) {
763
+ if (Date.now() > compileDeadlineAfterVerification(opts.absoluteToolDir, opts.deadlineMs)) {
601
764
  return {
602
765
  success: false,
603
766
  outcome: 'timeout',
@@ -624,6 +787,69 @@ async function driveJsonl(
624
787
  };
625
788
  }
626
789
 
790
+ export function collectDescendantPids(
791
+ rows: Array<{ pid: number; ppid: number }>,
792
+ rootPid: number,
793
+ ): number[] {
794
+ const childrenByParent = new Map<number, number[]>();
795
+ for (const row of rows) {
796
+ const children = childrenByParent.get(row.ppid) ?? [];
797
+ children.push(row.pid);
798
+ childrenByParent.set(row.ppid, children);
799
+ }
800
+ const descendants: number[] = [];
801
+ const visit = (pid: number): void => {
802
+ for (const childPid of childrenByParent.get(pid) ?? []) {
803
+ visit(childPid);
804
+ descendants.push(childPid);
805
+ }
806
+ };
807
+ visit(rootPid);
808
+ return descendants;
809
+ }
810
+
811
+ function liveDescendantPids(rootPid: number): number[] {
812
+ const ps = spawnSync('ps', ['-axo', 'pid=,ppid='], { encoding: 'utf8' });
813
+ if (ps.status !== 0 || typeof ps.stdout !== 'string') return [];
814
+ const rows = ps.stdout
815
+ .split('\n')
816
+ .map((line) => line.trim().split(/\s+/).map(Number))
817
+ .filter((pair): pair is [number, number] => pair.length === 2 && pair.every(Number.isFinite))
818
+ .map(([pid, ppid]) => ({ pid, ppid }));
819
+ return collectDescendantPids(rows, rootPid);
820
+ }
821
+
822
+ function signalPidOrGroup(pid: number, signal: NodeJS.Signals): void {
823
+ try {
824
+ process.kill(-pid, signal);
825
+ return;
826
+ } catch {
827
+ // The descendant may not lead its own group. Fall back to the process itself.
828
+ }
829
+ try {
830
+ process.kill(pid, signal);
831
+ } catch {
832
+ // already gone
833
+ }
834
+ }
835
+
836
+ function signalCodexProcessTree(child: ChildProcess, signal: NodeJS.Signals): void {
837
+ if (child.pid !== undefined) {
838
+ // Codex-launched MCP servers may create their own process groups. Killing only
839
+ // the detached Codex group leaves those grandchildren running after Ctrl-C,
840
+ // where they can keep compiling and issuing live calls. Snapshot and terminate
841
+ // descendants deepest-first while the parent relationship still exists.
842
+ for (const pid of liveDescendantPids(child.pid)) signalPidOrGroup(pid, signal);
843
+ try {
844
+ process.kill(-child.pid, signal);
845
+ return;
846
+ } catch {
847
+ // Fall back to the direct child when process groups are unavailable.
848
+ }
849
+ }
850
+ child.kill(signal);
851
+ }
852
+
627
853
  function traceCodexToolEvent(
628
854
  spans: Map<string, Span>,
629
855
  eventType: string,
@@ -670,6 +896,72 @@ function traceCodexToolEvent(
670
896
  spans.delete(id);
671
897
  }
672
898
 
899
+ function normalizeCodexToolEvent(evt: CodexJsonEvent):
900
+ | {
901
+ eventType: 'item.started' | 'item.completed';
902
+ item: NonNullable<CodexJsonEvent['item']>;
903
+ }
904
+ | undefined {
905
+ if (
906
+ (evt.type === 'item.started' || evt.type === 'item.completed') &&
907
+ evt.item &&
908
+ codexToolName(evt.item)
909
+ ) {
910
+ return { eventType: evt.type, item: evt.item };
911
+ }
912
+
913
+ const responseItem = isRecord(evt.response_item)
914
+ ? evt.response_item
915
+ : evt.type === 'response_item' && isRecord(evt.item)
916
+ ? evt.item
917
+ : undefined;
918
+ if (responseItem) {
919
+ const responseType = stringField(responseItem, 'type');
920
+ const name = stringField(responseItem, 'name') ?? stringField(responseItem, 'tool_name');
921
+ if (responseType === 'function_call' && name) {
922
+ return {
923
+ eventType:
924
+ stringField(responseItem, 'status') === 'in_progress' ? 'item.started' : 'item.completed',
925
+ item: {
926
+ id: stringField(responseItem, 'id') ?? stringField(responseItem, 'call_id'),
927
+ type: 'mcp_tool_call',
928
+ name,
929
+ arguments: responseItem.arguments,
930
+ status: stringField(responseItem, 'status'),
931
+ },
932
+ };
933
+ }
934
+ }
935
+
936
+ const eventMsg = isRecord(evt.event_msg)
937
+ ? evt.event_msg
938
+ : evt.type === 'event_msg' && isRecord(evt.item)
939
+ ? evt.item
940
+ : undefined;
941
+ if (eventMsg) {
942
+ const name = stringField(eventMsg, 'name') ?? stringField(eventMsg, 'tool_name');
943
+ if (name) {
944
+ const msgType = stringField(eventMsg, 'type') ?? evt.type;
945
+ return {
946
+ eventType:
947
+ msgType.includes('start') || msgType.includes('begin')
948
+ ? 'item.started'
949
+ : 'item.completed',
950
+ item: {
951
+ id: stringField(eventMsg, 'id') ?? stringField(eventMsg, 'call_id'),
952
+ type: 'mcp_tool_call',
953
+ name,
954
+ status: stringField(eventMsg, 'status'),
955
+ result: eventMsg.result ?? eventMsg.output,
956
+ error: eventMsg.error,
957
+ },
958
+ };
959
+ }
960
+ }
961
+
962
+ return undefined;
963
+ }
964
+
673
965
  function codexAgentMessageText(item: NonNullable<CodexJsonEvent['item']>): string | undefined {
674
966
  if (item.type !== 'agent_message') return undefined;
675
967
  if (typeof item.text === 'string') return item.text;
@@ -730,6 +1022,11 @@ function isRecord(value: unknown): value is Record<string, unknown> {
730
1022
  return typeof value === 'object' && value !== null && !Array.isArray(value);
731
1023
  }
732
1024
 
1025
+ function stringField(record: Record<string, unknown>, key: string): string | undefined {
1026
+ const value = record[key];
1027
+ return typeof value === 'string' ? value : undefined;
1028
+ }
1029
+
733
1030
  function finalErrorResult(opts: CompileViaCodexCliOptions, message: string): CompileAgentResult {
734
1031
  mkdirSync(opts.absoluteToolDir, { recursive: true });
735
1032
  const conversationLogPath = pathJoin(opts.absoluteToolDir, '.compile-log.json');