@ferris1225/pi-subagents 4.0.0 → 4.1.1

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/models.ts CHANGED
@@ -1,189 +1,189 @@
1
- /*
2
- * Model routing, capability-aware thinking, and setup-picker helpers.
3
- *
4
- * Runtime has one explicit fallback only: a configured agent model hands
5
- * off directly to the current main-window model. Setup lists only currently
6
- * available models and derives thinking choices from Pi's model metadata.
7
- */
8
-
9
- import {
10
- clampThinkingLevel,
11
- getSupportedThinkingLevels,
12
- type Api,
13
- type Model,
14
- } from "@earendil-works/pi-ai";
15
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
- import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
17
-
18
- export type ModelContext = Pick<ExtensionContext, "model" | "modelRegistry"> &
19
- Partial<Pick<ExtensionContext, "scopedModels">>;
20
-
21
- export const CURRENT_MAIN_MODEL = "__current_main_model__";
22
-
23
- export interface ModelPickerItem {
24
- value: string;
25
- label: string;
26
- description?: string;
27
- }
28
-
29
- export type ModelListEntry = Pick<
30
- Model<Api>,
31
- "provider" | "id" | "name" | "input" | "reasoning" | "thinkingLevelMap"
32
- >;
33
-
34
- export interface ResolvedAgentModelRoute {
35
- /** Effective first candidate. Undefined means let Pi use its normal default. */
36
- primaryRef?: string;
37
- /** Current main-window model when it differs from the selection. */
38
- mainFallbackRef?: string;
39
- /** Runtime order, useful for status/tests. */
40
- candidateRefs: string[];
41
- /** Configured ref skipped because Pi does not currently report it available. */
42
- unavailableSelectedRef?: string;
43
- }
44
-
45
- export interface AgentModelRouteInput {
46
- selectedRef?: string;
47
- mainRef?: string;
48
- declaredDefaultRef?: string;
49
- /** When supplied, a configured selection outside this live set is skipped. */
50
- availableRefs?: readonly string[];
51
- }
52
-
53
- function cleanModelRef(ref: string | undefined): string | undefined {
54
- const trimmed = ref?.trim();
55
- return trimmed || undefined;
56
- }
57
-
58
- export function modelRef(model: { provider: string; id: string }): string {
59
- return `${model.provider}/${model.id}`;
60
- }
61
-
62
- export function currentModelRef(ctx: Pick<ModelContext, "model">): string | undefined {
63
- return ctx.model ? modelRef(ctx.model) : undefined;
64
- }
65
-
66
- /**
67
- * Current authenticated registry models narrowed by the session scope. Scope
68
- * entries are a session snapshot, so they act only as a whitelist; the live
69
- * registry remains the source of truth for availability and model metadata.
70
- */
71
- export function availableModelsInScope(ctx: ModelContext): readonly Model<Api>[] {
72
- const models = ctx.modelRegistry.getAvailable();
73
- // scopedModels was added after the original Pi minimum. Treat a missing field
74
- // exactly like an empty scope and use the full live registry.
75
- const scopedModels = ctx.scopedModels ?? [];
76
- if (scopedModels.length === 0) return models;
77
- const scopedRefs = new Set(scopedModels.map((entry) => modelRef(entry.model)));
78
- return models.filter((model) => scopedRefs.has(modelRef(model)));
79
- }
80
-
81
- export function findModelByRef(
82
- models: readonly Model<Api>[],
83
- ref: string | undefined,
84
- ): Model<Api> | undefined {
85
- const normalized = cleanModelRef(ref);
86
- return normalized ? models.find((model) => modelRef(model) === normalized) : undefined;
87
- }
88
-
89
- /**
90
- * Resolve one agent's runtime route:
91
- *
92
- * configured selection -> current main-window model
93
- *
94
- * Without an override, current main is primary; the agent-declared default is
95
- * used only when no main model exists. A configured selection that Pi no longer
96
- * reports as available is skipped immediately instead of spawning a doomed child.
97
- */
98
- export function resolveAgentModelRoute(input: AgentModelRouteInput): ResolvedAgentModelRoute {
99
- const selectedRef = cleanModelRef(input.selectedRef);
100
- const mainRef = cleanModelRef(input.mainRef);
101
- const declaredDefaultRef = cleanModelRef(input.declaredDefaultRef);
102
- const available = input.availableRefs
103
- ? new Set(input.availableRefs.map((ref) => ref.trim()).filter(Boolean))
104
- : undefined;
105
- const selectedAvailable = !selectedRef || !available || available.has(selectedRef);
106
- const usableSelectedRef = selectedAvailable ? selectedRef : undefined;
107
- const primaryRef = usableSelectedRef ?? mainRef ?? declaredDefaultRef;
108
- const ordered = [primaryRef, usableSelectedRef && usableSelectedRef !== mainRef ? mainRef : undefined];
109
- const candidateRefs = [...new Set(ordered.filter((ref): ref is string => Boolean(ref)))];
110
- return {
111
- primaryRef,
112
- ...(candidateRefs[1] ? { mainFallbackRef: candidateRefs[1] } : {}),
113
- candidateRefs,
114
- ...(!selectedAvailable && selectedRef ? { unavailableSelectedRef: selectedRef } : {}),
115
- };
116
- }
117
-
118
- /** The exact levels Pi exposes for this model, including `off` when supported. */
119
- export function supportedThinkingLevels(model: Model<Api> | undefined): ThinkingLevel[] {
120
- return model ? (getSupportedThinkingLevels(model) as ThinkingLevel[]) : [];
121
- }
122
-
123
- /** Clamp an agent preference to the effective model's actual capability map. */
124
- export function resolveThinkingLevel(
125
- model: Model<Api> | undefined,
126
- preferred: ThinkingLevel = DEFAULT_THINKING_LEVEL,
127
- ): ThinkingLevel {
128
- return model ? (clampThinkingLevel(model, preferred) as ThinkingLevel) : preferred;
129
- }
130
-
131
- function modelCapabilities(model: ModelListEntry): string {
132
- const input = model.input.includes("image") ? "vision" : "text-only";
133
- const thinking = getSupportedThinkingLevels(model as Model<Api>).join("/");
134
- return `${input} · thinking: ${thinking}`;
135
- }
136
-
137
- /** Build one searchable list for agent model selection. Only models Pi
138
- * currently reports as available are supplied by setup. */
139
- export function buildModelPickerItems(options: {
140
- models: readonly ModelListEntry[];
141
- configuredRef?: string;
142
- mainRef?: string;
143
- }): ModelPickerItem[] {
144
- const configuredRef = cleanModelRef(options.configuredRef);
145
- const mainRef = cleanModelRef(options.mainRef);
146
- const byRef = new Map<string, ModelListEntry>();
147
- for (const model of options.models) {
148
- const ref = modelRef(model);
149
- if (!byRef.has(ref)) byRef.set(ref, model);
150
- }
151
-
152
- const refs = [...byRef.keys()]
153
- .sort((left, right) => {
154
- const leftRank = left === configuredRef ? 0 : left === mainRef ? 1 : 2;
155
- const rightRank = right === configuredRef ? 0 : right === mainRef ? 1 : 2;
156
- return leftRank - rightRank || left.localeCompare(right);
157
- });
158
-
159
- const dynamic: ModelPickerItem = {
160
- value: CURRENT_MAIN_MODEL,
161
- label: "Current main model (dynamic)",
162
- description: "Clear agent override; use the current main model dynamically",
163
- };
164
- const items: ModelPickerItem[] = [dynamic];
165
- for (const ref of refs) {
166
- const model = byRef.get(ref)!;
167
- const tags = [ref === configuredRef ? "configured" : "", ref === mainRef ? "current main" : ""]
168
- .filter(Boolean);
169
- const name = model.name.trim() && model.name !== model.id ? model.name.trim() : undefined;
170
- items.push({
171
- value: ref,
172
- label: ref,
173
- description: [name, modelCapabilities(model), ...tags].filter(Boolean).join(" · "),
174
- });
175
- }
176
- return items;
177
- }
178
-
179
- /** The dynamic choice removes the persisted per-agent override. */
180
- export function applyAgentModelChoice(
181
- current: Record<string, string>,
182
- agentName: string,
183
- choice: string,
184
- ): Record<string, string> {
185
- const next = { ...current };
186
- if (choice === CURRENT_MAIN_MODEL) delete next[agentName];
187
- else next[agentName] = choice.trim();
188
- return next;
189
- }
1
+ /*
2
+ * Model routing, capability-aware thinking, and setup-picker helpers.
3
+ *
4
+ * Runtime has one explicit fallback only: a configured agent model hands
5
+ * off directly to the current main-window model. Setup lists only currently
6
+ * available models and derives thinking choices from Pi's model metadata.
7
+ */
8
+
9
+ import {
10
+ clampThinkingLevel,
11
+ getSupportedThinkingLevels,
12
+ type Api,
13
+ type Model,
14
+ } from "@earendil-works/pi-ai";
15
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
17
+
18
+ export type ModelContext = Pick<ExtensionContext, "model" | "modelRegistry"> &
19
+ Partial<Pick<ExtensionContext, "scopedModels">>;
20
+
21
+ export const CURRENT_MAIN_MODEL = "__current_main_model__";
22
+
23
+ export interface ModelPickerItem {
24
+ value: string;
25
+ label: string;
26
+ description?: string;
27
+ }
28
+
29
+ export type ModelListEntry = Pick<
30
+ Model<Api>,
31
+ "provider" | "id" | "name" | "input" | "reasoning" | "thinkingLevelMap"
32
+ >;
33
+
34
+ export interface ResolvedAgentModelRoute {
35
+ /** Effective first candidate. Undefined means let Pi use its normal default. */
36
+ primaryRef?: string;
37
+ /** Current main-window model when it differs from the selection. */
38
+ mainFallbackRef?: string;
39
+ /** Runtime order, useful for status/tests. */
40
+ candidateRefs: string[];
41
+ /** Configured ref skipped because Pi does not currently report it available. */
42
+ unavailableSelectedRef?: string;
43
+ }
44
+
45
+ export interface AgentModelRouteInput {
46
+ selectedRef?: string;
47
+ mainRef?: string;
48
+ declaredDefaultRef?: string;
49
+ /** When supplied, a configured selection outside this live set is skipped. */
50
+ availableRefs?: readonly string[];
51
+ }
52
+
53
+ function cleanModelRef(ref: string | undefined): string | undefined {
54
+ const trimmed = ref?.trim();
55
+ return trimmed || undefined;
56
+ }
57
+
58
+ export function modelRef(model: { provider: string; id: string }): string {
59
+ return `${model.provider}/${model.id}`;
60
+ }
61
+
62
+ export function currentModelRef(ctx: Pick<ModelContext, "model">): string | undefined {
63
+ return ctx.model ? modelRef(ctx.model) : undefined;
64
+ }
65
+
66
+ /**
67
+ * Current authenticated registry models narrowed by the session scope. Scope
68
+ * entries are a session snapshot, so they act only as a whitelist; the live
69
+ * registry remains the source of truth for availability and model metadata.
70
+ */
71
+ export function availableModelsInScope(ctx: ModelContext): readonly Model<Api>[] {
72
+ const models = ctx.modelRegistry.getAvailable();
73
+ // scopedModels was added after the original Pi minimum. Treat a missing field
74
+ // exactly like an empty scope and use the full live registry.
75
+ const scopedModels = ctx.scopedModels ?? [];
76
+ if (scopedModels.length === 0) return models;
77
+ const scopedRefs = new Set(scopedModels.map((entry) => modelRef(entry.model)));
78
+ return models.filter((model) => scopedRefs.has(modelRef(model)));
79
+ }
80
+
81
+ export function findModelByRef(
82
+ models: readonly Model<Api>[],
83
+ ref: string | undefined,
84
+ ): Model<Api> | undefined {
85
+ const normalized = cleanModelRef(ref);
86
+ return normalized ? models.find((model) => modelRef(model) === normalized) : undefined;
87
+ }
88
+
89
+ /**
90
+ * Resolve one agent's runtime route:
91
+ *
92
+ * configured selection -> current main-window model
93
+ *
94
+ * Without an override, current main is primary; the agent-declared default is
95
+ * used only when no main model exists. A configured selection that Pi no longer
96
+ * reports as available is skipped immediately instead of spawning a doomed child.
97
+ */
98
+ export function resolveAgentModelRoute(input: AgentModelRouteInput): ResolvedAgentModelRoute {
99
+ const selectedRef = cleanModelRef(input.selectedRef);
100
+ const mainRef = cleanModelRef(input.mainRef);
101
+ const declaredDefaultRef = cleanModelRef(input.declaredDefaultRef);
102
+ const available = input.availableRefs
103
+ ? new Set(input.availableRefs.map((ref) => ref.trim()).filter(Boolean))
104
+ : undefined;
105
+ const selectedAvailable = !selectedRef || !available || available.has(selectedRef);
106
+ const usableSelectedRef = selectedAvailable ? selectedRef : undefined;
107
+ const primaryRef = usableSelectedRef ?? mainRef ?? declaredDefaultRef;
108
+ const ordered = [primaryRef, usableSelectedRef && usableSelectedRef !== mainRef ? mainRef : undefined];
109
+ const candidateRefs = [...new Set(ordered.filter((ref): ref is string => Boolean(ref)))];
110
+ return {
111
+ primaryRef,
112
+ ...(candidateRefs[1] ? { mainFallbackRef: candidateRefs[1] } : {}),
113
+ candidateRefs,
114
+ ...(!selectedAvailable && selectedRef ? { unavailableSelectedRef: selectedRef } : {}),
115
+ };
116
+ }
117
+
118
+ /** The exact levels Pi exposes for this model, including `off` when supported. */
119
+ export function supportedThinkingLevels(model: Model<Api> | undefined): ThinkingLevel[] {
120
+ return model ? (getSupportedThinkingLevels(model) as ThinkingLevel[]) : [];
121
+ }
122
+
123
+ /** Clamp an agent preference to the effective model's actual capability map. */
124
+ export function resolveThinkingLevel(
125
+ model: Model<Api> | undefined,
126
+ preferred: ThinkingLevel = DEFAULT_THINKING_LEVEL,
127
+ ): ThinkingLevel {
128
+ return model ? (clampThinkingLevel(model, preferred) as ThinkingLevel) : preferred;
129
+ }
130
+
131
+ function modelCapabilities(model: ModelListEntry): string {
132
+ const input = model.input.includes("image") ? "vision" : "text-only";
133
+ const thinking = getSupportedThinkingLevels(model as Model<Api>).join("/");
134
+ return `${input} · thinking: ${thinking}`;
135
+ }
136
+
137
+ /** Build one searchable list for agent model selection. Only models Pi
138
+ * currently reports as available are supplied by setup. */
139
+ export function buildModelPickerItems(options: {
140
+ models: readonly ModelListEntry[];
141
+ configuredRef?: string;
142
+ mainRef?: string;
143
+ }): ModelPickerItem[] {
144
+ const configuredRef = cleanModelRef(options.configuredRef);
145
+ const mainRef = cleanModelRef(options.mainRef);
146
+ const byRef = new Map<string, ModelListEntry>();
147
+ for (const model of options.models) {
148
+ const ref = modelRef(model);
149
+ if (!byRef.has(ref)) byRef.set(ref, model);
150
+ }
151
+
152
+ const refs = [...byRef.keys()]
153
+ .sort((left, right) => {
154
+ const leftRank = left === configuredRef ? 0 : left === mainRef ? 1 : 2;
155
+ const rightRank = right === configuredRef ? 0 : right === mainRef ? 1 : 2;
156
+ return leftRank - rightRank || left.localeCompare(right);
157
+ });
158
+
159
+ const dynamic: ModelPickerItem = {
160
+ value: CURRENT_MAIN_MODEL,
161
+ label: "Current main model (dynamic)",
162
+ description: "Clear agent override; use the current main model dynamically",
163
+ };
164
+ const items: ModelPickerItem[] = [dynamic];
165
+ for (const ref of refs) {
166
+ const model = byRef.get(ref)!;
167
+ const tags = [ref === configuredRef ? "configured" : "", ref === mainRef ? "current main" : ""]
168
+ .filter(Boolean);
169
+ const name = model.name.trim() && model.name !== model.id ? model.name.trim() : undefined;
170
+ items.push({
171
+ value: ref,
172
+ label: ref,
173
+ description: [name, modelCapabilities(model), ...tags].filter(Boolean).join(" · "),
174
+ });
175
+ }
176
+ return items;
177
+ }
178
+
179
+ /** The dynamic choice removes the persisted per-agent override. */
180
+ export function applyAgentModelChoice(
181
+ current: Record<string, string>,
182
+ agentName: string,
183
+ choice: string,
184
+ ): Record<string, string> {
185
+ const next = { ...current };
186
+ if (choice === CURRENT_MAIN_MODEL) delete next[agentName];
187
+ else next[agentName] = choice.trim();
188
+ return next;
189
+ }
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
+ }