@pi-archimedes/subagent 1.2.2 → 1.3.1
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/child.ts +299 -0
- package/src/handlers.ts +12 -9
- package/src/index.ts +2 -2
- package/src/ipc-ask-tool.ts +242 -0
- package/src/ipc-types.ts +79 -0
- package/src/spawn.ts +78 -114
- package/src/stream.ts +17 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/subagent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
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.
|
|
14
|
+
"@pi-archimedes/core": "1.3.1"
|
|
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/child.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
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/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) => {
|
|
@@ -0,0 +1,242 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
};
|
package/src/spawn.ts
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import { fork, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
5
|
+
import type { ParentToChild, ChildToParent } from "./ipc-types.js";
|
|
6
6
|
import type { AgentConfig } from "./agents.js";
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
process.on("exit", () => {
|
|
14
|
-
for (const dir of tempDirs) {
|
|
15
|
-
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
16
|
-
}
|
|
17
|
-
});
|
|
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");
|
|
18
12
|
|
|
19
13
|
export interface SpawnOptions {
|
|
20
14
|
task: string;
|
|
@@ -26,117 +20,87 @@ export interface SpawnOptions {
|
|
|
26
20
|
}
|
|
27
21
|
|
|
28
22
|
/**
|
|
29
|
-
*
|
|
30
|
-
*/
|
|
31
|
-
export function resolvePiCommand(): { command: string; args: string[] } {
|
|
32
|
-
let piPath: string;
|
|
33
|
-
try {
|
|
34
|
-
piPath = execSync(process.platform === "win32" ? 'where pi' : 'which pi', {
|
|
35
|
-
encoding: "utf-8",
|
|
36
|
-
}).trim();
|
|
37
|
-
} catch {
|
|
38
|
-
throw new Error("pi command not found on PATH. Is pi installed?");
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// On Windows, the CLI might be a .cmd script
|
|
42
|
-
if (process.platform === "win32" && !piPath.endsWith(".cmd") && !piPath.endsWith(".exe")) {
|
|
43
|
-
const cmdPath = piPath + ".cmd";
|
|
44
|
-
if (existsSync(cmdPath)) {
|
|
45
|
-
return { command: "cmd", args: ["/c", cmdPath] };
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return { command: piPath, args: [] };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Write system prompt to a temp file for --append-system-prompt.
|
|
53
|
-
*/
|
|
54
|
-
function writePromptToFile(agentName: string, prompt: string): { dir: string; filePath: string } {
|
|
55
|
-
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
56
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "pi-subagent-"));
|
|
57
|
-
tempDirs.add(tmpDir);
|
|
58
|
-
const filePath = join(tmpDir, `prompt-${safeName}.md`);
|
|
59
|
-
writeFileSync(filePath, prompt, { encoding: "utf-8" });
|
|
60
|
-
return { dir: tmpDir, filePath };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Clean up temp prompt file and directory.
|
|
65
|
-
*/
|
|
66
|
-
function cleanupTempFiles(dir: string | null, filePath: string | null): void {
|
|
67
|
-
if (filePath) {
|
|
68
|
-
try { unlinkSync(filePath); } catch { /* ignore */ }
|
|
69
|
-
}
|
|
70
|
-
if (dir) {
|
|
71
|
-
try {
|
|
72
|
-
rmdirSync(dir);
|
|
73
|
-
tempDirs.delete(dir);
|
|
74
|
-
} catch { /* leave in Set so exit handler can retry with rmSync */ }
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Spawn a child `pi` process in JSON mode.
|
|
23
|
+
* Spawn a child process via fork() with IPC channel.
|
|
80
24
|
*/
|
|
81
25
|
export function spawnSubagent(options: SpawnOptions): ChildProcess {
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
"-
|
|
87
|
-
"
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
// Resolve model with correct priority:
|
|
91
|
-
// 1. frontmatter model (agent.model)
|
|
92
|
-
// 2. explicit tool-call model (options.model)
|
|
93
|
-
// 3. currently active model (options.activeModel)
|
|
94
|
-
// 4. no --model flag → pi default
|
|
95
|
-
const modelToUse = options.agent?.model ?? options.model ?? options.activeModel;
|
|
96
|
-
if (modelToUse) {
|
|
97
|
-
args.push("--model", modelToUse);
|
|
98
|
-
}
|
|
26
|
+
// Fork the child script with IPC channel
|
|
27
|
+
const child = fork(childScriptPath, [], {
|
|
28
|
+
cwd: options.cwd || process.cwd(),
|
|
29
|
+
env: { ...process.env },
|
|
30
|
+
execArgv: ["--experimental-strip-types"],
|
|
31
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
32
|
+
});
|
|
99
33
|
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
34
|
+
// Send init message to child with all configuration
|
|
35
|
+
// Model priority: agent.model > options.model > options.activeModel
|
|
36
|
+
const model = options.agent?.model ?? options.model ?? options.activeModel;
|
|
37
|
+
const initBase: Partial<ParentToChild> & { type: "init"; task: string } = {
|
|
38
|
+
type: "init",
|
|
39
|
+
task: options.task,
|
|
40
|
+
};
|
|
41
|
+
if (model) initBase.model = model;
|
|
42
|
+
if (options.agent?.name) initBase.agentName = options.agent.name;
|
|
43
|
+
if (options.agent?.systemPrompt) initBase.agentSystemPrompt = options.agent.systemPrompt;
|
|
44
|
+
if (options.agent?.tools) initBase.agentTools = options.agent.tools;
|
|
45
|
+
if (options.agent?.model) initBase.agentModel = options.agent.model;
|
|
46
|
+
if (options.agent?.thinking) initBase.agentThinking = options.agent.thinking;
|
|
47
|
+
if (options.cwd) initBase.cwd = options.cwd;
|
|
48
|
+
child.send(initBase as ParentToChild);
|
|
49
|
+
|
|
50
|
+
// Handle messages from child
|
|
51
|
+
child.on("message", (msg: ChildToParent) => {
|
|
52
|
+
switch (msg.type) {
|
|
53
|
+
case "event": {
|
|
54
|
+
// Forward events to streamEvents via the child's message handler
|
|
55
|
+
// (streamEvents attaches its own listener)
|
|
56
|
+
break;
|
|
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;
|
|
74
|
+
}
|
|
108
75
|
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Temp files for system prompt
|
|
112
|
-
let tmpDir: string | null = null;
|
|
113
|
-
let tmpPath: string | null = null;
|
|
114
|
-
|
|
115
|
-
if (options.agent && options.agent.systemPrompt.trim()) {
|
|
116
|
-
const tmp = writePromptToFile(options.agent.name, options.agent.systemPrompt);
|
|
117
|
-
tmpDir = tmp.dir;
|
|
118
|
-
tmpPath = tmp.filePath;
|
|
119
|
-
args.push("--append-system-prompt", tmpPath);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
args.push(options.task);
|
|
76
|
+
});
|
|
123
77
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
78
|
+
// Handle ask responses from bus → send to child via IPC
|
|
79
|
+
const unsubAskResponse = getBus().on(Events.ASK_RESPONSE, (payload: unknown) => {
|
|
80
|
+
const data = payload as {
|
|
81
|
+
requestId: string;
|
|
82
|
+
cancelled: boolean;
|
|
83
|
+
results: Array<{ id: string; selectedOptions: string[]; customInput?: string }>;
|
|
84
|
+
};
|
|
85
|
+
const responseMsg: ParentToChild = {
|
|
86
|
+
type: "ask_response",
|
|
87
|
+
requestId: data.requestId,
|
|
88
|
+
cancelled: data.cancelled,
|
|
89
|
+
results: data.results,
|
|
90
|
+
};
|
|
91
|
+
child.send(responseMsg);
|
|
128
92
|
});
|
|
129
93
|
|
|
130
94
|
// Handle abort signal
|
|
131
95
|
let abortHandler: (() => void) | undefined;
|
|
132
96
|
if (options.signal) {
|
|
133
97
|
abortHandler = () => {
|
|
98
|
+
// Send abort message to child before killing
|
|
99
|
+
child.send({ type: "abort" });
|
|
134
100
|
if (child.pid && !child.killed) {
|
|
135
101
|
child.kill("SIGTERM");
|
|
136
102
|
const forceKill = setTimeout(() => {
|
|
137
|
-
if (!child.killed)
|
|
138
|
-
child.kill("SIGKILL");
|
|
139
|
-
}
|
|
103
|
+
if (!child.killed) child.kill("SIGKILL");
|
|
140
104
|
}, 3000);
|
|
141
105
|
forceKill.unref();
|
|
142
106
|
}
|
|
@@ -144,14 +108,14 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
|
|
|
144
108
|
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
145
109
|
}
|
|
146
110
|
|
|
147
|
-
// Clean up
|
|
111
|
+
// Clean up on exit
|
|
148
112
|
const exitCleanup = (): void => {
|
|
149
113
|
child.removeListener("exit", exitCleanup);
|
|
150
114
|
child.removeListener("error", exitCleanup);
|
|
151
115
|
if (abortHandler && options.signal) {
|
|
152
116
|
options.signal.removeEventListener("abort", abortHandler);
|
|
153
117
|
}
|
|
154
|
-
|
|
118
|
+
unsubAskResponse();
|
|
155
119
|
};
|
|
156
120
|
child.on("exit", exitCleanup);
|
|
157
121
|
child.on("error", exitCleanup);
|
package/src/stream.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createInterface } from "node:readline";
|
|
2
1
|
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 {
|
|
@@ -97,26 +97,21 @@ export function streamEvents(
|
|
|
97
97
|
// Periodic progress updates for live duration display
|
|
98
98
|
const heartbeat = setInterval(emitProgress, 1000);
|
|
99
99
|
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
stderrParts.push(data.toString());
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
// Parse JSON lines from stdout
|
|
107
|
-
const rl = createInterface({
|
|
108
|
-
input: child.stdout!,
|
|
109
|
-
crlfDelay: Infinity,
|
|
110
|
-
});
|
|
100
|
+
// Listen for IPC messages from child
|
|
101
|
+
child.on("message", (msg: unknown) => {
|
|
102
|
+
const childMsg = msg as ChildToParent;
|
|
111
103
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
} catch {
|
|
117
|
-
return; // Skip non-JSON lines
|
|
104
|
+
// Handle error messages from child
|
|
105
|
+
if (childMsg.type === "error") {
|
|
106
|
+
error = childMsg.message;
|
|
107
|
+
return;
|
|
118
108
|
}
|
|
119
109
|
|
|
110
|
+
// Only process "event" messages (AgentSessionEvent forwarded from child)
|
|
111
|
+
if (childMsg.type !== "event") return;
|
|
112
|
+
|
|
113
|
+
const event = childMsg.event as JsonEvent;
|
|
114
|
+
|
|
120
115
|
// First event received from the child means the model has engaged —
|
|
121
116
|
// from here on, the user controls lifetime via the abort signal.
|
|
122
117
|
clearStartupTimer();
|
|
@@ -141,17 +136,15 @@ export function streamEvents(
|
|
|
141
136
|
case "tool_execution_end": {
|
|
142
137
|
handleToolEnd(state);
|
|
143
138
|
emitProgress();
|
|
139
|
+
// Also capture tool result output here (previously in tool_result_end)
|
|
140
|
+
handleToolResult(state, event);
|
|
141
|
+
emitProgress();
|
|
144
142
|
break;
|
|
145
143
|
}
|
|
146
144
|
case "turn_start": {
|
|
147
145
|
state.turnCount++;
|
|
148
146
|
break;
|
|
149
147
|
}
|
|
150
|
-
case "tool_result_end": {
|
|
151
|
-
handleToolResult(state, event);
|
|
152
|
-
emitProgress();
|
|
153
|
-
break;
|
|
154
|
-
}
|
|
155
148
|
case "message_end": {
|
|
156
149
|
handleMessageEnd(state, event);
|
|
157
150
|
emitProgress();
|
|
@@ -171,9 +164,7 @@ export function streamEvents(
|
|
|
171
164
|
const durationMs = Date.now() - startTime;
|
|
172
165
|
const exitCode = code ?? 1;
|
|
173
166
|
|
|
174
|
-
|
|
175
|
-
error = stderrParts.join("").trim();
|
|
176
|
-
}
|
|
167
|
+
// Error is set via IPC error messages or remains undefined
|
|
177
168
|
|
|
178
169
|
const result: SubagentResult = {
|
|
179
170
|
agent: callbacks.agent ?? "subagent",
|