imprint-mcp 0.6.1 → 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.
@@ -94,6 +94,27 @@ export function jarCookiesValidated(cookies: Array<{ name: string; value: string
94
94
  return cookies.some((c) => c.name === 'bm_sv');
95
95
  }
96
96
 
97
+ export function buildNavigationSelectorExpression(selector: string): string {
98
+ return `document.querySelector(${JSON.stringify(selector)}) !== null`;
99
+ }
100
+
101
+ export function buildNavigationClickTargetExpression(selector: string): string {
102
+ return `(() => {
103
+ const target = document.querySelector(${JSON.stringify(selector)});
104
+ if (!(target instanceof Element)) return null;
105
+ target.scrollIntoView({ block: 'center', inline: 'center' });
106
+ const rect = target.getBoundingClientRect();
107
+ if (rect.width <= 0 || rect.height <= 0) return null;
108
+ if (target.matches(':disabled')) return null;
109
+ const x = rect.left + rect.width / 2;
110
+ const y = rect.top + rect.height / 2;
111
+ const hit = document.elementFromPoint(x, y);
112
+ if (hit !== target && !target.contains(hit) && !hit?.contains(target)) return null;
113
+ target.focus();
114
+ return { x, y };
115
+ })()`;
116
+ }
117
+
97
118
  export interface CdpBrowserFetch {
98
119
  /** typeof fetch — executes the request inside the live trusted Chrome page. */
99
120
  readonly fetchImpl: typeof fetch;
@@ -109,6 +130,9 @@ export interface CdpBrowserFetch {
109
130
  timeoutMs?: number;
110
131
  pollIntervalMs?: number;
111
132
  urlIncludes?: string;
133
+ selector?: string;
134
+ actions?: Array<{ action: 'click'; selector: string }>;
135
+ resultSelector?: string;
112
136
  cookie?: { name: string; domain?: string; path?: string };
113
137
  },
114
138
  ): Promise<Response>;
@@ -1253,6 +1277,49 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
1253
1277
  return currentPageUrl;
1254
1278
  };
1255
1279
 
