pi-harness-delegate 0.2.2 → 0.4.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.
package/README.md CHANGED
@@ -39,18 +39,45 @@ Only the prompt is required. A **harness as first word** and/or **mode as next w
39
39
  Some modes have **default tasks** when the prompt is omitted:
40
40
  `/delegate review` reviews the current git diff (`scope: diff`), `/delegate security-audit` audits the repo. Modes without a default (`plan`, `implement`, `docs`, `general`) print a hint asking for a prompt.
41
41
 
42
- The `delegate` tool takes: `harness`, `task`, `mode`, `scope` (`diff` = git diff, `pr` = PR diff, path list, or whole repo), `model`, `maxBudgetUsd`, `allowDangerous`, `sessionId`, `pr`.
42
+ The `delegate` tool takes: `harness`, `task`, `mode`, `scope` (`diff` = git diff, `pr` = PR diff, path list, or whole repo), `model`, `maxBudgetUsd`, `allowDangerous`, `sessionId`, `pr`. (`verify` is deliberately *not* a tool parameter — see below.)
43
43
 
44
44
  `claude_delegate` remains as a deprecated alias for `delegate{harness:claude}`.
45
45
 
46
+ ### Fan out to multiple harnesses
47
+
48
+ `harness` also accepts `all` or a comma-separated list — the same task runs on every harness **concurrently**, up to `maxConcurrent`, and comes back as one comparison report instead of one report per harness:
49
+
50
+ ```bash
51
+ /delegate all review the auth flow # every *detected* harness
52
+ /delegate claude,codex plan the migration # just these two
53
+ delegate({ harness: "all", mode: "review", scope: "diff" }) # tool call form
54
+ ```
55
+
56
+ - `all` resolves to whatever's actually installed (`detectAll()`) — an uninstalled harness is skipped and named in the report, it doesn't fail the run. An explicit list is validated the same way; an unknown name is also reported rather than aborting the rest.
57
+ - Each harness's run goes through the same `delegate()` engine as a single-harness call and writes its own transcript to its own `~/.pi/agent/delegate/outputs/<harness>/`. Runs are launched together and execute in parallel, bounded by `maxConcurrent` (default `4`, one slot per supported harness) — a run beyond the cap queues for a free slot instead of failing, and the cap is enforced across pi processes, not just this one. **This means fan-out spend is genuinely simultaneous**: with the default cap, a 4-harness fan-out can bill all four at once instead of one after another — budget accordingly (`maxBudgetUsd` still applies per run).
58
+ - The synthesized report is always ordered by the resolved harness list (e.g. `claude, codex, opencode`), regardless of which harness actually finishes first — it groups each harness's metrics + output and a total spend line (unknown-cost runs called out separately, same as `/delegate status`), assembled mechanically, not by asking a model to summarize.
59
+ - A single-harness call (`harness: "claude"`, or omitted) behaves exactly as before, including the concurrency guard: it still fails fast with "another delegate run is already in progress" at capacity rather than queueing. Fan-out is opt-in by typing `all`/a list.
60
+ - `/delegate all …` batches successful completions into one notification instead of one per harness; a failure is never delayed or folded into the batch — it surfaces immediately.
61
+ - In the TUI, a fan-out shows **one overlay for the whole run** — a compact row per harness (spinner/✓/✗, elapsed, current tool activity) — rather than one popup per harness or an interleaved feed you can't attribute to a harness:
62
+ ```
63
+ ╭─ ⠋ delegate all · review · 1/4 · ⏱ 0:42──────────────────╮
64
+ │ ✓ claude 0:38 done │
65
+ │ ⠹ codex 0:41 ▶ Bash: bun test │
66
+ │ ⠹ opencode 0:12 ✍ Looking at the auth middleware next… │
67
+ │ … amp queued │
68
+ │ esc cancel all · m minimize │
69
+ ╰────────────────────────────────────────────────────────────╯
70
+ ```
71
+ Double-ESC cancels every in-flight (and still-queued) run at once; `m` minimizes; the status bar chip shows aggregate state across every status (e.g. `● 1✓ 1✗ 1▶ 1…` — done, failed, running, queued; zero counts are omitted, so it reads `● 4▶` while all four are in flight). A harness that fails keeps its failure reason on its row rather than blanking, so the overlay still says *why*. Single-harness runs keep the original one-run overlay unchanged.
72
+
46
73
  ## Harnesses
47
74
 
48
75
  | Harness | Binary | Permission mapping | Notes |
49
76
  | --- | --- | --- | --- |
