@pi-archimedes/subagent 1.3.1 → 1.3.3

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