pi-jar 0.1.4 → 0.1.5

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
@@ -2,7 +2,8 @@ import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { basename, isAbsolute, join, resolve } from "node:path";
5
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { fileURLToPath } from "node:url";
6
+ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
6
7
  import { Text } from "@earendil-works/pi-tui";
7
8
  import { Type } from "typebox";
8
9
  import type { ChangeTracker } from "./changes.ts";
@@ -10,13 +11,28 @@ import { CHILD_BASELINE_ENV, readChildBaseline } from "./child-baselines.ts";
10
11
  import type { ModelRoleManager } from "./model-roles.ts";
11
12
  import { cleanText, ROLE_PREFIX } from "./status.ts";
12
13
  import type { Todo } from "./tasks.ts";
14
+ import { DISCUSSION_FILE_ENV, SUBAGENT_KEY_ENV, SUBAGENT_NAME_ENV } from "./discussion.ts";
15
+ import {
16
+ applyDelegateWorktree,
17
+ CHILD_WORKTREE_ENV,
18
+ createDelegateWorktree,
19
+ disposeDelegateWorktree,
20
+ worktreeChangedFiles,
21
+ WorktreeApplyError,
22
+ type DelegateWorktree
23
+ } from "./delegate-worktree.ts";
13
24
 
14
25
  export const DELEGATE_TOOL = "jar_delegate";
15
26
  /** Set in child processes so a subagent never delegates again. */
16
27
  export const CHILD_ENV = "PI_JAR_CHILD";
17
28
  export const MAX_DELEGATES = 4;
29
+ export const SUBAGENT_LIMIT_CHOICES = [2, 4, 6, 8, 16] as const;
18
30
  /** Read-only subagents also keep jar_todo so their checklist shows in the activity view. */
19
- export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "jar_todo"] as const;
31
+ export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "jar_todo", "jar_discuss"] as const;
32
+ /** 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;
34
+ export type DelegateMode = "scout" | "fork" | "worktree";
35
+ type DelegateExecutionMode = DelegateMode | "direct";
20
36
  const MAX_OUTPUT_CHARS = 12_000;
21
37
  const TIMEOUT_MS = 20 * 60_000;
22
38
  const STATUS_REFRESH_MS = 20_000;
@@ -34,6 +50,8 @@ const MAX_LINE_CHARS = 16 * 1024 * 1024;
34
50
  const MAX_FILES = 50;
35
51
  const MAX_TODOS = 50;
36
52
  const MAX_STEER_CHARS = 4000;
53
+ /** Resumed assignments/ballots can include bounded evidence and multiple options. */
54
+ export const MAX_RESUME_CHARS = 40_000;
37
55
  /** Finished subagents the registry keeps for the activity view after their tool call returns. */
38
56
  const MAX_FINISHED = 8;
39
57
  /** Abort reason for stopping one subagent from the activity view (vs. aborting the whole tool call). */
@@ -44,7 +62,7 @@ const HINT_KEYS = ["command", "path", "file_path", "pattern", "query", "url"] as
44
62
  const DIALOGS = new Set(["select", "confirm", "input", "editor"]);
45
63
  const ANSI = /\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g;
46
64
 
47
- export type DelegateState = "queued" | "working" | "done" | "failed";
65
+ export type DelegateState = "queued" | "working" | "idle" | "paused" | "stopped" | "done" | "failed";
48
66
  export type ToolStatus = "running" | "done" | "error";
49
67
  /** One transcript row; `rev` changes whenever the entry does, so views can cache their rendering. */
50
68
  export type TranscriptEntry =
