@yunazgr/pi-companion 0.2.5 → 0.3.0

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
@@ -2,6 +2,8 @@
2
2
 
3
3
  Lightweight, local-first remote control for Pi sessions.
4
4
 
5
+ **New in 0.3.0:** [automations](docs/automations.md) with manual/UTC-cron runs, read-only agent sessions, run history, native agent tools and a standalone MCP surface. Disconnected sessions auto-archive after seven days; mobile Settings is now in the header.
6
+
5
7
  Pi Companion is deliberately not another agent runtime. Pi owns execution and conversation state. A single Rust daemon owns session discovery, pairing, temporary file exchange, browser fan-out, and the embedded web UI.
6
8
 
7
9
  The UI is a static SvelteKit application. There is no Node runtime in production and no Tauri shell. Rust embeds the generated frontend into the daemon binary.
@@ -153,7 +155,7 @@ Dialogs from other extensions (`ctx.ui.select`, `ctx.ui.confirm`, `ctx.ui.input`
153
155
 
154
156
  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
157
 
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.
158
+ 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
159
 
158
160
  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
161
 
@@ -178,11 +180,37 @@ Paired devices (name, browser user agent, pairing and last-seen times, credentia
178
180
 
179
181
  A phone pairs once with the daemon. Individual Pi sessions still opt in using /companion.
180
182
 
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.
183
+ 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.
184
+
185
+ ## Session metadata and usage
186
+
187
+ 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.
188
+
189
+ **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.
190
+
191
+ Extensions can publish a provider-neutral event without importing Companion:
192
+
193
+ ```ts
194
+ pi.events.emit("companion:telemetry", {
195
+ source: "my-usage-extension", // distinguish independent producers
196
+ // sessionId: ctx.sessionManager.getSessionId(), // optional session guard
197
+ context: { tokens: 24000, window: 200000, percent: 12 },
198
+ cost: { amount: 0.25, currency: "USD" },
199
+ providers: [{
200
+ provider: "openai-codex", updatedAt: new Date().toISOString(),
201
+ weekly: { usedPercent: 35, resetsAt: "2026-10-12T00:00:00Z" },
202
+ fiveHour: { usedPercent: 20 } // omit windows the provider doesn't supply
203
+ }]
204
+ });
205
+ ```
206
+
207
+ 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.
208
+
209
+ 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
210
 
183
211
  ## Settings
184
212
 
185
- The **Settings** page (local console only) covers:
213
+ The **Settings → General** page provides appearance and device permissions on every device; daemon configuration is local-console-only:
186
214
 
187
215
  | Setting | Default | Notes |
188
216
  |---|---|---|
@@ -326,7 +354,7 @@ Clone the repository, then:
326
354
 
327
355
  `npm run serve` builds the embedded Svelte UI and starts the Rust daemon in the foreground. It prints:
328
356
 
329
- Pi Companion v0.2.5
357
+ Pi Companion v0.3.0
330
358
 
331
359
  Console http://127.0.0.1:43721
332
360
  Paired devices http://127.0.0.1:43722
package/SECURITY.md CHANGED
@@ -24,7 +24,7 @@ Strict Origin checks: WebSocket upgrades and POST/DELETE requests must carry an
24
24
 
25
25
  Pairing invitations are single-use and expire (5 minutes by default). The short typed code is rate limited: ten wrong codes within ten minutes withdraw every open invitation and further code claims get HTTP 429 until the window passes.
26
26
 
27
- Paired devices can see only sessions that explicitly enabled remote control.
27
+ Paired devices can see only sessions that explicitly enabled remote control, including daemon-owned automation sessions created by an authorized automation run. Paired devices may read automation definitions/history and start/stop existing automations, but cannot create, edit, delete, or enable/disable them.
28
28
 
29
29
  Revoking a device deletes its credential hash and closes its open connections immediately (WebSocket close 4003). Disconnecting closes them without deleting the credential (close 4001).
30
30
 
@@ -32,6 +32,14 @@ Revoking a device deletes its credential hash and closes its open connections im
32
32
 
33
33
  Paired devices (name, browser user agent, pairing and last-seen times, and the SHA-256 hash of the credential; never the credential) and settings are written atomically to `~/.pi/agent/pi-companion/state.json`. On Unix the directory is 0700 and the file is created 0600 before any byte is written; a looser mode found at startup is tightened.
34
34
 
35
+ Automation definitions, prompts, and bounded run output are stored separately in `automations.json`, using atomic replacement and private Unix permissions. Output can contain sensitive command/agent data; it is readable by paired devices. Failed or stopped runs are retained; interrupted runs are marked failed on daemon restart.
36
+
37
+ ### Automations
38
+
39
+ Only the local admin surface can author automation scripts. Native extension and standalone MCP tools use that surface and do not start the daemon implicitly. Commands use argv without an implicit shell, but both commands and spawned Pi agents run with the OS user's permissions; this is not a sandbox. A paired device can start a previously authorized script with side effects. Disabling a definition prevents scheduling, not explicit manual runs.
40
+
41
+ Automation sessions accept only question answers/cancellation from browsers. Prompts, steering, abort/plan/diff commands, uploads, and file deletion are denied server-side. Stop a run through its automation endpoint instead. Five-field UTC schedules run only while the daemon is alive, without overlapping runs or catch-up. Automatic session archiving after seven disconnected days removes registry/feed records only, never local Pi/project files.
42
+
35
43
  ### Web UI hardening
36
44
 
37
45
  HTML responses send a same-origin Content-Security-Policy (no third-party scripts, styles, fonts or connections), `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` and `X-Content-Type-Options: nosniff`. The UI loads nothing from the network beyond the daemon itself.
@@ -54,7 +62,7 @@ Agent deletion uses the opaque id rather than a path. Before unlinking, the daem
54
62
 
55
63
  Pi Companion does not provide:
56
64
 
57
- - remote shell access
65
+ - arbitrary remote shell access (paired devices can start admin-authored automation commands)
58
66
  - arbitrary filesystem browsing
59
67
  - arbitrary tool invocation
60
68
  - direct git push/commit endpoints
@@ -0,0 +1,63 @@
1
+ # Automations (0.3.0)
2
+
3
+ Automations are persisted, named JSON scripts run by the Companion daemon. Open **Automations** in the navigation to view definitions, start/stop runs, and inspect timestamped run history. Click a run to view its captured output and Pi summary. Desktop console users can create, edit, delete, and enable/disable definitions; mobile and paired-device users can only inspect and start/stop them.
4
+
5
+ ## JSON DSL
6
+
7
+ ```json
8
+ {
9
+ "name": "Daily repository check",
10
+ "enabled": true,
11
+ "schedule": "0 9 * * 1-5",
12
+ "preconditions": [
13
+ { "type": "command", "command": "git", "args": ["rev-parse", "--is-inside-work-tree"], "cwd": "/absolute/project" }
14
+ ],
15
+ "actions": [
16
+ { "type": "command", "command": "npm", "args": ["test"], "cwd": "/absolute/project", "timeoutSeconds": 600 },
17
+ { "type": "pi", "prompt": "Summarize the repository's current health. Do not edit files.", "cwd": "/absolute/project", "timeoutSeconds": 900 }
18
+ ],
19
+ "postActions": []
20
+ }
21
+ ```
22
+
23
+ Commands run as executable + argv, not through an implicit shell. A nonzero precondition skips the main actions. Actions execute in order. Post-actions provide a finalization stage. `schedule: null` is manual-only; otherwise use five cron fields (minute, hour, day of month, month, day of week), evaluated in **UTC**. Scheduled execution requires the daemon to remain running; it does not catch up missed executions. A definition cannot have overlapping runs. Disabling scheduling and stopping a run are separate operations.
24
+
25
+ A Pi action starts `pi --mode rpc --no-session` in the specified directory. Pi must be on PATH and already configured with credentials/model settings. It exposes a read-only Companion session: the feed and questions are visible, but steering, prompts, plan changes, file uploads, and diffs are unavailable. This restriction applies to browser control, **not** the agent's own tools or filesystem access. Pi's final assistant output is stored in the run result. Supported RPC `select`, `confirm`, `input`, and `editor` questions are forwarded; terminal-only custom dialogs cannot be displayed. The native `companion_ask_user` tool falls back to these RPC dialogs for daemon-owned runs.
26
+
27
+ **Security:** automations run with the daemon user's operating-system privileges. They are not sandboxed. Paired devices can start an existing automation, including commands with side effects. Pair only devices you trust, and review commands, prompts, working directories, and schedules before saving/enabling a definition. Native agent tools and MCP management use the local admin surface; do not expose that surface publicly.
28
+
29
+ ## Agent surface
30
+
31
+ The extension registers `companion_automation` and ships the `companion-automations` skill. Operations: `list`, `get`, `create`, `update`, `delete`, `enable`, `disable`, `start`, `stop`, `runs`, and `run`. Supply `id` except for list/create, a full `automation` definition for create/update, and `runId` for run detail.
32
+
33
+ ```json
34
+ {"action":"create","automation":{"name":"Check","enabled":true,"schedule":null,"preconditions":[],"actions":[{"type":"command","command":"git","args":["status","--short"],"cwd":"/absolute/project"}],"postActions":[]}}
35
+ ```
36
+
37
+ Tools only contact an already-running daemon. They never download/start one or activate sharing for a normal Pi session. Start Companion explicitly via `/companion` or `npm run serve` first. Inspect the final run status rather than assuming an accepted start means success.
38
+
39
+ ## Standalone MCP
40
+
41
+ Node >=22.17 can run the shipped stdio MCP server, without an MCP SDK dependency:
42
+
43
+ ```bash
44
+ pi mcp add companion-automations -- node --experimental-strip-types --no-warnings /absolute/path/to/pi-companion/src/automation-mcp.ts
45
+ ```
46
+
47
+ Other MCP clients can configure the same executable and arguments. The server exposes `companion_automation` with the same schema as the native extension tool. Configure only one surface if you want to avoid duplicate tools. It uses the default local admin address, overridable with `PI_COMPANION_URL=ws://127.0.0.1:PORT`; non-loopback management targets are rejected.
48
+
49
+ ## HTTP API
50
+
51
+ Admin console:
52
+
53
+ - `GET /api/automations` → `{automations}`; `POST` creates a definition.
54
+ - `GET /api/automations/{id}` → definition; `PUT` replaces it; `DELETE` removes it.
55
+ - `POST /api/automations/{id}/start` and `/stop`.
56
+ - `GET /api/automations/{id}/runs` → `{runs}`.
57
+ - `GET /api/automations/{id}/runs/{runId}` → run details including result.
58
+
59
+ The paired-device surface requires valid device authentication and allowed Origin, and exposes only GET/start/stop. Mobile layout is a UI restriction, not a separate authentication role; local console API callers remain administrators.
60
+
61
+ ## Automatic archiving
62
+
63
+ Disconnected/stopped session records older than seven days are archived automatically. Live command channels, active/idle sessions, and recent disconnections are retained. Archiving removes only the daemon registry/feed entries, never the project, local Pi history, or files. Automation definitions and run history are independent of session archiving.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunazgr/pi-companion",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
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",
@@ -28,6 +28,8 @@
28
28
  "main": "src/index.ts",
29
29
  "files": [
30
30
  "src",
31
+ "skills",
32
+ "docs/automations.md",
31
33
  "README.md",
32
34
  "SECURITY.md",
33
35
  "LICENSE"
@@ -37,6 +39,7 @@
37
39
  },
38
40
  "scripts": {
39
41
  "serve": "npm run --silent ui:build -- --logLevel warn && cargo run -q --manifest-path server/Cargo.toml",
42
+ "automation:mcp": "node --experimental-strip-types --no-warnings src/automation-mcp.ts",
40
43
  "server:dev": "npm run serve",
41
44
  "server:check": "npm run ui:build && cargo check --manifest-path server/Cargo.toml",
42
45
  "ui:dev": "cd ui && vite dev",
@@ -44,13 +47,17 @@
44
47
  "check": "tsc --noEmit && npm run ui:check",
45
48
  "ui:check": "cd ui && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
46
49
  "test": "npm run test:extension && npm run test:server",
47
- "test:extension": "node --test --experimental-transform-types --no-warnings test/*.test.ts",
48
- "test:server": "cargo test --manifest-path server/Cargo.toml"
50
+ "test:extension": "node --test --experimental-transform-types --no-warnings test/*.test.ts ui/automation.test.ts",
51
+ "test:server": "cargo test --manifest-path server/Cargo.toml",
52
+ "test:smoke": "npm run ui:build && cargo build --manifest-path server/Cargo.toml --locked && node tools/smoke-automations.mjs"
49
53
  },
50
54
  "pi": {
51
55
  "extensions": [
52
56
  "./src/index.ts"
53
57
  ],
58
+ "skills": [
59
+ "./skills"
60
+ ],
54
61
  "image": "https://raw.githubusercontent.com/ygrip/pi-companion/main/docs/dashboard.png"
55
62
  },
56
63
  "dependencies": {
@@ -0,0 +1,45 @@
1
+ ---
2
+ name: companion-automations
3
+ description: Create, update, delete, enable, disable, run, stop, and inspect Pi Companion automations with the companion_automation tool. Use for recurring repository tasks, scheduled agent work, and automation run history.
4
+ ---
5
+
6
+ # Companion automations
7
+
8
+ Use `companion_automation` (native extension or standalone MCP surface). It talks only to the local admin daemon; it does not launch the daemon or enable session sharing. If unavailable, ask the user to start Companion with `/companion` or `npm run serve`.
9
+
10
+ ## Safety
11
+
12
+ Before saving or starting an automation, confirm the user's intended command, working directory, schedule, and side effects. Do not silently schedule destructive commands, spend model quota, or turn an untrusted prompt into a recurring task. Commands and Pi runs execute with the user's OS permissions, not in a sandbox. Remote/mobile clients can inspect, start, and stop existing definitions, but cannot author them.
13
+
14
+ Read the current definition with `get` before `update`, `enable`, or `disable`. Updating replaces the full definition. Preserve fields not intentionally changed. `disable` prevents scheduled runs; use `stop` separately to cancel a running automation. Definitions and run history are daemon-owned; archiving sessions never deletes local Pi data.
15
+
16
+ ## Definition DSL
17
+
18
+ ```json
19
+ {
20
+ "name": "Repository check",
21
+ "enabled": true,
22
+ "schedule": "0 9 * * 1-5",
23
+ "preconditions": [],
24
+ "actions": [
25
+ { "type": "command", "command": "npm", "args": ["test"], "cwd": "/absolute/repo", "timeoutSeconds": 600 },
26
+ { "type": "pi", "prompt": "Summarize repository health. Do not edit files.", "cwd": "/absolute/repo", "timeoutSeconds": 900 }
27
+ ],
28
+ "postActions": []
29
+ }
30
+ ```
31
+
32
+ - `schedule` is a five-field **UTC** cron expression; `null` means manual only. The daemon must be running for scheduling. There is no catch-up while it is stopped.
33
+ - Commands use executable plus argument array, **no implicit shell**. Shell syntax must never be passed as an executable. Avoid explicit shells unless the user requested and authorized them.
34
+ - Preconditions gate execution. Actions run sequentially. Post-actions are the cleanup/finalization stage; inspect the run result to determine failures.
35
+ - A Pi action starts a daemon-owned, read-only Companion session: users can answer asks, but cannot steer, prompt, upload, request changes, or edit its plan. The agent itself still has ordinary Pi tool permissions.
36
+ - Pi must be installed on PATH and configured with model credentials. Its final assistant output is saved in run history.
37
+
38
+ ## Tool operations
39
+
40
+ - `list`, `get` (`id`)
41
+ - `create` (`automation`), `update` (`id`, full `automation`)
42
+ - `delete`, `enable`, `disable`, `start`, `stop` (`id`)
43
+ - `runs` (`id`), `run` (`id`, `runId`)
44
+
45
+ After `start`, inspect `runs`/`run`; an accepted start is not completion. Report the actual final status and stored result. Do not retry a side-effecting run automatically after an ambiguous network error.
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ /** Standalone stdio MCP surface. Run with Node >=22.17 (native TypeScript stripping). */
3
+ import { automationTool, executeAutomation, type AutomationParams } from "./automation.ts";
4
+
5
+ const inFlight = new Map<string | number, AbortController>();
6
+ let output = Promise.resolve();
7
+ function send(record: unknown) {
8
+ const line = JSON.stringify(record) + "\n";
9
+ output = output.then(() => new Promise<void>((resolve, reject) => {
10
+ process.stdout.write(line, error => error ? reject(error) : resolve());
11
+ }));
12
+ void output.catch(() => { process.exitCode = 1; process.stdin.destroy(); });
13
+ }
14
+ const error = (id: unknown, code: number, message: string) => send({ jsonrpc: "2.0", id, error: { code, message } });
15
+ async function receive(line: string) {
16
+ let message: { jsonrpc?: string; id?: string | number; method?: string; params?: Record<string, unknown> };
17
+ try { message = JSON.parse(line); }
18
+ catch { error(null, -32700, "Invalid JSON"); return; }
19
+ if (!message || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
20
+ error(message?.id ?? null, -32600, "Invalid JSON-RPC request"); return;
21
+ }
22
+ if (message.method === "notifications/cancelled") {
23
+ const id = message.params?.requestId;
24
+ if (typeof id === "string" || typeof id === "number") inFlight.get(id)?.abort();
25
+ return;
26
+ }
27
+ if (message.id === undefined) return;
28
+ const id = message.id;
29
+ const reply = (result: unknown) => send({ jsonrpc: "2.0", id, result });
30
+ switch (message.method) {
31
+ case "initialize": {
32
+ const versions = ["2025-06-18", "2025-03-26", "2024-11-05"];
33
+ const requested = message.params?.protocolVersion;
34
+ reply({ protocolVersion: versions.includes(String(requested)) ? requested : versions[0], capabilities: { tools: {} }, serverInfo: { name: "pi-companion-automations", version: "0.3.0" } });
35
+ return;
36
+ }
37
+ case "ping": reply({}); return;
38
+ case "tools/list": reply({ tools: [automationTool] }); return;
39
+ case "tools/call": {
40
+ if (message.params?.name !== automationTool.name) { error(id, -32602, "Unknown tool"); return; }
41
+ const args = message.params?.arguments;
42
+ if (!args || typeof args !== "object" || Array.isArray(args)) { error(id, -32602, "Expected tool arguments"); return; }
43
+ const controller = new AbortController();
44
+ inFlight.set(id, controller);
45
+ try {
46
+ const result = await executeAutomation(args as AutomationParams, controller.signal);
47
+ reply({ content: [{ type: "text", text: JSON.stringify(result, null, 2) }] });
48
+ } catch (failure) {
49
+ reply({ isError: true, content: [{ type: "text", text: String(failure instanceof Error ? failure.message : failure) }] });
50
+ } finally { inFlight.delete(id); }
51
+ return;
52
+ }
53
+ default: error(id, -32601, "Method not found");
54
+ }
55
+ }
56
+
57
+ // Split only LF: Unicode line separators are legal inside JSON string values.
58
+ let buffer = Buffer.alloc(0);
59
+ for await (const chunk of process.stdin) {
60
+ buffer = Buffer.concat([buffer, Buffer.from(chunk)]);
61
+ let newline: number;
62
+ while ((newline = buffer.indexOf(10)) !== -1) {
63
+ const line = buffer.subarray(0, newline).toString("utf8").replace(/\r$/, "");
64
+ buffer = buffer.subarray(newline + 1);
65
+ if (Buffer.byteLength(line) > 1024 * 1024) { error(null, -32600, "Request too large"); continue; }
66
+ if (line.trim()) void receive(line);
67
+ }
68
+ if (buffer.length > 1024 * 1024) { error(null, -32600, "Request too large"); process.stdin.destroy(); break; }
69
+ }
70
+ for (const controller of inFlight.values()) controller.abort();
71
+ await output;
@@ -0,0 +1,105 @@
1
+ import { adminHttpUrl } from "./daemon.ts";
2
+
3
+ export type AutomationAction =
4
+ | { type: "command"; command: string; args: string[]; cwd?: string; timeoutSeconds?: number }
5
+ | { type: "pi"; prompt: string; cwd: string; timeoutSeconds?: number };
6
+ export type AutomationDefinition = {
7
+ name: string;
8
+ enabled: boolean;
9
+ schedule: string | null;
10
+ preconditions: AutomationAction[];
11
+ actions: AutomationAction[];
12
+ postActions: AutomationAction[];
13
+ };
14
+ export type AutomationParams = {
15
+ action: "list" | "get" | "create" | "update" | "delete" | "enable" | "disable" | "start" | "stop" | "runs" | "run";
16
+ id?: string;
17
+ runId?: string;
18
+ automation?: AutomationDefinition;
19
+ };
20
+
21
+ const actionSchema = {
22
+ oneOf: [
23
+ { type: "object", additionalProperties: false, required: ["type", "command", "args"], properties: {
24
+ type: { const: "command" }, command: { type: "string", minLength: 1 },
25
+ args: { type: "array", items: { type: "string" } }, cwd: { type: "string" },
26
+ timeoutSeconds: { type: "integer", minimum: 1, maximum: 86400 }
27
+ } },
28
+ { type: "object", additionalProperties: false, required: ["type", "prompt", "cwd"], properties: {
29
+ type: { const: "pi" }, prompt: { type: "string", minLength: 1 }, cwd: { type: "string", minLength: 1 },
30
+ timeoutSeconds: { type: "integer", minimum: 1, maximum: 86400 }
31
+ } }
32
+ ]
33
+ };
34
+
35
+ export const automationTool = {
36
+ name: "companion_automation",
37
+ description: "Manage Pi Companion automations: list, get, create, update, delete, enable, disable, start, stop, runs, or run detail. Definitions use preconditions, actions, postActions and optional five-field UTC cron schedule. Commands execute argv without a shell. Mutations require the user's authorization; running actions has the computer user's permissions. Does not start the daemon or enable session sharing.",
38
+ inputSchema: {
39
+ type: "object", additionalProperties: false, required: ["action"],
40
+ properties: {
41
+ action: { type: "string", enum: ["list", "get", "create", "update", "delete", "enable", "disable", "start", "stop", "runs", "run"] },
42
+ id: { type: "string", minLength: 1 }, runId: { type: "string", minLength: 1 },
43
+ automation: {
44
+ type: "object", additionalProperties: false,
45
+ required: ["name", "enabled", "schedule", "preconditions", "actions", "postActions"],
46
+ properties: {
47
+ name: { type: "string", minLength: 1 }, enabled: { type: "boolean" },
48
+ schedule: { type: ["string", "null"], description: "Five-field UTC cron; null for manual only." },
49
+ preconditions: { type: "array", items: actionSchema },
50
+ actions: { type: "array", minItems: 1, items: actionSchema },
51
+ postActions: { type: "array", items: actionSchema }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ };
57
+
58
+ /** Explicit tool calls only: never probe, download, or launch a daemon here. */
59
+ export async function executeAutomation(params: AutomationParams, signal?: AbortSignal) {
60
+ const actions = ['list', 'get', 'create', 'update', 'delete', 'enable', 'disable', 'start', 'stop', 'runs', 'run'];
61
+ if (!params || typeof params !== 'object' || !actions.includes(params.action)) throw new Error("Unknown automation action.");
62
+ if (params.id !== undefined && (typeof params.id !== 'string' || !params.id.trim())) throw new Error("id must be a nonempty string.");
63
+ if (params.runId !== undefined && (typeof params.runId !== 'string' || !params.runId.trim())) throw new Error("runId must be a nonempty string.");
64
+ const base = adminHttpUrl();
65
+ const target = new URL(base);
66
+ if (!['127.0.0.1', 'localhost', '[::1]', '::1'].includes(target.hostname) || !['http:', 'https:'].includes(target.protocol)) {
67
+ throw new Error("Automation management requires the local Companion admin address.");
68
+ }
69
+ const request = async (path: string, method = "GET", body?: unknown) => {
70
+ const response = await fetch(base + path, {
71
+ method,
72
+ headers: body === undefined ? undefined : { "Content-Type": "application/json" },
73
+ body: body === undefined ? undefined : JSON.stringify(body),
74
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000)
75
+ });
76
+ if (!response.ok) throw new Error(`Companion ${response.status}: ${await response.text()}`);
77
+ return response.status === 204 ? { ok: true } : await response.json();
78
+ };
79
+ if (params.action === "list") return request("/api/automations");
80
+ if (params.action === "create") {
81
+ if (!params.automation) throw new Error("create requires automation.");
82
+ return request("/api/automations", "POST", params.automation);
83
+ }
84
+ if (!params.id) throw new Error(`${params.action} requires id.`);
85
+ const path = "/api/automations/" + encodeURIComponent(params.id);
86
+ switch (params.action) {
87
+ case "get": return request(path);
88
+ case "update":
89
+ if (!params.automation) throw new Error("update requires a complete automation definition.");
90
+ return request(path, "PUT", params.automation);
91
+ case "delete": return request(path, "DELETE");
92
+ case "enable": case "disable": {
93
+ const current = await request(path) as AutomationDefinition;
94
+ // PUT only definition fields, never server-generated metadata.
95
+ const { name, schedule, preconditions, actions, postActions } = current;
96
+ return request(path, "PUT", { name, schedule, preconditions, actions, postActions, enabled: params.action === "enable" });
97
+ }
98
+ case "start": case "stop": return request(path + "/" + params.action, "POST");
99
+ case "runs": return request(path + "/runs");
100
+ case "run":
101
+ if (!params.runId) throw new Error("run requires runId.");
102
+ return request(path + "/runs/" + encodeURIComponent(params.runId));
103
+ default: throw new Error("Unknown automation action.");
104
+ }
105
+ }
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
@@ -3,6 +3,7 @@ import { Type } from "typebox";
3
3
  import { formatAnswers, toQuestions } from "./ask.js";
4
4
  import { CompanionBridge } from "./bridge.js";
5
5
  import { adminHttpUrl, ensureDaemon } from "./daemon.js";
6
+ import { automationTool, executeAutomation, type AutomationParams } from "./automation.ts";
6
7
 
7
8
  const AskOptionParam = Type.Union([
8
9
  Type.String(),
@@ -71,6 +72,11 @@ export default function companionExtension(pi: ExtensionAPI) {
71
72
  let bridge = new CompanionBridge(pi);
72
73
  let sharingGeneration = 0;
73
74
 
75
+ // Any extension can publish the neutral contract. No dependency on a particular footer/provider.
76
+ for (const channel of ["companion:telemetry", "usage:update", "session:usage", "provider:usage"]) {
77
+ pi.events?.on(channel, value => bridge.ingestTelemetry(value, channel));
78
+ }
79
+
74
80
  pi.on("session_start", (_event, ctx) => {
75
81
  // Switching or starting a Pi session must not inherit the previous opt-in.
76
82
  sharingGeneration += 1;
@@ -84,6 +90,7 @@ export default function companionExtension(pi: ExtensionAPI) {
84
90
  bridge.setName(event.name);
85
91
  });
86
92
  pi.on("model_select", (_event, ctx) => {
93
+ bridge.invalidateContext();
87
94
  bridge.setContext(ctx);
88
95
  });
89
96
  pi.on("thinking_level_select", (_event, ctx) => {
@@ -99,6 +106,15 @@ export default function companionExtension(pi: ExtensionAPI) {
99
106
  bridge.updateStatus("idle");
100
107
  bridge.emit("agent.end", { messages: Array.isArray((event as { messages?: unknown[] }).messages) ? (event as { messages: unknown[] }).messages.length : 0 });
101
108
  });
109
+ pi.on("message_end", (_event, ctx) => bridge.setContext(ctx));
110
+ pi.on("session_compact", (_event, ctx) => {
111
+ bridge.invalidateContext();
112
+ bridge.setContext(ctx);
113
+ });
114
+ pi.on("session_tree", (_event, ctx) => {
115
+ bridge.invalidateContext();
116
+ bridge.setContext(ctx);
117
+ });
102
118
  // Forward compact deltas instead of the full partial message on every token.
103
119
  pi.on("message_update", event => {
104
120
  const update = event.assistantMessageEvent;
@@ -128,6 +144,22 @@ export default function companionExtension(pi: ExtensionAPI) {
128
144
  bridge.close();
129
145
  });
130
146
 
147
+ pi.registerTool({
148
+ name: automationTool.name,
149
+ label: "Companion automation",
150
+ description: automationTool.description,
151
+ parameters: Type.Unsafe<AutomationParams>(automationTool.inputSchema),
152
+ executionMode: "sequential",
153
+ async execute(_toolCallId, params, signal) {
154
+ try {
155
+ const result = await executeAutomation(params, signal);
156
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: { result, error: null as string | null } };
157
+ } catch (error) {
158
+ return { isError: true, content: [{ type: "text", text: String(error instanceof Error ? error.message : error) }], details: { result: null, error: String(error) } };
159
+ }
160
+ }
161
+ });
162
+
131
163
  pi.registerTool({
132
164
  name: "companion_ask_user",
133
165
  label: "Ask via Companion",
@@ -135,7 +167,46 @@ export default function companionExtension(pi: ExtensionAPI) {
135
167
  "Ask the user one to four questions through the Pi Companion web/phone UI. Each question can offer options (single or multi-select) and an optional free-text answer.",
136
168
  parameters: AskParams,
137
169
  executionMode: "sequential",
138
- async execute(_toolCallId, params, signal) {
170
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
171
+ // Daemon-owned RPC runs relay supported extension UI dialogs, not a second bridge.
172
+ // Normal sessions still require explicit /companion activation.
173
+ if (ctx?.mode === "rpc" && process.env.PI_COMPANION_AUTOMATION_RUN_ID) {
174
+ const items = params.questions?.length ? params.questions : params.question ? [{ question: params.question, options: params.options }] : [];
175
+ if (!items.length) return { content: [{ type: "text", text: "Provide `question` or `questions`." }], details: { questions: [], answers: null } };
176
+ const questions = toQuestions(items);
177
+ const answers: Record<string, string[]> = {};
178
+ for (const question of questions) {
179
+ if (signal?.aborted) break;
180
+ const choices = question.options.map(option => option.label);
181
+ const title = question.question + (question.options.some(option => option.description) ? "\n" + question.options.map(option => option.label + (option.description ? ": " + option.description : "")).join("\n") : "");
182
+ if (choices.length && !question.multiSelect) {
183
+ const custom = "Other (enter a response)";
184
+ const selected = await ctx.ui.select(title, question.allowCustom ? [...choices, custom] : choices, { signal, timeout: ASK_TIMEOUT_MS });
185
+ if (selected === undefined) break;
186
+ if (question.allowCustom && selected === custom) {
187
+ const value = await ctx.ui.input(question.question, "Your response", { signal, timeout: ASK_TIMEOUT_MS });
188
+ if (value === undefined) break;
189
+ answers[question.id] = [value];
190
+ } else answers[question.id] = [selected];
191
+ } else if (question.multiSelect && choices.length) {
192
+ answers[question.id] = [];
193
+ for (const choice of choices) {
194
+ if (signal?.aborted) break;
195
+ if (await ctx.ui.confirm(question.question, "Select: " + choice, { signal, timeout: ASK_TIMEOUT_MS })) answers[question.id].push(choice);
196
+ }
197
+ if (question.allowCustom) {
198
+ const value = await ctx.ui.input(question.question, "Optional additional response", { signal, timeout: ASK_TIMEOUT_MS });
199
+ if (value?.trim()) answers[question.id].push(value);
200
+ }
201
+ } else {
202
+ const value = await ctx.ui.input(title, question.placeholder, { signal, timeout: ASK_TIMEOUT_MS });
203
+ if (value === undefined) break;
204
+ answers[question.id] = [value];
205
+ }
206
+ }
207
+ const complete = !signal?.aborted && questions.every(question => question.id in answers);
208
+ return { content: [{ type: "text", text: complete ? formatAnswers(questions, answers) : "The user dismissed the question in Pi Companion." }], details: { questions, answers: complete ? answers : null } };
209
+ }
139
210
  if (!bridge.isActivated()) {
140
211
  return {
141
212
  content: [{ type: "text", text: "Pi Companion is not enabled for this session. Run /companion before asking through the dashboard." }],
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
+ }