pi-harness-delegate 0.2.2 → 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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Batches successful fan-out completion notifications so `/delegate all …` emits one
3
+ * notification instead of one per harness. Failures are never delayed or batched — they
4
+ * flush any pending batch and are emitted immediately. Confined to the notification path;
5
+ * not a general event system.
6
+ */
7
+
8
+ export type NotifyLevel = 'info' | 'warning' | 'error';
9
+ export type NotifyFn = (text: string, level: NotifyLevel) => void;
10
+
11
+ /** Pure joiner for a batch of success lines — testable without timers. */
12
+ export function joinBatch(lines: string[]): string {
13
+ if (lines.length === 1) return lines[0];
14
+ return `${lines.length} runs completed:\n${lines.map(l => ` · ${l}`).join('\n')}`;
15
+ }
16
+
17
+ export class NotifyBatcher {
18
+ private pending: string[] = [];
19
+ private timer: ReturnType<typeof setTimeout> | null = null;
20
+
21
+ constructor(
22
+ private readonly emit: NotifyFn,
23
+ private readonly debounceMs = 400,
24
+ ) {}
25
+
26
+ /** Queue a successful-completion line; flushes as one combined notification after a quiet period. */
27
+ success(line: string): void {
28
+ this.pending.push(line);
29
+ if (this.timer) clearTimeout(this.timer);
30
+ this.timer = setTimeout(() => this.flush(), this.debounceMs);
31
+ }
32
+
33
+ /** Flush any pending batch immediately, then emit this failure on its own — never delayed. */
34
+ failure(line: string): void {
35
+ this.flush();
36
+ this.emit(line, 'error');
37
+ }
38
+
39
+ /** Emit whatever is pending as one notification, then clear it. No-op when nothing is pending. */
40
+ flush(): void {
41
+ if (this.timer) {
42
+ clearTimeout(this.timer);
43
+ this.timer = null;
44
+ }
45
+ if (this.pending.length === 0) return;
46
+ const lines = this.pending;
47
+ this.pending = [];
48
+ this.emit(joinBatch(lines), 'info');
49
+ }
50
+ }
@@ -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
+ }
@@ -16,7 +16,7 @@ const SPIN_INTERVAL_MS = 100;
16
16
  const MAX_VISIBLE_ENTRIES = 12;
17
17
 
18
18
  export type FeedEntry =
19
- | { kind: 'tool'; text: string; ok?: boolean }
19
+ | { kind: 'tool'; text: string; ok?: boolean; id?: string }
20
20
  | { kind: 'thinking'; text: string }
21
21
  | { kind: 'text'; text: string };
22
22
 
