@pi-archimedes/subagent 1.3.0 → 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.3.0",
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.3.0"
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,22 +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 { createServer, type Socket } from "node:net";
5
- import { tmpdir } from "node:os";
6
- 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";
7
4
  import { getBus, Events } from "@pi-archimedes/core/bus";
5
+ import type { ParentToChild, ChildToParent } from "./ipc-types.js";
8
6
  import type { AgentConfig } from "./agents.js";
9
7
 
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
- });
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");
20
12
 
21
13
  export interface SpawnOptions {
22
14
  task: string;
@@ -28,186 +20,87 @@ export interface SpawnOptions {
28
20
  }
29
21
 
30
22
  /**
31
- * Resolve the `pi` command finds it on PATH.
32
- */
33
- export function resolvePiCommand(): { command: string; args: string[] } {
34
- let piPath: string;
35
- try {
36
- piPath = execSync(process.platform === "win32" ? 'where pi' : 'which pi', {
37
- encoding: "utf-8",
38
- }).trim();
39
- } catch {
40
- throw new Error("pi command not found on PATH. Is pi installed?");
41
- }
42
-
43
- // On Windows, the CLI might be a .cmd script
44
- if (process.platform === "win32" && !piPath.endsWith(".cmd") && !piPath.endsWith(".exe")) {
45
- const cmdPath = piPath + ".cmd";
46
- if (existsSync(cmdPath)) {
47
- return { command: "cmd", args: ["/c", cmdPath] };
48
- }
49
- }
50
- return { command: piPath, args: [] };
51
- }
52
-
53
- /**
54
- * Write system prompt to a temp file for --append-system-prompt.
55
- */
56
- function writePromptToFile(agentName: string, prompt: string): { dir: string; filePath: string } {
57
- const safeName = agentName.replace(/[^\w.-]+/g, "_");
58
- const tmpDir = mkdtempSync(join(tmpdir(), "pi-subagent-"));
59
- tempDirs.add(tmpDir);
60
- const filePath = join(tmpDir, `prompt-${safeName}.md`);
61
- writeFileSync(filePath, prompt, { encoding: "utf-8" });
62
- return { dir: tmpDir, filePath };
63
- }
64
-
65
- /**
66
- * Clean up temp prompt file and directory.
67
- */
68
- function cleanupTempFiles(dir: string | null, filePath: string | null): void {
69
- if (filePath) {
70
- try { unlinkSync(filePath); } catch { /* ignore */ }
71
- }
72
- if (dir) {
73
- try {
74
- rmdirSync(dir);
75
- tempDirs.delete(dir);
76
- } catch { /* leave in Set so exit handler can retry with rmSync */ }
77
- }
78
- }
79
-
80
- /**
81
- * Spawn a child `pi` process in JSON mode.
23
+ * Spawn a child process via fork() with IPC channel.
82
24
  */
83
25
  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
- ];
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
+ });
91
33
 
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
108
- let buffer = "";
109
- socket.on("data", (data: Buffer) => {
110
- buffer += data.toString();
111
- const lines = buffer.split("\n");
112
- buffer = lines.pop() ?? "";
113
- for (const line of lines) {
114
- try {
115
- const msg = JSON.parse(line);
116
- if (msg.type === "ask_request" && msg.requestId) {
117
- pendingAsks.set(msg.requestId, socket);
118
- getBus().emit(Events.ASK_REQUEST, {
119
- source: `subagent:${options.agent?.name ?? "general"}`,
120
- requestId: msg.requestId,
121
- questions: msg.questions,
122
- });
123
- }
124
- } catch { /* ignore non-JSON */ }
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;
125
57
  }
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);
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;
133
74
  }
134
- });
135
- });
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
75
  }
156
76
  });
157
77
 
158
- // Resolve model with correct priority:
159
- // 1. frontmatter model (agent.model)
160
- // 2. explicit tool-call model (options.model)
161
- // 3. currently active model (options.activeModel)
162
- // 4. no --model flag → pi default
163
- const modelToUse = options.agent?.model ?? options.model ?? options.activeModel;
164
- if (modelToUse) {
165
- args.push("--model", modelToUse);
166
- }
167
-
168
- // Apply other agent config options
169
- if (options.agent) {
170
- const agent = options.agent;
171
- if (agent.thinking) {
172
- args.push("--thinking", agent.thinking);
173
- }
174
- if (agent.tools && agent.tools.length > 0) {
175
- args.push("--tools", agent.tools.join(","));
176
- }
177
- }
178
-
179
- // Temp files for system prompt
180
- let tmpDir: string | null = null;
181
- let tmpPath: string | null = null;
182
-
183
- if (options.agent && options.agent.systemPrompt.trim()) {
184
- const tmp = writePromptToFile(options.agent.name, options.agent.systemPrompt);
185
- tmpDir = tmp.dir;
186
- tmpPath = tmp.filePath;
187
- args.push("--append-system-prompt", tmpPath);
188
- }
189
-
190
- args.push(options.task);
191
-
192
- const child = spawn(command, args, {
193
- cwd: options.cwd || process.cwd(),
194
- stdio: ["ignore", "pipe", "pipe"],
195
- env: {
196
- ...process.env,
197
- PI_SUBAGENT_SOCKET: socketPath,
198
- },
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);
199
92
  });
200
93
 
201
94
  // Handle abort signal
202
95
  let abortHandler: (() => void) | undefined;
203
96
  if (options.signal) {
204
97
  abortHandler = () => {
98
+ // Send abort message to child before killing
99
+ child.send({ type: "abort" });
205
100
  if (child.pid && !child.killed) {
206
101
  child.kill("SIGTERM");
207
102
  const forceKill = setTimeout(() => {
208
- if (!child.killed) {
209
- child.kill("SIGKILL");
210
- }
103
+ if (!child.killed) child.kill("SIGKILL");
211
104
  }, 3000);
212
105
  forceKill.unref();
213
106
  }
@@ -215,22 +108,17 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
215
108
  options.signal.addEventListener("abort", abortHandler, { once: true });
216
109
  }
217
110
 
218
- // Clean up temp files and listeners on exit
111
+ // Clean up on exit
219
112
  const exitCleanup = (): void => {
220
113
  child.removeListener("exit", exitCleanup);
221
114
  child.removeListener("error", exitCleanup);
222
115
  if (abortHandler && options.signal) {
223
116
  options.signal.removeEventListener("abort", abortHandler);
224
117
  }
225
- server.close();
226
118
  unsubAskResponse();
227
- if (clientSocket) clientSocket.destroy();
228
- cleanupTempFiles(tmpDir, tmpPath);
229
119
  };
230
120
  child.on("exit", exitCleanup);
231
121
  child.on("error", exitCleanup);
232
122
 
233
- // Attach client socket reference to child for streamEvents
234
- (child as ChildProcess & { clientSocket: Socket | undefined }).clientSocket = clientSocket;
235
123
  return child;
236
124
  }
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",