pi-harness-delegate 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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 plus elapsed and spend so far (e.g. `● 1✓ 1✗ 1▶ 1… · ⏱ 0:42 · $0.175` — done, failed, running, queued; zero status counts are omitted, so it reads `● 4▶ · ⏱ 0:05` while all four are in flight; the spend segment itself only appears once a run has actually reported a cost). A harness that fails keeps its failure reason on its row rather than blanking, so the overlay still says *why*. Once every row is done or failed, the overlay lingers ~3s on the finished board before closing (Esc or `m` dismisses it immediately) so glancing back after a fan-out still shows the final state instead of an empty screen. Single-harness runs keep the original one-run overlay unchanged, including its live activity feed showing a `+N earlier` marker instead of silently dropping older entries once the feed outgrows the visible window.
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,10 +100,19 @@ 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. */
104
112
  const VERIFY_TIMEOUT_MS = 5 * 60_000;
113
+ /** How long the fan-out overlay lingers on the finished board after the last run resolves, so a
114
+ * user who looked away still catches the final state instead of it clearing instantly. */
115
+ const FANOUT_LINGER_MS = 3000;
105
116
 
106
117
  /**
107
118
  * Run a verify command in-process on the host (never delegated to the harness). Report-only —
@@ -134,18 +145,6 @@ async function runVerify(pi: ExtensionAPI, cwd: string, command: string): Promis
134
145
  }
135
146
  }
136
147
 
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
148
  async function closeWhenMounted(getClose: () => (() => void) | null, capMs: number): Promise<void> {
150
149
  const close = getClose();
151
150
  if (close) {
@@ -419,7 +418,7 @@ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promis
419
418
  templates = loadTemplates(ctx.cwd, h).size;
420
419
  } catch {}
421
420
  // 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));
421
+ const active = activeCount(h);
423
422
  const hint = !det.ok && det.hint ? ` ← ${det.hint}` : '';
424
423
  lines.push(
425
424
  `${h.padEnd(20)} ${bin.padEnd(8)} ${ok.padEnd(3)} ${ver.padEnd(20)} ${String(outputs).padEnd(8)} ${String(templates).padEnd(10)} ${active}${hint}`,
@@ -437,7 +436,7 @@ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promis
437
436
  if (!harnessFilter) {
438
437
  lines.push('');
439
438
  lines.push(
440
- `global active: ${Math.max(globalActiveRuns, countActiveRuns())} · aliases: ${
439
+ `global active: ${activeCount()} · aliases: ${
441
440
  Object.entries(ALIASES)
442
441
  .map(([k, v]) => `${k}→${v}`)
443
442
  .join(', ') || '—'
@@ -524,32 +523,16 @@ async function delegate(
524
523
  const task = opts.task || template.defaultTask;
525
524
  if (!task) throw new Error(`delegate mode "${mode}" requires a task`);
526
525
 
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
- };
526
+ // concurrency guard — see concurrency.ts. Single runs (waitForSlot unset) fail fast at capacity,
527
+ // exactly as before; fan-out passes waitForSlot:true to queue instead.
528
+ const release = await acquireSlot({
529
+ harness: harnessName,
530
+ mode,
531
+ config,
532
+ wait: opts.waitForSlot ?? false,
533
+ signal: opts.signal,
534
+ });
535
+ opts.onAcquired?.();
553
536
 
554
537
  let scopeText: string | null = opts.scope ?? null;
555
538
  if (opts.scope === 'diff') {
@@ -851,8 +834,9 @@ async function runDelegateForTool(
851
834
  }
852
835
 
853
836
  /** `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. */
