@ferris1225/pi-subagents 4.3.11 → 4.3.12

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 CHANGED
@@ -1,8 +1,22 @@
1
1
  # Changelog
2
2
 
3
- Published versions of `@ferris1225/pi-subagents`. Unpublished numbers
4
- (`4.2.3`, `4.2.6`, `4.2.9`–`4.2.11`) never shipped on npm; their changes
5
- landed in the next published release.
3
+ Release notes for `@ferris1225/pi-subagents`. Only the most recent releases
4
+ are kept here; every published version is preserved as a GitHub Release.
5
+
6
+ ## 4.3.12
7
+
8
+ - Replace pi's built-in consumption line with a per-model cost footer for the
9
+ main window: each `provider/model` keeps its own token flow, cost, context
10
+ share, thinking level, and live `tok/s` throughput, directly under the
11
+ current-project line. Settled models rank by spend, cap at three rows, and
12
+ drop token flow before truncating under width pressure. The tally reseeds
13
+ from the session file on reload.
14
+ - Stop attaching awaited children's usage to the `subagent` tool result: pi
15
+ folds tool-result usage into one session total, which merged every model's
16
+ spend into the main window's consumption. Children keep reporting usage per
17
+ run with their model ref when they settle.
18
+ - Group parallel completion totals per model instead of summing different
19
+ models' costs into one number.
6
20
 
7
21
  ## 4.3.11
8
22
 
@@ -80,186 +94,3 @@ landed in the next published release.
80
94
  reviews.
81
95
  - Drop the 4.3.5 retirement filter: configs written by 4.3.5–4.3.7 adopt `sentinel` once
82
96
  through the built-in adoption rule, and a deliberate disable in setup remains disabled.
83
-
84
- ## 4.3.7
85
-
86
- - Make `subagent_control steer` continue rather than reject a thread that is no longer
87
- live: a child that reached `completed` or `failed` before guidance lands (including
88
- settlement between the state check and RPC acceptance) and a `parked` thread both
89
- resume the same stable id with the guidance as their appended objective and retained
90
- context when available.
91
- - Add `subagent_control park`: pause a running thread at a stable checkpoint, keep its
92
- session and active worktree, write its durable record immediately, and return the
93
- usage so far with the resume handle. Only an active running attempt with a retained
94
- session can be parked; the generation body leaves publication to the park owner.
95
- - Reject an exact re-run of a finished brief while the thread that did the work still
96
- holds its retained session, pointing at `resume` with an appended objective or at a
97
- brief that states what changed. Active duplicates are still named first.
98
- - Rewrite the injected delegation directive around the brief contract a memoryless child
99
- needs (objective and done condition, exact paths, established facts with citations,
100
- boundaries, expected output), effort scaling, the steer/resume/park/stop routing for
101
- follow-up work, and reading a truncated result's artifact only when the excerpt is
102
- insufficient. The `subagent` task parameter states the same contract.
103
- - Deepen the built-in roles: scout, artisan, and steward start from the brief's cited
104
- facts and stop at its done condition, resolve ambiguity by naming the reading taken
105
- instead of asking, scout never drafts fixes or patches and marks unverified
106
- conclusions `(inferred)`, artisan stops and reports a wrong premise instead of
107
- substituting a change, steward runs only the checks that cover its own edits, and
108
- every role reports each check as `command → result`.
109
- - Tell a resumed child that the workspace may have changed while the thread was inactive
110
- so it re-reads a file before editing it, and frame an appended-objective resume as a
111
- continuation of the same thread rather than sending the bare objective.
112
- - Clear a stopped generation's recorded child pids once its process tree has closed, so a
113
- long-lived parked record can never direct a later restore at a reassigned pid. Remove
114
- an unreachable objective-replacement prompt branch and the never-populated
115
- `SessionSeed.prompt`.
116
-
117
- ## 4.3.6
118
-
119
- - Add `subagent_control steer` for nonblank, parent-mediated guidance to the current active
120
- RPC attempt without replacing its logical objective. Stable control serialization orders
121
- steering against stop and AbortSignal shutdown; bounded ACKs keep stop responsive.
122
- - Refine the lean delegation directive: main owns architecture; for one high-stakes
123
- uncertainty at most two read-only scouts may examine distinct hypotheses, without
124
- capping unrelated disjoint scout scopes; main reconciles cited evidence, writers
125
- and briefs never overlap, and new evidence steers the existing phase instead of
126
- duplicating or restarting it.
127
- - Steer background completions and stop results into the next parent model boundary
128
- instead of queuing them until the whole parent run settles, preventing stale notifications
129
- from arriving after the main agent has already finished.
130
- - Validate durable session, worktree, repository, and recovery paths against their canonical
131
- project-scoped layout before restore or cleanup. Forged and junction-escaping records are
132
- dropped without deleting external targets.
133
- - Keep worktree and patch artifacts referenced by recovery records through durable sweeps and
134
- stale project-root retention until their recovery notice can be delivered.
135
- - Clarify that worktree isolation protects Git changes rather than sandboxing child privileges
136
- or third-party Pi packages.
137
-
138
- ## 4.3.5
139
-
140
- - Restore `/subagents-setup`'s nested menu flow, fuzzy model picker, and disabled
141
- custom-role discovery.
142
- - Retire the built-in `sentinel` role and mandatory pre-commit review workflow.
143
- Config normalization, loading, and saving now remove its enabled/known entries
144
- and model/thinking overrides while preserving every other custom role.
145
- - Keep role prompts self-contained: direct diagnosis, test, and cleanup rules remain,
146
- while external loading directives are removed.
147
- - Delegate substantial independent phases more proactively while capping the child-process
148
- pool at six and retaining phase leases, duplicate-dispatch rejection, and single-route delivery.
149
- - Move the extension assembly to package-root `index.ts`, group `src/` by responsibility,
150
- and split thread restoration, shared lifecycle coordination, RPC control, and Git command
151
- execution out of the largest modules. Pi now shows the package name without a `:src` suffix.
152
-
153
- ## 4.3.4
154
-
155
- - Keep artisan, steward, and sentinel fully usable when Ferris skills are absent.
156
- Installed matching skills add deeper guidance but are optional and never block a role.
157
-
158
- ## 4.3.3
159
-
160
- - Add `sentinel`, a concise read-only adversarial reviewer that runs after cleanup,
161
- follows artisan's configured model by default, requests maximum supported thinking,
162
- and stays on the shared checkout.
163
- - Replace `/subagents-setup`'s sequential menus with one transactional overlay for
164
- enabled roles, models, and thinking. Cancellation writes nothing; disabled custom
165
- roles remain visible, and newly shipped built-ins are adopted exactly once.
166
- - Show each active run's effective `think:<level>` in the widget.
167
- - Expand scout into primary-source external research, make artisan the complete
168
- primary-change owner, and keep detailed Ferris rules in skills while embedding the
169
- minimum diagnosis, testing, cleanup, and evidence gates in role prompts.
170
-
171
- ## 4.3.2
172
-
173
- - Let scout use active, known-safe retrieval plugins: `anchor_grep`, web content
174
- tools, and Context7 documentation tools. Shells, mutation tools, and unknown
175
- custom tools remain blocked.
176
-
177
- ## 4.3.1
178
-
179
- - Make phase ownership explicit and reject an exact active duplicate by normalized
180
- task plus resolved working directory, regardless of agent name.
181
- - Route each result exactly once: `wait: true` owns in-turn delivery, background
182
- completions use follow-up wakeups, and immediate failures flush earlier successes.
183
- - Move worktree preparation under the bounded queue, release child-process slots
184
- before Git finalization, and report repository-lane versus process-slot waits
185
- accurately.
186
- - Keep missing restored worktrees failed, retained, and non-resumable; compute RPC
187
- usage from generation-safe session-stat deltas.
188
- - Store the worktree recovery manifest under `ferris-pi-subagents/`, relocating
189
- an existing agent-root manifest without losing retained artifact pointers.
190
- - Enforce a strict read-only scout tool set and strict declared-tool intersection.
191
- Unknown custom tools remain conservatively write-capable for isolation.
192
- - Honor `enabledAgents`, including `[]`, without auto-enabling roles. Remove the
193
- completed role/config migration bridge and shorten role prompts, tool metadata,
194
- launch receipts, and handoffs.
195
-
196
- ## 4.3.0
197
-
198
- - Built-in team is `scout`, `artisan`, and `steward`. All three stay enabled.
199
- - `explorer` / `executor` configs rename in place (models and thinking
200
- overrides follow). `steward` is adopted. That migration is deleted in the
201
- next major.
202
- - Artisan owns implement / fix / refactor / test. Steward owns cleanup, docs
203
- sync, and merge — dispatched only when that work exists.
204
- - Thinking is a role default (scout low, artisan high, steward medium) that
205
- `/subagents-setup` can override. Per-call `thinking` and agent-file
206
- `thinking` are gone. There is no Auto row.
207
- - First session and first-run setup explain each role and ask for a model.
208
- - Unit tests cover catalog migration, role prompts, dispatch routing, and
209
- honest footer / truncation notes.
210
-
211
- ## 4.2.13
212
-
213
- - README: table of contents, a What's new lead-in, and a pointer at this
214
- changelog. Release notes describe the live `main` → npm path.
215
-
216
- ## 4.2.12
217
-
218
- - Executor confirms each named defect on current code before editing.
219
- - Footer settled counts stay on the line only while a sibling is live, and
220
- widget truncation no longer lies about what was cut.
221
- - Merging to `main` publishes an unpublished `package.json` version to npm
222
- and opens a matching GitHub Release.
223
-
224
- ## 4.2.8
225
-
226
- - Always-visible footer roll-up: `subagents 2 running · 1 repo lane · 3 done`.
227
- - `wait: true` streams progress onto the tool card and reports child token
228
- spend as the call's own usage.
229
- - Completions are held while context compaction rewrites history, then
230
- released on success, failure, or abort.
231
- - A delivered result no longer enters the parent context a second time.
232
- - Isolated worktrees link `node_modules`.
233
- - Widget worktree badge is spelled out (`worktree:a91f3c`).
234
-
235
- ## 4.2.7
236
-
237
- - Executor routing is a single self-contained deliverable; `thinking` can
238
- be set per dispatch.
239
- - Child prompt temp directories are removed recursively.
240
-
241
- ## 4.2.5
242
-
243
- - The threads manifest lives per project, beside that project's artifacts.
244
-
245
- ## 4.2.4
246
-
247
- - Explorer findings are one-line retrieval leads.
248
- - Worktree recovery retries cleanup when the patch was already applied.
249
-
250
- ## 4.2.2
251
-
252
- - A single artifact the main agent must fully absorb stays an inline read;
253
- re-reads are bounded.
254
-
255
- ## 4.2.1
256
-
257
- - Upgraded configs prune retired built-in roles so the setup wizard never
258
- mixes old and new names.
259
-
260
- ## 4.2.0
261
-
262
- - Built-in team is `explorer` and `executor`. The old
263
- `worker` / `cleaner` / `documenter` / `synthesizer` / `reviewer` set is
264
- gone.
265
- - Live widget splits each run into an identity line and a dim activity line.
package/README.md CHANGED
@@ -12,10 +12,11 @@ once and your main agent delegates on its own.
12
12
 
13
13
  ## What's new
14
14
 
15
- **4.3.11** — simplifies delegation and role prompts using GPT-6 Astra guidance:
16
- clear outcomes, task-sized research and verification, and optional cleanup/review
17
- instead of fixed routines. Existing permission boundaries, required project checks,
18
- one-shot ownership, and runtime safety mechanisms remain unchanged.
15
+ **4.3.12** — per-model accounting: the main window's consumption line becomes a
16
+ per-model cost footer (token flow, cost, context share, and live `tok/s` per
17
+ `provider/model`), awaited children's usage is no longer folded into the parent
18
+ session total, and parallel completion totals are grouped per model instead of
19
+ summed across them.
19
20
 
20
21
  See [CHANGELOG.md](./CHANGELOG.md).
21
22
 
@@ -367,6 +368,29 @@ RPC hosts as well as the TUI. Settled counts stay on the line only while a
367
368
  sibling is still live (`2 running · 3 done`); the line disappears once nothing
368
369
  is active.
369
370
 
371
+ ### Per-model cost footer
372
+
373
+ In TUI sessions the extension replaces pi's built-in consumption line with a
374
+ per-model tally of the main window's own spend, directly under the
375
+ current-project line. Models are never merged: each `provider/model` keeps its
376
+ own token flow, cost, and — for the current model — context share, effective
377
+ thinking level, and live throughput (`~` marks the streaming estimate; the
378
+ exact rate of the last completed message replaces it):
379
+
380
+ ```text
381
+ ~/projs/app (main)
382
+ ↑48.1k ↓112.7k R1.9M W302.4k $3.0812 · 41.2%/200.0k · ~58.3 tok/s zhipu/glm-4.7 • high
383
+ anthropic/claude-sonnet-4 ↑12.0k ↓31.2k R410.0k $0.9104
384
+ ```
385
+
386
+ Settled models rank by spend and cap at three rows (`… +N more models`);
387
+ under width pressure a settled row drops its token flow before truncating, so
388
+ `model $cost` always survives. Sub-agent spend is deliberately not folded in:
389
+ children report their own usage per run when they settle, and pi would
390
+ otherwise attribute it to one session total — exactly the cross-model merge
391
+ this footer exists to avoid. The ledger reseeds from the session file on
392
+ reload, so the tally survives restarts.
393
+
370
394
  Completions resume the main agent on their own, with a compact block of at most 40
371
395
  lines by default; longer output lands unchanged in a Markdown artifact whose path
