imprint-mcp 0.6.3 → 0.6.4

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.
@@ -10,7 +10,7 @@
10
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 } from '@opentelemetry/api';
13
+ import { type Span, context as otelContext } from '@opentelemetry/api';
14
14
  import type { AuthCliCompileMode } from './auth-compile-tools.ts';
15
15
  import { type SharedModuleManifestEntry, resolvePlanSliceFromFile } from './build-plan.ts';
16
16
  import type {
@@ -25,13 +25,12 @@ import { COMPILE_SENTINELS, compileDeadlineAfterVerification } from './mcp-compi
25
25
  import type { SharedCompileContext, ToolCandidate } from './tool-candidates.ts';
26
26
  import {
27
27
  endTraceSpan,
28
- llmSpanAttributes,
29
- resolveTraceTokenCount,
28
+ recordLlmUsageSpan,
30
29
  setSpanAttributes,
31
30
  startTraceSpan,
31
+ traceInputOutputAttributes,
32
32
  traceJsonInputOutputAttributes,
33
33
  traceLlmIoEnabled,
34
- traceLlmMessages,
35
34
  traceToolIoEnabled,
36
35
  traced,
37
36
  } from './tracing.ts';
@@ -44,6 +43,80 @@ const CLI_PATH = pathJoin(REPO_ROOT, 'src', 'cli.ts');
44
43
  const MCP_SERVER_NAME = 'imprint-compile';
45
44
  const MAX_VERIFICATION_CYCLES = 5;
46
45
  const MAX_MCP_TOOL_TIMEOUT_SEC = 1800;
46
+ const SENTINEL_USAGE_GRACE_MS = 15_000;
47
+
48
+ interface SentinelGraceController {
49
+ observeSentinel(): void;
50
+ observeTurnCompleted(): void;
51
+ dispose(): void;
52
+ }
53
+
54
+ interface TurnActivityTracker {
55
+ isActive(): boolean;
56
+ started(): void;
57
+ completed(): void;
58
+ }
59
+
60
+ /** Track Codex turn lifecycle without depending on optional tracing spans. */
61
+ export function createTurnActivityTracker(): TurnActivityTracker {
62
+ let active = false;
63
+ return {
64
+ isActive: () => active,
65
+ started: () => {
66
+ active = true;
67
+ },
68
+ completed: () => {
69
+ active = false;
70
+ },
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Wait just long enough for the terminal Codex usage event after done/give_up.
76
+ * A completed turn terminates immediately; the timer is only a bounded fallback
77
+ * for CLI versions that never emit turn.completed.
78
+ */
79
+ export function createSentinelGraceController(opts: {
80
+ hasActiveTurn: () => boolean;
81
+ terminate: () => void;
82
+ fallbackMs?: number;
83
+ schedule?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
84
+ cancel?: (timer: ReturnType<typeof setTimeout>) => void;
85
+ }): SentinelGraceController {
86
+ const schedule = opts.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs));
87
+ const cancel = opts.cancel ?? clearTimeout;
88
+ let sentinelObserved = false;
89
+ let terminated = false;
90
+ let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
91
+
92
+ const terminateOnce = (): void => {
93
+ if (terminated) return;
94
+ terminated = true;
95
+ if (fallbackTimer) cancel(fallbackTimer);
96
+ fallbackTimer = undefined;
97
+ opts.terminate();
98
+ };
99
+
100
+ return {
101
+ observeSentinel(): void {
102
+ sentinelObserved = true;
103
+ if (!opts.hasActiveTurn()) {
104
+ terminateOnce();
105
+ return;
106
+ }
107
+ if (fallbackTimer || terminated) return;
108
+ fallbackTimer = schedule(terminateOnce, opts.fallbackMs ?? SENTINEL_USAGE_GRACE_MS);
109
+ fallbackTimer.unref?.();
110
+ },
111
+ observeTurnCompleted(): void {
112
+ if (sentinelObserved) terminateOnce();
113
+ },
114
+ dispose(): void {
115
+ if (fallbackTimer) cancel(fallbackTimer);
116
+ fallbackTimer = undefined;
117
+ },
118
+ };
119
+ }
47
120
 
48
121
  function formatRevisionMode(enabled: boolean | undefined): string {
49
122
  return enabled
@@ -127,6 +200,7 @@ export async function compileViaCodexCli(
127
200
  },
128
201
  async (span) => {
129
202
  const result = await compileViaCodexCliImpl(opts, span);
203
+ const model = opts.model ?? preferredAgentModel('codex-cli');
130
204
  setSpanAttributes(span, {
131
205
  'imprint.compile.outcome': result.outcome,
132
206
  'imprint.compile.success': result.success,
@@ -136,6 +210,20 @@ export async function compileViaCodexCli(
136
210
  'imprint.compile.output_tokens': result.outputTokens,
137
211
  'imprint.compile.conversation_log': result.conversationLogPath,
138
212
  });
213
+ // Codex reports input_tokens as the total prompt; cached_input_tokens is
214
+ // a subset, not an additional token bucket.
215
+ recordLlmUsageSpan(
216
+ 'compile.codex_cli_usage',
217
+ {
218
+ provider: 'codex-cli',
219
+ model,
220
+ inputTokens: result.inputTokens,
221
+ outputTokens: result.outputTokens,
222
+ cacheReadTokens: result.cacheReadInputTokens,
223
+ cacheWriteTokens: result.cacheCreationInputTokens,
224
+ },
225
+ { 'imprint.compile.turns': result.turns },
226
+ );
139
227
  return result;
140
228
  },
141
229
  );
@@ -237,30 +325,16 @@ Use the imprint-compile MCP tools to inspect the session, write artifacts, run t
237
325
  }
238
326
 
239
327
  const model = opts.model ?? preferredAgentModel('codex-cli');
240
- const initialTokenCount = resolveTraceTokenCount(null, initialPrompt);
241
328
  const captureLlmIo = traceLlmIoEnabled();
242
329
  // done() owns a separately bounded live-verification phase, so its transport
243
330
  // timeout cannot be derived from the compiler's remaining reasoning budget.
244
331
  const mcpToolTimeoutSec = MAX_MCP_TOOL_TIMEOUT_SEC;
245
332
  setSpanAttributes(traceSpan, {
246
- ...llmSpanAttributes({
247
- provider: 'codex-cli',
248
- model,
249
- inputTokens: initialTokenCount.tokens,
250
- tokenCountsEstimated: true,
251
- inputTokenSource: initialTokenCount.source,
252
- inputMessages: captureLlmIo
253
- ? traceLlmMessages([{ role: 'user', content: initialPrompt }])
254
- : undefined,
255
- inputValue: captureLlmIo ? initialPrompt : undefined,
256
- invocationParameters: {
257
- command: 'codex exec',
258
- json: true,
259
- sandbox: 'workspace-write',
260
- tool_timeout_sec: mcpToolTimeoutSec,
261
- },
262
- }),
333
+ ...(captureLlmIo ? traceInputOutputAttributes('input', initialPrompt) : {}),
263
334
  'imprint.compile.initial_prompt_chars': initialPrompt.length,
335
+ 'imprint.compile.command': 'codex exec',
336
+ 'imprint.compile.sandbox': 'workspace-write',
337
+ 'imprint.compile.tool_timeout_sec': mcpToolTimeoutSec,
264
338
  });
265
339
 
266
340
  const execArgs = opts.resume
@@ -352,27 +426,9 @@ Use the imprint-compile MCP tools to inspect the session, write artifacts, run t
352
426
  } finally {
353
427
  process.removeListener('exit', terminateOnParentExit);
354
428
  }
