@yunazgr/pi-companion 0.3.5 → 0.3.7
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 +26 -24
- package/SECURITY.md +2 -0
- package/docs/automations.md +1 -1
- package/package.json +1 -1
- package/skills/companion-automations/SKILL.md +1 -1
- package/src/background-service.ts +65 -0
- package/src/bridge.ts +32 -7
- package/src/daemon.ts +45 -21
- package/src/index.ts +77 -42
- package/src/provider-status.ts +85 -0
- package/src/telemetry.ts +13 -0
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
|
+
**New in 0.3.7:** Resuming a previously shared Pi session reconnects automatically under the same dashboard identity. Explicit `/remote-control off` survives resume; new and forked sessions stay private. Unnamed session titles now reflect the first user prompt, refresh with branch context, and remain editable with `/name`.
|
|
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.
|
|
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 `/
|
|
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
|
-
|
|
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.
|
|
90
|
-
2.
|
|
91
|
-
3.
|
|
92
|
-
4.
|
|
93
|
-
5.
|
|
94
|
-
6.
|
|
95
|
-
7.
|
|
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
|
-
|
|
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 |
|
|
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.
|
|
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
|
|
195
|
+
A phone pairs once with the daemon. Individual Pi sessions opt in using `/remote-control`. This choice is saved in Pi session history: resuming that same session restores sharing unless you used `/remote-control off`. New sessions and forks do not inherit another session's opt-in. After upgrading from 0.3.6 or earlier, enable sharing once to save this choice; older versions did not record it.
|
|
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
|
|
|
@@ -294,6 +294,8 @@ A previously cached PWA shell can also explain gateway failures on reload. If th
|
|
|
294
294
|
|
|
295
295
|
## Session identity
|
|
296
296
|
|
|
297
|
+
Companion uses Pi's persistent session ID, so restarting, resuming, or re-enabling sharing replaces the same dashboard entry instead of creating a duplicate. Unnamed sessions use the first text-bearing user prompt in the current branch as their title (whitespace-normalized and capped at 120 characters), with the project folder as a fallback before a prompt exists. `/name` overrides that title and updates the dashboard live; clearing the name restores the prompt-derived title.
|
|
298
|
+
|
|
297
299
|
Every registered Pi session publishes a compact display model to the daemon:
|
|
298
300
|
|
|
299
301
|
- session name
|
|
@@ -304,7 +306,7 @@ Every registered Pi session publishes a compact display model to the daemon:
|
|
|
304
306
|
- working directory and process id
|
|
305
307
|
- remote-control state
|
|
306
308
|
|
|
307
|
-
Stopped sessions remain visible in the daemon registry so the dashboard does not lose context when a Pi process exits. Their controls are disabled and their temporary sandbox is cleaned after the reconnect grace period. Use **Archive session** on the list or in the session menu to remove a disconnected/stopped entry. The daemon rejects removal of active, idle, waiting, or otherwise connected sessions. Archiving removes only the daemon entry; it never deletes Pi history, project files or other files on disk. Temporary uploads still follow the existing disconnect cleanup lifecycle. Reopening
|
|
309
|
+
Stopped sessions remain visible in the daemon registry so the dashboard does not lose context when a Pi process exits. Their controls are disabled and their temporary sandbox is cleaned after the reconnect grace period. Use **Archive session** on the list or in the session menu to remove a disconnected/stopped entry. The daemon rejects removal of active, idle, waiting, or otherwise connected sessions. Archiving removes only the daemon entry; it never deletes Pi history, project files or other files on disk. Temporary uploads still follow the existing disconnect cleanup lifecycle. Reopening a previously shared Pi session registers it again automatically; otherwise use `/companion`. Entries created by older versions with random IDs can be archived once disconnected.
|
|
308
310
|
|
|
309
311
|
## UI
|
|
310
312
|
|
|
@@ -426,7 +428,7 @@ To test the same behavior as a published install, stop any development daemon fi
|
|
|
426
428
|
|
|
427
429
|
pi install npm:@yunazgr/pi-companion
|
|
428
430
|
|
|
429
|
-
Then start Pi
|
|
431
|
+
Then start Pi. Extension startup ensures the released daemon is available; use `/remote-control` only for sessions you want to share.
|
|
430
432
|
|
|
431
433
|
## Releases
|
|
432
434
|
|
|
@@ -440,7 +442,7 @@ Before publishing, the workflow requires X.Y.Z to match both package.json and se
|
|
|
440
442
|
|
|
441
443
|
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
444
|
|
|
443
|
-
On
|
|
445
|
+
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
446
|
|
|
445
447
|
## Security boundary
|
|
446
448
|
|
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).
|
package/docs/automations.md
CHANGED
|
@@ -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.
|
|
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.
|
|
3
|
+
"version": "0.3.7",
|
|
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,
|
|
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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
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>"${escape(script)}"</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
|
@@ -12,7 +12,7 @@ import type { AskAnswers, AskRequest, BridgeMessage, ServerMessage, SessionSnaps
|
|
|
12
12
|
const execFileAsync = promisify(execFile);
|
|
13
13
|
|
|
14
14
|
export class CompanionBridge implements AskChannel {
|
|
15
|
-
|
|
15
|
+
private pendingPrompt?: string;
|
|
16
16
|
/** Relays question tools that ask through ctx.ui.custom (fed from tool_execution_* events). */
|
|
17
17
|
readonly toolDialogs = new ToolDialogRelay(this);
|
|
18
18
|
readonly popups = new PopupRelay(popups => {
|
|
@@ -39,7 +39,7 @@ export class CompanionBridge implements AskChannel {
|
|
|
39
39
|
private asks = new Map<string, { request: AskRequest; settle: (answers: AskAnswers | null) => void }>();
|
|
40
40
|
private pendingDeletes = new Map<string, { resolve: (result: { ok: boolean; error?: string }) => void; timer: NodeJS.Timeout }>();
|
|
41
41
|
private snapshot: SessionSnapshot = {
|
|
42
|
-
id:
|
|
42
|
+
id: "",
|
|
43
43
|
cwd: process.cwd(),
|
|
44
44
|
pid: process.pid,
|
|
45
45
|
shortTitle: process.cwd().split(/[\\/]/).filter(Boolean).pop() ?? "Pi",
|
|
@@ -49,7 +49,9 @@ export class CompanionBridge implements AskChannel {
|
|
|
49
49
|
asks: []
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
-
constructor(private readonly pi: ExtensionAPI) {
|
|
52
|
+
constructor(private readonly pi: ExtensionAPI, readonly sessionId: string = randomUUID()) {
|
|
53
|
+
this.snapshot.id = sessionId;
|
|
54
|
+
}
|
|
53
55
|
|
|
54
56
|
setContext(ctx: ExtensionContext) {
|
|
55
57
|
this.ctx = ctx;
|
|
@@ -59,10 +61,32 @@ export class CompanionBridge implements AskChannel {
|
|
|
59
61
|
|
|
60
62
|
setName(name?: string) {
|
|
61
63
|
this.snapshot.name = name ?? null;
|
|
62
|
-
this.snapshot.shortTitle = name
|
|
64
|
+
this.snapshot.shortTitle = this.contextTitle(name, this.snapshot.cwd);
|
|
63
65
|
this.send({ type: "session.update", session: { name: name ?? null, shortTitle: this.snapshot.shortTitle } });
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/** Input arrives before Pi appends the user message to session history. */
|
|
69
|
+
setPrompt(text: string) {
|
|
70
|
+
if (text.trim()) this.pendingPrompt ??= text;
|
|
71
|
+
this.refreshMetadata();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private contextTitle(name: string | null | undefined, cwd: string) {
|
|
75
|
+
if (name?.trim()) return name.trim();
|
|
76
|
+
const entries = this.ctx?.sessionManager?.getBranch?.() ?? [];
|
|
77
|
+
let prompt = this.pendingPrompt;
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (entry.type !== "message" || entry.message.role !== "user") continue;
|
|
80
|
+
const content = entry.message.content;
|
|
81
|
+
const text = typeof content === "string" ? content : content
|
|
82
|
+
.filter(part => part.type === "text").map(part => part.text).join(" ");
|
|
83
|
+
if (text.trim()) { prompt = text; break; }
|
|
84
|
+
}
|
|
85
|
+
const title = prompt?.replace(/\s+/g, " ").trim();
|
|
86
|
+
if (title) return title.length > 120 ? title.slice(0, 119) + "…" : title;
|
|
87
|
+
return cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi";
|
|
88
|
+
}
|
|
89
|
+
|
|
66
90
|
/** Context getters remain live while idle; send only changed metadata, including explicit clears. */
|
|
67
91
|
refreshMetadata() {
|
|
68
92
|
const ctx = this.ctx;
|
|
@@ -72,7 +96,7 @@ export class CompanionBridge implements AskChannel {
|
|
|
72
96
|
if ((this.snapshot.mainModel ?? null) !== (ctx.model?.id ?? null)) this.telemetry.invalidateContext();
|
|
73
97
|
const next = {
|
|
74
98
|
name, cwd,
|
|
75
|
-
shortTitle: name
|
|
99
|
+
shortTitle: this.contextTitle(name, cwd),
|
|
76
100
|
mainModel: ctx.model?.id ?? null,
|
|
77
101
|
effort: ctx.thinkingLevel ?? this.pi.getThinkingLevel?.() ?? null,
|
|
78
102
|
telemetry: this.telemetry.snapshot(ctx),
|
|
@@ -91,6 +115,7 @@ export class CompanionBridge implements AskChannel {
|
|
|
91
115
|
}
|
|
92
116
|
|
|
93
117
|
invalidateContext() {
|
|
118
|
+
this.pendingPrompt = undefined;
|
|
94
119
|
this.telemetry.invalidateContext();
|
|
95
120
|
}
|
|
96
121
|
|
|
@@ -147,7 +172,7 @@ export class CompanionBridge implements AskChannel {
|
|
|
147
172
|
return [...this.tempFiles];
|
|
148
173
|
}
|
|
149
174
|
|
|
150
|
-
/** Only an explicit
|
|
175
|
+
/** Only an explicit sharing command may activate this session's bridge. */
|
|
151
176
|
activate() {
|
|
152
177
|
this.activated = true;
|
|
153
178
|
if (this.snapshot.remoteEnabled && this.ctx?.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(this.ctx.ui, this, this.toolDialogs, this.popups);
|
|
@@ -287,7 +312,7 @@ export class CompanionBridge implements AskChannel {
|
|
|
287
312
|
}
|
|
288
313
|
|
|
289
314
|
async deleteTempFile(fileId: string) {
|
|
290
|
-
if (!this.isActivated()) return { ok: false, error: "Run /
|
|
315
|
+
if (!this.isActivated()) return { ok: false, error: "Run /remote-control to share this session." };
|
|
291
316
|
const requestId = randomUUID();
|
|
292
317
|
this.send({ type: "file.delete", requestId, fileId });
|
|
293
318
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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"]) {
|
|
@@ -78,18 +92,40 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
pi.on("session_start", (_event, ctx) => {
|
|
81
|
-
//
|
|
95
|
+
// Restore only this session's explicit opt-in, never the previous session's.
|
|
82
96
|
sharingGeneration += 1;
|
|
97
|
+
quotas.stop();
|
|
98
|
+
quotas = new ProviderQuotaPoller(value => bridge.ingestTelemetry(value, 'subscription limits'));
|
|
83
99
|
bridge.close();
|
|
84
|
-
bridge = new CompanionBridge(pi);
|
|
100
|
+
bridge = new CompanionBridge(pi, ctx.sessionManager?.getSessionId?.());
|
|
85
101
|
bridge.setContext(ctx);
|
|
86
102
|
bridge.emit("session.start", { cwd: ctx.cwd });
|
|
103
|
+
const sharing = ctx.sessionManager?.getEntries?.().filter(entry =>
|
|
104
|
+
entry.type === "custom" && entry.customType === "companion:sharing"
|
|
105
|
+
).at(-1);
|
|
106
|
+
const saved = sharing?.type === "custom" ? sharing.data as { enabled?: boolean; sessionId?: string } | undefined : undefined;
|
|
107
|
+
if (saved?.enabled === true && saved.sessionId === bridge.sessionId) {
|
|
108
|
+
void setSharing(true, ctx, false);
|
|
109
|
+
} else {
|
|
110
|
+
void checkDaemon(ctx);
|
|
111
|
+
}
|
|
112
|
+
if (daemonMonitor) clearInterval(daemonMonitor);
|
|
113
|
+
daemonMonitor = setInterval(() => {
|
|
114
|
+
void checkDaemon(ctx);
|
|
115
|
+
if (bridge.isRemoteEnabled()) void quotas.refresh(ctx);
|
|
116
|
+
}, 30_000);
|
|
117
|
+
daemonMonitor.unref();
|
|
118
|
+
});
|
|
119
|
+
pi.on("input", (event, ctx) => {
|
|
120
|
+
bridge.setContext(ctx);
|
|
121
|
+
bridge.setPrompt(event.text);
|
|
87
122
|
});
|
|
88
123
|
pi.on("session_info_changed", (event, ctx) => {
|
|
89
124
|
bridge.setContext(ctx);
|
|
90
125
|
bridge.setName(event.name);
|
|
91
126
|
});
|
|
92
127
|
pi.on("model_select", (_event, ctx) => {
|
|
128
|
+
if (bridge.isRemoteEnabled()) void quotas.refresh(ctx);
|
|
93
129
|
bridge.invalidateContext();
|
|
94
130
|
bridge.setContext(ctx);
|
|
95
131
|
});
|
|
@@ -106,7 +142,11 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
106
142
|
bridge.updateStatus("idle");
|
|
107
143
|
bridge.emit("agent.end", { messages: Array.isArray((event as { messages?: unknown[] }).messages) ? (event as { messages: unknown[] }).messages.length : 0 });
|
|
108
144
|
});
|
|
109
|
-
pi.on("message_end", (
|
|
145
|
+
pi.on("message_end", (event, ctx) => {
|
|
146
|
+
bridge.setContext(ctx);
|
|
147
|
+
const failure = providerError(event.message);
|
|
148
|
+
if (failure) bridge.emit('provider.error', failure);
|
|
149
|
+
});
|
|
110
150
|
pi.on("session_compact", (_event, ctx) => {
|
|
111
151
|
bridge.invalidateContext();
|
|
112
152
|
bridge.setContext(ctx);
|
|
@@ -138,6 +178,9 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
138
178
|
});
|
|
139
179
|
});
|
|
140
180
|
pi.on("session_shutdown", event => {
|
|
181
|
+
quotas.stop();
|
|
182
|
+
if (daemonMonitor) clearInterval(daemonMonitor);
|
|
183
|
+
daemonMonitor = undefined;
|
|
141
184
|
sharingGeneration += 1;
|
|
142
185
|
bridge.updateStatus("stopped");
|
|
143
186
|
bridge.emit("session.shutdown", event);
|
|
@@ -275,52 +318,44 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
275
318
|
}
|
|
276
319
|
});
|
|
277
320
|
|
|
321
|
+
async function setSharing(enabled: boolean, ctx: ExtensionContext, persist = true) {
|
|
322
|
+
const generation = ++sharingGeneration;
|
|
323
|
+
bridge.setContext(ctx);
|
|
324
|
+
if (!enabled) {
|
|
325
|
+
if (persist) pi.appendEntry("companion:sharing", { enabled: false, sessionId: bridge.sessionId });
|
|
326
|
+
bridge.setRemoteEnabled(false);
|
|
327
|
+
ctx.ui.notify("Remote control disabled; Pi continues locally. The daemon remains available.", "info");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const currentBridge = bridge;
|
|
331
|
+
const up = await ensureDaemon((message, level = "info") => ctx.ui.notify(message, level));
|
|
332
|
+
if (bridge !== currentBridge || generation !== sharingGeneration) return;
|
|
333
|
+
if (!up) {
|
|
334
|
+
ctx.ui.notify("Pi Companion daemon is not reachable at " + adminHttpUrl(), "warning");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (persist) pi.appendEntry("companion:sharing", { enabled: true, sessionId: bridge.sessionId });
|
|
338
|
+
bridge.activate();
|
|
339
|
+
bridge.setRemoteEnabled(true);
|
|
340
|
+
void quotas.refresh(ctx);
|
|
341
|
+
void bridge.connect();
|
|
342
|
+
ctx.ui.notify("Remote control enabled for this session: " + adminHttpUrl(), "info");
|
|
343
|
+
}
|
|
344
|
+
|
|
278
345
|
pi.registerCommand("companion", {
|
|
279
346
|
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
|
-
}
|
|
347
|
+
handler: async (args, ctx) => setSharing(args.trim().toLowerCase() !== "off", ctx)
|
|
306
348
|
});
|
|
307
349
|
|
|
308
350
|
pi.registerCommand("remote-control", {
|
|
309
351
|
description: "Toggle remote control for this Pi session",
|
|
310
|
-
handler: async (
|
|
311
|
-
|
|
312
|
-
if (
|
|
313
|
-
ctx.ui.notify("
|
|
352
|
+
handler: async (args, ctx) => {
|
|
353
|
+
const mode = args.trim().toLowerCase();
|
|
354
|
+
if (mode && mode !== "on" && mode !== "off") {
|
|
355
|
+
ctx.ui.notify("Usage: /remote-control [on|off]", "warning");
|
|
314
356
|
return;
|
|
315
357
|
}
|
|
316
|
-
|
|
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
|
-
);
|
|
358
|
+
await setSharing(mode === "on" || (mode !== "off" && !bridge.isRemoteEnabled()), ctx);
|
|
324
359
|
}
|
|
325
360
|
});
|
|
326
361
|
}
|
|
@@ -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);
|