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/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 > 160) this.items.splice(0, this.items.length - 160);
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(ctx: any, session: SubagentSessionHandle): Promise<void> {
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<void>(
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
- await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
79
- return filePath;
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 unsubscribe: (() => void) | undefined;
120
- let abortHandler: (() => void) | undefined;
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] = agent.name;
244
+ env[ENV_AGENT] = address;
157
245
  env[ENV_CHILD_INDEX] = String(childIndex);
158
- ensureDir(channelDir(messageRoot, runId, agent.name, childIndex));
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
- unsubscribe = client.onEvent((event: any) => {
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
- 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(() => {});
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: agent.name,
319
+ agent: address,
208
320
  getMessages: () => activeClient.getMessages(),
209
321
  isStreaming: async () => (await activeClient.getState()).isStreaming,
210
322
  send: async (message: string) => {
211
- await initialPromptAccepted;
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
- 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);
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 && abortHandler) signal.removeEventListener("abort", abortHandler);
270
- unsubscribe?.();
271
- if (client) await client.stop().catch(() => {});
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: timedOut
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] = agent.name;
491
+ env[ENV_AGENT] = address;
329
492
  env[ENV_CHILD_INDEX] = String(childIndex);
330
- ensureDir(channelDir(messageRoot, runId, agent.name, childIndex));
493
+ ensureDir(channelDir(messageRoot, runId, address, childIndex));
331
494
 
332
495
  let wasAborted = false;
333
496
  let timedOut = false;