pi-editor-footer 0.6.0 → 0.6.2

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
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.2] - 2026-08-25
8
+
9
+ ### Fixed
10
+
11
+ - Live `↑` no longer exceeds context window or session total — `telemetry:peekAgentLive` and `endAgent` now use peak window (`max`) for `inputTokens` not sum (summing `50k+60k=110k` double-counted overlapping history `> 60k` window), `live-border` top `↑` now shows current window `peekLive` + per-agent `output`/`cost` capped to `contextUsage.tokens`, idle also via telemetry `max` capped, timeline `↑` prefers telemetry `max` capped
12
+
13
+ ## [0.6.1] - 2026-08-25
14
+
15
+ ### Fixed
16
+
17
+ - Live `↑` now per-agent delta (`279k-261k=18k` for 10 turns) not session total — `live-border` top `↑`/`↓` when idle uses `totals - baseline` at `agent_start` (`LiveBorder.setAgentBaseline`), timeline `↑`/`↓`/`$` also prefers baseline delta; when running uses per-agent sum via `telemetry:peekAgentLive()` which now always resets on `agent_start` (removed stale `if (agentStartMs===null)` guard) and handles `agent_end` alias for `agent_settled`
18
+
7
19
  ## [0.6.0] - 2026-08-24
8
20
 
9
21
  ### Changed
package/dist/index.js CHANGED
@@ -52,6 +52,7 @@ const REFRESH_MS = 1000;
52
52
  let liveTickTimer = null;
53
53
  let footerState = createInitialState();
54
54
  let agentStartMs = null;
55
+ let agentBaselineTotals = null;
55
56
  let currentModelInfo = {
56
57
  provider: "",
57
58
  modelId: "unknown",
@@ -170,7 +171,8 @@ function injectTimelineDimLine(_ctx, rawLine) {
170
171
  // SAFETY: pi custom entry is TUI-only, not sent to LLM
171
172
  extensionPi?.appendEntry?.("timeline", { text: rawLine });
172
173
  }
173
- catch { // SAFETY: best-effort, ignore recoverable error
174
+ catch {
175
+ // SAFETY: best-effort, ignore recoverable error
174
176
  // SAFETY: best-effort, ignore recoverable error
175
177
  }
176
178
  // keep legacy array in sync
@@ -320,7 +322,8 @@ export default function (pi) {
320
322
  return new Text(lines.join("\n"));
321
323
  });
322
324
  }
