pi-umbra 0.3.1 → 0.4.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 (34) hide show
  1. package/README.md +3 -4
  2. package/node_modules/pi-umbra-copy-chat/README.md +4 -7
  3. package/node_modules/pi-umbra-copy-chat/extensions/umbra-copy-chat.ts +16 -54
  4. package/node_modules/pi-umbra-copy-chat/package.json +1 -1
  5. package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +10 -1
  6. package/node_modules/pi-umbra-help/extensions/umbra-help.ts +31 -0
  7. package/node_modules/pi-umbra-help/package.json +1 -1
  8. package/node_modules/pi-umbra-inputbar/README.md +1 -1
  9. package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +1 -1
  10. package/node_modules/pi-umbra-inputbar/package.json +2 -2
  11. package/node_modules/pi-umbra-shimmer/package.json +1 -1
  12. package/node_modules/pi-umbra-shimmer/patch.mjs +23 -21
  13. package/node_modules/pi-umbra-skill-matcher/package.json +1 -1
  14. package/node_modules/pi-umbra-skill-matcher/patch.mjs +7 -0
  15. package/node_modules/pi-umbra-subagents/README.md +19 -4
  16. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar/bar-line.ts +9 -2
  17. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.check.ts +10 -1
  18. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/index.ts +50 -5
  19. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/spec.ts +24 -14
  20. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.check.ts +119 -4
  21. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.ts +138 -58
  22. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/worktree.ts +156 -0
  23. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.ts +19 -0
  24. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/fan/SKILL.md +25 -4
  25. package/node_modules/pi-umbra-subagents/package.json +2 -2
  26. package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +7 -1
  27. package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +2 -1
  28. package/node_modules/pi-umbra-theme/package.json +3 -3
  29. package/package.json +9 -12
  30. package/node_modules/pi-umbra-rename/LICENSE +0 -21
  31. package/node_modules/pi-umbra-rename/README.md +0 -18
  32. package/node_modules/pi-umbra-rename/checks/umbra-rename.check.ts +0 -55
  33. package/node_modules/pi-umbra-rename/extensions/umbra-rename.ts +0 -22
  34. package/node_modules/pi-umbra-rename/package.json +0 -36
package/README.md CHANGED
@@ -2,7 +2,7 @@
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, parallel read-only branches with a live panel, and the `/umb-*` commands.
5
+ tool, parallel pi branches (read-only, or editing in their own git worktree) with a live agent tree, and the `/umb-*` commands.
6
6
 
7
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
 
@@ -10,7 +10,7 @@ tool, parallel read-only branches with a live panel, and the `/umb-*` commands.
10
10
  pi install npm:pi-umbra
11
11
  ```
12
12
 
13
- The ten packages ship bundled inside this one tarball. Three of them patch pi's bundle, so
13
+ The nine packages ship bundled inside this one tarball. Three of them patch pi's bundle, so
14
14
  run the three scripts once now and again after every pi update, then restart pi:
15
15
 
16
16
  ```sh
