aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorTianren Dong <tianrendong.dtr@gmail.com>2026-05-16 12:46:57 -0400
committerTianren Dong <tianrendong.dtr@gmail.com>2026-05-16 12:46:57 -0400
commit2ac665ec31fe156b372540e8ac38aa42765b3001 (patch)
treee21d16dd67e5f3d518a21cf6e7967fba964989e0 /docs
parentd9f2d027bda8cd2e2c42fa54a843c477fa18db05 (diff)
0.17.0: standalone daemon, no more owner-vs-client topology
The Chrome bridge is now a separate detached daemon at ~/.cache/pi-chrome/<version>/daemon.cjs. Every Pi session is a uniform client; daemon lifecycle is decoupled from any Pi session. Killing a Pi never breaks Chrome control, and pi update followed by any new Pi session auto-replaces an incompatible daemon. Architecture: - New extensions/chrome-profile-bridge/daemon.cjs: ~700 LOC, zero deps, runs detached. Owns the HTTP listener, BridgeAuth state, command queue, /pair handshake. Idle-shuts-down after 10 min of no activity. - ChromeProfileBridge on Pi side becomes a pure client: start() probes /status, spawns daemon if missing/incompatible, kills+respawns if version mismatch. send() always uses signed /command. No more server-mode branch. - New signed /admin RPC for pair-window/reset/status state mutations the daemon owns. Pi clients sign with broker key (same contract as /command). - New /heartbeat for daemon idle-timer keepalive. - daemon.log at ~/.cache/pi-chrome/<version>/daemon.log. /chrome pair, /chrome unpair, /chrome onboard now go through /admin RPCs instead of mutating in-process bridge state. Removed the foreign-owner takeover dance from /chrome onboard (auto-handled at session_start now). Wire protocol is byte-identical to 0.16.x; existing paired extensions don't need to re-pair. See docs/DESIGN-0.17-daemon.md for the full write-up.
Diffstat (limited to 'docs')
-rw-r--r--docs/DESIGN-0.17-daemon.md106
1 files changed, 106 insertions, 0 deletions
diff --git a/docs/DESIGN-0.17-daemon.md b/docs/DESIGN-0.17-daemon.md
new file mode 100644
index 0000000..2e7f115
--- /dev/null
+++ b/docs/DESIGN-0.17-daemon.md
@@ -0,0 +1,106 @@
+# pi-chrome 0.17 — standalone versioned daemon
+
+**Status:** design draft. Implementing in this same release branch.
+
+## Goals
+
+1. **Decouple the bridge from any Pi session lifecycle.** Killing a Pi session must never break Chrome control for other sessions.
+2. **Eliminate the "owner vs client" topology.** Every Pi session should be a uniform client.
+3. **Make upgrades self-healing.** When `pi update` lands a new pi-chrome version, the next session that starts must Just Work without any manual `kill` or `/chrome onboard` ritual.
+4. **No new runtime deps.** Keep the zero-deps promise.
+
+## Non-goals (deferred to 0.18)
+
+- Transport change. Stays HTTP + long-poll for 0.17. WebSocket lands separately on top of the daemon.
+- Native messaging. Not pursued; see SECURITY.md threat model rationale.
+
+## Architecture
+
+```
+ ┌─────────────┐ ┌────────────────────────────┐
+ │ Chrome ext │ HTTPS │ pi-chrome daemon │ ─────┐
+ │ (SW polls ├────────►│ ~/.cache/pi-chrome/ │ │
+ │ /next) │ │ v0.17.0/daemon.cjs │ │ stat()
+ └─────────────┘ │ Listens :17318 │ │ on each
+ │ Owns BridgeAuth + state │ │ accept
+ └─────────────┬──────────────┘ │
+ │ │
+ signed /command (HTTP) │
+ │ │
+ ┌─────────────┴──────────────┐ │
+ │ Pi session N (any version) │ ──────┘
+ │ extension/index.ts as │
+ │ daemon supervisor + client │
+ └────────────────────────────┘
+```
+
+### Lifecycle
+
+- **First Pi session in a pi-chrome version family** spawns the daemon if no compatible one is running. Spawn is detached (`spawn(node, [daemon.cjs], { detached: true, stdio: 'ignore' })`), the parent Pi doesn't own the daemon's process group, and `unref()`'s it so Pi can exit independently.
+- **Daemon stays alive** as long as it has work to do (active extension poll, or pending peer connections). After `IDLE_SHUTDOWN_MS` (default: 10 min) of zero activity, daemon exits cleanly.
+- **Subsequent Pi sessions** at `session_start` probe `/status`:
+ - Healthy + compatible version → use as-is.
+ - Unresponsive or incompatible version → kill it, spawn the right-version daemon.
+- **`pi update`** changes the on-disk pi-chrome version. Next Pi session sees mismatch, kills old daemon, spawns new. **No in-process hot reload** — the old daemon's in-flight commands are allowed to drain (or time out normally) and the next request to the new daemon retries fresh.
+
+### Versioned daemon path
+
+Daemon is copied to `~/.cache/pi-chrome/<version>/daemon.cjs` on Pi session_start (idempotent, only writes if absent or shasum mismatches). Pi spawns the daemon from that path, never directly from the npm install location:
+
+- Survives `nvm use` switches.
+- Multiple pi-chrome versions can coexist on disk (the active daemon corresponds to the most recent Pi session that started). Old version dirs are garbage-collected when the daemon detects them.
+- The daemon executable is a self-contained CommonJS bundle (built at publish time, or generated by a postinstall hook). No `require()` from anywhere except `node:` builtins.
+
+### Why not ESM?
+
+CommonJS keeps the daemon a single file with no entry-point ceremony, no `package.json` next to it, no resolver surprises across Node versions. The cost is irrelevant — the daemon has no third-party deps.
+
+## Protocol (unchanged from 0.16.1)
+
+The HTTP endpoints and signed-envelope contract are byte-identical to 0.16.1. The daemon is structurally a refactor of the existing `ChromeProfileBridge` class:
+
+- `/status` (now adds `daemonPid`, `daemonStartedAt`)
+- `/next` (long-poll, signed `bridge->ext` response)
+- `/result` (signed `ext->bridge` body)
+- `/command` (signed `peer->owner` / `owner->peer`)
+- `/pair` (invite window, signed response with invite secret)
+
+The only addition is a new `/heartbeat` endpoint (cheap GET, returns `{ok:true,ts}`) used by Pi clients to keep the daemon's idle timer alive and to detect daemon health without consuming a full command round-trip.
+
+### Configuration file unchanged
+
+`~/.config/pi/chrome-bridge.json` keeps the same shape (`{protocol, bridgeId, extensionId, extensionPairKey, brokerKey}`). The daemon owns the read/write of that file; Pi clients only consult it to derive `brokerKey` for signing peer→owner requests.
+
+The cross-process `fs.watch` + `refreshIfStale` machinery becomes daemon-local — `chrome-bridge.json` is now exclusively the daemon's data file. Pi clients receive pairing state via `/status` instead of reading the file directly.
+
+## Extension changes
+
+**None.** The extension still polls `127.0.0.1:17318`. From its perspective the only thing that changed is "the process behind that port restarts itself occasionally during pi-chrome upgrades."
+
+The extension's existing version-mismatch reload path (`x-pi-chrome-version` header → `chrome.runtime.reload()`) handles its own upgrade story independently.
+
+## Pi-side client surface
+
+`extensions/chrome-profile-bridge/index.ts` is split into:
+
+1. **`bridge-client.ts`** — Pi-side talker. `BridgeClient.send(action, params, timeoutMs)` signs and posts to `/command` of the local daemon, verifies the response signature, returns the result. No long-poll loop, no HTTP server. ~200 LOC.
+2. **`daemon-supervisor.ts`** — at `session_start`: ensures daemon is running, spawns if not, registers a graceful keepalive. ~100 LOC.
+3. **`daemon.cjs`** (built artifact) — the bridge process. Internally identical to today's `ChromeProfileBridge` minus the "client mode" branch. Single self-contained file shipped in npm tarball + copied to versioned cache dir on first use.
+4. **`index.ts`** — pi-extension entry. Imports `bridge-client` and `daemon-supervisor`, registers the chrome_* tools, wires `/chrome` commands. ~500 LOC (everything else moved out).
+
+## Open questions
+
+1. **Daemon log location.** Default: `~/.cache/pi-chrome/<version>/daemon.log` (rotated at 1 MiB). Exposed via `/chrome doctor --logs`.
+2. **What happens during a takeover with in-flight Pi commands?** Old daemon dies → its in-flight `/command` returns a TCP RST to the Pi client → Pi client retries against the new daemon. Implement explicit retry-once on connection-refused/RST inside `BridgeClient.send` with a budget (5 s) before surfacing the error.
+3. **Pairing across daemon restarts.** `~/.config/pi/chrome-bridge.json` persists. New daemon `load()`s it, picks up the same `extensionPairKey`/`brokerKey`. Extension never has to re-pair across a daemon restart.
+4. **Concurrent daemon spawn race.** Two Pi sessions starting simultaneously could each try to spawn a daemon. Mitigation: spawn-then-bind, second one hits EADDRINUSE inside the daemon itself and exits cleanly; supervisor probes `/status` after spawn to confirm one is up.
+5. **Garbage-collecting old versioned dirs.** Daemon at startup deletes `~/.cache/pi-chrome/v*/` directories older than 30 days.
+
+## Migration
+
+0.16.1 → 0.17:
+- First Pi session on 0.17 detects an existing 0.16.x bridge on `:17318`, version-mismatches, kills it, spawns the 0.17 daemon. Same as the existing auto-takeover code in 0.16.1.
+- Extension picks up new bridge on next poll, sees same `expectedExtensionVersion`, no reload.
+- Pairing state in `~/.config/pi/chrome-bridge.json` is preserved across the transition.
+
+User-visible: nothing. `pi update` → next Pi session → done.