@yunazgr/pi-companion 0.2.1 → 0.2.3

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,11 @@ or straight from git:
21
23
 
22
24
  pi install git:github.com/ygrip/pi-companion
23
25
 
24
- Then start pi as usual and run /companion in each session you want to follow from the dashboard or your phone. It starts the daemon if needed, prints the dashboard address and shares that session with paired devices (`/companion off` stops sharing). Nothing else to configure: the extension finds or starts the daemon on its own, downloading the matching sha256-verified daemon binary from GitHub Releases on first use (see Daemon).
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.
27
+
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.
29
+
30
+ 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.
25
31
 
26
32
  ## Architecture
27
33
 
@@ -56,7 +62,32 @@ Then start pi as usual and run /companion in each session you want to follow fro
56
62
 
57
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.
58
64
 
59
- ## Daemon
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
+
73
+ ## How the daemon runs
74
+
75
+ For a normal install:
76
+
77
+ pi install npm:@yunazgr/pi-companion
78
+
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.
80
+
81
+ The lifecycle is:
82
+
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.
86
+ 4. If not, the extension resolves the daemon binary, downloading the matching GitHub Release on first use if necessary.
87
+ 5. It starts the daemon detached in the background.
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.
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.
90
+ 8. The daemon keeps running independently until it is stopped or the machine restarts.
60
91
 
61
92
  There is exactly one daemon per machine, shared by every Pi session. The extension manages it with no configuration:
62
93
 
@@ -109,7 +140,7 @@ Inside any Pi session:
109
140
  /companion off # stop sharing this session
110
141
  /remote-control # toggle sharing on/off
111
142
 
112
- 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.
113
144
 
114
145
  ## Questions from Pi
115
146
 
@@ -122,7 +153,7 @@ Dialogs from other extensions (`ctx.ui.select`, `ctx.ui.confirm`, `ctx.ui.input`
122
153
  Pairing is daemon-wide rather than session-specific. Everything lives on the **Devices** page of the local console (port 43721):
123
154
 
124
155
  1. Choose **Pair a device**. The daemon creates a single-use invitation (5 minutes by default, configurable in Settings) and shows it as a QR code, a copyable link and a short code (`XXXX-XXXX`).
125
- 2. On the phone, open the paired-device address. An unpaired device gets a connect screen: scan the QR code with the in-app camera (needs HTTPS; otherwise use the phone's camera app, which opens the invitation link) or type the short code, then give the device a name.
156
+ 2. On the phone, open the paired-device address. An unpaired device gets a connect screen: scan the QR code with the in-app camera (HTTPS required), use the phone's camera app to open the invitation link, or type the short code shown beside the QR, then give the device a name. Pi Companion explains why camera access is needed before triggering the browser permission prompt.
126
157
  3. The daemon issues a long random credential and stores only its SHA-256 hash.
127
158
 
128
159
  Typed codes are short, so wrong ones are counted: after ten misses within ten minutes every open invitation is withdrawn and code entry is refused (HTTP 429) until the window passes.
@@ -158,7 +189,7 @@ Each session has an isolated temporary directory beneath the operating system te
158
189
 
159
190
  On Unix the daemon attempts to set the Pi Companion and session directories to mode 0700.
160
191
 
161
- The browser can upload a file from the Files tab. Current constraints:
192
+ 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:
162
193
 
163
194
  - 25 MiB maximum per upload by default (Settings); a rejected or interrupted upload leaves no partial file
164
195
  - optional MIME allowlist: the type guessed from the file name and any specific declared Content-Type must both be allowed
@@ -182,6 +213,18 @@ which destroys one temporary file by opaque id.
182
213
 
183
214
  This means the agent can consume uploaded artifacts using its normal file capabilities while Pi Companion retains ownership of upload placement and cleanup.
184
215
 
216
+ ## Workspace and tunnel connection errors
217
+
218
+ 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.
219
+
220
+ - Ordinary API requests and live-connection handshakes time out after 10 seconds; uploads allow 60 seconds.
221
+ - Reconnection uses bounded backoff and retries when the device returns online. **Retry now** is available immediately.
222
+ - Previously loaded activity may be stale. Failed sends keep your draft; messages and uploads are never automatically resent.
223
+ - Network/tunnel errors preserve pairing. Explicit computer disconnects still require manual reconnect; revoked credentials still require pairing again.
224
+ - 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.
225
+
226
+ 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.
227
+
185
228
  ## Session identity
186
229
 
187
230
  Every registered Pi session publishes a compact display model to the daemon:
@@ -194,7 +237,7 @@ Every registered Pi session publishes a compact display model to the daemon:
194
237
  - working directory and process id
195
238
  - remote-control state
196
239
 
197
- 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.
240
+ 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.
198
241
 
199
242
  ## UI
200
243
 
@@ -202,15 +245,15 @@ A single-page SvelteKit app, embedded in the daemon binary:
202
245
 
203
246
  | Page | Who sees it | What it is for |
204
247
  |---|---|---|
205
- | Overview | everyone | Dot-field hero, live counts, sessions that need your answer, live sessions, Help entry |
206
- | Sessions | everyone | Searchable, filterable list (Working / Waiting / Ended). Paired devices only see shared sessions |
207
- | 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 |
248
+ | 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 |
249
+ | 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 |
250
+ | 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 |
208
251
  | Devices, Settings | local console only | Pairing, connection status, disconnect and revoke; daemon settings with a save bar that appears only for unsaved edits |
209
252
  | Help, Privacy, Terms | everyone | Setup stepper, commands, tools, troubleshooting; data handling; terms of use. Help is opened from the Overview |
210
253
 
211
254
  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).
212
255
 
213
- Git problems on the Changes tab (for example a folder that is not a git repository) show as a toast, never in the transcript.
256
+ Git problems in the Changes panel (for example a folder that is not a git repository) show as a toast, never in the transcript.
214
257
 
215
258
  Design notes:
216
259
 
@@ -218,11 +261,13 @@ Design notes:
218
261
  - 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)
