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
package/README.md CHANGED
@@ -2,25 +2,26 @@
2
2
 
3
3
  Every pi-umbra package in one install: seven dark themes, tool calls drawn as cards, a
4
4
  status footer, an input bar with the session name, a working line that names the running
5
- tool, and the `/umb-*` commands.
5
+ tool, parallel read-only branches with a live panel, and the `/umb-*` commands.
6
6
 
7
- ![Tool cards, the working line, a question from the model and the card settings](https://raw.githubusercontent.com/grknbyk/pi-umbra/main/assets/carousel.webp)
7
+ ![A one-minute tour: tool cards and diffs, the working line, a question from the model, parallel branches, the card settings and the seven themes](https://raw.githubusercontent.com/grknbyk/pi-umbra/main/assets/demo.webp)
8
8
 
9
9
  ```sh
10
10
  pi install npm:pi-umbra
11
11
  ```
12
12
 
13
- The nine packages ship bundled inside this one tarball. Two of them patch pi's bundle, so
14
- run both scripts once now and again after every pi update, then restart pi:
13
+ The ten packages ship bundled inside this one tarball. Three of them patch pi's bundle, so
14
+ run the three scripts once now and again after every pi update, then restart pi:
15
15
 
16
16
  ```sh
17
17
  node ~/.pi/agent/npm/node_modules/pi-umbra/node_modules/pi-umbra-shimmer/patch.mjs
18
18
  node ~/.pi/agent/npm/node_modules/pi-umbra/node_modules/pi-umbra-skill-matcher/patch.mjs
19
+ node ~/.pi/agent/npm/node_modules/pi-umbra/node_modules/pi-umbra-subagents/patch.mjs
19
20
  ```
20
21
 
21
22
  | Package | What it adds |
22
23
  |---|---|
23
- | [pi-umbra-theme](https://www.npmjs.com/package/pi-umbra-theme) | seven themes, terminal background, footer, gutter, tool cards, working line |
24
+ | [pi-umbra-theme](https://www.npmjs.com/package/pi-umbra-theme) | seven themes, terminal background, footer, gutter, tool cards, image viewer, working line |
24
25
  | [pi-umbra-inputbar](https://www.npmjs.com/package/pi-umbra-inputbar) | `❯` prompt, session name on the input border, `/umb-color` |
25
26
  | [pi-umbra-ask](https://www.npmjs.com/package/pi-umbra-ask) | a tool the model calls to ask you a question and wait |
26
27
  | [pi-umbra-shimmer](https://www.npmjs.com/package/pi-umbra-shimmer) | a wave of colour through the working line, `/umb-shimmer` |
@@ -29,6 +30,7 @@ node ~/.pi/agent/npm/node_modules/pi-umbra/node_modules/pi-umbra-skill-matcher/p
29
30
  | [pi-umbra-copy-chat](https://www.npmjs.com/package/pi-umbra-copy-chat) | `/umb-copy-chat`, the session on the clipboard |
30
31
  | [pi-umbra-preview](https://www.npmjs.com/package/pi-umbra-preview) | `/umb-preview`, render a document in the browser |
31
32
  | [pi-umbra-help](https://www.npmjs.com/package/pi-umbra-help) | `/umb-help` and `/umb-doctor` |
33
+ | [pi-umbra-subagents](https://www.npmjs.com/package/pi-umbra-subagents) | parallel read-only pi branches with a live panel, `/umb-fan`, `/umb-loop` |
32
34
 
33
35
  Full documentation: [github.com/grknbyk/pi-umbra](https://github.com/grknbyk/pi-umbra).
34
36
 
@@ -22,6 +22,7 @@ Reports the faults that give no error of their own:
22
22
  |---|---|
23
23
  | a bundle patch is missing | after a pi update, the shimmer and the mid-sentence skill menu stop without a word |
24
24
  | two skills share a name | `~/.pi/agent/skills`, `<project>/.pi/skills` and `~/.agents/skills` are read in that order and the first copy wins, even when a later one is newer |
25
+ | the terminal kept its own background | the theme is not shown in full; it says where to set the colour in your terminal |
25
26
  | no truecolor announced | `COLORTERM` is not set and the terminal is not a known truecolor one, so colours may band |
26
27
  | the theme is not an umbra one | only a note; everything still works |
27
28
 
@@ -87,6 +87,13 @@ const offTheme = diagnose(at({ theme: "dark" }));
87
87
  assert.equal(offTheme[0]!.level, "note", "another theme is a remark, not a fault");
88
88
  assert.equal(diagnose(at({ theme: "umbra-ember-ash" })).length, 0);
89
89
 
90
+ // A background the terminal kept is reported with the terminal's own fix; one it took, or one it
91
+ // never answered about, says nothing.
92
+ const kept = diagnose(at({ background: { wanted: "#0b0e14", ignored: true, howTo: "VS Code: add it." } }));
93
+ assert.equal(kept.length, 1);
94
+ assert.match(kept[0]!.detail, /#0b0e14.*VS Code: add it\./);
95
+ assert.equal(diagnose(at({ background: { wanted: "#0b0e14", ignored: false, howTo: "" } })).length, 0);
96
+
90
97
  // --- help ----------------------------------------------------------------------------------
91
98
  // Only umb- rows, sorted, with pi's own descriptions - so the list cannot drift from the commands.
92
99
  const help = renderHelp(
@@ -38,7 +38,7 @@ export const PROBES: Probe[] = [
38
38
  id: "loop",
39
39
  marker: "globalThis.__piSubmit",
40
40
  feature: "the /umb-loop resubmit",
41
- without: "the loop arms itself and then never submits anything",
41
+ without: "/umb-loop refuses to start",
42
42
  command: "umb-loop",
43
43
  },
44
44
  {
@@ -58,6 +58,8 @@ export type Facts = {
58
58
  skillRoots: { root: string; names: string[] }[];
59
59
  truecolor: boolean;
60
60
  theme: string;
61
+ /** Set by umbra-background once the terminal has answered; absent when it never did. */
62
+ background?: { wanted: string; ignored: boolean; howTo: string };
61
63
  };
62
64
 
63
65
  export type Finding = { level: "warn" | "note"; title: string; detail: string };
@@ -106,6 +108,14 @@ export const diagnose = (facts: Facts): Finding[] => {
106
108
  });
107
109
  }
108
110
 
111
+ if (facts.background?.ignored) {
112
+ findings.push({
113
+ level: "warn",
114
+ title: "the terminal kept its own background",
115
+ detail: `It did not take ${facts.background.wanted}, so the theme is not shown in full. ${facts.background.howTo}`,
116
+ });
117
+ }
118
+
109
119
  // A note rather than a warning: plenty of terminals handle 24-bit colour without announcing
110
120
  // it, so this is a guess that has to earn its line rather than cry wolf on every run.
111
121
  if (!facts.truecolor) {
@@ -208,6 +218,7 @@ export const gather = (cwd: string, theme: string, commands: Command[]): Facts =
208
218
  skillRoots: roots.map((root) => ({ root, names: namesIn(root) })).filter((entry) => entry.names.length > 0),
209
219
  truecolor: announcesTruecolor(process.env),
210
220
  theme,
221
+ background: (globalThis as { __umbraBackground?: Facts["background"] }).__umbraBackground,
211
222
  };
212
223
  };
213
224
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-umbra-help",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Two commands over the pi-umbra family: /umb-help lists what is installed and how to configure it, /umb-doctor reports the faults that would otherwise stay silent.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -130,7 +130,8 @@ export class InputBar extends CustomEditor {
130
130
  const blank = " ".repeat(gutter);
131
131
  return lines.map((line, i) => {
132
132
  if (i === 0 || i === shown + 1) return edge + line;
133
- if (i === 1) return this.borderColor(CHEVRON) + blank.slice(1) + line;
133
+ // The chevron is part of what you type into, so it takes the text colour, not the border's.
134
+ if (i === 1) return CHEVRON + blank.slice(1) + line;
134
135
  return blank + line;
135
136
  });
136
137
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-umbra-inputbar",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "pi's input bar, rebuilt: a chevron prompt, the session name on the border, a colour you pick, and a command to rename the session.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-umbra-shimmer",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "A wave of colour running through pi's working indicator, over both the spinner and the text. Needs the umbra bundle patches; without them it does nothing.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -10,20 +10,34 @@
10
10
  // Nothing here is destructive. Each patch is additive, a patch whose text no longer matches is
11
11
  // reported instead of forced, and reinstalling pi returns the bundle to stock. A pi upgrade
12
12
  // replaces the bundle and drops every patch silently, which is what --check is for.
13
+ import { execSync } from "node:child_process";
13
14
  import { createRequire } from "node:module";
14
- import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { existsSync, realpathSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
16
  import { homedir } from "node:os";
16
17
  import { dirname, join } from "node:path";
17
18
 
18
- // PI_UMBRA_PI points this at another pi tree. Otherwise pi is resolved from wherever this
19
- // package was installed, since it is a peer dependency, and the bun global path is the last resort.
19
+ // PI_UMBRA_PI points this at another pi tree. Otherwise the pi that runs is the one to patch:
20
+ // the `pi` on PATH, followed through its symlink. Windows puts a .cmd shim there instead, so
21
+ // npm's global root (%APPDATA%\npm\node_modules) comes next, then bun's. The copy npm installs
22
+ // beside this package as a peer dependency is last: pi never runs it. The first that has a built
23
+ // bundle wins.
20
24
  const resolvePi = () => {
21
25
  if (process.env.PI_UMBRA_PI) return process.env.PI_UMBRA_PI;
26
+ const quiet = { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 20_000 };
27
+ const candidates = [];
22
28
  try {
23
- return dirname(createRequire(import.meta.url).resolve("@earendil-works/pi-coding-agent/package.json"));
24
- } catch {
25
- return join(homedir(), ".bun/install/global/node_modules/@earendil-works/pi-coding-agent");
26
- }
29
+ const bin = execSync(process.platform === "win32" ? "where pi" : "command -v pi", quiet).split(/\r?\n/)[0].trim();
30
+ for (let dir = dirname(realpathSync(bin)); dir !== dirname(dir); dir = dirname(dir)) candidates.push(dir);
31
+ } catch {}
32
+ try {
33
+ const root = execSync("npm root -g", quiet).trim();
34
+ if (root) candidates.push(join(root, "@earendil-works", "pi-coding-agent"));
35
+ } catch {}
36
+ candidates.push(join(homedir(), ".bun", "install", "global", "node_modules", "@earendil-works", "pi-coding-agent"));
37
+ try {
38
+ candidates.push(dirname(createRequire(import.meta.url).resolve("@earendil-works/pi-coding-agent/package.json")));
39
+ } catch {}
40
+ return candidates.find((dir) => existsSync(join(dir, "dist", "bundle"))) ?? candidates[0];
27
41
  };
28
42
 
29
43
  const BUNDLE = join(resolvePi(), "dist/bundle");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-umbra-skill-matcher",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Type /huh and reach skill:huh. pi lists every skill under its bare name, which is not the name that dispatches.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -10,20 +10,34 @@
10
10
  // Nothing here is destructive. Each patch is additive, a patch whose text no longer matches is
11
11
  // reported instead of forced, and reinstalling pi returns the bundle to stock. A pi upgrade
12
12
  // replaces the bundle and drops every patch silently, which is what --check is for.
13
+ import { execSync } from "node:child_process";
13
14
  import { createRequire } from "node:module";
14
- import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { existsSync, realpathSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
16
  import { homedir } from "node:os";
16
17
  import { dirname, join } from "node:path";
17
18
 
18
- // PI_UMBRA_PI points this at another pi tree. Otherwise pi is resolved from wherever this
19
- // package was installed, since it is a peer dependency, and the bun global path is the last resort.
19
+ // PI_UMBRA_PI points this at another pi tree. Otherwise the pi that runs is the one to patch:
20
+ // the `pi` on PATH, followed through its symlink. Windows puts a .cmd shim there instead, so
21
+ // npm's global root (%APPDATA%\npm\node_modules) comes next, then bun's. The copy npm installs
22
+ // beside this package as a peer dependency is last: pi never runs it. The first that has a built
23
+ // bundle wins.
20
24
  const resolvePi = () => {
21
25
  if (process.env.PI_UMBRA_PI) return process.env.PI_UMBRA_PI;
26
+ const quiet = { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 20_000 };
27
+ const candidates = [];
22
28
  try {
23
- return dirname(createRequire(import.meta.url).resolve("@earendil-works/pi-coding-agent/package.json"));
24
- } catch {
25
- return join(homedir(), ".bun/install/global/node_modules/@earendil-works/pi-coding-agent");
26
- }
29
+ const bin = execSync(process.platform === "win32" ? "where pi" : "command -v pi", quiet).split(/\r?\n/)[0].trim();
30
+ for (let dir = dirname(realpathSync(bin)); dir !== dirname(dir); dir = dirname(dir)) candidates.push(dir);
31
+ } catch {}
32
+ try {
33
+ const root = execSync("npm root -g", quiet).trim();
34
+ if (root) candidates.push(join(root, "@earendil-works", "pi-coding-agent"));
35
+ } catch {}
36
+ candidates.push(join(homedir(), ".bun", "install", "global", "node_modules", "@earendil-works", "pi-coding-agent"));
37
+ try {
38
+ candidates.push(dirname(createRequire(import.meta.url).resolve("@earendil-works/pi-coding-agent/package.json")));
39
+ } catch {}
40
+ return candidates.find((dir) => existsSync(join(dir, "dist", "bundle"))) ?? candidates[0];
27
41
  };
28
42
 
29
43
  const BUNDLE = join(resolvePi(), "dist/bundle");
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 grkn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,101 @@
1
+ # pi-umbra-subagents
2
+
3
+ Parallel, read-only pi branches you can watch while they work. A branch is a separate `pi -p`
4
+ process with an empty context; a live list above the input box shows what each one is doing,
5
+ and their answers come back to the session when the last one ends. Also `/umb-loop`, which
6
+ sends a prompt again after every reply, on a count or a timer.
7
+
8
+ ![Three branches running under the input box](https://raw.githubusercontent.com/grknbyk/pi-umbra/main/assets/subagents.webp)
9
+
10
+ ```sh
11
+ pi install npm:pi-umbra-subagents
12
+ node ~/.pi/agent/npm/node_modules/pi-umbra-subagents/patch.mjs
13
+ ```
14
+
15
+ Restart pi after the patch. Only `/umb-loop` needs it; the branches work without it.
16
+
17
+ Nothing is added to the model's prompt and no tool is registered. The model starts a run by
18
+ ending its answer with a fenced `fan` block, which the bundled `fan` skill teaches it:
19
+
20
+ ````
21
+ ```fan
22
+ name: weather-map
23
+ desc: Map the weather API
24
+ # Map
25
+ routes: List every route in src/server.ts with file:line.
26
+ upstream: List what src/forecast.ts fetches, with file:line.
27
+ # Plan
28
+ design: Given the Map results, propose the change.
29
+ ```
30
+ ````
31
+
32
+ `# Title` starts a phase. Phases run in order; the branches inside one run at the same time.
33
+ `label@provider/model: task` picks a model for one branch, otherwise it runs on the session's
34
+ model. The results arrive as a follow-up message at the start of the next turn.
35
+
36
+ ## Commands and keys
37
+
38
+ | Command or key | Effect |
39
+ |---|---|
40
+ | `/umb-fan [spec]` | start a run yourself; with no argument an editor opens with a template |
41
+ | `/umb-agents`, `alt+a` | open the agent panel |
42
+ | `↓` from the last input line | move the `❯` from `main` into the agent list under the input box |
43
+ | `↑` `↓`, then `enter` in that list | pick an agent and open the panel on it; `↑` past the first agent or `esc` goes back |
44
+ | `↑` `↓` in the panel | move between phases, or between the agents of one phase |
45
+ | `→` `←` in the panel | go into a phase's agents, and back out to the phases |
46
+ | `x` in the panel | stop the selected phase, or the selected agent |
47
+ | `esc` in the panel | back to the input box, unsent text kept |
48
+ | `/umb-loop [count\|duration] [prompt]` | send the prompt again after each reply; run it again to stop |
49
+
50
+ `/umb-loop 5 fix the next failing test` runs five times, `/umb-loop 10m continue` for ten
51
+ minutes, `/umb-loop 0 …` until stopped. Without a prompt it sends `Continue.`. Escape cancels
52
+ one round and keeps the loop.
53
+
54
+ ## Skills
55
+
56
+ | Skill | Use |
57
+ |---|---|
58
+ | `fan` | the fenced block above; the extension owns the branches |
59
+ | `delegate` | the same branches started from a bash call (`dstart`, `branch`, `dwait`), for runs the model wants to read back itself, or continue with `dresume` after a branch asks a question |
60
+
61
+ Both write every branch's answer to `.pi-out/<run>/<phase>-<name>.md` and its errors to the
62
+ matching `.err`. Add `.pi-out/` to `.gitignore`. Quitting pi stops the branches; what they
63
+ wrote stays on disk.
64
+
65
+ ## Settings
66
+
67
+ | Variable | Default | Effect |
68
+ |---|---|---|
69
+ | `FAN_MODEL` | the session's model | model for `fan` branches that do not name one |
70
+ | `FAN_TOOLS` | `read,grep,find,ls` | tools a branch may use |
71
+ | `FAN_LOAD` | `$LOAD` from `delegate.env` | extra `-e <path>` flags; branches start with `--no-extensions`, so a provider that comes from an extension has to be listed here |
72
+ | `FAN_TIMEOUT_MS` | `$DELEGATE_TIMEOUT` × 1000, else `300000` | a branch still running after this long is cut off |
73
+
74
+ Both ways in share one settings file: the packaged `delegate.env`, then
75
+ `~/.pi/agent/delegate.env`, which wins.
76
+
77
+ A branch starts without extensions, so a model that only an extension provides is not there
78
+ for it. When no branch can start on its model, you get a warning and the model is told to ask
79
+ you which one to use. The suggestions are your scoped models (`enabledModels`) that a branch
80
+ can reach; if none can, free models; if there are none, the first few a branch can reach.
81
+ Name the choice per branch with `label@provider/model`, set `FAN_MODEL`, or load the extension
82
+ through `LOAD`.
83
+
84
+ A model id may carry a colon, as in `label@openrouter/some-model:free: task`; the key ends at
85
+ the first colon followed by a space.
86
+
87
+ ## The patch
88
+
89
+ `/umb-loop` sends its prompt the way the input box does, and the extension API has no way to
90
+ do that. `patch.mjs` makes one small additive edit to pi's installed bundle that exposes it. A
91
+ pi update removes the patch without any error, so run the script again after every update.
92
+
93
+ | Command | Effect |
94
+ |---|---|
95
+ | `node .../patch.mjs` | apply the patch; a part already applied is skipped |
96
+ | `node .../patch.mjs --check` | change nothing, exit 1 if the patch is missing |
97
+
98
+ The script patches the `pi` on your PATH. Set `PI_UMBRA_PI` to the `pi-coding-agent`
99
+ directory to patch another one.
100
+
101
+ MIT.
@@ -0,0 +1,102 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ // The editor's submit path is not on the extension API, so repatch.mjs publishes it.
4
+ const submit = (text: string) => (globalThis as any).__piSubmit?.(text);
5
+
6
+ const DEFAULT_PROMPT = "Continue.";
7
+ const UNIT_MS: Record<string, number> = { h: 3_600_000, m: 60_000, s: 1000 };
8
+ // "90s", "10 min", "1h30m" — a run of number+unit pairs at the very start of the args. A unit
9
+ // ends at any non-letter, not at \b, which never falls between "h" and "30".
10
+ const DURATION = /^(?:\d+\s*(?:hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)(?![a-z])\s*)+/i;
11
+ const COUNT = /^(\d+)\s*/;
12
+ const RESUBMIT_DELAY_MS = 50;
13
+
14
+ type Loop = { prompt: string; iterations?: number; deadline?: number; done: number };
15
+
16
+ const durationMs = (text: string) =>
17
+ [...text.matchAll(/(\d+)\s*([a-z]+)/gi)].reduce(
18
+ (sum, [, amount, unit]) => sum + Number(amount) * UNIT_MS[unit[0].toLowerCase()],
19
+ 0,
20
+ );
21
+
22
+ const parse = (args: string): Loop => {
23
+ const trimmed = args.trim();
24
+ const rest = (from: string) => trimmed.slice(from.length).trim() || DEFAULT_PROMPT;
25
+
26
+ const duration = trimmed.match(DURATION);
27
+ if (duration) return { prompt: rest(duration[0]), deadline: Date.now() + durationMs(duration[0]), done: 0 };
28
+
29
+ const count = trimmed.match(COUNT);
30
+ if (count) {
31
+ // 0 means "no limit". Either branch strips the number from the prompt, so
32
+ // "/umb-loop 0 fix the tests" submits "fix the tests", not "0 fix the tests".
33
+ const iterations = Number(count[1]);
34
+ return iterations > 0
35
+ ? { prompt: rest(count[0]), iterations, done: 0 }
36
+ : { prompt: rest(count[0]), done: 0 };
37
+ }
38
+
39
+ return { prompt: trimmed || DEFAULT_PROMPT, done: 0 };
40
+ };
41
+
42
+ export default function (pi: ExtensionAPI) {
43
+ let loop: Loop | undefined;
44
+ let cancelled = false;
45
+
46
+ const describe = () => {
47
+ if (!loop) return undefined;
48
+ if (loop.iterations) return `loop ${loop.done}/${loop.iterations}`;
49
+ if (loop.deadline) return `loop ${Math.max(0, Math.round((loop.deadline - Date.now()) / 1000))}s`;
50
+ return `loop ${loop.done}`;
51
+ };
52
+
53
+ const stop = (ctx: ExtensionContext, reason: string) => {
54
+ loop = undefined;
55
+ ctx.ui.setStatus("loop", undefined);
56
+ ctx.ui.notify(reason);
57
+ };
58
+
59
+ pi.registerCommand("umb-loop", {
60
+ description: "Toggle automatic resubmission after each yield: /umb-loop [count|duration] [prompt]",
61
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
62
+ if (loop) return stop(ctx, "loop off");
63
+ // Without the patch nothing would ever be sent, while the status claimed a running loop.
64
+ if (!(globalThis as any).__piSubmit) {
65
+ return ctx.ui.notify("umb-loop needs its patch: run pi-umbra-subagents/patch.mjs, then restart pi", "warning");
66
+ }
67
+
68
+ loop = parse(args);
69
+ cancelled = false;
70
+ ctx.ui.setStatus("loop", describe());
71
+ ctx.ui.notify(`loop on — "${loop.prompt}"`);
72
+ if (ctx.isIdle()) {
73
+ loop.done++;
74
+ submit(loop.prompt);
75
+ }
76
+ },
77
+ });
78
+
79
+ // An aborted run (Escape) keeps the loop armed but skips this yield, so the turn comes back to
80
+ // the user. Read from how the run ended, not from the Escape key: an Escape that only closed a
81
+ // panel or an autocomplete list aborts nothing and must not stall the loop.
82
+ pi.on("agent_end", (event) => {
83
+ const last = [...event.messages].reverse().find((message) => message.role === "assistant");
84
+ if (loop && last && "stopReason" in last && last.stopReason === "aborted") cancelled = true;
85
+ });
86
+
87
+ pi.on("agent_settled", (_event, ctx) => {
88
+ if (!loop) return;
89
+ if (cancelled) {
90
+ cancelled = false;
91
+ ctx.ui.notify("loop: iteration cancelled");
92
+ return;
93
+ }
94
+ if (loop.deadline && Date.now() >= loop.deadline) return stop(ctx, "loop done (time up)");
95
+ if (loop.iterations && loop.done >= loop.iterations) return stop(ctx, "loop done");
96
+
97
+ loop.done++;
98
+ ctx.ui.setStatus("loop", describe());
99
+ const prompt = loop.prompt;
100
+ setTimeout(() => loop && submit(prompt), RESUBMIT_DELAY_MS);
101
+ });
102
+ }