1280
+ const dispatchTrustedClick = async (
1281
+ c: CdpClient,
1282
+ x: number,
1283
+ y: number,
1284
+ label: string,
1285
+ timeoutMs: number,
1286
+ ): Promise<void> => {
1287
+ await withTimeout(c.Page.bringToFront(), `CDP Page.bringToFront(${label})`, timeoutMs);
1288
+ const timestamp = Date.now() / 1000;
1289
+ await withTimeout(
1290
+ c.Input.dispatchMouseEvent({ type: 'mouseMoved', x, y, timestamp }),
1291
+ `CDP Input.dispatchMouseEvent(${label} mouseMoved)`,
1292
+ timeoutMs,
1293
+ );
1294
+ await sleep(100);
1295
+ await withTimeout(
1296
+ c.Input.dispatchMouseEvent({
1297
+ type: 'mousePressed',
1298
+ button: 'left',
1299
+ buttons: 1,
1300
+ clickCount: 1,
1301
+ x,
1302
+ y,
1303
+ timestamp,
1304
+ }),
1305
+ `CDP Input.dispatchMouseEvent(${label} mousePressed)`,
1306
+ timeoutMs,
1307
+ );
1308
+ await sleep(50);
1309
+ await withTimeout(
1310
+ c.Input.dispatchMouseEvent({
1311
+ type: 'mouseReleased',
1312
+ button: 'left',
1313
+ clickCount: 1,
1314
+ x,
1315
+ y,
1316
+ timestamp: Date.now() / 1000,
1317
+ }),
1318
+ `CDP Input.dispatchMouseEvent(${label} mouseReleased)`,
1319
+ timeoutMs,
1320
+ );
1321
+ };
1322
+
1256
1323
  const navigate = async (
1257
1324
  rawUrl: string,
1258
1325
  options: {
@@ -1263,6 +1330,9 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
1263
1330
  timeoutMs?: number;
1264
1331
  pollIntervalMs?: number;
1265
1332
  urlIncludes?: string;
1333
+ selector?: string;
1334
+ actions?: Array<{ action: 'click'; selector: string }>;
1335
+ resultSelector?: string;
1266
1336
  cookie?: { name: string; domain?: string; path?: string };
1267
1337
  } = {},
1268
1338
  ): Promise<Response> => {
@@ -1402,44 +1472,7 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
1402
1472
  log(
1403
1473
  `form POST: trusted click on ${String(directive.tag ?? 'control')} type=${String(directive.type ?? 'unknown')} focused=${focusProbe.result.value === true} hit=${liveTarget.hit === true}`,
1404
1474
  );
1405
- const timestamp = Date.now() / 1000;
1406
- await withTimeout(
1407
- c.Input.dispatchMouseEvent({
1408
- type: 'mouseMoved',
1409
- x: clickX,
1410
- y: clickY,
1411
- timestamp,
1412
- }),
1413
- 'CDP Input.dispatchMouseEvent(form mouseMoved)',
1414
- cdpCommandTimeoutMs,
1415
- );
1416
- await sleep(100);
1417
- await withTimeout(
1418
- c.Input.dispatchMouseEvent({
1419
- type: 'mousePressed',
1420
- button: 'left',
1421
- buttons: 1,
1422
- clickCount: 1,
1423
- x: clickX,
1424
- y: clickY,
1425
- timestamp,
1426
- }),
1427
- 'CDP Input.dispatchMouseEvent(form mousePressed)',
1428
- cdpCommandTimeoutMs,
1429
- );
1430
- await sleep(50);
1431
- await withTimeout(
1432
- c.Input.dispatchMouseEvent({
1433
- type: 'mouseReleased',
1434
- button: 'left',
1435
- clickCount: 1,
1436
- x: clickX,
1437
- y: clickY,
1438
- timestamp: Date.now() / 1000,
1439
- }),
1440
- 'CDP Input.dispatchMouseEvent(form mouseReleased)',
1441
- cdpCommandTimeoutMs,
1442
- );
1475
+ await dispatchTrustedClick(c, clickX, clickY, 'form', cdpCommandTimeoutMs);
1443
1476
  await sleep(250);
1444
1477
  const clickProbe = await withTimeout(
1445
1478
  c.Runtime.evaluate({
@@ -1491,15 +1524,28 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
1491
1524
  }
1492
1525
  }
1493
1526
 
1494
- const hasPredicate = Boolean(options.urlIncludes || options.cookie);
1527
+ const selectorExists = async (selector?: string): Promise<boolean> => {
1528
+ if (!selector) return true;
1529
+ const evaluated = await withTimeout(
1530
+ c.Runtime.evaluate({
1531
+ expression: buildNavigationSelectorExpression(selector),
1532
+ returnByValue: true,
1533
+ }),
1534
+ 'CDP Runtime.evaluate(navigation selector)',
1535
+ cdpCommandTimeoutMs,
1536
+ );
1537
+ return evaluated.result.value === true;
1538
+ };
1539
+ const hasPredicate = Boolean(options.urlIncludes || options.selector || options.cookie);
1540
+ const deadline = Date.now() + timeoutMs;
1495
1541
  if (!hasPredicate) {
1496
1542
  await withTimeout(lifecyclePromise, `CDP Page.${options.waitUntil ?? 'load'}`, timeoutMs);
1497
1543
  } else {
1498
- const deadline = Date.now() + timeoutMs;
1499
1544
  let lastUrl = targetUrl;
1500
1545
  while (Date.now() < deadline) {
1501
1546
  lastUrl = await readCurrentPageUrl(c);
1502
1547
  const urlMatches = !options.urlIncludes || lastUrl.includes(options.urlIncludes);
1548
+ const selectorMatches = await selectorExists(options.selector);
1503
1549
  let cookieMatches = !options.cookie;
1504
1550
  if (options.cookie) {
1505
1551
  const expectedDomain = options.cookie.domain?.replace(/^\./, '').toLowerCase();
@@ -1515,7 +1561,7 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
1515
1561
  return !options.cookie?.path || cookie.path === options.cookie.path;
1516
1562
  });
1517
1563
  }
1518
- if (urlMatches && cookieMatches) break;
1564
+ if (urlMatches && selectorMatches && cookieMatches) break;
1519
1565
  await sleep(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())));
