@ferris1225/pi-subagents 4.1.7 → 4.1.9

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/durable.ts ADDED
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Durable thread state: a manifest next to the config that lets parked and
3
+ * settled sub-agent threads survive pi reloads and restarts, plus the durable
4
+ * state root that keeps their retained sessions and isolated worktrees out of
5
+ * the OS temp directory.
6
+ *
7
+ * Records are small path/state snapshots, never full transcripts; the retained
8
+ * Pi session files and worktrees they point at remain the actual context.
9
+ * Writes are atomic (tmp+rename) and serialized through the same
10
+ * withFileMutationQueue as the recovery manifest.
11
+ */
12
+
13
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
14
+ import { existsSync } from "node:fs";
15
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
16
+ import { dirname, join } from "node:path";
17
+ import type { UsageStats } from "./rpc-run.ts";
18
+ import type { SubagentThread } from "./runtime.ts";
19
+ import { getResultOutput, isFailedResult, type SingleResult } from "./spawn.ts";
20
+ import {
21
+ restoreWorktreeIsolation,
22
+ type IsolationMode,
23
+ normalizeWorktreeSnapshot,
24
+ worktreeSnapshot,
25
+ type WorktreeSnapshot,
26
+ } from "./worktree.ts";
27
+
28
+ export const THREADS_MANIFEST_FILE_NAME = "pi-subagents-threads.json";
29
+ const THREADS_MANIFEST_VERSION = 1;
30
+ export const STATE_DIR_NAME = "pi-subagents-state";
31
+
32
+ /** Fixed retention: settled results stop being resumable after a week,
33
+ * parked work (which may hold unintegrated changes) after a month. */
34
+ export const SETTLED_RECORD_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
35
+ export const PARKED_RECORD_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
36
+
37
+ /** Result excerpts are for status display after restore, not full transcripts. */
38
+ const RESULT_SUMMARY_MAX_CHARS = 4_000;
39
+
40
+ export interface ThreadResultSummary {
41
+ agent: string;
42
+ task: string;
43
+ exitCode: number;
44
+ failed: boolean;
45
+ stopReason?: string;
46
+ usage: UsageStats;
47
+ model?: string;
48
+ thinking?: string;
49
+ output: string;
50
+ }
51
+
52
+ export interface ThreadRecord {
53
+ runId: number;
54
+ createdAt: number;
55
+ updatedAt: number;
56
+ generation: number;
57
+ agentName: string;
58
+ task: string;
59
+ cwd: string;
60
+ executionCwd: string;
61
+ thinkingLevel?: string;
62
+ isolation: IsolationMode;
63
+ state: "parked" | "completed" | "failed";
64
+ elapsedMs: number;
65
+ sessionId?: string;
66
+ sessionDir?: string;
67
+ worktree?: WorktreeSnapshot;
68
+ childPids: number[];
69
+ resultSummary?: ThreadResultSummary;
70
+ }
71
+
72
+ interface ThreadsManifest {
73
+ version: number;
74
+ records: ThreadRecord[];
75
+ }
76
+
77
+ export function getStateRoot(configPath: string): string {
78
+ return join(dirname(configPath), STATE_DIR_NAME);
79
+ }
80
+
81
+ export function getThreadsManifestPath(configPath: string): string {
82
+ return join(dirname(configPath), THREADS_MANIFEST_FILE_NAME);
83
+ }
84
+
85
+ function normalizeUsage(value: unknown): UsageStats {
86
+ const raw = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
87
+ const num = (key: string): number => (typeof raw[key] === "number" && Number.isFinite(raw[key]) ? raw[key] : 0);
88
+ return {
89
+ input: num("input"),
90
+ output: num("output"),
91
+ cacheRead: num("cacheRead"),
92
+ cacheWrite: num("cacheWrite"),
93
+ cost: num("cost"),
94
+ contextTokens: num("contextTokens"),
95
+ turns: num("turns"),
96
+ };
97
+ }
98
+
99
+ function normalizeResultSummary(value: unknown): ThreadResultSummary | undefined {
100
+ if (!value || typeof value !== "object") return undefined;
101
+ const raw = value as Record<string, unknown>;
102
+ if (typeof raw.agent !== "string" || !raw.agent) return undefined;
103
+ if (typeof raw.output !== "string") return undefined;
104
+ return {
105
+ agent: raw.agent,
106
+ task: typeof raw.task === "string" ? raw.task : raw.agent,
107
+ exitCode: typeof raw.exitCode === "number" ? raw.exitCode : 0,
108
+ failed: raw.failed === true,
109
+ ...(typeof raw.stopReason === "string" && raw.stopReason ? { stopReason: raw.stopReason } : {}),
110
+ usage: normalizeUsage(raw.usage),
111
+ ...(typeof raw.model === "string" && raw.model ? { model: raw.model } : {}),
112
+ ...(typeof raw.thinking === "string" && raw.thinking ? { thinking: raw.thinking } : {}),
113
+ output: raw.output,
114
+ };
115
+ }
116
+
117
+ function normalizeRecord(value: unknown): ThreadRecord | undefined {
118
+ if (!value || typeof value !== "object") return undefined;
119
+ const raw = value as Record<string, unknown>;
120
+ if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
121
+ if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
122
+ if (typeof raw.updatedAt !== "number" || !Number.isFinite(raw.updatedAt)) return undefined;
123
+ if (typeof raw.agentName !== "string" || !raw.agentName) return undefined;
124
+ if (typeof raw.task !== "string" || !raw.task) return undefined;
125
+ if (typeof raw.cwd !== "string" || !raw.cwd) return undefined;
126
+ if (raw.isolation !== "shared" && raw.isolation !== "worktree") return undefined;
127
+ if (raw.state !== "parked" && raw.state !== "completed" && raw.state !== "failed") return undefined;
128
+ const worktree = raw.worktree === undefined ? undefined : normalizeWorktreeSnapshot(raw.worktree);
129
+ if (worktree === null) return undefined;
130
+ return {
131
+ runId: raw.runId,
132
+ createdAt: raw.createdAt,
133
+ updatedAt: raw.updatedAt,
134
+ generation: typeof raw.generation === "number" && Number.isInteger(raw.generation) && raw.generation >= 0 ? raw.generation : 0,
135
+ agentName: raw.agentName,
136
+ task: raw.task,
137
+ cwd: raw.cwd,
138
+ executionCwd: typeof raw.executionCwd === "string" && raw.executionCwd ? raw.executionCwd : raw.cwd,
139
+ ...(typeof raw.thinkingLevel === "string" && raw.thinkingLevel ? { thinkingLevel: raw.thinkingLevel } : {}),
140
+ isolation: raw.isolation,
141
+ state: raw.state,
142
+ elapsedMs: typeof raw.elapsedMs === "number" && Number.isFinite(raw.elapsedMs) ? Math.max(0, raw.elapsedMs) : 0,
143
+ ...(typeof raw.sessionId === "string" && raw.sessionId ? { sessionId: raw.sessionId } : {}),
144
+ ...(typeof raw.sessionDir === "string" && raw.sessionDir ? { sessionDir: raw.sessionDir } : {}),
145
+ ...(worktree ? { worktree } : {}),
146
+ childPids: Array.isArray(raw.childPids)
147
+ ? raw.childPids.filter((pid): pid is number => typeof pid === "number" && Number.isInteger(pid) && pid > 0)
148
+ : [],
149
+ ...(raw.resultSummary === undefined ? {} : { resultSummary: normalizeResultSummary(raw.resultSummary) }),
150
+ };
151
+ }
152
+
153
+ export async function readThreadRecords(configPath: string): Promise<ThreadRecord[]> {
154
+ try {
155
+ const parsed = JSON.parse(await readFile(getThreadsManifestPath(configPath), "utf8")) as {
156
+ records?: unknown;
157
+ };
158
+ if (!Array.isArray(parsed.records)) return [];
159
+ return parsed.records.flatMap((record) => {
160
+ const normalized = normalizeRecord(record);
161
+ return normalized ? [normalized] : [];
162
+ });
163
+ } catch {
164
+ return [];
165
+ }
166
+ }
167
+
168
+ async function writeManifest(configPath: string, records: readonly ThreadRecord[]): Promise<void> {
169
+ const path = getThreadsManifestPath(configPath);
170
+ if (records.length === 0) {
171
+ await rm(path, { force: true });
172
+ return;
173
+ }
174
+ await mkdir(dirname(path), { recursive: true });
175
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
176
+ try {
177
+ const manifest: ThreadsManifest = {
178
+ version: THREADS_MANIFEST_VERSION,
179
+ records: [...records],
180
+ };
181
+ await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
182
+ await rename(temporaryPath, path);
183
+ } finally {
184
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
185
+ }
186
+ }
187
+
188
+ export async function upsertThreadRecord(configPath: string, record: ThreadRecord): Promise<void> {
189
+ const path = getThreadsManifestPath(configPath);
190
+ await withFileMutationQueue(path, async () => {
191
+ const records = await readThreadRecords(configPath);
192
+ const index = records.findIndex((candidate) => candidate.runId === record.runId);
193
+ const merged: ThreadRecord = index === -1
194
+ ? record
195
+ : { ...record, createdAt: records[index]!.createdAt };
196
+ if (index === -1) records.push(merged);
197
+ else records[index] = merged;
198
+ await writeManifest(configPath, records);
199
+ });
200
+ }
201
+
202
+ export async function removeThreadRecord(configPath: string, runId: number): Promise<void> {
203
+ const path = getThreadsManifestPath(configPath);
204
+ await withFileMutationQueue(path, async () => {
205
+ const records = await readThreadRecords(configPath);
206
+ const next = records.filter((record) => record.runId !== runId);
207
+ if (next.length === records.length) return;
208
+ await writeManifest(configPath, next);
209
+ });
210
+ }
211
+
212
+ function truncateSummary(text: string): string {
213
+ if (text.length <= RESULT_SUMMARY_MAX_CHARS) return text;
214
+ return `${text.slice(0, RESULT_SUMMARY_MAX_CHARS - 1)}…`;
215
+ }
216
+
217
+ function summarizeResult(result: SingleResult): ThreadResultSummary | undefined {
218
+ if (!result) return undefined;
219
+ return {
220
+ agent: result.agent,
221
+ task: result.task,
222
+ exitCode: result.exitCode,
223
+ failed: isFailedResult(result),
224
+ ...(result.stopReason ? { stopReason: result.stopReason } : {}),
225
+ usage: result.usage,
226
+ ...(result.model ? { model: result.model } : {}),
227
+ ...(result.thinking ? { thinking: result.thinking } : {}),
228
+ output: truncateSummary(getResultOutput(result)),
229
+ };
230
+ }
231
+
232
+ /** Project a live thread into its durable record. Only handles whose
233
+ * filesystem is still meaningful are persisted; finalized-and-removed
234
+ * worktrees keep just their checkpoint commit for continuation resumes. */
235
+ export function threadRecordFromThread(
236
+ thread: SubagentThread,
237
+ state: "parked" | "completed" | "failed",
238
+ previous?: ThreadRecord,
239
+ now = Date.now(),
240
+ ): ThreadRecord {
241
+ const worktree = thread.worktree ? worktreeSnapshot(thread.worktree) : undefined;
242
+ return {
243
+ runId: thread.id,
244
+ createdAt: previous?.createdAt ?? now,
245
+ updatedAt: now,
246
+ generation: thread.generation,
247
+ agentName: thread.agentName,
248
+ task: thread.task,
249
+ cwd: thread.cwd,
250
+ executionCwd: thread.executionCwd,
251
+ ...(thread.thinkingLevel ? { thinkingLevel: thread.thinkingLevel } : {}),
252
+ isolation: thread.isolation,
253
+ state,
254
+ elapsedMs: thread.elapsedMs,
255
+ ...(thread.sessionId && thread.sessionDir ? { sessionId: thread.sessionId, sessionDir: thread.sessionDir } : {}),
256
+ ...(worktree ? { worktree } : {}),
257
+ childPids: thread.control?.getChildPids?.() ?? [],
258
+ ...(thread.lastResult ? { resultSummary: summarizeResult(thread.lastResult) } : {}),
259
+ };
260
+ }
261
+
262
+ /** Rebuild a displayable in-turn result from a persisted summary. The retained
263
+ * session holds the real context; this only lets subagent_wait/status show
264
+ * what the previous session's generation concluded. */
265
+ export function restoredResultFromSummary(record: ThreadRecord): SingleResult | undefined {
266
+ const summary = record.resultSummary;
267
+ if (!summary) return undefined;
268
+ return {
269
+ agent: summary.agent,
270
+ task: summary.task,
271
+ exitCode: summary.exitCode,
272
+ messages: summary.output
273
+ ? [{
274
+ role: "assistant",
275
+ content: [{ type: "text", text: summary.output }],
276
+ stopReason: "stop",
277
+ } as SingleResult["messages"][number]]
278
+ : [],
279
+ stderr: "",
280
+ usage: summary.usage,
281
+ isolation: record.isolation,
282
+ ...(summary.model ? { model: summary.model } : {}),
283
+ ...(summary.thinking ? { thinking: summary.thinking } : {}),
284
+ ...(summary.stopReason ? { stopReason: summary.stopReason } : {}),
285
+ ...(record.sessionId && record.sessionDir ? { sessionId: record.sessionId, sessionDir: record.sessionDir } : {}),
286
+ };
287
+ }
288
+
289
+ async function discardRecordArtifacts(record: ThreadRecord): Promise<void> {
290
+ if (record.sessionDir) {
291
+ await rm(record.sessionDir, { recursive: true, force: true }).catch(() => undefined);
292
+ }
293
+ if (record.worktree && (record.worktree.state === "active" || record.worktree.state === "retained")) {
294
+ const worktree = await restoreWorktreeIsolation(record.worktree).catch(() => undefined);
295
+ await worktree?.discard().catch(() => undefined);
296
+ }
297
+ }
298
+
299
+ /** Drop records past their retention age along with their artifacts. Runs at
300
+ * extension load; the fixed ages honor the no-config-knobs policy. */
301
+ export async function pruneThreadRecords(
302
+ configPath: string,
303
+ now = Date.now(),
304
+ ): Promise<void> {
305
+ const path = getThreadsManifestPath(configPath);
306
+ await withFileMutationQueue(path, async () => {
307
+ const records = await readThreadRecords(configPath);
308
+ if (records.length === 0) return;
309
+ let changed = false;
310
+ const kept: ThreadRecord[] = [];
311
+ for (const record of records) {
312
+ const maxAge = record.state === "parked" ? PARKED_RECORD_MAX_AGE_MS : SETTLED_RECORD_MAX_AGE_MS;
313
+ if (now - record.updatedAt <= maxAge) {
314
+ kept.push(record);
315
+ continue;
316
+ }
317
+ changed = true;
318
+ await discardRecordArtifacts(record);
319
+ }
320
+ if (changed) await writeManifest(configPath, kept);
321
+ });
322
+ }
323
+
324
+ /** Paths a manifest still references; used by the state-root sweep so
325
+ * freshly created-but-unrecorded directories are never touched. */
326
+ export function referencedDurablePaths(records: readonly ThreadRecord[]): Set<string> {
327
+ const paths = new Set<string>();
328
+ for (const record of records) {
329
+ if (record.sessionDir) paths.add(record.sessionDir);
330
+ if (record.worktree) {
331
+ paths.add(record.worktree.tempDir);
332
+ if (existsSync(record.worktree.worktreePath)) paths.add(record.worktree.worktreePath);
333
+ }
334
+ }
335
+ return paths;
336
+ }
package/src/fixloop.ts CHANGED
@@ -18,7 +18,13 @@
18
18
  import { isWriteCapableAgent, type AgentConfig } from "./agents.ts";
