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.
- package/README.md +3 -4
- package/node_modules/pi-umbra-copy-chat/README.md +4 -7
- package/node_modules/pi-umbra-copy-chat/extensions/umbra-copy-chat.ts +16 -54
- package/node_modules/pi-umbra-copy-chat/package.json +1 -1
- package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +10 -1
- package/node_modules/pi-umbra-help/extensions/umbra-help.ts +31 -0
- package/node_modules/pi-umbra-help/package.json +1 -1
- package/node_modules/pi-umbra-inputbar/README.md +1 -1
- package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +1 -1
- package/node_modules/pi-umbra-inputbar/package.json +2 -2
- package/node_modules/pi-umbra-shimmer/package.json +1 -1
- package/node_modules/pi-umbra-shimmer/patch.mjs +23 -21
- package/node_modules/pi-umbra-skill-matcher/package.json +1 -1
- package/node_modules/pi-umbra-skill-matcher/patch.mjs +7 -0
- package/node_modules/pi-umbra-subagents/README.md +19 -4
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar/bar-line.ts +9 -2
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.check.ts +10 -1
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/index.ts +50 -5
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/spec.ts +24 -14
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.check.ts +119 -4
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.ts +138 -58
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/worktree.ts +156 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.ts +19 -0
- package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/fan/SKILL.md +25 -4
- package/node_modules/pi-umbra-subagents/package.json +2 -2
- package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +7 -1
- package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +2 -1
- package/node_modules/pi-umbra-theme/package.json +3 -3
- package/package.json +9 -12
- package/node_modules/pi-umbra-rename/LICENSE +0 -21
- package/node_modules/pi-umbra-rename/README.md +0 -18
- package/node_modules/pi-umbra-rename/checks/umbra-rename.check.ts +0 -55
- package/node_modules/pi-umbra-rename/extensions/umbra-rename.ts +0 -22
- 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
|
|
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
|

|
|
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
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 {
|
|
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 = {
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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.
|
|
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`
|
|
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 /
|
|
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
|
-
"description": "pi's input bar, rebuilt: a chevron prompt, the session name on the border, a colour you pick
|
|
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.
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 (!
|
|
110
|
+
else if (!result) gone.push(patch.reason);
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
for (const reason of unapplied) console.log(`NOT APPLIED: ${reason}`);
|
|
@@ -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
|
|
4
|
-
|
|
5
|
-
|
|
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
|

|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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", () =>
|
|
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
|
-
|
|
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
|
}
|