@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/README.md +161 -476
- package/agents/cleaner.md +51 -52
- package/agents/documenter.md +39 -46
- package/agents/explorer.md +40 -50
- package/agents/reviewer.md +29 -31
- package/agents/worker.md +15 -34
- package/package.json +1 -1
- package/src/announcements.ts +8 -0
- package/src/background.ts +23 -5
- package/src/config.ts +4 -2
- package/src/dispatch.ts +645 -746
- package/src/durable.ts +336 -0
- package/src/format.ts +1 -8
- package/src/index.ts +7 -0
- package/src/monitor.ts +29 -30
- package/src/prompt.ts +22 -40
- package/src/rpc-run.ts +22 -228
- package/src/runtime.ts +71 -46
- package/src/session-fork.ts +7 -2
- package/src/setup.ts +25 -193
- package/src/spawn.ts +31 -28
- package/src/temp-hygiene.ts +194 -0
- package/src/thread-lifecycle.ts +1410 -1324
- package/src/tools.ts +21 -108
- package/src/widget.ts +3 -3
- package/src/workflow.ts +248 -0
- package/src/worktree.ts +144 -4
- package/src/fixloop.ts +0 -382
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}
|
|
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" | "
|
|
22
|
-
export type ContinuationKind = "resume-retained" | "resume-appended"
|
|
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 === "
|
|
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
|
|
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
|
|
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 === "
|
|
483
|
-
const isExecuting = status === "running" || status === "
|
|
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
|
|
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
|
|
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
|
|
3
|
-
* system prompt via `before_agent_start`.
|
|
4
|
-
*
|
|
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
|
-
"
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"
|
|
78
|
-
"
|
|
79
|
-
|
|
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;
|
|
91
|
-
"
|
|
92
|
-
"
|
|
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;
|
|
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
|
-
|
|
85
|
+
"A direct REVIEW_PASS with DOCUMENTATION: CLEAN delivers immediately; NEEDED or a missing marker runs one documentation sync.",
|
|
104
86
|
]
|
|
105
87
|
: []),
|
|
106
|
-
"
|
|
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
|
|
98
|
+
The \`subagent\` tool runs isolated leaf Pi child processes and context windows; each completion automatically resumes the main agent.
|
|
117
99
|
|
|
118
|
-
|
|
100
|
+
Agents:
|
|
119
101
|
${catalog}
|
|
120
102
|
|
|
121
103
|
Dispatch:
|