pi-harness-delegate 0.3.0 → 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
@@ -45,7 +45,7 @@ The `delegate` tool takes: `harness`, `task`, `mode`, `scope` (`diff` = git diff
45
45
 
46
46
  ### Fan out to multiple harnesses
47
47
 
48
- `harness` also accepts `all` or a comma-separated list — the same task runs on every harness, sequentially, and comes back as one comparison report instead of one report per harness:
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
49
 
50
50
  ```bash
51
51
  /delegate all review the auth flow # every *detected* harness
@@ -54,10 +54,21 @@ delegate({ harness: "all", mode: "review", scope: "diff" }) # tool call form
54
54
  ```
55
55
 
56
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>/`; a fan-out costs roughly a single run (respects `maxBudgetUsd` per run) and always runs sequentially, respecting `maxConcurrent`.
58
- - The synthesized report groups each harness's metrics + output and a total spend line (unknown-cost runs called out separately, same as `/delegate status`) — it's assembled mechanically, not by asking a model to summarize.
59
- - A single-harness call (`harness: "claude"`, or omitted) behaves exactly as before fan-out is opt-in by typing `all`/a list.
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
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.
61
72
 
62
73
  ## Harnesses
63
74
 
@@ -148,7 +159,7 @@ In `~/.pi/agent/settings.json`:
148
159
  "maxBudgetUsd": 3,
149
160
  "autoDelegateHints": false,
150
161
  "modelAliases": { "economy": "haiku", "balanced": "sonnet", "max": "opus" },
151
- "maxConcurrent": 1,
162
+ "maxConcurrent": 4,
152
163
  "maxTranscripts": 100,
153
164
  "harnesses": {
154
165
  "claude": { "model": "sonnet" },
@@ -162,7 +173,7 @@ In `~/.pi/agent/settings.json`:
162
173
  Legacy `claudeDelegate` is auto-migrated into `delegate.harnesses.claude` (deprecated).
163
174
 
164
175
  - `modelAliases` — templates may use `economy|balanced|max` or any alias; resolution: call → template → harness → global.
165
- - `maxConcurrent` — cap overlapping runs (default 1 global; may be `{global:1, perHarness:{claude:1}}`). Enforced across pi processes, not just the current one — a file-based registry under `~/.pi/agent/delegate/runs/` tracks active runs.
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).
166
177
  - `maxTranscripts` — oldest transcripts pruned beyond this count per harness (`0` disables).
167
178
 
168
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.
@@ -205,6 +205,17 @@ export interface FanoutRunSummary {
205
205
  verify?: VerifyResult;
206
206
  }
207
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
+
208
219
  /**
209
220
  * Mechanically assemble one comparison report across all fan-out runs — no second model call.
210
221
  * Groups per-harness metrics/output and rolls up total spend via `aggregateSpend`/`formatSpend`.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The `delegate()` concurrency guard, factored out of index.ts so it's usable — and testable —
3
+ * independent of the TUI.
4
+ *
5
+ * Combines the file-based cross-process registry (`run-registry.ts`) with an in-process counter
6
+ * fallback (registry I/O failures never block a delegation). `acquireSlot()` is the single choke
7
+ * point: `wait: false` preserves the original fail-fast behavior for ad-hoc single-harness runs
8
+ * (throws immediately at capacity); `wait: true` polls until a slot frees, which is what turns a
9
+ * fan-out into a bounded pool without a separate worker-pool abstraction — callers just kick off
10
+ * all the harnesses at once and let `acquireSlot` serialize the ones that don't fit yet.
11
+ */
12
+
13
+ import { type DelegateConfig, getMaxConcurrent } from './config.ts';
14
+ import { acquireRun, countActiveRuns, releaseRun } from './run-registry.ts';
15
+
16
+ const activeRuns = new Map<string, number>();
17
+ let globalActiveRuns = 0;
18
+
19
+ /** In-process active-run count (optionally filtered to one harness). Exposed for `/delegate status`. */
20
+ export function inProcessActiveCount(harness?: string): number {
21
+ return harness ? (activeRuns.get(harness) ?? 0) : globalActiveRuns;
22
+ }
23
+
24
+ /** Active-run count combining the in-process counter with the cross-process registry (the max of
25
+ * the two — registry I/O failures fall back to the in-process view). */
26
+ export function activeCount(harness?: string): number {
27
+ return Math.max(inProcessActiveCount(harness), countActiveRuns(harness));
28
+ }
29
+
30
+ /** Thrown by `acquireSlot({wait: false})` when at capacity. */
31
+ export class ConcurrencyLimitError extends Error {}
32
+
33
+ export interface AcquireSlotOptions {
34
+ harness: string;
35
+ mode: string;
36
+ config: DelegateConfig;
37
+ /** false (default): throw immediately at capacity. true: poll until a slot frees. */
38
+ wait: boolean;
39
+ /** Aborts a `wait: true` poll early. */
40
+ signal?: AbortSignal;
41
+ pollIntervalMs?: number;
42
+ }
43
+
44
+ function abortError(): Error {
45
+ const err = new Error('aborted');
46
+ err.name = 'AbortError';
47
+ return err;
48
+ }
49
+
50
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
51
+ return new Promise((resolve, reject) => {
52
+ if (signal?.aborted) {
53
+ reject(abortError());
54
+ return;
55
+ }
56
+ const onAbort = () => {
57
+ clearTimeout(timer);
58
+ reject(abortError());
59
+ };
60
+ const timer = setTimeout(() => {
61
+ signal?.removeEventListener('abort', onAbort);
62
+ resolve();
63
+ }, ms);
64
+ signal?.addEventListener('abort', onAbort, { once: true });
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Acquire a concurrency slot for one delegate() run. Resolves with a release function (idempotent,
70
+ * never throws) once a slot is held; the caller must call it exactly once when the run finishes.
71
+ *
72
+ * Checks the global limit before the per-harness limit — same precedence and error text as the
73
+ * original inline guard, so single-run (`wait: false`) callers see unchanged behavior.
74
+ */
75
+ export async function acquireSlot(opts: AcquireSlotOptions): Promise<() => void> {
76
+ const { harness, mode, config, wait, signal, pollIntervalMs = 200 } = opts;
77
+ for (;;) {
78
+ if (signal?.aborted) throw abortError();
79
+ const maxGlobal = getMaxConcurrent(config);
80
+ const globalCount = activeCount();
81
+ if (maxGlobal > 0 && globalCount >= maxGlobal) {
82
+ if (!wait) throw new ConcurrencyLimitError('another delegate run is already in progress (global limit)');
83
+ await sleep(pollIntervalMs, signal);
84
+ continue;
85
+ }
86
+ const perHarnessLimit = getMaxConcurrent(config, harness);
87
+ const perHarnessCount = activeCount(harness);
88
+ if (perHarnessLimit > 0 && perHarnessCount >= perHarnessLimit) {
89
+ if (!wait) throw new ConcurrencyLimitError(`another ${harness} run is already in progress`);
90
+ await sleep(pollIntervalMs, signal);
91
+ continue;
92
+ }
93
+
94
+ activeRuns.set(harness, perHarnessCount + 1);
95
+ globalActiveRuns++;
96
+ const runHandle = acquireRun(harness, mode);
97
+ let released = false;
98
+ return () => {
99
+ if (released) return;
100
+ released = true;
101
+ activeRuns.set(harness, Math.max(0, (activeRuns.get(harness) ?? 1) - 1));
102
+ globalActiveRuns = Math.max(0, globalActiveRuns - 1);
103
+ releaseRun(runHandle);
104
+ };
105
+ }
106
+ }
@@ -47,7 +47,7 @@ export function loadConfig(): DelegateConfig {
47
47
  inspectThinking: false,
48
48
  autoDelegateHints: false,
49
49
  modelAliases: { economy: 'haiku', balanced: 'sonnet', max: 'opus' },
50
- maxConcurrent: 1,
50
+ maxConcurrent: 4,
51
51
  maxTranscripts: 100,
52
52
  harnesses: {},
53
53
  };
@@ -40,6 +40,7 @@ import {
40
40
  formatMetrics,
41
41
  formatSpend,
42
42
  formatToolUse,
43
+ orderFanoutResults,
43
44
  parseTranscriptMeta,
44
45
  pruneOutputs,
45
46
  resolveVerifyPlan,
@@ -49,6 +50,7 @@ import {
49
50
  type VerifyResult,
50
51
  } from './activity.ts';
51
52
  import { isFanoutSpec, parseDelegateCommand, resolveDefaults, resolveHarnessList } from './command.ts';
53
+ import { acquireSlot, activeCount } from './concurrency.ts';
52
54
  import {
53
55
  type DelegateConfig,
54
56
  outputsDir as getOutputsDir,
@@ -69,7 +71,7 @@ import type { ActivityEvent, NormalizedPermission } from './harnesses/types.ts';
69
71
  import { delegationHint, stripMarker } from './hint.ts';
70
72
  import { NotifyBatcher } from './notify.ts';
71
73
  import { type FeedEntry, progressWindow } from './progress.ts';
72
- import { acquireRun, countActiveRuns, releaseRun } from './run-registry.ts';
74
+ import { formatFanoutChip, multiProgressWindow, type RunRow } from './progress-multi.ts';
73
75
  import { runHarness } from './runner.ts';
74
76
  import { type DelegateTemplate, loadTemplates } from './templates.ts';
75
77
  import { mapClaudeUsage } from './usage.ts';
@@ -98,6 +100,12 @@ interface DelegateOptions {
98
100
  onStream?: (text: string) => void;
99
101
  onActivity?: (ev: ActivityEvent) => void;
100
102
  signal?: AbortSignal;
103
+ /** Queue for a concurrency slot instead of failing fast when at capacity — fan-out only, see
104
+ * `acquireSlot` in concurrency.ts. Single-harness runs leave this false (the default). */
105
+ waitForSlot?: boolean;
106
+ /** Called once this run has acquired its concurrency slot and is about to actually start —
107
+ * fan-out uses it to flip a row from "queued" to "running". */
108
+ onAcquired?: () => void;
101
109
  }
102
110
 
103
111
  /** Verify commands run on the host after the harness exits — bounded independent of harness timeoutMs. */
@@ -134,18 +142,6 @@ async function runVerify(pi: ExtensionAPI, cwd: string, command: string): Promis
134
142
  }
135
143
  }
136
144
 
137
- const activeRuns = new Map<string, number>();
138
- let globalActiveRuns = 0;
139
-
140
- function getMaxConcurrentGlobal(): number {
141
- const cfg = loadConfig();
142
- if (typeof cfg.maxConcurrent === 'number') return cfg.maxConcurrent;
143
- // SAFETY: maxConcurrent is validated to be number or object with global/perHarness in loadConfig
144
- const mc = cfg.maxConcurrent as unknown as { global?: number }; // SAFETY: maxConcurrent validated in loadConfig
145
- if (typeof mc.global === 'number') return mc.global;
146
- return 1;
147
- }
148
-
149
145
  async function closeWhenMounted(getClose: () => (() => void) | null, capMs: number): Promise<void> {
150
146
  const close = getClose();
151
147
  if (close) {
@@ -419,7 +415,7 @@ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promis
419
415
  templates = loadTemplates(ctx.cwd, h).size;
420
416
  } catch {}
421
417
  // 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));
418
+ const active = activeCount(h);
423
419
  const hint = !det.ok && det.hint ? ` ← ${det.hint}` : '';
424
420
  lines.push(
425
421
  `${h.padEnd(20)} ${bin.padEnd(8)} ${ok.padEnd(3)} ${ver.padEnd(20)} ${String(outputs).padEnd(8)} ${String(templates).padEnd(10)} ${active}${hint}`,
@@ -437,7 +433,7 @@ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promis
437
433
  if (!harnessFilter) {
438
434
  lines.push('');
439
435
  lines.push(
440
- `global active: ${Math.max(globalActiveRuns, countActiveRuns())} · aliases: ${
436
+ `global active: ${activeCount()} · aliases: ${
441
437
  Object.entries(ALIASES)
442
438
  .map(([k, v]) => `${k}→${v}`)
443
439
  .join(', ') || '—'
@@ -524,32 +520,16 @@ async function delegate(
524
520
  const task = opts.task || template.defaultTask;
525
521
  if (!task) throw new Error(`delegate mode "${mode}" requires a task`);
526
522
 
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.
529
- const maxGlobal = getMaxConcurrentGlobal();
530
- const perHarnessCount = Math.max(activeRuns.get(harnessName) ?? 0, countActiveRuns(harnessName));
531
- const globalCount = Math.max(globalActiveRuns, countActiveRuns());
532
- if (maxGlobal > 0 && globalCount >= maxGlobal)
533
- throw new Error('another delegate run is already in progress (global limit)');
534
- // per-harness limit if configured as object
535
- const perHarnessLimit = (() => {
536
- const mc = config.maxConcurrent as unknown as { perHarness?: Record<string, number> };
537
- if (mc && typeof mc === 'object' && mc.perHarness && typeof mc.perHarness[harnessName] === 'number') {
538
- const v = mc.perHarness[harnessName];
539
- if (typeof v === 'number') return v;
540
- }
541
- return maxGlobal;
542
- })();
543
- if (perHarnessLimit > 0 && perHarnessCount >= perHarnessLimit)
544
- throw new Error(`another ${harnessName} run is already in progress`);
545
- activeRuns.set(harnessName, perHarnessCount + 1);
546
- globalActiveRuns++;
547
- const runHandle = acquireRun(harnessName, mode);
548
- const release = () => {
549
- activeRuns.set(harnessName, Math.max(0, (activeRuns.get(harnessName) ?? 1) - 1));
550
- globalActiveRuns = Math.max(0, globalActiveRuns - 1);
551
- releaseRun(runHandle);
552
- };
523
+ // concurrency guard — see concurrency.ts. Single runs (waitForSlot unset) fail fast at capacity,
524
+ // exactly as before; fan-out passes waitForSlot:true to queue instead.
525
+ const release = await acquireSlot({
526
+ harness: harnessName,
527
+ mode,
528
+ config,
529
+ wait: opts.waitForSlot ?? false,
530
+ signal: opts.signal,
531
+ });
532
+ opts.onAcquired?.();
553
533
 
554
534
  let scopeText: string | null = opts.scope ?? null;
555
535
  if (opts.scope === 'diff') {
@@ -851,8 +831,9 @@ async function runDelegateForTool(
851
831
  }
852
832
 
853
833
  /** `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. */
834
+ * existing `delegate()` engine concurrently across all of them (bounded by `maxConcurrent` via
835
+ * `acquireSlot({wait:true})` see concurrency.ts), and mechanically synthesize one comparison
836
+ * report ordered by the resolved harness list regardless of completion order. No second model call. */
856
837
  async function runFanoutTool(
857
838
  pi: ExtensionAPI,
858
839
  ctx: ExtensionContext,
@@ -875,16 +856,12 @@ async function runFanoutTool(
875
856
  }
876
857
 
877
858
  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
859
 
886
- for (const h of resolved) {
887
- onUpdate?.({ content: [{ type: 'text', text: `[${h}] running…` }], details: { progress: 0.5 } });
860
+ type TaskResult = FanoutRunSummary & {
861
+ usage?: import('./harnesses/types.ts').StreamedUsage | null;
862
+ };
863
+ const tasks = resolved.map(async (h): Promise<TaskResult> => {
864
+ onUpdate?.({ content: [{ type: 'text', text: `[${h}] queued…` }], details: { progress: 0.5 } });
888
865
  try {
889
866
  const run = await runDelegateForTool(
890
867
  pi,
@@ -901,13 +878,16 @@ async function runFanoutTool(
901
878
  sessionId: params.sessionId,
902
879
  pr: params.pr,
903
880
  // no verify: intentionally not model-settable — see DelegateToolParams
881
+ waitForSlot: true,
882
+ onAcquired: () =>
883
+ onUpdate?.({ content: [{ type: 'text', text: `[${h}] running…` }], details: { progress: 0.5 } }),
904
884
  },
905
885
  signal,
906
886
  onUpdate,
907
887
  `[${h}] `,
908
888
  );
909
889
  const summary = summarize(run.content);
910
- runs.push({
890
+ return {
911
891
  harness: h,
912
892
  ok: !run.result.isError,
913
893
  metrics: formatMetrics({
@@ -922,19 +902,32 @@ async function runFanoutTool(
922
902
  file: (run.details.file as string) ?? undefined,
923
903
  sessionId: (run.details.sessionId as string) ?? undefined,
924
904
  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
- }
905
+ usage: run.result.usage,
906
+ };
936
907
  } catch (err) {
937
- runs.push({ harness: h, ok: false, cost: null, error: err instanceof Error ? err.message : String(err) });
908
+ return { harness: h, ok: false, cost: null, error: err instanceof Error ? err.message : String(err) };
909
+ }
910
+ });
911
+
912
+ const settled = await Promise.all(tasks);
913
+ const runs = orderFanoutResults(resolved, settled);
914
+
915
+ let sumInput = 0;
916
+ let sumOutput = 0;
917
+ let sumCacheCreate = 0;
918
+ let sumCacheRead = 0;
919
+ let sumCost = 0;
920
+ let anyCostKnown = false;
921
+ for (const r of runs) {
922
+ if (r.usage) {
923
+ sumInput += r.usage.inputTokens;
924
+ sumOutput += r.usage.outputTokens;
925
+ sumCacheCreate += r.usage.cacheCreationInputTokens;
926
+ sumCacheRead += r.usage.cacheReadInputTokens;
927
+ }
928
+ if (r.cost !== null) {
929
+ sumCost += r.cost;
930
+ anyCostKnown = true;
938
931
  }
939
932
  }
940
933
 
@@ -1308,9 +1301,164 @@ export default function (pi: ExtensionAPI) {
1308
1301
  return { result: failed ? null : result, error: runState.error, cancelled };
1309
1302
  };
1310
1303
 
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. */
1304
+ interface FanoutSpec {
1305
+ harnessName: string;
1306
+ task: string;
1307
+ scope?: string;
1308
+ model?: string;
1309
+ budget?: number;
1310
+ sessionId?: string;
1311
+ pr?: string;
1312
+ verify?: string;
1313
+ isDanger: boolean;
1314
+ }
1315
+ interface FanoutOutcome {
1316
+ harnessName: string;
1317
+ result: Awaited<ReturnType<typeof delegate>> | null;
1318
+ error: Error | null;
1319
+ cancelled: boolean;
1320
+ }
1321
+
1322
+ /** Run `delegate()` concurrently across every spec in one multi-run overlay — the fan-out
1323
+ * counterpart to `runOneDelegation`. Concurrency is bounded by `maxConcurrent`: every run passes
1324
+ * `waitForSlot:true`, so `acquireSlot` (concurrency.ts) queues the ones that don't fit instead of
1325
+ * failing them, and a fan-out never exceeds the configured cap just because it's a fan-out.
1326
+ * Double-ESC cancel aborts every in-flight (and still-queued) run via one shared AbortController. */
1327
+ const runFanoutConcurrent = async (ctx: ExtensionContext, mode: string | undefined, specs: FanoutSpec[]) => {
1328
+ const ac = new AbortController();
1329
+ let cancelledAll = false;
1330
+ const runId = ++activeRunId;
1331
+ const clearActive = () => {
1332
+ if (activeOverlay?.runId === runId) activeOverlay = null;
1333
+ };
1334
+ const modeForDisplay = mode ?? 'general';
1335
+ const anyDanger = specs.some(s => s.isDanger);
1336
+ const overallStart = Date.now();
1337
+ const rows: RunRow[] = specs.map(s => ({
1338
+ harness: s.harnessName,
1339
+ startedAt: null,
1340
+ status: 'queued',
1341
+ activity: '',
1342
+ }));
1343
+ let requestRender: (() => void) | null = null;
1344
+
1345
+ let chipLastPush = 0;
1346
+ const pushChip = () => {
1347
+ if (!ctx.hasUI) return;
1348
+ const now = Date.now();
1349
+ if (now - chipLastPush < 500) return;
1350
+ chipLastPush = now;
1351
+ const theme = ctx.ui.theme;
1352
+ ctx.ui.setStatus('delegate', theme.fg('accent', '●') + theme.fg('dim', ` ${formatFanoutChip(rows)}`));
1353
+ };
1354
+
1355
+ const runOne = async (spec: FanoutSpec, idx: number): Promise<FanoutOutcome> => {
1356
+ const setRow = (patch: Partial<RunRow>) => {
1357
+ rows[idx] = { ...rows[idx], ...patch };
1358
+ requestRender?.();
1359
+ pushChip();
1360
+ };
1361
+ let liveTail = '';
1362
+ const onActivity = (ev: ActivityEvent) => {
1363
+ if (ev.kind === 'tool_input') setRow({ activity: `▶ ${formatToolUse(ev.name, ev.input)}` });
1364
+ else if (ev.kind === 'tool_result')
1365
+ setRow({
1366
+ activity: rows[idx].activity ? `${rows[idx].activity}${ev.isError ? ' ✗' : ' ✓'}` : rows[idx].activity,
1367
+ });
1368
+ else if (ev.kind === 'thinking') setRow({ activity: '💭 thinking…' });
1369
+ };
1370
+ const runState: { error: Error | null } = { error: null };
1371
+ const run = delegate(pi, ctx, {
1372
+ harness: spec.harnessName,
1373
+ task: spec.task,
1374
+ mode,
1375
+ scope: spec.scope,
1376
+ model: spec.model,
1377
+ maxBudgetUsd: spec.budget,
1378
+ sessionId: spec.sessionId,
1379
+ pr: spec.pr,
1380
+ verify: spec.verify,
1381
+ signal: ac.signal,
1382
+ waitForSlot: true,
1383
+ onAcquired: () => setRow({ status: 'running', startedAt: Date.now() }),
1384
+ onStream: t => {
1385
+ liveTail = (liveTail + t).slice(-200);
1386
+ setRow({ activity: `✍ ${liveTail}` });
1387
+ },
1388
+ onActivity,
1389
+ }).catch((err: unknown) => {
1390
+ runState.error = err instanceof Error ? err : new Error(String(err));
1391
+ return null;
1392
+ });
1393
+ const result = await run;
1394
+ const failed = cancelledAll || !result;
1395
+ // On failure keep context on the row: the reason if we have one, else whatever the run was
1396
+ // last doing. Blanking it here would drop the only on-screen hint at *why* it failed.
1397
+ const reason = runState.error ? runState.error.message.split('\n')[0].slice(0, 60) : '';
1398
+ setRow({
1399
+ status: failed ? 'failed' : 'done',
1400
+ activity: failed ? reason || rows[idx].activity : '',
1401
+ });
1402
+ return {
1403
+ harnessName: spec.harnessName,
1404
+ result: failed ? null : result,
1405
+ error: runState.error,
1406
+ cancelled: cancelledAll,
1407
+ };
1408
+ };
1409
+
1410
+ const allSettled = Promise.all(specs.map((spec, idx) => runOne(spec, idx)));
1411
+
1412
+ let closeWindow: (() => void) | null = null;
1413
+ let outcomes: FanoutOutcome[];
1414
+ if (ctx.hasUI) {
1415
+ let overlayHandle: OverlayHandle | null = null;
1416
+ const uiPromise = ctx.ui
1417
+ .custom(
1418
+ (tui, theme, _kb, done) => {
1419
+ requestRender = () => tui.requestRender();
1420
+ closeWindow = () => done(undefined);
1421
+ return multiProgressWindow(tui, theme, {
1422
+ mode: modeForDisplay,
1423
+ startedAt: overallStart,
1424
+ getRows: () => rows,
1425
+ dangerous: anyDanger,
1426
+ onCancel: () => {
1427
+ cancelledAll = true;
1428
+ ac.abort();
1429
+ },
1430
+ onMinimize: () => {
1431
+ overlayHandle?.setHidden(true);
1432
+ overlayHandle?.unfocus();
1433
+ },
1434
+ });
1435
+ },
1436
+ {
1437
+ overlay: true,
1438
+ overlayOptions: { width: '70%', maxHeight: '60%', anchor: 'top-center' },
1439
+ onHandle: h => {
1440
+ overlayHandle = h;
1441
+ activeOverlay = { show: () => h.setHidden(false), focus: () => h.focus(), runId };
1442
+ h.focus();
1443
+ },
1444
+ },
1445
+ )
1446
+ .catch(() => {});
1447
+ outcomes = await allSettled;
1448
+ await closeWhenMounted(() => closeWindow, 2000);
1449
+ await uiPromise;
1450
+ } else {
1451
+ outcomes = await allSettled;
1452
+ }
1453
+ clearActive();
1454
+ if (ctx.hasUI) ctx.ui.setStatus('delegate', undefined);
1455
+ return outcomes;
1456
+ };
1457
+
1458
+ /** `/delegate all …` / `/delegate a,b …` — resolve to detected harnesses, run `delegate()`
1459
+ * concurrently across all of them in one multi-run overlay (see `runFanoutConcurrent`), batch
1460
+ * success notifications, and inject one synthesized comparison report ordered by the resolved
1461
+ * harness list regardless of completion order. */
1314
1462
  const runFanoutCommand = async (ctx: ExtensionContext, parsed: ReturnType<typeof parseDelegateCommand>) => {
1315
1463
  const harnessSpec = parsed.harness as string;
1316
1464
  const detection = await detectAll();
@@ -1328,19 +1476,23 @@ export default function (pi: ExtensionAPI) {
1328
1476
  }
1329
1477
 
1330
1478
  const modeForReport = parsed.mode ?? loadConfig().defaultMode;
1331
- const runs: FanoutRunSummary[] = [];
1332
1479
  const batcher = new NotifyBatcher((text, level) => {
1333
1480
  if (ctx.hasUI) ctx.ui.notify(text, level);
1334
1481
  else process.stdout.write(`${text}\n`);
1335
1482
  });
1336
1483
 
1484
+ // Resolve each harness's task/scope/danger flag up front — cheap and synchronous — so a
1485
+ // harness that can't even start (e.g. mode needs a prompt) fails immediately instead of
1486
+ // occupying a concurrency slot.
1487
+ const specs: FanoutSpec[] = [];
1488
+ const immediateFailures: FanoutRunSummary[] = [];
1337
1489
  for (const h of resolved) {
1338
1490
  const templates = loadTemplates(ctx.cwd, h);
1339
1491
  const resolvedTaskScope = resolveDefaults(parsed, templates);
1340
1492
  const template = parsed.mode ? templates.get(parsed.mode) : undefined;
1341
1493
  if (!resolvedTaskScope) {
1342
1494
  const message = `mode "${parsed.mode ?? 'general'}" needs a prompt`;
1343
- runs.push({ harness: h, ok: false, cost: null, error: message });
1495
+ immediateFailures.push({ harness: h, ok: false, cost: null, error: message });
1344
1496
  batcher.failure(`${h}: ${message}`);
1345
1497
  continue;
1346
1498
  }
@@ -1349,9 +1501,8 @@ export default function (pi: ExtensionAPI) {
1349
1501
  (template?.nativePermission
1350
1502
  ? ['bypassPermissions', 'danger-full-access', 'danger'].includes(template.nativePermission)
1351
1503
  : false);
1352
- const outcome = await runOneDelegation(ctx, {
1504
+ specs.push({
1353
1505
  harnessName: h,
1354
- mode: parsed.mode,
1355
1506
  task: resolvedTaskScope.task,
1356
1507
  scope: resolvedTaskScope.scope,
1357
1508
  model: parsed.model,
@@ -1359,15 +1510,16 @@ export default function (pi: ExtensionAPI) {
1359
1510
  sessionId: parsed.sessionId,
1360
1511
  pr: parsed.pr,
1361
1512
  verify: parsed.verify,
1362
- template,
1363
1513
  isDanger,
1364
1514
  });
1515
+ }
1516
+
1517
+ const outcomes = specs.length > 0 ? await runFanoutConcurrent(ctx, parsed.mode, specs) : [];
1518
+ const completed: FanoutRunSummary[] = outcomes.map(outcome => {
1365
1519
  if (outcome.cancelled || !outcome.result) {
1366
1520
  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;
1521
+ batcher.failure(`${outcome.harnessName}: ${outcome.cancelled ? 'cancelled' : 'failed'} ${message}`);
1522
+ return { harness: outcome.harnessName, ok: false, cost: null, error: message };
1371
1523
  }
1372
1524
  const { content, details, result, verify } = outcome.result;
1373
1525
  const summary = summarize(content);
@@ -1378,8 +1530,9 @@ export default function (pi: ExtensionAPI) {
1378
1530
  contextPercent: typeof details.contextPercent === 'number' ? details.contextPercent : null,
1379
1531
  durationMs: typeof details.durationMs === 'number' ? details.durationMs : null,
1380
1532
  });
1381
- runs.push({
1382
- harness: h,
1533
+ batcher.success(`${outcome.harnessName} ${parsed.mode ?? 'general'} — ${metrics}`);
1534
+ return {
1535
+ harness: outcome.harnessName,
1383
1536
  ok: !result.isError,
1384
1537
  metrics,
1385
1538
  cost: result.totalCostUsd,
@@ -1387,10 +1540,10 @@ export default function (pi: ExtensionAPI) {
1387
1540
  file: (details.file as string) ?? undefined,
1388
1541
  sessionId: (details.sessionId as string) ?? undefined,
1389
1542
  verify,
1390
- });
1391
- batcher.success(`${h} ${parsed.mode ?? 'general'} — ${metrics}`);
1392
- }
1543
+ };
1544
+ });
1393
1545
 
1546
+ const runs = orderFanoutResults(resolved, [...immediateFailures, ...completed]);
1394
1547
  const okCount = runs.filter(r => r.ok).length;
1395
1548
  const report = buildFanoutReport({ runs, skipped, unknown });
1396
1549
  injectReport(ctx, {
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Live progress window for a concurrent multi-harness `/delegate all` fan-out — one overlay
3
+ * showing every run as a compact row (harness, elapsed, current activity, done/failed marker)
4
+ * instead of N stacked overlays or one feed with interleaved lines from different harnesses.
5
+ *
6
+ * Single-harness runs keep using `progressWindow` in progress.ts unchanged — this is only
7
+ * mounted for a fan-out. Shares `fmtElapsed` with it; deliberately not merged into one generic
8
+ * layout framework since the two views render fundamentally different things (one live feed vs
9
+ * N row summaries).
10
+ *
11
+ * Controls: same as progressWindow — ESC twice to cancel (aborts every in-flight run), `m` to
12
+ * minimize.
13
+ */
14
+
15
+ import type { Theme } from '@earendil-works/pi-coding-agent';
16
+ import { type Component, Key, matchesKey, type TUI, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
17
+ import { fmtElapsed } from './progress.ts';
18
+
19
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
20
+ const SPIN_INTERVAL_MS = 100;
21
+
22
+ export type RunStatus = 'queued' | 'running' | 'done' | 'failed';
23
+
24
+ export interface RunRow {
25
+ harness: string;
26
+ /** Set once the run has acquired its concurrency slot and started executing; null while queued. */
27
+ startedAt: number | null;
28
+ status: RunStatus;
29
+ /**
30
+ * Short current-activity text (tool call, "thinking…", or a text tail). Empty when queued or
31
+ * done; on a failed run it holds the failure reason (or the last activity seen), so the row
32
+ * still says *why* rather than going blank at the moment that matters most.
33
+ */
34
+ activity: string;
35
+ }
36
+
37
+ export interface MultiProgressWindowOptions {
38
+ /** Mode name shown in the title bar (e.g. "review"). */
39
+ mode: string;
40
+ /** Epoch ms when the fan-out started — drives the overall elapsed timer. */
41
+ startedAt: number;
42
+ /** Live per-harness row state. */
43
+ getRows: () => RunRow[];
44
+ /** Show an "unrestricted permissions" warning banner. */
45
+ dangerous?: boolean;
46
+ /** Called when the user confirms cancel — must abort every in-flight run. */
47
+ onCancel: () => void;
48
+ /** Called when the user presses `m` (minimize — runs continue in the background). */
49
+ onMinimize: () => void;
50
+ }
51
+
52
+ /** Per-status glyphs, matching the row markers in the overlay so the chip and the window read alike. */
53
+ const CHIP_GLYPHS: ReadonlyArray<readonly [RunStatus, string]> = [
54
+ ['done', '✓'],
55
+ ['failed', '✗'],
56
+ ['running', '▶'],
57
+ ['queued', '…'],
58
+ ];
59
+
60
+ /**
61
+ * Compact fan-out status-bar summary, e.g. `1✓ 1✗ 1▶ 1…`. Zero counts are omitted, so the common
62
+ * cases stay short (`4▶`, then `4✓`). Counting only `running` — as the first cut did — renders
63
+ * `0/4 running`, which reads as idle when runs have actually failed or are queued behind the cap.
64
+ * Pure — testable without a TUI.
65
+ */
66
+ export function formatFanoutChip(rows: RunRow[]): string {
67
+ const parts = CHIP_GLYPHS.map(([status, glyph]) => {
68
+ const n = rows.filter(r => r.status === status).length;
69
+ return n > 0 ? `${n}${glyph}` : null;
70
+ }).filter((s): s is string => s !== null);
71
+ return parts.length > 0 ? parts.join(' ') : `${rows.length}…`;
72
+ }
73
+
74
+ /** One row's marker + label, e.g. "✓ claude" / "✗ codex" / "⠋ opencode" / "… amp". Pure — testable
75
+ * without a TUI/theme. */
76
+ export function renderRowLabel(row: RunRow, frame: number): string {
77
+ const mark =
78
+ row.status === 'done'
79
+ ? '✓'
80
+ : row.status === 'failed'
81
+ ? '✗'
82
+ : row.status === 'running'
83
+ ? SPINNER[frame % SPINNER.length]
84
+ : '…';
85
+ return `${mark} ${row.harness}`;
86
+ }
87
+
88
+ /** Create the multi-run overlay component; disposes the spinner timer. */
89
+ export function multiProgressWindow(
90
+ tui: TUI,
91
+ theme: Theme,
92
+ opts: MultiProgressWindowOptions,
93
+ ): Component & { dispose(): void } {
94
+ let frame = 0;
95
+ let armed = false;
96
+ let armTimer: ReturnType<typeof setTimeout> | null = null;
97
+ const timer = setInterval(() => {
98
+ frame++;
99
+ tui.requestRender();
100
+ }, SPIN_INTERVAL_MS);
101
+
102
+ const disarm = () => {
103
+ armed = false;
104
+ if (armTimer) {
105
+ clearTimeout(armTimer);
106
+ armTimer = null;
107
+ }
108
+ };
109
+
110
+ return {
111
+ render(width: number): string[] {
112
+ const inner = Math.max(10, width - 4);
113
+ const padTo = (s: string, w: number) => `${s}${' '.repeat(Math.max(1, w - visibleWidth(s)))}`;
114
+ const out: string[] = [];
115
+ const rows = opts.getRows();
116
+
117
+ const done = rows.filter(r => r.status === 'done' || r.status === 'failed').length;
118
+ const title = `${SPINNER[frame % SPINNER.length]} delegate all · ${opts.mode} · ${done}/${rows.length}`;
119
+ const status = `⏱ ${fmtElapsed(Date.now() - opts.startedAt)}`;
120
+ const titleStr = `${title} · ${status}`;
121
+ const dash = '─'.repeat(Math.max(1, inner - visibleWidth(titleStr) - 2));
122
+ out.push(theme.fg('accent', `╭─ ${titleStr} ${dash}─╮`));
123
+
124
+ if (opts.dangerous) {
125
+ const banner = theme.fg('error', '⚠ danger — unrestricted access');
126
+ out.push(`│ ${padTo(banner, inner)} │`);
127
+ }
128
+
129
+ for (const row of rows) {
130
+ const label = renderRowLabel(row, frame);
131
+ const styledLabel =
132
+ row.status === 'done'
133
+ ? theme.fg('success', label)
134
+ : row.status === 'failed'
135
+ ? theme.fg('error', label)
136
+ : theme.fg('accent', label);
137
+ const elapsed = row.startedAt !== null ? fmtElapsed(Date.now() - row.startedAt) : 'queued';
138
+ const activity = row.activity ? ` ${theme.fg('muted', row.activity)}` : '';
139
+ const line = `${styledLabel} ${theme.fg('dim', elapsed)}${activity}`;
140
+ out.push(`│ ${padTo(truncateToWidth(line, inner), inner)} │`);
141
+ }
142
+
143
+ const hint = armed
144
+ ? theme.fg('warning', 'press esc again to cancel all') + theme.fg('dim', ' · m minimize')
145
+ : theme.fg('dim', 'esc cancel all') + theme.fg('dim', ' · m minimize');
146
+ out.push(`│ ${padTo(hint, inner)} │`);
147
+
148
+ out.push(theme.fg('accent', `╰${'─'.repeat(Math.max(1, width - 2))}╯`));
149
+ return out;
150
+ },
151
+ handleInput(data: string): void {
152
+ if (matchesKey(data, Key.escape)) {
153
+ if (armed) {
154
+ disarm();
155
+ opts.onCancel();
156
+ } else {
157
+ armed = true;
158
+ armTimer = setTimeout(() => {
159
+ armed = false;
160
+ armTimer = null;
161
+ tui.requestRender();
162
+ }, 1500);
163
+ tui.requestRender();
164
+ }
165
+ } else if (data === 'm') {
166
+ disarm();
167
+ opts.onMinimize();
168
+ }
169
+ },
170
+ invalidate(): void {
171
+ // stateless render — nothing to clear
172
+ },
173
+ dispose(): void {
174
+ clearInterval(timer);
175
+ disarm();
176
+ },
177
+ };
178
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-harness-delegate",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Delegate work to any harness (Claude Code, Muse, OpenCode, Amp) from the pi coding agent \u2014 code reviews, plans, implementation, security audits, docs, or your own custom templates.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.14",