@yunazgr/pi-companion 0.2.0 → 0.2.2

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.
Files changed (3) hide show
  1. package/README.md +44 -10
  2. package/package.json +8 -3
  3. package/src/daemon.ts +67 -11
package/README.md CHANGED
@@ -21,7 +21,11 @@ or straight from git:
21
21
 
22
22
  pi install git:github.com/ygrip/pi-companion
23
23
 
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).
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.
25
+
26
+ 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
+
28
+ 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
29
 
26
30
  ## Architecture
27
31
 
@@ -56,7 +60,24 @@ Then start pi as usual and run /companion in each session you want to follow fro
56
60
 
57
61
  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
62
 
59
- ## Daemon
63
+ ## How the daemon runs
64
+
65
+ For a normal install:
66
+
67
+ pi install npm:@yunazgr/pi-companion
68
+
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.
70
+
71
+ The lifecycle is:
72
+
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.
76
+ 4. If not, the extension resolves the daemon binary, downloading the matching GitHub Release on first use if necessary.
77
+ 5. It starts the daemon detached in the background.
78
+ 6. Other Pi sessions reuse the same daemon.
79
+ 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
+ 8. The daemon keeps running independently until it is stopped or the machine restarts.
60
81
 
61
82
  There is exactly one daemon per machine, shared by every Pi session. The extension manages it with no configuration:
62
83
 
@@ -122,7 +143,7 @@ Dialogs from other extensions (`ctx.ui.select`, `ctx.ui.confirm`, `ctx.ui.input`
122
143
  Pairing is daemon-wide rather than session-specific. Everything lives on the **Devices** page of the local console (port 43721):
123
144
 
124
145
  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.
146
+ 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
147
  3. The daemon issues a long random credential and stores only its SHA-256 hash.
127
148
 
128
149
  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.
@@ -219,6 +240,7 @@ Design notes:
219
240
  - 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
241
  - 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
221
242
  - 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
222
244
  - mobile: bottom tab bar, safe-area insets, 44px touch targets, 16px inputs (no iOS zoom), Enter inserts a newline on touch keyboards
223
245
  - accessibility: skip link, visible focus rings, arrow-key tabs, labelled controls, live regions for the feed and questions, reduced-motion support
224
246
  - the dot field is Raksara's component, loaded when the browser is idle, paused when hidden, and static under reduced motion
@@ -254,16 +276,20 @@ The initial slice supports:
254
276
 
255
277
  There is deliberately no arbitrary shell, arbitrary tool invocation, or arbitrary filesystem API.
256
278
 
257
- ## Development
279
+ ## Running locally from source
280
+
281
+ Use this only when developing Pi Companion itself. A published install does not require these steps.
258
282
 
259
283
  Requirements: Node 22.17+ and stable Rust.
260
284
 
285
+ Clone the repository, then:
286
+
261
287
  npm install
262
288
  npm run serve
263
289
 
264
- This builds the UI, starts the daemon and prints where it is listening:
290
+ `npm run serve` builds the embedded Svelte UI and starts the Rust daemon in the foreground. It prints:
265
291
 
266
- Pi Companion v0.1.0
292
+ Pi Companion v0.2.2
267
293
 
268
294
  Console http://127.0.0.1:43721
269
295
  Paired devices http://127.0.0.1:43722
@@ -286,17 +312,25 @@ Then, in another shell, start Pi with the extension from this checkout:
286
312
 
287
313
  pi -e ./src/index.ts
288
314
 
315
+ Because the development daemon is already listening on port 43721, the extension detects it and does not launch another daemon.
316
+
289
317
  For hot-reloading UI work, keep the daemon running and use:
290
318
 
291
319
  npm run ui:dev
292
320
 
293
321
  which proxies /api and /ws to the daemon on 43721.
294
322
 
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.
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.
324
+
325
+ To test the same behavior as a published install, stop any development daemon first and install the package normally:
326
+
327
+ pi install npm:@yunazgr/pi-companion
328
+
329
+ Then start Pi. The extension downloads and starts the released daemon automatically.
296
330
 
297
331
  ## Releases
298
332
 
299
- The package carries the pi-package keyword, so published npm versions appear in the Pi package gallery (https://pi.dev/packages). Host-provided packages (@earendil-works/pi-coding-agent, typebox) are peer dependencies, as Pi requires.
333
+ The package carries the required `pi-package` keyword, which makes the published npm package eligible for discovery in the Pi package gallery (https://pi.dev/packages). The manifest also includes focused discovery keywords such as `pi-extension`, `pi-coding-agent`, `remote-control`, and `session-dashboard`. Host-provided packages (@earendil-works/pi-coding-agent, typebox) are peer dependencies, as Pi requires.
300
334
 
301
335
  GitHub Actions run only for release tags. Pushing ordinary commits or opening pull requests does not trigger any workflow; CI can also be started manually with workflow_dispatch.
302
336
 
@@ -304,9 +338,9 @@ A tag matching vX.Y.Z triggers both the CI and release workflows.
304
338
 
305
339
  Before publishing, the workflow requires X.Y.Z to match both package.json and server/Cargo.toml. It then runs TypeScript and Rust checks, builds native daemon archives for Linux x86_64, macOS arm64, macOS x86_64, and Windows x86_64, generates SHA-256 checksums, and creates a GitHub Release.
306
340
 
307
- Only after the GitHub Release exists, and only if the repository has an NPM_TOKEN secret, the same tag publishes @yunazgr/pi-companion to npm with provenance, so an npm install never looks for a daemon binary that is not uploaded yet. Git installs need no token.
341
+ 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
342
 
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.
343
+ 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
344
 
311
345
  ## Security boundary
312
346
 
package/package.json CHANGED
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "name": "@yunazgr/pi-companion",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",
7
- "pi",
8
7
  "pi-extension",
9
8
  "pi-coding-agent",
9
+ "pi",
10
10
  "remote-control",
11
+ "remote-agent",
11
12
  "companion",
12
- "dashboard",
13
+ "session-dashboard",
14
+ "developer-tools",
13
15
  "mobile"
14
16
  ],
15
17
  "homepage": "https://github.com/ygrip/pi-companion#readme",
@@ -84,5 +86,8 @@
84
86
  "@google/genai": false,
85
87
  "protobufjs": false,
86
88
  "fsevents": false
89
+ },
90
+ "publishConfig": {
91
+ "access": "public"
87
92
  }
88
93
  }
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");