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,229 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { MAX_ROWS, layoutBar, layoutList, renderKey, renderLines } from "./bar/bar-line.ts";
|
|
3
|
+
import type { BranchView, RunState } from "./skills/delegate/state.ts";
|
|
4
|
+
|
|
5
|
+
// The widget sits directly above the input box, so two things can hurt and both are checked
|
|
6
|
+
// here rather than argued in a comment:
|
|
7
|
+
//
|
|
8
|
+
// 1. a composed line one column too wide wraps in the terminal and pushes the box down
|
|
9
|
+
// 2. the collapsed shape rendering anything other than exactly one line does the same
|
|
10
|
+
//
|
|
11
|
+
// Everything under test is pure, so this needs no terminal, no theme and no pi.
|
|
12
|
+
// Run with: bun run bar.check.ts
|
|
13
|
+
|
|
14
|
+
// A function declaration, not a const arrow: TypeScript only narrows past a never-returning
|
|
15
|
+
// call when it is declared this way, and every assertion below relies on that narrowing.
|
|
16
|
+
function fail(message: string): never {
|
|
17
|
+
throw new Error(message);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// SAFETY: only the fields the layouts read are populated; every one of them is here.
|
|
21
|
+
const branch = (over: Partial<BranchView> = {}): BranchView =>
|
|
22
|
+
({
|
|
23
|
+
phase: "map",
|
|
24
|
+
name: "general-purpose",
|
|
25
|
+
parent: null,
|
|
26
|
+
index: 1,
|
|
27
|
+
model: "Opus 5",
|
|
28
|
+
pid: 4242,
|
|
29
|
+
status: "running",
|
|
30
|
+
activity: "Grepping localStorage keys in office-persist.ts",
|
|
31
|
+
tokens: 56_500,
|
|
32
|
+
startedAt: 0,
|
|
33
|
+
updatedAt: 232_000,
|
|
34
|
+
report: null,
|
|
35
|
+
error: null,
|
|
36
|
+
stem: "map-general-purpose",
|
|
37
|
+
label: "map:general-purpose",
|
|
38
|
+
depth: 0,
|
|
39
|
+
elapsedMs: 232_000, // 3m 52s
|
|
40
|
+
idleMs: 0,
|
|
41
|
+
exitCode: null,
|
|
42
|
+
timedOut: false,
|
|
43
|
+
alive: true,
|
|
44
|
+
settled: false,
|
|
45
|
+
...over,
|
|
46
|
+
}) as BranchView;
|
|
47
|
+
|
|
48
|
+
const run = (over: Partial<RunState> = {}): RunState =>
|
|
49
|
+
({
|
|
50
|
+
dir: ".pi-out/20260905-030000",
|
|
51
|
+
name: "pi-toolcall-render",
|
|
52
|
+
description: "Map how pi renders tool calls and whether an extension can override it",
|
|
53
|
+
startedAt: 0,
|
|
54
|
+
phases: ["map", "design"],
|
|
55
|
+
activePhase: 0,
|
|
56
|
+
branches: [],
|
|
57
|
+
done: 2,
|
|
58
|
+
total: 4,
|
|
59
|
+
tokens: 397_800,
|
|
60
|
+
live: true,
|
|
61
|
+
...over,
|
|
62
|
+
}) as RunState;
|
|
63
|
+
|
|
64
|
+
const now = 278_000; // 4m 38s
|
|
65
|
+
|
|
66
|
+
// ---------- reference 2, the collapsed bar ----------
|
|
67
|
+
|
|
68
|
+
// One branch producing, so the leading glyph has something to be filled about. An empty
|
|
69
|
+
// branches[] would read as "the whole run is quiet", which is a different assertion below.
|
|
70
|
+
const phased = run({ branches: [branch()] });
|
|
71
|
+
|
|
72
|
+
for (let width = 8; width <= 200; width++) {
|
|
73
|
+
const parts = layoutBar(width, phased, now);
|
|
74
|
+
if (!parts) {
|
|
75
|
+
if (width - 2 >= 8) fail(`no layout at width ${width}`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const used =
|
|
79
|
+
visibleWidth(parts.left) + visibleWidth(parts.description) + parts.fill.length + visibleWidth(parts.right);
|
|
80
|
+
if (used !== width - 2) fail(`bar width ${width}: composed ${used}, expected ${width - 2}`);
|
|
81
|
+
if (!parts.right.startsWith("2/4")) fail(`bar width ${width}: counters mangled: ${parts.right}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const wide = layoutBar(100, phased, now);
|
|
85
|
+
if (!wide) fail("no layout at width 100");
|
|
86
|
+
if (wide.right !== "2/4 agents done · 4m 38s · ↓ 397.8k tokens") fail(`wide tier: ${wide.right}`);
|
|
87
|
+
if (!wide.description.includes("...")) fail(`description not truncated at 100: ${wide.description}`);
|
|
88
|
+
if (!wide.left.startsWith("●")) fail(`live glyph: ${wide.left}`);
|
|
89
|
+
|
|
90
|
+
// Reference 2's pasted frame reads `○ pi-toolcall-render ... 2/4 agents done` — a run that is
|
|
91
|
+
// live but waiting. One rule covers both screens: filled means a branch is producing right
|
|
92
|
+
// now, not merely that the run has not finished.
|
|
93
|
+
const waiting = layoutBar(100, run({ branches: [branch({ idleMs: 20_000 })] }), now);
|
|
94
|
+
if (!waiting?.left.startsWith("○")) fail(`live-but-quiet glyph: ${waiting?.left}`);
|
|
95
|
+
if (layoutBar(30, phased, now)?.description !== "") fail("description survived 30 columns");
|
|
96
|
+
|
|
97
|
+
// A finished run stops counting: the clock freezes at the last write, not at Date.now().
|
|
98
|
+
const settled = run({ live: false, done: 4, branches: [branch({ updatedAt: now, settled: true, alive: false })] });
|
|
99
|
+
const frozen = layoutBar(100, settled, now + 5_000);
|
|
100
|
+
if (!frozen) fail("no layout for a settled run inside its linger window");
|
|
101
|
+
if (!frozen.right.includes("4m 38s")) fail(`clock kept running after the run ended: ${frozen.right}`);
|
|
102
|
+
if (!frozen.left.startsWith("○")) fail(`finished glyph: ${frozen.left}`);
|
|
103
|
+
if (layoutBar(100, settled, now + 31_000)) fail("finished run outstayed LINGER_MS");
|
|
104
|
+
|
|
105
|
+
// Zero is a lie while a provider reports usage only at completion, so it renders as a dash.
|
|
106
|
+
if (!layoutBar(100, run({ tokens: 0 }), now)?.right.includes("↓ – tokens")) fail("zero tokens rendered as a number");
|
|
107
|
+
if (layoutBar(100, run({ total: 0 }), now)) fail("empty run rendered");
|
|
108
|
+
if (layoutBar(100, undefined, now)) fail("missing run rendered");
|
|
109
|
+
|
|
110
|
+
// ---------- reference 3, the flat list ----------
|
|
111
|
+
|
|
112
|
+
const flat = run({
|
|
113
|
+
phases: ["map"], // one phase: no workflow to collapse, so this is the list case
|
|
114
|
+
done: 0,
|
|
115
|
+
total: 2,
|
|
116
|
+
branches: [
|
|
117
|
+
branch(),
|
|
118
|
+
branch({
|
|
119
|
+
name: "Explore",
|
|
120
|
+
stem: "map-explore",
|
|
121
|
+
label: "map:Explore",
|
|
122
|
+
depth: 1,
|
|
123
|
+
parent: "map-general-purpose",
|
|
124
|
+
index: 2,
|
|
125
|
+
activity: "Confirming store.ts is unused",
|
|
126
|
+
tokens: 86_300,
|
|
127
|
+
elapsedMs: 172_000, // 2m 52s
|
|
128
|
+
// Hollow in the reference while its sibling is filled, and a branch that has not
|
|
129
|
+
// emitted for a quarter of a minute is the one state that can honestly produce that.
|
|
130
|
+
// The screen cannot tell us which of the two it was; the rule has to mean something,
|
|
131
|
+
// so it means the thing the panel already tracks.
|
|
132
|
+
idleMs: 20_000,
|
|
133
|
+
}),
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
for (let width = 8; width <= 200; width++) {
|
|
138
|
+
for (const state of [flat, undefined, settled]) {
|
|
139
|
+
const rows = layoutList(width, state, now, MAX_ROWS);
|
|
140
|
+
if (rows.length === 0) fail(`list width ${width}: zero rows un-mounts the widget`);
|
|
141
|
+
for (const row of rows) {
|
|
142
|
+
const used =
|
|
143
|
+
visibleWidth(row.head) + visibleWidth(row.activity) + row.fill.length + visibleWidth(row.right);
|
|
144
|
+
if (used !== width - 2) fail(`list width ${width}: row composed ${used}, expected ${width - 2}`);
|
|
145
|
+
}
|
|
146
|
+
// The activity column has to start in the same place on every row, or the sentences
|
|
147
|
+
// stagger and the list stops being readable at a glance.
|
|
148
|
+
const heads = new Set(rows.map((row) => visibleWidth(row.head)));
|
|
149
|
+
if (heads.size !== 1) fail(`list width ${width}: ragged head column ${[...heads].join()}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const list = layoutList(100, flat, now, MAX_ROWS);
|
|
154
|
+
if (list.length !== 3) fail(`expected main + two agents, got ${list.length}`);
|
|
155
|
+
// SAFETY: length checked immediately above.
|
|
156
|
+
const [main, first, nested] = list as [(typeof list)[0], (typeof list)[0], (typeof list)[0]];
|
|
157
|
+
if (main.head.trimEnd() !== "○ main") fail(`main row: ${JSON.stringify(main.head)}`);
|
|
158
|
+
if (main.branch !== undefined) fail("the main row is not a branch");
|
|
159
|
+
if (main.activity !== "" || main.right.trim() !== "") fail("the main row carries counters the reference does not show");
|
|
160
|
+
if (first.head.trimEnd() !== "● general-purpose") fail(`agent row: ${JSON.stringify(first.head)}`);
|
|
161
|
+
if (first.dotAt !== 0) fail(`dot column on a top-level row: ${first.dotAt}`);
|
|
162
|
+
if (!first.activity.endsWith("Grepping localStorage keys in office-persist.ts")) fail(`activity: ${first.activity}`);
|
|
163
|
+
if (first.right !== "3m 52s · ↓ 56.5k tokens") fail(`right column: ${JSON.stringify(first.right)}`);
|
|
164
|
+
if (nested.head.trimEnd() !== "└ ○ Explore") fail(`nested row: ${JSON.stringify(nested.head)}`);
|
|
165
|
+
if (nested.dotAt !== 2) fail(`dot column on a nested row: ${nested.dotAt}`);
|
|
166
|
+
if (nested.right !== "2m 52s · ↓ 86.3k tokens") fail(`nested right column: ${JSON.stringify(nested.right)}`);
|
|
167
|
+
|
|
168
|
+
// An idle session is one bare row, which is also the height a phased run collapses to — so the
|
|
169
|
+
// widget never changes height just by changing its mind about which screen it is drawing.
|
|
170
|
+
const idle = layoutList(100, undefined, now, MAX_ROWS);
|
|
171
|
+
if (idle.length !== 1 || idle[0]?.head.trimEnd() !== "○ main") fail("idle is not exactly one `○ main` row");
|
|
172
|
+
if (layoutList(100, settled, now + 31_000, MAX_ROWS).length !== 1) fail("finished run outstayed LINGER_MS in the list");
|
|
173
|
+
|
|
174
|
+
// A quiet branch goes hollow, a working one is filled, and the row budget is a ceiling rather
|
|
175
|
+
// than a suggestion.
|
|
176
|
+
const quiet = layoutList(100, run({ phases: ["map"], branches: [branch({ idleMs: 60_000 })] }), now, MAX_ROWS);
|
|
177
|
+
if (!quiet[1]?.head.startsWith("○")) fail(`an idle branch kept its filled dot: ${quiet[1]?.head}`);
|
|
178
|
+
|
|
179
|
+
const many = run({ phases: ["map"], branches: Array.from({ length: 40 }, (_, i) => branch({ stem: `map-b${i}` })) });
|
|
180
|
+
const capped = layoutList(100, many, now, MAX_ROWS);
|
|
181
|
+
if (capped.length !== MAX_ROWS) fail(`row budget ignored: ${capped.length}`);
|
|
182
|
+
if (!capped[MAX_ROWS - 1]?.head.startsWith("+33 more")) fail(`no overflow row: ${capped[MAX_ROWS - 1]?.head}`);
|
|
183
|
+
if (capped[MAX_ROWS - 1]?.dotAt !== -1) fail("the overflow row was given a status dot");
|
|
184
|
+
// A budget of one is `○ main` and nothing else, not `○ main` plus an apology.
|
|
185
|
+
if (layoutList(100, many, now, 1).length !== 1) fail("a one-row budget grew a second row");
|
|
186
|
+
if (layoutList(100, many, now, 2).length !== 2) fail("a two-row budget is main plus the overflow");
|
|
187
|
+
|
|
188
|
+
// ---------- the fixed-height rule ----------
|
|
189
|
+
|
|
190
|
+
const paint = (_color: unknown, text: string) => text; // identity: measure the plain frame
|
|
191
|
+
for (let width = 0; width <= 200; width++) {
|
|
192
|
+
const collapsed = renderLines(width, phased, now, MAX_ROWS, paint);
|
|
193
|
+
if (collapsed.length !== 1) fail(`collapsed rendered ${collapsed.length} lines at width ${width}`);
|
|
194
|
+
if (visibleWidth(collapsed[0] ?? "") !== Math.max(0, width)) fail(`collapsed overflowed at width ${width}`);
|
|
195
|
+
|
|
196
|
+
// Every other path through renderLines, at the same widths.
|
|
197
|
+
for (const [label, lines] of [
|
|
198
|
+
["panel open", renderLines(width, phased, now, MAX_ROWS, paint, true)],
|
|
199
|
+
["idle", renderLines(width, undefined, now, MAX_ROWS, paint)],
|
|
200
|
+
["list", renderLines(width, flat, now, MAX_ROWS, paint)],
|
|
201
|
+
["faded", renderLines(width, settled, now + 31_000, MAX_ROWS, paint)],
|
|
202
|
+
] as [string, string[]][]) {
|
|
203
|
+
// idle and faded now draw NOTHING, at every width, and that is the assertion - not a
|
|
204
|
+
// blank line. The slot is belowEditor, where pi passes spacerWhenEmpty and leadingSpacer
|
|
205
|
+
// both false, so zero lines is zero rows and the footer above never moves. The check
|
|
206
|
+
// runs width 0..200 because the faded test sits ahead of the narrow-terminal guard;
|
|
207
|
+
// putting it after would have made a 5-column terminal the one place a bare row survived.
|
|
208
|
+
if (label === "idle" || label === "faded") {
|
|
209
|
+
if (lines.length !== 0) fail(`${label} drew ${lines.length} lines at width ${width}, expected none`);
|
|
210
|
+
} else if (lines.length < 1) {
|
|
211
|
+
fail(`${label} rendered nothing at width ${width}`);
|
|
212
|
+
}
|
|
213
|
+
for (const line of lines) {
|
|
214
|
+
if (visibleWidth(line) !== Math.max(0, width)) fail(`${label} overflowed at width ${width}: ${line.length}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (renderLines(80, phased, now, MAX_ROWS, paint, true)[0]?.trim() !== "") fail("the open panel did not blank the widget");
|
|
219
|
+
|
|
220
|
+
// ---------- the no-op frame ----------
|
|
221
|
+
|
|
222
|
+
if (renderKey(flat, now) !== renderKey(flat, now)) fail("render key is not stable");
|
|
223
|
+
if (renderKey(undefined, now) !== "-") fail("an empty screen is not one key");
|
|
224
|
+
if (renderKey(settled, now + 31_000) !== "-") fail("a faded run is not the empty screen");
|
|
225
|
+
if (renderKey(flat, now) === renderKey(flat, now + 1_000)) fail("the clock moved and the key did not");
|
|
226
|
+
const rewritten = run({ ...flat, branches: [branch({ activity: "Reading hooks.server.ts and app.d.ts" }), ...flat.branches.slice(1)] });
|
|
227
|
+
if (renderKey(flat, now) === renderKey(rewritten, now)) fail("the activity sentence changed and the key did not");
|
|
228
|
+
|
|
229
|
+
console.log("ok");
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { CustomEditor, type ExtensionAPI, type ExtensionContext, type KeybindingsManager, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Editor, type Component, type EditorTheme, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
3
|
+
import { faded, MAX_ROWS, renderKey, renderLines } from "./bar/bar-line.ts";
|
|
4
|
+
import type { RunState } from "./skills/delegate/state.ts";
|
|
5
|
+
|
|
6
|
+
// Part 3: everything the widget slot below the input box draws, and the ways it opens into the
|
|
7
|
+
// full panel of reference 1.
|
|
8
|
+
//
|
|
9
|
+
// Two shapes, one widget. Reference 3 — the flat live list, `○ main` on top, one row per agent,
|
|
10
|
+
// `└` for a nested one — is the default, and is what an idle session shows as a single bare
|
|
11
|
+
// `○ main`. Reference 2 — one line: run name, truncated description, right-aligned counters —
|
|
12
|
+
// replaces it while a phased run is going, because that run has a panel worth zooming into and
|
|
13
|
+
// a per-agent list would only be that panel drawn badly.
|
|
14
|
+
//
|
|
15
|
+
// The widget is mounted once at session_start and never removed. It asks for belowEditor, and
|
|
16
|
+
// repatch.mjs moves that slot below pi's status line. The slot grows a Spacer(1) the moment it holds anything, so registering and
|
|
17
|
+
// unregistering it moves the input box by a row;
|
|
18
|
+
// and setWidget disposes and rebuilds its component on every call, so calling it per tick would
|
|
19
|
+
// be a per-tick rebuild. Register the factory once, then requestRender().
|
|
20
|
+
//
|
|
21
|
+
// The string[] form of setWidget is unusable here twice over: it is wrapped in a Text that
|
|
22
|
+
// word-wraps a long line into two rows and yields nothing at all for a blank one, and it is
|
|
23
|
+
// capped at 10 lines. Only the factory form is handed `width`, which is what makes every
|
|
24
|
+
// truncation in bar/bar-line.ts possible.
|
|
25
|
+
//
|
|
26
|
+
// Every column of the frame itself is in bar/bar-line.ts, down to and including the string
|
|
27
|
+
// concatenation, because bar.check.ts cannot import this file: `CustomEditor` is a value from
|
|
28
|
+
// pi's own package, and loading that package pulls in an experimental server module that is not
|
|
29
|
+
// installed. What is left here is the part a check could not have run anyway — a component, an
|
|
30
|
+
// editor subclass, and four registrations.
|
|
31
|
+
|
|
32
|
+
const BAR_KEY = "agent-bar";
|
|
33
|
+
// Rows the list leaves to the editor, the status line and pi's own footer on a short terminal.
|
|
34
|
+
const RESERVE = 6;
|
|
35
|
+
|
|
36
|
+
class BarComponent implements Component {
|
|
37
|
+
constructor(
|
|
38
|
+
private tui: TUI,
|
|
39
|
+
private theme: Theme,
|
|
40
|
+
private onOpen: () => void,
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
render(width: number): string[] {
|
|
44
|
+
// terminal.rows only changes on a resize, which repaints everything anyway, so reading
|
|
45
|
+
// it per frame costs nothing and keeps the list inside a shrunk window.
|
|
46
|
+
const budget = Math.max(1, Math.min(MAX_ROWS, this.tui.terminal.rows - RESERVE));
|
|
47
|
+
return renderLines(width, current, Date.now(), budget, (color, text) => this.theme.fg(color, text), panelOpen);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Mouse reporting is only on in the alternate-screen TUI (tuiMode "fullscreen"). Clicks are
|
|
51
|
+
// routed by position, not focus, so a click while another dialog holds the editor slot
|
|
52
|
+
// (ask_user_question, a select) would swap that dialog out and leave its promise hanging:
|
|
53
|
+
// the bar only opens the panel while an editor has focus.
|
|
54
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
55
|
+
if (event.type !== "click" || panelOpen || !current) return undefined;
|
|
56
|
+
if (!(this.tui.getFocusedComponent() instanceof Editor)) return undefined;
|
|
57
|
+
this.onOpen();
|
|
58
|
+
return { handled: true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
invalidate(): void {
|
|
62
|
+
// Nothing is cached between frames: render() reads the snapshot fresh every time.
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
refresh(): void {
|
|
66
|
+
// Never requestRender(true). That calls resetRenderState(), which forces a repaint of
|
|
67
|
+
// the whole screen instead of the changed band — which is the flicker.
|
|
68
|
+
this.tui.requestRender();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Down is not ours to steal outright: it moves the cursor, walks prompt history and drives the
|
|
73
|
+
// autocomplete list. It belongs to the bar only when the editor would do nothing with it —
|
|
74
|
+
// cursor already parked at the end of the last visual line — so the editor is asked rather than
|
|
75
|
+
// second-guessed. Its wrap map is private and re-deriving it would drift the first time pi
|
|
76
|
+
// changes its wrapping; a no-op cannot drift. registerShortcut("down") is not an option: the
|
|
77
|
+
// dispatcher consumes a matched key session-wide, history and autocomplete included.
|
|
78
|
+
class BarEditor extends CustomEditor {
|
|
79
|
+
constructor(
|
|
80
|
+
tui: TUI,
|
|
81
|
+
theme: EditorTheme,
|
|
82
|
+
private keys: KeybindingsManager,
|
|
83
|
+
private onDownAtEnd: () => void,
|
|
84
|
+
) {
|
|
85
|
+
super(tui, theme, keys);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
handleInput(data: string): void {
|
|
89
|
+
// With the autocomplete list open, down moves the highlight and changes neither the text
|
|
90
|
+
// nor the cursor, so the probe below would misread it as a no-op.
|
|
91
|
+
if (!this.keys.matches(data, "tui.editor.cursorDown") || this.isShowingAutocomplete()) {
|
|
92
|
+
super.handleInput(data);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const text = this.getText();
|
|
96
|
+
const before = this.getCursor();
|
|
97
|
+
super.handleInput(data);
|
|
98
|
+
const after = this.getCursor();
|
|
99
|
+
if (this.getText() === text && after.line === before.line && after.col === before.col) this.onDownAtEnd();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let current: RunState | undefined;
|
|
104
|
+
let panelOpen = false;
|
|
105
|
+
let bar: BarComponent | undefined;
|
|
106
|
+
let lastKey = "";
|
|
107
|
+
|
|
108
|
+
/** The bar holds no state and owns no timer: the reader that already polls `<cwd>/.pi-out` for
|
|
109
|
+
* the panel calls this every tick, so the two views can never disagree about the same run and
|
|
110
|
+
* only one thing is ever walking the disk. */
|
|
111
|
+
export const setRun = (run: RunState | undefined) => {
|
|
112
|
+
current = run;
|
|
113
|
+
const key = renderKey(run, Date.now());
|
|
114
|
+
if (key === lastKey) return;
|
|
115
|
+
lastKey = key;
|
|
116
|
+
bar?.refresh();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const installBar = (pi: ExtensionAPI, openPanel: (ctx: ExtensionContext) => Promise<void> | void) => {
|
|
120
|
+
// `quiet` is the down arrow and the click: both fire whether or not a run exists, and a
|
|
121
|
+
// notification on every press of a key the user meant as "move the cursor" is noise. The
|
|
122
|
+
// command and the shortcut were typed on purpose, so those get told why nothing happened.
|
|
123
|
+
const open = (ctx: ExtensionContext, quiet = false) => {
|
|
124
|
+
if (panelOpen) return quiet ? undefined : ctx.ui.notify("agent panel is already open");
|
|
125
|
+
// Opening on nothing would draw an empty box the user cannot fill. The quiet doors also
|
|
126
|
+
// skip a run the bar has already let go of: a key meant as "move the cursor" should not
|
|
127
|
+
// bring back a run that finished minutes ago. The command and alt+a still open it.
|
|
128
|
+
if (!current || (quiet && faded(current, Date.now()))) {
|
|
129
|
+
return quiet ? undefined : ctx.ui.notify("no agent run in this directory");
|
|
130
|
+
}
|
|
131
|
+
panelOpen = true;
|
|
132
|
+
bar?.refresh();
|
|
133
|
+
void Promise.resolve(openPanel(ctx)).finally(() => {
|
|
134
|
+
panelOpen = false;
|
|
135
|
+
bar?.refresh();
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// umbra-inputbar's editor announces a down at the end on pi's event bus. pi keeps one editor
|
|
140
|
+
// per session and does not sort extensions before loading them (under bun the order is the
|
|
141
|
+
// filesystem's), so a second editor class of our own would win or lose at random. The event
|
|
142
|
+
// works whichever loads first; the subscription is dropped by pi on /reload.
|
|
143
|
+
let lastCtx: ExtensionContext | undefined;
|
|
144
|
+
pi.events.on("editor:down-at-end", () => {
|
|
145
|
+
if (lastCtx) open(lastCtx, true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
pi.on("session_start", (_event, ctx) => {
|
|
149
|
+
if (ctx.mode !== "tui") return;
|
|
150
|
+
lastCtx = ctx;
|
|
151
|
+
// Re-registered on every session_start, including the "reload" one that follows
|
|
152
|
+
// resetExtensionUI() clearing every widget and the custom editor. Once per session is
|
|
153
|
+
// not once per tick: each call disposes the old component and builds a new one.
|
|
154
|
+
ctx.ui.setWidget(
|
|
155
|
+
BAR_KEY,
|
|
156
|
+
(tui, theme) => {
|
|
157
|
+
bar = new BarComponent(tui, theme, () => open(ctx, true));
|
|
158
|
+
return bar;
|
|
159
|
+
},
|
|
160
|
+
{ placement: "belowEditor" },
|
|
161
|
+
);
|
|
162
|
+
// Fallback for a setup without umbra-inputbar: then this editor does the same probe
|
|
163
|
+
// itself. With umbra-inputbar present its editor is kept, and the event above is the way
|
|
164
|
+
// in. Only one editor is ever live, so the panel cannot be asked to open twice.
|
|
165
|
+
if (ctx.ui.getEditorComponent()) return;
|
|
166
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => new BarEditor(tui, theme, keybindings, () => open(ctx, true)));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Not ctrl+<letter>: every one is already bound by pi, and extension shortcuts are matched
|
|
170
|
+
// before app keybindings, so one would silently shadow a built-in.
|
|
171
|
+
pi.registerShortcut("alt+a", { description: "Open the agent panel", handler: (ctx) => open(ctx) });
|
|
172
|
+
pi.registerCommand("umb-agents", { description: "Open the agent panel", handler: async (_args, ctx) => open(ctx) });
|
|
173
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { installBar, setRun } from "../bar.ts";
|
|
5
|
+
import { openPanel, type PanelSource } from "../panel.ts";
|
|
6
|
+
import { findBlock, parseSpec, TEMPLATE } from "./spec.ts";
|
|
7
|
+
import { reportOf, store } from "./store.ts";
|
|
8
|
+
import type { RunState } from "../skills/delegate/state.ts";
|
|
9
|
+
|
|
10
|
+
// Wiring only. store.ts owns the branches, bar.ts and panel.ts render them.
|
|
11
|
+
//
|
|
12
|
+
// Three ways in, all free: `/umb-fan` for the user, a fenced ```fan block in the model's own
|
|
13
|
+
// answer, and a bash call that turns out to be the delegate skill — the same run directory
|
|
14
|
+
// either way, so the panel does not care which one started it. No pi.registerTool anywhere,
|
|
15
|
+
// so the prompt is byte-for-byte unchanged.
|
|
16
|
+
|
|
17
|
+
const RUN_SH = join(dirname(fileURLToPath(import.meta.url)), "..", "skills", "delegate", "run.sh");
|
|
18
|
+
|
|
19
|
+
// A branch that stopped on a question goes to the model first: most questions are answered by
|
|
20
|
+
// the conversation the branch never saw, and only the rest reach the user.
|
|
21
|
+
const askingNote = (run: RunState): string => {
|
|
22
|
+
const asking = run.branches.filter((branch) => branch.report === "ASKING");
|
|
23
|
+
if (!asking.length) return "";
|
|
24
|
+
return (
|
|
25
|
+
`\n\n${asking.map((branch) => branch.stem).join(", ")} stopped on a question. Answer it from this ` +
|
|
26
|
+
"conversation if you can; if only the user can decide, ask them with ask_user_question first. " +
|
|
27
|
+
"Then continue each one in a single bash call and read its new report:\n\n" +
|
|
28
|
+
`. '${RUN_SH}'; ${asking.map((branch) => `dresume '${run.dir}' ${branch.stem} '<answer>'`).join("; ")}; wait; ` +
|
|
29
|
+
asking.map((branch) => `cat '${run.dir}/${branch.stem}.md'`).join("; ")
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const modelOf = (ctx: ExtensionContext) =>
|
|
34
|
+
process.env.FAN_MODEL || (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "");
|
|
35
|
+
|
|
36
|
+
// The one adapter between the producer and the two renderers. store keys a branch by its
|
|
37
|
+
// stem because that is its filename; the panel hands back the whole BranchView it drew,
|
|
38
|
+
// because a row it did not draw must not be stoppable. Converting here keeps both sides
|
|
39
|
+
// honest instead of widening either signature to meet the other.
|
|
40
|
+
const source: PanelSource = {
|
|
41
|
+
run: () => store.run(),
|
|
42
|
+
stop: (branch) => void store.stop(branch.stem),
|
|
43
|
+
subscribe: (fn) => store.subscribe(fn),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export default function (pi: ExtensionAPI) {
|
|
47
|
+
// The widget above the editor, the alt+a shortcut, the /umb-agents command and the down arrow
|
|
48
|
+
// out of the last editor line. All four open the same panel over the same source.
|
|
49
|
+
installBar(pi, (ctx) => openPanel(ctx, source));
|
|
50
|
+
// The bar is pushed to rather than polling a second time: store already ticks once a
|
|
51
|
+
// second while branches are live, and a second reader could only disagree with it.
|
|
52
|
+
store.subscribe(() => setRun(store.run()));
|
|
53
|
+
|
|
54
|
+
// The run directory this session started, and has therefore promised to report back on.
|
|
55
|
+
// A run the delegate skill started in bash is left alone: that caller reads $run/*.md
|
|
56
|
+
// itself, and a follow-up would hand the model the same text twice.
|
|
57
|
+
let awaiting: string | undefined;
|
|
58
|
+
|
|
59
|
+
// Branches must not outlive the pi that started them: nothing would be watching, and a
|
|
60
|
+
// branch left running keeps spending. Their reports are already on disk.
|
|
61
|
+
pi.on("session_shutdown", () => store.stopAll());
|
|
62
|
+
|
|
63
|
+
// A bash call may be the delegate skill mid-run. Arming the tick when one starts is what
|
|
64
|
+
// makes a skill-started run appear on the bar from its first frame rather than at the end;
|
|
65
|
+
// the turn's end disarms it, because no bash call outlives its turn.
|
|
66
|
+
pi.on("tool_execution_start", (event, ctx) => {
|
|
67
|
+
if (event.toolName === "bash") store.arm(ctx.cwd);
|
|
68
|
+
});
|
|
69
|
+
pi.on("agent_end", () => store.disarm());
|
|
70
|
+
pi.on("session_start", (_event, ctx) => store.watch(ctx.cwd));
|
|
71
|
+
|
|
72
|
+
pi.on("message_end", (event, ctx) => {
|
|
73
|
+
if (ctx.mode !== "tui" || event.message.role !== "assistant") return;
|
|
74
|
+
const text = event.message.content
|
|
75
|
+
.filter((part) => part.type === "text")
|
|
76
|
+
.map((part) => part.text)
|
|
77
|
+
.join("\n");
|
|
78
|
+
const block = findBlock(text);
|
|
79
|
+
if (!block) return;
|
|
80
|
+
const spec = parseSpec(block);
|
|
81
|
+
// The whole assistant message, not just the fenced block: the reasoning around the
|
|
82
|
+
// block is the half a branch cannot reconstruct from its own task line.
|
|
83
|
+
if (spec) awaiting = store.start(spec, modelOf(ctx), ctx.cwd, text) ?? awaiting;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// The branches' answers come back as a follow-up rather than as a tool result: the turn
|
|
87
|
+
// that asked for them already ended, so the model reads them at the top of the next one.
|
|
88
|
+
store.subscribe(() => {
|
|
89
|
+
const run = store.run();
|
|
90
|
+
if (!run || run.dir !== awaiting || run.live) return;
|
|
91
|
+
awaiting = undefined;
|
|
92
|
+
pi.sendUserMessage(`Branch results for ${run.name}:\n\n${reportOf(run)}${askingNote(run)}`, { deliverAs: "followUp" });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
pi.registerCommand("umb-fan", {
|
|
96
|
+
description: "Run parallel pi branches and watch them in the agent panel",
|
|
97
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
98
|
+
const text = args.trim() || (await ctx.ui.editor("fan", TEMPLATE));
|
|
99
|
+
if (!text) return;
|
|
100
|
+
const spec = parseSpec(text);
|
|
101
|
+
if (!spec) return ctx.ui.notify("fan: no branches in that spec", "warning");
|
|
102
|
+
// Started by the user, so the results are theirs to read on the panel; the model is
|
|
103
|
+
// only told about a run it asked for itself. One run at a time, and swallowing the
|
|
104
|
+
// refusal made /umb-fan look like it had worked.
|
|
105
|
+
if (!store.start(spec, modelOf(ctx), ctx.cwd, text)) {
|
|
106
|
+
ctx.ui.notify("fan: a run is already in flight — stop it from the panel first", "warning");
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { RunSpec } from "./store.ts";
|
|
2
|
+
|
|
3
|
+
// The one text format a run is described in. `/umb-fan` opens it in an editor, and the fan
|
|
4
|
+
// skill tells the model to emit the same thing in a ```fan block, so there is one parser
|
|
5
|
+
// and no schema for the model to get wrong.
|
|
6
|
+
//
|
|
7
|
+
// name: pi-toolcall-render
|
|
8
|
+
// desc: Map how pi renders tool calls
|
|
9
|
+
// # Map
|
|
10
|
+
// core-render: Read the render path and report where a tool call becomes lines.
|
|
11
|
+
// omp-intercept@openai/gpt-5: Check whether an extension can override it.
|
|
12
|
+
// # Design
|
|
13
|
+
// proposal: Given the Map phase, write the design.
|
|
14
|
+
//
|
|
15
|
+
// Phases run in order; every branch inside one phase runs at the same time.
|
|
16
|
+
|
|
17
|
+
export const TEMPLATE = ["name: ", "desc: ", "# Phase one", "label: task", ""].join("\n");
|
|
18
|
+
|
|
19
|
+
export const parseSpec = (text: string): RunSpec | undefined => {
|
|
20
|
+
const spec: RunSpec = { name: "", description: "", phases: [] };
|
|
21
|
+
for (const raw of text.split("\n")) {
|
|
22
|
+
const line = raw.trim();
|
|
23
|
+
if (!line || line.startsWith("//")) continue;
|
|
24
|
+
if (line.startsWith("#")) {
|
|
25
|
+
spec.phases.push({ title: line.replace(/^#+\s*/, "") || `Phase ${spec.phases.length + 1}`, branches: [] });
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
// Split on the first colon only: a task line is full of colons (file:line, URLs).
|
|
29
|
+
const colon = line.indexOf(":");
|
|
30
|
+
if (colon === -1) continue;
|
|
31
|
+
const key = line.slice(0, colon).trim();
|
|
32
|
+
const value = line.slice(colon + 1).trim();
|
|
33
|
+
if (!value) continue;
|
|
34
|
+
if (key === "name") spec.name = value;
|
|
35
|
+
else if (key === "desc") spec.description = value;
|
|
36
|
+
else {
|
|
37
|
+
// A branch before any "#" gets an implicit first phase, so the common
|
|
38
|
+
// single-phase run needs no header at all.
|
|
39
|
+
const phase = spec.phases[spec.phases.length - 1] ?? pushPhase(spec, "Branches");
|
|
40
|
+
const [label, model] = key.split("@");
|
|
41
|
+
if (label) phase.branches.push({ label, model, task: value });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
spec.phases = spec.phases.filter((phase) => phase.branches.length > 0);
|
|
45
|
+
if (!spec.phases.length) return undefined;
|
|
46
|
+
if (!spec.name) spec.name = spec.phases[0]?.branches[0]?.label ?? "run";
|
|
47
|
+
return spec;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const pushPhase = (spec: RunSpec, title: string) => {
|
|
51
|
+
const phase = { title, branches: [] as { label: string; model?: string; task: string }[] };
|
|
52
|
+
spec.phases.push(phase);
|
|
53
|
+
return phase;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** The fenced block the fan skill tells the model to write. */
|
|
57
|
+
export const findBlock = (text: string) => /```fan\s*\n([\s\S]*?)```/.exec(text)?.[1];
|
|
58
|
+
|
|
59
|
+
// One check for the two things that are easy to get wrong: a colon inside a task, and a
|
|
60
|
+
// branch written before any phase header.
|
|
61
|
+
const demo = () => {
|
|
62
|
+
const spec = parseSpec(`
|
|
63
|
+
name: toolcall
|
|
64
|
+
desc: Map how pi renders tool calls
|
|
65
|
+
lone: Read tui.d.ts:68 and report.
|
|
66
|
+
# Design
|
|
67
|
+
plan@openai/gpt-5: Write it up.
|
|
68
|
+
`);
|
|
69
|
+
if (!spec) throw new Error("parseSpec returned undefined for a valid spec");
|
|
70
|
+
console.assert(spec.name === "toolcall", "name");
|
|
71
|
+
console.assert(spec.description === "Map how pi renders tool calls", "desc");
|
|
72
|
+
console.assert(spec.phases.length === 2, `expected 2 phases, got ${spec.phases.length}`);
|
|
73
|
+
console.assert(spec.phases[0]?.title === "Branches", "implicit first phase");
|
|
74
|
+
console.assert(spec.phases[0]?.branches[0]?.task === "Read tui.d.ts:68 and report.", "colon in task");
|
|
75
|
+
console.assert(spec.phases[1]?.branches[0]?.model === "openai/gpt-5", "per-branch model");
|
|
76
|
+
console.assert(parseSpec("name: empty") === undefined, "a spec with no branches is not a run");
|
|
77
|
+
console.assert(findBlock("blah\n```fan\nx: y\n```\nblah") === "x: y\n", "block extraction");
|
|
78
|
+
console.log("spec.ts ok");
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (process.argv[1]?.endsWith("spec.ts")) demo();
|