@cat-factory/executor-harness 1.50.4 → 1.50.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import { createReadStream } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js';
5
+ export function createSliceTracker() {
6
+ // Insertion-ordered so the progress `items` render in dispatch order.
7
+ const slices = new Map();
8
+ return {
9
+ onAssistant(content) {
10
+ if (!Array.isArray(content))
11
+ return;
12
+ for (const block of content) {
13
+ if (!isObject(block) || block.type !== 'tool_use' || block.name !== 'Task')
14
+ continue;
15
+ const id = typeof block.id === 'string' ? block.id : undefined;
16
+ if (!id || slices.has(id))
17
+ continue;
18
+ const input = isObject(block.input) ? block.input : {};
19
+ const description = typeof input.description === 'string' && input.description.trim()
20
+ ? input.description.trim()
21
+ : `Subagent ${slices.size + 1}`;
22
+ slices.set(id, { toolUseId: id, description, done: false });
23
+ }
24
+ },
25
+ onUser(content) {
26
+ if (!Array.isArray(content))
27
+ return;
28
+ for (const block of content) {
29
+ if (!isObject(block) || block.type !== 'tool_result')
30
+ continue;
31
+ const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined;
32
+ const slice = id ? slices.get(id) : undefined;
33
+ if (slice)
34
+ slice.done = true;
35
+ }
36
+ },
37
+ hasSlices() {
38
+ return slices.size > 0;
39
+ },
40
+ progress() {
41
+ if (slices.size === 0)
42
+ return undefined;
43
+ const items = [...slices.values()].map((s) => ({
44
+ label: s.description,
45
+ status: (s.done ? 'completed' : 'in_progress'),
46
+ }));
47
+ const completed = items.filter((i) => i.status === 'completed').length;
48
+ return {
49
+ completed,
50
+ inProgress: items.length - completed,
51
+ total: items.length,
52
+ items,
53
+ };
54
+ },
55
+ };
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // Subagent transcript watcher (heartbeat + usage) (D3)
59
+ // ---------------------------------------------------------------------------
60
+ /** Default poll cadence for the transcript directory; well under the git timeout margin. */
61
+ const DEFAULT_POLL_MS = 3_000;
62
+ /**
63
+ * Start watching `dir` (the CLI's `<configHome>/subagents`) for `*.jsonl` transcripts,
64
+ * tailing each file by byte offset. New content feeds `onActivity` (heartbeat) and each
65
+ * assistant turn carrying usage is lifted into a {@link HarnessCallMetric} + summed into
66
+ * the cumulative usage. Best-effort throughout: the directory may not exist yet (created
67
+ * lazily by the CLI), a file may be mid-write, and the line/usage shape may change across
68
+ * CLI versions — every such case is swallowed so the watcher can only ever ADD signal,
69
+ * never break the run.
70
+ */
71
+ export function startSubagentWatcher(dir, opts) {
72
+ const secrets = opts.secrets ?? [];
73
+ const offsets = new Map();
74
+ const calls = [];
75
+ const usage = { inputTokens: 0, outputTokens: 0 };
76
+ // Per-file partial-line remainder, carried as raw BYTES (not a decoded string). A JSONL
77
+ // record can straddle two polls (the file is appended between ticks), and the byte offset
78
+ // we stop at can fall in the middle of a multi-byte UTF-8 character; decoding a partial
79
+ // read to a string would replace that split character with U+FFFD and corrupt the line.
80
+ // Buffering bytes and decoding only whole lines keeps the captured text faithful.
81
+ const carry = new Map();
82
+ let polling = false;
83
+ const ingestLine = (line) => {
84
+ const trimmed = line.trim();
85
+ if (!trimmed.startsWith('{'))
86
+ return;
87
+ let event;
88
+ try {
89
+ event = JSON.parse(trimmed);
90
+ }
91
+ catch {
92
+ return;
93
+ }
94
+ // Subagent transcripts mirror the session-transcript envelope: an `assistant` entry
95
+ // whose `message` carries the Anthropic `usage` + `content`. Read defensively.
96
+ if (event.type !== 'assistant' || !isObject(event.message))
97
+ return;
98
+ const message = event.message;
99
+ const u = claudeCallUsage(message.usage);
100
+ if (u.inputTokens === 0 && u.outputTokens === 0)
101
+ return;
102
+ const content = Array.isArray(message.content) ? message.content : [];
103
+ const { text, reasoning } = claudeAssistantContent(content);
104
+ calls.push({
105
+ ...(typeof message.model === 'string'
106
+ ? { model: message.model }
107
+ : opts.model
108
+ ? { model: opts.model }
109
+ : {}),
110
+ // The subagent's own transcript isn't a re-sendable prompt chain, so we don't
111
+ // reconstruct the request side (kept empty); the response + tokens are faithful.
112
+ promptText: '',
113
+ messageCount: 0,
114
+ responseText: redactBody(text, secrets),
115
+ reasoningText: redactBody(reasoning, secrets),
116
+ inputTokens: u.inputTokens,
117
+ cachedInputTokens: u.cachedInputTokens,
118
+ outputTokens: u.outputTokens,
119
+ finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
120
+ });
121
+ usage.inputTokens += u.inputTokens;
122
+ usage.outputTokens += u.outputTokens;
123
+ };
124
+ const NEWLINE = 0x0a;
125
+ const readNew = (path, from, to) => new Promise((resolve) => {
126
+ // Tail as raw bytes and split on the newline byte, decoding each COMPLETE line to
127
+ // UTF-8 only on that boundary (a '\n' is a single byte, never part of a multi-byte
128
+ // sequence), so a record — or a multi-byte character — that spans this read and the
129
+ // next is reassembled from the byte carry rather than corrupted at the seam.
130
+ let buffer = carry.get(path) ?? Buffer.alloc(0);
131
+ const stream = createReadStream(path, { start: from, end: to - 1 });
132
+ stream.on('data', (chunk) => {
133
+ buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
134
+ let nl = buffer.indexOf(NEWLINE);
135
+ while (nl !== -1) {
136
+ ingestLine(buffer.subarray(0, nl).toString('utf8'));
137
+ buffer = buffer.subarray(nl + 1);
138
+ nl = buffer.indexOf(NEWLINE);
139
+ }
140
+ });
141
+ stream.on('error', () => resolve());
142
+ stream.on('close', () => {
143
+ // Copy the remainder out of the shared chunk backing store before caching it, so a
144
+ // later Buffer.concat can't be aliased by a reused stream buffer.
145
+ carry.set(path, Buffer.from(buffer));
146
+ resolve();
147
+ });
148
+ });
149
+ const pollOnce = async () => {
150
+ if (polling)
151
+ return;
152
+ polling = true;
153
+ try {
154
+ let entries;
155
+ try {
156
+ entries = (await readdir(dir)).filter((n) => n.endsWith('.jsonl'));
157
+ }
158
+ catch {
159
+ return; // dir not created yet (or vanished) — try again next tick
160
+ }
161
+ let grew = false;
162
+ for (const name of entries) {
163
+ const path = join(dir, name);
164
+ let size;
165
+ try {
166
+ size = (await stat(path)).size;
167
+ }
168
+ catch {
169
+ continue;
170
+ }
171
+ const from = offsets.get(path) ?? 0;
172
+ if (size <= from)
173
+ continue;
174
+ grew = true;
175
+ await readNew(path, from, size);
176
+ offsets.set(path, size);
177
+ }
178
+ if (grew)
179
+ opts.onActivity?.();
180
+ }
181
+ catch (e) {
182
+ opts.log?.warn('subagent transcript poll failed', { error: String(e) });
183
+ }
184
+ finally {
185
+ polling = false;
186
+ }
187
+ };
188
+ const timer = setInterval(() => void pollOnce(), opts.intervalMs ?? DEFAULT_POLL_MS);
189
+ // Don't let the watcher's timer keep the container process alive on its own.
190
+ timer.unref?.();
191
+ return {
192
+ // Always does a final drain (idempotent clear of the timer), so a late transcript
193
+ // write between the last tick and stop is still captured, and a second stop() picks up
194
+ // anything appended since — the per-file offsets make re-polling safe (no double count).
195
+ async stop() {
196
+ clearInterval(timer);
197
+ await pollOnce();
198
+ },
199
+ usage() {
200
+ return { ...usage };
201
+ },
202
+ calls() {
203
+ return calls;
204
+ },
205
+ };
206
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.50.4",
3
+ "version": "1.50.8",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,8 +26,8 @@
26
26
  "hono": "^4.12.30",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/server": "0.138.13",
30
- "@cat-factory/spend": "0.12.63"
29
+ "@cat-factory/server": "0.140.2",
30
+ "@cat-factory/spend": "0.12.68"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
@@ -2,10 +2,19 @@ import { spawn } from 'node:child_process'
2
2
  import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
3
  import { homedir, tmpdir } from 'node:os'
4
4
  import { dirname, join } from 'node:path'
5
+ import {
6
+ claudeAssistantContent,
7
+ claudeCallUsage,
8
+ isObject,
9
+ numberOf,
10
+ redactBody,
11
+ } from './claude-stream.js'
5
12
  import type { Logger } from './logger.js'
6
13
  import type { HarnessCallMetric, PiRunOutcome, PiRunStats, TodoProgress } from './pi.js'
7
14
  import { killChildProcess, spawnDetached } from './process.js'
8
15
  import { redact, secretsToRedact } from './redact.js'
16
+ import { createSliceTracker, startSubagentWatcher } from './subagents.js'
17
+ import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js'
9
18
  import { retainSessionTranscripts } from './transcript-retention.js'
10
19
 
11
20
  // The alternate (subscription) harness runners. The Pi harness reaches models
@@ -79,15 +88,6 @@ export interface SubscriptionRunOptions {
79
88
  log?: Logger
80
89
  }
81
90
 
82
- function isObject(value: unknown): value is Record<string, unknown> {
83
- return typeof value === 'object' && value !== null
84
- }
85
-
86
- /** Scrub any leased-credential occurrences from a telemetry body (no-op when none). */
87
- function redactBody(text: string, secrets: string[]): string {
88
- return secrets.length ? redact(text, secrets) : text
89
- }
90
-
91
91
  /**
92
92
  * Fallback token attribution: if a CLI reported a cumulative total but no per-turn
93
93
  * usage (so every captured call has zero tokens), pin the whole total onto the LAST
@@ -112,10 +112,11 @@ function attributeCumulativeUsage(
112
112
  * never argv), `onActivity` on every chunk, abort kills the child, and the close
113
113
  * handler resolves/rejects. The caller's `onEvent` accumulates the outcome.
114
114
  *
115
- * `prompt` is fed over stdin: for Claude Code that is just the task prompt (the
116
- * system prompt rides `--append-system-prompt`); for Codex which has no
117
- * system-prompt flag the caller prepends the composed system prompt to it so
118
- * the role + best-practice context is not lost.
115
+ * `prompt` is fed over stdin: for Claude Code that is normally just the task prompt (the
116
+ * system prompt rides `--append-system-prompt`), unless the system prompt is too large for
117
+ * argv, in which case it is folded into `prompt` (see `carryClaudeSystemPrompt`); for Codex
118
+ * which has no system-prompt flag the caller always prepends the composed system prompt
119
+ * so the role + best-practice context is not lost.
119
120
  */
120
121
  function streamCli(
121
122
  cli: { command: string; args: string[] },
@@ -210,6 +211,46 @@ function streamCli(
210
211
  })
211
212
  }
212
213
 
214
+ /**
215
+ * Fold a composed system prompt into the task prompt so the role + best-practice context
216
+ * rides stdin as a single user turn. Used by the Codex runner (no system-prompt flag) and
217
+ * by the Claude runner's argv-overflow fallback. Empty system prompt ⇒ the task prompt is
218
+ * returned unchanged.
219
+ */
220
+ function foldSystemPrompt(systemPrompt: string, userPrompt: string): string {
221
+ return systemPrompt ? `${systemPrompt}\n\n---\n\n${userPrompt}` : userPrompt
222
+ }
223
+
224
+ /**
225
+ * Linux caps a SINGLE argv string at MAX_ARG_STRLEN (32 pages = 128 KiB) — a per-string limit,
226
+ * distinct from (and reached long before) the far larger total ARG_MAX for argv + env combined. A
227
+ * system prompt with best-practice fragments folded in can exceed that per-string cap, and `execve`
228
+ * then fails the whole spawn with `E2BIG` before the agent runs at all — the failure mode seen on
229
+ * the `pr-reviewer` step (a ~150 KiB composed prompt). The binding constraint is that per-string
230
+ * cap; 96 KiB stays comfortably under 128 KiB so the system-prompt argv can never approach it.
231
+ */
232
+ const MAX_ARGV_STRING_BYTES = 96 * 1024
233
+
234
+ /**
235
+ * Decide how the Claude Code runner carries the composed system prompt. Small prompts ride
236
+ * `--append-system-prompt` (a real system turn, cacheable) as before; a prompt too large for a
237
+ * single argv string is instead folded into the stdin task prompt (like the Codex runner), which
238
+ * has no size ceiling. Pure so the branch is unit-testable without spawning the CLI.
239
+ */
240
+ export function carryClaudeSystemPrompt(
241
+ systemPrompt: string,
242
+ userPrompt: string,
243
+ ): { appendArgs: string[]; prompt: string; folded: boolean } {
244
+ if (Buffer.byteLength(systemPrompt, 'utf8') <= MAX_ARGV_STRING_BYTES) {
245
+ return {
246
+ appendArgs: ['--append-system-prompt', systemPrompt],
247
+ prompt: userPrompt,
248
+ folded: false,
249
+ }
250
+ }
251
+ return { appendArgs: [], prompt: foldSystemPrompt(systemPrompt, userPrompt), folded: true }
252
+ }
253
+
213
254
  // ---------------------------------------------------------------------------
214
255
  // Claude Code
215
256
  // ---------------------------------------------------------------------------
@@ -255,20 +296,48 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
255
296
  let summary = ''
256
297
  let usage: { inputTokens: number; outputTokens: number } | undefined
257
298
 
299
+ // Decide how the composed system prompt is carried up front, so the telemetry seed below
300
+ // reflects what actually reaches the model: a small prompt rides `--append-system-prompt`
301
+ // (a real system turn), while an argv-overflowing prompt is folded into the first user turn
302
+ // — in which case NO system turn of ours is sent (the `E2BIG` fallback).
303
+ const { appendArgs, prompt, folded } = carryClaudeSystemPrompt(opts.systemPrompt, opts.userPrompt)
304
+ if (folded) {
305
+ opts.log?.warn('system prompt exceeds argv limit; folding into the task prompt', {
306
+ bytes: Buffer.byteLength(opts.systemPrompt, 'utf8'),
307
+ })
308
+ }
309
+
258
310
  // Reconstruct the full per-call request/response bodies for telemetry from the
259
311
  // stream. `--output-format stream-json --verbose` emits each turn as a near-verbatim
260
312
  // Anthropic Messages envelope, so `assistant` events carry the complete response
261
313
  // (text + tool_use blocks + usage), and `user` events carry the tool_result blocks
262
- // fed back — together the growing prompt transcript. We seed it with the two inputs
263
- // the harness supplies (they never appear in the stream): the system + first user
264
- // message. Bodies are credential-scrubbed (they can echo the leased token).
314
+ // fed back — together the growing prompt transcript. We seed it with the inputs the
315
+ // harness supplies (they never appear in the stream): the system + first user message
316
+ // when the prompt rides argv, or a single folded user turn when it doesn't — so the
317
+ // reconstruction never shows a system turn that was never sent. Bodies are
318
+ // credential-scrubbed (they can echo the leased token).
265
319
  const secrets = opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : []
266
- const messages: Array<{ role: string; content: unknown }> = [
267
- { role: 'system', content: opts.systemPrompt },
268
- { role: 'user', content: opts.userPrompt },
269
- ]
320
+ const messages: Array<{ role: string; content: unknown }> = folded
321
+ ? [{ role: 'user', content: prompt }]
322
+ : [
323
+ { role: 'system', content: opts.systemPrompt },
324
+ { role: 'user', content: opts.userPrompt },
325
+ ]
270
326
  const calls: HarnessCallMetric[] = []
271
327
 
328
+ // ADR 0026 D2.1: derive slice progress from the parent stream's `Task` dispatches +
329
+ // their terminal tool_results (both DO appear here — only a subagent's intermediate
330
+ // turns don't). A real parent TodoWrite plan, when the agent writes one, wins; the
331
+ // slice-derived progress is the fallback for the parallel-subagent shape that writes no
332
+ // parent plan (the pr-reviewer failure this fixes).
333
+ const sliceTracker = createSliceTracker()
334
+ let sawTodoPlan = false
335
+ const emitSliceProgress = (): void => {
336
+ if (sawTodoPlan || !opts.onProgress) return
337
+ const progress = sliceTracker.progress()
338
+ if (progress) opts.onProgress(progress)
339
+ }
340
+
272
341
  const onEvent = (event: Record<string, unknown>): void => {
273
342
  const type = event.type
274
343
  if (type === 'assistant' && isObject(event.message)) {
@@ -285,9 +354,14 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
285
354
  opts.onProgress
286
355
  ) {
287
356
  const progress = todosToProgress((block.input as Record<string, unknown>)?.todos)
288
- if (progress) opts.onProgress(progress)
357
+ if (progress) {
358
+ sawTodoPlan = true
359
+ opts.onProgress(progress)
360
+ }
289
361
  }
290
362
  }
363
+ sliceTracker.onAssistant(content)
364
+ emitSliceProgress()
291
365
  // Record this call BEFORE appending its turn: the prompt is the history that
292
366
  // produced this response. The append-only array keeps each call's prompt a strict
293
367
  // prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
@@ -307,7 +381,11 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
307
381
  } else if (type === 'user' && isObject(event.message)) {
308
382
  // tool_result blocks the harness fed back to the model — part of the next prompt.
309
383
  const content = (event.message as Record<string, unknown>).content
310
- if (Array.isArray(content)) messages.push({ role: 'tool', content })
384
+ if (Array.isArray(content)) {
385
+ sliceTracker.onUser(content)
386
+ emitSliceProgress()
387
+ messages.push({ role: 'tool', content })
388
+ }
311
389
  } else if (type === 'result') {
312
390
  if (typeof event.result === 'string') summary = event.result
313
391
  usage = claudeUsage(event.usage) ?? usage
@@ -333,16 +411,12 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
333
411
  // as already accepted so `-p` starts straight into the run. Best-effort: written
334
412
  // before the CLI starts; unknown keys are harmless if a CLI version ignores them.
335
413
  // (Ambient mode skips this — the developer's own config is already onboarded.)
414
+ // ADR 0026 D4: assert the pinned onboarding keys landed and log them with the CLI
415
+ // version, so a future first-run gate this set doesn't cover (which looks identical to
416
+ // a healthy-but-quiet subagent start) is diffable when the cold-start watchdog fires.
336
417
  if (configHome) {
337
- await writeFile(
338
- join(configHome, '.claude.json'),
339
- JSON.stringify({
340
- hasCompletedOnboarding: true,
341
- bypassPermissionsModeAccepted: true,
342
- hasTrustDialogAccepted: true,
343
- }),
344
- { mode: 0o600 },
345
- ).catch(() => {})
418
+ await writeOnboardingPreseed(configHome)
419
+ await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log)
346
420
  }
347
421
 
348
422
  // Repo-sourced Claude Skill (slice 2): install it as a native skill under the config dir's
@@ -372,6 +446,20 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
372
446
  : { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken! }),
373
447
  }
374
448
 
449
+ // ADR 0026 D2.1/D3: while the run is live, tail the CLI's `subagents/*.jsonl`
450
+ // transcripts (under the isolated config home) so a parallel-subagent review keeps the
451
+ // inactivity heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible
452
+ // token spend is lifted into the run's telemetry. Ambient mode has no isolated home to
453
+ // watch. Best-effort — a missing/renamed transcript layout just yields no extra signal.
454
+ const subagents = configHome
455
+ ? startSubagentWatcher(join(configHome, 'subagents'), {
456
+ ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
457
+ secrets,
458
+ model: opts.model,
459
+ ...(opts.log ? { log: opts.log } : {}),
460
+ })
461
+ : undefined
462
+
375
463
  try {
376
464
  const { stderrTail } = await streamCli(
377
465
  {
@@ -389,26 +477,50 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
389
477
  'bypassPermissions',
390
478
  '--model',
391
479
  opts.model,
392
- '--append-system-prompt',
393
- opts.systemPrompt,
480
+ ...appendArgs,
394
481
  ],
395
482
  },
396
- opts.userPrompt,
483
+ prompt,
397
484
  opts,
398
485
  env,
399
486
  opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [],
400
487
  onEvent,
401
488
  )
