pi-harness-delegate 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,526 @@
1
+ /**
2
+ * Fan-out (`harness: "all"` or a comma list): the tool path (`runFanoutTool`) and the `/delegate`
3
+ * command path (`runFanoutCommand` -> `runFanoutConcurrent`, one multi-run overlay). Split out of
4
+ * index.ts with no behavior change; the command path's overlay state is passed in as `RunUiState`.
5
+ */
6
+
7
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
8
+ import type { OverlayHandle } from '@earendil-works/pi-tui';
9
+ import {
10
+ buildFanoutReport,
11
+ type FanoutRunSummary,
12
+ formatMetrics,
13
+ formatToolUse,
14
+ orderFanoutResults,
15
+ } from './activity.ts';
16
+ import { fanoutResumeError, type parseDelegateCommand, resolveDefaults, resolveHarnessList } from './command.ts';
17
+ import { type DelegateConfig, loadConfig } from './config.ts';
18
+ import {
19
+ type DelegateToolParams,
20
+ delegate,
21
+ injectReport,
22
+ isProjectTrusted,
23
+ runDelegateForTool,
24
+ summarize,
25
+ type ToolProgressUpdate,
26
+ } from './engine.ts';
27
+ import { detectAll, HARNESS_NAMES, isKnownHarness, resolveHarnessName } from './harnesses/registry.ts';
28
+ import type { ActivityEvent } from './harnesses/types.ts';
29
+ import { NotifyBatcher } from './notify.ts';
30
+ import { formatFanoutChip, multiProgressWindow, type RunRow } from './progress-multi.ts';
31
+ import { loadTemplates } from './templates.ts';
32
+ import { mapClaudeUsage } from './usage.ts';
33
+ import { confirmDangerousCommand, validateDelegateInputs } from './validate.ts';
34
+ /** How long the fan-out overlay lingers on the finished board after the last run resolves, so a
35
+ * user who looked away still catches the final state instead of it clearing instantly. */
36
+ export const FANOUT_LINGER_MS = 3000;
37
+
38
+ export async function closeWhenMounted(getClose: () => (() => void) | null, capMs: number): Promise<void> {
39
+ const close = getClose();
40
+ if (close) {
41
+ close();
42
+ return;
43
+ }
44
+ await new Promise<void>(resolve => {
45
+ const start = Date.now();
46
+ const timer = setInterval(() => {
47
+ const fn = getClose();
48
+ if (fn || Date.now() - start > capMs) {
49
+ clearInterval(timer);
50
+ fn?.();
51
+ resolve();
52
+ }
53
+ }, 20);
54
+ });
55
+ }
56
+
57
+ /** `delegate({harness:"all"|"a,b"})` — resolve the requested harnesses to detected installs, run the
58
+ * existing `delegate()` engine concurrently across all of them (bounded by `maxConcurrent` via
59
+ * `acquireSlot({wait:true})` — see concurrency.ts), and mechanically synthesize one comparison
60
+ * report ordered by the resolved harness list regardless of completion order. No second model call. */
61
+ export async function runFanoutTool(
62
+ pi: ExtensionAPI,
63
+ ctx: ExtensionContext,
64
+ config: DelegateConfig,
65
+ params: DelegateToolParams,
66
+ signal: AbortSignal | undefined,
67
+ onUpdate: ((u: ToolProgressUpdate) => void) | undefined,
68
+ ): Promise<{ content: { type: string; text: string }[]; details: Record<string, unknown>; usage?: unknown }> {
69
+ const resumeErr = fanoutResumeError(params.harness, params.sessionId);
70
+ if (resumeErr) throw new Error(resumeErr);
71
+ validateDelegateInputs({
72
+ sessionId: params.sessionId,
73
+ model: params.model,
74
+ pr: params.pr,
75
+ addDirs: params.addDirs,
76
+ cwd: ctx.cwd,
77
+ });
78
+ const detection = await detectAll();
79
+ const { resolved, unknown, skipped } = resolveHarnessList(params.harness ?? 'all', {
80
+ knownHarnesses: HARNESS_NAMES,
81
+ aliasOf: resolveHarnessName,
82
+ isKnown: isKnownHarness,
83
+ detection,
84
+ });
85
+ if (resolved.length === 0) {
86
+ throw new Error(
87
+ `no harness available to fan out to (unknown: ${unknown.join(', ') || '—'}; not installed: ${skipped.join(', ') || '—'})`,
88
+ );
89
+ }
90
+
91
+ const mode = params.mode ?? config.defaultMode;
92
+
93
+ type TaskResult = FanoutRunSummary & {
94
+ usage?: import('./harnesses/types.ts').StreamedUsage | null;
95
+ };
96
+ const tasks = resolved.map(async (h): Promise<TaskResult> => {
97
+ onUpdate?.({ content: [{ type: 'text', text: `[${h}] queued…` }], details: { progress: 0.5 } });
98
+ try {
99
+ const run = await runDelegateForTool(
100
+ pi,
101
+ ctx,
102
+ config,
103
+ {
104
+ harness: h,
105
+ task: params.task,
106
+ mode: params.mode,
107
+ scope: params.scope,
108
+ model: params.model,
109
+ maxBudgetUsd: params.maxBudgetUsd,
110
+ allowDangerous: params.allowDangerous === true,
111
+ sessionId: params.sessionId,
112
+ pr: params.pr,
113
+ addDirs: params.addDirs,
114
+ // no verify: intentionally not model-settable — see DelegateToolParams
115
+ waitForSlot: true,
116
+ onAcquired: () =>
117
+ onUpdate?.({ content: [{ type: 'text', text: `[${h}] running…` }], details: { progress: 0.5 } }),
118
+ },
119
+ signal,
120
+ onUpdate,
121
+ `[${h}] `,
122
+ );
123
+ const summary = summarize(run.content);
124
+ return {
125
+ harness: h,
126
+ ok: !run.result.isError,
127
+ metrics: formatMetrics({
128
+ numTurns: run.result.numTurns,
129
+ totalCostUsd: run.result.totalCostUsd,
130
+ promptTokens: 0,
131
+ contextPercent: typeof run.details.contextPercent === 'number' ? run.details.contextPercent : null,
132
+ durationMs: run.result.durationMs,
133
+ }),
134
+ cost: run.result.totalCostUsd,
135
+ body: summary.text,
136
+ file: (run.details.file as string) ?? undefined,
137
+ sessionId: (run.details.sessionId as string) ?? undefined,
138
+ verify: run.verify,
139
+ usage: run.result.usage,
140
+ };
141
+ } catch (err) {
142
+ return { harness: h, ok: false, cost: null, error: err instanceof Error ? err.message : String(err) };
143
+ }
144
+ });
145
+
146
+ const settled = await Promise.all(tasks);
147
+ const runs = orderFanoutResults(resolved, settled);
148
+
149
+ let sumInput = 0;
150
+ let sumOutput = 0;
151
+ let sumCacheCreate = 0;
152
+ let sumCacheRead = 0;
153
+ let sumCost = 0;
154
+ let anyCostKnown = false;
155
+ for (const r of runs) {
156
+ if (r.usage) {
157
+ sumInput += r.usage.inputTokens;
158
+ sumOutput += r.usage.outputTokens;
159
+ sumCacheCreate += r.usage.cacheCreationInputTokens;
160
+ sumCacheRead += r.usage.cacheReadInputTokens;
161
+ }
162
+ if (r.cost !== null) {
163
+ sumCost += r.cost;
164
+ anyCostKnown = true;
165
+ }
166
+ }
167
+
168
+ const report = buildFanoutReport({ runs, skipped, unknown });
169
+ const okCount = runs.filter(r => r.ok).length;
170
+ const head = `## delegate all — ${mode} (${okCount}/${runs.length} ok)`;
171
+ const usage = mapClaudeUsage({
172
+ inputTokens: sumInput,
173
+ outputTokens: sumOutput,
174
+ cacheCreationInputTokens: sumCacheCreate,
175
+ cacheReadInputTokens: sumCacheRead,
176
+ totalCostUsd: anyCostKnown ? sumCost : null,
177
+ });
178
+ return {
179
+ content: [{ type: 'text', text: `${head}\n\n${report}` }],
180
+ details: { fanout: true, harness: 'all', mode, harnesses: resolved, skipped, unknown, runs },
181
+ usage,
182
+ };
183
+ }
184
+
185
+ /** Mutable UI state shared by the command-path runners — owned by the extension factory. */
186
+ export interface RunUiState {
187
+ activeRunId: number;
188
+ activeOverlay: { show(): void; focus(): void; runId: number } | null;
189
+ }
190
+
191
+ export interface FanoutSpec {
192
+ harnessName: string;
193
+ task: string;
194
+ scope?: string;
195
+ model?: string;
196
+ budget?: number;
197
+ sessionId?: string;
198
+ pr?: string;
199
+ addDirs?: string[];
200
+ verify?: string;
201
+ isDanger: boolean;
202
+ /** Only ever true after `confirmDangerousCommand` approved this invocation's --allow-dangerous. */
203
+ allowDangerous?: boolean;
204
+ }
205
+ export interface FanoutOutcome {
206
+ harnessName: string;
207
+ result: Awaited<ReturnType<typeof delegate>> | null;
208
+ error: Error | null;
209
+ cancelled: boolean;
210
+ }
211
+
212
+ /** Run `delegate()` concurrently across every spec in one multi-run overlay — the fan-out
213
+ * counterpart to `runOneDelegation`. Concurrency is bounded by `maxConcurrent`: every run passes
214
+ * `waitForSlot:true`, so `acquireSlot` (concurrency.ts) queues the ones that don't fit instead of
215
+ * failing them, and a fan-out never exceeds the configured cap just because it's a fan-out.
216
+ * Double-ESC cancel aborts every in-flight (and still-queued) run via one shared AbortController. */
217
+ export async function runFanoutConcurrent(
218
+ pi: ExtensionAPI,
219
+ ui: RunUiState,
220
+ ctx: ExtensionContext,
221
+ mode: string | undefined,
222
+ specs: FanoutSpec[],
223
+ ): Promise<FanoutOutcome[]> {
224
+ const ac = new AbortController();
225
+ let cancelledAll = false;
226
+ const runId = ++ui.activeRunId;
227
+ const clearActive = () => {
228
+ if (ui.activeOverlay?.runId === runId) ui.activeOverlay = null;
229
+ };
230
+ const modeForDisplay = mode ?? 'general';
231
+ const anyDanger = specs.some(s => s.isDanger);
232
+ const overallStart = Date.now();
233
+ const rows: RunRow[] = specs.map(s => ({
234
+ harness: s.harnessName,
235
+ startedAt: null,
236
+ status: 'queued',
237
+ activity: '',
238
+ costUsd: null,
239
+ }));
240
+ let requestRender: (() => void) | null = null;
241
+
242
+ let chipLastPush = 0;
243
+ const pushChip = () => {
244
+ if (!ctx.hasUI) return;
245
+ const now = Date.now();
246
+ if (now - chipLastPush < 500) return;
247
+ chipLastPush = now;
248
+ const theme = ctx.ui.theme;
249
+ ctx.ui.setStatus(
250
+ 'delegate',
251
+ theme.fg('accent', '●') + theme.fg('dim', ` ${formatFanoutChip(rows, now - overallStart)}`),
252
+ );
253
+ };
254
+
255
+ const runOne = async (spec: FanoutSpec, idx: number): Promise<FanoutOutcome> => {
256
+ const setRow = (patch: Partial<RunRow>) => {
257
+ rows[idx] = { ...rows[idx], ...patch };
258
+ requestRender?.();
259
+ pushChip();
260
+ };
261
+ let liveTail = '';
262
+ const onActivity = (ev: ActivityEvent) => {
263
+ if (ev.kind === 'tool_input') setRow({ activity: `▶ ${formatToolUse(ev.name, ev.input)}` });
264
+ else if (ev.kind === 'tool_result')
265
+ setRow({
266
+ activity: rows[idx].activity ? `${rows[idx].activity}${ev.isError ? ' ✗' : ' ✓'}` : rows[idx].activity,
267
+ });
268
+ else if (ev.kind === 'thinking') setRow({ activity: '💭 thinking…' });
269
+ };
270
+ const runState: { error: Error | null } = { error: null };
271
+ const run = delegate(pi, ctx, {
272
+ harness: spec.harnessName,
273
+ task: spec.task,
274
+ mode,
275
+ scope: spec.scope,
276
+ model: spec.model,
277
+ maxBudgetUsd: spec.budget,
278
+ sessionId: spec.sessionId,
279
+ pr: spec.pr,
280
+ addDirs: spec.addDirs,
281
+ verify: spec.verify,
282
+ allowDangerous: spec.allowDangerous === true, // never from config — only a confirmed --allow-dangerous
283
+ signal: ac.signal,
284
+ waitForSlot: true,
285
+ onAcquired: () => setRow({ status: 'running', startedAt: Date.now() }),
286
+ onStream: t => {
287
+ liveTail = (liveTail + t).slice(-200);
288
+ setRow({ activity: `✍ ${liveTail}` });
289
+ },
290
+ onActivity,
291
+ }).catch((err: unknown) => {
292
+ runState.error = err instanceof Error ? err : new Error(String(err));
293
+ return null;
294
+ });
295
+ const result = await run;
296
+ const failed = cancelledAll || !result;
297
+ // On failure keep context on the row: the reason if we have one, else whatever the run was
298
+ // last doing. Blanking it here would drop the only on-screen hint at *why* it failed.
299
+ const reason = runState.error ? runState.error.message.split('\n')[0].slice(0, 60) : '';
300
+ setRow({
301
+ status: failed ? 'failed' : 'done',
302
+ activity: failed ? reason || rows[idx].activity : '',
303
+ costUsd: !failed && result ? result.result.totalCostUsd : null,
304
+ });
305
+ return {
306
+ harnessName: spec.harnessName,
307
+ result: failed ? null : result,
308
+ error: runState.error,
309
+ cancelled: cancelledAll,
310
+ };
311
+ };
312
+
313
+ const allSettled = Promise.all(specs.map((spec, idx) => runOne(spec, idx)));
314
+
315
+ let closeWindow: (() => void) | null = null;
316
+ let outcomes: FanoutOutcome[];
317
+ if (ctx.hasUI) {
318
+ let overlayHandle: OverlayHandle | null = null;
319
+ let resolveDismiss = (): void => {};
320
+ const dismissed = new Promise<void>(resolve => {
321
+ resolveDismiss = () => resolve();
322
+ });
323
+ const uiPromise = ctx.ui
324
+ .custom(
325
+ (tui, theme, _kb, done) => {
326
+ requestRender = () => tui.requestRender();
327
+ closeWindow = () => done(undefined);
328
+ return multiProgressWindow(tui, theme, {
329
+ mode: modeForDisplay,
330
+ startedAt: overallStart,
331
+ getRows: () => rows,
332
+ dangerous: anyDanger,
333
+ onCancel: () => {
334
+ cancelledAll = true;
335
+ ac.abort();
336
+ },
337
+ onMinimize: () => {
338
+ overlayHandle?.setHidden(true);
339
+ overlayHandle?.unfocus();
340
+ },
341
+ onDismiss: () => resolveDismiss(),
342
+ });
343
+ },
344
+ {
345
+ overlay: true,
346
+ overlayOptions: { width: '70%', maxHeight: '60%', anchor: 'top-center' },
347
+ onHandle: h => {
348
+ overlayHandle = h;
349
+ ui.activeOverlay = { show: () => h.setHidden(false), focus: () => h.focus(), runId };
350
+ h.focus();
351
+ },
352
+ },
353
+ )
354
+ .catch(() => {});
355
+ outcomes = await allSettled;
356
+ // Cancelling already means "I'm done watching" — skip the linger so the overlay closes
357
+ // right away instead of sitting on a cancelled board for FANOUT_LINGER_MS.
358
+ if (cancelledAll) resolveDismiss();
359
+ // Tear the overlay down after a short linger (or immediately on Esc/m/cancel) — in the
360
+ // background, so this doesn't delay the outcomes we're about to return (and thus the
361
+ // injected report). `ui.activeOverlay` stays valid for `/delegate watch` until this settles.
362
+ void (async () => {
363
+ const timer = setTimeout(() => resolveDismiss(), FANOUT_LINGER_MS);
364
+ timer.unref?.();
365
+ await dismissed;
366
+ clearTimeout(timer);
367
+ await closeWhenMounted(() => closeWindow, 2000);
368
+ await uiPromise;
369
+ clearActive();
370
+ ctx.ui.setStatus('delegate', undefined);
371
+ })();
372
+ } else {
373
+ outcomes = await allSettled;
374
+ clearActive();
375
+ }
376
+ return outcomes;
377
+ }
378
+
379
+ /** `/delegate all …` / `/delegate a,b …` — resolve to detected harnesses, run `delegate()`
380
+ * concurrently across all of them in one multi-run overlay (see `runFanoutConcurrent`), batch
381
+ * success notifications, and inject one synthesized comparison report ordered by the resolved
382
+ * harness list regardless of completion order. */
383
+ export async function runFanoutCommand(
384
+ pi: ExtensionAPI,
385
+ ui: RunUiState,
386
+ ctx: ExtensionContext,
387
+ parsed: ReturnType<typeof parseDelegateCommand>,
388
+ ): Promise<void> {
389
+ const harnessSpec = parsed.harness as string;
390
+ const modeForReport = parsed.mode ?? loadConfig().defaultMode;
391
+ try {
392
+ const resumeErr = fanoutResumeError(harnessSpec, parsed.sessionId);
393
+ if (resumeErr) throw new Error(resumeErr);
394
+ validateDelegateInputs({
395
+ sessionId: parsed.sessionId,
396
+ model: parsed.model,
397
+ pr: parsed.pr,
398
+ addDirs: parsed.addDirs,
399
+ cwd: ctx.cwd,
400
+ });
401
+ // Headless never honors --allow-dangerous: refuse before even probing harness binaries. (With a
402
+ // UI the single confirm happens below, once the harness list is actually resolved.)
403
+ if (parsed.allowDangerous && !ctx.hasUI) {
404
+ await confirmDangerousCommand(ctx, { harnesses: [harnessSpec], mode: modeForReport, task: parsed.task });
405
+ }
406
+ } catch (err) {
407
+ const msg = err instanceof Error ? err.message : String(err);
408
+ if (ctx.hasUI) ctx.ui.notify(msg, 'error');
409
+ else process.stderr.write(`${msg}\n`);
410
+ return;
411
+ }
412
+ const detection = await detectAll();
413
+ const { resolved, unknown, skipped } = resolveHarnessList(harnessSpec, {
414
+ knownHarnesses: HARNESS_NAMES,
415
+ aliasOf: resolveHarnessName,
416
+ isKnown: isKnownHarness,
417
+ detection,
418
+ });
419
+ if (resolved.length === 0) {
420
+ const msg = `no harness available to fan out to (unknown: ${unknown.join(', ') || '—'}; not installed: ${skipped.join(', ') || '—'})`;
421
+ if (ctx.hasUI) ctx.ui.notify(msg, 'error');
422
+ else process.stderr.write(`${msg}\n`);
423
+ return;
424
+ }
425
+
426
+ const batcher = new NotifyBatcher((text, level) => {
427
+ if (ctx.hasUI) ctx.ui.notify(text, level);
428
+ else process.stdout.write(`${text}\n`);
429
+ });
430
+
431
+ // Resolve each harness's task/scope/danger flag up front — cheap and synchronous — so a
432
+ // harness that can't even start (e.g. mode needs a prompt) fails immediately instead of
433
+ // occupying a concurrency slot.
434
+ const specs: FanoutSpec[] = [];
435
+ const immediateFailures: FanoutRunSummary[] = [];
436
+ const trusted = isProjectTrusted(ctx);
437
+ for (const h of resolved) {
438
+ const templates = loadTemplates(ctx.cwd, h, trusted);
439
+ const resolvedTaskScope = resolveDefaults(parsed, templates);
440
+ const template = parsed.mode ? templates.get(parsed.mode) : undefined;
441
+ if (!resolvedTaskScope) {
442
+ const message = `mode "${parsed.mode ?? 'general'}" needs a prompt`;
443
+ immediateFailures.push({ harness: h, ok: false, cost: null, error: message });
444
+ batcher.failure(`${h}: ${message}`);
445
+ continue;
446
+ }
447
+ const isDanger =
448
+ template?.permission === 'danger' ||
449
+ (template?.nativePermission
450
+ ? ['bypassPermissions', 'danger-full-access', 'danger'].includes(template.nativePermission)
451
+ : false);
452
+ specs.push({
453
+ harnessName: h,
454
+ task: resolvedTaskScope.task,
455
+ scope: resolvedTaskScope.scope,
456
+ model: parsed.model,
457
+ budget: parsed.budget,
458
+ sessionId: parsed.sessionId,
459
+ pr: parsed.pr,
460
+ addDirs: parsed.addDirs,
461
+ verify: parsed.verify,
462
+ isDanger,
463
+ });
464
+ }
465
+
466
+ // --allow-dangerous: ONE confirm naming every harness that will actually run — never one per
467
+ // harness, and never a run before it's approved. A decline (or no UI) runs nothing.
468
+ if (parsed.allowDangerous && specs.length > 0) {
469
+ try {
470
+ await confirmDangerousCommand(ctx, {
471
+ harnesses: specs.map(s => s.harnessName),
472
+ mode: modeForReport,
473
+ task: specs[0].task,
474
+ });
475
+ } catch (err) {
476
+ const msg = err instanceof Error ? err.message : String(err);
477
+ if (ctx.hasUI) ctx.ui.notify(msg, 'warning');
478
+ else process.stderr.write(`${msg}\n`);
479
+ return;
480
+ }
481
+ for (const s of specs) {
482
+ s.allowDangerous = true;
483
+ s.isDanger = true; // escalated — the overlay shows the danger banner
484
+ }
485
+ }
486
+
487
+ const outcomes = specs.length > 0 ? await runFanoutConcurrent(pi, ui, ctx, parsed.mode, specs) : [];
488
+ const completed: FanoutRunSummary[] = outcomes.map(outcome => {
489
+ if (outcome.cancelled || !outcome.result) {
490
+ const message = outcome.error ? outcome.error.message : outcome.cancelled ? 'cancelled' : 'delegation failed';
491
+ batcher.failure(`${outcome.harnessName}: ${outcome.cancelled ? 'cancelled' : 'failed'} — ${message}`);
492
+ return { harness: outcome.harnessName, ok: false, cost: null, error: message };
493
+ }
494
+ const { content, details, result, verify } = outcome.result;
495
+ const summary = summarize(content);
496
+ const metrics = formatMetrics({
497
+ numTurns: result.numTurns,
498
+ totalCostUsd: result.totalCostUsd,
499
+ promptTokens: 0,
500
+ contextPercent: typeof details.contextPercent === 'number' ? details.contextPercent : null,
501
+ durationMs: typeof details.durationMs === 'number' ? details.durationMs : null,
502
+ });
503
+ batcher.success(`${outcome.harnessName} ${parsed.mode ?? 'general'} — ${metrics}`);
504
+ return {
505
+ harness: outcome.harnessName,
506
+ ok: !result.isError,
507
+ metrics,
508
+ cost: result.totalCostUsd,
509
+ body: summary.text,
510
+ file: (details.file as string) ?? undefined,
511
+ sessionId: (details.sessionId as string) ?? undefined,
512
+ verify,
513
+ };
514
+ });
515
+
516
+ const runs = orderFanoutResults(resolved, [...immediateFailures, ...completed]);
517
+ const okCount = runs.filter(r => r.ok).length;
518
+ const report = buildFanoutReport({ runs, skipped, unknown });
519
+ injectReport(ctx, {
520
+ harness: 'all',
521
+ mode: modeForReport,
522
+ metrics: `${okCount}/${runs.length} ok`,
523
+ body: report,
524
+ });
525
+ batcher.flush();
526
+ }
@@ -65,6 +65,8 @@ function extractAmpText(o: Record<string, unknown>): string | undefined {
65
65
  interface AmpHarnessState {
66
66
  sessionId?: string;
67
67
  costAccum?: number;
68
+ /** True once any turn_end actually carried a numeric `usage.cost.total` — absent is not $0. */
69
+ costSeen?: boolean;
68
70
  inputAccum?: number;
69
71
  outputAccum?: number;
70
72
  cacheReadAccum?: number;
@@ -137,11 +139,10 @@ export function parseAmpLine(line: string, state: ParseState): ParseOutcome {
137
139
  // real usage lives at message.usage on turn_end (per-turn, not cumulative — see accumulation below)
138
140
  if (typeStr === 'turn_end' && isRecord(msg?.usage)) {
139
141
  const u = msg.usage as Record<string, unknown>;
140
- const cost =
141
- isRecord(u.cost) && typeof (u.cost as Record<string, unknown>).total === 'number'
142
- ? ((u.cost as Record<string, unknown>).total as number)
143
- : 0;
144
- hs.costAccum = (hs.costAccum ?? 0) + cost;
142
+ if (isRecord(u.cost) && typeof (u.cost as Record<string, unknown>).total === 'number') {
143
+ hs.costAccum = (hs.costAccum ?? 0) + ((u.cost as Record<string, unknown>).total as number);
144
+ hs.costSeen = true;
145
+ }
145
146
  hs.inputAccum = (hs.inputAccum ?? 0) + (typeof u.input === 'number' ? u.input : 0);
146
147
  hs.outputAccum = (hs.outputAccum ?? 0) + (typeof u.output === 'number' ? u.output : 0);
147
148
  hs.cacheReadAccum = (hs.cacheReadAccum ?? 0) + (typeof u.cacheRead === 'number' ? u.cacheRead : 0);
@@ -176,7 +177,7 @@ export function parseAmpLine(line: string, state: ParseState): ParseOutcome {
176
177
  : state.streamedText || (text ?? ''),
177
178
  isError: o.is_error === true || isErrorTurn,
178
179
  numTurns: measured ? (hs.turnCount as number) : null,
179
- totalCostUsd: measured ? (hs.costAccum as number) : null,
180
+ totalCostUsd: hs.costSeen ? (hs.costAccum as number) : null,
180
181
  sessionId:
181
182
  typeof o.session_id === 'string' ? o.session_id : typeof o.id === 'string' ? o.id : (hs.sessionId ?? null),
182
183
  stopReason:
@@ -156,6 +156,8 @@ export const claudeHarness: Harness = {
156
156
  edit: ['acceptEdits'],
157
157
  danger: ['bypassPermissions'],
158
158
  },
159
+ // `--max-budget-usd` (see buildArgs) — claude enforces the cap itself.
160
+ nativeBudget: true,
159
161
  // No `acp` subcommand exists (docs/acp-harness-assessment.md §2) — confirmed against the full
160
162
  // `claude --help` output, not just an earlier probe.
161
163
  supportsTransports: ['stdout'],
@@ -246,14 +246,22 @@ export const codexHarness: Harness = {
246
246
  },
247
247
  buildArgs(opts: BuildArgsOpts): string[] {
248
248
  const sandbox = opts.nativePermission ?? SANDBOX_MAP[opts.permission] ?? 'workspace-write';
249
- const args = opts.resumeSessionId
250
- ? ['exec', 'resume', opts.resumeSessionId, opts.prompt, '--json']
251
- : ['exec', '--json', opts.prompt, '--sandbox', sandbox];
249
+ if (opts.resumeSessionId) {
250
+ // Positionals go after `--` so a session id (or prompt) can never be parsed as a flag —
251
+ // confirmed live: `codex exec resume --json -- --x "p"` takes `--x` as the session id,
252
+ // while the same without `--` is a hard "unexpected argument" error. Every flag must
253
+ // therefore come before the `--`.
254
+ // `codex exec resume --help` has no `--sandbox`/`--add-dir` at all (confirmed live: passing
255
+ // --add-dir to resume is a hard CLI error, "unexpected argument '--add-dir' found") — only the
256
+ // fresh-turn branch below supports either flag.
257
+ const args = ['exec', 'resume', '--json'];
258
+ if (opts.model) args.push('--model', opts.model);
259
+ args.push('--', opts.resumeSessionId, opts.prompt);
260
+ return args;
261
+ }
262
+ const args = ['exec', '--json', opts.prompt, '--sandbox', sandbox];
252
263
  if (opts.model) args.push('--model', opts.model);
253
- // `codex exec resume --help` has no `--sandbox`/`--add-dir` at all (confirmed live: passing
254
- // --add-dir to resume is a hard CLI error, "unexpected argument '--add-dir' found") — only the
255
- // fresh-turn branch above supports either flag.
256
- if (!opts.resumeSessionId) for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
264
+ for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
257
265
  return args;
258
266
  },
259
267
  parseLine(line: string, state: ParseState): ParseOutcome {
@@ -55,6 +55,8 @@ function extractOpencodeText(o: Record<string, unknown>): string | undefined {
55
55
  interface OpencodeHarnessState {
56
56
  sessionId?: string;
57
57
  costAccum?: number;
58
+ /** True once any step_finish actually carried a numeric `cost` — a step without one is not a $0 step. */
59
+ costSeen?: boolean;
58
60
  inputAccum?: number;
59
61
  outputAccum?: number;
60
62
  cacheReadAccum?: number;
@@ -124,7 +126,10 @@ export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome
124
126
  if (typeStr === 'step_finish' && part) {
125
127
  const tokens = isRecord(part.tokens) ? (part.tokens as Record<string, unknown>) : null;
126
128
  const cache = tokens && isRecord(tokens.cache) ? (tokens.cache as Record<string, unknown>) : null;
127
- hs.costAccum = (hs.costAccum ?? 0) + (typeof part.cost === 'number' ? part.cost : 0);
129
+ if (typeof part.cost === 'number') {
130
+ hs.costAccum = (hs.costAccum ?? 0) + part.cost;
131
+ hs.costSeen = true;
132
+ }
128
133
  hs.inputAccum = (hs.inputAccum ?? 0) + (tokens && typeof tokens.input === 'number' ? tokens.input : 0);
129
134
  hs.outputAccum = (hs.outputAccum ?? 0) + (tokens && typeof tokens.output === 'number' ? tokens.output : 0);
130
135
  hs.cacheReadAccum = (hs.cacheReadAccum ?? 0) + (cache && typeof cache.read === 'number' ? cache.read : 0);
@@ -151,7 +156,8 @@ export function parseOpencodeLine(line: string, state: ParseState): ParseOutcome
151
156
  : state.streamedText + (streamedText ?? ''),
152
157
  isError: o.is_error === true,
153
158
  numTurns: typeof o.num_turns === 'number' ? o.num_turns : measured ? (hs.stepCount as number) : null,
154
- totalCostUsd: measured
159
+ // Only a cost field actually seen counts — steps without one stay unmeasured (null), never $0.
160
+ totalCostUsd: hs.costSeen
155
161
  ? (hs.costAccum as number)
156
162
  : typeof o.total_cost_usd === 'number'
157
163
  ? o.total_cost_usd
@@ -243,7 +249,12 @@ function translateOpencodeAcpUpdate(update: Record<string, unknown>, state: Pars
243
249
  // docs/acp-harness-assessment.md §2 — and, per the same section, this is a running session
244
250
  // total already, latched from the *last* usage_update seen, never summed across a series.
245
251
  if (typeof update.size === 'number') hs.contextWindow = update.size;
246
- if (isRecord(update.cost) && typeof update.cost.amount === 'number') hs.costUsd = update.cost.amount;
252
+ if (isRecord(update.cost) && typeof update.cost.amount === 'number') {
253
+ hs.costUsd = update.cost.amount;
254
+ // surfaced to acp-runner.ts so it can enforce maxBudgetUsd mid-turn, not only once the
255
+ // session/prompt response arrives (by which point the turn has already finished)
256
+ return { streamedText, activities, runningCostUsd: update.cost.amount };
257
+ }
247
258
  break;
248
259
  default:
249
260
  break;
@@ -46,6 +46,10 @@ export interface ParseOutcome {
46
46
  streamedText?: string;
47
47
  activities?: ActivityEvent[];
48
48
  result?: StreamedResult | null;
49
+ /** The harness's own running cost total as of this line, when the line reports one mid-turn
50
+ * (e.g. an ACP `usage_update`) without being a final result — lets the ACP runner enforce
51
+ * `maxBudgetUsd` before the turn ends. Same semantics as `StreamedResult.totalCostUsd`. */
52
+ runningCostUsd?: number;
49
53
  }
50
54
 
51
55
  export interface StreamParseOutcome {
@@ -95,6 +99,10 @@ export interface Harness {
95
99
  /** Normalized -> native CLI arg fragments, used by the 'stdout' transport's buildArgs/nativePermission. */
96
100
  permissionMap?: Record<NormalizedPermission, string[]>;
97
101
  permissionHint?: (permission: NormalizedPermission) => string[];
102
+ /** True when `buildArgs` passes `maxBudgetUsd` to a native CLI budget flag (claude's
103
+ * `--max-budget-usd`). Otherwise the runners enforce it host-side from the streamed
104
+ * `totalCostUsd` — possible only when the harness actually reports cost. */
105
+ nativeBudget?: boolean;
98
106
  /** Which transports this harness's binary actually supports — the ceiling `config.harnesses.<name>.transport`
99
107
  * is validated against (see config.ts's `resolveTransport`), independent of what a user configures.
100
108
  * Omitted -> `[transport ?? 'stdout']` (today's single-transport harnesses). A harness legally offering