pi-jar 0.1.8 → 0.1.9

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