@yunazgr/pi-companion 0.2.4 → 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
@@ -13,7 +13,7 @@ The UI is a static SvelteKit application. There is no Node runtime in production
13
13
  <img src="docs/mobile.webp" alt="Session detail on a phone" width="28%" />
14
14
  </p>
15
15
 
16
- Screenshots use demo sessions, not private conversation data. See the [session menu](docs/session-menu.webp), [desktop session rows](docs/session-list-desktop.webp) and [mobile session list](docs/session-list.webp), plus [attachment previews](docs/attachments.webp), [file-grouped changes](docs/changes.webp) and [connection recovery](docs/connection-error.webp).
16
+ Screenshots use demo sessions, not private conversation data, and are regenerated with `node tools/screenshots.mjs` (see [Running locally from source](#running-locally-from-source)). See the [session menu](docs/session-menu.webp), [desktop session rows](docs/session-list-desktop.webp) and [mobile session list](docs/session-list.webp), plus [attachment previews](docs/attachments.webp), [file-grouped changes](docs/changes.webp) and [connection recovery](docs/connection-error.webp).
17
17
 
18
18
  ## Install
19
19
 
@@ -115,6 +115,7 @@ Optional environment variables (none are required):
115
115
  | PI_COMPANION_AUTOSTART=0 | never launch a daemon, only connect |
116
116
  | PI_COMPANION_PUBLIC_URL | daemon-side: public URL embedded in pairing QR codes |
117
117
  | PI_COMPANION_ALLOWED_ORIGINS | daemon-side: extra comma-separated browser origins accepted besides the loopback addresses and the public URL |
118
+ | PI_COMPANION_ADMIN_ADDR, PI_COMPANION_DEVICE_ADDR | daemon-side: loopback listen addresses (default `127.0.0.1:43721` / `127.0.0.1:43722`), for running a second daemon next to the usual one, e.g. for screenshots. The extension still looks for 43721 unless `PI_COMPANION_URL` points elsewhere |
118
119
 
119
120
  Local admin:
120
121
 
@@ -148,6 +149,16 @@ Pi's `companion_ask_user` tool asks one to four questions at once. Each question
148
149
 
149
150
  Dialogs from other extensions (`ctx.ui.select`, `ctx.ui.confirm`, `ctx.ui.input`) are relayed to the same sheet while the terminal dialog stays open: whichever side answers first wins and the other closes. Question tools that draw their own `ctx.ui.custom` picker are relayed through a small adapter: pi-jar's `jar_ask` is supported, and a companion answer completes its terminal picker. Other `ctx.ui.custom` components and `ctx.ui.editor` stay terminal-only because they cannot be answered from outside. Pending questions are part of the session snapshot, so a browser that connects later still sees them.
150
151
 
152
+ ## Notifications, camera and catching up
153
+
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
+
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
+
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
+
160
+ Attaching files uses plain HTTP, so it keeps working while the live socket is reconnecting (common right after a phone's file picker closes); only a deliberate disconnect or revoked access blocks it.
161
+
151
162
  ## Pairing and devices
152
163
 
153
164
  Pairing is daemon-wide rather than session-specific. Everything lives on the **Devices** page of the local console (port 43721):
@@ -167,11 +178,37 @@ Paired devices (name, browser user agent, pairing and last-seen times, credentia
167
178
 
168
179
  A phone pairs once with the daemon. Individual Pi sessions still opt in using /companion.
169
180
 
170
- 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.
171
208
 
172
209
  ## Settings
173
210
 
174
- 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:
175
212
 
176
213
  | Setting | Default | Notes |
177
214
  |---|---|---|
@@ -248,9 +285,10 @@ A single-page SvelteKit app, embedded in the daemon binary:
248
285
  | Page | Who sees it | What it is for |
249
286
  |---|---|---|
250
287
  | Overview | everyone | Dot-field hero, live counts, sessions that need your answer, a labeled live-session table, and a spaced onboarding/help panel with Claymorphism actions |
251
- | Sessions | everyone | Searchable, filterable full-width session rows (Working / Waiting / Ended), with short titles, workspace/model metadata, View details and safe Archive actions. Paired devices only see shared sessions |
288
+ | Sessions | everyone | Searchable, filterable full-width session rows (Working / Waiting / Ended); the whole row opens the session, the status badge sits top-right, and ended sessions offer a safe Archive action. Paired devices only see shared sessions |
252
289
  | Session detail | everyone | Activity-first transcript with Markdown, highlighted code, Mermaid diagrams, collapsible tool output/thinking and question sheets. Compact header and navigation leave more space for the shell. The expanding composer includes Auto/Plan selection, attachment previews and a full-width labeled Send/Steer action; Plan uses the existing `/plan` command, not an agent permission setting. The top-right session menu opens Shared files, Changes and Archive session. The session header shows a status dot; clicking its title reveals status and session metadata. Changes are grouped by file with clear separators and a filename filter |
253
- | Devices, Settings | local console only | Pairing, connection status, disconnect and revoke; daemon settings with a save bar that appears only for unsaved edits |
290
+ | Devices | local console only | Pairing, connection status, disconnect and revoke |
291
+ | Settings | everyone | Theme, notification and camera permissions; on the console also daemon settings with a save bar that appears only for unsaved edits |
254
292
  | Help, Privacy, Terms | everyone | Setup stepper, commands, tools, troubleshooting; data handling; terms of use. Help is opened from the Overview |
255
293
 
256
294
  The Live indicator in the header and sidebar opens the connection sheet (status, version and, on this computer, the console and device addresses and data folders).
@@ -268,7 +306,7 @@ Design notes:
268
306
  - installable PWA: scoped manifest with explicit 192×192 and 512×512 PNG icons plus a maskable icon, standalone display mode, Apple home-screen metadata, and a service worker that caches only the public shell and static assets; API/session traffic and uploads are never cached
269
307
  - mobile: bottom tab bar, safe-area insets, 44px touch targets, 16px inputs (no iOS zoom), Enter inserts a newline on touch keyboards
270
308
  - accessibility: skip link, visible focus rings, labeled controls and table metadata, accessible session menus, live regions for the feed and questions, reduced-motion support
271
- - the dot field is Raksara's component, loaded when the browser is idle, paused when hidden, and static under reduced motion
309
+ - the dot field is Raksara's component, loaded when the browser is idle, paused when hidden, and static under reduced motion. It opens on the Pi Companion mark, then morphs through a computer, a phone and a terminal
272
310
  - dropping a file anywhere outside the Shared files drop zone is ignored. Without this, the browser tries to open the file itself (Firefox reports this as "may not load or link to file:///")
273
311
 
274
312
  ### Caching and updates
@@ -314,7 +352,7 @@ Clone the repository, then:
314
352
 
315
353
  `npm run serve` builds the embedded Svelte UI and starts the Rust daemon in the foreground. It prints:
316
354
 
317
- Pi Companion v0.2.2
355
+ Pi Companion v0.2.6
318
356
 
319
357
  Console http://127.0.0.1:43721
320
358
  Paired devices http://127.0.0.1:43722
@@ -347,6 +385,13 @@ which proxies /api and /ws to the daemon on 43721.
347
385
 
348
386
  If you start Pi from the checkout without running `npm run serve`, running `/companion` can still auto-start a local daemon build if one exists under `server/target/debug` or `server/target/release`. For predictable UI work, prefer `npm run serve` in one terminal and `pi -e ./src/index.ts` in another.
349
387
 
388
+ To regenerate the README screenshots from demo sessions (your running daemon is left alone; the demo daemon uses ports 43731/43732 and a throwaway data folder):
389
+
390
+ npm run ui:build && cargo build --release --manifest-path server/Cargo.toml
391
+ node tools/screenshots.mjs
392
+
393
+ It needs Playwright and `cwebp`. Set `PLAYWRIGHT=/path/to/node_modules/playwright/index.mjs` if Playwright isn't installed in this repo, and `CHROME=/path/to/chrome` to use an existing browser.
394
+
350
395
  To test the same behavior as a published install, stop any development daemon first and install the package normally:
351
396
 
352
397
  pi install npm:@yunazgr/pi-companion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunazgr/pi-companion",
3
- "version": "0.2.4",
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
+ }