@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 +17 -186
- package/README.md +44 -10
- package/index.ts +4 -0
- package/package.json +1 -1
- package/src/delegation/dispatch.ts +5 -28
- package/src/lifecycle/completion.ts +26 -5
- package/src/lifecycle/thread-lifecycle.ts +1 -0
- package/src/presentation/announcements.ts +7 -1
- package/src/presentation/cost-footer.ts +201 -0
- package/src/presentation/cost-ledger.ts +286 -0
- package/src/presentation/monitor.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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);
|