219
262
  - 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
220
263
  - 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
264
+ - 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
221
265
  - the activity feed follows new output and stops following when you scroll up ("Jump to latest" brings you back)
266
+ - 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
222
267
  - mobile: bottom tab bar, safe-area insets, 44px touch targets, 16px inputs (no iOS zoom), Enter inserts a newline on touch keyboards
223
- - accessibility: skip link, visible focus rings, arrow-key tabs, labelled controls, live regions for the feed and questions, reduced-motion support
268
+ - accessibility: skip link, visible focus rings, labeled controls and table metadata, accessible session menus, live regions for the feed and questions, reduced-motion support
224
269
  - the dot field is Raksara's component, loaded when the browser is idle, paused when hidden, and static under reduced motion
225
- - 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:///")
270
+ - 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:///")
226
271
 
227
272
  ### Caching and updates
228
273
 
@@ -254,16 +299,20 @@ The initial slice supports:
254
299
 
255
300
  There is deliberately no arbitrary shell, arbitrary tool invocation, or arbitrary filesystem API.
256
301
 
257
- ## Development
302
+ ## Running locally from source
303
+
304
+ Use this only when developing Pi Companion itself. A published install does not require these steps.
258
305
 
259
306
  Requirements: Node 22.17+ and stable Rust.
260
307
 
308
+ Clone the repository, then:
309
+
261
310
  npm install
262
311
  npm run serve
263
312
 
264
- This builds the UI, starts the daemon and prints where it is listening:
313
+ `npm run serve` builds the embedded Svelte UI and starts the Rust daemon in the foreground. It prints:
265
314
 
266
- Pi Companion v0.2.1
315
+ Pi Companion v0.2.2
267
316
 
268
317
  Console http://127.0.0.1:43721
269
318
  Paired devices http://127.0.0.1:43722
@@ -276,7 +325,7 @@ Type checks cover the extension (`tsc`) and the UI (`svelte-check`, warnings fai
276
325
 
277
326
  npm run check
278
327
 
279
- 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`):
328
+ 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`):
280
329
 
281
330
  npm test
282
331
 
@@ -286,13 +335,21 @@ Then, in another shell, start Pi with the extension from this checkout:
286
335
 
287
336
  pi -e ./src/index.ts
288
337
 
338
+ After you run `/companion`, the extension detects the development daemon already listening on port 43721 and does not launch another daemon.
339
+
289
340
  For hot-reloading UI work, keep the daemon running and use:
290
341
 
291
342
  npm run ui:dev
292
343
 
293
344
  which proxies /api and /ws to the daemon on 43721.
294
345
 
295
- Running the daemon manually with npm run serve and the extension side by side just works: the extension sees the running daemon and connects to it. If you don't run it, the extension launches your local cargo build automatically.
346
+ 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.
347
+
348
+ To test the same behavior as a published install, stop any development daemon first and install the package normally:
349
+
350
+ pi install npm:@yunazgr/pi-companion
351
+
352
+ Then start Pi and run `/companion`. The extension downloads and starts the released daemon on demand; merely starting Pi does neither.
296
353
 
297
354
  ## Releases
298
355
 
@@ -306,7 +363,7 @@ Before publishing, the workflow requires X.Y.Z to match both package.json and se
306
363
 