@@ -58,6 +76,7 @@ export interface DelegateRun {
58
76
  task: string;
59
77
  role: string;
60
78
  model?: string;
79
+ mode: DelegateExecutionMode;
61
80
  state: DelegateState;
62
81
  activity?: string;
63
82
  tools: number;
@@ -71,6 +90,12 @@ export interface DelegateRun {
71
90
  toolCounts: Record<string, number>;
72
91
  filesRead: string[];
73
92
  filesEdited: string[];
93
+ /** Worktree changes successfully copied back into the parent workspace. */
94
+ appliedFiles: string[];
95
+ pauses: number;
96
+ resumes: number;
97
+ /** Kept only when isolated changes could not safely be applied automatically. */
98
+ workspace?: string;
74
99
  /** The subagent's own jar_todo checklist, mirrored from its tool results. */
75
100
  todos: Todo[];
76
101
  steered: number;
@@ -105,23 +130,43 @@ export function extensionFlags(argv = process.argv, cwd = process.cwd()): string
105
130
  return flags;
106
131
  }
107
132
 
108
- /** CLI arguments for one subagent: RPC (so it can be steered), no session, read-only tools unless `write`. */
109
- export function delegateArgs(model: string | undefined, thinking: string | undefined, write: boolean, argv = process.argv): string[] {
110
- const args = ["--mode", "rpc", "--no-session", ...extensionFlags(argv)];
133
+ export interface DelegateForkOptions {
134
+ source: string;
135
+ sessionDir: string;
136
+ tools?: readonly string[];
137
+ }
138
+
139
+ /** CLI arguments for one subagent: fresh ephemeral session or a real Pi session fork. */
140
+ export function delegateArgs(model: string | undefined, thinking: string | undefined, write: boolean, argv = process.argv,
141
+ fork?: DelegateForkOptions): string[] {
142
+ const session = fork ? ["--fork", fork.source, "--session-dir", fork.sessionDir] : ["--no-session"];
143
+ const extensions = extensionFlags(argv);
144
+ // Worktree cwd/session forks do not inherit project extension discovery from the parent.
145
+ if (fork) {
146
+ const ownExtension = fileURLToPath(new URL("../extensions/index.ts", import.meta.url));
147
+ if (!extensions.includes(ownExtension)) extensions.push("--extension", ownExtension);
148
+ }
149
+ const args = ["--mode", "rpc", ...session, ...extensions];
111
150
  if (model) args.push("--model", model);
112
151
  if (thinking) args.push("--thinking", thinking);
113
- if (!write) args.push("--tools", READ_ONLY_TOOLS.join(","));
152
+ const tools = fork?.tools ?? (!write ? READ_ONLY_TOOLS : undefined);
153
+ if (tools) args.push("--tools", tools.join(","));
114
154
  return args;
115
155
  }
116
156
 
117
157
  /** The subagent's prompt: its rules, how to report back, then the task. */
118
- export function delegatePrompt(task: string, write: boolean): string {
119
- const rules = write
120
- ? "You may edit files. Stay strictly within the task; other subagents may be working in the same repository at the same time."
121
- : "You are read-only: investigate and report, do not attempt to modify anything.";
158
+ export function delegatePrompt(task: string, write: boolean, mode: DelegateExecutionMode = write ? "direct" : "scout"): string {
159
+ const rules = mode === "worktree"
160
+ ? "You inherit the parent conversation but work inside an isolated disposable Git worktree. You may edit only with the provided file tools; shell tools are intentionally unavailable. Stay strictly within the task. Your changes remain private across idle/pause/resume and are reconciled into the parent for /diff review only when the moderator stops you."
161
+ : mode === "fork"
162
+ ? "You are a read-only fork of the parent conversation: use the inherited context, investigate, and report without modifying files."
163
+ : write
164
+ ? "You may edit files. Stay strictly within the task; other subagents may be working in the same repository at the same time."
165
+ : "You are read-only: investigate and report, do not attempt to modify anything.";
122
166
  return [
123
167
  `You are a focused subagent working for another agent. ${rules}`,
124
- "Track multi-step work with jar_todo (when available) so your progress is visible. The user may steer you while you work; follow their messages.",
168
+ "Track multi-step work with jar_todo (when available) so your progress is visible. The moderator may steer, pause or resume you; preserve scope across those controls.",
169
+ "Use jar_discuss when available for short cross-agent questions and answers. Keep discussion entries narrow; do not turn the shared paper into a transcript.",
125
170
  "Finish with a self-contained report in these sections:",
126
171
  "## Summary — the outcome in one to three sentences.",
127
172
  "## Details — findings or changes, with file paths (and line numbers where useful).",
@@ -229,47 +274,97 @@ export interface DelegateHooks {
229
274
  /** A successful edit/write by the child, with the path it gave. */
230
275
  edited?(path: string): void;
231
276
  }
232
- export interface DelegateHandle { done: Promise<void>; steer(text: string): boolean }
277
+ export interface DelegateHandle {
278
+ /** First point where the child is idle after its initial task. */
279
+ firstSettled: Promise<void>;
280
+ /** Process lifetime; persistent agents stay alive across many settled turns. */
281
+ closed: Promise<void>;
282
+ steer(text: string): boolean;
283
+ pause(): Promise<boolean>;
284
+ resume(text?: string): boolean;
285
+ ask(text: string): Promise<string | undefined>;
286
+ shutdown(): Promise<void>;
287
+ alive(): boolean;
288
+ }
233
289
 
234
290
  /**
235
- * Run one child Pi in RPC mode, feeding progress into `run` and calling `update` on every change.
236
- * RPC keeps stdin open so the user can steer the subagent; it is closed once the child settles.
291
+ * Run one child Pi in RPC mode. Persistent children remain alive after agent_settled so the
292
+ * moderator can peek, steer, pause, resume and ask them questions without rebuilding context.
237
293
  */
238
294
  export function startDelegate(run: DelegateRun, args: string[], prompt: string, cwd: string, signal: AbortSignal | undefined,
239
- update: () => void, spawnProcess: Spawn = spawn as Spawn, hooks: DelegateHooks = {}): DelegateHandle {
295
+ update: () => void, spawnProcess: Spawn = spawn as Spawn, hooks: DelegateHooks = {}, persistent = true): DelegateHandle {
240
296
  let steer: (text: string) => boolean = () => false;
241
- const done = new Promise<void>((resolveDone) => {
297
+ let pause: () => Promise<boolean> = async () => false;
298
+ let resume: (text?: string) => boolean = () => false;
299
+ let ask: (text: string) => Promise<string | undefined> = async () => undefined;
300
+ let shutdown: () => Promise<void> = async () => {};
301
+ let alive = false;
302
+ let resolveFirst!: () => void;
303
+ let firstResolved = false;
304
+ const firstSettled = new Promise<void>((resolve) => { resolveFirst = resolve; });
305
+
306
+ const closed = new Promise<void>((resolveClosed) => {
242
307
  const invocation = piInvocation(args);
243
308
  let child: ChildProcess;
244
309
  try {
245
- child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...hooks.env, [CHILD_ENV]: "1" } });
310
+ const env = { ...process.env };
311
+ for (const key of [CHILD_BASELINE_ENV, CHILD_WORKTREE_ENV, DISCUSSION_FILE_ENV, SUBAGENT_KEY_ENV, SUBAGENT_NAME_ENV]) delete env[key];
312
+ child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, stdio: ["pipe", "pipe", "pipe"], env: { ...env, ...hooks.env, [CHILD_ENV]: "1" } });
246
313
  } catch (error) {
247
- run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); run.endedAt = Date.now(); update(); resolveDone(); return;
314
+ run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); run.endedAt = Date.now(); update();
315
+ firstResolved = true; resolveFirst(); resolveClosed(); return;
248
316
  }
317
+
318
+ alive = true;
249
319
  run.state = "working";
250
320
  run.startedAt = Date.now();
321
+ run.endedAt = undefined;
251
322
  run.activity = "starting";
252
323
  update();
324
+
253
325
  let buffer = "";
254
- // A JSON line past this is dropped whole rather than buffered without bound.
255
326
  let overflow = false;
256
- // Streamed text repaints at most every LIVE_REPAINT_MS; the trailing timer shows the last tokens.
257
327
  let liveAt = 0;
258
328
  let liveTimer: ReturnType<typeof setTimeout> | undefined;
259
329
  let stderr = "";
260
330
  let finished = false;
261
331
  let settled = false;
332
+ let pauseRequested = false;
333
+ let closing = false;
334
+ let shutdownPromise: Promise<void> | undefined;
335
+ let askBusy = false;
336
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
337
+ let turnTimer: ReturnType<typeof setTimeout> | undefined;
338
+ const settleWaiters: Array<() => void> = [];
339
+
262
340
  const send = (command: object): boolean => {
263
341
  const stdin = child.stdin;
264
- if (!stdin || stdin.destroyed || stdin.writableEnded) return false;
342
+ if (!alive || !stdin || stdin.destroyed || stdin.writableEnded) return false;
265
343
  try { stdin.write(JSON.stringify(command) + "\n"); return true; } catch { return false; }
266
344
  };
267
- // EPIPE after the child exits is expected; anything else is kept for the failure message.
268
- child.stdin?.on("error", (error) => { if (!finished) stderr = (stderr + "\n" + error.message).slice(-4000); });
345
+ const clearTurnTimer = () => { if (turnTimer) clearTimeout(turnTimer); turnTimer = undefined; };
346
+ const hardStop = () => {
347
+ if (!alive) return;
348
+ child.kill("SIGTERM");
349
+ killTimer ??= setTimeout(() => { if (alive) child.kill("SIGKILL"); }, 3000);
350
+ killTimer.unref?.();
351
+ };
352
+ const armTurnTimer = () => {
353
+ clearTurnTimer();
354
+ turnTimer = setTimeout(() => {
355
+ hardStop();
356
+ finish("failed", "timed out");
357
+ }, TIMEOUT_MS);
358
+ turnTimer.unref?.();
359
+ };
360
+ const resolveSettled = () => {
361
+ for (const waiter of settleWaiters.splice(0)) waiter();
362
+ };
363
+ const waitSettled = () => settled || finished ? Promise.resolve() : new Promise<void>((resolve) => settleWaiters.push(resolve));
269
364
  const finish = (state: DelegateState, error?: string) => {
270
365
  if (finished) return;
271
366
  finished = true;
272
- clearTimeout(timer);
367
+ clearTurnTimer();
273
368
  clearTimeout(liveTimer);
274
369
  signal?.removeEventListener("abort", abort);
275
370
  if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
@@ -279,29 +374,44 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
279
374
  run.live = "";
280
375
  for (const entry of run.transcript) if (entry.kind === "tool" && entry.status === "running") { entry.status = "error"; entry.endedAt = Date.now(); entry.rev++; }
281
376
  run.endedAt = Date.now();
377
+ resolveSettled();
378
+ if (!alive && !firstResolved) { firstResolved = true; resolveFirst(); }
282
379
  update();
283
- resolveDone();
284
380
  };
285
- const stop = () => { child.kill("SIGTERM"); setTimeout(() => { if (child.exitCode === null) child.kill("SIGKILL"); }, 3000).unref?.(); };
286
- // Stopping one run from the activity view reads "stopped"; aborting the whole tool call reads "aborted".
287
- const abort = () => { stop(); finish("failed", signal?.reason === STOP_REASON ? "stopped" : "aborted"); };
288
- const timer = setTimeout(() => { stop(); finish("failed", "timed out"); }, TIMEOUT_MS);
289
- timer.unref?.();
290
- if (signal?.aborted) { abort(); return; }
381
+ const abort = () => { hardStop(); finish("failed", signal?.reason === STOP_REASON ? "stopped" : "aborted"); };
382
+
383
+ child.stdin?.on("error", (error) => { if (!finished) stderr = (stderr + "\n" + error.message).slice(-4000); });
291
384
  signal?.addEventListener("abort", abort, { once: true });
385
+ armTurnTimer();
386
+
387
+ const beginPrompt = (message: string, pauseAfter = false): boolean => {
388
+ const clean = cleanBlock(message, MAX_RESUME_CHARS);
389
+ if (finished || closing || !alive || !settled || !clean) return false;
390
+ if (!send({ id: "prompt-" + (run.resumes + 1), type: "prompt", message: clean })) return false;
391
+ settled = false;
392
+ run.error = undefined;
393
+ run.output = "";
394
+ pauseRequested = pauseAfter;
395
+ run.state = "working";
396
+ run.resumes++;
397
+ run.endedAt = undefined;
398
+ run.activity = "resuming";
399
+ armTurnTimer();
400
+ update();
401
+ return true;
402
+ };
403
+
292
404
  const running = new Map<string, { entry: ToolEntry; path?: string }>();
293
405
  const line = (raw: string) => {
294
- // Output that arrives after an abort or timeout must not revive a finished run's activity.
295
406
  if (finished || !raw.trim()) return;
296
407
  let parsed: unknown;
297
408
  try { parsed = JSON.parse(raw); } catch { return; }
298
409
  if (!parsed || typeof parsed !== "object") return;
299
- // Parsed child output: the object check above is all the cast assumes; every field stays unknown until read.
300
410
  const event = parsed as ChildEvent;
301
411
  switch (event.type) {
302
412
  case "response":
303
413
  if (event.success !== false) return;
304
- if (event.command === "prompt") { stop(); finish("failed", cleanText(String(event.error ?? "prompt rejected"), 300)); return; }
414
+ if (event.command === "prompt") { hardStop(); finish("failed", cleanText(String(event.error ?? "prompt rejected"), 300)); return; }
305
415
  pushEntry(run, { kind: "note", rev: 0, text: `${cleanText(String(event.command ?? "command"), 24)} rejected: ${cleanText(String(event.error ?? ""), 200)}` });
306
416
  update();
307
417
  return;
@@ -319,8 +429,8 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
319
429
  liveTimer = setTimeout(() => { liveTimer = undefined; liveAt = Date.now(); if (!finished) update(); }, LIVE_REPAINT_MS - (now - liveAt));
320
430
  liveTimer.unref?.();
321
431
  }
322
- } else if ((delta?.type === "thinking_start" || delta?.type === "thinking_delta") && run.activity !== "thinking") {
323
- run.activity = "thinking";
432
+ } else if (delta?.type === "thinking_start" || delta?.type === "thinking_delta") {
433
+ if (run.activity !== "thinking") run.activity = "thinking";
324
434
  update();
325
435
  }
326
436
  return;
@@ -374,22 +484,40 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
374
484
  run.cost += Number(event.message.usage?.cost?.total) || 0;
375
485
  const text = textOf(event.message).trim();
376
486
  if (text) { run.output = text.slice(0, MAX_OUTPUT_CHARS); pushEntry(run, { kind: "text", rev: 0, text: cleanBlock(text, MAX_ENTRY_TEXT) }); }
377
- if (event.message.stopReason === "error" && event.message.errorMessage) run.error = cleanText(String(event.message.errorMessage), 200);
487
+ if (event.message.stopReason === "error") run.error = cleanText(String(event.message.errorMessage ?? "assistant turn failed"), 200);
488
+ if (event.message.stopReason === "aborted" && !pauseRequested && !closing) run.error = "assistant turn aborted";
378
489
  run.live = "";
379
490
  run.activity = "thinking";
380
491
  update();
381
492
  return;
382
493
  }
383
- case "agent_settled":
384
- // Nothing more will run on its own: close stdin so Pi shuts down in order.
494
+ case "agent_settled": {
385
495
  settled = true;
386
- run.activity = "finishing";
387
- if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
496
+ clearTurnTimer();
497
+ for (const call of running.values()) {
498
+ call.entry.status = "error";
499
+ call.entry.endedAt = Date.now();
500
+ call.entry.rev++;
501
+ }
502
+ running.clear();
503
+ run.live = "";
504
+ run.activity = undefined;
505
+ run.endedAt = Date.now();
506
+ run.state = run.error ? "failed" : pauseRequested ? "paused" : "idle";
507
+ pauseRequested = false;
508
+ if (!firstResolved && !run.error) {
509
+ firstResolved = true;
510
+ resolveFirst();
511
+ }
512
+ resolveSettled();
388
513
  update();
514
+ if (run.error) { hardStop(); finish("failed", run.error); }
515
+ else if (!persistent && child.stdin && !child.stdin.writableEnded) child.stdin.end();
389
516
  return;
517
+ }
390
518
  }
391
519
  };
