@pi-archimedes/subagent 1.3.0 → 1.3.2
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 +4 -3
- package/src/handlers.ts +12 -9
- package/src/index.ts +2 -2
- package/src/spawn.ts +165 -169
- package/src/stream.ts +47 -43
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/subagent",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
],
|
|
12
12
|
"main": "./src/index.ts",
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@pi-archimedes/core": "1.3.
|
|
14
|
+
"@pi-archimedes/core": "1.3.2"
|
|
15
15
|
},
|
|
16
16
|
"peerDependencies": {
|
|
17
|
+
"@earendil-works/pi-ai": ">=0.1.0",
|
|
17
18
|
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
|
18
19
|
"@earendil-works/pi-tui": ">=0.1.0",
|
|
19
20
|
"typebox": ">=1.1.0"
|
|
@@ -21,7 +22,7 @@
|
|
|
21
22
|
"devDependencies": {
|
|
22
23
|
"@types/node": "^22.0.0",
|
|
23
24
|
"typebox": "^1.1.38",
|
|
24
|
-
"typescript": "^6.0.
|
|
25
|
+
"typescript": "^6.0.3"
|
|
25
26
|
},
|
|
26
27
|
"pi": {
|
|
27
28
|
"extensions": [
|
package/src/handlers.ts
CHANGED
|
@@ -67,20 +67,23 @@ export function handleToolEnd(state: StreamState): void {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
* Handle a
|
|
70
|
+
* Handle a tool_execution_end event — capture tool result output for live display.
|
|
71
|
+
* The result is in event.result (the tool's return value).
|
|
71
72
|
*/
|
|
72
73
|
export function handleToolResult(state: StreamState, event: JsonEvent): void {
|
|
73
|
-
const
|
|
74
|
-
if (!
|
|
74
|
+
const result = event.result as Record<string, unknown> | undefined;
|
|
75
|
+
if (!result) return;
|
|
75
76
|
|
|
76
|
-
const
|
|
77
|
-
const toolName = (toolMessage.toolName as string) ?? "tool";
|
|
77
|
+
const toolName = (event.toolName as string) ?? "tool";
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
// Extract text content from tool result
|
|
80
|
+
const content = result.content as Array<Record<string, unknown>> | string | undefined;
|
|
81
|
+
|
|
82
|
+
if (typeof content === "string" && content.trim()) {
|
|
83
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
81
84
|
state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0, ARGS_PREVIEW_MAX)}`);
|
|
82
|
-
} else if (Array.isArray(
|
|
83
|
-
for (const part of
|
|
85
|
+
} else if (Array.isArray(content)) {
|
|
86
|
+
for (const part of content) {
|
|
84
87
|
if (part.type === "text" && (part.text as string)?.trim()) {
|
|
85
88
|
const text = part.text as string;
|
|
86
89
|
const lines = text.split("\n").filter((l) => l.trim());
|
package/src/index.ts
CHANGED
|
@@ -98,7 +98,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
|
|
|
98
98
|
agentConfig: t.agent ? findAgent(agents, t.agent) : undefined,
|
|
99
99
|
task: t.task,
|
|
100
100
|
model: t.model,
|
|
101
|
-
activeModel: ctx.model
|
|
101
|
+
activeModel: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
|
|
102
102
|
cwd: t.cwd ?? undefined,
|
|
103
103
|
})),
|
|
104
104
|
signal: signal ?? undefined,
|
|
@@ -148,7 +148,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
|
|
|
148
148
|
agentConfig,
|
|
149
149
|
task: params.task,
|
|
150
150
|
model: params.model,
|
|
151
|
-
activeModel: ctx.model
|
|
151
|
+
activeModel: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
|
|
152
152
|
cwd: params.cwd ?? undefined,
|
|
153
153
|
signal: signal ?? undefined,
|
|
154
154
|
onUpdate: (progress: SubagentProgress) => {
|
package/src/spawn.ts
CHANGED
|
@@ -1,23 +1,12 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
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";
|
|
7
7
|
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
8
8
|
import type { AgentConfig } from "./agents.js";
|
|
9
9
|
|
|
10
|
-
// Track all temp dirs for cleanup on process exit (graceful shutdown only).
|
|
11
|
-
// Note: SIGKILL/OOM cannot be caught — the exit handler only fires for
|
|
12
|
-
// normal exits and catchable signals (SIGTERM, SIGINT, SIGHUP, etc.).
|
|
13
|
-
const tempDirs = new Set<string>();
|
|
14
|
-
|
|
15
|
-
process.on("exit", () => {
|
|
16
|
-
for (const dir of tempDirs) {
|
|
17
|
-
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
18
|
-
}
|
|
19
|
-
});
|
|
20
|
-
|
|
21
10
|
export interface SpawnOptions {
|
|
22
11
|
task: string;
|
|
23
12
|
model: string | undefined;
|
|
@@ -28,209 +17,216 @@ export interface SpawnOptions {
|
|
|
28
17
|
}
|
|
29
18
|
|
|
30
19
|
/**
|
|
31
|
-
* Resolve the
|
|
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.
|
|
32
25
|
*/
|
|
33
|
-
|
|
34
|
-
let piPath: string;
|
|
26
|
+
function resolvePiBinary(): string {
|
|
35
27
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
28
|
+
const entry = process.argv[1];
|
|
29
|
+
if (!entry) return "pi";
|
|
30
|
+
|
|
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
|
|
55
|
+
}
|
|
56
|
+
const parent = path.dirname(dir);
|
|
57
|
+
if (parent === dir) break;
|
|
58
|
+
dir = parent;
|
|
48
59
|
}
|
|
60
|
+
} catch {
|
|
61
|
+
// fall through
|
|
49
62
|
}
|
|
50
|
-
return
|
|
63
|
+
return "pi";
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
/**
|
|
54
|
-
*
|
|
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.
|
|
55
79
|
*/
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
80
|
+
function startAskSocketServer(agentName: string): { socketPath: string; cleanup: () => void } {
|
|
81
|
+
// Keep the socket path short — Linux limit is 108 chars.
|
|
82
|
+
const socketPath = path.join(os.tmpdir(), `pi-ask-${randomUUID().slice(0, 8)}.sock`);
|
|
83
|
+
|
|
84
|
+
// Map of pending ask requests: requestId → write-back callback
|
|
85
|
+
const pending = new Map<string, (response: unknown) => void>();
|
|
86
|
+
|
|
87
|
+
// Listen for ASK_RESPONSE from the bus and route back to the waiting socket conn
|
|
88
|
+
const unsubResponse = getBus().on(Events.ASK_RESPONSE, (payload: unknown) => {
|
|
89
|
+
const data = payload as {
|
|
90
|
+
requestId: string;
|
|
91
|
+
cancelled: boolean;
|
|
92
|
+
results: Array<{ id: string; selectedOptions: string[]; customInput?: string }>;
|
|
93
|
+
};
|
|
94
|
+
const send = pending.get(data.requestId);
|
|
95
|
+
if (send) {
|
|
96
|
+
pending.delete(data.requestId);
|
|
97
|
+
send({
|
|
98
|
+
type: "ask_response",
|
|
99
|
+
requestId: data.requestId,
|
|
100
|
+
cancelled: data.cancelled,
|
|
101
|
+
results: data.results,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
79
105
|
|
|
80
|
-
|
|
81
|
-
* Spawn a child `pi` process in JSON mode.
|
|
82
|
-
*/
|
|
83
|
-
export function spawnSubagent(options: SpawnOptions): ChildProcess {
|
|
84
|
-
const { command, args: baseArgs } = resolvePiCommand();
|
|
85
|
-
const args: string[] = [
|
|
86
|
-
...baseArgs,
|
|
87
|
-
"--mode", "json",
|
|
88
|
-
"-p",
|
|
89
|
-
"--no-session",
|
|
90
|
-
];
|
|
91
|
-
|
|
92
|
-
// Create IPC socket for ask tool responses.
|
|
93
|
-
// On Unix: filesystem Unix domain socket. On Windows: named pipe (\\.\pipe\).
|
|
94
|
-
const socketName = `pi-subagent-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
95
|
-
const socketPath = process.platform === "win32"
|
|
96
|
-
? `\\\\.\\pipe\\${socketName}`
|
|
97
|
-
: join(tmpdir(), socketName);
|
|
98
|
-
// Only Unix socket files need pre-unlinking; Windows named pipes auto-clean.
|
|
99
|
-
if (process.platform !== "win32") {
|
|
100
|
-
try { unlinkSync(socketPath); } catch { /* doesn't exist yet */ }
|
|
101
|
-
}
|
|
102
|
-
let clientSocket: Socket | undefined;
|
|
103
|
-
const pendingAsks = new Map<string, Socket>();
|
|
104
|
-
const server = createServer((socket: Socket) => {
|
|
105
|
-
clientSocket = socket;
|
|
106
|
-
(child as ChildProcess & { clientSocket: Socket | undefined }).clientSocket = socket;
|
|
107
|
-
// Parse incoming lines from the child's ask tool (ask_request) and forward to bus
|
|
106
|
+
const server = net.createServer((socket) => {
|
|
108
107
|
let buffer = "";
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
|
|
109
|
+
socket.on("data", (chunk: Buffer) => {
|
|
110
|
+
buffer += chunk.toString("utf-8");
|
|
111
111
|
const lines = buffer.split("\n");
|
|
112
112
|
buffer = lines.pop() ?? "";
|
|
113
|
+
|
|
113
114
|
for (const line of lines) {
|
|
115
|
+
const trimmed = line.trim();
|
|
116
|
+
if (!trimmed) continue;
|
|
114
117
|
try {
|
|
115
|
-
const msg = JSON.parse(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
const msg = JSON.parse(trimmed) as {
|
|
119
|
+
type: string;
|
|
120
|
+
requestId: string;
|
|
121
|
+
questions: unknown[];
|
|
122
|
+
};
|
|
123
|
+
if (msg.type === "ask_request") {
|
|
124
|
+
// Register write-back so ASK_RESPONSE handler can find this socket
|
|
125
|
+
pending.set(msg.requestId, (response) => {
|
|
126
|
+
try {
|
|
127
|
+
socket.write(JSON.stringify(response) + "\n");
|
|
128
|
+
} catch {
|
|
129
|
+
// socket already closed
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
// Forward to bus — ask package will show the TUI dialog
|
|
118
133
|
getBus().emit(Events.ASK_REQUEST, {
|
|
119
|
-
source: `subagent:${
|
|
134
|
+
source: `subagent:${agentName}`,
|
|
120
135
|
requestId: msg.requestId,
|
|
121
136
|
questions: msg.questions,
|
|
122
137
|
});
|
|
123
138
|
}
|
|
124
|
-
} catch {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
socket.on("close", () => {
|
|
128
|
-
clientSocket = undefined;
|
|
129
|
-
(child as ChildProcess & { clientSocket: Socket | undefined }).clientSocket = undefined;
|
|
130
|
-
// Clean up any pending asks tied to this socket
|
|
131
|
-
for (const [id, s] of pendingAsks) {
|
|
132
|
-
if (s === socket) pendingAsks.delete(id);
|
|
139
|
+
} catch {
|
|
140
|
+
// malformed JSON — ignore
|
|
141
|
+
}
|
|
133
142
|
}
|
|
134
143
|
});
|
|
144
|
+
|
|
145
|
+
socket.on("error", () => { /* connection dropped */ });
|
|
135
146
|
});
|
|
136
|
-
server.listen(socketPath); // async, non-blocking
|
|
137
|
-
|
|
138
|
-
// Write ask responses back to the child over the originating socket
|
|
139
|
-
const unsubAskResponse = getBus().on(Events.ASK_RESPONSE, (payload: unknown) => {
|
|
140
|
-
const data = payload as { requestId: string; cancelled: boolean; results: Array<{ id: string; selectedOptions: string[]; customInput?: string }> };
|
|
141
|
-
const socket = pendingAsks.get(data.requestId);
|
|
142
|
-
if (socket && socket.writable) {
|
|
143
|
-
pendingAsks.delete(data.requestId);
|
|
144
|
-
socket.write(JSON.stringify({
|
|
145
|
-
type: "ask_response",
|
|
146
|
-
requestId: data.requestId,
|
|
147
|
-
cancelled: data.cancelled,
|
|
148
|
-
results: data.results,
|
|
149
|
-
}) + "\n");
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
server.on("close", () => {
|
|
153
|
-
if (process.platform !== "win32") {
|
|
154
|
-
try { unlinkSync(socketPath); } catch { /* ignore */ }
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
147
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
148
|
+
server.listen(socketPath);
|
|
149
|
+
|
|
150
|
+
const cleanup = () => {
|
|
151
|
+
unsubResponse();
|
|
152
|
+
server.close();
|
|
153
|
+
try { fs.unlinkSync(socketPath); } catch { /* already gone */ }
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return { socketPath, cleanup };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Spawn a subagent as a fresh `pi --mode json --no-session -p <task>` process.
|
|
161
|
+
*
|
|
162
|
+
* A Unix socket server is started in the parent to bridge the child's ask tool
|
|
163
|
+
* back to the parent's TUI dialog. The socket path is passed via PI_SUBAGENT_SOCKET.
|
|
164
|
+
*/
|
|
165
|
+
export function spawnSubagent(options: SpawnOptions): ChildProcess {
|
|
166
|
+
const piBinary = resolvePiBinary();
|
|
167
|
+
const agentName = options.agent?.name ?? "general";
|
|
168
|
+
|
|
169
|
+
// Start ask bridge socket before spawning so the env var is ready
|
|
170
|
+
const { socketPath, cleanup: cleanupSocket } = startAskSocketServer(agentName);
|
|
171
|
+
|
|
172
|
+
// Build CLI args
|
|
173
|
+
const args: string[] = ["--mode", "json", "--no-session", "-p"];
|
|
174
|
+
|
|
175
|
+
// Model: agent.model > options.model > options.activeModel
|
|
176
|
+
const model = options.agent?.model ?? options.model ?? options.activeModel;
|
|
177
|
+
if (model) {
|
|
178
|
+
args.push("--model", model);
|
|
166
179
|
}
|
|
167
180
|
|
|
168
|
-
//
|
|
169
|
-
if (options.agent) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
181
|
+
// Thinking level from agent config
|
|
182
|
+
if (options.agent?.thinking) {
|
|
183
|
+
args.push("--thinking", options.agent.thinking);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Tool allowlist from agent config
|
|
187
|
+
if (options.agent?.tools && options.agent.tools.length > 0) {
|
|
188
|
+
args.push("--tools", options.agent.tools.join(","));
|
|
177
189
|
}
|
|
178
190
|
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
let tmpPath: string | null = null;
|
|
191
|
+
// Always exclude the subagent tool itself to prevent infinite recursion
|
|
192
|
+
args.push("--exclude-tools", "subagent");
|
|
182
193
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
args.push("--append-system-prompt", tmpPath);
|
|
194
|
+
// Agent system prompt
|
|
195
|
+
const systemPrompt = options.agent?.systemPrompt?.trim();
|
|
196
|
+
if (systemPrompt) {
|
|
197
|
+
args.push("--system-prompt", systemPrompt);
|
|
188
198
|
}
|
|
189
199
|
|
|
200
|
+
// The task is the final positional argument
|
|
190
201
|
args.push(options.task);
|
|
191
202
|
|
|
192
|
-
const child = spawn(
|
|
203
|
+
const child = spawn(piBinary, args, {
|
|
193
204
|
cwd: options.cwd || process.cwd(),
|
|
194
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
195
205
|
env: {
|
|
196
206
|
...process.env,
|
|
197
207
|
PI_SUBAGENT_SOCKET: socketPath,
|
|
198
208
|
},
|
|
209
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
199
210
|
});
|
|
200
211
|
|
|
212
|
+
// Clean up socket server when child exits
|
|
213
|
+
child.on("exit", cleanupSocket);
|
|
214
|
+
child.on("error", cleanupSocket);
|
|
215
|
+
|
|
201
216
|
// Handle abort signal
|
|
202
|
-
let abortHandler: (() => void) | undefined;
|
|
203
217
|
if (options.signal) {
|
|
204
|
-
abortHandler = () => {
|
|
218
|
+
const abortHandler = () => {
|
|
205
219
|
if (child.pid && !child.killed) {
|
|
206
220
|
child.kill("SIGTERM");
|
|
207
221
|
const forceKill = setTimeout(() => {
|
|
208
|
-
if (!child.killed)
|
|
209
|
-
child.kill("SIGKILL");
|
|
210
|
-
}
|
|
222
|
+
if (!child.killed) child.kill("SIGKILL");
|
|
211
223
|
}, 3000);
|
|
212
224
|
forceKill.unref();
|
|
213
225
|
}
|
|
214
226
|
};
|
|
215
227
|
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
228
|
+
child.on("exit", () => options.signal!.removeEventListener("abort", abortHandler));
|
|
216
229
|
}
|
|
217
230
|
|
|
218
|
-
// Clean up temp files and listeners on exit
|
|
219
|
-
const exitCleanup = (): void => {
|
|
220
|
-
child.removeListener("exit", exitCleanup);
|
|
221
|
-
child.removeListener("error", exitCleanup);
|
|
222
|
-
if (abortHandler && options.signal) {
|
|
223
|
-
options.signal.removeEventListener("abort", abortHandler);
|
|
224
|
-
}
|
|
225
|
-
server.close();
|
|
226
|
-
unsubAskResponse();
|
|
227
|
-
if (clientSocket) clientSocket.destroy();
|
|
228
|
-
cleanupTempFiles(tmpDir, tmpPath);
|
|
229
|
-
};
|
|
230
|
-
child.on("exit", exitCleanup);
|
|
231
|
-
child.on("error", exitCleanup);
|
|
232
|
-
|
|
233
|
-
// Attach client socket reference to child for streamEvents
|
|
234
|
-
(child as ChildProcess & { clientSocket: Socket | undefined }).clientSocket = clientSocket;
|
|
235
231
|
return child;
|
|
236
232
|
}
|
package/src/stream.ts
CHANGED
|
@@ -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,48 +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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
+
}
|
|
105
111
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
112
|
+
const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
113
|
+
|
|
114
|
+
rl.on("line", (line) => {
|
|
115
|
+
const trimmed = line.trim();
|
|
116
|
+
if (!trimmed) return;
|
|
111
117
|
|
|
112
|
-
rl.on("line", (line: string) => {
|
|
113
118
|
let event: JsonEvent;
|
|
114
119
|
try {
|
|
115
|
-
event = JSON.parse(
|
|
120
|
+
event = JSON.parse(trimmed) as JsonEvent;
|
|
116
121
|
} catch {
|
|
117
|
-
|
|
122
|
+
// Non-JSON output — ignore (can happen from pi startup messages)
|
|
123
|
+
return;
|
|
118
124
|
}
|
|
119
125
|
|
|
120
|
-
// First event
|
|
121
|
-
// from here on, the user controls lifetime via the abort signal.
|
|
126
|
+
// First real event means model has engaged — cancel startup watchdog
|
|
122
127
|
clearStartupTimer();
|
|
123
128
|
|
|
124
129
|
switch (event.type) {
|
|
125
130
|
case "tool_execution_start": {
|
|
126
131
|
handleToolStart(state, event);
|
|
127
132
|
emitProgress();
|
|
128
|
-
// Forward manage_todo_list writes to the
|
|
133
|
+
// Forward manage_todo_list writes to the parent's todo widget
|
|
129
134
|
if (event.toolName === "manage_todo_list") {
|
|
130
135
|
const args = event.args as Record<string, unknown> | undefined;
|
|
131
136
|
const todoList = args?.todoList as Array<unknown> | undefined;
|
|
@@ -141,17 +146,14 @@ export function streamEvents(
|
|
|
141
146
|
case "tool_execution_end": {
|
|
142
147
|
handleToolEnd(state);
|
|
143
148
|
emitProgress();
|
|
149
|
+
handleToolResult(state, event);
|
|
150
|
+
emitProgress();
|
|
144
151
|
break;
|
|
145
152
|
}
|
|
146
153
|
case "turn_start": {
|
|
147
154
|
state.turnCount++;
|
|
148
155
|
break;
|
|
149
156
|
}
|
|
150
|
-
case "tool_result_end": {
|
|
151
|
-
handleToolResult(state, event);
|
|
152
|
-
emitProgress();
|
|
153
|
-
break;
|
|
154
|
-
}
|
|
155
157
|
case "message_end": {
|
|
156
158
|
handleMessageEnd(state, event);
|
|
157
159
|
emitProgress();
|
|
@@ -161,6 +163,7 @@ export function streamEvents(
|
|
|
161
163
|
handleAgentEnd(state, event);
|
|
162
164
|
break;
|
|
163
165
|
}
|
|
166
|
+
// Ignore: session, agent_start, message_start, message_update, turn_end, tool_execution_update
|
|
164
167
|
}
|
|
165
168
|
});
|
|
166
169
|
|
|
@@ -171,8 +174,10 @@ export function streamEvents(
|
|
|
171
174
|
const durationMs = Date.now() - startTime;
|
|
172
175
|
const exitCode = code ?? 1;
|
|
173
176
|
|
|
174
|
-
if
|
|
175
|
-
|
|
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;
|
|
176
181
|
}
|
|
177
182
|
|
|
178
183
|
const result: SubagentResult = {
|
|
@@ -202,10 +207,9 @@ export function streamEvents(
|
|
|
202
207
|
},
|
|
203
208
|
};
|
|
204
209
|
|
|
205
|
-
// Final progress update
|
|
206
210
|
callbacks.onProgress?.(result.progress!);
|
|
207
211
|
|
|
208
|
-
// Clear subagent todos from the bus on
|
|
212
|
+
// Clear subagent todos from the bus on exit
|
|
209
213
|
getBus().emit(Events.TODOS_CLEAR, {
|
|
210
214
|
source: `subagent:${callbacks.agent ?? "general"}`,
|
|
211
215
|
});
|