@ferris1225/pi-subagents 4.1.1 → 4.1.3
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 +461 -381
- package/agents/cleaner.md +16 -6
- package/agents/documenter.md +46 -0
- package/agents/explorer.md +15 -11
- package/agents/reviewer.md +8 -4
- package/agents/worker.md +9 -4
- package/package.json +55 -55
- package/src/agents.ts +53 -0
- package/src/announcements.ts +18 -1
- package/src/completion.ts +160 -160
- package/src/config.ts +43 -13
- package/src/dispatch.ts +233 -303
- package/src/fixloop.ts +259 -62
- package/src/index.ts +3 -3
- package/src/models.ts +189 -189
- package/src/monitor.ts +101 -22
- package/src/prompt.ts +47 -12
- package/src/recovery.ts +145 -145
- package/src/rpc-run.ts +29 -10
- package/src/runtime.ts +13 -7
- package/src/session-fork.ts +80 -80
- package/src/setup.ts +162 -130
- package/src/spawn.ts +53 -13
- package/src/thread-lifecycle.ts +240 -54
- package/src/tools.ts +65 -37
- package/src/widget.ts +68 -22
- package/src/worktree.ts +27 -4
package/src/prompt.ts
CHANGED
|
@@ -11,22 +11,42 @@ function bullets(lines: readonly string[]): string {
|
|
|
11
11
|
return lines.map((line) => `- ${line}`).join("\n");
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export function buildDelegationDirective(
|
|
14
|
+
export function buildDelegationDirective(
|
|
15
|
+
agents: AgentConfig[],
|
|
16
|
+
options: { maxFixRounds?: number } = {},
|
|
17
|
+
): string {
|
|
15
18
|
if (agents.length === 0) return "";
|
|
16
19
|
|
|
17
20
|
const catalog = agents.map(formatCatalogEntry).join("\n");
|
|
18
21
|
const hasExplorer = agents.some((agent) => agent.name === "explorer");
|
|
19
22
|
const hasWorker = agents.some((agent) => agent.name === "worker");
|
|
20
23
|
const hasCleaner = agents.some((agent) => agent.name === "cleaner");
|
|
24
|
+
const hasDocumenter = agents.some((agent) => agent.name === "documenter");
|
|
21
25
|
const hasReviewer = agents.some((agent) => agent.name === "reviewer");
|
|
22
26
|
const hasMultiple = agents.length > 1;
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
:
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
const autoFixEnabled = hasWorker && (options.maxFixRounds ?? 1) > 0;
|
|
28
|
+
const codeWriterNames = [
|
|
29
|
+
...(hasWorker ? ["worker"] : []),
|
|
30
|
+
...(hasCleaner ? ["cleaner"] : []),
|
|
31
|
+
];
|
|
32
|
+
const reviewedWriterNames = [
|
|
33
|
+
...codeWriterNames,
|
|
34
|
+
...(hasDocumenter ? ["documenter"] : []),
|
|
35
|
+
];
|
|
36
|
+
const automaticWriterRoute = [
|
|
37
|
+
...(hasDocumenter ? ["documenter"] : []),
|
|
38
|
+
...(hasReviewer ? ["reviewer"] : []),
|
|
39
|
+
].join(" → ");
|
|
40
|
+
const namedWorktreeTargets = [
|
|
41
|
+
...(hasWorker ? ["worker"] : []),
|
|
42
|
+
...(hasCleaner ? ["cleaner"] : []),
|
|
43
|
+
...(hasDocumenter ? ["documenter"] : []),
|
|
44
|
+
];
|
|
45
|
+
const worktreeTargets = namedWorktreeTargets.length === 0
|
|
46
|
+
? "a"
|
|
47
|
+
: namedWorktreeTargets.length === 1
|
|
48
|
+
? `${namedWorktreeTargets[0]} or another`
|
|
49
|
+
: `${namedWorktreeTargets.slice(0, -1).join(", ")}, ${namedWorktreeTargets.at(-1)}, or another`;
|
|
30
50
|
|
|
31
51
|
const dispatchRules = [
|
|
32
52
|
"Handle simple work inline with direct tools: one-line lookups, known-target reads/edits, and quick questions do not justify a child process.",
|
|
@@ -40,12 +60,17 @@ export function buildDelegationDirective(agents: AgentConfig[]): string {
|
|
|
40
60
|
: []),
|
|
41
61
|
...(hasCleaner
|
|
42
62
|
? [
|
|
43
|
-
`Use \`cleaner\` only
|
|
63
|
+
`Use \`cleaner\` only for user-authorized cleanup, removal, simplification, duplicate-code consolidation, or maintenance. Once dispatched, it applies every safe proven in-scope cut without item-by-item approval; zero edits is valid only if none is proved. Generic or read-only audit, review, code-health, plan, or cleanup-candidate assessment goes to ${hasReviewer ? "`reviewer`" : "direct main-context inspection because `reviewer` is disabled"}. Never dispatch cleaner by PR count or as the pre-commit gate.`,
|
|
64
|
+
]
|
|
65
|
+
: []),
|
|
66
|
+
...(hasDocumenter
|
|
67
|
+
? [
|
|
68
|
+
`Use \`documenter\` directly for explicit whole-codebase maintenance or standalone documentation work.${codeWriterNames.length > 0 ? ` Successful ${codeWriterNames.join("/")} runs already auto-sync the actual diff; never dispatch a duplicate.` : ""} Zero edits is valid and broad mode is never inferred. It changes docs/comments only and never runtime behavior, versions, release state, or ${hasReviewer ? "the final reviewer gate" : "direct final verification"}.`,
|
|
44
69
|
]
|
|
45
70
|
: []),
|
|
46
71
|
...(hasReviewer
|
|
47
72
|
? [
|
|
48
|
-
`Use \`reviewer\` for
|
|
73
|
+
`Use \`reviewer\` for read-only assessments or an explicit gate.${reviewedWriterNames.length > 0 ? ` Successful ${reviewedWriterNames.join("/")} runs already get a fresh read-only reviewer gate.` : ""} Advisory output has no VERDICT: it stays read-only and does not authorize follow-up edits.`,
|
|
49
74
|
]
|
|
50
75
|
: []),
|
|
51
76
|
"Brief every child with the complete goal, exact paths, constraints, and expected output. It has no memory of this conversation.",
|
|
@@ -55,7 +80,7 @@ export function buildDelegationDirective(agents: AgentConfig[]): string {
|
|
|
55
80
|
"Dispatch independent work in one `tasks` array and let the resumed main agent start dependent work only after prerequisites finish.",
|
|
56
81
|
]
|
|
57
82
|
: []),
|
|
58
|
-
`Filesystem isolation: single tasks default to shared${hasWorker ? "; parallel worker tasks default to detached Git worktrees" : ""}${hasCleaner ? "; cleaner defaults to shared" : ""}. Request \`isolation: "worktree"\` only for ${worktreeTargets} write-capable agent in a Git repository with committed HEAD. Read-only agents reject it, and setup/integration failure never falls back silently to shared.`,
|
|
83
|
+
`Filesystem isolation: single tasks default to shared${hasWorker ? "; parallel worker tasks default to detached Git worktrees" : ""}${hasCleaner ? "; cleaner defaults to shared" : ""}${hasDocumenter ? "; documenter defaults to shared" : ""}. Request \`isolation: "worktree"\` only for ${worktreeTargets} write-capable agent in a Git repository with committed HEAD. Read-only agents reject it, and setup/integration failure never falls back silently to shared.`,
|
|
59
84
|
"A configured child model/provider failure automatically continues the same retained session on the current main model; do not redispatch. Ordinary tool/task failures stay on the selected model.",
|
|
60
85
|
"Trust but verify: inspect actual changes/results before reporting completion.",
|
|
61
86
|
];
|
|
@@ -69,9 +94,19 @@ export function buildDelegationDirective(agents: AgentConfig[]): string {
|
|
|
69
94
|
|
|
70
95
|
const verificationRules = [
|
|
71
96
|
"Never report an unrun check as passed; identify unavailable checks and pre-existing failures honestly.",
|
|
97
|
+
...(automaticWriterRoute && reviewedWriterNames.length > 0
|
|
98
|
+
? [
|
|
99
|
+
`Successful top-level write roles automatically continue through enabled downstream roles (${automaticWriterRoute}) to one final delivery; never duplicate stages.`,
|
|
100
|
+
]
|
|
101
|
+
: []),
|
|
72
102
|
...(hasReviewer
|
|
73
103
|
? [
|
|
74
|
-
|
|
104
|
+
...(hasDocumenter
|
|
105
|
+
? [
|
|
106
|
+
`A direct REVIEW_PASS is preliminary: runtime runs documenter on the pending diff, then a fresh reviewer. A direct REVIEW_FAIL ${autoFixEnabled ? "keeps auto-fix; maxFixRounds limits worker fixes only, not initial docs/review." : "cannot start fixes while worker/fix rounds are disabled."}`,
|
|
107
|
+
]
|
|
108
|
+
: []),
|
|
109
|
+
"Resolve every gate finding; do not bypass the configured auto-fix/re-review cap. A reviewer report without a standalone VERDICT is advisory and cannot trigger writes.",
|
|
75
110
|
"Use multi-model cross-review only when explicitly requested or for genuinely high-risk security, unsafe/FFI, persistence-migration, or concurrency changes.",
|
|
76
111
|
]
|
|
77
112
|
: []),
|
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/rpc-run.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
|
|
|
14
14
|
import { basename, join } from "node:path";
|
|
15
15
|
import { StringDecoder } from "node:string_decoder";
|
|
16
16
|
import type { Message } from "@earendil-works/pi-ai";
|
|
17
|
-
import type
|
|
17
|
+
import { SUBAGENT_TOOL_NAMES, type AgentConfig } from "./agents.ts";
|
|
18
18
|
import type { ThinkingLevel } from "./config.ts";
|
|
19
19
|
import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
|
|
20
20
|
|
|
@@ -66,10 +66,13 @@ export interface RpcSingleResult {
|
|
|
66
66
|
* model execution. This remains main-model handoff eligible even when an
|
|
67
67
|
* earlier, aborted objective left assistant text in the session. */
|
|
68
68
|
rpcPromptRejected?: boolean;
|
|
69
|
-
/**
|
|
70
|
-
* transport miss
|
|
69
|
+
/** Startup handshake failed before the initial prompt was dispatched. This
|
|
70
|
+
* transport miss is safe to retry and is not a model/provider failure. */
|
|
71
71
|
rpcStartupFailed?: boolean;
|
|
72
|
-
/** The
|
|
72
|
+
/** The parent dispatched the initial prompt command. Until its response is
|
|
73
|
+
* observed, Pi may already be running it, so startup retries must not replay it. */
|
|
74
|
+
rpcPromptDispatched?: boolean;
|
|
75
|
+
/** The child confirmed prompt acceptance (or emitted agent activity). */
|
|
73
76
|
rpcPromptAccepted?: boolean;
|
|
74
77
|
/** Pi emitted agent/turn/model/tool activity for this attempt. */
|
|
75
78
|
rpcActivity?: boolean;
|
|
@@ -415,6 +418,13 @@ interface RpcResponse {
|
|
|
415
418
|
data?: unknown;
|
|
416
419
|
}
|
|
417
420
|
|
|
421
|
+
class RpcCommandRejectedError extends Error {
|
|
422
|
+
constructor(message: string) {
|
|
423
|
+
super(message);
|
|
424
|
+
this.name = "RpcCommandRejectedError";
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
418
428
|
interface PendingRequest {
|
|
419
429
|
resolve: (response: RpcResponse) => void;
|
|
420
430
|
reject: (error: Error) => void;
|
|
@@ -459,7 +469,7 @@ export interface RunRpcAttemptOptions {
|
|
|
459
469
|
/** Run one persistent RPC child until a stable `agent_settled` or control action. */
|
|
460
470
|
export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise<RpcSingleResult> {
|
|
461
471
|
const { agent, agentName, task, thinkingLevel, idleTimeoutMs, signal, onLive, control } = options;
|
|
462
|
-
const args: string[] = ["--mode", "rpc", "--exclude-tools", "
|
|
472
|
+
const args: string[] = ["--mode", "rpc", "--exclude-tools", SUBAGENT_TOOL_NAMES.join(",")];
|
|
463
473
|
if (options.sessionDir && options.sessionId) {
|
|
464
474
|
args.push("--session-dir", options.sessionDir);
|
|
465
475
|
args.push(sessionExists(options.sessionDir, options.sessionId) ? "--session" : "--session-id", options.sessionId);
|
|
@@ -468,7 +478,10 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
468
478
|
}
|
|
469
479
|
if (agent.model) args.push("--model", agent.model);
|
|
470
480
|
args.push("--thinking", thinkingLevel);
|
|
471
|
-
if (agent.tools
|
|
481
|
+
if (agent.tools) {
|
|
482
|
+
if (agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
|
|
483
|
+
else args.push("--no-tools");
|
|
484
|
+
}
|
|
472
485
|
|
|
473
486
|
let tmpPromptDir: string | null = null;
|
|
474
487
|
let tmpPromptPath: string | null = null;
|
|
@@ -642,7 +655,9 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
642
655
|
},
|
|
643
656
|
);
|
|
644
657
|
}).then((response) => {
|
|
645
|
-
if (!response.success)
|
|
658
|
+
if (!response.success) {
|
|
659
|
+
throw new RpcCommandRejectedError(response.error || `RPC ${response.command} failed.`);
|
|
660
|
+
}
|
|
646
661
|
return response;
|
|
647
662
|
});
|
|
648
663
|
};
|
|
@@ -734,7 +749,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
734
749
|
result.exitCode = 1;
|
|
735
750
|
result.stopReason = "error";
|
|
736
751
|
result.errorMessage = `Replacement prompt was rejected: ${promptError.message}`;
|
|
737
|
-
if (
|
|
752
|
+
if (promptError instanceof RpcCommandRejectedError) result.rpcPromptRejected = true;
|
|
738
753
|
finish();
|
|
739
754
|
terminate();
|
|
740
755
|
if (!closed) await processClosed.promise;
|
|
@@ -1076,7 +1091,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
1076
1091
|
result.stopReason = "error";
|
|
1077
1092
|
result.errorMessage = error.message;
|
|
1078
1093
|
if (startup) result.rpcStartupFailed = true;
|
|
1079
|
-
else result.rpcPromptRejected = true;
|
|
1094
|
+
else if (error instanceof RpcCommandRejectedError) result.rpcPromptRejected = true;
|
|
1080
1095
|
finish();
|
|
1081
1096
|
terminate();
|
|
1082
1097
|
};
|
|
@@ -1091,11 +1106,15 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
1091
1106
|
}
|
|
1092
1107
|
}
|
|
1093
1108
|
if (!finished && !initialPromptResolved && !control?.isParkRequested() && !control?.isStopRequested()) {
|
|
1109
|
+
// Pi starts the agent immediately after prompt preflight, before its
|
|
1110
|
+
// success response necessarily reaches stdout. From this point on, a
|
|
1111
|
+
// missing ACK is ambiguous and must never be recovered by replay.
|
|
1112
|
+
result.rpcPromptDispatched = true;
|
|
1094
1113
|
void send({ type: "prompt", message: asPlainTextRpcPrompt(options.prompt) }).then(
|
|
1095
1114
|
() => resolveInitialPrompt(true),
|
|
1096
1115
|
(error) => {
|
|
1097
1116
|
const promptError = error instanceof Error ? error : new Error(String(error));
|
|
1098
|
-
failBeforePrompt(promptError,
|
|
1117
|
+
failBeforePrompt(promptError, false);
|
|
1099
1118
|
},
|
|
1100
1119
|
);
|
|
1101
1120
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -58,15 +58,17 @@ export interface SubagentThread {
|
|
|
58
58
|
state: ThreadState;
|
|
59
59
|
control: RpcRunControl;
|
|
60
60
|
queueController?: AbortController;
|
|
61
|
-
/** Resolves only after the current generation's
|
|
62
|
-
*
|
|
63
|
-
*
|
|
61
|
+
/** Resolves only after the current generation's top-level child, downstream
|
|
62
|
+
* managed workflow, and queue work have fully quiesced and released their
|
|
63
|
+
* concurrency slot. */
|
|
64
64
|
generationCompletion: Promise<void>;
|
|
65
65
|
/** Synchronous CAS used by lifecycle controls across their async preflight. */
|
|
66
66
|
lifecycleVersion: number;
|
|
67
67
|
lifecycleOperation?: ThreadLifecycleOperation;
|
|
68
68
|
sessionId?: string;
|
|
69
69
|
sessionDir?: string;
|
|
70
|
+
/** Active execution time accumulated across retained resume generations. */
|
|
71
|
+
elapsedMs: number;
|
|
70
72
|
/** Most recent generation result, retained for parked destructive-stop output. */
|
|
71
73
|
lastResult?: SingleResult;
|
|
72
74
|
/** A destructive stop retires context even if the active child settles later. */
|
|
@@ -81,7 +83,8 @@ export interface SubagentThread {
|
|
|
81
83
|
fork: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
|
|
82
84
|
forkedFromRunId?: number;
|
|
83
85
|
forkChildRunIds: number[];
|
|
84
|
-
/** Dispatch-owned, generation-guarded worktree settlement hook.
|
|
86
|
+
/** Dispatch-owned, generation-guarded worktree settlement hook. Its apply
|
|
87
|
+
* runs under the canonical original-repository lane. */
|
|
85
88
|
finalizeIsolation: (generation: number, result?: SingleResult) => Promise<WorktreeFinalization | undefined>;
|
|
86
89
|
/** Best-effort shutdown notification for retained integration artifacts. */
|
|
87
90
|
notifyIsolationFailure?: (finalization: WorktreeFinalization) => void;
|
|
@@ -91,6 +94,8 @@ export interface SubagentThread {
|
|
|
91
94
|
export interface SubagentRuntime {
|
|
92
95
|
configPath: string;
|
|
93
96
|
backgroundQueue: BackgroundTaskQueue;
|
|
97
|
+
/** Live parent tool names from ExtensionAPI, read again for each child launch. */
|
|
98
|
+
getActiveTools: () => string[];
|
|
94
99
|
/** False after session_shutdown; guards delivery and queue work. */
|
|
95
100
|
sessionActive: boolean;
|
|
96
101
|
/** Deliver a batch of completion messages to the main window, waking it only
|
|
@@ -110,7 +115,7 @@ export interface SubagentRuntime {
|
|
|
110
115
|
* next generation. Shutdown invalidates these claims and waits for cleanup. */
|
|
111
116
|
preflightOperations: Set<Promise<void>>;
|
|
112
117
|
/** Every session directory retained for this parent session, including
|
|
113
|
-
*
|
|
118
|
+
* managed-workflow internals that are not directly controllable. */
|
|
114
119
|
sessionDirs: Set<string>;
|
|
115
120
|
retainSession: (result: Pick<SingleResult, "sessionDir">) => void;
|
|
116
121
|
retireThreadSession: (thread: SubagentThread) => void;
|
|
@@ -127,6 +132,7 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
127
132
|
const runtime: SubagentRuntime = {
|
|
128
133
|
configPath,
|
|
129
134
|
backgroundQueue,
|
|
135
|
+
getActiveTools: () => pi.getActiveTools(),
|
|
130
136
|
sessionActive: true,
|
|
131
137
|
sendCompletionGroup: (items) => {
|
|
132
138
|
if (!runtime.sessionActive || items.length === 0) return;
|
|
@@ -134,8 +140,8 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
134
140
|
// Computing this at delivery (emit) time — not when the item was
|
|
135
141
|
// pushed — reflects the current monitor state, since finishing runs
|
|
136
142
|
// are removed from the monitor before their completion is pushed.
|
|
137
|
-
//
|
|
138
|
-
//
|
|
143
|
+
// Managed-workflow parents remain "running" through documenter,
|
|
144
|
+
// reviewer, and any fix rounds, so they are included without a special case.
|
|
139
145
|
const active = monitor
|
|
140
146
|
.getRuns()
|
|
141
147
|
.filter((run) => isRunActiveStatus(run.status))
|