@ferris1225/pi-subagents 4.2.7 → 4.2.8

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.
@@ -1,78 +1,81 @@
1
- /** Session-start recovery, stale-config migration, and widget installation. */
2
-
3
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { existsSync } from "node:fs";
5
- import { loadConfig, saveConfig } from "./config.ts";
6
- import { availableModelsInScope, filterUnavailableModelOverrides } from "./models.ts";
7
- import { announceRecoveryRecords } from "./recovery.ts";
8
- import type { SubagentRuntime } from "./runtime.ts";
9
- import { installActiveRunsWidget } from "./widget.ts";
10
-
11
- /**
12
- * One-time-per-stale-override migration: keep agent model selections Pi still
13
- * reports as available, drop the rest back to dynamic main-model routing, and
14
- * tell the user what was removed. Saving the cleaned config is what makes it
15
- * one-time the dropped refs no longer exist to re-trigger the notice.
16
- */
17
- async function migrateUnavailableAgentModels(
18
- ctx: { ui: { notify: (message: string, kind: "info" | "warning" | "error") => void } } & Parameters<typeof availableModelsInScope>[0],
19
- runtime: SubagentRuntime,
20
- ): Promise<void> {
21
- try {
22
- const config = await loadConfig(runtime.configPath);
23
- const overrides = Object.entries(config.agentModels);
24
- if (overrides.length === 0) return;
25
- const { kept, dropped } = filterUnavailableModelOverrides(config.agentModels, availableModelsInScope(ctx));
26
- if (dropped.length === 0) return;
27
- await saveConfig({ ...config, agentModels: kept }, runtime.configPath);
28
- const list = dropped.map(({ agent, ref }) => `${agent}: ${ref}`).join(", ");
29
- ctx.ui.notify(
30
- `pi-subagents: removed stale agent model overrides that are no longer available (${list}). Those agents now follow the current main model; run /subagents-setup to re-pick.`,
31
- "warning",
32
- );
33
- } catch {
34
- /* migration failures are non-fatal */
35
- }
36
- }
37
-
38
- export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime): void {
39
- pi.on("session_start", async (_event, ctx) => {
40
- if (!existsSync(runtime.configPath)) {
41
- ctx.ui.notify(
42
- "pi-subagents: no configuration yet — run /subagents-setup to pick agents, models, and thinking strengths. Defaults (all built-in agents on the main model) apply until then.",
43
- "info",
44
- );
45
- }
46
- await announceRecoveryRecords(runtime.configPath, ctx);
47
- await migrateUnavailableAgentModels(ctx, runtime);
48
- // Restore starts at extension load and session_start fires right behind
49
- // it, so without this the notice reports whatever the race left behind.
50
- await runtime.durableRestore;
51
- if (!runtime.restoredNotified && runtime.restoredRunIds.length > 0) {
52
- runtime.restoredNotified = true;
53
- const ids = runtime.restoredRunIds.map((id) => `#${id}`).join(", ");
54
- ctx.ui.notify(
55
- `pi-subagents: restored ${runtime.restoredRunIds.length} interrupted thread${runtime.restoredRunIds.length === 1 ? "" : "s"} (${ids}) with retained context. subagent_control resume continues one.`,
56
- "info",
57
- );
58
- }
59
- if (ctx.mode !== "tui") return;
60
- installActiveRunsWidget(ctx);
61
- });
62
-
63
- // Compaction failures are otherwise silent in long orchestration sessions
64
- // where subagent results accumulate; aborted (user-cancelled) compactions
65
- // are deliberate and not worth a notice.
66
- pi.on("session_compact_failed", async (event, ctx) => {
67
- if (event.aborted && !event.errorMessage) return;
68
- const detail = event.errorMessage ? `: ${event.errorMessage}` : "";
69
- if (event.willRetry) {
70
- ctx.ui.notify(`pi-subagents: session compaction failed${detail} — retrying automatically.`, "warning");
71
- return;
72
- }
73
- ctx.ui.notify(
74
- `pi-subagents: session compaction failed${detail}. Long threads may hit context limits soon; run /compact to retry or trim old results.`,
75
- "error",
76
- );
77
- });
78
- }
1
+ /** Session-start recovery, stale-config migration, and progress-surface installation. */
2
+
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { existsSync } from "node:fs";
5
+ import { loadConfig, saveConfig } from "./config.ts";
6
+ import { availableModelsInScope, filterUnavailableModelOverrides } from "./models.ts";
7
+ import { announceRecoveryRecords } from "./recovery.ts";
8
+ import type { SubagentRuntime } from "./runtime.ts";
9
+ import { installActiveRunsStatus } from "./status.ts";
10
+ import { installActiveRunsWidget } from "./widget.ts";
11
+
12
+ /**
13
+ * One-time-per-stale-override migration: keep agent model selections Pi still
14
+ * reports as available, drop the rest back to dynamic main-model routing, and
15
+ * tell the user what was removed. Saving the cleaned config is what makes it
16
+ * one-time — the dropped refs no longer exist to re-trigger the notice.
17
+ */
18
+ async function migrateUnavailableAgentModels(
19
+ ctx: { ui: { notify: (message: string, kind: "info" | "warning" | "error") => void } } & Parameters<typeof availableModelsInScope>[0],
20
+ runtime: SubagentRuntime,
21
+ ): Promise<void> {
22
+ try {
23
+ const config = await loadConfig(runtime.configPath);
24
+ const overrides = Object.entries(config.agentModels);
25
+ if (overrides.length === 0) return;
26
+ const { kept, dropped } = filterUnavailableModelOverrides(config.agentModels, availableModelsInScope(ctx));
27
+ if (dropped.length === 0) return;
28
+ await saveConfig({ ...config, agentModels: kept }, runtime.configPath);
29
+ const list = dropped.map(({ agent, ref }) => `${agent}: ${ref}`).join(", ");
30
+ ctx.ui.notify(
31
+ `pi-subagents: removed stale agent model overrides that are no longer available (${list}). Those agents now follow the current main model; run /subagents-setup to re-pick.`,
32
+ "warning",
33
+ );
34
+ } catch {
35
+ /* migration failures are non-fatal */
36
+ }
37
+ }
38
+
39
+ export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime): void {
40
+ pi.on("session_start", async (_event, ctx) => {
41
+ if (!existsSync(runtime.configPath)) {
42
+ ctx.ui.notify(
43
+ "pi-subagents: no configuration yet — run /subagents-setup to pick agents, models, and thinking strengths. Defaults (all built-in agents on the main model) apply until then.",
44
+ "info",
45
+ );
46
+ }
47
+ await announceRecoveryRecords(runtime.configPath, ctx);
48
+ await migrateUnavailableAgentModels(ctx, runtime);
49
+ // Restore starts at extension load and session_start fires right behind
50
+ // it, so without this the notice reports whatever the race left behind.
51
+ await runtime.durableRestore;
52
+ if (!runtime.restoredNotified && runtime.restoredRunIds.length > 0) {
53
+ runtime.restoredNotified = true;
54
+ const ids = runtime.restoredRunIds.map((id) => `#${id}`).join(", ");
55
+ ctx.ui.notify(
56
+ `pi-subagents: restored ${runtime.restoredRunIds.length} interrupted thread${runtime.restoredRunIds.length === 1 ? "" : "s"} (${ids}) with retained context. subagent_control resume continues one.`,
57
+ "info",
58
+ );
59
+ }
60
+ // The footer status works in every UI host (TUI and RPC); the widget is TUI-only.
61
+ installActiveRunsStatus(ctx);
62
+ if (ctx.mode !== "tui") return;
63
+ installActiveRunsWidget(ctx);
64
+ });
65
+
66
+ // Compaction failures are otherwise silent in long orchestration sessions
67
+ // where subagent results accumulate; aborted (user-cancelled) compactions
68
+ // are deliberate and not worth a notice.
69
+ pi.on("session_compact_failed", async (event, ctx) => {
70
+ if (event.aborted && !event.errorMessage) return;
71
+ const detail = event.errorMessage ? `: ${event.errorMessage}` : "";
72
+ if (event.willRetry) {
73
+ ctx.ui.notify(`pi-subagents: session compaction failed${detail} — retrying automatically.`, "warning");
74
+ return;
75
+ }
76
+ ctx.ui.notify(
77
+ `pi-subagents: session compaction failed${detail}. Long threads may hit context limits soon; run /compact to retry or trim old results.`,
78
+ "error",
79
+ );
80
+ });
81
+ }
package/src/dispatch.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * completion ownership live in thread-lifecycle.ts.
7
7
  */
