@pi-archimedes/subagent 1.2.1 → 1.3.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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/spawn.ts +77 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/subagent",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
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.2.1"
14
+ "@pi-archimedes/core": "1.3.0"
15
15
  },
16
16
  "peerDependencies": {
17
17
  "@earendil-works/pi-coding-agent": ">=0.1.0",
package/src/spawn.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { execSync } from "node:child_process";
3
3
  import { existsSync, writeFileSync, unlinkSync, mkdtempSync, rmdirSync, rmSync } from "node:fs";
4
+ import { createServer, type Socket } from "node:net";
4
5
  import { tmpdir } from "node:os";
5
6
  import { join } from "node:path";
7
+ import { getBus, Events } from "@pi-archimedes/core/bus";
6
8
  import type { AgentConfig } from "./agents.js";
7
9
 
8
10
  // Track all temp dirs for cleanup on process exit (graceful shutdown only).
@@ -87,6 +89,72 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
87
89
  "--no-session",
88
90
  ];
89
91
 
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 */ }
125
+ }
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);
133
+ }
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
+ }
156
+ });
157
+
90
158
  // Resolve model with correct priority:
91
159
  // 1. frontmatter model (agent.model)
92
160
  // 2. explicit tool-call model (options.model)
@@ -124,7 +192,10 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
124
192
  const child = spawn(command, args, {
125
193
  cwd: options.cwd || process.cwd(),
126
194
  stdio: ["ignore", "pipe", "pipe"],
127
- env: process.env,
195
+ env: {
196
+ ...process.env,
197
+ PI_SUBAGENT_SOCKET: socketPath,
198
+ },
128
199
  });
129
200
 
130
201
  // Handle abort signal
@@ -151,10 +222,15 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
151
222
  if (abortHandler && options.signal) {
152
223
  options.signal.removeEventListener("abort", abortHandler);
153
224
  }
225
+ server.close();
226
+ unsubAskResponse();
227
+ if (clientSocket) clientSocket.destroy();
154
228
  cleanupTempFiles(tmpDir, tmpPath);
155
229
  };
156
230
  child.on("exit", exitCleanup);
157
231
  child.on("error", exitCleanup);
158
232
 
233
+ // Attach client socket reference to child for streamEvents
234
+ (child as ChildProcess & { clientSocket: Socket | undefined }).clientSocket = clientSocket;
159
235
  return child;
160
236
  }