@ferris1225/pi-subagents 4.1.23 → 4.1.24
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/README.md +14 -2
- package/package.json +55 -55
- package/src/config.ts +50 -2
- package/src/monitor.ts +11 -0
- package/src/recovery.ts +145 -145
- package/src/rpc-run.ts +993 -993
- package/src/setup.ts +12 -15
- package/src/widget.ts +57 -61
package/README.md
CHANGED
|
@@ -236,7 +236,9 @@ chain) renders as a tree: the parent line carries the workflow-wide token/cost
|
|
|
236
236
|
totals and total elapsed, and every stage gets its own `├`/`└`-connected row
|
|
237
237
|
with its own model, token flow, and elapsed — settled stages keep the
|
|
238
238
|
telemetry frozen at settlement, the live stage shows its child's model and
|
|
239
|
-
current activity:
|
|
239
|
+
current activity. A live run renders two lines: what it is — agent, task,
|
|
240
|
+
token flow, cost, provider/model, elapsed — and, dim under the label column,
|
|
241
|
+
what it is doing right now:
|
|
240
242
|
|
|
241
243
|
```text
|
|
242
244
|
● pi subagent Implement the login redirect fix · openai/gpt-5/max · 12m06s
|
|
@@ -245,7 +247,8 @@ current activity:
|
|
|
245
247
|
├ ! review · ↑0.9k ↓6.0k R38.0k W0.9k $0.3300 · openai/gpt-5 · 1m12s
|
|
246
248
|
├ ● review fix — edit src/auth.ts · ↑0.2k ↓3.0k R12.0k $0.1200 · openai/gpt-5/medium · 41s
|
|
247
249
|
└ ○ re-review
|
|
248
|
-
● #15 explorer src/models.ts
|
|
250
|
+
● #15 explorer src/models.ts · ↑1.2k ↓8.4k R31.0k W1.1k $0.0900 · openai/gpt-5-mini · 3m07s
|
|
251
|
+
↳ grep fallback
|
|
249
252
|
○ #23 worker src/config.ts · repo lane
|
|
250
253
|
○ #24 worker ↻ tests/config.test.ts · queued · 5m02s
|
|
251
254
|
```
|
|
@@ -305,6 +308,7 @@ strength per agent. Everything else is config-file only, stored at
|
|
|
305
308
|
```json
|
|
306
309
|
{
|
|
307
310
|
"enabledAgents": ["explorer", "worker", "cleaner", "documenter", "synthesizer", "reviewer"],
|
|
311
|
+
"knownAgents": ["explorer", "worker", "cleaner", "documenter", "synthesizer", "reviewer"],
|
|
308
312
|
"agentModels": { "explorer": "anthropic/claude-haiku-4-5" },
|
|
309
313
|
"agentThinkingLevels": { "reviewer": "high" },
|
|
310
314
|
"notifyOnReviewPass": false,
|
|
@@ -317,6 +321,7 @@ strength per agent. Everything else is config-file only, stored at
|
|
|
317
321
|
| Field | Meaning |
|
|
318
322
|
| --------------------- | --------------------------------------------------------------------------------- |
|
|
319
323
|
| `enabledAgents` | Agents available for discovery and delegation. `[]` disables all. |
|
|
324
|
+
| `knownAgents` | Built-ins this config has seen; automatic bookkeeping — never edit it. |
|
|
320
325
|
| `agentModels` | Optional `provider/model-id` per agent; missing = current main model. |
|
|
321
326
|
| `agentThinkingLevels` | Optional manual level per agent; missing = Auto. |
|
|
322
327
|
| `notifyOnReviewPass` | Deliver a standalone passing gate without waking the main agent. Default `false`. |
|
|
@@ -331,6 +336,13 @@ start, model overrides pi no longer reports are removed with a one-time notice.
|
|
|
331
336
|
pi's own session compaction fails mid-thread, a notice surfaces the error and the
|
|
332
337
|
automatic retry instead of failing quietly.
|
|
333
338
|
|
|
339
|
+
Agents shipped by a newer package version turn themselves on at the next
|
|
340
|
+
session: a built-in the config has never seen is adopted into `enabledAgents`
|
|
341
|
+
and follows explorer's configured model and thinking level — the fast lane
|
|
342
|
+
these light roles need — while an agent you disabled stays disabled
|
|
343
|
+
(`knownAgents` is what tells the two cases apart). Enabling a role in
|
|
344
|
+
`/subagents-setup` adopts the same explorer route.
|
|
345
|
+
|
|
334
346
|
## Custom agents
|
|
335
347
|
|
|
336
348
|
Built-ins ship with the package. Add or replace them with Markdown files:
|
package/package.json
CHANGED
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "4.1.
|
|
4
|
-
"description": "A managed sub-agent team for pi: specialized roles, pre-commit documentation sync, retained threads, auto-fix chains, model fallback, and Git worktree isolation.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"publishConfig": {
|
|
8
|
-
"access": "public"
|
|
9
|
-
},
|
|
10
|
-
"keywords": [
|
|
11
|
-
"pi-package",
|
|
12
|
-
"pi-extension",
|
|
13
|
-
"subagent",
|
|
14
|
-
"sub-agent",
|
|
15
|
-
"delegation",
|
|
16
|
-
"code-cleanup",
|
|
17
|
-
"dead-code"
|
|
18
|
-
],
|
|
19
|
-
"files": [
|
|
20
|
-
"src",
|
|
21
|
-
"agents",
|
|
22
|
-
"README.md",
|
|
23
|
-
"LICENSE"
|
|
24
|
-
],
|
|
25
|
-
"pi": {
|
|
26
|
-
"extensions": [
|
|
27
|
-
"./src/index.ts"
|
|
28
|
-
]
|
|
29
|
-
},
|
|
30
|
-
"scripts": {
|
|
31
|
-
"check": "tsc --noEmit",
|
|
32
|
-
"test": "vitest run tests",
|
|
33
|
-
"prepack": "npm run check && npm test"
|
|
34
|
-
},
|
|
35
|
-
"peerDependencies": {
|
|
36
|
-
"@earendil-works/pi-agent-core": ">=0.84.4",
|
|
37
|
-
"@earendil-works/pi-ai": ">=0.84.4",
|
|
38
|
-
"@earendil-works/pi-coding-agent": ">=0.84.4",
|
|
39
|
-
"@earendil-works/pi-tui": ">=0.84.4",
|
|
40
|
-
"typebox": "*"
|
|
41
|
-
},
|
|
42
|
-
"devDependencies": {
|
|
43
|
-
"@earendil-works/pi-agent-core": "^0.84.4",
|
|
44
|
-
"@earendil-works/pi-ai": "^0.84.4",
|
|
45
|
-
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
46
|
-
"@earendil-works/pi-tui": "^0.84.4",
|
|
47
|
-
"@types/node": "^22.10.0",
|
|
48
|
-
"typebox": "^1.3.9",
|
|
49
|
-
"typescript": "^5.9.0",
|
|
50
|
-
"vitest": "^4.1.0"
|
|
51
|
-
},
|
|
52
|
-
"engines": {
|
|
53
|
-
"node": ">=22.19.0"
|
|
54
|
-
}
|
|
55
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@ferris1225/pi-subagents",
|
|
3
|
+
"version": "4.1.24",
|
|
4
|
+
"description": "A managed sub-agent team for pi: specialized roles, pre-commit documentation sync, retained threads, auto-fix chains, model fallback, and Git worktree isolation.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"pi-package",
|
|
12
|
+
"pi-extension",
|
|
13
|
+
"subagent",
|
|
14
|
+
"sub-agent",
|
|
15
|
+
"delegation",
|
|
16
|
+
"code-cleanup",
|
|
17
|
+
"dead-code"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"agents",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./src/index.ts"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"check": "tsc --noEmit",
|
|
32
|
+
"test": "vitest run tests",
|
|
33
|
+
"prepack": "npm run check && npm test"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@earendil-works/pi-agent-core": ">=0.84.4",
|
|
37
|
+
"@earendil-works/pi-ai": ">=0.84.4",
|
|
38
|
+
"@earendil-works/pi-coding-agent": ">=0.84.4",
|
|
39
|
+
"@earendil-works/pi-tui": ">=0.84.4",
|
|
40
|
+
"typebox": "*"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@earendil-works/pi-agent-core": "^0.84.4",
|
|
44
|
+
"@earendil-works/pi-ai": "^0.84.4",
|
|
45
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
46
|
+
"@earendil-works/pi-tui": "^0.84.4",
|
|
47
|
+
"@types/node": "^22.10.0",
|
|
48
|
+
"typebox": "^1.3.9",
|
|
49
|
+
"typescript": "^5.9.0",
|
|
50
|
+
"vitest": "^4.1.0"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=22.19.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -47,6 +47,11 @@ export const IDLE_TIMEOUT_SEC_LIMIT = 600;
|
|
|
47
47
|
export interface SubagentsConfig {
|
|
48
48
|
/** Agent names that are discoverable and injected. Fresh-install default: every built-in agent. */
|
|
49
49
|
enabledAgents: string[];
|
|
50
|
+
/** Built-in names this config has already surfaced. A shipped agent outside
|
|
51
|
+
* this set is new in an upgrade: loadConfig enables it instead of leaving it
|
|
52
|
+
* dark behind a stale allow-list. Bookkeeping only — maintained automatically,
|
|
53
|
+
* and it is what keeps an explicit disable from being undone. */
|
|
54
|
+
knownAgents: string[];
|
|
50
55
|
/** Per-agent model override, keyed by agent name, as "provider/model-id". */
|
|
51
56
|
agentModels: Record<string, string>;
|
|
52
57
|
/** Optional per-agent thinking preference. Runtime clamps it to the effective model's supported levels. */
|
|
@@ -74,6 +79,7 @@ export interface SubagentsConfig {
|
|
|
74
79
|
|
|
75
80
|
export const DEFAULT_CONFIG: SubagentsConfig = {
|
|
76
81
|
enabledAgents: [...DEFAULT_ENABLED_AGENTS],
|
|
82
|
+
knownAgents: [...BUILTIN_AGENT_NAMES],
|
|
77
83
|
agentModels: {},
|
|
78
84
|
agentThinkingLevels: {},
|
|
79
85
|
notifyOnReviewPass: false,
|
|
@@ -123,6 +129,20 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
|
|
|
123
129
|
config.enabledAgents = [...new Set(names.map((name) => name.trim()))];
|
|
124
130
|
}
|
|
125
131
|
|
|
132
|
+
// Known-agent bookkeeping starts empty for a parsed record (not the fresh
|
|
133
|
+
// default) so loadConfig can still tell which shipped agents this config
|
|
134
|
+
// has never seen. Every enabled name was necessarily surfaced.
|
|
135
|
+
config.knownAgents = [];
|
|
136
|
+
if (Array.isArray(raw.knownAgents)) {
|
|
137
|
+
const names = raw.knownAgents.filter(
|
|
138
|
+
(name): name is string => typeof name === "string" && name.trim().length > 0,
|
|
139
|
+
);
|
|
140
|
+
config.knownAgents = [...new Set(names.map((name) => name.trim()))];
|
|
141
|
+
}
|
|
142
|
+
for (const name of config.enabledAgents) {
|
|
143
|
+
if (!config.knownAgents.includes(name)) config.knownAgents.push(name);
|
|
144
|
+
}
|
|
145
|
+
|
|
126
146
|
if (isRecord(raw.agentModels)) {
|
|
127
147
|
for (const [rawKey, value] of Object.entries(raw.agentModels)) {
|
|
128
148
|
const key = rawKey.trim();
|
|
@@ -173,11 +193,39 @@ function defaultConfig(): SubagentsConfig {
|
|
|
173
193
|
};
|
|
174
194
|
}
|
|
175
195
|
|
|
196
|
+
/**
|
|
197
|
+
* A shipped agent the config has never recorded is new in this release; the
|
|
198
|
+
* stale allow-list must not keep it dark. Enable it and adopt explorer's
|
|
199
|
+
* configured model and thinking level, so an upgrade surfaces the new role on
|
|
200
|
+
* the fast light-task lane instead of silently spending the main model.
|
|
201
|
+
*/
|
|
202
|
+
function adoptNewBuiltins(config: SubagentsConfig): SubagentsConfig {
|
|
203
|
+
const known = new Set(config.knownAgents);
|
|
204
|
+
const fresh = BUILTIN_AGENT_NAMES.filter((name) => !known.has(name));
|
|
205
|
+
if (fresh.length === 0) return config;
|
|
206
|
+
const agentModels = { ...config.agentModels };
|
|
207
|
+
const agentThinkingLevels = { ...config.agentThinkingLevels };
|
|
208
|
+
for (const name of fresh) {
|
|
209
|
+
if (!agentModels[name] && config.agentModels.explorer) agentModels[name] = config.agentModels.explorer;
|
|
210
|
+
if (!agentThinkingLevels[name] && config.agentThinkingLevels.explorer) {
|
|
211
|
+
agentThinkingLevels[name] = config.agentThinkingLevels.explorer;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
...config,
|
|
216
|
+
enabledAgents: [...config.enabledAgents, ...fresh],
|
|
217
|
+
knownAgents: [...known, ...fresh],
|
|
218
|
+
agentModels,
|
|
219
|
+
agentThinkingLevels,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
176
223
|
/**
|
|
177
224
|
* Load config. A missing file is a normal state and yields the defaults (not an error).
|
|
178
225
|
* A corrupt file also falls back to defaults rather than throwing, so startup never breaks.
|
|
179
226
|
* A file from an older version (missing newer keys or holding extra keys) is
|
|
180
|
-
* normalized and persisted back, so the on-disk config stays current.
|
|
227
|
+
* normalized and persisted back, so the on-disk config stays current. Built-in
|
|
228
|
+
* agents the file has never seen are adopted: enabled with explorer's route.
|
|
181
229
|
*/
|
|
182
230
|
export async function loadConfig(configPath: string = getConfigPath()): Promise<SubagentsConfig> {
|
|
183
231
|
let text: string;
|
|
@@ -195,7 +243,7 @@ export async function loadConfig(configPath: string = getConfigPath()): Promise<
|
|
|
195
243
|
return defaultConfig();
|
|
196
244
|
}
|
|
197
245
|
|
|
198
|
-
const config = normalizeConfig(parsed);
|
|
246
|
+
const config = adoptNewBuiltins(normalizeConfig(parsed));
|
|
199
247
|
|
|
200
248
|
// Schema upgrade: persist the normalized shape when the file gained fields
|
|
201
249
|
// (new version) or dropped invalid ones.
|
package/src/monitor.ts
CHANGED
|
@@ -299,6 +299,17 @@ export function runLabel(task: string): string {
|
|
|
299
299
|
: `${takeGraphemes(chars, RUN_LABEL_MAX - 1)}${TASK_SUMMARY_ELLIPSIS}`;
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
/** Narrow an already-extracted run label to a smaller budget, keeping its
|
|
303
|
+
* tail: runLabel tail-weights path fragments because the filename is the
|
|
304
|
+
* recognisable part, and a second squeeze must not trade that tail away.
|
|
305
|
+
* Grapheme-safe. */
|
|
306
|
+
export function shrinkRunLabel(text: string, maxWidth: number): string {
|
|
307
|
+
if (maxWidth <= 0) return "";
|
|
308
|
+
if (visibleWidth(text) <= maxWidth) return text;
|
|
309
|
+
const chars = [...graphemeSegmenter.segment(text)].map((s) => s.segment);
|
|
310
|
+
return `${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(chars, maxWidth - 1)}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
302
313
|
function formatTokens(count: number): string {
|
|
303
314
|
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
304
315
|
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
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
|
+
}
|