@ferris1225/pi-subagents 4.1.23 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/monitor.ts CHANGED
@@ -20,7 +20,6 @@ import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
20
20
 
21
21
  export type RunStatus = "queued" | "running" | "interrupting" | "parked" | "done" | "failed";
22
22
  export type ContinuationKind = "resume-retained" | "resume-appended";
23
- export type WorkflowStageStatus = "done" | "active" | "pending" | "changes" | "failed";
24
23
 
25
24
  /** Why a queued run has produced no output yet. Three genuinely different
26
25
  * situations used to be reported as one "queued": waiting for a free process
@@ -32,19 +31,6 @@ export type WorkflowStageStatus = "done" | "active" | "pending" | "changes" | "f
32
31
  * "queued"; cleared on every transition out of it. */
33
32
  export type RunWaitReason = "process-slot" | "repository-lane" | "starting";
34
33
 
35
- /** Ephemeral projection of one real or currently planned managed stage. It is
36
- * live monitor state only; durable results remain the per-run chain records. */
37
- export interface WorkflowStage {
38
- agent: string;
39
- relation: string;
40
- status: WorkflowStageStatus;
41
- /** Telemetry snapshot frozen when the stage settled (the live child row
42
- * leaves the monitor at that moment); the active stage reads its live child. */
43
- model?: string;
44
- usage?: UsageStats;
45
- elapsedMs?: number;
46
- }
47
-
48
34
  export function isRunActiveStatus(status: RunStatus): boolean {
49
35
  return status === "queued" || status === "running" || status === "interrupting";
50
36
  }
@@ -88,45 +74,19 @@ export interface RunView {
88
74
  endedAt?: number;
89
75
  /** Why this generation reused retained context, shown in the widget/status. */
90
76
  continuationKind?: ContinuationKind;
91
- /** When set, this is an internal managed-workflow step. */
92
- groupId?: string;
93
- /** Human-readable role within a workflow, e.g. "final review" or "final documentation sync". */
94
- relationLabel?: string;
95
- /** Stable owning run whose row represents the whole managed workflow. */
96
- parentRunId?: number;
97
- /** This stable top-level row currently owns a multi-stage managed workflow.
98
- * Its elapsed time is workflow-wide; active child rows own stage telemetry. */
99
- managedWorkflow?: boolean;
100
- /** Live-only stage timeline retained on the parent while completed internal
101
- * child rows leave the monitor. */
102
- workflowStages?: WorkflowStage[];
103
77
  }
104
78
 
105
- /** Optional metadata for documenter/reviewer/fix children of a stable parent run. */
79
+ /** Extra metadata for a run whose row must carry isolation or resume context. */
106
80
  export interface RunChainMeta {
107
- groupId?: string;
108
- relationLabel?: string;
109
- parentRunId?: number;
110
81
  isolation?: IsolationMode;
111
82
  worktreeId?: string;
112
83
  continuationKind?: ContinuationKind;
113
84
  /** Initial wait reason; defaults to "process-slot" (a fresh dispatch enters
114
- * the process queue). Workflow-internal children pass "starting" because
115
- * they spawn immediately and never wait for a slot. */
85
+ * the process queue). Children spawned outside the queue pass "starting"
86
+ * because they never wait for a slot. */
116
87
  waitReason?: RunWaitReason;
117
88
  }
118
89
 
119
- /** Ephemeral activity of the parent pi model while its agent loop runs: the
120
- * live model/thinking ref and a one-line "what is it doing now". Not a run —
121
- * no id, usage, or chain machinery; the view disappears when the loop settles. */
122
- export interface MainActivity {
123
- model?: string;
124
- thinking?: string;
125
- activity?: string;
126
- /** Epoch ms when the current agent loop started. */
127
- activeSince: number;
128
- }
129
-
130
90
  // ---------------------------------------------------------------------------
131
91
  // Formatting helpers
132
92
  // ---------------------------------------------------------------------------
