@yunazgr/pi-companion 0.3.5 → 0.3.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
@@ -2,7 +2,9 @@
2
2
 
3
3
  Lightweight, local-first remote control for Pi sessions.
4
4
 
5
- **New in 0.3.5:** Shared files now have authenticated Download buttons, filename filtering, and You / Agent source badges. Custom terminal popups retain the scrollable claymorphism viewer and touch-friendly controls introduced in 0.3.4.
5
+ **New in 0.3.6:** The extension automatically ensures the background daemon is available, while sessions stay private until `/remote-control`. Per-user services restart the daemon; concurrent startup is locked and upgrades verify the replacement first. Subscription limits are tracked separately for supported OAuth providers, and provider failures appear as clear messages in the activity feed.
6
+
7
+ When a session is shared, Companion fetches OpenAI Codex and Anthropic subscription usage via Pi's existing OAuth resolver, independently per provider with five-minute caching and failure backoff. Credentials are never sent to the browser or stored by the daemon. API-key accounts and unsupported providers have no built-in subscription lookup; extensions can still publish the neutral telemetry contract. Provider rate-limit, quota and authentication failures are shown in the activity feed with safe remediation guidance rather than raw provider payloads.
6
8
 
7
9
  Filename search is case-insensitive. New browser uploads are marked **You**; local agent uploads can declare **Agent** by posting to `/api/sessions/{session_id}/files?source=agent`. This query is honored only on the local admin endpoint; remote uploads always remain user uploads. Older files without provenance show **Unknown**. Source badges describe upload provenance, not file-content authorship.
8
10
 
@@ -29,9 +31,9 @@ or straight from git:
29
31
 
30
32
  pi install git:github.com/ygrip/pi-companion
31
33
 
32
- Then start Pi as usual. Installing the Pi extension is enough for normal use: the extension does not probe, connect to, download, or start a daemon until you run `/companion` in that session. On first use it downloads the matching sha256-verified daemon binary from GitHub Releases, caches it under `~/.pi/agent/pi-companion/bin/<version>/`, and reuses the same daemon across Pi sessions.
34
+ Then start Pi as usual. When the extension loads, it checks the local daemon and starts it if needed, downloading the matching sha256-verified release into `~/.pi/agent/pi-companion/bin/<version>/`. Daemon startup does **not** share your session.
33
35
 
34
- Use `/companion` in each session you want to share with paired devices. It enables remote access for that session and prints the local dashboard address. `/companion off` stops sharing that session.
36
+ Use `/remote-control` in any session to toggle sharing; `/remote-control on` and `/remote-control off` are explicit alternatives. No `/companion` prerequisite. `/companion` and `/companion off` remain compatibility aliases.
35
37
 
36
38
  You do **not** need to run `pi-companion-server` manually for a normal npm install. Manual daemon startup is mainly for local development, debugging, or when `PI_COMPANION_AUTOSTART=0` is set.
37
39
 
@@ -82,25 +84,19 @@ For a normal install:
82
84
 
83
85
  pi install npm:@yunazgr/pi-companion
84
86
 
85
- That is enough. Start Pi normally, then run `/companion` in the session you want to share. Only then does the extension connect or start the daemon on demand and keep its daemon version aligned with the installed extension. Updating the Pi package is therefore enough to update both pieces. You do not need a separate terminal, launch agent, system service, or manual `pi-companion-server` process.
87
+ Start Pi normally; daemon availability is automatic. Sessions stay private until `/remote-control` enables sharing. Updating the package and reloading Pi upgrades the daemon to the matching release; older Pi processes do not downgrade a newer daemon.
86
88
 
87
89
  The lifecycle is:
88
90
 
89
- 1. A Pi session loads the extension without daemon network or startup work.
90
- 2. You run `/companion` in that session; the extension probes `http://127.0.0.1:43721`.
91
- 3. If the daemon is already running, this opted-in session connects to it.
92
- 4. If not, the extension resolves the daemon binary, downloading the matching GitHub Release on first use if necessary.
93
- 5. It starts the daemon detached in the background.
94
- 6. Other Pi sessions reuse the same daemon only after their own `/companion` command. New sessions start disconnected, even when another session has enabled Companion.
95
- 7. On extension upgrades, the extension compares its package version with the running daemon. If they differ, it stops the old local daemon, resolves/downloads the matching release, and starts the new daemon automatically.
96
- 8. The daemon keeps running independently until it is stopped or the machine restarts.
97
-
98
- There is exactly one daemon per machine, shared by every Pi session. The extension manages it with no configuration:
91
+ 1. Extension startup probes the daemon and repeats the check every 30 seconds while Pi is open, without publishing this session.
92
+ 2. A process-owned startup lock serializes concurrent sessions, including long downloads. The daemon also refuses duplicate port binds.
93
+ 3. Resolve/download and version-check the replacement **before** stopping the existing daemon. Missing releases or invalid binaries leave the old daemon untouched; there is no latest-release fallback.
94
+ 4. When starting a managed daemon, register one per-user background service: macOS `launchd`, Linux `systemd --user`, or Windows Task Scheduler. Its supervisor restarts the daemon after crashes and reads the updated binary configuration after upgrades.
95
+ 5. If service registration is unavailable (for example no Linux user systemd or insufficient Windows permissions), warn and start a detached daemon instead. Extension-load and periodic checks remain the fallback.
96
+ 6. `/remote-control` toggles only the current session. Session switches reset sharing; turning sharing off never shuts down the daemon.
97
+ 7. Upgrades close sockets so browsers and opted-in Pi sessions reconnect. Active automation runs are stopped during daemon shutdown; the in-memory feed and temporary-file registry do not survive daemon replacement.
99
98
 
