@pi-archimedes/subagent 1.3.1 → 1.3.3
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/package.json +2 -2
- package/src/spawn.ts +200 -84
- package/src/stream.ts +50 -37
- package/src/child.ts +0 -299
- package/src/ipc-ask-tool.ts +0 -242
- package/src/ipc-types.ts +0 -79
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/subagent",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
],
|
|
12
12
|
"main": "./src/index.ts",
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@pi-archimedes/core": "1.3.
|
|
14
|
+
"@pi-archimedes/core": "1.3.3"
|
|
15
15
|
},
|
|
16
16
|
"peerDependencies": {
|
|
17
17
|
"@earendil-works/pi-ai": ">=0.1.0",
|
package/src/spawn.ts
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as net from "node:net";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
4
7
|
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
5
|
-
import type { ParentToChild, ChildToParent } from "./ipc-types.js";
|
|
6
8
|
import type { AgentConfig } from "./agents.js";
|
|
7
9
|
|
|
8
|
-
// Resolve child script path from this file's location
|
|
9
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
-
const __dirname = dirname(__filename);
|
|
11
|
-
const childScriptPath = join(__dirname, "child.ts");
|
|
12
|
-
|
|
13
10
|
export interface SpawnOptions {
|
|
14
11
|
task: string;
|
|
15
12
|
model: string | undefined;
|
|
@@ -20,83 +17,213 @@ export interface SpawnOptions {
|
|
|
20
17
|
}
|
|
21
18
|
|
|
22
19
|
/**
|
|
23
|
-
*
|
|
20
|
+
* Resolve the pi binary path.
|
|
21
|
+
*
|
|
22
|
+
* Walk up from process.argv[1] (the pi CLI entry point) looking for the
|
|
23
|
+
* @earendil-works/pi-coding-agent package root, then resolve its bin.pi field.
|
|
24
|
+
* Falls back to "pi" (PATH lookup) if resolution fails.
|
|
24
25
|
*/
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
env: { ...process.env },
|
|
30
|
-
execArgv: ["--experimental-strip-types"],
|
|
31
|
-
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
32
|
-
});
|
|
26
|
+
function resolvePiBinary(): string {
|
|
27
|
+
try {
|
|
28
|
+
const entry = process.argv[1];
|
|
29
|
+
if (!entry) return "pi";
|
|
33
30
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
case "ask_request": {
|
|
59
|
-
// Forward ask requests to the bus for parent's ask dialog
|
|
60
|
-
getBus().emit(Events.ASK_REQUEST, {
|
|
61
|
-
source: `subagent:${options.agent?.name ?? "general"}`,
|
|
62
|
-
requestId: msg.requestId,
|
|
63
|
-
questions: msg.questions,
|
|
64
|
-
});
|
|
65
|
-
break;
|
|
66
|
-
}
|
|
67
|
-
case "ready": {
|
|
68
|
-
// Child is ready — session created
|
|
69
|
-
break;
|
|
70
|
-
}
|
|
71
|
-
case "error": {
|
|
72
|
-
console.error(`[subagent:child] ${msg.message}`);
|
|
73
|
-
break;
|
|
31
|
+
let dir = path.dirname(fs.realpathSync(entry));
|
|
32
|
+
const root = path.parse(dir).root;
|
|
33
|
+
|
|
34
|
+
while (dir !== root) {
|
|
35
|
+
const pkgPath = path.join(dir, "package.json");
|
|
36
|
+
try {
|
|
37
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as {
|
|
38
|
+
name?: string;
|
|
39
|
+
bin?: string | Record<string, string>;
|
|
40
|
+
};
|
|
41
|
+
if (pkg.name === "@earendil-works/pi-coding-agent") {
|
|
42
|
+
const binField = pkg.bin;
|
|
43
|
+
const binRelative =
|
|
44
|
+
typeof binField === "string"
|
|
45
|
+
? binField
|
|
46
|
+
: binField?.pi ?? Object.values(binField ?? {})[0];
|
|
47
|
+
if (binRelative) {
|
|
48
|
+
const resolved = path.resolve(dir, binRelative);
|
|
49
|
+
if (fs.existsSync(resolved)) return resolved;
|
|
50
|
+
}
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// package.json missing or invalid — keep walking
|
|
74
55
|
}
|
|
56
|
+
const parent = path.dirname(dir);
|
|
57
|
+
if (parent === dir) break;
|
|
58
|
+
dir = parent;
|
|
75
59
|
}
|
|
76
|
-
}
|
|
60
|
+
} catch {
|
|
61
|
+
// fall through
|
|
62
|
+
}
|
|
63
|
+
return "pi";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Start a Unix socket server that bridges the child's ask tool to the parent bus.
|
|
68
|
+
*
|
|
69
|
+
* The child's ask tool (in packages/ask) connects to PI_SUBAGENT_SOCKET and sends:
|
|
70
|
+
* { type: "ask_request", requestId, questions } as a JSON line
|
|
71
|
+
* and waits for:
|
|
72
|
+
* { type: "ask_response", requestId, cancelled, results } as a JSON line
|
|
73
|
+
*
|
|
74
|
+
* We forward the request onto the bus (ASK_REQUEST), the ask package shows the
|
|
75
|
+
* parent TUI dialog, then emits ASK_RESPONSE on the bus, and we write it back
|
|
76
|
+
* to the socket connection.
|
|
77
|
+
*
|
|
78
|
+
* Returns the socket path and a cleanup function.
|
|
79
|
+
*/
|
|
80
|
+
function startAskSocketServer(agentName: string): { socketPath: string; cleanup: () => void } {
|
|
81
|
+
// Use named pipes on Windows, Unix domain sockets elsewhere.
|
|
82
|
+
// Linux socket path limit is 108 chars — keep it short.
|
|
83
|
+
const id = randomUUID().slice(0, 8);
|
|
84
|
+
const socketPath =
|
|
85
|
+
process.platform === "win32"
|
|
86
|
+
? `\\\\.\\pipe\\pi-ask-${id}`
|
|
87
|
+
: path.join(os.tmpdir(), `pi-ask-${id}.sock`);
|
|
77
88
|
|
|
78
|
-
//
|
|
79
|
-
const
|
|
89
|
+
// Map of pending ask requests: requestId → write-back callback
|
|
90
|
+
const pending = new Map<string, (response: unknown) => void>();
|
|
91
|
+
|
|
92
|
+
// Listen for ASK_RESPONSE from the bus and route back to the waiting socket conn
|
|
93
|
+
const unsubResponse = getBus().on(Events.ASK_RESPONSE, (payload: unknown) => {
|
|
80
94
|
const data = payload as {
|
|
81
95
|
requestId: string;
|
|
82
96
|
cancelled: boolean;
|
|
83
97
|
results: Array<{ id: string; selectedOptions: string[]; customInput?: string }>;
|
|
84
98
|
};
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
99
|
+
const send = pending.get(data.requestId);
|
|
100
|
+
if (send) {
|
|
101
|
+
pending.delete(data.requestId);
|
|
102
|
+
send({
|
|
103
|
+
type: "ask_response",
|
|
104
|
+
requestId: data.requestId,
|
|
105
|
+
cancelled: data.cancelled,
|
|
106
|
+
results: data.results,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const server = net.createServer((socket) => {
|
|
112
|
+
let buffer = "";
|
|
113
|
+
|
|
114
|
+
socket.on("data", (chunk: Buffer) => {
|
|
115
|
+
buffer += chunk.toString("utf-8");
|
|
116
|
+
const lines = buffer.split("\n");
|
|
117
|
+
buffer = lines.pop() ?? "";
|
|
118
|
+
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
const trimmed = line.trim();
|
|
121
|
+
if (!trimmed) continue;
|
|
122
|
+
try {
|
|
123
|
+
const msg = JSON.parse(trimmed) as {
|
|
124
|
+
type: string;
|
|
125
|
+
requestId: string;
|
|
126
|
+
questions: unknown[];
|
|
127
|
+
};
|
|
128
|
+
if (msg.type === "ask_request") {
|
|
129
|
+
// Register write-back so ASK_RESPONSE handler can find this socket
|
|
130
|
+
pending.set(msg.requestId, (response) => {
|
|
131
|
+
try {
|
|
132
|
+
socket.write(JSON.stringify(response) + "\n");
|
|
133
|
+
} catch {
|
|
134
|
+
// socket already closed
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
// Forward to bus — ask package will show the TUI dialog
|
|
138
|
+
getBus().emit(Events.ASK_REQUEST, {
|
|
139
|
+
source: `subagent:${agentName}`,
|
|
140
|
+
requestId: msg.requestId,
|
|
141
|
+
questions: msg.questions,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// malformed JSON — ignore
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
socket.on("error", () => { /* connection dropped */ });
|
|
92
151
|
});
|
|
93
152
|
|
|
153
|
+
server.listen(socketPath);
|
|
154
|
+
|
|
155
|
+
const cleanup = () => {
|
|
156
|
+
unsubResponse();
|
|
157
|
+
server.close();
|
|
158
|
+
// Named pipes on Windows are cleaned up automatically; only unlink on Unix
|
|
159
|
+
if (process.platform !== "win32") {
|
|
160
|
+
try { fs.unlinkSync(socketPath); } catch { /* already gone */ }
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return { socketPath, cleanup };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Spawn a subagent as a fresh `pi --mode json --no-session -p <task>` process.
|
|
169
|
+
*
|
|
170
|
+
* A Unix socket server is started in the parent to bridge the child's ask tool
|
|
171
|
+
* back to the parent's TUI dialog. The socket path is passed via PI_SUBAGENT_SOCKET.
|
|
172
|
+
*/
|
|
173
|
+
export function spawnSubagent(options: SpawnOptions): ChildProcess {
|
|
174
|
+
const piBinary = resolvePiBinary();
|
|
175
|
+
const agentName = options.agent?.name ?? "general";
|
|
176
|
+
|
|
177
|
+
// Start ask bridge socket before spawning so the env var is ready
|
|
178
|
+
const { socketPath, cleanup: cleanupSocket } = startAskSocketServer(agentName);
|
|
179
|
+
|
|
180
|
+
// Build CLI args
|
|
181
|
+
const args: string[] = ["--mode", "json", "--no-session", "-p"];
|
|
182
|
+
|
|
183
|
+
// Model: agent.model > options.model > options.activeModel
|
|
184
|
+
const model = options.agent?.model ?? options.model ?? options.activeModel;
|
|
185
|
+
if (model) {
|
|
186
|
+
args.push("--model", model);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Thinking level from agent config
|
|
190
|
+
if (options.agent?.thinking) {
|
|
191
|
+
args.push("--thinking", options.agent.thinking);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Tool allowlist from agent config
|
|
195
|
+
if (options.agent?.tools && options.agent.tools.length > 0) {
|
|
196
|
+
args.push("--tools", options.agent.tools.join(","));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Always exclude the subagent tool itself to prevent infinite recursion
|
|
200
|
+
args.push("--exclude-tools", "subagent");
|
|
201
|
+
|
|
202
|
+
// Agent system prompt
|
|
203
|
+
const systemPrompt = options.agent?.systemPrompt?.trim();
|
|
204
|
+
if (systemPrompt) {
|
|
205
|
+
args.push("--system-prompt", systemPrompt);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The task is the final positional argument
|
|
209
|
+
args.push(options.task);
|
|
210
|
+
|
|
211
|
+
const child = spawn(piBinary, args, {
|
|
212
|
+
cwd: options.cwd || process.cwd(),
|
|
213
|
+
env: {
|
|
214
|
+
...process.env,
|
|
215
|
+
PI_SUBAGENT_SOCKET: socketPath,
|
|
216
|
+
},
|
|
217
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Clean up socket server when child exits
|
|
221
|
+
child.on("exit", cleanupSocket);
|
|
222
|
+
child.on("error", cleanupSocket);
|
|
223
|
+
|
|
94
224
|
// Handle abort signal
|
|
95
|
-
let abortHandler: (() => void) | undefined;
|
|
96
225
|
if (options.signal) {
|
|
97
|
-
abortHandler = () => {
|
|
98
|
-
// Send abort message to child before killing
|
|
99
|
-
child.send({ type: "abort" });
|
|
226
|
+
const abortHandler = () => {
|
|
100
227
|
if (child.pid && !child.killed) {
|
|
101
228
|
child.kill("SIGTERM");
|
|
102
229
|
const forceKill = setTimeout(() => {
|
|
@@ -106,19 +233,8 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
|
|
|
106
233
|
}
|
|
107
234
|
};
|
|
108
235
|
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
236
|
+
child.on("exit", () => options.signal!.removeEventListener("abort", abortHandler));
|
|
109
237
|
}
|
|
110
238
|
|
|
111
|
-
// Clean up on exit
|
|
112
|
-
const exitCleanup = (): void => {
|
|
113
|
-
child.removeListener("exit", exitCleanup);
|
|
114
|
-
child.removeListener("error", exitCleanup);
|
|
115
|
-
if (abortHandler && options.signal) {
|
|
116
|
-
options.signal.removeEventListener("abort", abortHandler);
|
|
117
|
-
}
|
|
118
|
-
unsubAskResponse();
|
|
119
|
-
};
|
|
120
|
-
child.on("exit", exitCleanup);
|
|
121
|
-
child.on("error", exitCleanup);
|
|
122
|
-
|
|
123
239
|
return child;
|
|
124
240
|
}
|
package/src/stream.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
1
2
|
import type { ChildProcess } from "node:child_process";
|
|
2
|
-
import type { ChildToParent } from "./ipc-types.js";
|
|
3
3
|
import type { StreamState, SubagentProgress, SubagentResult } from "./types.js";
|
|
4
4
|
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
5
5
|
import {
|
|
@@ -18,7 +18,11 @@ export interface StreamCallbacks {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Stream JSON events from a child pi process and build progress/result.
|
|
21
|
+
* Stream JSON events from a child `pi --mode json` process and build progress/result.
|
|
22
|
+
*
|
|
23
|
+
* The child writes one JSON object per line to stdout. We read each line via
|
|
24
|
+
* readline, parse it, and dispatch to the same handler functions used previously.
|
|
25
|
+
* stderr is drained silently (captured as the error string on non-zero exit).
|
|
22
26
|
*/
|
|
23
27
|
export function streamEvents(
|
|
24
28
|
child: ChildProcess,
|
|
@@ -27,20 +31,15 @@ export function streamEvents(
|
|
|
27
31
|
return new Promise((resolve, reject) => {
|
|
28
32
|
const startTime = Date.now();
|
|
29
33
|
|
|
30
|
-
// Startup safeguard: if
|
|
31
|
-
// STARTUP_TIMEOUT_MS, kill it. This guards against hangs during pi
|
|
32
|
-
// initialization (model never loads, auth fails, etc.) and is the only
|
|
33
|
-
// automatic timeout. Once any event arrives the model is considered
|
|
34
|
-
// active and runtime is controlled entirely by the user's abort
|
|
35
|
-
// signal — a model that is REALLY thinking is left alone until the
|
|
36
|
-
// user explicitly cancels.
|
|
34
|
+
// Startup safeguard: if no JSON event arrives within 2 minutes, kill the child.
|
|
37
35
|
const STARTUP_TIMEOUT_MS = 2 * 60 * 1000;
|
|
38
|
-
|
|
39
36
|
let startupTimer: NodeJS.Timeout | undefined = setTimeout(() => {
|
|
40
37
|
child.kill("SIGKILL");
|
|
41
|
-
reject(
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
reject(
|
|
39
|
+
new Error(
|
|
40
|
+
`subagent timed out: no output within ${STARTUP_TIMEOUT_MS / 60_000} minutes of startup`,
|
|
41
|
+
),
|
|
42
|
+
);
|
|
44
43
|
}, STARTUP_TIMEOUT_MS);
|
|
45
44
|
|
|
46
45
|
const clearStartupTimer = (): void => {
|
|
@@ -67,9 +66,13 @@ export function streamEvents(
|
|
|
67
66
|
toolCalls: [],
|
|
68
67
|
finalOutput: undefined,
|
|
69
68
|
};
|
|
69
|
+
|
|
70
|
+
// Collect stderr for error reporting
|
|
71
|
+
const stderrChunks: Buffer[] = [];
|
|
72
|
+
child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
|
|
73
|
+
|
|
70
74
|
let error: string | undefined;
|
|
71
75
|
|
|
72
|
-
// Build progress from state
|
|
73
76
|
const buildProgress = (): SubagentProgress => ({
|
|
74
77
|
agent: callbacks.agent ?? "subagent",
|
|
75
78
|
status: "running",
|
|
@@ -84,43 +87,50 @@ export function streamEvents(
|
|
|
84
87
|
cost: state.totalCost,
|
|
85
88
|
durationMs: Date.now() - startTime,
|
|
86
89
|
error,
|
|
87
|
-
output:
|
|
90
|
+
output:
|
|
91
|
+
state.accumulatedOutput.length > 0
|
|
92
|
+
? state.accumulatedOutput.join("\n\n")
|
|
93
|
+
: undefined,
|
|
88
94
|
recentOutput: state.recentOutput.length > 0 ? state.recentOutput : undefined,
|
|
89
95
|
toolCalls: state.toolCalls.length > 0 ? state.toolCalls : undefined,
|
|
90
96
|
model: state.model,
|
|
91
97
|
});
|
|
92
98
|
|
|
93
|
-
const emitProgress = () =>
|
|
94
|
-
callbacks.onProgress?.(buildProgress());
|
|
95
|
-
};
|
|
99
|
+
const emitProgress = () => callbacks.onProgress?.(buildProgress());
|
|
96
100
|
|
|
97
|
-
// Periodic
|
|
101
|
+
// Periodic heartbeat for live duration display
|
|
98
102
|
const heartbeat = setInterval(emitProgress, 1000);
|
|
99
103
|
|
|
100
|
-
//
|
|
101
|
-
child.
|
|
102
|
-
|
|
104
|
+
// Read stdout as newline-delimited JSON
|
|
105
|
+
if (!child.stdout) {
|
|
106
|
+
clearStartupTimer();
|
|
107
|
+
clearInterval(heartbeat);
|
|
108
|
+
reject(new Error("subagent child has no stdout pipe"));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
103
111
|
|
|
104
|
-
|
|
105
|
-
if (childMsg.type === "error") {
|
|
106
|
-
error = childMsg.message;
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
112
|
+
const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
109
113
|
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
rl.on("line", (line) => {
|
|
115
|
+
const trimmed = line.trim();
|
|
116
|
+
if (!trimmed) return;
|
|
112
117
|
|
|
113
|
-
|
|
118
|
+
let event: JsonEvent;
|
|
119
|
+
try {
|
|
120
|
+
event = JSON.parse(trimmed) as JsonEvent;
|
|
121
|
+
} catch {
|
|
122
|
+
// Non-JSON output — ignore (can happen from pi startup messages)
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
114
125
|
|
|
115
|
-
// First event
|
|
116
|
-
// from here on, the user controls lifetime via the abort signal.
|
|
126
|
+
// First real event means model has engaged — cancel startup watchdog
|
|
117
127
|
clearStartupTimer();
|
|
118
128
|
|
|
119
129
|
switch (event.type) {
|
|
120
130
|
case "tool_execution_start": {
|
|
121
131
|
handleToolStart(state, event);
|
|
122
132
|
emitProgress();
|
|
123
|
-
// Forward manage_todo_list writes to the
|
|
133
|
+
// Forward manage_todo_list writes to the parent's todo widget
|
|
124
134
|
if (event.toolName === "manage_todo_list") {
|
|
125
135
|
const args = event.args as Record<string, unknown> | undefined;
|
|
126
136
|
const todoList = args?.todoList as Array<unknown> | undefined;
|
|
@@ -136,7 +146,6 @@ export function streamEvents(
|
|
|
136
146
|
case "tool_execution_end": {
|
|
137
147
|
handleToolEnd(state);
|
|
138
148
|
emitProgress();
|
|
139
|
-
// Also capture tool result output here (previously in tool_result_end)
|
|
140
149
|
handleToolResult(state, event);
|
|
141
150
|
emitProgress();
|
|
142
151
|
break;
|
|
@@ -154,6 +163,7 @@ export function streamEvents(
|
|
|
154
163
|
handleAgentEnd(state, event);
|
|
155
164
|
break;
|
|
156
165
|
}
|
|
166
|
+
// Ignore: session, agent_start, message_start, message_update, turn_end, tool_execution_update
|
|
157
167
|
}
|
|
158
168
|
});
|
|
159
169
|
|
|
@@ -164,7 +174,11 @@ export function streamEvents(
|
|
|
164
174
|
const durationMs = Date.now() - startTime;
|
|
165
175
|
const exitCode = code ?? 1;
|
|
166
176
|
|
|
167
|
-
//
|
|
177
|
+
// Surface stderr as error if the process failed and we have no other error
|
|
178
|
+
if (exitCode !== 0 && !error) {
|
|
179
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
180
|
+
if (stderr) error = stderr;
|
|
181
|
+
}
|
|
168
182
|
|
|
169
183
|
const result: SubagentResult = {
|
|
170
184
|
agent: callbacks.agent ?? "subagent",
|
|
@@ -193,10 +207,9 @@ export function streamEvents(
|
|
|
193
207
|
},
|
|
194
208
|
};
|
|
195
209
|
|
|
196
|
-
// Final progress update
|
|
197
210
|
callbacks.onProgress?.(result.progress!);
|
|
198
211
|
|
|
199
|
-
// Clear subagent todos from the bus on
|
|
212
|
+
// Clear subagent todos from the bus on exit
|
|
200
213
|
getBus().emit(Events.TODOS_CLEAR, {
|
|
201
214
|
source: `subagent:${callbacks.agent ?? "general"}`,
|
|
202
215
|
});
|
package/src/child.ts
DELETED
|
@@ -1,299 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Forked child entry point for IPC-based subagent architecture.
|
|
3
|
-
*
|
|
4
|
-
* This script is forked by the parent process. It receives an "init" message
|
|
5
|
-
* with task and configuration, creates an AgentSession, runs the agent loop,
|
|
6
|
-
* and streams events back to the parent over IPC.
|
|
7
|
-
*
|
|
8
|
-
* Usage: node child.js (forked with stdio: ["pipe", "pipe", "pipe", "ipc"])
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
createAgentSession,
|
|
13
|
-
SessionManager,
|
|
14
|
-
} from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
16
|
-
import type { ParentToChild, ChildToParent, SerializedAgentEvent } from "./ipc-types.ts";
|
|
17
|
-
import { createIpcAskTool } from "./ipc-ask-tool.ts";
|
|
18
|
-
|
|
19
|
-
// ── Types ───────────────────────────────────────────────────────────────────
|
|
20
|
-
|
|
21
|
-
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
22
|
-
|
|
23
|
-
interface InitParams {
|
|
24
|
-
task: string;
|
|
25
|
-
model: string | undefined;
|
|
26
|
-
agentName: string | undefined;
|
|
27
|
-
agentSystemPrompt: string | undefined;
|
|
28
|
-
agentTools: string[] | undefined;
|
|
29
|
-
agentModel: string | undefined;
|
|
30
|
-
agentThinking: string | undefined;
|
|
31
|
-
cwd: string | undefined;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// ── Model resolution ────────────────────────────────────────────────────────
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Known providers' default model ids (subset of pi's defaultModelPerProvider).
|
|
38
|
-
* Used by buildFallbackModel to pick a template model when the requested
|
|
39
|
-
* model id isn't in the registry (e.g. newly-released openrouter models).
|
|
40
|
-
*/
|
|
41
|
-
const DEFAULT_MODEL_PER_PROVIDER: Record<string, string | undefined> = {
|
|
42
|
-
openrouter: "anthropic/claude-sonnet-4-5",
|
|
43
|
-
anthropic: "claude-sonnet-4-5",
|
|
44
|
-
openai: "gpt-4o",
|
|
45
|
-
google: "gemini-2.5-pro",
|
|
46
|
-
// fall back to first model of provider when undefined
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Build a fallback model for a known provider when the exact model id isn't
|
|
51
|
-
* registered. Mirrors pi's buildFallbackModel(): take the provider's default
|
|
52
|
-
* model as a template and override id/name with the requested modelId.
|
|
53
|
-
*/
|
|
54
|
-
function buildFallbackModel(provider: string, modelId: string, available: Model<any>[]): Model<any> | undefined {
|
|
55
|
-
const providerModels = available.filter((m) => m.provider === provider);
|
|
56
|
-
if (providerModels.length === 0) return undefined;
|
|
57
|
-
const defaultId = DEFAULT_MODEL_PER_PROVIDER[provider];
|
|
58
|
-
const baseModel = defaultId
|
|
59
|
-
? (providerModels.find((m) => m.id === defaultId) ?? providerModels[0]!)
|
|
60
|
-
: providerModels[0]!;
|
|
61
|
-
return { ...baseModel, id: modelId, name: modelId };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
interface ModelRegistryLike {
|
|
65
|
-
find(provider: string, modelId: string): Model<any> | undefined;
|
|
66
|
-
getAll(): Model<any>[];
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Resolve a model reference the same way the pi CLI does (resolveCliModel).
|
|
71
|
-
*
|
|
72
|
-
* Supports:
|
|
73
|
-
* - "provider/modelId" where provider is a known provider
|
|
74
|
-
* - "modelId" containing slashes (e.g. openrouter's "z-ai/glm-5.2")
|
|
75
|
-
* - bare "modelId"
|
|
76
|
-
* - fuzzy/partial matching on id within a provider
|
|
77
|
-
* - fallback: build a custom model for a known provider when the id isn't
|
|
78
|
-
* registered (handles newly-released models like openrouter/z-ai/glm-5.2)
|
|
79
|
-
*
|
|
80
|
-
* The registry must include extension-registered providers (e.g. "tama"),
|
|
81
|
-
* which is why this is called AFTER createAgentSession has loaded
|
|
82
|
-
* extensions — not with a bare ModelRegistry.create() that only knows
|
|
83
|
-
* built-ins + models.json.
|
|
84
|
-
*/
|
|
85
|
-
function resolveModel(modelString: string | undefined, registry: ModelRegistryLike): Model<any> | undefined {
|
|
86
|
-
if (!modelString) return undefined;
|
|
87
|
-
const cliModel = modelString.trim();
|
|
88
|
-
if (!cliModel) return undefined;
|
|
89
|
-
|
|
90
|
-
const available = registry.getAll();
|
|
91
|
-
if (available.length === 0) return undefined;
|
|
92
|
-
|
|
93
|
-
const lower = cliModel.toLowerCase();
|
|
94
|
-
|
|
95
|
-
// Build canonical provider lookup (case-insensitive)
|
|
96
|
-
const providerMap = new Map<string, string>();
|
|
97
|
-
for (const m of available) {
|
|
98
|
-
if (!providerMap.has(m.provider.toLowerCase())) {
|
|
99
|
-
providerMap.set(m.provider.toLowerCase(), m.provider);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// If the input is a full canonical "provider/id", match it directly.
|
|
104
|
-
// This handles models whose ids contain slashes (e.g. openrouter "z-ai/glm-5.2").
|
|
105
|
-
const canonicalMatch = available.find(
|
|
106
|
-
(m) => `${m.provider}/${m.id}`.toLowerCase() === lower,
|
|
107
|
-
);
|
|
108
|
-
if (canonicalMatch) return canonicalMatch;
|
|
109
|
-
|
|
110
|
-
// Try interpreting "provider/modelId" when the prefix is a known provider.
|
|
111
|
-
const slashIndex = cliModel.indexOf("/");
|
|
112
|
-
if (slashIndex !== -1) {
|
|
113
|
-
const maybeProvider = cliModel.substring(0, slashIndex);
|
|
114
|
-
const canonicalProvider = providerMap.get(maybeProvider.toLowerCase());
|
|
115
|
-
if (canonicalProvider) {
|
|
116
|
-
const modelId = cliModel.substring(slashIndex + 1);
|
|
117
|
-
// Exact match within provider
|
|
118
|
-
const within = available.filter(
|
|
119
|
-
(m) => m.provider === canonicalProvider && m.id.toLowerCase() === modelId.toLowerCase(),
|
|
120
|
-
);
|
|
121
|
-
if (within.length === 1) return within[0];
|
|
122
|
-
// Fallback: unknown model id on a known provider → build a custom model
|
|
123
|
-
return buildFallbackModel(canonicalProvider, modelId, available);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Bare id (no slash, or slash prefix isn't a known provider) — exact id match
|
|
128
|
-
const idMatches = available.filter((m) => m.id.toLowerCase() === lower);
|
|
129
|
-
if (idMatches.length === 1) return idMatches[0];
|
|
130
|
-
|
|
131
|
-
return undefined;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// ── Thinking level parsing ──────────────────────────────────────────────────
|
|
135
|
-
|
|
136
|
-
function parseThinkingLevel(level: string | undefined): ThinkingLevel | undefined {
|
|
137
|
-
if (!level) return undefined;
|
|
138
|
-
const validLevels: readonly ThinkingLevel[] = [
|
|
139
|
-
"off",
|
|
140
|
-
"minimal",
|
|
141
|
-
"low",
|
|
142
|
-
"medium",
|
|
143
|
-
"high",
|
|
144
|
-
"xhigh",
|
|
145
|
-
];
|
|
146
|
-
if (validLevels.includes(level as ThinkingLevel)) {
|
|
147
|
-
return level as ThinkingLevel;
|
|
148
|
-
}
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ── Parent message handler ──────────────────────────────────────────────────
|
|
153
|
-
|
|
154
|
-
let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | null = null;
|
|
155
|
-
|
|
156
|
-
function handleParentMessage(msg: ParentToChild): void {
|
|
157
|
-
switch (msg.type) {
|
|
158
|
-
case "abort": {
|
|
159
|
-
session?.abort();
|
|
160
|
-
break;
|
|
161
|
-
}
|
|
162
|
-
// "init" is handled synchronously before this listener is set up
|
|
163
|
-
// "ask_response" is handled by the IPC ask tool's own per-execution listener
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// ── Event serialization ─────────────────────────────────────────────────────
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Strip non-serializable fields (functions, symbols) from events.
|
|
171
|
-
*/
|
|
172
|
-
function serializeEvent(event: Record<string, unknown>): Record<string, unknown> {
|
|
173
|
-
return JSON.parse(JSON.stringify(event));
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ── Send helper ─────────────────────────────────────────────────────────────
|
|
177
|
-
|
|
178
|
-
function sendToParent(msg: ChildToParent): void {
|
|
179
|
-
process.send?.(msg);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ── Main ────────────────────────────────────────────────────────────────────
|
|
183
|
-
|
|
184
|
-
async function main(): Promise<void> {
|
|
185
|
-
// Wait for the "init" message from parent.
|
|
186
|
-
let initParams: InitParams | null = null;
|
|
187
|
-
|
|
188
|
-
const initPromise = new Promise<InitParams>((resolve) => {
|
|
189
|
-
const handler = (msg: ParentToChild) => {
|
|
190
|
-
if (msg.type === "init") {
|
|
191
|
-
process.removeListener("message", handler);
|
|
192
|
-
resolve({
|
|
193
|
-
task: msg.task,
|
|
194
|
-
model: msg.model,
|
|
195
|
-
agentName: msg.agentName,
|
|
196
|
-
agentSystemPrompt: msg.agentSystemPrompt,
|
|
197
|
-
agentTools: msg.agentTools,
|
|
198
|
-
agentModel: msg.agentModel,
|
|
199
|
-
agentThinking: msg.agentThinking,
|
|
200
|
-
cwd: msg.cwd,
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
process.on("message", handler);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
initParams = await initPromise;
|
|
208
|
-
|
|
209
|
-
// Resolve thinking level from agent config.
|
|
210
|
-
const thinkingLevel = parseThinkingLevel(initParams.agentThinking);
|
|
211
|
-
|
|
212
|
-
// Build tools allowlist from agent config.
|
|
213
|
-
const tools = initParams.agentTools;
|
|
214
|
-
|
|
215
|
-
// Build options object — omit undefined values to satisfy exactOptionalPropertyTypes.
|
|
216
|
-
// NOTE: we deliberately do NOT pass authStorage/modelRegistry/settingsManager —
|
|
217
|
-
// createAgentSession builds a DefaultResourceLoader that loads extensions
|
|
218
|
-
// (including custom providers like "tama") and registers them into the
|
|
219
|
-
// registry. Passing a bare ModelRegistry.create() would skip extension
|
|
220
|
-
// loading and break custom-provider model resolution.
|
|
221
|
-
const sessionOptions: Parameters<typeof createAgentSession>[0] = {
|
|
222
|
-
excludeTools: ["subagent"],
|
|
223
|
-
customTools: [createIpcAskTool()],
|
|
224
|
-
sessionManager: SessionManager.inMemory(),
|
|
225
|
-
...(thinkingLevel ? { thinkingLevel } : {}),
|
|
226
|
-
...(initParams.cwd ? { cwd: initParams.cwd } : {}),
|
|
227
|
-
...(tools ? { tools } : {}),
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
// Create the agent session with IPC ask tool for user questions.
|
|
231
|
-
// The session loads extensions (custom providers) and picks the settings
|
|
232
|
-
// default model when none is passed.
|
|
233
|
-
const { session: agentSession } = await createAgentSession(sessionOptions);
|
|
234
|
-
|
|
235
|
-
session = agentSession;
|
|
236
|
-
|
|
237
|
-
// If a specific model was requested, resolve it through the session's
|
|
238
|
-
// registry (which now includes extension-registered providers) and switch
|
|
239
|
-
// to it. This handles "tama/whatevers-hot-n-fresh" and bare ids like "glm".
|
|
240
|
-
const modelString = initParams.agentModel ?? initParams.model;
|
|
241
|
-
const resolvedModel = resolveModel(modelString, session.modelRegistry);
|
|
242
|
-
if (resolvedModel && (session.model?.provider !== resolvedModel.provider || session.model?.id !== resolvedModel.id)) {
|
|
243
|
-
await session.setModel(resolvedModel);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Bind extensions with empty bindings (no UI, no command context).
|
|
247
|
-
await session.bindExtensions({});
|
|
248
|
-
|
|
249
|
-
// Subscribe to agent events and stream them to parent.
|
|
250
|
-
session.subscribe((event) => {
|
|
251
|
-
const serialized = serializeEvent(event);
|
|
252
|
-
sendToParent({ type: "event", event: serialized as SerializedAgentEvent });
|
|
253
|
-
|
|
254
|
-
// On agent_end, exit the process.
|
|
255
|
-
if (event.type === "agent_end") {
|
|
256
|
-
const willRetry = (event as { willRetry?: boolean }).willRetry ?? false;
|
|
257
|
-
if (!willRetry) {
|
|
258
|
-
// Give events time to flush, then exit.
|
|
259
|
-
setTimeout(() => {
|
|
260
|
-
process.exit(0);
|
|
261
|
-
}, 100);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
// Set up parent message handler for ask_response and abort.
|
|
267
|
-
process.on("message", handleParentMessage);
|
|
268
|
-
|
|
269
|
-
// Send ready signal to parent.
|
|
270
|
-
sendToParent({ type: "ready" });
|
|
271
|
-
|
|
272
|
-
// Start the agent loop.
|
|
273
|
-
// Prepend the agent's system prompt to the first prompt (no SDK-level systemPrompt option).
|
|
274
|
-
const fullPrompt = initParams.agentSystemPrompt && initParams.agentSystemPrompt.trim()
|
|
275
|
-
? `${initParams.agentSystemPrompt}\n\n---\n\n${initParams.task}`
|
|
276
|
-
: initParams.task;
|
|
277
|
-
await session.prompt(fullPrompt);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// ── Graceful shutdown ───────────────────────────────────────────────────────
|
|
281
|
-
|
|
282
|
-
function gracefulShutdown(signal: string): void {
|
|
283
|
-
if (session) {
|
|
284
|
-
session.abort();
|
|
285
|
-
session.dispose();
|
|
286
|
-
}
|
|
287
|
-
process.exit(signal === "SIGTERM" ? 143 : 130);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|
291
|
-
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
292
|
-
|
|
293
|
-
// ── Entry point ─────────────────────────────────────────────────────────────
|
|
294
|
-
|
|
295
|
-
main().catch((err) => {
|
|
296
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
297
|
-
sendToParent({ type: "error", message });
|
|
298
|
-
process.exit(1);
|
|
299
|
-
});
|
package/src/ipc-ask-tool.ts
DELETED
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal "ask" tool for the forked child process.
|
|
3
|
-
*
|
|
4
|
-
* Uses IPC (process.send / process.on("message")) to forward questions
|
|
5
|
-
* to the parent, which displays the UI and sends back the response.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { Type, type Static } from "typebox";
|
|
10
|
-
import { randomUUID } from "node:crypto";
|
|
11
|
-
|
|
12
|
-
// ── Schema (matches the existing ask tool) ───────────────────────────────────
|
|
13
|
-
|
|
14
|
-
const OptionItemSchema = Type.Object({
|
|
15
|
-
label: Type.String({ description: "Display label" }),
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
const QuestionItemSchema = Type.Object({
|
|
19
|
-
id: Type.String({ description: "Question id" }),
|
|
20
|
-
question: Type.String({ description: "Question text" }),
|
|
21
|
-
description: Type.Optional(Type.String({
|
|
22
|
-
description: "Optional context in Markdown/plain text.",
|
|
23
|
-
})),
|
|
24
|
-
options: Type.Array(OptionItemSchema, {
|
|
25
|
-
description: "Available options. Do not include 'Other'.",
|
|
26
|
-
minItems: 1,
|
|
27
|
-
}),
|
|
28
|
-
multi: Type.Optional(Type.Boolean({ description: "Allow multi-select" })),
|
|
29
|
-
recommended: Type.Optional(Type.Number({
|
|
30
|
-
description: "0-indexed recommended option.",
|
|
31
|
-
})),
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
const AskParamsSchema = Type.Object({
|
|
35
|
-
questions: Type.Array(QuestionItemSchema, { description: "Questions to ask", minItems: 1 }),
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
type AskParams = Static<typeof AskParamsSchema>;
|
|
39
|
-
|
|
40
|
-
// ── Session content helpers (minimal from packages/ask) ──────────────────────
|
|
41
|
-
|
|
42
|
-
interface QuestionResult {
|
|
43
|
-
id: string;
|
|
44
|
-
question: string;
|
|
45
|
-
description: string | undefined;
|
|
46
|
-
options: string[];
|
|
47
|
-
multi: boolean;
|
|
48
|
-
selectedOptions: string[];
|
|
49
|
-
customInput: string | undefined;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function sanitizeForSessionText(value: string): string {
|
|
53
|
-
return value
|
|
54
|
-
.replace(/[\r\n\t]/g, " ")
|
|
55
|
-
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
|
|
56
|
-
.replace(/\s{2,}/g, " ")
|
|
57
|
-
.trim();
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function sanitizeMultilineForSessionText(value: string): string {
|
|
61
|
-
return value
|
|
62
|
-
.replace(/\r\n/g, "\n")
|
|
63
|
-
.replace(/\r/g, "\n")
|
|
64
|
-
.split("\n")
|
|
65
|
-
.map((line) => sanitizeForSessionText(line))
|
|
66
|
-
.join("\n")
|
|
67
|
-
.trim();
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function toSessionSafeQuestionResult(result: QuestionResult): QuestionResult {
|
|
71
|
-
const selectedOptions = result.selectedOptions
|
|
72
|
-
.map(sanitizeForSessionText)
|
|
73
|
-
.filter((s) => s.length > 0);
|
|
74
|
-
|
|
75
|
-
const rawDescription = result.description;
|
|
76
|
-
const description = rawDescription == null ? undefined : sanitizeMultilineForSessionText(rawDescription);
|
|
77
|
-
const rawCustomInput = result.customInput;
|
|
78
|
-
const customInput = rawCustomInput == null ? undefined : sanitizeForSessionText(rawCustomInput);
|
|
79
|
-
|
|
80
|
-
return {
|
|
81
|
-
id: sanitizeForSessionText(result.id) || "(unknown)",
|
|
82
|
-
question: sanitizeForSessionText(result.question) || "(empty question)",
|
|
83
|
-
description: description && description.length > 0 ? description : undefined,
|
|
84
|
-
options: result.options.map((o) => sanitizeForSessionText(o) || "(empty option)"),
|
|
85
|
-
multi: result.multi,
|
|
86
|
-
selectedOptions,
|
|
87
|
-
customInput: customInput && customInput.length > 0 ? customInput : undefined,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function formatSelectionForSummary(result: QuestionResult): string {
|
|
92
|
-
const hasSelected = result.selectedOptions.length > 0;
|
|
93
|
-
const hasCustom = Boolean(result.customInput);
|
|
94
|
-
|
|
95
|
-
if (!hasSelected && !hasCustom) return "(cancelled)";
|
|
96
|
-
if (hasSelected && hasCustom) {
|
|
97
|
-
const selected = result.multi ? `[${result.selectedOptions.join(", ")}]` : result.selectedOptions[0] ?? "";
|
|
98
|
-
return `${selected} + Other: "${result.customInput}"`;
|
|
99
|
-
}
|
|
100
|
-
if (hasCustom) return `"${result.customInput}"`;
|
|
101
|
-
if (result.multi) return `[${result.selectedOptions.join(", ")}]`;
|
|
102
|
-
return result.selectedOptions[0] ?? "";
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function formatQuestionResult(result: QuestionResult): string {
|
|
106
|
-
return `${result.id}: ${formatSelectionForSummary(result)}`;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function formatQuestionContext(result: QuestionResult, index: number): string {
|
|
110
|
-
const lines: string[] = [
|
|
111
|
-
`Question ${index + 1} (${result.id})`,
|
|
112
|
-
`Prompt: ${result.question}`,
|
|
113
|
-
];
|
|
114
|
-
|
|
115
|
-
if (result.description) {
|
|
116
|
-
lines.push("Context:");
|
|
117
|
-
for (const line of result.description.split("\n")) {
|
|
118
|
-
lines.push(` ${line}`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
lines.push("Options:");
|
|
123
|
-
lines.push(...result.options.map((opt, i) => ` ${i + 1}. ${opt}`));
|
|
124
|
-
lines.push("Response:");
|
|
125
|
-
|
|
126
|
-
const hasSelected = result.selectedOptions.length > 0;
|
|
127
|
-
const hasCustom = Boolean(result.customInput);
|
|
128
|
-
|
|
129
|
-
if (!hasSelected && !hasCustom) {
|
|
130
|
-
lines.push(" Selected: (cancelled)");
|
|
131
|
-
return lines.join("\n");
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (hasSelected) {
|
|
135
|
-
const text = result.multi ? `[${result.selectedOptions.join(", ")}]` : result.selectedOptions[0];
|
|
136
|
-
lines.push(` Selected: ${text}`);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (hasCustom) {
|
|
140
|
-
if (!hasSelected) lines.push(" Selected: (Other)");
|
|
141
|
-
lines.push(` Custom input: ${result.customInput}`);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return lines.join("\n");
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function buildAskSessionContent(results: QuestionResult[]): string {
|
|
148
|
-
const safe = results.map(toSessionSafeQuestionResult);
|
|
149
|
-
const summary = safe.map(formatQuestionResult).join("\n");
|
|
150
|
-
const context = safe.map((r, i) => formatQuestionContext(r, i)).join("\n\n");
|
|
151
|
-
return `User answers:\n${summary}\n\nAnswer context:\n${context}`;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// ── Tool definition ─────────────────────────────────────────────────────────
|
|
155
|
-
|
|
156
|
-
const ASK_TOOL_DESCRIPTION = `
|
|
157
|
-
Ask the user for clarification when a choice materially affects the outcome.
|
|
158
|
-
|
|
159
|
-
- Use when multiple valid approaches have different trade-offs.
|
|
160
|
-
- Prefer 2-5 concise options.
|
|
161
|
-
- Use multi=true when multiple answers are valid.
|
|
162
|
-
- Use recommended=<index> (0-indexed) to mark the default option.
|
|
163
|
-
- Use description to provide Markdown/plain context.
|
|
164
|
-
- You can ask multiple related questions in one call using questions[].
|
|
165
|
-
- Do NOT include an 'Other' option; UI adds it automatically.
|
|
166
|
-
`.trim();
|
|
167
|
-
|
|
168
|
-
export function createIpcAskTool(): ToolDefinition<any, unknown, any> {
|
|
169
|
-
return {
|
|
170
|
-
name: "ask",
|
|
171
|
-
label: "Ask",
|
|
172
|
-
description: ASK_TOOL_DESCRIPTION,
|
|
173
|
-
parameters: AskParamsSchema,
|
|
174
|
-
|
|
175
|
-
async execute(_toolCallId, params: AskParams, _signal, _onUpdate, _ctx) {
|
|
176
|
-
const requestId = randomUUID();
|
|
177
|
-
const questions = params.questions;
|
|
178
|
-
|
|
179
|
-
// Send ask_request to parent via IPC.
|
|
180
|
-
process.send?.({ type: "ask_request", requestId, questions });
|
|
181
|
-
|
|
182
|
-
// Await response from parent.
|
|
183
|
-
const response = await new Promise<{
|
|
184
|
-
cancelled: boolean;
|
|
185
|
-
results: Array<{ id: string; selectedOptions: string[]; customInput?: string }>;
|
|
186
|
-
}>((resolve) => {
|
|
187
|
-
let resolved = false;
|
|
188
|
-
const finish = (r: { cancelled: boolean; results: Array<{ id: string; selectedOptions: string[]; customInput?: string }> }) => {
|
|
189
|
-
if (resolved) return;
|
|
190
|
-
resolved = true;
|
|
191
|
-
clearTimeout(timer);
|
|
192
|
-
process.removeListener("message", listener);
|
|
193
|
-
resolve(r);
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
const listener = (msg: { type?: string; requestId?: string; cancelled?: boolean; results?: unknown[] }) => {
|
|
197
|
-
if (msg.type === "ask_response" && msg.requestId === requestId) {
|
|
198
|
-
finish({
|
|
199
|
-
cancelled: msg.cancelled ?? true,
|
|
200
|
-
results: msg.results as Array<{ id: string; selectedOptions: string[]; customInput?: string }>,
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
process.on("message", listener);
|
|
205
|
-
|
|
206
|
-
// 5-minute timeout.
|
|
207
|
-
const timer = setTimeout(() => {
|
|
208
|
-
finish({ cancelled: true, results: questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
|
|
209
|
-
}, 5 * 60 * 1000);
|
|
210
|
-
timer.unref();
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
// Build results matching the existing ask tool's format.
|
|
214
|
-
const results: QuestionResult[] = questions.map((q, i) => {
|
|
215
|
-
const r = response.results[i];
|
|
216
|
-
return {
|
|
217
|
-
id: q.id,
|
|
218
|
-
question: q.question,
|
|
219
|
-
description: q.description && q.description.trim().length > 0 ? q.description : undefined,
|
|
220
|
-
options: q.options.map((o) => o.label),
|
|
221
|
-
multi: q.multi ?? false,
|
|
222
|
-
selectedOptions: r?.selectedOptions ?? [],
|
|
223
|
-
customInput: r?.customInput ?? undefined,
|
|
224
|
-
};
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
const contentText = buildAskSessionContent(results);
|
|
228
|
-
|
|
229
|
-
if (response.cancelled && results.every((r) => r.selectedOptions.length === 0)) {
|
|
230
|
-
return {
|
|
231
|
-
content: [{ type: "text", text: "User cancelled the question." }],
|
|
232
|
-
details: {},
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
return {
|
|
237
|
-
content: [{ type: "text", text: contentText }],
|
|
238
|
-
details: {},
|
|
239
|
-
};
|
|
240
|
-
},
|
|
241
|
-
};
|
|
242
|
-
}
|
package/src/ipc-types.ts
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared IPC message types for fork+IPC subagent architecture.
|
|
3
|
-
*
|
|
4
|
-
* Parent sends "init" first, then "ask_response" / "abort".
|
|
5
|
-
* Child sends "ready" after init, then "event" / "ask_request" / "error".
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
// ── Parent → Child ──────────────────────────────────────────────────────────
|
|
9
|
-
|
|
10
|
-
export type ParentToChild =
|
|
11
|
-
| {
|
|
12
|
-
type: "ask_response";
|
|
13
|
-
requestId: string;
|
|
14
|
-
cancelled: boolean;
|
|
15
|
-
results: Array<{
|
|
16
|
-
id: string;
|
|
17
|
-
selectedOptions: string[];
|
|
18
|
-
customInput?: string;
|
|
19
|
-
}>;
|
|
20
|
-
}
|
|
21
|
-
| {
|
|
22
|
-
type: "abort";
|
|
23
|
-
}
|
|
24
|
-
| {
|
|
25
|
-
type: "init";
|
|
26
|
-
task: string;
|
|
27
|
-
model?: string;
|
|
28
|
-
agentName?: string;
|
|
29
|
-
agentSystemPrompt?: string;
|
|
30
|
-
agentTools?: string[];
|
|
31
|
-
agentModel?: string;
|
|
32
|
-
agentThinking?: string;
|
|
33
|
-
cwd?: string;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
// ── Serialized agent events ─────────────────────────────────────────────────
|
|
37
|
-
// Matches the event types forwarded by stream.ts. Nested fields use `unknown`
|
|
38
|
-
// because they contain complex objects (AgentMessage, etc.) serialized via
|
|
39
|
-
// JSON.parse(JSON.stringify()). The key point is typing the `type` discriminator
|
|
40
|
-
// and the top-level field names.
|
|
41
|
-
|
|
42
|
-
export type SerializedAgentEvent =
|
|
43
|
-
| { type: "agent_start" }
|
|
44
|
-
| { type: "agent_end"; messages: unknown[]; willRetry?: boolean }
|
|
45
|
-
| { type: "turn_start" }
|
|
46
|
-
| { type: "turn_end"; message: unknown; toolResults: unknown[] }
|
|
47
|
-
| { type: "message_start"; message: unknown }
|
|
48
|
-
| { type: "message_update"; message: unknown; assistantMessageEvent: unknown }
|
|
49
|
-
| { type: "message_end"; message: unknown }
|
|
50
|
-
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: unknown }
|
|
51
|
-
| { type: "tool_execution_update"; toolCallId: string; toolName: string; args: unknown; partialResult: unknown }
|
|
52
|
-
| { type: "tool_execution_end"; toolCallId: string; toolName: string; result: unknown; isError: boolean };
|
|
53
|
-
|
|
54
|
-
// ── Child → Parent ──────────────────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
export type ChildToParent =
|
|
57
|
-
| {
|
|
58
|
-
type: "event";
|
|
59
|
-
event: SerializedAgentEvent;
|
|
60
|
-
}
|
|
61
|
-
| {
|
|
62
|
-
type: "ask_request";
|
|
63
|
-
requestId: string;
|
|
64
|
-
questions: Array<{
|
|
65
|
-
id: string;
|
|
66
|
-
question: string;
|
|
67
|
-
description?: string;
|
|
68
|
-
options: Array<{ label: string }>;
|
|
69
|
-
multi?: boolean;
|
|
70
|
-
recommended?: number;
|
|
71
|
-
}>;
|
|
72
|
-
}
|
|
73
|
-
| {
|
|
74
|
-
type: "ready";
|
|
75
|
-
}
|
|
76
|
-
| {
|
|
77
|
-
type: "error";
|
|
78
|
-
message: string;
|
|
79
|
-
};
|