307
364
  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.
308
365
 
309
- 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>/`. A git install from a branch that is ahead of the newest tag falls back to the latest release.
366
+ 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.
310
367
 
311
368
  ## Security boundary
312
369
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunazgr/pi-companion",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Local-first remote control for Pi sessions: live activity feed, steering, git diff, file drop and phone pairing from a single Rust daemon.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/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
@@ -17,6 +17,8 @@ export class CompanionBridge implements AskChannel {
17
17
  private ctx?: ExtensionContext;
18
18
  private reconnect?: NodeJS.Timeout;
19
19
  private closed = false;
20
+ private activated = false;
21
+ private restoreDialogs?: () => void;
20
22
  private connecting = false;
21
23
  private everConnected = false;
22
24
  private downSince = 0;
@@ -40,11 +42,13 @@ export class CompanionBridge implements AskChannel {
40
42
 
41
43
  setContext(ctx: ExtensionContext) {
42
44
  this.ctx = ctx;
43
- if (ctx.hasUI) relayDialogs(ctx.ui, this, this.toolDialogs);
45
+ if (this.activated && this.snapshot.remoteEnabled && ctx.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(ctx.ui, this, this.toolDialogs);
46
+ const name = this.pi.getSessionName() ?? this.snapshot.name;
44
47
  this.snapshot = {
45
48
  ...this.snapshot,
49
+ name,
46
50
  cwd: ctx.cwd,
47
- shortTitle: this.snapshot.name?.trim() || ctx.cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi",
51
+ shortTitle: name?.trim() || ctx.cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi",
48
52
  mainModel: ctx.model?.id,
49
53
  effort: ctx.thinkingLevel,
50
54
  status: ctx.isIdle() ? "idle" : "active"
@@ -53,12 +57,32 @@ export class CompanionBridge implements AskChannel {
53
57
 
54
58
  setName(name?: string) {
55
59
  this.snapshot.name = name;
56
- this.send({ type: "session.update", session: { name } });
60
+ this.snapshot.shortTitle = name?.trim() || this.snapshot.cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi";
61
+ this.send({ type: "session.update", session: { name, shortTitle: this.snapshot.shortTitle } });
57
62
  }
58
63
 
59
64
  setRemoteEnabled(remoteEnabled: boolean) {
60
65
  this.snapshot.remoteEnabled = remoteEnabled;
61
- this.send({ type: "session.update", session: { remoteEnabled } });
66
+ this.snapshot.status = remoteEnabled ? (this.ctx?.isIdle() === false ? "active" : "idle") : "stopped";
67
+ this.send({ type: "session.update", session: { remoteEnabled, status: this.snapshot.status } });
68
+ if (remoteEnabled) {
69
+ if (this.isActivated()) { this.activate(); void this.connect(); }
70
+ return;
71
+ }
72
+ // End only Companion sharing: Pi itself and its local history keep running.
73
+ this.restoreDialogs?.();
74
+ this.restoreDialogs = undefined;
75
+ for (const entry of [...this.asks.values()]) entry.settle(null);
76
+ for (const pending of this.pendingDeletes.values()) {
77
+ clearTimeout(pending.timer);
78
+ pending.resolve({ ok: false, error: "Session sharing ended." });
79
+ }
80
+ this.pendingDeletes.clear();
81
+ if (this.reconnect) clearTimeout(this.reconnect);
82
+ this.reconnect = undefined;
83
+ const ws = this.ws;
84
+ this.ws = undefined;
85
+ ws?.close();
62
86
  }
63
87
 
64
88
  isRemoteEnabled() {
@@ -69,9 +93,19 @@ export class CompanionBridge implements AskChannel {
69
93
  return [...this.tempFiles];
70
94
  }
71
95
 
72
- /** Connect to the shared daemon, launching it only when nothing is listening. */
96
+ /** Only an explicit /companion command may activate this session's bridge. */
97
+ activate() {
98
+ this.activated = true;
99
+ if (this.snapshot.remoteEnabled && this.ctx?.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(this.ctx.ui, this, this.toolDialogs);
100
+ }
101
+
102
+ isActivated() {
103
+ return this.activated && !this.closed;
104
+ }
105
+
106
+ /** Connect to the shared daemon only after this session opted in. */
73
107
  async connect() {
74
- if (this.closed || this.connecting) return;
108
+ if (!this.activated || !this.snapshot.remoteEnabled || this.closed || this.connecting) return;
75
109
  if (this.ws && this.ws.readyState <= WebSocket.OPEN) return; // connecting or already live
76
110
  this.connecting = true;
77
111
  try {
@@ -79,7 +113,7 @@ export class CompanionBridge implements AskChannel {
79
113
  // period to come back before launching a replacement.
80
114
  const allowSpawn = !this.everConnected || (this.downSince > 0 && Date.now() - this.downSince > 20_000);
81
115
  if (allowSpawn) await ensureDaemon((message, level = "info") => this.log(message, level));
82
- if (this.closed) return;
116
+ if (this.closed || !this.snapshot.remoteEnabled) return;
83
117
  this.open();
84
118
  } finally {
85
119
  this.connecting = false;
@@ -91,12 +125,14 @@ export class CompanionBridge implements AskChannel {
91
125
  const ws = new WebSocket(base.replace(/\/$/, "") + "/ws/bridge/" + this.sessionId);
92
126
  this.ws = ws;
93
127
  ws.on("open", () => {
128
+ if (this.ws !== ws || this.closed || !this.snapshot.remoteEnabled) { ws.close(); return; }
94
129
  this.everConnected = true;
95
130
  this.downSince = 0;
96
131
  this.retryDelay = 1000;
97
132
  this.send({ type: "register", session: this.snapshot });
98
133
  });
99
134
  ws.on("message", raw => {
135
+ if (this.ws !== ws || this.closed || !this.snapshot.remoteEnabled) return;
100
136
  try { void this.handle(JSON.parse(raw.toString()) as ServerMessage); }
101
137
  catch (error) { this.send({ type: "error", message: "Invalid server message: " + String(error) }); }
102
138
  });
@@ -116,6 +152,9 @@ export class CompanionBridge implements AskChannel {
116
152
 
117
153
  close() {
118
154
  this.closed = true;
155
+ this.restoreDialogs?.();
156
+ this.restoreDialogs = undefined;
157
+ for (const entry of [...this.asks.values()]) entry.settle(null);
119
158
  if (this.reconnect) clearTimeout(this.reconnect);
120
159
  this.ws?.close();
121
160
  }
@@ -136,7 +175,7 @@ export class CompanionBridge implements AskChannel {
136
175
  ask(input: AskInput, signal?: AbortSignal) {
137
176
  const request: AskRequest = { ...input, requestId: randomUUID(), createdAt: new Date().toISOString() };
138
177
  return new Promise<AskAnswers | null>(resolve => {
139
- if (signal?.aborted) return resolve(null);
178
+ if (!this.isActivated() || !this.snapshot.remoteEnabled || signal?.aborted) return resolve(null);
140
179
  const onAbort = () => settle(null);
141
180
  const settle = (answers: AskAnswers | null) => {
142
181
  if (!this.asks.delete(request.requestId)) return;
@@ -157,6 +196,7 @@ export class CompanionBridge implements AskChannel {
157
196
  }
158
197
 
159
198
  async deleteTempFile(fileId: string) {
199
+ if (!this.isActivated()) return { ok: false, error: "Run /companion first to enable this session." };
160
200
  const requestId = randomUUID();
161
201
  this.send({ type: "file.delete", requestId, fileId });
162
202
  return await new Promise<{ ok: boolean; error?: string }>(resolve => {
@@ -174,7 +214,7 @@ export class CompanionBridge implements AskChannel {
174
214
 
175
215
  private scheduleReconnect() {
176
216
  if (this.reconnect) clearTimeout(this.reconnect);
177
- if (this.closed) return;
217
+ if (!this.activated || !this.snapshot.remoteEnabled || this.closed) return;
178
218
  this.reconnect = setTimeout(() => void this.connect(), this.retryDelay);
179
219
  this.retryDelay = Math.min(this.retryDelay * 2, 10_000);
180
220
  }
package/src/daemon.ts CHANGED
@@ -40,15 +40,22 @@ function isLocalTarget() {
40
40
  }
41
41
  }
42
42
 
43
- export async function isDaemonUp(timeoutMs = 800) {
43
+ type DaemonContext = { version?: string; pid?: number };
44
+
45
+ async function daemonContext(timeoutMs = 800): Promise<DaemonContext | null> {
44
46
  try {
45
47
  const response = await fetch(adminHttpUrl() + "/api/context", { signal: AbortSignal.timeout(timeoutMs) });
46
- return response.ok;
48
+ if (!response.ok) return null;
49
+ return await response.json() as DaemonContext;
47
50
  } catch {
48
- return false;
51
+ return null;
49
52
  }
50
53
  }
51
54
 
55
+ export async function isDaemonUp(timeoutMs = 800) {
56
+ return Boolean(await daemonContext(timeoutMs));
57
+ }
58
+
52
59
  function packageVersion() {
53
60
  try {
54
61
  return JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version as string;
@@ -182,35 +189,84 @@ async function acquireLock() {
182
189
  }
183
190
  }
184
191
 
185
- async function waitForDaemon(ms: number) {
192
+ async function waitForDaemon(ms: number, expectedVersion?: string) {
186
193
  const deadline = Date.now() + ms;
187
194
  while (Date.now() < deadline) {
188
- if (await isDaemonUp(500)) return true;
195
+ const context = await daemonContext(500);
196
+ if (context && (!expectedVersion || context.version === expectedVersion)) return true;
189
197
  await new Promise(resolve => setTimeout(resolve, 300));
190
198
  }
191
199
  return false;
192
200
  }
193
201
 
202
+ async function waitForDaemonDown(ms: number) {
203
+ const deadline = Date.now() + ms;
204
+ while (Date.now() < deadline) {
205
+ if (!(await isDaemonUp(300))) return true;
206
+ await new Promise(resolve => setTimeout(resolve, 200));
207
+ }
208
+ return false;
209
+ }
210
+
211
+ async function stopDaemonForUpgrade(context: DaemonContext, log: DaemonLog) {
212
+ try {
213
+ const response = await fetch(adminHttpUrl() + "/api/shutdown", {
214
+ method: "POST",
215
+ signal: AbortSignal.timeout(2_000)
216
+ });
217
+ if (response.ok && await waitForDaemonDown(5_000)) return true;
218
+ } catch {
219
+ // Older daemons do not have /api/shutdown. Fall through to a local process stop.
220
+ }
221
+
222
+ try {
223
+ if (typeof context.pid === "number" && Number.isInteger(context.pid) && context.pid > 1) {
224
+ process.kill(context.pid, "SIGTERM");
225
+ } else if (process.platform === "win32") {
226
+ await execFileAsync("taskkill", ["/IM", "pi-companion-server.exe", "/F"]);
227
+ } else {
228
+ await execFileAsync("pkill", ["-f", "pi-companion-server"]);
229
+ }
230
+ } catch {
231
+ // The process may already have exited between the probe and the stop attempt.
232
+ }
233
+
234
+ const stopped = await waitForDaemonDown(5_000);
235
+ if (!stopped) log("Could not stop the older Pi Companion daemon automatically.", "warning");
236
+ return stopped;
237
+ }
238
+
194
239
  /**
195
240
  * Make sure a daemon is reachable. Returns true when one answers.
196
241
  * Never spawns when a daemon (manual `cargo run`, another session's daemon, …) is already up.
197
242
  */
198
243
  export async function ensureDaemon(log: DaemonLog) {
199
- if (await isDaemonUp()) return true;
200
- if (process.env.PI_COMPANION_AUTOSTART === "0" || !isLocalTarget()) return false;
244
+ const expectedVersion = packageVersion();
245
+ const running = await daemonContext();
246
+ if (running?.version === expectedVersion) return true;
247
+ if (process.env.PI_COMPANION_AUTOSTART === "0" || !isLocalTarget()) return Boolean(running);
201
248
 
202
249
  const release = await acquireLock();
203
- if (!release) return await waitForDaemon(10_000); // another Pi session is starting it
250
+ if (!release) return await waitForDaemon(10_000, expectedVersion); // another Pi session is starting/upgrading it
204
251
  try {
205
- if (await isDaemonUp()) return true; // started while we were taking the lock
252
+ const current = await daemonContext();
253
+ if (current?.version === expectedVersion) return true;
254
+
255
+ if (current) {
256
+ log(
257
+ "Updating Pi Companion daemon from v" + (current.version ?? "unknown") + " to v" + expectedVersion + "…"
258
+ );
259
+ if (!(await stopDaemonForUpgrade(current, log))) return false;
260
+ }
261
+
206
262
  const binary = await resolveDaemonBinary(log);
207
263
  await new Promise<void>((resolve, reject) => {
208
264
  const child = spawn(binary, [], { detached: true, stdio: "ignore", windowsHide: true });
209
265
  child.once("error", reject);
210
266
  child.once("spawn", () => { child.unref(); resolve(); });
211
267
  });
212
- const up = await waitForDaemon(10_000);
213
- if (!up) log("Pi Companion daemon did not become ready (" + binary + ")", "warning");
268
+ const up = await waitForDaemon(10_000, expectedVersion);
269
+ if (!up) log("Pi Companion daemon v" + expectedVersion + " did not become ready (" + binary + ")", "warning");
214
270
  return up;
215
271
  } catch (error) {
216
272
  log("Could not start Pi Companion daemon: " + (error instanceof Error ? error.message : String(error)), "warning");
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
  }