pi-harness-delegate 0.2.2 → 0.3.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.
@@ -30,26 +30,55 @@ import {
30
30
  } from '@earendil-works/pi-tui';
31
31
  import { Type } from 'typebox';
32
32
  import {
33
+ aggregateSpend,
34
+ buildFanoutReport,
33
35
  buildReportContent,
34
36
  buildTranscript,
37
+ buildVerifyResult,
35
38
  collectActivityLog,
39
+ type FanoutRunSummary,
36
40
  formatMetrics,
41
+ formatSpend,
37
42
  formatToolUse,
38
43
  parseTranscriptMeta,
39
44
  pruneOutputs,
45
+ resolveVerifyPlan,
40
46
  safeSegmentName,
47
+ skipVerifyResult,
48
+ ToolCallIndex,
49
+ type VerifyResult,
41
50
  } from './activity.ts';
42
- import { parseDelegateCommand, resolveDefaults } from './command.ts';
43
- import { outputsDir as getOutputsDir, legacyOutputsDir, loadConfig, resolveModelForHarness } from './config.ts';
44
- import { ALIASES, detectAll, getHarness, HARNESS_NAMES, isKnownHarness } from './harnesses/registry.ts';
51
+ import { isFanoutSpec, parseDelegateCommand, resolveDefaults, resolveHarnessList } from './command.ts';
52
+ import {
53
+ type DelegateConfig,
54
+ outputsDir as getOutputsDir,
55
+ legacyOutputsDir,
56
+ loadConfig,
57
+ resolveModelForHarness,
58
+ } from './config.ts';
59
+ import {
60
+ ALIASES,
61
+ detectAll,
62
+ getHarness,
63
+ HARNESS_NAMES,
64
+ isKnownHarness,
65
+ resolveHarnessName,
66
+ } from './harnesses/registry.ts';
45
67
  import type { ActivityEvent, NormalizedPermission } from './harnesses/types.ts';
46
68
 
47
69
  import { delegationHint, stripMarker } from './hint.ts';
70
+ import { NotifyBatcher } from './notify.ts';
48
71
  import { type FeedEntry, progressWindow } from './progress.ts';
72
+ import { acquireRun, countActiveRuns, releaseRun } from './run-registry.ts';
49
73
  import { runHarness } from './runner.ts';
50
74
  import { type DelegateTemplate, loadTemplates } from './templates.ts';
51
75
  import { mapClaudeUsage } from './usage.ts';
52
76
 
