@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/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(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
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
- export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
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(getRecoveryManifestPath(configPath), "utf8")) as {
52
- records?: unknown;
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
- settleRun();
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 to the main window, waking it only
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
- if (completionGroupTriggersTurn(items)) {
174
- // steer: the result is injected after the current tool call even mid-turn,
175
- // or starts a new turn when idle. followUp would sit in the queue until the
176
- // whole turn ends a main agent waiting for the result (sleep/poll) would
177
- // never see it delivered, which is exactly the "returned but never woken"
178
- // failure mode.
179
- pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
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
- // No-wake delivery: nextTurn rides along with the next user turn and can
182
- // never start a continuation by itself. followUp would auto-continue
183
- // whenever pi is already streaming, defeating the opt-out.
184
- pi.sendMessage(message, { deliverAs: "nextTurn" });
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: the active-runs
337
- // footer and the steer/nextTurn choice must reflect delivery time, not
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);