@yunazgr/pi-companion 0.2.5 → 0.2.6

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/README.md CHANGED
@@ -153,7 +153,7 @@ Dialogs from other extensions (`ctx.ui.select`, `ctx.ui.confirm`, `ctx.ui.input`
153
153
 
154
154
  Settings → **Notifications and camera** (on every device, not just the console) asks the browser for both permissions from a tap, as browsers require, and shows whether each is allowed, blocked or unavailable. Overview also offers a one-time "Turn on notifications" banner.
155
155
 
156
- With notifications on, the device gets a system notification (through the service worker, so it works for installed apps and Android) when Pi asks a question, finishes a turn or a session ends. Tapping it opens that session. Nothing is sent while you are already looking at that session. iPhone and iPad only allow web notifications from an app added to the Home Screen. The camera is used only for the pairing QR scanner.
156
+ With notifications on, the device gets a system notification (through the service worker, so it works for installed apps and Android) when Pi asks a question, finishes a turn or a session ends. Tapping it opens that session. Nothing is sent while you are already looking at that session. iPhone and iPad only allow web notifications from an app added to the Home Screen. The camera is used only for the pairing QR scanner. Camera access requires HTTPS or localhost; plain HTTP LAN/phone links cannot prompt. After upgrading, restart the daemon and reload the app to refresh cached camera policies. Settings distinguishes browser denial, page/proxy policy blocks and transient camera errors, with retry or manual-code pairing guidance.
157
157
 
158
158
  Activity no longer lives only in the open tab. The daemon keeps a bounded, in-memory log of each session's recent feed (streamed text is merged, up to 800 entries) at `GET /api/sessions/{id}/activity` on both surfaces (paired devices: shared sessions only). The UI rebuilds the feed from it when a session opens and after every reconnect or resync, so a phone that slept, dropped its connection or was closed catches up without sending a new message. Prompts, steers and answers from any device are added to that log too. The log is cleared when the daemon restarts or the session is archived.
159
159
 
@@ -178,11 +178,37 @@ Paired devices (name, browser user agent, pairing and last-seen times, credentia
178
178
 
179
179
  A phone pairs once with the daemon. Individual Pi sessions still opt in using /companion.
180
180
 
181
- Network loss and daemon restarts reconnect automatically with bounded backoff. Heartbeats detect silent connections, and returning to the foreground or restoring network access retries immediately. Pairing credentials and unsent drafts survive outages; session snapshots, pending questions, and previously loaded upload lists refresh on reconnect. Missed activity is not replayed, and prompts/answers are never automatically resent. A deliberate **Disconnect** waits for the device’s explicit retry; **Revoke** removes access and requires pairing again.
181
+ Network loss and daemon restarts reconnect automatically with bounded backoff. Heartbeats detect silent connections, and returning to the foreground or restoring network access retries immediately. Pairing credentials and unsent drafts survive outages; session snapshots, pending questions, and previously loaded upload lists refresh on reconnect. Recent activity is replayed from the daemon’s bounded log, but prompts/answers are never automatically resent. A deliberate **Disconnect** waits for the device’s explicit retry; **Revoke** removes access and requires pairing again.
182
+
183
+ ## Session metadata and usage
184
+
185
+ Shared session title, model, effort and working directory refresh on Pi events and once per second while sharing, including while idle. Session detail shows context-window usage and estimated session cost. Native Pi context and recorded usage costs take precedence; independent extension reports fill unavailable fields. Missing usage is shown as unavailable, not zero.
186
+
187
+ **Settings → Usage** is available on the console and paired devices. It displays the latest snapshot per provider: weekly usage, 5-hour usage when reported, reset times, source and snapshot age. Quota snapshots are not added across sessions or accounts. These are extension-reported limits, not billing totals or locally inferred quotas; providers without a quota report remain unavailable.
188
+
189
+ Extensions can publish a provider-neutral event without importing Companion:
190
+
191
+ ```ts
192
+ pi.events.emit("companion:telemetry", {
193
+ source: "my-usage-extension", // distinguish independent producers
194
+ // sessionId: ctx.sessionManager.getSessionId(), // optional session guard
195
+ context: { tokens: 24000, window: 200000, percent: 12 },
196
+ cost: { amount: 0.25, currency: "USD" },
197
+ providers: [{
198
+ provider: "openai-codex", updatedAt: new Date().toISOString(),
199
+ weekly: { usedPercent: 35, resetsAt: "2026-10-12T00:00:00Z" },
200
+ fiveHour: { usedPercent: 20 } // omit windows the provider doesn't supply
201
+ }]
202
+ });
203
+ ```
204
+
205
+ Publish complete snapshots per provider; session context and cost may be supplied independently by different extensions. Companion also accepts `usage:update`, `session:usage` and `provider:usage` with this shape. On opt-in and every 30 seconds it emits `companion:telemetry:request` with the Pi `sessionId` and `companionSessionId`, allowing adapters to respond with their cached snapshots. It never reads provider credentials or calls quota APIs itself. Extensions with private, non-exported quota data need an adapter to publish this contract.
206
+
207
+ As a legacy fallback, `ctx.ui.setStatus` reports with explicit `ctx 12%` / `context 12%` and `cost: $0.25` (or cost/usage/footer keys containing `$0.25`) are recognized. Quota status lines must identify `provider=…` and label `7d`/`weekly` and `5h`/`5-hour` percentages (`used`, `left`, or `remaining`). Unlabelled percentages mean used. Status rendering is preserved. Invalid reports are ignored; extension context/cost expires after five minutes without a fresh reading. Context estimates are also invalidated on model changes, compaction and tree navigation; quota snapshots retain their original timestamps and visibly age.
182
208
 
183
209
  ## Settings
184
210
 
185
- The **Settings** page (local console only) covers:
211
+ The **Settings → General** page provides appearance and device permissions on every device; daemon configuration is local-console-only:
186
212
 
187
213
  | Setting | Default | Notes |
188
214
  |---|---|---|
@@ -326,7 +352,7 @@ Clone the repository, then:
326
352
 
327
353
  `npm run serve` builds the embedded Svelte UI and starts the Rust daemon in the foreground. It prints:
328
354
 
329
- Pi Companion v0.2.5
355
+ Pi Companion v0.2.6
330
356
 
331
357
  Console http://127.0.0.1:43721
332
358
  Paired devices http://127.0.0.1:43722
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunazgr/pi-companion",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Local-first remote control for Pi sessions: live activity feed, steering, git diff, file drop and phone pairing from a single Rust daemon.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/bridge.ts CHANGED
@@ -5,6 +5,7 @@ import WebSocket from "ws";
5
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import { relayDialogs, ToolDialogRelay, type AskChannel, type AskInput } from "./ask.js";
7
7
  import { ensureDaemon } from "./daemon.js";
8
+ import { TelemetryRelay } from "./telemetry.js";
8
9
  import type { AskAnswers, AskRequest, BridgeMessage, ServerMessage, SessionSnapshot, TempFile } from "./protocol.js";
9
10
 
10
11
  const execFileAsync = promisify(execFile);
@@ -17,6 +18,10 @@ export class CompanionBridge implements AskChannel {
17
18
  private ctx?: ExtensionContext;
18
19
  private reconnect?: NodeJS.Timeout;
19
20
  private heartbeat?: NodeJS.Timeout;
21
+ private metadataTimer?: NodeJS.Timeout;
22
+ private telemetryRequestedAt = 0;
23
+ private restoreStatus?: () => void;
24
+ private telemetry = new TelemetryRelay();
20
25
  private closed = false;
21
26
  private activated = false;
22
27
  private restoreDialogs?: () => void;
@@ -44,22 +49,55 @@ export class CompanionBridge implements AskChannel {
44
49
  setContext(ctx: ExtensionContext) {
45
50
  this.ctx = ctx;
46
51
  if (this.activated && this.snapshot.remoteEnabled && ctx.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(ctx.ui, this, this.toolDialogs);
47
- const name = this.pi.getSessionName() ?? this.snapshot.name;
48
- this.snapshot = {
49
- ...this.snapshot,
50
- name,
51
- cwd: ctx.cwd,
52
- shortTitle: name?.trim() || ctx.cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi",
53
- mainModel: ctx.model?.id,
54
- effort: ctx.thinkingLevel,
55
- status: ctx.isIdle() ? "idle" : "active"
56
- };
52
+ this.refreshMetadata();
57
53
  }
58
54
 
59
55
  setName(name?: string) {
60
- this.snapshot.name = name;
56
+ this.snapshot.name = name ?? null;
61
57
  this.snapshot.shortTitle = name?.trim() || this.snapshot.cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi";
62
- this.send({ type: "session.update", session: { name, shortTitle: this.snapshot.shortTitle } });
58
+ this.send({ type: "session.update", session: { name: name ?? null, shortTitle: this.snapshot.shortTitle } });
59
+ }
60
+
61
+ /** Context getters remain live while idle; send only changed metadata, including explicit clears. */
62
+ refreshMetadata() {
63
+ const ctx = this.ctx;
64
+ if (!ctx || this.closed) return;
65
+ const cwd = ctx.cwd || ctx.sessionManager?.getCwd?.() || process.cwd();
66
+ const name = this.pi.getSessionName() ?? null;
67
+ if ((this.snapshot.mainModel ?? null) !== (ctx.model?.id ?? null)) this.telemetry.invalidateContext();
68
+ const next = {
69
+ name, cwd,
70
+ shortTitle: name?.trim() || cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi",
71
+ mainModel: ctx.model?.id ?? null,
72
+ effort: ctx.thinkingLevel ?? this.pi.getThinkingLevel?.() ?? null,
73
+ telemetry: this.telemetry.snapshot(ctx)
74
+ };
75
+ const patch: Partial<SessionSnapshot> = {};
76
+ for (const key of Object.keys(next) as Array<keyof typeof next>) {
77
+ if (JSON.stringify(this.snapshot[key]) !== JSON.stringify(next[key])) Object.assign(patch, { [key]: next[key] });
78
+ }
79
+ Object.assign(this.snapshot, patch);
80
+ if (Object.keys(patch).length) this.send({ type: "session.update", session: patch });
81
+ if (this.activated && this.snapshot.remoteEnabled && Date.now() - this.telemetryRequestedAt >= 30_000) this.requestTelemetry();
82
+ }
83
+
84
+ invalidateContext() {
85
+ this.telemetry.invalidateContext();
86
+ }
87
+
88
+ ingestTelemetry(value: unknown, source: string) {
89
+ if (!this.isActivated() || !this.snapshot.remoteEnabled) return;
90
+ const id = value && typeof value === "object" ? (value as { sessionId?: unknown }).sessionId : undefined;
91
+ if (id !== undefined && id !== this.sessionId && id !== this.ctx?.sessionManager?.getSessionId?.()) return;
92
+ this.telemetry.ingest(value, source);
93
+ this.refreshMetadata();
94
+ }
95
+
96
+ private requestTelemetry() {
97
+ this.telemetryRequestedAt = Date.now();
98
+ try {
99
+ this.pi.events?.emit("companion:telemetry:request", { sessionId: this.ctx?.sessionManager?.getSessionId?.(), companionSessionId: this.sessionId });
100
+ } catch { /* A failing third-party responder must never interrupt sharing. */ }
63
101
  }
64
102
 
65
103
  setRemoteEnabled(remoteEnabled: boolean) {
@@ -73,6 +111,10 @@ export class CompanionBridge implements AskChannel {
73
111
  // End only Companion sharing: Pi itself and its local history keep running.
74
112
  this.restoreDialogs?.();
75
113
  this.restoreDialogs = undefined;
114
+ this.restoreStatus?.();
115
+ this.restoreStatus = undefined;
116
+ if (this.metadataTimer) clearInterval(this.metadataTimer);
117
+ this.metadataTimer = undefined;
76
118
  for (const entry of [...this.asks.values()]) entry.settle(null);
77
119
  for (const pending of this.pendingDeletes.values()) {
78
120
  clearTimeout(pending.timer);
@@ -99,6 +141,23 @@ export class CompanionBridge implements AskChannel {
99
141
  activate() {
100
142
  this.activated = true;
101
143
  if (this.snapshot.remoteEnabled && this.ctx?.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(this.ctx.ui, this, this.toolDialogs);
144
+ if (!this.snapshot.remoteEnabled) return;
145
+ const ui = this.ctx?.ui;
146
+ if (ui && typeof ui.setStatus === "function" && !this.restoreStatus) {
147
+ const original = ui.setStatus;
148
+ const relay = this.telemetry;
149
+ const wrapped: typeof original = function(key, value) {
150
+ original.call(ui, key, value);
151
+ relay.status(key, value);
152
+ };
153
+ ui.setStatus = wrapped;
154
+ this.restoreStatus = () => { if (ui.setStatus === wrapped) ui.setStatus = original; };
155
+ }
156
+ if (!this.metadataTimer) {
157
+ this.metadataTimer = setInterval(() => this.refreshMetadata(), 1000);
158
+ this.metadataTimer.unref();
159
+ }
160
+ this.refreshMetadata();
102
161
  }
103
162
 
104
163
  isActivated() {
@@ -170,6 +229,10 @@ export class CompanionBridge implements AskChannel {
170
229
  this.closed = true;
171
230
  this.restoreDialogs?.();
172
231
  this.restoreDialogs = undefined;
232
+ this.restoreStatus?.();
233
+ this.restoreStatus = undefined;
234
+ if (this.metadataTimer) clearInterval(this.metadataTimer);
235
+ this.metadataTimer = undefined;
173
236
  for (const entry of [...this.asks.values()]) entry.settle(null);
174
237
  if (this.reconnect) clearTimeout(this.reconnect);
175
238
  if (this.heartbeat) clearTimeout(this.heartbeat);
package/src/index.ts CHANGED
@@ -71,6 +71,11 @@ export default function companionExtension(pi: ExtensionAPI) {
71
71
  let bridge = new CompanionBridge(pi);
72
72
  let sharingGeneration = 0;
73
73
 
74
+ // Any extension can publish the neutral contract. No dependency on a particular footer/provider.
75
+ for (const channel of ["companion:telemetry", "usage:update", "session:usage", "provider:usage"]) {
76
+ pi.events?.on(channel, value => bridge.ingestTelemetry(value, channel));
77
+ }
78
+
74
79
  pi.on("session_start", (_event, ctx) => {
75
80
  // Switching or starting a Pi session must not inherit the previous opt-in.
76
81
  sharingGeneration += 1;
@@ -84,6 +89,7 @@ export default function companionExtension(pi: ExtensionAPI) {
84
89
  bridge.setName(event.name);
85
90
  });
86
91
  pi.on("model_select", (_event, ctx) => {
92
+ bridge.invalidateContext();
87
93
  bridge.setContext(ctx);
88
94
  });
89
95
  pi.on("thinking_level_select", (_event, ctx) => {
@@ -99,6 +105,15 @@ export default function companionExtension(pi: ExtensionAPI) {
99
105
  bridge.updateStatus("idle");
100
106
  bridge.emit("agent.end", { messages: Array.isArray((event as { messages?: unknown[] }).messages) ? (event as { messages: unknown[] }).messages.length : 0 });
101
107
  });
108
+ pi.on("message_end", (_event, ctx) => bridge.setContext(ctx));
109
+ pi.on("session_compact", (_event, ctx) => {
110
+ bridge.invalidateContext();
111
+ bridge.setContext(ctx);
112
+ });
113
+ pi.on("session_tree", (_event, ctx) => {
114
+ bridge.invalidateContext();
115
+ bridge.setContext(ctx);
116
+ });
102
117
  // Forward compact deltas instead of the full partial message on every token.
103
118
  pi.on("message_update", event => {
104
119
  const update = event.assistantMessageEvent;
package/src/protocol.ts CHANGED
@@ -36,15 +36,30 @@ export type AskRequest = {
36
36
  /** Answers keyed by question id; each value lists chosen option labels and/or custom text. */
37
37
  export type AskAnswers = Record<string, string[]>;
38
38
 
39
+ export type UsageWindow = { usedPercent: number; resetsAt?: string };
40
+ export type ProviderUsage = {
41
+ provider: string;
42
+ source?: string;
43
+ updatedAt: string;
44
+ weekly?: UsageWindow;
45
+ fiveHour?: UsageWindow;
46
+ };
47
+ export type SessionTelemetry = {
48
+ context?: { tokens?: number; window?: number; percent?: number; source?: string };
49
+ cost?: { amount: number; currency?: string; source?: string };
50
+ providers?: ProviderUsage[];
51
+ };
52
+
39
53
  export type SessionSnapshot = {
40
54
  id: string;
41
- name?: string;
55
+ name?: string | null;
42
56
  cwd: string;
43
57
  pid: number;
44
58
  shortTitle: string;
45
59
  status: "active" | "idle" | "stopped";
46
- mainModel?: string;
47
- effort?: string;
60
+ mainModel?: string | null;
61
+ effort?: string | null;
62
+ telemetry?: SessionTelemetry;
48
63
  remoteEnabled: boolean;
49
64
  connectedAt: string;
50
65
  /** Questions waiting for an answer. Kept in the snapshot so late-joining browsers see them. */
@@ -0,0 +1,145 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ProviderUsage, SessionTelemetry, UsageWindow } from "./protocol.js";
3
+
4
+ const record = (value: unknown): Record<string, unknown> | undefined =>
5
+ value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
6
+ const number = (value: unknown): number | undefined => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
7
+ const text = (value: unknown): string | undefined => typeof value === "string" && value.trim() ? value.slice(0, 160) : undefined;
8
+ function timestamp(value: unknown): string | undefined {
9
+ const millis = typeof value === "number" ? (value < 1e12 ? value * 1000 : value) : typeof value === "string" ? Date.parse(value) : NaN;
10
+ return Number.isFinite(millis) && Math.abs(millis) <= 8.64e15 ? new Date(millis).toISOString() : undefined;
11
+ }
12
+ function usageWindow(value: unknown): UsageWindow | undefined {
13
+ const raw = record(value);
14
+ if (!raw) return;
15
+ const remaining = number(raw.remainingPercent);
16
+ const usedPercent = number(raw.usedPercent ?? raw.used_percent) ?? (remaining !== undefined && remaining <= 100 ? 100 - remaining : undefined);
17
+ if (usedPercent === undefined || usedPercent > 100) return;
18
+ return { usedPercent, resetsAt: timestamp(raw.resetsAt ?? raw.resetAt ?? raw.reset_at) };
19
+ }
20
+
21
+ /** Allowlisted, provider-neutral input; never relay arbitrary extension data or credentials. */
22
+ export function normalizeTelemetry(value: unknown, source = "extension", now = Date.now()): SessionTelemetry {
23
+ const raw = record(value);
24
+ if (!raw) return {};
25
+ const result: SessionTelemetry = {};
26
+ const context = record(raw.context ?? raw.contextUsage);
27
+ if (context) {
28
+ const tokens = number(context.tokens);
29
+ const window = number(context.window ?? context.contextWindow);
30
+ const percent = number(context.percent) ?? (tokens !== undefined && window ? tokens / window * 100 : undefined);
31
+ if (tokens !== undefined || percent !== undefined) result.context = { tokens, window, percent, source };
32
+ }
33
+ const cost = record(raw.cost);
34
+ const amount = number(cost?.amount ?? cost?.total ?? raw.cost);
35
+ if (amount !== undefined) result.cost = { amount, currency: text(cost?.currency) ?? "USD", source };
36
+ const providers = Array.isArray(raw.providers) ? raw.providers.slice(0, 100) : raw.provider ? [raw] : [];
37
+ const normalized: ProviderUsage[] = [];
38
+ for (const item of providers) {
39
+ const provider = record(item);
40
+ const name = text(provider?.provider);
41
+ if (!provider || !name) continue;
42
+ const weekly = usageWindow(provider.weekly ?? provider.sevenDay ?? provider.seven_day);
43
+ const fiveHour = usageWindow(provider.fiveHour ?? provider.five_hour);
44
+ if (!weekly && !fiveHour) continue;
45
+ normalized.push({ provider: name, source, updatedAt: timestamp(provider.updatedAt) ?? new Date(now).toISOString(), weekly, fiveHour });
46
+ }
47
+ if (normalized.length) result.providers = normalized;
48
+ return result;
49
+ }
50
+
51
+ /** Independent sources may supply different fields; one broken/missing adapter cannot erase another. */
52
+ export class TelemetryRelay {
53
+ private sources = new Map<string, { telemetry: SessionTelemetry; contextAt?: number; costAt?: number }>();
54
+
55
+ ingest(value: unknown, fallbackSource = "extension", now = Date.now()) {
56
+ const raw = record(value);
57
+ const source = text(raw?.source) ?? fallbackSource;
58
+ const telemetry = normalizeTelemetry(raw?.telemetry ?? value, source, now);
59
+ if (!Object.keys(telemetry).length) return;
60
+ const previousEntry = this.sources.get(source);
61
+ const previous = previousEntry?.telemetry;
62
+ const providers = new Map(previous?.providers?.map(provider => [provider.provider, provider]));
63
+ for (const provider of telemetry.providers ?? []) {
64
+ const old = providers.get(provider.provider);
65
+ // A delayed adapter response must not roll a newer provider snapshot backward.
66
+ if (!old || Date.parse(provider.updatedAt) >= Date.parse(old.updatedAt)) providers.set(provider.provider, provider);
67
+ }
68
+ this.sources.delete(source);
69
+ this.sources.set(source, {
70
+ telemetry: { ...previous, ...telemetry, providers: [...providers.values()] },
71
+ contextAt: telemetry.context ? now : previousEntry?.contextAt,
72
+ costAt: telemetry.cost ? now : previousEntry?.costAt
73
+ });
74
+ if (this.sources.size > 100) this.sources.delete(this.sources.keys().next().value!);
75
+ }
76
+
77
+ /** Token estimates from the old projection/model are not valid after compaction or a switch. */
78
+ invalidateContext() {
79
+ for (const entry of this.sources.values()) {
80
+ delete entry.telemetry.context;
81
+ delete entry.contextAt;
82
+ }
83
+ }
84
+
85
+ /** Legacy status producers need no Companion dependency. Explicit labels avoid ambiguous numbers. */
86
+ status(key: string, value: string | undefined) {
87
+ if (!value) { this.sources.delete("status:" + key); return; }
88
+ const plain = value.replace(/\x1b\[[0-9;]*m/g, "");
89
+ const telemetry: Record<string, unknown> = { source: "status:" + key };
90
+ const context = plain.match(/(?:context|ctx)\s*[:=]?\s*([\d.]+)%/i);
91
+ const labelledCost = plain.match(/cost\s*[:=]?\s*\$([\d.]+)/i);
92
+ const cost = labelledCost ?? (/cost|usage|token|footer|context/i.test(key) ? plain.match(/\$([\d.]+)/) : null);
93
+ if (context) telemetry.context = { percent: Number(context[1]) };
94
+ if (cost) telemetry.cost = { amount: Number(cost[1]) };
95
+ const weekly = plain.match(/(?:weekly|7d|7-day)\s*[:=]?\s*([\d.]+)%\s*(left|remaining|used)?/i);
96
+ const fiveHour = plain.match(/(?:5h|5-hour)\s*[:=]?\s*([\d.]+)%\s*(left|remaining|used)?/i);
97
+ const window = (match: RegExpMatchArray | null) => match ? { usedPercent: /left|remaining/i.test(match[2] ?? "") ? 100 - Number(match[1]) : Number(match[1]) } : undefined;
98
+ const provider = plain.match(/provider\s*[:=]\s*([\w.-]+)/i)?.[1];
99
+ if (provider && (weekly || fiveHour)) telemetry.providers = [{ provider, weekly: window(weekly), fiveHour: window(fiveHour) }];
100
+ this.ingest(telemetry);
101
+ }
102
+
103
+ snapshot(ctx: ExtensionContext | undefined, now = Date.now()): SessionTelemetry {
104
+ const result: SessionTelemetry = {};
105
+ const providers = new Map<string, ProviderUsage>();
106
+ let latestContext = -Infinity;
107
+ let latestCost = -Infinity;
108
+ for (const { telemetry, contextAt, costAt } of this.sources.values()) {
109
+ if (telemetry.context && contextAt !== undefined && now - contextAt <= 300_000 && contextAt >= latestContext) {
110
+ result.context = telemetry.context;
111
+ latestContext = contextAt;
112
+ }
113
+ if (telemetry.cost && costAt !== undefined && now - costAt <= 300_000 && costAt >= latestCost) {
114
+ result.cost = telemetry.cost;
115
+ latestCost = costAt;
116
+ }
117
+ for (const provider of telemetry.providers ?? []) {
118
+ const old = providers.get(provider.provider);
119
+ if (!old || Date.parse(provider.updatedAt) >= Date.parse(old.updatedAt)) providers.set(provider.provider, provider);
120
+ }
121
+ }
122
+ if (providers.size) result.providers = [...providers.values()];
123
+ try {
124
+ const native = ctx?.getContextUsage?.();
125
+ if (native) {
126
+ // Native unknown after compaction is authoritative: do not resurrect stale extension tokens.
127
+ result.context = { tokens: number(native.tokens), window: number(native.contextWindow), percent: number(native.percent), source: "native" };
128
+ }
129
+ } catch { /* Older runtimes/other extensions may not implement this getter. */ }
130
+ try {
131
+ const entries = ctx?.sessionManager?.getEntries?.() ?? [];
132
+ let total = 0;
133
+ let known = false;
134
+ for (const entry of entries) {
135
+ const raw = record(entry);
136
+ const message = record(raw?.message);
137
+ const usage = record(raw?.type === "message" ? message?.usage : ["usage", "compaction", "branch_summary"].includes(String(raw?.type)) ? raw?.usage : undefined);
138
+ const cost = number(record(usage?.cost)?.total);
139
+ if (cost !== undefined) { known = true; total += cost; }
140
+ }
141
+ if (known && Number.isFinite(total)) result.cost = { amount: total, currency: "USD", source: "native" };
142
+ } catch { /* Preserve extension estimate when native usage is unavailable. */ }
143
+ return result;
144
+ }
145
+ }