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