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 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…` — 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.
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
 
@@ -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('delegate', theme.fg('accent', '●') + theme.fg('dim', ` ${formatFanoutChip(rows)}`));
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
- await closeWhenMounted(() => closeWindow, 2000);
1449
- await uiPromise;
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…`. 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
- * Puretestable without a TUI.
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
- return parts.length > 0 ? parts.join(' ') : `${rows.length}…`;
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 = 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');
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();
@@ -46,6 +46,15 @@ export function fmtElapsed(ms: number): string {
46
46
  return m > 0 ? `${m}:${String(s).padStart(2, '0')}` : `0:${String(s).padStart(2, '0')}`;
47
47
  }
48
48
 
49
+ /**
50
+ * Slice a feed down to its last `max` entries, reporting how many were dropped so the caller can
51
+ * show a "+N earlier" marker instead of silently truncating with no hint older entries existed.
52
+ * Pure — testable without a TUI.
53
+ */
54
+ export function truncateFeed<T>(entries: T[], max: number): { visible: T[]; hiddenCount: number } {
55
+ return { visible: entries.slice(-max), hiddenCount: Math.max(0, entries.length - max) };
56
+ }
57
+
49
58
  /** Style one feed entry; the returned string may contain ANSI colors. */
50
59
  export function renderEntry(entry: FeedEntry, theme: Theme): string {
51
60
  switch (entry.kind) {
@@ -98,8 +107,13 @@ export function progressWindow(tui: TUI, theme: Theme, opts: ProgressWindowOptio
98
107
  out.push(`│ ${padTo(banner, inner)} │`);
99
108
  }
100
109
 
101
- // feed
102
- for (const entry of opts.getEntries().slice(-MAX_VISIBLE_ENTRIES)) {
110
+ // feed — "+N earlier" marker when older entries were dropped, instead of truncating silently
111
+ const { visible, hiddenCount } = truncateFeed(opts.getEntries(), MAX_VISIBLE_ENTRIES);
112
+ if (hiddenCount > 0) {
113
+ const marker = theme.fg('dim', `+${hiddenCount} earlier`);
114
+ out.push(`│ ${padTo(truncateToWidth(marker, inner), inner)} │`);
115
+ }
116
+ for (const entry of visible) {
103
117
  out.push(`│ ${padTo(truncateToWidth(renderEntry(entry, theme), inner), inner)} │`);
104
118
  }
105
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-harness-delegate",
3
- "version": "0.4.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",