@@ -26,11 +26,10 @@ node ~/.pi/agent/npm/node_modules/pi-umbra/node_modules/pi-umbra-subagents/patch
26
26
  | [pi-umbra-ask](https://www.npmjs.com/package/pi-umbra-ask) | a tool the model calls to ask you a question and wait |
27
27
  | [pi-umbra-shimmer](https://www.npmjs.com/package/pi-umbra-shimmer) | a wave of colour through the working line, `/umb-shimmer` |
28
28
  | [pi-umbra-skill-matcher](https://www.npmjs.com/package/pi-umbra-skill-matcher) | `/name` runs `skill:name`, completion mid-sentence |
29
- | [pi-umbra-rename](https://www.npmjs.com/package/pi-umbra-rename) | `/umb-rename`, session name and terminal title |
30
29
  | [pi-umbra-copy-chat](https://www.npmjs.com/package/pi-umbra-copy-chat) | `/umb-copy-chat`, the session on the clipboard |
31
30
  | [pi-umbra-preview](https://www.npmjs.com/package/pi-umbra-preview) | `/umb-preview`, render a document in the browser |
32
31
  | [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
+ | [pi-umbra-subagents](https://www.npmjs.com/package/pi-umbra-subagents) | parallel pi branches, read-only or in their own git worktree, with a live agent tree, `/umb-fan`, `/umb-loop` |
34
33
 
35
34
  Full documentation: [github.com/grknbyk/pi-umbra](https://github.com/grknbyk/pi-umbra).
36
35
 
@@ -47,12 +47,9 @@ When it is done, pi shows the size, for example `42 KB on the clipboard.`
47
47
 
48
48
  ## Clipboard
49
49
 
50
- | System | Program |
51
- |---|---|
52
- | Linux | `xclip` (X11 only) |
53
- | macOS | `pbcopy` |
54
- | Windows | `clip` |
55
-
56
- On Wayland, change the `linux` entry of the `COPY` table in the extension to `["wl-copy"]`.
50
+ The text goes through pi's own clipboard writer, the one `/copy` uses: `wl-copy`, `xclip` or
51
+ `xsel` on Linux, the native clipboard on macOS and Windows, and OSC 52 over SSH. When none
52
+ works, pi says which program to install. pi caps OSC 52 at about 73 KB of text, so over SSH a long
53
+ session may be too big to copy.
57
54
 
58
55
  MIT.
@@ -1,7 +1,4 @@
1
- import { spawn } from "node:child_process";
2
- import { readFileSync } from "node:fs";
3
- import { platform } from "node:os";
4
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
+ import { copyToClipboard, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
5
2
 
6
3
  // /umb-copy-chat puts this session on the clipboard as markdown, in full, and does nothing else.
7
4
  // No file is written, no model turn is spent, nothing is uploaded.
@@ -26,30 +23,9 @@ const PREAMBLE = `> Kaydedilmiş bir pi oturumu, bağlam olarak yapıştırıld
26
23
  > da terk edilmiş olabilir), \`## call\` / \`## result\` gerçekten çalışmış araç
27
24
  > çağrıları. Oku, sonra asıl isteği bekle.`;
28
25
 
29
- // One copy command per platform. On Linux this is the X11 one; swap it for
30
- // ["wl-copy"] on a Wayland session.
31
- const COPY: Record<string, string[]> = {
32
- win32: ["clip"],
33
- darwin: ["pbcopy"],
34
- linux: ["xclip", "-selection", "clipboard"],
35
- };
36
-
37
26
  type Block = { type: string; text?: string; thinking?: string; name?: string; arguments?: unknown };
38
27
  type Message = { role: string; content: unknown; toolName?: string; isError?: boolean };
39
- type Entry = { id?: string; parentId?: string; type?: string; timestamp?: string; message?: Message };
40
-
41
- /** The turns still on the board. */
42
- // A rewind (pi's /fork) does not delete anything: it writes the new turn with its parentId
43
- // pointing at an older message, and the turns it walked back over stay in the file. Reading
44
- // the lines in order would hand the reader both the abandoned attempt and the one that
45
- // replaced it, with nothing marking which is which. Walking parentId back from the last
46
- // entry is what the runtime itself is showing on screen.
47
- export const activeBranch = (rows: Entry[]): Entry[] => {
48
- const byId = new Map(rows.map((row) => [row.id, row]));
49
- const chain: Entry[] = [];
50
- for (let row = rows[rows.length - 1]; row !== undefined; row = byId.get(row.parentId ?? "")) chain.unshift(row);
51
- return chain;
52
- };
28
+ type Entry = { type?: string; timestamp?: string; message?: Message };
53
29
 
54
30
  /** `2026-09-07 00:31` the first time and on each new day, `00:31` in between. */
55
31
  export const stamp = (iso: string, lastDay: string | undefined): [string, string] => {
@@ -81,15 +57,13 @@ const textOf = (content: unknown): string =>
81
57
  .join("")
82
58
  .trim();
83
59
 
84
- export const transcribe = (jsonl: string): string => {
85
- const rows = jsonl
86
- .split("\n")
87
- .filter((line) => line.trim() !== "")
88
- .map((line) => JSON.parse(line) as Entry);
89
-
60
+ /** The branch on screen, as pi hands it over: root to leaf, the turns a rewind or a /tree walked
61
+ * back over already left out. Taken from pi rather than from the session file, because only pi
62
+ * knows the leaf after a /tree with no new message yet. */
63
+ export const transcribe = (branch: Entry[]): string => {
90
64
  const out: string[] = [];
91
65
  let day: string | undefined;
92
- for (const row of activeBranch(rows)) {
66
+ for (const row of branch) {
93
67
  // session, model_change, thinking_level_change, custom_message and session_info.
94
68
  if (row.type !== "message" || row.message === undefined) continue;
95
69
  const { role, content, toolName, isError } = row.message;
@@ -121,34 +95,22 @@ export const transcribe = (jsonl: string): string => {
121
95
  return out.join("\n");
122
96
  };
123
97
 
124
- const copy = (text: string) =>
125
- new Promise<boolean>((resolve) => {
126
- const argv = COPY[platform()];
127
- if (!argv) return resolve(false);
128
- const child = spawn(argv[0] as string, argv.slice(1));
129
- // A missing binary arrives as an "error" event, not a throw, so both paths resolve.
130
- child.on("error", () => resolve(false));
131
- child.on("close", (code) => resolve(code === 0));
132
- child.stdin.end(text);
133
- });
134
-
135
98
  export default function (pi: ExtensionAPI) {
136
99
  pi.registerCommand("umb-copy-chat", {
137
100
  description: "Copy this whole session to the clipboard as markdown",
138
101
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
139
- const sessionFile = ctx.sessionManager.getSessionFile();
140
- if (!sessionFile) return ctx.ui.notify("No session file yet - send one message first.", "warning");
141
-
142
- const body = transcribe(readFileSync(sessionFile, "utf8"));
102
+ const body = transcribe(ctx.sessionManager.getBranch() as Entry[]);
143
103
  if (body === "") return ctx.ui.notify("Nothing to copy yet.", "warning");
144
104
 
145
105
  const markdown = `${PREAMBLE}\n\n${body}`;
146
- const copied = await copy(markdown);
147
- const kb = Math.max(1, Math.round(markdown.length / 1024));
148
- ctx.ui.notify(
149
- copied ? `${kb} KB on the clipboard.` : `Could not reach the clipboard on ${platform()}.`,
150
- copied ? "info" : "error",
151
- );
106
+ // pi's own /copy writer: wl-copy, xclip, xsel, the native clipboard or OSC 52, whichever
107
+ // works here. It throws with the install hint when none does.
108
+ try {
109
+ await copyToClipboard(markdown);
110
+ } catch (error) {
111
+ return ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
112
+ }
113
+ ctx.ui.notify(`${Math.max(1, Math.round(markdown.length / 1024))} KB on the clipboard.`, "info");
152
114
  },
153
115
  });
154
116
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-umbra-copy-chat",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "/copy-chat puts the conversation on the clipboard.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -6,6 +6,7 @@ import assert from "node:assert";
6
6
  import { type Facts, PROBES, announcesTruecolor, diagnose, renderDoctor, renderHelp, shadowed } from "../extensions/umbra-help.ts";
7
7
 
8
8
  const clean: Facts = {
9
+ packages: ["pi-umbra", "pi-claude-bridge"],
9
10
  patches: Object.fromEntries(PROBES.map((probe) => [probe.id, true])),
10
11
  commands: ["umb-shimmer", "umb-bg", "umb-help"],
11
12
  skillRoots: [{ root: "/a", names: ["adhd"] }],
@@ -112,4 +113,12 @@ assert.match(help, /"background": "auto"/, "the live settings, not a description
112
113
  assert.match(help, /umb-doctor/, "and a pointer to the other half");
113
114
  assert.match(renderHelp([], {}, "dark"), /No umb- command is registered/);
114
115
 
115
- console.log("umbra-help.check.ts ok - diagnose 11 cases, truecolor 7, shadowed 4, help 6");
116
+ // A package umbra dropped still loads without a word from pi, so the doctor names it.
117
+ const stale = diagnose(at({ packages: ["pi-umbra", "pi-umbra-rename"] }));
118
+ assert.equal(stale.length, 1, "a retired package listed in settings is reported");
119
+ assert.match(stale[0]!.title, /pi-umbra-rename is no longer part of umbra/);
120
+ assert.match(stale[0]!.detail, /pi remove npm:pi-umbra-rename/, "with the command that removes it");
121
+ const loose = diagnose(at({ commands: [...clean.commands, "umb-rename"] }));
122
+ assert.match(loose[0]!.detail, /still registered by something/, "a leftover file is found by its command");
123
+
124
+ console.log("umbra-help.check.ts ok - diagnose 14 cases, truecolor 7, shadowed 4, help 6");
@@ -49,7 +49,15 @@ export const PROBES: Probe[] = [
49
49
  },
50
50
  ];
51
51
 
52
+ // Packages that were umbra once and are not any more, with what replaced them. An install that
53
+ // still carries one runs code nobody maintains, and pi never says so: the package loads fine.
54
+ export const RETIRED: { name: string; command: string; why: string }[] = [
55
+ { name: "pi-umbra-rename", command: "umb-rename", why: "pi's own /name sets the session name and the terminal title since pi 1.0" },
56
+ ];
57
+
52
58
  export type Facts = {
59
+ /** The npm packages listed in the global and the project settings, without the `npm:` prefix. */
60
+ packages: string[];
53
61
  /** Probe id to whether its marker is in place. */
54
62
  patches: Record<string, boolean>;
55
63
  /** The umb- commands this session actually registered. */
@@ -86,6 +94,18 @@ export const shadowed = (roots: Facts["skillRoots"]): { name: string; winner: st
86
94
  export const diagnose = (facts: Facts): Finding[] => {
87
95
  const findings: Finding[] = [];
88
96
 
97
+ for (const retired of RETIRED) {
98
+ const listed = facts.packages.includes(retired.name);
99
+ if (!listed && !facts.commands.includes(retired.command)) continue;
100
+ findings.push({
101
+ level: "warn",
102
+ title: `${retired.name} is no longer part of umbra`,
103
+ detail: listed
104
+ ? `${retired.why}. Remove it: pi remove npm:${retired.name}`
105
+ : `${retired.why}, but /${retired.command} is still registered by something. Delete the file that adds it.`,
106
+ });
107
+ }
108
+
89
109
  for (const probe of PROBES) {
90
110
  if (facts.patches[probe.id] !== false) continue;
91
111
  // A patch nothing depends on is not a fault. Only say so when the feature is installed.
@@ -210,9 +230,20 @@ export const gather = (cwd: string, theme: string, commands: Command[]): Facts =
210
230
  for (const probe of PROBES) patches[probe.id] = has(chunks, probe.marker);
211
231
  }
212
232
 
233
+ // Settings list a package as "npm:name", "npm:name@1.2" or { source: "npm:name" }.
234
+ const packages = [join(AGENT, "settings.json"), join(cwd, ".pi", "settings.json")].flatMap((file) => {
235
+ try {
236
+ const list = (JSON.parse(readFileSync(file, "utf8")).packages ?? []) as (string | { source?: string })[];
237
+ return list.map((entry) => (typeof entry === "string" ? entry : (entry.source ?? "")));
238
+ } catch {
239
+ return [];
240
+ }
241
+ });
242
+
213
243
  // The order pi resolves them in, which is the order that decides a collision.
214
244
  const roots = [join(AGENT, "skills"), join(cwd, ".pi", "skills"), join(homedir(), ".agents", "skills")];
215
245
  return {
246
+ packages: packages.filter((source) => source.startsWith("npm:")).map((source) => source.slice(4).replace(/(.)@.*$/, "$1")),
216
247
  patches,
217
248
  commands: commands.map((command) => command.name),
218
249
  skillRoots: roots.map((root) => ({ root, names: namesIn(root) })).filter((entry) => entry.names.length > 0),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-umbra-help",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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",
@@ -33,7 +33,7 @@ The colour is kept in `~/.pi/agent/input-color` and applies to every session.
33
33
 
34
34
  ## Session name
35
35
 
36
- The name set with `/name` or `/umb-rename` sits at the right end of the top border and shows
36
+ The name set with `/name` sits at the right end of the top border and shows
37
37
  on the next repaint. When the border has no room for it, it is left out.
38
38
 
39
39
  ## For extension authors
@@ -1,7 +1,7 @@
1
1
  // The input bar: the session name on its top border (right side) and /umb-color for its border
2
2
  // colour. Both ride on pi's own extension point - ctx.ui.setEditorComponent with a subclass of
3
3
  // CustomEditor - so no bundle patch is involved. The name is read from pi.getSessionName() at
4
- // every render, so /umb-rename shows on the next repaint; /umb-color persists in
4
+ // every render, so /name shows on the next repaint; /umb-color persists in
5
5
  // ~/.pi/agent/input-color.
6
6
  import { CustomEditor, type ExtensionAPI, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
7
7
  import { isKeyRepeat, visibleWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-umbra-inputbar",
3
- "version": "0.1.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.",
3
+ "version": "0.1.5",
4
+ "description": "pi's input bar, rebuilt: a chevron prompt, the session name on the border, a colour you pick.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-umbra-shimmer",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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",
@@ -60,8 +60,11 @@ const PATCHES = [
60
60
  // wave: the spinner colour function was skipped for them while the message kept being
61
61
  // repainted every frame. Verbatim still holds when no shimmer is installed.
62
62
  reason: "and paints the custom frames too",
63
- from: `return this.renderIndicatorVerbatim?frame2:this.spinnerColorFn(frame2)`,
64
- to: `return this.renderIndicatorVerbatim&&!globalThis.__umbraShimmer?frame2:this.spinnerColorFn(frame2)`,
63
+ // pi 1.0 bundles the same line with the local named `frame` rather than `frame2`.
64
+ variants: ["frame2", "frame"].map((name) => ({
65
+ from: `return this.renderIndicatorVerbatim?${name}:this.spinnerColorFn(${name})`,
66
+ to: `return this.renderIndicatorVerbatim&&!globalThis.__umbraShimmer?${name}:this.spinnerColorFn(${name})`,
67
+ })),
65
68
  },
66
69
  {
67
70
  // The working indicator paints its spinner with `accent` and its message with `muted`, both
@@ -79,33 +82,32 @@ const CHECK = process.argv.includes("--check");
79
82
  const unapplied = [];
80
83
  const gone = [];
81
84
 
82
- for (const patch of PATCHES) {
83
- let done = false;
85
+ // One patch, every file read once: "applied", "unapplied" (--check), or undefined when gone.
86
+ const apply = (patch) => {
84
87
  for (const path of files) {
85
88
  const source = readFileSync(path, "utf8");
86
89
  // A multi-line patch is written with newline escapes; match whatever line ending the file uses.
87
90
  const eol = source.includes("\r\n") ? "\r\n" : "\n";
88
- const from = patch.from.split("\n").join(eol);
89
- const to = patch.to.split("\n").join(eol);
90
- if (source.includes(to)) {
91
- if (!CHECK) console.log(`already patched: ${patch.reason}`);
92
- done = true;
93
- break;
94
- }
95
- if (!source.includes(from)) continue;
96
- if (CHECK) {
97
- unapplied.push(patch.reason);
98
- done = true;
99
- break;
91
+ for (const variant of patch.variants ?? [patch]) {
92
+ const from = variant.from.split("\n").join(eol);
93
+ const to = variant.to.split("\n").join(eol);
94
+ if (source.includes(to)) return "applied";
95
+ if (!source.includes(from)) continue;
96
+ if (CHECK) return "unapplied";
97
+ writeFileSync(path, source.replace(from, to));
98
+ console.log(`patched: ${patch.reason}`);
99
+ return "applied";
100
100
  }
101
- writeFileSync(path, source.replace(from, to));
102
- console.log(`patched: ${patch.reason}`);
103
- done = true;
104
- break;
105
101
  }
102
+ return undefined;
103
+ };
104
+
105
+ for (const patch of PATCHES) {
106
+ const result = apply(patch);
107
+ if (result === "unapplied") unapplied.push(patch.reason);
106
108
  // Neither the original text nor the patched text is there, so pi changed the code this
107
109
  // patch names. Report it rather than force anything.
108
- if (!done) gone.push(patch.reason);
110
+ else if (!result) gone.push(patch.reason);
109
111
  }
110
112
 
111
113
  for (const reason of unapplied) console.log(`NOT APPLIED: ${reason}`);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-umbra-skill-matcher",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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",
@@ -58,6 +58,7 @@ const PATCHES = [
58
58
  },
59
59
  {
60
60
  reason: "the command list names skills without the prefix too",
61
+ needs: "/huh instead of /skill:huh",
61
62
  from: "skills=this._resourceLoader.getSkills().skills.map(skill=>({name:`skill:${skill.name}`,",
62
63
  to: "skills=this._resourceLoader.getSkills().skills.map(skill=>({name:skill.name,",
63
64
  },
@@ -83,6 +84,7 @@ const PATCHES = [
83
84
  // `isAtStartOfMessage`. At that moment the token after the slash is still empty, which is
84
85
  // exactly the shape `umbraMidSlash` accepts.
85
86
  reason: "typing a mid-sentence slash opens the menu on the slash",
87
+ needs: "a mid-sentence /name opens the slash menu",
86
88
  from: `char==="/"&&this.isAtStartOfMessage()`,
87
89
  to: `char==="/"&&(this.isAtStartOfMessage()||this.umbraMidSlash((this.state.lines[this.state.cursorLine]||"").slice(0,this.state.cursorCol)))`,
88
90
  },
@@ -94,6 +96,11 @@ const unapplied = [];
94
96
  const gone = [];
95
97
 
96
98
  for (const patch of PATCHES) {
99
+ // It would call a method, or offer a name, that only the patch it needs provides.
100
+ if (gone.includes(patch.needs)) {
101
+ gone.push(`${patch.reason} (held back: needs "${patch.needs}")`);
102
+ continue;
103
+ }
97
104
  let done = false;
98
105
  for (const path of files) {
99
106
  const source = readFileSync(path, "utf8");
@@ -1,8 +1,10 @@
1
1
  # pi-umbra-subagents
2
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
3
+ Parallel pi branches you can watch while they work. A branch is a separate `pi -p` process with
4
+ an empty context; a live tree under the input box shows what each one is doing, and their answers
5
+ come back to the session when the last one ends. Branches are read-only, unless one is marked
6
+ `[write]`: that one works in its own git worktree, and its changes wait on a git branch until you
7
+ say whether to merge them. Also `/umb-loop`, which
6
8
  sends a prompt again after every reply, on a count or a timer.
7
9
 
8
10
  ![Three branches running under the input box](https://raw.githubusercontent.com/grknbyk/pi-umbra/main/assets/subagents.webp)
@@ -33,6 +35,19 @@ design: Given the Map results, propose the change.
33
35
  `label@provider/model: task` picks a model for one branch, otherwise it runs on the session's
34
36
  model. The results arrive as a follow-up message at the start of the next turn.
35
37
 
38
+ `label [write]: task` gives one branch `edit`, `write` and `bash`, in its own git worktree on a
39
+ new `fan/<run>/<branch>` branch that starts from your working tree, uncommitted work included.
40
+ Its changes are committed to that branch when it ends and its row reads `Changed +12 −3 · 2 files
41
+ on fan/...`; nothing is merged until the session asks you and you say yes. It needs a git
42
+ repository, and pi has no sandbox: bash can still reach outside the worktree, and only the
43
+ branch's instructions forbid it.
44
+
45
+ The worktree lives under `.git/fan-worktrees/`, where test runners, linters and `git clean` do not
46
+ look. A write branch cannot stop on a question, because its worktree closes when it ends, so its
47
+ task has to carry every decision. Every write branch starts from the tree as it was when the run started, so a later
48
+ phase does not see an earlier phase's changes. A run you start with `/umb-fan` tells you when a write branch
49
+ left changes; ask the session to merge or discard them.
50
+
36
51
  ## Commands and keys
37
52
 
38
53
  | Command or key | Effect |
@@ -70,7 +85,7 @@ wrote stays on disk.
70
85
  | Variable | Default | Effect |
71
86
  |---|---|---|
72
87
  | `FAN_MODEL` | the session's model | model for `fan` branches that do not name one |
73
- | `FAN_TOOLS` | `read,grep,find,ls` | tools a branch may use |
88
+ | `FAN_TOOLS` | `read,grep,find,ls` | tools a read-only branch may use (`[write]` branches get `edit,write,bash` too) |
74
89
  | `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 |
75
90
  | `FAN_TIMEOUT_MS` | `$DELEGATE_TIMEOUT` × 1000, else `300000` | a branch still running after this long is cut off |
76
91
 
@@ -173,12 +173,19 @@ export const countsOf = (branches: BranchView[]) =>
173
173
  /** What the row says it is doing. A branch that was killed never got to write "done", so its
174
174
  * own `activity` is frozen on whatever it was mid-way through — a row reading "Running cd"
175
175
  * on a process that has been dead for four minutes. The exit file already knows better. */
176
- export const said = (branch: BranchView): string => {
176
+ const endedAs = (branch: BranchView): string => {
177
177
  if (branch.timedOut) return "Timed out";
178
178
  if (branch.status === "error") return branch.error ? `Failed: ${branch.error}` : "Failed";
179
179
  // Killed by hand or by a parent going away: a non-zero exit with no error text of its own.
180
180
  if (branch.settled && branch.exitCode !== null && branch.exitCode !== 0 && !branch.report) return "Stopped";
181
- return branch.activity ?? "";
181
+ return "";
182
+ };
183
+
184
+ export const said = (branch: BranchView): string => {
185
+ // A write branch's answer is its diff, committed even when it was stopped or timed out.
186
+ const { git } = branch;
187
+ const changed = git?.error ? `Not committed: ${git.error}` : git?.stat ? `Changed ${git.stat} on ${git.branch}` : "";
188
+ return [endedAs(branch), changed].filter(Boolean).join(" · ") || (branch.activity ?? "");
182
189
  };
183
190
 
184
191
  // A branch says what it is doing; a row with hidden agents under it says how they are doing
@@ -1,5 +1,5 @@
1
1
  import { visibleWidth } from "@earendil-works/pi-tui";
2
- import { MAX_ROWS, countsOf, layoutList, linesFor, renderKey, renderLines, stateOf, windowOf, type View } from "./bar/bar-line.ts";
2
+ import { MAX_ROWS, countsOf, layoutList, linesFor, renderKey, renderLines, said, stateOf, windowOf, type View } from "./bar/bar-line.ts";
3
3
  import type { BranchView, RunState } from "./skills/delegate/state.ts";
4
4
 
5
5
  // The widget sits directly above the input box, so two things can hurt and both are checked
@@ -253,4 +253,13 @@ if (renderKey(flat, now) === renderKey(flat, now + 1_000)) fail("the clock moved
253
253
  const rewritten = run({ ...flat, branches: [branch({ activity: "Reading hooks.server.ts and app.d.ts" }), ...flat.branches.slice(1)] });
254
254
  if (renderKey(flat, now) === renderKey(rewritten, now)) fail("the activity sentence changed and the key did not");
255
255
 
256
+ // A write branch that finished with changes says where they are; a read-only one says what it said.
257
+ const git = { root: "/r", branch: "fan/r/build-fix", base: "abc", stat: "+1 −0 · 1 file" };
258
+ const wrote = branch({ settled: true, exitCode: 0, activity: "Done", git });
259
+ if (said(wrote) !== "Changed +1 −0 · 1 file on fan/r/build-fix") fail(`write branch row: ${said(wrote)}`);
260
+ // Committed work outlives a timeout, and the row says both.
261
+ const late = branch({ settled: true, exitCode: 124, timedOut: true, activity: "Editing", git });
262
+ if (said(late) !== "Timed out · Changed +1 −0 · 1 file on fan/r/build-fix") fail(`timed-out write row: ${said(late)}`);
263
+ if (said(branch({ settled: true, exitCode: 0, activity: "Done" })) !== "Done") fail("a read-only branch keeps its own sentence");
264
+
256
265
  console.log("ok");
@@ -16,7 +16,8 @@ import type { RunState } from "../skills/delegate/state.ts";
16
16
  // A branch that stopped on a question goes to the model first: most questions are answered by
17
17
  // the conversation the branch never saw, and only the rest reach the user.
18
18
  const askingNote = (run: RunState): string => {
19
- const asking = run.branches.filter((branch) => branch.report === "ASKING");
19
+ // A write branch cannot be continued: its worktree closed when it ended.
20
+ const asking = run.branches.filter((branch) => branch.report === "ASKING" && !branch.git);
20
21
  if (!asking.length) return "";
21
22
  return (
22
23
  `\n\n${asking.map((branch) => branch.stem).join(", ")} stopped on a question. Answer it from this ` +
@@ -27,6 +28,14 @@ const askingNote = (run: RunState): string => {
27
28
  );
28
29
  };
29
30
 
31
+ // A `[write]` branch leaves its work on a git branch, and merging it is always the user's call:
32
+ // the model asks, then does what they chose. reportOf already carries each branch's merge and
33
+ // discard commands.
34
+ const mergeNote = (run: RunState): string =>
35
+ run.branches.some((branch) => branch.git?.stat)
36
+ ? "\n\nAsk the user with ask_user_question, one question per changed branch, whether to merge it, and do only what they choose."
37
+ : "";
38
+
30
39
  // A branch starts with --no-extensions, so a model that only an extension provides is not there
31
40
  // for it. Rather than guess another one, the model asks the user: which model to spend on is
32
41
  // theirs to choose.
@@ -61,10 +70,19 @@ export default function (pi: ExtensionAPI) {
61
70
  // A run the delegate skill started in bash is left alone: that caller reads $run/*.md
62
71
  // itself, and a follow-up would hand the model the same text twice.
63
72
  let awaiting: string | undefined;
73
+ // A run the user started with /umb-fan is theirs to read on the panel, but work a write
74
+ // branch committed must not go unmentioned once the row has faded.
75
+ let watching: { dir: string; ctx: ExtensionContext } | undefined;
64
76
 
65
77
  // Branches must not outlive the pi that started them: nothing would be watching, and a
66
78
  // branch left running keeps spending. Their reports are already on disk.
67
- pi.on("session_shutdown", () => store.stopAll());
79
+ pi.on("session_shutdown", () => {
80
+ // A ctx from the session that is ending throws on every use once it is gone, and the
81
+ // children killed below still settle after this.
82
+ awaiting = undefined;
83
+ watching = undefined;
84
+ store.stopAll();
85
+ });
68
86
 
69
87
  // A bash call may be the delegate skill mid-run. Arming the tick when one starts is what
70
88
  // makes a skill-started run appear on the bar from its first frame rather than at the end;
@@ -93,7 +111,16 @@ export default function (pi: ExtensionAPI) {
93
111
  if (reply) pi.sendUserMessage(modelNote(missing, offer), { deliverAs: "followUp" });
94
112
  return undefined;
95
113
  }
96
- const dir = store.start(spec, fallback, ctx.cwd, brief, settings);
114
+ let dir: string | undefined;
115
+ try {
116
+ dir = store.start(spec, fallback, ctx.cwd, brief, settings);
117
+ } catch (error) {
118
+ // A [write] branch outside a git repo, a worktree git refused, a full disk: nothing started.
119
+ const reason = error instanceof Error ? error.message.split("\n")[0] : String(error);
120
+ ctx.ui.notify(`fan: ${reason}`, "warning");
121
+ if (reply) pi.sendUserMessage(`The fan run did not start: ${reason}`, { deliverAs: "followUp" });
122
+ return undefined;
123
+ }
97
124
  if (!dir) ctx.ui.notify("fan: a run is already in flight — stop it from the panel first", "warning");
98
125
  return dir;
99
126
  };
@@ -121,9 +148,26 @@ export default function (pi: ExtensionAPI) {
121
148
  // that asked for them already ended, so the model reads them at the top of the next one.
122
149
  store.subscribe(() => {
123
150
  const run = store.run();
151
+ if (run && run.dir === watching?.dir && !run.live) {
152
+ const { ctx } = watching;
153
+ watching = undefined;
154
+ const left = run.branches.filter((branch) => branch.git?.stat || branch.git?.error);
155
+ if (left.length) {
156
+ ctx.ui.notify(
157
+ `fan: ${left.map((branch) => (branch.git?.error ? `${branch.stem} could not be committed (${branch.git.error})` : `${branch.stem} changed ${branch.git?.stat} on ${branch.git?.branch}`)).join("; ")}. Ask the session about it.`,
158
+ "info",
159
+ );
160
+ // The session learns the branches and their merge commands with the next message the
161
+ // user sends, without a turn of its own: asked to merge, it must not guess `git merge`.
162
+ pi.sendMessage(
163
+ { customType: "umbra-fan", content: `Branch results for ${run.name}, a run the user started:\n\n${reportOf(run)}`, display: false },
164
+ { deliverAs: "nextTurn" },
165
+ );
166
+ }
167
+ }
124
168
  if (!run || run.dir !== awaiting || run.live) return;
125
169
  awaiting = undefined;
126
- pi.sendUserMessage(`Branch results for ${run.name}:\n\n${reportOf(run)}${askingNote(run)}`, { deliverAs: "followUp" });
170
+ pi.sendUserMessage(`Branch results for ${run.name}:\n\n${reportOf(run)}${askingNote(run)}${mergeNote(run)}`, { deliverAs: "followUp" });
127
171
  });
128
172
 
129
173
  pi.registerCommand("umb-fan", {
@@ -135,7 +179,8 @@ export default function (pi: ExtensionAPI) {
135
179
  if (!spec) return ctx.ui.notify("fan: no branches in that spec", "warning");
136
180
  // Started by the user, so the results are theirs to read on the panel; the model is
137
181
  // only told about a run it asked for itself. launch says why when nothing started.
138
- await launch(spec, ctx, text, false);
182
+ const dir = await launch(spec, ctx, text, false);
183
+ if (dir) watching = { dir, ctx };
139
184
  },
140
185
  });
141
186
  }