@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 +52 -7
- package/package.json +1 -1
- package/src/bridge.ts +75 -12
- package/src/index.ts +15 -0
- package/src/protocol.ts +18 -3
- package/src/telemetry.ts +145 -0
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.
|
|
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
|
|
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)
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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. */
|
package/src/telemetry.ts
ADDED
|
@@ -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
|
+
}
|