@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.
- package/dist/agent-runner.js +135 -69
- package/dist/claude-stream.js +48 -0
- package/dist/onboarding-preseed.js +67 -0
- package/dist/runner.js +31 -0
- package/dist/subagents.js +206 -0
- package/package.json +3 -3
- package/src/agent-runner.ts +151 -82
- package/src/claude-stream.ts +58 -0
- package/src/onboarding-preseed.ts +78 -0
- package/src/runner.ts +54 -0
- package/src/subagents.ts +276 -0
|
@@ -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.
|
|
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.
|
|
30
|
-
"@cat-factory/spend": "0.12.
|
|
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",
|
package/src/agent-runner.ts
CHANGED
|
@@ -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`)
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
|
263
|
-
//
|
|
264
|
-
//
|
|
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: '
|
|
268
|
-
|
|
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)
|
|
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))
|
|
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
|
|
338
|
-
|
|
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
|
-
|
|
393
|
-
opts.systemPrompt,
|
|
480
|
+
...appendArgs,
|
|
394
481
|
],
|
|
395
482
|
},
|
|
396
|
-
|
|
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
|
-
...(
|
|
409
|
-
...(
|
|
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
|
-
|
|
544
|
-
|
|
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
|
+
}
|