@ferris1225/pi-subagents 4.2.13 → 4.3.1
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/CHANGELOG.md +34 -0
- package/README.md +113 -149
- package/agents/artisan.md +19 -0
- package/agents/scout.md +18 -0
- package/agents/steward.md +19 -0
- package/package.json +4 -3
- package/src/agents.ts +24 -28
- package/src/announcements.ts +11 -18
- package/src/background.ts +56 -9
- package/src/completion.ts +0 -6
- package/src/config.ts +55 -90
- package/src/dispatch.ts +52 -67
- package/src/durable.ts +6 -53
- package/src/index.ts +7 -10
- package/src/monitor.ts +2 -2
- package/src/prompt.ts +104 -43
- package/src/recovery.ts +35 -10
- package/src/rpc-run.ts +59 -1
- package/src/runtime.ts +74 -17
- package/src/setup.ts +108 -102
- package/src/spawn.ts +6 -4
- package/src/thread-lifecycle.ts +127 -95
- package/src/tools.ts +4 -20
- package/agents/executor.md +0 -54
- package/agents/explorer.md +0 -37
package/src/recovery.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { existsSync } from "node:fs";
|
|
|
5
5
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { stripVTControlCharacters } from "node:util";
|
|
8
|
+
import { getSubagentsRoot } from "./spawn.ts";
|
|
8
9
|
import { removeWorktreeGroup, worktreeGroupDir, type WorktreeFinalization } from "./worktree.ts";
|
|
9
10
|
|
|
10
11
|
export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
|
|
@@ -27,7 +28,7 @@ interface RecoveryManifest {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export function getRecoveryManifestPath(configPath: string): string {
|
|
30
|
-
return join(
|
|
31
|
+
return join(getSubagentsRoot(configPath), RECOVERY_MANIFEST_FILE_NAME);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
function normalizeRecord(value: unknown): RecoveryRecord | undefined {
|
|
@@ -46,21 +47,45 @@ function normalizeRecord(value: unknown): RecoveryRecord | undefined {
|
|
|
46
47
|
};
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
interface RecoveryManifestRead {
|
|
51
|
+
valid: boolean;
|
|
52
|
+
records: RecoveryRecord[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readManifest(path: string): Promise<RecoveryManifestRead> {
|
|
50
56
|
try {
|
|
51
|
-
const parsed = JSON.parse(await readFile(
|
|
52
|
-
|
|
57
|
+
const parsed = JSON.parse(await readFile(path, "utf8")) as { records?: unknown };
|
|
58
|
+
if (!Array.isArray(parsed.records)) return { valid: false, records: [] };
|
|
59
|
+
return {
|
|
60
|
+
valid: true,
|
|
61
|
+
records: parsed.records.flatMap((record) => {
|
|
62
|
+
const normalized = normalizeRecord(record);
|
|
63
|
+
return normalized ? [normalized] : [];
|
|
64
|
+
}),
|
|
53
65
|
};
|
|
54
|
-
if (!Array.isArray(parsed.records)) return [];
|
|
55
|
-
return parsed.records.flatMap((record) => {
|
|
56
|
-
const normalized = normalizeRecord(record);
|
|
57
|
-
return normalized ? [normalized] : [];
|
|
58
|
-
});
|
|
59
66
|
} catch {
|
|
60
|
-
return [];
|
|
67
|
+
return { valid: false, records: [] };
|
|
61
68
|
}
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
|
|
72
|
+
return (await readManifest(getRecoveryManifestPath(configPath))).records;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Move the previous agent-root manifest into the internal-state root without
|
|
76
|
+
* dropping retained artifact pointers. Invalid legacy files stay untouched. */
|
|
77
|
+
export async function relocateRecoveryManifest(configPath: string): Promise<void> {
|
|
78
|
+
const legacyPath = join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
|
|
79
|
+
const currentPath = getRecoveryManifestPath(configPath);
|
|
80
|
+
if (legacyPath === currentPath || !existsSync(legacyPath)) return;
|
|
81
|
+
await withFileMutationQueue(legacyPath, async () => {
|
|
82
|
+
const legacy = await readManifest(legacyPath);
|
|
83
|
+
if (!legacy.valid) return;
|
|
84
|
+
await persistRecoveryRecords(configPath, legacy.records);
|
|
85
|
+
await rm(legacyPath, { force: true });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
64
89
|
function recoveryKey(record: RecoveryRecord): string {
|
|
65
90
|
return `${record.runId}\0${record.worktreePath ?? ""}\0${record.patchPath ?? ""}\0${record.error ?? ""}`;
|
|
66
91
|
}
|
package/src/rpc-run.ts
CHANGED
|
@@ -397,6 +397,40 @@ interface RpcResponse {
|
|
|
397
397
|
data?: unknown;
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
+
interface RpcSessionUsage {
|
|
401
|
+
input: number;
|
|
402
|
+
output: number;
|
|
403
|
+
cacheRead: number;
|
|
404
|
+
cacheWrite: number;
|
|
405
|
+
cost: number;
|
|
406
|
+
contextTokens: number;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function sessionUsage(data: unknown): RpcSessionUsage | undefined {
|
|
410
|
+
if (!data || typeof data !== "object") return undefined;
|
|
411
|
+
const value = data as { tokens?: Record<string, unknown>; cost?: unknown; contextUsage?: { tokens?: unknown } };
|
|
412
|
+
if (!value.tokens || typeof value.tokens !== "object") return undefined;
|
|
413
|
+
const number = (candidate: unknown): number =>
|
|
414
|
+
typeof candidate === "number" && Number.isFinite(candidate) ? candidate : 0;
|
|
415
|
+
return {
|
|
416
|
+
input: number(value.tokens.input),
|
|
417
|
+
output: number(value.tokens.output),
|
|
418
|
+
cacheRead: number(value.tokens.cacheRead),
|
|
419
|
+
cacheWrite: number(value.tokens.cacheWrite),
|
|
420
|
+
cost: number(value.cost),
|
|
421
|
+
contextTokens: number(value.contextUsage?.tokens),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function applySessionUsageDelta(result: RpcSingleResult, before: RpcSessionUsage, after: RpcSessionUsage): void {
|
|
426
|
+
result.usage.input = Math.max(0, after.input - before.input);
|
|
427
|
+
result.usage.output = Math.max(0, after.output - before.output);
|
|
428
|
+
result.usage.cacheRead = Math.max(0, after.cacheRead - before.cacheRead);
|
|
429
|
+
result.usage.cacheWrite = Math.max(0, after.cacheWrite - before.cacheWrite);
|
|
430
|
+
result.usage.cost = Math.max(0, after.cost - before.cost);
|
|
431
|
+
result.usage.contextTokens = after.contextTokens;
|
|
432
|
+
}
|
|
433
|
+
|
|
400
434
|
class RpcCommandRejectedError extends Error {
|
|
401
435
|
constructor(message: string) {
|
|
402
436
|
super(message);
|
|
@@ -523,6 +557,8 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
523
557
|
let abortSettlement: Deferred<void> | undefined;
|
|
524
558
|
let droppedQueuedCount = 0;
|
|
525
559
|
let initialPromptResolved = false;
|
|
560
|
+
let usageBaseline: RpcSessionUsage | undefined;
|
|
561
|
+
let usageSettlementStarted = false;
|
|
526
562
|
const initialPrompt = deferred<{ accepted: boolean; error?: Error }>();
|
|
527
563
|
const pendingRequests = new Map<string, PendingRequest>();
|
|
528
564
|
const outcome = deferred<void>();
|
|
@@ -635,6 +671,23 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
635
671
|
});
|
|
636
672
|
};
|
|
637
673
|
|
|
674
|
+
const settleRunWithUsage = async (): Promise<void> => {
|
|
675
|
+
if (usageSettlementStarted) return;
|
|
676
|
+
usageSettlementStarted = true;
|
|
677
|
+
try {
|
|
678
|
+
if (usageBaseline) {
|
|
679
|
+
const response = await send({ type: "get_session_stats" });
|
|
680
|
+
const finalUsage = sessionUsage(response.data);
|
|
681
|
+
if (finalUsage) applySessionUsageDelta(result, usageBaseline, finalUsage);
|
|
682
|
+
if (finalUsage) emit({ kind: "usage", usage: { ...result.usage }, model: result.model });
|
|
683
|
+
}
|
|
684
|
+
} catch {
|
|
685
|
+
/* message events remain the generation-safe accounting fallback */
|
|
686
|
+
} finally {
|
|
687
|
+
settleRun();
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
|
|
638
691
|
const waitForAbortSettlement = (): Deferred<void> => {
|
|
639
692
|
if (abortSettlement) throw new Error("Another RPC abort transition is already in progress.");
|
|
640
693
|
abortSettlement = deferred<void>();
|
|
@@ -846,7 +899,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
846
899
|
stable.resolve();
|
|
847
900
|
return;
|
|
848
901
|
}
|
|
849
|
-
|
|
902
|
+
void settleRunWithUsage();
|
|
850
903
|
}
|
|
851
904
|
};
|
|
852
905
|
|
|
@@ -951,6 +1004,11 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
951
1004
|
};
|
|
952
1005
|
try {
|
|
953
1006
|
await send({ type: "get_state" }, readyTimeoutMs);
|
|
1007
|
+
try {
|
|
1008
|
+
usageBaseline = sessionUsage((await send({ type: "get_session_stats" })).data);
|
|
1009
|
+
} catch {
|
|
1010
|
+
/* older or degraded RPC children fall back to message usage */
|
|
1011
|
+
}
|
|
954
1012
|
} catch (error) {
|
|
955
1013
|
const handshakeError = error instanceof Error ? error : new Error(String(error));
|
|
956
1014
|
if (!control?.isStopRequested()) {
|
package/src/runtime.ts
CHANGED
|
@@ -12,7 +12,6 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
12
12
|
import { rmSync } from "node:fs";
|
|
13
13
|
import { resolveSubagentConcurrency, BackgroundTaskQueue } from "./background.ts";
|
|
14
14
|
import {
|
|
15
|
-
completionGroupTriggersTurn,
|
|
16
15
|
createCompletionBatcher,
|
|
17
16
|
formatActiveRunsFooter,
|
|
18
17
|
formatCompletionMessage,
|
|
@@ -54,6 +53,10 @@ export interface SubagentThread {
|
|
|
54
53
|
requestedThinkingLevel?: ThinkingLevel;
|
|
55
54
|
isolation: IsolationMode;
|
|
56
55
|
worktree?: WorktreeIsolation;
|
|
56
|
+
/** Durable restoration failure that permanently blocks continuation. */
|
|
57
|
+
resumeUnavailableReason?: string;
|
|
58
|
+
/** Original durable evidence retained when its worktree handle is unavailable. */
|
|
59
|
+
restorationRecord?: ThreadRecord;
|
|
57
60
|
state: ThreadState;
|
|
58
61
|
control: RpcRunControl;
|
|
59
62
|
queueController?: AbortController;
|
|
@@ -104,9 +107,17 @@ export interface SubagentRuntime {
|
|
|
104
107
|
* one-time session-start notice. */
|
|
105
108
|
restoredRunIds: number[];
|
|
106
109
|
restoredNotified: boolean;
|
|
107
|
-
/** Deliver a batch of completion messages
|
|
108
|
-
* when the batch needs a turn. */
|
|
110
|
+
/** Deliver a batch of completion messages as a waking follow-up. */
|
|
109
111
|
sendCompletionGroup: (items: CompletionMessageItem[]) => void;
|
|
112
|
+
/** Claim the sole delivery route before a generation can settle. */
|
|
113
|
+
claimRunDelivery: (runId: number, route: "background" | "await") => void;
|
|
114
|
+
/** Publish a terminal completion through its claimed route. Immediate failures
|
|
115
|
+
* flush older successful batches first. */
|
|
116
|
+
publishRunCompletion: (runId: number, item: CompletionMessageItem, immediate: boolean) => void;
|
|
117
|
+
/** Mark awaited results as returned in the tool response. */
|
|
118
|
+
completeAwaitDelivery: (runIds: readonly number[]) => void;
|
|
119
|
+
/** Transfer aborted awaited calls back to completion delivery. */
|
|
120
|
+
fallbackAwaitDelivery: (runIds: readonly number[]) => void;
|
|
110
121
|
completionBatcher: CompletionBatcher<CompletionMessageItem>;
|
|
111
122
|
/** Abort controllers per active run, so subagent_stop can cancel a run in-turn. */
|
|
112
123
|
runControllers: Map<number, AbortController>;
|
|
@@ -138,6 +149,11 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
138
149
|
let compactionInFlight = false;
|
|
139
150
|
let heldCompletions: CompletionMessageItem[] = [];
|
|
140
151
|
|
|
152
|
+
const runDeliveries = new Map<number, {
|
|
153
|
+
route: "background" | "await";
|
|
154
|
+
completion?: CompletionMessageItem;
|
|
155
|
+
immediate: boolean;
|
|
156
|
+
}>();
|
|
141
157
|
const runtime: SubagentRuntime = {
|
|
142
158
|
configPath,
|
|
143
159
|
backgroundQueue,
|
|
@@ -148,6 +164,9 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
148
164
|
restoredNotified: false,
|
|
149
165
|
sendCompletionGroup: (items) => {
|
|
150
166
|
if (!runtime.sessionActive || items.length === 0) return;
|
|
167
|
+
// Direct (immediate-failure/stop) delivery must follow successes already
|
|
168
|
+
// held by the debounce batcher. A batcher's own emit sees an empty batch.
|
|
169
|
+
runtime.completionBatcher?.flush();
|
|
151
170
|
if (compactionInFlight) {
|
|
152
171
|
heldCompletions.push(...items);
|
|
153
172
|
return;
|
|
@@ -170,18 +189,41 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
170
189
|
content: formatCompletionMessage(items) + formatActiveRunsFooter(active),
|
|
171
190
|
display: true,
|
|
172
191
|
};
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
192
|
+
// Follow-ups never interrupt an active parent lane. triggerTurn wakes an
|
|
193
|
+
// idle parent immediately, while a streaming parent receives the result
|
|
194
|
+
// only after its current tool/assistant lane settles.
|
|
195
|
+
pi.sendMessage(message, { deliverAs: "followUp", triggerTurn: true });
|
|
196
|
+
},
|
|
197
|
+
claimRunDelivery: (runId, route) => {
|
|
198
|
+
runDeliveries.set(runId, { route, immediate: false });
|
|
199
|
+
},
|
|
200
|
+
publishRunCompletion: (runId, item, immediate) => {
|
|
201
|
+
const delivery = runDeliveries.get(runId) ?? { route: "background" as const, immediate: false };
|
|
202
|
+
delivery.completion = item;
|
|
203
|
+
delivery.immediate = immediate;
|
|
204
|
+
runDeliveries.set(runId, delivery);
|
|
205
|
+
if (delivery.route === "await") return;
|
|
206
|
+
runDeliveries.delete(runId);
|
|
207
|
+
if (immediate) {
|
|
208
|
+
runtime.sendCompletionGroup([item]);
|
|
180
209
|
} else {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
210
|
+
runtime.completionBatcher.push(item);
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
completeAwaitDelivery: (runIds) => {
|
|
214
|
+
for (const runId of runIds) {
|
|
215
|
+
const delivery = runDeliveries.get(runId);
|
|
216
|
+
if (delivery?.route === "await") runDeliveries.delete(runId);
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
fallbackAwaitDelivery: (runIds) => {
|
|
220
|
+
for (const runId of runIds) {
|
|
221
|
+
const delivery = runDeliveries.get(runId);
|
|
222
|
+
if (!delivery || delivery.route !== "await") continue;
|
|
223
|
+
delivery.route = "background";
|
|
224
|
+
if (delivery.completion) {
|
|
225
|
+
runtime.publishRunCompletion(runId, delivery.completion, delivery.immediate);
|
|
226
|
+
}
|
|
185
227
|
}
|
|
186
228
|
},
|
|
187
229
|
completionBatcher: undefined as unknown as CompletionBatcher<CompletionMessageItem>,
|
|
@@ -279,6 +321,18 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
279
321
|
const records: ThreadRecord[] = [];
|
|
280
322
|
for (const thread of runtime.threads.values()) {
|
|
281
323
|
if (thread.retired) continue;
|
|
324
|
+
if (thread.resumeUnavailableReason) {
|
|
325
|
+
// Restoration failures retain their durable evidence and session until
|
|
326
|
+
// an explicit destructive stop retires them.
|
|
327
|
+
if (thread.restorationRecord) {
|
|
328
|
+
records.push({
|
|
329
|
+
...thread.restorationRecord,
|
|
330
|
+
updatedAt: Date.now(),
|
|
331
|
+
elapsedMs: thread.elapsedMs,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
282
336
|
const previous = previousStates.get(thread.id) ?? thread.state;
|
|
283
337
|
let state: "parked" | "completed" | "failed";
|
|
284
338
|
if (previous === "completed" || previous === "failed") {
|
|
@@ -313,6 +367,7 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
313
367
|
runtime.settledRuns.clear();
|
|
314
368
|
runtime.settledListeners.clear();
|
|
315
369
|
runtime.runControllers.clear();
|
|
370
|
+
runDeliveries.clear();
|
|
316
371
|
// sessionDirs entries still referenced by records stay owned by the
|
|
317
372
|
// manifest; the next process re-registers them at restore.
|
|
318
373
|
runtime.sessionDirs.clear();
|
|
@@ -329,13 +384,15 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
329
384
|
compactionInFlight = true;
|
|
330
385
|
});
|
|
331
386
|
const releaseHeldCompletions = (): void => {
|
|
387
|
+
// Drain the debounce while the compaction gate is still closed so newer
|
|
388
|
+
// pending successes append after completions already held by that gate.
|
|
389
|
+
runtime.completionBatcher.flush();
|
|
332
390
|
compactionInFlight = false;
|
|
333
391
|
if (heldCompletions.length === 0) return;
|
|
334
392
|
const items = heldCompletions;
|
|
335
393
|
heldCompletions = [];
|
|
336
|
-
// Re-enter the normal path now that the gate is open
|
|
337
|
-
// footer
|
|
338
|
-
// the moment the items were held.
|
|
394
|
+
// Re-enter the normal path now that the gate is open so the active-runs
|
|
395
|
+
// footer reflects delivery time, not the moment the items were held.
|
|
339
396
|
runtime.sendCompletionGroup(items);
|
|
340
397
|
};
|
|
341
398
|
pi.on("session_compact", releaseHeldCompletions);
|