@jerryan/pi-subagent-tools 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/LICENSE +21 -21
- package/README.md +49 -17
- package/agents.ts +878 -0
- package/index.ts +9 -418
- package/package.json +22 -12
- package/prompts/delegate.md +6 -0
- package/prompts/explore.md +1 -1
- package/prompts/review.md +1 -1
- package/render.ts +87 -0
- package/sandbox-bash.ts +148 -0
- package/tui.ts +16 -9
- package/ui-bridge.ts +198 -0
- package/spawn.ts +0 -262
package/sandbox-bash.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandboxed read-only bash for review/explore child sessions.
|
|
3
|
+
*
|
|
4
|
+
* One tool replaces the former read/grep/find/ls allowlist plus the git
|
|
5
|
+
* tool's policy table: a just-bash interpreter over a composed filesystem
|
|
6
|
+
* (see createSandboxFs), with just-git providing git inside the sandbox.
|
|
7
|
+
* Read-only is enforced at the capability layer — see DESIGN.md.
|
|
8
|
+
*
|
|
9
|
+
* The `disabled` git list below is UX only (clean "not available" errors
|
|
10
|
+
* for pure mutators); enforcement is the read-only filesystem. Dual-purpose
|
|
11
|
+
* verbs (branch, tag, stash, config, remote, worktree) stay enabled so
|
|
12
|
+
* their read modes work; their write modes fail at the filesystem.
|
|
13
|
+
*
|
|
14
|
+
* Each call runs in a freshly constructed interpreter: per-call
|
|
15
|
+
* construction is the statelessness guarantee, and it avoids any
|
|
16
|
+
* shared-state questions between parallel children.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { Type } from "@sinclair/typebox";
|
|
21
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { Bash, InMemoryFs, MountableFs, OverlayFs, type ExecResult } from "just-bash";
|
|
23
|
+
import { createGit, type GitCommandName } from "just-git";
|
|
24
|
+
|
|
25
|
+
/** Pure-mutator git verbs, disabled for clean UX errors. FS enforces the rest. */
|
|
26
|
+
const DISABLED_GIT: GitCommandName[] = [
|
|
27
|
+
"init",
|
|
28
|
+
"add",
|
|
29
|
+
"commit",
|
|
30
|
+
"checkout",
|
|
31
|
+
"switch",
|
|
32
|
+
"restore",
|
|
33
|
+
"reset",
|
|
34
|
+
"merge",
|
|
35
|
+
"cherry-pick",
|
|
36
|
+
"revert",
|
|
37
|
+
"rebase",
|
|
38
|
+
"mv",
|
|
39
|
+
"rm",
|
|
40
|
+
"clean",
|
|
41
|
+
"bisect",
|
|
42
|
+
"gc",
|
|
43
|
+
"repack",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** Mount point of the project root inside the sandbox. */
|
|
47
|
+
const MOUNT_POINT = "/repo";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compose the sandbox filesystem: the project root mounted read-only at
|
|
51
|
+
* /repo over a writable in-memory base. The base provides working /dev/null
|
|
52
|
+
* (stderr silencing is a core shell idiom; on a real read-only mount the
|
|
53
|
+
* device still works) and per-call in-memory scratch (/tmp, ...) that
|
|
54
|
+
* evaporates with the interpreter. Note MountableFs strips the mount
|
|
55
|
+
* prefix before delegating, so the inner OverlayFs mounts at "/".
|
|
56
|
+
*/
|
|
57
|
+
function createSandboxFs(projectRoot: string): MountableFs {
|
|
58
|
+
const fs = new MountableFs({ base: new InMemoryFs() });
|
|
59
|
+
fs.mount(
|
|
60
|
+
MOUNT_POINT,
|
|
61
|
+
new OverlayFs({ root: projectRoot, mountPoint: "/", readOnly: true }),
|
|
62
|
+
);
|
|
63
|
+
return fs;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const MAX_OUTPUT_CHARS = 50_000;
|
|
67
|
+
const MAX_OUTPUT_LINES = 2_000;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Filesystem-style error codes the sandbox can raise. just-bash reports
|
|
71
|
+
* command-level failures (touch, rm) as exit codes, but interpreter-level
|
|
72
|
+
* failures — output redirections write through the interpreter's own FS
|
|
73
|
+
* path — REJECT the exec promise with these. Both shapes mean the same
|
|
74
|
+
* thing to the caller: the command failed. Anything outside this taxonomy
|
|
75
|
+
* is a genuine interpreter bug and is rethrown, loudly.
|
|
76
|
+
*/
|
|
77
|
+
const FS_ERROR_PATTERN =
|
|
78
|
+
/^(EROFS|EACCES|EPERM|ENOENT|EFBIG|ENOSPC|EISDIR|ENOTDIR|ELOOP|ENOTEMPTY|EEXIST|EINVAL|EBUSY|EXDEV|EIO)\b/;
|
|
79
|
+
|
|
80
|
+
function truncateOutput(output: string): string {
|
|
81
|
+
const lines = output.split("\n");
|
|
82
|
+
if (lines.length <= MAX_OUTPUT_LINES && output.length <= MAX_OUTPUT_CHARS) {
|
|
83
|
+
return output;
|
|
84
|
+
}
|
|
85
|
+
const kept = lines.slice(0, MAX_OUTPUT_LINES).join("\n").slice(0, MAX_OUTPUT_CHARS);
|
|
86
|
+
const keptLines = kept.split("\n").length;
|
|
87
|
+
return `${kept}\n\n[output truncated: showing ${keptLines} lines, ${kept.length} chars]`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const sandboxBashTool = defineTool({
|
|
91
|
+
name: "bash",
|
|
92
|
+
label: "bash (read-only)",
|
|
93
|
+
description:
|
|
94
|
+
"Run a command in a sandboxed, read-only bash environment. " +
|
|
95
|
+
"The working directory is the project root (mounted at /repo); use relative paths with forward slashes. " +
|
|
96
|
+
"Standard utilities are available (grep, find, sed, awk, head, tail, sort, uniq, wc, cat, ls, ...) " +
|
|
97
|
+
"plus git for repository inspection (log, diff, show, blame, grep, ls-files, branch/tag listing; " +
|
|
98
|
+
"note: git status is slow in large repos — prefer the targeted commands). " +
|
|
99
|
+
"The environment has no network access and the project directory is read-only by design: all writes to it fail. " +
|
|
100
|
+
"Paths outside /repo (like /tmp) are in-memory scratch, discarded after each call. " +
|
|
101
|
+
"Each call runs in a fresh shell — cd and environment variables do not persist between calls; " +
|
|
102
|
+
"use compound commands (cd dir && command) for multi-step work.",
|
|
103
|
+
promptSnippet: "Run a command in the read-only sandboxed shell",
|
|
104
|
+
promptGuidelines: [
|
|
105
|
+
"Use bash for searching, file inspection, and git history — never attempt to modify files; all writes fail by design.",
|
|
106
|
+
],
|
|
107
|
+
parameters: Type.Object({
|
|
108
|
+
command: Type.String({
|
|
109
|
+
description:
|
|
110
|
+
"The command line to execute (e.g. 'grep -rn \"pattern\" src/', 'git log --oneline -10', 'find . -name \"*.ts\" | head').",
|
|
111
|
+
}),
|
|
112
|
+
}),
|
|
113
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
114
|
+
const bash = new Bash({
|
|
115
|
+
fs: createSandboxFs(path.resolve(ctx.cwd)),
|
|
116
|
+
cwd: MOUNT_POINT,
|
|
117
|
+
customCommands: [createGit({ network: false, disabled: DISABLED_GIT })],
|
|
118
|
+
});
|
|
119
|
+
const result = await execSafely(bash, params.command, signal);
|
|
120
|
+
const stdout = result.stdout;
|
|
121
|
+
const stderr = result.stderr;
|
|
122
|
+
const combined =
|
|
123
|
+
stdout && stderr ? `${stdout}\n${stderr}` : stdout || stderr || "(no output)";
|
|
124
|
+
const text =
|
|
125
|
+
result.exitCode === 0
|
|
126
|
+
? truncateOutput(combined)
|
|
127
|
+
: `Exit code ${result.exitCode}\n${truncateOutput(combined)}`;
|
|
128
|
+
return {
|
|
129
|
+
content: [{ type: "text" as const, text }],
|
|
130
|
+
details: { exitCode: result.exitCode },
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
async function execSafely(
|
|
136
|
+
bash: Bash,
|
|
137
|
+
command: string,
|
|
138
|
+
signal?: AbortSignal,
|
|
139
|
+
): Promise<ExecResult> {
|
|
140
|
+
try {
|
|
141
|
+
return await bash.exec(command, { signal });
|
|
142
|
+
} catch (err: any) {
|
|
143
|
+
if (FS_ERROR_PATTERN.test(err?.message ?? "")) {
|
|
144
|
+
return { stdout: "", stderr: err.message, exitCode: 1 };
|
|
145
|
+
}
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
}
|
package/tui.ts
CHANGED
|
@@ -39,28 +39,35 @@ export function formatTokens(n: number): string {
|
|
|
39
39
|
return s.padStart(6);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** Cap a string at max chars, replacing overflow with "...". */
|
|
43
|
+
export function truncate(s: string, max: number): string {
|
|
44
|
+
return s.length > max ? s.slice(0, max - 3) + "..." : s;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Prefix marking tool-call summary lines in subagent progress text. */
|
|
48
|
+
export const TOOL_LINE_PREFIX = "▸";
|
|
49
|
+
|
|
42
50
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Time-derived spinner frame. Stateless — no per-caller state to create,
|
|
54
|
+
* leak, or clean up. All concurrent spinners share the global clock, which
|
|
55
|
+
* is visually indistinguishable from per-caller frames.
|
|
56
|
+
*/
|
|
57
|
+
export function spinnerFrame(): string {
|
|
58
|
+
return SPINNER[Math.floor(Date.now() / 100) % SPINNER.length];
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
export interface UsageStats {
|
|
54
62
|
turns: number;
|
|
55
63
|
input: number;
|
|
56
64
|
output: number;
|
|
57
|
-
total: number;
|
|
58
65
|
durationMs: number;
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
export function formatUsage(u: UsageStats, spin: () => string): string {
|
|
62
69
|
const parts: string[] = [`${spin()} · Turn ${u.turns}`];
|
|
63
70
|
parts.push(`Tokens: input ${formatTokens(u.input)} | output ${formatTokens(u.output)}`);
|
|
64
|
-
|
|
71
|
+
parts.push(formatDuration(u.durationMs));
|
|
65
72
|
return parts.join(" · ");
|
|
66
73
|
}
|
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
|
-
}
|