392
- // Decode as UTF-8 across chunk boundaries, and scan only each new chunk for line ends.
520
+
393
521
  child.stdout?.setEncoding?.("utf8");
394
522
  child.stdout?.on("data", (chunk) => {
395
523
  const text = String(chunk);
@@ -406,28 +534,157 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
406
534
  if (buffer.length > MAX_LINE_CHARS) { buffer = ""; overflow = true; }
407
535
  });
408
536
  child.stderr?.on("data", (chunk) => { stderr = (stderr + String(chunk)).slice(-4000); });
409
- child.on("error", (error) => finish("failed", error.message));
410
- child.on("close", (code) => {
411
- if (buffer && !overflow) line(buffer);
537
+ const exited = (code: number | null) => {
538
+ alive = false;
539
+ clearTimeout(killTimer);
540
+ if (buffer && !overflow) { line(buffer); buffer = ""; }
412
541
  if (code === 0 && settled && !run.error) finish("done");
413
542
  else finish("failed", run.error ?? (cleanText(stderr, 300) || (settled ? `exited ${code}` : `exited ${code} before finishing`)));
543
+ if (!firstResolved) { firstResolved = true; resolveFirst(); }
544
+ resolveClosed();
545
+ };
546
+ child.on("error", (error) => { hardStop(); finish("failed", error.message); });
547
+ // Exit retires control immediately, but finalization waits for close so stdio drains first.
548
+ child.once("exit", (code) => {
549
+ alive = false;
550
+ clearTurnTimer();
551
+ clearTimeout(killTimer);
552
+ if (!finished) {
553
+ run.state = code === 0 && settled && !run.error ? "done" : "failed";
554
+ if (run.state === "failed") run.error ??= `exited ${code}`;
555
+ run.activity = undefined;
556
+ resolveSettled();
557
+ update();
558
+ }
414
559
  });
560
+ child.once("close", exited);
561
+
415
562
  steer = (text) => {
416
563
  const message = cleanBlock(text, MAX_STEER_CHARS);
417
- if (finished || settled || !message || !send({ type: "steer", message })) return false;
564
+ if (finished || closing || !message || run.state !== "working" || !send({ type: "steer", message })) return false;
418
565
  pushEntry(run, { kind: "steer", rev: 0, text: message });
419
566
  run.steered++;
420
567
  update();
421
568
  return true;
422
569
  };
423
- if (!send({ id: "prompt", type: "prompt", message: prompt })) { stop(); finish("failed", "could not send the task to the subagent"); }
570
+ pause = async () => {
571
+ if (finished || closing || !alive) return false;
572
+ if (run.state === "paused") return true;
573
+ if (run.state === "idle") { run.state = "paused"; run.pauses++; update(); return true; }
574
+ if (run.state !== "working") return false;
575
+ pauseRequested = true;
576
+ run.pauses++;
577
+ run.activity = "pausing";
578
+ const waiting = waitSettled();
579
+ send({ type: "clear_queue" });
580
+ if (!send({ type: "abort" })) {
581
+ pauseRequested = false;
582
+ run.pauses = Math.max(0, run.pauses - 1);
583
+ run.activity = "thinking";
584
+ update();
585
+ return false;
586
+ }
587
+ update();
588
+ await waiting;
589
+ return (run.state as DelegateState) === "paused";
590
+ };
591
+ resume = (text) => {
592
+ if (run.state !== "paused" && run.state !== "idle") return false;
593
+ const message = text?.trim() || "Continue the assigned task from where you stopped. Review your current checklist and remaining work first.";
594
+ return beginPrompt(message);
595
+ };
596
+ ask = async (text) => {
597
+ const question = cleanBlock(text, MAX_STEER_CHARS);
598
+ if (!question || finished || closing || askBusy) return undefined;
599
+ askBusy = true;
600
+ const wasPaused = run.state === "paused";
601
+ try {
602
+ // Cross a settled boundary so the active task's report cannot answer the question.
603
+ if (run.state === "working" && !await pause()) return undefined;
604
+ const instruction = "Moderator BTW: answer only this question in one compact paragraph. Preserve your assigned task scope for the next resume. Question: " + question;
605
+ if (!beginPrompt(instruction, wasPaused)) return undefined;
606
+ let timer: ReturnType<typeof setTimeout> | undefined;
607
+ const timeout = new Promise<boolean>((resolve) => {
608
+ timer = setTimeout(() => resolve(false), 2 * 60_000);
609
+ timer.unref?.();
610
+ });
611
+ const answered = await Promise.race([waitSettled().then(() => true), timeout]);
612
+ clearTimeout(timer);
613
+ if (!answered) { void pause(); return undefined; }
614
+ return !finished && !run.error ? run.output || undefined : undefined;
615
+ } finally { askBusy = false; }
616
+ };
617
+ shutdown = () => {
618
+ if (shutdownPromise) return shutdownPromise;
619
+ closing = true;
620
+ shutdownPromise = Promise.resolve().then(async () => {
621
+ if (finished) { await closed; return; }
622
+ if (run.state === "working") {
623
+ const waiting = waitSettled();
624
+ send({ type: "clear_queue" });
625
+ if (send({ type: "abort" })) {
626
+ let settledInTime = false;
627
+ const grace = new Promise<void>((resolve) => {
628
+ const timer = setTimeout(resolve, 3000);
629
+ timer.unref?.();
630
+ });
631
+ await Promise.race([waiting.then(() => { settledInTime = true; }), grace]);
632
+ if (!settledInTime && !finished) hardStop();
633
+ } else hardStop();
634
+ }
635
+ if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
636
+ const killer = setTimeout(() => { if (child.exitCode === null) hardStop(); }, 3000);
637
+ killer.unref?.();
638
+ await closed;
639
+ clearTimeout(killer);
640
+ });
641
+ return shutdownPromise;
642
+ };
643
+
644
+ if (signal?.aborted) { abort(); return; }
645
+ if (!send({ id: "prompt", type: "prompt", message: prompt })) { hardStop(); finish("failed", "could not send the task to the subagent"); }
424
646
  });
425
- return { done, steer: (text) => steer(text) };
647
+ return {
648
+ firstSettled,
649
+ closed,
650
+ steer: (text) => steer(text),
651
+ pause: () => pause(),
652
+ resume: (text) => resume(text),
653
+ ask: (text) => ask(text),
654
+ shutdown: () => shutdown(),
655
+ alive: () => alive
656
+ };
426
657
  }
427
658
 
428
- const isActive = (state: DelegateState) => state === "queued" || state === "working";
659
+ const isRunning = (state: DelegateState) => state === "queued" || state === "working";
660
+ const isRetained = (state: DelegateState) => isRunning(state) || state === "idle" || state === "paused";
429
661
 
430
- export interface SubagentRecord { key: string; batch: number; run: DelegateRun; stop(): void; steer(text: string): boolean }
662
+ export interface SubagentStopReport {
663
+ key: string;
664
+ name: string;
665
+ state: DelegateState;
666
+ task: string;
667
+ workspace?: string;
668
+ changed: string[];
669
+ applied: string[];
670
+ remaining: string[];
671
+ last: string;
672
+ error?: string;
673
+ }
674
+ export interface SubagentRecord {
675
+ key: string;
676
+ batch: number;
677
+ run: DelegateRun;
678
+ stop(): Promise<SubagentStopReport>;
679
+ pause(): Promise<boolean>;
680
+ resume(text?: string): boolean;
681
+ steer(text: string): boolean;
682
+ ask(text: string): Promise<string | undefined>;
683
+ changed(): string[];
684
+ discard(): void;
685
+ alive?(): boolean;
686
+ closed?(): Promise<void>;
687
+ }
431
688
 
