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 +17 -6
- package/extensions/activity.ts +11 -0
- package/extensions/concurrency.ts +106 -0
- package/extensions/config.ts +1 -1
- package/extensions/index.ts +261 -82
- package/extensions/progress-multi.ts +216 -0
- package/extensions/progress.ts +16 -2
- package/package.json +1 -1
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
|
|
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
|
|
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`)
|
|
59
|
-
- A single-harness call (`harness: "claude"`, or omitted) behaves exactly as before
|
|
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":
|
|
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
|
|
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.
|
package/extensions/activity.ts
CHANGED
|
@@ -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
|
+
}
|
package/extensions/config.ts
CHANGED
|
@@ -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:
|
|
50
|
+
maxConcurrent: 4,
|
|
51
51
|
maxTranscripts: 100,
|
|
52
52
|
harnesses: {},
|
|
53
53
|
};
|
package/extensions/index.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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: ${
|
|
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 —
|
|
528
|
-
//
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
|
855
|
-
*
|
|
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
|
-
|
|
887
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1368
|
-
|
|
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
|
-
|
|
1382
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/extensions/progress.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
"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",
|