@@ -299,6 +259,17 @@ export function runLabel(task: string): string {
299
259
  : `${takeGraphemes(chars, RUN_LABEL_MAX - 1)}${TASK_SUMMARY_ELLIPSIS}`;
300
260
  }
301
261
 
262
+ /** Narrow an already-extracted run label to a smaller budget, keeping its
263
+ * tail: runLabel tail-weights path fragments because the filename is the
264
+ * recognisable part, and a second squeeze must not trade that tail away.
265
+ * Grapheme-safe. */
266
+ export function shrinkRunLabel(text: string, maxWidth: number): string {
267
+ if (maxWidth <= 0) return "";
268
+ if (visibleWidth(text) <= maxWidth) return text;
269
+ const chars = [...graphemeSegmenter.segment(text)].map((s) => s.segment);
270
+ return `${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(chars, maxWidth - 1)}`;
271
+ }
272
+
302
273
  function formatTokens(count: number): string {
303
274
  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
304
275
  if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
@@ -483,71 +454,6 @@ export class MonitorStore {
483
454
  private runs: RunView[] = [];
484
455
  private nextId = 1;
485
456
  private subscribers = new Set<() => void>();
486
- private mainModel?: string;
487
- private mainThinking?: string;
488
- private mainActivity?: string;
489
- private mainActiveSince?: number;
490
-
491
- // --- parent pi model activity ------------------------------------------
492
- // Fed by the parent session's extension events (agent loop, streaming,
493
- // tool executions); rendered as the widget's first line. Change-guarded so
494
- // per-token streaming deltas do not flood subscribers.
495
-
496
- setMainModel(model?: string): void {
497
- if (!model || this.mainModel === model) return;
498
- this.mainModel = model;
499
- this.notify();
500
- }
501
-
502
- setMainThinking(thinking?: string): void {
503
- if (!thinking || this.mainThinking === thinking) return;
504
- this.mainThinking = thinking;
505
- this.notify();
506
- }
507
-
508
- setMainActivity(text: string): void {
509
- const activity = sanitizeActivityText(text) || undefined;
510
- if (!activity || this.mainActivity === activity) return;
511
- this.mainActivity = activity;
512
- this.notify();
513
- }
514
-
515
- /** Record the main model starting a tool; the activity shows the tool's
516
- * most telling argument, same vocabulary as subagent rows. */
517
- recordMainToolStart(toolName: string, activity: string): void {
518
- const safeToolName = sanitizeActivityText(toolName) || "tool";
519
- this.setMainActivity(activity || safeToolName);
520
- }
521
-
522
- /** Record a failed main-model tool; successful completions keep their last
523
- * activity until the next model event supplies a better description. */
524
- recordMainToolEnd(toolName: string, isError: boolean): void {
525
- if (isError) this.setMainActivity(`✗ ${sanitizeActivityText(toolName) || "tool"} failed`);
526
- }
527
-
528
- /** Track the parent agent loop: started at agent_start, cleared when the
529
- * loop settles (agent_end / agent_settled). */
530
- setMainAgentActive(active: boolean): void {
531
- if ((this.mainActiveSince !== undefined) === active) return;
532
- this.mainActiveSince = active ? Date.now() : undefined;
533
- this.mainActivity = undefined;
534
- this.notify();
535
- }
536
-
537
- /** Live view of the parent model while its agent loop runs; undefined when idle. */
538
- getMainActivity(): MainActivity | undefined {
539
- if (this.mainActiveSince === undefined) return undefined;
540
- return {
541
- ...(this.mainModel ? { model: this.mainModel } : {}),
542
- ...(this.mainThinking ? { thinking: this.mainThinking } : {}),
543
- ...(this.mainActivity ? { activity: this.mainActivity } : {}),
544
- activeSince: this.mainActiveSince,
545
- };
546
- }
547
-
548
- isMainAgentActive(): boolean {
549
- return this.mainActiveSince !== undefined;
550
- }
551
457
 
552
458
  beginTurn(): void {
553
459
  // Clear finished runs from a previous turn, but keep active and parked
@@ -597,9 +503,6 @@ export class MonitorStore {
597
503
  waitReason: meta?.waitReason ?? "process-slot",
598
504
  usage: emptyUsage(),
599
505
  elapsedMs: 0,
600
- ...(meta?.groupId ? { groupId: meta.groupId } : {}),
601
- ...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
602
- ...(meta?.parentRunId !== undefined ? { parentRunId: meta.parentRunId } : {}),
603
506
  ...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined, ...(meta.worktreeId ? { worktreeId: meta.worktreeId } : {}) } : {}),
604
507
  ...(meta?.continuationKind ? { continuationKind: meta.continuationKind } : {}),
605
508
  });