372
396
  comes with the message, stated as how much was actually cut (`40 of 137 lines
@@ -381,11 +405,19 @@ Delivery is held while a context compaction is in flight and released once it
381
405
  settles — on failure and abort too — so a result a child spent minutes producing
382
406
  is never swallowed by the summary that replaces the history.
383
407
 
384
- A `wait: true` dispatch streams its progress onto the tool card while it waits,
385
- and reports the awaited children's token spend as the tool call's own usage, so
386
- sub-agent cost lands in the footer, `/session`, and RPC session totals. A
387
- background dispatch returns before its children finish, so it reports no usage
388
- rather than a fabricated number.
408
+ A `wait: true` dispatch streams its progress onto the tool card while it waits.
409
+ Usage is no longer attached to the tool result: pi folds tool-result usage into
410
+ one session total, which merged every model's spend into the main window's
411
+ consumption line. Instead, each child reports its usage per run with its full
412
+ `provider/model` ref — when it settles, and a parallel group's footer totals
413
+ are grouped per model, never summed across them:
414
+
415
+ ```text
416
+ Totals: 3 runs · zhipu/glm-4.7: ↓300 $0.0500 · anthropic/claude-sonnet-4: ↓4.0k $1.5000
417
+ ```
418
+
419
+ A background dispatch returns before its children finish; their usage arrives
420
+ with the completion message instead.
389
421
 
390
422
  ## Models, thinking, and tools
391
423
 
@@ -543,7 +575,9 @@ It is not bundled into the extension, and no replacement RPC server is introduce
543
575
 
544
576
  ## Changelog
545
577
 
546
- See [CHANGELOG.md](./CHANGELOG.md) for published release notes.
578
+ See [CHANGELOG.md](./CHANGELOG.md) for the latest release notes. Every
579
+ published version is preserved as a GitHub Release; older entries are trimmed
580
+ from the file.
547
581
 
548
582
  ## Release
549
583
 
package/index.ts CHANGED
@@ -31,6 +31,8 @@ import { createRuntime } from "./src/lifecycle/runtime.ts";
31
31
  import { bootstrapDurableState } from "./src/lifecycle/thread-restore.ts";
32
32
  import { registerLookupTools } from "./src/lifecycle/tools.ts";
33
33
  import { registerAnnouncements } from "./src/presentation/announcements.ts";
34
+ import { registerMainCostTracking } from "./src/presentation/cost-ledger.ts";
35
+ import { clearCostFooter } from "./src/presentation/cost-footer.ts";
34
36
  import { matchRunIds } from "./src/presentation/format.ts";
35
37
  import { clearActiveRunsStatus } from "./src/presentation/status.ts";
36
38
  import { clearActiveRunsWidget } from "./src/presentation/widget.ts";
@@ -66,12 +68,14 @@ export default function (pi: ExtensionAPI): void {
66
68
  pi.on("session_shutdown", async (_event, ctx) => {
67
69
  clearActiveRunsStatus(ctx);
68
70
  clearActiveRunsWidget(ctx);
71
+ clearCostFooter(ctx);
69
72
  await runtime.shutdown();
70
73
  });
71
74
 
72
75
  registerSubagentTool(pi, runtime);
73
76
  registerSubagentRiskTool(pi);
74
77
  registerLookupTools(pi, runtime);
78
+ registerMainCostTracking(pi);
75
79
 
76
80
  pi.registerCommand("subagents-setup", {
77
81
  description: "Configure pi-subagents: agents, models, and per-role thinking",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "4.3.11",
3
+ "version": "4.3.12",
4
4
  "description": "A managed sub-agent team for pi: scout, artisan, steward, and sentinel roles, one-shot runs, read-only status, and Git worktree isolation.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -6,7 +6,7 @@
6
6
  * completion ownership live in thread-lifecycle.ts.
7
7
  */
8
8
 
9
- import { StringEnum, type Usage } from "@earendil-works/pi-ai";
9
+ import { StringEnum } from "@earendil-works/pi-ai";
10
10
  import { resolve } from "node:path";
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { Text } from "@earendil-works/pi-tui";
@@ -20,7 +20,6 @@ import {
20
20
  monitor,
21
21
  statusIcon,
22
22
  statusLabel,
23
- sumUsage,
24
23
  type RunWaitReason,
25
24
  } from "../presentation/monitor.ts";
26
25
  import { findDuplicateDispatch, formatParallelScopeAdmissionNote, formatPhaseLeaseReceipt } from "./prompt.ts";
@@ -227,30 +226,10 @@ function parallelAdmissionConflict(
227
226
  return undefined;
228
227
  }
229
228
 
230
- /** Map the child's own usage tally onto pi's tool-result `Usage`, so sub-agent
231
- * token spend lands in the parent's footer, /session, and RPC session totals
232
- * instead of being invisible. Only the total cost is known here: a child
233
- * reports one cost number, not a per-bucket split. */
234
- function toToolUsage(stats: UsageStats): Usage {
235
- return {
236
- input: stats.input,
237
- output: stats.output,
238
- cacheRead: stats.cacheRead,
239
- cacheWrite: stats.cacheWrite,
240
- totalTokens: stats.input + stats.output + stats.cacheRead + stats.cacheWrite,
241
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: stats.cost },
242
- };
243
- }
244
-
245
- /** Usage of the runs awaited in-turn. Omitted entirely in the background path:
246
- * those children have not finished when the tool returns, so any number there
247
- * would be a fabrication. */
248
- function toolUsage(runtime: SubagentRuntime, runIds: number[]): { usage?: Usage } {
249
- const parts = runIds
250
- .map((id) => runtime.settledRuns.get(id)?.usage)
251
- .filter((usage): usage is UsageStats => usage !== undefined);
252
- return parts.length > 0 ? { usage: toToolUsage(sumUsage(parts)) } : {};
253
- }
229
+ /** Awaited children report their usage per run and per model in the result
230
+ * blocks below; nothing is attached to the tool result itself, because pi
231
+ * folds tool-result usage into one session total that merged every
232
+ * model's spend into the main window's consumption line. */
254
233
 
255
234
  /** In-turn wait for a fresh dispatch. Registration resolves it without a model-chosen
256
235
  * timer; parent abort or removal ends the wait without losing background delivery. */
@@ -631,7 +610,6 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
631
610
  return {
632
611
  content: [{ type: "text", text }],
633
612
  details: makeDetails("parallel", true)(results),
634
- ...toolUsage(runtime, startedIds),
635
613
  };
636
614
  }
637
615
  const text = [
@@ -686,7 +664,6 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
686
664
  return {
687
665
  content: [{ type: "text", text: blocks }],
688
666
  details: makeDetails("single", true)([result]),
689
- ...toolUsage(runtime, [result.runId]),
690
667
  };
691
668
  }
692
669
  return {
@@ -7,9 +7,8 @@
7
7
  * failure directly so it is never delayed.
8
8
  */
9
9
 
10
- import { formatUsageCompact, sumUsage, type RunWaitReason } from "../presentation/monitor.ts";
10
+ import { formatUsageTokens, sumUsage, type RunWaitReason } from "../presentation/monitor.ts";
11
11
  import type { UsageStats } from "../execution/rpc-control.ts";
12
-
13
12
  export interface CompletionBatchTimings {
14
13
  debounceMs: number;
15
14
  maxWaitMs: number;
@@ -100,20 +99,42 @@ export interface CompletionMessageItem {
100
99
  block: string;
101
100
  /** Final usage of the underlying run (or chain); aggregated into the group totals. */
102
101
  usage?: UsageStats;
102
+ /** Model ref that produced this run's usage; group totals stay per model
103
+ * instead of summing different models' spend into one number. */
104
+ model?: string;
103
105
  }
104
106
 
105
- /** Keep the established single-result shape; add a group header and an aggregate
107
+ /** Keep the established single-result shape; add a group header and a per-model
106
108
  * token/cost footer only for real groups. */
107
109
  export function formatCompletionMessage(items: readonly CompletionMessageItem[]): string {
108
110
  if (items.length === 0) return "";
109
111
  if (items.length === 1) return items[0].block;
110
112
  const agents = items.map((item) => item.agent).join(", ");
111
- const withUsage = items.filter((item) => item.usage !== undefined);
112
- const totals = withUsage.length > 0 ? formatUsageCompact(sumUsage(withUsage.map((item) => item.usage!))) : "";
113
+ const totals = perModelTotals(items);
113
114
  const footer = totals ? `\n\nTotals: ${items.length} runs · ${totals}` : "";
114
115
  return `### Subagents completed (${items.length}): ${agents}\n\n${items.map((item) => item.block).join("\n\n")}${footer}`;
115
116
  }
116
117
 
118
+ /** One `model ↑x ↓y $z` segment per model, first-seen order — models are never
119
+ * merged, because each model's spend comes out of its own budget. The cost is
120
+ * always present (even `$0.0000`) so every model line reads as a tally. */
121
+ export function perModelTotals(items: readonly CompletionMessageItem[]): string {
122
+ const byModel = new Map<string, UsageStats[]>();
123
+ for (const item of items) {
124
+ if (item.usage === undefined) continue;
125
+ const key = item.model?.trim() || "unknown model";
126
+ byModel.set(key, [...(byModel.get(key) ?? []), item.usage]);
127
+ }
128
+ if (byModel.size === 0) return "";
129
+ return [...byModel.entries()]
130
+ .map(([model, parts]) => {
131
+ const total = sumUsage(parts);
132
+ const tokens = formatUsageTokens(total);
133
+ return `${model}: ${tokens ? `${tokens} ` : ""}$${total.cost.toFixed(4)}`;
134
+ })
135
+ .join(" · ");
136
+ }
137
+
117
138
  /** Minimal shape of an active run, for the "others still running" footer. Kept
118
139
  * decoupled from the monitor's RunView so this stays a pure, easily tested
119
140
  * formatter; the caller maps its live runs into this shape. */
@@ -343,6 +343,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
343
343
  ? `${formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) })}\n\n${modelLevelTakeoverNote(result)}`
344
344
  : formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) }),
345
345
  usage: result.usage,
346
+ model: result.model,
346
347
  };
