@cat-factory/executor-harness 1.64.2 → 1.66.0
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.
- package/dist/agent-runner.js +94 -50
- package/dist/claude-call-aggregator.js +232 -0
- package/dist/claude-stream.js +12 -6
- package/dist/inline.js +29 -1
- package/dist/subagents.js +14 -3
- package/package.json +4 -4
- package/src/agent-runner.ts +123 -60
- package/src/claude-call-aggregator.ts +331 -0
- package/src/claude-stream.ts +15 -7
- package/src/inline.ts +34 -1
- package/src/job.ts +14 -1
- package/src/pi.ts +14 -1
- package/src/subagents.ts +26 -6
package/src/agent-runner.ts
CHANGED
|
@@ -2,13 +2,8 @@ import { spawn } from 'node:child_process'
|
|
|
2
2
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
claudeCallUsage,
|
|
8
|
-
isObject,
|
|
9
|
-
numberOf,
|
|
10
|
-
redactBody,
|
|
11
|
-
} from './claude-stream.js'
|
|
5
|
+
import { claudeAssistantContent, isObject, numberOf, redactBody } from './claude-stream.js'
|
|
6
|
+
import { createClaudeRunTelemetry, subagentDispatchId } from './claude-call-aggregator.js'
|
|
12
7
|
import type { Logger } from './logger.js'
|
|
13
8
|
import {
|
|
14
9
|
createCallMetricPublisher,
|
|
@@ -359,31 +354,40 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
359
354
|
}
|
|
360
355
|
|
|
361
356
|
// Reconstruct the full per-call request/response bodies for telemetry from the
|
|
362
|
-
// stream. `--output-format stream-json --verbose` emits
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
// credential-scrubbed (they can echo the leased token).
|
|
357
|
+
// stream. `--output-format stream-json --verbose` emits a near-verbatim Anthropic
|
|
358
|
+
// Messages envelope per response CONTENT BLOCK (not per call), so the aggregator below
|
|
359
|
+
// folds the envelopes sharing a `message.id` back into one call and buffers that call's
|
|
360
|
+
// `user` tool_result turns — together the growing prompt transcript, in the shape the
|
|
361
|
+
// model was actually sent. We seed it with the inputs the harness supplies (they never
|
|
362
|
+
// appear in the stream): the system + first user message when the prompt rides argv, or
|
|
363
|
+
// a single folded user turn when it doesn't — so the reconstruction never shows a system
|
|
364
|
+
// turn that was never sent. Bodies are credential-scrubbed (they can echo the leased token).
|
|
370
365
|
const secrets = opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : []
|
|
371
|
-
const messages: Array<{ role: string; content: unknown }> = folded
|
|
372
|
-
? [{ role: 'user', content: prompt }]
|
|
373
|
-
: [
|
|
374
|
-
{ role: 'system', content: opts.systemPrompt },
|
|
375
|
-
{ role: 'user', content: opts.userPrompt },
|
|
376
|
-
]
|
|
377
366
|
const calls: HarnessCallMetric[] = []
|
|
378
367
|
// Streams each call as the CLI yields it, EXCEPT one whose tokens `attributeCumulativeUsage`
|
|
379
368
|
// may still rewrite below (a published call must be final — see the publisher).
|
|
380
369
|
const publisher = createCallMetricPublisher(calls, opts.onCallMetric)
|
|
370
|
+
// `watcherOwnsSubagents` tracks the `startSubagentWatcher` wiring below: it is started only when
|
|
371
|
+
// the CLI has an isolated config home to watch, which an `ambientAuth` run does not have. The
|
|
372
|
+
// telemetry routes the CLI's tagged subagent turns accordingly — see `createClaudeRunTelemetry`.
|
|
373
|
+
const telemetry = createClaudeRunTelemetry({
|
|
374
|
+
seed: folded
|
|
375
|
+
? [{ role: 'user', content: prompt }]
|
|
376
|
+
: [
|
|
377
|
+
{ role: 'system', content: opts.systemPrompt },
|
|
378
|
+
{ role: 'user', content: opts.userPrompt },
|
|
379
|
+
],
|
|
380
|
+
secrets,
|
|
381
|
+
watcherOwnsSubagents: !opts.ambientAuth,
|
|
382
|
+
publish: (metric) => publisher.publish(metric),
|
|
383
|
+
})
|
|
381
384
|
|
|
382
385
|
// ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from the two views the run
|
|
383
386
|
// produces of the SAME slicing. The parent's subagent dispatches + their terminal tool_results
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
// `planTracker` + `lastTodo`) is the
|
|
387
|
+
// appear on this stream (as do the subagents' own intermediate turns, tagged with the dispatch
|
|
388
|
+
// that spawned them — see `isSubagentEvent`), so `sliceTracker` knows which slices are in flight
|
|
389
|
+
// and which have returned; the parent's own plan (tracked by `planTracker` + `lastTodo`) is the
|
|
390
|
+
// only place a not-yet-dispatched slice is named at all.
|
|
387
391
|
//
|
|
388
392
|
// The plan arrives in one of two tool vocabularies depending on the bundled CLI build:
|
|
389
393
|
// `TodoWrite` (whole-list snapshots, tracked in `lastTodo`) or the incremental
|
|
@@ -441,12 +445,19 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
441
445
|
|
|
442
446
|
const onEvent = (event: Record<string, unknown>, meta?: { final?: boolean }): void => {
|
|
443
447
|
const type = event.type
|
|
448
|
+
// A subagent's turns ride the parent's stdout tagged with the dispatch that spawned them;
|
|
449
|
+
// `telemetry` routes them off the parent's chain (and decides who bills them). Progress, slice
|
|
450
|
+
// tracking, the guard and `stats` below deliberately see EVERY event: a subagent grinding on
|
|
451
|
+
// errors should trip the guard exactly as the parent would, and whether the agent acted at all
|
|
452
|
+
// does not depend on which channel billed it.
|
|
453
|
+
const dispatchId = subagentDispatchId(event)
|
|
444
454
|
if (type === 'assistant' && isObject(event.message)) {
|
|
445
455
|
const message = event.message as Record<string, unknown>
|
|
446
456
|
const content = Array.isArray(message.content) ? message.content : []
|
|
447
|
-
const { text,
|
|
457
|
+
const { text, toolUses } = claudeAssistantContent(content)
|
|
448
458
|
stats.assistantChars += text.length
|
|
449
459
|
stats.toolCalls += toolUses
|
|
460
|
+
telemetry.onAssistant(dispatchId, message)
|
|
450
461
|
for (const block of content) {
|
|
451
462
|
if (!isObject(block) || block.type !== 'tool_use') continue
|
|
452
463
|
// Remember each call's name against its id so the guard can pair it with the
|
|
@@ -462,22 +473,6 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
462
473
|
sliceTracker.onAssistant(content)
|
|
463
474
|
planTracker.onAssistant(content)
|
|
464
475
|
emitProgress()
|
|
465
|
-
// Record this call BEFORE appending its turn: the prompt is the history that
|
|
466
|
-
// produced this response. The append-only array keeps each call's prompt a strict
|
|
467
|
-
// prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
|
|
468
|
-
const u = claudeCallUsage(message.usage)
|
|
469
|
-
publisher.publish({
|
|
470
|
-
...(typeof message.model === 'string' ? { model: message.model } : {}),
|
|
471
|
-
promptText: redactBody(JSON.stringify(messages), secrets),
|
|
472
|
-
messageCount: messages.length,
|
|
473
|
-
responseText: redactBody(text, secrets),
|
|
474
|
-
reasoningText: redactBody(reasoning, secrets),
|
|
475
|
-
inputTokens: u.inputTokens,
|
|
476
|
-
cachedInputTokens: u.cachedInputTokens,
|
|
477
|
-
outputTokens: u.outputTokens,
|
|
478
|
-
finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
|
|
479
|
-
})
|
|
480
|
-
messages.push({ role: 'assistant', content })
|
|
481
476
|
} else if (type === 'user' && isObject(event.message)) {
|
|
482
477
|
// tool_result blocks the harness fed back to the model — part of the next prompt.
|
|
483
478
|
const content = (event.message as Record<string, unknown>).content
|
|
@@ -488,7 +483,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
488
483
|
// Not on the at-close flush: the CLI has already exited, so tripping the guard there
|
|
489
484
|
// would kill nothing and only convert a clean exit into a spurious failure.
|
|
490
485
|
if (!meta?.final) feedGuard(content)
|
|
491
|
-
|
|
486
|
+
telemetry.onToolResult(dispatchId, content)
|
|
492
487
|
}
|
|
493
488
|
} else if (type === 'result') {
|
|
494
489
|
if (typeof event.result === 'string') summary = event.result
|
|
@@ -587,6 +582,8 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
587
582
|
onEvent,
|
|
588
583
|
)
|
|
589
584
|
|
|
585
|
+
// The stream has ended, so the last call has no successor envelope to complete it.
|
|
586
|
+
telemetry.flush()
|
|
590
587
|
return await assembleClaudeOutcome({
|
|
591
588
|
summary,
|
|
592
589
|
stats,
|
|
@@ -595,8 +592,16 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
595
592
|
publisher,
|
|
596
593
|
usage,
|
|
597
594
|
subagents,
|
|
595
|
+
expectSubagentCalls: telemetry.expectsWatcherCalls(),
|
|
596
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
598
597
|
})
|
|
599
598
|
} catch (err) {
|
|
599
|
+
// The stream ended abnormally (guard trip, watchdog kill, CLI crash). Complete the call in
|
|
600
|
+
// flight anyway, and release whatever the publisher was withholding: a killed run never
|
|
601
|
+
// returns an outcome, so the live channel is the ONLY record of what it spent, and dropping
|
|
602
|
+
// its last turn is what the streaming exists to avoid.
|
|
603
|
+
telemetry.flush()
|
|
604
|
+
publisher.flush()
|
|
600
605
|
// A tripped no-progress guard aborted the CLI; streamCli rejects with its generic abort
|
|
601
606
|
// message, so replace it with the guard's actionable diagnostic — carrying the stderr tail
|
|
602
607
|
// it attached, since that is usually the only evidence of what the CLI was doing when it was
|
|
@@ -655,6 +660,10 @@ function buildClaudeEnv(
|
|
|
655
660
|
* terminal `result` event's cumulative) covers ONLY the parent loop, and the subagent tokens live
|
|
656
661
|
* exclusively in the per-session `subagents/*.jsonl` transcripts the watcher reads. Extracted from
|
|
657
662
|
* {@link runClaudeCode} verbatim to keep its cyclomatic complexity down.
|
|
663
|
+
*
|
|
664
|
+
* The invariant is about the aggregate `usage` only. `calls` was NEVER disjoint from the watcher's
|
|
665
|
+
* on its own: the CLI streams a subagent's turns onto the parent's stdout as well, so the parent
|
|
666
|
+
* loop's telemetry must filter them (`subagentDispatchId`) for this concatenation to hold.
|
|
658
667
|
*/
|
|
659
668
|
async function assembleClaudeOutcome(args: {
|
|
660
669
|
summary: string
|
|
@@ -665,6 +674,14 @@ async function assembleClaudeOutcome(args: {
|
|
|
665
674
|
publisher: CallMetricPublisher
|
|
666
675
|
usage: { inputTokens: number; outputTokens: number } | undefined
|
|
667
676
|
subagents: ReturnType<typeof startSubagentWatcher> | undefined
|
|
677
|
+
/**
|
|
678
|
+
* The parent stream carried subagent turns AND the watcher was the channel meant to record them.
|
|
679
|
+
* A watcher that then yields nothing means the run lost its subagent rows entirely — the CLI's
|
|
680
|
+
* transcript layout is not a stable contract (ADR 0027 Defect A moved it once already), so say
|
|
681
|
+
* so rather than under-reporting the spend in silence.
|
|
682
|
+
*/
|
|
683
|
+
expectSubagentCalls: boolean
|
|
684
|
+
log?: Logger
|
|
668
685
|
}): Promise<PiRunOutcome> {
|
|
669
686
|
const { summary, stats, stderrTail, calls, publisher, usage, subagents } = args
|
|
670
687
|
// The parent's cumulative-usage fallback applies to the PARENT calls only (before the
|
|
@@ -679,6 +696,12 @@ async function assembleClaudeOutcome(args: {
|
|
|
679
696
|
await subagents?.stop()
|
|
680
697
|
const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 }
|
|
681
698
|
const subCalls = subagents?.calls() ?? []
|
|
699
|
+
if (args.expectSubagentCalls && !subCalls.length) {
|
|
700
|
+
args.log?.warn(
|
|
701
|
+
'subagent turns were streamed but the transcript watcher captured no calls; their token ' +
|
|
702
|
+
'spend is missing from this run’s telemetry (check the CLI’s subagents/*.jsonl layout)',
|
|
703
|
+
)
|
|
704
|
+
}
|
|
682
705
|
const mergedCalls = [...calls, ...subCalls]
|
|
683
706
|
const mergedUsage =
|
|
684
707
|
usage || subUsage.inputTokens || subUsage.outputTokens
|
|
@@ -724,7 +747,11 @@ function claudeUsage(raw: unknown): { inputTokens: number; outputTokens: number
|
|
|
724
747
|
export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
|
|
725
748
|
const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
|
|
726
749
|
let summary = ''
|
|
727
|
-
|
|
750
|
+
// The running CUMULATIVE total, kept in its reported (inclusive) form plus the cached share
|
|
751
|
+
// it contains. `PiRunOutcome.usage` needs the inclusive figure — it is the key-rotation
|
|
752
|
+
// weight — while the fallback call metric below needs the split, so both are derived from
|
|
753
|
+
// this one value rather than one being reconstructed from the other.
|
|
754
|
+
let cumulative: CodexCumulativeUsage | undefined
|
|
728
755
|
|
|
729
756
|
// Codex reads its credentials from $CODEX_HOME/auth.json with file-backed
|
|
730
757
|
// storage. CRITICAL: this home must live OUTSIDE the cloned checkout (`opts.cwd`)
|
|
@@ -789,7 +816,7 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
789
816
|
const progress = codexPlanProgress(event)
|
|
790
817
|
if (progress && opts.onProgress) opts.onProgress(progress)
|
|
791
818
|
const turnUsage = codexUsage(event)
|
|
792
|
-
if (turnUsage)
|
|
819
|
+
if (turnUsage) cumulative = turnUsage
|
|
793
820
|
// A `token_count` event closes a model turn: pair its per-turn usage with the
|
|
794
821
|
// assistant text seen since the previous turn as one telemetry call.
|
|
795
822
|
const perTurn = codexLastTurnUsage(event)
|
|
@@ -803,7 +830,8 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
803
830
|
responseText: redactBody(pendingText, secrets),
|
|
804
831
|
reasoningText: '',
|
|
805
832
|
inputTokens: perTurn.inputTokens,
|
|
806
|
-
|
|
833
|
+
cacheReadTokens: perTurn.cacheReadTokens,
|
|
834
|
+
cacheWriteTokens: perTurn.cacheWriteTokens,
|
|
807
835
|
outputTokens: perTurn.outputTokens,
|
|
808
836
|
finishReason: null,
|
|
809
837
|
},
|
|
@@ -839,7 +867,11 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
839
867
|
|
|
840
868
|
// Fallback for a CLI/version that never emits per-turn `last_token_usage`: record a
|
|
841
869
|
// single call from the cumulative total + final text so the run is still observable.
|
|
842
|
-
|
|
870
|
+
// The cumulative total is inclusive of its cached share exactly as a per-turn one is, so
|
|
871
|
+
// it is split the same way rather than being filed wholesale as fresh — which would report
|
|
872
|
+
// a cache-heavy run as if nothing had been cached, the one reading this telemetry exists
|
|
873
|
+
// to rule out.
|
|
874
|
+
if (calls.length === 0 && (cumulative || summary)) {
|
|
843
875
|
publishCallMetric(
|
|
844
876
|
calls,
|
|
845
877
|
{
|
|
@@ -848,14 +880,23 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
848
880
|
messageCount: messages.length,
|
|
849
881
|
responseText: redactBody(summary, secrets),
|
|
850
882
|
reasoningText: '',
|
|
851
|
-
inputTokens:
|
|
852
|
-
|
|
853
|
-
|
|
883
|
+
inputTokens: Math.max(
|
|
884
|
+
0,
|
|
885
|
+
(cumulative?.inputTokens ?? 0) - (cumulative?.cachedInputTokens ?? 0),
|
|
886
|
+
),
|
|
887
|
+
cacheReadTokens: cumulative?.cachedInputTokens ?? 0,
|
|
888
|
+
// Codex reports no separate cache-WRITE class; 0 rather than guessed.
|
|
889
|
+
cacheWriteTokens: 0,
|
|
890
|
+
outputTokens: cumulative?.outputTokens ?? 0,
|
|
854
891
|
finishReason: null,
|
|
855
892
|
},
|
|
856
893
|
opts.onCallMetric,
|
|
857
894
|
)
|
|
858
895
|
}
|
|
896
|
+
// The outcome's usage is the key-rotation WEIGHT, so it keeps the inclusive input count.
|
|
897
|
+
const usage = cumulative
|
|
898
|
+
? { inputTokens: cumulative.inputTokens, outputTokens: cumulative.outputTokens }
|
|
899
|
+
: undefined
|
|
859
900
|
return {
|
|
860
901
|
summary,
|
|
861
902
|
stats,
|
|
@@ -926,6 +967,19 @@ function codexPlanProgress(event: Record<string, unknown>): TodoProgress | undef
|
|
|
926
967
|
return toProgress(items)
|
|
927
968
|
}
|
|
928
969
|
|
|
970
|
+
/**
|
|
971
|
+
* Codex's running cumulative usage, kept in the form the CLI reports it: `inputTokens` is the
|
|
972
|
+
* TOTAL prompt count (OpenAI semantics) with `cachedInputTokens` a SUBSET already inside it,
|
|
973
|
+
* never a bucket to add on top. The cached share is carried rather than discarded so a
|
|
974
|
+
* consumer that needs the fresh figure can subtract it at the point of use, instead of the
|
|
975
|
+
* only two readings of this number being "inclusive" and "lost".
|
|
976
|
+
*/
|
|
977
|
+
interface CodexCumulativeUsage {
|
|
978
|
+
inputTokens: number
|
|
979
|
+
cachedInputTokens: number
|
|
980
|
+
outputTokens: number
|
|
981
|
+
}
|
|
982
|
+
|
|
929
983
|
/**
|
|
930
984
|
* Best-effort: pull token usage out of a Codex usage event. Codex `exec --json`
|
|
931
985
|
* reports a running CUMULATIVE total on `token_count` events under
|
|
@@ -933,12 +987,8 @@ function codexPlanProgress(event: Record<string, unknown>): TodoProgress | undef
|
|
|
933
987
|
* other shapes put it on `usage` / `info.usage` directly. We read the cumulative
|
|
934
988
|
* total when present so the caller can simply overwrite (not sum) — summing
|
|
935
989
|
* cumulative totals across events would multiply-count. Checked most-likely first.
|
|
936
|
-
* `input_tokens` is the TOTAL prompt count (OpenAI semantics: `cached_input_tokens`
|
|
937
|
-
* is a subset already inside it), so it is NOT summed with the cached share.
|
|
938
990
|
*/
|
|
939
|
-
function codexUsage(
|
|
940
|
-
event: Record<string, unknown>,
|
|
941
|
-
): { inputTokens: number; outputTokens: number } | undefined {
|
|
991
|
+
function codexUsage(event: Record<string, unknown>): CodexCumulativeUsage | undefined {
|
|
942
992
|
const info = isObject(event.info) ? (event.info as Record<string, unknown>) : undefined
|
|
943
993
|
const raw =
|
|
944
994
|
(info && isObject(info.total_token_usage) ? info.total_token_usage : undefined) ??
|
|
@@ -949,20 +999,28 @@ function codexUsage(
|
|
|
949
999
|
const input = numberOf(raw.input_tokens)
|
|
950
1000
|
const output = numberOf(raw.output_tokens)
|
|
951
1001
|
if (input === 0 && output === 0) return undefined
|
|
952
|
-
return {
|
|
1002
|
+
return {
|
|
1003
|
+
inputTokens: input,
|
|
1004
|
+
cachedInputTokens: numberOf(raw.cached_input_tokens),
|
|
1005
|
+
outputTokens: output,
|
|
1006
|
+
}
|
|
953
1007
|
}
|
|
954
1008
|
|
|
955
1009
|
/**
|
|
956
1010
|
* Per-TURN Codex token usage off a `token_count` event's `info.last_token_usage` (the
|
|
957
1011
|
* delta for the turn just completed, as opposed to `codexUsage`'s cumulative total).
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
*
|
|
1012
|
+
*
|
|
1013
|
+
* OpenAI semantics: `input_tokens` is the turn's WHOLE prompt count and already INCLUDES
|
|
1014
|
+
* the cached share, so the fresh figure is the difference. Clamped at 0 because the two
|
|
1015
|
+
* counts come off the same event and a vendor inconsistency must not mint a negative token
|
|
1016
|
+
* count. Codex reports no separate cache-WRITE class, so that class is 0 here rather than
|
|
1017
|
+
* guessed.
|
|
961
1018
|
*/
|
|
962
1019
|
function codexLastTurnUsage(event: Record<string, unknown>):
|
|
963
1020
|
| {
|
|
964
1021
|
inputTokens: number
|
|
965
|
-
|
|
1022
|
+
cacheReadTokens: number
|
|
1023
|
+
cacheWriteTokens: number
|
|
966
1024
|
outputTokens: number
|
|
967
1025
|
}
|
|
968
1026
|
| undefined {
|
|
@@ -973,7 +1031,12 @@ function codexLastTurnUsage(event: Record<string, unknown>):
|
|
|
973
1031
|
const cached = numberOf(raw.cached_input_tokens)
|
|
974
1032
|
const output = numberOf(raw.output_tokens)
|
|
975
1033
|
if (input === 0 && output === 0) return undefined
|
|
976
|
-
return {
|
|
1034
|
+
return {
|
|
1035
|
+
inputTokens: Math.max(0, input - cached),
|
|
1036
|
+
cacheReadTokens: cached,
|
|
1037
|
+
cacheWriteTokens: 0,
|
|
1038
|
+
outputTokens: output,
|
|
1039
|
+
}
|
|
977
1040
|
}
|
|
978
1041
|
|
|
979
1042
|
/** Dispatch to the configured subscription harness runner. */
|