pi-jar 0.1.8 → 0.2.0

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