@yunazgr/pi-companion 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,6 +13,8 @@ The UI is a static SvelteKit application. There is no Node runtime in production
13
13
  <img src="docs/mobile.webp" alt="Session detail on a phone" width="28%" />
14
14
  </p>
15
15
 
16
+ Screenshots use demo sessions, not private conversation data. See the [session menu](docs/session-menu.webp), [desktop session rows](docs/session-list-desktop.webp) and [mobile session list](docs/session-list.webp), plus [attachment previews](docs/attachments.webp), [file-grouped changes](docs/changes.webp) and [connection recovery](docs/connection-error.webp).
17
+
16
18
  ## Install
17
19
 
18
20
  pi install npm:@yunazgr/pi-companion
@@ -21,7 +23,7 @@ or straight from git:
21
23
 
22
24
  pi install git:github.com/ygrip/pi-companion
23
25
 
24
- Then start Pi as usual. Installing the Pi extension is enough for normal use: when a Pi session starts, the extension probes the local admin port and automatically starts the daemon if needed. 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.
26
+ 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.
25
27
 
26
28
  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.
27
29
 
@@ -60,22 +62,30 @@ You do **not** need to run `pi-companion-server` manually for a normal npm insta
60
62
 
61
63
  The local administration surface and paired-device surface are separate listeners. If you expose Pi Companion through a tunnel, target only port 43722. Never expose port 43721.
62
64
 
65
+ ## Install the web app on your phone
66
+
67
+ Open the daemon’s **HTTPS device URL** and pair the phone first. On Android/Chromium, choose **Install app** (or **Add to Home screen**) in the browser menu. On iPhone/iPad, open Safari and choose **Share → Add to Home Screen**; enable **Open as Web App** if offered. Installation does not grant additional session permissions.
68
+
69
+ Service workers require HTTPS; `http://localhost` is permitted for desktop development, but ordinary LAN HTTP addresses are not. An insecure-origin banner explains this in the UI. Install prompts vary by browser and may not appear inside in-app browsers.
70
+
71
+ After an online visit, the public app shell and visited immutable assets are cached. This is not an offline agent: sending, live activity, pairing, uploads and changes require the daemon connection. No credentials, API responses or session files are stored in the service-worker cache. Reopen online after an update to load the new build.
72
+
63
73
  ## How the daemon runs
64
74
 
65
75
  For a normal install:
66
76
 
67
77
  pi install npm:@yunazgr/pi-companion
68
78
 
69
- That is enough. Start Pi normally; the extension will 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.
79
+ 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.
70
80
 
71
81
  The lifecycle is:
72
82
 
73
- 1. A Pi session loads the extension.
74
- 2. The extension probes `http://127.0.0.1:43721`.
75
- 3. If the daemon is already running, the session connects to it.
83
+ 1. A Pi session loads the extension without daemon network or startup work.
84
+ 2. You run `/companion` in that session; the extension probes `http://127.0.0.1:43721`.
85
+ 3. If the daemon is already running, this opted-in session connects to it.
76
86
  4. If not, the extension resolves the daemon binary, downloading the matching GitHub Release on first use if necessary.
77
87
  5. It starts the daemon detached in the background.
78
- 6. Other Pi sessions reuse the same daemon.
88
+ 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.
79
89
  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.
80
90
  8. The daemon keeps running independently until it is stopped or the machine restarts.
81
91
 
@@ -130,7 +140,7 @@ Inside any Pi session:
130
140
  /companion off # stop sharing this session
131
141
  /remote-control # toggle sharing on/off
132
142
 
133
- A paired device cannot access sessions that have not explicitly enabled remote control.
143
+ 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.
134
144
 
135
145
  ## Questions from Pi
136
146
 
@@ -157,6 +167,8 @@ Paired devices (name, browser user agent, pairing and last-seen times, credentia
157
167
 
158
168
  A phone pairs once with the daemon. Individual Pi sessions still opt in using /companion.
159
169
 
170
+ Network loss and daemon restarts reconnect automatically with bounded backoff. Heartbeats detect silent connections, and returning to the foreground or restoring network access retries immediately. Pairing credentials and unsent drafts survive outages; session snapshots, pending questions, and previously loaded upload lists refresh on reconnect. Missed activity is not replayed, and prompts/answers are never automatically resent. A deliberate **Disconnect** waits for the device’s explicit retry; **Revoke** removes access and requires pairing again.
171
+
160
172
  ## Settings
161
173
 
162
174
  The **Settings** page (local console only) covers:
@@ -179,7 +191,7 @@ Each session has an isolated temporary directory beneath the operating system te
179
191
 
180
192
  On Unix the daemon attempts to set the Pi Companion and session directories to mode 0700.
181
193
 
182
- The browser can upload a file from the Files tab. Current constraints:
194
+ The browser can upload a file using the composer’s attachment button, or from **Shared files** in the session menu. Composer attachments show previews, file names, sizes and upload status; validation and upload failures appear beside the file. Image thumbnails are limited to safe raster image types. Removing a preview removes that attachment from the draft; deleting a shared upload remains an explicit action. The current daemon upload policy is checked before selection is uploaded, and the daemon remains authoritative. Current constraints:
183
195
 
184
196
  - 25 MiB maximum per upload by default (Settings); a rejected or interrupted upload leaves no partial file
185
197
  - optional MIME allowlist: the type guessed from the file name and any specific declared Content-Type must both be allowed
@@ -203,6 +215,18 @@ which destroys one temporary file by opaque id.
203
215
 
204
216
  This means the agent can consume uploaded artifacts using its normal file capabilities while Pi Companion retains ownership of upload placement and cleanup.
205
217
 
218
+ ## Workspace and tunnel connection errors
219
+
220
+ If a tunnel closes, the workspace stops, or your device loses its network, Companion shows an actionable **Workspace connection interrupted** notice with **Retry now** and reconnect instructions. On initial connection failure, it shows **Workspace unavailable**, not a misleading empty session list or “session not found.” Gateway errors (including HTTP 502/503/504 and tunnel-provider errors) are translated into readable messages rather than raw HTML or JSON parse errors. A browser cannot always distinguish a closed tunnel from a daemon, DNS or network failure, so these messages describe possible causes rather than claiming certainty.
221
+
222
+ - Ordinary API requests and live-connection handshakes time out after 10 seconds; uploads allow 60 seconds.
223
+ - Reconnection uses bounded backoff and retries when the device returns online. **Retry now** is available immediately.
224
+ - Previously loaded activity may be stale. Failed sends keep your draft; messages and uploads are never automatically resent.
225
+ - Network/tunnel errors preserve pairing. Explicit computer disconnects still require manual reconnect; revoked credentials still require pairing again.
226
+ - Restart the tunnel, keep the workspace running, and run `/companion` in Pi if the daemon is not running. If the tunnel URL changed, open the new HTTPS device URL; a different hostname/origin may require pairing again in that browser.
227
+
228
+ A previously cached PWA shell can also explain gateway failures on reload. If the app has never loaded successfully on that device and no shell is cached, the browser/tunnel provider owns the initial error page; Companion cannot display its own UI until its assets are reachable.
229
+
206
230
  ## Session identity
207
231
 
208
232
  Every registered Pi session publishes a compact display model to the daemon:
@@ -215,7 +239,7 @@ Every registered Pi session publishes a compact display model to the daemon:
215
239
  - working directory and process id
216
240
  - remote-control state
217
241
 
218
- 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.
242
+ 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 the Pi session and running `/companion` can register it again.
219
243
 
220
244
  ## UI
221
245
 
@@ -223,15 +247,15 @@ A single-page SvelteKit app, embedded in the daemon binary:
223
247
 
224
248
  | Page | Who sees it | What it is for |
225
249
  |---|---|---|
226
- | Overview | everyone | Dot-field hero, live counts, sessions that need your answer, live sessions, Help entry |
227
- | Sessions | everyone | Searchable, filterable list (Working / Waiting / Ended). Paired devices only see shared sessions |
228
- | Session detail | everyone | Shell-style transcript that follows the theme: Markdown replies with highlight.js code and Mermaid diagrams, collapsible tool output and thinking. Question sheet, files, git changes, /plan; the composer (message / steer) appears on Activity only. Session details are collapsed under the title |
250
+ | Overview | everyone | Dot-field hero, live counts, sessions that need your answer, a labeled live-session table, and a spaced onboarding/help panel with Claymorphism actions |
251
+ | Sessions | everyone | Searchable, filterable full-width session rows (Working / Waiting / Ended), with short titles, workspace/model metadata, View details and safe Archive actions. Paired devices only see shared sessions |
252
+ | Session detail | everyone | Activity-first transcript with Markdown, highlighted code, Mermaid diagrams, collapsible tool output/thinking and question sheets. Compact header and navigation leave more space for the shell. The expanding composer includes Auto/Plan selection, attachment previews and a full-width labeled Send/Steer action; Plan uses the existing `/plan` command, not an agent permission setting. The top-right session menu opens Shared files, Changes and Archive session. The session header shows a status dot; clicking its title reveals status and session metadata. Changes are grouped by file with clear separators and a filename filter |
229
253
  | Devices, Settings | local console only | Pairing, connection status, disconnect and revoke; daemon settings with a save bar that appears only for unsaved edits |
230
254
  | Help, Privacy, Terms | everyone | Setup stepper, commands, tools, troubleshooting; data handling; terms of use. Help is opened from the Overview |
231
255
 
232
256
  The Live indicator in the header and sidebar opens the connection sheet (status, version and, on this computer, the console and device addresses and data folders).
233
257
 
234
- Git problems on the Changes tab (for example a folder that is not a git repository) show as a toast, never in the transcript.
258
+ Git problems in the Changes panel (for example a folder that is not a git repository) show as a toast, never in the transcript.
235
259
 
236
260
  Design notes:
237
261
 
@@ -239,12 +263,13 @@ Design notes:
239
263
  - claymorphism: solid surfaces with an outer drop shadow plus inset highlight and shade (no blur or glass); small icons are Lucide line icons on raised clay wells, feature art is 3D clay renders from [3dicons](https://3dicons.co) (CC0)
240
264
  - warm amber on graphite or paper, matching the logo; system fonts only, so nothing loads from the network. highlight.js and Mermaid load only when a reply contains code or a diagram
241
265
  - the window never scrolls. The sidebar, page content, activity feed, file list, diff, chips and tabs each scroll inside their own container, and the composer and mobile tab bar stay put
266
+ - the shell gently breathes while the displayed session is working, with a slower [thinking animation](docs/thinking.webp) and a soft progress sweep on the mobile header; it stops when idle or disconnected and respects reduced-motion preferences
242
267
  - the activity feed follows new output and stops following when you scroll up ("Jump to latest" brings you back)
243
- - installable PWA: web app manifest, standalone display mode, Apple home-screen metadata, and a small service worker that caches only the shell and immutable assets; API/session traffic is never cached
268
+ - installable PWA: scoped manifest with explicit 192×192 and 512×512 PNG icons plus a maskable icon, standalone display mode, Apple home-screen metadata, and a service worker that caches only the public shell and static assets; API/session traffic and uploads are never cached
244
269
  - mobile: bottom tab bar, safe-area insets, 44px touch targets, 16px inputs (no iOS zoom), Enter inserts a newline on touch keyboards
245
- - accessibility: skip link, visible focus rings, arrow-key tabs, labelled controls, live regions for the feed and questions, reduced-motion support
270
+ - accessibility: skip link, visible focus rings, labeled controls and table metadata, accessible session menus, live regions for the feed and questions, reduced-motion support
246
271
  - the dot field is Raksara's component, loaded when the browser is idle, paused when hidden, and static under reduced motion
247
- - dropping a file anywhere outside the Files drop zone is ignored. Without this, the browser tries to open the file itself (Firefox reports this as "may not load or link to file:///")
272
+ - dropping a file anywhere outside the Shared files drop zone is ignored. Without this, the browser tries to open the file itself (Firefox reports this as "may not load or link to file:///")
248
273
 
249
274
  ### Caching and updates
250
275
 
@@ -302,7 +327,7 @@ Type checks cover the extension (`tsc`) and the UI (`svelte-check`, warnings fai
302
327
 
303
328
  npm run check
304
329
 
305
- Tests cover the question relay (`node --test`) and the daemon's wire protocol, pairing expiry and code throttling, origin policy, bridge and device reconnects, path traversal, upload limits, MIME allowlist, state-file permissions and multi-session isolation (`cargo test`):
330
+ Tests cover the question relay, deferred per-session startup and remote-off/re-enable behavior (including an executable extension/bridge harness), attachment validation and path references, changed-file grouping/filtering, workspace error classification and actual connection-store recovery, install icon dimensions and service-worker cache isolation (`node --test`), plus the daemon's wire protocol, safe session archiving, pairing expiry and code throttling, origin policy, bridge and device reconnects, path traversal, upload limits, MIME allowlist, state-file permissions and multi-session isolation (`cargo test`):
306
331
 
307
332
  npm test
308
333
 
@@ -312,7 +337,7 @@ Then, in another shell, start Pi with the extension from this checkout:
312
337
 
313
338
  pi -e ./src/index.ts
314
339
 
315
- Because the development daemon is already listening on port 43721, the extension detects it and does not launch another daemon.
340
+ After you run `/companion`, the extension detects the development daemon already listening on port 43721 and does not launch another daemon.
316
341
 
317
342
  For hot-reloading UI work, keep the daemon running and use:
318
343
 
@@ -320,13 +345,13 @@ For hot-reloading UI work, keep the daemon running and use:
320
345
 
321
346
  which proxies /api and /ws to the daemon on 43721.
322
347
 
323
- If you start Pi from the checkout without running `npm run serve`, the extension can still auto-start a local daemon build if one exists under `server/target/debug` or `server/target/release`. For predictable UI work, prefer `npm run serve` in one terminal and `pi -e ./src/index.ts` in another.
348
+ If you start Pi from the checkout without running `npm run serve`, running `/companion` can still auto-start a local daemon build if one exists under `server/target/debug` or `server/target/release`. For predictable UI work, prefer `npm run serve` in one terminal and `pi -e ./src/index.ts` in another.
324
349
 
325
350
  To test the same behavior as a published install, stop any development daemon first and install the package normally:
326
351
 
327
352
  pi install npm:@yunazgr/pi-companion
328
353
 
329
- Then start Pi. The extension downloads and starts the released daemon automatically.
354
+ Then start Pi and run `/companion`. The extension downloads and starts the released daemon on demand; merely starting Pi does neither.
330
355
 
331
356
  ## Releases
332
357
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunazgr/pi-companion",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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",
@@ -71,7 +71,7 @@
71
71
  "highlight.js": "^11.12.0",
72
72
  "jsqr": "^1.4.0",
73
73
  "marked": "^18.1.0",
74
- "mermaid": "^12.1.0",
74
+ "mermaid": "^10.9.8",
75
75
  "svelte": "^5.57.2",
76
76
  "svelte-check": "^4.7.6",
77
77
  "typebox": "^1.0.13",
@@ -79,7 +79,10 @@
79
79
  "vite": "^8.0.12"
80
80
  },
81
81
  "overrides": {
82
- "node-domexception": "file:./tools/shims/node-domexception"
82
+ "node-domexception": "file:./tools/shims/node-domexception",
83
+ "mermaid": {
84
+ "katex": "^0.18.2"
85
+ }
83
86
  },
84
87
  "allowScripts": {
85
88
  "esbuild": true,
package/src/ask.ts CHANGED
@@ -28,6 +28,7 @@ export function relayDialogs(ui: ExtensionUIContext, channel: AskChannel, tools:
28
28
  const confirm = ui.confirm.bind(ui);
29
29
  const input = ui.input.bind(ui);
30
30
  const custom = ui.custom.bind(ui);
31
+ const originals = { select: ui.select, confirm: ui.confirm, input: ui.input, custom: ui.custom };
31
32
 
32
33
  ui.custom = (factory, options) => {
33
34
  const complete = tools.claim();
@@ -73,6 +74,11 @@ export function relayDialogs(ui: ExtensionUIContext, channel: AskChannel, tools:
73
74
  undefined,
74
75
  opts?.signal
75
76
  );
77
+
78
+ return () => {
79
+ Object.assign(ui, originals);
80
+ delete target[RELAYED];
81
+ };
76
82
  }
77
83
 
78
84
  async function race<T>(
package/src/bridge.ts CHANGED
@@ -16,7 +16,10 @@ export class CompanionBridge implements AskChannel {
16
16
  private ws?: WebSocket;
17
17
  private ctx?: ExtensionContext;
18
18
  private reconnect?: NodeJS.Timeout;
19
+ private heartbeat?: NodeJS.Timeout;
19
20
  private closed = false;
21
+ private activated = false;
22
+ private restoreDialogs?: () => void;
20
23
  private connecting = false;
21
24
  private everConnected = false;
22
25
  private downSince = 0;
@@ -40,11 +43,13 @@ export class CompanionBridge implements AskChannel {
40
43
 
41
44
  setContext(ctx: ExtensionContext) {
42
45
  this.ctx = ctx;
43
- if (ctx.hasUI) relayDialogs(ctx.ui, this, this.toolDialogs);
46
+ if (this.activated && this.snapshot.remoteEnabled && ctx.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(ctx.ui, this, this.toolDialogs);
47
+ const name = this.pi.getSessionName() ?? this.snapshot.name;
44
48
  this.snapshot = {
45
49
  ...this.snapshot,
50
+ name,
46
51
  cwd: ctx.cwd,
47
- shortTitle: this.snapshot.name?.trim() || ctx.cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi",
52
+ shortTitle: name?.trim() || ctx.cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi",
48
53
  mainModel: ctx.model?.id,
49
54
  effort: ctx.thinkingLevel,
50
55
  status: ctx.isIdle() ? "idle" : "active"
@@ -53,12 +58,33 @@ export class CompanionBridge implements AskChannel {
53
58
 
54
59
  setName(name?: string) {
55
60
  this.snapshot.name = name;
56
- this.send({ type: "session.update", session: { name } });
61
+ this.snapshot.shortTitle = name?.trim() || this.snapshot.cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi";
62
+ this.send({ type: "session.update", session: { name, shortTitle: this.snapshot.shortTitle } });
57
63
  }
58
64
 
59
65
  setRemoteEnabled(remoteEnabled: boolean) {
60
66
  this.snapshot.remoteEnabled = remoteEnabled;
61
- this.send({ type: "session.update", session: { remoteEnabled } });
67
+ this.snapshot.status = remoteEnabled ? (this.ctx?.isIdle() === false ? "active" : "idle") : "stopped";
68
+ this.send({ type: "session.update", session: { remoteEnabled, status: this.snapshot.status } });
69
+ if (remoteEnabled) {
70
+ if (this.isActivated()) { this.activate(); void this.connect(); }
71
+ return;
72
+ }
73
+ // End only Companion sharing: Pi itself and its local history keep running.
74
+ this.restoreDialogs?.();
75
+ this.restoreDialogs = undefined;
76
+ for (const entry of [...this.asks.values()]) entry.settle(null);
77
+ for (const pending of this.pendingDeletes.values()) {
78
+ clearTimeout(pending.timer);
79
+ pending.resolve({ ok: false, error: "Session sharing ended." });
80
+ }
81
+ this.pendingDeletes.clear();
82
+ if (this.reconnect) clearTimeout(this.reconnect);
83
+ this.reconnect = undefined;
84
+ if (this.heartbeat) clearTimeout(this.heartbeat);
85
+ const ws = this.ws;
86
+ this.ws = undefined;
87
+ ws?.close();
62
88
  }
63
89
 
64
90
  isRemoteEnabled() {
@@ -69,9 +95,19 @@ export class CompanionBridge implements AskChannel {
69
95
  return [...this.tempFiles];
70
96
  }
71
97
 
72
- /** Connect to the shared daemon, launching it only when nothing is listening. */
98
+ /** Only an explicit /companion command may activate this session's bridge. */
99
+ activate() {
100
+ this.activated = true;
101
+ if (this.snapshot.remoteEnabled && this.ctx?.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(this.ctx.ui, this, this.toolDialogs);
102
+ }
103
+
104
+ isActivated() {
105
+ return this.activated && !this.closed;
106
+ }
107
+
108
+ /** Connect to the shared daemon only after this session opted in. */
73
109
  async connect() {
74
- if (this.closed || this.connecting) return;
110
+ if (!this.activated || !this.snapshot.remoteEnabled || this.closed || this.connecting) return;
75
111
  if (this.ws && this.ws.readyState <= WebSocket.OPEN) return; // connecting or already live
76
112
  this.connecting = true;
77
113
  try {
@@ -79,8 +115,11 @@ export class CompanionBridge implements AskChannel {
79
115
  // period to come back before launching a replacement.
80
116
  const allowSpawn = !this.everConnected || (this.downSince > 0 && Date.now() - this.downSince > 20_000);
81
117
  if (allowSpawn) await ensureDaemon((message, level = "info") => this.log(message, level));
82
- if (this.closed) return;
118
+ if (this.closed || !this.snapshot.remoteEnabled) return;
83
119
  this.open();
120
+ } catch (error) {
121
+ this.log("Could not reconnect to Pi Companion: " + String(error), "warning");
122
+ this.scheduleReconnect();
84
123
  } finally {
85
124
  this.connecting = false;
86
125
  }
@@ -88,20 +127,33 @@ export class CompanionBridge implements AskChannel {
88
127
 
89
128
  private open() {
90
129
  const base = process.env.PI_COMPANION_URL ?? "ws://127.0.0.1:43721";
91
- const ws = new WebSocket(base.replace(/\/$/, "") + "/ws/bridge/" + this.sessionId);
130
+ const ws = new WebSocket(base.replace(/\/$/, "") + "/ws/bridge/" + this.sessionId, { handshakeTimeout: 15_000 });
92
131
  this.ws = ws;
132
+ const alive = () => {
133
+ if (this.ws !== ws || this.closed) return;
134
+ if (this.heartbeat) clearTimeout(this.heartbeat);
135
+ this.heartbeat = setTimeout(() => ws.terminate(), 45_000);
136
+ this.heartbeat.unref();
137
+ };
138
+ ws.on("ping", alive); // ws automatically pongs; the watchdog detects silent loss.
93
139
  ws.on("open", () => {
140
+ if (this.ws !== ws || this.closed || !this.snapshot.remoteEnabled) return ws.terminate();
141
+ alive();
94
142
  this.everConnected = true;
95
143
  this.downSince = 0;
96
144
  this.retryDelay = 1000;
97
145
  this.send({ type: "register", session: this.snapshot });
98
146
  });
99
147
  ws.on("message", raw => {
100
- try { void this.handle(JSON.parse(raw.toString()) as ServerMessage); }
101
- catch (error) { this.send({ type: "error", message: "Invalid server message: " + String(error) }); }
148
+ if (this.ws !== ws || this.closed || !this.snapshot.remoteEnabled) return;
149
+ alive();
150
+ const failure = (error: unknown) => this.send({ type: "error", message: "Invalid server message: " + String(error) });
151
+ try { void this.handle(JSON.parse(raw.toString()) as ServerMessage).catch(failure); }
152
+ catch (error) { failure(error); }
102
153
  });
103
154
  ws.on("close", () => {
104
155
  if (this.ws !== ws) return;
156
+ if (this.heartbeat) clearTimeout(this.heartbeat);
105
157
  if (!this.downSince) this.downSince = Date.now();
106
158
  this.scheduleReconnect();
107
159
  });
@@ -116,7 +168,11 @@ export class CompanionBridge implements AskChannel {
116
168
 
117
169
  close() {
118
170
  this.closed = true;
171
+ this.restoreDialogs?.();
172
+ this.restoreDialogs = undefined;
173
+ for (const entry of [...this.asks.values()]) entry.settle(null);
119
174
  if (this.reconnect) clearTimeout(this.reconnect);
175
+ if (this.heartbeat) clearTimeout(this.heartbeat);
120
176
  this.ws?.close();
121
177
  }
122
178
 
@@ -136,7 +192,7 @@ export class CompanionBridge implements AskChannel {
136
192
  ask(input: AskInput, signal?: AbortSignal) {
137
193
  const request: AskRequest = { ...input, requestId: randomUUID(), createdAt: new Date().toISOString() };
138
194
  return new Promise<AskAnswers | null>(resolve => {
139
- if (signal?.aborted) return resolve(null);
195
+ if (!this.isActivated() || !this.snapshot.remoteEnabled || signal?.aborted) return resolve(null);
140
196
  const onAbort = () => settle(null);
141
197
  const settle = (answers: AskAnswers | null) => {
142
198
  if (!this.asks.delete(request.requestId)) return;
@@ -157,6 +213,7 @@ export class CompanionBridge implements AskChannel {
157
213
  }
158
214
 
159
215
  async deleteTempFile(fileId: string) {
216
+ if (!this.isActivated()) return { ok: false, error: "Run /companion first to enable this session." };
160
217
  const requestId = randomUUID();
161
218
  this.send({ type: "file.delete", requestId, fileId });
162
219
  return await new Promise<{ ok: boolean; error?: string }>(resolve => {
@@ -174,7 +231,7 @@ export class CompanionBridge implements AskChannel {
174
231
 
175
232
  private scheduleReconnect() {
176
233
  if (this.reconnect) clearTimeout(this.reconnect);
177
- if (this.closed) return;
234
+ if (!this.activated || !this.snapshot.remoteEnabled || this.closed) return;
178
235
  this.reconnect = setTimeout(() => void this.connect(), this.retryDelay);
179
236
  this.retryDelay = Math.min(this.retryDelay * 2, 10_000);
180
237
  }
package/src/index.ts CHANGED
@@ -68,11 +68,15 @@ function resultText(result: unknown) {
68
68
  }
69
69
 
70
70
  export default function companionExtension(pi: ExtensionAPI) {
71
- const bridge = new CompanionBridge(pi);
71
+ let bridge = new CompanionBridge(pi);
72
+ let sharingGeneration = 0;
72
73
 
73
74
  pi.on("session_start", (_event, ctx) => {
75
+ // Switching or starting a Pi session must not inherit the previous opt-in.
76
+ sharingGeneration += 1;
77
+ bridge.close();
78
+ bridge = new CompanionBridge(pi);
74
79
  bridge.setContext(ctx);
75
- void bridge.connect();
76
80
  bridge.emit("session.start", { cwd: ctx.cwd });
77
81
  });
78
82
  pi.on("session_info_changed", (event, ctx) => {
@@ -118,6 +122,7 @@ export default function companionExtension(pi: ExtensionAPI) {
118
122
  });
119
123
  });
120
124
  pi.on("session_shutdown", event => {
125
+ sharingGeneration += 1;
121
126
  bridge.updateStatus("stopped");
122
127
  bridge.emit("session.shutdown", event);
123
128
  bridge.close();
@@ -131,6 +136,12 @@ export default function companionExtension(pi: ExtensionAPI) {
131
136
  parameters: AskParams,
132
137
  executionMode: "sequential",
133
138
  async execute(_toolCallId, params, signal) {
139
+ if (!bridge.isActivated()) {
140
+ return {
141
+ content: [{ type: "text", text: "Pi Companion is not enabled for this session. Run /companion before asking through the dashboard." }],
142
+ details: { questions: [], answers: null }
143
+ };
144
+ }
134
145
  const items = params.questions?.length
135
146
  ? params.questions
136
147
  : params.question
@@ -196,18 +207,28 @@ export default function companionExtension(pi: ExtensionAPI) {
196
207
  pi.registerCommand("companion", {
197
208
  description: "Enable Pi Companion for this session and show the dashboard address (`/companion off` to stop sharing)",
198
209
  handler: async (args, ctx) => {
210
+ const generation = ++sharingGeneration;
211
+ bridge.setContext(ctx);
212
+ if (args.trim().toLowerCase() === "off") {
213
+ bridge.setRemoteEnabled(false);
214
+ ctx.ui.notify("Pi Companion sharing ended for this session. Pi continues locally.", "info");
215
+ return;
216
+ }
217
+ const currentBridge = bridge;
199
218
  const up = await ensureDaemon((message, level = "info") => ctx.ui.notify(message, level));
219
+ if (bridge !== currentBridge || generation !== sharingGeneration) return;
200
220
  if (!up) {
201
221
  ctx.ui.notify("Pi Companion daemon is not reachable at " + adminHttpUrl(), "warning");
202
222
  return;
203
223
  }
224
+ bridge.activate();
204
225
  void bridge.connect();
205
- const enabled = args.trim().toLowerCase() !== "off";
226
+ const enabled = true;
206
227
  bridge.setRemoteEnabled(enabled);
207
228
  ctx.ui.notify(
208
229
  enabled
209
230
  ? "Pi Companion enabled for this session: " + adminHttpUrl() + " (paired devices can now see it)"
210
- : "Pi Companion: this session is no longer shared with paired devices.",
231
+ : "Pi Companion sharing ended for this session. Pi continues locally.",
211
232
  "info"
212
233
  );
213
234
  }
@@ -216,12 +237,17 @@ export default function companionExtension(pi: ExtensionAPI) {
216
237
  pi.registerCommand("remote-control", {
217
238
  description: "Toggle remote control for this Pi session",
218
239
  handler: async (_args, ctx) => {
240
+ sharingGeneration += 1;
241
+ if (!bridge.isActivated()) {
242
+ ctx.ui.notify("Run /companion first to enable this session's daemon connection.", "warning");
243
+ return;
244
+ }
219
245
  const enabled = !bridge.isRemoteEnabled();
220
246
  bridge.setRemoteEnabled(enabled);
221
247
  ctx.ui.notify(
222
248
  enabled
223
249
  ? "Remote control enabled for this session. Pair a device from Pi Companion."
224
- : "Remote control disabled for this session.",
250
+ : "Remote control disabled; this Companion session has ended. Pi continues locally.",
225
251
  enabled ? "info" : "warning"
226
252
  );
227
253
  }