pi-jar 0.1.6 → 0.1.7

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/src/delegate.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { signalProcessTree } from "./async-process.ts";
3
+ import { CHILD_TOOLS_ENV, RECURSIVE_TOOLS, WEB_TOOLS } from "./subagent-tools.ts";
4
+ import { existsSync, mkdtempSync } from "node:fs";
5
+ import { rm } from "node:fs/promises";
3
6
  import { tmpdir } from "node:os";
4
7
  import { basename, isAbsolute, join, resolve } from "node:path";
5
8
  import { fileURLToPath } from "node:url";
@@ -30,7 +33,11 @@ export const SUBAGENT_LIMIT_CHOICES = [2, 4, 6, 8, 16] as const;
30
33
  /** Read-only subagents also keep jar_todo so their checklist shows in the activity view. */
31
34
  export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "jar_todo", "jar_discuss"] as const;
32
35
  /** Writable worktree children deliberately have no shell: edits stay inside path-guarded file tools. */
33
- export const WORKTREE_TOOLS = ["read", "edit", "write", "grep", "find", "ls", "jar_todo", "jar_discuss"] as const;
36
+ export const WORKTREE_TOOLS = ["read", "edit", "write", "multi_file_edit", "grep", "find", "ls", "jar_todo", "jar_discuss"] as const;
37
+ const READ_ONLY_SAFE_TOOLS = new Set<string>([...READ_ONLY_TOOLS, ...WEB_TOOLS, "ffgrep", "fffind", "lsp_diagnostics", "obs_recall"]);
38
+ const WORKTREE_SAFE_TOOLS = new Set<string>([...WORKTREE_TOOLS, ...WEB_TOOLS]);
39
+ const allowedInMode = (mode: DelegateExecutionMode, name: string) => !RECURSIVE_TOOLS.has(name)
40
+ && (mode === "direct" || (mode === "worktree" ? WORKTREE_SAFE_TOOLS.has(name) : READ_ONLY_SAFE_TOOLS.has(name)));
34
41
  export type DelegateMode = "scout" | "fork" | "worktree";
35
42
  type DelegateExecutionMode = DelegateMode | "direct";
36
43
  const MAX_OUTPUT_CHARS = 12_000;
@@ -146,7 +153,7 @@ export interface DelegateForkOptions {
146
153
 
147
154
  /** CLI arguments for one subagent: fresh ephemeral session or a real Pi session fork. */
148
155
  export function delegateArgs(model: string | undefined, thinking: string | undefined, write: boolean, argv = process.argv,
149
- fork?: DelegateForkOptions): string[] {
156
+ fork?: DelegateForkOptions, toolOverride?: readonly string[]): string[] {
150
157
  const session = fork ? ["--fork", fork.source, "--session-dir", fork.sessionDir] : ["--no-session"];
151
158
  const extensions = extensionFlags(argv);
152
159
  // Worktree cwd/session forks do not inherit project extension discovery from the parent.
@@ -157,7 +164,7 @@ export function delegateArgs(model: string | undefined, thinking: string | undef
157
164
  const args = ["--mode", "rpc", ...session, ...extensions];
158
165
  if (model) args.push("--model", model);
159
166
  if (thinking) args.push("--thinking", thinking);
160
- const tools = fork?.tools ?? (!write ? READ_ONLY_TOOLS : undefined);
167
+ const tools = fork?.tools ?? toolOverride ?? (!write ? READ_ONLY_TOOLS : undefined);
161
168
  if (tools) args.push("--tools", tools.join(","));
162
169
  return args;
163
170
  }
@@ -286,6 +293,7 @@ interface ChildEvent {
286
293
  id?: unknown;
287
294
  command?: unknown;
288
295
  success?: unknown;
296
+ data?: { disposition?: unknown };
289
297
  error?: unknown;
290
298
  method?: unknown;
291
299
  toolCallId?: unknown;
@@ -338,8 +346,8 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
338
346
  let child: ChildProcess;
339
347
  try {
340
348
  const env = { ...process.env };
341
- for (const key of [CHILD_BASELINE_ENV, CHILD_WORKTREE_ENV, DISCUSSION_FILE_ENV, SUBAGENT_KEY_ENV, SUBAGENT_NAME_ENV]) delete env[key];
342
- child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, stdio: ["pipe", "pipe", "pipe"], env: { ...env, ...hooks.env, [CHILD_ENV]: "1" } });
349
+ for (const key of [CHILD_BASELINE_ENV, CHILD_WORKTREE_ENV, CHILD_TOOLS_ENV, DISCUSSION_FILE_ENV, SUBAGENT_KEY_ENV, SUBAGENT_NAME_ENV]) delete env[key];
350
+ child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], env: { ...env, ...hooks.env, [CHILD_ENV]: "1" } });
343
351
  } catch (error) {
344
352
  run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); run.endedAt = Date.now(); update();
345
353
  firstResolved = true; resolveFirst(); resolveClosed(); return;
@@ -373,13 +381,22 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
373
381
  const send = (command: object): boolean => {
374
382
  const stdin = child.stdin;
375
383
  if (!alive || !stdin || stdin.destroyed || stdin.writableEnded) return false;
384
+ // Node queues writes until drain. Bound that queue if a hung RPC child stops reading;
385
+ // accepted commands remain complete JSONL records, never partial/manual framing.
386
+ if (stdin.writableLength > 256 * 1024) return false;
376
387
  try { stdin.write(JSON.stringify(command) + "\n"); return true; } catch { return false; }
377
388
  };
378
389
  const clearTurnTimer = () => { if (turnTimer) clearTimeout(turnTimer); turnTimer = undefined; };
379
390
  const hardStop = () => {
380
391
  if (!alive) return;
381
- child.kill("SIGTERM");
382
- killTimer ??= setTimeout(() => { if (alive) child.kill("SIGKILL"); }, 3000);
392
+ signalProcessTree(child, "SIGTERM");
393
+ killTimer ??= setTimeout(() => {
394
+ // Kill the group even if its leader exited: descendants can ignore SIGTERM and keep pipes.
395
+ signalProcessTree(child, "SIGKILL");
396
+ child.stdout?.destroy?.();
397
+ child.stderr?.destroy?.();
398
+ exited(child.exitCode);
399
+ }, 3000);
383
400
  killTimer.unref?.();
384
401
  };
385
402
  const armTurnTimer = (delay = IDLE_TIMEOUT_MS) => {
@@ -452,6 +469,27 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
452
469
  };
453
470
 
