pi-jar 0.1.7 → 0.1.9

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,10 +1,10 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { signalProcessTree } from "./async-process.ts";
3
3
  import { CHILD_TOOLS_ENV, RECURSIVE_TOOLS, WEB_TOOLS } from "./subagent-tools.ts";
4
- import { existsSync, mkdtempSync } from "node:fs";
4
+ import { existsSync, mkdtempSync, realpathSync } from "node:fs";
5
5
  import { rm } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
- import { basename, isAbsolute, join, resolve } from "node:path";
7
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
10
10
  import { Text } from "@earendil-works/pi-tui";
@@ -14,7 +14,7 @@ import { CHILD_BASELINE_ENV, readChildBaseline } from "./child-baselines.ts";
14
14
  import type { ModelRoleManager } from "./model-roles.ts";
15
15
  import { cleanText, ROLE_PREFIX } from "./status.ts";
16
16
  import type { Todo } from "./tasks.ts";
17
- import { DISCUSSION_FILE_ENV, SUBAGENT_KEY_ENV, SUBAGENT_NAME_ENV } from "./discussion.ts";
17
+ import { DISCUSSION_ENV_KEYS, SUBAGENT_KEY_ENV, SUBAGENT_NAME_ENV } from "./discussion.ts";
18
18
  import {
19
19
  applyDelegateWorktree,
20
20
  CHILD_WORKTREE_ENV,
@@ -47,6 +47,10 @@ const IDLE_TIMEOUT_MS = 10 * 60_000;
47
47
  const CLOSE_GRACE_MS = 2000;
48
48
  /** How long pause waits for the child to settle after abort before reporting that it has not paused yet. */
49
49
  const PAUSE_GRACE_MS = 15_000;
50
+ /** A settled retained child closes its Pi process after this much quiet; resume/ask relaunch it on the same session. */
51
+ export const HIBERNATE_AFTER_MS = 30_000;
52
+ /** How long hibernation waits for the child to report its session file before it stays awake instead. */
53
+ const STATE_QUERY_MS = 5000;
50
54
  const STATUS_REFRESH_MS = 20_000;
51
55
  const LIVE_UPDATE_MS = 1000;
52
56
  /** Visible message that wakes the moderator when a resumed subagent's turn ends. */
@@ -69,6 +73,10 @@ const MAX_STEER_CHARS = 4000;
69
73
  export const MAX_RESUME_CHARS = 40_000;
70
74
  /** Finished subagents the registry keeps for the activity view after their tool call returns. */
71
75
  const MAX_FINISHED = 8;
76
+ /** Unresolved worktree recovery workspaces, counting active worktree agents that may still become one. */
77
+ export const RECOVERY_LIMIT = 4;
78
+ /** A recovery record keeps this much of its last report; its transcript is released. */
79
+ const MAX_RECOVERY_OUTPUT = 1200;
72
80
  /** Abort reason for stopping one subagent from the activity view (vs. aborting the whole tool call). */
73
81
  const STOP_REASON = "stopped";
74
82
  /** Tool arguments that best describe a call in the transcript, most telling first. */
@@ -77,7 +85,7 @@ const HINT_KEYS = ["command", "path", "file_path", "pattern", "query", "url"] as
77
85
  const DIALOGS = new Set(["select", "confirm", "input", "editor"]);
78
86
  const ANSI = /\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g;
79
87
 
80
- export type DelegateState = "queued" | "working" | "idle" | "paused" | "stopped" | "done" | "failed";
88
+ export type DelegateState = "queued" | "working" | "idle" | "paused" | "hibernated" | "stopped" | "done" | "failed";
81
89
  export type ToolStatus = "running" | "done" | "error";
82
90
  /** One transcript row; `rev` changes whenever the entry does, so views can cache their rendering. */
83
91
  export type TranscriptEntry =
@@ -145,26 +153,33 @@ export function extensionFlags(argv = process.argv, cwd = process.cwd()): string
145
153
  return flags;
146
154
  }
147
155
 
