@ferris1225/pi-subagents 0.31.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/monitor.ts CHANGED
@@ -2,38 +2,28 @@
2
2
  * Sub-agent monitor: a module-level singleton store that tracks subagent runs
3
3
  * for the current turn.
4
4
  *
5
- * The store notifies subscribers on every mutation so the persistent widget
6
- * above the editor can re-render. Each run carries timing information
7
- * (started/ended) plus a concise activity string describing what the run is
8
- * doing right now ("thinking", "read src/index.ts", ...). Runs are removed
9
- * as soon as they finish: the tool result is the durable record in the main
10
- * conversation, so a stale "done" row must not linger in the widget.
5
+ * The store notifies wait/status consumers on every mutation. Each run carries
6
+ * timing information plus a concise activity string ("thinking",
7
+ * "read src/index.ts", ...). Runs are removed after publication; tool results
8
+ * and the finished-run registry are the durable user-facing records.
11
9
  */
12
10
 
13
11
  import { stripVTControlCharacters } from "node:util";
14
12
  import type { Theme } from "@earendil-works/pi-coding-agent";
15
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
+ import { visibleWidth } from "@earendil-works/pi-tui";
16
14
  import type { UsageStats } from "./spawn.ts";
15
+ import { redactSensitiveText } from "./trajectory.ts";
16
+ import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
17
17
 
18
18
  // ---------------------------------------------------------------------------
19
19
  // Types
20
20
  // ---------------------------------------------------------------------------
21
21
 
22
- export type RunStatus = "queued" | "running" | "done" | "failed";
22
+ export type RunStatus = "queued" | "running" | "steering" | "interrupting" | "parked" | "done" | "failed";
23
23
 
24
- /** Soft state-awareness signals, complementary to the hard idle-kill: a run may
25
- * be alive (stdout streaming) yet "stuck thinking" (no tool running for a while),
26
- * or simply taking a long time. Both are surfaced as widget annotations so the
27
- * user can tell a healthy busy run from one that needs a nudge. */
28
- export type ActivityState = "needs_attention" | "active_long_running";
29
-
30
- /** A run with no tool running and no activity for this long is "needs attention"
31
- * (the model may be stuck between turns). Below the idle-kill threshold so the
32
- * soft signal always fires before the hard kill. */
33
- export const NEEDS_ATTENTION_AFTER_MS = 60_000;
34
- /** A run whose total elapsed time exceeds this is "long-running": still active
35
- * but worth flagging so the user can decide whether to wait or steer. */
36
- export const ACTIVE_LONG_RUNNING_AFTER_MS = 240_000;
24
+ export function isRunActiveStatus(status: RunStatus): boolean {
25
+ return status === "queued" || status === "running" || status === "steering" || status === "interrupting";
26
+ }
37
27
 
