pi-umbra-subagents 0.1.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,193 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { visibleWidth, type TUI } from "@earendil-works/pi-tui";
3
+ import type { Theme } from "@earendil-works/pi-coding-agent";
4
+ import type { BranchView, RunState } from "./skills/delegate/state.ts";
5
+ import { createPanel, type PanelSource } from "./panel.ts";
6
+
7
+ // The one runnable check for the panel. Run it with:
8
+ // node --experimental-strip-types build/panel.check.ts
9
+ // (node resolves "@earendil-works/*" through build/node_modules, a junction to the copy pi
10
+ // already has: mklink /J build\node_modules %USERPROFILE%\.bun\install\global\node_modules)
11
+ //
12
+ // It renders the component against a stub theme, a stub TUI and a stub source, so it never
13
+ // starts pi, never reads a run directory and never kills anything. What it protects is the
14
+ // arithmetic: the height and the two box widths must be identical on every frame whatever
15
+ // the data does, because a line that wraps or a box that disagrees costs a row and moves
16
+ // everything under it. The keys are checked for what they promise in the footer and
17
+ // nothing more.
18
+
19
+ // SAFETY: the panel only ever calls fg, bold and terminal.rows. Uncoloured output also
20
+ // makes every assertion below a plain string comparison.
21
+ const theme = { fg: (_color: string, text: string) => text, bold: (text: string) => text } as unknown as Theme;
22
+ let renders = 0;
23
+ // SAFETY: same, for requestRender and terminal.rows.
24
+ const tui = { terminal: { rows: 40 }, requestRender: () => renders++ } as unknown as TUI;
25
+
26
+ const now = Date.now();
27
+ const branch = (phase: string, name: string, index: number, over: Partial<BranchView> = {}): BranchView => ({
28
+ phase,
29
+ name,
30
+ index,
31
+ parent: null,
32
+ model: "Opus 5",
33
+ pid: 1000 + index,
34
+ status: "running",
35
+ activity: "Reading vite.config.ts",
36
+ tokens: 72_700,
37
+ startedAt: now - 195_000,
38
+ updatedAt: now,
39
+ report: null,
40
+ error: null,
41
+ stem: `${phase}-${name}`,
42
+ label: `${phase}:${name}`,
43
+ depth: 0,
44
+ elapsedMs: 195_000,
45
+ idleMs: 0,
46
+ exitCode: null,
47
+ timedOut: false,
48
+ alive: true,
49
+ settled: false,
50
+ ...over,
51
+ });
52
+
53
+ const run: RunState = {
54
+ dir: "/tmp/.pi-out/20260905-030000-pi-toolcall-render",
55
+ name: "pi-toolcall-render",
56
+ description: "Map how pi renders tool calls and whether an extension can override it",
57
+ startedAt: now - 196_000,
58
+ phases: ["map", "design"],
59
+ activePhase: 0,
60
+ branches: [
61
+ branch("map", "core-render", 1, { idleMs: 53_000 }),
62
+ branch("map", "omp-intercept", 2, { tokens: 89_600 }),
63
+ branch("map", "api-surface", 3, { tokens: 108_600 }),
64
+ branch("map", "flicker", 4, { tokens: 106_900 }),
65
+ ],
66
+ done: 0,
67
+ total: 4,
68
+ tokens: 397_800,
69
+ live: true,
70
+ };
71
+
72
+ const stopped: string[] = [];
73
+ const source: PanelSource = {
74
+ run: () => run,
75
+ stop: (dead) => stopped.push(dead.stem),
76
+ subscribe: () => () => {},
77
+ };
78
+
79
+ let closed = 0;
80
+ const panel = createPanel(tui, theme, source, () => closed++);
81
+ // handleInput is optional on Component, so bind it once here rather than asserting non-null at
82
+ // each of the fourteen call sites. A panel that did not implement it is a failure worth naming.
83
+ if (!panel.handleInput) throw new Error("the panel implements no handleInput; ↑↓, x and esc cannot work");
84
+ const press = panel.handleInput.bind(panel);
85
+ const KEY = { up: "\x1b[A", down: "\x1b[B", escape: "\x1b", stop: "x" };
86
+ const WIDTH = 88;
87
+ const height = panel.render(WIDTH).length;
88
+
89
+ const boxLines = (lines: string[]) => lines.filter((line) => /[┌│└]/.test(line));
90
+ // Height and width are one assertion, not two: a line one column too wide wraps in the
91
+ // terminal, which costs a row the array cannot show. So the header, the footer and every
92
+ // other piece of chrome is measured, not just the box.
93
+ const fits = (lines: string[], width: number, label: string) => {
94
+ for (const line of lines) {
95
+ assert.ok(visibleWidth(line) <= width, `${label}: a line is ${visibleWidth(line)} wide, over ${width}`);
96
+ }
97
+ const widths = new Set(boxLines(lines).map(visibleWidth));
98
+ assert.equal(widths.size, 1, `${label}: box lines disagree on width: ${[...widths]}`);
99
+ return [...widths][0];
100
+ };
101
+
102
+ const frame = (label: string) => {
103
+ const lines = panel.render(WIDTH);
104
+ assert.equal(lines.length, height, `${label}: height changed`);
105
+ fits(lines, WIDTH, label);
106
+ return lines.join("\n");
107
+ };
108
+
109
+ // The reference-1 frame: header, sidebar, four rows, footer.
110
+ const first = frame("initial");
111
+ assert.match(first, /pi-toolcall-render\s+0\/4 agents · 3m16s/);
112
+ assert.match(first, /> 1 Map 0\/4/);
113
+ assert.match(first, /2 Design/);
114
+ assert.match(first, /Map · 4 agents/);
115
+ assert.match(first, /● map:core-render\s+Opus 5 · 72\.7k tok · idle 53s\s+3m15s/);
116
+ assert.match(first, /↑↓ select · x stop · esc back/);
117
+ assert.ok(!first.includes("pause") && !first.includes("save"), "the dropped keys are back in the footer");
118
+
119
+ // Selection wraps through the whole run, so it crosses into the next phase, and the
120
+ // sidebar marker and the box title follow it there rather than needing a second focus.
121
+ run.branches.push(branch("design", "api", 5));
122
+ run.total = 5;
123
+ for (let n = 0; n < 4; n++) press(KEY.down);
124
+ const crossed = frame("after crossing into Design");
125
+ assert.match(crossed, /> 2 Design 0\/1/);
126
+ assert.match(crossed, /Design · 1 agents/);
127
+ assert.equal(renders, 4, "every handled key asks for exactly one render");
128
+
129
+ // x stops the selected unsettled branch, and only an unsettled one: a finished branch's
130
+ // pid belongs to whatever the OS handed it to next.
131
+ press(KEY.stop);
132
+ assert.deepEqual(stopped, ["design-api"]);
133
+ run.branches[4] = branch("design", "api", 5, { status: "done", settled: true, alive: false, exitCode: 0, report: "OK" });
134
+ press(KEY.stop);
135
+ assert.deepEqual(stopped, ["design-api"], "a settled branch must not be killed twice");
136
+
137
+ // A branch arriving mid-run must not slide the cursor onto another row, and the panel must
138
+ // not grow a line for it either: the box scrolls instead.
139
+ run.branches.unshift(branch("design", "late", 6));
140
+ run.total = 6;
141
+ const arrived = frame("after a branch arrives");
142
+ assert.match(arrived, /Design · 2 agents/, "the new branch joined the shown phase");
143
+ assert.match(arrived, /design:api/, "the selection is still on its own row");
144
+
145
+ // A nested branch gets reference 3's marker here too, and the marker is part of the label
146
+ // column, so the model column stays aligned with its parent's.
147
+ run.branches[0] = branch("design", "late", 6, { parent: "design-api", depth: 1 });
148
+ assert.match(frame("nested branch"), /└ design:late\s+Opus 5/);
149
+
150
+ // The honest dash, not a fake 0, and the three states that are not "running".
151
+ run.branches[0] = branch("design", "late", 6, { tokens: null });
152
+ assert.match(frame("no usage reported yet"), /design:late\s+Opus 5 · –/);
153
+ run.branches[0] = branch("design", "late", 6, { timedOut: true, exitCode: 124, settled: true, status: "running" });
154
+ assert.match(frame("timed out"), /design:late\s+Opus 5 · 72\.7k tok · timeout/);
155
+ run.branches[0] = branch("design", "late", 6, { status: "error", error: "no credentials", settled: true });
156
+ assert.match(frame("errored"), /design:late\s+Opus 5 · 72\.7k tok · error/);
157
+
158
+ // A phase that run.json never declared still gets its own box: the title is read off the
159
+ // selected branch, not off a phase index, so no row can vanish into another phase's box.
160
+ // The sidebar simply carries no marker while that row is selected.
161
+ run.branches[0] = branch("verify", "late", 6);
162
+ press(KEY.down);
163
+ const undeclared = frame("undeclared phase");
164
+ assert.match(undeclared, /Verify · 1 agents/);
165
+ assert.match(undeclared, /● verify:late/);
166
+
167
+ // Every width the boxes claim to support draws to exactly that width. One column too wide
168
+ // wraps, and a wrapped line costs a row that everything below it then moves by.
169
+ for (let width = 13; width <= 200; width++) {
170
+ const lines = panel.render(width);
171
+ assert.equal(lines.length, height, `width ${width}: height changed`);
172
+ assert.equal(fits(lines, width, `width ${width}`), width, `width ${width}: the boxes are not ${width} wide`);
173
+ }
174
+
175
+ // Nothing to draw is not a crash, and neither is a run that ends under the panel: an empty
176
+ // list must not reach Math.max() with no seed, which is the -Infinity the seed extension
177
+ // still carries.
178
+ run.branches.length = 0;
179
+ run.done = 0;
180
+ frame("empty run");
181
+ run.live = false;
182
+ frame("ended run");
183
+ press(KEY.stop);
184
+ press(KEY.down);
185
+
186
+ // esc closes, and closing is the only thing that closes.
187
+ press("q");
188
+ assert.equal(closed, 0);
189
+ press(KEY.escape);
190
+ assert.equal(closed, 1);
191
+ panel.dispose();
192
+
193
+ console.log(`ok - panel: ${height} lines, ${WIDTH} columns, ${renders} renders`);
@@ -0,0 +1,333 @@
1
+ import { Editor, truncateToWidth, visibleWidth, type Component, type TUI, matchesKey } from "@earendil-works/pi-tui";
2
+ import type { ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
3
+ import type { BranchView, RunState } from "./skills/delegate/state.ts";
4
+
5
+ // Reference 1, the full-screen panel: the zoomed-in view of the run the collapsed bar
6
+ // summarises. Phases on the left, the selected phase's branches on the right, one header
7
+ // line and one footer line.
8
+ //
9
+ // Opened with ctx.ui.custom() and NO `overlay` option, so it takes editorContainer's slot
10
+ // instead of floating over it. pi snapshots the editor's unsent text before the swap and
11
+ // calls setText with it on close, so "esc returns to the input box with unsent text
12
+ // intact" costs zero lines here — the overlay path is the one that would not.
13
+ //
14
+ // The panel reads a snapshot and nothing else: no file, no process, no kill. The bar
15
+ // already polls readRun() once a second for both views, so a second reader could only
16
+ // disagree with it by a frame, and a component with no I/O cannot be hung by a branch that
17
+ // dies. Its one timer asks for a frame and reads nothing, because elapsed and "idle Ns"
18
+ // move with the clock even on a run whose files have stopped changing.
19
+
20
+ /** What the panel needs from whoever owns the poll. `stop` is the producer's kill. */
21
+ export type PanelSource = {
22
+ /** undefined when no run has been seen yet. */
23
+ run(): RunState | undefined;
24
+ /** Called only for a branch the panel drew as unsettled. */
25
+ stop(branch: BranchView): void;
26
+ /** Fires on every change; returns the unsubscribe. */
27
+ subscribe(fn: () => void): () => void;
28
+ };
29
+
30
+ // A finished run closes the panel on its own after this long, so a panel left open does not sit
31
+ // over the editor. Only a run the panel watched finish: one opened already finished stays.
32
+ const AUTO_CLOSE_MS = 8_000;
33
+
34
+ // The user dropped `p pause` and `s save`: they said they never used them.
35
+ const FOOTER = "↑↓ select · x stop · esc back";
36
+
37
+ // Rule, header, description, box top, box bottom, footer — the lines that are not body.
38
+ const CHROME = 6;
39
+ // Rows left to the transcript, the status line, the collapsed bar and pi's own footer.
40
+ const RESERVE = 4;
41
+ const MIN_BODY = 3;
42
+ // Reference 1 ends every agent row `3m15s │`, three columns clear of the border.
43
+ const RIGHT_GUTTER = 3;
44
+ // Reference 1 shows "idle 53s", so the marker has to appear well before a minute.
45
+ const IDLE_MS = 15_000;
46
+
47
+ // "45s", "3m15s". No space before the seconds: this string sits at the right edge of every
48
+ // row, and the panel's columns are tighter than the flat list's.
49
+ const clock = (ms: number) => {
50
+ const total = Math.max(0, Math.round(ms / 1000));
51
+ const minutes = Math.floor(total / 60);
52
+ return minutes ? `${minutes}m${String(total % 60).padStart(2, "0")}s` : `${total}s`;
53
+ };
54
+
55
+ // A timed-out branch answered nothing but is not a crash, and a soft report is not a
56
+ // success. Colour carries the difference; note() repeats it in text, because a monochrome
57
+ // theme would otherwise make the two identical.
58
+ const dotColor = (branch: BranchView): ThemeColor => {
59
+ if (branch.timedOut) return "warning";
60
+ if (branch.status === "error" || (branch.exitCode !== null && branch.exitCode !== 0)) return "error";
61
+ if (branch.report === "PARTIAL" || branch.report === "NEED_STRONGER" || branch.report === "ASKING") return "warning";
62
+ if (branch.settled) return "success";
63
+ return branch.status === "running" ? "accent" : "dim";
64
+ };
65
+
66
+ const note = (branch: BranchView): string => {
67
+ if (branch.timedOut) return " · timeout";
68
+ if (branch.status === "error") return " · error";
69
+ // A settled branch stopped emitting because it finished, not because it is stuck.
70
+ if (!branch.settled && branch.idleMs >= IDLE_MS) return ` · idle ${clock(branch.idleMs)}`;
71
+ return "";
72
+ };
73
+
74
+ // The producer leaves tokens at 0 or null until the provider reports, and some report only
75
+ // at completion. "0 tok" would be a fake number for minutes; the dash is an honest one.
76
+ const tokenText = (count: number | null) =>
77
+ !count ? "–" : count < 1000 ? `${count} tok` : `${(count / 1000).toFixed(1)}k tok`;
78
+
79
+ // Run.phases holds the branch's own identifier ("map"), because the stem is
80
+ // `${phase}-${name}` and has to be a legal NTFS filename and a legal --session-id.
81
+ // Reference 1's sidebar reads "Map" while its rows read "map:core-render", so the capital
82
+ // is put back at render time rather than stored a second time in the producer.
83
+ const phaseTitle = (name: string) => name.charAt(0).toUpperCase() + name.slice(1);
84
+
85
+ // Reference 3's nesting marker, kept here too: a branch that was spawned by another is
86
+ // otherwise indistinguishable from its siblings, and readRun() already orders parents
87
+ // before children, so the prefix needs no tree walk.
88
+ const nest = (depth: number) => (depth > 0 ? `${" ".repeat(depth - 1)}└ ` : "");
89
+
90
+ /**
91
+ * The panel component. `close` is the `done` callback ctx.ui.custom hands the factory:
92
+ * calling it restores the editor, resolves the promise, then calls dispose().
93
+ */
94
+ export const createPanel = (
95
+ tui: TUI,
96
+ theme: Theme,
97
+ source: PanelSource,
98
+ close: () => void,
99
+ orphaned: () => void = () => {},
100
+ ): Component & { dispose(): void } => {
101
+ let shown = source.run();
102
+ let sawLive = shown?.live === true;
103
+ let finishedAt: number | undefined;
104
+ // Selection is keyed on the stem, never on a row index: a branch arriving mid-run would
105
+ // otherwise slide the cursor onto a different row under the user's hands.
106
+ let selectedStem =
107
+ shown?.branches.find((branch) => branch.phase === shown?.phases[shown.activePhase])?.stem ??
108
+ shown?.branches[0]?.stem;
109
+ let top = 0;
110
+
111
+ // Fixed for the life of the panel. Growing the box as branches arrive would move every
112
+ // line below it, and a height change is one of the things that forces a full repaint.
113
+ const bodyAtOpen = Math.max(MIN_BODY, shown?.phases.length ?? 0, shown?.branches.length ?? 0);
114
+
115
+ // Never requestRender(true): that resets the render state and repaints the whole screen.
116
+ const unsubscribe = source.subscribe(() => tui.requestRender());
117
+ // Elapsed and "idle Ns" move with the clock rather than with the files, so the panel keeps
118
+ // a second hand of its own instead of inheriting whatever cadence the reader polls at. It
119
+ // only asks for a frame — nothing is re-read here — and pi coalesces the request at 16 ms.
120
+ const tick = setInterval(() => {
121
+ // Another dialog (ask_user_question, a select) that takes the editor slot while the panel
122
+ // is up swaps it out without calling done(), and pi later restores the editor, not the
123
+ // panel. Focus back on an editor while this panel still thinks it is open is that case:
124
+ // let go, or every door stays shut until /reload. Not done(): pi's restore would put back
125
+ // text saved before the dialog over whatever was typed since.
126
+ const focused = tui.getFocusedComponent();
127
+ if (focused !== self && focused instanceof Editor) {
128
+ self.dispose();
129
+ orphaned();
130
+ return;
131
+ }
132
+ const run = source.run();
133
+ if (run?.live) {
134
+ sawLive = true;
135
+ finishedAt = undefined;
136
+ } else if (sawLive) {
137
+ finishedAt ??= Date.now();
138
+ if (Date.now() - finishedAt >= AUTO_CLOSE_MS) return close();
139
+ }
140
+ tui.requestRender();
141
+ }, 1000);
142
+ // A panel timer must never be the reason node refuses to exit.
143
+ tick.unref();
144
+
145
+ const box = {
146
+ top(title: string, inner: number): string {
147
+ const text = truncateToWidth(title, Math.max(0, inner - 4), "…");
148
+ // "┌ " + text + " " + dashes + "┐" has to come to inner + 2 columns.
149
+ const dashes = Math.max(0, inner - visibleWidth(text) - 2);
150
+ return theme.fg("border", "┌ ") + theme.fg("text", text) + theme.fg("border", ` ${"─".repeat(dashes)}┐`);
151
+ },
152
+ row(content: string, inner: number): string {
153
+ // truncateToWidth pads as well as cuts, so a row is exactly `inner` wide however
154
+ // much colour is inside it and the two boxes can never drift apart.
155
+ return theme.fg("border", "│") + truncateToWidth(content, inner, "…", true) + theme.fg("border", "│");
156
+ },
157
+ bottom(inner: number): string {
158
+ return theme.fg("border", `└${"─".repeat(inner)}┘`);
159
+ },
160
+ };
161
+
162
+ const self = {
163
+ render(width: number): string[] {
164
+ // A run can be replaced under the panel; keeping the last one means the component
165
+ // never blanks for a frame while the reader is between ticks.
166
+ shown = source.run() ?? shown;
167
+ const run = shown;
168
+ const now = Date.now();
169
+ // One leading space, like the reference. The floor keeps the two boxes summing to
170
+ // exactly `inner`: under 13 columns they do not fit, the line wraps, and a wrapped
171
+ // line costs a row that everything below it then moves by.
172
+ const inner = Math.max(12, width - 1);
173
+ // Every line is cut to `width`, ask.ts style: one column too many wraps in the
174
+ // terminal, and a wrapped line costs a row that everything below it then moves by.
175
+ // The box rows are built to exactly `width` already, so only the chrome needs it.
176
+ const fit = (line: string) => truncateToWidth(line, width, "…");
177
+ if (!run) return [fit(` ${theme.fg("dim", "no run")}`), fit(` ${theme.fg("dim", FOOTER)}`)];
178
+
179
+ const branches = run.branches;
180
+ const selected = branches.find((branch) => branch.stem === selectedStem) ?? branches[0];
181
+ // The sidebar follows the selection instead of being a second focus. One cursor,
182
+ // no left/right keys, and the footer stays literally true. The phase is looked up
183
+ // FROM the selected branch rather than the branch found from a phase index, so a
184
+ // branch whose phase run.json never declared is still drawn under its own name
185
+ // instead of disappearing into the first phase's box. Then the sidebar simply has
186
+ // no marker on that frame, which is honest.
187
+ const title = selected?.phase ?? run.phases[run.activePhase] ?? "";
188
+ const phase = run.phases.indexOf(title);
189
+ const list = branches.filter((branch) => branch.phase === title);
190
+
191
+ const sidebarRows = run.phases.map((name, index) => {
192
+ const mine = branches.filter((branch) => branch.phase === name);
193
+ const done = mine.filter((branch) => branch.settled).length;
194
+ return `${index === phase ? ">" : " "} ${index + 1} ${phaseTitle(name)}${mine.length ? ` ${done}/${mine.length}` : ""}`;
195
+ });
196
+ // 10 is "┌ Phases ─┐" without its borders: the box title has to fit as well as the
197
+ // rows. A third of the width is the ceiling, so a long phase name cannot squeeze
198
+ // the agent list down to nothing.
199
+ const sidebarInner = Math.min(
200
+ Math.max(10, ...sidebarRows.map((row) => row.length)) + 1,
201
+ Math.max(3, Math.floor(inner / 3)),
202
+ );
203
+ const agentInner = Math.max(3, inner - sidebarInner - 4);
204
+
205
+ // terminal.rows only changes on a resize, which repaints everything anyway, so
206
+ // reading it per frame costs nothing and keeps the panel inside a shrunk window.
207
+ const body = Math.min(bodyAtOpen, Math.max(MIN_BODY, tui.terminal.rows - CHROME - RESERVE));
208
+
209
+ const index = Math.max(0, list.findIndex((branch) => branch.stem === selected?.stem));
210
+ if (index < top) top = index;
211
+ if (index >= top + body) top = index - body + 1;
212
+ top = Math.max(0, Math.min(top, list.length - body));
213
+
214
+ // Seeded, because Math.max() over an empty list is -Infinity — the bug the seed
215
+ // extension carries at agents-panel.ts:75.
216
+ const labelWidth = Math.max(0, ...list.map((branch) => nest(branch.depth).length + branch.label.length));
217
+ const agentRows = list.slice(top, top + body).map((branch) => {
218
+ const right = theme.fg("dim", clock(branch.elapsedMs));
219
+ const head = (nest(branch.depth) + branch.label).padEnd(labelWidth);
220
+ const left =
221
+ `${theme.fg(dotColor(branch), "●")} ` +
222
+ theme.fg(branch.stem === selected?.stem ? "accent" : "text", head) +
223
+ ` ${theme.fg("dim", `${branch.model} · ${tokenText(branch.tokens)}${note(branch)}`)}`;
224
+ // One space in from the left border, three out to the right one: reference 1
225
+ // writes `3m15s │`, and a clock hard against the border reads as an overflow.
226
+ // Widths are measured, never assumed: `left` is full of escape codes.
227
+ // The sidebar's `>` marks the phase the selection is in, not a second focus, so this
228
+ // row needs a marker of its own. Without one the accent colour was the only sign of
229
+ // the cursor, the sidebar's arrow was the only `>` on screen, and `x` read as though
230
+ // it would stop a phase. One column makes the footer's "x stop" true to the eye.
231
+ const cursor = branch.stem === selected?.stem ? theme.fg("accent", ">") : " ";
232
+ const room = Math.max(0, agentInner - visibleWidth(right) - RIGHT_GUTTER - 3);
233
+ return `${cursor} ${truncateToWidth(left, room, "…", true)} ${right}${" ".repeat(RIGHT_GUTTER)}`;
234
+ });
235
+ if (!agentRows.length) agentRows.push(` ${theme.fg("dim", "no branches in this phase")}`);
236
+
237
+ // RunState has no endedAt, so a finished run's clock is the last branch to stop
238
+ // rather than a wall clock that keeps ticking after everything is done.
239
+ const elapsed = run.live
240
+ ? now - run.startedAt
241
+ : Math.max(0, ...branches.map((branch) => branch.startedAt + branch.elapsedMs - run.startedAt));
242
+ const stat = `${run.done}/${run.total} agents · ${clock(elapsed)}`;
243
+ const name = truncateToWidth(
244
+ theme.bold(theme.fg("text", run.name)),
245
+ Math.max(0, inner - visibleWidth(stat) - 3),
246
+ "…",
247
+ );
248
+ const gap = Math.max(1, inner - 2 - visibleWidth(name) - visibleWidth(stat));
249
+
250
+ const lines = [
251
+ ` ${theme.fg("borderMuted", "─".repeat(inner))}`,
252
+ fit(` ${name}${" ".repeat(gap)}${theme.fg("dim", stat)}`),
253
+ ` ${theme.fg("muted", truncateToWidth(run.description, Math.max(0, inner - 2), "…"))}`,
254
+ ` ${box.top("Phases", sidebarInner)}${box.top(`${title ? phaseTitle(title) : "Agents"} · ${list.length} agents`, agentInner)}`,
255
+ ];
256
+ for (let row = 0; row < body; row++) {
257
+ lines.push(` ${box.row(sidebarRows[row] ?? "", sidebarInner)}${box.row(agentRows[row] ?? "", agentInner)}`);
258
+ }
259
+ lines.push(` ${box.bottom(sidebarInner)}${box.bottom(agentInner)}`);
260
+ lines.push(fit(` ${theme.fg("dim", FOOTER)}`));
261
+ return lines;
262
+ },
263
+
264
+ handleInput(data: string): void {
265
+ // ctrl+c is pi's own select cancel, and alt+a opened the panel, so it closes it too.
266
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "alt+a")) return close();
267
+ // Read the source, not the last frame: a key can land between renders, and `x` has
268
+ // to judge a branch by its status now rather than by the one drawn 16 ms ago.
269
+ shown = source.run() ?? shown;
270
+ const branches = shown?.branches ?? [];
271
+ if (!branches.length) return;
272
+ // Up and down walk the whole run, not one phase, so falling off the end of Map
273
+ // lands on the first branch of Design and the sidebar and box title follow.
274
+ const index = Math.max(0, branches.findIndex((branch) => branch.stem === selectedStem));
275
+ if (matchesKey(data, "up")) selectedStem = branches[(index - 1 + branches.length) % branches.length]?.stem;
276
+ else if (matchesKey(data, "down")) selectedStem = branches[(index + 1) % branches.length]?.stem;
277
+ // matchesKey, not a byte compare: Caps Lock or Shift sends "X", and the kitty protocol
278
+ // can send x with lock bits set.
279
+ else if (matchesKey(data, "x") || matchesKey(data, "shift+x")) {
280
+ const branch = branches[index];
281
+ // A settled branch's pid belongs to whatever the OS handed it to next, so the
282
+ // guard is here as well as in the producer. A pid not yet written by the beacon is
283
+ // no reason to refuse: the producer finds its own child without it. Stopping is
284
+ // silent and immediate: the footer promises `x stop`, not a confirmation.
285
+ if (branch && !branch.settled) source.stop(branch);
286
+ } else return;
287
+ tui.requestRender();
288
+ },
289
+
290
+ invalidate(): void {
291
+ // Nothing is cached between frames; render() reads the snapshot fresh every time.
292
+ },
293
+
294
+ dispose(): void {
295
+ unsubscribe();
296
+ clearInterval(tick);
297
+ },
298
+ };
299
+ return self;
300
+ };
301
+
302
+ // One panel at a time. A second ctx.ui.custom() would clear editorContainer again, and the
303
+ // first panel's close would then restore an editor the second one had already replaced.
304
+ let showing = false;
305
+
306
+ /**
307
+ * The one door in. The bar owns `/umb-agents`, `alt+a` and the down-arrow probe and calls this
308
+ * from all three, so the panel registers no command and no shortcut of its own — a second
309
+ * registration of the same name is a collision, not a third way in.
310
+ */
311
+ export const openPanel = async (ctx: ExtensionContext, source: PanelSource): Promise<void> => {
312
+ if (ctx.mode !== "tui" || showing) return;
313
+ // Opening on nothing draws an empty box the user cannot fill, and the down-arrow door
314
+ // fires whether or not a run exists.
315
+ if (!source.run()) return ctx.ui.notify("no agent run to show");
316
+ showing = true;
317
+ let orphan = () => {};
318
+ const orphaned = new Promise<void>((resolve) => (orphan = resolve));
319
+ try {
320
+ // No `overlay` option: the panel takes the editor's slot rather than floating over
321
+ // it, which is the path that saves and restores the user's unsent text. The promise
322
+ // resolves when the component calls done(), and pi calls dispose() straight after;
323
+ // `orphaned` resolves when another dialog took the slot and pi never will.
324
+ await Promise.race([
325
+ ctx.ui.custom<undefined>((tui, theme, _keybindings, done) =>
326
+ createPanel(tui, theme, source, () => done(undefined), orphan),
327
+ ),
328
+ orphaned,
329
+ ]);
330
+ } finally {
331
+ showing = false;
332
+ }
333
+ };
@@ -0,0 +1,95 @@
1
+ ---
2
+ name: delegate
3
+ description: Fan out independent read-only work (find usages across many files, summarize several modules, run tests and report, compare options) to separate cheap pi processes and read back their result files. Use whenever a task splits into 2+ lookups that do not need each other, or when the user writes "delegate", "fan out", "subagent", or starts a message with "delegate:".
4
+ ---
5
+
6
+ # Delegate
7
+
8
+ pi has no subagent tool. A branch is a separate `pi -p` process with an empty
9
+ context and read-only tools; its result is a file this session reads. Nothing
10
+ is added to this session's prompt. Each branch keeps its session under
11
+ `$run/sessions/<stem>`, so a finished branch can be continued with `dresume`.
12
+
13
+ Each branch also writes a small live state file, so the agent panel can show
14
+ what it is doing while it runs instead of only what it concluded at the end.
15
+ That is handled by `run.sh` — do not hand-roll the `pi -p` command line.
16
+
17
+ ## Run
18
+
19
+ One bash call. `dstart` records the session cwd and must run **before** any
20
+ `cd`, because the panel watches the directory pi started in. The smart tier is
21
+ this session's own model, `$PI_PROVIDER/$PI_MODEL`; `$FAST` and `$LOAD` come
22
+ from `delegate.env`, then from `~/.pi/agent/delegate.env` when it exists.
23
+
24
+ ```bash
25
+ . <skill dir>/run.sh # <skill dir>: the directory this SKILL.md is in
26
+ dstart <slug> "<one line describing the whole run>" map
27
+ cd <deepest folder the branches touch> # optional, and only after dstart
28
+
29
+ branch map explorer-1 "$FAST" "Find every file that imports X. List file:line."
30
+ branch map explorer-2 "$FAST" "..."
31
+ dwait
32
+ grep -L '^STATUS: OK' "$run"/*.md
33
+ ```
34
+
35
+ `dstart <slug> <description> <phase>...` names every phase up front, in the
36
+ order the panel lists them. `branch <phase> <name> <model> <task>` seeds and
37
+ launches one branch. `dwait` publishes the run and waits for all of them.
38
+
39
+ `<slug>`, phase names and branch names must all match `^[a-z0-9][a-z0-9._-]*$`:
40
+ they become filenames. A duplicate `<phase>-<name>` is refused rather than
41
+ silently sharing one state file with another branch.
42
+
43
+ Every branch defaults to `$FAST`. Use `$PI_PROVIDER/$PI_MODEL` when the user
44
+ asks for the smart tier or the same model, or any `provider/id` from `/model`
45
+ when they name one.
46
+
47
+ ## Read
48
+
49
+ Read `$run/*.md`. The `grep -L` line lists branches that did not report
50
+ `STATUS: OK` (PARTIAL, NEED_STRONGER, ASKING, or a malformed answer). Re-run
51
+ each PARTIAL or NEED_STRONGER one with the same task on
52
+ `$PI_PROVIDER/$PI_MODEL`. A missing STATUS line is NEED_STRONGER, never
53
+ success. Check FILES paths with `ls` before relying on them.
54
+
55
+ `STATUS: ASKING` means the branch stopped on a decision, with `QUESTION:` and
56
+ `OPTIONS:` lines. Answer it yourself from this conversation when you can;
57
+ only when the user alone can decide, ask them with `ask_user_question` first.
58
+ Then continue the branch in its own session and read the new report:
59
+
60
+ ```bash
61
+ . <skill dir>/run.sh
62
+ dresume "$run" <stem> "<answer>" # one per asking branch
63
+ wait; cat "$run/<stem>.md"
64
+ ```
65
+
66
+ A second wave is a second batch of `branch` calls on the next phase name
67
+ followed by another `dwait`, in the same bash call.
68
+
69
+ ## Rules
70
+
71
+ - Branches are read-only (`--tools read,grep,find,ls`). Add `bash` only for
72
+ a test-and-report branch. Work that must edit files stays in this session.
73
+ - A branch given `bash` may source `run.sh` and fan out again. It joins THIS run
74
+ directory instead of starting a rival one, keeps this branch's phase, and the
75
+ panel draws its children indented under it. Two levels is the sensible limit.
76
+ - A branch that needs one more extension gets it explicitly, e.g.
77
+ `-e $HOME/.pi/agent/npm/node_modules/pi-web-access-lean` for research —
78
+ append it to `$LOAD` before the first `branch` call, because
79
+ `--no-extensions` drops provider extensions too.
80
+ - 2 to 6 branches per run. Each task is self-contained: name the files or
81
+ directory, say exactly what to return.
82
+ - `.pi-out/` is gitignored scratch. Follow-up to a branch: same `branch` call,
83
+ a new name, new task text.
84
+ - Read `$run/<phase>-<name>.err` when a branch reports nothing; that is where
85
+ a bad model id or a missing provider extension lands.
86
+
87
+ ## Files
88
+
89
+ - `run.sh` — `dstart` / `branch` / `dwait` / `dresume`. Check: `bash run.check.sh`.
90
+ - `beacon.ts` — loaded into each branch with `-e`, writes its state file.
91
+ - `state.ts` — the on-disk schema and `readRun()`, which the panel imports.
92
+ Check: `bun run state.check.ts`.
93
+ - `report.md` — the report contract, appended to every branch's system prompt.
94
+ - `delegate.env` — defaults for `$FAST`, `$LOAD` and `$DELEGATE_TIMEOUT`. The owner's
95
+ own values go in `~/.pi/agent/delegate.env`, which is read after it.