@ferris1225/pi-subagents 4.1.8 → 4.1.11

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/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. */
@@ -75,7 +73,7 @@ export interface RunView {
75
73
  continuationKind?: ContinuationKind;
76
74
  /** When set, this is an internal managed-workflow step. */
77
75
  groupId?: string;
78
- /** Human-readable role within a chain, e.g. "fix round 1" or "re-review round 1". */
76
+ /** Human-readable role within a workflow, e.g. "final review" or "final documentation sync". */
79
77
  relationLabel?: string;
80
78
  /** Stable owning run whose row represents the whole managed workflow. */
81
79
  parentRunId?: number;
@@ -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":
package/src/prompt.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Builds the authoritative delegation directive injected into the parent model's
3
- * system prompt via `before_agent_start`. Tool metadata stays intentionally
4
- * minimal so role/process guidance is not paid for twice.
2
+ * Builds the delegation directive injected into the parent model's
3
+ * system prompt via `before_agent_start`. It is paid on every turn, so it
4
+ * stays a lean routing contract when a child context pays for itself,
5
+ * how wide to fan out, and how results come back. Tool metadata stays
6
+ * intentionally minimal so role/process guidance is not paid for twice.
5
7
  */
6
8
 
7
9
  import type { AgentConfig } from "./agents.ts";
@@ -22,13 +24,10 @@ export function buildDelegationDirective(
22
24
  const hasCleaner = agents.some((agent) => agent.name === "cleaner");
23
25
  const hasDocumenter = agents.some((agent) => agent.name === "documenter");
24
26
  const hasReviewer = agents.some((agent) => agent.name === "reviewer");
25
- const hasMultiple = agents.length > 1;
26
- const autoFixEnabled = hasWorker;
27
27
  const codeWriterNames = [
28
28
  ...(hasWorker ? ["worker"] : []),
29
29
  ...(hasCleaner ? ["cleaner"] : []),
30
30
  ];
31
- const reviewedWriterNames = [...codeWriterNames];
32
31
  const namedWorktreeTargets = [
33
32
  ...(hasWorker ? ["worker"] : []),
34
33
  ...(hasCleaner ? ["cleaner"] : []),
@@ -39,71 +38,54 @@ export function buildDelegationDirective(
39
38
  : namedWorktreeTargets.length === 1
40
39
  ? `${namedWorktreeTargets[0]} or another`
41
40
  : `${namedWorktreeTargets.slice(0, -1).join(", ")}, ${namedWorktreeTargets.at(-1)}, or another`;
42
- const managedWriterWorkflowRule = reviewedWriterNames.length === 0
43
- ? undefined
44
- : hasReviewer && hasDocumenter
45
- ? `Successful top-level ${reviewedWriterNames.join("/")} runs continue through the enabled reviewer gate. Only REVIEW_PASS can authorize documenter, which runs for DOCUMENTATION: NEEDED or a missing marker; the workflow delivers once. Never duplicate stages.`
46
- : hasReviewer
47
- ? `Successful top-level ${reviewedWriterNames.join("/")} runs continue through the enabled reviewer gate and then deliver once; never duplicate the gate.`
48
- : hasDocumenter
49
- ? `With reviewer disabled, successful top-level ${reviewedWriterNames.join("/")} runs use documenter as the conservative final fallback and then deliver once; never duplicate the fallback.`
50
- : undefined;
51
41
 
52
42
  const dispatchRules = [
53
- "Keep small, known-target work in the main thread with direct tools: lookups and focused reads/edits do not justify a child context.",
43
+ "Route substantive work to sub-agents so your context stays lean for orchestration; inline only trivial work — a one-shot lookup, a single focused read/edit, or an answer already in context.",
54
44
  ...(hasExplorer
55
45
  ? [
56
- "Use `explorer` proactively only for broad or cross-file reconnaissance: mapping unfamiliar code, tracing symbols/dependencies, or finding multi-file references. It is a lightweight retrieval index, never an automatic gate. Re-read load-bearing files before edits or high-risk decisions. Use a stronger model/specialist for dynamic, concurrent, migration, or security analysis.",
46
+ "Use `explorer` for any broad or multi-file search; it is a retrieval index, never a gate — re-read load-bearing files before acting on its findings.",
57
47
  ]
58
48
  : []),
59
49
  ...(hasWorker
60
- ? ["Use `worker` for a self-contained implementation, fix, refactor, or test whose separate context pays for itself—not a small known-target edit."]
50
+ ? ["Use `worker` for a self-contained implementation, fix, refactor, or test whose separate context pays for itself."]
61
51
  : []),
62
52
  ...(hasCleaner
63
53
  ? [
64
- `Use \`cleaner\` only as the separate evidence-first entry for user-authorized cleanup, removal, simplification, duplicate-code consolidation, or maintenance; never substitute it for \`worker\`. It applies every safe proven in-scope cut without item-by-item approval. 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.`,
54
+ `Use \`cleaner\` only for user-authorized cleanup or deduplication; it applies every safe proven cut without item-by-item approval, and never runs as a pre-commit gate or by PR count.`,
65
55
  ]
66
56
  : []),
67
57
  ...(hasDocumenter
68
58
  ? [
69
- `Use \`documenter\` directly only for explicit whole-codebase maintenance or standalone documentation/comment work; a top-level documenter delivers directly without an automatic reviewer.${codeWriterNames.length > 0 ? ` ${codeWriterNames.join("/")} must sync existing docs they directly affect; runtime runs documenter only after REVIEW_PASS with DOCUMENTATION: NEEDED or a missing marker, or as the reviewer-disabled fallback—never dispatch a duplicate.` : ""} It never changes runtime behavior, versions, or release state.`,
59
+ `Use \`documenter\` directly only for explicit standalone documentation work; a top-level documenter delivers without a gate.${codeWriterNames.length > 0 ? ` The runtime runs the final docs sync after REVIEW_PASS with DOCUMENTATION: NEEDED or a missing marker, or as the reviewer-disabled fallback; writers sync docs they directly affect never dispatch a duplicate.` : ""}`,
70
60
  ]
71
61
  : []),
72
62
  ...(hasReviewer
73
63
  ? [
74
- `Use \`reviewer\` for read-only assessments or a gate.${reviewedWriterNames.length > 0 ? ` Successful ${reviewedWriterNames.join("/")} runs already get one fresh read-only reviewer gate, independent of the writer.` : ""} Advisory output has no VERDICT and cannot authorize follow-up edits${hasDocumenter ? "; gates classify docs separately for the enabled documenter." : "."}`,
64
+ `Use \`reviewer\` for read-only assessments or gates.${codeWriterNames.length > 0 ? ` Successful ${codeWriterNames.join("/")} runs already get one fresh gate and then deliver once.` : ""} Advisory output has no VERDICT and cannot authorize edits; dispatch your own re-verification with \`advisory: true\`.`,
75
65
  ]
76
66
  : []),
77
- "Brief each child with the complete goal, exact paths, constraints, and expected output; it has no conversation memory.",
78
- "Children are leaf processes without delegation tools; use `subagent_control fork` on a parked/settled thread for an independent continuation.",
79
- ...(hasMultiple
80
- ? [
81
- "Dispatch independent work in one `tasks` array and let the resumed main agent start dependent work only after prerequisites finish.",
82
- ]
83
- : []),
84
- `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.`,
67
+ "You own the fan-out breadth: every genuinely independent unit goes in one `tasks` array — there is no per-call cap, and extra tasks queue for the next free process slot. One child owns one coherent deliverable and its files; no two children share a file or re-answer the same question; dependent work starts only after its prerequisite delivers.",
68
+ "Brief each child with the complete goal, exact paths, constraints, and expected output; it has no conversation memory and cannot delegate. Continue a parked/settled thread with `subagent_control resume`.",
69
+ `Single tasks share the checkout${hasWorker ? "; parallel workers default to detached Git worktrees" : ""}. Request \`isolation: "worktree"\` only for ${worktreeTargets} write-capable agent in a Git repo with committed HEAD; setup failure never silently falls back to shared.`,
85
70
  "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.",
86
- "Trust but verify: inspect actual changes/results before reporting completion.",
87
71
  ];
88
72
 
89
73
  const handoffRules = [
90
- "Dispatch ends this turn; results resume the main agent, even mid-turn. Never sleep, poll, or call `subagent_wait` to hold the turn.",
91
- "Use `subagent_wait` with explicit `timeoutMs` only when the user asks to wait in-turn; its default lookup is non-blocking.",
92
- "Results are already shown. Do not restate, paraphrase, or re-summarize them; add only your conclusion or next action.",
93
- "A delivered result does not mean siblings are finished. Before declaring the overall task done, use `subagent_status` to confirm that no runs remain active.",
74
+ "Dispatch ends this turn; each completion resumes the main agent automatically never sleep, poll, or call `subagent_wait` to hold the turn.",
75
+ "Results are already shown; add only your conclusion or next action, never a restatement.",
76
+ "Before declaring the overall task done, use `subagent_status` to confirm that no runs remain active.",
94
77
  ];
95
78
 
96
79
  const verificationRules = [
97
- "Never report an unrun check as passed; identify unavailable checks and pre-existing failures honestly.",
98
- ...(managedWriterWorkflowRule ? [managedWriterWorkflowRule] : []),
80
+ "Never report an unrun check as passed; surface unavailable checks and pre-existing failures honestly, and inspect actual changes before reporting completion.",
99
81
  ...(hasReviewer
100
82
  ? [
101
83
  ...(hasDocumenter
102
84
  ? [
103
- `A direct REVIEW_PASS with DOCUMENTATION: CLEAN delivers immediately; NEEDED or a missing marker runs one documentation sync. A direct REVIEW_FAIL ${autoFixEnabled ? "keeps bounded worker/reviewer auto-fix, with docs considered only after its terminal REVIEW_PASS." : "cannot start fixes while worker is disabled."}`,
85
+ "A direct REVIEW_PASS with DOCUMENTATION: CLEAN delivers immediately; NEEDED or a missing marker runs one documentation sync.",
104
86
  ]
105
87
  : []),
106
- "Resolve every gate finding; do not bypass the auto-fix/re-review cap. A reviewer report without a standalone VERDICT is advisory and cannot trigger writes.",
88
+ "A REVIEW_FAIL — direct or from a managed gate returns the findings to you: resolve them yourself, inline or via a worker you brief, without waiting for the user; the runtime never auto-fixes. Ask only for genuinely destructive or scope-changing fixes. Advisory reports cannot trigger writes.",
107
89
  "Use multi-model cross-review only when explicitly requested or for genuinely high-risk security, unsafe/FFI, persistence-migration, or concurrency changes.",
108
90
  ]
109
91
  : []),
@@ -113,9 +95,9 @@ export function buildDelegationDirective(
113
95
  return `
114
96
  ## Sub-agent delegation (pi-subagents)
115
97
 
116
- The \`subagent\` tool starts isolated Pi child processes and context windows. Completions automatically resume the main agent.
98
+ The \`subagent\` tool runs isolated leaf Pi child processes and context windows; each completion automatically resumes the main agent.
117
99
 
118
- Available agents:
100
+ Agents:
119
101
  ${catalog}
120
102
 
121
103
  Dispatch: