pi-umbra 0.2.0 → 0.3.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.
Files changed (60) hide show
  1. package/README.md +7 -5
  2. package/node_modules/pi-umbra-help/README.md +1 -0
  3. package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +7 -0
  4. package/node_modules/pi-umbra-help/extensions/umbra-help.ts +12 -1
  5. package/node_modules/pi-umbra-help/package.json +1 -1
  6. package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +2 -1
  7. package/node_modules/pi-umbra-inputbar/package.json +1 -1
  8. package/node_modules/pi-umbra-shimmer/package.json +1 -1
  9. package/node_modules/pi-umbra-shimmer/patch.mjs +21 -7
  10. package/node_modules/pi-umbra-skill-matcher/package.json +1 -1
  11. package/node_modules/pi-umbra-skill-matcher/patch.mjs +21 -7
  12. package/node_modules/pi-umbra-subagents/LICENSE +21 -0
  13. package/node_modules/pi-umbra-subagents/README.md +101 -0
  14. package/node_modules/pi-umbra-subagents/extensions/umbra-loop.ts +102 -0
  15. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar/bar-line.ts +262 -0
  16. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.check.ts +198 -0
  17. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.ts +229 -0
  18. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/index.ts +141 -0
  19. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/models.ts +137 -0
  20. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/spec.ts +88 -0
  21. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.check.ts +140 -0
  22. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.ts +490 -0
  23. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/panel.check.ts +237 -0
  24. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/panel.ts +378 -0
  25. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/SKILL.md +95 -0
  26. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/beacon.ts +210 -0
  27. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/delegate.env +12 -0
  28. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/report.md +15 -0
  29. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/run.check.sh +120 -0
  30. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/run.sh +170 -0
  31. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.check.ts +137 -0
  32. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.ts +295 -0
  33. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/fan/SKILL.md +52 -0
  34. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents.ts +4 -0
  35. package/node_modules/pi-umbra-subagents/package.json +43 -0
  36. package/node_modules/pi-umbra-subagents/patch.mjs +97 -0
  37. package/node_modules/pi-umbra-theme/README.md +39 -16
  38. package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +44 -1
  39. package/node_modules/pi-umbra-theme/checks/umbra-image-viewer.check.ts +85 -0
  40. package/node_modules/pi-umbra-theme/checks/umbra-toolbox.check.ts +20 -1
  41. package/node_modules/pi-umbra-theme/checks/umbra-working.check.ts +17 -10
  42. package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +52 -1
  43. package/node_modules/pi-umbra-theme/extensions/umbra-image-viewer.ts +230 -0
  44. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/compact-mode.ts +3 -3
  45. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/hover.ts +0 -4
  46. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/interaction.ts +20 -68
  47. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/layout.ts +0 -4
  48. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/scroll.ts +6 -190
  49. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/diff/diff-palette.ts +44 -3
  50. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/diff/shiki-highlight.ts +5 -3
  51. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/names.ts +1 -0
  52. package/node_modules/pi-umbra-theme/extensions/umbra-working.ts +16 -6
  53. package/node_modules/pi-umbra-theme/package.json +1 -1
  54. package/node_modules/pi-umbra-theme/themes/umbra-astral-veil.json +1 -1
  55. package/node_modules/pi-umbra-theme/themes/umbra-deep-current.json +4 -4
  56. package/node_modules/pi-umbra-theme/themes/umbra-ember-ash.json +10 -10
  57. package/node_modules/pi-umbra-theme/themes/umbra-onyx-slate.json +5 -5
  58. package/node_modules/pi-umbra-theme/themes/umbra-tidal-drift.json +20 -20
  59. package/node_modules/pi-umbra-theme/themes/umbra-venom-dusk.json +4 -4
  60. package/package.json +16 -9