402
489
 
490
+ // The parent's cumulative-usage fallback applies to the PARENT calls only (before the
491
+ // subagent calls, which carry their own per-turn tokens, are concatenated).
403
492
  attributeCumulativeUsage(calls, usage)
493
+ // Final drain of any subagent transcript writes that landed after the last poll, then
494
+ // fold the subagents' usage + per-call telemetry into the run's outcome — their tokens
495
+ // never appear on the parent stream, so this is the only place they are accounted.
496
+ await subagents?.stop()
497
+ const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 }
498
+ const subCalls = subagents?.calls() ?? []
499
+ const mergedCalls = [...calls, ...subCalls]
500
+ // INVARIANT (do not "fix" this into a double count): the run total is the parent usage
501
+ // PLUS the subagent usage because the two are disjoint sources. The parent `usage` here
502
+ // is the terminal `result` event's cumulative, which covers ONLY the parent loop — the
503
+ // ADR 0026 incident is itself the proof: a heavily subagent-parallelised review reported
504
+ // ~0 tokens, i.e. the parent stream (and its `result` total) never included the subagent
505
+ // spend. The subagent tokens live exclusively in the `subagents/*.jsonl` transcripts (a
506
+ // directory distinct from the parent's `projects/` session transcript), which the watcher
507
+ // reads and nothing else does — so neither `calls` nor `usage` can already contain them.
508
+ const mergedUsage =
509
+ usage || subUsage.inputTokens || subUsage.outputTokens
510
+ ? {
511
+ inputTokens: (usage?.inputTokens ?? 0) + subUsage.inputTokens,
512
+ outputTokens: (usage?.outputTokens ?? 0) + subUsage.outputTokens,
513
+ }
514
+ : undefined
404
515
  return {
405
516
  summary,
406
517
  stats,
407
518
  stderrTail,
408
- ...(usage ? { usage } : {}),
409
- ...(calls.length ? { callMetrics: calls } : {}),
519
+ ...(mergedUsage ? { usage: mergedUsage } : {}),
520
+ ...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
410
521
  }