355
- const hasActualUsage = result.inputTokens > 0 || result.outputTokens > 0;
356
- const inputTokenCount = resolveTraceTokenCount(
357
- hasActualUsage ? result.inputTokens : null,
358
- initialPrompt,
359
- );
360
- const outputTokenCount = resolveTraceTokenCount(
361
- hasActualUsage ? result.outputTokens : null,
362
- result.message,
363
- );
364
429
  setSpanAttributes(traceSpan, {
365
- ...llmSpanAttributes({
366
- provider: 'codex-cli',
367
- model,
368
- inputTokens: inputTokenCount.tokens,
369
- outputTokens: outputTokenCount.tokens,
370
- tokenCountsEstimated:
371
- inputTokenCount.source === 'estimated' || outputTokenCount.source === 'estimated',
372
- inputTokenSource: inputTokenCount.source,
373
- outputTokenSource: outputTokenCount.source,
374
- }),
375
430
  'imprint.compile.message': result.message,
431
+ ...(captureLlmIo ? traceInputOutputAttributes('output', result.message) : {}),
376
432
  });
377
433
  return result;
378
434
  }
@@ -392,6 +448,10 @@ async function driveJsonl(
392
448
  opts: CompileViaCodexCliOptions,
393
449
  traceSpan?: Span,
394
450
  ): Promise<CompileAgentResult> {
451
+ // Bun's child-process event emitters do not preserve AsyncLocalStorage.
452
+ // Restore the compile span context inside stdout callbacks so turn/tool
453
+ // spans remain children of compile.codex_cli_agent in Phoenix.
454
+ const parentCtx = otelContext.active();
395
455
  const conversationLog: unknown[] = [];
396
456
  const conversationLogPath = pathJoin(opts.absoluteToolDir, '.compile-log.json');
397
457
  const rawStdoutPath = pathJoin(opts.absoluteToolDir, '.codex-stdout.jsonl');
@@ -414,6 +474,7 @@ async function driveJsonl(
414
474
  const rawStderrChunks: string[] = [];
415
475
  let inputTokens = 0;
416
476
  let outputTokens = 0;
477
+ let cacheReadInputTokens = 0;
417
478
  let turn = 0;
418
479
  let lastErrorMessage = '';
419
480
  let stderrBuf = '';
@@ -421,6 +482,8 @@ async function driveJsonl(
421
482
  let capturedSessionId: string | undefined;
422
483
  const toolSpans = new Map<string, Span>();
423
484
  let currentTurnSpan: Span | null = null;
485
+ const turnActivity = createTurnActivityTracker();
486
+ let onTurnCompletedAfterSentinel: (() => void) | undefined;
424
487
 
425
488
  const budgetMs = Math.max(0, opts.deadlineMs - Date.now());
426
489
  const fireProgress = (phase: 'thinking' | 'tool', toolName?: string): void => {
@@ -481,103 +544,106 @@ async function driveJsonl(
481
544
 
482
545
  let stdoutBuf = '';
483
546
  child.stdout?.on('data', (chunk: Buffer) => {
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;
493
-
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
- }
547
+ otelContext.with(parentCtx, () => {
548
+ const chunkText = chunk.toString('utf8');
549
+ rawStdoutChunks.push(chunkText);
550
+ stdoutBuf += chunkText;
551
+ while (true) {
552
+ const nl = stdoutBuf.indexOf('\n');
553
+ if (nl < 0) break;
554
+ const line = stdoutBuf.slice(0, nl).trim();
555
+ stdoutBuf = stdoutBuf.slice(nl + 1);
556
+ if (!line) continue;
557
+
558
+ let evt: CodexJsonEvent;
559
+ try {
560
+ evt = JSON.parse(line) as CodexJsonEvent;
561
+ } catch (err) {
562
+ log(`unparseable jsonl line: ${errMsg(err)}`);
563
+ continue;
564
+ }
501
565
 
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();
508
-
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
- }
566
+ conversationLog.push(evt);
567
+ // Keep the human-readable compile log current during a long agent turn.
568
+ // Rewriting is throttled so streamed bursts do not cause one full-file
569
+ // write per JSONL event, while a stalled/crashed turn still leaves the
570
+ // latest tool activity visible on disk.
571
+ scheduleLogFlush();
572
+
573
+ if (evt.type === 'thread.started') {
574
+ log(`thread_id=${evt.thread_id ?? '(none)'}`);
575
+ setSpanAttributes(traceSpan, { 'codex.thread_id': evt.thread_id });
576
+ if (evt.thread_id) capturedSessionId = evt.thread_id;
577
+ continue;
578
+ }
515
579
 
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
- }
580
+ if (evt.type === 'turn.started') {
581
+ turnActivity.started();
582
+ if (currentTurnSpan) endTraceSpan(currentTurnSpan);
583
+ flushLog();
584
+ turn++;
585
+ currentTurnSpan = startTraceSpan(`agent.turn.${turn}`, 'CHAIN', {
586
+ 'imprint.agent.turn': turn,
587
+ 'imprint.agent.cumulative_input_tokens': inputTokens,
588
+ 'imprint.agent.cumulative_output_tokens': outputTokens,
589
+ });
590
+ fireProgress('thinking');
591
+ continue;
592
+ }
528
593
 
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);
594
+ const normalizedToolEvt = normalizeCodexToolEvent(evt);
595
+ if (normalizedToolEvt) {
596
+ const { eventType, item } = normalizedToolEvt;
597
+ const toolName = codexToolName(item);
598
+ if (toolName) {
599
+ traceCodexToolEvent(toolSpans, eventType, item, toolName);
600
+ fireProgress(eventType === 'item.started' ? 'tool' : 'thinking', toolName);
601
+ }
602
+ continue;
536
603
  }
537
- continue;
538
- }
539
604
 
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
- });
605
+ if ((evt.type === 'item.started' || evt.type === 'item.completed') && evt.item) {
606
+ const agentMessage = codexAgentMessageText(evt.item);
607
+ if (agentMessage && evt.type === 'item.completed') {
608
+ agentMessageCount++;
609
+ setSpanAttributes(traceSpan, {
610
+ 'imprint.codex.agent_messages': agentMessageCount,
611
+ 'imprint.codex.last_agent_message_chars': agentMessage.length,
612
+ ...(traceLlmIoEnabled()
613
+ ? traceInputOutputAttributes('output', agentMessage, undefined, 'agent_message')
614
+ : {}),
615
+ });
616
+ continue;
617
+ }
556
618
  continue;
557
619
  }
558
- continue;
559
- }
560
620
 
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;
621
+ if (evt.type === 'turn.completed') {
622
+ const turnInput = evt.usage?.input_tokens ?? 0;
623
+ const turnOutput = evt.usage?.output_tokens ?? 0;
624
+ const turnCacheRead = evt.usage?.cached_input_tokens ?? 0;
625
+ inputTokens += turnInput;
626
+ outputTokens += turnOutput;
627
+ cacheReadInputTokens += turnCacheRead;
628
+ if (currentTurnSpan) {
629
+ setSpanAttributes(currentTurnSpan, {
630
+ 'imprint.agent.turn_input_tokens': turnInput,
631
+ 'imprint.agent.turn_output_tokens': turnOutput,
632
+ 'imprint.agent.turn_cache_read_input_tokens': turnCacheRead,
633
+ });
634
+ endTraceSpan(currentTurnSpan);
635
+ currentTurnSpan = null;
636
+ }
637
+ turnActivity.completed();
638
+ onTurnCompletedAfterSentinel?.();
639
+ continue;
573
640
  }
574
- continue;
575
- }
576
641
 
577
- if (evt.type === 'error' || evt.type === 'turn.failed') {
578
- lastErrorMessage = evt.message ?? evt.error?.message ?? JSON.stringify(evt);
642
+ if (evt.type === 'error' || evt.type === 'turn.failed') {
643
+ lastErrorMessage = evt.message ?? evt.error?.message ?? JSON.stringify(evt);
644
+ }
579
645
  }
580
- }
646
+ });
581
647
  });