432
689
  /**
433
690
  * Live subagents for the activity view. Tool details are saved in the session, so they stay small;
@@ -437,12 +694,22 @@ export class DelegateRegistry {
437
694
  private entries = new Map<string, { record: SubagentRecord; finished?: number }>();
438
695
  private listeners = new Set<() => void>();
439
696
  private sequence = 0;
697
+ private reservations = 0;
698
+ private draining = new Set<SubagentRecord>();
699
+
700
+ /** Synchronous reservation prevents concurrent/reentrant batches from oversubscribing. */
701
+ reserve(count: number, maximum: number): (() => void) | undefined {
702
+ if (count < 1 || this.retained() + this.reservations + count > maximum) return undefined;
703
+ this.reservations += count;
704
+ let released = false;
705
+ return () => { if (!released) { released = true; this.reservations -= count; } };
706
+ }
440
707
 
441
708
  subscribe(listener: () => void): () => void {
442
709
  this.listeners.add(listener);
443
710
  return () => { this.listeners.delete(listener); };
444
711
  }
445
- /** Queued/working first (oldest first), then finished ones newest first. */
712
+ /** Retained agents first (oldest first), then retired ones newest first. */
446
713
  records(): SubagentRecord[] {
447
714
  this.sweep();
448
715
  const live: SubagentRecord[] = [];
@@ -454,53 +721,87 @@ export class DelegateRegistry {
454
721
  return [...live, ...finished.map((entry) => entry.record)];
455
722
  }
456
723
  get(key: string): SubagentRecord | undefined { return this.entries.get(key)?.record; }
457
- /** Abort one queued/working run, leaving the rest of its batch alone; false when unknown or already finished. */
458
- stop(key: string): boolean {
459
- const record = this.entries.get(key)?.record;
460
- if (!record || !isActive(record.run.state)) return false;
461
- record.stop();
724
+ resolve(ref: string): SubagentRecord | undefined {
725
+ const exact = this.get(ref);
726
+ if (exact) return exact;
727
+ const name = cleanText(ref, 48).toLowerCase();
728
+ const matches = this.records().filter((record) => record.run.name.toLowerCase() === name);
729
+ return matches.length === 1 ? matches[0] : undefined;
730
+ }
731
+ async stop(key: string): Promise<SubagentStopReport | undefined> {
732
+ const record = this.resolve(key);
733
+ if (!record || record.run.state === "stopped" || (record.run.state === "done" && !record.run.workspace)) return undefined;
734
+ const report = await record.stop();
462
735
  this.notify();
463
- return true;
736
+ return report;
737
+ }
738
+ async pause(key: string): Promise<boolean> {
739
+ const record = this.resolve(key);
740
+ if (!record || !isRetained(record.run.state)) return false;
741
+ const paused = await record.pause();
742
+ this.notify();
743
+ return paused;
744
+ }
745
+ resume(key: string, text?: string): boolean {
746
+ const record = this.resolve(key);
747
+ if (!record || !isRetained(record.run.state)) return false;
748
+ const resumed = record.resume(text);
749
+ if (resumed) this.notify();
750
+ return resumed;
464
751
  }
465
- /** Send a steering message to one working run; false when it cannot take one. */
466
752
  steer(key: string, text: string): boolean {
467
- const record = this.entries.get(key)?.record;
753
+ const record = this.resolve(key);
468
754
  return !!record && record.run.state === "working" && record.steer(text);
469
755
  }
756
+ async ask(key: string, text: string): Promise<string | undefined> {
757
+ const record = this.resolve(key);
758
+ if (!record || !isRetained(record.run.state)) return undefined;
759
+ return record.ask(text);
760
+ }
470
761
  running(): number {
471
762
  let count = 0;
472
- for (const { record } of this.entries.values()) if (isActive(record.run.state)) count++;
763
+ for (const { record } of this.entries.values()) if (isRunning(record.run.state)) count++;
473
764
  return count;
474
765
  }
475
- /** Stop outstanding runs on session changes/shutdown and forget their transcript. */
766
+ retained(): number {
767
+ let count = this.draining.size;
768
+ for (const { record } of this.entries.values()) if (isRetained(record.run.state) || record.alive?.()) count++;
769
+ return count;
770
+ }
771
+ /** Session changes discard retained workers and their private workspaces; they never auto-apply here. */
476
772
  clear(): void {
477
- const stopping = [...this.entries.values()].filter(({ record }) => isActive(record.run.state)).map(({ record }) => record.stop);
773
+ const records = [...this.entries.values()].map(({ record }) => record);
478
774
  this.entries.clear();
479
- for (const stop of stopping) stop();
775
+ for (const record of records) {
776
+ if (record.closed && (isRetained(record.run.state) || record.alive?.())) {
777
+ this.draining.add(record);
778
+ void record.closed().finally(() => { this.draining.delete(record); this.notify(); });
779
+ }
780
+ try { record.discard(); } catch { /* best effort */ }
781
+ }
480
782
  this.notify();
481
783
  }
482
- /** jar_delegate's side: list new runs. */
483
784
  add(...records: SubagentRecord[]): void {
484
785
  for (const record of records) this.entries.set(record.key, { record });
485
786
  this.notify();
486
787
  }
487
- /** jar_delegate's side: a run changed. */
488
788
  notify(): void {
489
789
  this.sweep();
490
790
  for (const listener of this.listeners) {
491
791
  try { listener(); } catch { /* views are decoration; a broken one must not break the run */ }
492
792
  }
493
793
  }
494
- /** Stamp runs in the order they finish and keep only the newest MAX_FINISHED of them. */
495
794
  private sweep(): void {
496
795
  let finished = 0;
497
796
  for (const entry of this.entries.values()) {
498
- if (isActive(entry.record.run.state)) continue;
797
+ if (isRetained(entry.record.run.state) || entry.record.alive?.()) { entry.finished = undefined; continue; }
499
798
  entry.finished ??= ++this.sequence;
500
799
  finished++;
501
800
  }
502
801
  if (finished <= MAX_FINISHED) return;
503
- const oldest = [...this.entries].filter(([, entry]) => entry.finished !== undefined).sort((a, b) => a[1].finished! - b[1].finished!);
802
+ // Conflicted/failed workspaces are recovery state, not disposable transcript history.
803
+ const oldest = [...this.entries].filter(([, entry]) => entry.finished !== undefined && !entry.record.run.workspace)
804
+ .sort((a, b) => a[1].finished! - b[1].finished!);
504
805
  for (const [key] of oldest.slice(0, finished - MAX_FINISHED)) this.entries.delete(key);
505
806
  }
506
807
  }
@@ -520,7 +821,10 @@ export function runReport(run: DelegateRun, now = Date.now()): string {
520
821
  run.cost ? `$${run.cost.toFixed(3)}` : "", run.steered ? `steered ${run.steered}×` : ""
521
822
  ].filter(Boolean).join(" · ");
522
823
  const lines = [`## [${run.index}] ${run.name} (${run.role}${run.model ? " · " + run.model : ""}) — ${run.state}${run.error ? ": " + run.error : ""}`, `- ${facts}`];
824
+ if (run.mode === "fork" || run.mode === "worktree") lines.push(`- mode: ${run.mode}`);
523
825
  if (run.filesEdited.length) lines.push(`- files edited: ${fileList(run.filesEdited, 20)}`);
826
+ if (run.appliedFiles.length) lines.push(`- applied to parent: ${fileList(run.appliedFiles, 20)}`);
827
+ if (run.workspace) lines.push(`- workspace: ${run.workspace}`);
524
828
  if (run.filesRead.length) lines.push(`- files read: ${fileList(run.filesRead, 12)}`);
525
829
  if (run.todos.length) {
526
830
  const leaves = leafTodos(run.todos);
@@ -532,69 +836,194 @@ export function runReport(run: DelegateRun, now = Date.now()): string {
532
836
  return lines.join("\n");
533
837
  }
534
838
 
839
+ const DelegateModeSchema = Type.Union([Type.Literal("scout"), Type.Literal("fork"), Type.Literal("worktree")]);
535
840
  const Parameters = Type.Object({
536
841
  tasks: Type.Array(Type.Object({
537
- task: Type.String({ description: "A complete, self-contained instruction: the subagent sees nothing else." }),
842
+ task: Type.String({ description: "Task instruction. scout sees only this task; fork/worktree also inherit the parent's active conversation branch." }),
538
843
  name: Type.Optional(Type.String({ description: "Short label, e.g. \"auth scout\"." })),
539
- role: Type.Optional(Type.String({ description: "pi-jar role for the model (default \"task\", falls back to the current model)." }))
540
- }), { minItems: 1, maxItems: MAX_DELEGATES }),
541
- write: Type.Optional(Type.Boolean({ description: "Allow file edits (default false: read-only tools)." }))
844
+ role: Type.Optional(Type.String({ description: "pi-jar role override. Defaults by mode: scout→scout, fork→reviewer, worktree→worker; then role fallbacks/current model." })),
845
+ mode: Type.Optional(DelegateModeSchema)
846
+ }), { minItems: 1, maxItems: 16 }),
847
+ mode: Type.Optional(DelegateModeSchema),
848
+ write: Type.Optional(Type.Boolean({ description: "Deprecated compatibility switch. true keeps the old shared-workspace editing mode; prefer mode=\"worktree\"." }))
542
849
  });