347
348
  if (modelLevel) {
348
349
  const detail = result.errorMessage?.trim() || "model unavailable or broken";
@@ -6,6 +6,8 @@ import { FIRST_RUN_SETUP_HINT, loadConfig, saveConfig } from "../configuration/c
6
6
  import { availableModelsInScope, filterUnavailableModelOverrides } from "../configuration/models.ts";
7
7
  import { announceRecoveryRecords, relocateRecoveryManifest } from "../isolation/recovery.ts";
8
8
  import type { SubagentRuntime } from "../lifecycle/runtime.ts";
9
+ import { seedCostLedgerFromSession } from "./cost-ledger.ts";
10
+ import { installCostFooter } from "./cost-footer.ts";
9
11
  import { installActiveRunsStatus } from "./status.ts";
10
12
  import { installActiveRunsWidget } from "./widget.ts";
11
13
 
@@ -50,10 +52,14 @@ export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime
50
52
  "info",
51
53
  );
52
54
  }
53
- // The footer status works in every UI host (TUI and RPC); the widget is TUI-only.
55
+ // The footer status works in every UI host (TUI and RPC); the widget and
56
+ // the per-model cost footer are TUI-only. Seeding first means the first
57
+ // footer render already carries the reloaded session's per-model spend.
54
58
  installActiveRunsStatus(ctx);
55
59
  if (ctx.mode !== "tui") return;
60
+ seedCostLedgerFromSession(ctx);
56
61
  installActiveRunsWidget(ctx);
62
+ installCostFooter(ctx);
57
63
  });
58
64
 