@@ -638,27 +541,6 @@ export class MonitorStore {
638
541
  this.notify();
639
542
  }
640
543
 
641
- /** Switch a stable top-level row from one model run to workflow ownership.
642
- * The original role remains for identity; child rows show stage telemetry. */
643
- setManagedWorkflow(id: number, active: boolean): void {
644
- const run = this.find(id);
645
- if (!run) return;
646
- run.managedWorkflow = active || undefined;
647
- if (!active) run.workflowStages = undefined;
648
- this.notify();
649
- }
650
-
651
- /** Replace the live workflow projection atomically so renderers never observe
652
- * a half-updated fix/re-review plan. */
653
- setWorkflowStages(id: number, stages: readonly WorkflowStage[]): void {
654
- const run = this.find(id);
655
- if (!run) return;
656
- run.workflowStages = stages.length > 0
657
- ? stages.map((stage) => ({ ...stage }))
658
- : undefined;
659
- this.notify();
660
- }
661
-
662
544
  setUsage(id: number, usage: UsageStats, model?: string): void {
663
545
  const run = this.find(id);
664
546
  if (!run) return;
@@ -795,8 +677,6 @@ export class MonitorStore {
795
677
  run.waitReason = "process-slot";
796
678
  run.usage = emptyUsage();
797
679
  run.activity = undefined;
798
- run.managedWorkflow = undefined;
799
- run.workflowStages = undefined;
800
680
  run.activeSince = undefined;
801
681
  run.endedAt = undefined;
802
682
  run.elapsedMs = Math.max(run.elapsedMs, meta?.elapsedMs ?? 0);
@@ -814,10 +694,6 @@ export class MonitorStore {
814
694
  * finishRun calls from the old session remain safe no-ops. */
815
695
  clear(): void {
816
696
  this.runs = [];
817
- this.mainModel = undefined;
818
- this.mainThinking = undefined;
819
- this.mainActivity = undefined;
820
- this.mainActiveSince = undefined;
821
697
  this.notify();
822
698
  }
823
699
 
@@ -843,14 +719,13 @@ export class MonitorStore {
843
719
 
844
720
  summarize(run: RunView): string {
845
721
  const usage = formatUsageCompact(run.usage);
846
- const parts = [run.managedWorkflow ? `${run.agent} workflow` : run.agent];
722
+ const parts = [run.agent];
847
723
  const continuation = continuationLabel(run.continuationKind);
848
724
  if (continuation) parts.push(continuation);
849
- if (run.relationLabel) parts.push(run.relationLabel);
850
- if (!run.managedWorkflow && run.model) parts.push(run.model);
851
- if (!run.managedWorkflow && run.thinking) parts.push(`thinking ${run.thinking}`);
725
+ if (run.model) parts.push(run.model);
726
+ if (run.thinking) parts.push(`thinking ${run.thinking}`);
852
727
  if (run.isolation === "worktree") parts.push(`worktree ${run.integrationStatus ?? "active"}`);
853
- if (!run.managedWorkflow && usage) parts.push(usage);
728
+ if (usage) parts.push(usage);
854
729
  const elapsed = formatElapsed(run);
855
730
  if (elapsed) parts.push(elapsed);
856
731
  return parts.join(" · ");
package/src/prompt.ts CHANGED
@@ -20,38 +20,21 @@ export function buildDelegationDirective(
20
20
 
21
21
  const catalog = agents.map(formatCatalogEntry).join("\n");
22
22
  const hasExplorer = agents.some((agent) => agent.name === "explorer");
23
- const hasWorker = agents.some((agent) => agent.name === "worker");
24
- const hasCleaner = agents.some((agent) => agent.name === "cleaner");
25
- const hasDocumenter = agents.some((agent) => agent.name === "documenter");
26
- const hasReviewer = agents.some((agent) => agent.name === "reviewer");
27
- const hasSynthesizer = agents.some((agent) => agent.name === "synthesizer");
28
- const codeWriterNames = [
29
- ...(hasWorker ? ["worker"] : []),
30
- ...(hasCleaner ? ["cleaner"] : []),
31
- ];
23
+ const hasExecutor = agents.some((agent) => agent.name === "executor");
32
24
 
33
25
  const dispatchRules = [
34
- `Delegate aggressively: child contexts are cheap, yours is scarce. Inline only trivial work — a lookup, a single focused edit, an answer already in context${hasWorker ? "; default every non-trivial implementation, fix, refactor, or test task to `worker`" : ""}.`,
26
+ `Delegate aggressively: child contexts are cheap, yours is scarce. Inline only trivial work — a lookup, a single focused edit, an answer already in context${hasExecutor ? "; default every non-trivial delegated task (implementation, fix, refactor, test, cleanup, docs sync, result merging) to `executor`" : ""}.`,
35
27
  ...(hasExplorer
36
28
  ? [
37
29
  "`explorer`: split a broad question into parallel explorers with disjoint scopes. Its findings are leads, never proof — re-read load-bearing files before acting yourself (a child you brief re-verifies).",
38
30
  ]
39
31
  : []),
40
- ...(hasCleaner
41
- ? ["`cleaner`: dispatch for requested cleanup AND proactively when finished work leaves dead code or duplication. Your brief is its edit authorization — every safe proven cut applies without per-item approval; never a gate. Scope: the uncommitted diff, or whatever your brief names (Git range, directory)."]
42
- : []),
43
- ...(hasDocumenter
44
- ? ["`documenter`: standalone docs/comment work; dispatch it proactively when a change — yours or a child's — leaves README/docs/comment drift no writer already synced; cheap and may make zero edits."]
45
- : []),
46
- ...(hasReviewer
32
+ ...(hasExecutor
47
33
  ? [
48
- `\`reviewer\`: read-only assessments and gates${codeWriterNames.length > 0 ? `; successful ${codeWriterNames.join("/")} runs get one fresh gate, and failing gates are fixed by the reviewer itself in bounded fix/re-review rounds (a still-failing gate returns to you). Pass \`review: "none"\` for mechanical, low-risk edits you verify yourself; keep the default gate whenever behavior can change` : ""}. Advisory output has no VERDICT and cannot authorize edits.`,
34
+ "`executor`: brief it as the edit authorization. For cleanup, name the scope (uncommitted diff, Git range, directory) every safe proven cut applies without per-item approval; finding no safe cut is a valid result. After a wide fan-out, pass the result-artifact paths to one executor and read its merged brief instead of every result yourself.",
49
35
  ]
50
36
  : []),
51
- ...(hasSynthesizer
52
- ? ["`synthesizer`: after a wide fan-out, pass the result-artifact paths to one synthesizer and read its brief instead of every result yourself."]
53
- : []),
54
- `Parallelize by default: map the todo list onto ONE \`tasks\` dispatch. One child owns one deliverable and its files; only genuinely dependent work waits for its prerequisite.`,
37
+ "Parallelize by default: map the todo list onto ONE `tasks` dispatch. One child owns one deliverable and its files; only genuinely dependent work waits for its prerequisite.",
55
38
  "Brief each child completely — goal, exact paths, constraints, expected output; it has no conversation memory and cannot delegate. Resume parked threads with `subagent_control resume`.",
56
39
  ];
57
40
 
@@ -63,13 +46,7 @@ export function buildDelegationDirective(
63
46
 
64
47
  const verificationRules = [
65
48
  "Never report an unrun check as passed; surface unavailable checks and pre-existing failures, and inspect actual changes before reporting completion.",
66
- ...(hasReviewer
67
- ? [
68
- "A REVIEW_FAIL from a gate you dispatched directly returns its findings to you: fix them inline or via a briefed worker without waiting for the user, then re-verify ONCE. If the gate still fails, report the remaining findings and move on — never loop gate dispatches.",
69
- "Multi-model cross-review only when explicitly requested or for high-risk security, FFI, migration, or concurrency changes.",
70
- ]
71
- : []),
72
- "Commit or push only when explicitly requested, applicable checks pass, and no review finding remains unresolved.",
49
+ "Commit or push only when explicitly requested and applicable checks pass.",
73
50
  ];
74
51
 
75
52
  return `
@@ -86,6 +63,6 @@ ${bullets(dispatchRules)}
86
63
  Result handoff:
87
64
  ${bullets(handoffRules)}
88
65
 
89
- Review and verification:
66
+ Verification:
90
67
  ${bullets(verificationRules)}`;
91
68
  }
package/src/recovery.ts CHANGED
@@ -1,145 +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
- }
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
+ }
package/src/runtime.ts CHANGED
@@ -25,7 +25,6 @@ import { isRunActiveStatus, monitor } from "./monitor.ts";
25
25
  import type { RpcRunControl } from "./rpc-run.ts";
26
26
  import type { StartBackgroundInternal } from "./thread-lifecycle.ts";
27
27
  import { isFailedResult, type SingleResult } from "./spawn.ts";
28
- import type { ReviewMode } from "./workflow.ts";
29
28
  import type { IsolationMode, WorktreeFinalization, WorktreeIsolation } from "./worktree.ts";
30
29
 
31
30
  export type ThreadState =
@@ -51,15 +50,12 @@ export interface SubagentThread {
51
50
  executionCwd: string;
52
51
  thinkingLevel?: ThinkingLevel;
53
52
  isolation: IsolationMode;
54
- /** Dispatch-time gate intensity; "none" skips the automatic post-writer
55
- * reviewer for this thread (kept across resumes and reloads). */
56
- review?: ReviewMode;
57
53
  worktree?: WorktreeIsolation;
58
54
  state: ThreadState;
59
55
  control: RpcRunControl;
60
56
  queueController?: AbortController;
61
- /** Resolves only after the current generation's top-level child, downstream
62
- * managed workflow, and queue work have fully quiesced and released their
57
+ /** Resolves only after the current generation's child process, isolation
58
+ * finalization, and queue work have fully quiesced and released their
63
59
  * concurrency slot. */
64
60
  generationCompletion: Promise<void>;
65
61
  /** Synchronous CAS used by lifecycle controls across their async preflight. */
@@ -122,8 +118,7 @@ export interface SubagentRuntime {
122
118
  /** Resume setup that has claimed a thread but has not yet enqueued its
123
119
  * next generation. Shutdown invalidates these claims and waits for cleanup. */
124
120
  preflightOperations: Set<Promise<void>>;
125
- /** Every session directory retained for this parent session, including
126
- * managed-workflow internals that are not directly controllable. */
121
+ /** Every session directory retained for this parent session. */
127
122
  sessionDirs: Set<string>;
128
123
  retainSession: (result: Pick<SingleResult, "sessionDir">) => void;
129
124
  retireThreadSession: (thread: SubagentThread) => void;
@@ -148,8 +143,6 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
148
143
  // Computing this at delivery (emit) time — not when the item was
149
144
  // pushed — reflects the current monitor state, since finishing runs
150
145
  // are removed from the monitor before their completion is pushed.
151
- // Managed-workflow parents remain "running" through reviewer and
152
- // documenter stages, so they are included without a special case.
153
146
  const active = monitor
154
147
  .getRuns()
155
148
  .filter((run) => isRunActiveStatus(run.status))