543
850
 
544
- const glyph = (state: DelegateState) => state === "done" ? "✔" : state === "failed" ? "✖" : state === "working" ? "●" : "○";
851
+ const glyph = (state: DelegateState) => state === "done" ? "✔" : state === "failed" ? "✖" : state === "stopped" ? "■" : state === "working" ? "●" : "○";
545
852
  const color = (state: DelegateState) => state === "done" ? "success" : state === "failed" ? "error" : state === "working" ? "accent" : "dim";
546
853
  type RunDetails = Omit<DelegateRun, "transcript" | "live">;
547
854
 
855
+ /** Plain orchestration snapshots. id/key are the stable registry key. */
856
+ export interface SubagentReport {
857
+ id: string;
858
+ key: string;
859
+ name: string;
860
+ task: string;
861
+ mode: DelegateExecutionMode;
862
+ state: DelegateState;
863
+ output: string;
864
+ error?: string;
865
+ }
866
+ export interface DelegateController {
867
+ list(): SubagentReport[];
868
+ spawnScout(task: string, signal?: AbortSignal): Promise<SubagentReport>;
869
+ resumeScout(id: string, prompt: string, signal?: AbortSignal): Promise<SubagentReport>;
870
+ }
871
+
548
872
  export interface DelegateOptions {
873
+ /** Allowed sizes: 2, 4, 6, 8, 16; defaults to 4. Read on every launch. */
874
+ getMaxSubagents?: () => number;
875
+ /** Shares the delegate lifecycle and capacity with scout orchestration. */
876
+ onController?: (controller: DelegateController) => void;
549
877
  spawnProcess?: Spawn;
550
- /** The parent's change tracker: subagent edits join /diff. */
878
+ /** The parent's change tracker: finalized worktree edits join /diff. */
551
879
  changes?: () => ChangeTracker | undefined;
552
880
  /** Called after a subagent edit was added to the tracker. */
553
881
  changed?: () => void;
882
+ /** Session-scoped shared discussion paper. */
883
+ discussionFile?: () => string | undefined;
554
884
  }
555
885
 