454
471
  const running = new Map<string, { entry: ToolEntry; path?: string }>();
472
+ const settleTurn = () => {
473
+ if (settled || finished) return;
474
+ settled = true;
475
+ clearTurnTimer();
476
+ for (const call of running.values()) {
477
+ call.entry.status = "error";
478
+ call.entry.endedAt = Date.now();
479
+ call.entry.rev++;
480
+ }
481
+ running.clear();
482
+ run.live = "";
483
+ run.activity = undefined;
484
+ run.endedAt = Date.now();
485
+ run.state = run.error ? "failed" : pauseRequested ? "paused" : "idle";
486
+ pauseRequested = false;
487
+ if (!firstResolved && !run.error) { firstResolved = true; resolveFirst(); }
488
+ resolveSettled();
489
+ update();
490
+ if (run.error) { hardStop(); finish("failed", run.error); }
491
+ else if (!persistent && child.stdin && !child.stdin.writableEnded) child.stdin.end();
492
+ };
455
493
  const line = (raw: string) => {
456
494
  if (finished || !raw.trim()) return;
457
495
  let parsed: unknown;
@@ -461,7 +499,11 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
461
499
  activityAt = Date.now();
462
500
  switch (event.type) {
463
501
  case "response":
464
- if (event.success !== false) return;
502
+ // A handled prompt starts no run, so Pi deliberately sends no agent_settled event.
503
+ if (event.success !== false) {
504
+ if (event.command === "prompt" && event.data?.disposition === "handled") settleTurn();
505
+ return;
506
+ }
465
507
  if (event.command === "prompt") { hardStop(); finish("failed", cleanText(String(event.error ?? "prompt rejected"), 300)); return; }
466
508
  pushEntry(run, { kind: "note", rev: 0, text: `${cleanText(String(event.command ?? "command"), 24)} rejected: ${cleanText(String(event.error ?? ""), 200)}` });
467
509
  update();
@@ -537,30 +579,9 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
537
579
  update();
538
580
  return;
539
581
  }
540
- case "agent_settled": {
541
- settled = true;
542
- clearTurnTimer();
543
- for (const call of running.values()) {
544
- call.entry.status = "error";
545
- call.entry.endedAt = Date.now();
546
- call.entry.rev++;
547
- }
548
- running.clear();
549
- run.live = "";
550
- run.activity = undefined;
551
- run.endedAt = Date.now();
552
- run.state = run.error ? "failed" : pauseRequested ? "paused" : "idle";
553
- pauseRequested = false;
554
- if (!firstResolved && !run.error) {
555
- firstResolved = true;
556
- resolveFirst();
557
- }
558
- resolveSettled();
559
- update();
560
- if (run.error) { hardStop(); finish("failed", run.error); }
561
- else if (!persistent && child.stdin && !child.stdin.writableEnded) child.stdin.end();
582
+ case "agent_settled":
583
+ settleTurn();
562
584
  return;
563
- }
564
585
  }
565
586
  };
566
587
 
@@ -570,7 +591,7 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
570
591
  let start = 0;