59
65
  // Compaction failures are otherwise silent in long orchestration sessions
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Persistent cost footer: replaces pi's built-in consumption line with a
3
+ * per-model tally of the main window's own spend, so models are never summed
4
+ * into one number — each `provider/model` keeps its own token flow, cost,
5
+ * context share, and live throughput. The current-project line stays first,
6
+ * exactly where pi put it; extension statuses stay last.
7
+ *
8
+ * Sub-agent spend is intentionally absent: children report per-run usage with
9
+ * their model ref when they settle, and injecting it here (or into the parent
10
+ * session totals) is what mixed unrelated models' costs before.
11
+ */
12
+
13
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
14
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
+ import { isAbsolute, relative, resolve, sep } from "node:path";
16
+ import { stripVTControlCharacters } from "node:util";
17
+ import { costLedger, latestTrackedContext, type ModelSpendRow } from "./cost-ledger.ts";
18
+ import { formatTokens, formatUsageTokens } from "./monitor.ts";
19
+ import type { UsageStats } from "../execution/rpc-control.ts";
20
+
21
+ /** Footer data as injected by pi's `setFooter` factory; only the read-only
22
+ * surface exists in types, so keep the structural shape local. */
23
+ interface FooterData {
24
+ getGitBranch(): string | null;
25
+ getExtensionStatuses(): ReadonlyMap<string, string>;
26
+ onBranchChange(callback: () => void): () => void;
27
+ }
28
+
29
+ /** Context pieces the footer renders against; falls back to the latest event
30
+ * context so a fresh install still sees live session state. */
31
+ type RenderContext = Partial<Pick<ExtensionContext, "sessionManager" | "getContextUsage" | "model" | "thinkingLevel">>;
32
+
33
+ /** Minimum gap between the stats left side and the right-aligned model. */
34
+ const MIN_PADDING = 2;
35
+ /** Settled-model rows shown after the current one; anything older collapses
36
+ * into one overflow marker so the footer's height stays bounded no matter how
37
+ * many models a session switched through. */
38
+ const MAX_SETTLED_MODEL_ROWS = 3;
39
+
40
+ /** Home-relative project path, matching the built-in footer's `~` form. */
41
+ function formatProjectPath(cwd: string, home: string | undefined): string {
42
+ if (!home) return cwd;
43
+ const resolvedCwd = resolve(cwd);
44
+ const resolvedHome = resolve(home);
45
+ const relativeToHome = relative(resolvedHome, resolvedCwd);
46
+ const isInsideHome = relativeToHome === "" ||
47
+ (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
48
+ if (!isInsideHome) return cwd;
49
+ return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
50
+ }
51
+
52
+ /** The `12%/200k` context share of the current model; colored by pressure
53
+ * like the built-in footer did. */
54
+ function contextPart(theme: Theme, usage: { percent?: number | null; contextWindow?: number } | undefined): string {
55
+ if (!usage || !usage.contextWindow || usage.contextWindow <= 0) return "";
56
+ const percent = usage.percent === null || usage.percent === undefined
57
+ ? "?"
58
+ : usage.percent.toFixed(1);
59
+ const display = `${percent}%/${formatTokens(usage.contextWindow)}`;
60
+ if (typeof usage.percent === "number" && usage.percent > 90) return theme.fg("error", display);
61
+ if (typeof usage.percent === "number" && usage.percent > 70) return theme.fg("warning", display);
62
+ return display;
63
+ }
64
+
65
+ function speedPart(speed: { tokensPerSecond: number; streaming: boolean } | undefined): string {
66
+ if (!speed || speed.tokensPerSecond <= 0) return "";
67
+ return `${speed.streaming ? "~" : ""}${speed.tokensPerSecond.toFixed(1)} tok/s`;
68
+ }
69
+
70
+ function spendUsage(spend: ModelSpendRow["spend"]): UsageStats {
71
+ return { ...spend, contextTokens: 0, turns: 0 };
72
+ }
73
+
74
+ /** One `↑in ↓out R r W w $cost` flow for a model row; zero components drop
75
+ * out, but a model with no spend at all still shows its bare `$0.0000` so the
76
+ * row reads as a tally rather than an empty label. */
77
+ function spendPart(spend: ModelSpendRow["spend"]): string {
78
+ return [formatUsageTokens(spendUsage(spend)), `$${spend.cost.toFixed(4)}`].filter(Boolean).join(" ");
79
+ }
80
+
81
+ /** Current-model row: stats left, model identity right-aligned — the built-in
82
+ * footer's geometry, applied to the ledger row. */
83
+ function currentModelLine(
84
+ row: ModelSpendRow,
85
+ theme: Theme,
86
+ width: number,
87
+ ctx: RenderContext,
88
+ ): string {
89
+ const parts = [spendPart(row.spend)];
90
+ const context = contextPart(theme, ctx.getContextUsage?.());
91
+ if (context) parts.push(context);
92
+ const speed = costLedger.speed();
93
+ if (speed && speed.model === row.model) {
94
+ const part = speedPart(speed);
95
+ if (part) parts.push(part);
96
+ }
97
+ let left = parts.join(" ");
98
+ if (visibleWidth(left) > width) left = truncateToWidth(left, width, "…");
99
+
100
+ let right = row.model;
101
+ if (ctx.model?.reasoning && ctx.thinkingLevel) {
102
+ right = ctx.thinkingLevel === "off" ? `${right} • thinking off` : `${right} • ${ctx.thinkingLevel}`;
103
+ }
104
+ // The provider prefix duplicates the ref's own `provider/` half, so keep it
105
+ // only for the bare-id shape a providerless ref produces.
106
+ const leftWidth = visibleWidth(left);
107
+ if (leftWidth + MIN_PADDING + visibleWidth(right) > width) {
108
+ const available = width - leftWidth - MIN_PADDING;
109
+ if (available <= 0) return theme.fg("dim", left);
110
+ right = truncateToWidth(right, available, "");
111
+ }
112
+ const padding = " ".repeat(Math.max(MIN_PADDING, width - leftWidth - visibleWidth(right)));
113
+ return theme.fg("dim", `${left}${padding}${right}`);
114
+ }
115
+
116
+ /** Settled-model row: the model ref leads because that is the line's whole
117
+ * point — which model, what it cost. Under width pressure the token flow drops
118
+ * first (the cost is the headline), then the line truncates. */
119
+ function settledModelLine(row: ModelSpendRow, theme: Theme, width: number): string {
120
+ const full = `${row.model} ${spendPart(row.spend)}`;
121
+ if (visibleWidth(full) <= width) return theme.fg("dim", full);
122
+ const bare = `${row.model} $${row.spend.cost.toFixed(4)}`;
123
+ if (visibleWidth(bare) <= width) return theme.fg("dim", bare);
124
+ return truncateToWidth(theme.fg("dim", full), width, "…");
125
+ }
126
+
127
+ /** Render the replacement footer: project line first, one line per model the
128
+ * main window spent on (current model with context + throughput), extension
129
+ * statuses last — the same slots the built-in footer used. Settled models are
130
+ * ranked by spend and capped, so a session that switched through many models
131
+ * never grows the footer without bound. */
132
+ export function renderCostFooter(
133
+ width: number,
134
+ theme: Theme,
135
+ footerData: FooterData | undefined,
136
+ ctx: RenderContext,
137
+ ): string[] {
138
+ const live = latestTrackedContext() ?? ctx;
139
+ const sessionManager = live.sessionManager ?? ctx.sessionManager;
140
+ const lines: string[] = [];
141
+
142
+ let project = formatProjectPath(sessionManager?.getCwd() ?? "", process.env.HOME || process.env.USERPROFILE);
143
+ const branch = footerData?.getGitBranch();
144
+ if (branch) project = `${project} (${branch})`;
145
+ const sessionName = sessionManager?.getSessionName();
146
+ if (sessionName) project = `${project} • ${sessionName}`;
147
+ lines.push(truncateToWidth(theme.fg("dim", project), width, theme.fg("dim", "…")));
148
+
149
+ const rows = costLedger.snapshot();
150
+ if (rows.length > 0) {
151
+ const current = rows.find((row) => row.current);
152
+ if (current) lines.push(currentModelLine(current, theme, width, live));
153
+ const settled = rows
154
+ .filter((row) => !row.current)
155
+ .sort((left, right) => right.spend.cost - left.spend.cost);
156
+ for (const row of settled.slice(0, MAX_SETTLED_MODEL_ROWS)) {
157
+ lines.push(settledModelLine(row, theme, width));
158
+ }
159
+ const hidden = settled.length - MAX_SETTLED_MODEL_ROWS;
160
+ if (hidden > 0) {
161
+ lines.push(theme.fg("dim", `… +${hidden} more model${hidden === 1 ? "" : "s"}`));
162
+ }
163
+ }
164
+
165
+ const statuses = footerData?.getExtensionStatuses();
166
+ if (statuses && statuses.size > 0) {
167
+ const statusLine = [...statuses.entries()]
168
+ .sort(([left], [right]) => left.localeCompare(right))
169
+ .map(([, text]) => stripVTControlCharacters(text).replace(/[\r\n\t]+/g, " ").replace(/ +/g, " ").trim())
170
+ .join(" ");
171
+ lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "…")));
172
+ }
173
+ return lines;
174
+ }
175
+
176
+ /**
177
+ * Replace pi's built-in footer with the per-model cost footer. The factory
178
+ * re-renders on ledger updates and git branch changes; context, model, and
179
+ * thinking state come from the freshest event context available.
180
+ */
181
+ export function installCostFooter(ctx: Pick<ExtensionContext, "mode" | "ui"> & RenderContext): void {
182
+ if (ctx.mode !== "tui") return;
183
+ ctx.ui.setFooter((tui, theme, footerData) => {
184
+ const unsubscribe = [
185
+ costLedger.subscribe(() => tui.requestRender()),
186
+ footerData.onBranchChange(() => tui.requestRender()),
187
+ ];
188
+ return {
189
+ render: (width: number) => renderCostFooter(width, theme, footerData, ctx),
190
+ invalidate() {},
191
+ dispose() {
192
+ for (const stop of unsubscribe) stop();
193
+ },
194
+ };
195
+ });
196
+ }
197
+
198
+ /** Restore pi's built-in footer. */
199
+ export function clearCostFooter(ctx: Pick<ExtensionContext, "mode" | "ui">): void {
200
+ if (ctx.mode === "tui") ctx.ui.setFooter(undefined);
201
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Per-model spend ledger for the main window's own generation.
3
+ *
4
+ * Sub-agent runs are deliberately not tracked here: every child reports its
5
+ * usage with its model ref when it settles (completion block, widget row,
6
+ * per-model completion totals), so mixing its live spend into the parent's
7
+ * footer would just re-create the cross-model sum this ledger exists to
8
+ * avoid. The ledger covers the main window only — across `/model` switches,
9
+ * each model keeps its own tally — plus its live token throughput.
10
+ */
11
+
12
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { modelRef } from "../configuration/models.ts";
14
+
15
+ export interface ModelSpend {
16
+ input: number;
17
+ output: number;
18
+ cacheRead: number;
19
+ cacheWrite: number;
20
+ cost: number;
21
+ }
22
+
23
+ export interface ModelSpendRow {
24
+ /** Full `provider/model` ref. */
25
+ model: string;
26
+ /** True while this is the window's current model. */
27
+ current: boolean;
28
+ spend: ModelSpend;
29
+ }
30
+
31
+ export interface MainStreamSpeed {
32
+ model: string;
33
+ tokensPerSecond: number;
34
+ /** True while the assistant is still streaming (value is an estimate). */
35
+ streaming: boolean;
36
+ }
37
+
38
+ interface StreamState {
39
+ model: string;
40
+ startedAt: number;
41
+ estimatedChars: number;
42
+ }
43
+
44
+ /** Rough chars-per-token for the live estimate; the exact rate replaces it at
45
+ * `message_end`, where real output tokens are known. */
46
+ const ESTIMATED_CHARS_PER_TOKEN = 4;
47
+
48
+ /** pi Usage shape: numeric token buckets plus a cost that is either already a
49
+ * number (child tallies) or the provider object with a `total`. */
50
+ interface UsageLike {
51
+ input?: unknown;
52
+ output?: unknown;
53
+ cacheRead?: unknown;
54
+ cacheWrite?: unknown;
55
+ cost?: unknown;
56
+ }
57
+
58
+ function emptySpend(): ModelSpend {
59
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
60
+ }
61
+
62
+ /** `provider/model` for a message; falls back to the bare model id when a
63
+ * providerless message sneaks through. */
64
+ export function messageModelRef(message: { provider?: string; model?: string }): string | undefined {
65
+ const model = message.model?.trim();
66
+ if (!model) return undefined;
67
+ return message.provider?.trim() ? `${message.provider.trim()}/${model}` : model;
68
+ }
69
+
70
+ function finiteUsage(usage: UsageLike | undefined): ModelSpend {
71
+ const num = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) ? value : 0);
72
+ const cost = usage?.cost;
73
+ return {
74
+ input: num(usage?.input),
75
+ output: num(usage?.output),
76
+ cacheRead: num(usage?.cacheRead),
77
+ cacheWrite: num(usage?.cacheWrite),
78
+ cost: num(typeof cost === "object" && cost !== null ? (cost as { total?: unknown }).total : cost),
79
+ };
80
+ }
81
+
82
+ export class CostLedger {
83
+ private rows = new Map<string, ModelSpend>();
84
+ /** First-seen order, so row order is stable between notifications. */
85
+ private order: string[] = [];
86
+ private currentModel: string | undefined;
87
+ private stream: StreamState | undefined;
88
+ private lastSpeed: MainStreamSpeed | undefined;
89
+ private subscribers = new Set<() => void>();
90
+
91
+ record(model: string, spend: UsageLike | undefined): void {
92
+ const ref = model.trim();
93
+ if (!ref) return;
94
+ const row = this.rows.get(ref) ?? emptySpend();
95
+ const add = finiteUsage(spend);
96
+ row.input += add.input;
97
+ row.output += add.output;
98
+ row.cacheRead += add.cacheRead;
99
+ row.cacheWrite += add.cacheWrite;
100
+ row.cost += add.cost;
101
+ if (!this.rows.has(ref)) this.order.push(ref);
102
+ this.rows.set(ref, row);
103
+ this.notify();
104
+ }
105
+
106
+ markCurrentModel(model: string | undefined): void {
107
+ const ref = model?.trim() || undefined;
108
+ if (ref === this.currentModel) return;
109
+ this.currentModel = ref;
110
+ this.notify();
111
+ }
112
+
113
+ getCurrentModel(): string | undefined {
114
+ return this.currentModel;
115
+ }
116
+
117
+ /** Current model first, everything else in first-seen order. A current
118
+ * model with no spend yet still gets a zero row so the footer keeps its
119
+ * shape from the first render. */
120
+ snapshot(): ModelSpendRow[] {
121
+ const rows: ModelSpendRow[] = [];
122
+ if (this.currentModel) {
123
+ rows.push({ model: this.currentModel, current: true, spend: this.rows.get(this.currentModel) ?? emptySpend() });
124
+ }
125
+ for (const model of this.order) {
126
+ if (model === this.currentModel) continue;
127
+ rows.push({ model, current: false, spend: this.rows.get(model) ?? emptySpend() });
128
+ }
129
+ return rows;
130
+ }
131
+
132
+ reset(): void {
133
+ this.rows.clear();
134
+ this.order = [];
135
+ this.currentModel = undefined;
136
+ this.stream = undefined;
137
+ this.lastSpeed = undefined;
138
+ this.notify();
139
+ }
140
+
141
+ subscribe(cb: () => void): () => void {
142
+ this.subscribers.add(cb);
143
+ return () => {
144
+ this.subscribers.delete(cb);
145
+ };
146
+ }
147
+
148
+ noteStreamStart(model: string): void {
149
+ this.stream = { model, startedAt: Date.now(), estimatedChars: 0 };
150
+ this.lastSpeed = undefined;
151
+ this.notify();
152
+ }
153
+
154
+ noteStreamDelta(chars: number): void {
155
+ if (!this.stream || chars <= 0) return;
156
+ this.stream.estimatedChars += chars;
157
+ this.notify();
158
+ }
159
+
160
+ isStreaming(): boolean {
161
+ return this.stream !== undefined;
162
+ }
163
+
164
+ noteStreamEnd(model: string, outputTokens: number): void {
165
+ const stream = this.stream;
166
+ this.stream = undefined;
167
+ if (stream && outputTokens > 0) {
168
+ const seconds = Math.max(0.001, (Date.now() - stream.startedAt) / 1_000);
169
+ this.lastSpeed = { model, tokensPerSecond: outputTokens / seconds, streaming: false };
170
+ }
171
+ this.notify();
172
+ }
173
+
174
+ /** Live estimate while streaming, exact rate of the last completed
175
+ * assistant message afterwards; undefined before any generation. */
176
+ speed(): MainStreamSpeed | undefined {
177
+ if (this.stream) {
178
+ const seconds = Math.max(0.001, (Date.now() - this.stream.startedAt) / 1_000);
179
+ return {
180
+ model: this.stream.model,
181
+ tokensPerSecond: this.stream.estimatedChars / ESTIMATED_CHARS_PER_TOKEN / seconds,
182
+ streaming: true,
183
+ };
184
+ }
185
+ return this.lastSpeed;
186
+ }
187
+
188
+ private notify(): void {
189
+ for (const cb of this.subscribers) {
190
+ try {
191
+ cb();
192
+ } catch {
193
+ /* subscriber errors must not break accounting */
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ export const costLedger = new CostLedger();
200
+
201
+ /** Latest event context, so the footer renders against live session state
202
+ * (context usage, cwd, session name) without holding a stale install-time ctx. */
203
+ let latestContext: ExtensionContext | undefined;
204
+
205
+ /** Wire the main window's own generation into the ledger: assistant messages
206
+ * carry the serving model and exact usage, model switches re-key the current
207
+ * row, and compaction calls land on the model that made them. */
208
+ export function registerMainCostTracking(pi: ExtensionAPI): void {
209
+ const remember = (ctx: ExtensionContext): void => {
210
+ latestContext = ctx;
211
+ };
212
+
213
+ pi.on("message_start", async (event, ctx) => {
214
+ remember(ctx);
215
+ const message = (event as { message?: { role?: string } }).message;
216
+ if (message?.role !== "assistant") return;
217
+ const ref = messageModelRef(message as { provider?: string; model?: string });
218
+ if (ref) costLedger.noteStreamStart(ref);
219
+ });
220
+
221
+ pi.on("message_update", async (event, ctx) => {
222
+ remember(ctx);
223
+ const delta = (event as { assistantMessageEvent?: { type?: string; delta?: string } }).assistantMessageEvent;
224
+ if (delta?.type !== "text_delta" && delta?.type !== "thinking_delta") return;
225
+ // `message_start` does not always carry the model yet; the first delta
226
+ // of the stream is just as good a clock start.
227
+ if (!costLedger.isStreaming()) {
228
+ const partial = (event as { message?: { role?: string } }).message;
229
+ if (partial?.role === "assistant") {
230
+ const ref = messageModelRef(partial as { provider?: string; model?: string });
231
+ if (ref) costLedger.noteStreamStart(ref);
232
+ }
233
+ }
234
+ costLedger.noteStreamDelta(delta.delta?.length ?? 0);
235
+ });
236
+
237
+ pi.on("message_end", async (event, ctx) => {
238
+ remember(ctx);
239
+ const message = (event as { message?: { role?: string; provider?: string; model?: string; usage?: UsageLike } }).message;
240
+ if (message?.role !== "assistant") return;
241
+ const ref = messageModelRef(message) ?? costLedger.getCurrentModel();
242
+ if (!ref) return;
243
+ const usage = finiteUsage(message.usage);
244
+ costLedger.noteStreamEnd(ref, usage.output);
245
+ costLedger.record(ref, usage);
246
+ });
247
+
248
+ pi.on("model_select", async (event, ctx) => {
249
+ remember(ctx);
250
+ costLedger.markCurrentModel(modelRef(event.model));
251
+ });
252
+
253
+ pi.on("session_compact", async (event, ctx) => {
254
+ remember(ctx);
255
+ const usage = (event as { compactionEntry?: { usage?: UsageLike } }).compactionEntry?.usage;
256
+ const ref = costLedger.getCurrentModel() ?? (latestContext?.model ? modelRef(latestContext.model) : undefined);
257
+ if (usage && ref) costLedger.record(ref, finiteUsage(usage));
258
+ });
259
+ }
260
+
261
+ /** Rebuild the ledger from the persisted session after a reload or session
262
+ * switch, then key the current row to the window's model. Compaction and
263
+ * branch-summary LLM calls carry no model of their own, so they land on the
264
+ * current model — the one that made them. */
265
+ export function seedCostLedgerFromSession(ctx: {
266
+ sessionManager: { getEntries(): Array<{ type: string; message?: unknown; usage?: UsageLike }> };
267
+ model?: { provider: string; id: string } | undefined;
268
+ }): void {
269
+ costLedger.reset();
270
+ costLedger.markCurrentModel(ctx.model ? modelRef(ctx.model) : undefined);
271
+ const current = costLedger.getCurrentModel();
272
+ for (const entry of ctx.sessionManager.getEntries()) {
273
+ if (entry.type === "message") {
274
+ const message = entry.message as { role?: string; provider?: string; model?: string; usage?: UsageLike } | undefined;
275
+ if (message?.role !== "assistant") continue;
276
+ const ref = messageModelRef(message);
277
+ if (ref) costLedger.record(ref, finiteUsage(message.usage));
278
+ } else if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage && current) {
279
+ costLedger.record(current, finiteUsage(entry.usage));
280
+ }
281
+ }
282
+ }
283
+
284
+ export function latestTrackedContext(): ExtensionContext | undefined {
285
+ return latestContext;
286
+ }
@@ -264,7 +264,7 @@ export function shrinkRunLabel(text: string, maxWidth: number): string {
264
264
  return `${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(chars, maxWidth - 1)}`;
265
265
  }
266
266
 
267
- function formatTokens(count: number): string {
267
+ export function formatTokens(count: number): string {
268
268
  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
269
269
  if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
270
270
  return String(count);