556
- /** jar_delegate. Every run is listed live in `registry` (with its transcript and stop/steer handles) for the activity view. */
886
+ const roleForMode = (mode: DelegateExecutionMode): string =>
887
+ mode === "scout" ? "scout" : mode === "fork" ? "reviewer" : mode === "worktree" ? "worker" : "task";
888
+
889
+ const remainingTasks = (run: DelegateRun): string[] => {
890
+ const leaves = leafTodos(run.todos);
891
+ return leaves.filter((todo) => todo.status !== "completed").map((todo) => todo.title).slice(0, 20);
892
+ };
893
+
894
+ const controlReport = (report: SubagentStopReport): string => [
895
+ `${report.key} · ${report.name} · ${report.state}`,
896
+ `task: ${report.task}`,
897
+ `workspace: ${report.workspace ?? "none"}`,
898
+ `changed: ${report.changed.length ? report.changed.join(", ") : "none"}`,
899
+ `applied: ${report.applied.length ? report.applied.join(", ") : "none"}`,
900
+ `remaining: ${report.remaining.length ? report.remaining.join("; ") : "none explicitly tracked"}`,
901
+ report.error ? `error: ${report.error}` : "",
902
+ report.last ? `last: ${cleanBlock(report.last, 1200)}` : ""
903
+ ].filter(Boolean).join("\n");
904
+
905
+ const peekRecord = (record: SubagentRecord): string => {
906
+ const run = record.run;
907
+ const leaves = leafTodos(run.todos);
908
+ const done = leaves.filter((todo) => todo.status === "completed").length;
909
+ const changed = (() => { try { return record.changed(); } catch { return run.filesEdited; } })();
910
+ const remaining = remainingTasks(run);
911
+ return [
912
+ `${record.key} · ${run.name} · ${run.state} · ${run.role}${run.model ? " · " + run.model : ""} · ${run.mode}`,
913
+ `task: ${cleanText(run.task, 300)}`,
914
+ `progress: ${leaves.length ? done + "/" + leaves.length + " tasks" : "no checklist"}${run.activity ? " · " + cleanText(run.activity, 100) : ""}`,
915
+ `changed: ${changed.length ? fileList(changed, 12) : "none"}`,
916
+ remaining.length ? `remaining: ${remaining.join("; ")}` : "remaining: none explicitly tracked",
917
+ run.workspace ? `workspace: ${run.workspace}` : "",
918
+ run.output ? `last: ${cleanBlock(run.output, 700)}` : ""
919
+ ].filter(Boolean).join("\n");
920
+ };
921
+
922
+ /** jar_delegate plus jar_subagent: the parent remains a moderator while retained workers do the work. */
557
923
  export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, registry: DelegateRegistry, options: DelegateOptions = {}): void {
558
924
  if (process.env[CHILD_ENV] || typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool !== "function") return;
559
925
  let batch = 0;
560
- pi.registerTool({
926
+ let controllerContext: ExtensionContext | undefined;
927
+ const maxSubagents = () => {
928
+ const value = options.getMaxSubagents?.() ?? MAX_DELEGATES;
929
+ return SUBAGENT_LIMIT_CHOICES.includes(value as never) ? value : MAX_DELEGATES;
930
+ };
931
+ pi.on?.("session_start", (_event, ctx) => { controllerContext = ctx; });
932
+ const roleCandidates = (role: string) => {
933
+ const manager = roles as ModelRoleManager & { resolveCandidates?: (name: string) => ReturnType<ModelRoleManager["resolveCandidates"]> };
934
+ if (typeof manager.resolveCandidates === "function") return manager.resolveCandidates(role);
935
+ const resolved = roles.resolve(role);
936
+ return resolved ? [resolved] : [];
937
+ };
938
+
939
+ let moderatorContextDirty = true;
940
+ registry.subscribe(() => { moderatorContextDirty = true; });
941
+ pi.on?.("before_agent_start", (_event, ctx) => {
942
+ controllerContext = ctx;
943
+ moderatorContextDirty = true;
944
+ const retained = registry.records().filter((record) => isRetained(record.run.state));
945
+ if (!retained.length) return;
946
+ const fleet = retained.map((record) =>
947
+ `- ${record.key} · ${record.run.name} · ${record.run.state} · ${record.run.role}: ${cleanText(record.run.task, 140)}`).join("\n");
948
+ return { message: { customType: "pi-jar.moderator-context", display: false, content: [
949
+ "[PI-JAR MODERATOR MODE]",
950
+ "You are the coordinator while retained subagents do delegated work. Decompose and route work, inspect with jar_subagent peek, steer only to correct direction, use ask for terse BTW questions, and use jar_discuss for structured cross-agent Q/A.",
951
+ "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.",
952
+ "Retained fleet:",
953
+ fleet
954
+ ].join("\n") } };
955
+ });
956
+ pi.on?.("context", (event) => {
957
+ if (!moderatorContextDirty) return;
958
+ const active = registry.retained() > 0;
959
+ let latest = -1;
960
+ let prune = false;
961
+ for (let index = event.messages.length - 1; index >= 0; index--) {
962
+ if ((event.messages[index] as { customType?: string }).customType !== "pi-jar.moderator-context") continue;
963
+ if (!active || latest >= 0) prune = true;
964
+ else latest = index;
965
+ }
966
+ moderatorContextDirty = false;
967
+ if (!prune) return;
968
+ return { messages: event.messages.filter((raw, index) =>
969
+ (raw as { customType?: string }).customType !== "pi-jar.moderator-context" || (active && index === latest)) };
970
+ });
971
+
972
+ const delegateTool: ToolDefinition<typeof Parameters> = {
561
973
  name: DELEGATE_TOOL,
562
974
  label: "delegate",
563
- description: `Run up to ${MAX_DELEGATES} subagents in parallel, each an isolated Pi with a fresh context, on a pi-jar model role (default "task"). Read-only unless write is true. Returns each subagent's report with its tool calls, files read/edited, task checklist and failures.`,
564
- promptSnippet: "Use jar_delegate to fan out independent investigations to parallel subagents.",
975
+ 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.`,
976
+ 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.",
565
977
  promptGuidelines: [
566
- "Use jar_delegate for independent, parallelizable investigation (scouting several areas, reviewing, researching); give each task complete context because subagents see nothing else.",
567
- "Keep delegated work read-only by default. Only set write for clearly separated edits that cannot conflict; never have two subagents edit the same files. Files they edit join /diff for review."
978
+ "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.",
979
+ "Use scout for cheap independent discovery, fork/reviewer for context-aware review, and worktree/worker for implementation. Explicit task.role overrides these mode defaults.",
980
+ "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.",
981
+ "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.",
982
+ "Respect the configured pool limit, including idle and paused agents. Stop agents you no longer need."
568
983
  ],
569
984
  parameters: Parameters,
570
985
  async execute(_id, params, signal, onUpdate, ctx) {
986
+ controllerContext = ctx;
987
+ const requested = params.tasks;
988
+ const maximum = maxSubagents();
989
+ const releaseReservation = registry.reserve(requested.length, maximum);
990
+ if (!releaseReservation) {
991
+ return { content: [{ type: "text", text: `Subagent pool is full: ${registry.retained()}/${maximum} retained. Reuse or stop an existing agent with jar_subagent.` }], details: { runs: [], write: false }, isError: true };
992
+ }
993
+ try {
994
+
571
995
  const id = ++batch;
572
- const write = params.write === true;
573
- const runs: DelegateRun[] = params.tasks.slice(0, MAX_DELEGATES).map((item, index) => {
574
- const role = cleanText(item.role ?? "task", 32) || "task";
575
- const resolved = roles.resolve(role) ?? roles.resolve("default");
576
- const model = resolved ? `${resolved.provider}/${resolved.model}` : ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
577
- return { index: index + 1, name: cleanText(item.name ?? `agent ${index + 1}`, 24), task: item.task, role, ...(model ? { model } : {}),
578
- state: "queued", tools: 0, turns: 0, cost: 0, output: "", toolCounts: {}, filesRead: [], filesEdited: [], todos: [], steered: 0, transcript: [], live: "" };
996
+ const defaultMode: DelegateExecutionMode = params.mode ?? (params.write === true ? "direct" : "scout");
997
+ let available = new Set<string>();
998
+ try { available = new Set(ctx.modelRegistry.getAvailable().map((model) => model.provider + "/" + model.id)); } catch { /* current model remains the fallback */ }
999
+ const thinkingByRun = new Map<number, string | undefined>();
1000
+ const runs: DelegateRun[] = requested.map((item, index) => {
1001
+ const mode: DelegateExecutionMode = item.mode ?? defaultMode;
1002
+ const role = cleanText(item.role ?? roleForMode(mode), 32) || roleForMode(mode);
1003
+ const candidates = [...roleCandidates(role), ...roleCandidates("default")];
1004
+ const selected = candidates.find((candidate) => available.has(candidate.provider + "/" + candidate.model));
1005
+ const model = selected ? `${selected.provider}/${selected.model}` : ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
1006
+ thinkingByRun.set(index + 1, selected?.thinking);
1007
+ return {
1008
+ index: index + 1, name: cleanText(item.name ?? `${role} ${index + 1}`, 24), task: item.task, role, mode, ...(model ? { model } : {}),
1009
+ state: "queued", tools: 0, turns: 0, cost: 0, output: "", toolCounts: {}, filesRead: [], filesEdited: [], appliedFiles: [],
1010
+ pauses: 0, resumes: 0, todos: [], steered: 0, transcript: [], live: ""
1011
+ };
579
1012
  });
580
- // One controller per run: aborting the tool call stops them all, the activity view stops one.
1013
+
581
1014
  const controllers = runs.map(() => new AbortController());
582
1015
  const abortAll = () => { for (const controller of controllers) controller.abort(); };
583
1016
  if (signal?.aborted) abortAll(); else signal?.addEventListener("abort", abortAll, { once: true });
584
1017
  let handles: DelegateHandle[] = [];
585
- // The key doubles as the role-status id, so views can tell our own teammates from other extensions'.
586
- const records: SubagentRecord[] = runs.map((run, index) => ({
587
- key: `delegate-${id}-${run.index}`, batch: id, run,
588
- stop: () => controllers[index]!.abort(STOP_REASON),
589
- steer: (text) => handles[index]?.steer(text) ?? false
590
- }));
591
- registry.add(...records);
592
- const thinking = (role: string) => (roles.resolve(role) ?? roles.resolve("default"))?.thinking;
593
- // Editing subagents record each file's content before their first change here, for the parent's /diff.
1018
+ let records: SubagentRecord[] = [];
1019
+ const worktrees = new Map<number, DelegateWorktree>();
1020
+ const sessionDirs = new Map<number, string>();
1021
+ const parentSession = ctx.sessionManager?.getSessionFile?.();
1022
+
594
1023
  let baselines: string | undefined;
595
- if (write && options.changes) {
1024
+ if (runs.some((run) => run.mode === "direct") && options.changes) {
596
1025
  try { baselines = mkdtempSync(join(tmpdir(), "pi-jar-baselines-")); }
597
- catch (error) { console.error("pi-jar: subagent edits will not join /diff", error); }
1026
+ catch (error) { console.error("pi-jar: direct subagent edits will not join /diff", error); }
598
1027
  }
599
1028
  const edited = (path: string) => {
600
1029
  const tracker = options.changes?.();
@@ -603,76 +1032,349 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
603
1032
  const baseline = readChildBaseline(baselines, absolute);
604
1033
  if (baseline !== undefined && tracker.adopt(absolute, baseline)) options.changed?.();
605
1034
  };
606
- // Live teammates: the welcome TEAM row and the footer read this public status contract.
1035
+
607
1036
  const publish = () => {
608
1037
  if (!ctx.hasUI) return;
609
1038
  for (const { key, run } of records) {
610
1039
  try {
611
- ctx.ui.setStatus(`${ROLE_PREFIX}${key}`, run.state === "done" || run.state === "failed" ? undefined : JSON.stringify({
612
- name: run.name, label: run.name.slice(0, 12), state: run.state === "queued" ? "waiting" : "working", task: cleanText(run.task, 60), expiresAt: Date.now() + 25_000
1040
+ ctx.ui.setStatus(`${ROLE_PREFIX}${key}`, run.state === "done" || run.state === "failed" || run.state === "stopped" ? undefined : JSON.stringify({
1041
+ name: run.name, label: run.name.slice(0, 12),
1042
+ state: run.state === "queued" || run.state === "paused" ? "waiting" : run.state === "idle" ? "idle" : "working",
1043
+ task: cleanText(run.task, 60), expiresAt: Date.now() + 24 * 60 * 60_000
613
1044
  }));
614
1045
  } catch { /* status is decoration */ }
615
1046
  }
616
1047
  };
617
- // Details are persisted with the session: never the transcript or live text, which the registry holds instead.
618
1048
  const details = (includeOutput = true) => ({
619
- runs: runs.map(({ transcript: _transcript, live: _live, ...run }): RunDetails => includeOutput ? run : { ...run, output: "" }), write
1049
+ batch: id,
1050
+ runs: runs.map(({ transcript: _transcript, live: _live, ...run }): RunDetails => includeOutput ? run : { ...run, output: "" }),
1051
+ write: runs.some((run) => run.mode === "direct" || run.mode === "worktree")
620
1052
  });
621
1053
  let updateTimer: ReturnType<typeof setTimeout> | undefined;
622
1054
  let lastUpdateAt = 0;
1055
+ let toolReturned = false;
623
1056
  const emitUpdate = () => {
624
1057
  updateTimer = undefined;
625
1058
  lastUpdateAt = Date.now();
626
1059
  publish();
627
- onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}${run.activity ? ` · ${run.activity}` : ""}`).join("\n") }], details: details(false) });
1060
+ onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}${run.activity ? " · " + run.activity : ""}`).join("\n") }], details: details(false) });
628
1061
  };
629
1062
  const update = () => {
630
1063
  registry.notify();
631
- if (!onUpdate) { publish(); return; }
1064
+ if (toolReturned || !onUpdate) { publish(); return; }
632
1065
  const wait = Math.max(0, LIVE_UPDATE_MS - (Date.now() - lastUpdateAt));
633
- if (wait === 0) { if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; } emitUpdate(); return; }
634
- if (!updateTimer) {
635
- updateTimer = setTimeout(emitUpdate, wait);
636
- updateTimer.unref?.();
637
- }
1066
+ if (wait === 0) { if (updateTimer) clearTimeout(updateTimer); emitUpdate(); return; }
1067
+ if (!updateTimer) { updateTimer = setTimeout(emitUpdate, wait); updateTimer.unref?.(); }
638
1068
  };
639
1069
  const flushUpdate = () => {
640
1070
  if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; }
641
1071
  emitUpdate();
642
1072
  };
643
- const refresh = setInterval(publish, STATUS_REFRESH_MS);
644
- refresh.unref?.();
645
- const hooks: DelegateHooks = { ...(baselines ? { env: { [CHILD_BASELINE_ENV]: baselines } } : {}), edited };
1073
+ const failedHandle = (): DelegateHandle => ({
1074
+ firstSettled: Promise.resolve(), closed: Promise.resolve(), steer: () => false, pause: async () => false,
1075
+ resume: () => false, ask: async () => undefined, shutdown: async () => {}, alive: () => false
1076
+ });
1077
+ const cleanupPrivate = (index: number, discardWorktree: boolean) => {
1078
+ const dir = sessionDirs.get(index);
1079
+ if (dir) { rmSync(dir, { recursive: true, force: true }); sessionDirs.delete(index); }
1080
+ if (discardWorktree) {
1081
+ const worktree = worktrees.get(index);
1082
+ if (worktree) {
1083
+ try { disposeDelegateWorktree(worktree); } catch { /* best effort */ }
1084
+ worktrees.delete(index);
1085
+ }
1086
+ }
1087
+ };
1088
+ const failBeforeStart = (run: DelegateRun, error: unknown) => {
1089
+ run.state = "failed";
1090
+ run.error = cleanText(error instanceof Error ? error.message : String(error), 300);
1091
+ run.endedAt = Date.now();
1092
+ update();
1093
+ };
1094
+
1095
+ let discarded = false;
1096
+ handles = runs.map(failedHandle);
1097
+ const readyResolvers: Array<() => void> = [];
1098
+ const ready = runs.map(() => new Promise<void>((resolve) => readyResolvers.push(resolve)));
1099
+ const startRuns = () => runs.forEach((run, index) => {
1100
+ if (discarded) { run.state = "stopped"; readyResolvers[index]!(); return; }
1101
+ const start = (): DelegateHandle => {
1102
+ if ((run.mode === "fork" || run.mode === "worktree") && !parentSession) {
1103
+ failBeforeStart(run, "fork/worktree mode requires a persisted parent Pi session");
1104
+ return failedHandle();
1105
+ }
1106
+ let cwd = ctx.cwd;
1107
+ let fork: DelegateForkOptions | undefined;
1108
+ const env: Record<string, string> = {
1109
+ [SUBAGENT_KEY_ENV]: `delegate-${id}-${run.index}`,
1110
+ [SUBAGENT_NAME_ENV]: run.name
1111
+ };
1112
+ const discussion = options.discussionFile?.();
1113
+ if (discussion) env[DISCUSSION_FILE_ENV] = discussion;
1114
+ const hooks: DelegateHooks = {};
1115
+ const write = run.mode === "direct" || run.mode === "worktree";
1116
+
1117
+ if (run.mode === "fork" || run.mode === "worktree") {
1118
+ try {
1119
+ const sessionDir = mkdtempSync(join(tmpdir(), "pi-jar-fork-"));
1120
+ sessionDirs.set(run.index, sessionDir);
1121
+ fork = { source: parentSession!, sessionDir, ...(run.mode === "worktree" ? { tools: WORKTREE_TOOLS } : {}) };
1122
+ } catch (error) {
1123
+ failBeforeStart(run, error);
1124
+ return failedHandle();
1125
+ }
1126
+ }
1127
+ if (run.mode === "worktree") {
1128
+ try {
1129
+ const worktree = createDelegateWorktree(ctx.cwd);
1130
+ worktrees.set(run.index, worktree);
1131
+ run.workspace = worktree.root;
1132
+ cwd = worktree.cwd;
1133
+ env[CHILD_WORKTREE_ENV] = worktree.root;
1134
+ } catch (error) {
1135
+ cleanupPrivate(run.index, true);
1136
+ failBeforeStart(run, error);
1137
+ return failedHandle();
1138
+ }
1139
+ } else if (run.mode === "direct" && baselines) {
1140
+ env[CHILD_BASELINE_ENV] = baselines;
1141
+ hooks.edited = edited;
1142
+ }
1143
+ hooks.env = env;
1144
+ return startDelegate(run, delegateArgs(run.model, thinkingByRun.get(run.index), write, process.argv, fork),
1145
+ delegatePrompt(run.task, write, run.mode), cwd, controllers[index]!.signal, update, options.spawnProcess, hooks, run.mode !== "direct");
1146
+ };
1147
+ let handle: DelegateHandle;
1148
+ try { handle = start(); }
1149
+ catch (error) { failBeforeStart(run, error); handle = failedHandle(); }
1150
+ handles[index] = handle;
1151
+ readyResolvers[index]!();
1152
+ void handle.closed.then(() => {
1153
+ cleanupPrivate(run.index, discarded || run.mode !== "worktree");
1154
+ update();
1155
+ });
1156
+ if (discarded) void handle.shutdown().finally(() => cleanupPrivate(run.index, true));
1157
+ });
1158
+
1159
+ const stopping = new Map<number, Promise<SubagentStopReport>>();
1160
+ const stopRequested = new Set<number>();
1161
+ const stopRun = (index: number): Promise<SubagentStopReport> => {
1162
+ const existing = stopping.get(index);
1163
+ if (existing) return existing;
1164
+ stopRequested.add(index);
1165
+ const promise = finalizeRun(index);
1166
+ stopping.set(index, promise);
1167
+ // A reconciliation conflict is retryable after the parent drift is resolved.
1168
+ void promise.then(() => { if (worktrees.has(runs[index]!.index)) stopping.delete(index); });
1169
+ return promise;
1170
+ };
1171
+ const finalizeRun = async (index: number): Promise<SubagentStopReport> => {
1172
+ const run = runs[index]!;
1173
+ const key = `delegate-${id}-${run.index}`;
1174
+ const workspace = run.workspace;
1175
+ if (run.error?.startsWith("changes not applied:")) run.error = undefined;
1176
+ await ready[index];
1177
+ const handle = handles[index]!;
1178
+ await handle.shutdown();
1179
+
1180
+ let changed: string[] = [];
1181
+ const worktree = worktrees.get(run.index);
1182
+ if (worktree && !discarded) {
1183
+ try { changed = worktreeChangedFiles(worktree); run.filesEdited = changed; }
1184
+ catch (error) { run.error = "could not inspect isolated changes: " + cleanText(String(error), 240); }
1185
+ if (changed.length && !run.error) {
1186
+ try {
1187
+ run.appliedFiles = applyDelegateWorktree(worktree, ctx.cwd, changed, options.changes?.());
1188
+ if (run.appliedFiles.length) {
1189
+ try { options.changed?.(); } catch (error) { console.error("pi-jar: could not refresh parent change UI", error); }
1190
+ }
1191
+ disposeDelegateWorktree(worktree);
1192
+ worktrees.delete(run.index);
1193
+ run.workspace = undefined;
1194
+ } catch (error) {
1195
+ if (error instanceof WorktreeApplyError) run.appliedFiles = error.appliedFiles;
1196
+ run.error = "changes not applied: " + cleanText(error instanceof Error ? error.message : String(error), 240);
1197
+ }
1198
+ } else if (!changed.length) {
1199
+ try { disposeDelegateWorktree(worktree); } catch { /* best effort */ }
1200
+ worktrees.delete(run.index);
1201
+ run.workspace = undefined;
1202
+ }
1203
+ }
1204
+ cleanupPrivate(run.index, false);
1205
+ run.state = run.error ? "failed" : "stopped";
1206
+ run.endedAt ??= Date.now();
1207
+ update();
1208
+ return {
1209
+ key, name: run.name, state: run.state, task: run.task, ...(workspace ? { workspace } : {}),
1210
+ changed, applied: [...run.appliedFiles], remaining: remainingTasks(run), last: run.output, ...(run.error ? { error: run.error } : {})
1211
+ };
1212
+ };
1213
+
1214
+ records = runs.map((run, index) => ({
1215
+ key: `delegate-${id}-${run.index}`, batch: id, run,
1216
+ stop: () => stopRun(index),
1217
+ pause: () => discarded || stopRequested.has(index) ? Promise.resolve(false) : handles[index]!.pause(),
1218
+ resume: (text) => !discarded && !stopRequested.has(index) && handles[index]!.resume(text),
1219
+ steer: (text) => !discarded && !stopRequested.has(index) && handles[index]!.steer(text),
1220
+ ask: (text) => discarded || stopRequested.has(index) ? Promise.resolve(undefined) : handles[index]!.ask(text),
1221
+ alive: () => handles[index]!.alive(),
1222
+ closed: () => ready[index]!.then(() => handles[index]!.closed),
1223
+ changed: () => {
1224
+ const worktree = worktrees.get(run.index);
1225
+ if (!worktree) return [...run.filesEdited];
1226
+ try { return worktreeChangedFiles(worktree); } catch { return [...run.filesEdited]; }
1227
+ },
1228
+ discard: () => {
1229
+ discarded = true;
1230
+ // Cancellation is synchronous: a concurrent stop must never apply discarded edits.
1231
+ controllers[index]!.abort(STOP_REASON);
1232
+ void ready[index]!.then(() => handles[index]!.shutdown()).finally(() => cleanupPrivate(run.index, true));
1233
+ }
1234
+ }));
1235
+ // Transfer the reservation into queued records before notifying reentrant listeners.
1236
+ releaseReservation();
1237
+ registry.add(...records);
1238
+ startRuns();
1239
+ publish();
1240
+
646
1241
  try {
647
- handles = runs.map((run, index) => startDelegate(run, delegateArgs(run.model, thinking(run.role), write), delegatePrompt(run.task, write),
648
- ctx.cwd, controllers[index]!.signal, update, options.spawnProcess, hooks));
649
- await Promise.all(handles.map((handle) => handle.done));
1242
+ await Promise.all(handles.map((handle, index) => runs[index]!.mode === "direct" ? handle.closed : handle.firstSettled));
650
1243
  } finally {
651
- clearInterval(refresh);
652
1244
  signal?.removeEventListener("abort", abortAll);
653
1245
  if (baselines) rmSync(baselines, { recursive: true, force: true });
654
- for (const run of runs) if (run.state !== "done") { run.state = "failed"; run.endedAt ??= Date.now(); }
1246
+ for (const run of runs) {
1247
+ if (run.mode === "direct") cleanupPrivate(run.index, true);
1248
+ else if (run.state === "failed") cleanupPrivate(run.index, false);
1249
+ }
655
1250
  registry.notify();
656
1251
  flushUpdate();
1252
+ toolReturned = true;
657
1253
  }
658
- return { content: [{ type: "text", text: runs.map((run) => runReport(run)).join("\n\n") }], details: details() };
1254
+ return { content: [{ type: "text", text: runs.map((run) => runReport(run)).join("\n\n") }], details: details(),
1255
+ ...(runs.some((run) => run.state === "failed") ? { isError: true } : {}) };
1256
+ } finally { releaseReservation(); }
659
1257
  },
660
1258
  renderCall(args, theme) {
661
1259
  const count = Array.isArray(args.tasks) ? args.tasks.length : 0;
1260
+ const requested = args.mode ?? (args.write ? "direct" : "scout");
662
1261
  return new Text(theme.fg("toolTitle", theme.bold("delegate")) + " " + theme.fg("accent", `${count} subagent${count === 1 ? "" : "s"}`)
663
- + theme.fg("dim", args.write ? " · can edit" : " · read-only"), 0, 0);
1262
+ + theme.fg("dim", ` · ${requested}`), 0, 0);
664
1263
  },
665
1264
  renderResult(result, { expanded }, theme) {
666
1265
  const runs = (result.details as { runs?: Array<Partial<RunDetails> & Pick<DelegateRun, "name" | "state" | "role">> } | undefined)?.runs ?? [];
667
1266
  const rows = runs.map((run) => {
668
1267
  const leaves = leafTodos(run.todos ?? []);
669
- const meta = [run.role, run.activity, run.tools ? `${run.tools} tools` : "", leaves.length ? `${leaves.filter((todo) => todo.done).length}/${leaves.length} tasks` : "",
670
- run.filesEdited?.length ? `${run.filesEdited.length} edited` : "", run.cost ? `$${run.cost.toFixed(3)}` : "", run.error].filter(Boolean).join(" · ");
1268
+ const meta = [run.role, run.mode === "fork" || run.mode === "worktree" ? run.mode : "", run.activity, run.tools ? `${run.tools} tools` : "",
1269
+ leaves.length ? `${leaves.filter((todo) => todo.done).length}/${leaves.length} tasks` : "",
1270
+ run.appliedFiles?.length ? `${run.appliedFiles.length} applied` : run.filesEdited?.length ? `${run.filesEdited.length} edited` : "",
1271
+ run.cost ? `$${run.cost.toFixed(3)}` : "", run.error].filter(Boolean).join(" · ");
671
1272
  let row = theme.fg(color(run.state) as never, ` ${glyph(run.state)} ${run.name}`) + theme.fg("dim", " · " + meta);
672
1273
  if (expanded && run.output) row += "\n" + run.output.split("\n").map((line) => theme.fg("muted", " " + line)).join("\n");
673
1274
  return row;
674
1275
  });
675
1276
  return new Text(rows.join("\n") || theme.fg("dim", "No subagents."), 0, 0);
676
1277
  }
1278
+ };
1279
+ pi.registerTool(delegateTool);
1280
+
1281
+ const snapshot = (record: SubagentRecord): SubagentReport => ({
1282
+ id: record.key, key: record.key, name: record.run.name, task: record.run.task,
1283
+ mode: record.run.mode, state: record.run.state, output: record.run.output,
1284
+ ...(record.run.error ? { error: record.run.error } : {})
1285
+ });
1286
+ options.onController?.({
1287
+ list: () => registry.records().map(snapshot),
1288
+ async spawnScout(task, signal) {
1289
+ if (!controllerContext) throw new Error("Delegate controller has no active session context");
1290
+ if (signal?.aborted) throw new Error("Scout launch aborted");
1291
+ const result = await delegateTool.execute("scout", { tasks: [{ task, mode: "scout" }] }, signal, undefined, controllerContext);
1292
+ const launched = (result.details as { batch?: number }).batch;
1293
+ if (!launched) throw new Error(result.content.map((part) => "text" in part ? part.text : "").join("\n"));
1294
+ const record = registry.get(`delegate-${launched}-1`);
1295
+ if (!record) throw new Error("Scout session was discarded");
1296
+ if (record.run.error || record.run.state !== "idle") throw new Error(record.run.error ?? "Scout failed to settle");
1297
+ return snapshot(record);
1298
+ },
1299
+ async resumeScout(id, prompt, signal) {
1300
+ const record = registry.get(id);
1301
+ if (!record || record.run.mode !== "scout") throw new Error("Only fresh read-only scouts can vote");
1302
+ if (!prompt.trim()) throw new Error("Scout resume requires a prompt");
1303
+ if (signal?.aborted) throw new Error("Scout resume aborted");
1304
+ const result = new Promise<SubagentReport>((resolve, reject) => {
1305
+ let unsubscribe = () => {};
1306
+ const cleanup = () => { unsubscribe(); signal?.removeEventListener("abort", abort); };
1307
+ const check = () => {
1308
+ if (registry.get(id) !== record) { cleanup(); reject(new Error("Scout session was discarded")); return; }
1309
+ if (isRunning(record.run.state)) return;
1310
+ cleanup();
1311
+ if (record.run.error || !isRetained(record.run.state)) reject(new Error(record.run.error ?? "Scout exited before reporting"));
1312
+ else resolve(snapshot(record));
1313
+ };
1314
+ const abort = () => { cleanup(); void registry.stop(id); reject(new Error("Scout resume aborted")); };
1315
+ unsubscribe = registry.subscribe(check);
1316
+ signal?.addEventListener("abort", abort, { once: true });
1317
+ if (!registry.resume(id, prompt)) { cleanup(); reject(new Error("Scout is not idle or paused")); }
1318
+ else check();
1319
+ });
1320
+ return result;
1321
+ }
1322
+ });
1323
+
1324
+ pi.registerTool({
1325
+ name: "jar_subagent",
1326
+ label: "subagent",
1327
+ description: "Moderator control for retained subagents: peek high-level 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.",
1328
+ 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.",
1329
+ promptGuidelines: [
1330
+ "Use peek periodically instead of polling constantly. It returns task progress, current activity, changed files, remaining checklist items and last report.",
1331
+ "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.",
1332
+ "Pause when an agent should stop spending tokens but keep its context/workspace. Resume the same agent later instead of spawning a replacement.",
1333
+ "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."
1334
+ ],
1335
+ parameters: Type.Object({
1336
+ action: Type.Union([
1337
+ Type.Literal("peek"), Type.Literal("steer"), Type.Literal("ask"),
1338
+ Type.Literal("pause"), Type.Literal("resume"), Type.Literal("stop")
1339
+ ]),
1340
+ agent: Type.Optional(Type.String({ description: "Subagent key or unique name. Omit only for peek to show all retained agents." })),
1341
+ message: Type.Optional(Type.String({ description: "Direction, BTW question, or resume instruction." }))
1342
+ }),
1343
+ async execute(_id, params) {
1344
+ const result = await (async () => {
1345
+ if (params.action === "peek") {
1346
+ const targets = params.agent ? [registry.resolve(params.agent)].filter((item): item is SubagentRecord => !!item)
1347
+ : registry.records().filter((record) => isRetained(record.run.state));
1348
+ if (!targets.length) return { content: [{ type: "text", text: params.agent ? "Subagent not found: " + params.agent : "No retained subagents." }] };
1349
+ return { content: [{ type: "text", text: targets.map(peekRecord).join("\n\n") }] };
1350
+ }
1351
+ const agent = params.agent ?? "";
1352
+ const record = registry.resolve(agent);
1353
+ if (!record) return { content: [{ type: "text", text: "Subagent not found or name is ambiguous: " + agent }], isError: true };
1354
+ if (params.action === "steer") {
1355
+ const message = cleanBlock(params.message ?? "", MAX_STEER_CHARS);
1356
+ if (!message) return { content: [{ type: "text", text: "steer requires message." }], isError: true };
1357
+ return { content: [{ type: "text", text: registry.steer(record.key, message) ? "Steered " + record.run.name + "." : "Subagent is not currently working; use resume instead." }] };
1358
+ }
1359
+ if (params.action === "ask") {
1360
+ const message = cleanBlock(params.message ?? "", MAX_STEER_CHARS);
1361
+ if (!message) return { content: [{ type: "text", text: "ask requires message." }], isError: true };
1362
+ const answer = await registry.ask(record.key, message);
1363
+ return { content: [{ type: "text", text: answer ? record.run.name + ": " + cleanBlock(answer, 2000) : "No answer arrived before the BTW timeout." }] };
1364
+ }
1365
+ if (params.action === "pause") {
1366
+ const paused = await registry.pause(record.key);
1367
+ return { content: [{ type: "text", text: paused ? "Paused " + record.run.name + "; context and workspace are retained." : "Could not pause " + record.run.name + "." }] };
1368
+ }
1369
+ if (params.action === "resume") {
1370
+ const resumed = registry.resume(record.key, params.message);
1371
+ return { content: [{ type: "text", text: resumed ? "Resumed " + record.run.name + "." : record.run.name + " is not idle or paused." }] };
1372
+ }
1373
+ const report = await registry.stop(record.key);
1374
+ if (!report) return { content: [{ type: "text", text: "Subagent is already retired: " + record.run.name }] };
1375
+ return { content: [{ type: "text", text: controlReport(report) }], details: report };
1376
+ })();
1377
+ return { ...result, content: result.content.map((part) => ({ ...part, type: "text" as const })), details: result.details };
1378
+ }
677
1379
  });
678
1380
  }