571
592
  for (let end = text.indexOf("\n", start); end >= 0; end = text.indexOf("\n", start)) {
572
593
  const item = buffer + text.slice(start, end);
573
- if (!overflow) line(item.endsWith("\r") ? item.slice(0, -1) : item);
594
+ if (!overflow && item.length <= MAX_LINE_CHARS) line(item.endsWith("\r") ? item.slice(0, -1) : item);
574
595
  buffer = "";
575
596
  overflow = false;
576
597
  start = end + 1;
@@ -585,10 +606,11 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
585
606
  if (closedOnce) return;
586
607
  closedOnce = true;
587
608
  alive = false;
609
+ if (killTimer) signalProcessTree(child, "SIGKILL");
588
610
  clearTimeout(killTimer);
589
611
  clearTimeout(closeTimer);
590
612
  if (buffer && !overflow) { line(buffer); buffer = ""; }
591
- if (code === 0 && settled && !run.error) finish("done");
613
+ if ((closing || (code === 0 && settled)) && !run.error) finish("done");
592
614
  else finish("failed", run.error ?? (cleanText(stderr, 300) || (settled ? `exited ${code}` : `exited ${code} before finishing`)));
593
615
  if (!firstResolved) { firstResolved = true; resolveFirst(); }
594
616
  resolveClosed();
@@ -598,6 +620,7 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
598
620
  const drainThenClose = (code: number | null) => {
599
621
  if (closedOnce || closeTimer) return;
600
622
  closeTimer = setTimeout(() => {
623
+ signalProcessTree(child, "SIGKILL");
601
624
  child.stdout?.destroy?.();
602
625
  child.stderr?.destroy?.();
603
626
  exited(code);
@@ -614,9 +637,8 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
614
637
  child.once("exit", (code) => {
615
638
  alive = false;
616
639
  clearTurnTimer();
617
- clearTimeout(killTimer);
618
640
  if (!finished) {
619
- run.state = code === 0 && settled && !run.error ? "done" : "failed";
641
+ run.state = (closing || (code === 0 && settled)) && !run.error ? "done" : "failed";
620
642
  if (run.state === "failed") run.error ??= `exited ${code}`;
621
643
  run.activity = undefined;
622
644
  resolveSettled();
@@ -898,7 +920,10 @@ const Parameters = Type.Object({
898
920
  task: Type.String({ description: "Task instruction. scout sees only this task; fork/worktree also inherit the parent's active conversation branch." }),
899
921
  name: Type.Optional(Type.String({ description: "Short label, e.g. \"auth scout\"." })),
900
922
  role: Type.Optional(Type.String({ description: "pi-jar role override. Defaults by mode: scout→scout, fork→reviewer, worktree→worker; then role fallbacks/current model." })),
901
- mode: Type.Optional(DelegateModeSchema)
923
+ mode: Type.Optional(DelegateModeSchema),
924
+ tools: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 80 }), { minItems: 1, maxItems: 64, uniqueItems: true,
925
+ description: "Explicit child tool allowlist. scout/fork remain read-only; worktree accepts only sandbox-aware file tools. Browser/web tools may be enabled when active in the parent." })),
926
+ inheritTools: Type.Optional(Type.Boolean({ description: "Inherit the parent's currently active tool names, subject to mode safety restrictions. Recursive pi-jar delegation tools are always removed." }))
902
927
  }), { minItems: 1, maxItems: 16 }),
903
928
  mode: Type.Optional(DelegateModeSchema),
904
929
  write: Type.Optional(Type.Boolean({ description: "Deprecated compatibility switch. true keeps the old shared-workspace editing mode; prefer mode=\"worktree\"." }))
@@ -928,6 +953,8 @@ export interface DelegateController {
928
953
  }
929
954
 
930
955
  export interface DelegateOptions {
956
+ /** Snapshot names of tools active in the parent session. */
957
+ getActiveToolNames?: () => readonly string[];
931
958
  /** Allowed sizes: 2, 4, 6, 8, 16; defaults to 4. Read on every launch. */
932
959
  getMaxSubagents?: () => number;
933
960
  /** Shares the delegate lifecycle and capacity with scout orchestration. */
@@ -1005,7 +1032,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1005
1032
  return { message: { customType: "pi-jar.moderator-context", display: false, content: [
1006
1033
  "[PI-JAR MODERATOR MODE]",
1007
1034
  "You are the coordinator while retained subagents do delegated work. Decompose and route work, steer only to correct direction, use ask for terse BTW questions, and use jar_discuss for structured cross-agent Q/A.",
1008
- "A resumed agent wakes you with a pi-jar.subagent message when its turn ends: do not poll jar_subagent peek while you wait; end your turn or do other work.",
1035
+ "Initial/resumed turns and asynchronous ask/pause/stop operations wake you with pi-jar.subagent events. Accepted receipts are not completion: do not poll peek or claim files applied before the final event. Keep responding to user steering or do other work.",
1009
1036
  "Do not duplicate work already owned by a retained subagent. Stop completed workers to obtain their handoff and reconcile worktree changes into /diff. Synthesize the final answer from their reports and evidence.",
1010
1037
  "Retained fleet:",
1011
1038
  fleet
@@ -1026,19 +1053,26 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1026
1053
  (raw as { customType?: string }).customType !== "pi-jar.moderator-context" || (active && index === latest)) };
1027
1054
  });
1028
1055
 
1029
- /** Agents resumed by the moderator: their next settled turn wakes it, so it never has to poll peek. */
1056
+ /** Native RPC drives registry state; Pi's message bus delivers frozen completion events to the moderator. */
1057
+ const eventBusAvailable = typeof pi.sendMessage === "function";
1030
1058
  const watched = new Map<string, SubagentRecord>();
1031
- const settledQueue = new Map<string, SubagentRecord>();
1059
+ const settledQueue = new Map<string, { record: SubagentRecord; content: string; turn: number }>();
1060
+ const deleteSettled = (key: string) => {
1061
+ for (const [eventKey, event] of settledQueue) if (event.record.key === key) settledQueue.delete(eventKey);
1062
+ };
1032
1063
  let notifyTimer: NodeJS.Timeout | undefined;
1033
1064
  const flushSettled = () => {
1034
1065
  notifyTimer = undefined;
1035
- const records = [...settledQueue.values()].filter((record) => registry.get(record.key) === record);
1066
+ const records = [...settledQueue.values()].filter(({ record }) => registry.get(record.key) === record);
1036
1067
  settledQueue.clear();
1037
1068
  if (!records.length) return;
1038
1069
  try {
1039
1070
  pi.sendMessage?.({
1040
- customType: SUBAGENT_MESSAGE, display: true, details: { keys: records.map((record) => record.key) },
1041
- content: ["[PI-JAR SUBAGENT] A resumed turn ended. Review, then resume, ask or stop:", ...records.map(settledRecord)].join("\n\n")
1071
+ customType: SUBAGENT_MESSAGE, display: true, details: {
1072
+ keys: [...new Set(records.map(({ record }) => record.key))],
1073
+ turns: records.map(({ record, turn }) => ({ key: record.key, turn }))
1074
+ },
1075
+ content: ["[PI-JAR SUBAGENT] A subagent turn ended. Review, then resume, ask or stop:", ...records.map(event => event.content)].join("\n\n")
1042
1076
  }, { triggerTurn: true, deliverAs: "followUp" });
1043
1077
  } catch (error) { console.error("pi-jar: could not deliver a subagent event", error); }
1044
1078
  };
@@ -1048,7 +1082,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1048
1082
  if (registry.get(key) !== record) { watched.delete(key); continue; }
1049
1083
  if (isRunning(record.run.state)) continue;
1050
1084
  watched.delete(key);
1051
- if (record.run.state !== "stopped") settledQueue.set(key, record);
1085
+ if (record.run.state !== "stopped") {
1086
+ // Freeze reports at the settled boundary. A fast resume must not overwrite a queued
1087
+ // initial report, and multiple turns in the coalescing window must each be delivered.
1088
+ settledQueue.set(`${key}:${record.run.resumes}`, { record, turn: record.run.resumes, content: settledRecord(record) });
1089
+ }
1052
1090
  }
1053
1091
  if (settledQueue.size && !notifyTimer) {
1054
1092
  notifyTimer = setTimeout(flushSettled, NOTIFY_COALESCE_MS);
@@ -1059,12 +1097,12 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1059
1097
  const delegateTool: ToolDefinition<typeof Parameters> = {
1060
1098
  name: DELEGATE_TOOL,
1061
1099
  label: "delegate",
1062
- description: `Spawn retained session-scoped subagents within the configured live pool limit. scout is fresh/read-only and defaults to the scout role; fork inherits the parent conversation read-only and defaults to reviewer; worktree inherits context, defaults to worker, and edits in an isolated Git worktree. Retained agents become idle after a turn and are controlled with jar_subagent. Worktree changes apply only when the moderator stops that agent.`,
1063
- promptSnippet: "Act as moderator: delegate parallel work, inspect progress with jar_subagent peek, steer only when needed, and synthesize results instead of duplicating subagent work.",
1100
+ description: `Start retained session-scoped subagents within the configured live pool limit and return immediately. scout is fresh/read-only and defaults to the scout role; fork inherits the parent conversation read-only and defaults to reviewer; worktree inherits context, defaults to worker, and edits in an isolated Git worktree. Each task may provide an explicit tools allowlist or inheritTools=true to filter the parent's active tools by mode. Turn completions arrive as pi-jar.subagent event messages; control workers with jar_subagent. Worktree changes apply only when the moderator stops that agent.`,
1101
+ promptSnippet: "Act as moderator: delegate parallel work, then continue responding to the user while subagents run. Their turn completions arrive as pi-jar.subagent event messages; steer, pause or stop them with jar_subagent without polling.",
1064
1102
  promptGuidelines: [
1065
1103
  "When useful work can be delegated, act as the moderator: decompose, assign, monitor, resolve disagreements, and synthesize. Do not redo a delegated implementation yourself while its worker is active.",
1066
- "Use scout for cheap independent discovery, fork/reviewer for context-aware review, and worktree/worker for implementation. Explicit task.role overrides these mode defaults.",
1067
- "Retained agents become idle after each turn. Reuse them with jar_subagent resume instead of spawning replacements; pause them when priorities change; stop completed workers to reconcile their worktree into /diff.",
1104
+ "Use scout for cheap independent discovery, fork/reviewer for context-aware review, and worktree/worker for implementation. Explicit task.role overrides these mode defaults. Grant optional task.tools narrowly; task.inheritTools snapshots active parent tools then filters by mode. Scout/fork stay read-only; worktree only accepts path-guarded tools.",
1105
+ "Retained agents become idle after each turn. Initial and resumed turn completions arrive as pi-jar.subagent event messages, so keep responding to user steering while they work; do not poll jar_subagent peek. Reuse them with jar_subagent resume instead of spawning replacements; pause them when priorities change; stop completed workers to reconcile their worktree into /diff.",
1068
1106
  "Use jar_subagent ask for a brief BTW question to one worker. For agent-to-agent questions, have them use the bounded jar_discuss paper so answers stay structured and cheap.",
1069
1107
  "Respect the configured pool limit, including idle and paused agents. Stop agents you no longer need."
1070
1108
  ],
@@ -1072,6 +1110,44 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1072
1110
  async execute(_id, params, signal, onUpdate, ctx) {
1073
1111
  controllerContext = ctx;
1074
1112
  const requested = params.tasks;
1113
+ const parentToolNames = (): string[] | undefined => {
1114
+ try {
1115
+ const supplied = options.getActiveToolNames?.();
1116
+ if (supplied) return [...new Set(supplied.filter(name => typeof name === "string" && name.trim()).map(name => name.trim()))];
1117
+ const tools = pi.getActiveTools?.();
1118
+ return tools ? [...new Set(tools.filter(name => typeof name === "string" && name.trim()))] : undefined;
1119
+ } catch { return undefined; }
1120
+ };
1121
+ const activeNames = parentToolNames();
1122
+ const defaultWorktreeTools = WORKTREE_TOOLS.filter(name => name !== "multi_file_edit" || activeNames?.includes(name));
1123
+ for (const [index, item] of requested.entries()) {
1124
+ const mode = item.mode ?? params.mode ?? (params.write === true ? "direct" : "scout");
1125
+ const selected = item.tools ?? (item.inheritTools && activeNames ? activeNames.filter(name =>
1126
+ allowedInMode(mode, name)) : undefined);
1127
+ if ((item.inheritTools || item.tools) && !activeNames) {
1128
+ return { content: [{ type: "text", text: `Task ${index + 1}: this Pi host does not expose active tool names; configurable tools cannot be validated. Use default tools or a host with getActiveTools().` }], details: { runs: [], write: false }, isError: true };
1129
+ }
1130
+ if (item.inheritTools && !selected?.length) {
1131
+ return { content: [{ type: "text", text: `Task ${index + 1}: no active tools remain after applying ${mode} safety filters. Choose explicit safe tools or use the mode defaults.` }], details: { runs: [], write: false }, isError: true };
1132
+ }
1133
+ if (selected) {
1134
+ if (activeNames) {
1135
+ const missing = selected.filter(name => !activeNames.includes(name));
1136
+ if (missing.length) return { content: [{ type: "text", text: `Task ${index + 1}: tools are not currently active in the parent: ${missing.join(", ")}. Enable their extensions/tools first.` }], details: { runs: [], write: false }, isError: true };
1137
+ } else if (item.inheritTools) {
1138
+ return { content: [{ type: "text", text: `Task ${index + 1}: cannot resolve inherited tools from this Pi host.` }], details: { runs: [], write: false }, isError: true };
1139
+ }
1140
+ const recursive = selected.filter(name => RECURSIVE_TOOLS.has(name));
1141
+ if (recursive.length) return { content: [{ type: "text", text: `Task ${index + 1}: recursive delegation tools are never available to subagents: ${recursive.join(", ")}.` }], details: { runs: [], write: false }, isError: true };
1142
+ if ((mode === "scout" || mode === "fork") && selected.some(name => !READ_ONLY_SAFE_TOOLS.has(name))) {
1143
+ return { content: [{ type: "text", text: `Task ${index + 1}: ${mode} is read-only; remove edit/write/shell tools or use mode="worktree" for sandboxed code changes.` }], details: { runs: [], write: false }, isError: true };
1144
+ }
1145
+ if (mode === "worktree") {
1146
+ const unsafe = selected.filter(name => !WORKTREE_SAFE_TOOLS.has(name));
1147
+ if (unsafe.length) return { content: [{ type: "text", text: `Task ${index + 1}: worktree cannot safely use tools outside its path-guarded allowlist: ${unsafe.join(", ")}. Arbitrary extension tools remain disabled unless the worktree boundary validates them.` }], details: { runs: [], write: false }, isError: true };
1148
+ }
1149
+ }
1150
+ }
1075
1151
  const maximum = maxSubagents();
1076
1152
  const releaseReservation = registry.reserve(requested.length, maximum);
1077
1153
  if (!releaseReservation) {
@@ -1084,8 +1160,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1084
1160
  let available = new Set<string>();
1085
1161
  try { available = new Set(ctx.modelRegistry.getAvailable().map((model) => model.provider + "/" + model.id)); } catch { /* current model remains the fallback */ }
1086
1162
  const thinkingByRun = new Map<number, string | undefined>();
1163
+ const toolsByRun = new Map<number, readonly string[] | undefined>();
1087
1164
  const runs: DelegateRun[] = requested.map((item, index) => {
1088
1165
  const mode: DelegateExecutionMode = item.mode ?? defaultMode;
1166
+ toolsByRun.set(index + 1, item.tools ?? (item.inheritTools && activeNames ? activeNames.filter(name =>
1167
+ allowedInMode(mode, name)) : mode === "worktree" ? defaultWorktreeTools : undefined));
1089
1168
  const role = cleanText(item.role ?? roleForMode(mode), 32) || roleForMode(mode);
1090
1169
  const candidates = [...roleCandidates(role), ...roleCandidates("default")];
1091
1170
  const selected = candidates.find((candidate) => available.has(candidate.provider + "/" + candidate.model));
@@ -1166,13 +1245,13 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1166
1245
  firstSettled: Promise.resolve(), closed: Promise.resolve(), steer: () => false, pause: async () => false,
1167
1246
  resume: () => false, ask: async () => undefined, shutdown: async () => {}, alive: () => false
1168
1247
  });
1169
- const cleanupPrivate = (index: number, discardWorktree: boolean) => {
1248
+ const cleanupPrivate = async (index: number, discardWorktree: boolean) => {
1170
1249
  const dir = sessionDirs.get(index);
1171
- if (dir) { rmSync(dir, { recursive: true, force: true }); sessionDirs.delete(index); }
1250
+ if (dir) { await rm(dir, { recursive: true, force: true }); sessionDirs.delete(index); }
1172
1251
  if (discardWorktree) {
1173
1252
  const worktree = worktrees.get(index);
1174
1253
  if (worktree) {
1175
- try { disposeDelegateWorktree(worktree); } catch { /* best effort */ }
1254
+ try { await disposeDelegateWorktree(worktree); } catch { /* best effort */ }
1176
1255
  worktrees.delete(index);
1177
1256
  }
1178
1257
  }
@@ -1185,12 +1264,20 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1185
1264
  };
1186
1265
 
1187
1266
  let discarded = false;
1267
+ const markStartStopped = (run: DelegateRun) => {
1268
+ run.state = "stopped";
1269
+ run.error = undefined;
1270
+ run.endedAt = Date.now();
1271
+ update();
1272
+ };
1188
1273
  handles = runs.map(failedHandle);
1274
+ const started = new Set<number>();
1189
1275
  const readyResolvers: Array<() => void> = [];
1190
1276
  const ready = runs.map(() => new Promise<void>((resolve) => readyResolvers.push(resolve)));
1191
- const startRuns = () => runs.forEach((run, index) => {
1277
+ const startRuns = async () => Promise.all(runs.map(async (run, index) => {
1278
+ try {
1192
1279
  if (discarded) { run.state = "stopped"; readyResolvers[index]!(); return; }
1193
- const start = (): DelegateHandle => {
1280
+ const start = async (): Promise<DelegateHandle> => {
1194
1281
  if ((run.mode === "fork" || run.mode === "worktree") && !parentSession) {
1195
1282
  failBeforeStart(run, "fork/worktree mode requires a persisted parent Pi session");
1196
1283
  return failedHandle();
@@ -1201,6 +1288,8 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1201
1288
  [SUBAGENT_KEY_ENV]: `delegate-${id}-${run.index}`,
1202
1289
  [SUBAGENT_NAME_ENV]: run.name
1203
1290
  };
1291
+ const effectiveTools = toolsByRun.get(run.index) ?? (run.mode === "worktree" ? WORKTREE_TOOLS : run.mode === "direct" ? undefined : READ_ONLY_TOOLS);
1292
+ if (effectiveTools) env[CHILD_TOOLS_ENV] = JSON.stringify(effectiveTools);
1204
1293
  const discussion = options.discussionFile?.();
1205
1294
  if (discussion) env[DISCUSSION_FILE_ENV] = discussion;
1206
1295
  const hooks: DelegateHooks = {};
@@ -1210,7 +1299,9 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1210
1299
  try {
1211
1300
  const sessionDir = mkdtempSync(join(tmpdir(), "pi-jar-fork-"));
1212
1301
  sessionDirs.set(run.index, sessionDir);
1213
- fork = { source: parentSession!, sessionDir, ...(run.mode === "worktree" ? { tools: WORKTREE_TOOLS } : {}) };
1302
+ const requestedTools = toolsByRun.get(run.index);
1303
+ fork = { source: parentSession!, sessionDir,
1304
+ ...((run.mode === "worktree" || run.mode === "fork") ? { tools: requestedTools ?? (run.mode === "worktree" ? WORKTREE_TOOLS : READ_ONLY_TOOLS) } : {}) };
1214
1305
  } catch (error) {
1215
1306
  failBeforeStart(run, error);
1216
1307
  return failedHandle();
@@ -1218,14 +1309,15 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1218
1309
  }
1219
1310
  if (run.mode === "worktree") {
1220
1311
  try {
1221
- const worktree = createDelegateWorktree(ctx.cwd);
1312
+ const worktree = await createDelegateWorktree(ctx.cwd, controllers[index]!.signal);
1222
1313
  worktrees.set(run.index, worktree);
1223
1314
  run.workspace = worktree.root;
1224
1315
  cwd = worktree.cwd;
1225
1316
  env[CHILD_WORKTREE_ENV] = worktree.root;
1226
1317
  } catch (error) {
1227
- cleanupPrivate(run.index, true);
1228
- failBeforeStart(run, error);
1318
+ await cleanupPrivate(run.index, true);
1319
+ if (discarded || stopRequested.has(index)) markStartStopped(run);
1320
+ else failBeforeStart(run, error);
1229
1321
  return failedHandle();
1230
1322
  }
1231
1323
  } else if (run.mode === "direct" && baselines) {
@@ -1233,20 +1325,36 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1233
1325
  hooks.edited = edited;
1234
1326
  }
1235
1327
  hooks.env = env;
1236
- return startDelegate(run, delegateArgs(run.model, thinkingByRun.get(run.index), write, process.argv, fork),
1328
+ return startDelegate(run, delegateArgs(run.model, thinkingByRun.get(run.index), write, process.argv, fork,
1329
+ run.mode === "scout" ? toolsByRun.get(run.index) : undefined),
1237
1330
  delegatePrompt(run.task, write, run.mode), cwd, controllers[index]!.signal, update, options.spawnProcess, hooks, run.mode !== "direct");
1238
1331
  };
1239
1332
  let handle: DelegateHandle;
1240
- try { handle = start(); }
1241
- catch (error) { failBeforeStart(run, error); handle = failedHandle(); }
1333
+ try { handle = await start(); }
1334
+ catch (error) {
1335
+ if (discarded || stopRequested.has(index)) markStartStopped(run);
1336
+ else failBeforeStart(run, error);
1337
+ handle = failedHandle();
1338
+ }
1242
1339
  handles[index] = handle;
1340
+ started.add(index);
1243
1341
  readyResolvers[index]!();
1244
- void handle.closed.then(() => {
1245
- cleanupPrivate(run.index, discarded || run.mode !== "worktree");
1342
+ void handle.closed.then(async () => {
1343
+ await cleanupPrivate(run.index, discarded || run.mode !== "worktree");
1344
+ update();
1345
+ }).catch((error) => {
1346
+ run.error ??= "subagent cleanup failed: " + cleanText(error instanceof Error ? error.message : String(error), 240);
1246
1347
  update();
1247
1348
  });
1248
- if (discarded) void handle.shutdown().finally(() => cleanupPrivate(run.index, true));
1249
- });
1349
+ if (discarded) void handle.shutdown().finally(() => cleanupPrivate(run.index, true))
1350
+ .catch(error => console.error("pi-jar: subagent cleanup failed", error));
1351
+ } catch (error) {
1352
+ failBeforeStart(run, error);
1353
+ } finally {
1354
+ // Even a filesystem cleanup or decoration failure must release lifecycle waiters.
1355
+ readyResolvers[index]!();
1356
+ }
1357
+ }));
1250
1358
 
1251
1359
  const stopping = new Map<number, Promise<SubagentStopReport>>();
1252
1360
  const stopRequested = new Set<number>();
@@ -1254,10 +1362,13 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1254
1362
  const existing = stopping.get(index);
1255
1363
  if (existing) return existing;
1256
1364
  stopRequested.add(index);
1365
+ // Cancel asynchronous worktree setup as well as an already-running child before waiting
1366
+ // for ready; otherwise stop can queue behind a stalled startup operation.
1367
+ if (!started.has(index)) controllers[index]!.abort(STOP_REASON);
1257
1368
  const promise = finalizeRun(index);
1258
1369
  stopping.set(index, promise);
1259
1370
  // A reconciliation conflict is retryable after the parent drift is resolved.
1260
- void promise.then(() => { if (worktrees.has(runs[index]!.index)) stopping.delete(index); });
1371
+ void promise.then(() => { if (worktrees.has(runs[index]!.index)) stopping.delete(index); }, () => { stopping.delete(index); });
1261
1372
  return promise;
1262
1373
  };
1263
1374
  const finalizeRun = async (index: number): Promise<SubagentStopReport> => {
@@ -1272,15 +1383,15 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1272
1383
  let changed: string[] = [];
1273
1384
  const worktree = worktrees.get(run.index);
1274
1385
  if (worktree && !discarded) {
1275
- try { changed = worktreeChangedFiles(worktree); run.filesEdited = changed; }
1386
+ try { changed = await worktreeChangedFiles(worktree, controllers[index]!.signal); run.filesEdited = changed; }
1276
1387
  catch (error) { run.error = "could not inspect isolated changes: " + cleanText(String(error), 240); }
1277
1388
  if (changed.length && !run.error) {
1278
1389
  try {
1279
- run.appliedFiles = applyDelegateWorktree(worktree, ctx.cwd, changed, options.changes?.());
1390
+ run.appliedFiles = await applyDelegateWorktree(worktree, ctx.cwd, changed, options.changes?.(), controllers[index]!.signal);
1280
1391
  if (run.appliedFiles.length) {
1281
1392
  try { options.changed?.(); } catch (error) { console.error("pi-jar: could not refresh parent change UI", error); }
1282
1393
  }
1283
- disposeDelegateWorktree(worktree);
1394
+ await disposeDelegateWorktree(worktree);
1284
1395
  worktrees.delete(run.index);
1285
1396
  run.workspace = undefined;
1286
1397
  } catch (error) {
@@ -1288,12 +1399,13 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1288
1399
  run.error = "changes not applied: " + cleanText(error instanceof Error ? error.message : String(error), 240);
1289
1400
  }
1290
1401
  } else if (!changed.length) {
1291
- try { disposeDelegateWorktree(worktree); } catch { /* best effort */ }
1402
+ try { await disposeDelegateWorktree(worktree); } catch { /* best effort */ }
1292
1403
  worktrees.delete(run.index);
1293
1404
  run.workspace = undefined;
1294
1405
  }
1295
1406
  }
1296
- cleanupPrivate(run.index, false);
1407
+ await cleanupPrivate(run.index, discarded);
1408
+ if (discarded) { run.error = undefined; run.workspace = undefined; }
1297
1409
  run.state = run.error ? "failed" : "stopped";
1298
1410
  run.endedAt ??= Date.now();
1299
1411
  update();
@@ -1306,39 +1418,61 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1306
1418
  records = runs.map((run, index) => ({
1307
1419
  key: `delegate-${id}-${run.index}`, batch: id, run,
1308
1420
  stop: () => stopRun(index),
1309
- pause: () => discarded || stopRequested.has(index) ? Promise.resolve(false) : handles[index]!.pause(),
1421
+ pause: async () => {
1422
+ await ready[index];
1423
+ return discarded || stopRequested.has(index) ? false : handles[index]!.pause();
1424
+ },
1310
1425
  resume: (text) => !discarded && !stopRequested.has(index) && handles[index]!.resume(text),
1311
1426
  steer: (text) => !discarded && !stopRequested.has(index) && handles[index]!.steer(text),
1312
- ask: (text) => discarded || stopRequested.has(index) ? Promise.resolve(undefined) : handles[index]!.ask(text),
1427
+ ask: async (text) => {
1428
+ await ready[index];
1429
+ return discarded || stopRequested.has(index) ? undefined : handles[index]!.ask(text);
1430
+ },
1313
1431
  alive: () => handles[index]!.alive(),
1314
1432
  closed: () => ready[index]!.then(() => handles[index]!.closed),
1315
1433
  changed: () => {
1316
1434
  const worktree = worktrees.get(run.index);
1317
1435
  if (!worktree) return [...run.filesEdited];
1318
- try { return worktreeChangedFiles(worktree); } catch { return [...run.filesEdited]; }
1436
+ return [...run.filesEdited]; // synchronous activity snapshots never launch subprocesses
1319
1437
  },
1320
1438
  discard: () => {
1321
1439
  discarded = true;
1322
1440
  // Cancellation is synchronous: a concurrent stop must never apply discarded edits.
1323
1441
  controllers[index]!.abort(STOP_REASON);
1324
- void ready[index]!.then(() => handles[index]!.shutdown()).finally(() => cleanupPrivate(run.index, true));
1442
+ void ready[index]!.then(() => handles[index]!.shutdown()).finally(() => cleanupPrivate(run.index, true))
1443
+ .catch(error => console.error("pi-jar: subagent discard failed", error));
1325
1444
  }
1326
1445
  }));
1446
+ // Initial turns are event-driven too: the retained launch returns immediately so the
1447
+ // moderator can act on user messages instead of remaining trapped in this tool call.
1448
+ // The Pi RPC stream continues independently and the registry wakes the moderator on settle.
1449
+ if (eventBusAvailable) for (const record of records) watched.set(record.key, record);
1327
1450
  // Transfer the reservation into queued records before notifying reentrant listeners.
1328
1451
  releaseReservation();
1329
1452
  registry.add(...records);
1330
- startRuns();
1453
+ const startup = startRuns();
1454
+ if (eventBusAvailable) {
1455
+ void startup.then(async () => {
1456
+ // Legacy direct writers also run off the main tool path. Remove their shared baseline
1457
+ // directory only after all direct streams drained and their edit events were adopted.
1458
+ await Promise.all(handles.map((handle, index) => runs[index]!.mode === "direct" ? handle.closed : Promise.resolve()));
1459
+ if (baselines) await rm(baselines, { recursive: true, force: true });
1460
+ }).catch((error) => console.error("pi-jar: subagent startup/cleanup failed", error));
1461
+ signal?.removeEventListener("abort", abortAll);
1462
+ publish();
1463
+ toolReturned = true;
1464
+ return { content: [{ type: "text", text: runs.map((run) => runReport(run)).join("\n\n") + "\n\nSubagent launch is asynchronous; turn reports arrive as pi-jar.subagent events. You can continue working or steer the fleet with jar_subagent." }], details: details() };
1465
+ }
1466
+ await startup;
1331
1467
  publish();
1332
1468
 
1333
1469
  try {
1334
1470
  await Promise.all(handles.map((handle, index) => runs[index]!.mode === "direct" ? handle.closed : handle.firstSettled));
1335
1471
  } finally {
1336
1472
  signal?.removeEventListener("abort", abortAll);
1337
- if (baselines) rmSync(baselines, { recursive: true, force: true });
1338
- for (const run of runs) {
1339
- if (run.mode === "direct") cleanupPrivate(run.index, true);
1340
- else if (run.state === "failed") cleanupPrivate(run.index, false);
1341
- }
1473
+ if (baselines) await rm(baselines, { recursive: true, force: true });
1474
+ await Promise.all(runs.map(run => run.mode === "direct" || run.state === "failed"
1475
+ ? cleanupPrivate(run.index, run.mode === "direct") : Promise.resolve()));
1342
1476
  registry.notify();
1343
1477
  flushUpdate();
1344
1478
  toolReturned = true;
@@ -1386,7 +1520,25 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1386
1520
  if (!launched) throw new Error(result.content.map((part) => "text" in part ? part.text : "").join("\n"));
1387
1521
  const record = registry.get(`delegate-${launched}-1`);
1388
1522
  if (!record) throw new Error("Scout session was discarded");
1389
- if (record.run.error || record.run.state !== "idle") throw new Error(record.run.error ?? "Scout failed to settle");
1523
+ // This launch is an internal democracy vote; its caller is already awaiting the result,
1524
+ // so the moderator does not need a duplicate unsolicited completion notification.
1525
+ watched.delete(record.key);
1526
+ deleteSettled(record.key);
1527
+ await new Promise<void>((resolve, reject) => {
1528
+ let unsubscribe = () => {};
1529
+ const cleanup = () => { unsubscribe(); signal?.removeEventListener("abort", abort); };
1530
+ const check = () => {
1531
+ if (registry.get(record.key) !== record) { cleanup(); reject(new Error("Scout session was discarded")); return; }
1532
+ if (isRunning(record.run.state)) return;
1533
+ cleanup();
1534
+ if (record.run.error || record.run.state !== "idle") reject(new Error(record.run.error ?? "Scout failed to settle"));
1535
+ else resolve();
1536
+ };
1537
+ const abort = () => { cleanup(); void registry.stop(record.key); reject(new Error("Scout launch aborted")); };
1538
+ unsubscribe = registry.subscribe(check);
1539
+ signal?.addEventListener("abort", abort, { once: true });
1540
+ if (signal?.aborted) abort(); else check();
1541
+ });
1390
1542
  return snapshot(record);
1391
1543
  },
1392
1544
  async resumeScout(id, prompt, signal) {
@@ -1414,14 +1566,28 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1414
1566
  }
1415
1567
  });
1416
1568
 
1569
+ let operationSequence = 0;
1570
+ const pendingControls = new Map<string, { id: string; action: string; message?: string }>();
1571
+ const deliverControl = (record: SubagentRecord, operation: { id: string; action: string }, result: { content: Array<{ text: string }>; details?: unknown; isError?: boolean }) => {
1572
+ // A session switch discards the old fleet. Never wake a new session with stale handoffs.
1573
+ if (registry.get(record.key) !== record) return;
1574
+ try {
1575
+ pi.sendMessage({
1576
+ customType: SUBAGENT_MESSAGE, display: true,
1577
+ details: { operationId: operation.id, action: operation.action, key: record.key, status: result.isError ? "failed" : "completed", report: result.details },
1578
+ content: `[PI-JAR SUBAGENT] ${operation.action} ${operation.id} completed:\n${result.content.map(part => part.text).join("\n")}`
1579
+ }, { triggerTurn: true, deliverAs: "followUp" });
1580
+ } catch (error) { console.error("pi-jar: could not deliver a control event", error); }
1581
+ };
1582
+
1417
1583
  pi.registerTool({
1418
1584
  name: "jar_subagent",
1419
1585
  label: "subagent",
1420
- description: "Moderator control for retained subagents: peek task progress, steer a working agent, ask a brief BTW question, pause without losing context, resume the same agent, or stop it and receive a progress/change/remaining-work handoff. Stopping a worktree agent safely applies reviewable changes to the parent.",
1586
+ description: "Non-blocking moderator control for retained subagents. peek, steer and resume return immediately. ask, pause and stop return an accepted operation receipt; the final answer or handoff arrives as a pi-jar.subagent event. Stop completion safely reconciles worktree changes; acceptance does not mean changes are applied yet.",
1421
1587
  promptSnippet: "Use jar_subagent as the moderator control plane. Prefer peek over rereading transcripts; reuse paused/idle agents with resume; stop finished workers to reconcile their work.",
1422
1588
  promptGuidelines: [
1423
- "peek returns only task progress (done/total, the in-progress task, activity, the next few open tasks). Do not poll it: after resume, a pi-jar.subagent message wakes you (or queues for your current run) with that progress plus the turn's report when the agent's turn ends; stop returns changed files and the full handoff.",
1424
- "Use steer to correct direction while an agent is working. Use ask for a short BTW question; it waits for the agent's next compact answer.",
1589
+ "peek returns only task progress. Do not poll: initial/resumed turns and control completions send pi-jar.subagent events. Stop returns an accepted receipt first, then changed files and the full handoff in its completion event.",
1590
+ "Use steer for active direction and ask for a brief BTW question. ask, pause and stop do not wait on the child; continue responding to the user until their completion event arrives.",
1425
1591
  "Pause when an agent should stop spending tokens but keep its context/workspace. Resume the same agent later instead of spawning a replacement.",
1426
1592
  "Stop when an agent is no longer needed. Stop aborts any active turn, retires the process, reports progress/workspace/changes/remaining work, and reconciles safe worktree changes into /diff."
1427
1593
  ],
@@ -1434,6 +1600,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1434
1600
  message: Type.Optional(Type.String({ description: "Direction, BTW question, or resume instruction." }))
1435
1601
  }),
1436
1602
  async execute(_id, params, signal) {
1603
+ if (signal?.aborted) return { content: [{ type: "text" as const, text: "Cancelled before accepting the operation." }], details: undefined, isError: true };
1437
1604
  const work = (async () => {
1438
1605
  if (params.action === "peek") {
1439
1606
  const targets = params.agent ? [registry.resolve(params.agent)].filter((item): item is SubagentRecord => !!item)
@@ -1444,6 +1611,15 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1444
1611
  const agent = params.agent ?? "";
1445
1612
  const record = registry.resolve(agent);
1446
1613
  if (!record) return { content: [{ type: "text", text: "Subagent not found or name is ambiguous: " + agent }], isError: true };
1614
+ const stopping = pendingControls.get(record.key + ":stop");
1615
+ const controlling = pendingControls.get(record.key + ":control");
1616
+ if (stopping || (controlling && params.action !== "stop")) {
1617
+ const pending = stopping ?? controlling!;
1618
+ if (pending.action === params.action && pending.message === params.message) {
1619
+ return { content: [{ type: "text", text: `${pending.action} already accepted (${pending.id}); completion will arrive as an event.` }], details: { operationId: pending.id, status: "accepted", action: pending.action, key: record.key } };
1620
+ }
1621
+ return { content: [{ type: "text", text: `${record.run.name} has pending ${pending.action} (${pending.id}). Stop may preempt other controls; otherwise wait for its event.` }], isError: true };
1622
+ }
1447
1623
  if (params.action === "steer") {
1448
1624
  const message = cleanBlock(params.message ?? "", MAX_STEER_CHARS);
1449
1625
  if (!message) return { content: [{ type: "text", text: "steer requires message." }], isError: true };
@@ -1451,18 +1627,20 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1451
1627
  }
1452
1628
  // The moderator is handling this agent directly now; its settle no longer needs a wake-up.
1453
1629
  if (params.action !== "resume") watched.delete(record.key);
1630
+ const controlWork = async () => {
1454
1631
  if (params.action === "ask") {
1455
1632
  const message = cleanBlock(params.message ?? "", MAX_STEER_CHARS);
1456
1633
  if (!message) return { content: [{ type: "text", text: "ask requires message." }], isError: true };
1457
1634
  const answer = await registry.ask(record.key, message);
1458
- return { content: [{ type: "text", text: answer ? record.run.name + ": " + cleanBlock(answer, 2000) : "No answer arrived before the BTW timeout." }] };
1635
+ return { content: [{ type: "text", text: answer ? record.run.name + ": " + cleanBlock(answer, 2000) : "No answer arrived before the BTW timeout or the worker stopped." }], isError: !answer };
1459
1636
  }
1460
1637
  if (params.action === "pause") {
1461
1638
  const paused = await registry.pause(record.key);
1639
+ if (!paused && isRunning(record.run.state) && !pendingControls.has(record.key + ":stop")) watched.set(record.key, record);
1462
1640
  const text = paused ? "Paused " + record.run.name + "; context and workspace are retained."
1463
1641
  : record.run.state === "working" ? record.run.name + " has not settled yet; it pauses when its current turn ends."
1464
1642
  : "Could not pause " + record.run.name + ".";
1465
- return { content: [{ type: "text", text }] };
1643
+ return { content: [{ type: "text", text }], isError: !paused };
1466
1644
  }
1467
1645
  if (params.action === "resume") {
1468
1646
  const resumed = registry.resume(record.key, params.message);
@@ -1471,7 +1649,26 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1471
1649
  }
1472
1650
  const report = await registry.stop(record.key);
1473
1651
  if (!report) return { content: [{ type: "text", text: "Subagent is already retired: " + record.run.name }] };
1474
- return { content: [{ type: "text", text: controlReport(report) }], details: report };
1652
+ return { content: [{ type: "text", text: controlReport(report) }], details: report, isError: !!report.error };
1653
+ };
1654
+ // Internal registry APIs remain awaited for shutdown/reconciliation correctness. Only the
1655
+ // external tool boundary is detached, so parent steering never waits on a child turn or Git.
1656
+ if (eventBusAvailable && params.action !== "resume") {
1657
+ if (params.action === "ask" && !params.message?.trim()) return { content: [{ type: "text", text: "ask requires message." }], isError: true };
1658
+ const operation = { id: `subagent-op-${++operationSequence}`, action: params.action, message: params.message };
1659
+ const pendingKey = record.key + (params.action === "stop" ? ":stop" : ":control");
1660
+ pendingControls.set(pendingKey, operation);
1661
+ deleteSettled(record.key);
1662
+ void Promise.resolve().then(controlWork).then(result => {
1663
+ if (pendingControls.get(pendingKey) === operation) pendingControls.delete(pendingKey);
1664
+ deliverControl(record, operation, result);
1665
+ }, error => {
1666
+ if (pendingControls.get(pendingKey) === operation) pendingControls.delete(pendingKey);
1667
+ deliverControl(record, operation, { content: [{ text: error instanceof Error ? error.message : String(error) }], isError: true });
1668
+ });
1669
+ return { content: [{ type: "text", text: `${params.action} accepted for ${record.run.name} (${operation.id}). Completion and any reconciled changes will arrive as an event; keep responding to the user, do not poll.` }], details: { operationId: operation.id, status: "accepted", action: params.action, key: record.key } };
1670
+ }
1671
+ return controlWork();
1475
1672
  })();
1476
1673
  // Pi waits on a tool until it resolves, so Esc must never be stuck behind a child that ignores abort.
1477
1674
  const result = await untilAborted(work, signal)