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.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/extensions/umbra-loop.ts +102 -0
- package/extensions/umbra-subagents/bar/bar-line.ts +344 -0
- package/extensions/umbra-subagents/bar.check.ts +229 -0
- package/extensions/umbra-subagents/bar.ts +173 -0
- package/extensions/umbra-subagents/fan/index.ts +110 -0
- package/extensions/umbra-subagents/fan/spec.ts +81 -0
- package/extensions/umbra-subagents/fan/store.check.ts +140 -0
- package/extensions/umbra-subagents/fan/store.ts +491 -0
- package/extensions/umbra-subagents/panel.check.ts +193 -0
- package/extensions/umbra-subagents/panel.ts +333 -0
- package/extensions/umbra-subagents/skills/delegate/SKILL.md +95 -0
- package/extensions/umbra-subagents/skills/delegate/beacon.ts +210 -0
- package/extensions/umbra-subagents/skills/delegate/delegate.env +12 -0
- package/extensions/umbra-subagents/skills/delegate/report.md +15 -0
- package/extensions/umbra-subagents/skills/delegate/run.check.sh +120 -0
- package/extensions/umbra-subagents/skills/delegate/run.sh +170 -0
- package/extensions/umbra-subagents/skills/delegate/state.check.ts +137 -0
- package/extensions/umbra-subagents/skills/delegate/state.ts +295 -0
- package/extensions/umbra-subagents/skills/fan/SKILL.md +52 -0
- package/extensions/umbra-subagents.ts +4 -0
- package/package.json +43 -0
- package/patch.mjs +97 -0
|
@@ -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.
|