100
- 1. It probes http://127.0.0.1:43721. If anything answers (a daemon started by another session, or one you ran yourself with cargo run), it just connects. It never starts a second copy.
101
- 2. If nothing answers, one Pi session takes a lock (so several sessions starting at once don't race), launches the daemon detached, and waits for it to be ready. The others wait for that daemon.
102
- 3. If a live connection drops, the extension waits 20 seconds before launching a replacement, so restarting a dev daemon doesn't get pre-empted.
103
- 4. The daemon also refuses to run twice: if its ports are taken it prints a message and exits 0.
99
+ Services run with your user privileges, not administrator/root privileges. Login/logout behavior depends on the OS: Linux may need user lingering for logout persistence, and Windows scheduling may be restricted by policy. A pre-existing same-version manual daemon is reused without takeover; service registration happens when Companion needs to start or upgrade it. Package installation alone does not execute a downloader—Pi must load the extension once.
104
100
 
105
101
  The daemon binary is resolved in this order:
106
102
 
@@ -112,13 +108,17 @@ The daemon binary is resolved in this order:
112
108
  | 4 | pi-companion-server on PATH |
113
109
  | 5 | download of the GitHub release matching the package version, verified against SHA256SUMS, then cached |
114
110
 
111
+ To remove background startup, first set `PI_COMPANION_SERVICE=0` for Pi, then disable the per-user service: macOS `launchctl bootout gui/$(id -u)/dev.pi-companion.daemon` and remove `~/Library/LaunchAgents/dev.pi-companion.daemon.plist`; Linux `systemctl --user disable --now pi-companion.service` and remove `~/.config/systemd/user/pi-companion.service`; Windows `schtasks /End /TN "Pi Companion"` then `schtasks /Delete /TN "Pi Companion" /F`. Package removal alone does not uninstall an OS service. The supervisor/configuration lives under the daemon home directory's `service/` folder.
112
+
115
113
  Optional environment variables (none are required):
116
114
 
117
115
  | Variable | Effect |
118
116
  |---|---|
119
117
  | PI_COMPANION_SERVER | use this daemon binary |
120
118
  | PI_COMPANION_URL | daemon WebSocket URL (default ws://127.0.0.1:43721); autostart only happens for loopback URLs |
121
- | PI_COMPANION_AUTOSTART=0 | never launch a daemon, only connect |
119
+ | PI_COMPANION_AUTOSTART=0 | disable automatic starts/upgrades and startup monitoring (does not uninstall a previously registered service) |
120
+ | PI_COMPANION_SERVICE=0 | use detached startup instead of registering a background service (does not uninstall an existing service) |
121
+ | PI_COMPANION_QUOTAS=0 | disable Companion's OAuth subscription-limit requests |
122
122
  | PI_COMPANION_PUBLIC_URL | daemon-side: public URL embedded in pairing QR codes |
123
123
  | PI_COMPANION_ALLOWED_ORIGINS | daemon-side: extra comma-separated browser origins accepted besides the loopback addresses and the public URL |
124
124
  | 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 |
@@ -147,7 +147,7 @@ Inside any Pi session:
147
147
  /companion off # stop sharing this session
148
148
  /remote-control # toggle sharing on/off
149
149
 
150
- A paired device cannot access sessions that have not explicitly enabled remote control. Turning `/remote-control` off or running `/companion off` closes this session’s daemon channel: it disappears from paired devices and becomes **Ended** on the local dashboard. Pi continues locally; local Pi history is untouched. Sharing does not reconnect itself while disabled. After the initial opt-in, `/remote-control` can enable sharing again; `/companion` can also resume it. Temporary uploads follow the usual disconnected-session cleanup below.
150
+ A paired device cannot access sessions that have not explicitly enabled remote control. Turning `/remote-control` off or running `/companion off` closes this session’s daemon channel: it disappears from paired devices and becomes **Ended** on the local dashboard. Pi continues locally; local Pi history is untouched. Sharing does not reconnect itself while disabled. `/remote-control` can enable sharing directly, including on a fresh session; `/companion` can also resume it. Temporary uploads follow the usual disconnected-session cleanup below.
151
151
 
152
152
  ## Questions from Pi
153
153
 
@@ -192,7 +192,7 @@ Each paired device shows a live **Connected** / **Disconnected** status (with th
192
192
 
193
193
  Paired devices (name, browser user agent, pairing and last-seen times, credential hash) and settings survive daemon restarts. They are stored in `~/.pi/agent/pi-companion/state.json`, created with mode 0600 inside a 0700 directory and replaced atomically; a looser mode found on startup is tightened. Override the directory with `PI_COMPANION_HOME`.
194
194
 
195
- A phone pairs once with the daemon. Individual Pi sessions still opt in using /companion.
195
+ A phone pairs once with the daemon. Individual Pi sessions opt in using `/remote-control`.
196
196
 
197
197
  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.
198
198
 
@@ -426,7 +426,7 @@ To test the same behavior as a published install, stop any development daemon fi
426
426
 
427
427
  pi install npm:@yunazgr/pi-companion
428
428
 
429
- Then start Pi and run `/companion`. The extension downloads and starts the released daemon on demand; merely starting Pi does neither.
429
+ Then start Pi. Extension startup ensures the released daemon is available; use `/remote-control` only for sessions you want to share.
430
430
 
431
431
  ## Releases
432
432
 
@@ -440,7 +440,7 @@ Before publishing, the workflow requires X.Y.Z to match both package.json and se
440
440
 
441
441
  Only after the GitHub Release exists, the same tag publishes @yunazgr/pi-companion to npm with provenance through npm Trusted Publishing (GitHub OIDC). No long-lived NPM_TOKEN is required. The trusted publisher should point to GitHub owner ygrip, repository pi-companion, workflow release.yml. Git installs need no npm credential.
442
442
 
443
- On first use the extension downloads `pi-companion-server-<os>-<arch>` for its own package version (`releases/download/v<version>/`), verifies it against `SHA256SUMS`, and caches it under `~/.pi/agent/pi-companion/bin/<version>/`. On later extension upgrades it detects a running daemon with a different version, stops it locally, and starts the matching daemon automatically. A git install from a branch that is ahead of the newest tag falls back to the latest release.
443
+ On extension load, Companion downloads `pi-companion-server-<os>-<arch>` for its own package version (`releases/download/v<version>/`) if needed, verifies `SHA256SUMS` and the binary version, and caches it under `~/.pi/agent/pi-companion/bin/<version>/`. Upgrades prepare the replacement before gracefully stopping the older daemon. Git installs ahead of the newest release must provide a matching local build; missing releases never trigger a downgrade or shutdown.
444
444
 
445
445
  ## Security boundary
446
446
 
package/SECURITY.md CHANGED
@@ -24,6 +24,8 @@ 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
+ Extension startup may download/start the daemon and register a per-user login service (launchd, systemd user service, or Windows scheduled task), but never activates normal session sharing. Services run with the current user's permissions. Downloads are SHA256-verified and version-checked before an older daemon is stopped. `PI_COMPANION_AUTOSTART=0` disables extension-managed starts/upgrades; `PI_COMPANION_SERVICE=0` disables new service registration. Neither setting uninstalls an existing service; see README for removal instructions.
28
+
27
29
  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
30
 
29
31
  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).
@@ -78,7 +78,7 @@ The extension registers `companion_automation` and ships the `companion-automati
78
78
  {"action":"create","automation":{"name":"Check","enabled":true,"schedule":null,"preconditions":[],"actions":[{"type":"command","command":"git","args":["status","--short"],"cwd":"/absolute/project"}],"postActions":[]}}
79
79
  ```
80
80
 
81
- 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.
81
+ Tools only contact an already-running daemon. They never download/start one or activate sharing for a normal Pi session. The extension checks/starts the daemon automatically on load, without sharing a session. Standalone MCP users can start it via `npm run serve`. Inspect the final run status rather than assuming an accepted start means success.
82
82
 
83
83
  ## Standalone MCP
84
84
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunazgr/pi-companion",
3
- "version": "0.3.5",
3
+ "version": "0.3.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",
@@ -5,7 +5,7 @@ description: Create, update, delete, enable, disable, run, stop, and inspect Pi
5
5
 
6
6
  # Companion automations
7
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`.
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. The Pi extension checks/starts the daemon on load independently of session sharing. If unavailable, reload the extension or start a development daemon with `npm run serve`. `/remote-control` is only needed to share a normal session.
9
9
 
10
10
  ## Safety
11
11
 
@@ -0,0 +1,65 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, writeFile, rename } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ const exec = promisify(execFile);
7
+ const name = 'pi-companion';
8
+
9
+ /** A fixed, per-user service name: multiple extensions update the same service under the spawn lock. */
10
+ export async function startBackgroundService(binary: string): Promise<void> {
11
+ const home = process.env.PI_COMPANION_HOME ?? join(homedir(), '.pi', 'agent', 'pi-companion');
12
+ const dir = join(home, 'service');
13
+ await mkdir(dir, { recursive: true, mode: 0o700 });
14
+ const script = join(dir, 'supervisor.mjs');
15
+ const config = join(dir, 'daemon.json');
16
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key, value]) => value && (key.startsWith('PI_COMPANION_') || key === 'PATH')));
17
+ await writeFile(config + '.tmp', JSON.stringify({ binary, env }), { mode: 0o600 });
18
+ await rename(config + '.tmp', config);
19
+ await writeFile(script, `import { spawn } from 'node:child_process';
20
+ import { readFileSync } from 'node:fs';
21
+ let child; let stopping = false;
22
+ function run() {
23
+ if (stopping) return;
24
+ try {
25
+ const { binary, env } = JSON.parse(readFileSync(new URL('./daemon.json', import.meta.url), 'utf8'));
26
+ child = spawn(binary, [], { env: { ...process.env, ...env }, stdio: 'ignore', windowsHide: true });
27
+ let scheduled = false;
28
+ const retry = () => { if (!scheduled && !stopping) { scheduled = true; setTimeout(run, 3000); } };
29
+ child.once('error', retry); child.once('exit', retry);
30
+ } catch { setTimeout(run, 3000); }
31
+ }
32
+ for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => { stopping = true; child?.kill('SIGTERM'); setTimeout(() => process.exit(), 1000); });
33
+ run();
34
+ `, { mode: 0o600 });
35
+ const run = (command: string, args: string[]) => exec(command, args, { timeout: 15_000 });
36
+ if (process.platform === 'darwin') {
37
+ const escape = (text: string) => text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
38
+ const label = 'dev.pi-companion.daemon';
39
+ const plist = join(homedir(), 'Library', 'LaunchAgents', label + '.plist');
40
+ await mkdir(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true });
41
+ await writeFile(plist, `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>Label</key><string>${label}</string><key>ProgramArguments</key><array><string>${escape(process.execPath)}</string><string>${escape(script)}</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/></dict></plist>`, { mode: 0o600 });
42
+ const domain = 'gui/' + process.getuid!();
43
+ try { await run('launchctl', ['print', domain + '/' + label]); }
44
+ catch { await run('launchctl', ['bootstrap', domain, plist]); }
45
+ await run('launchctl', ['kickstart', domain + '/' + label]);
46
+ } else if (process.platform === 'linux') {
47
+ const unitDir = join(homedir(), '.config', 'systemd', 'user');
48
+ await mkdir(unitDir, { recursive: true });
49
+ const quote = (text: string) => '"' + text.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%').replaceAll('$', '$$') + '"';
50
+ await writeFile(join(unitDir, name + '.service'), `[Unit]\nDescription=Pi Companion daemon\n[Service]\nExecStart=${quote(process.execPath)} ${quote(script)}\nRestart=always\nRestartSec=3\n[Install]\nWantedBy=default.target\n`, { mode: 0o600 });
51
+ await run('systemctl', ['--user', 'daemon-reload']);
52
+ await run('systemctl', ['--user', 'enable', '--now', name + '.service']);
53
+ } else if (process.platform === 'win32') {
54
+ const escape = (text: string) => text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
55
+ const user = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join('\\');
56
+ if (!user) throw new Error('Cannot resolve the current Windows account');
57
+ const task = join(dir, 'task.xml');
58
+ // No default 72-hour limit, battery/idle stop, or overlapping task instances.
59
+ await writeFile(task, `\uFEFF<?xml version="1.0" encoding="UTF-16"?><Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"><Triggers><LogonTrigger><Enabled>true</Enabled><UserId>${escape(user)}</UserId></LogonTrigger></Triggers><Principals><Principal id="User"><UserId>${escape(user)}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals><Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure></Settings><Actions Context="User"><Exec><Command>${escape(process.execPath)}</Command><Arguments>&quot;${escape(script)}&quot;</Arguments></Exec></Actions></Task>`, { encoding: 'utf16le', mode: 0o600 });
60
+ await run('schtasks', ['/Create', '/F', '/TN', 'Pi Companion', '/XML', task]);
61
+ await run('schtasks', ['/Run', '/TN', 'Pi Companion']);
62
+ } else {
63
+ throw new Error('No supported per-user background service on ' + process.platform);
64
+ }
65
+ }
package/src/bridge.ts CHANGED
@@ -147,7 +147,7 @@ export class CompanionBridge implements AskChannel {
147
147
  return [...this.tempFiles];
148
148
  }