582
648
 
583
649
  child.stderr?.on('data', (chunk: Buffer) => {
@@ -593,6 +659,8 @@ async function driveJsonl(
593
659
  const finish = (code: number): void => {
594
660
  if (resolved) return;
595
661
  resolved = true;
662
+ sentinelGrace.dispose();
663
+ onTurnCompletedAfterSentinel = undefined;
596
664
  if (forcedKillTimer) clearTimeout(forcedKillTimer);
597
665
  clearInterval(sentinelTimer);
598
666
  resolve(code);
@@ -616,10 +684,17 @@ async function driveJsonl(
616
684
  }, 2000);
617
685
  forcedKillTimer.unref?.();
618
686
  };
687
+ const sentinelGrace = createSentinelGraceController({
688
+ // Turn lifecycle is independent of tracing. startTraceSpan() returns null
689
+ // when tracing is disabled, but terminal usage still must be captured.
690
+ hasActiveTurn: turnActivity.isActive,
691
+ terminate: terminateForSentinel,
692
+ });
693
+ onTurnCompletedAfterSentinel = sentinelGrace.observeTurnCompleted;
619
694
  const sentinelTimer = setInterval(() => {
620
695
  const checkpointReached = opts.authMode && existsSync(checkpointSentinel);
621
696
  if (!existsSync(doneSentinel) && !existsSync(giveUpSentinel) && !checkpointReached) return;
622
- terminateForSentinel();
697
+ sentinelGrace.observeSentinel();
623
698
  }, 500);
624
699
  child.once('close', (code) => {
625
700
  finish(code ?? -1);
@@ -629,6 +704,7 @@ async function driveJsonl(
629
704
  });
630
705
  });
631
706
  childExited = true;
707
+ turnActivity.completed();
632
708
  clearTimeout(deadlineTimer);
633
709
  if (currentTurnSpan) endTraceSpan(currentTurnSpan);
634
710
  for (const span of toolSpans.values()) endTraceSpan(span);
@@ -687,7 +763,7 @@ async function driveJsonl(
687
763
  durationMs: Date.now() - opts.startTime,
688
764
  inputTokens,
689
765
  outputTokens,
690
- cacheReadInputTokens: 0,
766
+ cacheReadInputTokens,
691
767
  cacheCreationInputTokens: 0,
692
768
  sessionId: capturedSessionId,
693
769
  };
@@ -5,13 +5,20 @@
5
5
  * `[IMPRINT]` is exact-match — keep stable.
6
6
  */
7
7
 
8
- export const INJECTED_LISTENER_SOURCE = `
8
+ export function createInjectedListenerSource(eventToken: string): string {
9
+ return `
9
10
  (function imprintInjector() {
10
11
  if (window.__imprint_injected__) return;
11
12
  window.__imprint_injected__ = true;
12
13
 
13
14
  const SENTINEL = '[IMPRINT]';
15
+ const EVENT_TOKEN = ${JSON.stringify(eventToken)};
16
+ const emitLog = console.log.bind(console);
17
+ const stringify = JSON.stringify.bind(JSON);
14
18
  const MAX_VAL = 200;
19
+ let lastIntentTarget = null;
20
+ let lastIntentAt = 0;
21
+ let lastIntentKind = null;
15
22
 
16
23
  function safeStr(v) {
17
24
  try {
@@ -53,6 +60,7 @@ export const INJECTED_LISTENER_SOURCE = `
53
60
  text: safeStr((el.textContent || '').trim()),
54
61
  ariaLabel: el.getAttribute && el.getAttribute('aria-label') || null,
55
62
  href: el.tagName === 'A' ? el.getAttribute('href') : null,
63
+ absoluteHref: el.tagName === 'A' ? el.href : null,
56
64
  selector: selectorFor(el),
57
65
  };
58
66
  } catch (e) { return {}; }
@@ -60,17 +68,33 @@ export const INJECTED_LISTENER_SOURCE = `
60
68
 
61
69
  function emit(type, payload) {
62
70
  try {
63
- console.log(SENTINEL, type, JSON.stringify(payload));
71
+ emitLog(SENTINEL, EVENT_TOKEN, type, stringify(payload));
64
72
  } catch (e) { /* ignore */ }
65
73
  }
66
74
 
67
75
  function onClick(ev) {
68
76
  try {
69
- const tgt = ev.target;
77
+ onIntent(ev, 'click');
78
+ const tgt = ev.target && ev.target.closest ? ev.target.closest('a,button,input,form') || ev.target : ev.target;
70
79
  emit('click', describe(tgt));
71
80
  } catch (e) { /* ignore */ }
72
81
  }
73
82
 
83
+ function onIntent(ev, intentKind) {
84
+ try {
85
+ if (!ev.isTrusted) return;
86
+ const tgt = ev.target && ev.target.closest ? ev.target.closest('a,button,input,form') : ev.target;
87
+ if (!tgt) return;
88
+ const now = Date.now();
89
+ const activation = intentKind === 'pointerdown' || intentKind === 'click';
90
+ if (!activation && tgt === lastIntentTarget && intentKind === lastIntentKind && now - lastIntentAt < 250) return;
91
+ lastIntentTarget = tgt;
92
+ lastIntentAt = now;
93
+ lastIntentKind = intentKind;
94
+ emit('intent', { ...describe(tgt), intentAt: now, intentKind: intentKind });
95
+ } catch (e) { /* ignore */ }
96
+ }
97
+
74
98
  function onInput(ev) {
75
99
  try {
76
100
  const tgt = ev.target;
@@ -128,10 +152,14 @@ export const INJECTED_LISTENER_SOURCE = `
128
152
  // Capture phase = true so we see the event before the site has a chance to
129
153
  // stopPropagation it.
130
154
  document.addEventListener('click', onClick, true);
155
+ document.addEventListener('pointerover', (ev) => onIntent(ev, 'pointerover'), true);
156
+ document.addEventListener('pointerdown', (ev) => onIntent(ev, 'pointerdown'), true);
157
+ document.addEventListener('focusin', (ev) => onIntent(ev, 'focusin'), true);
131
158
  document.addEventListener('input', onInput, true);
132
159
  document.addEventListener('change', onChange, true);
133
160
  document.addEventListener('submit', onSubmit, true);
134
161
  })();
135
162
  `;
163
+ }
136
164
 
137
165
  export const IMPRINT_SENTINEL = '[IMPRINT]';
@@ -627,9 +627,9 @@ async function traceAnalyze(
627
627
  throw err;
628
628
  }
629
629
  // Providers report `inputTokens` as the *uncached* input only; the cached
630
- // portion lives in the cache fields. `llmCostAttributes` expects the TOTAL
631
- // prompt tokens (it derives uncached = total − cacheRead − cacheWrite), so
632
- // sum them here. A real total is also large enough to clear the
630
+ // portion lives in the cache fields. Phoenix expects the TOTAL prompt plus
631
+ // the cache breakdown so it can calculate cost server-side, so sum them
632
+ // here. A real total is also large enough to clear the
633
633
  // resolveTraceTokenCount sanity check, so cache-hit calls stop falling back
634
634
  // to the chars/4 estimate.
635
635
  const cacheReadTokens = result.cacheReadInputTokens ?? undefined;
@@ -0,0 +1,31 @@
1
+ /** Bound per-row metadata for untrusted React Flight bodies below the byte cap. */
2
+ export const MAX_REACT_FLIGHT_ROWS = 16_384;
3
+
4
+ /** Bound aggregate parsed JSON width across an untrusted React Flight body. */
5
+ export const MAX_REACT_FLIGHT_JSON_NODES = 32_768;
6
+
7
+ /**
8
+ * Estimate JSON node count before JSON.parse allocates the object graph.
9
+ * For valid JSON, one root plus each container opener and comma is a
10
+ * conservative upper approximation of containers and values. Delimiters in
11
+ * strings are ignored, including escaped quotes.
12
+ */
13
+ export function boundedJsonNodeCount(json: string, limit: number): number | null {
14
+ if (limit < 1) return null;
15
+ let count = 1;
16
+ let inString = false;
17
+ for (let index = 0; index < json.length; index++) {
18
+ const char = json.charCodeAt(index);
19
+ if (inString) {
20
+ if (char === 0x5c) index++;
21
+ else if (char === 0x22) inString = false;
22
+ continue;
23
+ }
24
+ if (char === 0x22) {
25
+ inString = true;
26
+ } else if (char === 0x7b || char === 0x5b || char === 0x2c) {
27
+ if (++count > limit) return null;
28
+ }
29
+ }
30
+ return count;
31
+ }