@ferris1225/pi-subagents 4.1.8 → 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
@@ -20,11 +20,11 @@ import { getResultOutput, isFailedResult, reviewVerdict, type SingleResult } fro
20
20
  import { formatUsageCompact, sumUsage } from "./monitor.ts";
21
21
 
22
22
  /**
23
- * Worker fixes allowed after REVIEW_FAIL. Each fix is followed by a reviewer
24
- * re-review; this cap does not suppress the post-writer review gate or its
25
- * conditional/reviewer-disabled documentation fallback.
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
26
  */
27
- export const MAX_FIX_ROUNDS = 2;
27
+ export const MAX_FIX_ROUNDS = 1;
28
28
 
29
29
  /**
30
30
  * Whether a completed result should trigger the auto-fix loop instead of being
@@ -114,8 +114,9 @@ export function canStartManagedWorkflow(
114
114
  export function getManagedWorkflowPlan(
115
115
  result: SingleResult,
116
116
  availability: WorkflowAgentAvailability,
117
+ advisoryReview = false,
117
118
  ): ManagedWorkflowPlan | undefined {
118
- if (result.parked || result.dispatchFailed || isFailedResult(result)) return undefined;
119
+ if (result.dispatchFailed || isFailedResult(result)) return undefined;
119
120
  if (result.agent === "worker" || result.agent === "cleaner") {
120
121
  if (!availability.documenter && !availability.reviewer) return undefined;
121
122
  return {
@@ -127,6 +128,9 @@ export function getManagedWorkflowPlan(
127
128
  // owns the writer lane but delivers directly without an automatic code gate.
128
129
  if (result.agent === "documenter") return undefined;
129
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;
130
134
 
131
135
  const output = getResultOutput(result);
132
136
  const verdict = reviewVerdict(output);
@@ -149,11 +153,12 @@ export function getManagedWorkflowPlan(
149
153
  * Build the worker task brief for one fix round from a reviewer's findings.
150
154
  * The worker gets the full review text — findings plus their fix instructions
151
155
  * — and closes every finding either by implementing the instruction or by
152
- * 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.
153
159
  */
154
160
  export function buildFixTaskBrief(reviewerResult: SingleResult, round: number, maxRounds: number): string {
155
161
  const review = getResultOutput(reviewerResult);
156
- const remaining = maxRounds - round;
157
162
  return [
158
163
  `Auto-fix round ${round} of ${maxRounds} (triggered by a failed review).`,
159
164
  ``,
@@ -162,18 +167,12 @@ export function buildFixTaskBrief(reviewerResult: SingleResult, round: number, m
162
167
  review,
163
168
  `---`,
164
169
  ``,
165
- `Each finding in the reviewer's report carries a fix instruction. Close EVERY finding — there is no severity triage; all of them get fixed.`,
166
- `You own the fix: when an instruction is factually wrong, clearly out of scope, or a sounder fix exists, implement YOUR fix instead,`,
167
- `then push back explicitly per finding cite it, refute the instruction's reasoning, and state what you shipped instead.`,
168
- `A pushback without a working alternative or concrete reasoning will be re-opened.`,
169
- `Do NOT refactor unrelated code beyond what the findings require.`,
170
- `Synchronize any existing README/docs/examples/comments directly affected by your fixes; do not broaden into standalone documentation maintenance.`,
171
- `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.`,
172
- `After editing, run the project's format/build/tests when they exist and report`,
173
- `exactly what you changed (paths + short rationale) plus any pushback, so a reviewer can verify.`,
174
- remaining > 0
175
- ? `A reviewer will re-review your changes automatically after you finish.`
176
- : `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.`,
177
176
  ].join("\n");
178
177
  }
179
178
 
@@ -210,11 +209,11 @@ export function buildFinalDocumenterBrief(
210
209
  `Final documentation sync: the review gate settled and you are the last managed stage before delivery.`,
211
210
  ``,
212
211
  ...reportSections,
213
- `Inspect the actual git diff (the complete pending diff) and relevant implementation; the reports are only leads.`,
214
- `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.`,
215
- `Change documentation surfaces only; never alter runtime behavior or tests to make prose true.`,
216
- `Make zero edits when the diff creates no documentation drift.`,
217
- `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.`,
218
217
  `Report exact documentation/comment paths changed, or state explicitly that no sync was needed.`,
219
218
  ].join("\n");
220
219
  }