148
- export interface DelegateForkOptions {
149
- source: string;
156
+ export interface DelegateSessionOptions {
157
+ /** This child's own private session directory; removed when its record is permanently discarded. */
150
158
  sessionDir: string;
159
+ /** fork/worktree: the parent session this child forks, so it inherits the conversation. */
160
+ source?: string;
161
+ /** This child's own session file, continued after hibernation instead of creating or forking one. */
162
+ resume?: string;
151
163
  tools?: readonly string[];
152
164
  }
153
165
 
154
- /** CLI arguments for one subagent: fresh ephemeral session or a real Pi session fork. */
166
+ /** CLI arguments for one subagent: an ephemeral one-shot, a private resumable session, or a real Pi session fork. */
155
167
  export function delegateArgs(model: string | undefined, thinking: string | undefined, write: boolean, argv = process.argv,
156
- fork?: DelegateForkOptions, toolOverride?: readonly string[]): string[] {
157
- const session = fork ? ["--fork", fork.source, "--session-dir", fork.sessionDir] : ["--no-session"];
168
+ session?: DelegateSessionOptions, toolOverride?: readonly string[]): string[] {
169
+ const sessionArgs = !session ? ["--no-session"]
170
+ : session.resume ? ["--session", session.resume, "--session-dir", session.sessionDir]
171
+ : session.source ? ["--fork", session.source, "--session-dir", session.sessionDir]
172
+ : ["--session-dir", session.sessionDir];
158
173
  const extensions = extensionFlags(argv);
159
- // Worktree cwd/session forks do not inherit project extension discovery from the parent.
160
- if (fork) {
174
+ // Worktree cwd/session forks (resumed ones too) do not inherit project extension discovery from the parent.
175
+ if (session?.source) {
161
176
  const ownExtension = fileURLToPath(new URL("../extensions/index.ts", import.meta.url));
162
177
  if (!extensions.includes(ownExtension)) extensions.push("--extension", ownExtension);
163
178
  }
164
- const args = ["--mode", "rpc", ...session, ...extensions];
179
+ const args = ["--mode", "rpc", ...sessionArgs, ...extensions];
165
180
  if (model) args.push("--model", model);
166
181
  if (thinking) args.push("--thinking", thinking);
167
- const tools = fork?.tools ?? toolOverride ?? (!write ? READ_ONLY_TOOLS : undefined);
182
+ const tools = session?.tools ?? toolOverride ?? (!write ? READ_ONLY_TOOLS : undefined);
168
183
  if (tools) args.push("--tools", tools.join(","));
169
184
  return args;
170
185
  }
@@ -172,7 +187,7 @@ export function delegateArgs(model: string | undefined, thinking: string | undef
172
187
  /** The subagent's prompt: its rules, how to report back, then the task. */
173
188
  export function delegatePrompt(task: string, write: boolean, mode: DelegateExecutionMode = write ? "direct" : "scout"): string {
174
189
  const rules = mode === "worktree"
175
- ? "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."
190
+ ? "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, hibernation and resume, and are reconciled into the parent for /diff review only when the moderator stops you."
176
191
  : mode === "fork"
177
192
  ? "You are a read-only fork of the parent conversation: use the inherited context, investigate, and report without modifying files."
178
193
  : write
@@ -181,7 +196,7 @@ export function delegatePrompt(task: string, write: boolean, mode: DelegateExecu
181
196
  return [
182
197
  `You are a focused subagent working for another agent. ${rules}`,
183
198
  "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.",
184
- "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.",
199
+ "Use jar_discuss when available for short cross-agent questions and answers. Keep discussion messages narrow; it is for coordination, not a transcript.",
185
200
  "Finish with a self-contained report in these sections:",
186
201
  "## Summary — the outcome in one to three sentences.",
187
202
  "## Details — findings or changes, with file paths (and line numbers where useful).",
@@ -293,7 +308,7 @@ interface ChildEvent {
293
308
  id?: unknown;
294
309
  command?: unknown;
295
310
  success?: unknown;
296
- data?: { disposition?: unknown };
311
+ data?: { disposition?: unknown; sessionFile?: unknown };
297
312
  error?: unknown;
298
313
  method?: unknown;
299
314
  toolCallId?: unknown;
@@ -307,314 +322,370 @@ interface ChildEvent {
307
322
  }
308
323
 
309
324
  export interface DelegateHooks {
310
- /** Extra environment for the child (the baseline directory for /diff). */
311
- env?: Record<string, string>;
325
+ /** Extra environment for each child process: read again when a hibernated child relaunches. */
326
+ env?: () => Record<string, string>;
312
327
  /** A successful edit/write by the child, with the path it gave. */
313
328
  edited?(path: string): void;
329
+ /** CLI arguments continuing this child's own session file, or undefined to refuse it. Without it the child never hibernates. */
330
+ resumeArgs?(sessionFile: string): string[] | undefined;
331
+ /** Settled quiet time before a resumable child's process closes; absent or non-finite never hibernates. */
332
+ hibernateAfterMs?: number;
314
333
  }
315
334
  export interface DelegateHandle {
316
335
  /** First point where the child is idle after its initial task. */
317
336
  firstSettled: Promise<void>;
318
- /** Process lifetime; persistent agents stay alive across many settled turns. */
337
+ /** The agent's permanent end. Hibernation closes only the process; resume and ask relaunch it. */
319
338
  closed: Promise<void>;
320
339
  steer(text: string): boolean;
321
340
  pause(): Promise<boolean>;
322
341
  resume(text?: string): boolean;
323
342
  ask(text: string): Promise<string | undefined>;
324
343
  shutdown(): Promise<void>;
344
+ /** Whether a child process is running right now; false while hibernated. */
325
345
  alive(): boolean;
346
+ pid(): number | undefined;
347
+ }
348
+
349
+ /** One Pi process of a subagent; a hibernating agent runs a series of them on one session file. */
350
+ interface RpcProcess {
351
+ child: ChildProcess;
352
+ /** Until exit: control commands can still reach the child. */
353
+ alive: boolean;
354
+ /** Closing on purpose to hibernate, so its exit is not the agent's end. */
355
+ hibernating: boolean;
356
+ stderr: string;
357
+ killTimer?: NodeJS.Timeout;
358
+ /** Final teardown once stdio closed or stopped draining; idempotent. */
359
+ exited(code: number | null): void;
360
+ /** Resolves after `exited`. */
361
+ ended: Promise<void>;
326
362
  }
327
363
 
328
364
  /**
329
- * Run one child Pi in RPC mode. Persistent children remain alive after agent_settled so the
330
- * moderator can peek, steer, pause, resume and ask them questions without rebuilding context.
365
+ * Run one subagent over Pi RPC. Persistent children stay retained after agent_settled so the moderator
366
+ * can peek, steer, pause, resume and ask them without rebuilding context. A resumable child that stays
367
+ * settled hibernates: its process closes cleanly, and resume/ask relaunch Pi on the same session file.
331
368
  */
332
369
  export function startDelegate(run: DelegateRun, args: string[], prompt: string, cwd: string, signal: AbortSignal | undefined,
333
370
  update: () => void, spawnProcess: Spawn = spawn as Spawn, hooks: DelegateHooks = {}, persistent = true): DelegateHandle {
334
- let steer: (text: string) => boolean = () => false;
335
- let pause: () => Promise<boolean> = async () => false;
336
- let resume: (text?: string) => boolean = () => false;
337
- let ask: (text: string) => Promise<string | undefined> = async () => undefined;
338
- let shutdown: () => Promise<void> = async () => {};
339
- let alive = false;
340
371
  let resolveFirst!: () => void;
341
372
  let firstResolved = false;
342
373
  const firstSettled = new Promise<void>((resolve) => { resolveFirst = resolve; });
374
+ let resolveClosed!: () => void;
375
+ const closed = new Promise<void>((resolve) => { resolveClosed = resolve; });
343
376
 
344
- const closed = new Promise<void>((resolveClosed) => {
345
- const invocation = piInvocation(args);
346
- let child: ChildProcess;
347
- try {
348
- const env = { ...process.env };
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" } });
351
- } catch (error) {
352
- run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); run.endedAt = Date.now(); update();
353
- firstResolved = true; resolveFirst(); resolveClosed(); return;
354
- }
377
+ /** The running process: none before the first spawn, after the end, or while hibernated. */
378
+ let current: RpcProcess | undefined;
379
+ let finished = false;
380
+ let settled = false;
381
+ let pauseRequested = false;
382
+ let closing = false;
383
+ let shutdownPromise: Promise<void> | undefined;
384
+ let askBusy = false;
385
+ let liveAt = 0;
386
+ let liveTimer: NodeJS.Timeout | undefined;
387
+ let turnTimer: NodeJS.Timeout | undefined;
388
+ let hibernateTimer: NodeJS.Timeout | undefined;
389
+ /** Last RPC event while working; the watchdog only fires after this much silence. */
390
+ let activityAt = Date.now();
391
+ /** How to continue this child's session after hibernation, learned from Pi before its process closed. */
392
+ let relaunchArgs: string[] | undefined;
393
+ /** A hibernated agent's settled state: a BTW answer returns a paused agent to paused. */
394
+ let rest: "idle" | "paused" = "idle";
395
+ /** A prompt accepted while the hibernating process was still exiting; its relaunch sends it. */
396
+ let wake: { message: string; id: string } | undefined;
397
+ let querySequence = 0;
398
+ const queries = new Map<string, (file: string | undefined) => void>();
399
+ const settleWaiters: Array<() => void> = [];
400
+ const running = new Map<string, { entry: ToolEntry; path?: string }>();
355
401
 
356
- alive = true;
357
- run.state = "working";
358
- run.startedAt = Date.now();
359
- run.endedAt = undefined;
360
- run.activity = "starting";
402
+ const send = (command: object): boolean => {
403
+ const stdin = current?.alive ? current.child.stdin : undefined;
404
+ if (!stdin || stdin.destroyed || stdin.writableEnded) return false;
405
+ // Node queues writes until drain. Bound that queue if a hung RPC child stops reading;
406
+ // accepted commands remain complete JSONL records, never partial/manual framing.
407
+ if (stdin.writableLength > 256 * 1024) return false;
408
+ try { stdin.write(JSON.stringify(command) + "\n"); return true; } catch { return false; }
409
+ };
410
+ const clearTurnTimer = () => { clearTimeout(turnTimer); turnTimer = undefined; };
411
+ const hardStop = () => {
412
+ const proc = current;
413
+ if (!proc?.alive) return;
414
+ signalProcessTree(proc.child, "SIGTERM");
415
+ proc.killTimer ??= setTimeout(() => {
416
+ // Kill the group even if its leader exited: descendants can ignore SIGTERM and keep pipes.
417
+ signalProcessTree(proc.child, "SIGKILL");
418
+ proc.child.stdout?.destroy?.();
419
+ proc.child.stderr?.destroy?.();
420
+ proc.exited(proc.child.exitCode);
421
+ }, 3000);
422
+ proc.killTimer.unref?.();
423
+ };
424
+ const armTurnTimer = (delay = IDLE_TIMEOUT_MS) => {
425
+ clearTurnTimer();
426
+ turnTimer = setTimeout(() => {
427
+ turnTimer = undefined;
428
+ const quiet = Date.now() - activityAt;
429
+ if (quiet < IDLE_TIMEOUT_MS) { armTurnTimer(IDLE_TIMEOUT_MS - quiet); return; }
430
+ hardStop();
431
+ finish("failed", `timed out: no activity for ${duration(IDLE_TIMEOUT_MS)}`);
432
+ }, delay);
433
+ turnTimer.unref?.();
434
+ };
435
+ /** High-frequency stream events repaint at most every LIVE_REPAINT_MS. */
436
+ const repaint = () => {
437
+ const now = Date.now();
438
+ if (now - liveAt >= LIVE_REPAINT_MS) {
439
+ liveAt = now;
440
+ if (liveTimer) { clearTimeout(liveTimer); liveTimer = undefined; }
441
+ update();
442
+ return;
443
+ }
444
+ if (liveTimer) return;
445
+ liveTimer = setTimeout(() => { liveTimer = undefined; liveAt = Date.now(); if (!finished) update(); }, LIVE_REPAINT_MS - (now - liveAt));
446
+ liveTimer.unref?.();
447
+ };
448
+ const resolveSettled = () => {
449
+ for (const waiter of settleWaiters.splice(0)) waiter();
450
+ };
451
+ const waitSettled = () => settled || finished ? Promise.resolve() : new Promise<void>((resolve) => settleWaiters.push(resolve));
452
+ const finish = (state: DelegateState, error?: string) => {
453
+ if (finished) return;
454
+ finished = true;
455
+ clearTurnTimer();
456
+ clearTimeout(liveTimer);
457
+ clearTimeout(hibernateTimer);
458
+ wake = undefined;
459
+ for (const answer of [...queries.values()]) answer(undefined);
460
+ signal?.removeEventListener("abort", abort);
461
+ const stdin = current?.child.stdin;
462
+ if (stdin && !stdin.writableEnded) stdin.end();
463
+ run.state = state;
464
+ if (error) run.error = error;
465
+ run.activity = undefined;
466
+ run.live = "";
467
+ for (const entry of run.transcript) if (entry.kind === "tool" && entry.status === "running") { entry.status = "error"; entry.endedAt = Date.now(); entry.rev++; }
468
+ running.clear();
469
+ run.endedAt = Date.now();
470
+ resolveSettled();
471
+ if (!firstResolved) { firstResolved = true; resolveFirst(); }
361
472
  update();
473
+ // With no process left (spawn failure, or ended while hibernated) nothing else reports the end.
474
+ if (!current) resolveClosed();
475
+ };
476
+ const abort = () => { hardStop(); finish("failed", signal?.reason === STOP_REASON ? "stopped" : "aborted"); };
362
477
 
363
- let buffer = "";
364
- let overflow = false;
365
- let liveAt = 0;
366
- let liveTimer: ReturnType<typeof setTimeout> | undefined;
367
- let stderr = "";
368
- let finished = false;
369
- let settled = false;
370
- let pauseRequested = false;
371
- let closing = false;
372
- let shutdownPromise: Promise<void> | undefined;
373
- let askBusy = false;
374
- let killTimer: ReturnType<typeof setTimeout> | undefined;
375
- let turnTimer: ReturnType<typeof setTimeout> | undefined;
376
- let closeTimer: NodeJS.Timeout | undefined;
377
- /** Last RPC event while working; the watchdog only fires after this much silence. */
378
- let activityAt = Date.now();
379
- const settleWaiters: Array<() => void> = [];
380
-
381
- const send = (command: object): boolean => {
382
- const stdin = child.stdin;
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;
387
- try { stdin.write(JSON.stringify(command) + "\n"); return true; } catch { return false; }
388
- };
389
- const clearTurnTimer = () => { if (turnTimer) clearTimeout(turnTimer); turnTimer = undefined; };
390
- const hardStop = () => {
391
- if (!alive) return;
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);
400
- killTimer.unref?.();
401
- };
402
- const armTurnTimer = (delay = IDLE_TIMEOUT_MS) => {
403
- clearTurnTimer();
404
- turnTimer = setTimeout(() => {
405
- turnTimer = undefined;
406
- const quiet = Date.now() - activityAt;
407
- if (quiet < IDLE_TIMEOUT_MS) { armTurnTimer(IDLE_TIMEOUT_MS - quiet); return; }
408
- hardStop();
409
- finish("failed", `timed out: no activity for ${duration(IDLE_TIMEOUT_MS)}`);
410
- }, delay);
411
- turnTimer.unref?.();
412
- };
413
- /** High-frequency stream events repaint at most every LIVE_REPAINT_MS. */
414
- const repaint = () => {
415
- const now = Date.now();
416
- if (now - liveAt >= LIVE_REPAINT_MS) {
417
- liveAt = now;
418
- if (liveTimer) { clearTimeout(liveTimer); liveTimer = undefined; }
478
+ /** Settled, persistent and running: the process could close without losing the agent. */
479
+ const sleepable = () => persistent && !finished && !closing && settled && !!current?.alive && !current.hibernating
480
+ && (run.state === "idle" || run.state === "paused");
481
+ const armHibernate = () => {
482
+ clearTimeout(hibernateTimer);
483
+ hibernateTimer = undefined;
484
+ const delay = hooks.hibernateAfterMs;
485
+ if (!hooks.resumeArgs || delay === undefined || !Number.isFinite(delay) || !sleepable()) return;
486
+ hibernateTimer = setTimeout(() => { hibernateTimer = undefined; void hibernate(); }, Math.max(0, delay));
487
+ hibernateTimer.unref?.();
488
+ };
489
+ /** The session file Pi reports for this child; undefined when it cannot say in time. */
490
+ const querySessionFile = () => new Promise<string | undefined>((resolve) => {
491
+ const id = `state-${++querySequence}`;
492
+ let timer: NodeJS.Timeout | undefined;
493
+ const answer = (file: string | undefined) => { clearTimeout(timer); queries.delete(id); resolve(file); };
494
+ queries.set(id, answer);
495
+ timer = setTimeout(() => answer(undefined), STATE_QUERY_MS);
496
+ timer.unref?.();
497
+ if (!send({ id, type: "get_state" })) answer(undefined);
498
+ });
499
+ /** Close a settled child's process but keep the agent: its session file is all a relaunch needs. */
500
+ const hibernate = async () => {
501
+ if (!sleepable() || askBusy) return;
502
+ const proc = current!;
503
+ const file = await querySessionFile();
504
+ // A session Pi has not written yet (no assistant message) has nothing to continue, so the child
505
+ // stays up. Anything that touched the agent meanwhile (resume, ask, stop) also wins over sleeping.
506
+ const continued = file && existsSync(file) ? hooks.resumeArgs?.(file) : undefined;
507
+ if (!continued || !sleepable() || askBusy || current !== proc) return;
508
+ relaunchArgs = continued;
509
+ proc.hibernating = true;
510
+ run.activity = "hibernating";
511
+ update();
512
+ // EOF is Pi's orderly shutdown: it disposes the runtime, and session entries are already on disk.
513
+ const stdin = proc.child.stdin;
514
+ if (stdin && !stdin.writableEnded) stdin.end();
515
+ // A child that ignores EOF must not linger: the same escalation as shutdown.
516
+ const killer = setTimeout(() => { if (current === proc) hardStop(); }, 3000);
517
+ killer.unref?.();
518
+ await proc.ended;
519
+ clearTimeout(killer);
520
+ };
521
+ const settleTurn = () => {
522
+ if (settled || finished) return;
523
+ settled = true;
524
+ clearTurnTimer();
525
+ for (const call of running.values()) {
526
+ call.entry.status = "error";
527
+ call.entry.endedAt = Date.now();
528
+ call.entry.rev++;
529
+ }
530
+ running.clear();
531
+ run.live = "";
532
+ run.activity = undefined;
533
+ run.endedAt = Date.now();
534
+ run.state = run.error ? "failed" : pauseRequested ? "paused" : "idle";
535
+ pauseRequested = false;
536
+ if (!firstResolved && !run.error) { firstResolved = true; resolveFirst(); }
537
+ resolveSettled();
538
+ update();
539
+ if (run.error) { hardStop(); finish("failed", run.error); return; }
540
+ const stdin = current?.child.stdin;
541
+ if (!persistent) { if (stdin && !stdin.writableEnded) stdin.end(); }
542
+ else armHibernate();
543
+ };
544
+ const line = (raw: string) => {
545
+ if (finished || !raw.trim()) return;
546
+ let parsed: unknown;
547
+ try { parsed = JSON.parse(raw); } catch { return; }
548
+ if (!parsed || typeof parsed !== "object") return;
549
+ const event = parsed as ChildEvent;
550
+ activityAt = Date.now();
551
+ switch (event.type) {
552
+ case "response": {
553
+ const query = typeof event.id === "string" ? queries.get(event.id) : undefined;
554
+ if (query) { query(event.success !== false && typeof event.data?.sessionFile === "string" ? event.data.sessionFile : undefined); return; }
555
+ // A handled prompt starts no run, so Pi deliberately sends no agent_settled event.
556
+ if (event.success !== false) {
557
+ if (event.command === "prompt" && event.data?.disposition === "handled") settleTurn();
558
+ return;
559
+ }
560
+ if (event.command === "prompt") { hardStop(); finish("failed", cleanText(String(event.error ?? "prompt rejected"), 300)); return; }
561
+ pushEntry(run, { kind: "note", rev: 0, text: `${cleanText(String(event.command ?? "command"), 24)} rejected: ${cleanText(String(event.error ?? ""), 200)}` });
419
562
  update();
420
563
  return;
421
564
  }
422
- if (liveTimer) return;
423
- liveTimer = setTimeout(() => { liveTimer = undefined; liveAt = Date.now(); if (!finished) update(); }, LIVE_REPAINT_MS - (now - liveAt));
424
- liveTimer.unref?.();
425
- };
426
- const resolveSettled = () => {
427
- for (const waiter of settleWaiters.splice(0)) waiter();
428
- };
429
- const waitSettled = () => settled || finished ? Promise.resolve() : new Promise<void>((resolve) => settleWaiters.push(resolve));
430
- const finish = (state: DelegateState, error?: string) => {
431
- if (finished) return;
432
- finished = true;
433
- clearTurnTimer();
434
- clearTimeout(liveTimer);
435
- signal?.removeEventListener("abort", abort);
436
- if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
437
- run.state = state;
438
- if (error) run.error = error;
439
- run.activity = undefined;
440
- run.live = "";
441
- for (const entry of run.transcript) if (entry.kind === "tool" && entry.status === "running") { entry.status = "error"; entry.endedAt = Date.now(); entry.rev++; }
442
- run.endedAt = Date.now();
443
- resolveSettled();
444
- if (!firstResolved) { firstResolved = true; resolveFirst(); }
445
- update();
446
- };
447
- const abort = () => { hardStop(); finish("failed", signal?.reason === STOP_REASON ? "stopped" : "aborted"); };
448
-
449
- child.stdin?.on("error", (error) => { if (!finished) stderr = (stderr + "\n" + error.message).slice(-4000); });
450
- signal?.addEventListener("abort", abort, { once: true });
451
- armTurnTimer();
452
-
453
- const beginPrompt = (message: string, pauseAfter = false): boolean => {
454
- const clean = cleanBlock(message, MAX_RESUME_CHARS);
455
- if (finished || closing || !alive || !settled || !clean) return false;
456
- if (!send({ id: "prompt-" + (run.resumes + 1), type: "prompt", message: clean })) return false;
457
- settled = false;
458
- run.error = undefined;
459
- run.output = "";
460
- pauseRequested = pauseAfter;
461
- run.state = "working";
462
- run.resumes++;
463
- run.endedAt = undefined;
464
- run.activity = "resuming";
465
- activityAt = Date.now();
466
- armTurnTimer();
467
- update();
468
- return true;
469
- };
470
-
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
- };
493
- const line = (raw: string) => {
494
- if (finished || !raw.trim()) return;
495
- let parsed: unknown;
496
- try { parsed = JSON.parse(raw); } catch { return; }
497
- if (!parsed || typeof parsed !== "object") return;
498
- const event = parsed as ChildEvent;
499
- activityAt = Date.now();
500
- switch (event.type) {
501
- case "response":
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
- }
507
- if (event.command === "prompt") { hardStop(); finish("failed", cleanText(String(event.error ?? "prompt rejected"), 300)); return; }
508
- pushEntry(run, { kind: "note", rev: 0, text: `${cleanText(String(event.command ?? "command"), 24)} rejected: ${cleanText(String(event.error ?? ""), 200)}` });
509
- update();
510
- return;
511
- case "extension_ui_request":
512
- if (DIALOGS.has(String(event.method))) send({ type: "extension_ui_response", id: event.id, cancelled: true });
513
- return;
514
- case "message_update": {
515
- const delta = event.assistantMessageEvent;
516
- if (delta?.type === "text_delta" && typeof delta.delta === "string") {
517
- run.live = (run.live + delta.delta).slice(-MAX_LIVE_CHARS);
518
- run.activity = "writing";
519
- repaint();
520
- } else if ((delta?.type === "thinking_start" || delta?.type === "thinking_delta") && run.activity !== "thinking") {
521
- run.activity = "thinking";
522
- update();
523
- }
524
- return;
525
- }
526
- case "tool_execution_start": {
527
- const name = cleanText(String(event.toolName ?? "tool"), 24) || "tool";
528
- const hint = cleanText(toolHint(event.args), 200);
529
- const id = typeof event.toolCallId === "string" && event.toolCallId ? event.toolCallId : `call-${run.tools}`;
530
- const entry: ToolEntry = { kind: "tool", rev: 0, id, name, hint, args: argsText(event.args), status: "running", output: "", startedAt: Date.now() };
531
- const path = toolPath(event.args);
532
- run.tools++;
533
- run.toolCounts[name] = (run.toolCounts[name] ?? 0) + 1;
534
- if (name === "read" && path) addFile(run.filesRead, path);
535
- running.set(id, { entry, ...(path ? { path } : {}) });
536
- pushEntry(run, entry);
537
- run.activity = cleanText(hint ? `${name} ${hint}` : name, 60);
538
- update();
539
- return;
540
- }
541
- case "tool_execution_update": {
542
- const call = typeof event.toolCallId === "string" ? running.get(event.toolCallId) : undefined;
543
- if (!call || !event.partialResult) return;
544
- call.entry.output = cleanBlock(textOf(event.partialResult, MAX_ENTRY_TEXT * 2), MAX_ENTRY_TEXT);
545
- call.entry.rev++;
565
+ case "extension_ui_request":
566
+ if (DIALOGS.has(String(event.method))) send({ type: "extension_ui_response", id: event.id, cancelled: true });
567
+ return;
568
+ case "message_update": {
569
+ const delta = event.assistantMessageEvent;
570
+ if (delta?.type === "text_delta" && typeof delta.delta === "string") {
571
+ run.live = (run.live + delta.delta).slice(-MAX_LIVE_CHARS);
572
+ run.activity = "writing";
546
573
  repaint();
547
- return;
548
- }
549
- case "tool_execution_end": {
550
- const call = typeof event.toolCallId === "string" ? running.get(event.toolCallId) : undefined;
551
- if (call) running.delete(event.toolCallId as string);
552
- const failed = event.isError === true;
553
- const name = call?.entry.name ?? cleanText(String(event.toolName ?? ""), 24);
554
- if (call) {
555
- call.entry.status = failed ? "error" : "done";
556
- call.entry.output = event.result ? cleanBlock(textOf(event.result, MAX_ENTRY_TEXT * 2), MAX_ENTRY_TEXT) : call.entry.output;
557
- call.entry.endedAt = Date.now();
558
- call.entry.rev++;
559
- }
560
- if (name === "jar_todo" && !failed) { const todos = childTodos(event.result?.details); if (todos) run.todos = todos; }
561
- if ((name === "edit" || name === "write") && !failed && call?.path) {
562
- addFile(run.filesEdited, call.path);
563
- try { hooks.edited?.(call.path); } catch (error) { console.error("pi-jar: could not track a subagent edit", error); }
564
- }
574
+ } else if ((delta?.type === "thinking_start" || delta?.type === "thinking_delta") && run.activity !== "thinking") {
565
575
  run.activity = "thinking";
566
576
  update();
567
- return;
568
577
  }
569
- case "message_end": {
570
- if (event.message?.role !== "assistant") return;
571
- run.turns++;
572
- run.cost += Number(event.message.usage?.cost?.total) || 0;
573
- const text = textOf(event.message).trim();
574
- if (text) { run.output = text.slice(0, MAX_OUTPUT_CHARS); pushEntry(run, { kind: "text", rev: 0, text: cleanBlock(text, MAX_ENTRY_TEXT) }); }
575
- if (event.message.stopReason === "error") run.error = cleanText(String(event.message.errorMessage ?? "assistant turn failed"), 200);
576
- if (event.message.stopReason === "aborted" && !pauseRequested && !closing) run.error = "assistant turn aborted";
577
- run.live = "";
578
- run.activity = "thinking";
579
- update();
580
- return;
578
+ return;
579
+ }
580
+ case "tool_execution_start": {
581
+ const name = cleanText(String(event.toolName ?? "tool"), 24) || "tool";
582
+ const hint = cleanText(toolHint(event.args), 200);
583
+ const id = typeof event.toolCallId === "string" && event.toolCallId ? event.toolCallId : `call-${run.tools}`;
584
+ const entry: ToolEntry = { kind: "tool", rev: 0, id, name, hint, args: argsText(event.args), status: "running", output: "", startedAt: Date.now() };
585
+ const path = toolPath(event.args);
586
+ run.tools++;
587
+ run.toolCounts[name] = (run.toolCounts[name] ?? 0) + 1;
588
+ if (name === "read" && path) addFile(run.filesRead, path);
589
+ running.set(id, { entry, ...(path ? { path } : {}) });
590
+ pushEntry(run, entry);
591
+ run.activity = cleanText(hint ? `${name} ${hint}` : name, 60);
592
+ update();
593
+ return;
594
+ }
595
+ case "tool_execution_update": {
596
+ const call = typeof event.toolCallId === "string" ? running.get(event.toolCallId) : undefined;
597
+ if (!call || !event.partialResult) return;
598
+ call.entry.output = cleanBlock(textOf(event.partialResult, MAX_ENTRY_TEXT * 2), MAX_ENTRY_TEXT);
599
+ call.entry.rev++;
600
+ repaint();
601
+ return;
602
+ }
603
+ case "tool_execution_end": {
604
+ const call = typeof event.toolCallId === "string" ? running.get(event.toolCallId) : undefined;
605
+ if (call) running.delete(event.toolCallId as string);
606
+ const failed = event.isError === true;
607
+ const name = call?.entry.name ?? cleanText(String(event.toolName ?? ""), 24);
608
+ if (call) {
609
+ call.entry.status = failed ? "error" : "done";
610
+ call.entry.output = event.result ? cleanBlock(textOf(event.result, MAX_ENTRY_TEXT * 2), MAX_ENTRY_TEXT) : call.entry.output;
611
+ call.entry.endedAt = Date.now();
612
+ call.entry.rev++;
581
613
  }
582
- case "agent_settled":
583
- settleTurn();
584
- return;
614
+ if (name === "jar_todo" && !failed) { const todos = childTodos(event.result?.details); if (todos) run.todos = todos; }
615
+ if ((name === "edit" || name === "write") && !failed && call?.path) {
616
+ addFile(run.filesEdited, call.path);
617
+ try { hooks.edited?.(call.path); } catch (error) { console.error("pi-jar: could not track a subagent edit", error); }
618
+ }
619
+ run.activity = "thinking";
620
+ update();
621
+ return;
585
622
  }
586
- };
587
-
588
- child.stdout?.setEncoding?.("utf8");
589
- child.stdout?.on("data", (chunk) => {
590
- const text = String(chunk);
591
- let start = 0;
592
- for (let end = text.indexOf("\n", start); end >= 0; end = text.indexOf("\n", start)) {
593
- const item = buffer + text.slice(start, end);
594
- if (!overflow && item.length <= MAX_LINE_CHARS) line(item.endsWith("\r") ? item.slice(0, -1) : item);
595
- buffer = "";
596
- overflow = false;
597
- start = end + 1;
623
+ case "message_end": {
624
+ if (event.message?.role !== "assistant") return;
625
+ run.turns++;
626
+ run.cost += Number(event.message.usage?.cost?.total) || 0;
627
+ const text = textOf(event.message).trim();
628
+ if (text) { run.output = text.slice(0, MAX_OUTPUT_CHARS); pushEntry(run, { kind: "text", rev: 0, text: cleanBlock(text, MAX_ENTRY_TEXT) }); }
629
+ if (event.message.stopReason === "error") run.error = cleanText(String(event.message.errorMessage ?? "assistant turn failed"), 200);
630
+ if (event.message.stopReason === "aborted" && !pauseRequested && !closing) run.error = "assistant turn aborted";
631
+ run.live = "";
632
+ run.activity = "thinking";
633
+ update();
634
+ return;
598
635
  }
599
- if (overflow) return;
600
- buffer += text.slice(start);
601
- if (buffer.length > MAX_LINE_CHARS) { buffer = ""; overflow = true; }
602
- });
603
- child.stderr?.on("data", (chunk) => { stderr = (stderr + String(chunk)).slice(-4000); });
636
+ case "agent_settled":
637
+ settleTurn();
638
+ return;
639
+ }
640
+ };
641
+ /** The hibernating process is gone: the agent rests, or relaunches for a prompt accepted meanwhile. */
642
+ const slept = () => {
643
+ const pending = wake;
644
+ wake = undefined;
645
+ if (pending) { relaunch(pending.message, pending.id); return; }
646
+ rest = run.state === "paused" ? "paused" : "idle";
647
+ run.state = "hibernated";
648
+ run.activity = undefined;
649
+ update();
650
+ };
651
+
652
+ /** Spawn one Pi RPC process for this agent and wire its stream; false when spawning failed and the agent ended. */
653
+ const launch = (launchArgs: string[]): boolean => {
654
+ const invocation = piInvocation(launchArgs);
655
+ let child: ChildProcess;
656
+ try {
657
+ const env = { ...process.env };
658
+ for (const key of [CHILD_BASELINE_ENV, CHILD_WORKTREE_ENV, CHILD_TOOLS_ENV, ...DISCUSSION_ENV_KEYS]) delete env[key];
659
+ child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], env: { ...env, ...hooks.env?.(), [CHILD_ENV]: "1" } });
660
+ } catch (error) {
661
+ finish("failed", error instanceof Error ? error.message : String(error));
662
+ return false;
663
+ }
664
+ let buffer = "";
665
+ let overflow = false;
604
666
  let closedOnce = false;
667
+ let closeTimer: NodeJS.Timeout | undefined;
668
+ let ended!: () => void;
605
669
  const exited = (code: number | null) => {
606
670
  if (closedOnce) return;
607
671
  closedOnce = true;
608
- alive = false;
609
- if (killTimer) signalProcessTree(child, "SIGKILL");
610
- clearTimeout(killTimer);
672
+ proc.alive = false;
673
+ if (proc.killTimer) signalProcessTree(child, "SIGKILL");
674
+ clearTimeout(proc.killTimer);
611
675
  clearTimeout(closeTimer);
612
676
  if (buffer && !overflow) { line(buffer); buffer = ""; }
613
- if ((closing || (code === 0 && settled)) && !run.error) finish("done");
614
- else finish("failed", run.error ?? (cleanText(stderr, 300) || (settled ? `exited ${code}` : `exited ${code} before finishing`)));
615
- if (!firstResolved) { firstResolved = true; resolveFirst(); }
616
- resolveClosed();
677
+ for (const answer of [...queries.values()]) answer(undefined);
678
+ if (current === proc) current = undefined;
679
+ if (proc.hibernating && !finished && !closing) slept();
680
+ else {
681
+ if ((closing || (code === 0 && settled)) && !run.error) finish("done");
682
+ else finish("failed", run.error ?? (cleanText(proc.stderr, 300) || (settled ? `exited ${code}` : `exited ${code} before finishing`)));
683
+ resolveClosed();
684
+ }
685
+ ended();
617
686
  };
687
+ const proc: RpcProcess = { child, alive: true, hibernating: false, stderr: "", exited, ended: new Promise<void>((resolve) => { ended = resolve; }) };
688
+ current = proc;
618
689
  // A grandchild that inherited stdout/stderr keeps them open after the child is gone, and 'close'
619
690
  // then never fires; stop waiting after a short drain so stop/shutdown can never hang on it.
620
691
  const drainThenClose = (code: number | null) => {
@@ -627,115 +698,192 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
627
698
  }, CLOSE_GRACE_MS);
628
699
  closeTimer.unref?.();
629
700
  };
701
+ child.stdin?.on("error", (error) => { if (!finished) proc.stderr = (proc.stderr + "\n" + error.message).slice(-4000); });
702
+ child.stdout?.setEncoding?.("utf8");
703
+ child.stdout?.on("data", (chunk) => {
704
+ const text = String(chunk);
705
+ let start = 0;
706
+ for (let end = text.indexOf("\n", start); end >= 0; end = text.indexOf("\n", start)) {
707
+ const item = buffer + text.slice(start, end);
708
+ if (!overflow && item.length <= MAX_LINE_CHARS) line(item.endsWith("\r") ? item.slice(0, -1) : item);
709
+ buffer = "";
710
+ overflow = false;
711
+ start = end + 1;
712
+ }
713
+ if (overflow) return;
714
+ buffer += text.slice(start);
715
+ if (buffer.length > MAX_LINE_CHARS) { buffer = ""; overflow = true; }
716
+ });
717
+ child.stderr?.on("data", (chunk) => { proc.stderr = (proc.stderr + String(chunk)).slice(-4000); });
630
718
  child.on("error", (error) => {
631
719
  hardStop();
632
720
  finish("failed", error.message);
633
721
  // A process that never spawned has no exit event to wait for.
634
- if (child.pid === undefined) { alive = false; drainThenClose(null); }
722
+ if (child.pid === undefined) { proc.alive = false; drainThenClose(null); }
635
723
  });
636
724
  // Exit retires control immediately, but finalization waits for close so stdio drains first.
637
725
  child.once("exit", (code) => {
638
- alive = false;
639
- clearTurnTimer();
640
- if (!finished) {
641
- run.state = (closing || (code === 0 && settled)) && !run.error ? "done" : "failed";
642
- if (run.state === "failed") run.error ??= `exited ${code}`;
643
- run.activity = undefined;
644
- resolveSettled();
645
- update();
726
+ proc.alive = false;
727
+ // A process exiting to hibernate leaves the agent's settled state alone.
728
+ if (!proc.hibernating || finished || closing) {
729
+ clearTurnTimer();
730
+ if (!finished) {
731
+ run.state = (closing || (code === 0 && settled)) && !run.error ? "done" : "failed";
732
+ if (run.state === "failed") run.error ??= `exited ${code}`;
733
+ run.activity = undefined;
734
+ resolveSettled();
735
+ update();
736
+ }
646
737
  }
647
738
  drainThenClose(code);
648
739
  });
649
740
  child.once("close", exited);
741
+ return true;
742
+ };
743
+ /** Continue the hibernated session in a fresh process with the prompt beginPrompt accepted. */
744
+ const relaunch = (message: string, id: string): boolean => {
745
+ if (finished || closing) return false;
746
+ if (!relaunchArgs) { finish("failed", "the hibernated session cannot be resumed"); return false; }
747
+ if (!launch(relaunchArgs)) return false;
748
+ if (signal?.aborted) { abort(); return false; }
749
+ if (send({ id, type: "prompt", message })) return true;
750
+ hardStop();
751
+ finish("failed", "could not send the prompt to the resumed subagent");
752
+ return false;
753
+ };
650
754
 
651
- steer = (text) => {
652
- const message = cleanBlock(text, MAX_STEER_CHARS);
653
- if (finished || closing || !message || run.state !== "working" || !send({ type: "steer", message })) return false;
654
- pushEntry(run, { kind: "steer", rev: 0, text: message });
655
- run.steered++;
656
- update();
755
+ const beginPrompt = (message: string, pauseAfter = false): boolean => {
756
+ const clean = cleanBlock(message, MAX_RESUME_CHARS);
757
+ if (finished || closing || !settled || !clean) return false;
758
+ const id = "prompt-" + (run.resumes + 1);
759
+ const proc = current;
760
+ // Hibernated, or still exiting to hibernate: the prompt relaunches Pi on the same session file.
761
+ const asleep = !proc || proc.hibernating;
762
+ if (asleep ? !relaunchArgs : !send({ id, type: "prompt", message: clean })) return false;
763
+ settled = false;
764
+ run.error = undefined;
765
+ run.output = "";
766
+ pauseRequested = pauseAfter;
767
+ run.state = "working";
768
+ run.resumes++;
769
+ run.endedAt = undefined;
770
+ run.activity = "resuming";
771
+ activityAt = Date.now();
772
+ clearTimeout(hibernateTimer);
773
+ armTurnTimer();
774
+ update();
775
+ if (!asleep) return true;
776
+ if (proc) { wake = { message: clean, id }; return true; }
777
+ return relaunch(clean, id);
778
+ };
779
+ const steer = (text: string): boolean => {
780
+ const message = cleanBlock(text, MAX_STEER_CHARS);
781
+ if (finished || closing || !message || run.state !== "working" || !send({ type: "steer", message })) return false;
782
+ pushEntry(run, { kind: "steer", rev: 0, text: message });
783
+ run.steered++;
784
+ update();
785
+ return true;
786
+ };
787
+ const pause = async (): Promise<boolean> => {
788
+ if (finished || closing) return false;
789
+ // A hibernated agent spends nothing already; remember the pause for its next BTW answer.
790
+ if (run.state === "hibernated") {
791
+ if (rest !== "paused") { rest = "paused"; run.pauses++; update(); }
657
792
  return true;
658
- };
659
- pause = async () => {
660
- if (finished || closing || !alive) return false;
661
- if (run.state === "paused") return true;
662
- if (run.state === "idle") { run.state = "paused"; run.pauses++; update(); return true; }
663
- if (run.state !== "working") return false;
664
- pauseRequested = true;
665
- run.pauses++;
666
- run.activity = "pausing";
667
- const waiting = waitSettled();
668
- send({ type: "clear_queue" });
669
- if (!send({ type: "abort" })) {
670
- pauseRequested = false;
671
- run.pauses = Math.max(0, run.pauses - 1);
672
- run.activity = "thinking";
673
- update();
674
- return false;
675
- }
793
+ }
794
+ if (!current?.alive) return false;
795
+ if (run.state === "paused") return true;
796
+ if (run.state === "idle") { run.state = "paused"; run.pauses++; update(); return true; }
797
+ if (run.state !== "working") return false;
798
+ pauseRequested = true;
799
+ run.pauses++;
800
+ run.activity = "pausing";
801
+ const waiting = waitSettled();
802
+ send({ type: "clear_queue" });
803
+ if (!send({ type: "abort" })) {
804
+ pauseRequested = false;
805
+ run.pauses = Math.max(0, run.pauses - 1);
806
+ run.activity = "thinking";
676
807
  update();
677
- // A child that ignores abort must not hang the moderator; it still pauses when it settles.
678
- if (!await settlesWithin(waiting, PAUSE_GRACE_MS)) return false;
679
- return (run.state as DelegateState) === "paused";
680
- };
681
- resume = (text) => {
682
- if (run.state !== "paused" && run.state !== "idle") return false;
683
- const message = text?.trim() || "Continue the assigned task from where you stopped. Review your current checklist and remaining work first.";
684
- return beginPrompt(message);
685
- };
686
- ask = async (text) => {
687
- const question = cleanBlock(text, MAX_STEER_CHARS);
688
- if (!question || finished || closing || askBusy) return undefined;
689
- askBusy = true;
690
- const wasPaused = run.state === "paused";
691
- try {
692
- // Cross a settled boundary so the active task's report cannot answer the question.
693
- if (run.state === "working" && !await pause()) return undefined;
694
- const instruction = "Moderator BTW: answer only this question in one compact paragraph. Preserve your assigned task scope for the next resume. Question: " + question;
695
- if (!beginPrompt(instruction, wasPaused)) return undefined;
696
- const answered = await settlesWithin(waitSettled(), 2 * 60_000);
697
- if (!answered) { void pause(); return undefined; }
698
- return !finished && !run.error ? run.output || undefined : undefined;
699
- } finally { askBusy = false; }
700
- };
701
- shutdown = () => {
702
- if (shutdownPromise) return shutdownPromise;
703
- closing = true;
704
- shutdownPromise = Promise.resolve().then(async () => {
808
+ return false;
809
+ }
810
+ update();
811
+ // A child that ignores abort must not hang the moderator; it still pauses when it settles.
812
+ if (!await settlesWithin(waiting, PAUSE_GRACE_MS)) return false;
813
+ return (run.state as DelegateState) === "paused";
814
+ };
815
+ const resume = (text?: string): boolean => {
816
+ if (run.state !== "paused" && run.state !== "idle" && run.state !== "hibernated") return false;
817
+ const message = text?.trim() || "Continue the assigned task from where you stopped. Review your current checklist and remaining work first.";
818
+ return beginPrompt(message);
819
+ };
820
+ const ask = async (text: string): Promise<string | undefined> => {
821
+ const question = cleanBlock(text, MAX_STEER_CHARS);
822
+ if (!question || finished || closing || askBusy) return undefined;
823
+ askBusy = true;
824
+ const wasPaused = run.state === "paused" || (run.state === "hibernated" && rest === "paused");
825
+ try {
826
+ // Cross a settled boundary so the active task's report cannot answer the question.
827
+ if (run.state === "working" && !await pause()) return undefined;
828
+ const instruction = "Moderator BTW: answer only this question in one compact paragraph. Preserve your assigned task scope for the next resume. Question: " + question;
829
+ if (!beginPrompt(instruction, wasPaused)) return undefined;
830
+ const answered = await settlesWithin(waitSettled(), 2 * 60_000);
831
+ if (!answered) { void pause(); return undefined; }
832
+ return !finished && !run.error ? run.output || undefined : undefined;
833
+ } finally {
834
+ askBusy = false;
835
+ // The BTW turn settled while askBusy kept the child awake: its quiet period starts now.
836
+ armHibernate();
837
+ }
838
+ };
839
+ const shutdown = (): Promise<void> => {
840
+ if (shutdownPromise) return shutdownPromise;
841
+ closing = true;
842
+ clearTimeout(hibernateTimer);
843
+ shutdownPromise = Promise.resolve().then(async () => {
705
844
  if (finished) { await closed; return; }
706
- if (run.state === "working") {
845
+ const proc = current;
846
+ // Hibernated: there is no process to stop, so the agent simply ends here.
847
+ if (!proc) { finish("done"); return; }
848
+ if (run.state === "working" && !proc.hibernating) {
707
849
  const waiting = waitSettled();
708
850
  send({ type: "clear_queue" });
709
851
  if (send({ type: "abort" })) {
710
852
  if (!await settlesWithin(waiting, 3000) && !finished) hardStop();
711
853
  } else hardStop();
712
854
  }
713
- if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
714
- const killer = setTimeout(() => { if (child.exitCode === null) hardStop(); }, 3000);
855
+ const stdin = proc.child.stdin;
856
+ if (stdin && !stdin.writableEnded) stdin.end();
857
+ const killer = setTimeout(() => { if (proc.child.exitCode === null) hardStop(); }, 3000);
715
858
  killer.unref?.();
716
859
  await closed;
717
860
  clearTimeout(killer);
718
- });
719
- return shutdownPromise;
720
- };
861
+ });
862
+ return shutdownPromise;
863
+ };
721
864
 
722
- if (signal?.aborted) { abort(); return; }
723
- if (!send({ id: "prompt", type: "prompt", message: prompt })) { hardStop(); finish("failed", "could not send the task to the subagent"); }
724
- });
865
+ if (launch(args)) {
866
+ run.state = "working";
867
+ run.startedAt = Date.now();
868
+ run.endedAt = undefined;
869
+ run.activity = "starting";
870
+ update();
871
+ signal?.addEventListener("abort", abort, { once: true });
872
+ armTurnTimer();
873
+ if (signal?.aborted) abort();
874
+ else if (!send({ id: "prompt", type: "prompt", message: prompt })) { hardStop(); finish("failed", "could not send the task to the subagent"); }
875
+ }
725
876
  return {
726
- firstSettled,
727
- closed,
728
- steer: (text) => steer(text),
729
- pause: () => pause(),
730
- resume: (text) => resume(text),
731
- ask: (text) => ask(text),
732
- shutdown: () => shutdown(),
733
- alive: () => alive
877
+ firstSettled, closed, steer, pause, resume, ask, shutdown,
878
+ alive: () => !!current?.alive,
879
+ pid: () => current?.alive ? current.child.pid : undefined
734
880
  };
735
881
  }
736
882
 
737
883
  const isRunning = (state: DelegateState) => state === "queued" || state === "working";
738
- const isRetained = (state: DelegateState) => isRunning(state) || state === "idle" || state === "paused";
884
+ const isRetained = (state: DelegateState) => isRunning(state) || state === "idle" || state === "paused" || state === "hibernated";
885
+ /** A finished worktree whose private changes were not reconciled: kept until stop retries or discard removes it. */
886
+ const isRecovery = (record: SubagentRecord) => !!record.run.workspace && !isRetained(record.run.state) && !record.alive?.();
739
887
 
740
888
  export interface SubagentStopReport {
741
889
  key: string;
@@ -759,10 +907,36 @@ export interface SubagentRecord {
759
907
  steer(text: string): boolean;
760
908
  ask(text: string): Promise<string | undefined>;
761
909
  changed(): string[];
762
- discard(): void;
910
+ /** Retire for good without applying edits; `keepWorkspace` leaves an unresolved recovery worktree on disk. */
911
+ discard(keepWorkspace?: boolean): Promise<void>;
763
912
  alive?(): boolean;
913
+ pid?(): number | undefined;
764
914
  closed?(): Promise<void>;
765
915
  }
916
+ /** What an unresolved worktree recovery keeps: enough to retry reconciliation or discard it, nothing bulky. */
917
+ export interface RecoveryRecord {
918
+ key: string;
919
+ name: string;
920
+ workspace: string;
921
+ changed: string[];
922
+ error?: string;
923
+ startedAt?: number;
924
+ failedAt?: number;
925
+ }
926
+ /** On-demand lifecycle diagnostics; `live` and `pids` describe child Pi processes running right now. */
927
+ export interface SubagentStats { retained: number; live: number; hibernated: number; recovery: number; recoveryLimit: number; pids: number[] }
928
+
929
+ /** Recovery keeps the facts needed to retry or discard; the transcript, stream and read list are released. */
930
+ const releaseForRecovery = (run: DelegateRun) => {
931
+ run.transcript = [{ kind: "note", rev: 0, text: `Recovery workspace kept at ${run.workspace}: stop retries reconciliation; discard removes it without applying.` }];
932
+ run.live = "";
933
+ run.filesRead = [];
934
+ run.output = cleanBlock(run.output, MAX_RECOVERY_OUTPUT);
935
+ };
936
+ /** Symlinked temp roots (macOS /var → /private/var) must not make a child's own directory look foreign. */
937
+ const sameDirectory = (a: string, b: string) => {
938
+ try { return realpathSync(a) === realpathSync(b); } catch { return false; } // a missing directory is nobody's session
939
+ };
766
940
 
767
941
  /**
768
942
  * Live subagents for the activity view. Tool details are saved in the session, so they stay small;
@@ -846,18 +1020,69 @@ export class DelegateRegistry {
846
1020
  for (const { record } of this.entries.values()) if (isRetained(record.run.state) || record.alive?.()) count++;
847
1021
  return count;
848
1022
  }
849
- /** Session changes discard retained workers and their private workspaces; they never auto-apply here. */
850
- clear(): void {
1023
+ /** Unresolved worktree recovery workspaces, oldest first. */
1024
+ recoveries(): RecoveryRecord[] {
1025
+ const recoveries: RecoveryRecord[] = [];
1026
+ for (const { record } of this.entries.values()) {
1027
+ if (!isRecovery(record)) continue;
1028
+ const { key, run } = record;
1029
+ recoveries.push({ key, name: run.name, workspace: run.workspace!, changed: [...run.filesEdited],
1030
+ ...(run.error ? { error: run.error } : {}),
1031
+ ...(run.startedAt !== undefined ? { startedAt: run.startedAt } : {}),
1032
+ ...(run.endedAt !== undefined ? { failedAt: run.endedAt } : {}) });
1033
+ }
1034
+ return recoveries.sort((a, b) => (a.failedAt ?? 0) - (b.failedAt ?? 0));
1035
+ }
1036
+ /** Remove an unresolved recovery workspace without applying it, freeing its recovery slot. */
1037
+ async discard(key: string): Promise<RecoveryRecord | undefined> {
1038
+ const record = this.resolve(key);
1039
+ const recovery = record && this.recoveries().find((item) => item.key === record.key);
1040
+ if (!record || !recovery) return undefined;
1041
+ await record.discard();
1042
+ if (this.get(record.key) === record) this.entries.delete(record.key);
1043
+ this.notify();
1044
+ return recovery;
1045
+ }
1046
+ /** Lifecycle counts computed on demand from memory: no polling, no filesystem access. */
1047
+ stats(): SubagentStats {
1048
+ let live = 0;
1049
+ let hibernated = 0;
1050
+ let recovery = 0;
1051
+ const pids: number[] = [];
1052
+ const visit = (record: SubagentRecord) => {
1053
+ if (!record.alive?.()) return;
1054
+ live++;
1055
+ const pid = record.pid?.();
1056
+ if (pid !== undefined) pids.push(pid);
1057
+ };
1058
+ for (const { record } of this.entries.values()) {
1059
+ visit(record);
1060
+ if (record.run.state === "hibernated") hibernated++;
1061
+ else if (isRecovery(record)) recovery++;
1062
+ }
1063
+ for (const record of this.draining) visit(record);
1064
+ return { retained: this.retained(), live, hibernated, recovery, recoveryLimit: RECOVERY_LIMIT, pids };
1065
+ }
1066
+ /**
1067
+ * Session changes discard retained workers without applying edits. Any worktree that might hold
1068
+ * changes stays on disk and its path is returned, including a worker still running at shutdown.
1069
+ */
1070
+ clear(): string[] {
851
1071
  const records = [...this.entries.values()].map(({ record }) => record);
852
1072
  this.entries.clear();
1073
+ const preserved: string[] = [];
853
1074
  for (const record of records) {
1075
+ const keepWorkspace = !!record.run.workspace;
1076
+ if (keepWorkspace) preserved.push(record.run.workspace!);
854
1077
  if (record.closed && (isRetained(record.run.state) || record.alive?.())) {
855
1078
  this.draining.add(record);
856
1079
  void record.closed().finally(() => { this.draining.delete(record); this.notify(); });
857
1080
  }
858
- try { record.discard(); } catch { /* best effort */ }
1081
+ try { void Promise.resolve(record.discard(keepWorkspace)).catch((error) => console.error("pi-jar: subagent discard failed", error)); }
1082
+ catch (error) { console.error("pi-jar: subagent discard failed", error); }
859
1083
  }
860
1084
  this.notify();
1085
+ return preserved;
861
1086
  }
862
1087
  add(...records: SubagentRecord[]): void {
863
1088
  for (const record of records) this.entries.set(record.key, { record });
@@ -874,13 +1099,19 @@ export class DelegateRegistry {
874
1099
  for (const entry of this.entries.values()) {
875
1100
  if (isRetained(entry.record.run.state) || entry.record.alive?.()) { entry.finished = undefined; continue; }
876
1101
  entry.finished ??= ++this.sequence;
877
- finished++;
1102
+ // Recovery workspaces are bounded by worktree admission, never by this history cap.
1103
+ if (!entry.record.run.workspace) finished++;
878
1104
  }
879
1105
  if (finished <= MAX_FINISHED) return;
880
1106
  // Conflicted/failed workspaces are recovery state, not disposable transcript history.
881
1107
  const oldest = [...this.entries].filter(([, entry]) => entry.finished !== undefined && !entry.record.run.workspace)
882
1108
  .sort((a, b) => a[1].finished! - b[1].finished!);
883
- for (const [key] of oldest.slice(0, finished - MAX_FINISHED)) this.entries.delete(key);
1109
+ for (const [key, { record }] of oldest.slice(0, finished - MAX_FINISHED)) {
1110
+ this.entries.delete(key);
1111
+ // A sibling still registered keeps the whole batch closure alive; free what only views read.
1112
+ record.run.transcript = [];
1113
+ record.run.live = "";
1114
+ }
884
1115
  }
885
1116
  }
886
1117
 
@@ -964,8 +1195,14 @@ export interface DelegateOptions {
964
1195
  changes?: () => ChangeTracker | undefined;
965
1196
  /** Called after a subagent edit was added to the tracker. */
966
1197
  changed?: () => void;
967
- /** Session-scoped shared discussion paper. */
968
- discussionFile?: () => string | undefined;
1198
+ /** Discussion broker environment for one child spawn; idempotent per key and read again on every relaunch. */
1199
+ discussionEnv?: (key: string, name: string) => Record<string, string> | undefined;
1200
+ /** The agent was permanently stopped or discarded: release its discussion identity. */
1201
+ discussionRetire?: (key: string) => void;
1202
+ /** Unread discussion messages waiting for an agent, shown in the moderator's fleet line. */
1203
+ discussionUnread?: (key: string) => number;
1204
+ /** Settled quiet time before a retained child's process hibernates; defaults to HIBERNATE_AFTER_MS. */
1205
+ hibernateAfterMs?: number;
969
1206
  }
970
1207
 
971
1208
  const roleForMode = (mode: DelegateExecutionMode): string =>
@@ -976,9 +1213,15 @@ const remainingTasks = (run: DelegateRun): string[] => {
976
1213
  return leaves.filter((todo) => todo.status !== "completed").map((todo) => todo.title).slice(0, 20);
977
1214
  };
978
1215
 
1216
+ const taskLabel = (task: string): string => {
1217
+ const text = cleanText(task, 4096);
1218
+ return text.length > 160 ? text.slice(0, 159) + "…" : text;
1219
+ };
1220
+
979
1221
  const controlReport = (report: SubagentStopReport): string => [
980
1222
  `${report.key} · ${report.name} · ${report.state}`,
981
- `task: ${report.task}`,
1223
+ // The moderator already holds the full task in its own jar_delegate call; echo only a label.
1224
+ `task: ${taskLabel(report.task)}`,
982
1225
  `workspace: ${report.workspace ?? "none"}`,
983
1226
  `changed: ${report.changed.length ? report.changed.join(", ") : "none"}`,
984
1227
  `applied: ${report.applied.length ? report.applied.join(", ") : "none"}`,
@@ -995,17 +1238,41 @@ const peekRecord = (record: SubagentRecord): string => {
995
1238
  const current = open.find((todo) => todo.status === "in_progress");
996
1239
  const next = open.filter((todo) => todo !== current).slice(0, 3).map((todo) => cleanText(todo.title, 80));
997
1240
  const more = open.length - (current ? 1 : 0) - next.length;
1241
+ const recovery = isRecovery(record);
998
1242
  return [
999
- `${record.key} · ${run.name} · ${run.state}`,
1243
+ `${record.key} · ${run.name} · ${run.state}${recovery ? " · unresolved recovery" : ""}`,
1000
1244
  `progress: ${leaves.length ? `${leaves.length - open.length}/${leaves.length} tasks` : "no checklist"}`
1001
1245
  + (current ? ` · now: ${cleanText(current.title, 80)}` : "") + (run.activity ? ` · ${cleanText(run.activity, 60)}` : ""),
1002
- next.length ? `next: ${next.join("; ")}${more > 0 ? ` (+${more} more)` : ""}` : ""
1246
+ next.length ? `next: ${next.join("; ")}${more > 0 ? ` (+${more} more)` : ""}` : "",
1247
+ ...(recovery ? [
1248
+ `workspace: ${run.workspace}`,
1249
+ `changed: ${run.filesEdited.length ? fileList(run.filesEdited, 20) : "none recorded"}`,
1250
+ run.error ? `error: ${cleanText(run.error, 300)}` : "",
1251
+ "resolve: stop retries reconciliation into /diff; discard removes the workspace without applying it"
1252
+ ] : [])
1003
1253
  ].filter(Boolean).join("\n");
1004
1254
  };
1005
1255
  /** The wake-up after a resumed turn: its progress plus the bounded report the turn produced. */
1006
1256
  const settledRecord = (record: SubagentRecord): string =>
1007
1257
  peekRecord(record) + (record.run.output ? `\nlast: ${cleanBlock(record.run.output, 1200)}` : "");
1008
1258
 
1259
+ /**
1260
+ * Every active worktree agent may still end up needing recovery, so admission reserves a recovery slot for
1261
+ * it: unresolved recovery workspaces never exceed RECOVERY_LIMIT, and none is ever deleted to make room.
1262
+ */
1263
+ function worktreeAdmission(registry: DelegateRegistry, requested: number): string | undefined {
1264
+ const recoveries = registry.recoveries();
1265
+ const active = registry.records().filter((record) => record.run.mode === "worktree" && (isRetained(record.run.state) || record.alive?.())).length;
1266
+ if (recoveries.length + active + requested <= RECOVERY_LIMIT) return undefined;
1267
+ return [
1268
+ `Worktree recovery limit: ${plural(recoveries.length, "unresolved recovery workspace")} and ${plural(active, "active worktree agent")} leave no room for ${requested} more (limit ${RECOVERY_LIMIT}).`,
1269
+ recoveries.length ? "Resolve recovery records with jar_subagent stop (retry reconciliation) or discard (remove without applying):"
1270
+ : "Stop finished worktree agents first so their changes reconcile.",
1271
+ ...recoveries.map((item) => `- ${item.key} · ${item.name} · ${item.workspace}`
1272
+ + (item.changed.length ? ` · changed: ${fileList(item.changed, 5)}` : "") + (item.error ? ` · ${cleanText(item.error, 160)}` : ""))
1273
+ ].join("\n");
1274
+ }
1275
+
1009
1276
  /** jar_delegate plus jar_subagent: the parent remains a moderator while retained workers do the work. */
1010
1277
  export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, registry: DelegateRegistry, options: DelegateOptions = {}): void {
1011
1278
  if (process.env[CHILD_ENV] || typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool !== "function") return;
@@ -1022,18 +1289,32 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1022
1289
  const resolved = roles.resolve(role);
1023
1290
  return resolved ? [resolved] : [];
1024
1291
  };
1292
+ const discussionEnv = (key: string, name: string): Record<string, string> => {
1293
+ try { return options.discussionEnv?.(key, name) ?? {}; }
1294
+ catch (error) { console.error("pi-jar: discussion broker unavailable for a subagent", error); return {}; }
1295
+ };
1296
+ const discussionUnread = (key: string): number => {
1297
+ try { return Math.max(0, Math.floor(options.discussionUnread?.(key) ?? 0)) || 0; }
1298
+ catch { return 0; } // the count is decoration; the fleet line must still render
1299
+ };
1300
+ const retireDiscussion = (key: string) => {
1301
+ try { options.discussionRetire?.(key); } catch (error) { console.error("pi-jar: could not retire a subagent's discussion identity", error); }
1302
+ };
1025
1303
 
1026
1304
  pi.on?.("before_agent_start", (_event, ctx) => {
1027
1305
  controllerContext = ctx;
1028
1306
  const retained = registry.records().filter((record) => isRetained(record.run.state));
1029
1307
  if (!retained.length) return;
1030
- const fleet = retained.map((record) =>
1031
- `- ${record.key} · ${record.run.name} · ${record.run.state} · ${record.run.role}: ${cleanText(record.run.task, 140)}`).join("\n");
1308
+ const fleet = retained.map((record) => {
1309
+ const unread = discussionUnread(record.key);
1310
+ return `- ${record.key} · ${record.run.name} · ${record.run.state}${unread ? ` · ${plural(unread, "unread discussion message")}` : ""} · ${record.run.role}: ${cleanText(record.run.task, 140)}`;
1311
+ }).join("\n");
1032
1312
  return { message: { customType: "pi-jar.moderator-context", display: false, content: [
1033
1313
  "[PI-JAR MODERATOR MODE]",
1034
1314
  "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.",
1035
1315
  "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.",
1036
1316
  "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.",
1317
+ "Hibernated agents are idle with their process closed to save memory; resume and ask relaunch them with context and workspace intact.",
1037
1318
  "Retained fleet:",
1038
1319
  fleet
1039
1320
  ].join("\n") } };
@@ -1097,14 +1378,14 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1097
1378
  const delegateTool: ToolDefinition<typeof Parameters> = {
1098
1379
  name: DELEGATE_TOOL,
1099
1380
  label: "delegate",
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.`,
1381
+ description: `Start retained session-scoped subagents within the configured retained 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. Settled agents hibernate (process closed, context kept) until resumed. Worktree changes apply only when the moderator stops that agent; at most ${RECOVERY_LIMIT} worktree workspaces may be active or awaiting recovery at once.`,
1101
1382
  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.",
1102
1383
  promptGuidelines: [
1103
1384
  "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.",
1104
1385
  "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.",
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.",
1107
- "Respect the configured pool limit, including idle and paused agents. Stop agents you no longer need."
1386
+ "Retained agents become idle after each turn and hibernate after a quiet period; resume and ask relaunch them with their context. 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.",
1387
+ "Use jar_subagent ask for a brief BTW question to one worker. For agent-to-agent questions, have them use jar_discuss so answers stay structured and cheap.",
1388
+ "Respect the configured pool limit, including idle, paused and hibernated agents. Stop agents you no longer need; resolve failed worktree recovery records (stop to retry, discard to drop) before starting more worktree agents."
1108
1389
  ],
1109
1390
  parameters: Parameters,
1110
1391
  async execute(_id, params, signal, onUpdate, ctx) {
@@ -1148,6 +1429,9 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1148
1429
  }
1149
1430
  }
1150
1431
  }
1432
+ const worktreeTasks = requested.filter((item) => (item.mode ?? params.mode ?? (params.write === true ? "direct" : "scout")) === "worktree").length;
1433
+ const recoveryBlock = worktreeTasks ? worktreeAdmission(registry, worktreeTasks) : undefined;
1434
+ if (recoveryBlock) return { content: [{ type: "text", text: recoveryBlock }], details: { runs: [], write: false }, isError: true };
1151
1435
  const maximum = maxSubagents();
1152
1436
  const releaseReservation = registry.reserve(requested.length, maximum);
1153
1437
  if (!releaseReservation) {
@@ -1205,7 +1489,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1205
1489
  if (!ctx.hasUI) return;
1206
1490
  for (const { key, run } of records) {
1207
1491
  const state = run.state === "done" || run.state === "failed" || run.state === "stopped" ? ""
1208
- : run.state === "queued" || run.state === "paused" ? "waiting" : run.state === "idle" ? "idle" : "working";
1492
+ : run.state === "queued" || run.state === "paused" ? "waiting" : run.state === "idle" || run.state === "hibernated" ? "idle" : "working";
1209
1493
  if (published.get(key) === state) continue;
1210
1494
  published.set(key, state);
1211
1495
  try {
@@ -1243,7 +1527,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1243
1527
  };
1244
1528
  const failedHandle = (): DelegateHandle => ({
1245
1529
  firstSettled: Promise.resolve(), closed: Promise.resolve(), steer: () => false, pause: async () => false,
1246
- resume: () => false, ask: async () => undefined, shutdown: async () => {}, alive: () => false
1530
+ resume: () => false, ask: async () => undefined, shutdown: async () => {}, alive: () => false, pid: () => undefined
1247
1531
  });
1248
1532
  const cleanupPrivate = async (index: number, discardWorktree: boolean) => {
1249
1533
  const dir = sessionDirs.get(index);
@@ -1263,7 +1547,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1263
1547
  update();
1264
1548
  };
1265
1549
 
1266
- let discarded = false;
1550
+ /** Runs discarded by a session change or the discard action: their edits are never applied. */
1551
+ const discarded = new Set<number>();
1552
+ /** Discarded recovery runs whose unresolved workspace stays on disk for the user. */
1553
+ const preserved = new Set<number>();
1554
+ const dropsWorktree = (index: number) => discarded.has(index) && !preserved.has(index);
1267
1555
  const markStartStopped = (run: DelegateRun) => {
1268
1556
  run.state = "stopped";
1269
1557
  run.error = undefined;
@@ -1275,33 +1563,30 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1275
1563
  const readyResolvers: Array<() => void> = [];
1276
1564
  const ready = runs.map(() => new Promise<void>((resolve) => readyResolvers.push(resolve)));
1277
1565
  const startRuns = async () => Promise.all(runs.map(async (run, index) => {
1566
+ const key = `delegate-${id}-${run.index}`;
1278
1567
  try {
1279
- if (discarded) { run.state = "stopped"; readyResolvers[index]!(); return; }
1568
+ if (discarded.has(index)) { run.state = "stopped"; readyResolvers[index]!(); return; }
1280
1569
  const start = async (): Promise<DelegateHandle> => {
1281
1570
  if ((run.mode === "fork" || run.mode === "worktree") && !parentSession) {
1282
1571
  failBeforeStart(run, "fork/worktree mode requires a persisted parent Pi session");
1283
1572
  return failedHandle();
1284
1573
  }
1285
1574
  let cwd = ctx.cwd;
1286
- let fork: DelegateForkOptions | undefined;
1287
- const env: Record<string, string> = {
1288
- [SUBAGENT_KEY_ENV]: `delegate-${id}-${run.index}`,
1289
- [SUBAGENT_NAME_ENV]: run.name
1290
- };
1575
+ let session: DelegateSessionOptions | undefined;
1576
+ const env: Record<string, string> = { [SUBAGENT_KEY_ENV]: key, [SUBAGENT_NAME_ENV]: run.name };
1291
1577
  const effectiveTools = toolsByRun.get(run.index) ?? (run.mode === "worktree" ? WORKTREE_TOOLS : run.mode === "direct" ? undefined : READ_ONLY_TOOLS);
1292
1578
  if (effectiveTools) env[CHILD_TOOLS_ENV] = JSON.stringify(effectiveTools);
1293
- const discussion = options.discussionFile?.();
1294
- if (discussion) env[DISCUSSION_FILE_ENV] = discussion;
1295
1579
  const hooks: DelegateHooks = {};
1296
1580
  const write = run.mode === "direct" || run.mode === "worktree";
1297
1581
 
1298
- if (run.mode === "fork" || run.mode === "worktree") {
1582
+ // Retained modes keep a private session so a settled child can hibernate and continue it later;
1583
+ // legacy direct writers are one-shot and stay ephemeral.
1584
+ if (run.mode !== "direct") {
1299
1585
  try {
1300
- const sessionDir = mkdtempSync(join(tmpdir(), "pi-jar-fork-"));
1586
+ const sessionDir = mkdtempSync(join(tmpdir(), run.mode === "scout" ? "pi-jar-scout-" : "pi-jar-fork-"));
1301
1587
  sessionDirs.set(run.index, sessionDir);
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) } : {}) };
1588
+ session = run.mode === "scout" ? { sessionDir } : { source: parentSession!, sessionDir,
1589
+ tools: toolsByRun.get(run.index) ?? (run.mode === "worktree" ? WORKTREE_TOOLS : READ_ONLY_TOOLS) };
1305
1590
  } catch (error) {
1306
1591
  failBeforeStart(run, error);
1307
1592
  return failedHandle();
@@ -1316,7 +1601,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1316
1601
  env[CHILD_WORKTREE_ENV] = worktree.root;
1317
1602
  } catch (error) {
1318
1603
  await cleanupPrivate(run.index, true);
1319
- if (discarded || stopRequested.has(index)) markStartStopped(run);
1604
+ if (discarded.has(index) || stopRequested.has(index)) markStartStopped(run);
1320
1605
  else failBeforeStart(run, error);
1321
1606
  return failedHandle();
1322
1607
  }
@@ -1324,15 +1609,23 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1324
1609
  env[CHILD_BASELINE_ENV] = baselines;
1325
1610
  hooks.edited = edited;
1326
1611
  }
1327
- hooks.env = env;
1328
- return startDelegate(run, delegateArgs(run.model, thinkingByRun.get(run.index), write, process.argv, fork,
1329
- run.mode === "scout" ? toolsByRun.get(run.index) : undefined),
1330
- delegatePrompt(run.task, write, run.mode), cwd, controllers[index]!.signal, update, options.spawnProcess, hooks, run.mode !== "direct");
1612
+ // Read at every spawn, so a relaunch after hibernation reaches the current discussion broker.
1613
+ hooks.env = () => ({ ...env, ...discussionEnv(key, run.name) });
1614
+ const argsFor = (resume?: string) => delegateArgs(run.model, thinkingByRun.get(run.index), write, process.argv,
1615
+ session && resume ? { ...session, resume } : session, run.mode === "scout" ? toolsByRun.get(run.index) : undefined);
1616
+ if (session) {
1617
+ const own = session.sessionDir;
1618
+ // Only ever continue this child's own private session, never a file it reports elsewhere.
1619
+ hooks.resumeArgs = (file) => sameDirectory(dirname(file), own) ? argsFor(file) : undefined;
1620
+ hooks.hibernateAfterMs = options.hibernateAfterMs ?? HIBERNATE_AFTER_MS;
1621
+ }
1622
+ return startDelegate(run, argsFor(), delegatePrompt(run.task, write, run.mode), cwd, controllers[index]!.signal,
1623
+ update, options.spawnProcess, hooks, run.mode !== "direct");
1331
1624
  };
1332
1625
  let handle: DelegateHandle;
1333
1626
  try { handle = await start(); }
1334
1627
  catch (error) {
1335
- if (discarded || stopRequested.has(index)) markStartStopped(run);
1628
+ if (discarded.has(index) || stopRequested.has(index)) markStartStopped(run);
1336
1629
  else failBeforeStart(run, error);
1337
1630
  handle = failedHandle();
1338
1631
  }
@@ -1340,13 +1633,17 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1340
1633
  started.add(index);
1341
1634
  readyResolvers[index]!();
1342
1635
  void handle.closed.then(async () => {
1343
- await cleanupPrivate(run.index, discarded || run.mode !== "worktree");
1636
+ // The agent ended for good (hibernation never resolves closed): drop its private session and
1637
+ // discussion identity. An unreconciled worktree outlives it as lightweight recovery state.
1638
+ retireDiscussion(key);
1639
+ await cleanupPrivate(run.index, dropsWorktree(index) || run.mode !== "worktree");
1640
+ if (worktrees.has(run.index) && !stopRequested.has(index)) releaseForRecovery(run);
1344
1641
  update();
1345
1642
  }).catch((error) => {
1346
1643
  run.error ??= "subagent cleanup failed: " + cleanText(error instanceof Error ? error.message : String(error), 240);
1347
1644
  update();
1348
1645
  });
1349
- if (discarded) void handle.shutdown().finally(() => cleanupPrivate(run.index, true))
1646
+ if (discarded.has(index)) void handle.shutdown().finally(() => cleanupPrivate(run.index, dropsWorktree(index)))
1350
1647
  .catch(error => console.error("pi-jar: subagent cleanup failed", error));
1351
1648
  } catch (error) {
1352
1649
  failBeforeStart(run, error);
@@ -1375,14 +1672,15 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1375
1672
  const run = runs[index]!;
1376
1673
  const key = `delegate-${id}-${run.index}`;
1377
1674
  const workspace = run.workspace;
1378
- if (run.error?.startsWith("changes not applied:")) run.error = undefined;
1675
+ // Reconciliation failures are retryable once the parent drift or Git problem is resolved.
1676
+ if (run.error?.startsWith("changes not applied:") || run.error?.startsWith("could not inspect isolated changes:")) run.error = undefined;
1379
1677
  await ready[index];
1380
1678
  const handle = handles[index]!;
1381
1679
  await handle.shutdown();
1382
1680
 
1383
1681
  let changed: string[] = [];
1384
1682
  const worktree = worktrees.get(run.index);
1385
- if (worktree && !discarded) {
1683
+ if (worktree && !discarded.has(index)) {
1386
1684
  try { changed = await worktreeChangedFiles(worktree, controllers[index]!.signal); run.filesEdited = changed; }
1387
1685
  catch (error) { run.error = "could not inspect isolated changes: " + cleanText(String(error), 240); }
1388
1686
  if (changed.length && !run.error) {
@@ -1398,16 +1696,17 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1398
1696
  if (error instanceof WorktreeApplyError) run.appliedFiles = error.appliedFiles;
1399
1697
  run.error = "changes not applied: " + cleanText(error instanceof Error ? error.message : String(error), 240);
1400
1698
  }
1401
- } else if (!changed.length) {
1699
+ } else if (!changed.length && !run.error?.startsWith("could not inspect isolated changes:")) {
1402
1700
  try { await disposeDelegateWorktree(worktree); } catch { /* best effort */ }
1403
1701
  worktrees.delete(run.index);
1404
1702
  run.workspace = undefined;
1405
1703
  }
1406
1704
  }
1407
- await cleanupPrivate(run.index, discarded);
1408
- if (discarded) { run.error = undefined; run.workspace = undefined; }
1705
+ await cleanupPrivate(run.index, dropsWorktree(index));
1706
+ if (dropsWorktree(index)) { run.error = undefined; run.workspace = undefined; }
1409
1707
  run.state = run.error ? "failed" : "stopped";
1410
1708
  run.endedAt ??= Date.now();
1709
+ if (worktrees.has(run.index)) releaseForRecovery(run);
1411
1710
  update();
1412
1711
  return {
1413
1712
  key, name: run.name, state: run.state, task: run.task, ...(workspace ? { workspace } : {}),
@@ -1420,26 +1719,28 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1420
1719
  stop: () => stopRun(index),
1421
1720
  pause: async () => {
1422
1721
  await ready[index];
1423
- return discarded || stopRequested.has(index) ? false : handles[index]!.pause();
1722
+ return discarded.has(index) || stopRequested.has(index) ? false : handles[index]!.pause();
1424
1723
  },
1425
- resume: (text) => !discarded && !stopRequested.has(index) && handles[index]!.resume(text),
1426
- steer: (text) => !discarded && !stopRequested.has(index) && handles[index]!.steer(text),
1724
+ resume: (text) => !discarded.has(index) && !stopRequested.has(index) && handles[index]!.resume(text),
1725
+ steer: (text) => !discarded.has(index) && !stopRequested.has(index) && handles[index]!.steer(text),
1427
1726
  ask: async (text) => {
1428
1727
  await ready[index];
1429
- return discarded || stopRequested.has(index) ? undefined : handles[index]!.ask(text);
1728
+ return discarded.has(index) || stopRequested.has(index) ? undefined : handles[index]!.ask(text);
1430
1729
  },
1431
1730
  alive: () => handles[index]!.alive(),
1731
+ pid: () => handles[index]!.pid(),
1432
1732
  closed: () => ready[index]!.then(() => handles[index]!.closed),
1433
1733
  changed: () => {
1434
1734
  const worktree = worktrees.get(run.index);
1435
1735
  if (!worktree) return [...run.filesEdited];
1436
1736
  return [...run.filesEdited]; // synchronous activity snapshots never launch subprocesses
1437
1737
  },
1438
- discard: () => {
1439
- discarded = true;
1738
+ discard: (keepWorkspace = false) => {
1739
+ discarded.add(index);
1740
+ if (keepWorkspace) preserved.add(index);
1440
1741
  // Cancellation is synchronous: a concurrent stop must never apply discarded edits.
1441
1742
  controllers[index]!.abort(STOP_REASON);
1442
- void ready[index]!.then(() => handles[index]!.shutdown()).finally(() => cleanupPrivate(run.index, true))
1743
+ return ready[index]!.then(() => handles[index]!.shutdown()).finally(() => cleanupPrivate(run.index, dropsWorktree(index)))
1443
1744
  .catch(error => console.error("pi-jar: subagent discard failed", error));
1444
1745
  }
1445
1746
  }));
@@ -1531,7 +1832,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1531
1832
  if (registry.get(record.key) !== record) { cleanup(); reject(new Error("Scout session was discarded")); return; }
1532
1833
  if (isRunning(record.run.state)) return;
1533
1834
  cleanup();
1534
- if (record.run.error || record.run.state !== "idle") reject(new Error(record.run.error ?? "Scout failed to settle"));
1835
+ if (record.run.error || (record.run.state !== "idle" && record.run.state !== "hibernated")) reject(new Error(record.run.error ?? "Scout failed to settle"));
1535
1836
  else resolve();
1536
1837
  };
1537
1838
  const abort = () => { cleanup(); void registry.stop(record.key); reject(new Error("Scout launch aborted")); };
@@ -1559,7 +1860,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1559
1860
  const abort = () => { cleanup(); void registry.stop(id); reject(new Error("Scout resume aborted")); };
1560
1861
  unsubscribe = registry.subscribe(check);
1561
1862
  signal?.addEventListener("abort", abort, { once: true });
1562
- if (!registry.resume(id, prompt)) { cleanup(); reject(new Error("Scout is not idle or paused")); }
1863
+ if (!registry.resume(id, prompt)) { cleanup(); reject(new Error(record.run.error ?? "Scout is not idle, paused or hibernated")); }
1563
1864
  else check();
1564
1865
  });
1565
1866
  return result;
@@ -1583,18 +1884,18 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1583
1884
  pi.registerTool({
1584
1885
  name: "jar_subagent",
1585
1886
  label: "subagent",
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.",
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.",
1887
+ description: "Non-blocking moderator control for retained subagents. peek, steer, resume and discard return immediately. ask, pause and stop return an accepted operation receipt; the final answer or handoff arrives as a pi-jar.subagent event. Settled agents hibernate (process closed, context and workspace kept); resume and ask relaunch them transparently. Stop completion safely reconciles worktree changes; acceptance does not mean changes are applied yet. discard removes an unresolved worktree recovery workspace without applying it.",
1888
+ promptSnippet: "Use jar_subagent as the moderator control plane. Prefer peek over rereading transcripts; reuse paused/idle/hibernated agents with resume; stop finished workers to reconcile their work.",
1588
1889
  promptGuidelines: [
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.",
1890
+ "peek returns only task progress, plus workspace and changed files for unresolved worktree recovery records. 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
1891
  "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.",
1591
- "Pause when an agent should stop spending tokens but keep its context/workspace. Resume the same agent later instead of spawning a replacement.",
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."
1892
+ "Pause when an agent should stop spending tokens but keep its context/workspace. Resume the same agent later instead of spawning a replacement; a hibernated agent resumes exactly like an idle one.",
1893
+ "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. On a failed/conflicted worktree recovery record, stop retries reconciliation; discard it only when its changes are not wanted."
1593
1894
  ],
1594
1895
  parameters: Type.Object({
1595
1896
  action: Type.Union([
1596
1897
  Type.Literal("peek"), Type.Literal("steer"), Type.Literal("ask"),
1597
- Type.Literal("pause"), Type.Literal("resume"), Type.Literal("stop")
1898
+ Type.Literal("pause"), Type.Literal("resume"), Type.Literal("stop"), Type.Literal("discard")
1598
1899
  ]),
1599
1900
  agent: Type.Optional(Type.String({ description: "Subagent key or unique name. Omit only for peek to show all retained agents." })),
1600
1901
  message: Type.Optional(Type.String({ description: "Direction, BTW question, or resume instruction." }))
@@ -1604,7 +1905,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1604
1905
  const work = (async () => {
1605
1906
  if (params.action === "peek") {
1606
1907
  const targets = params.agent ? [registry.resolve(params.agent)].filter((item): item is SubagentRecord => !!item)
1607
- : registry.records().filter((record) => isRetained(record.run.state));
1908
+ : registry.records().filter((record) => isRetained(record.run.state) || isRecovery(record));
1608
1909
  if (!targets.length) return { content: [{ type: "text", text: params.agent ? "Subagent not found: " + params.agent : "No retained subagents." }] };
1609
1910
  return { content: [{ type: "text", text: targets.map(peekRecord).join("\n\n") }] };
1610
1911
  }
@@ -1625,6 +1926,15 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1625
1926
  if (!message) return { content: [{ type: "text", text: "steer requires message." }], isError: true };
1626
1927
  return { content: [{ type: "text", text: registry.steer(record.key, message) ? "Steered " + record.run.name + "." : "Subagent is not currently working; use resume instead." }] };
1627
1928
  }
1929
+ if (params.action === "discard") {
1930
+ const removed = await registry.discard(record.key);
1931
+ if (!removed) return { content: [{ type: "text", text: `${record.run.name} is not an unresolved worktree recovery record; discard only removes recovery workspaces (use stop for agents).` }], isError: true };
1932
+ const left = existsSync(removed.workspace);
1933
+ return { content: [{ type: "text", text: left
1934
+ ? `Discarded recovery record ${removed.key}, but could not delete ${removed.workspace}; remove it manually.`
1935
+ : `Discarded ${removed.name}: removed ${removed.workspace} without applying${removed.changed.length ? ` its changes (${fileList(removed.changed, 10)})` : " it"}.` }],
1936
+ ...(left ? { isError: true } : {}) };
1937
+ }
1628
1938
  // The moderator is handling this agent directly now; its settle no longer needs a wake-up.
1629
1939
  if (params.action !== "resume") watched.delete(record.key);
1630
1940
  const controlWork = async () => {
@@ -1645,7 +1955,8 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
1645
1955
  if (params.action === "resume") {
1646
1956
  const resumed = registry.resume(record.key, params.message);
1647
1957
  if (resumed) watched.set(record.key, record);
1648
- return { content: [{ type: "text", text: resumed ? "Resumed " + record.run.name + ". You will be woken when its turn ends; do not poll peek." : record.run.name + " is not idle or paused." }] };
1958
+ return { content: [{ type: "text", text: resumed ? "Resumed " + record.run.name + ". You will be woken when its turn ends; do not poll peek."
1959
+ : record.run.state === "failed" && record.run.error ? `Could not resume ${record.run.name}: ${record.run.error}` : record.run.name + " is not idle, paused or hibernated." }] };
1649
1960
  }
1650
1961
  const report = await registry.stop(record.key);
1651
1962
  if (!report) return { content: [{ type: "text", text: "Subagent is already retired: " + record.run.name }] };