@cat-factory/executor-harness 1.50.6 → 1.50.10
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 +70 -51
- package/dist/agent.js +23 -1
- package/dist/claude-stream.js +48 -0
- package/dist/git.js +40 -0
- package/dist/job.js +2 -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 +79 -64
- package/src/agent.ts +26 -0
- package/src/claude-stream.ts +58 -0
- package/src/git.ts +51 -0
- package/src/job.ts +11 -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.10",
|
|
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.140.
|
|
30
|
-
"@cat-factory/spend": "0.12.
|
|
29
|
+
"@cat-factory/server": "0.140.4",
|
|
30
|
+
"@cat-factory/spend": "0.12.69"
|
|
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
|
|
@@ -325,6 +325,19 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
325
325
|
]
|
|
326
326
|
const calls: HarnessCallMetric[] = []
|
|
327
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
|
+
|
|
328
341
|
const onEvent = (event: Record<string, unknown>): void => {
|
|
329
342
|
const type = event.type
|
|
330
343
|
if (type === 'assistant' && isObject(event.message)) {
|
|
@@ -341,9 +354,14 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
341
354
|
opts.onProgress
|
|
342
355
|
) {
|
|
343
356
|
const progress = todosToProgress((block.input as Record<string, unknown>)?.todos)
|
|
344
|
-
if (progress)
|
|
357
|
+
if (progress) {
|
|
358
|
+
sawTodoPlan = true
|
|
359
|
+
opts.onProgress(progress)
|
|
360
|
+
}
|
|
345
361
|
}
|
|
346
362
|
}
|
|
363
|
+
sliceTracker.onAssistant(content)
|
|
364
|
+
emitSliceProgress()
|
|
347
365
|
// Record this call BEFORE appending its turn: the prompt is the history that
|
|
348
366
|
// produced this response. The append-only array keeps each call's prompt a strict
|
|
349
367
|
// prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
|
|
@@ -363,7 +381,11 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
363
381
|
} else if (type === 'user' && isObject(event.message)) {
|
|
364
382
|
// tool_result blocks the harness fed back to the model — part of the next prompt.
|
|
365
383
|
const content = (event.message as Record<string, unknown>).content
|
|
366
|
-
if (Array.isArray(content))
|
|
384
|
+
if (Array.isArray(content)) {
|
|
385
|
+
sliceTracker.onUser(content)
|
|
386
|
+
emitSliceProgress()
|
|
387
|
+
messages.push({ role: 'tool', content })
|
|
388
|
+
}
|
|
367
389
|
} else if (type === 'result') {
|
|
368
390
|
if (typeof event.result === 'string') summary = event.result
|
|
369
391
|
usage = claudeUsage(event.usage) ?? usage
|
|
@@ -389,16 +411,12 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
389
411
|
// as already accepted so `-p` starts straight into the run. Best-effort: written
|
|
390
412
|
// before the CLI starts; unknown keys are harmless if a CLI version ignores them.
|
|
391
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.
|
|
392
417
|
if (configHome) {
|
|
393
|
-
await
|
|
394
|
-
|
|
395
|
-
JSON.stringify({
|
|
396
|
-
hasCompletedOnboarding: true,
|
|
397
|
-
bypassPermissionsModeAccepted: true,
|
|
398
|
-
hasTrustDialogAccepted: true,
|
|
399
|
-
}),
|
|
400
|
-
{ mode: 0o600 },
|
|
401
|
-
).catch(() => {})
|
|
418
|
+
await writeOnboardingPreseed(configHome)
|
|
419
|
+
await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log)
|
|
402
420
|
}
|
|
403
421
|
|
|
404
422
|
// Repo-sourced Claude Skill (slice 2): install it as a native skill under the config dir's
|
|
@@ -428,6 +446,20 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
428
446
|
: { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken! }),
|
|
429
447
|
}
|
|
430
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
|
+
|
|
431
463
|
try {
|
|
432
464
|
const { stderrTail } = await streamCli(
|
|
433
465
|
{
|
|
@@ -455,15 +487,40 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
455
487
|
onEvent,
|
|
456
488
|
)
|
|
457
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).
|
|
458
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
|
|
459
515
|
return {
|
|
460
516
|
summary,
|
|
461
517
|
stats,
|
|
462
518
|
stderrTail,
|
|
463
|
-
...(
|
|
464
|
-
...(
|
|
519
|
+
...(mergedUsage ? { usage: mergedUsage } : {}),
|
|
520
|
+
...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
|
|
465
521
|
}
|
|
466
522
|
} finally {
|
|
523
|
+
await subagents?.stop()
|
|
467
524
|
if (configHome) {
|
|
468
525
|
// Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
|
|
469
526
|
// home is deleted — the credential lives at the home root, never in `projects/`, so this
|
|
@@ -511,44 +568,6 @@ function claudeUsage(raw: unknown): { inputTokens: number; outputTokens: number
|
|
|
511
568
|
return { inputTokens: input, outputTokens: output }
|
|
512
569
|
}
|
|
513
570
|
|
|
514
|
-
/** Pull the text + reasoning out of a Claude `assistant` message's content blocks. */
|
|
515
|
-
function claudeAssistantContent(content: unknown[]): {
|
|
516
|
-
text: string
|
|
517
|
-
reasoning: string
|
|
518
|
-
toolUses: number
|
|
519
|
-
} {
|
|
520
|
-
let text = ''
|
|
521
|
-
let reasoning = ''
|
|
522
|
-
let toolUses = 0
|
|
523
|
-
for (const block of content) {
|
|
524
|
-
if (!isObject(block)) continue
|
|
525
|
-
if (block.type === 'text' && typeof block.text === 'string') text += block.text
|
|
526
|
-
else if (block.type === 'thinking' && typeof block.thinking === 'string')
|
|
527
|
-
reasoning += block.thinking
|
|
528
|
-
else if (block.type === 'tool_use') toolUses += 1
|
|
529
|
-
}
|
|
530
|
-
return { text, reasoning, toolUses }
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/**
|
|
534
|
-
* Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
|
|
535
|
-
* the cumulative `result` total). `inputTokens` counts every billed input bucket (fresh
|
|
536
|
-
* + both cache buckets); `cachedInputTokens` is the cache share, surfaced separately.
|
|
537
|
-
*/
|
|
538
|
-
function claudeCallUsage(raw: unknown): {
|
|
539
|
-
inputTokens: number
|
|
540
|
-
cachedInputTokens: number
|
|
541
|
-
outputTokens: number
|
|
542
|
-
} {
|
|
543
|
-
if (!isObject(raw)) return { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }
|
|
544
|
-
const cached = numberOf(raw.cache_read_input_tokens) + numberOf(raw.cache_creation_input_tokens)
|
|
545
|
-
return {
|
|
546
|
-
inputTokens: numberOf(raw.input_tokens) + cached,
|
|
547
|
-
cachedInputTokens: cached,
|
|
548
|
-
outputTokens: numberOf(raw.output_tokens),
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
571
|
// ---------------------------------------------------------------------------
|
|
553
572
|
// Codex
|
|
554
573
|
// ---------------------------------------------------------------------------
|
|
@@ -808,10 +827,6 @@ function codexLastTurnUsage(event: Record<string, unknown>):
|
|
|
808
827
|
return { inputTokens: input, cachedInputTokens: cached, outputTokens: output }
|
|
809
828
|
}
|
|
810
829
|
|
|
811
|
-
function numberOf(value: unknown): number {
|
|
812
|
-
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
813
|
-
}
|
|
814
|
-
|
|
815
830
|
/** Dispatch to the configured subscription harness runner. */
|
|
816
831
|
export function runSubscriptionHarness(
|
|
817
832
|
harness: SubscriptionHarness,
|
package/src/agent.ts
CHANGED
|
@@ -18,9 +18,11 @@ import {
|
|
|
18
18
|
cloneRepo,
|
|
19
19
|
commitAll,
|
|
20
20
|
conflictDiff,
|
|
21
|
+
fetchPullRequestHead,
|
|
21
22
|
fetchReferenceBranches,
|
|
22
23
|
hasAgentChanges,
|
|
23
24
|
headCommit,
|
|
25
|
+
inferVcsProvider,
|
|
24
26
|
mergeBranch,
|
|
25
27
|
openPullRequest,
|
|
26
28
|
prepareExistingCheckout,
|
|
@@ -493,6 +495,30 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
|
|
|
493
495
|
})
|
|
494
496
|
}
|
|
495
497
|
|
|
498
|
+
// The pr-reviewer reviews an EXISTING PR: fetch its HEAD into `origin/pr-head` so the
|
|
499
|
+
// read-only agent can inspect the PROPOSED code — files the PR adds (absent from this base
|
|
500
|
+
// checkout) and the head version of every modified file. The agent holds no git credential
|
|
501
|
+
// of its own, so this harness-side fetch (token out of band) is the only way the head is
|
|
502
|
+
// reachable; the prompt then diffs `origin/<base>...origin/pr-head`. Best-effort: on failure
|
|
503
|
+
// the review proceeds on the base checkout + the injected `.cat-context/pr-diff.md`.
|
|
504
|
+
if (job.reviewPrNumber !== undefined) {
|
|
505
|
+
const provider = job.repo.provider ?? inferVcsProvider(job.repo.cloneUrl)
|
|
506
|
+
const fetched = await fetchPullRequestHead({
|
|
507
|
+
dir,
|
|
508
|
+
number: job.reviewPrNumber,
|
|
509
|
+
provider,
|
|
510
|
+
ghToken: job.ghToken,
|
|
511
|
+
signal: opts.signal,
|
|
512
|
+
onSkip: (reason) =>
|
|
513
|
+
logger.warn('agent(explore): PR head fetch skipped', {
|
|
514
|
+
number: job.reviewPrNumber,
|
|
515
|
+
provider,
|
|
516
|
+
reason,
|
|
517
|
+
}),
|
|
518
|
+
})
|
|
519
|
+
logger.info('agent(explore): PR head fetch', { number: job.reviewPrNumber, fetched })
|
|
520
|
+
}
|
|
521
|
+
|
|
496
522
|
// Optional infra stand-up (the tester): bring the service's docker-compose
|
|
497
523
|
// dependencies up at the repo root for the duration of the run, tearing them down in
|
|
498
524
|
// the `finally`. A stand-up failure is non-fatal — it's surfaced to the agent as a
|
|
@@ -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
|
+
}
|
package/src/git.ts
CHANGED
|
@@ -862,6 +862,57 @@ export async function fetchReferenceBranches(opts: {
|
|
|
862
862
|
return fetched
|
|
863
863
|
}
|
|
864
864
|
|
|
865
|
+
/** The local tracking ref a fetched PR/MR head lands on, so the reviewer reads `origin/pr-head`. */
|
|
866
|
+
export const PR_HEAD_REF = 'refs/remotes/origin/pr-head'
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* The `git fetch` refspec that maps a PR/MR's server-side HEAD ref onto {@link PR_HEAD_REF}. A
|
|
870
|
+
* PR head is a synthetic ref the host maintains, NOT part of a normal clone: GitHub exposes it at
|
|
871
|
+
* `refs/pull/<n>/head`, GitLab at `refs/merge-requests/<n>/head`. Pure so the provider branch is
|
|
872
|
+
* unit-tested without a network. The leading `+` forces the update (the ref is read-only here).
|
|
873
|
+
*/
|
|
874
|
+
export function pullHeadRefspec(number: number, provider: 'github' | 'gitlab'): string {
|
|
875
|
+
const src =
|
|
876
|
+
provider === 'gitlab' ? `refs/merge-requests/${number}/head` : `refs/pull/${number}/head`
|
|
877
|
+
return `+${src}:${PR_HEAD_REF}`
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Fetch the reviewed PR/MR's HEAD into {@link PR_HEAD_REF} so a read-only reviewer can inspect the
|
|
882
|
+
* PROPOSED code — files the PR adds (absent from the base checkout) and the head version of every
|
|
883
|
+
* modified file — with `git diff origin/<base>...origin/pr-head`, `git show origin/pr-head:<path>`.
|
|
884
|
+
* The base clone never includes the pull ref, and the container agent holds no git credential of
|
|
885
|
+
* its own (the token lives with the harness), so the agent's own `git fetch pull/<n>/head` fails
|
|
886
|
+
* on a private repo — this harness-side fetch (which carries the token out of band via GIT_ASKPASS,
|
|
887
|
+
* exactly like {@link fetchReferenceBranches}) is what actually makes the head reachable.
|
|
888
|
+
*
|
|
889
|
+
* Best-effort: a fetch failure (a closed/deleted PR, a host without the pull ref, a transient
|
|
890
|
+
* network error) is reported via `onSkip` and swallowed — the review then proceeds on the base
|
|
891
|
+
* checkout + the injected diff, never fails. Returns whether the head was fetched.
|
|
892
|
+
*/
|
|
893
|
+
export async function fetchPullRequestHead(opts: {
|
|
894
|
+
dir: string
|
|
895
|
+
number: number
|
|
896
|
+
provider: 'github' | 'gitlab'
|
|
897
|
+
ghToken: string
|
|
898
|
+
signal?: AbortSignal
|
|
899
|
+
/** Called when the fetch failed, so the caller (which owns a logger) can warn. */
|
|
900
|
+
onSkip?: (reason: string) => void
|
|
901
|
+
}): Promise<boolean> {
|
|
902
|
+
const { dir, number, provider, ghToken, signal, onSkip } = opts
|
|
903
|
+
try {
|
|
904
|
+
await git(['fetch', '--no-tags', 'origin', pullHeadRefspec(number, provider)], {
|
|
905
|
+
cwd: dir,
|
|
906
|
+
signal,
|
|
907
|
+
env: await authEnv(ghToken),
|
|
908
|
+
})
|
|
909
|
+
return true
|
|
910
|
+
} catch (err) {
|
|
911
|
+
onSkip?.(err instanceof Error ? err.message : String(err))
|
|
912
|
+
return false
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
865
916
|
/**
|
|
866
917
|
* Push the work branch to origin. The remote URL carries only the username, so
|
|
867
918
|
* the token is supplied here via the askpass env (never in argv).
|
package/src/job.ts
CHANGED
|
@@ -784,6 +784,15 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
784
784
|
* the same primary repo. Absent ⇒ none. Consumed by the coding + explore flows.
|
|
785
785
|
*/
|
|
786
786
|
referenceBranches?: string[]
|
|
787
|
+
/**
|
|
788
|
+
* Explore mode (the `pr-reviewer`): the reviewed PR/MR number. Present ⇒ after the base
|
|
789
|
+
* checkout the harness fetches that PR's HEAD into `origin/pr-head` (best-effort) so the
|
|
790
|
+
* read-only reviewer can diff/read the PROPOSED code — files the PR adds are otherwise absent
|
|
791
|
+
* from the base checkout, and the agent has no git credential to fetch the head itself. The
|
|
792
|
+
* GitHub-vs-GitLab pull ref is chosen from `repo.provider` (host-inferred when absent). Absent
|
|
793
|
+
* ⇒ no head fetch (every non-review run). See {@link file://./git.ts} `fetchPullRequestHead`.
|
|
794
|
+
*/
|
|
795
|
+
reviewPrNumber?: number
|
|
787
796
|
/**
|
|
788
797
|
* Coding mode: whether a no-op run (nothing changed) is a failure. The implementer
|
|
789
798
|
* fails on a no-op; the in-place fixers (ci-fix / fix-tests) treat it as a non-fatal
|
|
@@ -1277,6 +1286,7 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1277
1286
|
const testSecrets = parseTestSecrets(o.testSecrets)
|
|
1278
1287
|
const guardLimits = parseGuardLimits(o.guardLimits)
|
|
1279
1288
|
const validation = parseValidationSpec(o.validation)
|
|
1289
|
+
const reviewPrNumber = posInt(o.reviewPrNumber)
|
|
1280
1290
|
const job: AgentJob = {
|
|
1281
1291
|
jobId: str(o.jobId, 'jobId'),
|
|
1282
1292
|
mode,
|
|
@@ -1308,6 +1318,7 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1308
1318
|
...(peerRepos.length ? { peerRepos } : {}),
|
|
1309
1319
|
...(referenceRepos.length ? { referenceRepos } : {}),
|
|
1310
1320
|
...(referenceBranches.length ? { referenceBranches } : {}),
|
|
1321
|
+
...(reviewPrNumber !== undefined ? { reviewPrNumber } : {}),
|
|
1311
1322
|
...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
|
|
1312
1323
|
...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
|
|
1313
1324
|
...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
|