@@ -314,15 +313,13 @@ export function buildFinalReviewBrief(
314
313
  `---`,
315
314
  ``,
316
315
  `Run \`git status\` and \`git diff\` and inspect the actual pending code; the report is context, not proof.`,
317
- `Remain read-only. Verify correctness, regressions, and tests.`,
318
- `Attach a concrete fix instruction to EVERY gate finding: what to change, where, and how to verify the fix.`,
319
- `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.`,
320
318
  ...(options.documenterPending
321
319
  ? [
322
- `A conditional documentation sync is available AFTER this gate, so documentation drift is not a code-gate finding.`,
323
- `If documentation is stale, add a separate short "## Documentation notes" list and emit the standalone line`,
324
- `DOCUMENTATION: NEEDED. If no documentation update is needed, emit DOCUMENTATION: CLEAN instead.`,
325
- `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.`,
326
323
  ]
327
324
  : [
328
325
  `No documenter is pending, so documentation drift is an ordinary gate finding.`,
@@ -356,15 +353,14 @@ export function buildReReviewBrief(
356
353
  review,
357
354
  `---`,
358
355
  ``,
359
- `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):`,
360
357
  `---`,
361
358
  workerReport,
362
359
  `---`,
363
360
  ``,
364
361
  `Rule on EVERY previous finding: resolved, or still open. Judge the code as it now stands — a finding is`,
365
362
  `resolved when the pending diff fixes it soundly, whether or not the worker followed your fix instruction.`,
366
- `A finding the worker pushed back on must be adjudicated ONCE — accept the worker's fix unless you can`,
367
- `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.`,
368
364
  `Run \`git diff\` to see what changed, then add NEW findings only for defects this round's edits introduced or exposed.`,
369
365
  `Re-review never opens findings unrelated to this round's edits; issues the earlier review missed belong to a fresh gate.`,
370
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);
package/src/monitor.ts CHANGED
@@ -18,8 +18,8 @@ import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
18
18
  // Types
19
19
  // ---------------------------------------------------------------------------
20
20
 
21
- export type RunStatus = "queued" | "running" | "steering" | "interrupting" | "parked" | "done" | "failed";
22
- export type ContinuationKind = "resume-retained" | "resume-appended" | "fork-retained" | "fork-appended" | "retarget";
21
+ export type RunStatus = "queued" | "running" | "interrupting" | "parked" | "done" | "failed";
22
+ export type ContinuationKind = "resume-retained" | "resume-appended";
23
23
  export type WorkflowStageStatus = "done" | "active" | "pending" | "changes" | "failed";
24
24
 
25
25
  /** Ephemeral projection of one real or currently planned managed stage. It is
@@ -31,7 +31,7 @@ export interface WorkflowStage {
31
31
  }
32
32
 
33
33
  export function isRunActiveStatus(status: RunStatus): boolean {
34
- return status === "queued" || status === "running" || status === "steering" || status === "interrupting";
34
+ return status === "queued" || status === "running" || status === "interrupting";
35
35
  }
36
36
 
37
37
  /** Durable integration projection of a worktree-isolated run: pending before
@@ -57,8 +57,6 @@ export interface RunView {
57
57
  /** Short worktree-group identity (mkdtemp suffix) shared by every run inside
58
58
  * one isolated worktree; changes when a continuation worktree is created. */
59
59
  worktreeId?: string;
60
- forkedFromRunId?: number;
61
- forkChildRunIds?: number[];
62
60
  status: RunStatus;
63
61
  usage: UsageStats;
64
62
  /** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
@@ -94,7 +92,6 @@ export interface RunChainMeta {
94
92
  parentRunId?: number;
95
93
  isolation?: IsolationMode;
96
94
  worktreeId?: string;
97
- forkedFromRunId?: number;
98
95
  continuationKind?: ContinuationKind;
99
96
  }
100
97
 
@@ -325,13 +322,10 @@ export function formatElapsed(run: RunView, now: number = Date.now()): string {
325
322
  return formatDuration(elapsedMilliseconds(run, now));
326
323
  }
327
324
 
328
- export function continuationLabel(kind: ContinuationKind | undefined, sourceRunId?: number): string | undefined {
325
+ export function continuationLabel(kind: ContinuationKind | undefined): string | undefined {
329
326
  switch (kind) {
330
327
  case "resume-retained": return "resume: current objective";
331
328
  case "resume-appended": return "resume: appended objective";
332
- case "fork-retained": return `fork${sourceRunId === undefined ? "" : ` #${sourceRunId}`}: current objective`;
333
- case "fork-appended": return `fork${sourceRunId === undefined ? "" : ` #${sourceRunId}`}: appended objective`;
334
- case "retarget": return "retarget: replacement objective";
335
329
  default: return undefined;
336
330
  }
337
331
  }
@@ -452,6 +446,26 @@ export class MonitorStore {
452
446
  return this.nextId++;
453
447
  }
454
448
 
449
+ /** Keep newly allocated ids above a restored id so reload-restored threads
450
+ * never collide with runs started in the current process. */
451
+ ensureNextIdAbove(id: number): void {
452
+ if (id >= this.nextId) this.nextId = id + 1;
453
+ }
454
+
455
+ /** Re-register a durable thread restored from a previous process. The row
456
+ * keeps its stable id and historical elapsed time. */
457
+ restoreRun(view: Pick<RunView, "id" | "agent" | "task" | "status"> & Partial<RunView>): void {
458
+ if (this.find(view.id)) return;
459
+ this.runs.push({
460
+ label: runLabel(view.task),
461
+ usage: emptyUsage(),
462
+ elapsedMs: 0,
463
+ ...view,
464
+ });
465
+ this.ensureNextIdAbove(view.id);
466
+ this.notify();
467
+ }
468
+
455
469
  addRun(agent: string, task: string, model?: string, thinking?: string, meta?: RunChainMeta): number {
456
470
  const id = this.reserveRunId();
457
471
  this.runs.push({
@@ -468,7 +482,6 @@ export class MonitorStore {
468
482
  ...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
469
483
  ...(meta?.parentRunId !== undefined ? { parentRunId: meta.parentRunId } : {}),
470
484
  ...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined, ...(meta.worktreeId ? { worktreeId: meta.worktreeId } : {}) } : {}),
471
- ...(meta?.forkedFromRunId !== undefined ? { forkedFromRunId: meta.forkedFromRunId } : {}),
472
485
  ...(meta?.continuationKind ? { continuationKind: meta.continuationKind } : {}),
473
486
  });
474
487
  this.notify();
@@ -479,8 +492,8 @@ export class MonitorStore {
479
492
  const run = this.find(id);
480
493
  if (!run) return;
481
494
  const previousStatus = run.status;
482
- const wasExecuting = previousStatus === "running" || previousStatus === "steering" || previousStatus === "interrupting";
483
- const isExecuting = status === "running" || status === "steering" || status === "interrupting";
495
+ const wasExecuting = previousStatus === "running" || previousStatus === "interrupting";
496
+ const isExecuting = status === "running" || status === "interrupting";
484
497
  const now = Date.now();
485
498
  run.status = status;
486
499
  if (isExecuting && !wasExecuting) {
@@ -582,18 +595,8 @@ export class MonitorStore {
582
595
  this.notify();
583
596
  }
584
597
 
585
- setForkRelation(sourceRunId: number, childRunId: number): void {
586
- const source = this.find(sourceRunId);
587
- if (source) {
588
- source.forkChildRunIds ??= [];
589
- if (!source.forkChildRunIds.includes(childRunId)) source.forkChildRunIds.push(childRunId);
590
- }
591
- const child = this.find(childRunId);
592
- if (child) child.forkedFromRunId = sourceRunId;
593
- this.notify();
594
- }
595
598
 
596
- /** Update the objective shown for a queued retarget or resumed generation. */
599
+ /** Update the objective shown for a resumed generation. */
597
600
  setTask(id: number, task: string): void {
598
601
  const run = this.find(id);
599
602
  if (!run) return;
@@ -706,7 +709,7 @@ export class MonitorStore {
706
709
  summarize(run: RunView): string {
707
710
  const usage = formatUsageCompact(run.usage);
708
711
  const parts = [run.managedWorkflow ? `${run.agent} workflow` : run.agent];
709
- const continuation = continuationLabel(run.continuationKind, run.forkedFromRunId);
712
+ const continuation = continuationLabel(run.continuationKind);
710
713
  if (continuation) parts.push(continuation);
711
714
  if (run.relationLabel) parts.push(run.relationLabel);
712
715
  if (!run.managedWorkflow && run.model) parts.push(run.model);
@@ -743,8 +746,6 @@ export function statusIcon(status: RunStatus, theme: Theme): string {
743
746
  switch (status) {
744
747
  case "running":
745
748
  return theme.fg("accent", "●");
746
- case "steering":
747
- return theme.fg("accent", "◆");
748
749
  case "interrupting":
749
750
  return theme.fg("warning", "◐");
750
751
  case "parked":
@@ -765,8 +766,6 @@ export function statusLabel(status: RunStatus): string {
765
766
  return "ready";
766
767
  case "running":
767
768
  return "running";
768
- case "steering":
769
- return "steering";
770
769
  case "interrupting":
771
770
  return "interrupting";
772
771
  case "parked":