@ferris1225/pi-subagents 4.3.0 → 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 +19 -0
- package/README.md +89 -131
- package/agents/artisan.md +11 -39
- package/agents/scout.md +10 -28
- package/agents/steward.md +11 -43
- package/package.json +1 -1
- package/src/agents.ts +21 -18
- package/src/announcements.ts +9 -23
- package/src/background.ts +56 -9
- package/src/completion.ts +0 -6
- package/src/config.ts +11 -180
- package/src/dispatch.ts +48 -52
- package/src/durable.ts +6 -53
- package/src/index.ts +6 -9
- package/src/prompt.ts +101 -46
- package/src/recovery.ts +35 -10
- package/src/rpc-run.ts +59 -1
- package/src/runtime.ts +74 -17
- package/src/setup.ts +5 -19
- package/src/spawn.ts +6 -4
- package/src/thread-lifecycle.ts +119 -75
- package/src/tools.ts +4 -20
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);
|
package/src/setup.ts
CHANGED
|
@@ -15,14 +15,12 @@ import {
|
|
|
15
15
|
BUILTIN_AGENT_NAMES,
|
|
16
16
|
DEFAULT_CONFIG,
|
|
17
17
|
DEFAULT_ENABLED_AGENTS,
|
|
18
|
-
REQUIRED_ENABLED_AGENTS,
|
|
19
18
|
agentProfile,
|
|
20
19
|
errorMessage,
|
|
21
20
|
getConfigPath,
|
|
22
21
|
loadConfig,
|
|
23
22
|
roleThinkingLevel,
|
|
24
23
|
saveConfig,
|
|
25
|
-
withRequiredAgents,
|
|
26
24
|
type SubagentsConfig,
|
|
27
25
|
type ThinkingLevel,
|
|
28
26
|
} from "./config.ts";
|
|
@@ -52,10 +50,9 @@ const THINKING_LEVEL_HINTS: Record<ThinkingLevel, string> = {
|
|
|
52
50
|
function agentPickerItems(): Array<{ value: string; label: string; description: string }> {
|
|
53
51
|
return BUILTIN_AGENT_NAMES.map((name) => {
|
|
54
52
|
const profile = AGENT_PROFILES[name];
|
|
55
|
-
const required = (REQUIRED_ENABLED_AGENTS as readonly string[]).includes(name);
|
|
56
53
|
return {
|
|
57
54
|
value: name,
|
|
58
|
-
label:
|
|
55
|
+
label: name,
|
|
59
56
|
description: `${profile.summary} — ${profile.remark}`,
|
|
60
57
|
};
|
|
61
58
|
});
|
|
@@ -82,20 +79,11 @@ async function pickEnabledAgents(
|
|
|
82
79
|
const picked = await promptSelectMany(
|
|
83
80
|
ctx,
|
|
84
81
|
"Which agents should run?",
|
|
85
|
-
"Each line is a role and its job.
|
|
82
|
+
"Each line is a role and its job. Space toggles • Enter confirms • Esc back",
|
|
86
83
|
agentPickerItems(),
|
|
87
84
|
current,
|
|
88
85
|
);
|
|
89
|
-
|
|
90
|
-
const enabled = withRequiredAgents(picked);
|
|
91
|
-
const forced = REQUIRED_ENABLED_AGENTS.filter((name) => !picked.includes(name));
|
|
92
|
-
if (forced.length > 0) {
|
|
93
|
-
ctx.ui.notify(
|
|
94
|
-
`pi-subagents: ${forced.join(", ")} stay enabled — the shipped team stays on.`,
|
|
95
|
-
"info",
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
return enabled;
|
|
86
|
+
return picked;
|
|
99
87
|
}
|
|
100
88
|
|
|
101
89
|
async function pickConfiguredModel(
|
|
@@ -251,8 +239,7 @@ function applyThinkingChoice(
|
|
|
251
239
|
async function introduceSetup(ctx: ExtensionCommandContext): Promise<boolean> {
|
|
252
240
|
const lines = BUILTIN_AGENT_NAMES.map((name) => {
|
|
253
241
|
const profile = AGENT_PROFILES[name];
|
|
254
|
-
|
|
255
|
-
return `${name} — ${profile.summary}${required}. ${profile.remark}`;
|
|
242
|
+
return `${name} — ${profile.summary}. ${profile.remark}`;
|
|
256
243
|
});
|
|
257
244
|
ctx.ui.notify(
|
|
258
245
|
`pi-subagents: ${lines.join(" ")} Pick a model for each role next. Thinking defaults per role (scout low, artisan high, steward medium); change it on a role when you want.`,
|
|
@@ -281,7 +268,6 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
|
|
|
281
268
|
|
|
282
269
|
const next: SubagentsConfig = {
|
|
283
270
|
enabledAgents: enabled,
|
|
284
|
-
knownAgents: [...BUILTIN_AGENT_NAMES],
|
|
285
271
|
agentModels,
|
|
286
272
|
agentThinkingLevels: keepAgentEntries(base.agentThinkingLevels, enabled),
|
|
287
273
|
maxResultLines: base.maxResultLines,
|
|
@@ -299,7 +285,7 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
|
|
|
299
285
|
async function runMenu(ctx: ExtensionCommandContext, configPath: string, config: SubagentsConfig): Promise<void> {
|
|
300
286
|
while (true) {
|
|
301
287
|
const choice = await ctx.ui.select("pi-subagents settings", [
|
|
302
|
-
"Enable/disable agents —
|
|
288
|
+
"Enable/disable agents — choose which roles are available",
|
|
303
289
|
"Configure an agent — model and thinking, with its job on the row",
|
|
304
290
|
"Full re-setup — walk through the team and pick models again",
|
|
305
291
|
]);
|
package/src/spawn.ts
CHANGED
|
@@ -115,16 +115,18 @@ export const RESULT_ARTIFACT_MAX_FILES_PER_PROJECT = 50;
|
|
|
115
115
|
// Explicit current prefix plus the strict timestamp/token convention used by 1.1.0.
|
|
116
116
|
const RESULT_ARTIFACT_NAME = /^(?:pi-subagent-\d{13,}-[0-9a-f]{12}|\d{13,}-[a-z0-9]{6})-[\w.-]+\.md$/;
|
|
117
117
|
|
|
118
|
-
/** Name of the single
|
|
119
|
-
* holds every project-scoped artifact (sessions, worktrees, result excerpts).
|
|
120
|
-
* Nothing long-lived is written to the OS temp directory. */
|
|
118
|
+
/** Name of the single internal-state root under the pi agent directory. */
|
|
121
119
|
export const PROJECT_ROOTS_DIR_NAME = "ferris-pi-subagents";
|
|
122
120
|
|
|
121
|
+
export function getSubagentsRoot(configPath: string): string {
|
|
122
|
+
return join(dirname(configPath), PROJECT_ROOTS_DIR_NAME);
|
|
123
|
+
}
|
|
124
|
+
|
|
123
125
|
/** Per-project directory that groups every durable artifact of one checkout:
|
|
124
126
|
* `<pi home>/ferris-pi-subagents/<project-slug-hash>/{sessions,worktrees,results}`.
|
|
125
127
|
* Callers join the kind-specific subdirectory themselves. */
|
|
126
128
|
export function getProjectRoot(configPath: string, cwd?: string): string {
|
|
127
|
-
return join(
|
|
129
|
+
return join(getSubagentsRoot(configPath), resultArtifactProjectKey(cwd));
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
interface ResultArtifactRetentionOptions {
|