837
+ * existing `delegate()` engine concurrently across all of them (bounded by `maxConcurrent` via
838
+ * `acquireSlot({wait:true})` see concurrency.ts), and mechanically synthesize one comparison
839
+ * report ordered by the resolved harness list regardless of completion order. No second model call. */
856
840
  async function runFanoutTool(
857
841
  pi: ExtensionAPI,
858
842
  ctx: ExtensionContext,
@@ -875,16 +859,12 @@ async function runFanoutTool(
875
859
  }
876
860
 
877
861
  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
862
 
886
- for (const h of resolved) {
887
- onUpdate?.({ content: [{ type: 'text', text: `[${h}] running…` }], details: { progress: 0.5 } });
863
+ type TaskResult = FanoutRunSummary & {
864
+ usage?: import('./harnesses/types.ts').StreamedUsage | null;
865
+ };
866
+ const tasks = resolved.map(async (h): Promise<TaskResult> => {
867
+ onUpdate?.({ content: [{ type: 'text', text: `[${h}] queued…` }], details: { progress: 0.5 } });
888
868
  try {
889
869
  const run = await runDelegateForTool(
890
870
  pi,
@@ -901,13 +881,16 @@ async function runFanoutTool(
901
881
  sessionId: params.sessionId,
902
882
  pr: params.pr,
903
883
  // no verify: intentionally not model-settable — see DelegateToolParams
884
+ waitForSlot: true,
885
+ onAcquired: () =>
886
+ onUpdate?.({ content: [{ type: 'text', text: `[${h}] running…` }], details: { progress: 0.5 } }),
904
887
  },
905
888
  signal,
906
889
  onUpdate,
907
890
  `[${h}] `,
908
891
  );
909
892
  const summary = summarize(run.content);
910
- runs.push({
893
+ return {
911
894
  harness: h,
912
895
  ok: !run.result.isError,
913
896
  metrics: formatMetrics({
@@ -922,19 +905,32 @@ async function runFanoutTool(
922
905
  file: (run.details.file as string) ?? undefined,
923
906
  sessionId: (run.details.sessionId as string) ?? undefined,
924
907
  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
- }
908
+ usage: run.result.usage,
909
+ };
936
910
  } catch (err) {
937
- runs.push({ harness: h, ok: false, cost: null, error: err instanceof Error ? err.message : String(err) });
911
+ return { harness: h, ok: false, cost: null, error: err instanceof Error ? err.message : String(err) };
912
+ }
913
+ });
914
+
915
+ const settled = await Promise.all(tasks);
916
+ const runs = orderFanoutResults(resolved, settled);
917
+
918
+ let sumInput = 0;
919
+ let sumOutput = 0;
920
+ let sumCacheCreate = 0;
921
+ let sumCacheRead = 0;
922
+ let sumCost = 0;
923
+ let anyCostKnown = false;
924
+ for (const r of runs) {
925
+ if (r.usage) {
926
+ sumInput += r.usage.inputTokens;
927
+ sumOutput += r.usage.outputTokens;
928
+ sumCacheCreate += r.usage.cacheCreationInputTokens;
929
+ sumCacheRead += r.usage.cacheReadInputTokens;
930
+ }
931
+ if (r.cost !== null) {
932
+ sumCost += r.cost;
933
+ anyCostKnown = true;
938
934
  }
939
935
  }
940
936
 
@@ -1308,9 +1304,187 @@ export default function (pi: ExtensionAPI) {
1308
1304
  return { result: failed ? null : result, error: runState.error, cancelled };
1309
1305
  };
1310
1306
 
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. */
1307
+ interface FanoutSpec {
1308
+ harnessName: string;
1309
+ task: string;
1310
+ scope?: string;
1311
+ model?: string;
1312
+ budget?: number;
1313
+ sessionId?: string;
1314
+ pr?: string;
1315
+ verify?: string;
1316
+ isDanger: boolean;
1317
+ }
1318
+ interface FanoutOutcome {
1319
+ harnessName: string;
1320
+ result: Awaited<ReturnType<typeof delegate>> | null;
1321
+ error: Error | null;
1322
+ cancelled: boolean;
1323
+ }
1324
+
1325
+ /** Run `delegate()` concurrently across every spec in one multi-run overlay — the fan-out
1326
+ * counterpart to `runOneDelegation`. Concurrency is bounded by `maxConcurrent`: every run passes
1327
+ * `waitForSlot:true`, so `acquireSlot` (concurrency.ts) queues the ones that don't fit instead of
1328
+ * failing them, and a fan-out never exceeds the configured cap just because it's a fan-out.
1329
+ * Double-ESC cancel aborts every in-flight (and still-queued) run via one shared AbortController. */
1330
+ const runFanoutConcurrent = async (ctx: ExtensionContext, mode: string | undefined, specs: FanoutSpec[]) => {
1331
+ const ac = new AbortController();
1332
+ let cancelledAll = false;
1333
+ const runId = ++activeRunId;
1334
+ const clearActive = () => {
1335
+ if (activeOverlay?.runId === runId) activeOverlay = null;
1336
+ };
1337
+ const modeForDisplay = mode ?? 'general';
1338
+ const anyDanger = specs.some(s => s.isDanger);
1339
+ const overallStart = Date.now();
1340
+ const rows: RunRow[] = specs.map(s => ({
1341
+ harness: s.harnessName,
1342
+ startedAt: null,
1343
+ status: 'queued',
1344
+ activity: '',
1345
+ costUsd: null,
1346
+ }));
1347
+ let requestRender: (() => void) | null = null;
1348
+
1349
+ let chipLastPush = 0;
1350
+ const pushChip = () => {
1351
+ if (!ctx.hasUI) return;
1352
+ const now = Date.now();
1353
+ if (now - chipLastPush < 500) return;
1354
+ chipLastPush = now;
1355
+ const theme = ctx.ui.theme;
1356
+ ctx.ui.setStatus(
1357
+ 'delegate',
1358
+ theme.fg('accent', '●') + theme.fg('dim', ` ${formatFanoutChip(rows, now - overallStart)}`),
1359
+ );
1360
+ };
1361
+
1362
+ const runOne = async (spec: FanoutSpec, idx: number): Promise<FanoutOutcome> => {
1363
+ const setRow = (patch: Partial<RunRow>) => {
1364
+ rows[idx] = { ...rows[idx], ...patch };
1365
+ requestRender?.();
1366
+ pushChip();
1367
+ };
1368
+ let liveTail = '';
1369
+ const onActivity = (ev: ActivityEvent) => {
1370
+ if (ev.kind === 'tool_input') setRow({ activity: `▶ ${formatToolUse(ev.name, ev.input)}` });
1371
+ else if (ev.kind === 'tool_result')
1372
+ setRow({
1373
+ activity: rows[idx].activity ? `${rows[idx].activity}${ev.isError ? ' ✗' : ' ✓'}` : rows[idx].activity,
1374
+ });
1375
+ else if (ev.kind === 'thinking') setRow({ activity: '💭 thinking…' });
1376
+ };
1377
+ const runState: { error: Error | null } = { error: null };
1378
+ const run = delegate(pi, ctx, {
1379
+ harness: spec.harnessName,
1380
+ task: spec.task,
1381
+ mode,
1382
+ scope: spec.scope,
1383
+ model: spec.model,
1384
+ maxBudgetUsd: spec.budget,
1385
+ sessionId: spec.sessionId,
1386
+ pr: spec.pr,
1387
+ verify: spec.verify,
1388
+ signal: ac.signal,
1389
+ waitForSlot: true,
1390
+ onAcquired: () => setRow({ status: 'running', startedAt: Date.now() }),
1391
+ onStream: t => {
1392
+ liveTail = (liveTail + t).slice(-200);
1393
+ setRow({ activity: `✍ ${liveTail}` });
1394
+ },
1395
+ onActivity,
1396
+ }).catch((err: unknown) => {
1397
+ runState.error = err instanceof Error ? err : new Error(String(err));
1398
+ return null;
1399
+ });
1400
+ const result = await run;
1401
+ const failed = cancelledAll || !result;
1402
+ // On failure keep context on the row: the reason if we have one, else whatever the run was
1403
+ // last doing. Blanking it here would drop the only on-screen hint at *why* it failed.
1404
+ const reason = runState.error ? runState.error.message.split('\n')[0].slice(0, 60) : '';
1405
+ setRow({
1406
+ status: failed ? 'failed' : 'done',
1407
+ activity: failed ? reason || rows[idx].activity : '',
1408
+ costUsd: !failed && result ? result.result.totalCostUsd : null,
1409
+ });
1410
+ return {
1411
+ harnessName: spec.harnessName,
1412
+ result: failed ? null : result,
1413
+ error: runState.error,
1414
+ cancelled: cancelledAll,
1415
+ };
1416
+ };
1417
+
1418
+ const allSettled = Promise.all(specs.map((spec, idx) => runOne(spec, idx)));
1419
+
1420
+ let closeWindow: (() => void) | null = null;
1421
+ let outcomes: FanoutOutcome[];
1422
+ if (ctx.hasUI) {
1423
+ let overlayHandle: OverlayHandle | null = null;
1424
+ let resolveDismiss = (): void => {};
1425
+ const dismissed = new Promise<void>(resolve => {
1426
+ resolveDismiss = () => resolve();
1427
+ });
1428
+ const uiPromise = ctx.ui
1429
+ .custom(
1430
+ (tui, theme, _kb, done) => {
1431
+ requestRender = () => tui.requestRender();
1432
+ closeWindow = () => done(undefined);
1433
+ return multiProgressWindow(tui, theme, {
1434
+ mode: modeForDisplay,
1435
+ startedAt: overallStart,
1436
+ getRows: () => rows,
1437
+ dangerous: anyDanger,
1438
+ onCancel: () => {
1439
+ cancelledAll = true;
1440
+ ac.abort();
1441
+ },
1442
+ onMinimize: () => {
1443
+ overlayHandle?.setHidden(true);
1444
+ overlayHandle?.unfocus();
1445
+ },
1446
+ onDismiss: () => resolveDismiss(),
1447
+ });
1448
+ },
1449
+ {
1450
+ overlay: true,
1451
+ overlayOptions: { width: '70%', maxHeight: '60%', anchor: 'top-center' },
1452
+ onHandle: h => {
1453
+ overlayHandle = h;
1454
+ activeOverlay = { show: () => h.setHidden(false), focus: () => h.focus(), runId };
1455
+ h.focus();
1456
+ },
1457
+ },
1458
+ )
1459
+ .catch(() => {});
1460
+ outcomes = await allSettled;
1461
+ // Cancelling already means "I'm done watching" — skip the linger so the overlay closes
1462
+ // right away instead of sitting on a cancelled board for FANOUT_LINGER_MS.
1463
+ if (cancelledAll) resolveDismiss();
1464
+ // Tear the overlay down after a short linger (or immediately on Esc/m/cancel) — in the
1465
+ // background, so this doesn't delay the outcomes we're about to return (and thus the
1466
+ // injected report). `activeOverlay` stays valid for `/delegate watch` until this settles.
1467
+ void (async () => {
1468
+ const timer = setTimeout(() => resolveDismiss(), FANOUT_LINGER_MS);
1469
+ timer.unref?.();
1470
+ await dismissed;
1471
+ clearTimeout(timer);
1472
+ await closeWhenMounted(() => closeWindow, 2000);
1473
+ await uiPromise;
1474
+ clearActive();
1475
+ ctx.ui.setStatus('delegate', undefined);
1476
+ })();
1477
+ } else {
1478
+ outcomes = await allSettled;
1479
+ clearActive();
1480
+ }
1481
+ return outcomes;
1482
+ };
1483
+
1484
+ /** `/delegate all …` / `/delegate a,b …` — resolve to detected harnesses, run `delegate()`
1485
+ * concurrently across all of them in one multi-run overlay (see `runFanoutConcurrent`), batch
1486
+ * success notifications, and inject one synthesized comparison report ordered by the resolved
1487
+ * harness list regardless of completion order. */
1314
1488
  const runFanoutCommand = async (ctx: ExtensionContext, parsed: ReturnType<typeof parseDelegateCommand>) => {
1315
1489
  const harnessSpec = parsed.harness as string;
1316
1490
  const detection = await detectAll();
@@ -1328,19 +1502,23 @@ export default function (pi: ExtensionAPI) {
1328
1502
  }
1329
1503
 
1330
1504
  const modeForReport = parsed.mode ?? loadConfig().defaultMode;
1331
- const runs: FanoutRunSummary[] = [];
1332
1505
  const batcher = new NotifyBatcher((text, level) => {
1333
1506
  if (ctx.hasUI) ctx.ui.notify(text, level);
1334
1507
  else process.stdout.write(`${text}\n`);
1335
1508
  });
1336
1509
 
1510
+ // Resolve each harness's task/scope/danger flag up front — cheap and synchronous — so a
1511
+ // harness that can't even start (e.g. mode needs a prompt) fails immediately instead of
1512
+ // occupying a concurrency slot.
1513
+ const specs: FanoutSpec[] = [];
1514
+ const immediateFailures: FanoutRunSummary[] = [];
1337
1515
  for (const h of resolved) {
1338
1516
  const templates = loadTemplates(ctx.cwd, h);
1339
1517
  const resolvedTaskScope = resolveDefaults(parsed, templates);
1340
1518
  const template = parsed.mode ? templates.get(parsed.mode) : undefined;
1341
1519
  if (!resolvedTaskScope) {
1342
1520
  const message = `mode "${parsed.mode ?? 'general'}" needs a prompt`;
1343
- runs.push({ harness: h, ok: false, cost: null, error: message });
1521
+ immediateFailures.push({ harness: h, ok: false, cost: null, error: message });
1344
1522
  batcher.failure(`${h}: ${message}`);
1345
1523
  continue;
1346
1524
  }
@@ -1349,9 +1527,8 @@ export default function (pi: ExtensionAPI) {
1349
1527
  (template?.nativePermission
1350
1528
  ? ['bypassPermissions', 'danger-full-access', 'danger'].includes(template.nativePermission)
1351
1529
  : false);
1352
- const outcome = await runOneDelegation(ctx, {
1530
+ specs.push({
1353
1531
  harnessName: h,
1354
- mode: parsed.mode,
1355
1532
  task: resolvedTaskScope.task,
1356
1533
  scope: resolvedTaskScope.scope,
1357
1534
  model: parsed.model,
@@ -1359,15 +1536,16 @@ export default function (pi: ExtensionAPI) {
1359
1536
  sessionId: parsed.sessionId,
1360
1537
  pr: parsed.pr,
1361
1538
  verify: parsed.verify,
1362
- template,
1363
1539
  isDanger,
1364
1540
  });
1541
+ }
1542
+
1543
+ const outcomes = specs.length > 0 ? await runFanoutConcurrent(ctx, parsed.mode, specs) : [];
1544
+ const completed: FanoutRunSummary[] = outcomes.map(outcome => {
1365
1545
  if (outcome.cancelled || !outcome.result) {
1366
1546
  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;
1547
+ batcher.failure(`${outcome.harnessName}: ${outcome.cancelled ? 'cancelled' : 'failed'} ${message}`);
1548
+ return { harness: outcome.harnessName, ok: false, cost: null, error: message };
1371
1549
  }
1372
1550
  const { content, details, result, verify } = outcome.result;
1373
1551
  const summary = summarize(content);
@@ -1378,8 +1556,9 @@ export default function (pi: ExtensionAPI) {
1378
1556
  contextPercent: typeof details.contextPercent === 'number' ? details.contextPercent : null,
1379
1557
  durationMs: typeof details.durationMs === 'number' ? details.durationMs : null,
1380
1558
  });
1381
- runs.push({
1382
- harness: h,
1559
+ batcher.success(`${outcome.harnessName} ${parsed.mode ?? 'general'} — ${metrics}`);
1560
+ return {
1561
+ harness: outcome.harnessName,
1383
1562
  ok: !result.isError,
1384
1563
  metrics,
1385
1564
  cost: result.totalCostUsd,
@@ -1387,10 +1566,10 @@ export default function (pi: ExtensionAPI) {
1387
1566
  file: (details.file as string) ?? undefined,
1388
1567
  sessionId: (details.sessionId as string) ?? undefined,
1389
1568
  verify,
1390
- });
1391
- batcher.success(`${h} ${parsed.mode ?? 'general'} — ${metrics}`);
1392
- }
1569
+ };
1570
+ });
1393
1571
 
1572
+ const runs = orderFanoutResults(resolved, [...immediateFailures, ...completed]);
1394
1573
  const okCount = runs.filter(r => r.ok).length;
1395
1574
  const report = buildFanoutReport({ runs, skipped, unknown });
1396
1575
  injectReport(ctx, {
@@ -0,0 +1,216 @@
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. Once every row is terminal, a single Esc or `m` instead dismisses immediately (see
13
+ * `isFanoutComplete`) rather than arming/minimizing, since there's nothing left to cancel.
14
+ */
15
+
16
+ import type { Theme } from '@earendil-works/pi-coding-agent';
17
+ import { type Component, Key, matchesKey, type TUI, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
18
+ import { fmtElapsed } from './progress.ts';
19
+
20
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
21
+ const SPIN_INTERVAL_MS = 100;
22
+
23
+ export type RunStatus = 'queued' | 'running' | 'done' | 'failed';
24
+
25
+ export interface RunRow {
26
+ harness: string;
27
+ /** Set once the run has acquired its concurrency slot and started executing; null while queued. */
28
+ startedAt: number | null;
29
+ status: RunStatus;
30
+ /**
31
+ * Short current-activity text (tool call, "thinking…", or a text tail). Empty when queued or
32
+ * done; on a failed run it holds the failure reason (or the last activity seen), so the row
33
+ * still says *why* rather than going blank at the moment that matters most.
34
+ */
35
+ activity: string;
36
+ /** Cost reported once the run completes successfully; null while queued/running/failed, or when
37
+ * the harness didn't report one. Feeds the chip's aggregate spend figure. */
38
+ costUsd: number | null;
39
+ }
40
+
41
+ export interface MultiProgressWindowOptions {
42
+ /** Mode name shown in the title bar (e.g. "review"). */
43
+ mode: string;
44
+ /** Epoch ms when the fan-out started — drives the overall elapsed timer. */
45
+ startedAt: number;
46
+ /** Live per-harness row state. */
47
+ getRows: () => RunRow[];
48
+ /** Show an "unrestricted permissions" warning banner. */
49
+ dangerous?: boolean;
50
+ /** Called when the user confirms cancel — must abort every in-flight run. */
51
+ onCancel: () => void;
52
+ /** Called when the user presses `m` (minimize — runs continue in the background). */
53
+ onMinimize: () => void;
54
+ /**
55
+ * Called when the user presses Esc or `m` while every row is already terminal — i.e. during the
56
+ * post-completion linger, before the caller tears the overlay down on its own timer. Lets a user
57
+ * who's still watching dismiss the finished board immediately instead of waiting it out. Optional
58
+ * so existing callers/tests that don't care about the linger keep working.
59
+ */
60
+ onDismiss?: () => void;
61
+ }
62
+
63
+ /** True once every row has reached a terminal state — the fan-out is fully done. Pure — testable
64
+ * without a TUI. Used to gate the post-completion dismiss-on-any-key behavior. */
65
+ export function isFanoutComplete(rows: RunRow[]): boolean {
66
+ return rows.length > 0 && rows.every(r => r.status === 'done' || r.status === 'failed');
67
+ }
68
+
69
+ /** Per-status glyphs, matching the row markers in the overlay so the chip and the window read alike. */
70
+ const CHIP_GLYPHS: ReadonlyArray<readonly [RunStatus, string]> = [
71
+ ['done', '✓'],
72
+ ['failed', '✗'],
73
+ ['running', '▶'],
74
+ ['queued', '…'],
75
+ ];
76
+
77
+ /**
78
+ * Compact fan-out status-bar summary, e.g. `1✓ 1✗ 1▶ 1… · ⏱ 0:42 · $0.123`. Zero status counts are
79
+ * omitted, so the common cases stay short (`4▶`, then `4✓`); the aggregate spend segment is
80
+ * likewise omitted until at least one run has actually reported a cost. Counting only `running` —
81
+ * as the first cut did — renders `0/4 running`, which reads as idle when runs have actually failed
82
+ * or are queued behind the cap. `elapsedMs` is passed in (rather than read via `Date.now()`
83
+ * internally) so this stays pure and testable without a TUI.
84
+ */
85
+ export function formatFanoutChip(rows: RunRow[], elapsedMs: number): string {
86
+ const parts = CHIP_GLYPHS.map(([status, glyph]) => {
87
+ const n = rows.filter(r => r.status === status).length;
88
+ return n > 0 ? `${n}${glyph}` : null;
89
+ }).filter((s): s is string => s !== null);
90
+ const statusText = parts.length > 0 ? parts.join(' ') : `${rows.length}…`;
91
+
92
+ const costs = rows.map(r => r.costUsd).filter((c): c is number => typeof c === 'number');
93
+ const totalCost = costs.length > 0 ? costs.reduce((sum, c) => sum + c, 0) : null;
94
+
95
+ const segments = [statusText, `⏱ ${fmtElapsed(elapsedMs)}`];
96
+ if (totalCost !== null) segments.push(`$${totalCost.toFixed(3)}`);
97
+ return segments.join(' · ');
98
+ }
99
+
100
+ /** One row's marker + label, e.g. "✓ claude" / "✗ codex" / "⠋ opencode" / "… amp". Pure — testable
101
+ * without a TUI/theme. */
102
+ export function renderRowLabel(row: RunRow, frame: number): string {
103
+ const mark =
104
+ row.status === 'done'
105
+ ? '✓'
106
+ : row.status === 'failed'
107
+ ? '✗'
108
+ : row.status === 'running'
109
+ ? SPINNER[frame % SPINNER.length]
110
+ : '…';
111
+ return `${mark} ${row.harness}`;
112
+ }
113
+
114
+ /** Create the multi-run overlay component; disposes the spinner timer. */
115
+ export function multiProgressWindow(
116
+ tui: TUI,
117
+ theme: Theme,
118
+ opts: MultiProgressWindowOptions,
119
+ ): Component & { dispose(): void } {
120
+ let frame = 0;
121
+ let armed = false;
122
+ let armTimer: ReturnType<typeof setTimeout> | null = null;
123
+ const timer = setInterval(() => {
124
+ frame++;
125
+ tui.requestRender();
126
+ }, SPIN_INTERVAL_MS);
127
+
128
+ const disarm = () => {
129
+ armed = false;
130
+ if (armTimer) {
131
+ clearTimeout(armTimer);
132
+ armTimer = null;
133
+ }
134
+ };
135
+
136
+ return {
137
+ render(width: number): string[] {
138
+ const inner = Math.max(10, width - 4);
139
+ const padTo = (s: string, w: number) => `${s}${' '.repeat(Math.max(1, w - visibleWidth(s)))}`;
140
+ const out: string[] = [];
141
+ const rows = opts.getRows();
142
+
143
+ const done = rows.filter(r => r.status === 'done' || r.status === 'failed').length;
144
+ const title = `${SPINNER[frame % SPINNER.length]} delegate all · ${opts.mode} · ${done}/${rows.length}`;
145
+ const status = `⏱ ${fmtElapsed(Date.now() - opts.startedAt)}`;
146
+ const titleStr = `${title} · ${status}`;
147
+ const dash = '─'.repeat(Math.max(1, inner - visibleWidth(titleStr) - 2));
148
+ out.push(theme.fg('accent', `╭─ ${titleStr} ${dash}─╮`));
149
+
150
+ if (opts.dangerous) {
151
+ const banner = theme.fg('error', '⚠ danger — unrestricted access');
152
+ out.push(`│ ${padTo(banner, inner)} │`);
153
+ }
154
+
155
+ for (const row of rows) {
156
+ const label = renderRowLabel(row, frame);
157
+ const styledLabel =
158
+ row.status === 'done'
159
+ ? theme.fg('success', label)
160
+ : row.status === 'failed'
161
+ ? theme.fg('error', label)
162
+ : theme.fg('accent', label);
163
+ const elapsed = row.startedAt !== null ? fmtElapsed(Date.now() - row.startedAt) : 'queued';
164
+ const activity = row.activity ? ` ${theme.fg('muted', row.activity)}` : '';
165
+ const line = `${styledLabel} ${theme.fg('dim', elapsed)}${activity}`;
166
+ out.push(`│ ${padTo(truncateToWidth(line, inner), inner)} │`);
167
+ }
168
+
169
+ const hint = isFanoutComplete(rows)
170
+ ? theme.fg('dim', 'esc/m dismiss')
171
+ : armed
172
+ ? theme.fg('warning', 'press esc again to cancel all') + theme.fg('dim', ' · m minimize')
173
+ : theme.fg('dim', 'esc cancel all') + theme.fg('dim', ' · m minimize');
174
+ out.push(`│ ${padTo(hint, inner)} │`);
175
+
176
+ out.push(theme.fg('accent', `╰${'─'.repeat(Math.max(1, width - 2))}╯`));
177
+ return out;
178
+ },
179
+ handleInput(data: string): void {
180
+ // Once every run has reached a terminal state, the overlay is just lingering on the
181
+ // finished board before the caller closes it on a timer — any Esc/m here dismisses it right
182
+ // away instead of making a user who's still watching wait out the linger.
183
+ if (isFanoutComplete(opts.getRows()) && opts.onDismiss) {
184
+ if (matchesKey(data, Key.escape) || data === 'm') {
185
+ disarm();
186
+ opts.onDismiss();
187
+ }
188
+ return;
189
+ }
190
+ if (matchesKey(data, Key.escape)) {
191
+ if (armed) {
192
+ disarm();
193
+ opts.onCancel();
194
+ } else {
195
+ armed = true;
196
+ armTimer = setTimeout(() => {
197
+ armed = false;
198
+ armTimer = null;
199
+ tui.requestRender();
200
+ }, 1500);
201
+ tui.requestRender();
202
+ }
203
+ } else if (data === 'm') {
204
+ disarm();
205
+ opts.onMinimize();
206
+ }
207
+ },
208
+ invalidate(): void {
209
+ // stateless render — nothing to clear
210
+ },
211
+ dispose(): void {
212
+ clearInterval(timer);
213
+ disarm();
214
+ },
215
+ };
216
+ }
@@ -46,6 +46,15 @@ export function fmtElapsed(ms: number): string {
46
46
  return m > 0 ? `${m}:${String(s).padStart(2, '0')}` : `0:${String(s).padStart(2, '0')}`;
47
47
  }
48
48
 
49
+ /**
50
+ * Slice a feed down to its last `max` entries, reporting how many were dropped so the caller can
51
+ * show a "+N earlier" marker instead of silently truncating with no hint older entries existed.
52
+ * Pure — testable without a TUI.
53
+ */
54
+ export function truncateFeed<T>(entries: T[], max: number): { visible: T[]; hiddenCount: number } {
55
+ return { visible: entries.slice(-max), hiddenCount: Math.max(0, entries.length - max) };
56
+ }
57
+
49
58
  /** Style one feed entry; the returned string may contain ANSI colors. */
50
59
  export function renderEntry(entry: FeedEntry, theme: Theme): string {
51
60
  switch (entry.kind) {
@@ -98,8 +107,13 @@ export function progressWindow(tui: TUI, theme: Theme, opts: ProgressWindowOptio
98
107
  out.push(`│ ${padTo(banner, inner)} │`);
99
108
  }
100
109
 
101
- // feed
102
- for (const entry of opts.getEntries().slice(-MAX_VISIBLE_ENTRIES)) {
110
+ // feed — "+N earlier" marker when older entries were dropped, instead of truncating silently
111
+ const { visible, hiddenCount } = truncateFeed(opts.getEntries(), MAX_VISIBLE_ENTRIES);
112
+ if (hiddenCount > 0) {
113
+ const marker = theme.fg('dim', `+${hiddenCount} earlier`);
114
+ out.push(`│ ${padTo(truncateToWidth(marker, inner), inner)} │`);
115
+ }
116
+ for (const entry of visible) {
103
117
  out.push(`│ ${padTo(truncateToWidth(renderEntry(entry, theme), inner), inner)} │`);
104
118
  }
105
119
 
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.1",
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",