323
- catch { // SAFETY: best-effort, ignore recoverable error
325
+ catch {
326
+ // SAFETY: best-effort, ignore recoverable error
324
327
  // SAFETY: best-effort, ignore recoverable error
325
328
  }
326
329
  let headerCleanupInner = null;
@@ -451,6 +454,8 @@ export default function (pi) {
451
454
  }
452
455
  currentModelInfo = modelInfoOf(ctx);
453
456
  lastSessionCtx = ctx;
457
+ agentBaselineTotals = null;
458
+ liveBorder.setAgentBaseline(null);
454
459
  // Deferred so we win the single editor slot (see installEditor).
455
460
  deferredInstallTimer = setTimeout(() => installEditor(ctx.ui), 0);
456
461
  // header disabled — first line workspace/hints removed per user request; cwd preserved in footer below input
@@ -544,6 +549,8 @@ export default function (pi) {
544
549
  // timeline entries are custom entries interleaved — no aboveEditor widget to clear
545
550
  wallTimeHistory = [];
546
551
  agentStartMs = null;
552
+ agentBaselineTotals = null;
553
+ liveBorder.setAgentBaseline(null);
547
554
  footerState = {
548
555
  ...footerState,
549
556
  workingSince: undefined,
@@ -591,9 +598,21 @@ export default function (pi) {
591
598
  const refreshAllLive = () => {
592
599
  liveBorder.render();
593
600
  };
594
- pi.on("agent_start", (e) => {
601
+ pi.on("agent_start", (e, ctx) => {
595
602
  telemetryTracker.handle(e);
596
603
  runActivityTracker.startRun();
604
+ // Capture baseline totals for per-agent delta (live input 18k not 279k = totals - baseline)
605
+ try {
606
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime
607
+ const baselineCtx = (ctx ?? lastSessionCtx);
608
+ if (baselineCtx?.sessionManager?.getEntries) {
609
+ agentBaselineTotals = getUsageTotals(baselineCtx);
610
+ liveBorder.setAgentBaseline(agentBaselineTotals);
611
+ }
612
+ }
613
+ catch {
614
+ // SAFETY: best-effort, ignore recoverable error
615
+ }
597
616
  // timeline stays permanently between each run — do not hide on start
598
617
  agentStartMs = Date.now();
599
618
  footerState = {
@@ -604,10 +623,15 @@ export default function (pi) {
604
623
  startLiveTick();
605
624
  refreshAllLive();
606
625
  });
626
+ pi.on("agent_end", (e) => {
627
+ // Alias for agent_settled — ensure tracker resets even if only agent_end is emitted
628
+ telemetryTracker.handle(e);
629
+ runActivityTracker.settle();
630
+ });
607
631
  pi.on("turn_start", (e, ctx) => {
608
632
  // Input is known at turn_start via context usage — seed live input so peekLive shows it during streaming (output already streams via liveDeltaChars)
609
633
  const usageTokens = ctx // SAFETY: pi context seam — getContextUsage is ExtensionContext API
610
- ?.getContextUsage?.()?.tokens;
634
+ ?.getContextUsage?.()?.tokens;
611
635
  if (typeof usageTokens === "number" &&
612
636
  Number.isFinite(usageTokens) &&
613
637
  usageTokens > 0) {
@@ -691,10 +715,34 @@ export default function (pi) {
691
715
  const cacheRate = totals.latestCacheHitRate ?? 0;
692
716
  const cacheStr = `${glyphs.cacheHit} ${cacheRate.toFixed(1)}%`;
693
717
  // Respect timeline.* toggles for specified metrics (wallTime/tokens/cost), but datetime/cache/turn/tools are always shown per user spec
694
- // Timeline tokens now mirror telemetry (per-agent) not session totals input known at turn_start via liveInputTokens
695
- const telInput = tel ? tel.inputTokens : totals.input;
696
- const telOutput = tel ? tel.outputTokens : totals.output;
697
- const telCost = tel ? tel.costUsd : totals.cost;
718
+ // Timeline tokens per-agent: input is peak window (max), not sumsumming full prompts
719
+ // double-counts overlapping history (50k+60k=110k > window 60k). Prefer telemetry max
720
+ // (tel.inputTokens after fix) when available, else delta capped to context/total.
721
+ // Output/cost remain per-agent sum for billing.
722
+ let telInput;
723
+ let telOutput;
724
+ let telCost;
725
+ if (tel) {
726
+ telInput = tel.inputTokens;
727
+ telOutput = tel.outputTokens;
728
+ telCost = tel.costUsd;
729
+ }
730
+ else if (agentBaselineTotals) {
731
+ telInput = Math.max(0, totals.input - agentBaselineTotals.input);
732
+ telOutput = Math.max(0, totals.output - agentBaselineTotals.output);
733
+ telCost = Math.max(0, totals.cost - agentBaselineTotals.cost);
734
+ // Cap input to not exceed context window (peak) or session total
735
+ const ctxTokens = lastSessionCtx?.getContextUsage?.()?.tokens;
736
+ if (typeof ctxTokens === "number" && Number.isFinite(ctxTokens))
737
+ telInput = Math.min(telInput, ctxTokens);
738
+ if (totals.input > 0)
739
+ telInput = Math.min(telInput, totals.input);
740
+ }
741
+ else {
742
+ telInput = totals.input;
743
+ telOutput = totals.output;
744
+ telCost = totals.cost;
745
+ }
698
746
  const line1Parts = [dt];
699
747
  if (currentConfig.timeline.wallTime)
700
748
  line1Parts.push(wallDur);
@@ -724,7 +772,8 @@ export default function (pi) {
724
772
  wallText);
725
773
  }
726
774
  }
727
- catch { // SAFETY: best-effort, ignore recoverable error
775
+ catch {
776
+ // SAFETY: best-effort, ignore recoverable error
728
777
  // SAFETY: best-effort, ignore recoverable error
729
778
  }
730
779
  // final settled telemetry overwrites live peek with authoritative totals
@@ -20,9 +20,14 @@ export class LiveBorder {
20
20
  timer = null;
21
21
  lastRenderMs = 0;
22
22
  pendingRender = null;
23
+ agentBaseline = null;
23
24
  constructor(deps) {
24
25
  this.deps = deps;
25
26
  }
27
+ /** Set baseline totals at agent_start for per-agent delta (input/output/cost). */
28
+ setAgentBaseline(baseline) {
29
+ this.agentBaseline = baseline ? { ...baseline } : null;
30
+ }
26
31
  /** Coalesced render: top (run-activity) + bottom (telemetry) + context bar → editor. */
27
32
  render() {
28
33
  const now = Date.now();
@@ -191,12 +196,87 @@ export class LiveBorder {
191
196
  // Tokens line above model info — left aligned, no border; hidden at startup per user request
192
197
  let tokensText = "";
193
198
  if (cfg.telemetry.enabled && cfg.telemetry.tokens) {
194
- // Live input/output per agent run (option B) — cumulative across turns in this agent, like live output
195
- // SAFETY: pi seam — intentional unsafe cast, validated at runtime telemetry tracker for top tokens line (agent run)
196
- const tracker = this.deps.telemetryTracker;
197
- const live = tracker.peekAgentLive() ?? tracker.getLastTelemetry();
198
- if (live) {
199
- tokensText = formatTelemetryTokens(live, theme, cfg.telemetry, glyphs);
199
+ const isRunning = this.deps.runActivityTracker.isRunning();
200
+ // Live input: window occupancy (max), not sum. Summing full prompts double-counts
201
+ // overlapping history (50k+60k=110k > window 60k) and exceeds context/total.
202
+ // Idle shows per-agent max via telemetry (fallback to delta capped), running shows
203
+ // current turn window (peekLive) + per-agent output/cost, both capped to context/total.
204
+ if (!isRunning && this.agentBaseline) {
205
+ const cur = snapshot.totals;
206
+ const base = this.agentBaseline;
207
+ // Idle per-agent display — input is peak window (max), not sum, to avoid
208
+ // double-count exceed (sum of prompts double-counts overlapping history).
209
+ // Prefer telemetry max when available, else delta capped to context/totals.
210
+ const trackerIdle = this.deps.telemetryTracker;
211
+ const telIdle = trackerIdle.peekAgentLive() ?? trackerIdle.getLastTelemetry();
212
+ let displayInput;
213
+ let displayOutput;
214
+ let displayCost;
215
+ if (telIdle) {
216
+ displayInput = telIdle.inputTokens;
217
+ displayOutput = telIdle.outputTokens;
218
+ displayCost = telIdle.costUsd;
219
+ }
220
+ else {
221
+ displayInput = Math.max(0, cur.input - base.input);
222
+ displayOutput = Math.max(0, cur.output - base.output);
223
+ displayCost = Math.max(0, cur.cost - base.cost);
224
+ }
225
+ // Guarantee invariant: live input never exceeds context window or session total
226
+ if (snapshot.contextUsage?.tokens)
227
+ displayInput = Math.min(displayInput, snapshot.contextUsage.tokens);
228
+ if (cur.input > 0)
229
+ displayInput = Math.min(displayInput, cur.input);
230
+ const deltaTel = {
231
+ tps: null,
232
+ ttftMs: 0,
233
+ totalMs: 0,
234
+ inputTokens: displayInput,
235
+ outputTokens: displayOutput,
236
+ stallMs: 0,
237
+ stallCount: 0,
238
+ rateUsdPerMTokens: null,
239
+ generationMs: 0,
240
+ totalTokens: displayInput + displayOutput,
241
+ costUsd: displayCost,
242
+ measurementMs: null,
243
+ };
244
+ tokensText = formatTelemetryTokens(deltaTel, theme, cfg.telemetry, glyphs);
245
+ }
246
+ else {
247
+ // Live per-agent — input is current window (peekLive), not per-agent sum,
248
+ // to avoid sum(50k+60k)=110k > window 60k. Output/cost still per-agent sum.
249
+ const tracker = this.deps.telemetryTracker;
250
+ const agentLive = tracker.peekAgentLive() ?? tracker.getLastTelemetry();
251
+ const liveTurn = tracker.peekLive();
252
+ let displayLive = agentLive;
253
+ if (agentLive &&
254
+ liveTurn &&
255
+ this.deps.runActivityTracker.isRunning()) {
256
+ // Input = current turn window (liveTurn), output/cost = per-agent sum
257
+ displayLive = {
258
+ ...agentLive,
259
+ inputTokens: liveTurn.inputTokens,
260
+ totalTokens: liveTurn.inputTokens + agentLive.outputTokens,
261
+ };
262
+ }
263
+ if (displayLive) {
264
+ let cappedInput = displayLive.inputTokens;
265
+ if (snapshot.contextUsage?.tokens)
266
+ cappedInput = Math.min(cappedInput, snapshot.contextUsage.tokens);
267
+ // Note: not capping to session totals during running — totals is authoritative
268
+ // (lagging) while liveTurn is predictive (current window). Capping to totals
269
+ // would make live stale (50k) during second turn streaming instead of showing
270
+ // current window 60k. After fix to max, live 60k == context 60k, not exceed context;
271
+ // live may still be > authoritative totals interim (60k > 50k) but will be <= totals
272
+ // after turn completes (60k <= 110k). This is expected predictive vs authoritative.
273
+ displayLive = {
274
+ ...displayLive,
275
+ inputTokens: cappedInput,
276
+ totalTokens: cappedInput + displayLive.outputTokens,
277
+ };
278
+ tokensText = formatTelemetryTokens(displayLive, theme, cfg.telemetry, glyphs);
279
+ }
200
280
  }
201
281
  }
202
282
  editor.setTopContextText(contextText);
package/dist/telemetry.js CHANGED
@@ -83,11 +83,12 @@ export class TurnTelemetryTracker {
83
83
  let stallCount = 0;
84
84
  let generationMs = 0;
85
85
  let ttftMs = 0;
86
- // sum completed turns
86
+ // input per turn is the full prompt (includes history), summing double-counts overlapping
87
+ // history and makes live input exceed context usage (e.g. 50k+60k=110k > window 60k).
88
+ // Display input as peak window occupancy (max), not sum. Output/cost still sum.
87
89
  for (const t of this.agentTurns) {
88
- inputTokens += t.inputTokens;
90
+ inputTokens = Math.max(inputTokens, t.inputTokens);
89
91
  outputTokens += t.outputTokens;
90
- totalTokens += t.totalTokens;
91
92
  costUsd += t.costUsd;
92
93
  stallMs += t.stallMs;
93
94
  stallCount += t.stallCount;
@@ -96,9 +97,8 @@ export class TurnTelemetryTracker {
96
97
  if (this.agentTurns.length > 0)
97
98
  ttftMs = this.agentTurns[0].ttftMs;
98
99
  if (live) {
99
- inputTokens += live.inputTokens;
100
+ inputTokens = Math.max(inputTokens, live.inputTokens);
100
101
  outputTokens += live.outputTokens;
101
- totalTokens += live.totalTokens;
102
102
  costUsd += live.costUsd;
103
103
  stallMs += live.stallMs;
104
104
  stallCount += live.stallCount;
@@ -106,6 +106,8 @@ export class TurnTelemetryTracker {
106
106
  if (ttftMs === 0)
107
107
  ttftMs = live.ttftMs;
108
108
  }
109
+ // totalTokens is window input (max) + cumulative output, not sum of per-turn totals
110
+ totalTokens = inputTokens + outputTokens;
109
111
  const now = this.now();
110
112
  const totalMs = Math.max(0, now - this.agentStartMs);
111
113
  const measurementMs = outputTokens > 0 && generationMs > 0 ? generationMs : null;
@@ -225,11 +227,10 @@ export class TurnTelemetryTracker {
225
227
  handle(event) {
226
228
  switch (event.type) {
227
229
  case "agent_start":
228
- if (this.agentStartMs === null) {
229
- this.agentStartMs = this.now();
230
- this.agentTurns = [];
231
- }
230
+ this.agentStartMs = this.now();
231
+ this.agentTurns = [];
232
232
  return;
233
+ case "agent_end":
233
234
  case "agent_settled":
234
235
  return this.endAgent();
235
236
  case "turn_start":
@@ -398,8 +399,9 @@ export class TurnTelemetryTracker {
398
399
  if (startMs === null || turns.length === 0)
399
400
  return;
400
401
  const outputTokens = turns.reduce((sum, t) => sum + t.outputTokens, 0);
401
- const inputTokens = turns.reduce((sum, t) => sum + t.inputTokens, 0);
402
- const totalTokens = turns.reduce((sum, t) => sum + t.totalTokens, 0);
402
+ const inputTokens = turns.reduce((sum, t) => Math.max(sum, t.inputTokens), 0);
403
+ // totalTokens is window input (max) + cumulative output, not sum of per-turn totals
404
+ const totalTokens = inputTokens + outputTokens;
403
405
  const costUsd = turns.reduce((sum, t) => sum + t.costUsd, 0);
404
406
  const stallMs = turns.reduce((sum, t) => sum + t.stallMs, 0);
405
407
  const stallCount = turns.reduce((sum, t) => sum + t.stallCount, 0);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-editor-footer",
3
3
  "type": "module",
4
- "description": "Pi TUI theme project-aware footer, model border, and skill detail window (TrackingEditor, live theme)",
4
+ "description": "Pi TUI theme \u2014 project-aware footer, model border, and skill detail window (TrackingEditor, live theme)",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test test/*.test.ts",
7
7
  "typecheck": "tsc --noEmit",
@@ -18,8 +18,7 @@
18
18
  "@earendil-works/pi-coding-agent": "*",
19
19
  "@earendil-works/pi-tui": "*"
20
20
  },
21
- "version": "0.6.0",
22
- "main": "./dist/index.js",
21
+ "version": "0.6.2",
23
22
  "files": [
24
23
  "dist",
25
24
  "src",
package/src/index.ts CHANGED
@@ -122,8 +122,8 @@ let lastSessionCtx: ExtensionContextLike | null = null;
122
122
  let extensionPi: unknown = null;
123
123
  let wallTimeHistory: string[] = []; // kept for compat, not used for widget
124
124
  const timeline = new TranscriptTimeline({
125
- getLastSessionCtx: () => lastSessionCtx,
126
- getTuiRef: () => tuiRef,
125
+ getLastSessionCtx: () => lastSessionCtx,
126
+ getTuiRef: () => tuiRef,
127
127
  });
128
128
  void timeline; // keep import used while now using pi.appendEntry interleaved path
129
129
  let currentConfig: ThemeConfig = loadConfig();
@@ -140,6 +140,7 @@ const REFRESH_MS = 1000;
140
140
  let liveTickTimer: ReturnType<typeof setInterval> | null = null;
141
141
  let footerState: FooterState = createInitialState();
142
142
  let agentStartMs: number | null = null;
143
+ let agentBaselineTotals: ReturnType<typeof getUsageTotals> | null = null;
143
144
  let currentModelInfo: ModelInfo = {
144
145
  provider: "",
145
146
  modelId: "unknown",
@@ -267,28 +268,31 @@ function formatDateTimeWithTimezone(d: Date = new Date()): string {
267
268
  // en-CA gives YYYY-MM-DD, HH:MM:SS
268
269
  return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}:${get("second")} ${tz}`.trim();
269
270
  } catch {
270
- // SAFETY: best-effort, ignore recoverable error
271
+ // SAFETY: best-effort, ignore recoverable error
271
272
  return d.toLocaleString();
272
273
  }
273
274
  }
274
275
 
275
276
  function injectTimelineDimLine(
276
- _ctx: ExtensionUIContextLike,
277
- rawLine: string,
277
+ _ctx: ExtensionUIContextLike,
278
+ rawLine: string,
278
279
  ): void {
279
- // Use pi.appendEntry so it is interleaved in chat container (not aboveEditor stacked widget)
280
- try {
281
- // SAFETY: pi custom entry is TUI-only, not sent to LLM
282
- (extensionPi as unknown as { appendEntry?: (t:string,d:unknown)=>void })?.appendEntry?.("timeline", { text: rawLine });
283
- } catch {// SAFETY: best-effort, ignore recoverable error
284
- // SAFETY: best-effort, ignore recoverable error
285
- }
286
- // keep legacy array in sync
287
- wallTimeHistory = [...wallTimeHistory, rawLine];
280
+ // Use pi.appendEntry so it is interleaved in chat container (not aboveEditor stacked widget)
281
+ try {
282
+ // SAFETY: pi custom entry is TUI-only, not sent to LLM
283
+ (
284
+ extensionPi as unknown as { appendEntry?: (t: string, d: unknown) => void }
285
+ )?.appendEntry?.("timeline", { text: rawLine });
286
+ } catch {
287
+ // SAFETY: best-effort, ignore recoverable error
288
+ // SAFETY: best-effort, ignore recoverable error
289
+ }
290
+ // keep legacy array in sync
291
+ wallTimeHistory = [...wallTimeHistory, rawLine];
288
292
  }
289
293
  function clearTimelineHistory(_ctx?: ExtensionUIContextLike): void {
290
- // No widget to clear — entries are interleaved and persist with session
291
- wallTimeHistory = [];
294
+ // No widget to clear — entries are interleaved and persist with session
295
+ wallTimeHistory = [];
292
296
  }
293
297
 
294
298
  /** shift+up/down handler: scroll the detail window one line, clamped. */
@@ -435,30 +439,36 @@ export default function (pi: ExtensionAPILike): void {
435
439
  extensionPi = pi;
436
440
  let watchTimer: ReturnType<typeof setInterval> | null = null;
437
441
  let deferredInstallTimer: ReturnType<typeof setTimeout> | null = null;
438
- // Timeline now uses pi.appendEntry("timeline") interleaved in chat container — not aboveEditor widget
439
- // Register renderer for timeline custom entries (dim, left-aligned, interleaved)
440
- try {
441
- // SAFETY: pi entry renderer is public API — timeline entries are TUI-only, not sent to LLM
442
- (pi as unknown as { registerEntryRenderer?: (t:string, r:unknown)=>void }).registerEntryRenderer?.(
443
- "timeline",
444
- (entry: unknown, _opts: unknown, theme: unknown) => {
445
- const data = (entry as { data?: { text?: string } }).data;
446
- const text = data?.text ?? "";
447
- const lines = text.split("\n").map((l: string) => {
448
- try {
449
- return (theme as { fg: (c:string,s:string)=>string }).fg("dim", " " + l);
450
- } catch {
451
- // SAFETY: best-effort, ignore recoverable error
452
- return " " + l;
453
- }
454
- });
455
- // SAFETY: intentional unsafe cast — validated at runtime
456
- return new Text(lines.join("\n")) as unknown as Component;
457
- },
458
- );
459
- } catch {// SAFETY: best-effort, ignore recoverable error
460
- // SAFETY: best-effort, ignore recoverable error
461
- }
442
+ // Timeline now uses pi.appendEntry("timeline") interleaved in chat container — not aboveEditor widget
443
+ // Register renderer for timeline custom entries (dim, left-aligned, interleaved)
444
+ try {
445
+ // SAFETY: pi entry renderer is public API — timeline entries are TUI-only, not sent to LLM
446
+ (
447
+ pi as unknown as { registerEntryRenderer?: (t: string, r: unknown) => void }
448
+ ).registerEntryRenderer?.(
449
+ "timeline",
450
+ (entry: unknown, _opts: unknown, theme: unknown) => {
451
+ const data = (entry as { data?: { text?: string } }).data;
452
+ const text = data?.text ?? "";
453
+ const lines = text.split("\n").map((l: string) => {
454
+ try {
455
+ return (theme as { fg: (c: string, s: string) => string }).fg(
456
+ "dim",
457
+ " " + l,
458
+ );
459
+ } catch {
460
+ // SAFETY: best-effort, ignore recoverable error
461
+ return " " + l;
462
+ }
463
+ });
464
+ // SAFETY: intentional unsafe cast — validated at runtime
465
+ return new Text(lines.join("\n")) as unknown as Component;
466
+ },
467
+ );
468
+ } catch {
469
+ // SAFETY: best-effort, ignore recoverable error
470
+ // SAFETY: best-effort, ignore recoverable error
471
+ }
462
472
  let headerCleanupInner: (() => void) | null = null;
463
473
 
464
474
  // Toggle the border glow + model label (off restores pi's stock border).
@@ -602,7 +612,8 @@ export default function (pi: ExtensionAPILike): void {
602
612
 
603
613
  currentModelInfo = modelInfoOf(ctx);
604
614
  lastSessionCtx = ctx;
605
-
615
+ agentBaselineTotals = null;
616
+ liveBorder.setAgentBaseline(null);
606
617
  // Deferred so we win the single editor slot (see installEditor).
607
618
  deferredInstallTimer = setTimeout(() => installEditor(ctx.ui), 0);
608
619
 
@@ -703,10 +714,12 @@ export default function (pi: ExtensionAPILike): void {
703
714
  // before re-evaluating the module on /reload). Any timer that captured this
704
715
  // session's ctx must be dead before then — otherwise its next tick hits the
705
716
  // stale `ctx.ui` getter and assertActive() throws, crashing the process.
706
- pi.on("session_shutdown", () => {
707
- // timeline entries are custom entries interleaved — no aboveEditor widget to clear
708
- wallTimeHistory = [];
717
+ pi.on("session_shutdown", () => {
718
+ // timeline entries are custom entries interleaved — no aboveEditor widget to clear
719
+ wallTimeHistory = [];
709
720
  agentStartMs = null;
721
+ agentBaselineTotals = null;
722
+ liveBorder.setAgentBaseline(null);
710
723
  footerState = {
711
724
  ...footerState,
712
725
  workingSince: undefined,
@@ -755,9 +768,22 @@ export default function (pi: ExtensionAPILike): void {
755
768
  liveBorder.render();
756
769
  };
757
770
 
758
- pi.on("agent_start", (e) => {
771
+ pi.on("agent_start", (e, ctx) => {
759
772
  telemetryTracker.handle(e as never);
760
773
  runActivityTracker.startRun();
774
+ // Capture baseline totals for per-agent delta (live input 18k not 279k = totals - baseline)
775
+ try {
776
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime
777
+ const baselineCtx = (ctx ?? lastSessionCtx) as unknown as Parameters<
778
+ typeof getUsageTotals
779
+ >[0];
780
+ if (baselineCtx?.sessionManager?.getEntries) {
781
+ agentBaselineTotals = getUsageTotals(baselineCtx);
782
+ liveBorder.setAgentBaseline(agentBaselineTotals);
783
+ }
784
+ } catch {
785
+ // SAFETY: best-effort, ignore recoverable error
786
+ }
761
787
  // timeline stays permanently between each run — do not hide on start
762
788
  agentStartMs = Date.now();
763
789
  footerState = {
@@ -768,12 +794,16 @@ export default function (pi: ExtensionAPILike): void {
768
794
  startLiveTick();
769
795
  refreshAllLive();
770
796
  });
797
+ pi.on("agent_end", (e) => {
798
+ // Alias for agent_settled — ensure tracker resets even if only agent_end is emitted
799
+ telemetryTracker.handle(e as never);
800
+ runActivityTracker.settle();
801
+ });
771
802
  pi.on("turn_start", (e, ctx) => {
772
803
  // Input is known at turn_start via context usage — seed live input so peekLive shows it during streaming (output already streams via liveDeltaChars)
773
804
  const usageTokens = (
774
- ctx as unknown as { getContextUsage?: () => { tokens?: number } }
775
- ) // SAFETY: pi context seam — getContextUsage is ExtensionContext API
776
- ?.getContextUsage?.()?.tokens;
805
+ ctx as unknown as { getContextUsage?: () => { tokens?: number } } // SAFETY: pi context seam — getContextUsage is ExtensionContext API
806
+ )?.getContextUsage?.()?.tokens;
777
807
  if (
778
808
  typeof usageTokens === "number" &&
779
809
  Number.isFinite(usageTokens) &&
@@ -864,10 +894,34 @@ export default function (pi: ExtensionAPILike): void {
864
894
  const cacheRate = totals.latestCacheHitRate ?? 0;
865
895
  const cacheStr = `${glyphs.cacheHit} ${cacheRate.toFixed(1)}%`;
866
896
  // Respect timeline.* toggles for specified metrics (wallTime/tokens/cost), but datetime/cache/turn/tools are always shown per user spec
867
- // Timeline tokens now mirror telemetry (per-agent) not session totals input known at turn_start via liveInputTokens
868
- const telInput = tel ? tel.inputTokens : totals.input;
869
- const telOutput = tel ? tel.outputTokens : totals.output;
870
- const telCost = tel ? tel.costUsd : totals.cost;
897
+ // Timeline tokens per-agent: input is peak window (max), not sumsumming full prompts
898
+ // double-counts overlapping history (50k+60k=110k > window 60k). Prefer telemetry max
899
+ // (tel.inputTokens after fix) when available, else delta capped to context/total.
900
+ // Output/cost remain per-agent sum for billing.
901
+ let telInput: number;
902
+ let telOutput: number;
903
+ let telCost: number;
904
+ if (tel) {
905
+ telInput = tel.inputTokens;
906
+ telOutput = tel.outputTokens;
907
+ telCost = tel.costUsd;
908
+ } else if (agentBaselineTotals) {
909
+ telInput = Math.max(0, totals.input - agentBaselineTotals.input);
910
+ telOutput = Math.max(0, totals.output - agentBaselineTotals.output);
911
+ telCost = Math.max(0, totals.cost - agentBaselineTotals.cost);
912
+ // Cap input to not exceed context window (peak) or session total
913
+ const ctxTokens = (
914
+ lastSessionCtx as unknown as { // SAFETY: pi seam — intentional unsafe cast, validated at runtime
915
+ getContextUsage?: () => { tokens?: number };
916
+ }
917
+ )?.getContextUsage?.()?.tokens;
918
+ if (typeof ctxTokens === "number" && Number.isFinite(ctxTokens)) telInput = Math.min(telInput, ctxTokens);
919
+ if (totals.input > 0) telInput = Math.min(telInput, totals.input);
920
+ } else {
921
+ telInput = totals.input;
922
+ telOutput = totals.output;
923
+ telCost = totals.cost;
924
+ }
871
925
  const line1Parts: string[] = [dt];
872
926
  if (currentConfig.timeline.wallTime) line1Parts.push(wallDur);
873
927
  else line1Parts.push(wallDur); // wall time always per spec (11s)
@@ -894,9 +948,10 @@ export default function (pi: ExtensionAPILike): void {
894
948
  wallText,
895
949
  );
896
950
  }
897
- } catch {// SAFETY: best-effort, ignore recoverable error
898
- // SAFETY: best-effort, ignore recoverable error
899
- }
951
+ } catch {
952
+ // SAFETY: best-effort, ignore recoverable error
953
+ // SAFETY: best-effort, ignore recoverable error
954
+ }
900
955
  // final settled telemetry overwrites live peek with authoritative totals
901
956
  if (tel && installedEditor && currentConfig.telemetry.enabled) {
902
957
  try {
@@ -23,6 +23,7 @@ import {
23
23
  formatTurnDuration,
24
24
  formatTurnTelemetry,
25
25
  } from "./telemetry.js";
26
+ import type { UsageTotals } from "./state.js";
26
27
  import type { TurnTelemetry } from "./telemetry.js";
27
28
  import type { TurnTelemetryTracker } from "./telemetry.js";
28
29
  import type { ThemeConfig } from "./config.js";
@@ -43,9 +44,15 @@ export class LiveBorder {
43
44
  private timer: ReturnType<typeof setInterval> | null = null;
44
45
  private lastRenderMs = 0;
45
46
  private pendingRender: ReturnType<typeof setTimeout> | null = null;
47
+ private agentBaseline: UsageTotals | null = null;
46
48
 
47
49
  constructor(private readonly deps: LiveBorderDeps) {}
48
50
 
51
+ /** Set baseline totals at agent_start for per-agent delta (input/output/cost). */
52
+ setAgentBaseline(baseline: UsageTotals | null): void {
53
+ this.agentBaseline = baseline ? { ...baseline } : null;
54
+ }
55
+
49
56
  /** Coalesced render: top (run-activity) + bottom (telemetry) + context bar → editor. */
50
57
  render(): void {
51
58
  const now = Date.now();
@@ -237,21 +244,105 @@ export class LiveBorder {
237
244
  // Tokens line above model info — left aligned, no border; hidden at startup per user request
238
245
  let tokensText = "";
239
246
  if (cfg.telemetry.enabled && cfg.telemetry.tokens) {
240
- // Live input/output per agent run (option B) — cumulative across turns in this agent, like live output
241
- // SAFETY: pi seam — intentional unsafe cast, validated at runtime telemetry tracker for top tokens line (agent run)
242
- const tracker = this.deps.telemetryTracker as unknown as {
243
- // SAFETY: pi seam intentional unsafe cast, validated at runtime
244
- peekAgentLive(): import("./telemetry.js").TurnTelemetry | null;
245
- getLastTelemetry(): import("./telemetry.js").TurnTelemetry | null;
246
- };
247
- const live = tracker.peekAgentLive() ?? tracker.getLastTelemetry();
248
- if (live) {
247
+ const isRunning = this.deps.runActivityTracker.isRunning();
248
+ // Live input: window occupancy (max), not sum. Summing full prompts double-counts
249
+ // overlapping history (50k+60k=110k > window 60k) and exceeds context/total.
250
+ // Idle shows per-agent max via telemetry (fallback to delta capped), running shows
251
+ // current turn window (peekLive) + per-agent output/cost, both capped to context/total.
252
+ if (!isRunning && this.agentBaseline) {
253
+ const cur = snapshot.totals;
254
+ const base = this.agentBaseline;
255
+ // Idle per-agent display — input is peak window (max), not sum, to avoid
256
+ // double-count exceed (sum of prompts double-counts overlapping history).
257
+ // Prefer telemetry max when available, else delta capped to context/totals.
258
+ const trackerIdle = this.deps.telemetryTracker as unknown as {
259
+ peekAgentLive(): import("./telemetry.js").TurnTelemetry | null;
260
+ getLastTelemetry(): import("./telemetry.js").TurnTelemetry | null;
261
+ };
262
+ const telIdle =
263
+ trackerIdle.peekAgentLive() ?? trackerIdle.getLastTelemetry();
264
+ let displayInput: number;
265
+ let displayOutput: number;
266
+ let displayCost: number;
267
+ if (telIdle) {
268
+ displayInput = telIdle.inputTokens;
269
+ displayOutput = telIdle.outputTokens;
270
+ displayCost = telIdle.costUsd;
271
+ } else {
272
+ displayInput = Math.max(0, cur.input - base.input);
273
+ displayOutput = Math.max(0, cur.output - base.output);
274
+ displayCost = Math.max(0, cur.cost - base.cost);
275
+ }
276
+ // Guarantee invariant: live input never exceeds context window or session total
277
+ if (snapshot.contextUsage?.tokens)
278
+ displayInput = Math.min(displayInput, snapshot.contextUsage.tokens);
279
+ if (cur.input > 0) displayInput = Math.min(displayInput, cur.input);
280
+ const deltaTel: TurnTelemetry = {
281
+ tps: null,
282
+ ttftMs: 0,
283
+ totalMs: 0,
284
+ inputTokens: displayInput,
285
+ outputTokens: displayOutput,
286
+ stallMs: 0,
287
+ stallCount: 0,
288
+ rateUsdPerMTokens: null,
289
+ generationMs: 0,
290
+ totalTokens: displayInput + displayOutput,
291
+ costUsd: displayCost,
292
+ measurementMs: null,
293
+ };
249
294
  tokensText = formatTelemetryTokens(
250
- live,
295
+ deltaTel,
251
296
  theme as never,
252
297
  cfg.telemetry,
253
298
  glyphs as never,
254
299
  );
300
+ } else {
301
+ // Live per-agent — input is current window (peekLive), not per-agent sum,
302
+ // to avoid sum(50k+60k)=110k > window 60k. Output/cost still per-agent sum.
303
+ const tracker = this.deps.telemetryTracker as unknown as {
304
+ peekAgentLive(): import("./telemetry.js").TurnTelemetry | null;
305
+ peekLive(): import("./telemetry.js").TurnTelemetry | null;
306
+ getLastTelemetry(): import("./telemetry.js").TurnTelemetry | null;
307
+ };
308
+ const agentLive =
309
+ tracker.peekAgentLive() ?? tracker.getLastTelemetry();
310
+ const liveTurn = tracker.peekLive();
311
+ let displayLive = agentLive;
312
+ if (
313
+ agentLive &&
314
+ liveTurn &&
315
+ this.deps.runActivityTracker.isRunning()
316
+ ) {
317
+ // Input = current turn window (liveTurn), output/cost = per-agent sum
318
+ displayLive = {
319
+ ...agentLive,
320
+ inputTokens: liveTurn.inputTokens,
321
+ totalTokens: liveTurn.inputTokens + agentLive.outputTokens,
322
+ };
323
+ }
324
+ if (displayLive) {
325
+ let cappedInput = displayLive.inputTokens;
326
+ if (snapshot.contextUsage?.tokens)
327
+ cappedInput = Math.min(cappedInput, snapshot.contextUsage.tokens);
328
+ // Note: not capping to session totals during running — totals is authoritative
329
+ // (lagging) while liveTurn is predictive (current window). Capping to totals
330
+ // would make live stale (50k) during second turn streaming instead of showing
331
+ // current window 60k. After fix to max, live 60k == context 60k, not exceed context;
332
+ // live may still be > authoritative totals interim (60k > 50k) but will be <= totals
333
+ // after turn completes (60k <= 110k). This is expected predictive vs authoritative.
334
+ displayLive = {
335
+ ...displayLive,
336
+ inputTokens: cappedInput,
337
+ totalTokens: cappedInput + displayLive.outputTokens,
338
+ };
339
+ tokensText = formatTelemetryTokens(
340
+ displayLive,
341
+ theme as never,
342
+ cfg.telemetry,
343
+ glyphs as never,
344
+ );
345
+ }
255
346
  }
256
347
  }
257
348
  editor.setTopContextText(contextText);
package/src/telemetry.ts CHANGED
@@ -60,6 +60,7 @@ type AgentMessage = { role: string } & AssistantMessage;
60
60
 
61
61
  export type TelemetryEvent =
62
62
  | { type: "agent_start" }
63
+ | { type: "agent_end" }
63
64
  | { type: "agent_settled" }
64
65
  | {
65
66
  type: "turn_start";
@@ -184,11 +185,12 @@ export class TurnTelemetryTracker {
184
185
  let stallCount = 0;
185
186
  let generationMs = 0;
186
187
  let ttftMs = 0;
187
- // sum completed turns
188
+ // input per turn is the full prompt (includes history), summing double-counts overlapping
189
+ // history and makes live input exceed context usage (e.g. 50k+60k=110k > window 60k).
190
+ // Display input as peak window occupancy (max), not sum. Output/cost still sum.
188
191
  for (const t of this.agentTurns) {
189
- inputTokens += t.inputTokens;
192
+ inputTokens = Math.max(inputTokens, t.inputTokens);
190
193
  outputTokens += t.outputTokens;
191
- totalTokens += t.totalTokens;
192
194
  costUsd += t.costUsd;
193
195
  stallMs += t.stallMs;
194
196
  stallCount += t.stallCount;
@@ -196,15 +198,16 @@ export class TurnTelemetryTracker {
196
198
  }
197
199
  if (this.agentTurns.length > 0) ttftMs = this.agentTurns[0]!.ttftMs;
198
200
  if (live) {
199
- inputTokens += live.inputTokens;
201
+ inputTokens = Math.max(inputTokens, live.inputTokens);
200
202
  outputTokens += live.outputTokens;
201
- totalTokens += live.totalTokens;
202
203
  costUsd += live.costUsd;
203
204
  stallMs += live.stallMs;
204
205
  stallCount += live.stallCount;
205
206
  generationMs += live.generationMs;
206
207
  if (ttftMs === 0) ttftMs = live.ttftMs;
207
208
  }
209
+ // totalTokens is window input (max) + cumulative output, not sum of per-turn totals
210
+ totalTokens = inputTokens + outputTokens;
208
211
  const now = this.now();
209
212
  const totalMs = Math.max(0, now - this.agentStartMs);
210
213
  const measurementMs =
@@ -329,11 +332,10 @@ export class TurnTelemetryTracker {
329
332
  handle(event: TelemetryEvent): TurnTelemetry | undefined {
330
333
  switch (event.type) {
331
334
  case "agent_start":
332
- if (this.agentStartMs === null) {
333
- this.agentStartMs = this.now();
334
- this.agentTurns = [];
335
- }
335
+ this.agentStartMs = this.now();
336
+ this.agentTurns = [];
336
337
  return;
338
+ case "agent_end":
337
339
  case "agent_settled":
338
340
  return this.endAgent();
339
341
  case "turn_start":
@@ -524,8 +526,12 @@ export class TurnTelemetryTracker {
524
526
  if (startMs === null || turns.length === 0) return;
525
527
 
526
528
  const outputTokens = turns.reduce((sum, t) => sum + t.outputTokens, 0);
527
- const inputTokens = turns.reduce((sum, t) => sum + t.inputTokens, 0);
528
- const totalTokens = turns.reduce((sum, t) => sum + t.totalTokens, 0);
529
+ const inputTokens = turns.reduce(
530
+ (sum, t) => Math.max(sum, t.inputTokens),
531
+ 0,
532
+ );
533
+ // totalTokens is window input (max) + cumulative output, not sum of per-turn totals
534
+ const totalTokens = inputTokens + outputTokens;
529
535
  const costUsd = turns.reduce((sum, t) => sum + t.costUsd, 0);
530
536
  const stallMs = turns.reduce((sum, t) => sum + t.stallMs, 0);
531
537
  const stallCount = turns.reduce((sum, t) => sum + t.stallCount, 0);