@vzlentin/harness 0.0.0-stage → 0.2.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 (47) hide show
  1. package/README.md +66 -2
  2. package/dist/answer.d.ts +29 -0
  3. package/dist/answer.js +176 -0
  4. package/dist/chat.d.ts +48 -0
  5. package/dist/chat.js +208 -0
  6. package/dist/children.d.ts +215 -0
  7. package/dist/children.js +419 -0
  8. package/dist/chrome.d.ts +18 -0
  9. package/dist/chrome.js +74 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +251 -0
  12. package/dist/client.d.ts +49 -0
  13. package/dist/client.js +176 -0
  14. package/dist/context.d.ts +13 -0
  15. package/dist/context.js +115 -0
  16. package/dist/durable-entry.d.ts +13 -0
  17. package/dist/durable-entry.js +33 -0
  18. package/dist/entries.d.ts +9 -0
  19. package/dist/entries.js +50 -0
  20. package/dist/herdr.d.ts +38 -0
  21. package/dist/herdr.js +111 -0
  22. package/dist/host.d.ts +18 -0
  23. package/dist/host.js +827 -0
  24. package/dist/picker.d.ts +22 -0
  25. package/dist/picker.js +70 -0
  26. package/dist/projects.d.ts +23 -0
  27. package/dist/projects.js +96 -0
  28. package/dist/protocol.d.ts +323 -0
  29. package/dist/protocol.js +126 -0
  30. package/dist/setup.d.ts +13 -0
  31. package/dist/setup.js +133 -0
  32. package/dist/tasks.d.ts +31 -0
  33. package/dist/tasks.js +74 -0
  34. package/dist/theme.d.ts +3 -0
  35. package/dist/theme.js +19 -0
  36. package/dist/tool.d.ts +137 -0
  37. package/dist/tool.js +213 -0
  38. package/dist/trust.d.ts +5 -0
  39. package/dist/trust.js +15 -0
  40. package/dist/view-state.d.ts +19 -0
  41. package/dist/view-state.js +44 -0
  42. package/dist/viewer.d.ts +20 -0
  43. package/dist/viewer.js +307 -0
  44. package/package.json +57 -5
  45. package/pi/index.ts +168 -0
  46. package/pi/package.json +14 -0
  47. package/scripts/postinstall.js +13 -0