411
522
  } finally {
523
+ await subagents?.stop()
412
524
  if (configHome) {
413
525
  // Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
414
526
  // home is deleted — the credential lives at the home root, never in `projects/`, so this
@@ -456,44 +568,6 @@ function claudeUsage(raw: unknown): { inputTokens: number; outputTokens: number
456
568
  return { inputTokens: input, outputTokens: output }
457
569
  }
458
570
 
459
- /** Pull the text + reasoning out of a Claude `assistant` message's content blocks. */
460
- function claudeAssistantContent(content: unknown[]): {
461
- text: string
462
- reasoning: string
463
- toolUses: number
464
- } {
465
- let text = ''
466
- let reasoning = ''
467
- let toolUses = 0
468
- for (const block of content) {
469
- if (!isObject(block)) continue
470
- if (block.type === 'text' && typeof block.text === 'string') text += block.text
471
- else if (block.type === 'thinking' && typeof block.thinking === 'string')
472
- reasoning += block.thinking
473
- else if (block.type === 'tool_use') toolUses += 1
474
- }
475
- return { text, reasoning, toolUses }
476
- }
477
-
478
- /**
479
- * Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
480
- * the cumulative `result` total). `inputTokens` counts every billed input bucket (fresh
481
- * + both cache buckets); `cachedInputTokens` is the cache share, surfaced separately.
482
- */
483
- function claudeCallUsage(raw: unknown): {
484
- inputTokens: number
485
- cachedInputTokens: number
486
- outputTokens: number
487
- } {
488
- if (!isObject(raw)) return { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }
489
- const cached = numberOf(raw.cache_read_input_tokens) + numberOf(raw.cache_creation_input_tokens)
490
- return {
491
- inputTokens: numberOf(raw.input_tokens) + cached,
492
- cachedInputTokens: cached,
493
- outputTokens: numberOf(raw.output_tokens),
494
- }
495
- }
496
-
497
571
  // ---------------------------------------------------------------------------
498
572
  // Codex
499
573
  // ---------------------------------------------------------------------------
@@ -539,10 +613,9 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
539
613
  }
540
614
 
541
615
  // Codex has no system-prompt flag, so fold the composed role + best-practice
542
- // context into the prompt itself (Claude Code instead rides --append-system-prompt).
543
- const prompt = opts.systemPrompt
544
- ? `${opts.systemPrompt}\n\n---\n\n${opts.userPrompt}`
545
- : opts.userPrompt
616
+ // context into the prompt itself (Claude Code instead rides --append-system-prompt,
617
+ // falling back to this same fold when the prompt overflows argv).
618
+ const prompt = foldSystemPrompt(opts.systemPrompt, opts.userPrompt)
546
619
 
547
620
  // Codex's `exec --json` is far thinner than Claude Code's stream: it surfaces only
548
621
  // flat assistant text and (on `token_count` events) the per-turn `last_token_usage`
@@ -754,10 +827,6 @@ function codexLastTurnUsage(event: Record<string, unknown>):
754
827
  return { inputTokens: input, cachedInputTokens: cached, outputTokens: output }
755
828
  }