1520
1566
  }
1521
1567
 
@@ -1528,6 +1574,7 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
1528
1574
  await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
1529
1575
 
1530
1576
  const finalUrl = await readCurrentPageUrl(c);
1577
+ const finalSelectorMatches = await selectorExists(options.selector);
1531
1578
  const allCookies = await snapshotAllCookies(c);
1532
1579
  const expectedDomain = options.cookie?.domain?.replace(/^\./, '').toLowerCase();
1533
1580
  const matchedCookie = !options.cookie
@@ -1542,9 +1589,14 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
1542
1589
  }
1543
1590
  return !options.cookie?.path || cookie.path === options.cookie.path;
1544
1591
  });
1545
- if ((options.urlIncludes && !finalUrl.includes(options.urlIncludes)) || !matchedCookie) {
1592
+ if (
1593
+ (options.urlIncludes && !finalUrl.includes(options.urlIncludes)) ||
1594
+ !finalSelectorMatches ||
1595
+ !matchedCookie
1596
+ ) {
1546
1597
  const criteria = [
1547
1598
  options.urlIncludes ? `URL containing ${JSON.stringify(options.urlIncludes)}` : null,
1599
+ options.selector ? `CSS selector ${JSON.stringify(options.selector)}` : null,
1548
1600
  options.cookie ? `cookie ${options.cookie.name}` : null,
1549
1601
  ]
1550
1602
  .filter(Boolean)
@@ -1555,6 +1607,47 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
1555
1607
  }
1556
1608
  }
1557
1609
 
1610
+ for (const interaction of options.actions ?? []) {
1611
+ if (interaction.action !== 'click') continue;
1612
+ const target = await withTimeout(
1613
+ c.Runtime.evaluate({
1614
+ expression: buildNavigationClickTargetExpression(interaction.selector),
1615
+ returnByValue: true,
1616
+ }),
1617
+ `CDP Runtime.evaluate(navigation click target ${JSON.stringify(interaction.selector)})`,
1618
+ Math.max(1, deadline - Date.now()),
1619
+ ).catch(() => null);
1620
+ const point = target?.result.value as { x?: unknown; y?: unknown } | null | undefined;
1621
+ if (typeof point?.x !== 'number' || typeof point.y !== 'number') {
1622
+ throw new Error(
1623
+ `browser navigation action could not find a visible click target for ${JSON.stringify(interaction.selector)}`,
1624
+ );
1625
+ }
1626
+ await dispatchTrustedClick(
1627
+ c,
1628
+ point.x,
1629
+ point.y,
1630
+ 'navigation',
1631
+ Math.max(1, deadline - Date.now()),
1632
+ );
1633
+ await sleep(150);
1634
+ }
1635
+
1636
+ if (options.resultSelector) {
1637
+ let matched = false;
1638
+ while (Date.now() < deadline) {
1639
+ matched = await selectorExists(options.resultSelector).catch(() => false);
1640
+ if (matched) break;
1641
+ await sleep(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())));
1642
+ }
1643
+ if (!matched) {
1644
+ throw new Error(
1645
+ `browser navigation timed out after ${timeoutMs}ms waiting for post-action CSS selector ${JSON.stringify(options.resultSelector)}`,
1646
+ );
1647
+ }
1648
+ await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
1649
+ }
1650
+
1558
1651
  currentPageUrl = await readCurrentPageUrl(c);
1559
1652
  let html = '';