50
- | `claude` | `claude` | `readonly→plan`, `edit→acceptEdits`, `danger→bypassPermissions` | Full stream-json, cost + context% |
51
- | `codex` | `codex` | `readonly→read-only`, `edit→workspace-write`, `danger→danger-full-access` | `codex exec --json`, best-effort JSONL |
52
- | `opencode` | `opencode` | `readonly→read-only`, `edit→allow-edit`, `danger→danger` | `opencode run --format json` |
53
- | `amp` | `amp` (`omp` alias) | `readonly→read-only`, `edit→workspace`, `danger→danger` | `amp --output jsonl` |
77
+ | `claude` | `claude` | `readonly→plan`, `edit→acceptEdits`, `danger→bypassPermissions` | Full stream-json, cost + context%. Schema-verified against Claude Code 2.1.247. |
78
+ | `codex` | `codex` | `readonly→read-only`, `edit→workspace-write`, `danger→danger-full-access` | `codex exec --json`. Schema-verified against codex-cli 0.149.1; cost is always unmeasured (`null`) on ChatGPT-plan auth. |
79
+ | `opencode` | `opencode` | `readonly→read-only`, `edit→allow-edit`, `danger→danger` | `opencode run --format json`. Schema-verified against opencode 1.18.16. |
80
+ | `amp` | `amp` (`omp` alias) | `readonly→read-only`, `edit→workspace`, `danger→danger` | `<binary> -p --mode json`, resolves whichever of `amp`/`omp` is actually on `PATH`. Schema-verified against omp 17.2.9 (Sourcegraph's real Amp CLI is unverified). |
54
81
 
55
82
  Detect availability: `delegate` checks `harness --version` at startup; missing harnesses hint install instructions.
56
83
 
@@ -69,19 +96,24 @@ Each mode is a markdown template with frontmatter:
69
96
 
70
97
  ```yaml
71
98
  ---
72
- name: review
73
- description: Code review of a scope. Read-only.
74
- permission: readonly # normalized: readonly | edit | danger
99
+ name: implement
100
+ description: Implement a task with file edits. Runs checks.
101
+ permission: edit # normalized: readonly | edit | danger
75
102
  model: sonnet # or gpt-5 for codex, etc. Aliases resolved via modelAliases
76
- defaultTask: Review the current git diff
77
- defaultScope: diff
103
+ verify: bun test # optional — host-run check after the harness exits
78
104
  ---
79
- You are a senior code reviewer delegated by the pi coding agent.
105
+ You are a senior engineer delegated by the pi coding agent.
80
106
  ...
81
107
  ```
82
108
 
83
109
  **Native escape hatch:** if you need a harness-specific permission not covered by the normalized set, use the native key (`permissionMode: dontAsk`, `sandbox: ...`) — it overrides `permission` for that harness.
84
110
 
111
+ **Verify:** `verify` is a shell command run **on the host** (never handed to the harness) right after it exits — e.g. `verify: bun test` on an `implement`/`docs`/`general` template turns "the harness says it's done" into an actual pass/fail. It's report-only: a failing verify is appended as its own section in the transcript and injected report, and surfaced in the tool result's `details.verify` (`{command, exitCode, ok}`), but it never changes whether the run itself is reported as an error — that stays whatever the harness reported. No template ships one by default — there's no universally-correct check command, so nothing is invented for you.
112
+
113
+ - **Sources, deliberately limited:** a verify command can only come from a template's `verify:` frontmatter, or a human typing `/delegate --verify="<cmd>"` (quotes needed for multi-word commands) — the call-level value wins over the template's. **It is not a parameter on the `delegate` tool** — that's on purpose, not an oversight: a tool param is set by the model, and the model's context includes repo content and delegated-harness output, both of which an attacker could influence, so a model-settable verify command would be a prompt-injection → arbitrary-host-command path. A model that wants verification simply picks a template that declares one.
114
+ - **Never runs on a `readonly` template.** `readonly` (`review`/`plan`/`security-audit`) guarantees no execution or modification — a verify command riding along on one would quietly break that guarantee. If a `readonly` template (or override) has a `verify` configured, it's recorded as skipped (`### Verify: \`cmd\`` / `⊘ skipped (readonly run)`) rather than run, and never silently dropped.
115
+ - A project-local template's `verify` command is gated by the same trust check (`PI_TRUSTED=1` / `.pi/trusted`) as the rest of the template.
116
+
85
117
  **Template sources (later wins):**
86
118
 
87
119
  - `templates/shared/*.md` — portable prompt bodies
@@ -127,7 +159,7 @@ In `~/.pi/agent/settings.json`:
127
159
  "maxBudgetUsd": 3,
128
160
  "autoDelegateHints": false,
129
161
  "modelAliases": { "economy": "haiku", "balanced": "sonnet", "max": "opus" },
130
- "maxConcurrent": 1,
162
+ "maxConcurrent": 4,
131
163
  "maxTranscripts": 100,
132
164
  "harnesses": {
133
165
  "claude": { "model": "sonnet" },
@@ -141,7 +173,7 @@ In `~/.pi/agent/settings.json`:
141
173
  Legacy `claudeDelegate` is auto-migrated into `delegate.harnesses.claude` (deprecated).
142
174
 
143
175
  - `modelAliases` — templates may use `economy|balanced|max` or any alias; resolution: call → template → harness → global.
144
- - `maxConcurrent` — cap overlapping runs (default 1 global; may be `{global:1, perHarness:{claude:1}}`).
176
+ - `maxConcurrent` — cap overlapping runs (default **`4`**, one slot per supported harness; may be `{global:4, perHarness:{claude:1}}`). Enforced across pi processes, not just the current one — a file-based registry under `~/.pi/agent/delegate/runs/` tracks active runs, so the slots available to you also depend on any other pi session running `delegate`. This is a **genuinely parallel** spend cap now, not just a "don't overlap" guard: a single-harness `/delegate` call still fails fast (`another delegate run is already in progress`) the moment it's at capacity, but `/delegate all …` fan-out queues for a free slot instead and can run up to `maxConcurrent` harnesses at once — meaning up to that many harnesses billing simultaneously. Lower it if you want fan-out to stay sequential/cheaper (`"maxConcurrent": 1` restores the old one-at-a-time behavior for everything, single runs included).
145
177
  - `maxTranscripts` — oldest transcripts pruned beyond this count per harness (`0` disables).
146
178
 
147
179
  `autoDelegateHints` is off by default — no system-prompt bias. When `true`, explicit markers (`@harness`, `with codex`, `delegate … to claude`) and imperative review/plan phrasing append a hint.
@@ -150,6 +182,8 @@ Legacy `claudeDelegate` is auto-migrated into `delegate.harnesses.claude` (depre
150
182
 
151
183
  Every run records in details + transcript: harness, mode, permission (normalized + native), cost, tokens (input/output/cache), context% (prompt ÷ window), model, turns, duration, TTFT, stop reason, session id. Token + cost feed pi's `Usage`.
152
184
 
185
+ Claude reports turns and cost on every run; Codex/OpenCode/Amp don't always. An unmeasured turn count or cost renders as `—`/`n/a` (never `0`/`$0.000`) everywhere it's shown — the transcript header, `formatMetrics`, tool results, and `/delegate history` — so an unmeasured run is never mistaken for a free one. `/delegate status` shows a per-harness spend rollup (e.g. `$1.234 over 12 run(s) (3 unknown)`); runs with unknown cost are counted separately rather than folded into the total as `$0`.
186
+
153
187
  ## Security model
154
188
 
155
189
  - `readonly` — no edits (e.g. Claude `plan`, Codex `read-only`).
@@ -1,6 +1,6 @@
1
1
  import { readdirSync, rmSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import type { ActivityEvent } from './harnesses/types.ts';
3
+ import type { ActivityEvent, NormalizedPermission } from './harnesses/types.ts';
4
4
 
5
5
  function truncate(s: string, max: number): string {
6
6
  return s.length > max ? `${s.slice(0, max - 1)}…` : s;
@@ -13,18 +13,18 @@ export function safeSegmentName(name: string): string {
13
13
  }
14
14
 
15
15
  export interface MetricsInput {
16
- numTurns: number;
17
- totalCostUsd: number;
16
+ numTurns: number | null;
17
+ totalCostUsd: number | null;
18
18
  promptTokens: number;
19
19
  contextPercent: number | null;
20
20
  durationMs: number | null;
21
21
  }
22
22
 
23
- /** Compact run summary: `3 turn(s) · $0.54 · 62k tok · 6.2% ctx · 12s`. */
23
+ /** Compact run summary: `3 turn(s) · $0.54 · 62k tok · 6.2% ctx · 12s`. Unknown numTurns/cost render as `—`. */
24
24
  export function formatMetrics(m: MetricsInput): string {
25
25
  const parts: Array<string | null> = [
26
- `${m.numTurns} turn(s)`,
27
- `$${m.totalCostUsd.toFixed(3)}`,
26
+ m.numTurns !== null ? `${m.numTurns} turn(s)` : '— turn(s)',
27
+ m.totalCostUsd !== null ? `$${m.totalCostUsd.toFixed(3)}` : '$—',
28
28
  m.promptTokens > 0 ? `${Math.round(m.promptTokens / 1000)}k tok` : null,
29
29
  typeof m.contextPercent === 'number' ? `${m.contextPercent.toFixed(1)}% ctx` : null,
30
30
  typeof m.durationMs === 'number' && m.durationMs !== null ? `${(m.durationMs / 1000).toFixed(0)}s` : null,
@@ -32,15 +32,15 @@ export function formatMetrics(m: MetricsInput): string {
32
32
  return parts.filter((p): p is string => Boolean(p)).join(' · ');
33
33
  }
34
34
 
35
- /** Parse the metadata header of a transcript file (without loading the whole body). */
35
+ /** Parse the metadata header of a transcript file (without loading the whole body). `cost` is null when unknown. */
36
36
  export function parseTranscriptMeta(head: string): {
37
37
  mode: string;
38
- cost: number;
38
+ cost: number | null;
39
39
  sessionId: string | null;
40
40
  harness: string | null;
41
41
  } {
42
42
  let mode = 'delegate';
43
- let cost = 0;
43
+ let cost: number | null = null;
44
44
  let sessionId: string | null = null;
45
45
  let harness: string | null = null;
46
46
  const mm = /^# Delegated (?:Claude|Harness) run — (.+)$/m.exec(head);
@@ -58,6 +58,117 @@ export function parseTranscriptMeta(head: string): {
58
58
  return { mode, cost, sessionId, harness };
59
59
  }
60
60
 
61
+ /** One history entry's harness + cost, for spend aggregation. */
62
+ export interface SpendEntry {
63
+ harness: string;
64
+ cost: number | null;
65
+ }
66
+
67
+ /** Total cost, run count and unknown-cost run count — either per harness or overall. */
68
+ export interface HarnessSpend {
69
+ totalCostUsd: number;
70
+ runs: number;
71
+ unknownRuns: number;
72
+ }
73
+
74
+ /** Roll up cost across history entries, per harness and overall. Runs with unknown cost are counted
75
+ * separately rather than silently treated as $0. Pure — testable without the TUI. */
76
+ export function aggregateSpend(entries: SpendEntry[]): {
77
+ byHarness: Record<string, HarnessSpend>;
78
+ total: HarnessSpend;
79
+ } {
80
+ const byHarness: Record<string, HarnessSpend> = {};
81
+ const total: HarnessSpend = { totalCostUsd: 0, runs: 0, unknownRuns: 0 };
82
+ for (const e of entries) {
83
+ if (!byHarness[e.harness]) byHarness[e.harness] = { totalCostUsd: 0, runs: 0, unknownRuns: 0 };
84
+ const h = byHarness[e.harness];
85
+ h.runs++;
86
+ total.runs++;
87
+ if (e.cost === null) {
88
+ h.unknownRuns++;
89
+ total.unknownRuns++;
90
+ } else {
91
+ h.totalCostUsd += e.cost;
92
+ total.totalCostUsd += e.cost;
93
+ }
94
+ }
95
+ return { byHarness, total };
96
+ }
97
+
98
+ /** Format a `HarnessSpend` as `$1.234 over 12 run(s) (3 unknown)`. */
99
+ export function formatSpend(s: HarnessSpend): string {
100
+ const unknown = s.unknownRuns > 0 ? ` (${s.unknownRuns} unknown)` : '';
101
+ return `$${s.totalCostUsd.toFixed(3)} over ${s.runs} run(s)${unknown}`;
102
+ }
103
+
104
+ /**
105
+ * Host-run verification (e.g. `bun test`) result — evidence only, never flips a run's `isError`.
106
+ * `skipped` is set (with a human-readable reason) when a verify command was configured but
107
+ * deliberately not executed — e.g. a `readonly` permission tier — instead of `exitCode`/`ok`
108
+ * describing a real run. Never silently dropped: a skip is still recorded and reported.
109
+ */
110
+ export interface VerifyResult {
111
+ command: string;
112
+ /** `null` when the command was never run (see `skipped`). */
113
+ exitCode: number | null;
114
+ ok: boolean;
115
+ /** Combined stdout+stderr, capped to the last `maxTail` chars. Empty when skipped. */
116
+ outputTail: string;
117
+ /** Reason the command was not run, e.g. `"readonly run"`. Absent when it actually ran. */
118
+ skipped?: string;
119
+ }
120
+
121
+ const VERIFY_TAIL_MAX = 2000;
122
+
123
+ /** Which verify command applies for a run: an explicit per-call override wins over the template's. */
124
+ export function resolveVerifyCommand(
125
+ callOverride: string | undefined,
126
+ templateVerify: string | undefined,
127
+ ): string | undefined {
128
+ return callOverride || templateVerify || undefined;
129
+ }
130
+
131
+ /**
132
+ * Resolve the verify command AND decide whether it may actually run, given the run's effective
133
+ * permission. `readonly` templates guarantee no execution/modification — a verify command must
134
+ * never ride along on one (see AGENTS.md's Conventions), so `skip` is true whenever `permission`
135
+ * is `readonly`, regardless of who configured the command. Pure — testable without `pi.exec`.
136
+ */
137
+ export function resolveVerifyPlan(
138
+ callOverride: string | undefined,
139
+ templateVerify: string | undefined,
140
+ permission: NormalizedPermission,
141
+ ): { command: string; skip: boolean } | undefined {
142
+ const command = resolveVerifyCommand(callOverride, templateVerify);
143
+ if (!command) return undefined;
144
+ return { command, skip: permission === 'readonly' };
145
+ }
146
+
147
+ /** Build a `VerifyResult` from a raw exit code + combined output, capping the tail. Pure. */
148
+ export function buildVerifyResult(
149
+ command: string,
150
+ exitCode: number,
151
+ output: string,
152
+ maxTail = VERIFY_TAIL_MAX,
153
+ ): VerifyResult {
154
+ const outputTail = output.length > maxTail ? output.slice(-maxTail) : output;
155
+ return { command, exitCode, ok: exitCode === 0, outputTail };
156
+ }
157
+
158
+ /** A `VerifyResult` recording that a configured verify command was deliberately not run. */
159
+ export function skipVerifyResult(command: string, reason: string): VerifyResult {
160
+ return { command, exitCode: null, ok: false, outputTail: '', skipped: reason };
161
+ }
162
+
163
+ /** Markdown section for a verify result — report-only evidence, distinct from the harness's own isError. */
164
+ export function formatVerifySection(v: VerifyResult): string {
165
+ if (v.skipped) return `### Verify: \`${v.command}\`\n⊘ skipped (${v.skipped})`;
166
+ const lines = [`### Verify: \`${v.command}\``, v.ok ? `✓ exit ${v.exitCode}` : `✗ exit ${v.exitCode}`];
167
+ const tail = v.outputTail.trim();
168
+ if (tail) lines.push(...tail.split('\n').map(l => ` ${l}`));
169
+ return lines.join('\n');
170
+ }
171
+
61
172
  /** Build the markdown report content injected into the session on the next turn. */
62
173
  export function buildReportContent(opts: {
63
174
  harness?: string;
@@ -66,6 +177,7 @@ export function buildReportContent(opts: {
66
177
  body: string;
67
178
  file?: string;
68
179
  sessionId?: string;
180
+ verify?: VerifyResult;
69
181
  }): string {
70
182
  const harness = opts.harness ?? 'claude';
71
183
  const header = `## ${harness} ${opts.mode} (${opts.metrics})`;
@@ -75,7 +187,61 @@ export function buildReportContent(opts: {
75
187
  foot.push(
76
188
  `resume: \`/delegate --harness=${opts.harness} --resume=${opts.sessionId} <prompt>\` (or /${opts.harness} --resume=${opts.sessionId})`,
77
189
  );
78
- return [header, '', opts.body, foot.length > 0 ? `\n_${foot.join(' · ')}_` : ''].join('\n');
190
+ const verifySection = opts.verify ? `\n${formatVerifySection(opts.verify)}\n` : '';
191
+ return [header, '', opts.body, verifySection, foot.length > 0 ? `\n_${foot.join(' · ')}_` : ''].join('\n');
192
+ }
193
+
194
+ /** One harness's outcome within a fan-out, for the synthesized comparison report. */
195
+ export interface FanoutRunSummary {
196
+ harness: string;
197
+ ok: boolean;
198
+ /** `formatMetrics` output — omitted (and `error` used instead) when the run failed to complete. */
199
+ metrics?: string;
200
+ cost: number | null;
201
+ body?: string;
202
+ file?: string;
203
+ sessionId?: string;
204
+ error?: string;
205
+ verify?: VerifyResult;
206
+ }
207
+
208
+ /**
209
+ * Order fan-out results by the originally resolved harness list rather than completion order.
210
+ * Concurrent fan-out runs finish in whatever order their harnesses happen to complete; this keeps
211
+ * `buildFanoutReport`'s output deterministic regardless of which one lands first. Entries with a
212
+ * harness not present in `order` are dropped (shouldn't happen — every result comes from `order`).
213
+ */
214
+ export function orderFanoutResults<T extends { harness: string }>(order: readonly string[], results: T[]): T[] {
215
+ const byHarness = new Map(results.map(r => [r.harness, r]));
216
+ return order.map(h => byHarness.get(h)).filter((r): r is T => r !== undefined);
217
+ }
218
+
219
+ /**
220
+ * Mechanically assemble one comparison report across all fan-out runs — no second model call.
221
+ * Groups per-harness metrics/output and rolls up total spend via `aggregateSpend`/`formatSpend`.
222
+ * Header-free — callers wrap this body with their own `## <label> (...)` header.
223
+ */
224
+ export function buildFanoutReport(opts: { runs: FanoutRunSummary[]; skipped: string[]; unknown: string[] }): string {
225
+ const lines: string[] = [];
226
+ if (opts.unknown.length > 0) lines.push(`_unknown harness(es), skipped: ${opts.unknown.join(', ')}_`);
227
+ if (opts.skipped.length > 0) lines.push(`_not installed, skipped: ${opts.skipped.join(', ')}_`);
228
+ if (opts.unknown.length > 0 || opts.skipped.length > 0) lines.push('');
229
+
230
+ const spend = aggregateSpend(opts.runs.map(r => ({ harness: r.harness, cost: r.cost })));
231
+ lines.push(`**Total spend:** ${formatSpend(spend.total)}`, '');
232
+
233
+ for (const r of opts.runs) {
234
+ lines.push(`### ${r.harness}${r.ok ? '' : ' — failed'}`);
235
+ if (r.ok) {
236
+ lines.push(r.metrics ?? '', '', r.body ?? '(empty)');
237
+ } else {
238
+ lines.push(`error: ${r.error ?? 'unknown error'}`);
239
+ }
240
+ if (r.verify) lines.push('', formatVerifySection(r.verify));
241
+ if (r.file) lines.push('', `_transcript: ${r.file}_`);
242
+ lines.push('');
243
+ }
244
+ return lines.join('\n').trimEnd();
79
245
  }
80
246
 
81
247
  /** Legacy wrapper for compat */
@@ -124,16 +290,54 @@ export function formatToolUse(name: string, input: Record<string, unknown>): str
124
290
  return first ? `${name}: ${truncate(first, 90)}` : name;
125
291
  }
126
292
 
293
+ /**
294
+ * Tracks pending `tool_input` array indices by id, so a later `tool_result` can be matched to
295
+ * the row it actually belongs to instead of always landing on the last row.
296
+ *
297
+ * A harness emits N `tool_input` events followed by N `tool_result` events for a parallel
298
+ * tool-call batch, so "attach the result to the last entry" stamps every mark on the last row.
299
+ * Harnesses that don't carry an id fall back to that last-entry behavior via `resolve`.
300
+ * Shared by the transcript log builder and the live-feed builders (which also splice old
301
+ * entries off the front — `shift` keeps pending indices correct after that).
302
+ */
303
+ export class ToolCallIndex {
304
+ private pending = new Map<string, number>();
305
+
306
+ set(id: string | undefined, index: number): void {
307
+ if (id) this.pending.set(id, index);
308
+ }
309
+
310
+ /** Index to mark, or -1 when an id was given but has no matching pending entry (don't mis-attribute). */
311
+ resolve(id: string | undefined, fallbackIndex: number): number {
312
+ if (id === undefined) return fallbackIndex;
313
+ const idx = this.pending.get(id);
314
+ if (idx === undefined) return -1;
315
+ this.pending.delete(id);
316
+ return idx;
317
+ }
318
+
319
+ /** Adjust pending indices after removing `count` entries from the front of the backing array. */
320
+ shift(count: number): void {
321
+ for (const [id, idx] of this.pending) {
322
+ const next = idx - count;
323
+ if (next < 0) this.pending.delete(id);
324
+ else this.pending.set(id, next);
325
+ }
326
+ }
327
+ }
328
+
127
329
  /** Compact per-line activity log for the transcript (tool_input + results only). */
128
330
  export function collectActivityLog(events: ActivityEvent[]): string[] {
129
331
  const log: string[] = [];
332
+ const index = new ToolCallIndex();
130
333
  for (const ev of events) {
131
334
  if (ev.kind === 'tool_input') {
132
335
  log.push(`▶ ${formatToolUse(ev.name, ev.input)}`);
336
+ index.set(ev.id, log.length - 1);
133
337
  } else if (ev.kind === 'tool_result') {
134
- const last = log.length - 1;
135
- if (last >= 0 && log[last].startsWith('▶')) {
136
- log[last] += ev.isError ? ' ✗ error' : ' ✓';
338
+ const idx = index.resolve(ev.id, log.length - 1);
339
+ if (idx >= 0 && log[idx].startsWith('▶')) {
340
+ log[idx] += ev.isError ? ' ✗ error' : ' ✓';
137
341
  }
138
342
  }
139
343
  }
@@ -152,8 +356,8 @@ export function buildTranscript(
152
356
  cwd: string;
153
357
  sessionId: string | null;
154
358
  resumed: boolean;
155
- numTurns: number;
156
- totalCostUsd: number;
359
+ numTurns: number | null;
360
+ totalCostUsd: number | null;
157
361
  isError: boolean;
158
362
  stopReason: string | null;
159
363
  durationMs: number | null;
@@ -167,6 +371,8 @@ export function buildTranscript(
167
371
  contextWindow: number | null;
168
372
  activityLog: string[];
169
373
  output: string;
374
+ /** Host-run verification result, when a `verify` command was configured for this run. */
375
+ verify?: VerifyResult;
170
376
  } & Record<string, unknown>,
171
377
  ): string {
172
378
  const harness = (opts.harness as string | undefined) ?? 'claude';
@@ -215,7 +421,7 @@ export function buildTranscript(
215
421
  `- model: ${opts.model ?? 'default'}`,
216
422
  `- cwd: ${opts.cwd}`,
217
423
  `- session: ${opts.sessionId ?? 'n/a'}${opts.resumed ? ' (resumed)' : ''}`,
218
- `- turns: ${opts.numTurns} · cost: $${opts.totalCostUsd.toFixed(4)} · isError: ${opts.isError}`,
424
+ `- turns: ${opts.numTurns ?? 'n/a'} · cost: ${opts.totalCostUsd !== null ? `$${opts.totalCostUsd.toFixed(4)}` : 'n/a'} · isError: ${opts.isError}`,
219
425
  `- tokens: ${tokens ?? 'n/a'}`,
220
426
  `- context: ${context ?? 'n/a'}`,
221
427
  `- duration: ${duration ?? 'n/a'}`,
@@ -227,6 +433,7 @@ export function buildTranscript(
227
433
  '## Output',
228
434
  opts.output || '(empty)',
229
435
  '',
436
+ ...(opts.verify ? [formatVerifySection(opts.verify), ''] : []),
230
437
  ].join('\n');
231
438
  }
232
439
 
@@ -16,32 +16,47 @@ export interface DelegateCommandArgs {
16
16
  sessionId?: string;
17
17
  /** GitHub PR number/URL to review (--pr=). */
18
18
  pr?: string;
19
+ /** Host-run verification command override (--verify=); takes precedence over the template's. */
20
+ verify?: string;
19
21
  }
20
22
 
21
23
  export type ClaudeCommandArgs = DelegateCommandArgs;
22
24
 
23
25
  const KNOWN_HARNESSES = new Set(['claude', 'codex', 'opencode', 'amp', 'omp']);
24
26
 
27
+ /** True when `word` is `all`, a single known harness/alias, or a comma-separated list of them. */
28
+ function looksLikeHarnessSpec(word: string, knownHarnesses: ReadonlySet<string>): boolean {
29
+ const lower = word.toLowerCase();
30
+ if (lower === 'all' || knownHarnesses.has(lower)) return true;
31
+ const parts = lower.split(',').filter(Boolean);
32
+ return parts.length > 1 && parts.every(p => knownHarnesses.has(p));
33
+ }
34
+
25
35
  export function parseDelegateCommand(
26
36
  raw: string,
27
37
  knownModes: ReadonlySet<string>,
28
38
  knownHarnesses: ReadonlySet<string> = KNOWN_HARNESSES,
29
39
  ): DelegateCommandArgs {
30
40
  const flags: Record<string, string> = {};
31
- const rest = raw.replace(/--([a-zA-Z-]+)=(\S+)/g, (_m, k: string, v: string) => {
32
- flags[k] = v;
33
- return '';
34
- });
41
+ // supports quoted values ("…"/'…') so multi-word flags like --verify="bun test" survive intact
42
+ const rest = raw.replace(
43
+ /--([a-zA-Z-]+)=(?:"([^"]*)"|'([^']*)'|(\S+))/g,
44
+ (_m, k: string, dq: string | undefined, sq: string | undefined, bare: string | undefined) => {
45
+ flags[k] = dq ?? sq ?? bare ?? '';
46
+ return '';
47
+ },
48
+ );
35
49
 
36
50
  let harness = flags.harness?.toLowerCase();
37
51
  let mode = flags.mode;
38
52
  let task = rest.trim();
39
53
 
40
- // First word handling: harness, mode, or both
54
+ // First word handling: harness (single, `all`, or comma list), mode, or both
41
55
  const words = task.split(/\s+/).filter(Boolean);
42
56
  let idx = 0;
43
- if (!harness && words[idx] && knownHarnesses.has(words[idx].toLowerCase())) {
57
+ if (!harness && words[idx] && looksLikeHarnessSpec(words[idx], knownHarnesses)) {
44
58
  harness = words[idx].toLowerCase();
59
+ // single-name alias normalization only — a list/`all` is resolved later by resolveHarnessList
45
60
  if (harness === 'omp') harness = 'amp';
46
61
  idx++;
47
62
  }
@@ -62,6 +77,7 @@ export function parseDelegateCommand(
62
77
  }
63
78
  if (flags.resume) out.sessionId = flags.resume;
64
79
  if (flags.pr) out.pr = flags.pr;
80
+ if (flags.verify) out.verify = flags.verify;
65
81
  return out;
66
82
  }
67
83
 
@@ -69,6 +85,67 @@ export function parseClaudeCommand(raw: string, knownModes: ReadonlySet<string>)
69
85
  return parseDelegateCommand(raw, knownModes);
70
86
  }
71
87
 
88
+ /** True when a `harness` field selects more than one harness: `all` or a comma-separated list. */
89
+ export function isFanoutSpec(harness: string | undefined): boolean {
90
+ if (!harness) return false;
91
+ const lower = harness.trim().toLowerCase();
92
+ return lower === 'all' || lower.includes(',');
93
+ }
94
+
95
+ export interface HarnessListResolution {
96
+ /** Canonical harness names to run, in request order, deduped. */
97
+ resolved: string[];
98
+ /** Requested names that don't match any known harness or alias. */
99
+ unknown: string[];
100
+ /** Known harnesses that were requested/selected by `all` but aren't detected as installed. */
101
+ skipped: string[];
102
+ }
103
+
104
+ /**
105
+ * Resolve a `harness` field (`all` or a comma-separated list of names/aliases) into the
106
+ * canonical harness names a fan-out should actually run. Pure — detection results and the
107
+ * known-harness/alias lookups are passed in, no I/O happens here.
108
+ *
109
+ * `all` resolves to every *detected* harness (skipping uninstalled ones). An explicit list is
110
+ * validated against `isKnown`/`aliasOf` and also filtered by detection, so a named-but-uninstalled
111
+ * harness is reported (via `skipped`) instead of failing the whole run.
112
+ */
113
+ export function resolveHarnessList(
114
+ spec: string,
115
+ opts: {
116
+ knownHarnesses: readonly string[];
117
+ aliasOf: (name: string) => string;
118
+ isKnown: (name: string) => boolean;
119
+ detection: Readonly<Record<string, { ok: boolean }>>;
120
+ },
121
+ ): HarnessListResolution {
122
+ const lower = spec.trim().toLowerCase();
123
+ const isAll = lower === 'all';
124
+ const requested = isAll
125
+ ? opts.knownHarnesses
126
+ : lower
127
+ .split(',')
128
+ .map(s => s.trim())
129
+ .filter(Boolean);
130
+
131
+ const resolved: string[] = [];
132
+ const unknown: string[] = [];
133
+ const skipped: string[] = [];
134
+ const seen = new Set<string>();
135
+ for (const raw of requested) {
136
+ if (!isAll && !opts.isKnown(raw)) {
137
+ unknown.push(raw);
138
+ continue;
139
+ }
140
+ const canon = opts.aliasOf(raw);
141
+ if (seen.has(canon)) continue;
142
+ seen.add(canon);
143
+ if (opts.detection[canon]?.ok) resolved.push(canon);
144
+ else skipped.push(canon);
145
+ }
146
+ return { resolved, unknown, skipped };
147
+ }
148
+
72
149
  /**
73
150
  * Apply template defaults when the prompt is empty.
74
151
  */