@@ -0,0 +1,44 @@
1
+ import { InboxDoc, LiveDoc, } from "@earendil-works/pi-durable";
2
+ /** The built-in document of `token` in `view`, if the conversation has it. */
3
+ export function docOf(view, token) {
4
+ // SAFETY: pi-durable keeps each built-in conversation document in `docs` under the kind of its token.
5
+ return view.docs[token.definition.kind];
6
+ }
7
+ export function liveOf(view) {
8
+ return docOf(view, LiveDoc) ?? {};
9
+ }
10
+ export function inboxOf(view) {
11
+ return docOf(view, InboxDoc) ?? { items: [] };
12
+ }
13
+ export function userText(content) {
14
+ if (!Array.isArray(content))
15
+ return content;
16
+ return content
17
+ .filter((block) => block.type === "text")
18
+ .map((block) => block.text)
19
+ .join("");
20
+ }
21
+ /** The queued steers and follow-ups, in inbox order. */
22
+ export function queued(inbox) {
23
+ return inbox.items.flatMap((item) => item.mode === "write" ? [] : [{ mode: item.mode, text: userText(item.content) }]);
24
+ }
25
+ /** What the conversation does now, as Pi's working indicator says it; empty when it does nothing. */
26
+ export function statusText(live) {
27
+ const generation = live.generation;
28
+ const compaction = live.compactions?.[0];
29
+ const runningTool = live.tools?.find((slot) => slot.status === "running");
30
+ if (generation?.retry !== undefined)
31
+ return `Retrying (attempt ${generation.attempt + 1}): ${generation.retry.error}`;
32
+ if (generation?.deferred !== undefined)
33
+ return "Waiting for deferred response...";
34
+ if (compaction !== undefined) {
35
+ return compaction.retry
36
+ ? `Retrying ${compaction.reason} compaction (attempt ${compaction.attempt + 1})...`
37
+ : `Compacting (${compaction.reason})...`;
38
+ }
39
+ if (runningTool !== undefined)
40
+ return `Running ${runningTool.name}... (esc to interrupt)`;
41
+ if (live.run !== undefined)
42
+ return "Working... (esc to interrupt)";
43
+ return "";
44
+ }
@@ -0,0 +1,20 @@
1
+ import { type Terminal } from "@earendil-works/pi-tui";
2
+ declare module "@earendil-works/pi-tui" {
3
+ interface Keybindings {
4
+ "app.scroll.lineUp": true;
5
+ "app.scroll.lineDown": true;
6
+ "app.scroll.pageUp": true;
7
+ "app.scroll.pageDown": true;
8
+ }
9
+ }
10
+ export type AttachOptions = {
11
+ readonly terminal: Terminal;
12
+ readonly env: NodeJS.ProcessEnv;
13
+ };
14
+ /**
15
+ * Shows the conversation of the child `handle`, or of the project agent for `agent`, in `terminal` and sends
16
+ * what the user types to it. It starts the host when none runs, and rejects before the terminal starts when the handle
17
+ * is unknown. It resolves with the exit code: 0 after Ctrl+C or when the child is stopped, 1 when the host closes the
18
+ * connection. The child keeps running.
19
+ */
20
+ export declare function attach(handle: string, options: AttachOptions): Promise<number>;
package/dist/viewer.js ADDED
@@ -0,0 +1,307 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { apply } from "@earendil-works/chord/delta";
3
+ import { getSelectListTheme, initTheme } from "@earendil-works/pi-coding-agent";
4
+ import { Container, Editor, KeybindingsManager, Loader, ScrollView, setKeybindings, Text, TUI_KEYBINDINGS, TuiAltScreen, VStack, } from "@earendil-works/pi-tui";
5
+ import { ChatView } from "./chat.js";
6
+ import { footerText, headerText, Line } from "./chrome.js";
7
+ import { connect } from "./client.js";
8
+ import { agentReporter, viewerTarget } from "./herdr.js";
9
+ import { ModelPicker } from "./picker.js";
10
+ import { childLabel, runDisplayName, } from "./protocol.js";
11
+ import { color } from "./theme.js";
12
+ import { inboxOf, liveOf, queued, statusText } from "./view-state.js";
13
+ /** pi-tui's keys and the viewer's keys, with Pi's ids and defaults where Pi has the same action. */
14
+ const KEYBINDINGS = {
15
+ ...TUI_KEYBINDINGS,
16
+ "app.thinking.cycle": { defaultKeys: "shift+tab", description: "Cycle thinking level" },
17
+ "app.model.select": { defaultKeys: "ctrl+l", description: "Select model" },
18
+ "app.tools.expand": { defaultKeys: "ctrl+o", description: "Toggle tool output" },
19
+ "app.interrupt": { defaultKeys: "escape", description: "Abort the turn" },
20
+ "app.message.followUp": { defaultKeys: "alt+enter", description: "Queue a follow-up message" },
21
+ "app.exit": { defaultKeys: "ctrl+c", description: "Detach" },
22
+ "app.scroll.lineUp": { defaultKeys: "shift+up", description: "Scroll up one line" },
23
+ "app.scroll.lineDown": { defaultKeys: "shift+down", description: "Scroll down one line" },
24
+ "app.scroll.pageUp": { defaultKeys: "shift+pageUp", description: "Scroll up one page" },
25
+ "app.scroll.pageDown": { defaultKeys: "shift+pageDown", description: "Scroll down one page" },
26
+ };
27
+ /**
28
+ * Shows the conversation of the child `handle`, or of the project agent for `agent`, in `terminal` and sends
29
+ * what the user types to it. It starts the host when none runs, and rejects before the terminal starts when the handle
30
+ * is unknown. It resolves with the exit code: 0 after Ctrl+C or when the child is stopped, 1 when the host closes the
31
+ * connection. The child keeps running.
32
+ */
33
+ export async function attach(handle, options) {
34
+ const client = await connect({ start: true });
35
+ if (client === undefined)
36
+ throw new Error("Could not connect to the harness host");
37
+ let watched;
38
+ let models;
39
+ try {
40
+ [watched, models] = await Promise.all([client.watch(handle), client.request({ type: "models" })]);
41
+ }
42
+ catch (error) {
43
+ client.close();
44
+ throw error;
45
+ }
46
+ return new Viewer(handle, options, client, watched, models).start();
47
+ }
48
+ /** The full-screen view of one attached child: header, transcript, status, editor and footer. */
49
+ class Viewer {
50
+ tui;
51
+ chat;
52
+ scroll;
53
+ header = new Line();
54
+ working = new Container();
55
+ queued = new Text("", 1, 0);
56
+ notice = new Text("", 1, 0);
57
+ editor;
58
+ footer = new Line();
59
+ keybindings;
60
+ loader;
61
+ loaderText = "";
62
+ reporter;
63
+ exited;
64
+ #exit;
65
+ handle;
66
+ /** The title of the child and the command that opens this viewer again. */
67
+ target;
68
+ terminal;
69
+ client;
70
+ view;
71
+ lines;
72
+ child;
73
+ models;
74
+ picker;
75
+ done = false;
76
+ /** The viewer's keys. A row with a hint shows in the footer, with its first key. */
77
+ keys = [
78
+ { id: "app.thinking.cycle", action: () => this.cycleThinking(), hint: "thinking" },
79
+ { id: "app.model.select", action: () => this.pickModel(), hint: "model" },
80
+ { id: "app.tools.expand", action: () => this.toggleExpanded(), hint: "expand" },
81
+ {
82
+ id: "app.exit",
83
+ action: () => void this.finish(0, `${this.target.title(this.child)} keeps running. Reattach: harness ${this.target.resume.join(" ")}`),
84
+ hint: "exit",
85
+ },
86
+ {
87
+ id: "app.interrupt",
88
+ // An idle child has no turn to abort.
89
+ action: () => {
90
+ if (this.child.state === "busy")
91
+ this.abort();
92
+ },
93
+ },
94
+ { id: "app.message.followUp", action: () => this.followUp() },
95
+ { id: "app.scroll.lineUp", action: () => this.scrollBy(-1) },
96
+ { id: "app.scroll.lineDown", action: () => this.scrollBy(1) },
97
+ { id: "app.scroll.pageUp", action: () => this.scrollBy(-this.pageSize()) },
98
+ { id: "app.scroll.pageDown", action: () => this.scrollBy(this.pageSize()) },
99
+ ];
100
+ constructor(handle, options, client, watched, models) {
101
+ this.handle = handle;
102
+ this.models = models;
103
+ this.terminal = options.terminal;
104
+ this.client = client;
105
+ this.target = viewerTarget(handle, client.project);
106
+ this.view = watched.view;
107
+ this.child = watched.child;
108
+ this.lines = watched.lines;
109
+ initTheme();
110
+ this.keybindings = new KeybindingsManager(KEYBINDINGS);
111
+ setKeybindings(this.keybindings);
112
+ this.tui = new TuiAltScreen(this.terminal, false);
113
+ this.chat = new ChatView(this.tui, this.child.cwd);
114
+ this.scroll = new ScrollView(this.chat.transcript, { follow: "end", primary: true });
115
+ this.editor = new Editor(this.tui, { borderColor: (text) => color("borderMuted", text), selectList: getSelectListTheme() }, { paddingX: 1 });
116
+ const status = new Container();
117
+ status.addChild(this.working);
118
+ status.addChild(this.queued);
119
+ status.addChild(this.notice);
120
+ this.tui.setLayoutRoot(new VStack([
121
+ { component: this.header, basis: "auto" },
122
+ { component: this.scroll, basis: 0, grow: 1, minSize: 3 },
123
+ { component: status, basis: "auto" },
124
+ { component: this.editor, basis: "auto", shrink: 1, minSize: 1 },
125
+ { component: this.footer, basis: "auto" },
126
+ ]));
127
+ this.footer.style = (text) => color("dim", text);
128
+ this.footer.hints = this.keys.flatMap(({ id, hint }) => hint === undefined ? [] : [`${this.keybindings.getKeys(id)[0]} ${hint}`]);
129
+ this.tui.setFocus(this.editor);
130
+ this.reporter = agentReporter(options.env, childLabel(this.child), handle, client.project);
131
+ const { promise, resolve } = Promise.withResolvers();
132
+ this.exited = promise;
133
+ this.#exit = resolve;
134
+ this.editor.onSubmit = (text) => this.send(text, false);
135
+ this.tui.addInputListener((data) => this.handleKey(data));
136
+ }
137
+ /** Starts the terminal and the watch, and resolves with the exit code. */
138
+ start() {
139
+ this.tui.start();
140
+ this.render();
141
+ this.reporter.report(this.child.state === "busy");
142
+ void this.follow(this.lines);
143
+ return this.exited;
144
+ }
145
+ render() {
146
+ this.chat.apply(this.view);
147
+ this.header.text = headerText(this.child, this.target.title(this.child));
148
+ this.syncLoader(statusText(liveOf(this.view)));
149
+ this.queued.setText(queued(inboxOf(this.view))
150
+ .map(({ mode, text }) => color("dim", `${mode === "steer" ? "Steer" : "Follow-up"}: ${text.split("\n")[0] ?? ""}`))
151
+ .join("\n"));
152
+ this.footer.text = footerText(this.child, this.view, this.modelInfo()?.contextWindow);
153
+ this.tui.requestRender();
154
+ }
155
+ /** Shows Pi's working indicator with `text`, or removes it when `text` is empty. */
156
+ syncLoader(text) {
157
+ if (text === this.loaderText)
158
+ return;
159
+ this.loaderText = text;
160
+ this.loader?.stop();
161
+ this.loader = undefined;
162
+ this.working.clear();
163
+ if (text === "")
164
+ return;
165
+ this.loader = new Loader(this.tui, (value) => color("accent", value), (value) => color("muted", value), text);
166
+ this.working.addChild(this.loader);
167
+ }
168
+ handleKey(data) {
169
+ // The model picker gets every key while it shows.
170
+ if (this.picker !== undefined)
171
+ return undefined;
172
+ const key = this.keys.find(({ id }) => this.keybindings.matches(data, id));
173
+ if (key === undefined)
174
+ return undefined;
175
+ key.action();
176
+ return { consume: true };
177
+ }
178
+ toggleExpanded() {
179
+ this.chat.toggleExpanded();
180
+ this.tui.requestRender();
181
+ }
182
+ /** Alt+Enter: queues the editor's text as a follow-up. */
183
+ followUp() {
184
+ const text = this.editor.getText();
185
+ this.editor.setText("");
186
+ this.send(text, true);
187
+ }
188
+ pageSize() {
189
+ return Math.max(1, this.scroll.viewportHeight - 1);
190
+ }
191
+ scrollBy(lines) {
192
+ this.scroll.scrollBy(lines);
193
+ this.tui.requestRender();
194
+ }
195
+ modelInfo() {
196
+ return this.models.find((model) => model.provider === this.child.provider && model.id === this.child.model);
197
+ }
198
+ /** Shift+Tab: the next thinking level of the child's model, after the last one `off` again. */
199
+ cycleThinking() {
200
+ const levels = this.modelInfo()?.thinkingLevels ?? [];
201
+ const thinking = levels[(levels.indexOf(this.child.thinking) + 1) % levels.length];
202
+ if (levels.length < 2 || thinking === undefined) {
203
+ this.showNotice(`${this.child.provider}/${this.child.model} has no thinking levels`);
204
+ return;
205
+ }
206
+ this.configure({ thinking });
207
+ }
208
+ /** Ctrl+L: a list of the models with a login, as the host has them now. Typing filters it, Enter picks, Esc closes. */
209
+ pickModel() {
210
+ this.client.request({ type: "models" }).then((models) => {
211
+ this.models = models;
212
+ if (this.picker !== undefined)
213
+ return;
214
+ if (models.length === 0) {
215
+ this.showNotice("The host has no models with a login");
216
+ return;
217
+ }
218
+ const picker = new ModelPicker(models, this.modelInfo(), (text) => color("borderMuted", text), (model) => {
219
+ this.closePicker();
220
+ this.configure({ provider: model.provider, model: model.id });
221
+ }, () => this.closePicker());
222
+ this.picker = this.tui.showOverlay(picker, { width: "70%", minWidth: 40, maxHeight: "60%" });
223
+ this.tui.setFocus(picker);
224
+ this.tui.requestRender();
225
+ }, this.showError);
226
+ }
227
+ closePicker() {
228
+ this.picker?.hide();
229
+ this.picker = undefined;
230
+ this.tui.setFocus(this.editor);
231
+ this.tui.requestRender();
232
+ }
233
+ configure(change) {
234
+ this.client.request({ type: "configure", handle: this.handle, ...change }).then((child) => {
235
+ this.child = child;
236
+ this.notice.setText("");
237
+ this.render();
238
+ }, this.showError);
239
+ }
240
+ showNotice(text) {
241
+ this.notice.setText(color("muted", text));
242
+ this.tui.requestRender();
243
+ }
244
+ /** Aborts the turn. The host withdraws the queued messages, so they go back to the editor, steers first. */
245
+ abort() {
246
+ const inputs = queued(inboxOf(this.view));
247
+ const texts = [
248
+ ...inputs.filter((input) => input.mode === "steer"),
249
+ ...inputs.filter((input) => input.mode === "followUp"),
250
+ ].map((input) => input.text);
251
+ this.client.request({ type: "abort", handle: this.handle }).then(() => {
252
+ this.notice.setText("");
253
+ const text = [...texts, this.editor.getText()].filter((part) => part.trim() !== "").join("\n\n");
254
+ if (text !== this.editor.getText())
255
+ this.editor.setText(text);
256
+ this.tui.requestRender();
257
+ }, this.showError);
258
+ }
259
+ send(message, followUp) {
260
+ if (message.trim() === "")
261
+ return;
262
+ this.editor.addToHistory(message);
263
+ const request = {
264
+ type: "input",
265
+ requestId: randomUUID(),
266
+ handle: this.handle,
267
+ message,
268
+ followUp,
269
+ };
270
+ this.client.request(request).then(() => this.notice.setText(""), this.showError);
271
+ }
272
+ /** Shows each event of the watch, and finishes when the connection closes. */
273
+ async follow(lines) {
274
+ for await (const line of lines)
275
+ this.onLine(line);
276
+ await this.finish(1, "The harness host closed the connection");
277
+ }
278
+ onLine(line) {
279
+ if (this.done)
280
+ return;
281
+ if (line.stopped) {
282
+ void this.finish(0, `${runDisplayName(this.child)} was stopped`);
283
+ return;
284
+ }
285
+ this.view = apply(this.view, line.ops);
286
+ this.child = line.child;
287
+ this.render();
288
+ this.reporter.report(this.child.state === "busy");
289
+ }
290
+ async finish(code, message) {
291
+ if (this.done)
292
+ return;
293
+ this.done = true;
294
+ this.loader?.stop();
295
+ this.chat.stopTimers();
296
+ this.tui.stop();
297
+ if (message !== undefined)
298
+ this.terminal.write(`${message}\n`);
299
+ this.client.close();
300
+ await this.reporter.release();
301
+ this.#exit(code);
302
+ }
303
+ showError = (cause) => {
304
+ this.notice.setText(color("error", `Error: ${cause instanceof Error ? cause.message : String(cause)}`));
305
+ this.tui.requestRender();
306
+ };
307
+ }
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
- "name": "@vzlentin/harness",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@vzlentin/harness",
3
+ "version": "0.2.0",
4
+ "description": "A durable agent harness on Pi, starting with observable, persistent subagents",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Vzlentin/harness.git"
8
+ },
9
+ "type": "module",
10
+ "bin": {
11
+ "harness": "dist/cli.js"
12
+ },
13
+ "exports": {
14
+ "./*": {
15
+ "types": "./dist/*.d.ts",
16
+ "default": "./dist/*.js"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "pi",
23
+ "scripts/postinstall.js"
24
+ ],
25
+ "engines": {
26
+ "node": ">=22.19.0"
27
+ },
28
+ "scripts": {
29
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
30
+ "prepack": "npm run build",
31
+ "postinstall": "node scripts/postinstall.js",
32
+ "check": "biome check . && oxlint --deny-warnings && npm run build && tsc -p tsconfig.json && vitest --run --passWithNoTests",
33
+ "test": "vitest --run --passWithNoTests"
34
+ },
35
+ "dependencies": {
36
+ "@earendil-works/chord": "1.3.0",
37
+ "@earendil-works/pi-ai": "1.3.0",
38
+ "@earendil-works/pi-coding-agent": "1.3.0",
39
+ "@earendil-works/pi-durable": "1.3.0",
40
+ "@earendil-works/pi-tui": "1.3.0",
41
+ "jiti": "2.7.0",
42
+ "typebox": "1.3.27"
43
+ },
44
+ "devDependencies": {
45
+ "@biomejs/biome": "2.5.15",
46
+ "@oxlint/plugins": "1.88.0",
47
+ "@types/node": "26.6.4",
48
+ "oxlint": "1.88.0",
49
+ "typescript": "7.0.2",
50
+ "vitest": "5.0.3"
51
+ },
52
+ "retiredPiPackages": [
53
+ "npm:@vzlentin/pi-subagent"
54
+ ],
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }
package/pi/index.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The `subagent` tool, `/subagent` and the subagents widget. They use the client of the harness package that holds
3
+ * this folder, from its built `dist/`, and start the host of that package.
4
+ */
5
+ import { resolve } from "node:path";
6
+ import { Type } from "@earendil-works/pi-ai";
7
+ import {
8
+ type ExtensionAPI,
9
+ type ExtensionContext,
10
+ getAgentDir,
11
+ hasTrustRequiringProjectResources,
12
+ ProjectTrustStore,
13
+ SettingsManager,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import { request } from "../dist/client.js";
16
+ import { openViewerTab } from "../dist/herdr.js";
17
+ import { childLine, runDisplayName } from "../dist/protocol.js";
18
+ import * as tool from "../dist/tool.js";
19
+
20
+ const SESSION_TRUST_HINT =
21
+ "The child gets no project skills (.pi/skills, .agents/skills): this project is trusted for this Pi session only, and children see only the trust saved for future sessions. To give children project skills, run /trust and choose Trust.";
22
+
23
+ export default function subagentExtension(pi: ExtensionAPI) {
24
+ const { parameters, output } = tool.toolSchemas(Type);
25
+ pi.registerTool({
26
+ name: "subagent",
27
+ label: "Subagent",
28
+ description: tool.TOOL_DESCRIPTION,
29
+ parameters,
30
+ outputSchema: output,
31
+ executionMode: "sequential",
32
+ async execute(toolCallId, args, signal, _onUpdate, ctx) {
33
+ const { text, data } = await tool.runTool(args, {
34
+ caller: ctx.sessionManager.getSessionId(),
35
+ callKey: toolCallId,
36
+ provider: ctx.model?.provider,
37
+ model: ctx.model?.id,
38
+ thinking: pi.getThinkingLevel(),
39
+ cwd: ctx.cwd,
40
+ send: tool.sendToHost,
41
+ signal,
42
+ });
43
+
44
+ // Children follow only the saved trust, so a session that trusts its project for itself says what the child misses.
45
+ const sessionOnly =
46
+ args.action === "spawn" &&
47
+ ctx.isProjectTrusted() &&
48
+ !savedTrust(ctx.cwd) &&
49
+ !savedTrust(resolve(ctx.cwd, args.cwd ?? "."));
50
+
51
+ const hint = sessionOnly ? `\n${SESSION_TRUST_HINT}\n` : "";
52
+
53
+ return { content: [{ type: "text", text: text + hint }], details: undefined, structuredContent: data };
54
+ },
55
+ });
56
+
57
+ pi.registerCommand("subagent", {
58
+ description: "Open the viewer of a subagent spawned by this session",
59
+ handler: async (_args, ctx) => {
60
+ const children = await request({ type: "list", caller: ctx.sessionManager.getSessionId() }, { start: true });
61
+
62
+ if (children.length === 0) {
63
+ ctx.ui.notify("No subagents spawned by this session", "info");
64
+
65
+ return;
66
+ }
67
+
68
+ const chosen = await ctx.ui.select("Attach to subagent", children.map(childLine));
69
+
70
+ // A name can start with the display name of another child, so the longest match is the chosen child.
71
+ const child = children
72
+ .filter((c: { handle: string; name?: string }) => chosen?.startsWith(`${runDisplayName(c)} `))
73
+ .sort(
74
+ (a: { handle: string; name?: string }, b: { handle: string; name?: string }) =>
75
+ runDisplayName(b).length - runDisplayName(a).length,
76
+ )[0];
77
+
78
+ if (child === undefined) return;
79
+ const command = `harness attach ${child.handle}`;
80
+
81
+ if (!process.env.HERDR_ENV) {
82
+ ctx.ui.notify(`Run in another terminal: ${command}`, "info");
83
+
84
+ return;
85
+ }
86
+
87
+ try {
88
+ await openViewerTab(child, process.env);
89
+ } catch (error) {
90
+ const message = error instanceof Error ? error.message : String(error);
91
+ ctx.ui.notify(`Could not open a Herdr tab: ${message}. Run in another terminal: ${command}`, "error");
92
+ }
93
+ },
94
+ });
95
+
96
+ let widgetTimer: ReturnType<typeof setInterval> | undefined;
97
+ let widgetContext: ExtensionContext | undefined;
98
+ let refreshing = false;
99
+
100
+ // The widget does not start the host: when none runs, it shows nothing.
101
+ const refreshWidget = async (): Promise<void> => {
102
+ if (!widgetContext || refreshing) return;
103
+ refreshing = true;
104
+
105
+ try {
106
+ const context = widgetContext;
107
+
108
+ const children = await request(
109
+ { type: "list", caller: context.sessionManager.getSessionId() },
110
+ { start: false },
111
+ ).catch(() => undefined);
112
+
113
+ if (context !== widgetContext) return;
114
+
115
+ if (children === undefined || children.length === 0) {
116
+ context.ui.setWidget("subagents", undefined);
117
+
118
+ return;
119
+ }
120
+
121
+ const theme = context.ui.theme;
122
+
123
+ const visible = children
124
+ .slice(0, 5)
125
+ .map(({ name, handle, state }: { name?: string; handle: string; state: string }) => {
126
+ const color = state === "busy" ? "warning" : state === "idle" ? "success" : "error";
127
+
128
+ return theme.fg(color, `${name ?? handle}:${state}`);
129
+ });
130
+
131
+ if (children.length > visible.length) visible.push(theme.fg("muted", `+${children.length - visible.length}`));
132
+ context.ui.setWidget("subagents", [theme.fg("dim", "subagents: ") + visible.join(theme.fg("dim", " | "))], {
133
+ placement: "belowEditor",
134
+ });
135
+ } finally {
136
+ refreshing = false;
137
+ }
138
+ };
139
+
140
+ pi.on("session_start", (_event, ctx) => {
141
+ if (!ctx.hasUI) return;
142
+ widgetContext = ctx;
143
+ void refreshWidget();
144
+ widgetTimer = setInterval(() => void refreshWidget(), 1000);
145
+ widgetTimer.unref();
146
+ });
147
+
148
+ pi.on("session_shutdown", (_event, ctx) => {
149
+ if (widgetTimer) clearInterval(widgetTimer);
150
+ widgetTimer = undefined;
151
+ widgetContext = undefined;
152
+ ctx.ui.setWidget("subagents", undefined);
153
+ });
154
+ }
155
+
156
+ /**
157
+ * Whether Pi trusts the project in `cwd` with no one to ask, as the host does in `src/trust.ts` of harness. It is here
158
+ * with Pi's own modules, so Pi does not load the copy of pi-coding-agent that harness installs.
159
+ */
160
+ function savedTrust(cwd: string): boolean {
161
+ if (!hasTrustRequiringProjectResources(cwd)) return true;
162
+ const agentDir = getAgentDir();
163
+ const decision = new ProjectTrustStore(agentDir).get(cwd);
164
+
165
+ if (decision !== null) return decision;
166
+
167
+ return SettingsManager.create(cwd, agentDir, { projectTrusted: false }).getDefaultProjectTrust() === "always";
168
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "private": true,
3
+ "description": "The Pi extension of harness: the subagent tool, the /subagent command and the subagents widget",
4
+ "type": "module",
5
+ "pi": {
6
+ "extensions": [
7
+ "./index.ts"
8
+ ]
9
+ },
10
+ "peerDependencies": {
11
+ "@earendil-works/pi-ai": "*",
12
+ "@earendil-works/pi-coding-agent": "*"
13
+ }
14
+ }
@@ -0,0 +1,13 @@
1
+ // Registers `pi/` in Pi after a global install, with `setup` of `dist/setup.js`. Plain JavaScript, since Node does not
2
+ // strip types under node_modules. Other installs, such as `npm ci` in a checkout without `dist/`, do nothing, and an
3
+ // error must not fail the npm install.
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ if (process.env.npm_config_global === "true") {
7
+ try {
8
+ const { setup } = await import("../dist/setup.js");
9
+ setup(fileURLToPath(new URL("..", import.meta.url)));
10
+ } catch (error) {
11
+ console.warn(`harness: setup failed: ${error instanceof Error ? error.message : String(error)}`);
12
+ }
13
+ }