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/README.md +205 -0
- package/agents/actor.md +51 -0
- package/agents/planner.md +57 -0
- package/agents/reviewer.md +40 -0
- package/agents.ts +87 -0
- package/index.ts +1204 -0
- package/message.ts +572 -0
- package/orchestrator.md +131 -0
- package/package.json +36 -0
- package/pool.ts +578 -0
- package/session-ui.ts +648 -0
- package/session.ts +8 -0
- package/spawn.ts +457 -0
- package/wait-graph.ts +56 -0
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-agent-squad",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Interactive multi-agent orchestration, messaging, and live sessions for the Pi Coding Agent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pi-coding-agent",
|
|
10
|
+
"subagents",
|
|
11
|
+
"multi-agent"
|
|
12
|
+
],
|
|
13
|
+
"exports": "./index.ts",
|
|
14
|
+
"files": [
|
|
15
|
+
"*.ts",
|
|
16
|
+
"agents/*.md",
|
|
17
|
+
"orchestrator.md",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"pi-compact-ui": "^0.1.0"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
25
|
+
"@earendil-works/pi-tui": "*",
|
|
26
|
+
"typebox": "*"
|
|
27
|
+
},
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./index.ts"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/pool.ts
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } 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 { randomUUID } from "node:crypto";
|
|
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
|
+
} from "./message.ts";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// RPC-resident subagent process pool
|
|
19
|
+
//
|
|
20
|
+
// One resident `pi --mode rpc` process per agent. Commands go over stdin,
|
|
21
|
+
// JSON events come back over stdout. Processes stay alive between tasks so
|
|
22
|
+
// they can receive inter-subagent messages.
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
export interface TaskResult {
|
|
26
|
+
agent: string;
|
|
27
|
+
task: string;
|
|
28
|
+
messages: Array<{ role: string; content?: unknown; [k: string]: unknown }>;
|
|
29
|
+
stderr: string;
|
|
30
|
+
usage: {
|
|
31
|
+
input: number;
|
|
32
|
+
output: number;
|
|
33
|
+
cacheRead: number;
|
|
34
|
+
cacheWrite: number;
|
|
35
|
+
cost: number;
|
|
36
|
+
contextTokens: number;
|
|
37
|
+
turns: number;
|
|
38
|
+
};
|
|
39
|
+
model?: string;
|
|
40
|
+
stopReason?: string;
|
|
41
|
+
errorMessage?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function finalOutput(messages: TaskResult["messages"]): string {
|
|
45
|
+
let out = "";
|
|
46
|
+
for (const msg of messages) {
|
|
47
|
+
if (msg.role !== "assistant") continue;
|
|
48
|
+
const content = msg.content;
|
|
49
|
+
if (Array.isArray(content)) {
|
|
50
|
+
for (const block of content as Array<{ type?: string; text?: string }>) {
|
|
51
|
+
if (block.type === "text" && block.text) out += block.text;
|
|
52
|
+
}
|
|
53
|
+
} else if (typeof content === "string") {
|
|
54
|
+
out += content;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const IDLE_RECYCLE_MS = 10 * 60 * 1000; // recycle idle processes after 10 min
|
|
61
|
+
const FORCE_KILL_DELAY_MS = 5000;
|
|
62
|
+
|
|
63
|
+
interface TurnWaiter {
|
|
64
|
+
resolve: () => void;
|
|
65
|
+
reject: (error: Error) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface PooledProcess {
|
|
69
|
+
agent: AgentConfig;
|
|
70
|
+
proc: ChildProcess;
|
|
71
|
+
nextId: number;
|
|
72
|
+
pending: Map<string, (data: unknown) => void>;
|
|
73
|
+
events: Array<Record<string, unknown>>;
|
|
74
|
+
lastUsed: number;
|
|
75
|
+
spawnError?: string;
|
|
76
|
+
closed: boolean;
|
|
77
|
+
// Run-completion notification: RPC returns the prompt response at preflight,
|
|
78
|
+
// so we wait for agent_settled to include retries and queued interactions.
|
|
79
|
+
turnWaiters: TurnWaiter[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class SubagentPool {
|
|
83
|
+
private procs = new Map<string, PooledProcess>();
|
|
84
|
+
private eventListeners = new Map<string, Set<(event: any) => void>>();
|
|
85
|
+
private taskTails = new Map<string, Promise<void>>();
|
|
86
|
+
private recycleTimer: ReturnType<typeof setInterval>;
|
|
87
|
+
private messageRoot: string;
|
|
88
|
+
private workingDirectory = process.cwd();
|
|
89
|
+
private runIdCounter = new Map<string, number>();
|
|
90
|
+
private idleCheckInterval = 30 * 1000;
|
|
91
|
+
private disposed = false;
|
|
92
|
+
|
|
93
|
+
constructor(messageRoot: string) {
|
|
94
|
+
this.messageRoot = messageRoot;
|
|
95
|
+
this.recycleTimer = setInterval(() => this.recycleIdle(), this.idleCheckInterval);
|
|
96
|
+
this.recycleTimer.unref?.();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setIntercomRoot(root: string) {
|
|
100
|
+
this.messageRoot = root;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
setWorkingDirectory(cwd: string) {
|
|
104
|
+
if (!cwd || cwd === this.workingDirectory) return;
|
|
105
|
+
this.workingDirectory = cwd;
|
|
106
|
+
for (const [name, pooled] of [...this.procs]) {
|
|
107
|
+
this.terminateProcess(name, pooled, new Error("Subagent working directory changed."));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Serialize tasks per agent so one agent_end cannot complete multiple callers. */
|
|
112
|
+
private async withAgentLock<T>(agentName: string, run: () => Promise<T>): Promise<T> {
|
|
113
|
+
const previous = this.taskTails.get(agentName) ?? Promise.resolve();
|
|
114
|
+
let release!: () => void;
|
|
115
|
+
const current = new Promise<void>((resolve) => {
|
|
116
|
+
release = resolve;
|
|
117
|
+
});
|
|
118
|
+
const tail = previous.catch(() => {}).then(() => current);
|
|
119
|
+
this.taskTails.set(agentName, tail);
|
|
120
|
+
await previous.catch(() => {});
|
|
121
|
+
try {
|
|
122
|
+
if (this.disposed) throw new Error("Subagent pool is disposed.");
|
|
123
|
+
return await run();
|
|
124
|
+
} finally {
|
|
125
|
+
release();
|
|
126
|
+
if (this.taskTails.get(agentName) === tail) this.taskTails.delete(agentName);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private terminateProcess(agentName: string, pooled: PooledProcess, reason: Error): void {
|
|
131
|
+
if (this.procs.get(agentName) === pooled) this.procs.delete(agentName);
|
|
132
|
+
pooled.closed = true;
|
|
133
|
+
this.emitAgentEvent(agentName, { type: "session_closed", error: reason.message });
|
|
134
|
+
const waiters = pooled.turnWaiters.splice(0);
|
|
135
|
+
for (const waiter of waiters) waiter.reject(reason);
|
|
136
|
+
try {
|
|
137
|
+
pooled.proc.kill("SIGTERM");
|
|
138
|
+
} catch {
|
|
139
|
+
/* process may already be gone */
|
|
140
|
+
}
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
if (pooled.proc.exitCode !== null || pooled.proc.signalCode !== null) return;
|
|
143
|
+
try {
|
|
144
|
+
pooled.proc.kill("SIGKILL");
|
|
145
|
+
} catch {
|
|
146
|
+
/* process may already be gone */
|
|
147
|
+
}
|
|
148
|
+
}, FORCE_KILL_DELAY_MS);
|
|
149
|
+
timer.unref?.();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private invocation(args: string[]): { command: string; args: string[] } {
|
|
153
|
+
const currentScript = process.argv[1];
|
|
154
|
+
if (currentScript && fs.existsSync(currentScript)) {
|
|
155
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
156
|
+
}
|
|
157
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
158
|
+
if (!/^(node|bun)(\.exe)?$/.test(execName)) {
|
|
159
|
+
return { command: process.execPath, args };
|
|
160
|
+
}
|
|
161
|
+
return { command: "pi", args };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private async writePromptToTempFile(agentName: string, prompt: string): Promise<string | null> {
|
|
165
|
+
if (!prompt.trim()) return null;
|
|
166
|
+
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
|
|
167
|
+
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
168
|
+
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
|
|
169
|
+
await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
|
|
170
|
+
return filePath;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Ensure a resident process exists for the agent */
|
|
174
|
+
async ensureProcess(agent: AgentConfig): Promise<PooledProcess> {
|
|
175
|
+
if (this.disposed) throw new Error("Subagent pool is disposed.");
|
|
176
|
+
const existing = this.procs.get(agent.name);
|
|
177
|
+
if (existing && !existing.closed && existing.proc.exitCode === null && existing.proc.signalCode === null) {
|
|
178
|
+
return existing;
|
|
179
|
+
}
|
|
180
|
+
// kill the old process, if any
|
|
181
|
+
if (existing) {
|
|
182
|
+
this.terminateProcess(agent.name, existing, new Error(`Replacing stale subagent process for ${agent.name}.`));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const args: string[] = ["--mode", "rpc", "--no-session"];
|
|
186
|
+
if (agent.model) args.push("--model", agent.model);
|
|
187
|
+
if (agent.thinking) args.push("--thinking", agent.thinking);
|
|
188
|
+
const tools = agent.tools ? [...agent.tools] : [];
|
|
189
|
+
for (const t of ["send_message", "read_inbox", "reply_message"]) {
|
|
190
|
+
if (!tools.includes(t)) tools.push(t);
|
|
191
|
+
}
|
|
192
|
+
if (tools.length > 0) args.push("--tools", tools.join(","));
|
|
193
|
+
|
|
194
|
+
let tmpPromptPath: string | null = null;
|
|
195
|
+
if (agent.systemPrompt.trim()) {
|
|
196
|
+
tmpPromptPath = await this.writePromptToTempFile(agent.name, agent.systemPrompt);
|
|
197
|
+
args.push("--append-system-prompt", tmpPromptPath);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const runId = randomUUID();
|
|
201
|
+
const childIndex = this.runIdCounter.get(agent.name) ?? 0;
|
|
202
|
+
this.runIdCounter.set(agent.name, childIndex + 1);
|
|
203
|
+
|
|
204
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
205
|
+
env[ENV_ROLE] = ROLE_CHILD;
|
|
206
|
+
env[ENV_CHANNEL_ROOT] = this.messageRoot;
|
|
207
|
+
env[ENV_RUN_ID] = runId;
|
|
208
|
+
env[ENV_AGENT] = agent.name;
|
|
209
|
+
env[ENV_CHILD_INDEX] = String(childIndex);
|
|
210
|
+
|
|
211
|
+
const pooled: PooledProcess = {
|
|
212
|
+
agent,
|
|
213
|
+
proc: null as unknown as ChildProcess,
|
|
214
|
+
nextId: 1,
|
|
215
|
+
pending: new Map(),
|
|
216
|
+
events: [],
|
|
217
|
+
lastUsed: Date.now(),
|
|
218
|
+
closed: false,
|
|
219
|
+
turnWaiters: [],
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const invocation = this.invocation(args);
|
|
223
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
224
|
+
cwd: this.workingDirectory,
|
|
225
|
+
shell: false,
|
|
226
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
227
|
+
env,
|
|
228
|
+
});
|
|
229
|
+
pooled.proc = proc;
|
|
230
|
+
|
|
231
|
+
let buffer = "";
|
|
232
|
+
let readyResolve: (() => void) | undefined;
|
|
233
|
+
const ready = new Promise<void>((r) => (readyResolve = r));
|
|
234
|
+
let readyTimer: ReturnType<typeof setTimeout> | undefined;
|
|
235
|
+
proc.stdout.on("data", (data) => {
|
|
236
|
+
buffer += data.toString();
|
|
237
|
+
const lines = buffer.split("\n");
|
|
238
|
+
buffer = lines.pop() || "";
|
|
239
|
+
for (const line of lines) this.processLine(pooled, line);
|
|
240
|
+
// any event means the RPC loop is up
|
|
241
|
+
if (readyResolve && pooled.events.length > 0) {
|
|
242
|
+
readyResolve();
|
|
243
|
+
readyResolve = undefined;
|
|
244
|
+
if (readyTimer) clearTimeout(readyTimer);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
proc.stderr.on("data", (data) => {
|
|
248
|
+
pooled.spawnError = (pooled.spawnError ?? "") + data.toString();
|
|
249
|
+
});
|
|
250
|
+
proc.on("close", (code) => {
|
|
251
|
+
pooled.closed = true;
|
|
252
|
+
this.emitAgentEvent(agent.name, {
|
|
253
|
+
type: "session_closed",
|
|
254
|
+
error: `Subagent process exited (code ${code ?? "unknown"}).`,
|
|
255
|
+
});
|
|
256
|
+
if (readyResolve) {
|
|
257
|
+
readyResolve();
|
|
258
|
+
readyResolve = undefined;
|
|
259
|
+
if (readyTimer) clearTimeout(readyTimer);
|
|
260
|
+
}
|
|
261
|
+
for (const [, resolve] of pooled.pending) {
|
|
262
|
+
resolve({ type: "process_exit", code });
|
|
263
|
+
}
|
|
264
|
+
pooled.pending.clear();
|
|
265
|
+
pooled.events.push({ type: "process_exit", code });
|
|
266
|
+
const waiters = pooled.turnWaiters.splice(0);
|
|
267
|
+
const error = new Error(
|
|
268
|
+
`Subagent process exited before completing the task (code ${code ?? "unknown"}).${pooled.spawnError ? ` ${pooled.spawnError.trim()}` : ""}`,
|
|
269
|
+
);
|
|
270
|
+
for (const waiter of waiters) waiter.reject(error);
|
|
271
|
+
});
|
|
272
|
+
proc.on("error", (err) => {
|
|
273
|
+
pooled.spawnError = err.message;
|
|
274
|
+
if (readyResolve) {
|
|
275
|
+
readyResolve();
|
|
276
|
+
readyResolve = undefined;
|
|
277
|
+
if (readyTimer) clearTimeout(readyTimer);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// wait for RPC readiness: first event, or a 2s fallback timeout
|
|
282
|
+
readyTimer = setTimeout(() => {
|
|
283
|
+
if (readyResolve) {
|
|
284
|
+
readyResolve();
|
|
285
|
+
readyResolve = undefined;
|
|
286
|
+
}
|
|
287
|
+
}, 2000);
|
|
288
|
+
try {
|
|
289
|
+
await ready;
|
|
290
|
+
if (this.disposed) {
|
|
291
|
+
this.terminateProcess(agent.name, pooled, new Error("Subagent pool disposed during process startup."));
|
|
292
|
+
throw new Error("Subagent pool is disposed.");
|
|
293
|
+
}
|
|
294
|
+
if (pooled.closed || proc.exitCode !== null || proc.signalCode !== null) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`Failed to start subagent ${agent.name}.${pooled.spawnError ? ` ${pooled.spawnError.trim()}` : ""}`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
this.procs.set(agent.name, pooled);
|
|
300
|
+
return pooled;
|
|
301
|
+
} finally {
|
|
302
|
+
if (tmpPromptPath) {
|
|
303
|
+
try {
|
|
304
|
+
fs.unlinkSync(tmpPromptPath);
|
|
305
|
+
fs.rmdirSync(path.dirname(tmpPromptPath));
|
|
306
|
+
} catch {
|
|
307
|
+
/* ignore */
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private processLine(pooled: PooledProcess, line: string) {
|
|
314
|
+
if (!line.trim()) return;
|
|
315
|
+
let event: any;
|
|
316
|
+
try {
|
|
317
|
+
event = JSON.parse(line);
|
|
318
|
+
} catch {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (event?.type === "response" && event?.id !== undefined) {
|
|
322
|
+
const resolve = pooled.pending.get(String(event.id));
|
|
323
|
+
if (resolve) {
|
|
324
|
+
pooled.pending.delete(String(event.id));
|
|
325
|
+
resolve(event);
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
pooled.events.push(event);
|
|
330
|
+
this.emitAgentEvent(pooled.agent.name, event);
|
|
331
|
+
// The whole session-level run is finished only at agent_settled. Waiting
|
|
332
|
+
// for agent_end would close the activity before queued interactive steer
|
|
333
|
+
// or follow-up messages have been processed.
|
|
334
|
+
if (event?.type === "agent_settled" && pooled.turnWaiters.length > 0) {
|
|
335
|
+
const waiters = pooled.turnWaiters.splice(0);
|
|
336
|
+
for (const waiter of waiters) waiter.resolve();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private emitAgentEvent(agentName: string, event: any): void {
|
|
341
|
+
for (const listener of this.eventListeners.get(agentName) ?? []) {
|
|
342
|
+
try {
|
|
343
|
+
listener(event);
|
|
344
|
+
} catch {
|
|
345
|
+
/* UI listeners must not affect the resident process */
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private sendCommand(pooled: PooledProcess, cmd: Record<string, unknown>): string {
|
|
351
|
+
if (pooled.closed || !pooled.proc.stdin || !pooled.proc.stdin.writable) {
|
|
352
|
+
throw new Error(`Subagent process for ${pooled.agent.name} is not writable.`);
|
|
353
|
+
}
|
|
354
|
+
const id = String(pooled.nextId++);
|
|
355
|
+
pooled.proc.stdin.write(JSON.stringify({ ...cmd, id }) + "\n");
|
|
356
|
+
return id;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private sendCommandAndWait(pooled: PooledProcess, cmd: Record<string, unknown>, timeoutMs: number): Promise<unknown> {
|
|
360
|
+
const id = this.sendCommand(pooled, cmd);
|
|
361
|
+
return new Promise((resolve, reject) => {
|
|
362
|
+
const timer = setTimeout(() => {
|
|
363
|
+
pooled.pending.delete(id);
|
|
364
|
+
reject(new Error("RPC command timed out"));
|
|
365
|
+
}, timeoutMs);
|
|
366
|
+
pooled.pending.set(id, (data) => {
|
|
367
|
+
clearTimeout(timer);
|
|
368
|
+
resolve(data);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private responseData(response: any): any {
|
|
374
|
+
if (!response || response.type !== "response") throw new Error("Invalid RPC response.");
|
|
375
|
+
if (response.success === false) throw new Error(response.error || `RPC ${response.command || "command"} failed.`);
|
|
376
|
+
return response.data;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Interactive handle for the exact resident process/session used by routed messages. */
|
|
380
|
+
getSessionHandle(agent: AgentConfig): SubagentSessionHandle {
|
|
381
|
+
return {
|
|
382
|
+
agent: agent.name,
|
|
383
|
+
getMessages: async () => {
|
|
384
|
+
const pooled = await this.ensureProcess(agent);
|
|
385
|
+
const response = await this.sendCommandAndWait(pooled, { type: "get_messages" }, 10000);
|
|
386
|
+
return this.responseData(response)?.messages ?? [];
|
|
387
|
+
},
|
|
388
|
+
isStreaming: async () => {
|
|
389
|
+
const pooled = await this.ensureProcess(agent);
|
|
390
|
+
const response = await this.sendCommandAndWait(pooled, { type: "get_state" }, 10000);
|
|
391
|
+
return this.responseData(response)?.isStreaming === true;
|
|
392
|
+
},
|
|
393
|
+
send: async (message: string) => {
|
|
394
|
+
const pooled = await this.ensureProcess(agent);
|
|
395
|
+
const stateResponse = await this.sendCommandAndWait(pooled, { type: "get_state" }, 10000);
|
|
396
|
+
const streaming = this.responseData(stateResponse)?.isStreaming === true;
|
|
397
|
+
const command = streaming ? { type: "steer", message } : { type: "prompt", message };
|
|
398
|
+
const response = await this.sendCommandAndWait(pooled, command, 10000);
|
|
399
|
+
this.responseData(response);
|
|
400
|
+
},
|
|
401
|
+
abort: async () => {
|
|
402
|
+
const pooled = await this.ensureProcess(agent);
|
|
403
|
+
const response = await this.sendCommandAndWait(pooled, { type: "abort" }, 10000);
|
|
404
|
+
this.responseData(response);
|
|
405
|
+
},
|
|
406
|
+
subscribe: (listener) => {
|
|
407
|
+
let listeners = this.eventListeners.get(agent.name);
|
|
408
|
+
if (!listeners) {
|
|
409
|
+
listeners = new Set();
|
|
410
|
+
this.eventListeners.set(agent.name, listeners);
|
|
411
|
+
}
|
|
412
|
+
listeners.add(listener);
|
|
413
|
+
return () => {
|
|
414
|
+
listeners?.delete(listener);
|
|
415
|
+
if (listeners?.size === 0) this.eventListeners.delete(agent.name);
|
|
416
|
+
};
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Send a prompt command (fire-and-forget; events land in events) */
|
|
422
|
+
sendPrompt(agent: AgentConfig, message: string): string {
|
|
423
|
+
const pooled = this.procs.get(agent.name);
|
|
424
|
+
if (!pooled || pooled.closed) throw new Error(`No process for agent ${agent.name}`);
|
|
425
|
+
pooled.lastUsed = Date.now();
|
|
426
|
+
return this.sendCommand(pooled, { type: "prompt", message });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Run a task synchronously: send a prompt and wait for completion, collecting events */
|
|
430
|
+
async runTask(
|
|
431
|
+
agent: AgentConfig,
|
|
432
|
+
task: string,
|
|
433
|
+
timeoutMs = 6 * 60 * 60 * 1000,
|
|
434
|
+
onUpdate?: (events: unknown[]) => void,
|
|
435
|
+
signal?: AbortSignal,
|
|
436
|
+
): Promise<TaskResult> {
|
|
437
|
+
return this.withAgentLock(agent.name, async () => {
|
|
438
|
+
const pooled = await this.ensureProcess(agent);
|
|
439
|
+
pooled.lastUsed = Date.now();
|
|
440
|
+
|
|
441
|
+
const result: TaskResult = {
|
|
442
|
+
agent: agent.name,
|
|
443
|
+
task,
|
|
444
|
+
messages: [],
|
|
445
|
+
stderr: "",
|
|
446
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
447
|
+
model: agent.model,
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
// event index at the start of this task
|
|
451
|
+
const startIndex = pooled.events.length;
|
|
452
|
+
// Register the waiter before sending the prompt so an extremely fast
|
|
453
|
+
// agent_end cannot arrive in the gap and leave us waiting until timeout.
|
|
454
|
+
const completion = new Promise<void>((resolve, reject) => {
|
|
455
|
+
let waiter: TurnWaiter;
|
|
456
|
+
let abortHandler: (() => void) | undefined;
|
|
457
|
+
const cleanup = () => {
|
|
458
|
+
clearTimeout(timer);
|
|
459
|
+
if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
|
|
460
|
+
};
|
|
461
|
+
const timer = setTimeout(() => {
|
|
462
|
+
const i = pooled.turnWaiters.indexOf(waiter);
|
|
463
|
+
if (i >= 0) pooled.turnWaiters.splice(i, 1);
|
|
464
|
+
const error = new Error(`Subagent task timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
465
|
+
this.terminateProcess(agent.name, pooled, error);
|
|
466
|
+
cleanup();
|
|
467
|
+
reject(error);
|
|
468
|
+
}, timeoutMs);
|
|
469
|
+
waiter = {
|
|
470
|
+
resolve: () => {
|
|
471
|
+
cleanup();
|
|
472
|
+
resolve();
|
|
473
|
+
},
|
|
474
|
+
reject: (error) => {
|
|
475
|
+
cleanup();
|
|
476
|
+
reject(error);
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
pooled.turnWaiters.push(waiter);
|
|
480
|
+
if (signal) {
|
|
481
|
+
abortHandler = () => {
|
|
482
|
+
const i = pooled.turnWaiters.indexOf(waiter);
|
|
483
|
+
if (i >= 0) pooled.turnWaiters.splice(i, 1);
|
|
484
|
+
const error = new Error("Subagent task cancelled.");
|
|
485
|
+
this.terminateProcess(agent.name, pooled, error);
|
|
486
|
+
cleanup();
|
|
487
|
+
reject(error);
|
|
488
|
+
};
|
|
489
|
+
if (signal.aborted) abortHandler();
|
|
490
|
+
else signal.addEventListener("abort", abortHandler, { once: true });
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
try {
|
|
494
|
+
this.sendCommand(pooled, { type: "prompt", message: task });
|
|
495
|
+
} catch (e) {
|
|
496
|
+
const waiter = pooled.turnWaiters.pop();
|
|
497
|
+
waiter?.reject(e instanceof Error ? e : new Error(String(e)));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// RPC returns the prompt response at preflight; wait for agent_settled
|
|
501
|
+
// so retries, compaction, and interactive queued messages are included.
|
|
502
|
+
await completion;
|
|
503
|
+
|
|
504
|
+
// collect the events for this task
|
|
505
|
+
const taskEvents = pooled.events.slice(startIndex);
|
|
506
|
+
for (const ev of taskEvents as any[]) {
|
|
507
|
+
if (ev.type === "message_end" && ev.message) {
|
|
508
|
+
result.messages.push(ev.message);
|
|
509
|
+
if (ev.message.role === "assistant") {
|
|
510
|
+
result.usage.turns++;
|
|
511
|
+
const u = ev.message.usage;
|
|
512
|
+
if (u) {
|
|
513
|
+
result.usage.input += u.input || 0;
|
|
514
|
+
result.usage.output += u.output || 0;
|
|
515
|
+
result.usage.cacheRead += u.cacheRead || 0;
|
|
516
|
+
result.usage.cacheWrite += u.cacheWrite || 0;
|
|
517
|
+
result.usage.cost += u.cost?.total || 0;
|
|
518
|
+
result.usage.contextTokens = u.totalTokens || 0;
|
|
519
|
+
}
|
|
520
|
+
if (!result.model && ev.message.model) result.model = ev.message.model;
|
|
521
|
+
if (ev.message.stopReason) result.stopReason = ev.message.stopReason;
|
|
522
|
+
if (ev.message.errorMessage) result.errorMessage = ev.message.errorMessage;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (ev.type === "tool_result_end" && ev.message) {
|
|
526
|
+
result.messages.push(ev.message);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
onUpdate?.(taskEvents);
|
|
530
|
+
return result;
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Get unconsumed events for an agent's process (for message-route scanning) */
|
|
535
|
+
drainEvents(agentName: string): Array<Record<string, unknown>> {
|
|
536
|
+
const pooled = this.procs.get(agentName);
|
|
537
|
+
if (!pooled) return [];
|
|
538
|
+
const out = pooled.events;
|
|
539
|
+
pooled.events = [];
|
|
540
|
+
return out;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Whether the process is alive */
|
|
544
|
+
isAlive(agentName: string): boolean {
|
|
545
|
+
const p = this.procs.get(agentName);
|
|
546
|
+
return !!p && !p.closed && p.proc.exitCode === null && p.proc.signalCode === null;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Recycle idle processes */
|
|
550
|
+
private recycleIdle() {
|
|
551
|
+
const now = Date.now();
|
|
552
|
+
for (const [name, pooled] of this.procs) {
|
|
553
|
+
if (pooled.closed || pooled.proc.exitCode !== null || pooled.proc.signalCode !== null) {
|
|
554
|
+
this.procs.delete(name);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
// RPC responses live in pending; model/task completion lives in
|
|
558
|
+
// turnWaiters. Either means the resident process is active.
|
|
559
|
+
if (pooled.pending.size > 0 || pooled.turnWaiters.length > 0) continue;
|
|
560
|
+
if (now - pooled.lastUsed > IDLE_RECYCLE_MS) {
|
|
561
|
+
this.terminateProcess(name, pooled, new Error(`Subagent process ${name} recycled after being idle.`));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Shut down all processes */
|
|
567
|
+
dispose() {
|
|
568
|
+
if (this.disposed) return;
|
|
569
|
+
this.disposed = true;
|
|
570
|
+
clearInterval(this.recycleTimer);
|
|
571
|
+
for (const [name, pooled] of this.procs) {
|
|
572
|
+
this.terminateProcess(name, pooled, new Error("Subagent pool disposed."));
|
|
573
|
+
}
|
|
574
|
+
this.procs.clear();
|
|
575
|
+
this.taskTails.clear();
|
|
576
|
+
this.eventListeners.clear();
|
|
577
|
+
}
|
|
578
|
+
}
|