38
28
  export interface RunView {
39
29
  id: number;
@@ -44,8 +34,14 @@ export interface RunView {
44
34
  * are doing, not just their run id. */
45
35
  label?: string;
46
36
  model?: string;
37
+ /** Primary model ref when the run advanced to another candidate in its pool. */
38
+ modelFallbackFrom?: string;
47
39
  /** Effective thinking strength this run was launched with (frontmatter/config/global). */
48
40
  thinking?: string;
41
+ isolation?: IsolationMode;
42
+ integrationStatus?: "pending" | WorktreeFinalizationStatus;
43
+ forkedFromRunId?: number;
44
+ forkChildRunIds?: number[];
49
45
  status: RunStatus;
50
46
  usage: UsageStats;
51
47
  /** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
@@ -66,15 +62,14 @@ export interface RunView {
66
62
  groupId?: string;
67
63
  /** Human-readable role within a chain, e.g. "fix round 1" or "re-review round 1". */
68
64
  relationLabel?: string;
69
- /** Free-form note shown in the widget next to the status label (e.g. "auto-fix chain running"). */
65
+ /** Free-form orchestration note (e.g. "auto-fix chain running"). */
70
66
  annotation?: string;
71
- /** One-line outcome summary of a finished chain run, shown in the widget so
72
- * each auto-fix round reads as what it did: a reviewer reports its verdict
67
+ /** One-line outcome summary of a finished chain run: a reviewer reports its verdict
73
68
  * plus key fragments of what it found ("fail · src/index.ts · render()"), a
74
69
  * worker the fragments of what it changed. Unset for non-chain runs. */
75
70
  summary?: string;
76
- /** True when a finished run is intentionally kept in the widget (e.g. an
77
- * auto-fix chain parent whose chain is still running). beginTurn preserves
71
+ /** True when a finished run remains in monitor state (e.g. an auto-fix
72
+ * parent whose chain is still running). beginTurn preserves
78
73
  * retained runs so they are not swept between turns. */
79
74
  retained?: boolean;
80
75
  }
@@ -83,6 +78,8 @@ export interface RunView {
83
78
  export interface RunChainMeta {
84
79
  groupId?: string;
85
80
  relationLabel?: string;
81
+ isolation?: IsolationMode;
82
+ forkedFromRunId?: number;
86
83
  }
87
84
 
88
85
  // ---------------------------------------------------------------------------
@@ -94,7 +91,7 @@ const TASK_SUMMARY_ELLIPSIS = "…";
94
91
  /** Columns reserved at the END of a truncated summary so the distinguishing
95
92
  * keywords (paths, symbols, ...) survive; the head gets the rest. */
96
93
  const TASK_SUMMARY_TAIL_MAX = 28;
97
- /** Tail share of a non-default maxWidth (narrow widgets keep a usable tail). */
94
+ /** Tail share of a non-default maxWidth (narrow summaries keep a usable tail). */
98
95
  const TASK_SUMMARY_TAIL_SHARE = 0.35;
99
96
  const TASK_SUMMARY_TAIL_MIN = 8;
100
97
  const TASK_SUMMARY_KEY_SEP = " · ";
@@ -292,63 +289,18 @@ export function formatElapsed(run: RunView, now: number = Date.now()): string {
292
289
  return formatDuration(end - run.startedAt);
293
290
  }
294
291
 
295
- /** Strip the provider prefix from a "provider/model-id" reference for compact
296
- * widget display ("anthropic/claude-sonnet-4" → "claude-sonnet-4"). A bare id is
297
- * left unchanged. */
298
- export function compactModelRef(model: string | undefined): string {
299
- if (!model) return "";
300
- const slash = model.lastIndexOf("/");
301
- return slash >= 0 ? model.slice(slash + 1) : model;
302
- }
303
-
304
- /** Human-readable label for a soft activity-state annotation. */
305
- export function activityStateLabel(state: ActivityState): string {
306
- return state === "needs_attention" ? "idle" : "long-running";
307
- }
308
-
309
- /** Derive the soft activity state of a run at render time: needs_attention
310
- * (no tool running, idle past the threshold) takes priority over
311
- * active_long_running (total elapsed past its threshold). Both are suppressed
312
- * for non-running runs. */
313
- export function deriveActivityState(run: RunView, now: number = Date.now()): ActivityState | undefined {
314
- if (run.status !== "running") return undefined;
315
- if (!run.currentTool) {
316
- const since = run.lastActivityAt ?? run.startedAt ?? now;
317
- if (now - since >= NEEDS_ATTENTION_AFTER_MS) return "needs_attention";
318
- }
319
- if (run.startedAt !== undefined && now - run.startedAt >= ACTIVE_LONG_RUNNING_AFTER_MS) {
320
- return "active_long_running";
321
- }
322
- return undefined;
323
- }
324
-
325
- /** Left/right split a widget line so the right side (status, elapsed) is always
326
- * visible and the left side (title) clips on overflow instead of pushing it off.
327
- * `left`/`right` may carry ANSI styling; widths are measured display-column-wise. */
328
- export function rightAlign(left: string, right: string, width: number): string {
329
- const rightWidth = visibleWidth(right);
330
- const leftMax = Math.max(0, width - rightWidth - 1);
331
- const leftClipped = truncateToWidth(left, leftMax);
332
- const gap = Math.max(1, width - visibleWidth(leftClipped) - rightWidth);
333
- return truncateToWidth(`${leftClipped}${" ".repeat(gap)}${right}`, width);
334
- }
335
-
336
- /** Concatenate `left` and `right` (no center padding) and clip the combined line
337
- * to `width`. Unlike {@link rightAlign}, the right side trails the left side
338
- * instead of being pinned to the far edge, so a short header leaves no gap in
339
- * the middle. `right` should carry its own leading separator (e.g. ` \u00b7 `);
340
- * pass an empty string when there is nothing to append. Styled strings are
341
- * measured by display width. */
342
- export function compactLine(left: string, right: string, width: number): string {
343
- return truncateToWidth(`${left}${right}`, width);
344
- }
345
-
346
292
  /** Max length of the argument target inside a formatted activity line. */
347
293
  export const ACTIVITY_TARGET_MAX = 60;
348
294
 
295
+ /** Monitor activity is returned to the parent model and rendered in the terminal,
296
+ * so treat every live string as untrusted before it reaches store state. */
297
+ function sanitizeActivityText(value: string): string {
298
+ return redactSensitiveText(value).replace(/\s+/g, " ").trim();
299
+ }
300
+
349
301
  function shortTarget(value: unknown): string {
350
302
  if (typeof value !== "string") return "";
351
- const oneLine = value.replace(/\s+/g, " ").trim();
303
+ const oneLine = sanitizeActivityText(value);
352
304
  // Slice by code point so emoji / CJK-ext never leave a lone surrogate.
353
305
  const chars = [...oneLine];
354
306
  return chars.length > ACTIVITY_TARGET_MAX ? `${chars.slice(0, ACTIVITY_TARGET_MAX - 1).join("")}…` : oneLine;
@@ -397,7 +349,8 @@ export function formatToolActivity(toolName: string, args: unknown): string {
397
349
  default:
398
350
  target = pick("path", "command", "query", "pattern", "url", "file", "task");
399
351
  }
400
- return target ? `${toolName} ${target}` : toolName;
352
+ const safeToolName = sanitizeActivityText(toolName) || "tool";
353
+ return target ? `${safeToolName} ${target}` : safeToolName;
401
354
  }
402
355
 
403
356
  // ---------------------------------------------------------------------------
@@ -416,7 +369,7 @@ export class MonitorStore {
416
369
  // running) are also preserved — their status is "done" but they must
417
370
  // stay visible until the chain resolves.
418
371
  this.runs = this.runs.filter(
419
- (r) => r.status === "queued" || r.status === "running" || r.retained,
372
+ (r) => isRunActiveStatus(r.status) || r.status === "parked" || r.retained,
420
373
  );
421
374
  this.notify();
422
375
  }
@@ -434,6 +387,8 @@ export class MonitorStore {
434
387
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
435
388
  ...(meta?.groupId ? { groupId: meta.groupId } : {}),
436
389
  ...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
390
+ ...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined } : {}),
391
+ ...(meta?.forkedFromRunId !== undefined ? { forkedFromRunId: meta.forkedFromRunId } : {}),
437
392
  });