@@ -0,0 +1,229 @@
1
+ import { CustomEditor, type ExtensionAPI, type ExtensionContext, type KeybindingsManager, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Editor, isKeyRelease, matchesKey, type Component, type EditorTheme, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
3
+ import { faded, layoutList, MARK, 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
+ // One shape, the way Claude Code draws it: `○ main` on top, one row per agent, `└` for a nested
10
+ // one, and `❯` on the row the user is on. Down at the end of the editor moves the `❯` into the
11
+ // list, ↑↓ walk it, enter opens the panel on that agent, and up past the first agent, esc or
12
+ // any other key hands the keyboard back to the editor.
13
+ //
14
+ // The widget is mounted once at session_start and never removed. It asks for belowEditor, and
15
+ // repatch.mjs moves that slot below pi's status line. The slot grows a Spacer(1) the moment it holds anything, so registering and
16
+ // unregistering it moves the input box by a row;
17
+ // and setWidget disposes and rebuilds its component on every call, so calling it per tick would
18
+ // be a per-tick rebuild. Register the factory once, then requestRender().
19
+ //
20
+ // The string[] form of setWidget is unusable here twice over: it is wrapped in a Text that
21
+ // word-wraps a long line into two rows and yields nothing at all for a blank one, and it is
22
+ // capped at 10 lines. Only the factory form is handed `width`, which is what makes every
23
+ // truncation in bar/bar-line.ts possible.
24
+ //
25
+ // Every column of the frame itself is in bar/bar-line.ts, down to and including the string
26
+ // concatenation, because bar.check.ts cannot import this file: `CustomEditor` is a value from
27
+ // pi's own package, and loading that package pulls in an experimental server module that is not
28
+ // installed. What is left here is the part a check could not have run anyway — a component, an
29
+ // editor subclass, and four registrations.
30
+
31
+ const BAR_KEY = "agent-bar";
32
+ // Rows the list leaves to the editor, the status line and pi's own footer on a short terminal.
33
+ const RESERVE = 6;
34
+
35
+ class BarComponent implements Component {
36
+ constructor(
37
+ private tui: TUI,
38
+ private theme: Theme,
39
+ private onOpen: (stem?: string) => void,
40
+ ) {}
41
+
42
+ // The stems the last frame drew, in order: the cursor only walks rows the user can see.
43
+ private stems: string[] = [];
44
+ private release: (() => void) | undefined;
45
+
46
+ render(width: number): string[] {
47
+ // terminal.rows only changes on a resize, which repaints everything anyway, so reading
48
+ // it per frame costs nothing and keeps the list inside a shrunk window.
49
+ const budget = Math.max(1, Math.min(MAX_ROWS, this.tui.terminal.rows - RESERVE));
50
+ const now = Date.now();
51
+ this.stems = layoutList(width - MARK, current, now, budget).flatMap((row) => (row.branch ? [row.branch.stem] : []));
52
+ // A row that left the list (the run faded, the panel took over) takes the cursor with it.
53
+ if (cursor && !this.stems.includes(cursor)) this.leave();
54
+ return renderLines(width, current, now, budget, (color, text) => this.theme.fg(color, text), panelOpen, cursor);
55
+ }
56
+
57
+ /** Down at the end of the editor: the `❯` moves onto the first agent. The keys go through an
58
+ * input listener rather than a focus change, because the editor keeps its text, its cursor
59
+ * and its focus while the user looks down the list. */
60
+ enter(): void {
61
+ if (this.release || panelOpen || !this.stems[0]) return;
62
+ cursor = this.stems[0];
63
+ this.release = this.tui.addInputListener((data) => this.key(data));
64
+ this.refresh();
65
+ }
66
+
67
+ private leave(): void {
68
+ cursor = undefined;
69
+ this.release?.();
70
+ this.release = undefined;
71
+ this.refresh();
72
+ }
73
+
74
+ private key(data: string): { consume: boolean } | undefined {
75
+ // Listeners see key releases before pi filters them, and a release of the ↓ that opened
76
+ // the list would move the cursor a second row.
77
+ if (isKeyRelease(data)) return undefined;
78
+ // A dialog that took the editor's slot (ask_user_question) owns the keys now, and the
79
+ // panel opening over it would leave its promise hanging.
80
+ if (panelOpen || !(this.tui.getFocusedComponent() instanceof Editor)) {
81
+ this.leave();
82
+ return undefined;
83
+ }
84
+ const index = this.stems.indexOf(cursor ?? "");
85
+ if (matchesKey(data, "down")) cursor = this.stems[Math.min(index + 1, this.stems.length - 1)];
86
+ else if (matchesKey(data, "up")) {
87
+ if (index <= 0) this.leave();
88
+ else cursor = this.stems[index - 1];
89
+ } else if (matchesKey(data, "enter") || matchesKey(data, "right")) {
90
+ const stem = cursor;
91
+ this.leave();
92
+ this.onOpen(stem);
93
+ } else if (matchesKey(data, "escape")) this.leave();
94
+ else {
95
+ // Typing goes on where it was: the key reaches the editor as if the list were not there.
96
+ this.leave();
97
+ return undefined;
98
+ }
99
+ this.refresh();
100
+ return { consume: true };
101
+ }
102
+
103
+ // Mouse reporting is only on in the alternate-screen TUI (tuiMode "fullscreen"). Clicks are
104
+ // routed by position, not focus, so a click while another dialog holds the editor slot
105
+ // (ask_user_question, a select) would swap that dialog out and leave its promise hanging:
106
+ // the bar only opens the panel while an editor has focus.
107
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
108
+ if (event.type !== "click" || panelOpen || !current) return undefined;
109
+ if (!(this.tui.getFocusedComponent() instanceof Editor)) return undefined;
110
+ this.onOpen();
111
+ return { handled: true };
112
+ }
113
+
114
+ invalidate(): void {
115
+ // Nothing is cached between frames: render() reads the snapshot fresh every time.
116
+ }
117
+
118
+ // setWidget disposes the old bar on every session_start; its listener must not outlive it.
119
+ dispose(): void {
120
+ this.leave();
121
+ }
122
+
123
+ refresh(): void {
124
+ // Never requestRender(true). That calls resetRenderState(), which forces a repaint of
125
+ // the whole screen instead of the changed band — which is the flicker.
126
+ this.tui.requestRender();
127
+ }
128
+ }
129
+
130
+ // Down is not ours to steal outright: it moves the cursor, walks prompt history and drives the
131
+ // autocomplete list. It belongs to the bar only when the editor would do nothing with it —
132
+ // cursor already parked at the end of the last visual line — so the editor is asked rather than
133
+ // second-guessed. Its wrap map is private and re-deriving it would drift the first time pi
134
+ // changes its wrapping; a no-op cannot drift. registerShortcut("down") is not an option: the
135
+ // dispatcher consumes a matched key session-wide, history and autocomplete included.
136
+ class BarEditor extends CustomEditor {
137
+ constructor(
138
+ tui: TUI,
139
+ theme: EditorTheme,
140
+ private keys: KeybindingsManager,
141
+ private onDownAtEnd: () => void,
142
+ ) {
143
+ super(tui, theme, keys);
144
+ }
145
+
146
+ handleInput(data: string): void {
147
+ // With the autocomplete list open, down moves the highlight and changes neither the text
148
+ // nor the cursor, so the probe below would misread it as a no-op.
149
+ if (!this.keys.matches(data, "tui.editor.cursorDown") || this.isShowingAutocomplete()) {
150
+ super.handleInput(data);
151
+ return;
152
+ }
153
+ const text = this.getText();
154
+ const before = this.getCursor();
155
+ super.handleInput(data);
156
+ const after = this.getCursor();
157
+ if (this.getText() === text && after.line === before.line && after.col === before.col) this.onDownAtEnd();
158
+ }
159
+ }
160
+
161
+ let current: RunState | undefined;
162
+ let panelOpen = false;
163
+ let bar: BarComponent | undefined;
164
+ let lastKey = "";
165
+ // The stem the `❯` is on while the list has the keyboard; undefined leaves it on `○ main`.
166
+ let cursor: string | undefined;
167
+
168
+ /** The bar holds no state and owns no timer: the reader that already polls `<cwd>/.pi-out` for
169
+ * the panel calls this every tick, so the two views can never disagree about the same run and
170
+ * only one thing is ever walking the disk. */
171
+ export const setRun = (run: RunState | undefined) => {
172
+ current = run;
173
+ const key = renderKey(run, Date.now());
174
+ if (key === lastKey) return;
175
+ lastKey = key;
176
+ bar?.refresh();
177
+ };
178
+
179
+ export const installBar = (pi: ExtensionAPI, openPanel: (ctx: ExtensionContext, stem?: string) => Promise<void> | void) => {
180
+ // `quiet` is the down arrow and the click: both fire whether or not a run exists, and a
181
+ // notification on every press of a key the user meant as "move the cursor" is noise. The
182
+ // command and the shortcut were typed on purpose, so those get told why nothing happened.
183
+ const open = (ctx: ExtensionContext, quiet = false, stem?: string) => {
184
+ if (panelOpen) return quiet ? undefined : ctx.ui.notify("agent panel is already open");
185
+ // Opening on nothing would draw an empty box the user cannot fill. The quiet doors also
186
+ // skip a run the bar has already let go of: a key meant as "move the cursor" should not
187
+ // bring back a run that finished minutes ago. The command and alt+a still open it.
188
+ if (!current || (quiet && faded(current, Date.now()))) {
189
+ return quiet ? undefined : ctx.ui.notify("no agent run in this directory");
190
+ }
191
+ panelOpen = true;
192
+ bar?.refresh();
193
+ void Promise.resolve(openPanel(ctx, stem)).finally(() => {
194
+ panelOpen = false;
195
+ bar?.refresh();
196
+ });
197
+ };
198
+
199
+ // umbra-inputbar's editor announces a down at the end on pi's event bus. pi keeps one editor
200
+ // per session and does not sort extensions before loading them (under bun the order is the
201
+ // filesystem's), so a second editor class of our own would win or lose at random. The event
202
+ // works whichever loads first; the subscription is dropped by pi on /reload.
203
+ pi.events.on("editor:down-at-end", () => bar?.enter());
204
+
205
+ pi.on("session_start", (_event, ctx) => {
206
+ if (ctx.mode !== "tui") return;
207
+ // Re-registered on every session_start, including the "reload" one that follows
208
+ // resetExtensionUI() clearing every widget and the custom editor. Once per session is
209
+ // not once per tick: each call disposes the old component and builds a new one.
210
+ ctx.ui.setWidget(
211
+ BAR_KEY,
212
+ (tui, theme) => {
213
+ bar = new BarComponent(tui, theme, (stem) => open(ctx, true, stem));
214
+ return bar;
215
+ },
216
+ { placement: "belowEditor" },
217
+ );
218
+ // Fallback for a setup without umbra-inputbar: then this editor does the same probe
219
+ // itself. With umbra-inputbar present its editor is kept, and the event above is the way
220
+ // in. Only one editor is ever live, so the panel cannot be asked to open twice.
221
+ if (ctx.ui.getEditorComponent()) return;
222
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => new BarEditor(tui, theme, keybindings, () => bar?.enter()));
223
+ });
224
+
225
+ // Not ctrl+<letter>: every one is already bound by pi, and extension shortcuts are matched
226
+ // before app keybindings, so one would silently shadow a built-in.
227
+ pi.registerShortcut("alt+a", { description: "Open the agent panel", handler: (ctx) => open(ctx) });
228
+ pi.registerCommand("umb-agents", { description: "Open the agent panel", handler: async (_args, ctx) => open(ctx) });
229
+ };
@@ -0,0 +1,141 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { installBar, setRun } from "../bar.ts";
3
+ import { openPanel, type PanelSource } from "../panel.ts";
4
+ import { canReach, describeOffer, fanSettings, forget, offerModels, reachableModels, RUN_SH, type Offer } from "./models.ts";
5
+ import { findBlock, parseSpec, TEMPLATE, type RunSpec } from "./spec.ts";
6
+ import { piCommand, reportOf, store } from "./store.ts";
7
+ import type { RunState } from "../skills/delegate/state.ts";
8
+
9
+ // Wiring only. store.ts owns the branches, bar.ts and panel.ts render them.
10
+ //
11
+ // Three ways in, all free: `/umb-fan` for the user, a fenced ```fan block in the model's own
12
+ // answer, and a bash call that turns out to be the delegate skill — the same run directory
13
+ // either way, so the panel does not care which one started it. No pi.registerTool anywhere,
14
+ // so the prompt is byte-for-byte unchanged.
15
+
16
+ // A branch that stopped on a question goes to the model first: most questions are answered by
17
+ // the conversation the branch never saw, and only the rest reach the user.
18
+ const askingNote = (run: RunState): string => {
19
+ const asking = run.branches.filter((branch) => branch.report === "ASKING");
20
+ if (!asking.length) return "";
21
+ return (
22
+ `\n\n${asking.map((branch) => branch.stem).join(", ")} stopped on a question. Answer it from this ` +
23
+ "conversation if you can; if only the user can decide, ask them with ask_user_question first. " +
24
+ "Then continue each one in a single bash call and read its new report:\n\n" +
25
+ `. '${RUN_SH}'; ${asking.map((branch) => `dresume '${run.dir}' ${branch.stem} '<answer>'`).join("; ")}; wait; ` +
26
+ asking.map((branch) => `cat '${run.dir}/${branch.stem}.md'`).join("; ")
27
+ );
28
+ };
29
+
30
+ // A branch starts with --no-extensions, so a model that only an extension provides is not there
31
+ // for it. Rather than guess another one, the model asks the user: which model to spend on is
32
+ // theirs to choose.
33
+ const modelNote = (missing: string[], offer: Offer): string =>
34
+ `The fan run did not start: branches run without extensions, so ${missing.join(", ")} is not ` +
35
+ "available to them. Ask the user with ask_user_question which model the branches should use, " +
36
+ `offering ${describeOffer(offer)}. Then write the fan block again with \`label@provider/model\` ` +
37
+ "on each branch.";
38
+
39
+ const modelOf = (ctx: ExtensionContext) =>
40
+ process.env.FAN_MODEL || (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "");
41
+
42
+ // The one adapter between the producer and the two renderers. store keys a branch by its
43
+ // stem because that is its filename; the panel hands back the whole BranchView it drew,
44
+ // because a row it did not draw must not be stoppable. Converting here keeps both sides
45
+ // honest instead of widening either signature to meet the other.
46
+ const source: PanelSource = {
47
+ run: () => store.run(),
48
+ stop: (branch) => void store.stop(branch.stem),
49
+ subscribe: (fn) => store.subscribe(fn),
50
+ };
51
+
52
+ export default function (pi: ExtensionAPI) {
53
+ // The widget above the editor, the alt+a shortcut, the /umb-agents command and the down arrow
54
+ // out of the last editor line. All four open the same panel over the same source.
55
+ installBar(pi, (ctx, stem) => openPanel(ctx, source, stem));
56
+ // The bar is pushed to rather than polling a second time: store already ticks once a
57
+ // second while branches are live, and a second reader could only disagree with it.
58
+ store.subscribe(() => setRun(store.run()));
59
+
60
+ // The run directory this session started, and has therefore promised to report back on.
61
+ // A run the delegate skill started in bash is left alone: that caller reads $run/*.md
62
+ // itself, and a follow-up would hand the model the same text twice.
63
+ let awaiting: string | undefined;
64
+
65
+ // Branches must not outlive the pi that started them: nothing would be watching, and a
66
+ // branch left running keeps spending. Their reports are already on disk.
67
+ pi.on("session_shutdown", () => store.stopAll());
68
+
69
+ // A bash call may be the delegate skill mid-run. Arming the tick when one starts is what
70
+ // makes a skill-started run appear on the bar from its first frame rather than at the end;
71
+ // the turn's end disarms it, because no bash call outlives its turn.
72
+ pi.on("tool_execution_start", (event, ctx) => {
73
+ if (event.toolName === "bash") store.arm(ctx.cwd);
74
+ });
75
+ pi.on("agent_end", () => store.disarm());
76
+ pi.on("session_start", (_event, ctx) => store.watch(ctx.cwd));
77
+
78
+ // Every branch's model is checked before anything is seeded. A model a branch cannot reach
79
+ // stops the run here, with the user told and, for a run the model asked for, the model asked
80
+ // to get a choice from the user. Returns the run directory, or undefined when nothing started.
81
+ const launch = async (spec: RunSpec, ctx: ExtensionContext, brief: string, reply: boolean) => {
82
+ const settings = await fanSettings();
83
+ const fallback = modelOf(ctx);
84
+ const reachable = await reachableModels(piCommand(), settings.load);
85
+ const wanted = [...new Set(spec.phases.flatMap((phase) => phase.branches.map((branch) => branch.model || fallback)))];
86
+ // An empty listing means pi could not be asked, not that nothing is reachable.
87
+ const missing = reachable.length ? wanted.filter((model) => !canReach(model, reachable)) : [];
88
+ if (missing.length) {
89
+ // The user may fix it with /login or models.json before the retry.
90
+ forget(settings.load);
91
+ const offer = offerModels(ctx.cwd, reachable);
92
+ ctx.ui.notify(`fan: ${missing.join(", ")} is not available to branches; pick ${describeOffer(offer)}`, "warning");
93
+ if (reply) pi.sendUserMessage(modelNote(missing, offer), { deliverAs: "followUp" });
94
+ return undefined;
95
+ }
96
+ const dir = store.start(spec, fallback, ctx.cwd, brief, settings);
97
+ if (!dir) ctx.ui.notify("fan: a run is already in flight — stop it from the panel first", "warning");
98
+ return dir;
99
+ };
100
+
101
+ pi.on("message_end", (event, ctx) => {
102
+ if (ctx.mode !== "tui" || event.message.role !== "assistant") return;
103
+ const text = event.message.content
104
+ .filter((part) => part.type === "text")
105
+ .map((part) => part.text)
106
+ .join("\n");
107
+ const block = findBlock(text);
108
+ if (!block) return;
109
+ const spec = parseSpec(block);
110
+ // The whole assistant message, not just the fenced block: the reasoning around the
111
+ // block is the half a branch cannot reconstruct from its own task line.
112
+ // Caught here: a throw after the awaits (a read-only cwd, a full disk) is no longer inside
113
+ // pi's handler, and an unhandled rejection can take pi down with it.
114
+ if (spec)
115
+ launch(spec, ctx, text, true)
116
+ .then((dir) => (awaiting = dir ?? awaiting))
117
+ .catch((error: unknown) => ctx.ui.notify(`fan: ${error instanceof Error ? error.message : String(error)}`, "error"));
118
+ });
119
+
120
+ // The branches' answers come back as a follow-up rather than as a tool result: the turn
121
+ // that asked for them already ended, so the model reads them at the top of the next one.
122
+ store.subscribe(() => {
123
+ const run = store.run();
124
+ if (!run || run.dir !== awaiting || run.live) return;
125
+ awaiting = undefined;
126
+ pi.sendUserMessage(`Branch results for ${run.name}:\n\n${reportOf(run)}${askingNote(run)}`, { deliverAs: "followUp" });
127
+ });
128
+
129
+ pi.registerCommand("umb-fan", {
130
+ description: "Run parallel pi branches and watch them in the agent panel",
131
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
132
+ const text = args.trim() || (await ctx.ui.editor("fan", TEMPLATE));
133
+ if (!text) return;
134
+ const spec = parseSpec(text);
135
+ if (!spec) return ctx.ui.notify("fan: no branches in that spec", "warning");
136
+ // Started by the user, so the results are theirs to read on the panel; the model is
137
+ // only told about a run it asked for itself. launch says why when nothing started.
138
+ await launch(spec, ctx, text, false);
139
+ },
140
+ });
141
+ }
@@ -0,0 +1,137 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // Which models a branch can run on, checked before a run starts. A branch is `pi -p
8
+ // --no-extensions`, so a model that only an extension provides is not there for it unless $LOAD
9
+ // puts that extension back. Checking first costs one `--list-models` per session; finding out
10
+ // afterwards cost a started run of failed branches, read back from pi's error text.
11
+
12
+ export type FanSettings = { load: string[]; timeoutMs: number };
13
+ export type Offer = { from: "scoped" | "free" | "reachable"; models: string[] };
14
+
15
+ export const RUN_SH = join(dirname(fileURLToPath(import.meta.url)), "..", "skills", "delegate", "run.sh");
16
+
17
+ const run = (command: string, args: string[], timeout: number) =>
18
+ new Promise<string>((resolve) =>
19
+ execFile(command, args, { timeout, maxBuffer: 4 << 20, windowsHide: true }, (_error, stdout) => resolve(stdout ?? "")),
20
+ );
21
+
22
+ /** The environment alone: what a caller with no bash, and the check, get. */
23
+ export const envSettings = (load = "", timeout = ""): FanSettings => ({
24
+ load: (process.env.FAN_LOAD ?? load).split(/\s+/).filter(Boolean),
25
+ timeoutMs: Number(process.env.FAN_TIMEOUT_MS) || Number(timeout) * 1000 || 300_000,
26
+ });
27
+
28
+ /** $LOAD and $DELEGATE_TIMEOUT through run.sh's own dconfig, so fan and the delegate skill
29
+ * read one set of files the same way. FAN_LOAD and FAN_TIMEOUT_MS win. */
30
+ export const fanSettings = async (): Promise<FanSettings> => {
31
+ const script = '. "$1"; dconfig; printf "%s\\n%s" "$LOAD" "$DELEGATE_TIMEOUT"';
32
+ const [load = "", timeout = ""] = (await run("bash", ["-c", script, "fan", RUN_SH], 5_000)).split("\n");
33
+ return envSettings(load, timeout);
34
+ };
35
+
36
+ // One listing per extension set. A failed listing is not kept, and forget() drops one after a
37
+ // refusal, so a /login or a models.json edit is seen on the next try.
38
+ const listings = new Map<string, Promise<string[]>>();
39
+
40
+ export const forget = (load: string[]) => listings.delete(load.join(" "));
41
+
42
+ /** `provider/id` for every model a branch started with `load` can reach. Empty when the listing
43
+ * failed, which the caller reads as "cannot tell", never as "nothing is reachable". */
44
+ export const reachableModels = (pi: string[], load: string[]): Promise<string[]> => {
45
+ const key = load.join(" ");
46
+ if (!listings.has(key)) {
47
+ const [command, ...prefix] = pi;
48
+ const listing = run(command as string, [...prefix, "--no-extensions", "--no-skills", ...load, "--list-models"], 60_000).then((out) => {
49
+ const models = parseListing(out);
50
+ if (!models.length) listings.delete(key);
51
+ return models;
52
+ });
53
+ listings.set(key, listing);
54
+ }
55
+ return listings.get(key) as Promise<string[]>;
56
+ };
57
+
58
+ /** The table after its `provider model ...` header. Anything a LOAD extension printed while
59
+ * loading comes before the header, so it is skipped rather than read as a provider. */
60
+ export const parseListing = (out: string): string[] => {
61
+ const lines = out.split("\n").map((line) => line.trim().split(/\s+/));
62
+ const header = lines.findIndex(([first, second]) => first === "provider" && second === "model");
63
+ return header === -1
64
+ ? []
65
+ : lines
66
+ .slice(header + 1)
67
+ .filter((cells) => cells.length > 1)
68
+ .map(([provider, model]) => `${provider}/${model}`);
69
+ };
70
+
71
+ const THINKING = /:(off|minimal|low|medium|high|xhigh|max)$/i;
72
+
73
+ /** pi's own rule, loosely: a known model, an id some provider lists as is (`anthropic/x` under
74
+ * openrouter), any id under a known provider (pi then runs it as a custom id and only warns),
75
+ * or a bare name that some known id contains. */
76
+ export const canReach = (model: string, reachable: string[]): boolean => {
77
+ const id = model.replace(THINKING, "").toLowerCase();
78
+ const known = reachable.map((entry) => entry.toLowerCase());
79
+ if (known.includes(id) || known.some((entry) => entry.slice(entry.indexOf("/") + 1) === id)) return true;
80
+ const slash = id.indexOf("/");
81
+ if (slash > 0) return known.some((entry) => entry.startsWith(`${id.slice(0, slash)}/`));
82
+ return known.some((entry) => entry.includes(id));
83
+ };
84
+
85
+ /** An `enabledModels` entry against the list, the way pi reads them: exact, glob or fuzzy,
86
+ * case-insensitive, with an optional thinking suffix. */
87
+ const matches = (pattern: string, reachable: string[]): string[] => {
88
+ const bare = pattern.replace(THINKING, "").toLowerCase();
89
+ if (bare.includes("*")) {
90
+ const glob = new RegExp(`^${bare.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i");
91
+ return reachable.filter((entry) => glob.test(entry) || glob.test(entry.slice(entry.indexOf("/") + 1)));
92
+ }
93
+ const exact = reachable.filter((entry) => entry.toLowerCase() === bare || entry.toLowerCase().endsWith(`/${bare}`));
94
+ return exact.length ? exact : reachable.filter((entry) => entry.toLowerCase().includes(bare));
95
+ };
96
+
97
+ /** What to offer instead: the user's scoped models (global, then project settings) that a
98
+ * branch can reach; else the free ones it can reach; else the first few it can reach. */
99
+ export const offerModels = (cwd: string, reachable: string[]): Offer => {
100
+ const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
101
+ const scoped = [join(agentDir, "settings.json"), join(cwd, ".pi", "settings.json")]
102
+ .flatMap((file) => {
103
+ try {
104
+ return (JSON.parse(readFileSync(file, "utf8")).enabledModels as string[] | undefined) ?? [];
105
+ } catch {
106
+ return [];
107
+ }
108
+ })
109
+ .flatMap((pattern) => matches(pattern, reachable));
110
+ if (scoped.length) return { from: "scoped", models: [...new Set(scoped)].slice(0, 6) };
111
+ const free = reachable.filter((model) => model.endsWith(":free"));
112
+ return free.length ? { from: "free", models: free.slice(0, 6) } : { from: "reachable", models: reachable.slice(0, 6) };
113
+ };
114
+
115
+ export const describeOffer = (offer: Offer): string =>
116
+ offer.models.length
117
+ ? `${{ scoped: "from your scoped models", free: "free models", reachable: "models a branch can reach" }[offer.from]}: ${offer.models.join(", ")}`
118
+ : "no model a branch can reach was found (`pi --no-extensions --list-models` shows them)";
119
+
120
+ // The matching rules above against a fixed listing, so a drift from pi's resolver shows here.
121
+ const demo = () => {
122
+ const reachable = parseListing(
123
+ "loading provider...\nprovider model context\nopenrouter anthropic/claude-x 200K\nopenrouter nvidia/nemo:free 128K\nanthropic claude-opus-5 1M\n",
124
+ );
125
+ console.assert(reachable.length === 3 && reachable[0] === "openrouter/anthropic/claude-x", `listing: ${reachable}`);
126
+ console.assert(canReach("anthropic/claude-x", reachable), "an id a provider lists under a slash");
127
+ console.assert(canReach("anthropic/claude-opus-5:high", reachable), "thinking suffix");
128
+ console.assert(canReach("anthropic/custom-id", reachable), "custom id under a known provider");
129
+ console.assert(canReach("opus-5", reachable), "bare name");
130
+ console.assert(!canReach("openai/gpt-5", reachable), "unknown provider");
131
+ console.assert(matches("openrouter/*", reachable).length === 2, "glob");
132
+ console.assert(matches("claude-opus-5:high", reachable)[0] === "anthropic/claude-opus-5", "exact with a suffix");
133
+ console.assert(parseListing("no table here").length === 0, "no header is no listing");
134
+ console.log("models.ts ok");
135
+ };
136
+
137
+ if (process.argv[1]?.endsWith("models.ts")) demo();
@@ -0,0 +1,88 @@
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
+ // A task is full of colons (file:line, URLs), so the key ends at the first one. A model id
29
+ // can carry colons too (`openrouter/x:free`, `openai/gpt-5:high`), so a key with `@` ends
30
+ // at the first colon followed by a space instead, falling back to the first colon.
31
+ const spaced = line.search(/:(\s|$)/);
32
+ const colon = /^[^:\s]+@/.test(line) && spaced !== -1 ? spaced : line.indexOf(":");
33
+ if (colon === -1) continue;
34
+ const key = line.slice(0, colon).trim();
35
+ const value = line.slice(colon + 1).trim();
36
+ if (!value) continue;
37
+ if (key === "name") spec.name = value;
38
+ else if (key === "desc") spec.description = value;
39
+ else {
40
+ // A branch before any "#" gets an implicit first phase, so the common
41
+ // single-phase run needs no header at all.
42
+ const phase = spec.phases[spec.phases.length - 1] ?? pushPhase(spec, "Branches");
43
+ const [label, model] = key.split("@");
44
+ if (label) phase.branches.push({ label, model, task: value });
45
+ }
46
+ }
47
+ spec.phases = spec.phases.filter((phase) => phase.branches.length > 0);
48
+ if (!spec.phases.length) return undefined;
49
+ if (!spec.name) spec.name = spec.phases[0]?.branches[0]?.label ?? "run";
50
+ return spec;
51
+ };
52
+
53
+ const pushPhase = (spec: RunSpec, title: string) => {
54
+ const phase = { title, branches: [] as { label: string; model?: string; task: string }[] };
55
+ spec.phases.push(phase);
56
+ return phase;
57
+ };
58
+
59
+ /** The fenced block the fan skill tells the model to write. */
60
+ export const findBlock = (text: string) => /```fan\s*\n([\s\S]*?)```/.exec(text)?.[1];
61
+
62
+ // One check for the two things that are easy to get wrong: a colon inside a task, and a
63
+ // branch written before any phase header.
64
+ const demo = () => {
65
+ const spec = parseSpec(`
66
+ name: toolcall
67
+ desc: Map how pi renders tool calls
68
+ lone: Read tui.d.ts:68 and report.
69
+ # Design
70
+ plan@openai/gpt-5: Write it up.
71
+ free@openrouter/nvidia/nemotron-3-super-120b-a12b:free: Check src/a.ts:12.
72
+ `);
73
+ if (!spec) throw new Error("parseSpec returned undefined for a valid spec");
74
+ console.assert(spec.name === "toolcall", "name");
75
+ console.assert(spec.description === "Map how pi renders tool calls", "desc");
76
+ console.assert(spec.phases.length === 2, `expected 2 phases, got ${spec.phases.length}`);
77
+ console.assert(spec.phases[0]?.title === "Branches", "implicit first phase");
78
+ console.assert(spec.phases[0]?.branches[0]?.task === "Read tui.d.ts:68 and report.", "colon in task");
79
+ console.assert(spec.phases[1]?.branches[0]?.model === "openai/gpt-5", "per-branch model");
80
+ const free = spec.phases[1]?.branches[1];
81
+ console.assert(free?.model === "openrouter/nvidia/nemotron-3-super-120b-a12b:free", "colon in model");
82
+ console.assert(free?.task === "Check src/a.ts:12.", "task after a model with a colon");
83
+ console.assert(parseSpec("name: empty") === undefined, "a spec with no branches is not a run");
84
+ console.assert(findBlock("blah\n```fan\nx: y\n```\nblah") === "x: y\n", "block extraction");
85
+ console.log("spec.ts ok");
86
+ };
87
+
88
+ if (process.argv[1]?.endsWith("spec.ts")) demo();