77
+ /** Render a possibly-unknown cost — `null` means the harness didn't report one, not a measured $0. */
78
+ function formatCost(cost: number | null): string {
79
+ return cost !== null ? `$${cost.toFixed(3)}` : '$—';
80
+ }
81
+
53
82
  interface DelegateOptions {
54
83
  harness?: string;
55
84
  task: string;
@@ -60,11 +89,51 @@ interface DelegateOptions {
60
89
  allowDangerous?: boolean;
61
90
  sessionId?: string;
62
91
  pr?: string;
92
+ /**
93
+ * Host-run verification command override — takes precedence over the template's `verify`
94
+ * frontmatter. Internal engine option only, not exposed on the `delegate` tool's schema — see
95
+ * the trust-model note on `runVerify` below for why.
96
+ */
97
+ verify?: string;
63
98
  onStream?: (text: string) => void;
64
99
  onActivity?: (ev: ActivityEvent) => void;
65
100
  signal?: AbortSignal;
66
101
  }
67
102
 
103
+ /** Verify commands run on the host after the harness exits — bounded independent of harness timeoutMs. */
104
+ const VERIFY_TIMEOUT_MS = 5 * 60_000;
105
+
106
+ /**
107
+ * Run a verify command in-process on the host (never delegated to the harness). Report-only —
108
+ * callers must not let this flip a run's `isError`.
109
+ *
110
+ * Trust model: a verify command can only come from two places — on-disk template frontmatter
111
+ * (project-local templates are already behind `isTrusted()`) or a human typing `/delegate
112
+ * --verify=<cmd>` at the CLI. It is deliberately **not** a `delegate` tool parameter: a tool
113
+ * param is set by the model, whose context includes repo content and delegated-harness output —
114
+ * both attacker-influenceable, so a model-settable `verify` would be a prompt-injection ->
115
+ * arbitrary-host-command path (e.g. injected text in a reviewed file steering the parent agent
116
+ * into `delegate({verify: "curl ... | sh"})`). A model that wants verification selects a
117
+ * template that declares one instead.
118
+ *
119
+ * `resolveVerifyPlan` additionally never lets a verify command run on a `readonly` permission —
120
+ * `readonly` guarantees no execution/modification, and a verify command riding along on one
121
+ * would silently break that guarantee (a permission-tier bypass), independent of how trusted its
122
+ * source is. See the matching Conventions entry in AGENTS.md.
123
+ *
124
+ * Runs via `sh -c` (not a fixed binary+argv) so compound commands like `bun test && bun run
125
+ * lint` work — safe only because of the source/permission restrictions above, not because the
126
+ * command itself is sanitized.
127
+ */
128
+ async function runVerify(pi: ExtensionAPI, cwd: string, command: string): Promise<VerifyResult> {
129
+ try {
130
+ const res = await pi.exec('sh', ['-c', command], { cwd, timeout: VERIFY_TIMEOUT_MS });
131
+ return buildVerifyResult(command, res.code, `${res.stdout}${res.stderr}`);
132
+ } catch (err) {
133
+ return buildVerifyResult(command, 1, err instanceof Error ? err.message : String(err));
134
+ }
135
+ }
136
+
68
137
  const activeRuns = new Map<string, number>();
69
138
  let globalActiveRuns = 0;
70
139
 
@@ -160,7 +229,7 @@ interface HistoryEntry {
160
229
  file: string;
161
230
  mode: string;
162
231
  harness: string;
163
- cost: number;
232
+ cost: number | null;
164
233
  sessionId: string | null;
165
234
  mtime: number;
166
235
  }
@@ -172,7 +241,7 @@ function readHistory(dir: string, harness: string): HistoryEntry[] {
172
241
  .map(f => {
173
242
  const file = join(dir, f);
174
243
  let mode = 'delegate';
175
- let cost = 0;
244
+ let cost: number | null = null;
176
245
  let sessionId: string | null = null;
177
246
  try {
178
247
  const meta = parseTranscriptMeta(readFileSync(file, 'utf8').slice(0, 2000));
@@ -205,11 +274,11 @@ function readAllHistory(): HistoryEntry[] {
205
274
  }
206
275
  // also legacy dir for migration display
207
276
  try {
208
- const legacy = readdirSync(legacyOutputsDir()).filter(f => f.endsWith('.md'));
277
+ const legacy = readdirSync(legacyOutputsDir()).filter(f => f.endsWith('.md') && !f.includes('-partial'));
209
278
  for (const f of legacy) {
210
279
  const file = join(legacyOutputsDir(), f);
211
280
  let mode = 'delegate';
212
- let cost = 0;
281
+ let cost: number | null = null;
213
282
  let sessionId: string | null = null;
214
283
  try {
215
284
  const meta = parseTranscriptMeta(readFileSync(file, 'utf8').slice(0, 2000));
@@ -289,13 +358,13 @@ async function showHistory(ctx: ExtensionContext, harnessFilter?: string): Promi
289
358
  }
290
359
  if (!ctx.hasUI) {
291
360
  for (const e of entries)
292
- process.stdout.write(`${e.harness} ${e.mode} · $${e.cost.toFixed(3)} · ${e.sessionId ?? '-'}\n`);
361
+ process.stdout.write(`${e.harness} ${e.mode} · ${formatCost(e.cost)} · ${e.sessionId ?? '-'}\n`);
293
362
  return;
294
363
  }
295
364
  const entry = await ctx.ui.custom((tui, theme, _kb, done) => {
296
365
  const items: SelectItem[] = entries.map(e => ({
297
366
  value: e.file,
298
- label: `${e.harness} ${e.mode} · $${e.cost.toFixed(3)} · ${new Date(e.mtime).toISOString().slice(0, 16)}`,
367
+ label: `${e.harness} ${e.mode} · ${formatCost(e.cost)} · ${new Date(e.mtime).toISOString().slice(0, 16)}`,
299
368
  description: e.sessionId ? `session ${e.sessionId.slice(0, 8)}…` : undefined,
300
369
  }));
301
370
  const list = new SelectList(items, Math.min(items.length, 10), {
@@ -349,16 +418,26 @@ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promis
349
418
  try {
350
419
  templates = loadTemplates(ctx.cwd, h).size;
351
420
  } catch {}
352
- const active = activeRuns.get(h) ?? 0;
421
+ // cross-process count via the file registry, combined with the in-process counter as a fallback
422
+ const active = Math.max(activeRuns.get(h) ?? 0, countActiveRuns(h));
353
423
  const hint = !det.ok && det.hint ? ` ← ${det.hint}` : '';
354
424
  lines.push(
355
425
  `${h.padEnd(20)} ${bin.padEnd(8)} ${ok.padEnd(3)} ${ver.padEnd(20)} ${String(outputs).padEnd(8)} ${String(templates).padEnd(10)} ${active}${hint}`,
356
426
  );
357
427
  }
428
+ const historyEntries = harnessFilter ? readAllHistory().filter(e => e.harness === harnessFilter) : readAllHistory();
429
+ const spend = aggregateSpend(historyEntries.map(e => ({ harness: e.harness, cost: e.cost })));
430
+ lines.push('');
431
+ lines.push('spend:');
432
+ for (const h of harnessFilter ? allHarnesses : HARNESS_NAMES) {
433
+ const s = spend.byHarness[h];
434
+ lines.push(` ${h}: ${s ? formatSpend(s) : '$0.000 over 0 run(s)'}`);
435
+ }
436
+ if (!harnessFilter) lines.push(` total: ${formatSpend(spend.total)}`);
358
437
  if (!harnessFilter) {
359
438
  lines.push('');
360
439
  lines.push(
361
- `global active: ${globalActiveRuns} · aliases: ${
440
+ `global active: ${Math.max(globalActiveRuns, countActiveRuns())} · aliases: ${
362
441
  Object.entries(ALIASES)
363
442
  .map(([k, v]) => `${k}→${v}`)
364
443
  .join(', ') || '—'
@@ -426,6 +505,7 @@ async function delegate(
426
505
  details: Record<string, unknown>;
427
506
  result: import('./harnesses/types.ts').StreamedResult & { streamedText: string; harness: string };
428
507
  activityLog: string[];
508
+ verify?: VerifyResult;
429
509
  }> {
430
510
  const config = loadConfig();
431
511
  const harnessName = opts.harness ?? config.defaultHarness ?? 'claude';
@@ -444,10 +524,12 @@ async function delegate(
444
524
  const task = opts.task || template.defaultTask;
445
525
  if (!task) throw new Error(`delegate mode "${mode}" requires a task`);
446
526
 
447
- // concurrency guard
527
+ // concurrency guard — combines the file-based cross-process registry with the in-process
528
+ // counters as a fallback, so registry I/O failures never block a delegation.
448
529
  const maxGlobal = getMaxConcurrentGlobal();
449
- const perHarnessCount = activeRuns.get(harnessName) ?? 0;
450
- if (maxGlobal > 0 && globalActiveRuns >= maxGlobal)
530
+ const perHarnessCount = Math.max(activeRuns.get(harnessName) ?? 0, countActiveRuns(harnessName));
531
+ const globalCount = Math.max(globalActiveRuns, countActiveRuns());
532
+ if (maxGlobal > 0 && globalCount >= maxGlobal)
451
533
  throw new Error('another delegate run is already in progress (global limit)');
452
534
  // per-harness limit if configured as object
453
535
  const perHarnessLimit = (() => {
@@ -462,9 +544,11 @@ async function delegate(
462
544
  throw new Error(`another ${harnessName} run is already in progress`);
463
545
  activeRuns.set(harnessName, perHarnessCount + 1);
464
546
  globalActiveRuns++;
547
+ const runHandle = acquireRun(harnessName, mode);
465
548
  const release = () => {
466
- activeRuns.set(harnessName, (activeRuns.get(harnessName) ?? 1) - 1);
549
+ activeRuns.set(harnessName, Math.max(0, (activeRuns.get(harnessName) ?? 1) - 1));
467
550
  globalActiveRuns = Math.max(0, globalActiveRuns - 1);
551
+ releaseRun(runHandle);
468
552
  };
469
553
 
470
554
  let scopeText: string | null = opts.scope ?? null;
@@ -544,8 +628,8 @@ async function delegate(
544
628
  cwd: ctx.cwd,
545
629
  sessionId: null,
546
630
  resumed: Boolean(opts.sessionId),
547
- numTurns: 0,
548
- totalCostUsd: 0,
631
+ numTurns: null,
632
+ totalCostUsd: null,
549
633
  isError: true,
550
634
  stopReason: null,
551
635
  durationMs: null,
@@ -575,6 +659,16 @@ async function delegate(
575
659
  const contextPercent =
576
660
  promptTokens !== null && result.contextWindow ? (promptTokens / result.contextWindow) * 100 : null;
577
661
 
662
+ // Host-run post-hoc verification — report-only evidence, never flips `result.isError`. Never
663
+ // actually executes on a readonly permission (permission-tier bypass) — recorded as skipped
664
+ // instead of silently dropped. See the trust-model note on runVerify().
665
+ const verifyPlan = resolveVerifyPlan(opts.verify, template.verify, permission);
666
+ const verify = verifyPlan
667
+ ? verifyPlan.skip
668
+ ? skipVerifyResult(verifyPlan.command, 'readonly run')
669
+ : await runVerify(pi, ctx.cwd, verifyPlan.command)
670
+ : undefined;
671
+
578
672
  const file = saveOutput(
579
673
  harnessName,
580
674
  mode,
@@ -597,6 +691,7 @@ async function delegate(
597
691
  contextWindow: result.contextWindow,
598
692
  activityLog: collectActivityLog(activityEvents),
599
693
  output: result.result || result.streamedText,
694
+ verify,
600
695
  }),
601
696
  );
602
697
  pruneOutputs(outputsDirFor(harnessName), config.maxTranscripts);
@@ -626,9 +721,11 @@ async function delegate(
626
721
  contextPercent,
627
722
  promptTokens,
628
723
  usage: result.usage,
724
+ verify,
629
725
  },
630
726
  result,
631
727
  activityLog: collectActivityLog(activityEvents),
728
+ verify,
632
729
  };
633
730
  }
634
731
 
@@ -644,7 +741,15 @@ interface PendingReport {
644
741
  let pendingReport: PendingReport | null = null;
645
742
  function injectReport(
646
743
  _ctx: ExtensionContext,
647
- opts: { harness: string; mode: string; metrics: string; body: string; file?: string; sessionId?: string },
744
+ opts: {
745
+ harness: string;
746
+ mode: string;
747
+ metrics: string;
748
+ body: string;
749
+ file?: string;
750
+ sessionId?: string;
751
+ verify?: VerifyResult;
752
+ },
648
753
  ): void {
649
754
  pendingReport = {
650
755
  content: buildReportContent({
@@ -654,6 +759,7 @@ function injectReport(
654
759
  body: opts.body,
655
760
  file: opts.file,
656
761
  sessionId: opts.sessionId,
762
+ verify: opts.verify,
657
763
  }),
658
764
  details: {
659
765
  harness: opts.harness,
@@ -665,6 +771,190 @@ function injectReport(
665
771
  };
666
772
  }
667
773
 
774
+ interface ToolProgressUpdate {
775
+ content: { type: string; text: string }[];
776
+ details: { progress: number };
777
+ }
778
+
779
+ /**
780
+ * `delegate` tool params. Deliberately has no `verify` field — a tool param is model-controlled,
781
+ * and the model's context (repo content, delegated-harness output) is attacker-influenceable, so
782
+ * a model-settable verify command would be a prompt-injection -> arbitrary-host-command path.
783
+ * Verify only comes from on-disk template frontmatter or a human-typed `/delegate --verify=`.
784
+ */
785
+ interface DelegateToolParams {
786
+ harness?: string;
787
+ task: string;
788
+ mode?: string;
789
+ scope?: string;
790
+ model?: string;
791
+ maxBudgetUsd?: number;
792
+ allowDangerous?: boolean;
793
+ sessionId?: string;
794
+ pr?: string;
795
+ }
796
+
797
+ /** One `delegate()` call with the tool's live-feed progress reporting (`onUpdate`). Shared by the
798
+ * single-harness tool path and the fan-out loop — `labelPrefix` tags fan-out feed lines by harness. */
799
+ async function runDelegateForTool(
800
+ pi: ExtensionAPI,
801
+ ctx: ExtensionContext,
802
+ config: DelegateConfig,
803
+ callOpts: DelegateOptions,
804
+ signal: AbortSignal | undefined,
805
+ onUpdate: ((u: ToolProgressUpdate) => void) | undefined,
806
+ labelPrefix: string,
807
+ ): Promise<Awaited<ReturnType<typeof delegate>>> {
808
+ const feed: string[] = [];
809
+ const feedIndex = new ToolCallIndex();
810
+ let liveTail = '';
811
+ let thinkingChars = 0;
812
+ let lastPushAt = 0;
813
+ const THROTTLE_MS = 250;
814
+ const pushFeed = () => {
815
+ const now = Date.now();
816
+ if (now - lastPushAt < THROTTLE_MS) return;
817
+ lastPushAt = now;
818
+ const lines: string[] = [...feed.slice(-6)];
819
+ if (thinkingChars > 0)
820
+ lines.push(config.inspectThinking ? `💭 thinking… (${thinkingChars} chars)` : '💭 thinking…');
821
+ if (liveTail) lines.push(`✍ ${liveTail}`);
822
+ if (lines.length === 0) return;
823
+ onUpdate?.({
824
+ content: [{ type: 'text', text: lines.map(l => `${labelPrefix}${l}`).join('\n') }],
825
+ details: { progress: 0.5 },
826
+ });
827
+ };
828
+ return delegate(pi, ctx, {
829
+ ...callOpts,
830
+ signal,
831
+ onStream: t => {
832
+ liveTail = (liveTail + t).slice(-400);
833
+ pushFeed();
834
+ },
835
+ onActivity: ev => {
836
+ if (ev.kind === 'tool_input') {
837
+ feed.push(`▶ ${formatToolUse(ev.name, ev.input)}`);
838
+ feedIndex.set(ev.id, feed.length - 1);
839
+ if (feed.length > 40) {
840
+ const removed = feed.length - 40;
841
+ feed.splice(0, removed);
842
+ feedIndex.shift(removed);
843
+ }
844
+ } else if (ev.kind === 'tool_result') {
845
+ const idx = feedIndex.resolve(ev.id, feed.length - 1);
846
+ if (idx >= 0 && feed[idx]?.startsWith('▶')) feed[idx] += ev.isError ? ' ✗' : ' ✓';
847
+ } else if (ev.kind === 'thinking') thinkingChars += ev.chars;
848
+ pushFeed();
849
+ },
850
+ });
851
+ }
852
+
853
+ /** `delegate({harness:"all"|"a,b"})` — resolve the requested harnesses to detected installs, run the
854
+ * existing `delegate()` engine once per harness sequentially (respects `maxConcurrent`), and
855
+ * mechanically synthesize one comparison report. No second model call. */
856
+ async function runFanoutTool(
857
+ pi: ExtensionAPI,
858
+ ctx: ExtensionContext,
859
+ config: DelegateConfig,
860
+ params: DelegateToolParams,
861
+ signal: AbortSignal | undefined,
862
+ onUpdate: ((u: ToolProgressUpdate) => void) | undefined,
863
+ ): Promise<{ content: { type: string; text: string }[]; details: Record<string, unknown>; usage?: unknown }> {
864
+ const detection = await detectAll();
865
+ const { resolved, unknown, skipped } = resolveHarnessList(params.harness ?? 'all', {
866
+ knownHarnesses: HARNESS_NAMES,
867
+ aliasOf: resolveHarnessName,
868
+ isKnown: isKnownHarness,
869
+ detection,
870
+ });
871
+ if (resolved.length === 0) {
872
+ throw new Error(
873
+ `no harness available to fan out to (unknown: ${unknown.join(', ') || '—'}; not installed: ${skipped.join(', ') || '—'})`,
874
+ );
875
+ }
876
+
877
+ const mode = params.mode ?? config.defaultMode;
878
+ const runs: FanoutRunSummary[] = [];
879
+ let sumInput = 0;
880
+ let sumOutput = 0;
881
+ let sumCacheCreate = 0;
882
+ let sumCacheRead = 0;
883
+ let sumCost = 0;
884
+ let anyCostKnown = false;
885
+
886
+ for (const h of resolved) {
887
+ onUpdate?.({ content: [{ type: 'text', text: `[${h}] running…` }], details: { progress: 0.5 } });
888
+ try {
889
+ const run = await runDelegateForTool(
890
+ pi,
891
+ ctx,
892
+ config,
893
+ {
894
+ harness: h,
895
+ task: params.task,
896
+ mode: params.mode,
897
+ scope: params.scope,
898
+ model: params.model,
899
+ maxBudgetUsd: params.maxBudgetUsd,
900
+ allowDangerous: params.allowDangerous === true,
901
+ sessionId: params.sessionId,
902
+ pr: params.pr,
903
+ // no verify: intentionally not model-settable — see DelegateToolParams
904
+ },
905
+ signal,
906
+ onUpdate,
907
+ `[${h}] `,
908
+ );
909
+ const summary = summarize(run.content);
910
+ runs.push({
911
+ harness: h,
912
+ ok: !run.result.isError,
913
+ metrics: formatMetrics({
914
+ numTurns: run.result.numTurns,
915
+ totalCostUsd: run.result.totalCostUsd,
916
+ promptTokens: 0,
917
+ contextPercent: typeof run.details.contextPercent === 'number' ? run.details.contextPercent : null,
918
+ durationMs: run.result.durationMs,
919
+ }),
920
+ cost: run.result.totalCostUsd,
921
+ body: summary.text,
922
+ file: (run.details.file as string) ?? undefined,
923
+ sessionId: (run.details.sessionId as string) ?? undefined,
924
+ verify: run.verify,
925
+ });
926
+ if (run.result.usage) {
927
+ sumInput += run.result.usage.inputTokens;
928
+ sumOutput += run.result.usage.outputTokens;
929
+ sumCacheCreate += run.result.usage.cacheCreationInputTokens;
930
+ sumCacheRead += run.result.usage.cacheReadInputTokens;
931
+ }
932
+ if (run.result.totalCostUsd !== null) {
933
+ sumCost += run.result.totalCostUsd;
934
+ anyCostKnown = true;
935
+ }
936
+ } catch (err) {
937
+ runs.push({ harness: h, ok: false, cost: null, error: err instanceof Error ? err.message : String(err) });
938
+ }
939
+ }
940
+
941
+ const report = buildFanoutReport({ runs, skipped, unknown });
942
+ const okCount = runs.filter(r => r.ok).length;
943
+ const head = `## delegate all — ${mode} (${okCount}/${runs.length} ok)`;
944
+ const usage = mapClaudeUsage({
945
+ inputTokens: sumInput,
946
+ outputTokens: sumOutput,
947
+ cacheCreationInputTokens: sumCacheCreate,
948
+ cacheReadInputTokens: sumCacheRead,
949
+ totalCostUsd: anyCostKnown ? sumCost : null,
950
+ });
951
+ return {
952
+ content: [{ type: 'text', text: `${head}\n\n${report}` }],
953
+ details: { fanout: true, harness: 'all', mode, harnesses: resolved, skipped, unknown, runs },
954
+ usage,
955
+ };
956
+ }
957
+
668
958
  export default function (pi: ExtensionAPI) {
669
959
  let activeRunId = 0;
670
960
  let activeOverlay: { show(): void; focus(): void; runId: number } | null = null;
@@ -674,12 +964,13 @@ export default function (pi: ExtensionAPI) {
674
964
  name: 'delegate',
675
965
  label: 'Delegate',
676
966
  description:
677
- 'Delegate a task to any harness (claude, codex, opencode, amp) running headless in the repo and return its streamed report (cost, token usage, context %, session id). harness selects the backend (default from config, fallback claude). mode selects a template: review, plan, implement, security-audit, docs, general, or custom. scope restricts work: diff for current git diff, pr for PR diff, path list, or whole repo. sessionId continues a prior session.',
967
+ 'Delegate a task to any harness (claude, codex, opencode, amp) running headless in the repo and return its streamed report (cost, token usage, context %, session id). harness selects the backend (default from config, fallback claude) — pass "all" or a comma list (e.g. "claude,codex") to fan out the same task to several harnesses and get back one comparison report. mode selects a template: review, plan, implement, security-audit, docs, general, or custom — some templates run a host-side check (e.g. "bun test") after the harness exits and report pass/fail as separate evidence; that is configured on the template, not a parameter here. scope restricts work: diff for current git diff, pr for PR diff, path list, or whole repo. sessionId continues a prior session.',
678
968
  promptSnippet: 'Delegate a subtask to a harness and return its report',
679
969
  promptGuidelines: [
680
970
  'delegate runs a harness headless in the working directory and returns a streamed report with cost, token usage, and a session id for follow-ups.',
681
971
  'Pass harness (claude|codex|opencode|amp) + focused task string + intent and constraints. Use scope: diff for current git diff, pr for PR diff, path list, or omit for whole repo.',
682
- 'mode selects the template and its permission level: review/plan/security-audit are readonly; implement/docs/general are edit. Custom template names also work.',
972
+ 'mode selects the template and its permission level: review/plan/security-audit are readonly; implement/docs/general are edit. Custom template names also work. Some templates verify their own work (e.g. running tests) automatically after the harness finishes — that is not something you configure here.',
973
+ 'harness: "all" or a comma list (e.g. "codex,opencode") fans the same task out to each detected harness and returns one synthesized comparison report — costs multiply, so only use it when the user actually wants a multi-harness comparison.',
683
974
  'sessionId resumes a previous delegated session instead of starting fresh.',
684
975
  'Do not set allowDangerous unless the user explicitly asks for unrestricted access (danger permission).',
685
976
  ],
@@ -687,7 +978,7 @@ export default function (pi: ExtensionAPI) {
687
978
  harness: Type.Optional(
688
979
  Type.String({
689
980
  description:
690
- 'Harness to use: claude, codex, opencode, amp (aliases: omp). Defaults to config defaultHarness.',
981
+ 'Harness to use: claude, codex, opencode, amp (aliases: omp). "all" or a comma list (e.g. "claude,codex") fans out to each detected harness. Defaults to config defaultHarness.',
691
982
  }),
692
983
  ),
693
984
  task: Type.String({ description: 'The task/intent to delegate. Be specific.' }),
@@ -718,72 +1009,44 @@ export default function (pi: ExtensionAPI) {
718
1009
  }),
719
1010
  ),
720
1011
  pr: Type.Optional(Type.String({ description: 'GitHub PR number/URL (alternative to scope pr).' })),
1012
+ // Deliberately no `verify` param — see the trust-model comment on DelegateToolParams/runVerify.
721
1013
  }),
722
1014
  async execute(
723
1015
  _toolCallId: string,
724
- params: {
725
- harness?: string;
726
- task: string;
727
- mode?: string;
728
- scope?: string;
729
- model?: string;
730
- maxBudgetUsd?: number;
731
- allowDangerous?: boolean;
732
- sessionId?: string;
733
- pr?: string;
734
- },
1016
+ params: DelegateToolParams,
735
1017
  signal: AbortSignal | undefined,
736
- onUpdate: ((u: { content: { type: string; text: string }[]; details: { progress: number } }) => void) | undefined,
1018
+ onUpdate: ((u: ToolProgressUpdate) => void) | undefined,
737
1019
  ctx: ExtensionContext,
738
1020
  ) {
739
1021
  const config = loadConfig();
740
- const feed: string[] = [];
741
- let liveTail = '';
742
- let thinkingChars = 0;
743
- let lastPushAt = 0;
744
- const THROTTLE_MS = 250;
745
- const pushFeed = () => {
746
- const now = Date.now();
747
- if (now - lastPushAt < THROTTLE_MS) return;
748
- lastPushAt = now;
749
- const lines: string[] = [...feed.slice(-6)];
750
- if (thinkingChars > 0)
751
- lines.push(config.inspectThinking ? `💭 thinking… (${thinkingChars} chars)` : '💭 thinking…');
752
- if (liveTail) lines.push(`✍ ${liveTail}`);
753
- if (lines.length === 0) return;
754
- onUpdate?.({ content: [{ type: 'text', text: lines.join('\n') }], details: { progress: 0.5 } });
755
- };
756
- const { content, details, result } = await delegate(pi, ctx, {
757
- harness: params.harness,
758
- task: params.task,
759
- mode: params.mode,
760
- scope: params.scope,
761
- model: params.model,
762
- maxBudgetUsd: params.maxBudgetUsd,
763
- allowDangerous: params.allowDangerous === true, // invariant: never inherit from config.allowDangerous — danger requires explicit per-call approval
764
- sessionId: params.sessionId,
765
- pr: params.pr,
766
- signal,
767
- onStream: text => {
768
- liveTail = (liveTail + text).slice(-400);
769
- pushFeed();
770
- },
771
- onActivity: ev => {
772
- if (ev.kind === 'tool_input') {
773
- feed.push(`▶ ${formatToolUse(ev.name, ev.input)}`);
774
- if (feed.length > 40) feed.splice(0, feed.length - 40);
775
- } else if (ev.kind === 'tool_result') {
776
- const last = feed.length - 1;
777
- if (last >= 0 && feed[last].startsWith('▶')) feed[last] += ev.isError ? ' ✗' : ' ✓';
778
- } else if (ev.kind === 'thinking') thinkingChars += ev.chars;
779
- pushFeed();
1022
+ if (params.harness && isFanoutSpec(params.harness)) {
1023
+ return runFanoutTool(pi, ctx, config, params, signal, onUpdate);
1024
+ }
1025
+ const { content, details, result } = await runDelegateForTool(
1026
+ pi,
1027
+ ctx,
1028
+ config,
1029
+ {
1030
+ harness: params.harness,
1031
+ task: params.task,
1032
+ mode: params.mode,
1033
+ scope: params.scope,
1034
+ model: params.model,
1035
+ maxBudgetUsd: params.maxBudgetUsd,
1036
+ allowDangerous: params.allowDangerous === true, // invariant: never inherit from config.allowDangerous danger requires explicit per-call approval
1037
+ sessionId: params.sessionId,
1038
+ pr: params.pr,
1039
+ // no verify: intentionally not model-settable — see DelegateToolParams
780
1040
  },
781
- });
1041
+ signal,
1042
+ onUpdate,
1043
+ '',
1044
+ );
782
1045
  const summary = summarize(content);
783
1046
  const resumed = details.resumed ? ' · resumed' : '';
784
1047
  const head = result.isError
785
1048
  ? `⚠ ${details.harness} reported an error`
786
- : `${details.harness} ${details.mode} (${result.numTurns} turn(s), $${result.totalCostUsd.toFixed(3)})${resumed}`;
1049
+ : `${details.harness} ${details.mode} (${result.numTurns ?? '—'} turn(s), ${formatCost(result.totalCostUsd)})${resumed}`;
787
1050
  const body = result.isError ? `\n${summary.text}` : `\n\n${summary.text}`;
788
1051
  const footer = summary.truncated ? `\nFull output: ${details.file}` : `\nTranscript: ${details.file}`;
789
1052
  (details as Record<string, unknown>).markdown = summary.text;
@@ -818,8 +1081,8 @@ export default function (pi: ExtensionAPI) {
818
1081
  const details = (result.details ?? {}) as Record<string, unknown>;
819
1082
  const harness = typeof details.harness === 'string' ? details.harness : 'delegate';
820
1083
  const mode = typeof details.mode === 'string' ? details.mode : 'delegate';
821
- const cost = typeof details.totalCostUsd === 'number' ? details.totalCostUsd : 0;
822
- const turns = typeof details.numTurns === 'number' ? details.numTurns : 0;
1084
+ const cost = typeof details.totalCostUsd === 'number' ? details.totalCostUsd : null;
1085
+ const turns = typeof details.numTurns === 'number' ? details.numTurns : null;
823
1086
  const isError = details.isError === true;
824
1087
  const resumed = details.resumed === true;
825
1088
  const file = typeof details.file === 'string' ? details.file : null;
@@ -828,8 +1091,8 @@ export default function (pi: ExtensionAPI) {
828
1091
  container.addChild(
829
1092
  new Text(
830
1093
  theme.fg(isError ? 'error' : 'accent', `${harness} ${mode}`) +
831
- theme.fg('dim', ` · ${turns} turn(s) · `) +
832
- theme.fg('warning', `$${cost.toFixed(3)}`) +
1094
+ theme.fg('dim', ` · ${turns ?? '—'} turn(s) · `) +
1095
+ theme.fg('warning', formatCost(cost)) +
833
1096
  (resumed ? theme.fg('dim', ' · resumed') : ''),
834
1097
  1,
835
1098
  1,
@@ -868,17 +1131,7 @@ export default function (pi: ExtensionAPI) {
868
1131
  parameters: (delegateToolDef as { parameters: unknown }).parameters as never,
869
1132
  async execute(
870
1133
  toolCallId: string,
871
- params: {
872
- harness?: string;
873
- task: string;
874
- mode?: string;
875
- scope?: string;
876
- model?: string;
877
- maxBudgetUsd?: number;
878
- allowDangerous?: boolean;
879
- sessionId?: string;
880
- pr?: string;
881
- },
1134
+ params: DelegateToolParams,
882
1135
  signal: AbortSignal | undefined,
883
1136
  onUpdate: never,
884
1137
  ctx: ExtensionContext,
@@ -902,6 +1155,253 @@ export default function (pi: ExtensionAPI) {
902
1155
  } as unknown as Parameters<typeof pi.registerTool>[0]); // SAFETY: alias tool matches overload
903
1156
 
904
1157
  // ── Commands ─────────────────────────────────────────────────────────────
1158
+
1159
+ /** One `delegate()` call with the command's progress-window UI (spinner, cancel, minimize).
1160
+ * Shared by the single-harness `/delegate` path and the fan-out loop, one call per harness. */
1161
+ const runOneDelegation = async (
1162
+ ctx: ExtensionContext,
1163
+ opts: {
1164
+ harnessName: string;
1165
+ mode?: string;
1166
+ task: string;
1167
+ scope?: string;
1168
+ model?: string;
1169
+ budget?: number;
1170
+ sessionId?: string;
1171
+ pr?: string;
1172
+ verify?: string;
1173
+ template?: DelegateTemplate;
1174
+ isDanger: boolean;
1175
+ },
1176
+ ): Promise<{
1177
+ result: Awaited<ReturnType<typeof delegate>> | null;
1178
+ error: Error | null;
1179
+ cancelled: boolean;
1180
+ }> => {
1181
+ const { harnessName, mode, task, scope, model, budget, sessionId, pr, verify, template, isDanger } = opts;
1182
+ const modeForDisplay = mode ?? 'general';
1183
+
1184
+ const feed: FeedEntry[] = [];
1185
+ const feedIndex = new ToolCallIndex();
1186
+ let thinkingChars = 0;
1187
+ let liveTail = '';
1188
+ let requestRender: (() => void) | null = null;
1189
+ const getEntries = (): FeedEntry[] => {
1190
+ const entries = [...feed.slice(-12)];
1191
+ if (thinkingChars > 0) entries.push({ kind: 'thinking', text: '💭 thinking…' });
1192
+ if (liveTail) entries.push({ kind: 'text', text: liveTail.slice(-200) });
1193
+ return entries;
1194
+ };
1195
+ let chipActivity = '';
1196
+ let chipActivityId: string | undefined;
1197
+ let chipLastPush = 0;
1198
+ const pushChip = () => {
1199
+ if (!ctx.hasUI) return;
1200
+ const now = Date.now();
1201
+ if (now - chipLastPush < 500) return;
1202
+ chipLastPush = now;
1203
+ const theme = ctx.ui.theme;
1204
+ const activity = chipActivity ? ` ${chipActivity}` : theme.fg('dim', ' running…');
1205
+ ctx.ui.setStatus(
1206
+ 'delegate',
1207
+ theme.fg('accent', '●') + theme.fg('dim', ` ${harnessName} ${modeForDisplay}`) + activity,
1208
+ );
1209
+ };
1210
+ const onActivity = (ev: ActivityEvent) => {
1211
+ if (ev.kind === 'tool_input') {
1212
+ chipActivity = `▶ ${formatToolUse(ev.name, ev.input)}`;
1213
+ chipActivityId = ev.id;
1214
+ feed.push({ kind: 'tool', text: formatToolUse(ev.name, ev.input), id: ev.id });
1215
+ feedIndex.set(ev.id, feed.length - 1);
1216
+ if (feed.length > 40) {
1217
+ const removed = feed.length - 40;
1218
+ feed.splice(0, removed);
1219
+ feedIndex.shift(removed);
1220
+ }
1221
+ } else if (ev.kind === 'tool_result') {
1222
+ // only stamp the chip when the result belongs to the tool it's currently showing
1223
+ if (chipActivity.startsWith('▶') && (ev.id === undefined || ev.id === chipActivityId))
1224
+ chipActivity += ev.isError ? ' ✗' : ' ✓';
1225
+ const idx = feedIndex.resolve(ev.id, feed.length - 1);
1226
+ if (idx >= 0 && feed[idx]?.kind === 'tool') feed[idx] = { ...feed[idx], ok: !ev.isError };
1227
+ } else if (ev.kind === 'thinking') {
1228
+ chipActivity = '💭 thinking…';
1229
+ chipActivityId = undefined;
1230
+ thinkingChars += ev.chars;
1231
+ }
1232
+ pushChip();
1233
+ requestRender?.();
1234
+ };
1235
+ const ac = new AbortController();
1236
+ let cancelled = false;
1237
+ const runState: { error: Error | null } = { error: null };
1238
+ const runId = ++activeRunId;
1239
+ const clearActive = () => {
1240
+ if (activeOverlay?.runId === runId) activeOverlay = null;
1241
+ };
1242
+ const run = delegate(pi, ctx, {
1243
+ harness: harnessName,
1244
+ task,
1245
+ mode,
1246
+ scope,
1247
+ model,
1248
+ maxBudgetUsd: budget,
1249
+ sessionId,
1250
+ pr,
1251
+ verify,
1252
+ signal: ac.signal,
1253
+ onStream: t => {
1254
+ liveTail = (liveTail + t).slice(-400);
1255
+ requestRender?.();
1256
+ },
1257
+ onActivity,
1258
+ }).catch((err: unknown) => {
1259
+ runState.error = err instanceof Error ? err : new Error(String(err));
1260
+ return null;
1261
+ });
1262
+
1263
+ let closeWindow: (() => void) | null = null;
1264
+ let result: Awaited<ReturnType<typeof delegate>> | null = null;
1265
+ if (ctx.hasUI) {
1266
+ let overlayHandle: OverlayHandle | null = null;
1267
+ const uiPromise = ctx.ui
1268
+ .custom(
1269
+ (tui, theme, _kb, done) => {
1270
+ requestRender = () => tui.requestRender();
1271
+ closeWindow = () => done(undefined);
1272
+ return progressWindow(tui, theme, {
1273
+ mode: `${harnessName} ${modeForDisplay}`,
1274
+ model: model ?? template?.model ?? loadConfig().harnesses[harnessName]?.model ?? loadConfig().model,
1275
+ startedAt: Date.now(),
1276
+ getEntries,
1277
+ dangerous: isDanger,
1278
+ onCancel: () => {
1279
+ cancelled = true;
1280
+ ac.abort();
1281
+ },
1282
+ onMinimize: () => {
1283
+ overlayHandle?.setHidden(true);
1284
+ overlayHandle?.unfocus();
1285
+ },
1286
+ });
1287
+ },
1288
+ {
1289
+ overlay: true,
1290
+ overlayOptions: { width: '70%', maxHeight: '60%', anchor: 'top-center' },
1291
+ onHandle: h => {
1292
+ overlayHandle = h;
1293
+ activeOverlay = { show: () => h.setHidden(false), focus: () => h.focus(), runId };
1294
+ h.focus();
1295
+ },
1296
+ },
1297
+ )
1298
+ .catch(() => {});
1299
+ result = await run;
1300
+ await closeWhenMounted(() => closeWindow, 2000);
1301
+ await uiPromise;
1302
+ } else {
1303
+ result = await run;
1304
+ }
1305
+ clearActive();
1306
+ if (ctx.hasUI) ctx.ui.setStatus('delegate', undefined);
1307
+ const failed = cancelled || !result;
1308
+ return { result: failed ? null : result, error: runState.error, cancelled };
1309
+ };
1310
+
1311
+ /** `/delegate all …` / `/delegate a,b …` — resolve to detected harnesses, run each sequentially
1312
+ * through `runOneDelegation` (respects `maxConcurrent`), batch success notifications, and inject
1313
+ * one synthesized comparison report instead of one report per harness. */
1314
+ const runFanoutCommand = async (ctx: ExtensionContext, parsed: ReturnType<typeof parseDelegateCommand>) => {
1315
+ const harnessSpec = parsed.harness as string;
1316
+ const detection = await detectAll();
1317
+ const { resolved, unknown, skipped } = resolveHarnessList(harnessSpec, {
1318
+ knownHarnesses: HARNESS_NAMES,
1319
+ aliasOf: resolveHarnessName,
1320
+ isKnown: isKnownHarness,
1321
+ detection,
1322
+ });
1323
+ if (resolved.length === 0) {
1324
+ const msg = `no harness available to fan out to (unknown: ${unknown.join(', ') || '—'}; not installed: ${skipped.join(', ') || '—'})`;
1325
+ if (ctx.hasUI) ctx.ui.notify(msg, 'error');
1326
+ else process.stderr.write(`${msg}\n`);
1327
+ return;
1328
+ }
1329
+
1330
+ const modeForReport = parsed.mode ?? loadConfig().defaultMode;
1331
+ const runs: FanoutRunSummary[] = [];
1332
+ const batcher = new NotifyBatcher((text, level) => {
1333
+ if (ctx.hasUI) ctx.ui.notify(text, level);
1334
+ else process.stdout.write(`${text}\n`);
1335
+ });
1336
+
1337
+ for (const h of resolved) {
1338
+ const templates = loadTemplates(ctx.cwd, h);
1339
+ const resolvedTaskScope = resolveDefaults(parsed, templates);
1340
+ const template = parsed.mode ? templates.get(parsed.mode) : undefined;
1341
+ if (!resolvedTaskScope) {
1342
+ const message = `mode "${parsed.mode ?? 'general'}" needs a prompt`;
1343
+ runs.push({ harness: h, ok: false, cost: null, error: message });
1344
+ batcher.failure(`${h}: ${message}`);
1345
+ continue;
1346
+ }
1347
+ const isDanger =
1348
+ template?.permission === 'danger' ||
1349
+ (template?.nativePermission
1350
+ ? ['bypassPermissions', 'danger-full-access', 'danger'].includes(template.nativePermission)
1351
+ : false);
1352
+ const outcome = await runOneDelegation(ctx, {
1353
+ harnessName: h,
1354
+ mode: parsed.mode,
1355
+ task: resolvedTaskScope.task,
1356
+ scope: resolvedTaskScope.scope,
1357
+ model: parsed.model,
1358
+ budget: parsed.budget,
1359
+ sessionId: parsed.sessionId,
1360
+ pr: parsed.pr,
1361
+ verify: parsed.verify,
1362
+ template,
1363
+ isDanger,
1364
+ });
1365
+ if (outcome.cancelled || !outcome.result) {
1366
+ const message = outcome.error ? outcome.error.message : outcome.cancelled ? 'cancelled' : 'delegation failed';
1367
+ runs.push({ harness: h, ok: false, cost: null, error: message });
1368
+ batcher.failure(`${h}: ${outcome.cancelled ? 'cancelled' : 'failed'} — ${message}`);
1369
+ if (outcome.cancelled) break; // user cancelled — stop the rest of the fan-out
1370
+ continue;
1371
+ }
1372
+ const { content, details, result, verify } = outcome.result;
1373
+ const summary = summarize(content);
1374
+ const metrics = formatMetrics({
1375
+ numTurns: result.numTurns,
1376
+ totalCostUsd: result.totalCostUsd,
1377
+ promptTokens: 0,
1378
+ contextPercent: typeof details.contextPercent === 'number' ? details.contextPercent : null,
1379
+ durationMs: typeof details.durationMs === 'number' ? details.durationMs : null,
1380
+ });
1381
+ runs.push({
1382
+ harness: h,
1383
+ ok: !result.isError,
1384
+ metrics,
1385
+ cost: result.totalCostUsd,
1386
+ body: summary.text,
1387
+ file: (details.file as string) ?? undefined,
1388
+ sessionId: (details.sessionId as string) ?? undefined,
1389
+ verify,
1390
+ });
1391
+ batcher.success(`${h} ${parsed.mode ?? 'general'} — ${metrics}`);
1392
+ }
1393
+
1394
+ const okCount = runs.filter(r => r.ok).length;
1395
+ const report = buildFanoutReport({ runs, skipped, unknown });
1396
+ injectReport(ctx, {
1397
+ harness: 'all',
1398
+ mode: modeForReport,
1399
+ metrics: `${okCount}/${runs.length} ok`,
1400
+ body: report,
1401
+ });
1402
+ batcher.flush();
1403
+ };
1404
+
905
1405
  const makeHandler = (forcedHarness?: string) => async (args: string, ctx: ExtensionContext) => {
906
1406
  const sub = args.trim();
907
1407
  const subLower = sub.toLowerCase();
@@ -977,6 +1477,14 @@ export default function (pi: ExtensionAPI) {
977
1477
  const parsed = parseDelegateCommand(rawForParse, allModes, knownHarnessesSet);
978
1478
  // if forcedHarness provided, it wins
979
1479
  if (forcedHarness) parsed.harness = forcedHarness;
1480
+
1481
+ // fan-out: harness field is `all` or a comma list — resolve to detected harnesses and run
1482
+ // the engine once per harness instead of the single-harness flow below.
1483
+ if (parsed.harness && isFanoutSpec(parsed.harness)) {
1484
+ await runFanoutCommand(ctx, parsed);
1485
+ return;
1486
+ }
1487
+
980
1488
  const harnessName = parsed.harness ?? loadConfig().defaultHarness ?? 'claude';
981
1489
  const templates = loadTemplates(ctx.cwd, harnessName);
982
1490
  const resolved = resolveDefaults(parsed, templates);
@@ -995,134 +1503,36 @@ export default function (pi: ExtensionAPI) {
995
1503
  );
996
1504
  else
997
1505
  ctx.ui.notify?.(
998
- 'Usage: /delegate [--harness=claude|codex|opencode|amp] [--mode=…] [--model=…] [--scope=…] <prompt>',
1506
+ 'Usage: /delegate [--harness=claude|codex|opencode|amp|all] [--mode=…] [--model=…] [--scope=…] [--verify=…] <prompt>',
999
1507
  'warning',
1000
1508
  );
1001
1509
  return;
1002
1510
  }
1003
- const modeForDisplay = parsed.mode ?? 'general';
1004
- const harnessForDisplay = harnessName;
1005
1511
 
1006
- const feed: FeedEntry[] = [];
1007
- let thinkingChars = 0;
1008
- let liveTail = '';
1009
- let requestRender: (() => void) | null = null;
1010
- const getEntries = (): FeedEntry[] => {
1011
- const entries = [...feed.slice(-12)];
1012
- if (thinkingChars > 0) entries.push({ kind: 'thinking', text: '💭 thinking…' });
1013
- if (liveTail) entries.push({ kind: 'text', text: liveTail.slice(-200) });
1014
- return entries;
1015
- };
1016
- let chipActivity = '';
1017
- let chipLastPush = 0;
1018
- const pushChip = () => {
1019
- if (!ctx.hasUI) return;
1020
- const now = Date.now();
1021
- if (now - chipLastPush < 500) return;
1022
- chipLastPush = now;
1023
- const theme = ctx.ui.theme;
1024
- const activity = chipActivity ? ` ${chipActivity}` : theme.fg('dim', ' running…');
1025
- ctx.ui.setStatus(
1026
- 'delegate',
1027
- theme.fg('accent', '●') + theme.fg('dim', ` ${harnessForDisplay} ${modeForDisplay}`) + activity,
1028
- );
1029
- };
1030
- const onActivity = (ev: ActivityEvent) => {
1031
- if (ev.kind === 'tool_input') {
1032
- chipActivity = `▶ ${formatToolUse(ev.name, ev.input)}`;
1033
- feed.push({ kind: 'tool', text: formatToolUse(ev.name, ev.input) });
1034
- if (feed.length > 40) feed.splice(0, feed.length - 40);
1035
- } else if (ev.kind === 'tool_result') {
1036
- if (chipActivity.startsWith('▶')) chipActivity += ev.isError ? ' ✗' : ' ✓';
1037
- const last = feed.length - 1;
1038
- if (last >= 0 && feed[last].kind === 'tool') feed[last] = { ...feed[last], ok: !ev.isError };
1039
- } else if (ev.kind === 'thinking') {
1040
- chipActivity = '💭 thinking…';
1041
- thinkingChars += ev.chars;
1042
- }
1043
- pushChip();
1044
- requestRender?.();
1045
- };
1046
- const ac = new AbortController();
1047
- let cancelled = false;
1048
- const runState: { error: Error | null } = { error: null };
1049
- const runId = ++activeRunId;
1050
- const clearActive = () => {
1051
- if (activeOverlay?.runId === runId) activeOverlay = null;
1052
- };
1053
- const run = delegate(pi, ctx, {
1054
- harness: harnessName,
1055
- task: resolved.task,
1512
+ const outcome = await runOneDelegation(ctx, {
1513
+ harnessName,
1056
1514
  mode: parsed.mode,
1515
+ task: resolved.task,
1057
1516
  scope: resolved.scope,
1058
1517
  model: parsed.model,
1059
- maxBudgetUsd: parsed.budget,
1518
+ budget: parsed.budget,
1060
1519
  sessionId: parsed.sessionId,
1061
1520
  pr: parsed.pr,
1062
- signal: ac.signal,
1063
- onStream: t => {
1064
- liveTail = (liveTail + t).slice(-400);
1065
- requestRender?.();
1066
- },
1067
- onActivity,
1068
- }).catch((err: unknown) => {
1069
- runState.error = err instanceof Error ? err : new Error(String(err));
1070
- return null;
1521
+ verify: parsed.verify,
1522
+ template,
1523
+ isDanger,
1071
1524
  });
1072
-
1073
- let closeWindow: (() => void) | null = null;
1074
- let result: Awaited<ReturnType<typeof delegate>> | null = null;
1075
- if (ctx.hasUI) {
1076
- let overlayHandle: OverlayHandle | null = null;
1077
- const uiPromise = ctx.ui
1078
- .custom(
1079
- (tui, theme, _kb, done) => {
1080
- requestRender = () => tui.requestRender();
1081
- closeWindow = () => done(undefined);
1082
- return progressWindow(tui, theme, {
1083
- mode: `${harnessForDisplay} ${modeForDisplay}`,
1084
- model:
1085
- parsed.model ?? template?.model ?? loadConfig().harnesses[harnessName]?.model ?? loadConfig().model,
1086
- startedAt: Date.now(),
1087
- getEntries,
1088
- dangerous: isDanger,
1089
- onCancel: () => {
1090
- cancelled = true;
1091
- ac.abort();
1092
- },
1093
- onMinimize: () => {
1094
- overlayHandle?.setHidden(true);
1095
- overlayHandle?.unfocus();
1096
- },
1097
- });
1098
- },
1099
- {
1100
- overlay: true,
1101
- overlayOptions: { width: '70%', maxHeight: '60%', anchor: 'top-center' },
1102
- onHandle: h => {
1103
- overlayHandle = h;
1104
- activeOverlay = { show: () => h.setHidden(false), focus: () => h.focus(), runId };
1105
- h.focus();
1106
- },
1107
- },
1108
- )
1109
- .catch(() => {});
1110
- result = await run;
1111
- await closeWhenMounted(() => closeWindow, 2000);
1112
- await uiPromise;
1113
- } else {
1114
- result = await run;
1115
- }
1116
- clearActive();
1117
- if (cancelled || !result) {
1118
- if (ctx.hasUI) ctx.ui.setStatus('delegate', undefined);
1119
- const message = runState.error ? runState.error.message : cancelled ? 'cancelled' : 'delegation failed';
1525
+ if (outcome.cancelled || !outcome.result) {
1526
+ const message = outcome.error ? outcome.error.message : outcome.cancelled ? 'cancelled' : 'delegation failed';
1120
1527
  if (ctx.hasUI)
1121
- ctx.ui.notify(`delegate ${cancelled ? 'cancelled' : 'failed'}: ${message}`, cancelled ? 'warning' : 'error');
1528
+ ctx.ui.notify(
1529
+ `delegate ${outcome.cancelled ? 'cancelled' : 'failed'}: ${message}`,
1530
+ outcome.cancelled ? 'warning' : 'error',
1531
+ );
1122
1532
  else process.stderr.write(`${message}\n`);
1123
1533
  return;
1124
1534
  }
1125
- const { content, details } = result;
1535
+ const { content, details, verify } = outcome.result;
1126
1536
  const summary = summarize(content);
1127
1537
  const file = (details.file as string) ?? null;
1128
1538
  const sessionId = (details.sessionId as string) ?? null;
@@ -1139,8 +1549,8 @@ export default function (pi: ExtensionAPI) {
1139
1549
  ? (usage.inputTokens ?? 0) + (usage.cacheCreationInputTokens ?? 0) + (usage.cacheReadInputTokens ?? 0)
1140
1550
  : 0;
1141
1551
  const metrics = formatMetrics({
1142
- numTurns: (details.numTurns as number) ?? 0,
1143
- totalCostUsd: (details.totalCostUsd as number) ?? 0,
1552
+ numTurns: typeof details.numTurns === 'number' ? details.numTurns : null,
1553
+ totalCostUsd: typeof details.totalCostUsd === 'number' ? details.totalCostUsd : null,
1144
1554
  promptTokens,
1145
1555
  contextPercent: typeof details.contextPercent === 'number' ? (details.contextPercent as number) : null,
1146
1556
  durationMs:
@@ -1153,6 +1563,7 @@ export default function (pi: ExtensionAPI) {
1153
1563
  body: summary.text,
1154
1564
  file: file ?? undefined,
1155
1565
  sessionId: sessionId ?? undefined,
1566
+ verify,
1156
1567
  });
1157
1568
  if (ctx.hasUI) {
1158
1569
  ctx.ui.setStatus('delegate', undefined);
@@ -1162,7 +1573,7 @@ export default function (pi: ExtensionAPI) {
1162
1573
 
1163
1574
  pi.registerCommand('delegate', {
1164
1575
  description:
1165
- 'Delegate a task to any harness. Usage: /delegate [--harness=claude|codex|opencode|amp] [--mode=review|plan|implement|security-audit|docs|general] [--model=...] [--scope=diff|pr|paths] [--resume=<id>] <prompt> — or use harness as first word: /delegate codex review <prompt>',
1576
+ 'Delegate a task to any harness. Usage: /delegate [--harness=claude|codex|opencode|amp|all] [--mode=review|plan|implement|security-audit|docs|general] [--model=...] [--scope=diff|pr|paths] [--verify=<cmd>] [--resume=<id>] <prompt> — or use harness as first word: /delegate codex review <prompt>. harness=all or a comma list (e.g. claude,codex) fans out to every detected harness and returns one comparison report.',
1166
1577
  handler: makeHandler(),
1167
1578
  });
1168
1579
  pi.registerCommand('claude', {