@jerryan/pi-subagent-tools 0.1.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/ui-bridge.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * UI bridge — presents an ExtensionUIContext to an in-process subagent
3
+ * session that forwards interactive prompts to the parent session's TUI.
4
+ *
5
+ * Usage:
6
+ * const bridge = createUIBridge(ctx.ui, { label: "delegate" });
7
+ * await childSession.bindExtensions({ uiContext: bridge, mode: "rpc" });
8
+ *
9
+ * Mode is "rpc" (not "tui") because the bridge deliberately mirrors the
10
+ * documented RPC degradation contract: dialogs work, terminal chrome does
11
+ * not. Extensions running in the child should not assume direct TUI access.
12
+ *
13
+ * Method mapping
14
+ * ──────────────
15
+ * Dialogs (serialized through a shared queue, title prefixed with label):
16
+ * select, confirm, input, editor, custom
17
+ * Dialog opts (signal, timeout) pass through unchanged, so the child can
18
+ * abort-dismiss and auto-dismiss exactly as documented.
19
+ *
20
+ * Fire-and-forget (forwarded):
21
+ * notify — message prefixed with [label]
22
+ * setStatus — key namespaced as "<label>:<key>"
23
+ * setWidget — key namespaced; string arrays and component factories
24
+ * both forwarded (factories are valid in-process)
25
+ *
26
+ * Fire-and-forget (dropped — they would hijack the parent's chrome):
27
+ * setTitle, setWorkingMessage, setWorkingVisible, setWorkingIndicator,
28
+ * setHiddenThinkingLabel, onTerminalInput, pasteToEditor, setEditorText,
29
+ * setEditorComponent, setFooter, setHeader, addAutocompleteProvider,
30
+ * setToolsExpanded
31
+ *
32
+ * Reads:
33
+ * theme, getAllThemes, getTheme — read-through to the parent
34
+ * getToolsExpanded — read-through
35
+ * getEditorText, getEditorComponent — return "" / undefined
36
+ *
37
+ * Writes blocked:
38
+ * setTheme — returns { success: false, error }
39
+ */
40
+
41
+ import type {
42
+ ExtensionUIContext,
43
+ ExtensionWidgetOptions,
44
+ Theme,
45
+ } from "@earendil-works/pi-coding-agent";
46
+
47
+ export interface UIBridgeOptions {
48
+ /**
49
+ * Short name identifying the subagent (e.g. "delegate", "review").
50
+ * Used to prefix dialog titles and notifications, and to namespace
51
+ * status/widget keys so concurrent subagents don't clobber each other.
52
+ */
53
+ label: string;
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Dialog serialization
58
+ //
59
+ // The parent TUI can only show one modal at a time. Multiple subagents may
60
+ // request dialogs concurrently (parallel tool calls), so all dialog methods
61
+ // across ALL bridge instances funnel through one process-wide queue.
62
+ // ---------------------------------------------------------------------------
63
+
64
+ let dialogQueue: Promise<unknown> = Promise.resolve();
65
+
66
+ function enqueueDialog<T>(fn: () => Promise<T>): Promise<T> {
67
+ const result = dialogQueue.then(fn, fn);
68
+ // Keep the queue alive even if fn rejects.
69
+ dialogQueue = result.then(
70
+ () => undefined,
71
+ () => undefined,
72
+ );
73
+ return result;
74
+ }
75
+
76
+ /** Test hook: reset the dialog queue. Not part of the public API. */
77
+ export function _resetDialogQueue(): void {
78
+ dialogQueue = Promise.resolve();
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Bridge factory
83
+ // ---------------------------------------------------------------------------
84
+
85
+ export function createUIBridge(
86
+ parent: ExtensionUIContext,
87
+ options: UIBridgeOptions,
88
+ ): ExtensionUIContext {
89
+ const { label } = options;
90
+ const tag = `[${label}]`;
91
+ const titled = (title: string) => `${tag} ${title}`;
92
+ const keyed = (key: string) => `${label}:${key}`;
93
+
94
+ const noop = () => {};
95
+ const noopUnsubscribe = () => noop;
96
+
97
+ const bridge: ExtensionUIContext = {
98
+ // --- Dialogs (serialized, forwarded) ----------------------------------
99
+
100
+ select(title, options_, opts) {
101
+ return enqueueDialog(() => parent.select(titled(title), options_, opts));
102
+ },
103
+
104
+ confirm(title, message, opts) {
105
+ return enqueueDialog(() => parent.confirm(titled(title), message, opts));
106
+ },
107
+
108
+ input(title, placeholder, opts) {
109
+ return enqueueDialog(() => parent.input(titled(title), placeholder, opts));
110
+ },
111
+
112
+ editor(title, prefill) {
113
+ return enqueueDialog(() => parent.editor(titled(title), prefill));
114
+ },
115
+
116
+ // Contextually typed against the generic interface signature; the
117
+ // explicit <T> is inferred from the ExtensionUIContext annotation.
118
+ custom(factory, customOptions) {
119
+ // In-process the factory executes against the parent's real TUI, so
120
+ // custom components work — unlike RPC mode, which degrades to undefined.
121
+ return enqueueDialog(() => parent.custom(factory, customOptions));
122
+ },
123
+
124
+ // --- Fire-and-forget (forwarded) ---------------------------------------
125
+
126
+ notify(message, type) {
127
+ parent.notify(`${tag} ${message}`, type);
128
+ },
129
+
130
+ setStatus(key, text) {
131
+ parent.setStatus(keyed(key), text);
132
+ },
133
+
134
+ setWidget(
135
+ key: string,
136
+ content: string[] | ((...args: any[]) => any) | undefined,
137
+ widgetOptions?: ExtensionWidgetOptions,
138
+ ) {
139
+ parent.setWidget(keyed(key), content as any, widgetOptions);
140
+ },
141
+
142
+ // --- Fire-and-forget (dropped) ------------------------------------------
143
+ // These manipulate the parent session's chrome (terminal title, streaming
144
+ // indicator, editor, footer/header). A subagent has no business touching
145
+ // them. Mirrors the RPC-mode degradation contract.
146
+
147
+ setTitle: noop,
148
+ setWorkingMessage: noop,
149
+ setWorkingVisible: noop,
150
+ setWorkingIndicator: noop,
151
+ setHiddenThinkingLabel: noop,
152
+ pasteToEditor: noop,
153
+ setEditorText: noop,
154
+ setEditorComponent: noop,
155
+ setFooter: noop,
156
+ setHeader: noop,
157
+ addAutocompleteProvider: noop,
158
+ setToolsExpanded: noop,
159
+ onTerminalInput: noopUnsubscribe,
160
+
161
+ // --- Reads ---------------------------------------------------------------
162
+
163
+ get theme(): Theme {
164
+ return parent.theme;
165
+ },
166
+
167
+ getAllThemes() {
168
+ return parent.getAllThemes();
169
+ },
170
+
171
+ getTheme(name) {
172
+ return parent.getTheme(name);
173
+ },
174
+
175
+ getToolsExpanded() {
176
+ return parent.getToolsExpanded();
177
+ },
178
+
179
+ getEditorText() {
180
+ return "";
181
+ },
182
+
183
+ getEditorComponent() {
184
+ return undefined;
185
+ },
186
+
187
+ // --- Writes blocked -------------------------------------------------------
188
+
189
+ setTheme() {
190
+ return {
191
+ success: false,
192
+ error: "Subagents cannot change the parent session's theme.",
193
+ };
194
+ },
195
+ };
196
+
197
+ return bridge;
198
+ }
package/spawn.ts DELETED
@@ -1,262 +0,0 @@
1
- /**
2
- * Shared subagent spawn logic.
3
- */
4
-
5
- import { spawn } from "node:child_process";
6
- import * as fs from "node:fs";
7
- import * as path from "node:path";
8
- import type { Message } from "@earendil-works/pi-ai";
9
- import type { AgentToolResult } from "@earendil-works/pi-agent-core";
10
- import type { UsageStats } from "./tui.ts";
11
-
12
- // ---------------------------------------------------------------------------
13
- // Types
14
- // ---------------------------------------------------------------------------
15
-
16
- export interface SpawnOptions {
17
- cwd: string;
18
- task: string;
19
- skills?: string[];
20
- allowedTools?: string[];
21
- extensionFile?: string;
22
- systemPromptFile?: string;
23
- childRole?: "review" | "explore" | "delegate";
24
- signal?: AbortSignal;
25
- onUpdate?: (result: AgentToolResult) => void;
26
- }
27
-
28
- export interface SpawnResult {
29
- output: string;
30
- isError: boolean;
31
- exitCode: number;
32
- tokens?: { input: number; output: number; total: number };
33
- turns: number;
34
- toolCalls: string[];
35
- durationMs: number;
36
- stderr?: string;
37
- }
38
-
39
- // ---------------------------------------------------------------------------
40
- // Constants
41
- // ---------------------------------------------------------------------------
42
-
43
- export const READONLY_TOOLS = ["read", "grep", "find", "ls", "git"];
44
-
45
- // ---------------------------------------------------------------------------
46
- // Pi process detection
47
- // ---------------------------------------------------------------------------
48
-
49
- export function getPiInvocation(args: string[]): {
50
- command: string;
51
- args: string[];
52
- } {
53
- const currentScript = process.argv[1];
54
- if (currentScript && fs.existsSync(currentScript)) {
55
- return { command: process.execPath, args: [currentScript, ...args] };
56
- }
57
- const npmGlobalRoot = process.env.APPDATA
58
- ? path.join(process.env.APPDATA, "npm", "node_modules")
59
- : process.env.HOME
60
- ? path.join(process.env.HOME, ".npm-global", "node_modules")
61
- : null;
62
- if (npmGlobalRoot) {
63
- const cliPath = path.join(npmGlobalRoot, "@earendil-works", "pi-coding-agent", "dist", "cli.js");
64
- if (fs.existsSync(cliPath)) {
65
- return { command: process.execPath, args: [cliPath, ...args] };
66
- }
67
- }
68
- const execName = path.basename(process.execPath).toLowerCase();
69
- const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
70
- if (!isGenericRuntime) return { command: process.execPath, args };
71
- return { command: "pi", args };
72
- }
73
-
74
- // ---------------------------------------------------------------------------
75
- // Argument construction
76
- // ---------------------------------------------------------------------------
77
-
78
- export function buildSpawnArgs(opts: SpawnOptions): string[] {
79
- const args: string[] = ["--mode", "json", "-p"];
80
-
81
- args.push("--no-session");
82
-
83
- if (opts.allowedTools && opts.allowedTools.length > 0) {
84
- args.push("--tools", opts.allowedTools.join(","));
85
- }
86
-
87
- for (const skill of opts.skills ?? []) {
88
- args.push("--skill", skill);
89
- }
90
-
91
- if (opts.extensionFile) {
92
- args.push("--extension", opts.extensionFile);
93
- }
94
-
95
- if (opts.systemPromptFile) {
96
- args.push("--append-system-prompt", opts.systemPromptFile);
97
- }
98
-
99
- args.push(opts.task);
100
- return args;
101
- }
102
-
103
- // ---------------------------------------------------------------------------
104
- // JSONL parsing
105
- // ---------------------------------------------------------------------------
106
-
107
- function getFinalOutput(messages: Message[]): string {
108
- for (let i = messages.length - 1; i >= 0; i--) {
109
- const msg = messages[i];
110
- if (msg.role === "assistant") {
111
- for (const part of msg.content) {
112
- if (part.type === "text") return part.text ?? "";
113
- }
114
- }
115
- }
116
- return "";
117
- }
118
-
119
- // ---------------------------------------------------------------------------
120
- // Main spawn function
121
- // ---------------------------------------------------------------------------
122
-
123
- export async function runSubagent(opts: SpawnOptions): Promise<SpawnResult> {
124
- const args = buildSpawnArgs(opts);
125
- const invocation = getPiInvocation(args);
126
-
127
- let buffer = "";
128
- let stderr = "";
129
- let spawnError: string | null = null;
130
- const messages: Message[] = [];
131
- let turnCount = 0;
132
- let tokens = { input: 0, output: 0, total: 0 };
133
- const toolCallNames: string[] = [];
134
- const env: Record<string, string | undefined> = { ...process.env };
135
- if (opts.childRole) env.PI_SUBAGENT_TOOLS_ROLE = opts.childRole;
136
-
137
- const startTime = Date.now();
138
-
139
- let previousTurnsText = "";
140
- let streamText = "";
141
-
142
- const displayText = () => previousTurnsText + (previousTurnsText && streamText ? "\n" : "") + streamText;
143
- const proc = spawn(invocation.command, invocation.args, {
144
- cwd: opts.cwd,
145
- shell: false,
146
- stdio: ["ignore", "pipe", "pipe"],
147
- env,
148
- });
149
-
150
- const MAX_MSG_COUNT = 5;
151
- const MAX_PREV_LINES = 20;
152
-
153
- const trimPrev = () => {
154
- const lines = previousTurnsText.split("\n");
155
- if (lines.length > MAX_PREV_LINES) previousTurnsText = lines.slice(-MAX_PREV_LINES).join("\n");
156
- };
157
-
158
- let lastUpdate = 0;
159
- let pendingTimer: ReturnType<typeof setTimeout> | null = null;
160
- const UPDATE_INTERVAL = 200;
161
-
162
- const doUpdate = () => {
163
- if (!opts.onUpdate) return;
164
- const bottom = displayText();
165
- opts.onUpdate({
166
- content: [{ type: "text", text: bottom }],
167
- details: { usage: { turns: turnCount, input: tokens.input, output: tokens.output, total: tokens.total, durationMs: Date.now() - startTime } satisfies UsageStats, toolCalls: [...toolCallNames] },
168
- });
169
- };
170
-
171
- const fireUpdate = () => {
172
- if (!opts.onUpdate) return;
173
- const now = Date.now();
174
- if (now - lastUpdate >= UPDATE_INTERVAL) {
175
- lastUpdate = now;
176
- doUpdate();
177
- } else if (!pendingTimer) {
178
- pendingTimer = setTimeout(() => {
179
- pendingTimer = null;
180
- lastUpdate = Date.now();
181
- doUpdate();
182
- }, UPDATE_INTERVAL - (now - lastUpdate));
183
- }
184
- };
185
-
186
- const processLine = (line: string) => {
187
- if (!line.trim()) return;
188
- let event: any;
189
- try { event = JSON.parse(line); } catch { return; }
190
-
191
- // Per-token streaming: show text on every message_update
192
- if (event.type === "message_update" && event.message) {
193
- const text = (event.message.content ?? [])
194
- .filter((c: any) => c.type === "text" || c.type === "thinking")
195
- .map((c: any) => c[c.type]).join("");
196
- streamText = text;
197
- fireUpdate();
198
- }
199
-
200
- if (event.type === "message_end" && event.message) {
201
- const msg = event.message as Message & { content: any[] };
202
- messages.push(msg);
203
- if (messages.length > MAX_MSG_COUNT) messages.splice(0, messages.length - MAX_MSG_COUNT);
204
- if (msg.role === "assistant") {
205
- turnCount++;
206
- if (msg.usage) {
207
- tokens.input += msg.usage.input || 0;
208
- tokens.output += msg.usage.output || 0;
209
- tokens.total += msg.usage.totalTokens || 0;
210
- }
211
- if (streamText) {
212
- previousTurnsText = previousTurnsText ? previousTurnsText + "\n" + streamText : streamText;
213
- streamText = "";
214
- trimPrev();
215
- } else {
216
- const text = (msg.content ?? []).filter((c: any) => c.type === "text" || c.type === "thinking").map((c: any) => c[c.type]).join("");
217
- if (text) {
218
- previousTurnsText = previousTurnsText ? previousTurnsText + "\n" + text : text;
219
- trimPrev();
220
- }
221
- }
222
- for (const tc of (msg.content ?? []).filter((c: any) => c.type === "toolCall")) {
223
- toolCallNames.push(tc.name);
224
- const parts = [`▸ ${tc.name}`];
225
- if (tc.arguments && typeof tc.arguments === "object" && !Array.isArray(tc.arguments)) {
226
- const entries = Object.entries(tc.arguments);
227
- const capPerParam = entries.length > 1;
228
- for (const [k, v] of entries) {
229
- const val = typeof v === "string" ? v : JSON.stringify(v);
230
- const short = capPerParam && val.length > 40 ? val.slice(0, 37) + "..." : val;
231
- parts.push(`${k}=${short}`);
232
- }
233
- }
234
- let line = parts.join(" ");
235
- if (line.length > 80) line = line.slice(0, 77) + "...";
236
- previousTurnsText = previousTurnsText ? `${previousTurnsText}\n${line}` : line;
237
- trimPrev();
238
- }
239
- fireUpdate();
240
- }
241
- }
242
- };
243
-
244
- const exitCode = await new Promise<number>((resolve) => {
245
- const killProc = () => { proc.kill("SIGTERM"); setTimeout(() => { if (!proc.killed) try { proc.kill("SIGKILL"); } catch { /* not supported on all platforms */ } }, 5000); };
246
- if (opts.signal) {
247
- if (opts.signal.aborted) killProc();
248
- else opts.signal.addEventListener("abort", killProc, { once: true });
249
- }
250
- proc.stdout.on("data", (data) => { buffer += data.toString(); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) processLine(line); });
251
- proc.stderr.on("data", (data) => { stderr += data.toString(); });
252
- proc.on("close", (code) => { if (opts.signal) opts.signal.removeEventListener("abort", killProc); if (buffer.trim()) processLine(buffer); resolve(code ?? 1); });
253
- proc.on("error", (err) => { if (opts.signal) opts.signal.removeEventListener("abort", killProc); spawnError = err.message; resolve(1); });
254
- });
255
-
256
- if (opts.signal?.aborted) return { output: "Subagent was aborted", isError: true, exitCode: 1, tokens, turns: turnCount, toolCalls: toolCallNames, durationMs: Date.now() - startTime };
257
- if (spawnError) return { output: `Failed to start subagent: ${spawnError}`, isError: true, exitCode: 1, tokens, turns: turnCount, toolCalls: toolCallNames, durationMs: Date.now() - startTime };
258
- if (exitCode !== 0) return { output: stderr || `Subagent exited with code ${exitCode}`, isError: true, exitCode, tokens, turns: turnCount, toolCalls: toolCallNames, durationMs: Date.now() - startTime };
259
-
260
- const output = getFinalOutput(messages);
261
- return { output: output || "(no output)", isError: false, exitCode: 0, tokens, turns: turnCount, toolCalls: toolCallNames, durationMs: Date.now() - startTime, stderr: stderr.trim() || undefined };
262
- }