149
149
 
150
- /** Only an explicit /companion command may activate this session's bridge. */
150
+ /** Only an explicit sharing command may activate this session's bridge. */
151
151
  activate() {
152
152
  this.activated = true;
153
153
  if (this.snapshot.remoteEnabled && this.ctx?.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(this.ctx.ui, this, this.toolDialogs, this.popups);
@@ -287,7 +287,7 @@ export class CompanionBridge implements AskChannel {
287
287
  }
288
288
 
289
289
  async deleteTempFile(fileId: string) {
290
- if (!this.isActivated()) return { ok: false, error: "Run /companion first to enable this session." };
290
+ if (!this.isActivated()) return { ok: false, error: "Run /remote-control to share this session." };
291
291
  const requestId = randomUUID();
292
292
  this.send({ type: "file.delete", requestId, fileId });
293
293
  return await new Promise<{ ok: boolean; error?: string }>(resolve => {
package/src/daemon.ts CHANGED
@@ -102,17 +102,9 @@ async function downloadRelease(version: string, log: DaemonLog) {
102
102
  const dir = cacheDir(version);
103
103
  const target = join(dir, EXE);
104
104
  const exact = "https://github.com/" + REPO + "/releases/download/v" + version + "/";
105
- // A git install from a branch can be ahead of the newest tag; fall back to the latest release.
106
- const latest = "https://github.com/" + REPO + "/releases/latest/download/";
107
-
108
105
  log("Downloading Pi Companion daemon v" + version + "…");
109
- let base = exact;
110
- let archiveRes = await fetch(base + asset, { signal: AbortSignal.timeout(120_000) });
111
- if (archiveRes.status === 404) {
112
- log("No daemon release for v" + version + "; using the latest release.", "warning");
113
- base = latest;
114
- archiveRes = await fetch(base + asset, { signal: AbortSignal.timeout(120_000) });
115
- }
106
+ const base = exact;
107
+ const archiveRes = await fetch(base + asset, { signal: AbortSignal.timeout(120_000) });
116
108
  if (!archiveRes.ok) throw new Error("download failed: " + archiveRes.status + " " + asset);
117
109
  const sumsRes = await fetch(base + "SHA256SUMS", { signal: AbortSignal.timeout(30_000) });
118
110
  if (!sumsRes.ok) throw new Error("checksum download failed: " + sumsRes.status);
@@ -180,7 +172,14 @@ async function acquireLock() {
180
172
  } catch {
181
173
  try {
182
174
  const info = await stat(lock);
183
- if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
175
+ const owner = Number(readFileSync(lock, "utf8").trim());
176
+ let alive = false;
177
+ if (Number.isInteger(owner) && owner > 1) {
178
+ try { process.kill(owner, 0); alive = true; }
179
+ catch (error) { alive = (error as NodeJS.ErrnoException).code !== "ESRCH"; }
180
+ }
181
+ // Downloads can take minutes. Age alone must never steal a live owner's lock.
182
+ if (!alive && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
184
183
  await rm(lock, { force: true });
185
184
  return await acquireLock();
186
185
  }
@@ -199,22 +198,31 @@ async function waitForDaemon(ms: number, expectedVersion?: string) {
199
198
  return false;
200
199
  }
201
200
 
202
- async function waitForDaemonDown(ms: number) {
201
+ async function waitForDaemonDown(ms: number, old: DaemonContext) {
203
202
  const deadline = Date.now() + ms;
204
203
  while (Date.now() < deadline) {
205
- if (!(await isDaemonUp(300))) return true;
204
+ const current = await daemonContext(300);
205
+ if (!current || (current.pid !== undefined && old.pid !== undefined && current.pid !== old.pid)) return true;
206
206
  await new Promise(resolve => setTimeout(resolve, 200));
207
207
  }
208
208
  return false;
209
209
  }
210
210
 
211
+ export function versionAtLeast(actual: string | undefined, expected: string): boolean {
212
+ if (actual === expected) return true;
213
+ if (!actual || !/^\d+\.\d+\.\d+$/.test(actual) || !/^\d+\.\d+\.\d+$/.test(expected)) return false;
214
+ const a = actual.split('.').map(Number), b = expected.split('.').map(Number);
215
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
216
+ return true;
217
+ }
218
+
211
219
  async function stopDaemonForUpgrade(context: DaemonContext, log: DaemonLog) {
212
220
  try {
213
221
  const response = await fetch(adminHttpUrl() + "/api/shutdown", {
214
222
  method: "POST",
215
223
  signal: AbortSignal.timeout(2_000)
216
224
  });
217
- if (response.ok && await waitForDaemonDown(5_000)) return true;
225
+ if (response.ok && await waitForDaemonDown(5_000, context)) return true;
218
226
  } catch {
219
227
  // Older daemons do not have /api/shutdown. Fall through to a local process stop.
220
228
  }
@@ -222,16 +230,15 @@ async function stopDaemonForUpgrade(context: DaemonContext, log: DaemonLog) {
222
230
  try {
223
231
  if (typeof context.pid === "number" && Number.isInteger(context.pid) && context.pid > 1) {
224
232
  process.kill(context.pid, "SIGTERM");
225
- } else if (process.platform === "win32") {
226
- await execFileAsync("taskkill", ["/IM", "pi-companion-server.exe", "/F"]);
227
233
  } else {
228
- await execFileAsync("pkill", ["-f", "pi-companion-server"]);
234
+ log("The older daemon has no verifiable PID; refusing to stop unrelated processes.", "warning");
235
+ return false;
229
236
  }
230
237
  } catch {
231
238
  // The process may already have exited between the probe and the stop attempt.
232
239
  }
233
240
 
234
- const stopped = await waitForDaemonDown(5_000);
241
+ const stopped = await waitForDaemonDown(5_000, context);
235
242
  if (!stopped) log("Could not stop the older Pi Companion daemon automatically.", "warning");
236
243
  return stopped;
237
244
  }
@@ -243,15 +250,29 @@ async function stopDaemonForUpgrade(context: DaemonContext, log: DaemonLog) {
243
250
  export async function ensureDaemon(log: DaemonLog) {
244
251
  const expectedVersion = packageVersion();
245
252
  const running = await daemonContext();
246
- if (running?.version === expectedVersion) return true;
253
+ if (running && versionAtLeast(running.version, expectedVersion)) return true;
247
254
  if (process.env.PI_COMPANION_AUTOSTART === "0" || !isLocalTarget()) return Boolean(running);
248
255
 
249
256
  const release = await acquireLock();
250
257
  if (!release) return await waitForDaemon(10_000, expectedVersion); // another Pi session is starting/upgrading it
251
258
  try {
252
259
  const current = await daemonContext();
253
- if (current?.version === expectedVersion) return true;
260
+ if (current && versionAtLeast(current.version, expectedVersion)) return true;
254
261
 
262
+ // Download and validate before touching the healthy running process.
263
+ const binary = await resolveDaemonBinary(log);
264
+ const { stdout } = await execFileAsync(binary, ["--version"], { timeout: 10_000 });
265
+ if (stdout.trim() !== "pi-companion-server " + expectedVersion) {
266
+ throw new Error("replacement daemon version does not match installed extension v" + expectedVersion);
267
+ }
268
+ let managed = false;
269
+ if (process.env.PI_COMPANION_SERVICE !== "0") {
270
+ try {
271
+ const { startBackgroundService } = await import("./background-service.js");
272
+ await startBackgroundService(binary); managed = true;
273
+ }
274
+ catch (error) { log("Background service unavailable; using detached daemon: " + (error instanceof Error ? error.message : String(error)), "warning"); }
275
+ }
255
276
  if (current) {
256
277
  log(
257
278
  "Updating Pi Companion daemon from v" + (current.version ?? "unknown") + " to v" + expectedVersion + "…"
@@ -259,7 +280,10 @@ export async function ensureDaemon(log: DaemonLog) {
259
280
  if (!(await stopDaemonForUpgrade(current, log))) return false;
260
281
  }
261
282
 
262
- const binary = await resolveDaemonBinary(log);
283
+ if (managed) {
284
+ // A supervisor already running the old binary restarts using the updated config.
285
+ return await waitForDaemon(15_000, expectedVersion);
286
+ }
263
287
  await new Promise<void>((resolve, reject) => {
264
288
  const child = spawn(binary, [], { detached: true, stdio: "ignore", windowsHide: true });
265
289
  child.once("error", reject);
package/src/index.ts CHANGED
@@ -1,8 +1,9 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  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 { ProviderQuotaPoller, providerError } from "./provider-status.ts";
6
7
  import { automationTool, executeAutomation, type AutomationParams } from "./automation.ts";
7
8
 
8
9
  const AskOptionParam = Type.Union([
@@ -71,6 +72,19 @@ function resultText(result: unknown) {
71
72
  export default function companionExtension(pi: ExtensionAPI) {
72
73
  let bridge = new CompanionBridge(pi);
73
74
  let sharingGeneration = 0;
75
+ let quotas = new ProviderQuotaPoller(value => bridge.ingestTelemetry(value, 'subscription limits'));
76
+ let daemonCheck: Promise<boolean> | undefined;
77
+ let daemonMonitor: ReturnType<typeof setInterval> | undefined;
78
+ const checkDaemon = (ctx: ExtensionContext) => {
79
+ if (process.env.PI_COMPANION_AUTOSTART === "0") return Promise.resolve(false);
80
+ if (!daemonCheck) {
81
+ daemonCheck = ensureDaemon((message, level = "info") => {
82
+ // Background startup should not interrupt the user unless something fails.
83
+ if (level !== "info") ctx.ui.notify(message, level);
84
+ }).finally(() => { daemonCheck = undefined; });
85
+ }
86
+ return daemonCheck;
87
+ };
74
88
 
75
89
  // Any extension can publish the neutral contract. No dependency on a particular footer/provider.
76
90
  for (const channel of ["companion:telemetry", "usage:update", "session:usage", "provider:usage"]) {
@@ -80,16 +94,26 @@ export default function companionExtension(pi: ExtensionAPI) {
80
94
  pi.on("session_start", (_event, ctx) => {
81
95
  // Switching or starting a Pi session must not inherit the previous opt-in.
82
96
  sharingGeneration += 1;
97
+ quotas.stop();
98
+ quotas = new ProviderQuotaPoller(value => bridge.ingestTelemetry(value, 'subscription limits'));
83
99
  bridge.close();
84
100
  bridge = new CompanionBridge(pi);
85
101
  bridge.setContext(ctx);
86
102
  bridge.emit("session.start", { cwd: ctx.cwd });
103
+ void checkDaemon(ctx);
104
+ if (daemonMonitor) clearInterval(daemonMonitor);
105
+ daemonMonitor = setInterval(() => {
106
+ void checkDaemon(ctx);
107
+ if (bridge.isRemoteEnabled()) void quotas.refresh(ctx);
108
+ }, 30_000);
109
+ daemonMonitor.unref();
87
110
  });
88
111
  pi.on("session_info_changed", (event, ctx) => {
89
112
  bridge.setContext(ctx);
90
113
  bridge.setName(event.name);
91
114
  });
92
115
  pi.on("model_select", (_event, ctx) => {
116
+ if (bridge.isRemoteEnabled()) void quotas.refresh(ctx);
93
117
  bridge.invalidateContext();
94
118
  bridge.setContext(ctx);
95
119
  });
@@ -106,7 +130,11 @@ export default function companionExtension(pi: ExtensionAPI) {
106
130
  bridge.updateStatus("idle");
107
131
  bridge.emit("agent.end", { messages: Array.isArray((event as { messages?: unknown[] }).messages) ? (event as { messages: unknown[] }).messages.length : 0 });
108
132
  });
109
- pi.on("message_end", (_event, ctx) => bridge.setContext(ctx));
133
+ pi.on("message_end", (event, ctx) => {
134
+ bridge.setContext(ctx);
135
+ const failure = providerError(event.message);
136
+ if (failure) bridge.emit('provider.error', failure);
137
+ });
110
138
  pi.on("session_compact", (_event, ctx) => {
111
139
  bridge.invalidateContext();
112
140
  bridge.setContext(ctx);
@@ -138,6 +166,9 @@ export default function companionExtension(pi: ExtensionAPI) {
138
166
  });
139
167
  });
140
168
  pi.on("session_shutdown", event => {
169
+ quotas.stop();
170
+ if (daemonMonitor) clearInterval(daemonMonitor);
171
+ daemonMonitor = undefined;
141
172
  sharingGeneration += 1;
142
173
  bridge.updateStatus("stopped");
143
174
  bridge.emit("session.shutdown", event);
@@ -275,52 +306,42 @@ export default function companionExtension(pi: ExtensionAPI) {
275
306
  }
276
307
  });
277
308
 
309
+ async function setSharing(enabled: boolean, ctx: ExtensionContext) {
310
+ const generation = ++sharingGeneration;
311
+ bridge.setContext(ctx);
312
+ if (!enabled) {
313
+ bridge.setRemoteEnabled(false);
314
+ ctx.ui.notify("Remote control disabled; Pi continues locally. The daemon remains available.", "info");
315
+ return;
316
+ }
317
+ const currentBridge = bridge;
318
+ const up = await ensureDaemon((message, level = "info") => ctx.ui.notify(message, level));
319
+ if (bridge !== currentBridge || generation !== sharingGeneration) return;
320
+ if (!up) {
321
+ ctx.ui.notify("Pi Companion daemon is not reachable at " + adminHttpUrl(), "warning");
322
+ return;
323
+ }
324
+ bridge.activate();
325
+ bridge.setRemoteEnabled(true);
326
+ void quotas.refresh(ctx);
327
+ void bridge.connect();
328
+ ctx.ui.notify("Remote control enabled for this session: " + adminHttpUrl(), "info");
329
+ }
330
+
278
331
  pi.registerCommand("companion", {
279
332
  description: "Enable Pi Companion for this session and show the dashboard address (`/companion off` to stop sharing)",
280
- handler: async (args, ctx) => {
281
- const generation = ++sharingGeneration;
282
- bridge.setContext(ctx);
283
- if (args.trim().toLowerCase() === "off") {
284
- bridge.setRemoteEnabled(false);
285
- ctx.ui.notify("Pi Companion sharing ended for this session. Pi continues locally.", "info");
286
- return;
287
- }
288
- const currentBridge = bridge;
289
- const up = await ensureDaemon((message, level = "info") => ctx.ui.notify(message, level));
290
- if (bridge !== currentBridge || generation !== sharingGeneration) return;
291
- if (!up) {
292
- ctx.ui.notify("Pi Companion daemon is not reachable at " + adminHttpUrl(), "warning");
293
- return;
294
- }
295
- bridge.activate();
296
- void bridge.connect();
297
- const enabled = true;
298
- bridge.setRemoteEnabled(enabled);
299
- ctx.ui.notify(
300
- enabled
301
- ? "Pi Companion enabled for this session: " + adminHttpUrl() + " (paired devices can now see it)"
302
- : "Pi Companion sharing ended for this session. Pi continues locally.",
303
- "info"
304
- );
305
- }
333
+ handler: async (args, ctx) => setSharing(args.trim().toLowerCase() !== "off", ctx)
306
334
  });
307
335
 
308
336
  pi.registerCommand("remote-control", {
309
337
  description: "Toggle remote control for this Pi session",
310
- handler: async (_args, ctx) => {
311
- sharingGeneration += 1;
312
- if (!bridge.isActivated()) {
313
- ctx.ui.notify("Run /companion first to enable this session's daemon connection.", "warning");
338
+ handler: async (args, ctx) => {
339
+ const mode = args.trim().toLowerCase();
340
+ if (mode && mode !== "on" && mode !== "off") {
341
+ ctx.ui.notify("Usage: /remote-control [on|off]", "warning");
314
342
  return;
315
343
  }
316
- const enabled = !bridge.isRemoteEnabled();
317
- bridge.setRemoteEnabled(enabled);
318
- ctx.ui.notify(
319
- enabled
320
- ? "Remote control enabled for this session. Pair a device from Pi Companion."
321
- : "Remote control disabled; this Companion session has ended. Pi continues locally.",
322
- enabled ? "info" : "warning"
323
- );
344
+ await setSharing(mode === "on" || (mode !== "off" && !bridge.isRemoteEnabled()), ctx);
324
345
  }
325
346
  });
326
347
  }
@@ -0,0 +1,85 @@
1
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import type { ProviderUsage, UsageWindow } from './protocol.js';
3
+ const object = (v: unknown): Record<string, unknown> => v && typeof v === 'object' ? v as Record<string, unknown> : {};
4
+ const window = (used: unknown, reset: unknown): UsageWindow | undefined => {
5
+ if (typeof used !== 'number' || !Number.isFinite(used) || used < 0 || used > 100) return;
6
+ const date = typeof reset === 'number' ? new Date(reset * 1000) : typeof reset === 'string' ? new Date(reset) : undefined;
7
+ return { usedPercent: used, ...(date && Number.isFinite(date.getTime()) ? { resetsAt: date.toISOString() } : {}) };
8
+ };
9
+ export function parseProviderQuota(provider: string, value: unknown): Pick<ProviderUsage, 'weekly' | 'fiveHour'> {
10
+ const body = object(value);
11
+ if (provider === 'anthropic') return {
12
+ weekly: window(object(body.seven_day).utilization, object(body.seven_day).resets_at),
13
+ fiveHour: window(object(body.five_hour).utilization, object(body.five_hour).resets_at)
14
+ };
15
+ const quota: Pick<ProviderUsage, 'weekly' | 'fiveHour'> = {};
16
+ const rate = object(body.rate_limit);
17
+ for (const [raw, fallback] of [[rate.primary_window, 'fiveHour'], [rate.secondary_window, 'weekly']] as const) {
18
+ const item = object(raw), parsed = window(item.used_percent, item.reset_at);
19
+ if (!parsed) continue;
20
+ const seconds = item.limit_window_seconds;
21
+ const key = typeof seconds === 'number' && seconds > 0 ? (seconds <= 86400 ? 'fiveHour' : 'weekly') : fallback;
22
+ quota[key] = parsed;
23
+ }
24
+ return quota;
25
+ }
26
+
27
+ /** Fetch only known subscription endpoints using Pi's OAuth resolver. No credentials leave Pi. */
28
+ export class ProviderQuotaPoller {
29
+ private pending = new Set<string>();
30
+ private next = new Map<string, number>();
31
+ private failures = new Map<string, number>();
32
+ private stopped = false;
33
+ private controllers = new Set<AbortController>();
34
+ constructor(private publish: (value: unknown) => void) {}
35
+ stop() { this.stopped = true; for (const controller of this.controllers) controller.abort(); }
36
+ async refresh(ctx: ExtensionContext) {
37
+ if (this.stopped || process.env.PI_COMPANION_QUOTAS === '0') return;
38
+ let available: ReturnType<ExtensionContext['modelRegistry']['getAvailable']>;
39
+ try { available = ctx.modelRegistry?.getAvailable?.() ?? []; } catch { return; }
40
+ for (const provider of ['openai-codex', 'anthropic']) {
41
+ const model = available.find(item => item.provider === provider);
42
+ let oauth = false;
43
+ try { oauth = Boolean(model && ctx.modelRegistry.isUsingOAuth(model)); } catch { /* unsupported registry */ }
44
+ if (!model || !oauth || this.pending.has(provider) || Date.now() < (this.next.get(provider) ?? 0)) continue;
45
+ this.pending.add(provider);
46
+ const controller = new AbortController(); this.controllers.add(controller);
47
+ this.next.set(provider, Date.now() + 300_000);
48
+ try {
49
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
50
+ if (!auth.ok || !auth.apiKey || this.stopped) continue;
51
+ const headers: Record<string, string> = { Authorization: 'Bearer ' + auth.apiKey, Accept: 'application/json' };
52
+ let url = 'https://api.anthropic.com/api/oauth/usage';
53
+ if (provider === 'openai-codex') {
54
+ const payload = JSON.parse(Buffer.from(auth.apiKey.split('.')[1] ?? '', 'base64url').toString('utf8'));
55
+ const id = object(payload['https://api.openai.com/auth']).chatgpt_account_id;
56
+ if (typeof id !== 'string' || !id) continue;
57
+ headers['ChatGPT-Account-Id'] = id;
58
+ url = 'https://chatgpt.com/backend-api/wham/usage';
59
+ } else headers['anthropic-beta'] = 'oauth-2025-04-20';
60
+ const response = await fetch(url, { headers, signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]) });
61
+ if (!response.ok) throw new Error('quota lookup failed');
62
+ const quota = parseProviderQuota(provider, await response.json());
63
+ if (!this.stopped && (quota.weekly || quota.fiveHour)) this.publish({ source: 'Companion subscription limits', providers: [{ provider, ...quota, updatedAt: new Date().toISOString() }] });
64
+ this.failures.delete(provider);
65
+ } catch {
66
+ // Never forward response bodies or authentication data. Back off per provider.
67
+ const failures = Math.min(6, (this.failures.get(provider) ?? 0) + 1);
68
+ this.failures.set(provider, failures);
69
+ this.next.set(provider, Date.now() + Math.min(1800_000, 300_000 * 2 ** failures));
70
+ } finally { this.pending.delete(provider); this.controllers.delete(controller); }
71
+ }
72
+ }
73
+ }
74
+
75
+ export function providerError(value: unknown): { provider: string; title: string; message: string } | undefined {
76
+ const raw = object(value);
77
+ if (raw.role !== 'assistant' || raw.stopReason !== 'error') return;
78
+ const text = typeof raw.errorMessage === 'string' ? raw.errorMessage : '';
79
+ const provider = typeof raw.provider === 'string' ? raw.provider : 'Provider';
80
+ const quota = /quota|usage.limit|credit|billing|insufficient_quota|exhausted/i.test(text);
81
+ const rate = /rate.limit|429|too many requests/i.test(text);
82
+ const auth = /401|403|unauthori[sz]ed|authentication|invalid.api.key/i.test(text);
83
+ return { provider, title: quota ? 'Provider quota exhausted' : rate ? 'Provider rate limit' : auth ? 'Provider authentication failed' : 'Provider request failed',
84
+ message: quota ? 'The provider reports that your usage allowance or credits are exhausted. Check your plan or wait for the limit to reset, then retry or switch provider.' : rate ? 'The provider is limiting requests. Wait before retrying, or switch provider. Pi may retry automatically.' : auth ? 'Your provider login or credentials were rejected. Reauthenticate in Pi, then retry.' : 'The provider could not complete this request. Check the Pi terminal for details, then retry or switch provider.' };
85
+ }
package/src/telemetry.ts CHANGED
@@ -87,6 +87,19 @@ export class TelemetryRelay {
87
87
  status(key: string, value: string | undefined) {
88
88
  if (!value) { this.sources.delete("status:" + key); return; }
89
89
  const plain = value.replace(/\x1b\[[0-9;]*m/g, "");
90
+ if (key.startsWith('pi-jar.quota.')) {
91
+ try {
92
+ const quota = record(JSON.parse(plain));
93
+ const expiresAt = number(quota?.expiresAt);
94
+ if (!quota || !expiresAt || expiresAt <= Date.now() || expiresAt > Date.now() + 300_000) return;
95
+ const convert = (value: unknown) => {
96
+ const item = record(value);
97
+ return item ? { usedPercent: item.used, resetsAt: typeof item.resetsAt === 'number' ? new Date(item.resetsAt).toISOString() : undefined } : undefined;
98
+ };
99
+ this.ingest({ providers: [{ provider: key.slice('pi-jar.quota.'.length), weekly: convert(quota.week), fiveHour: convert(quota.fiveHour) }] }, 'status:' + key);
100
+ } catch { /* Malformed or expired quota reports are not authoritative. */ }
101
+ return;
102
+ }
90
103
  const telemetry: Record<string, unknown> = { source: "status:" + key };
91
104
  const context = plain.match(/(?:context|ctx)\s*[:=]?\s*([\d.]+)%/i);
92
105
  const labelledCost = plain.match(/cost\s*[:=]?\s*\$([\d.]+)/i);