19
19
  import { getResultOutput, isFailedResult, reviewVerdict, type SingleResult } from "./spawn.ts";
20
20
  import { formatUsageCompact, sumUsage } from "./monitor.ts";
21
- import type { SubagentsConfig } from "./config.ts";
21
+
22
+ /**
23
+ * Worker fixes allowed after REVIEW_FAIL: one fix, one re-review. Anything
24
+ * still unresolved is handed back to the main window instead of burning more
25
+ * rounds, keeping chain latency and cost bounded.
26
+ */
27
+ export const MAX_FIX_ROUNDS = 1;
22
28
 
23
29
  /**
24
30
  * Whether a completed result should trigger the auto-fix loop instead of being
@@ -27,8 +33,7 @@ import type { SubagentsConfig } from "./config.ts";
27
33
  * normally. Loop-internal re-review results never reach this path (they are
28
34
  * awaited inside the loop, not delivered through the completion flow).
29
35
  */
30
- export function shouldTriggerFixLoop(result: SingleResult, config: SubagentsConfig): boolean {
31
- if (config.maxFixRounds <= 0) return false;
36
+ export function shouldTriggerFixLoop(result: SingleResult): boolean {
32
37
  if (result.agent !== "reviewer") return false;
33
38
  if (isFailedResult(result)) return false;
34
39
  // A dispatch crash (spawn infra, delivery API, ...) is never a real review
@@ -97,8 +102,8 @@ export function canStartManagedWorkflow(
97
102
  if (isWriteCapableAgent(agent)) return true;
98
103
  if (agent.name === "reviewer") {
99
104
  // Hold a stable diff snapshot against every discoverable writer even when
100
- // this review is advisory or maxFixRounds=0. Classification happens only
101
- // after the read-only child returns, too late to acquire the lane safely.
105
+ // this review is advisory. Classification happens only after the read-only
106
+ // child returns, too late to acquire the lane safely.
102
107
  return availability.writer;
103
108
  }
104
109
  return false;
@@ -108,10 +113,10 @@ export function canStartManagedWorkflow(
108
113
  * machine verdict is advisory and cannot start any write-capable child. */
109
114
  export function getManagedWorkflowPlan(
110
115
  result: SingleResult,
111
- config: SubagentsConfig,
112
116
  availability: WorkflowAgentAvailability,
117
+ advisoryReview = false,
113
118
  ): ManagedWorkflowPlan | undefined {
114
- if (result.parked || result.dispatchFailed || isFailedResult(result)) return undefined;
119
+ if (result.dispatchFailed || isFailedResult(result)) return undefined;
115
120
  if (result.agent === "worker" || result.agent === "cleaner") {
116
121
  if (!availability.documenter && !availability.reviewer) return undefined;
117
122
  return {
@@ -123,6 +128,9 @@ export function getManagedWorkflowPlan(
123
128
  // owns the writer lane but delivers directly without an automatic code gate.
124
129
  if (result.agent === "documenter") return undefined;
125
130
  if (result.agent !== "reviewer") return undefined;
131
+ // An advisory dispatch never chains: the caller asked for a report, so even
132
+ // a stray gate verdict must be delivered rather than acted on.
133
+ if (advisoryReview) return undefined;
126
134
 
127
135
  const output = getResultOutput(result);
128
136
  const verdict = reviewVerdict(output);
@@ -135,7 +143,7 @@ export function getManagedWorkflowPlan(
135
143
  ) {
136
144
  return { kind: "review-pass-sync", initialRelation: "pre-documentation review" };
137
145
  }
138
- if (verdict === "fail" && availability.worker && shouldTriggerFixLoop(result, config)) {
146
+ if (verdict === "fail" && availability.worker && shouldTriggerFixLoop(result)) {
139
147
  return { kind: "auto-fix", initialRelation: "initial review" };
140
148
  }
141
149
  return undefined;
@@ -145,11 +153,12 @@ export function getManagedWorkflowPlan(
145
153
  * Build the worker task brief for one fix round from a reviewer's findings.
146
154
  * The worker gets the full review text — findings plus their fix instructions
147
155
  * — and closes every finding either by implementing the instruction or by
148
- * shipping a sounder fix with an explicit per-finding pushback.
156
+ * shipping a sounder fix with an explicit per-finding pushback. The standing
157
+ * pushback and release-boundary contract lives in the worker system prompt;
158
+ * the brief carries only what is specific to this round.
149
159
  */
150
160
  export function buildFixTaskBrief(reviewerResult: SingleResult, round: number, maxRounds: number): string {
151
161
  const review = getResultOutput(reviewerResult);
152
- const remaining = maxRounds - round;
153
162
  return [
154
163
  `Auto-fix round ${round} of ${maxRounds} (triggered by a failed review).`,
155
164
  ``,
@@ -158,18 +167,12 @@ export function buildFixTaskBrief(reviewerResult: SingleResult, round: number, m
158
167
  review,
159
168
  `---`,
160
169
  ``,
161
- `Each finding in the reviewer's report carries a fix instruction. Close EVERY finding — there is no severity triage; all of them get fixed.`,
162
- `You own the fix: when an instruction is factually wrong, clearly out of scope, or a sounder fix exists, implement YOUR fix instead,`,
163
- `then push back explicitly per finding cite it, refute the instruction's reasoning, and state what you shipped instead.`,
164
- `A pushback without a working alternative or concrete reasoning will be re-opened.`,
165
- `Do NOT refactor unrelated code beyond what the findings require.`,
166
- `Synchronize any existing README/docs/examples/comments directly affected by your fixes; do not broaden into standalone documentation maintenance.`,
167
- `Do NOT commit, push, publish, tag, or release; do not bump versions. The parent chain still owns re-review and any conditional final documentation sync.`,
168
- `After editing, run the project's format/build/tests when they exist and report`,
169
- `exactly what you changed (paths + short rationale) plus any pushback, so a reviewer can verify.`,
170
- remaining > 0
171
- ? `A reviewer will re-review your changes automatically after you finish.`
172
- : `This is the last auto-fix round; the workflow conditionally runs any needed final documentation sync and then delivers.`,
170
+ `Close EVERY finding — there is no severity triage; all of them get fixed. Each finding carries a fix instruction:`,
171
+ `follow it, or ship a sounder fix and push back per finding with your reasoning.`,
172
+ `Do NOT refactor unrelated code. Synchronize any existing README/docs/examples/comments directly affected by your fixes.`,
173
+ `Run the project's format/build/tests when they exist, then report exactly what you changed (paths + short rationale)`,
174
+ `plus any pushback, so a reviewer can verify.`,
175
+ `A reviewer re-reviews your changes automatically after you finish; anything still unresolved after that re-review goes back to the main window.`,
173
176
  ].join("\n");
174
177
  }
175
178
 
@@ -206,11 +209,11 @@ export function buildFinalDocumenterBrief(
206
209
  `Final documentation sync: the review gate settled and you are the last managed stage before delivery.`,
207
210
  ``,
208
211
  ...reportSections,
209
- `Inspect the actual git diff (the complete pending diff) and relevant implementation; the reports are only leads.`,
210
- `Apply every documentation note the reviews recorded, then synchronize stale README/docs, examples, API comments, docstrings, and explanatory comments with the behavior that will be committed.`,
211
- `Change documentation surfaces only; never alter runtime behavior or tests to make prose true.`,
212
- `Make zero edits when the diff creates no documentation drift.`,
213
- `Do NOT commit, push, publish, tag, or release; do not bump versions. The parent workflow delivers directly after you; no fresh reviewer runs.`,
212
+ `Inspect the actual git diff and relevant implementation; the reports are only leads.`,
213
+ `Apply every documentation note the reviews recorded, then synchronize stale README/docs, examples, API comments, docstrings,`,
214
+ `and explanatory comments with the behavior that will be committed.`,
215
+ `Change documentation surfaces only; make zero edits when the diff creates no documentation drift.`,
216
+ `The workflow delivers directly after you; no fresh reviewer runs.`,
214
217
  `Report exact documentation/comment paths changed, or state explicitly that no sync was needed.`,
215
218
  ].join("\n");
216
219
  }
@@ -310,15 +313,13 @@ export function buildFinalReviewBrief(
310
313
  `---`,
311
314
  ``,
312
315
  `Run \`git status\` and \`git diff\` and inspect the actual pending code; the report is context, not proof.`,
313
- `Remain read-only. Verify correctness, regressions, and tests.`,
314
- `Attach a concrete fix instruction to EVERY gate finding: what to change, where, and how to verify the fix.`,
315
- `A worker will implement your instructions unless it can justify a sounder fix and push back, so make each instruction specific enough to act on.`,
316
+ `Remain read-only. Attach a concrete fix instruction to EVERY gate finding: what to change, where, and how to verify the fix`,
317
+ `— a worker will implement your instructions unless it can justify a sounder fix and push back.`,
316
318
  ...(options.documenterPending
317
319
  ? [
318
- `A conditional documentation sync is available AFTER this gate, so documentation drift is not a code-gate finding.`,
319
- `If documentation is stale, add a separate short "## Documentation notes" list and emit the standalone line`,
320
- `DOCUMENTATION: NEEDED. If no documentation update is needed, emit DOCUMENTATION: CLEAN instead.`,
321
- `Always emit exactly one of those standalone documentation lines; fail the gate only for code or test findings.`,
320
+ `A conditional documentation sync is available AFTER this gate, so documentation drift is not a code-gate finding:`,
321
+ `record it under "## Documentation notes" and emit the standalone line DOCUMENTATION: NEEDED,`,
322
+ `or DOCUMENTATION: CLEAN when no documentation update is needed.`,
322
323
  ]
323
324
  : [
324
325
  `No documenter is pending, so documentation drift is an ordinary gate finding.`,
@@ -352,15 +353,14 @@ export function buildReReviewBrief(
352
353
  review,
353
354
  `---`,
354
355
  ``,
355
- `The worker's report (what it changed, plus any pushback where it replaced your fix instruction with its own fix):`,
356
+ `The worker's report (what it changed, plus any pushback where it replaced your fix instruction):`,
356
357
  `---`,
357
358
  workerReport,
358
359
  `---`,
359
360
  ``,
360
361
  `Rule on EVERY previous finding: resolved, or still open. Judge the code as it now stands — a finding is`,
361
362
  `resolved when the pending diff fixes it soundly, whether or not the worker followed your fix instruction.`,
362
- `A finding the worker pushed back on must be adjudicated ONCE — accept the worker's fix unless you can`,
363
- `concretely refute its reasoning; never simply restate the finding for another round.`,
363
+ `Adjudicate each pushback once: accept the worker's fix unless you can concretely refute its reasoning.`,
364
364
  `Run \`git diff\` to see what changed, then add NEW findings only for defects this round's edits introduced or exposed.`,
365
365
  `Re-review never opens findings unrelated to this round's edits; issues the earlier review missed belong to a fresh gate.`,
366
366
  `Do NOT re-open a finding you verified as resolved.`,
package/src/format.ts CHANGED
@@ -100,11 +100,6 @@ export function formatCompletionBlock(
100
100
  const startupRetryNote = result.startupRetries
101
101
  ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
102
102
  : "";
103
- const relations = [
104
- result.forkedFromRunId !== undefined ? `forked from #${result.forkedFromRunId}` : undefined,
105
- (result.forkChildRunIds?.length ?? 0) > 0 ? `fork children ${result.forkChildRunIds!.map((id) => `#${id}`).join(", ")}` : undefined,
106
- ].filter((value): value is string => Boolean(value));
107
- const relationNote = relations.length > 0 ? ` · ${relations.join(" · ")}` : "";
108
103
  const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
109
104
  const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
110
105
  if (result.isolation === "worktree") {
@@ -118,13 +113,11 @@ export function formatCompletionBlock(
118
113
  ? "worktree · changes applied, but cleanup failed; recovery artifacts retained"
119
114
  : "worktree · integration failed; recovery artifacts retained"
120
115
  : "worktree · isolated";
121
- lines.push(`Isolation: ${isolation}${relationNote}`);
116
+ lines.push(`Isolation: ${isolation}`);
122
117
  if (result.integrationWorktreePath) lines.push(`Retained worktree: ${result.integrationWorktreePath}`);
123
118
  if (result.integrationPatchPath) lines.push(`Retained patch: ${result.integrationPatchPath}`);
124
119
  if (result.integrationError) lines.push(`Integration error: ${result.integrationError}`);
125
120
  lines.push("");
126
- } else if (relations.length > 0) {
127
- lines.push(`Relation: ${relations.join(" · ")}`, "");
128
121
  }
129
122
  lines.push(text);
130
123
  // Failed-tool diagnostics are deliberate opt-in via subagent_status: agents
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ import { buildDelegationDirective } from "./prompt.ts";
29
29
  import { createRuntime } from "./runtime.ts";
30
30
  import { runSetup } from "./setup.ts";
31
31
  import { currentSubagentDepth } from "./spawn.ts";
32
+ import { bootstrapDurableState } from "./thread-lifecycle.ts";
32
33
  import { registerLookupTools } from "./tools.ts";
33
34
  import { clearActiveRunsWidget } from "./widget.ts";
34
35
 
@@ -77,6 +78,12 @@ export default function (pi: ExtensionAPI): void {
77
78
 
78
79
  registerAnnouncements(pi, runtime);
79
80
 
81
+ // Durable bootstrap: restore parked/settled threads from the manifest so a
82
+ // reload or restart keeps status and resume working, then age out old
83
+ // records and sweep leaked temp/state directories. Fire-and-forget; every
84
+ // stage is best-effort and never blocks registration.
85
+ void bootstrapDurableState(runtime);
86
+
80
87
  // Proactive dispatch: inject the delegation directive into the parent system prompt.
81
88
  pi.on("before_agent_start", async (event, ctx) => {
82
89
  const config = await loadConfig(configPath);
@@ -86,7 +93,7 @@ export default function (pi: ExtensionAPI): void {
86
93
  enabledNames: config.enabledAgents,
87
94
  projectTrusted: ctx.isProjectTrusted?.() === true,
88
95
  });
89
- const directive = buildDelegationDirective(agents, { maxFixRounds: config.maxFixRounds });
96
+ const directive = buildDelegationDirective(agents);
90
97
  if (!directive) return undefined;
91
98
  return { systemPrompt: `${event.systemPrompt}\n${directive}` };
92
99
  });
package/src/models.ts CHANGED
@@ -86,6 +86,22 @@ export function findModelByRef(
86
86
  return normalized ? models.find((model) => modelRef(model) === normalized) : undefined;
87
87
  }
88
88
 
89
+ /** Split persisted agent model overrides into the ones Pi still reports as
90
+ * available and the stale ones. Stale refs are dropped at session start (with
91
+ * a user notice) so the config never carries models that can no longer run. */
92
+ export function filterUnavailableModelOverrides(
93
+ agentModels: Record<string, string>,
94
+ models: readonly Model<Api>[],
95
+ ): { kept: Record<string, string>; dropped: Array<{ agent: string; ref: string }> } {
96
+ const kept: Record<string, string> = {};
97
+ const dropped: Array<{ agent: string; ref: string }> = [];
98
+ for (const [agent, ref] of Object.entries(agentModels)) {
99
+ if (findModelByRef(models, ref)) kept[agent] = ref;
100
+ else dropped.push({ agent, ref });
101
+ }
102
+ return { kept, dropped };
103
+ }
104
+
89
105
  /**
90
106
  * Resolve one agent's runtime route:
91
107
  *