@@ -0,0 +1,90 @@
1
+ /**
2
+ * File-based active-run registry — makes the concurrency guard and `/delegate status`
3
+ * active count accurate across pi processes, not just the current one.
4
+ *
5
+ * One small JSON file per active run in `<agentDir>/delegate/runs/`. Best-effort throughout:
6
+ * registry I/O failures never break a delegation — callers should combine this with their own
7
+ * in-process counters as a fallback.
8
+ *
9
+ * Concurrency cap is best-effort, not a hard mutex: `countActiveRuns()` (read) and
10
+ * `acquireRun()` (write) are two separate steps with no lock between them, so two pi
11
+ * processes starting at the same instant can both observe a count under the limit and both
12
+ * proceed — `maxConcurrent` can be exceeded by a small margin under a tight race. This is a
13
+ * deliberate simplicity tradeoff (see AGENTS.md); do not rely on it for a hard cap.
14
+ */
15
+
16
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { agentDir } from './config.ts';
19
+
20
+ function runsDir(): string {
21
+ return join(agentDir(), 'delegate', 'runs');
22
+ }
23
+
24
+ function isAlive(pid: number): boolean {
25
+ try {
26
+ process.kill(pid, 0);
27
+ return true;
28
+ } catch (err) {
29
+ // ESRCH: no such process. EPERM: exists but we can't signal it — still alive.
30
+ return (err as NodeJS.ErrnoException).code === 'EPERM';
31
+ }
32
+ }
33
+
34
+ export interface RunHandle {
35
+ file: string;
36
+ }
37
+
38
+ /** Register an active run. Returns null (never throws) if the registry can't be written. */
39
+ export function acquireRun(harness: string, mode: string): RunHandle | null {
40
+ try {
41
+ const dir = runsDir();
42
+ mkdirSync(dir, { recursive: true });
43
+ const file = join(dir, `${process.pid}-${harness}-${Math.random().toString(36).slice(2, 8)}.json`);
44
+ writeFileSync(file, JSON.stringify({ pid: process.pid, harness, mode, startedAt: Date.now() }));
45
+ return { file };
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /** Release a previously-acquired run. Best-effort — never throws. */
52
+ export function releaseRun(handle: RunHandle | null): void {
53
+ if (!handle) return;
54
+ try {
55
+ rmSync(handle.file, { force: true });
56
+ } catch {
57
+ // best-effort
58
+ }
59
+ }
60
+
61
+ /** Count active runs across processes (optionally filtered to one harness), cleaning up
62
+ * entries left behind by dead processes. Returns 0 (never throws) if the registry is unreadable. */
63
+ export function countActiveRuns(harness?: string): number {
64
+ let files: string[];
65
+ try {
66
+ files = readdirSync(runsDir());
67
+ } catch {
68
+ return 0;
69
+ }
70
+ let count = 0;
71
+ for (const f of files) {
72
+ if (!f.endsWith('.json')) continue;
73
+ const full = join(runsDir(), f);
74
+ try {
75
+ const data = JSON.parse(readFileSync(full, 'utf8')) as { pid: number; harness: string };
76
+ if (typeof data.pid === 'number' && isAlive(data.pid)) {
77
+ if (!harness || data.harness === harness) count++;
78
+ } else {
79
+ rmSync(full, { force: true }); // stale (dead pid) or corrupt (non-numeric pid)
80
+ }
81
+ } catch {
82
+ try {
83
+ rmSync(full, { force: true }); // corrupt entry
84
+ } catch {
85
+ // best-effort
86
+ }
87
+ }
88
+ }
89
+ return count;
90
+ }
@@ -28,6 +28,8 @@ export interface DelegateTemplate {
28
28
  skill?: string;
29
29
  defaultTask?: string;
30
30
  defaultScope?: string;
31
+ /** Host-run shell command executed after the harness exits to check its claims (e.g. `bun test`). */
32
+ verify?: string;
31
33
  prompt: string;
32
34
  harness?: string;
33
35
  }
@@ -95,6 +97,7 @@ export function parseTemplate(text: string): DelegateTemplate | null {
95
97
  skill: meta.skill || undefined,
96
98
  defaultTask: meta.defaultTask || undefined,
97
99
  defaultScope: meta.defaultScope || undefined,
100
+ verify: meta.verify || undefined,
98
101
  prompt: m[2].trim(),
99
102
  harness: meta.harness || undefined,
100
103
  };
@@ -5,16 +5,20 @@ export interface HarnessUsage {
5
5
  outputTokens: number;
6
6
  cacheCreationInputTokens: number;
7
7
  cacheReadInputTokens: number;
8
- totalCostUsd: number;
8
+ /** null when the harness didn't report cost — distinct from a measured $0. */
9
+ totalCostUsd: number | null;
9
10
  }
10
11
 
11
12
  export type ClaudeUsage = HarnessUsage;
12
13
 
13
14
  /**
14
15
  * Map harness usage/cost into pi's `Usage` shape so delegated runs appear
15
- * in the pi footer token/cost stats and /session totals.
16
+ * in the pi footer token/cost stats and /session totals. Returns undefined when cost
17
+ * is unknown — `Usage.cost.total` is mandatory, so there's no honest number to put there,
18
+ * and reporting a fake $0 would silently under-report spend in pi's session totals.
16
19
  */
17
- export function mapHarnessUsage(u: HarnessUsage): Usage {
20
+ export function mapHarnessUsage(u: HarnessUsage): Usage | undefined {
21
+ if (u.totalCostUsd === null) return undefined;
18
22
  const input = u.inputTokens + u.cacheCreationInputTokens;
19
23
  const cacheRead = u.cacheReadInputTokens;
20
24
  const output = u.outputTokens;
@@ -35,6 +39,6 @@ export function mapHarnessUsage(u: HarnessUsage): Usage {
35
39
  };
36
40
  }
37
41
 
38
- export function mapClaudeUsage(u: ClaudeUsage): Usage {
42
+ export function mapClaudeUsage(u: ClaudeUsage): Usage | undefined {
39
43
  return mapHarnessUsage(u);
40
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-harness-delegate",
3
- "version": "0.2.2",
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",