1560
1653
  try {
@@ -39,7 +39,7 @@ import type {
39
39
  import { formatCandidateContext, formatToolPlan } from './compile-agent-types.ts';
40
40
  import { preferredAgentModel } from './llm.ts';
41
41
  import { createLog } from './log.ts';
42
- import { COMPILE_SENTINELS } from './mcp-compile-server.ts';
42
+ import { COMPILE_SENTINELS, compileDeadlineAfterVerification } from './mcp-compile-server.ts';
43
43
  import type { SharedCompileContext, ToolCandidate } from './tool-candidates.ts';
44
44
  import {
45
45
  endTraceSpan,
@@ -60,6 +60,12 @@ const CLI_PATH = pathJoin(REPO_ROOT, 'src', 'cli.ts');
60
60
  const MCP_SERVER_NAME = 'imprint-compile';
61
61
  const MAX_VERIFICATION_CYCLES = 5;
62
62
 
63
+ function formatRevisionMode(enabled: boolean | undefined): string {
64
+ return enabled
65
+ ? '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.'
66
+ : '';
67
+ }
68
+
63
69
  /**
64
70
  * Thinking effort for the compile agent. Deliberately `high`, not `max`:
65
71
  * empirically, max-effort thinking generates a large volume of reasoning tokens
@@ -110,6 +116,8 @@ interface CompileViaClaudeCliOptions {
110
116
  sharedModules?: SharedModuleManifestEntry[];
111
117
  /** Per-tool implementation plan injected into the agent's initial message. */
112
118
  toolPlan?: string;
119
+ /** Revise existing generated artifacts from durable verification feedback. */
120
+ revisionMode?: boolean;
113
121
  /** Present → drive an auth compile rather than a data compile. */
114
122
  authMode?: AuthCliCompileMode;
115
123
  /** Auth segments only: resume a prior segment's claude session with a new user
@@ -275,6 +283,7 @@ async function runClaudeCliAttempt(opts: CompileViaClaudeCliOptions): Promise<Co
275
283
  COMPILE_SENTINELS.done,
276
284
  COMPILE_SENTINELS.giveUp,
277
285
  COMPILE_SENTINELS.checkpoint,
286
+ COMPILE_SENTINELS.verificationState,
278
287
  ]) {
279
288
  const p = pathJoin(opts.absoluteToolDir, name);
280
289
  if (existsSync(p)) {
@@ -325,10 +334,13 @@ async function runClaudeCliAttempt(opts: CompileViaClaudeCliOptions): Promise<Co
325
334
  sessionPathAbs,
326
335
  '--tool-dir',
327
336
  opts.absoluteToolDir,
337
+ '--provider',
338
+ 'claude-cli',
328
339
  ...(opts.candidate ? ['--candidate-json', JSON.stringify(opts.candidate)] : []),
329
340
  ...(opts.sharedContext ? ['--shared-context-json', JSON.stringify(opts.sharedContext)] : []),
330
341
  ...(opts.buildPlanPath ? ['--build-plan-path', opts.buildPlanPath] : []),
331
342
  ...(opts.sharedModules ? ['--shared-modules-json', JSON.stringify(opts.sharedModules)] : []),
343
+ ...(opts.revisionMode ? ['--revision-mode'] : []),
332
344
  ];
333
345
  allowedToolNames = [
334
346
  'read_session_summary',
@@ -355,6 +367,7 @@ Tool directory: ${opts.absoluteToolDir}
355
367
  You will write artifacts into the tool directory.
356
368
  ${formatCandidateContext(opts.candidate, opts.sharedContext, assignedSharedModules)}
357
369
  ${formatToolPlan(opts.toolPlan)}
370
+ ${formatRevisionMode(opts.revisionMode)}
358
371
 
359
372
  Begin by calling read_session_summary to orient yourself, then proceed per the system prompt.`;
360
373
  }
@@ -425,19 +438,14 @@ Begin by calling read_session_summary to orient yourself, then proceed per the s
425
438
  child = spawn('claude', args, {
426
439
  cwd: REPO_ROOT,
427
440
  // Claude CLI's default MCP_TOOL_TIMEOUT is 60s. The compile MCP
428
- // server's `done` tool runs external verification inline — bun test
429
- // (up to 60s × 3 retries for the integration suite + 120s for the
430
- // parser suite) plus typechecking. On bot-protected sites where the
431
- // integration test escalates fetch → fetch-bootstrap → stealth-fetch
432
- // for every assertion, a single bun test pass can run 30s × 3
433
- // rungs × N tests = 10-15 min before the outer wrapper kills it,
434
- // and 3 retries push the total well past 30 min. A 10-min cap was
435
- // not enough — set 30 min so the worst-case verification can
436
- // actually complete and the agent receives the failure feedback
437
- // (and ships with `liveVerified: false` via the waiver path)
438
- // rather than getting `-32000: Connection closed` mid-call and
439
- // wasting the rest of its turn budget. Honor user-set env so an
440
- // operator on a fast network can tighten without editing source.
441
+ // server's `done` tool runs external verification inline: one live
442
+ // integration suite, parser tests, typechecking, and a fresh semantic
443
+ // verifier agent that may make a narrowly targeted follow-up call. On
444
+ // bot-protected sites one suite can still take 10-15 minutes as each
445
+ // assertion escalates fetch → fetch-bootstrap → stealth-fetch. Keep the
446
+ // 30-minute cap so the agent receives semantic feedback rather than an
447
+ // MCP connection-close midway through review. Honor user-set env so an
448
+ // operator on a fast network can tighten it without editing source.
441
449
  // Connection-startup timeout stays at 60s for cold Playwright boot.
442
450
  env: {
443
451
  ...process.env,
@@ -516,9 +524,19 @@ async function driveStreamJson(
516
524
  };
517
525
 
518
526
  const scheduleDeadlineCheck = (): ReturnType<typeof setTimeout> => {
519
- const remaining = Math.max(0, currentDeadlineMs - Date.now());
527
+ const effectiveDeadlineMs = compileDeadlineAfterVerification(
528
+ opts.absoluteToolDir,
529
+ currentDeadlineMs,
530
+ );
531
+ const remaining = Math.max(0, effectiveDeadlineMs - Date.now());
520
532
  return setTimeout(async () => {
521
533
  if (childExited) return;
534
+ // done() verification runs in the MCP child. Re-read its lifecycle clock
535
+ // before killing the compiler so only reasoning time counts here.
536
+ if (Date.now() < compileDeadlineAfterVerification(opts.absoluteToolDir, currentDeadlineMs)) {
537
+ deadlineTimer = scheduleDeadlineCheck();
538
+ return;
539
+ }
522
540
  if (opts.onDeadlineReached) {
523
541
  const extensionMs = await opts.onDeadlineReached();
524
542
  if (childExited) return;
@@ -748,7 +766,11 @@ async function driveStreamJson(
748
766
  }
749
767
 
750
768
  // Wall-clock deadline exceeded?
751
- if (Date.now() > currentDeadlineMs && !existsSync(doneSentinel) && !existsSync(giveUpSentinel)) {
769
+ if (
770
+ Date.now() > compileDeadlineAfterVerification(opts.absoluteToolDir, currentDeadlineMs) &&
771
+ !existsSync(doneSentinel) &&
772
+ !existsSync(giveUpSentinel)
773
+ ) {
752
774
  return {
753
775
  success: false,
754
776
  outcome: 'timeout',
@@ -7,7 +7,7 @@
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
13
  import type { Span } from '@opentelemetry/api';
@@ -21,7 +21,7 @@ import type {
21
21
  import { formatCandidateContext, formatToolPlan } from './compile-agent-types.ts';
22
22
  import { preferredAgentModel } from './llm.ts';
23
23
  import { createLog } from './log.ts';
24
- import { COMPILE_SENTINELS } from './mcp-compile-server.ts';
24
+ import { COMPILE_SENTINELS, compileDeadlineAfterVerification } from './mcp-compile-server.ts';
25
25
  import type { SharedCompileContext, ToolCandidate } from './tool-candidates.ts';
26
26
  import {
27
27
  endTraceSpan,
@@ -43,9 +43,14 @@ const REPO_ROOT = pathJoin(import.meta.dir, '..', '..');
43
43
  const CLI_PATH = pathJoin(REPO_ROOT, 'src', 'cli.ts');
44
44
  const MCP_SERVER_NAME = 'imprint-compile';
45
45
  const MAX_VERIFICATION_CYCLES = 5;
46
- const MIN_MCP_TOOL_TIMEOUT_SEC = 300;
47
46
  const MAX_MCP_TOOL_TIMEOUT_SEC = 1800;
48
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
+
49
54
  interface CompileViaCodexCliOptions {
50
55
  session: Session;
51
56
  absoluteToolDir: string;
@@ -63,6 +68,8 @@ interface CompileViaCodexCliOptions {
63
68
  sharedModules?: SharedModuleManifestEntry[];
64
69
  /** Per-tool implementation plan injected into the agent's initial message. */
65
70
  toolPlan?: string;
71
+ /** Revise existing generated artifacts from durable verification feedback. */
72
+ revisionMode?: boolean;
66
73
  /** Present → drive an auth compile rather than a data compile. */
67
74
  authMode?: AuthCliCompileMode;
68
75
  /** Auth segments only: resume the same non-interactive Codex session. */
@@ -143,6 +150,7 @@ async function compileViaCodexCliImpl(
143
150
  COMPILE_SENTINELS.done,
144
151
  COMPILE_SENTINELS.giveUp,
145
152
  COMPILE_SENTINELS.checkpoint,
153
+ COMPILE_SENTINELS.verificationState,
146
154
  ]) {
147
155
  const p = pathJoin(opts.absoluteToolDir, name);
148
156
  if (existsSync(p)) {
@@ -199,10 +207,13 @@ async function compileViaCodexCliImpl(
199
207
  sessionPathAbs,
200
208
  '--tool-dir',
201
209
  opts.absoluteToolDir,
210
+ '--provider',
211
+ 'codex-cli',
202
212
  ...(opts.candidate ? ['--candidate-json', JSON.stringify(opts.candidate)] : []),
203
213
  ...(opts.sharedContext ? ['--shared-context-json', JSON.stringify(opts.sharedContext)] : []),
204
214
  ...(opts.buildPlanPath ? ['--build-plan-path', opts.buildPlanPath] : []),
205
215
  ...(opts.sharedModules ? ['--shared-modules-json', JSON.stringify(opts.sharedModules)] : []),
216
+ ...(opts.revisionMode ? ['--revision-mode'] : []),
206
217
  ];
207
218
  const { assignedSharedModules } = resolvePlanSliceFromFile(
208
219
  opts.buildPlanPath,
@@ -220,6 +231,7 @@ Tool directory: ${opts.absoluteToolDir}
220
231
  You will write artifacts into the tool directory.
221
232
  ${formatCandidateContext(opts.candidate, opts.sharedContext, assignedSharedModules)}
222
233
  ${formatToolPlan(opts.toolPlan)}
234
+ ${formatRevisionMode(opts.revisionMode)}
223
235
 
224
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.`;
225
237
  }
@@ -227,7 +239,9 @@ Use the imprint-compile MCP tools to inspect the session, write artifacts, run t
227
239
  const model = opts.model ?? preferredAgentModel('codex-cli');
228
240
  const initialTokenCount = resolveTraceTokenCount(null, initialPrompt);
229
241
  const captureLlmIo = traceLlmIoEnabled();
230
- 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;
231
245
  setSpanAttributes(traceSpan, {
232
246
  ...llmSpanAttributes({
233
247
  provider: 'codex-cli',
@@ -324,7 +338,20 @@ Use the imprint-compile MCP tools to inspect the session, write artifacts, run t
324
338
  return finalErrorResult(opts, `failed to send prompt to codex-cli: ${errMsg(err)}`);
325
339
  }
326
340
 
327
- 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
+ }
328
355
  const hasActualUsage = result.inputTokens > 0 || result.outputTokens > 0;
329
356
  const inputTokenCount = resolveTraceTokenCount(
330
357
  hasActualUsage ? result.inputTokens : null,
@@ -350,14 +377,6 @@ Use the imprint-compile MCP tools to inspect the session, write artifacts, run t
350
377
  return result;
351
378
  }
352
379
 
353
- function resolveMcpToolTimeoutSec(deadlineMs: number, now = Date.now()): number {
354
- const remainingSec = Math.ceil(Math.max(0, deadlineMs - now) / 1000);
355
- return Math.max(
356
- MIN_MCP_TOOL_TIMEOUT_SEC,
357
- Math.min(MAX_MCP_TOOL_TIMEOUT_SEC, remainingSec || MIN_MCP_TOOL_TIMEOUT_SEC),
358
- );
359
- }
360
-
361
380
  function buildAuthCodexInitialPrompt(systemPrompt: string, initialPrompt: string): string {
362
381
  return `<system_instructions>
363
382
  ${systemPrompt}
@@ -382,6 +401,15 @@ async function driveJsonl(
382
401
  writeFileSync(conversationLogPath, JSON.stringify(conversationLog, null, 2), 'utf8');
383
402
  } catch {}
384
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
+ };
385
413
  const rawStdoutChunks: string[] = [];
386
414
  const rawStderrChunks: string[] = [];
387
415
  let inputTokens = 0;
@@ -430,13 +458,26 @@ async function driveJsonl(
430
458
  }, graceMs);
431
459
  forceTimer.unref?.();
432
460
  };
433
- const deadlineTimer = setTimeout(
434
- () => {
435
- log('wall-clock deadline exceeded, terminating codex');
436
- terminateChild(5000);
437
- },
438
- Math.max(0, opts.deadlineMs - Date.now()),
439
- );
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();
440
481
 
441
482
  let stdoutBuf = '';
442
483
  child.stdout?.on('data', (chunk: Buffer) => {
@@ -459,6 +500,11 @@ async function driveJsonl(
459
500
  }
460
501
 
461
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();
462
508
 
463
509
  if (evt.type === 'thread.started') {
464
510
  log(`thread_id=${evt.thread_id ?? '(none)'}`);
@@ -582,6 +628,7 @@ async function driveJsonl(
582
628
  finish(-1);
583
629
  });
584
630
  });
631
+ childExited = true;
585
632
  clearTimeout(deadlineTimer);
586
633
  if (currentTurnSpan) endTraceSpan(currentTurnSpan);
587
634
  for (const span of toolSpans.values()) endTraceSpan(span);
@@ -597,6 +644,7 @@ async function driveJsonl(
597
644
  } catch {
598
645
  // best effort diagnostics
599
646
  }
647
+ if (flushLogTimer) clearTimeout(flushLogTimer);
600
648
  flushLog();
601
649
 
602
650
  const verifiedOk =
@@ -712,7 +760,7 @@ async function driveJsonl(
712
760
  };
713
761
  }
714
762
 
715
- if (Date.now() > opts.deadlineMs) {
763
+ if (Date.now() > compileDeadlineAfterVerification(opts.absoluteToolDir, opts.deadlineMs)) {
716
764
  return {
717
765
  success: false,
718
766
  outcome: 'timeout',
@@ -739,8 +787,59 @@ async function driveJsonl(
739
787
  };
740
788
  }
741
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
+
742
836
  function signalCodexProcessTree(child: ChildProcess, signal: NodeJS.Signals): void {
743
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);
744
843
  try {
745
844
  process.kill(-child.pid, signal);
746
845
  return;