pi-agent-squad 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -12
- package/active-runs.ts +87 -0
- package/agents/actor.md +14 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents.ts +27 -6
- package/index.ts +425 -125
- package/message.ts +605 -112
- package/orchestrator.md +118 -134
- package/package.json +1 -1
- package/pool.ts +387 -167
- package/session-ui.ts +44 -3
- package/session.ts +7 -0
- package/spawn.ts +232 -69
package/session-ui.ts
CHANGED
|
@@ -185,7 +185,19 @@ class SubagentSessionComponent implements Component, Focusable {
|
|
|
185
185
|
|
|
186
186
|
private pushItem(item: TranscriptItem): void {
|
|
187
187
|
this.items.push(item);
|
|
188
|
-
if (this.items.length
|
|
188
|
+
if (this.items.length <= 160) return;
|
|
189
|
+
this.items.splice(0, this.items.length - 160);
|
|
190
|
+
const retainedTools = new Set<string>();
|
|
191
|
+
for (const retained of this.items) {
|
|
192
|
+
if (retained.kind !== "compact") continue;
|
|
193
|
+
for (const tool of retained.state.tools) retainedTools.add(tool.id);
|
|
194
|
+
}
|
|
195
|
+
for (const id of this.tools.keys()) {
|
|
196
|
+
if (!retainedTools.has(id)) {
|
|
197
|
+
this.tools.delete(id);
|
|
198
|
+
this.toolGroups.delete(id);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
189
201
|
}
|
|
190
202
|
|
|
191
203
|
private addUser(text: string): UserTranscriptItem | undefined {
|
|
@@ -602,9 +614,15 @@ class SubagentSessionComponent implements Component, Focusable {
|
|
|
602
614
|
}
|
|
603
615
|
}
|
|
604
616
|
|
|
605
|
-
export async function openSubagentSessionOverlay(
|
|
617
|
+
export async function openSubagentSessionOverlay(
|
|
618
|
+
ctx: any,
|
|
619
|
+
session: SubagentSessionHandle,
|
|
620
|
+
registerClose?: (close: (() => void) | undefined) => void,
|
|
621
|
+
): Promise<void> {
|
|
606
622
|
const bufferedEvents: any[] = [];
|
|
607
623
|
let component: SubagentSessionComponent | undefined;
|
|
624
|
+
let finishOverlay: (() => void) | undefined;
|
|
625
|
+
let visibilityTimer: ReturnType<typeof setInterval> | undefined;
|
|
608
626
|
const unsubscribe = session.subscribe((event) => {
|
|
609
627
|
if (component) component.pushEvent(event);
|
|
610
628
|
else bufferedEvents.push(event);
|
|
@@ -616,8 +634,9 @@ export async function openSubagentSessionOverlay(ctx: any, session: SubagentSess
|
|
|
616
634
|
/* a just-starting process may not have history yet */
|
|
617
635
|
}
|
|
618
636
|
try {
|
|
619
|
-
await ctx.ui.custom
|
|
637
|
+
await ctx.ui.custom(
|
|
620
638
|
(tui: any, theme: any, keybindings: any, done: () => void) => {
|
|
639
|
+
finishOverlay = () => done();
|
|
621
640
|
component = new SubagentSessionComponent(
|
|
622
641
|
tui,
|
|
623
642
|
theme,
|
|
@@ -639,10 +658,32 @@ export async function openSubagentSessionOverlay(ctx: any, session: SubagentSess
|
|
|
639
658
|
anchor: "center",
|
|
640
659
|
margin: 0,
|
|
641
660
|
},
|
|
661
|
+
onHandle: (handle: any) => {
|
|
662
|
+
// The host's hideOverlay() removes the entry without calling
|
|
663
|
+
// done() or component.dispose(). Detect that external hide and
|
|
664
|
+
// complete the custom UI promise ourselves.
|
|
665
|
+
visibilityTimer = setInterval(() => {
|
|
666
|
+
if (!handle.isHidden() && !handle.isFocused()) {
|
|
667
|
+
component?.dispose();
|
|
668
|
+
finishOverlay?.();
|
|
669
|
+
}
|
|
670
|
+
}, 500);
|
|
671
|
+
visibilityTimer.unref?.();
|
|
672
|
+
registerClose?.(() => {
|
|
673
|
+
// Pi's host may remove an overlay without calling done()
|
|
674
|
+
// or disposing its component. Complete the custom promise
|
|
675
|
+
// explicitly so callers can tear down the session safely.
|
|
676
|
+
component?.dispose();
|
|
677
|
+
finishOverlay?.();
|
|
678
|
+
});
|
|
679
|
+
},
|
|
642
680
|
},
|
|
643
681
|
);
|
|
644
682
|
} catch (error) {
|
|
645
683
|
if (!component) unsubscribe();
|
|
646
684
|
throw error;
|
|
685
|
+
} finally {
|
|
686
|
+
if (visibilityTimer) clearInterval(visibilityTimer);
|
|
687
|
+
registerClose?.(undefined);
|
|
647
688
|
}
|
|
648
689
|
}
|
package/session.ts
CHANGED
|
@@ -3,6 +3,13 @@ export interface SubagentSessionHandle {
|
|
|
3
3
|
getMessages(): Promise<any[]>;
|
|
4
4
|
isStreaming(): Promise<boolean>;
|
|
5
5
|
send(message: string): Promise<void>;
|
|
6
|
+
/**
|
|
7
|
+
* Deliver a routed message to this exact session and wait until that
|
|
8
|
+
* message's turn settles. The UI only needs `send`; the message router
|
|
9
|
+
* uses this stronger operation so it never accidentally falls back to a
|
|
10
|
+
* different process with the same logical agent name.
|
|
11
|
+
*/
|
|
12
|
+
sendAndWait?(message: string, timeoutMs: number, signal?: AbortSignal): Promise<string>;
|
|
6
13
|
abort(): Promise<void>;
|
|
7
14
|
subscribe(listener: (event: any) => void): () => void;
|
|
8
15
|
}
|
package/spawn.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
@@ -43,6 +43,8 @@ export interface SingleResult {
|
|
|
43
43
|
export interface SpawnOptions {
|
|
44
44
|
agent: AgentConfig;
|
|
45
45
|
task: string;
|
|
46
|
+
/** Runtime address. Defaults to the logical agent name for compatibility. */
|
|
47
|
+
address?: string;
|
|
46
48
|
cwd?: string;
|
|
47
49
|
messageRoot: string;
|
|
48
50
|
runId: string;
|
|
@@ -58,6 +60,33 @@ export interface InteractiveSpawnOptions extends SpawnOptions {
|
|
|
58
60
|
|
|
59
61
|
const DEFAULT_SPAWN_TIMEOUT_MS = 6 * 60 * 60 * 1000;
|
|
60
62
|
const FORCE_KILL_DELAY_MS = 5000;
|
|
63
|
+
const TEARDOWN_ABORT_CAP_MS = 3000;
|
|
64
|
+
const CRASH_STDERR_MAX_CHARS = 2000;
|
|
65
|
+
|
|
66
|
+
/** Bound a best-effort teardown step so a stuck child can never stall cleanup. */
|
|
67
|
+
async function raceWithCap(promise: Promise<void>, capMs: number): Promise<void> {
|
|
68
|
+
let capTimer: ReturnType<typeof setTimeout> | undefined;
|
|
69
|
+
try {
|
|
70
|
+
await Promise.race([
|
|
71
|
+
promise,
|
|
72
|
+
new Promise<void>((resolve) => {
|
|
73
|
+
capTimer = setTimeout(resolve, capMs);
|
|
74
|
+
capTimer.unref?.();
|
|
75
|
+
}),
|
|
76
|
+
]);
|
|
77
|
+
} finally {
|
|
78
|
+
if (capTimer) clearTimeout(capTimer);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Best-effort access to the child process behind an RpcClient. The field is
|
|
84
|
+
* private upstream; observing it lets a crash settle the run immediately
|
|
85
|
+
* instead of waiting out the run timeout. Returns null when unavailable.
|
|
86
|
+
*/
|
|
87
|
+
function getChildProcess(client: RpcClient): ChildProcess | null {
|
|
88
|
+
return (client as unknown as { process?: ChildProcess | null }).process ?? null;
|
|
89
|
+
}
|
|
61
90
|
|
|
62
91
|
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
63
92
|
const currentScript = process.argv[1];
|
|
@@ -75,8 +104,13 @@ async function writePromptToTempFile(agentName: string, prompt: string): Promise
|
|
|
75
104
|
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
|
|
76
105
|
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
77
106
|
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
|
|
78
|
-
|
|
79
|
-
|
|
107
|
+
try {
|
|
108
|
+
await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
|
|
109
|
+
return filePath;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
80
114
|
}
|
|
81
115
|
|
|
82
116
|
export function getFinalOutput(messages: SingleResult["messages"]): string {
|
|
@@ -100,10 +134,19 @@ export function getFinalOutput(messages: SingleResult["messages"]): string {
|
|
|
100
134
|
* one-shot process, this keeps one in-memory session alive for the duration of
|
|
101
135
|
* the task so the main TUI can attach, stream events, steer it, and return to
|
|
102
136
|
* main without losing the subagent's context.
|
|
137
|
+
*
|
|
138
|
+
* Lifecycle: the initial prompt, run settle, timeout, abort signal, and child
|
|
139
|
+
* process exit all feed one terminal-cause promise; whichever fires first
|
|
140
|
+
* decides the outcome. `finally` then tears down the client, timers, and
|
|
141
|
+
* listeners exactly once, so a timeout, cancel, or crash can never leave a
|
|
142
|
+
* waiter or a child process behind.
|
|
103
143
|
*/
|
|
104
144
|
export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): Promise<SingleResult> {
|
|
105
145
|
const { agent, task, cwd, messageRoot, runId, childIndex, signal } = opts;
|
|
146
|
+
if (signal?.aborted) throw new Error("Subagent was aborted");
|
|
147
|
+
const address = opts.address ?? agent.name;
|
|
106
148
|
const timeoutMs = Math.max(1000, opts.timeoutMs ?? DEFAULT_SPAWN_TIMEOUT_MS);
|
|
149
|
+
const timeoutMessage = `Subagent timed out after ${Math.round(timeoutMs / 1000)}s`;
|
|
107
150
|
const result: SingleResult = {
|
|
108
151
|
agent: agent.name,
|
|
109
152
|
task,
|
|
@@ -114,13 +157,52 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
|
|
|
114
157
|
model: agent.model,
|
|
115
158
|
};
|
|
116
159
|
|
|
160
|
+
// ---- unified cancellable lifecycle -------------------------------------
|
|
161
|
+
type TerminalCause =
|
|
162
|
+
| { kind: "settled" }
|
|
163
|
+
| { kind: "timeout" }
|
|
164
|
+
| { kind: "aborted" }
|
|
165
|
+
| { kind: "crashed"; error: Error };
|
|
166
|
+
let terminalCause: TerminalCause | undefined;
|
|
167
|
+
let settleLifecycle!: (cause: TerminalCause) => void;
|
|
168
|
+
const lifecycle = new Promise<TerminalCause>((resolve) => {
|
|
169
|
+
settleLifecycle = (cause) => {
|
|
170
|
+
if (terminalCause) return; // first cause wins, later ones are no-ops
|
|
171
|
+
terminalCause = cause;
|
|
172
|
+
resolve(cause);
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
// Rejects when the child process dies so in-flight session waiters fail
|
|
176
|
+
// promptly instead of hanging until their own timeout.
|
|
177
|
+
let rejectCrashed!: (error: Error) => void;
|
|
178
|
+
const crashed = new Promise<never>((_resolve, reject) => {
|
|
179
|
+
rejectCrashed = reject;
|
|
180
|
+
});
|
|
181
|
+
crashed.catch(() => {}); // a crash after every waiter is gone is not an error
|
|
182
|
+
|
|
183
|
+
// Resolved once the initial prompt was accepted; session sends gate on it.
|
|
184
|
+
let initialPromptAccepted = false;
|
|
185
|
+
let acceptInitialPrompt!: () => void;
|
|
186
|
+
let rejectInitialPrompt!: (error: unknown) => void;
|
|
187
|
+
const initialPromptAcceptedPromise = new Promise<void>((resolve, reject) => {
|
|
188
|
+
acceptInitialPrompt = () => {
|
|
189
|
+
initialPromptAccepted = true;
|
|
190
|
+
resolve();
|
|
191
|
+
};
|
|
192
|
+
rejectInitialPrompt = (error: unknown) => {
|
|
193
|
+
initialPromptAccepted = true;
|
|
194
|
+
reject(error);
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
void initialPromptAcceptedPromise.catch(() => {});
|
|
198
|
+
|
|
117
199
|
let tmpPromptPath: string | null = null;
|
|
118
200
|
let client: RpcClient | undefined;
|
|
119
|
-
let
|
|
120
|
-
let
|
|
201
|
+
let childProcess: ChildProcess | null = null;
|
|
202
|
+
let unsubscribeEvents: (() => void) | undefined;
|
|
203
|
+
let signalAbortHandler: (() => void) | undefined;
|
|
204
|
+
let childExitListener: ((code: number | null, exitSignal: string | null) => void) | undefined;
|
|
121
205
|
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
|
122
|
-
let timedOut = false;
|
|
123
|
-
let wasAborted = false;
|
|
124
206
|
const sessionListeners = new Set<(event: any) => void>();
|
|
125
207
|
|
|
126
208
|
const emitSessionEvent = (event: any) => {
|
|
@@ -132,6 +214,12 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
|
|
|
132
214
|
}
|
|
133
215
|
}
|
|
134
216
|
};
|
|
217
|
+
const sessionClosedError = (): string | undefined => {
|
|
218
|
+
if (terminalCause?.kind === "aborted") return "Subagent was aborted";
|
|
219
|
+
if (terminalCause?.kind === "timeout") return timeoutMessage;
|
|
220
|
+
if (terminalCause?.kind === "crashed") return terminalCause.error.message;
|
|
221
|
+
return undefined;
|
|
222
|
+
};
|
|
135
223
|
|
|
136
224
|
try {
|
|
137
225
|
const args: string[] = ["--no-session"];
|
|
@@ -153,9 +241,9 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
|
|
|
153
241
|
env[ENV_ROLE] = ROLE_CHILD;
|
|
154
242
|
env[ENV_CHANNEL_ROOT] = messageRoot;
|
|
155
243
|
env[ENV_RUN_ID] = runId;
|
|
156
|
-
env[ENV_AGENT] =
|
|
244
|
+
env[ENV_AGENT] = address;
|
|
157
245
|
env[ENV_CHILD_INDEX] = String(childIndex);
|
|
158
|
-
ensureDir(channelDir(messageRoot, runId,
|
|
246
|
+
ensureDir(channelDir(messageRoot, runId, address, childIndex));
|
|
159
247
|
|
|
160
248
|
const currentScript = process.argv[1];
|
|
161
249
|
const cliPath =
|
|
@@ -169,8 +257,9 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
|
|
|
169
257
|
model: agent.model,
|
|
170
258
|
args,
|
|
171
259
|
});
|
|
260
|
+
const activeClient = client;
|
|
172
261
|
|
|
173
|
-
|
|
262
|
+
unsubscribeEvents = client.onEvent((event: any) => {
|
|
174
263
|
opts.onEvent?.(event);
|
|
175
264
|
emitSessionEvent(event);
|
|
176
265
|
if (event?.type === "message_end" && event.message) {
|
|
@@ -192,27 +281,93 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
|
|
|
192
281
|
if (message.errorMessage) result.errorMessage = message.errorMessage;
|
|
193
282
|
}
|
|
194
283
|
}
|
|
284
|
+
// Run settle is owned by the lifecycle (this replaces
|
|
285
|
+
// RpcClient.waitForIdle, whose internal timer and listener could not
|
|
286
|
+
// be cancelled during teardown). The subscription above is live
|
|
287
|
+
// before the prompt is sent, so a very fast run cannot settle in the
|
|
288
|
+
// gap between prompt acceptance and waiter setup.
|
|
289
|
+
if (event?.type === "agent_settled") settleLifecycle({ kind: "settled" });
|
|
195
290
|
});
|
|
196
291
|
|
|
292
|
+
// Timeout and abort feed the same terminal-cause promise as settle/crash.
|
|
293
|
+
timeoutTimer = setTimeout(() => settleLifecycle({ kind: "timeout" }), timeoutMs);
|
|
294
|
+
timeoutTimer.unref?.();
|
|
295
|
+
signalAbortHandler = () => settleLifecycle({ kind: "aborted" });
|
|
296
|
+
if (signal?.aborted) signalAbortHandler();
|
|
297
|
+
else signal?.addEventListener("abort", signalAbortHandler, { once: true });
|
|
298
|
+
|
|
197
299
|
await client.start();
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
300
|
+
// RpcClient creates its child during start(), so inspect it only now.
|
|
301
|
+
// Looking before start() always returned null and missed child crashes.
|
|
302
|
+
childProcess = getChildProcess(activeClient);
|
|
303
|
+
if (childProcess) {
|
|
304
|
+
childExitListener = (code, exitSignal) => {
|
|
305
|
+
const stderr = activeClient.getStderr().trim();
|
|
306
|
+
const error = new Error(
|
|
307
|
+
`Subagent process exited unexpectedly (code=${code} signal=${exitSignal})` +
|
|
308
|
+
(stderr ? `: ${stderr.slice(-CRASH_STDERR_MAX_CHARS)}` : "."),
|
|
309
|
+
);
|
|
310
|
+
rejectCrashed(error);
|
|
311
|
+
settleLifecycle({ kind: "crashed", error });
|
|
312
|
+
};
|
|
313
|
+
childProcess.once("exit", childExitListener);
|
|
314
|
+
if (childProcess.exitCode !== null || childProcess.signalCode !== null) {
|
|
315
|
+
childExitListener(childProcess.exitCode, childProcess.signalCode);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
206
318
|
const session: SubagentSessionHandle = {
|
|
207
|
-
agent:
|
|
319
|
+
agent: address,
|
|
208
320
|
getMessages: () => activeClient.getMessages(),
|
|
209
321
|
isStreaming: async () => (await activeClient.getState()).isStreaming,
|
|
210
322
|
send: async (message: string) => {
|
|
211
|
-
await
|
|
323
|
+
await initialPromptAcceptedPromise;
|
|
212
324
|
const state = await activeClient.getState();
|
|
213
325
|
if (state.isStreaming) await activeClient.steer(message);
|
|
214
326
|
else await activeClient.prompt(message);
|
|
215
327
|
},
|
|
328
|
+
sendAndWait: async (message: string, messageTimeoutMs: number, messageSignal?: AbortSignal) => {
|
|
329
|
+
await initialPromptAcceptedPromise;
|
|
330
|
+
if (messageSignal?.aborted) throw new Error("Message delivery cancelled.");
|
|
331
|
+
const before = await activeClient.getMessages();
|
|
332
|
+
// Own settle detection so the timer and listener are always
|
|
333
|
+
// cancelled below (RpcClient.waitForIdle would leak both).
|
|
334
|
+
let resolveSettled!: () => void;
|
|
335
|
+
let rejectSettled!: (error: unknown) => void;
|
|
336
|
+
const settled = new Promise<void>((resolve, reject) => {
|
|
337
|
+
resolveSettled = resolve;
|
|
338
|
+
rejectSettled = reject;
|
|
339
|
+
});
|
|
340
|
+
let unsubscribeSettle: (() => void) | undefined;
|
|
341
|
+
let settleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
342
|
+
let abortHandler: (() => void) | undefined;
|
|
343
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
344
|
+
abortHandler = () => reject(new Error("Message delivery cancelled."));
|
|
345
|
+
if (messageSignal?.aborted) abortHandler();
|
|
346
|
+
else messageSignal?.addEventListener("abort", abortHandler, { once: true });
|
|
347
|
+
});
|
|
348
|
+
try {
|
|
349
|
+
unsubscribeSettle = activeClient.onEvent((event: any) => {
|
|
350
|
+
if (event?.type === "agent_settled") resolveSettled();
|
|
351
|
+
});
|
|
352
|
+
settleTimer = setTimeout(() => {
|
|
353
|
+
rejectSettled(
|
|
354
|
+
new Error(`Timeout waiting for agent to become idle. Stderr: ${activeClient.getStderr()}`),
|
|
355
|
+
);
|
|
356
|
+
}, messageTimeoutMs);
|
|
357
|
+
settleTimer.unref?.();
|
|
358
|
+
const state = await activeClient.getState();
|
|
359
|
+
if (state.isStreaming) await activeClient.steer(message);
|
|
360
|
+
else await activeClient.prompt(message);
|
|
361
|
+
// `crashed` fails fast if the child dies mid-message.
|
|
362
|
+
await Promise.race([settled, aborted, crashed]);
|
|
363
|
+
} finally {
|
|
364
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
365
|
+
unsubscribeSettle?.();
|
|
366
|
+
if (messageSignal && abortHandler) messageSignal.removeEventListener("abort", abortHandler);
|
|
367
|
+
}
|
|
368
|
+
const after = await activeClient.getMessages();
|
|
369
|
+
return getFinalOutput(after.slice(before.length) as unknown as SingleResult["messages"]);
|
|
370
|
+
},
|
|
216
371
|
abort: () => activeClient.abort(),
|
|
217
372
|
subscribe: (listener) => {
|
|
218
373
|
sessionListeners.add(listener);
|
|
@@ -221,61 +376,66 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
|
|
|
221
376
|
};
|
|
222
377
|
opts.onSession?.(session);
|
|
223
378
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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);
|
|
379
|
+
// Never launch the initial task for a run that is already terminal.
|
|
380
|
+
if (!terminalCause) {
|
|
381
|
+
try {
|
|
382
|
+
await Promise.race([
|
|
383
|
+
activeClient.prompt(`Task: ${task}`),
|
|
384
|
+
lifecycle.then((cause) => {
|
|
385
|
+
if (cause.kind === "timeout") throw new Error(timeoutMessage);
|
|
386
|
+
if (cause.kind === "aborted") throw new Error("Subagent was aborted");
|
|
387
|
+
if (cause.kind === "crashed") throw cause.error;
|
|
388
|
+
}),
|
|
389
|
+
]);
|
|
390
|
+
acceptInitialPrompt();
|
|
391
|
+
} catch (error) {
|
|
392
|
+
rejectInitialPrompt(error);
|
|
393
|
+
throw error;
|
|
263
394
|
}
|
|
264
395
|
}
|
|
396
|
+
|
|
397
|
+
const cause = terminalCause ?? (await lifecycle);
|
|
398
|
+
if (cause.kind !== "settled") {
|
|
399
|
+
// Ask the child to finish its turn, but never let an unresponsive
|
|
400
|
+
// child stall teardown — stop() below escalates to SIGKILL.
|
|
401
|
+
await raceWithCap(activeClient.abort().catch(() => {}), TEARDOWN_ABORT_CAP_MS);
|
|
402
|
+
if (cause.kind === "aborted") throw new Error("Subagent was aborted");
|
|
403
|
+
if (cause.kind === "crashed") throw cause.error;
|
|
404
|
+
// timeout: report as a timed-out result (previous behavior)
|
|
405
|
+
result.exitCode = 124;
|
|
406
|
+
result.stopReason = "error";
|
|
407
|
+
result.errorMessage = timeoutMessage;
|
|
408
|
+
}
|
|
265
409
|
result.stderr = activeClient.getStderr();
|
|
266
410
|
return result;
|
|
267
411
|
} finally {
|
|
268
412
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
269
|
-
if (signal &&
|
|
270
|
-
|
|
271
|
-
|
|
413
|
+
if (signal && signalAbortHandler) signal.removeEventListener("abort", signalAbortHandler);
|
|
414
|
+
if (childProcess && childExitListener) {
|
|
415
|
+
try {
|
|
416
|
+
childProcess.off("exit", childExitListener);
|
|
417
|
+
} catch {
|
|
418
|
+
/* process may already be gone */
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
unsubscribeEvents?.();
|
|
422
|
+
// Release anything still gated on the initial prompt so no session
|
|
423
|
+
// caller can hang on a run that ended before it started.
|
|
424
|
+
if (!initialPromptAccepted) {
|
|
425
|
+
rejectInitialPrompt(new Error("Subagent session closed before the initial prompt was accepted."));
|
|
426
|
+
}
|
|
427
|
+
if (client) {
|
|
428
|
+
const childDied =
|
|
429
|
+
childProcess !== null && (childProcess.exitCode !== null || childProcess.signalCode !== null);
|
|
430
|
+
if (!childDied) {
|
|
431
|
+
// stop() waits up to its internal 1s kill timer; skip it when the
|
|
432
|
+
// child already exited so a crash tears down promptly.
|
|
433
|
+
await client.stop().catch(() => {});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
272
436
|
emitSessionEvent({
|
|
273
437
|
type: "session_closed",
|
|
274
|
-
error:
|
|
275
|
-
? `Subagent timed out after ${Math.round(timeoutMs / 1000)}s`
|
|
276
|
-
: wasAborted
|
|
277
|
-
? "Subagent was aborted"
|
|
278
|
-
: undefined,
|
|
438
|
+
error: sessionClosedError(),
|
|
279
439
|
});
|
|
280
440
|
sessionListeners.clear();
|
|
281
441
|
if (tmpPromptPath)
|
|
@@ -291,6 +451,7 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
|
|
|
291
451
|
/** Spawn a subagent (isolated context, message channel + system prompt), parse the JSON event stream */
|
|
292
452
|
export async function spawnSubagent(opts: SpawnOptions): Promise<SingleResult> {
|
|
293
453
|
const { agent, task, cwd, messageRoot, runId, childIndex, signal } = opts;
|
|
454
|
+
const address = opts.address ?? agent.name;
|
|
294
455
|
const timeoutMs = Math.max(1000, opts.timeoutMs ?? DEFAULT_SPAWN_TIMEOUT_MS);
|
|
295
456
|
const emptyUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
|
|
296
457
|
const result: SingleResult = {
|
|
@@ -305,6 +466,8 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SingleResult> {
|
|
|
305
466
|
|
|
306
467
|
let tmpPromptPath: string | null = null;
|
|
307
468
|
try {
|
|
469
|
+
// A cancel that arrives before spawn must not launch the process at all.
|
|
470
|
+
if (signal?.aborted) throw new Error("Subagent was aborted");
|
|
308
471
|
const args: string[] = ["--mode", "json", "-p", "--no-session"];
|
|
309
472
|
if (agent.model) args.push("--model", agent.model);
|
|
310
473
|
if (agent.thinking) args.push("--thinking", agent.thinking);
|
|
@@ -325,9 +488,9 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SingleResult> {
|
|
|
325
488
|
env[ENV_ROLE] = ROLE_CHILD;
|
|
326
489
|
env[ENV_CHANNEL_ROOT] = messageRoot;
|
|
327
490
|
env[ENV_RUN_ID] = runId;
|
|
328
|
-
env[ENV_AGENT] =
|
|
491
|
+
env[ENV_AGENT] = address;
|
|
329
492
|
env[ENV_CHILD_INDEX] = String(childIndex);
|
|
330
|
-
ensureDir(channelDir(messageRoot, runId,
|
|
493
|
+
ensureDir(channelDir(messageRoot, runId, address, childIndex));
|
|
331
494
|
|
|
332
495
|
let wasAborted = false;
|
|
333
496
|
let timedOut = false;
|