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