438
393
  this.notify();
439
394
  return id;
@@ -443,13 +398,13 @@ export class MonitorStore {
443
398
  const run = this.find(id);
444
399
  if (!run) return;
445
400
  run.status = status;
446
- if (status === "running") {
401
+ if (status === "running" || status === "steering" || status === "interrupting") {
447
402
  if (run.startedAt === undefined) run.startedAt = Date.now();
448
- // A model-fallback retry after a failed attempt restarts the clock; a
403
+ // A model-fallback retry or resumed generation restarts the clock; a
449
404
  // stale endedAt would freeze the elapsed display at the first attempt.
450
405
  if (run.endedAt !== undefined) run.endedAt = undefined;
451
406
  run.lastActivityAt = Date.now();
452
- } else if ((status === "done" || status === "failed") && run.endedAt === undefined) {
407
+ } else if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
453
408
  run.endedAt = Date.now();
454
409
  }
455
410
  this.notify();
@@ -463,11 +418,20 @@ export class MonitorStore {
463
418
  this.notify();
464
419
  }
465
420
 
421
+ /** Record the final actual model and primary-to-backup transition. */
422
+ setModel(id: number, model?: string, fallbackFrom?: string): void {
423
+ const run = this.find(id);
424
+ if (!run) return;
425
+ if (model) run.model = model;
426
+ run.modelFallbackFrom = fallbackFrom;
427
+ this.notify();
428
+ }
429
+
466
430
  /** Update the run's current one-line activity (what it is doing now). */
467
431
  setActivity(id: number, text: string): void {
468
432
  const run = this.find(id);
469
433
  if (!run) return;
470
- run.activity = text;
434
+ run.activity = sanitizeActivityText(text) || undefined;
471
435
  run.lastActivityAt = Date.now();
472
436
  this.notify();
473
437
  }
@@ -478,9 +442,10 @@ export class MonitorStore {
478
442
  recordToolStart(id: number, toolName: string, activity: string): void {
479
443
  const run = this.find(id);
480
444
  if (!run) return;
445
+ const safeToolName = sanitizeActivityText(toolName) || "tool";
481
446
  run.toolCount = (run.toolCount ?? 0) + 1;
482
- run.currentTool = toolName;
483
- run.activity = activity;
447
+ run.currentTool = safeToolName;
448
+ run.activity = sanitizeActivityText(activity) || safeToolName;
484
449
  run.lastActivityAt = Date.now();
485
450
  this.notify();
486
451
  }
@@ -492,11 +457,11 @@ export class MonitorStore {
492
457
  if (!run) return;
493
458
  run.currentTool = undefined;
494
459
  run.lastActivityAt = Date.now();
495
- if (isError) run.activity = `✗ ${toolName} failed`;
460
+ if (isError) run.activity = `✗ ${sanitizeActivityText(toolName) || "tool"} failed`;
496
461
  this.notify();
497
462
  }
498
463
 
499
- /** Set a widget note on the run (e.g. that its auto-fix chain is still running). */
464
+ /** Set an orchestration note on the run (e.g. auto-fix chain running). */
500
465
  setAnnotation(id: number, text: string): void {
501
466
  const run = this.find(id);
502
467
  if (!run) return;
@@ -512,7 +477,7 @@ export class MonitorStore {
512
477
  this.notify();
513
478
  }
514
479
 
515
- /** Mark a run as retained (kept in the widget despite being finished). */
480
+ /** Keep a finished chain step in status state until its group settles. */
516
481
  setRetained(id: number, retained: boolean): void {
517
482
  const run = this.find(id);
518
483
  if (!run) return;
@@ -520,6 +485,73 @@ export class MonitorStore {
520
485
  this.notify();
521
486
  }
522
487
 
488
+ setIsolation(id: number, isolation: IsolationMode, integrationStatus?: "pending" | WorktreeFinalizationStatus): void {
489
+ const run = this.find(id);
490
+ if (!run) return;
491
+ run.isolation = isolation;
492
+ run.integrationStatus = integrationStatus;
493
+ this.notify();
494
+ }
495
+
496
+ setForkRelation(sourceRunId: number, childRunId: number): void {
497
+ const source = this.find(sourceRunId);
498
+ if (source) {
499
+ source.forkChildRunIds ??= [];
500
+ if (!source.forkChildRunIds.includes(childRunId)) source.forkChildRunIds.push(childRunId);
501
+ }
502
+ const child = this.find(childRunId);
503
+ if (child) child.forkedFromRunId = sourceRunId;
504
+ this.notify();
505
+ }
506
+
507
+ /** Update the objective shown for a queued retarget or resumed generation. */
508
+ setTask(id: number, task: string): void {
509
+ const run = this.find(id);
510
+ if (!run) return;
511
+ run.task = task;
512
+ run.label = runLabel(task);
513
+ this.notify();
514
+ }
515
+
516
+ /** Reuse a stable logical run id for a resumed generation. */
517
+ restartRun(id: number, agent: string, task: string, model?: string, thinking?: string, isolation?: IsolationMode): void {
518
+ const run = this.find(id);
519
+ if (!run) {
520
+ this.runs.push({
521
+ id,
522
+ agent,
523
+ task,
524
+ label: runLabel(task),
525
+ model,
526
+ thinking,
527
+ ...(isolation ? { isolation, integrationStatus: isolation === "worktree" ? "pending" as const : undefined } : {}),
528
+ status: "queued",
529
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
530
+ });
531
+ this.notify();
532
+ return;
533
+ }
534
+ run.agent = agent;
535
+ run.task = task;
536
+ run.label = runLabel(task);
537
+ run.model = model;
538
+ run.thinking = thinking;
539
+ if (isolation) run.isolation = isolation;
540
+ run.integrationStatus = isolation === "worktree" ? "pending" : undefined;
541
+ run.status = "queued";
542
+ run.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
543
+ run.activity = undefined;
544
+ run.toolCount = undefined;
545
+ run.currentTool = undefined;
546
+ run.lastActivityAt = undefined;
547
+ run.startedAt = undefined;
548
+ run.endedAt = undefined;
549
+ run.annotation = undefined;
550
+ run.summary = undefined;
551
+ run.retained = undefined;
552
+ this.notify();
553
+ }
554
+
523
555
  /** Look up a run by id without removing it. */
524
556
  findRun(id: number): RunView | undefined {
525
557
  return this.find(id);
@@ -533,7 +565,7 @@ export class MonitorStore {
533
565
  this.notify();
534
566
  }
535
567
 
536
- /** Remove a run (finished runs leave the widget). Returns the removed run. */
568
+ /** Remove a run after publication. Returns the removed run. */
537
569
  removeRun(id: number): RunView | undefined {
538
570
  const index = this.runs.findIndex((r) => r.id === id);
539
571
  if (index === -1) return undefined;
@@ -560,6 +592,7 @@ export class MonitorStore {
560
592
  if (run.summary) parts.push(run.summary);
561
593
  if (run.model) parts.push(run.model);
562
594
  if (run.thinking) parts.push(`thinking ${run.thinking}`);
595
+ if (run.isolation === "worktree") parts.push(`worktree ${run.integrationStatus ?? "active"}`);
563
596
  if (usage) parts.push(usage);
564
597
  const elapsed = formatElapsed(run);
565
598
  if (elapsed) parts.push(elapsed);
@@ -591,6 +624,12 @@ export function statusIcon(status: RunStatus, theme: Theme): string {
591
624
  switch (status) {
592
625
  case "running":
593
626
  return theme.fg("accent", "●");
627
+ case "steering":
628
+ return theme.fg("accent", "◆");
629
+ case "interrupting":
630
+ return theme.fg("warning", "◐");
631
+ case "parked":
632
+ return theme.fg("dim", "■");
594
633
  case "done":
595
634
  return theme.fg("success", "✓");
596
635
  case "failed":
@@ -600,13 +639,19 @@ export function statusIcon(status: RunStatus, theme: Theme): string {
600
639
  }
601
640
  }
602
641
 
603
- /** User-facing status label shown in the widget. */
642
+ /** User-facing status label used by tool/status rendering. */
604
643
  export function statusLabel(status: RunStatus): string {
605
644
  switch (status) {
606
645
  case "queued":
607
646
  return "ready";
608
647
  case "running":
609
648
  return "running";
649
+ case "steering":
650
+ return "steering";
651
+ case "interrupting":
652
+ return "interrupting";
653
+ case "parked":
654
+ return "parked";
610
655
  case "done":
611
656
  return "done";
612
657
  case "failed":
@@ -615,10 +660,13 @@ export function statusLabel(status: RunStatus): string {
615
660
  }
616
661
 
617
662
  /** Theme color matching the status label. */
618
- export function statusColor(status: RunStatus): "accent" | "success" | "error" | "dim" {
663
+ export function statusColor(status: RunStatus): "accent" | "success" | "error" | "warning" | "dim" {
619
664
  switch (status) {
620
665
  case "running":
666
+ case "steering":
621
667
  return "accent";
668
+ case "interrupting":
669
+ return "warning";
622
670
  case "done":
623
671
  return "success";
624
672
  case "failed":
package/src/prompt.ts CHANGED
@@ -58,8 +58,9 @@ ${routing ? `Routing:\n${routing}\n` : ""}Dispatch discipline:
58
58
  - Delegate only when isolation genuinely pays: a self-contained implementation/fix with its own validation (worker), or a fresh-context review gate (reviewer).
59
59
  - When in doubt, start with a direct tool call in the main context; escalate to \`explore\` as soon as the search turns broad or crosses multiple files.
60
60
  - For an already-known or trivial target, use a direct search/read tool (e.g. grep/find/read) — do not over-delegate a one-line lookup.
61
- ${hasMultiple ? "- Run INDEPENDENT tasks in parallel: one subagent call with a `tasks` array, and track them with your todo list. Let the automatically resumed main agent launch dependent work only after its prerequisite result arrives (e.g. explore, then worker, then reviewer).\n" : ""}- Brief each sub-agent as self-contained: goal, exact paths, constraints, expected output. It has NO memory of this conversation.
62
- - Treat delegated agents as leaf workers: do not ask a sub-agent to dispatch another sub-agent; child processes do not have this tool.
61
+ ${hasMultiple ? "- Run INDEPENDENT tasks in parallel: one subagent call with a `tasks` array, and track them with your todo list. Parallel worker items default to detached Git worktree isolation; pass `isolation: \"shared\"` only when a worker intentionally needs the caller's live uncommitted tree. Let the automatically resumed main agent launch dependent work only after its prerequisite result arrives (e.g. explore, then worker, then reviewer).\n" : ""}- Single dispatch stays in the shared working tree by default. Use \`isolation: "worktree"\` only for worker/write-capable agents in a Git repository; never request it for explore/reviewer, and never silently retry shared after setup fails.
62
+ - Brief each sub-agent as self-contained: goal, exact paths, constraints, expected output. It has NO memory of this conversation.
63
+ - Treat delegated agents as leaf workers: do not ask a sub-agent to dispatch another sub-agent; child processes do not have this tool. Use \`subagent_control fork\` on a parked/settled retained thread when you need an independent continuation with preserved context and a new run id.
63
64
  - Trust but verify: a sub-agent's summary describes intent, not outcome. Check the actual changes/results before reporting work done.
64
65
 
65
66
  Vision tasks:
@@ -0,0 +1,145 @@
1
+ /** Durable handoff for worktree integration/cleanup failures across sessions. */
2
+
3
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
+ import { existsSync } from "node:fs";
5
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+ import { stripVTControlCharacters } from "node:util";
8
+ import type { WorktreeFinalization } from "./worktree.ts";
9
+
10
+ export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
11
+ const RECOVERY_MANIFEST_VERSION = 1;
12
+
13
+ export interface RecoveryRecord {
14
+ runId: number;
15
+ createdAt: number;
16
+ integrated: boolean;
17
+ worktreePath?: string;
18
+ patchPath?: string;
19
+ error?: string;
20
+ }
21
+
22
+ interface RecoveryManifest {
23
+ version: number;
24
+ records: RecoveryRecord[];
25
+ }
26
+
27
+ export function getRecoveryManifestPath(configPath: string): string {
28
+ return join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
29
+ }
30
+
31
+ function normalizeRecord(value: unknown): RecoveryRecord | undefined {
32
+ if (!value || typeof value !== "object") return undefined;
33
+ const raw = value as Record<string, unknown>;
34
+ if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
35
+ if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
36
+ return {
37
+ runId: raw.runId,
38
+ createdAt: raw.createdAt,
39
+ integrated: raw.integrated === true,
40
+ ...(typeof raw.worktreePath === "string" && raw.worktreePath ? { worktreePath: raw.worktreePath } : {}),
41
+ ...(typeof raw.patchPath === "string" && raw.patchPath ? { patchPath: raw.patchPath } : {}),
42
+ ...(typeof raw.error === "string" && raw.error ? { error: raw.error } : {}),
43
+ };
44
+ }
45
+
46
+ export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
47
+ try {
48
+ const parsed = JSON.parse(await readFile(getRecoveryManifestPath(configPath), "utf8")) as {
49
+ records?: unknown;
50
+ };
51
+ if (!Array.isArray(parsed.records)) return [];
52
+ return parsed.records.flatMap((record) => {
53
+ const normalized = normalizeRecord(record);
54
+ return normalized ? [normalized] : [];
55
+ });
56
+ } catch {
57
+ return [];
58
+ }
59
+ }
60
+
61
+ function recoveryKey(record: RecoveryRecord): string {
62
+ return `${record.runId}\0${record.worktreePath ?? ""}\0${record.patchPath ?? ""}\0${record.error ?? ""}`;
63
+ }
64
+
65
+ async function writeManifest(path: string, records: readonly RecoveryRecord[]): Promise<void> {
66
+ if (records.length === 0) {
67
+ await rm(path, { force: true });
68
+ return;
69
+ }
70
+ await mkdir(dirname(path), { recursive: true });
71
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
72
+ try {
73
+ const manifest: RecoveryManifest = {
74
+ version: RECOVERY_MANIFEST_VERSION,
75
+ records: [...records],
76
+ };
77
+ await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
78
+ await rename(temporaryPath, path);
79
+ } finally {
80
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
81
+ }
82
+ }
83
+
84
+ /** Merge retained artifacts into the durable manifest. */
85
+ export async function persistRecoveryRecords(
86
+ configPath: string,
87
+ records: readonly RecoveryRecord[],
88
+ ): Promise<void> {
89
+ if (records.length === 0) return;
90
+ const path = getRecoveryManifestPath(configPath);
91
+ await withFileMutationQueue(path, async () => {
92
+ const merged = new Map<string, RecoveryRecord>();
93
+ for (const record of await readRecoveryRecords(configPath)) merged.set(recoveryKey(record), record);
94
+ for (const record of records) merged.set(recoveryKey(record), record);
95
+ await writeManifest(path, [...merged.values()]);
96
+ });
97
+ }
98
+
99
+ export function recoveryRecordFromFinalization(
100
+ runId: number,
101
+ finalization: WorktreeFinalization,
102
+ now = Date.now(),
103
+ ): RecoveryRecord {
104
+ return {
105
+ runId,
106
+ createdAt: now,
107
+ integrated: finalization.integrated,
108
+ ...(finalization.worktreePath ? { worktreePath: finalization.worktreePath } : {}),
109
+ ...(finalization.patchPath ? { patchPath: finalization.patchPath } : {}),
110
+ ...(finalization.error ? { error: finalization.error } : {}),
111
+ };
112
+ }
113
+
114
+ /** Show retained recovery paths on every later session start until the user
115
+ * removes the artifacts. Stale records are pruned automatically. */
116
+ export async function announceRecoveryRecords(
117
+ configPath: string,
118
+ ctx: {
119
+ hasUI?: boolean;
120
+ ui: { notify(message: string, kind: "info" | "warning" | "error"): void };
121
+ },
122
+ ): Promise<void> {
123
+ if (ctx.hasUI === false) return;
124
+ const records = await readRecoveryRecords(configPath);
125
+ if (records.length === 0) return;
126
+ const live = records.filter((record) =>
127
+ (record.worktreePath ? existsSync(record.worktreePath) : false) ||
128
+ (record.patchPath ? existsSync(record.patchPath) : false),
129
+ );
130
+ if (live.length !== records.length) {
131
+ const path = getRecoveryManifestPath(configPath);
132
+ await withFileMutationQueue(path, () => writeManifest(path, live)).catch(() => undefined);
133
+ }
134
+ for (const record of live) {
135
+ const paths = [
136
+ record.worktreePath ? `worktree ${stripVTControlCharacters(record.worktreePath)}` : undefined,
137
+ record.patchPath ? `patch ${stripVTControlCharacters(record.patchPath)}` : undefined,
138
+ ].filter(Boolean).join(" · ");
139
+ const reason = record.error ? ` · ${stripVTControlCharacters(record.error)}` : "";
140
+ ctx.ui.notify(
141
+ `pi-subagents recovery for run #${record.runId}: ${record.integrated ? "changes were applied but cleanup failed" : "integration failed"}${paths ? ` · retained ${paths}` : ""}${reason}`,
142
+ "error",
143
+ );
144
+ }
145
+ }