pi-agent-squad 0.7.0

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/spawn.ts ADDED
@@ -0,0 +1,457 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { getPackageDir, RpcClient } from "@earendil-works/pi-coding-agent";
6
+ import type { AgentConfig } from "./agents.ts";
7
+ import type { SubagentSessionHandle } from "./session.ts";
8
+ import {
9
+ ENV_AGENT,
10
+ ENV_CHANNEL_ROOT,
11
+ ENV_CHILD_INDEX,
12
+ ENV_ROLE,
13
+ ENV_RUN_ID,
14
+ ROLE_CHILD,
15
+ channelDir,
16
+ ensureDir,
17
+ } from "./message.ts";
18
+
19
+ // ============================================================================
20
+ // Subagent spawn (isolated `pi --mode json -p` process)
21
+ // ============================================================================
22
+
23
+ export interface SingleResult {
24
+ agent: string;
25
+ task: string;
26
+ exitCode: number;
27
+ messages: Array<{ role: string; content?: unknown; [k: string]: unknown }>;
28
+ stderr: string;
29
+ usage: {
30
+ input: number;
31
+ output: number;
32
+ cacheRead: number;
33
+ cacheWrite: number;
34
+ cost: number;
35
+ contextTokens: number;
36
+ turns: number;
37
+ };
38
+ model?: string;
39
+ stopReason?: string;
40
+ errorMessage?: string;
41
+ }
42
+
43
+ export interface SpawnOptions {
44
+ agent: AgentConfig;
45
+ task: string;
46
+ cwd?: string;
47
+ messageRoot: string;
48
+ runId: string;
49
+ childIndex: number;
50
+ signal?: AbortSignal;
51
+ timeoutMs?: number;
52
+ }
53
+
54
+ export interface InteractiveSpawnOptions extends SpawnOptions {
55
+ onSession?: (session: SubagentSessionHandle) => void;
56
+ onEvent?: (event: any) => void;
57
+ }
58
+
59
+ const DEFAULT_SPAWN_TIMEOUT_MS = 6 * 60 * 60 * 1000;
60
+ const FORCE_KILL_DELAY_MS = 5000;
61
+
62
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
63
+ const currentScript = process.argv[1];
64
+ if (currentScript && fs.existsSync(currentScript)) {
65
+ return { command: process.execPath, args: [currentScript, ...args] };
66
+ }
67
+ const execName = path.basename(process.execPath).toLowerCase();
68
+ if (!/^(node|bun)(\.exe)?$/.test(execName)) {
69
+ return { command: process.execPath, args };
70
+ }
71
+ return { command: "pi", args };
72
+ }
73
+
74
+ async function writePromptToTempFile(agentName: string, prompt: string): Promise<string> {
75
+ const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
76
+ const safeName = agentName.replace(/[^\w.-]+/g, "_");
77
+ const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
78
+ await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
79
+ return filePath;
80
+ }
81
+
82
+ export function getFinalOutput(messages: SingleResult["messages"]): string {
83
+ let out = "";
84
+ for (const msg of messages) {
85
+ if (msg.role !== "assistant") continue;
86
+ const content = msg.content;
87
+ if (Array.isArray(content)) {
88
+ for (const block of content as Array<{ type?: string; text?: string }>) {
89
+ if (block.type === "text" && block.text) out += block.text;
90
+ }
91
+ } else if (typeof content === "string") {
92
+ out += content;
93
+ }
94
+ }
95
+ return out.trim();
96
+ }
97
+
98
+ /**
99
+ * Spawn a conversational RPC-backed subagent run. Unlike the legacy JSON
100
+ * one-shot process, this keeps one in-memory session alive for the duration of
101
+ * the task so the main TUI can attach, stream events, steer it, and return to
102
+ * main without losing the subagent's context.
103
+ */
104
+ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): Promise<SingleResult> {
105
+ const { agent, task, cwd, messageRoot, runId, childIndex, signal } = opts;
106
+ const timeoutMs = Math.max(1000, opts.timeoutMs ?? DEFAULT_SPAWN_TIMEOUT_MS);
107
+ const result: SingleResult = {
108
+ agent: agent.name,
109
+ task,
110
+ exitCode: 0,
111
+ messages: [],
112
+ stderr: "",
113
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
114
+ model: agent.model,
115
+ };
116
+
117
+ let tmpPromptPath: string | null = null;
118
+ let client: RpcClient | undefined;
119
+ let unsubscribe: (() => void) | undefined;
120
+ let abortHandler: (() => void) | undefined;
121
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
122
+ let timedOut = false;
123
+ let wasAborted = false;
124
+ const sessionListeners = new Set<(event: any) => void>();
125
+
126
+ const emitSessionEvent = (event: any) => {
127
+ for (const listener of sessionListeners) {
128
+ try {
129
+ listener(event);
130
+ } catch {
131
+ /* one overlay listener must not break the run */
132
+ }
133
+ }
134
+ };
135
+
136
+ try {
137
+ const args: string[] = ["--no-session"];
138
+ if (agent.thinking) args.push("--thinking", agent.thinking);
139
+ const tools = agent.tools ? [...agent.tools] : [];
140
+ for (const tool of ["send_message", "read_inbox", "reply_message"]) {
141
+ if (!tools.includes(tool)) tools.push(tool);
142
+ }
143
+ if (tools.length > 0) args.push("--tools", tools.join(","));
144
+ if (agent.systemPrompt.trim()) {
145
+ tmpPromptPath = await writePromptToTempFile(agent.name, agent.systemPrompt);
146
+ args.push("--append-system-prompt", tmpPromptPath);
147
+ }
148
+
149
+ const env: Record<string, string> = {};
150
+ for (const [key, value] of Object.entries(process.env)) {
151
+ if (value !== undefined) env[key] = value;
152
+ }
153
+ env[ENV_ROLE] = ROLE_CHILD;
154
+ env[ENV_CHANNEL_ROOT] = messageRoot;
155
+ env[ENV_RUN_ID] = runId;
156
+ env[ENV_AGENT] = agent.name;
157
+ env[ENV_CHILD_INDEX] = String(childIndex);
158
+ ensureDir(channelDir(messageRoot, runId, agent.name, childIndex));
159
+
160
+ const currentScript = process.argv[1];
161
+ const cliPath =
162
+ currentScript && fs.existsSync(currentScript)
163
+ ? currentScript
164
+ : path.join(getPackageDir(), "dist", "cli.js");
165
+ client = new RpcClient({
166
+ cliPath,
167
+ cwd: cwd ?? process.cwd(),
168
+ env,
169
+ model: agent.model,
170
+ args,
171
+ });
172
+
173
+ unsubscribe = client.onEvent((event: any) => {
174
+ opts.onEvent?.(event);
175
+ emitSessionEvent(event);
176
+ if (event?.type === "message_end" && event.message) {
177
+ const message = event.message;
178
+ result.messages.push(message);
179
+ if (message.role === "assistant") {
180
+ result.usage.turns++;
181
+ const usage = message.usage;
182
+ if (usage) {
183
+ result.usage.input += usage.input || 0;
184
+ result.usage.output += usage.output || 0;
185
+ result.usage.cacheRead += usage.cacheRead || 0;
186
+ result.usage.cacheWrite += usage.cacheWrite || 0;
187
+ result.usage.cost += usage.cost?.total || 0;
188
+ result.usage.contextTokens = usage.totalTokens || 0;
189
+ }
190
+ if (!result.model && message.model) result.model = message.model;
191
+ if (message.stopReason) result.stopReason = message.stopReason;
192
+ if (message.errorMessage) result.errorMessage = message.errorMessage;
193
+ }
194
+ }
195
+ });
196
+
197
+ await client.start();
198
+ const activeClient = client;
199
+ let acceptInitialPrompt!: () => void;
200
+ let rejectInitialPrompt!: (error: unknown) => void;
201
+ const initialPromptAccepted = new Promise<void>((resolve, reject) => {
202
+ acceptInitialPrompt = resolve;
203
+ rejectInitialPrompt = reject;
204
+ });
205
+ void initialPromptAccepted.catch(() => {});
206
+ const session: SubagentSessionHandle = {
207
+ agent: agent.name,
208
+ getMessages: () => activeClient.getMessages(),
209
+ isStreaming: async () => (await activeClient.getState()).isStreaming,
210
+ send: async (message: string) => {
211
+ await initialPromptAccepted;
212
+ const state = await activeClient.getState();
213
+ if (state.isStreaming) await activeClient.steer(message);
214
+ else await activeClient.prompt(message);
215
+ },
216
+ abort: () => activeClient.abort(),
217
+ subscribe: (listener) => {
218
+ sessionListeners.add(listener);
219
+ return () => sessionListeners.delete(listener);
220
+ },
221
+ };
222
+ opts.onSession?.(session);
223
+
224
+ const timeout = new Promise<never>((_resolve, reject) => {
225
+ timeoutTimer = setTimeout(() => {
226
+ timedOut = true;
227
+ reject(new Error(`Subagent timed out after ${Math.round(timeoutMs / 1000)}s`));
228
+ }, timeoutMs);
229
+ timeoutTimer.unref?.();
230
+ });
231
+ const aborted = new Promise<never>((_resolve, reject) => {
232
+ abortHandler = () => {
233
+ wasAborted = true;
234
+ reject(new Error("Subagent was aborted"));
235
+ };
236
+ if (signal?.aborted) abortHandler();
237
+ else signal?.addEventListener("abort", abortHandler, { once: true });
238
+ });
239
+
240
+ // Register the settled waiter before prompting so an extremely fast run
241
+ // cannot finish in the gap between prompt acceptance and listener setup.
242
+ const settled = activeClient.waitForIdle(timeoutMs + 1000);
243
+ try {
244
+ await activeClient.prompt(`Task: ${task}`);
245
+ acceptInitialPrompt();
246
+ } catch (error) {
247
+ rejectInitialPrompt(error);
248
+ throw error;
249
+ }
250
+ try {
251
+ await Promise.race([settled, timeout, aborted]);
252
+ } catch (error) {
253
+ await activeClient.abort().catch(() => {});
254
+ if (wasAborted) throw error;
255
+ if (timedOut) {
256
+ result.exitCode = 124;
257
+ result.stopReason = "error";
258
+ result.errorMessage = `Subagent timed out after ${Math.round(timeoutMs / 1000)}s`;
259
+ } else {
260
+ result.exitCode = 1;
261
+ result.stopReason = "error";
262
+ result.errorMessage = error instanceof Error ? error.message : String(error);
263
+ }
264
+ }
265
+ result.stderr = activeClient.getStderr();
266
+ return result;
267
+ } finally {
268
+ if (timeoutTimer) clearTimeout(timeoutTimer);
269
+ if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
270
+ unsubscribe?.();
271
+ if (client) await client.stop().catch(() => {});
272
+ emitSessionEvent({
273
+ type: "session_closed",
274
+ error: timedOut
275
+ ? `Subagent timed out after ${Math.round(timeoutMs / 1000)}s`
276
+ : wasAborted
277
+ ? "Subagent was aborted"
278
+ : undefined,
279
+ });
280
+ sessionListeners.clear();
281
+ if (tmpPromptPath)
282
+ try {
283
+ fs.unlinkSync(tmpPromptPath);
284
+ fs.rmdirSync(path.dirname(tmpPromptPath));
285
+ } catch {
286
+ /* ignore */
287
+ }
288
+ }
289
+ }
290
+
291
+ /** Spawn a subagent (isolated context, message channel + system prompt), parse the JSON event stream */
292
+ export async function spawnSubagent(opts: SpawnOptions): Promise<SingleResult> {
293
+ const { agent, task, cwd, messageRoot, runId, childIndex, signal } = opts;
294
+ const timeoutMs = Math.max(1000, opts.timeoutMs ?? DEFAULT_SPAWN_TIMEOUT_MS);
295
+ const emptyUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
296
+ const result: SingleResult = {
297
+ agent: agent.name,
298
+ task,
299
+ exitCode: 0,
300
+ messages: [],
301
+ stderr: "",
302
+ usage: { ...emptyUsage },
303
+ model: agent.model,
304
+ };
305
+
306
+ let tmpPromptPath: string | null = null;
307
+ try {
308
+ const args: string[] = ["--mode", "json", "-p", "--no-session"];
309
+ if (agent.model) args.push("--model", agent.model);
310
+ if (agent.thinking) args.push("--thinking", agent.thinking);
311
+ // tool allowlist must include the messaging tools or subagents can't see them
312
+ const tools = agent.tools ? [...agent.tools] : [];
313
+ for (const t of ["send_message", "read_inbox", "reply_message"]) {
314
+ if (!tools.includes(t)) tools.push(t);
315
+ }
316
+ if (tools.length > 0) args.push("--tools", tools.join(","));
317
+ if (agent.systemPrompt.trim()) {
318
+ tmpPromptPath = await writePromptToTempFile(agent.name, agent.systemPrompt);
319
+ args.push("--append-system-prompt", tmpPromptPath);
320
+ }
321
+ args.push(`Task: ${task}`);
322
+
323
+ // inject the messaging environment
324
+ const env: NodeJS.ProcessEnv = { ...process.env };
325
+ env[ENV_ROLE] = ROLE_CHILD;
326
+ env[ENV_CHANNEL_ROOT] = messageRoot;
327
+ env[ENV_RUN_ID] = runId;
328
+ env[ENV_AGENT] = agent.name;
329
+ env[ENV_CHILD_INDEX] = String(childIndex);
330
+ ensureDir(channelDir(messageRoot, runId, agent.name, childIndex));
331
+
332
+ let wasAborted = false;
333
+ let timedOut = false;
334
+ const exitCode = await new Promise<number>((resolve) => {
335
+ const invocation = getPiInvocation(args);
336
+ const proc = spawn(invocation.command, invocation.args, {
337
+ cwd: cwd ?? process.cwd(),
338
+ shell: false,
339
+ stdio: ["ignore", "pipe", "pipe"],
340
+ env,
341
+ });
342
+ let buffer = "";
343
+ let settled = false;
344
+ let exited = false;
345
+ let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
346
+ let abortHandler: (() => void) | undefined;
347
+
348
+ const cleanup = () => {
349
+ clearTimeout(timeoutTimer);
350
+ if (forceKillTimer) clearTimeout(forceKillTimer);
351
+ if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
352
+ };
353
+ const finish = (code: number) => {
354
+ if (settled) return;
355
+ settled = true;
356
+ cleanup();
357
+ resolve(code);
358
+ };
359
+ const terminate = () => {
360
+ if (exited) return;
361
+ try {
362
+ proc.kill("SIGTERM");
363
+ } catch {
364
+ /* process may already be gone */
365
+ }
366
+ forceKillTimer = setTimeout(() => {
367
+ if (exited) return;
368
+ try {
369
+ proc.kill("SIGKILL");
370
+ } catch {
371
+ /* process may already be gone */
372
+ }
373
+ }, FORCE_KILL_DELAY_MS);
374
+ forceKillTimer.unref?.();
375
+ };
376
+ const timeoutTimer = setTimeout(() => {
377
+ timedOut = true;
378
+ terminate();
379
+ }, timeoutMs);
380
+ timeoutTimer.unref?.();
381
+
382
+ const processLine = (line: string) => {
383
+ if (!line.trim()) return;
384
+ let event: any;
385
+ try {
386
+ event = JSON.parse(line);
387
+ } catch {
388
+ return;
389
+ }
390
+ if (event.type === "message_end" && event.message) {
391
+ const msg = event.message;
392
+ result.messages.push(msg);
393
+ if (msg.role === "assistant") {
394
+ result.usage.turns++;
395
+ const u = msg.usage;
396
+ if (u) {
397
+ result.usage.input += u.input || 0;
398
+ result.usage.output += u.output || 0;
399
+ result.usage.cacheRead += u.cacheRead || 0;
400
+ result.usage.cacheWrite += u.cacheWrite || 0;
401
+ result.usage.cost += u.cost?.total || 0;
402
+ result.usage.contextTokens = u.totalTokens || 0;
403
+ }
404
+ if (!result.model && msg.model) result.model = msg.model;
405
+ if (msg.stopReason) result.stopReason = msg.stopReason;
406
+ if (msg.errorMessage) result.errorMessage = msg.errorMessage;
407
+ }
408
+ }
409
+ if (event.type === "tool_result_end" && event.message) {
410
+ result.messages.push(event.message);
411
+ }
412
+ };
413
+ proc.stdout.on("data", (data) => {
414
+ buffer += data.toString();
415
+ const lines = buffer.split("\n");
416
+ buffer = lines.pop() || "";
417
+ for (const line of lines) processLine(line);
418
+ });
419
+ proc.stderr.on("data", (data) => {
420
+ result.stderr += data.toString();
421
+ });
422
+ proc.on("close", (code) => {
423
+ exited = true;
424
+ if (buffer.trim()) processLine(buffer);
425
+ finish(code ?? (timedOut ? 124 : 0));
426
+ });
427
+ proc.on("error", (err) => {
428
+ result.stderr += `${err instanceof Error ? err.message : String(err)}\n`;
429
+ finish(1);
430
+ });
431
+ if (signal) {
432
+ abortHandler = () => {
433
+ wasAborted = true;
434
+ terminate();
435
+ };
436
+ if (signal.aborted) abortHandler();
437
+ else signal.addEventListener("abort", abortHandler, { once: true });
438
+ }
439
+ });
440
+
441
+ result.exitCode = timedOut ? 124 : exitCode;
442
+ if (wasAborted) throw new Error("Subagent was aborted");
443
+ if (timedOut) {
444
+ result.stopReason = "error";
445
+ result.errorMessage = `Subagent timed out after ${Math.round(timeoutMs / 1000)}s`;
446
+ }
447
+ return result;
448
+ } finally {
449
+ if (tmpPromptPath)
450
+ try {
451
+ fs.unlinkSync(tmpPromptPath);
452
+ fs.rmdirSync(path.dirname(tmpPromptPath));
453
+ } catch {
454
+ /* ignore */
455
+ }
456
+ }
457
+ }
package/wait-graph.ts ADDED
@@ -0,0 +1,56 @@
1
+ export class MessageWaitGraph {
2
+ private edges = new Map<string, Map<string, number>>();
3
+
4
+ private findPath(from: string, to: string): string[] | undefined {
5
+ const queue: string[][] = [[from]];
6
+ const visited = new Set<string>([from]);
7
+ while (queue.length > 0) {
8
+ const path = queue.shift()!;
9
+ const node = path[path.length - 1]!;
10
+ if (node === to) return path;
11
+ for (const next of this.edges.get(node)?.keys() ?? []) {
12
+ if (visited.has(next)) continue;
13
+ visited.add(next);
14
+ queue.push([...path, next]);
15
+ }
16
+ }
17
+ return undefined;
18
+ }
19
+
20
+ acquire(from: string, to: string): { release?: () => void; cycle?: string[] } {
21
+ const pathBack = this.findPath(to, from);
22
+ if (pathBack) return { cycle: [from, ...pathBack] };
23
+
24
+ let targets = this.edges.get(from);
25
+ if (!targets) {
26
+ targets = new Map();
27
+ this.edges.set(from, targets);
28
+ }
29
+ targets.set(to, (targets.get(to) ?? 0) + 1);
30
+
31
+ let released = false;
32
+ return {
33
+ release: () => {
34
+ if (released) return;
35
+ released = true;
36
+ const currentTargets = this.edges.get(from);
37
+ const count = currentTargets?.get(to) ?? 0;
38
+ if (count <= 1) currentTargets?.delete(to);
39
+ else currentTargets?.set(to, count - 1);
40
+ if (currentTargets?.size === 0) this.edges.delete(from);
41
+ },
42
+ };
43
+ }
44
+
45
+ clear(): void {
46
+ this.edges.clear();
47
+ }
48
+ }
49
+
50
+ export function deadlockMessage(cycle: string[]): string {
51
+ return [
52
+ `Deadlock prevented: ${cycle.join(" -> ")}.`,
53
+ "Do not synchronously wait on an agent that is already waiting on you.",
54
+ "Use wait=false, delegate in background, or return the question in the current task result.",
55
+ ].join(" ");
56
+ }