8
8
 
9
- import { StringEnum } from "@earendil-works/pi-ai";
9
+ import { StringEnum, type Usage } from "@earendil-works/pi-ai";
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import { Text } from "@earendil-works/pi-tui";
12
12
  import { join, resolve } from "node:path";
@@ -19,6 +19,8 @@ import {
19
19
  formatToolActivity,
20
20
  monitor,
21
21
  statusIcon,
22
+ statusLabel,
23
+ sumUsage,
22
24
  type RunView,
23
25
  type RunWaitReason,
24
26
  } from "./monitor.ts";
@@ -32,6 +34,7 @@ import {
32
34
  type SingleResult,
33
35
  type SubagentDetails,
34
36
  type SubagentLiveEvent,
37
+ type UsageStats,
35
38
  } from "./spawn.ts";
36
39
  import {
37
40
  createBackgroundDispatcher,
@@ -116,6 +119,31 @@ export function defaultIsolationMode(
116
119
  return mode === "parallel" && writeCapable ? "worktree" : "shared";
117
120
  }
118
121
 
122
+ /** Map the child's own usage tally onto pi's tool-result `Usage`, so sub-agent
123
+ * token spend lands in the parent's footer, /session, and RPC session totals
124
+ * instead of being invisible. Only the total cost is known here: a child
125
+ * reports one cost number, not a per-bucket split. */
126
+ function toToolUsage(stats: UsageStats): Usage {
127
+ return {
128
+ input: stats.input,
129
+ output: stats.output,
130
+ cacheRead: stats.cacheRead,
131
+ cacheWrite: stats.cacheWrite,
132
+ totalTokens: stats.input + stats.output + stats.cacheRead + stats.cacheWrite,
133
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: stats.cost },
134
+ };
135
+ }
136
+
137
+ /** Usage of the runs awaited in-turn. Omitted entirely in the background path:
138
+ * those children have not finished when the tool returns, so any number there
139
+ * would be a fabrication. */
140
+ function toolUsage(runtime: SubagentRuntime, runIds: number[]): { usage?: Usage } {
141
+ const parts = runIds
142
+ .map((id) => runtime.settledRuns.get(id)?.usage)
143
+ .filter((usage): usage is UsageStats => usage !== undefined);
144
+ return parts.length > 0 ? { usage: toToolUsage(sumUsage(parts)) } : {};
145
+ }
146
+
119
147
  /** In-turn wait behind dispatch `wait: true` — the escape hatch for one-shot
120
148
  * `pi -p` parents that exit at end of turn: hold the call until every run it
121
149
  * started settles, then hand back their result blocks. Interactive sessions
@@ -130,6 +158,7 @@ export async function awaitRunResults(
130
158
  signal: AbortSignal | undefined,
131
159
  maxResultLines: number,
132
160
  fallbackCwd: string,
161
+ onProgress?: (text: string) => void,
133
162
  ): Promise<string> {
134
163
  const waitForRun = (runId: number): Promise<{ result?: SingleResult; note?: string }> => {
135
164
  const already = runtime.settledRuns.get(runId);
@@ -189,12 +218,38 @@ export async function awaitRunResults(
189
218
  else signal?.addEventListener("abort", onAbort, { once: true });
190
219
  });
191
220
  };
192
- const outcomes = await Promise.all(runIds.map(waitForRun));
193
- return outcomes.map((outcome) =>
194
- outcome.result
195
- ? formatCompletionBlock(outcome.result, maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, outcome.result.projectCwd ?? fallbackCwd) })
196
- : (outcome.note ?? "(no outcome)"),
197
- ).join("\n\n");
221
+ // One shared subscription drives the progress line: each waiter already
222
+ // subscribes for its own settlement, and the tool card wants a single
223
+ // rolled-up line rather than one per run.
224
+ let lastProgress: string | undefined;
225
+ const emitProgress = onProgress
226
+ ? (): void => {
227
+ const parts = runIds.map((id) => {
228
+ const settled = runtime.settledRuns.get(id);
229
+ if (settled) return `#${id} ${isFailedResult(settled) ? "failed" : "done"}`;
230
+ const live = monitor.findRun(id);
231
+ return live ? `#${id} ${statusLabel(live.status)}` : `#${id} …`;
232
+ });
233
+ const text = `Waiting in-turn on ${runIds.length} run${runIds.length === 1 ? "" : "s"} · ${parts.join(", ")}`;
234
+ // The monitor notifies on every usage and activity change; this line
235
+ // names only statuses, so most notifications leave it identical.
236
+ if (text === lastProgress) return;
237
+ lastProgress = text;
238
+ onProgress(text);
239
+ }
240
+ : undefined;
241
+ const progressUnsub = emitProgress ? monitor.subscribe(emitProgress) : undefined;
242
+ emitProgress?.();
243
+ try {
244
+ const outcomes = await Promise.all(runIds.map(waitForRun));
245
+ return outcomes.map((outcome) =>
246
+ outcome.result
247
+ ? formatCompletionBlock(outcome.result, maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, outcome.result.projectCwd ?? fallbackCwd) })
248
+ : (outcome.note ?? "(no outcome)"),
249
+ ).join("\n\n");
250
+ } finally {
251
+ progressUnsub?.();
252
+ }
198
253
  }
199
254
 
200
255
  export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
@@ -332,7 +387,13 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
332
387
  "Dispatch isolated background agents for recon, implementation, cleanup, docs sync, or result merging; never blocks your turn, and completions wake you automatically.",
333
388
  parameters: SubagentParams,
334
389
 
335
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
390
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
391
+ // `wait: true` holds this call for minutes and would otherwise show a
392
+ // blank card; the background path returns at once and has nothing to
393
+ // stream. Frames carry the final details shape because renderResult
394
+ // falls back to "(no output)" without it.
395
+ const makeProgress = (details: SubagentDetails): ((text: string) => void) | undefined =>
396
+ onUpdate ? (text: string): void => onUpdate({ content: [{ type: "text", text }], details }) : undefined;
336
397
  // Run ids are allocated below; restore raises the allocator above every
337
398
  // id a durable record still owns, so a dispatch racing it could hand a
338
399
  // fresh run the id of a parked thread and overwrite its record.
@@ -438,7 +499,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
438
499
  const startedIds = startedRuns
439
500
  .map((result) => result.runId)
440
501
  .filter((id): id is number => id !== undefined);
441
- const blocks = await awaitRunResults(runtime, startedIds, signal, config.maxResultLines, ctx.cwd);
502
+ const blocks = await awaitRunResults(runtime, startedIds, signal, config.maxResultLines, ctx.cwd, makeProgress(makeDetails("parallel", true)(results)));
442
503
  const text = [
443
504
  `Started ${started} subagent${started === 1 ? "" : "s"} (${startedRefs.join(", ")}) and waited in-turn.`,
444
505
  ...(failureLines.length > 0
@@ -450,6 +511,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
450
511
  return {
451
512
  content: [{ type: "text", text }],
452
513
  details: makeDetails("parallel", true)(results),
514
+ ...toolUsage(runtime, startedIds),
453
515
  };
454
516
  }
455
517
  const text = [
@@ -483,10 +545,11 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
483
545
  }
484
546
  const runRef = result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`;
485
547
  if (params.wait && result.runId !== undefined) {
486
- const blocks = await awaitRunResults(runtime, [result.runId], signal, config.maxResultLines, ctx.cwd);
548
+ const blocks = await awaitRunResults(runtime, [result.runId], signal, config.maxResultLines, ctx.cwd, makeProgress(makeDetails("single", true)([result])));
487
549
  return {
488
550
  content: [{ type: "text", text: `Started ${runRef} and waited in-turn.\n\n${blocks}` }],
489
551
  details: makeDetails("single", true)([result]),
552
+ ...toolUsage(runtime, [result.runId]),
490
553
  };
491
554
  }
492
555
  return {