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.
- package/README.md +7 -5
- package/node_modules/pi-umbra-help/README.md +1 -0
- package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +7 -0
- package/node_modules/pi-umbra-help/extensions/umbra-help.ts +12 -1
- package/node_modules/pi-umbra-help/package.json +1 -1
- package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +2 -1
- package/node_modules/pi-umbra-inputbar/package.json +1 -1
- package/node_modules/pi-umbra-shimmer/package.json +1 -1
- package/node_modules/pi-umbra-shimmer/patch.mjs +21 -7
- package/node_modules/pi-umbra-skill-matcher/package.json +1 -1
- package/node_modules/pi-umbra-skill-matcher/patch.mjs +21 -7
- package/node_modules/pi-umbra-subagents/LICENSE +21 -0
- package/node_modules/pi-umbra-subagents/README.md +101 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-loop.ts +102 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar/bar-line.ts +262 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.check.ts +198 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.ts +229 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/index.ts +141 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/models.ts +137 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/spec.ts +88 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.check.ts +140 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.ts +490 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/panel.check.ts +237 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/panel.ts +378 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/SKILL.md +95 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/beacon.ts +210 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/delegate.env +12 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/report.md +15 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/run.check.sh +120 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/run.sh +170 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.check.ts +137 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.ts +295 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/fan/SKILL.md +52 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents.ts +4 -0
- package/node_modules/pi-umbra-subagents/package.json +43 -0
- package/node_modules/pi-umbra-subagents/patch.mjs +97 -0
- package/node_modules/pi-umbra-theme/README.md +39 -16
- package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +44 -1
- package/node_modules/pi-umbra-theme/checks/umbra-image-viewer.check.ts +85 -0
- package/node_modules/pi-umbra-theme/checks/umbra-toolbox.check.ts +20 -1
- package/node_modules/pi-umbra-theme/checks/umbra-working.check.ts +17 -10
- package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +52 -1
- package/node_modules/pi-umbra-theme/extensions/umbra-image-viewer.ts +230 -0
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/compact-mode.ts +3 -3
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/hover.ts +0 -4
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/interaction.ts +20 -68
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/layout.ts +0 -4
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/scroll.ts +6 -190
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/diff/diff-palette.ts +44 -3
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/diff/shiki-highlight.ts +5 -3
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/names.ts +1 -0
- package/node_modules/pi-umbra-theme/extensions/umbra-working.ts +16 -6
- package/node_modules/pi-umbra-theme/package.json +1 -1
- package/node_modules/pi-umbra-theme/themes/umbra-astral-veil.json +1 -1
- package/node_modules/pi-umbra-theme/themes/umbra-deep-current.json +4 -4
- package/node_modules/pi-umbra-theme/themes/umbra-ember-ash.json +10 -10
- package/node_modules/pi-umbra-theme/themes/umbra-onyx-slate.json +5 -5
- package/node_modules/pi-umbra-theme/themes/umbra-tidal-drift.json +20 -20
- package/node_modules/pi-umbra-theme/themes/umbra-venom-dusk.json +4 -4
- 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();
|