756
829
 
757
- function numberOf(value: unknown): number {
758
- return typeof value === 'number' && Number.isFinite(value) ? value : 0
759
- }
760
-
761
830
  /** Dispatch to the configured subscription harness runner. */
762
831
  export function runSubscriptionHarness(
763
832
  harness: SubscriptionHarness,
@@ -0,0 +1,58 @@
1
+ import { redact } from './redact.js'
2
+
3
+ // Shared parsing of Claude Code's stream-json / session-transcript envelope. The parent
4
+ // runner (`agent-runner.ts`) reads these off the CLI's stdout; the subagent watcher
5
+ // (`subagents.ts`) reads the same shapes off the `subagents/*.jsonl` transcripts. Kept in
6
+ // one place so both read usage/content identically and the cycle between the two modules
7
+ // is broken.
8
+
9
+ export function isObject(value: unknown): value is Record<string, unknown> {
10
+ return typeof value === 'object' && value !== null
11
+ }
12
+
13
+ export function numberOf(value: unknown): number {
14
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0
15
+ }
16
+
17
+ /** Scrub any leased-credential occurrences from a telemetry body (no-op when none). */
18
+ export function redactBody(text: string, secrets: string[]): string {
19
+ return secrets.length ? redact(text, secrets) : text
20
+ }
21
+
22
+ /** Pull the text + reasoning out of a Claude `assistant` message's content blocks. */
23
+ export function claudeAssistantContent(content: unknown[]): {
24
+ text: string
25
+ reasoning: string
26
+ toolUses: number
27
+ } {
28
+ let text = ''
29
+ let reasoning = ''
30
+ let toolUses = 0
31
+ for (const block of content) {
32
+ if (!isObject(block)) continue
33
+ if (block.type === 'text' && typeof block.text === 'string') text += block.text
34
+ else if (block.type === 'thinking' && typeof block.thinking === 'string')
35
+ reasoning += block.thinking
36
+ else if (block.type === 'tool_use') toolUses += 1
37
+ }
38
+ return { text, reasoning, toolUses }
39
+ }
40
+
41
+ /**
42
+ * Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
43
+ * the cumulative `result` total). `inputTokens` counts every billed input bucket (fresh
44
+ * + both cache buckets); `cachedInputTokens` is the cache share, surfaced separately.
45
+ */
46
+ export function claudeCallUsage(raw: unknown): {
47
+ inputTokens: number
48
+ cachedInputTokens: number
49
+ outputTokens: number
50
+ } {
51
+ if (!isObject(raw)) return { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }
52
+ const cached = numberOf(raw.cache_read_input_tokens) + numberOf(raw.cache_creation_input_tokens)
53
+ return {
54
+ inputTokens: numberOf(raw.input_tokens) + cached,
55
+ cachedInputTokens: cached,
56
+ outputTokens: numberOf(raw.output_tokens),
57
+ }
58
+ }