@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/subagent",
3
- "version": "1.2.2",
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.2.2"
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.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 tool_result_end event — capture tool output for live display.
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 toolMessage = event.message as Record<string, unknown> | undefined;
74
- if (!toolMessage || toolMessage.role !== "toolResult") return;
74
+ const result = event.result as Record<string, unknown> | undefined;
75
+ if (!result) return;
75
76
 
76
- const toolContent = toolMessage.content as Array<Record<string, unknown>> | string | undefined;
77
- const toolName = (toolMessage.toolName as string) ?? "tool";
77
+ const toolName = (event.toolName as string) ?? "tool";
78
78
 
79
- if (typeof toolContent === "string" && toolContent.trim()) {
80
- const lines = toolContent.split("\n").filter((l) => l.trim());
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(toolContent)) {
83
- for (const part of toolContent) {
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?.id,
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?.id,
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
+ }
@@ -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 { spawn, type ChildProcess } from "node:child_process";
2
- import { execSync } from "node:child_process";
3
- import { existsSync, writeFileSync, unlinkSync, mkdtempSync, rmdirSync, rmSync } from "node:fs";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
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
- // Track all temp dirs for cleanup on process exit (graceful shutdown only).
9
- // Note: SIGKILL/OOM cannot be caught — the exit handler only fires for
10
- // normal exits and catchable signals (SIGTERM, SIGINT, SIGHUP, etc.).
11
- const tempDirs = new Set<string>();
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
- * Resolve the `pi` command finds it on PATH.
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
- const { command, args: baseArgs } = resolvePiCommand();
83
- const args: string[] = [
84
- ...baseArgs,
85
- "--mode", "json",
86
- "-p",
87
- "--no-session",
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
- // Apply other agent config options
101
- if (options.agent) {
102
- const agent = options.agent;
103
- if (agent.thinking) {
104
- args.push("--thinking", agent.thinking);
105
- }
106
- if (agent.tools && agent.tools.length > 0) {
107
- args.push("--tools", agent.tools.join(","));
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
- const child = spawn(command, args, {
125
- cwd: options.cwd || process.cwd(),
126
- stdio: ["ignore", "pipe", "pipe"],
127
- env: process.env,
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 temp files and listeners on exit
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
- cleanupTempFiles(tmpDir, tmpPath);
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
- // Collect stderr
101
- const stderrParts: string[] = [];
102
- child.stderr?.on("data", (data: Buffer) => {
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
- rl.on("line", (line: string) => {
113
- let event: JsonEvent;
114
- try {
115
- event = JSON.parse(line);
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
- if (stderrParts.length > 0 && exitCode !== 0) {
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",