pi-harness-delegate 0.4.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 +1 -1
- package/extensions/index.ts +31 -5
- package/extensions/progress-multi.ts +48 -10
- package/extensions/progress.ts +16 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ delegate({ harness: "all", mode: "review", scope: "diff" }) # tool call form
|
|
|
68
68
|
│ esc cancel all · m minimize │
|
|
69
69
|
╰────────────────────────────────────────────────────────────╯
|
|
70
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
|
|
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.
|
|
72
72
|
|
|
73
73
|
## Harnesses
|
|
74
74
|
|
package/extensions/index.ts
CHANGED
|
@@ -110,6 +110,9 @@ interface DelegateOptions {
|
|
|
110
110
|
|
|
111
111
|
/** Verify commands run on the host after the harness exits — bounded independent of harness timeoutMs. */
|
|
112
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;
|
|
113
116
|
|
|
114
117
|
/**
|
|
115
118
|
* Run a verify command in-process on the host (never delegated to the harness). Report-only —
|
|
@@ -1339,6 +1342,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1339
1342
|
startedAt: null,
|
|
1340
1343
|
status: 'queued',
|
|
1341
1344
|
activity: '',
|
|
1345
|
+
costUsd: null,
|
|
1342
1346
|
}));
|
|
1343
1347
|
let requestRender: (() => void) | null = null;
|
|
1344
1348
|
|
|
@@ -1349,7 +1353,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1349
1353
|
if (now - chipLastPush < 500) return;
|
|
1350
1354
|
chipLastPush = now;
|
|
1351
1355
|
const theme = ctx.ui.theme;
|
|
1352
|
-
ctx.ui.setStatus(
|
|
1356
|
+
ctx.ui.setStatus(
|
|
1357
|
+
'delegate',
|
|
1358
|
+
theme.fg('accent', '●') + theme.fg('dim', ` ${formatFanoutChip(rows, now - overallStart)}`),
|
|
1359
|
+
);
|
|
1353
1360
|
};
|
|
1354
1361
|
|
|
1355
1362
|
const runOne = async (spec: FanoutSpec, idx: number): Promise<FanoutOutcome> => {
|
|
@@ -1398,6 +1405,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1398
1405
|
setRow({
|
|
1399
1406
|
status: failed ? 'failed' : 'done',
|
|
1400
1407
|
activity: failed ? reason || rows[idx].activity : '',
|
|
1408
|
+
costUsd: !failed && result ? result.result.totalCostUsd : null,
|
|
1401
1409
|
});
|
|
1402
1410
|
return {
|
|
1403
1411
|
harnessName: spec.harnessName,
|
|
@@ -1413,6 +1421,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1413
1421
|
let outcomes: FanoutOutcome[];
|
|
1414
1422
|
if (ctx.hasUI) {
|
|
1415
1423
|
let overlayHandle: OverlayHandle | null = null;
|
|
1424
|
+
let resolveDismiss = (): void => {};
|
|
1425
|
+
const dismissed = new Promise<void>(resolve => {
|
|
1426
|
+
resolveDismiss = () => resolve();
|
|
1427
|
+
});
|
|
1416
1428
|
const uiPromise = ctx.ui
|
|
1417
1429
|
.custom(
|
|
1418
1430
|
(tui, theme, _kb, done) => {
|
|
@@ -1431,6 +1443,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1431
1443
|
overlayHandle?.setHidden(true);
|
|
1432
1444
|
overlayHandle?.unfocus();
|
|
1433
1445
|
},
|
|
1446
|
+
onDismiss: () => resolveDismiss(),
|
|
1434
1447
|
});
|
|
1435
1448
|
},
|
|
1436
1449
|
{
|
|
@@ -1445,13 +1458,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
1445
1458
|
)
|
|
1446
1459
|
.catch(() => {});
|
|
1447
1460
|
outcomes = await allSettled;
|
|
1448
|
-
|
|
1449
|
-
|
|
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
|
+
})();
|
|
1450
1477
|
} else {
|
|
1451
1478
|
outcomes = await allSettled;
|
|
1479
|
+
clearActive();
|
|
1452
1480
|
}
|
|
1453
|
-
clearActive();
|
|
1454
|
-
if (ctx.hasUI) ctx.ui.setStatus('delegate', undefined);
|
|
1455
1481
|
return outcomes;
|
|
1456
1482
|
};
|
|
1457
1483
|
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* N row summaries).
|
|
10
10
|
*
|
|
11
11
|
* Controls: same as progressWindow — ESC twice to cancel (aborts every in-flight run), `m` to
|
|
12
|
-
* minimize.
|
|
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.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import type { Theme } from '@earendil-works/pi-coding-agent';
|
|
@@ -32,6 +33,9 @@ export interface RunRow {
|
|
|
32
33
|
* still says *why* rather than going blank at the moment that matters most.
|
|
33
34
|
*/
|
|
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;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export interface MultiProgressWindowOptions {
|
|
@@ -47,6 +51,19 @@ export interface MultiProgressWindowOptions {
|
|
|
47
51
|
onCancel: () => void;
|
|
48
52
|
/** Called when the user presses `m` (minimize — runs continue in the background). */
|
|
49
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');
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
/** Per-status glyphs, matching the row markers in the overlay so the chip and the window read alike. */
|
|
@@ -58,17 +75,26 @@ const CHIP_GLYPHS: ReadonlyArray<readonly [RunStatus, string]> = [
|
|
|
58
75
|
];
|
|
59
76
|
|
|
60
77
|
/**
|
|
61
|
-
* Compact fan-out status-bar summary, e.g. `1✓ 1✗ 1▶ 1
|
|
62
|
-
* cases stay short (`4▶`, then `4✓`)
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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.
|
|
65
84
|
*/
|
|
66
|
-
export function formatFanoutChip(rows: RunRow[]): string {
|
|
85
|
+
export function formatFanoutChip(rows: RunRow[], elapsedMs: number): string {
|
|
67
86
|
const parts = CHIP_GLYPHS.map(([status, glyph]) => {
|
|
68
87
|
const n = rows.filter(r => r.status === status).length;
|
|
69
88
|
return n > 0 ? `${n}${glyph}` : null;
|
|
70
89
|
}).filter((s): s is string => s !== null);
|
|
71
|
-
|
|
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(' · ');
|
|
72
98
|
}
|
|
73
99
|
|
|
74
100
|
/** One row's marker + label, e.g. "✓ claude" / "✗ codex" / "⠋ opencode" / "… amp". Pure — testable
|
|
@@ -140,15 +166,27 @@ export function multiProgressWindow(
|
|
|
140
166
|
out.push(`│ ${padTo(truncateToWidth(line, inner), inner)} │`);
|
|
141
167
|
}
|
|
142
168
|
|
|
143
|
-
const hint =
|
|
144
|
-
? theme.fg('
|
|
145
|
-
:
|
|
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');
|
|
146
174
|
out.push(`│ ${padTo(hint, inner)} │`);
|
|
147
175
|
|
|
148
176
|
out.push(theme.fg('accent', `╰${'─'.repeat(Math.max(1, width - 2))}╯`));
|
|
149
177
|
return out;
|
|
150
178
|
},
|
|
151
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
|
+
}
|
|
152
190
|
if (matchesKey(data, Key.escape)) {
|
|
153
191
|
if (armed) {
|
|
154
192
|
disarm();
|
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.4.
|
|
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",
|