Compare commits

..

42 Commits

Author SHA1 Message Date
jay e5dd64a1f7 docs: T-2.10 background lifecycle done; update next-steps 2026-05-17 13:25:09 +02:00
jay 0882ef8038 docs: bring NEXT-STEPS + SYNC up to current state (2026-05-16)
Phase 2 reality vs plan:
- Done: T-2.0..T-2.6, T-2.8, T-2.9, T-2.11
- Bonus done: XCUITest target + 8/8 tests, idb automation guide
- Open: T-2.7, T-2.10, T-2.12, T-2.13
- Bug backlog: 4 Keychain entitlements failures + 4 Pairing http-scheme tests

Also documented conventions: async subagent dispatch, reviewer fan-in,
--uitest mode + idb workflow, sidecar manual restart pattern.

Replaced obsolete Phase-1 orchestration instructions with current
priorities.
2026-05-16 22:20:55 +02:00
jay 2c627ea095 Merge fix/post-sessions-response-shape: POST /sessions returns full shape 2026-05-16 22:18:54 +02:00
jay df735aa279 fix(sidecar): POST /sessions response now matches GET shape (id+name+state+lastOutputAt)
Previously POST returned only { id, name }, while GET returns
{ id, name, state, lastOutputAt }. iOS clients that share a Decodable
for both endpoints (e.g. SessionItem in pi-remote-ios) failed to decode
the POST response with 'data couldn't be read because it is missing'.

The new session always starts in 'idle' state with empty lastOutputAt.
Documented the new shape in the route header comment.
2026-05-16 22:07:54 +02:00
jay 964226847b Merge fix/ws-input-handler: WS handler processes keys/key/paste 2026-05-16 12:07:53 +02:00
jay a7c7b8f3d7 fix(sidecar): WS stream handler — process keys/key/paste messages 2026-05-16 12:07:16 +02:00
jay 0604fd7c03 Merge feat/sidecar-pair-smoke: POST /pair smoke test 2026-05-16 11:54:33 +02:00
jay 8eb8360387 test: POST /pair smoke test (T-1.3 regression guard) 2026-05-16 04:17:18 +02:00
jay 547df01c21 fix: spawn sessions at 80x24 default (smaller mismatch before iOS resize arrives) 2026-05-16 04:00:02 +02:00
jay 2e44a7f286 fix: listSessions filters to @pi-remote-managed sessions only — excludes pi-sidecar and other unrelated tmux sessions 2026-05-16 03:51:27 +02:00
jay 4b428df0a4 fix: capturePane with escapes=true for color-accurate snapshots 2026-05-16 03:46:24 +02:00
jay 8ff635e6f5 fix: tsc errors — use correct ExtensionAPI event names (tool_execution_start/end, session_start) 2026-05-16 03:32:58 +02:00
jay fcfe729d23 fix: terminal size sync — resize message handler + xterm-256color default-terminal 2026-05-16 03:30:27 +02:00
Johannes Merz b64aaab40a fix: WS upgrade auth — multi-token bearer not validated
Problem: isAuthenticated() for WS upgrade only checked legacy single token.
iOS bearer token (from POST /pair → createToken()) was rejected → 403 on WS.

Fix:
- warmTokenCache(): pre-load all multi-tokens into a sync Set on startup
- validateBearerSync(): O(1) sync lookup against the cache
- createToken(): adds to cache immediately on creation
- isAuthenticated(): checks validateBearerSync() as third fallback
2026-05-16 03:12:43 +02:00
Johannes Merz 38cad794e2 feat: tsconfig.json + npm run typecheck
- tsconfig.json simulates pi's ESM TypeScript runtime
- Resolves peer deps from pi's global node_modules
- 'type: module' added to package.json (correct — pi loads as ESM)
- Fixes found by tsc:
  - buffer/writer.ts: correct Dirent import from node:fs
  - messages.ts: toolCall id/name may be undefined, default to empty string
- Remaining warnings: pi event API names (session_switch etc.) not in types;
  these are guarded with try/catch at runtime — acceptable
- npm run typecheck: tsc --noEmit
2026-05-16 03:08:02 +02:00
Johannes Merz 920f6d8fc3 fix: import readBody+sendJson in server.ts — POST /pair was crashing 2026-05-16 03:04:07 +02:00
Johannes Merz 571cf8c9ec feat: GET /pair-qr endpoint — QR code in terminal, fix double-port bug 2026-05-16 02:56:06 +02:00
Johannes Merz 1f36636e06 feat: POST /pair endpoint + async bearer token auth
- POST /pair: consumes one-time pairingToken, creates named bearer token,
  returns { bearerToken, sidecarId } per IC-3
- isAuthenticatedAsync(): checks legacy token + new multi-token store
- isAuthenticated(): extended with Bearer header support for WS upgrade
- Smoke still 12/12 green
2026-05-16 02:46:15 +02:00
Johannes Merz 91b1ad1a44 docs: Phase 2 in progress — T-2.0..T-2.5+T-2.9 done, app on device 2026-05-16 02:42:01 +02:00
Johannes Merz 911d3f7625 feat(T-1.8/1.9): stream integration smoke, operator guide, Phase 1 complete
- T-1.8: stream.test.mjs — session CRUD, WS stream attach, send-keys,
  marker observation, reconnect+delta replay, thumbnail, delete. 12/12 green.
- T-1.9: docs/reference/OPERATOR.md — full operator guide; README sidecar section.
- Fix: tmux/control.ts -CC → -C (passthrough mode bypassed %output events).
- Fix: tmux/input.ts + snapshot.ts drop hardcoded :0.0 pane (base-index safety).
- SYNC.md + NEXT-STEPS.md: Phase 1 marked done, Phase 2 unblocked.
2026-05-15 11:43:59 +02:00
Johannes Merz b94b668df6 feat(T-1.5/1.6/1.7): stream+input+snapshot routes, sessions CRUD, commands, side-channel, health endpoint 2026-05-15 11:35:55 +02:00
Johannes Merz db6be6dcf8 feat(T-1.10): APNs scaffold — JWT provider auth, push primitive, device-token stub 2026-05-15 11:32:05 +02:00
Johannes Merz f89abd1125 feat(T-1.4): pi adapter — events, commands, autoname 2026-05-15 11:31:36 +02:00
Johannes Merz 6f106d2411 feat(T-1.3): auth tokens, pairing, TLS, CLI (pair/auth list/revoke/name) 2026-05-15 11:30:54 +02:00
Johannes Merz 17c32e7e93 feat(T-1.2): sequence counter + disk ring-buffer writer/reader 2026-05-15 11:29:41 +02:00
Johannes Merz bd990a07ab feat(T-1.1): tmux manager, control-mode client, input, snapshot 2026-05-15 11:28:45 +02:00
Johannes Merz 4f6fa0e83b docs: resolve OQ-3 — tmux control-mode per-session 2026-05-15 11:25:16 +02:00
Johannes Merz d74341af2a merge: T-1.0 server refactor + T-1.0a smoke harness 2026-05-15 11:20:32 +02:00
jay af990f6592 sync: release T-1.0a claim, add history + file ownership [@worker-t1.0a] 2026-05-15 11:18:56 +02:00
jay a7dad86901 feat(t-1.0a): smoke test harness MVP
- scripts/smoke/helpers.mjs — spawn-pi (via python3 pty.spawn), wait-for-port,
  fetch, WebSocket helpers; createSmokeHome/removeSmokeHome for isolated HOME
- scripts/smoke/smoke.mjs — 6 node:test assertions:
    GET /manifest.json → 200 + JSON shape
    GET /icon.svg → 200 + <svg body
    GET / (with token) → 302→200 + HTML marker
    GET / (no token) → 403
    WS /ws (with token) → 101 upgrade
    pi process alive check
- scripts/smoke/README.md — usage, design notes, extension guide
- package.json: add 'smoke' script
- docs/SYNC.md: add scripts/smoke/** ownership row + History entry

All 6 tests pass in ~1.4 s locally.

Key finding: pi requires a PTY to enter interactive mode and fire
session_start. Spawning without a TTY causes immediate exit. Workaround:
python3 pty.spawn() — allocates a PTY with no additional deps.
2026-05-15 11:18:20 +02:00
jay 174fa7fb31 sync: claim T-1.0a smoke test harness [@worker-t1.0a] 2026-05-15 11:12:10 +02:00
jay 3e813eb90a sync: release T-1.0 claim, add history entry (@worker-t1.0) 2026-05-15 10:58:07 +02:00
jay 568931901d refactor(T-1.0): carve server.ts into server/ sub-modules
Create the modular server/ layout from PHASE-1-sidecar.md §Architecture Sketch:

  server/types.ts    — shared WsClient/WsServer/RemoteServer interfaces
  server/upgrade.ts  — WS upgrade routing per path (LEGACY /ws)
  server/server.ts   — HTTP bootstrap, middleware, LEGACY HTML routes
  server/routes/     — empty dir, placeholder for T-1.5/T-1.6/T-1.7

The original extensions/remote-control/server.ts is replaced with a
thin re-export shim so that index.ts continues to resolve
'./server.js' without changes.

All LEGACY code paths (manifest.json, icon.svg, /, /ws) are tagged
with // LEGACY: … comments. No behaviour changes. No new endpoints.

Pre-existing biome errors in auth.ts, config.ts, index.ts are
unchanged — NOT introduced by this commit.
2026-05-15 10:57:52 +02:00
jay e396cfcaaa sync: claim T-1.0 — server refactor scaffold (@worker-t1.0) 2026-05-15 10:56:04 +02:00
Johannes Merz ba23050eda docs: resolve OQ-4 — T-1.0 worker = sonnet-4-6 2026-05-15 10:51:27 +02:00
Johannes Merz 460c5fac7a sync: clear stale T-1.0 claim (qwen swarm reset) 2026-05-15 10:48:56 +02:00
Johannes Merz 07522e5974 docs: mark IC-1..IC-4 frozen in NEXT-STEPS, resolve OQ-1/OQ-2 2026-05-15 10:48:38 +02:00
jay c9bdfce890 sync: claim T-1.0 server refactor 2026-05-15 06:56:58 +02:00
jay 7c40c49b1a chore: freeze IC-1..IC-4 interface contracts
Resolved OQ-1 (drop tree event), OQ-2 (defer resize), OQ-3 (per-session
control-mode, T-1.1 to decide final), OQ-4 (sonnet-4-5 for T-1.0).

IC-3: clarified deviceToken/environment optional pre-Phase-2, mandatory
from Phase 2 onward.
2026-05-15 06:56:38 +02:00
jay aa8aa42655 docs: update README for iOS app direction + sidecar architecture 2026-05-15 04:50:22 +02:00
jay b2b82c82ce docs: incorporate Phase 0.5 verdict (Path B), prep Phase 1
- PHASE-1-sidecar.md
  - Note at top: streaming primitive decided as tmux control mode
  - Architecture sketch: tmux/pipe.ts → tmux/control.ts
  - T-1.1 description updated to reference control mode + spike-cc.ts
  - Risks R4 (parser throughput) and R5 (tmux ≥2.5 required) added

- SYNC.md
  - File ownership map: tmux/** note about control mode
  - Frozen Interface Contracts table: added Status + Frozen-at columns
    so contracts can be tracked from draft → frozen with date stamp
  - Freeze protocol prose

- NEW: NEXT-STEPS.md — single resume pointer for the next session.
  Lists what's done, what's draft, the orchestrator todo list (freeze
  contracts → dispatch T-1.0 → plan fan-out), open questions, and
  things explicitly NOT to do tomorrow.

- README.md: NEXT-STEPS.md added at top of the docs index.
2026-05-15 04:48:20 +02:00
jay 86c4d3e869 docs: mark Phase 0.5 complete in SYNC
- Phase 0.5 status: done
- Verdict: Path B (tmux control mode) recommended
- Active claim removed
- History entry added
- Phase 1 status updated: ready to start with control mode
2026-05-15 04:18:41 +02:00
46 changed files with 4940 additions and 1112 deletions

144
README.md
View File

@ -1,53 +1,145 @@
# pi-remote-control # pi-remote-control
A `pi` extension that exposes running sessions over WebSocket — used today
as a browser-based remote control, and the foundation for a native iOS app
currently in development.
## Disclaimer ## Disclaimer
This project is for personal use and research only. It is provided as-is, and the author accepts no liability for any damage, loss, misuse, or operational consequences that result from installing or using it. The server has no built-in authentication beyond a session token and no HTTPS on the dynamic port — see [Security notes](#security-notes) for details. Do not use it for safety-critical, multi-user, or untrusted-network deployments. Personal use and research only. Provided as-is, no liability for damage,
misuse, or operational consequences. See [Security notes](#security-notes).
## Install ---
## Current state: browser client
The extension ships a working HTML/WebSocket client that mirrors a pi
session in any browser — including iPhone Safari.
### Install
```bash ```bash
pi install https://github.com/goofansu/pi-remote-control pi install git:git.vpsj.de/jay/pi-remote-control
``` ```
## Usage ### Usage
Run `/remote-control` to open the menu: Run `/remote-control` inside pi to open the menu:
- **Turn on / Turn off** — start or stop the server - **Turn on / Turn off** — start or stop the server
- **Configure URL** — set the base URL exposed by your local tunnel or proxy, saved to `~/.pi/agent/remote-control.json` - **Configure URL** — set the base URL for your tunnel or proxy
- **Status** — show the QR code and connection URL (only when server is running) - **Status** — show the QR code and current session URL
> **Note:** On first use, you must configure the URL before the server can start. To start the server automatically:
To start the server automatically on launch:
```bash ```bash
pi --remote-control pi --remote-control
``` ```
## Use case The server binds to `127.0.0.1` and is reached through a local tunnel
(e.g. Surge Ponte, Tailscale). Open the QR URL in any browser.
The remote-control server binds to `127.0.0.1` on the host running `pi` and is reached through a local tunnel or proxy. This example uses [Surge Ponte](https://kb.nssurge.com/surge-knowledge-base/guidelines/ponte), which provides an end-to-end encrypted device-to-device tunnel without exposing the server to the LAN. ![pi remote control on iPhone](assets/screenshot-mobile.png)
The setup is: ---
1. Install this extension on the Mac that runs `pi`. ## In development: native iOS app
2. Enable Surge Ponte on that Mac and give it a device name such as `pi`.
3. On the same Mac, open `pi` and run the `/remote-control` command.
4. Choose `Configure URL` and set the base URL to your Surge Ponte hostname, for example `http://pi.sgponte`.
5. Choose `Turn on`.
6. Open `Status` to get the QR code and connection URL for the current session.
7. On another device on the same Surge Ponte network, open that URL in a browser.
In this setup, the browser URL is `http://pi.sgponte:<port>`, where the port is assigned when the server starts. Use `Status` to get the current URL or scan the QR code — it changes each time the server restarts. A native iOS app is being built on top of this extension's WebSocket
infrastructure. Design goals:
Here's what it looks like on iPhone — this is an actual session asking `pi` about its hardware environment: - **Byte-exact mirror** of the terminal session — what you see over SSH
is what you see on the phone, rendered via SwiftTerm.
- **Session persistence** — sessions run for days, the app reconnects
instantly after backgrounding (< 1s, via sequence-cursor delta replay).
- **Multi-session** — spawn, name, and switch between pi sessions from
the phone as easily as browser tabs.
- **Pi-aware augmentation** — modifier bar tuned for pi (Ctrl, Esc, Tab,
arrows, Shift+Enter), slash-command palette, status bar showing what pi
is currently doing, push notifications when pi is waiting for input.
- **QR pairing** — scan once, self-signed TLS + token pinned automatically.
<img src="assets/screenshot-mobile.png" width="300" alt="pi remote control on iPhone via pi.sgponte"> ### Architecture (in progress)
```
pi (Ink TUI) ◄──► tmux session ◄──► SSH (Mac terminal)
tmux -C (control mode)
pi-remote sidecar (this extension, extended)
│ wss:// (binary ANSI + JSON side-channel)
iOS app (SwiftUI + SwiftTerm)
```
Streaming uses tmux control mode (`tmux -C`) rather than `pipe-pane`
verified reliable across alternate-screen transitions in a PoC spike.
### Implementation docs
All planning and coordination lives in [`docs/`](./docs/):
| File | Purpose |
|---|---|
| [`docs/NEXT-STEPS.md`](./docs/NEXT-STEPS.md) | Current state + what to do next |
| [`docs/PHASE-1-sidecar.md`](./docs/PHASE-1-sidecar.md) | Sidecar production-ready |
| [`docs/PHASE-2-ios-mvp.md`](./docs/PHASE-2-ios-mvp.md) | iOS app MVP |
| [`docs/PHASE-3-ios-augmentation.md`](./docs/PHASE-3-ios-augmentation.md) | iOS polish features |
| [`docs/SYNC.md`](./docs/SYNC.md) | Multi-agent coordination |
| [`docs/reference/SPEC-ios-app.md`](./docs/reference/SPEC-ios-app.md) | Full feature spec (v3) |
---
## Running pi-remote as a sidecar (Phase 1)
All Phase 1 sidecar modules are implemented. See the full **[Operator Guide](docs/reference/OPERATOR.md)** for details.
### Quick start
```bash
# Start pi with the sidecar auto-enabled
pi -e extensions/remote-control --remote-control
# → Remote-control started: http://127.0.0.1:7777/?token=<TOKEN>
# Create a tmux session
curl -s "http://127.0.0.1:7777/sessions?token=<TOKEN>" \
-X POST -H 'Content-Type: application/json' -d '{"name":"work"}'
# Stream output via wscat
npm install -g wscat
wscat -c "ws://127.0.0.1:7777/sessions/work/stream?token=<TOKEN>"
# → send: {"type":"resume","lastSeq":null}
# Send a keystroke
curl -s "http://127.0.0.1:7777/sessions/work/input?token=<TOKEN>" \
-X POST -H 'Content-Type: application/json' -d '{"type":"keys","data":"ls"}'
# Pair the iOS app (generates QR code)
node extensions/remote-control/cli/index.js pair
```
### What's implemented
| Module | Files |
|---|---|
| Server scaffold | `server/server.ts`, `server/upgrade.ts`, `server/types.ts` |
| tmux control-mode | `tmux/manager.ts`, `tmux/control.ts`, `tmux/input.ts`, `tmux/snapshot.ts` |
| Disk ring-buffer | `buffer/writer.ts`, `buffer/reader.ts`, `sequence.ts` |
| Auth + pairing + TLS | `auth/tokens.ts`, `auth/pairing.ts`, `auth/tls.ts` |
| pi adapter | `pi/events.ts`, `pi/commands.ts`, `pi/autoname.ts` |
| REST routes | `server/routes/{sessions,commands,health}.ts` |
| WS routes | `server/routes/{stream,side}.ts`, `server/upgrade.ts` |
| APNs scaffold | `apns/push.ts` |
| CLI | `cli/index.ts` (`pair`, `auth list/create/revoke/name`) |
---
## Security notes ## Security notes
- The server only listens on localhost. Remote access depends on whatever local tunnel or proxy you configure. - The server only listens on localhost. Remote access depends on your tunnel.
- There is no multi-user authentication. Treat the connection URL as a secret for the lifetime of the session. - No multi-user authentication. The connection URL is a per-session secret.
- If you use a reverse proxy instead of Surge Ponte, configure it to terminate TLS at a fixed `https://` endpoint and forward to the server's dynamic backend port. Do not expose the dynamic port directly over a public network, as the server does not support HTTPS and any token or session cookie would be transmitted in cleartext. - **iOS app** (in development) uses bearer tokens stored in the iOS Keychain,
self-signed TLS with fingerprint pinning via QR pairing, and optional
Face-ID gate — no CA or public PKI required.
- If using a reverse proxy, terminate TLS there and do not expose the
dynamic backend port directly.

96
docs/NEXT-STEPS.md Normal file
View File

@ -0,0 +1,96 @@
# Next Steps — Resume Pointer
> **Last updated:** 2026-05-17.
> **Where we are:** Phase 2 mostly done. T-2.0..T-2.6, T-2.8, T-2.9, T-2.10, T-2.11 on main. App runs end-to-end on iPhone 12 mini + simulator. 130 unit tests (8 pre-existing failures), 12/12 UI tests green.
> **Where we go next:** T-2.7 PreConnectPool, T-2.12 TestFlight, T-2.13 MVP smoke. Plus: fix 8 pre-existing unit-test failures (4 Keychain entitlements + 4 Pairing http-scheme).
This document is the "where did I leave off" anchor. Read this first when
resuming work. The rest of `docs/` is reference.
---
## State at end of session
| Item | Status |
|---|---|
| Phase 0 — Stream Spike | ✅ done. Verdict GREEN with caveat (pipe-pane unreliable). Branch `feat/spike-stream` kept. |
| Phase 0.5 — Control-Mode Spike | ✅ done. Verdict: **Path B — tmux control mode**. |
| Phase 1 plan | ✅ updated to Path B. |
| Interface Contracts (IC-1..IC-4) | ✅ **frozen** 2026-05-15. |
| Phase 1 implementation | ✅ **done** 2026-05-15. All T-1.0..T-1.10 on main. Smoke 12/12. |
| Phase 1 — follow-up fixes | ✅ POST /pair smoke test, WS keys/key/paste handler, POST /sessions response shape. |
| Phase 2 iOS — T-2.0 scaffold | ✅ done. |
| Phase 2 iOS — T-2.1..T-2.5, T-2.9 core | ✅ done. WebSocket, Pairing, Terminal, ModifierBar, SessionConnection, APNs. |
| Phase 2 iOS — T-2.6 SessionSwitcher | ✅ **done** 2026-05-16. SessionRegistry + SessionSwitcher + SessionRow. |
| Phase 2 iOS — T-2.7 PreConnectPool | ⛔ not started. |
| Phase 2 iOS — T-2.8 StatusBar | ✅ **done** 2026-05-16. State indicator, action buttons. |
| Phase 2 iOS — T-2.10 Background lifecycle | ✅ **done** 2026-05-17. AppState.lifecycleTransitions publisher, SessionConnection suspend/resume w/ ResumeCursor, stale-frame freeze, post-Face-ID reconnect. 22 lifecycle tests + 6 follow-up. TDD pattern: tests → impl → review → fixup (B-1 + 3 nits + 4 coverage gaps). |
| Phase 2 iOS — T-2.11 Face-ID + Settings | ✅ **done** 2026-05-16. SettingsView, FaceIDGate, LockView. |
| Phase 2 iOS — T-2.12 TestFlight | ⛔ not started. Needs Apple credentials. |
| Phase 2 iOS — T-2.13 MVP smoke | ⛔ not started. |
| Phase 2 iOS — XCUITest infrastructure (bonus) | ✅ **done** 2026-05-16. piRemoteUITests target, 8/8 tests, `--uitest` mode, accessibility IDs, idb workflow documented in `docs/SIMULATOR-AUTOMATION.md` (iOS repo). |
| Bug Backlog | ⚠️ 8 pre-existing unit-test failures (4 Keychain `-34018` entitlements + 4 Pairing http-scheme tests). Not regressions, undocumented. |
Branches on remote `git.vpsj.de/jay/pi-remote-control`:
- `main` — all Phase 1 + follow-up fixes (latest: POST /sessions shape).
- `feat/spike-stream` — Phase 0 PoC, throwaway.
- `feat/spike-tmux-cc` — Phase 0.5 PoC, throwaway. Reference impl for T-1.1.
Branches on remote `git.vpsj.de/jay/pi-remote-ios`:
- `main` — T-2.0..T-2.6, T-2.8, T-2.9, T-2.11 + UI test target + idb docs.
- legacy `feat/p2-t2.*` branches kept for history; all already on main via merge commits.
---
## Open work (in priority order)
### Phase 2 remaining
- **T-2.7 PreConnectPool.** Hot WS per known session + cached last frame;
switching shows the cached frame instantly with a "syncing…" pill.
Touches: `Sources/Core/Sessions/PreConnectPool.swift` (new),
`Sources/Core/Sessions/SessionConnection.swift`. Now safe to schedule
after T-2.10's lifecycle hooks landed.
- **T-2.12 TestFlight pipeline.** Build / archive / upload scripts +
internal testers list. Verify production APNs path. Needs Apple
Developer credentials (paid program). Touches: `docs/DISTRIBUTION.md`,
`scripts/` or Fastfile.
- **T-2.13 MVP smoke.** Manual on-device checklist: pair → render → input
→ backgrounded → push → reopen <1s session-switch. Documents in
`docs/PHASE-2-report.md` (iOS repo).
### Bug backlog (not regressions, pre-existing)
- **Keychain unit tests fail with `-34018` errSecMissingEntitlement** (4 cases).
Likely needs Keychain-Access-Group in the test target's entitlements,
or running under signed bundle. Investigate or skip with a documented reason.
- **Pairing unit tests `testParseQR_{http,https,wrongScheme}_throws` fail**
(4 cases). Either the parser silently accepts http/https now (regression
hidden by the `fp` optional change) or the tests need updating to match
the new lenient behaviour.
### Phase 3 — not yet in scope
Slash palette, voice, themes, search, etc. See `docs/PHASE-3-ios-augmentation.md`.
---
## Reading order for resumption
1. `docs/NEXT-STEPS.md` (this file)
2. `docs/SYNC.md` — current claims, gate, contracts
3. `docs/PHASE-2-ios-mvp.md` — what's left to build for iOS
4. iOS repo: `docs/SIMULATOR-AUTOMATION.md` — how to drive the sim
5. iOS repo: `docs/BUILD.md` — build + install + launch commands
6. Spec only if you forget the why: `docs/reference/SPEC-ios-app.md`
---
## Conventions established
- **Async subagent dispatch.** All worker tasks via `subagent({ async: true, context: "fresh" })`. Worktree isolation for parallel-on-same-repo work; same-repo iOS+sidecar splits are inherently isolated.
- **Reviewer fan-in.** After parallel implementation, dispatch one reviewer agent with all branch summaries pre-loaded; reviewer writes `review.md` for the orchestrator to apply.
- **Sim test infra (`--uitest` mode).** Pre-fetch a fresh `/pair-qr` token per test, launch with `--reset-state --pair-with-url <url>`. `MainTerminalView` in `--uitest` mode skips the WS so XCUITest can reach app-idle within the 120 s window. Pasteboard prompt suppressed via `xcrun simctl privacy grant pasteboard de.vpsj.pi-remote`.
- **Sidecar manual restart pattern.** `tmux kill-session -t pi-sidecar; tmux new-session -d -s pi-sidecar -x 220 -y 50 "pi -nt -ne -ns -np -nc --no-session --offline -e extensions/remote-control --remote-control"`. Pre-fill 3 sessions (`main`, `work`, `logs`) for any manual E2E test.
- Don't expand scope. Stick to v3 spec; new ideas go into a v4 review
round, not into open PRs.

View File

@ -6,6 +6,13 @@
> stream (see `SYNC.md`). > stream (see `SYNC.md`).
> **Spec reference:** [`reference/SPEC-ios-app.md`](./reference/SPEC-ios-app.md) §4. > **Spec reference:** [`reference/SPEC-ios-app.md`](./reference/SPEC-ios-app.md) §4.
> **Streaming primitive (decided in Phase 0.5):** tmux **control mode**
> (`tmux -C attach`). Pane output is delivered via parsed `%output`
> events, which is robust across alternate-screen transitions — unlike
> `pipe-pane` (which Phase 0 found unreliable). See
> `reference/PHASE-0.5-report.md` and the spike code in branch
> `feat/spike-tmux-cc`.
## Goal ## Goal
The `pi-remote-control` extension is extended into a full sidecar that can The `pi-remote-control` extension is extended into a full sidecar that can
@ -54,10 +61,10 @@ extensions/remote-control/
│ │ ├── side.ts — S-07 state side-channel │ │ ├── side.ts — S-07 state side-channel
│ │ └── health.ts — S-12 health │ │ └── health.ts — S-12 health
│ └── upgrade.ts — WS upgrade routing per session/topic │ └── upgrade.ts — WS upgrade routing per session/topic
├── tmux/ — NEW: tmux wrapper ├── tmux/ — NEW: tmux wrapper (control-mode client)
│ ├── manager.ts — spawn/list/kill, metadata via @options │ ├── manager.ts — spawn/list/kill sessions, metadata via @options
│ ├── pipe.ts — pipe-pane, FIFO read, byte streaming │ ├── control.ts — `tmux -C` control-mode client, %output parser, byte streaming
│ ├── input.ts — send-keys translation │ ├── input.ts — send-keys translation (key names → tmux send-keys)
│ └── snapshot.ts — capture-pane wrapper │ └── snapshot.ts — capture-pane wrapper
├── buffer/ — NEW: disk ringbuffer per session ├── buffer/ — NEW: disk ringbuffer per session
│ ├── writer.ts — append, cap enforcement, watchdog │ ├── writer.ts — append, cap enforcement, watchdog
@ -88,7 +95,7 @@ column lists the files an agent may modify.
| ID | Task | Touches | Depends on | Parallel With | | ID | Task | Touches | Depends on | Parallel With |
|---|---|---|---|---| |---|---|---|---|---|
| T-1.0 | **Server refactor scaffold.** Carve `server.ts` into the `server/` and route modules above; existing HTML behaviour must still work; CI green. | `extensions/remote-control/server/**`, minimal edit of `index.ts` | — | none — must land first | | T-1.0 | **Server refactor scaffold.** Carve `server.ts` into the `server/` and route modules above; existing HTML behaviour must still work; CI green. | `extensions/remote-control/server/**`, minimal edit of `index.ts` | — | none — must land first |
| T-1.1 | **tmux/manager + tmux/pipe + tmux/snapshot.** Spawn, list, kill, metadata via `@description`. Pipe-pane FIFO reader. Snapshot via `capture-pane`. | `tmux/**` | T-1.0 | T-1.2, T-1.3, T-1.4, T-1.5, T-1.6 | | T-1.1 | **tmux/manager + tmux/control + tmux/snapshot.** Spawn, list, kill, metadata via `@description`. **Control-mode client** (`tmux -C attach`), `%output` parser with octal-escape decoder, broadcast bytes to subscribers. Snapshot via `capture-pane`. Reference: `feat/spike-tmux-cc` branch (`spike-cc.ts`). | `tmux/**` | T-1.0 | T-1.2, T-1.3, T-1.4, T-1.5, T-1.6 |
| T-1.2 | **Sequence module + buffer/writer + buffer/reader.** Monotone chunk IDs, disk ringbuffer with caps (100MB/session, 1GB global, free-space watchdog), idle-cleanup. | `sequence.ts`, `buffer/**` | T-1.0 | T-1.1, T-1.3, T-1.4, T-1.5, T-1.6 | | T-1.2 | **Sequence module + buffer/writer + buffer/reader.** Monotone chunk IDs, disk ringbuffer with caps (100MB/session, 1GB global, free-space watchdog), idle-cleanup. | `sequence.ts`, `buffer/**` | T-1.0 | T-1.1, T-1.3, T-1.4, T-1.5, T-1.6 |
| T-1.3 | **Auth: tokens + pairing + TLS.** Self-signed cert generation, fingerprint, bearer-token CRUD, `pi-remote pair` CLI + QR rendering, `pi-remote auth list/revoke/name`. | `auth/**`, `cli/index.ts` (subcommands only) | T-1.0 | T-1.1, T-1.2, T-1.4, T-1.5, T-1.6 | | T-1.3 | **Auth: tokens + pairing + TLS.** Self-signed cert generation, fingerprint, bearer-token CRUD, `pi-remote pair` CLI + QR rendering, `pi-remote auth list/revoke/name`. | `auth/**`, `cli/index.ts` (subcommands only) | T-1.0 | T-1.1, T-1.2, T-1.4, T-1.5, T-1.6 |
| T-1.4 | **pi adapter.** Subscribe ExtensionAPI events, expose `getCommands`, implement `autoname.ts` spawning `pi -p`. | `pi/**`, edits in `index.ts` to wire subscriptions | T-1.0 | T-1.1, T-1.2, T-1.3, T-1.5, T-1.6 | | T-1.4 | **pi adapter.** Subscribe ExtensionAPI events, expose `getCommands`, implement `autoname.ts` spawning `pi -p`. | `pi/**`, edits in `index.ts` to wire subscriptions | T-1.0 | T-1.1, T-1.2, T-1.3, T-1.5, T-1.6 |
@ -120,7 +127,8 @@ type ClientToServer =
type ServerToClient = type ServerToClient =
| { type: "state"; value: "thinking" | "tool" | "idle" | "awaiting-input"; tool?: string; ts: number } | { type: "state"; value: "thinking" | "tool" | "idle" | "awaiting-input"; tool?: string; ts: number }
| { type: "tree"; nodes: TreeNode[]; current: string } // optional, read-only // tree event dropped — out of iOS scope. Revisit if a dashboard wants it.
// resize ClientToServer deferred — fixed 120×40 for v1.
| { type: "snapshot"; seq: number; data: string } // base64 ANSI snapshot | { type: "snapshot"; seq: number; data: string } // base64 ANSI snapshot
| { type: "session-meta"; name: string; description?: string; createdAt: string } | { type: "session-meta"; name: string; description?: string; createdAt: string }
| { type: "error"; code: string; message: string }; | { type: "error"; code: string; message: string };
@ -152,7 +160,9 @@ QR encodes a `pi-remote://` URL:
pi-remote://<host>:<port>?pair=<pairing-token>&fp=<sha256-hex>&name=<sidecar-name> pi-remote://<host>:<port>?pair=<pairing-token>&fp=<sha256-hex>&name=<sidecar-name>
``` ```
Pairing exchange: client `POST /pair` with `{ pairingToken, deviceToken?, environment?, deviceName? }` → server replies `{ bearerToken, sidecarId }`. Owner: T-1.3. Pairing exchange: client `POST /pair` with `{ pairingToken, deviceToken?, environment?, deviceName? }` → server replies `{ bearerToken, sidecarId }`.
`deviceToken` and `environment` are **optional** pre-Phase-2, **mandatory** from Phase 2 onward. Owner: T-1.3.
### IC-4 — Config schema (TOML) ### IC-4 — Config schema (TOML)
@ -212,6 +222,15 @@ Owner: T-1.7.
`pi/events.ts`. `pi/events.ts`.
- **R3.** `pi -p` auto-name calls cost money. Mitigation: gate behind - **R3.** `pi -p` auto-name calls cost money. Mitigation: gate behind
`[autoname] enabled`, debounce, skip if user already named the session. `[autoname] enabled`, debounce, skip if user already named the session.
- **R4.** tmux control-mode protocol is text-framed; binary pane bytes are
octal-escaped (`\NNN`). Parser must handle high-throughput bursts (~50fps
during tool output). Mitigation: streaming line-parser with no full-buffer
copies; per-line decode allocates only the escaped payload. Reference
decode in `spike-cc.ts`.
- **R5.** tmux version requirement. Control mode is stable from tmux 2.0;
modern features (e.g. `pane-died` event) need 2.5+. Mitigation:
`tmux/manager.ts` checks `tmux -V` at startup, refuses to run on < 2.5
with a clear error.
## Exit / Handover ## Exit / Handover

View File

@ -4,8 +4,11 @@ This folder drives the implementation work for the pi-remote iOS app and
its sidecar. Background / spec / audit material lives in its sidecar. Background / spec / audit material lives in
[`reference/`](./reference/). [`reference/`](./reference/).
**Start here when resuming work:** [`NEXT-STEPS.md`](./NEXT-STEPS.md).
| File | Purpose | | File | Purpose |
|---|---| |---|---|
| [`NEXT-STEPS.md`](./NEXT-STEPS.md) | Resume pointer — state of play, next orchestrator actions, open questions. Read first. |
| [`PHASE-0-spike-stream.md`](./PHASE-0-spike-stream.md) | Stream PoC — verify tmux + pipe-pane + WebSocket. ~1 day, single agent. **Done**, see `reference/PHASE-0-report.md`. | | [`PHASE-0-spike-stream.md`](./PHASE-0-spike-stream.md) | Stream PoC — verify tmux + pipe-pane + WebSocket. ~1 day, single agent. **Done**, see `reference/PHASE-0-report.md`. |
| [`PHASE-0.5-spike-tmux-control-mode.md`](./PHASE-0.5-spike-tmux-control-mode.md) | Follow-up spike: tmux control mode (`-CC`) vs pipe-pane + watchdog. Decides Phase 1 streaming path. ~2h, single agent. | | [`PHASE-0.5-spike-tmux-control-mode.md`](./PHASE-0.5-spike-tmux-control-mode.md) | Follow-up spike: tmux control mode (`-CC`) vs pipe-pane + watchdog. Decides Phase 1 streaming path. ~2h, single agent. |
| [`PHASE-1-sidecar.md`](./PHASE-1-sidecar.md) | Sidecar production-ready: all S-features, multi-agent parallel work. Blocked on Phase 0.5. | | [`PHASE-1-sidecar.md`](./PHASE-1-sidecar.md) | Sidecar production-ready: all S-features, multi-agent parallel work. Blocked on Phase 0.5. |

View File

@ -37,9 +37,9 @@ The point: no central scheduler is required. A short structured edit on
| Phase | Status | Notes | | Phase | Status | Notes |
|---|---|---| |---|---|---|
| Phase 0 — Spike Stream | done | ✅ GREEN LIGHT with caveat: pipe-pane unreliable. See `reference/PHASE-0-report.md`. | | Phase 0 — Spike Stream | done | ✅ GREEN LIGHT with caveat: pipe-pane unreliable. See `reference/PHASE-0-report.md`. |
| Phase 0.5 — Spike tmux Control Mode | ready to start | Decides streaming path before Phase 1. See `PHASE-0.5-spike-tmux-control-mode.md`. | | Phase 0.5 — Spike tmux Control Mode | done | ✅ VERDICT: Path B (control mode) recommended. See `reference/PHASE-0.5-report.md`. |
| Phase 1 — Sidecar | blocked on Phase 0.5 | Streaming path must be decided before T-1.1 starts. | | Phase 1 — Sidecar | **done** | All T-1.0..T-1.10 implemented. Smoke 22/22 green (incl. POST /pair). Follow-up fixes on main: WS keys handler, POST /sessions shape, control-mode encoding. |
| Phase 2 — iOS MVP | blocked on Phase 1 | Sidecar must be reachable and stable. | | Phase 2 — iOS MVP | **in progress** | T-2.0..T-2.6, T-2.8, T-2.9, T-2.10, T-2.11 on main. Bonus: XCUITest target + idb automation docs. Open: T-2.7, T-2.12, T-2.13. Repo: `pi-remote-ios`. |
| Phase 3 — iOS Augmentation | blocked on Phase 2 | Continuous after MVP ships. | | Phase 3 — iOS Augmentation | blocked on Phase 2 | Continuous after MVP ships. |
Update the **Status** column when a phase transitions. Allowed states: Update the **Status** column when a phase transitions. Allowed states:
@ -51,7 +51,7 @@ Update the **Status** column when a phase transitions. Allowed states:
| Task | Branch | Owner | Claimed at | ETA | Notes | | Task | Branch | Owner | Claimed at | ETA | Notes |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| T-0.5 | feat/spike-tmux-cc | @worker-phase0.5 | 2026-05-15 | +3h | Evaluating tmux control mode | | _(none)_ | | | | | |
Example of a filled row: Example of a filled row:
``` ```
@ -78,7 +78,7 @@ add a row or open a Contract Change Request.
| `extensions/remote-control/index.ts` | T-1.0, T-1.4 (events wiring only) | | `extensions/remote-control/index.ts` | T-1.0, T-1.4 (events wiring only) |
| `extensions/remote-control/server.ts` (legacy) | nobody after T-1.0; legacy frozen | | `extensions/remote-control/server.ts` (legacy) | nobody after T-1.0; legacy frozen |
| `extensions/remote-control/server/**` | T-1.0 (refactor), T-1.5, T-1.6, T-1.7 | | `extensions/remote-control/server/**` | T-1.0 (refactor), T-1.5, T-1.6, T-1.7 |
| `extensions/remote-control/tmux/**` | T-1.1 | | `extensions/remote-control/tmux/**` | T-1.1 (control mode, not pipe-pane — see Phase 0.5) |
| `extensions/remote-control/buffer/**` | T-1.2 | | `extensions/remote-control/buffer/**` | T-1.2 |
| `extensions/remote-control/sequence.ts` | T-1.2 | | `extensions/remote-control/sequence.ts` | T-1.2 |
| `extensions/remote-control/auth/**` | T-1.3, T-1.10 (device tokens only) | | `extensions/remote-control/auth/**` | T-1.3, T-1.10 (device tokens only) |
@ -86,6 +86,7 @@ add a row or open a Contract Change Request.
| `extensions/remote-control/apns/**` | T-1.10, Phase-2 T-2.9 (when iOS supplies tokens) | | `extensions/remote-control/apns/**` | T-1.10, Phase-2 T-2.9 (when iOS supplies tokens) |
| `extensions/remote-control/cli/**` | T-1.3, T-1.7 | | `extensions/remote-control/cli/**` | T-1.3, T-1.7 |
| `extensions/remote-control/config.ts` | T-1.7 | | `extensions/remote-control/config.ts` | T-1.7 |
| `scripts/smoke/**` | T-1.0a (harness), any T-1.* task (add new test files) |
| `docs/SYNC.md` | all (this file) | | `docs/SYNC.md` | all (this file) |
| `docs/PHASE-*.md` | nobody once a phase has started (frozen plan) — open a CCR to amend | | `docs/PHASE-*.md` | nobody once a phase has started (frozen plan) — open a CCR to amend |
| `docs/reference/**` | nobody during implementation — archival | | `docs/reference/**` | nobody during implementation — archival |
@ -97,13 +98,18 @@ Phase 2 kicks off.
## Frozen Interface Contracts ## Frozen Interface Contracts
| ID | Defined in | Owner of changes | | ID | Defined in | Status | Frozen at | Owner of changes |
|---|---|---| |---|---|---|---|---|
| IC-1 — WebSocket frame protocol | `PHASE-1-sidecar.md` §Interface Contracts | T-1.5 lead, with sign-off from any active T-2.x owner | | IC-1 — WebSocket frame protocol | `PHASE-1-sidecar.md` §Interface Contracts | **frozen** | 2026-05-15 | T-1.5 lead, with sign-off from any active T-2.x owner |
| IC-2 — HTTP REST shape | `PHASE-1-sidecar.md` §Interface Contracts | T-1.5..T-1.7 leads | | IC-2 — HTTP REST shape | `PHASE-1-sidecar.md` §Interface Contracts | **frozen** | 2026-05-15 | T-1.5..T-1.7 leads |
| IC-3 — Pairing payload | `PHASE-1-sidecar.md` §Interface Contracts | T-1.3 lead | | IC-3 — Pairing payload | `PHASE-1-sidecar.md` §Interface Contracts | **frozen** | 2026-05-15 | T-1.3 lead |
| IC-4 — Config TOML schema | `PHASE-1-sidecar.md` §Interface Contracts | T-1.7 lead | | IC-4 — Config TOML schema | `PHASE-1-sidecar.md` §Interface Contracts | **frozen** | 2026-05-15 | T-1.7 lead |
| IC-2.1 — `SessionConnection` Swift surface | `PHASE-2-ios-mvp.md` §Interface Contracts | T-2.5 lead | | IC-2.1 — `SessionConnection` Swift surface | `PHASE-2-ios-mvp.md` §Interface Contracts | **draft** — freeze at Phase 2 kickoff | — | T-2.5 lead |
**Freeze protocol:** when the orchestrator is ready to fan out work that
depends on a contract, they (a) re-read the contract spec, (b) update the
Status column to `frozen` and fill in the `Frozen at` date, (c) commit
that change to main. After that, modifications require a CCR.
Once a contract is *frozen* (i.e. at least one consumer task has started Once a contract is *frozen* (i.e. at least one consumer task has started
work that depends on it), changes require a CCR. work that depends on it), changes require a CCR.
@ -153,4 +159,9 @@ yyyy-mm-dd @handle T-x.y what was done
``` ```
2026-05-15 @worker-phase0 T-0.* Phase 0 spike complete. tmux+pipe-pane PoC validated. GREEN LIGHT for Phase 1. Report: reference/PHASE-0-report.md. Branch: feat/spike-stream (kept for reference, not merged). 2026-05-15 @worker-phase0 T-0.* Phase 0 spike complete. tmux+pipe-pane PoC validated. GREEN LIGHT for Phase 1. Report: reference/PHASE-0-report.md. Branch: feat/spike-stream (kept for reference, not merged).
2026-05-15 @worker-phase0.5 T-0.5 Phase 0.5 spike complete. tmux control mode validated. VERDICT: Path B recommended. Report: reference/PHASE-0.5-report.md. Branch: feat/spike-tmux-cc (kept for reference, not merged).
2026-05-15 @worker-t1.0 T-1.0 Server refactor scaffold complete. server.ts carved into server/{types,server,upgrade}.ts + server/routes/ stub. LEGACY html path preserved end-to-end. Reviewer APPROVE. Branch: feat/p1-t1-0-server-refactor (merged).
2026-05-15 @worker-t1.0a T-1.0a Smoke test harness MVP complete. scripts/smoke/ with helpers.mjs + smoke.mjs. 6 tests: manifest, icon, GET/ auth+unauth, WS upgrade, process-alive. All green in ~1.4s. Key finding: pi requires PTY; workaround via python3 pty.spawn(). Branch: feat/p1-t1-0a-smoke (merged).
2026-05-15 @jay T-1.1..T-1.10 Phase 1 complete. All modules implemented sequentially on single branch: tmux control-mode client (fix: -C not -CC), disk ring-buffer, auth/pairing/TLS, pi adapter, stream+input+sessions+commands+side+health routes, APNs scaffold. T-1.8 stream integration smoke 12/12 green. T-1.9 operator guide written. Two production bugs fixed: base-index issue (send-keys target was :0.0, now session-only) and -CC→-C flag in control mode.
2026-05-15 @jay T-2.0..T-2.5,T-2.9 Phase 2 started. Repo pi-remote-ios created. T-2.0 Xcode scaffold (de.vpsj.pi-remote, team KNXX8R3648, SwiftTerm+Starscream). T-2.1 WebSocketClient+FrameCodec+ResumeCursor (IC-1). T-2.2 Pairing+Keychain+QRScanner (IC-3). T-2.3 TerminalView+ThemeStore+FontStore. T-2.4 ModifierBar+ModifierState+PasteSheet. T-2.5 SessionConnection (IC-2.1)+ScrollbackCache. T-2.9 APNs NotificationDelegate+DeviceTokenRegistrar. 111 unit tests. App boots on device (iOS 26, wireless, Xcode 16.4 Intel). APNs key: ~/.local/share/pi-remote/apns/AuthKey_285C2X4689.p8.
``` ```

216
docs/reference/OPERATOR.md Normal file
View File

@ -0,0 +1,216 @@
# Operator Guide — pi-remote-control sidecar
> **Phase 1 state:** all sidecar modules implemented (T-1.0..T-1.10).
> The iOS app (Phase 2) is not yet built; use `wscat` or the legacy
> browser HTML UI to verify a running sidecar.
---
## Requirements
| Dependency | Minimum | Notes |
|---|---|---|
| Node.js | 18 | Must be on PATH (pi's runtime) |
| tmux | 2.5 | Control mode + `pane-died` events |
| openssl | any | Self-signed TLS cert generation |
| Python 3 | any | Smoke-test PTY workaround only |
---
## Quick start
```bash
# 1. Start pi with remote-control and auto-start the sidecar
pi -e extensions/remote-control --remote-control
# The sidecar prints its URL:
# Remote-control started: http://127.0.0.1:7777/?token=<TOKEN>
```
The server binds to `127.0.0.1:7777` by default (configurable — see below).
---
## Configuration
Create `~/.pi/remote-control/config.json` to override defaults:
```json
{
"bindAddress": "0.0.0.0:7777",
"publicBaseUrl": "https://pi.example.com"
}
```
IC-4 (config.toml, Phase 1.7 wiring) will migrate this to TOML. For now,
use the JSON format above.
**Key fields:**
| Field | Default | Description |
|---|---|---|
| `bindAddress` | `127.0.0.1:0` | Host:port to bind (`:0` = random free port) |
| `publicBaseUrl` | (none) | URL printed in QR / UI — use your tunnel URL |
---
## API reference (IC-2)
All endpoints require bearer token auth. Pass via:
- `?token=<TOKEN>` query param (same token as the startup URL)
- `Authorization: Bearer <TOKEN>` header
### REST endpoints
```
GET /health
→ { ok, uptime, sessions, sessionIds, bufferMb, diskFreeGb, warnings }
POST /sessions
body: { name?: string }
→ 201 { id, name }
GET /sessions
→ [{ id, name, description, state, lastOutputAt }]
PATCH /sessions/:id
body: { description?: string }
→ 200 { id, description }
DELETE /sessions/:id
→ 204 (also deletes session buffer)
GET /sessions/:id/commands
→ [{ name, description, args? }]
GET /sessions/:id/thumbnail
→ text/plain, 40×12 capture-pane snapshot
POST /sessions/:id/input
body: { type: "key"|"keys"|"paste", name?:string, data?:string }
→ 204
```
### WebSocket endpoints
```
WS /sessions/:id/stream
Client→Server (JSON text frames):
{ type:"resume"; lastSeq: number|null } — connect/reconnect
{ type:"key"; name: string } — single named key
{ type:"keys"; data: string } — literal text
{ type:"paste"; data: string } — bracketed-paste
{ type:"snapshot-request" } — request a snapshot
Server→Client (binary frames):
[seq: 8 bytes BE uint64][raw ANSI bytes]
Server→Client (JSON text frames):
{ type:"state"; value:"thinking"|"tool"|"idle"|"awaiting-input"; tool?:string; ts:number }
{ type:"snapshot"; seq:number; data:string } — base64 ANSI snapshot
{ type:"session-meta"; name:string; description?:string; createdAt:string }
{ type:"error"; code:string; message:string }
WS /sessions/:id/side
State-only side-channel (no binary output).
Server→Client: same JSON frames as /stream (state, session-meta, error).
Client→Server: (none expected)
```
---
## Pairing (iOS / CLI)
```bash
# Generate a QR code to pair the iOS app
node extensions/remote-control/cli/index.js pair
# List bearer tokens
node extensions/remote-control/cli/index.js auth list
# Create a named token
node extensions/remote-control/cli/index.js auth create "Jay's iPhone"
# Revoke a token
node extensions/remote-control/cli/index.js auth revoke <id>
```
The QR encodes a `pi-remote://` URL (IC-3) containing host, port, pairing
token, TLS fingerprint, and sidecar name. The iOS app scans it and calls
`POST /pair` to exchange a bearer token.
---
## TLS
Self-signed cert is generated on first run at:
```
~/.local/share/pi-remote/tls/cert.pem
~/.local/share/pi-remote/tls/key.pem
```
The SHA-256 fingerprint is included in the QR code. The iOS app pins to
this fingerprint — no CA needed.
> **Note:** T-1.3 implements cert generation. T-1.5 wiring TLS into the
> HTTP server is a Phase 2 task (server currently runs plain HTTP;
> terminate TLS at your reverse proxy for now).
---
## Disk buffer
Each session's output is written to:
```
~/.local/share/pi-remote/buffers/<session-id>.buf
```
Caps (defaults, configurable via IC-4 TOML in T-1.7):
- Per-session: 100 MB
- Global: 1 GB
- Minimum free disk: 1 GB (writes suspended below this)
- Idle cleanup: sessions inactive > 30 days are deleted on startup
---
## Smoke tests
```bash
# Basic smoke (server start, HTML, manifest, icon, WS)
npm run smoke
# Stream integration (session CRUD, send-keys, reconnect, thumbnail)
npm run smoke:stream
# Both
npm run smoke:all
```
Requires `python3` on PATH (PTY workaround for pi's TUI requirement).
---
## Troubleshooting
**Server doesn't start / port conflict**
Set `bindAddress` in config.json to a free port.
**`tmux >= 2.5 required`**
Upgrade tmux: `brew upgrade tmux` (macOS) or `apt upgrade tmux`.
**`can't find window`**
Older code bug: was using hardcoded `:0.0` pane targets, which fails if
tmux `base-index` is 1. Fixed in T-1.1 — target is now just the session name.
**Marker not appearing in stream**
The ControlClient uses `-C` (not `-CC`) for control mode. If you see no
`%output` events, check tmux version and that `-C` works:
```bash
tmux -C attach -t <session>
# Should print: %begin ... %end ... %output ...
```
**APNs push not working**
APNs is scaffolded (T-1.10) but device tokens are only available once the
iOS app pairs in Phase 2. Check `[apns]` config and that `.p8` key path
is correct.

View File

@ -1,339 +0,0 @@
# Phase 0.5 Report — tmux Control Mode Spike
> **Date:** 2026-05-15
> **Branch:** `feat/spike-tmux-cc`
> **Author:** @worker-phase0.5
> **Duration:** ~2.5 hours
> **Verdict:** ✅ **Path B — tmux Control Mode is RECOMMENDED**
---
## Executive Summary
tmux control mode (`tmux -CC`) **successfully solves the pipe-pane reliability issue** discovered in Phase 0. The spike demonstrates that control mode:
1. **Reliably delivers pane output** across alternate-screen transitions (the Phase 0 failure trigger)
2. **Maintains acceptable latency** comparable to pipe-pane
3. **Allows parallel SSH attach** without interference (P-2 critical requirement verified)
4. **Requires a straightforward parser** (~200 lines for production-quality implementation)
**Recommendation:** Proceed with **Path B (tmux control mode)** for Phase 1 Task T-1.1 (tmux/pipe.ts).
---
## Test Environment
- **tmux version:** 3.6a (modern, stable)
- **pi binary:** `/usr/local/bin/pi` (global install)
- **Test duration:** 5 minutes (300 seconds) as specified
- **Test platform:** macOS (POSIX-compliant, representative of target deployment)
---
## Acceptance Criteria — Detailed Answers
### R-CC-1. Does control mode deliver pane output reliably across alternate-screen transitions?
**✅ YES — PASS**
**Evidence:**
- Test session ran for 5 minutes with deliberate alternate-screen trigger (`/settings`)
- Total output events: **462**
- Total bytes received: **179,641 bytes** (175 KB)
- **No disconnection** or stream freeze observed
- Output events counter increased continuously throughout test (35 → 462)
**Alternate-Screen Test Sequence:**
1. Sent `/settings` command (enters alternate screen)
2. Navigated with arrow keys (Down, Down, Up)
3. Exited with Escape key (exits alternate screen)
4. Verified continued streaming with test prompts
**Result:** Unlike pipe-pane (which silently detaches after alternate-screen use), control mode **maintained the connection** and continued delivering output events without interruption.
**Stats timeline (sample):**
```
@ 10s: Output events: 35
@ 60s: Output events: 169
@ 120s: Output events: 341
@ 180s: Output events: 381
@ 240s: Output events: 381
@ 300s: Output events: 462
```
The stream remained active for the full duration. Event count increases correspond to pi activity (startup, idle waiting, test commands).
---
### R-CC-2. Latency compared to pipe-pane (same order of magnitude)?
**✅ YES — COMPARABLE**
**Rough latency analysis:**
- Control mode processes output events at **~1.5-2 events/second** during active periods
- Each event contains multiple bytes (average ~400 bytes/event based on 179KB ÷ 462 events)
- Events arrive synchronously with pi's terminal updates
**Comparison to Phase 0 pipe-pane:**
- Phase 0 pipe-pane: sub-50ms localhost frames (per Phase 0 report)
- Control mode: Events arrive **immediately** when tmux buffers pane output
- No observable user-perceived lag when visually comparing `tmux attach` vs. control mode output
**Order of magnitude verdict:**
Control mode latency is **same order of magnitude** as pipe-pane. Both deliver output in **real-time** (< 100ms perceived delay). Control mode adds minimal parsing overhead (octal decode) which is negligible compared to network/rendering costs.
---
### R-CC-3. Does parallel `tmux attach` SSH client still work while a control-mode client is connected?
**✅ YES — P-2 CRITICAL REQUIREMENT VERIFIED**
**Test protocol:**
1. Started control mode client (`npm run spike-cc`)
2. Launched `tmux attach -t pi-cc` in parallel (simulates SSH user)
3. Observed both clients active simultaneously
4. Control mode client **did not block** or interfere with attach
**Result:**
- Parallel attach **succeeded** without errors
- Control mode client **continued running** during and after attach
- No `%exit` event triggered by parallel attach
- Both clients can coexist peacefully
**Why this works:**
tmux control mode is designed for this use case. It's a **side-channel observer** — it does not claim exclusive session ownership. Normal tmux clients (SSH users doing `tmux attach`) continue to work as expected. This is exactly how iTerm2's tmux integration operates in production.
---
### R-CC-4. Is the control-mode protocol parser non-trivial? Order of complexity estimate.
**✅ STRAIGHTFORWARD — LOW COMPLEXITY**
**Parser complexity: O(200-300 lines) for production-quality implementation.**
**What the parser needs to do:**
1. **Read stdout line-by-line** from `tmux -C attach`
2. **Parse notification lines** starting with `%`
3. **Extract `%output %<pane-id> <value>`** events
4. **Decode octal escapes** (`\NNN` → bytes)
5. **Ignore other events** (`%layout-change`, `%window-renamed`, etc.)
**Spike implementation:**
- **~200 lines** of TypeScript (including stats, logging, error handling)
- Core parser: **~50 lines** (notification parsing + octal decode)
- Octal decode function: **~20 lines** (straightforward string scan)
**Comparison to alternatives:**
- **Simpler than** a WebSocket protocol parser (which Phase 1 needs anyway)
- **Simpler than** a pipe-pane watchdog with reconnect logic (Path A)
- **Similar complexity to** reading from a UNIX socket
**Production considerations:**
- Add robust error handling for malformed events (< 20 lines)
- Add pane ID filtering if multiple panes exist (< 10 lines)
- Handle `%begin`/`%end` command responses if sending commands (< 30 lines)
**Verdict:** Parser is **not a blocker**. Complexity is manageable and well-documented in `man tmux` CONTROL MODE section.
---
### R-CC-5. Verdict: Path B or Path A for Phase 1?
**✅ RECOMMENDATION: Path B — tmux Control Mode**
**Reasoning:**
| Criterion | Path A (pipe-pane + watchdog) | Path B (control mode) | Winner |
|-----------|-------------------------------|----------------------|--------|
| **Reliability** | Fragile. pipe-pane detaches after alternate-screen. Watchdog can miss bytes between detach and re-arm. | **Robust.** Control mode is designed for this. Used in production by iTerm2. No known detach issues. | **B** |
| **Complexity** | Watchdog: poll `#{pane_pipe}`, detect `0`, re-exec pipe-pane. Race conditions. Lost bytes. ~100 lines. | Parser: read lines, parse `%output`, decode octal. No races. ~200 lines. | **B** (cleaner, no races) |
| **Latency** | Sub-50ms (Phase 0 measured) | Same order of magnitude (spike verified) | **Tie** |
| **Parallel attach** | Works (pipe-pane doesn't block) | **Works (spike verified P-2)** | **Tie** |
| **Production readiness** | Workaround for a tmux quirk. Needs constant monitoring. | **Protocol designed for this use case.** iTerm2 production reference. | **B** |
| **Future-proofing** | Watchdog may break with tmux updates or edge cases. | Protocol is stable (tmux 2.x+). Versioned and documented. | **B** |
**Decision:**
Path B (control mode) is **strictly superior** to Path A. It solves the root cause (reliable event delivery) instead of working around a symptom (pipe-pane detaches).
**Path A fallback scenario:**
If during Phase 1 implementation we discover a **blocker** in control mode (e.g., incompatibility with a specific tmux version, unexpected behavior with send-keys), we can still pivot to Path A. But based on this spike, **no blockers are anticipated**.
---
## Implementation Notes for Phase 1
When implementing `tmux/pipe.ts` (T-1.1) with control mode:
### 1. Session launch
```bash
tmux new-session -d -s <session-name> -x <width> -y <height> 'pi'
tmux -C attach -t <session-name>
```
### 2. Parser structure
- Spawn `tmux -C attach` as child process
- Pipe stdout through `readline` interface
- Parse lines: if starts with `%`, dispatch to notification handler
- Handle `%output %<pane-id> <value>` → decode octal → emit to WebSocket clients
### 3. Event types to handle
- `%output`**required**, primary data stream
- `%exit`**required**, clean shutdown
- `%session-changed` — optional, for multi-session support (out of scope for MVP)
- All others — log and ignore for Phase 1
### 4. Octal decode
```typescript
function decodeOctalEscapes(input: string): Buffer {
// Replace \NNN with byte value
// Example: "hello\\012world" → Buffer("hello\nworld")
}
```
See `spike-cc.ts` for reference implementation.
### 5. Send-keys (for T-1.4)
Control mode supports **sending commands** via stdin:
```
send-keys -t <pane-id> "<text>" Enter
```
Responses come back as `%begin`...`%end` blocks. Ignore response for fire-and-forget send-keys.
### 6. Error handling
- If tmux process dies → emit error, trigger reconnect (if Phase 1 adds reconnect)
- If `%exit` received → clean shutdown
- If octal decode fails → log warning, skip frame (don't crash)
---
## Comparison to Phase 0 pipe-pane Findings
| Aspect | Phase 0 (pipe-pane) | Phase 0.5 (control mode) |
|--------|---------------------|--------------------------|
| **Reliability** | ❌ Detaches after `/settings` | ✅ Stable across alternate-screen |
| **Latency** | ✅ < 50ms | Same order of magnitude |
| **Parallel attach** | ✅ Works | ✅ Works (verified) |
| **Parser complexity** | ✅ Simple (read FIFO) | ✅ Straightforward (parse lines) |
| **Production readiness** | ⚠️ Needs watchdog | ✅ Production protocol (iTerm2) |
**Conclusion:** Control mode is pipe-pane **without the fragility**.
---
## Test Artifacts
### Files Created
- `extensions/remote-control/spike-cc.ts` (268 lines)
- Control mode client
- `%output` event parser
- Octal escape decoder
- Stats tracking
- `test-spike-cc.sh` (158 lines)
- Automated test protocol
- Alternate-screen trigger
- Parallel attach verification
- 5-minute duration test
### Test Log
- **Location:** `/tmp/spike-cc-test-1778811031.log`
- **Size:** 182 KB (raw ANSI output + stats)
- **Duration:** 300+ seconds
- **Events:** 462 total
### How to Reproduce
```bash
cd /path/to/pi-remote-control
git checkout feat/spike-tmux-cc
npm run spike-cc # Terminal 1
tmux attach -t pi-cc # Terminal 2 (parallel attach test)
# Wait 5+ minutes, send /settings, verify stream continues
```
---
## Risks and Mitigations
### R-1. Octal decode performance on large bursts
**Risk:** If pi produces very large output (e.g., 10MB JSON dump), octal decoding might lag.
**Mitigation:**
- Octal decode is O(n) where n = string length. Fast enough for typical pi output.
- If needed, optimize: precompile regex, use Buffer operations.
- Not a concern for MVP (pi output is conversational, not bulk data).
### R-2. tmux version compatibility
**Risk:** Older tmux versions (< 2.0) may have incomplete control mode.
**Mitigation:**
- Require tmux ≥ 2.x in Phase 1 documentation.
- Check `tmux -V` at runtime, emit clear error if too old.
- macOS default tmux is 3.x, most Linux servers have 2.x+.
### R-3. Control mode blocks send-keys
**Risk:** Sending commands via control mode stdin might block if output buffer is full.
**Mitigation:**
- Use non-blocking writes for send-keys.
- For Phase 1, send-keys is infrequent (user typing only), not a bottleneck.
- If blocking occurs, switch to separate `tmux send-keys -t <session>` subprocess calls.
---
## Recommendations for Phase 1
1. **Adopt Path B (control mode)** for T-1.1 (`tmux/pipe.ts`)
2. **Reuse spike parser** as starting point (copy `decodeOctalEscapes` function)
3. **Document tmux ≥ 2.0 requirement** in README
4. **Add integration test** similar to `test-spike-cc.sh` for CI
5. **Consider iTerm2 source** as reference for edge cases: https://github.com/gnachman/iTerm2/blob/master/sources/TmuxGateway.m
---
## Conclusion
tmux control mode is a **proven, reliable solution** for streaming pi output. It solves the pipe-pane fragility discovered in Phase 0 without adding significant complexity. The spike demonstrates all acceptance criteria are met.
**Final Verdict: GREEN LIGHT for Path B — tmux Control Mode.**
Phase 1 can proceed with confidence.
---
## Appendix: Protocol Reference
### Key Events
```
%output %<pane-id> <octal-escaped-value>
→ Pane produced output. Decode octal and stream to clients.
%exit [reason]
→ Control mode client is exiting. Clean shutdown.
%session-changed <session-id> <name>
→ Session switched (multi-session tmux). Informational.
%layout-change <window-id> <layout> <visible-layout> <flags>
→ Window resized. Ignore for Phase 1.
%window-renamed <window-id> <name>
→ Window title changed. Ignore for Phase 1.
```
### Octal Escape Format
- Non-printable bytes encoded as `\NNN` where NNN is 3-digit octal
- Example: newline (`\n` = byte 10 = octal 012) → `\\012`
- Example: escape (`\x1b` = byte 27 = octal 033) → `\\033`
- Regular printable ASCII passed through unchanged
**Decode algorithm:**
1. Scan string for `\\`
2. If followed by 3 octal digits, parse to byte value
3. Otherwise, treat `\\` as literal backslash
4. Collect bytes, return Buffer
See `spike-cc.ts:51-70` for reference implementation.
---
**End of Report**

View File

@ -0,0 +1,156 @@
/**
* APNs scaffold JWT generation + push primitive.
*
* Provides a minimal APNs HTTP/2 push implementation using the JWT
* provider-auth method (no persistent connection pool deferred to Phase 2
* when iOS delivers real device tokens and call volume is known).
*
* Config comes from [apns] section in config.toml (T-1.7 wires this).
* Until then, an explicit ApnsConfig object is passed in.
*
* Owner: T-1.10
*/
import { createSign } from "node:crypto";
import fs from "node:fs/promises";
export interface ApnsConfig {
teamId: string; // 10-char Apple Team ID
keyId: string; // 10-char APNs key ID
keyPath: string; // path to .p8 private key file
bundleId: string; // app bundle ID, e.g. "com.example.pi-remote"
}
export interface PushPayload {
title?: string;
body?: string;
badge?: number;
data?: Record<string, unknown>;
}
/** APNs host per environment */
const APNS_HOST = {
production: "api.push.apple.com",
sandbox: "api.development.push.apple.com",
} as const;
// ---------------------------------------------------------------------------
// JWT generation (ES256, valid 60 minutes per APNs spec)
// ---------------------------------------------------------------------------
interface JwtCache {
token: string;
issuedAt: number;
}
const _jwtCache = new Map<string, JwtCache>();
/**
* Generate (or return cached) APNs provider JWT.
* APNs rejects tokens older than 60 min; we refresh at 55 min.
*/
export async function getProviderJwt(cfg: ApnsConfig): Promise<string> {
const cacheKey = `${cfg.teamId}:${cfg.keyId}`;
const cached = _jwtCache.get(cacheKey);
const now = Math.floor(Date.now() / 1000);
if (cached && now - cached.issuedAt < 55 * 60) {
return cached.token;
}
const keyPem = await fs.readFile(cfg.keyPath, "utf8");
const header = base64url(JSON.stringify({ alg: "ES256", kid: cfg.keyId }));
const claims = base64url(JSON.stringify({ iss: cfg.teamId, iat: now }));
const signingInput = `${header}.${claims}`;
const sign = createSign("SHA256");
sign.update(signingInput);
const sig = sign.sign({ key: keyPem, dsaEncoding: "ieee-p1363" });
const sigB64 = sig.toString("base64url");
const token = `${signingInput}.${sigB64}`;
_jwtCache.set(cacheKey, { token, issuedAt: now });
return token;
}
// ---------------------------------------------------------------------------
// Push primitive
// ---------------------------------------------------------------------------
export interface PushResult {
ok: boolean;
status?: number;
apnsId?: string;
error?: string;
}
/**
* Send a single APNs push notification.
*
* Uses the Node fetch API (available since Node 18).
* No connection pooling Phase 2 can upgrade to http2 if throughput demands.
*/
export async function sendPush(opts: {
cfg: ApnsConfig;
deviceToken: string;
environment: "production" | "sandbox";
payload: PushPayload;
collapseId?: string;
}): Promise<PushResult> {
const { cfg, deviceToken, environment, payload, collapseId } = opts;
const jwt = await getProviderJwt(cfg);
const host = APNS_HOST[environment];
const url = `https://${host}/3/device/${deviceToken}`;
const apsPayload = {
aps: {
...(payload.title || payload.body
? { alert: { title: payload.title, body: payload.body } }
: {}),
...(payload.badge !== undefined ? { badge: payload.badge } : {}),
},
...payload.data,
};
const headers: Record<string, string> = {
authorization: `bearer ${jwt}`,
"apns-topic": cfg.bundleId,
"content-type": "application/json",
};
if (collapseId) headers["apns-collapse-id"] = collapseId;
try {
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(apsPayload),
});
const apnsId = res.headers.get("apns-id") ?? undefined;
if (res.ok) {
return { ok: true, status: res.status, apnsId };
}
const body = await res.text().catch(() => "");
let errorReason = body;
try {
errorReason = (JSON.parse(body) as { reason?: string }).reason ?? body;
} catch {
// use raw body
}
return { ok: false, status: res.status, apnsId, error: errorReason };
} catch (err) {
return { ok: false, error: String(err) };
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function base64url(input: string): string {
return Buffer.from(input).toString("base64url");
}

View File

@ -0,0 +1,85 @@
/**
* QR-based pairing.
*
* Implements IC-3 the `pi-remote://` URL scheme and `POST /pair` exchange.
*
* Pairing flow:
* 1. Server generates a one-time pairingToken (short-lived, 10 min).
* 2. `pi-remote pair` CLI prints the QR code containing the URL.
* 3. iOS app scans the QR, extracts host/port/pairingToken/fingerprint.
* 4. iOS app calls `POST /pair` with { pairingToken, deviceToken?, environment?, deviceName? }.
* 5. Server validates pairingToken, creates a bearer token, returns { bearerToken, sidecarId }.
*
* Owner: T-1.3
*/
import { randomBytes } from "node:crypto";
export interface PairingToken {
token: string;
expiresAt: number; // unix ms
}
// In-memory store of active pairing tokens (cleared on server restart)
const _pairingTokens = new Map<string, PairingToken>();
const PAIRING_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes
/** Generate a new pairing token. Invalidates any existing one. */
export function generatePairingToken(): PairingToken {
const token = randomBytes(16).toString("base64url");
const entry: PairingToken = {
token,
expiresAt: Date.now() + PAIRING_TOKEN_TTL_MS,
};
_pairingTokens.set(token, entry);
return entry;
}
/** Validate and consume a pairing token. Returns true if valid. */
export function consumePairingToken(token: string): boolean {
const entry = _pairingTokens.get(token);
if (!entry) return false;
_pairingTokens.delete(token);
if (Date.now() > entry.expiresAt) return false;
return true;
}
/**
* Build the IC-3 pairing URL.
*
* pi-remote://<host>:<port>?pair=<pairingToken>&fp=<sha256-hex>&name=<sidecarName>
*/
export function buildPairingUrl(opts: {
host: string;
port: number;
pairingToken: string;
fingerprint: string;
sidecarName?: string;
}): string {
const {
host,
port,
pairingToken,
fingerprint,
sidecarName = "pi-remote",
} = opts;
const params = new URLSearchParams({
pair: pairingToken,
fp: fingerprint,
name: sidecarName,
});
return `pi-remote://${host}:${port}?${params.toString()}`;
}
/**
* Render the pairing URL as a QR code to the terminal.
* Uses the `qrcode` package bundled as a dependency.
*/
export async function printPairingQr(url: string): Promise<void> {
// Dynamic import so this file loads even if qrcode isn't installed
const qrcode = await import("qrcode");
const qr = await qrcode.toString(url, { type: "terminal", small: true });
console.log(qr);
console.log(url);
}

View File

@ -0,0 +1,116 @@
/**
* Self-signed TLS certificate generation + fingerprint.
*
* Generates a self-signed cert for the sidecar server. The SHA-256 fingerprint
* is included in the QR pairing URL (IC-3 `fp` field) so the iOS app can pin.
*
* Uses openssl CLI (available on macOS/Linux). Falls back to plain HTTP if
* openssl is not available.
*
* Owner: T-1.3
*/
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export interface TlsCert {
certPath: string;
keyPath: string;
/** SHA-256 hex fingerprint of the DER-encoded cert */
fingerprint: string;
}
export interface TlsCertOptions {
stateDir?: string;
/** Common name for the cert (default: "pi-remote") */
cn?: string;
/** Days the cert is valid (default: 3650 — 10 years) */
days?: number;
}
function certDir(stateDir?: string): string {
return path.join(
stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote"),
"tls",
);
}
/**
* Load existing cert or generate a new one.
* Returns paths + fingerprint.
*/
export async function loadOrCreateCert(
opts: TlsCertOptions = {},
): Promise<TlsCert> {
const { stateDir, cn = "pi-remote", days = 3650 } = opts;
const dir = certDir(stateDir);
await fs.mkdir(dir, { recursive: true });
const certPath = path.join(dir, "cert.pem");
const keyPath = path.join(dir, "key.pem");
// Reuse existing cert if present
try {
await fs.access(certPath);
await fs.access(keyPath);
const fingerprint = await computeFingerprint(certPath);
return { certPath, keyPath, fingerprint };
} catch {
// generate new
}
// Generate via openssl
await execFileAsync("openssl", [
"req",
"-x509",
"-newkey",
"rsa:2048",
"-keyout",
keyPath,
"-out",
certPath,
"-days",
String(days),
"-nodes",
"-subj",
`/CN=${cn}`,
]);
await fs.chmod(keyPath, 0o600);
const fingerprint = await computeFingerprint(certPath);
return { certPath, keyPath, fingerprint };
}
/**
* Compute SHA-256 fingerprint of a PEM cert.
* Returns hex string (no colons), e.g. "a1b2c3...".
*/
export async function computeFingerprint(certPath: string): Promise<string> {
// Use openssl to get DER bytes, then hash
const { stdout } = await execFileAsync("openssl", [
"x509",
"-in",
certPath,
"-outform",
"DER",
]);
return createHash("sha256").update(stdout).digest("hex");
}
/**
* Check if openssl is available on PATH.
*/
export async function isOpensslAvailable(): Promise<boolean> {
try {
await execFileAsync("openssl", ["version"]);
return true;
} catch {
return false;
}
}

View File

@ -0,0 +1,163 @@
/**
* Bearer-token CRUD.
*
* Extends the minimal token support in auth.ts (legacy single-token) with
* named multi-token management. Each token entry has:
* - id: short random identifier
* - token: the bearer secret (base64url, 32 bytes)
* - name: human label (e.g. "Jay's iPhone")
* - createdAt: ISO timestamp
* - deviceToken?: APNs device token (set when device pairs in Phase 2)
* - environment?: "production" | "sandbox"
*
* Stored as JSON in $state_dir/auth/tokens.json (mode 0o600).
*
* Owner: T-1.3
*/
import { randomBytes, timingSafeEqual } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
// ---------------------------------------------------------------------------
// In-memory cache for sync validation (WS upgrade can't await)
// ---------------------------------------------------------------------------
const _tokenCache = new Set<string>();
function cacheToken(token: string): void {
_tokenCache.add(token);
}
/** Sync bearer validation — checks in-memory cache populated at runtime. */
export function validateBearerSync(bearer: string): boolean {
return _tokenCache.has(bearer);
}
/** Warm the cache from disk on startup. */
export async function warmTokenCache(stateDir?: string): Promise<void> {
const entries = await loadTokens(stateDir);
for (const e of entries) _tokenCache.add(e.token);
}
// ---------------------------------------------------------------------------
export interface TokenEntry {
id: string;
token: string;
name: string;
createdAt: string;
deviceToken?: string; // APNs — optional pre-Phase-2, mandatory Phase-2+
environment?: "production" | "sandbox";
}
function tokensPath(stateDir?: string): string {
const base =
stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote");
return path.join(base, "auth", "tokens.json");
}
async function loadTokens(stateDir?: string): Promise<TokenEntry[]> {
try {
const raw = await fs.readFile(tokensPath(stateDir), "utf8");
return JSON.parse(raw) as TokenEntry[];
} catch {
return [];
}
}
async function saveTokens(
entries: TokenEntry[],
stateDir?: string,
): Promise<void> {
const fp = tokensPath(stateDir);
await fs.mkdir(path.dirname(fp), { recursive: true });
await fs.writeFile(fp, JSON.stringify(entries, null, 2), {
encoding: "utf8",
mode: 0o600,
});
}
/** Create a new named bearer token. Returns the new entry (token visible once). */
export async function createToken(
name: string,
stateDir?: string,
): Promise<TokenEntry> {
const entries = await loadTokens(stateDir);
const entry: TokenEntry = {
id: randomBytes(6).toString("base64url"),
token: randomBytes(32).toString("base64url"),
name,
createdAt: new Date().toISOString(),
};
entries.push(entry);
await saveTokens(entries, stateDir);
cacheToken(entry.token);
return entry;
}
/** List all tokens (token field is included — protect at the API layer). */
export async function listTokens(stateDir?: string): Promise<TokenEntry[]> {
return loadTokens(stateDir);
}
/** Revoke a token by id. Returns true if found and removed. */
export async function revokeToken(
id: string,
stateDir?: string,
): Promise<boolean> {
const entries = await loadTokens(stateDir);
const before = entries.length;
const filtered = entries.filter((e) => e.id !== id);
if (filtered.length === before) return false;
await saveTokens(filtered, stateDir);
return true;
}
/** Rename a token. Returns true if found. */
export async function renameToken(
id: string,
newName: string,
stateDir?: string,
): Promise<boolean> {
const entries = await loadTokens(stateDir);
const entry = entries.find((e) => e.id === id);
if (!entry) return false;
entry.name = newName;
await saveTokens(entries, stateDir);
return true;
}
/** Update device token (called during Phase-2 pairing). */
export async function setDeviceToken(
id: string,
deviceToken: string,
environment: "production" | "sandbox",
stateDir?: string,
): Promise<boolean> {
const entries = await loadTokens(stateDir);
const entry = entries.find((e) => e.id === id);
if (!entry) return false;
entry.deviceToken = deviceToken;
entry.environment = environment;
await saveTokens(entries, stateDir);
return true;
}
/**
* Validate a bearer token string against the store.
* Returns the matching entry or null.
*/
export async function validateBearer(
bearer: string,
stateDir?: string,
): Promise<TokenEntry | null> {
const entries = await loadTokens(stateDir);
const b = Buffer.from(bearer);
for (const entry of entries) {
const a = Buffer.from(entry.token);
if (a.length === b.length && timingSafeEqual(a, b)) return entry;
}
return null;
}

View File

@ -0,0 +1,91 @@
/**
* Disk ring-buffer reader.
*
* Reads chunks from a session buffer file, optionally starting from a
* given seq number. Used by the stream route for reconnect replay (T-1.5).
*
* File format (mirrors writer.ts):
* Each record: [seq: 8 bytes BE uint64] [length: 4 bytes BE uint32] [data: N bytes]
*
* Owner: T-1.2
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export interface BufferChunk {
seq: number;
data: Buffer;
}
export interface ReaderConfig {
stateDir?: string;
}
function stateDir(cfg?: ReaderConfig): string {
return (
cfg?.stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote")
);
}
function bufferPath(session: string, cfg?: ReaderConfig): string {
return path.join(stateDir(cfg), "buffers", `${session}.buf`);
}
/**
* Read all chunks from a session buffer, optionally starting after `afterSeq`.
*
* Returns chunks in seq order. If the file doesn't exist, returns [].
* Stops at the first parse error (truncated file at end is tolerated).
*/
export function readChunks(
session: string,
opts: { afterSeq?: number; cfg?: ReaderConfig } = {},
): BufferChunk[] {
const { afterSeq = 0, cfg } = opts;
const fp = bufferPath(session, cfg);
let buf: Buffer;
try {
buf = fs.readFileSync(fp);
} catch {
return [];
}
const chunks: BufferChunk[] = [];
let offset = 0;
while (offset + 12 <= buf.length) {
const seqBig = buf.readBigUInt64BE(offset);
const seq = Number(seqBig);
const length = buf.readUInt32BE(offset + 8);
offset += 12;
if (offset + length > buf.length) break; // truncated record at end
if (seq > afterSeq) {
chunks.push({ seq, data: buf.slice(offset, offset + length) });
}
offset += length;
}
return chunks;
}
/**
* Read chunks as an async generator (streaming, for large buffers).
* Yields one chunk at a time after `afterSeq`.
*/
export async function* streamChunks(
session: string,
opts: { afterSeq?: number; cfg?: ReaderConfig } = {},
): AsyncGenerator<BufferChunk> {
// Simple implementation: read all and yield. For large files T-1.5 can
// switch to a streaming file read if needed.
const { afterSeq = 0, cfg } = opts;
const chunks = readChunks(session, { afterSeq, cfg });
for (const chunk of chunks) {
yield chunk;
}
}

View File

@ -0,0 +1,210 @@
/**
* Disk ring-buffer writer.
*
* Appends chunks to a per-session file, enforcing:
* - Per-session cap: 100 MB (configurable)
* - Global cap: 1 GB across all sessions (configurable)
* - Free-space watchdog: refuse writes if free disk < 1 GB
* - Idle cleanup: sessions inactive for > 30 days are deleted
*
* File format (binary, append-only):
* Each record: [seq: 8 bytes BE uint64] [length: 4 bytes BE uint32] [data: N bytes]
*
* Risk R1 mitigation: all writes serialised through a per-session async queue.
* Global cap protected by a module-level mutex (simple flag since JS is single-threaded).
*
* Owner: T-1.2
*/
import fs from "node:fs/promises";
import type { Dirent } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { SeqNum } from "../sequence.js";
// ---------------------------------------------------------------------------
// Config defaults (can be overridden; T-1.7 will wire these from config.toml)
// ---------------------------------------------------------------------------
export interface BufferConfig {
stateDir: string;
perSessionMb: number; // default 100
globalGb: number; // default 1
freeMinGb: number; // default 1
idleDays: number; // default 30
}
function defaultConfig(): BufferConfig {
return {
stateDir: path.join(os.homedir(), ".local", "share", "pi-remote"),
perSessionMb: 100,
globalGb: 1,
freeMinGb: 1,
idleDays: 30,
};
}
let _config: BufferConfig = defaultConfig();
export function configureBuffer(cfg: Partial<BufferConfig>): void {
_config = { ..._config, ...cfg };
}
// ---------------------------------------------------------------------------
// Global cap mutex (JS single-threaded so a flag suffices)
// ---------------------------------------------------------------------------
let _globalBusy = false;
let _globalBytes = 0; // tracked in-memory; recalculated on startup
// ---------------------------------------------------------------------------
// Per-session writer
// ---------------------------------------------------------------------------
export class BufferWriter {
readonly session: string;
private filePath: string;
private queue: Promise<void> = Promise.resolve();
private sessionBytes = 0;
private lastWriteAt = Date.now();
constructor(session: string) {
this.session = session;
this.filePath = path.join(_config.stateDir, "buffers", `${session}.buf`);
}
async open(): Promise<void> {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
// Load existing size for cap tracking
try {
const stat = await fs.stat(this.filePath);
this.sessionBytes = stat.size;
_globalBytes += stat.size;
} catch {
this.sessionBytes = 0;
}
}
/**
* Enqueue a chunk write. Writes are serialised per session.
*/
write(seq: SeqNum, data: Buffer): void {
this.queue = this.queue.then(() => this._write(seq, data));
}
private async _write(seq: SeqNum, data: Buffer): Promise<void> {
const perSessionCap = _config.perSessionMb * 1024 * 1024;
const globalCap = _config.globalGb * 1024 * 1024 * 1024;
// Free-space watchdog
try {
const { available } = await checkFreeSpace(path.dirname(this.filePath));
const freeMin = _config.freeMinGb * 1024 * 1024 * 1024;
if (available < freeMin) return; // silently drop; could emit a warning
} catch {
// If we can't check, don't block writes
}
// Cap enforcement
const recordSize = 8 + 4 + data.length;
if (
_globalBusy ||
this.sessionBytes + recordSize > perSessionCap ||
_globalBytes + recordSize > globalCap
) {
return; // drop oldest strategy: just don't write (ring via truncation not implemented yet)
}
_globalBusy = true;
try {
const header = Buffer.allocUnsafe(12);
header.writeBigUInt64BE(BigInt(seq), 0);
header.writeUInt32BE(data.length, 8);
await fs.appendFile(this.filePath, Buffer.concat([header, data]));
this.sessionBytes += recordSize;
_globalBytes += recordSize;
this.lastWriteAt = Date.now();
} finally {
_globalBusy = false;
}
}
async close(): Promise<void> {
await this.queue; // drain
}
/** Delete the buffer file and reclaim global tracking bytes. */
async delete(): Promise<void> {
await this.queue;
try {
await fs.unlink(this.filePath);
_globalBytes = Math.max(0, _globalBytes - this.sessionBytes);
this.sessionBytes = 0;
} catch {
// already gone
}
}
get idleMs(): number {
return Date.now() - this.lastWriteAt;
}
}
// ---------------------------------------------------------------------------
// Idle cleanup
// ---------------------------------------------------------------------------
/**
* Delete buffer files for sessions idle longer than idleDays.
* Safe to call periodically (e.g. on startup or daily timer).
*/
export async function cleanupIdleBuffers(
cfg: BufferConfig = _config,
): Promise<string[]> {
const dir = path.join(cfg.stateDir, "buffers");
const maxIdleMs = cfg.idleDays * 24 * 60 * 60 * 1000;
const deleted: string[] = [];
let entries: Dirent[] = [];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return deleted;
}
for (const entry of entries) {
if (!entry.name.endsWith(".buf")) continue;
const fp = path.join(dir, entry.name);
try {
const stat = await fs.stat(fp);
if (Date.now() - stat.mtimeMs > maxIdleMs) {
await fs.unlink(fp);
deleted.push(entry.name.replace(/\.buf$/, ""));
}
} catch {
// skip
}
}
return deleted;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Approximate free disk space on the filesystem containing `dir`. */
async function checkFreeSpace(dir: string): Promise<{ available: number }> {
// Node doesn't expose statvfs directly; use df -k as a fallback.
// If it fails, caller ignores the error.
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const exec = promisify(execFile);
const { stdout } = await exec("df", ["-k", dir]);
const lines = stdout.trim().split("\n");
const last = lines[lines.length - 1];
const parts = last.split(/\s+/);
// df -k columns: Filesystem 1K-blocks Used Available Use% Mounted
const availKb = parseInt(parts[3], 10);
return { available: availKb * 1024 };
}

View File

@ -0,0 +1,223 @@
/**
* pi-remote CLI entrypoints.
*
* Subcommands:
* pi-remote pair generate QR code for device pairing
* pi-remote auth list list bearer tokens
* pi-remote auth revoke <id> revoke a token
* pi-remote auth name <id> <name> rename a token
*
* Invoked by the extension via pi's flag registration or as a standalone
* script: `node cli/index.js <args>`
*
* Owner: T-1.3
*/
import os from "node:os";
import path from "node:path";
import {
buildPairingUrl,
generatePairingToken,
printPairingQr,
} from "../auth/pairing.js";
import { loadOrCreateCert } from "../auth/tls.js";
import {
createToken,
listTokens,
renameToken,
revokeToken,
} from "../auth/tokens.js";
import { readRemoteControlConfig } from "../config.js";
const DEFAULT_STATE_DIR = path.join(
os.homedir(),
".local",
"share",
"pi-remote",
);
export async function runCli(
argv: string[] = process.argv.slice(2),
): Promise<void> {
const [cmd, sub, ...rest] = argv;
switch (cmd) {
case "pair":
await cmdPair();
break;
case "auth":
await cmdAuth(sub, rest);
break;
case "help":
case "--help":
case "-h":
printHelp();
break;
default:
console.error(`Unknown command: ${cmd ?? "(none)"}`);
printHelp();
process.exitCode = 1;
}
}
// ---------------------------------------------------------------------------
// pair
// ---------------------------------------------------------------------------
async function cmdPair(): Promise<void> {
const config = await readRemoteControlConfig();
const stateDir = DEFAULT_STATE_DIR;
// Try to get TLS fingerprint; fall back to empty string if openssl unavailable
let fingerprint = "";
try {
const cert = await loadOrCreateCert({ stateDir });
fingerprint = cert.fingerprint;
} catch {
console.warn(
"[pi-remote] Warning: openssl not available; fingerprint will be empty.",
);
}
const pairingTokenEntry = generatePairingToken();
// Determine host for QR — use advertised URL or fall back to hostname
const bindAddress = config.bindAddress ?? "0.0.0.0:7777";
const portMatch = bindAddress.match(/:(\d+)$/);
const port = portMatch ? parseInt(portMatch[1], 10) : 7777;
const host =
(config.publicBaseUrl ?? config.advertisedBaseUrl)?.replace(
/^https?:\/\//,
"",
) ?? os.hostname();
const url = buildPairingUrl({
host,
port,
pairingToken: pairingTokenEntry.token,
fingerprint,
sidecarName: "pi-remote",
});
console.log("\nScan this QR code with the pi-remote iOS app:\n");
await printPairingQr(url);
console.log(
`\nPairing token expires in 10 minutes. Run "pi-remote pair" again to refresh.`,
);
}
// ---------------------------------------------------------------------------
// auth
// ---------------------------------------------------------------------------
async function cmdAuth(sub: string | undefined, args: string[]): Promise<void> {
const stateDir = DEFAULT_STATE_DIR;
switch (sub) {
case "list": {
const tokens = await listTokens(stateDir);
if (tokens.length === 0) {
console.log(
"No tokens. Use `pi-remote auth create <name>` to create one.",
);
return;
}
console.log("ID NAME CREATED");
for (const t of tokens) {
const created = new Date(t.createdAt).toLocaleDateString();
console.log(
`${t.id.padEnd(12)}${t.name.padEnd(22)}${created}${t.deviceToken ? " [device paired]" : ""}`,
);
}
break;
}
case "create": {
const name = args[0] ?? "unnamed";
const entry = await createToken(name, stateDir);
console.log(`Created token "${entry.name}" (id: ${entry.id})`);
console.log(`Bearer token: ${entry.token}`);
console.log("Save this token — it won't be shown again.");
break;
}
case "revoke": {
const id = args[0];
if (!id) {
console.error("Usage: pi-remote auth revoke <id>");
process.exitCode = 1;
return;
}
const ok = await revokeToken(id, stateDir);
if (ok) {
console.log(`Revoked token ${id}.`);
} else {
console.error(`Token ${id} not found.`);
process.exitCode = 1;
}
break;
}
case "name": {
const [id, ...nameParts] = args;
const newName = nameParts.join(" ");
if (!id || !newName) {
console.error("Usage: pi-remote auth name <id> <new-name>");
process.exitCode = 1;
return;
}
const ok = await renameToken(id, newName, stateDir);
if (ok) {
console.log(`Renamed token ${id} to "${newName}".`);
} else {
console.error(`Token ${id} not found.`);
process.exitCode = 1;
}
break;
}
default:
console.error(`Unknown auth subcommand: ${sub ?? "(none)"}`);
console.error("Available: list, create, revoke, name");
process.exitCode = 1;
}
}
// ---------------------------------------------------------------------------
// help
// ---------------------------------------------------------------------------
function printHelp(): void {
console.log(`
pi-remote CLI for the pi-remote-control sidecar
Commands:
pair Generate a QR code to pair the iOS app
auth list List all bearer tokens
auth create <name> Create a new named bearer token
auth revoke <id> Revoke a token by id
auth name <id> <name> Rename a token
Options:
--help, -h Show this help
`);
}
// ---------------------------------------------------------------------------
// Standalone entrypoint
// ---------------------------------------------------------------------------
// Run when invoked as: node cli/index.js <args>
if (
process.argv[1] &&
(process.argv[1].endsWith("cli/index.js") ||
process.argv[1].endsWith("cli/index.ts"))
) {
runCli().catch((err) => {
console.error(err);
process.exit(1);
});
}

View File

@ -93,7 +93,9 @@ export default function remoteControl(pi: ExtensionAPI) {
updateStatus(ctx); updateStatus(ctx);
}); });
pi.on("session_switch", async (_event, ctx) => { // session_switch is not in the ExtensionAPI — use session_start instead
// to sync state when a session becomes active.
pi.on("session_start", async (_event, ctx) => {
scheduleSync(ctx); scheduleSync(ctx);
}); });

View File

@ -58,8 +58,8 @@ export function serializeMessage(
const toolCalls = (msg.content as RawContent[]) const toolCalls = (msg.content as RawContent[])
.filter((c) => c.type === "toolCall") .filter((c) => c.type === "toolCall")
.map((c) => ({ .map((c) => ({
id: c.id, id: c.id ?? "",
name: c.name, name: c.name ?? "",
args: JSON.stringify(c.arguments, null, 2), args: JSON.stringify(c.arguments, null, 2),
})); }));
return { return {

View File

@ -0,0 +1,73 @@
/**
* Auto-naming via `pi -p` (S-09a).
*
* After a configurable number of user messages, spawn a cheap `pi -p` call
* to generate a short session name from the conversation context.
* The result is stored as the tmux session's @description.
*
* Gated by [autoname] enabled in config.toml (T-1.7 wires the config;
* until then defaults are used).
*
* Owner: T-1.4
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { setDescription } from "../tmux/manager.js";
const execFileAsync = promisify(execFile);
export interface AutonameConfig {
enabled: boolean;
triggerAfter: number; // number of user messages before naming
model: string; // e.g. "claude-haiku-4-5"
}
export const DEFAULT_AUTONAME_CONFIG: AutonameConfig = {
enabled: true,
triggerAfter: 3,
model: "claude-haiku-4-5",
};
/**
* Attempt to auto-name a session using `pi -p`.
* If pi is not on PATH or the call fails, silently no-ops.
*
* @param sessionId tmux session name to set @description on
* @param context recent conversation context (short excerpt)
* @param cfg autoname configuration
*/
export async function autoname(
sessionId: string,
context: string,
cfg: AutonameConfig = DEFAULT_AUTONAME_CONFIG,
): Promise<void> {
if (!cfg.enabled) return;
const prompt = `Give a 2-4 word title for this conversation. Reply with only the title, no punctuation.\n\n${context.slice(0, 800)}`;
try {
const { stdout } = await execFileAsync(
"pi",
[
"-p",
"--model",
cfg.model,
"--no-session",
"--no-tools",
"--no-extensions",
"--no-skills",
"--offline",
prompt,
],
{ timeout: 15_000 },
);
const name = stdout.trim().slice(0, 60); // cap at 60 chars
if (name) {
await setDescription(sessionId, name);
}
} catch {
// Autoname failures are non-fatal
}
}

View File

@ -0,0 +1,51 @@
/**
* pi.getCommands() wrapper.
*
* Fetches available slash commands from the pi ExtensionAPI and normalises
* them into the shape used by the /sessions/:id/commands REST endpoint (T-1.6).
*
* Owner: T-1.4
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export interface SlashCommand {
name: string;
description: string;
args?: string;
}
/**
* Get the list of registered slash commands from pi.
* Returns an empty array if the API doesn't support getCommands.
*/
export async function getCommands(pi: ExtensionAPI): Promise<SlashCommand[]> {
try {
// getCommands may not exist in all pi versions
if (
typeof (pi as unknown as { getCommands?: unknown }).getCommands !==
"function"
) {
return [];
}
const raw = await (
pi as unknown as { getCommands: () => Promise<unknown[]> }
).getCommands();
if (!Array.isArray(raw)) return [];
return raw
.filter(
(c): c is { name: string; description?: string; args?: string } =>
c !== null &&
typeof c === "object" &&
typeof (c as { name?: unknown }).name === "string",
)
.map((c) => ({
name: c.name,
description: c.description ?? "",
args: c.args,
}));
} catch {
return [];
}
}

View File

@ -0,0 +1,68 @@
/**
* pi ExtensionAPI event subscriptions.
*
* Bridges pi's lifecycle events into the sidecar's state model.
* Emits structured state updates that the WebSocket broadcaster (T-1.5)
* can forward as IC-1 `{ type: "state"; value: ... }` frames.
*
* Subscribes to:
* - agent_start / agent_end "thinking" / "idle"
* - tool_start / tool_end "tool" (with tool name)
* - awaiting_input "awaiting-input"
*
* Owner: T-1.4
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
/** IC-1 state values */
export type AgentState = "thinking" | "tool" | "idle" | "awaiting-input";
export interface StateEvent {
value: AgentState;
tool?: string;
ts: number;
}
export type StateCallback = (event: StateEvent) => void;
/**
* Subscribe to pi agent lifecycle events.
*
* Note: `pi.on()` returns void and has no unsubscribe mechanism event
* handlers are scoped to the extension lifetime, not to individual calls.
* The returned function is a no-op kept for API compatibility.
*/
export function subscribeAgentEvents(
pi: ExtensionAPI,
onState: StateCallback,
): () => void {
// agent_start → thinking
pi.on("agent_start", () => {
onState({ value: "thinking", ts: Date.now() });
});
// agent_end → idle
pi.on("agent_end", () => {
onState({ value: "idle", ts: Date.now() });
});
// tool_execution_start → tool (carries toolName directly on the event)
pi.on("tool_execution_start", (event) => {
onState({ value: "tool", tool: event.toolName, ts: Date.now() });
});
// tool_execution_end → thinking (agent loop continues after tool completes)
pi.on("tool_execution_end", () => {
onState({ value: "thinking", ts: Date.now() });
});
// input → awaiting-input (fired when pi pauses to wait for user input)
pi.on("input", () => {
onState({ value: "awaiting-input", ts: Date.now() });
});
return () => {
// No-op: pi event subscriptions cannot be cancelled.
};
}

View File

@ -0,0 +1,35 @@
/**
* Monotonic sequence number generator shared by stream + buffer.
*
* Each chunk of output gets a unique, monotonically increasing seq number.
* This lets clients resume a stream from a known position (IC-1 `lastSeq`).
*
* Owner: T-1.2
*/
export type SeqNum = number; // safe JS integer, starts at 1
/**
* Per-session sequence counter.
* Create one instance per session; share between the buffer writer and the
* WebSocket broadcaster.
*/
export class SequenceCounter {
private current: SeqNum = 0;
/** Increment and return the next seq number. */
next(): SeqNum {
this.current += 1;
return this.current;
}
/** Current value without incrementing. */
peek(): SeqNum {
return this.current;
}
/** Reset (e.g. after session restart). */
reset(): void {
this.current = 0;
}
}

View File

@ -1,335 +1,18 @@
/** /**
* HTTP + WebSocket server for remote-control. * LEGACY: re-export shim.
* *
* Handles authentication, serves the web UI, and manages WebSocket connections * The server implementation has been moved to server/ sub-modules:
* for real-time message streaming between the pi session and browser clients. * server/server.ts HTTP bootstrap, TLS, middleware, LEGACY HTML routes
* server/upgrade.ts WebSocket upgrade routing per session/topic
* server/types.ts shared WS + RemoteServer type definitions
* server/routes/ route modules (populated by T-1.5/T-1.6/T-1.7)
*
* This shim is kept so that the existing import in index.ts
* (`import { startServer } from "./server.js"`) continues to resolve
* without modification. It will be removed once all consumers have been
* updated to import directly from server/ sub-modules.
*/ */
import { randomBytes } from "node:crypto"; export { startServer } from "./server/server.js";
import type { IncomingMessage } from "node:http"; // LEGACY: re-exports for backward-compatibility
import { createServer } from "node:http"; export type { RemoteServer } from "./server/types.js";
import { createRequire } from "node:module";
import type { AddressInfo, Socket } from "node:net";
import type {
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import {
generateSessionId,
loadOrCreateToken,
parseCookies,
SESSION_COOKIE,
validateToken,
} from "./auth.js";
import { parseBindAddress, readRemoteControlConfig } from "./config.js";
import { buildHTML } from "./html.js";
import { buildSyncMessage } from "./messages.js";
interface WsClient {
readyState: number;
send(data: string): void;
terminate(): void;
on(event: "message", listener: (data: Buffer) => void): void;
on(event: "close" | "error", listener: () => void): void;
}
interface WsServer {
on(event: "connection", listener: (ws: WsClient) => void): void;
on(event: "error", listener: (err: Error) => void): void;
handleUpgrade(
request: IncomingMessage,
socket: Socket,
head: Buffer,
cb: (ws: WsClient) => void,
): void;
emit(event: string, ...args: unknown[]): void;
close(cb?: () => void): void;
}
// Load ws (bundled with pi) without needing @types/ws installed locally
const _require = createRequire(import.meta.url);
const wsModule = _require("ws") as {
WebSocketServer: new (opts: { noServer: boolean }) => WsServer;
OPEN: number;
};
const { WebSocketServer, OPEN } = wsModule;
export interface RemoteServer {
broadcast: (msg: object) => void;
sync: (ctx: ExtensionContext) => void;
stop: () => Promise<void>;
clientCount: () => number;
onClientChange: (cb: () => void) => void;
port: number;
token: string;
}
export async function startServer(
pi: ExtensionAPI,
ctx: ExtensionContext,
): Promise<RemoteServer> {
const config = await readRemoteControlConfig();
const bindAddr = config.bindAddress ?? "";
const { host: bindHost, port: bindPort } = bindAddr
? parseBindAddress(bindAddr)
: { host: "127.0.0.1", port: 0 };
const clientChangeListeners: Array<() => void> = [];
const clients = new Set<WsClient>();
const token = await loadOrCreateToken();
// Map of valid session IDs → expiry timestamp (ms since epoch)
const SESSION_TTL_MS = 86_400_000; // 24 h — matches cookie Max-Age
const validSessions = new Map<string, number>();
const pruneExpiredSessions = (): void => {
const now = Date.now();
for (const [id, expiresAt] of validSessions) {
if (expiresAt <= now) validSessions.delete(id);
}
};
/** Check if a request is authenticated (valid token query param OR valid session cookie) */
function isAuthenticated(req: IncomingMessage): boolean {
// Check session cookie first
const cookies = parseCookies(req.headers.cookie);
const sessionId = cookies[SESSION_COOKIE];
const sessionExpiry = sessionId ? validSessions.get(sessionId) : undefined;
if (sessionExpiry !== undefined && sessionExpiry > Date.now()) return true;
// Check token query param
const url = new URL(req.url ?? "/", "http://localhost");
const providedToken = url.searchParams.get("token");
if (providedToken && validateToken(providedToken, token)) return true;
return false;
}
function broadcast(msg: object): void {
const data = JSON.stringify(msg);
for (const client of clients) {
if (client.readyState === OPEN) {
try {
client.send(data);
} catch {
/* ignore */
}
}
}
}
function sync(currentCtx: ExtensionContext): void {
broadcast(buildSyncMessage(currentCtx));
}
const httpServer = createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
const pathname = url.pathname;
if (pathname === "/manifest.json") {
res.writeHead(200, { "Content-Type": "application/manifest+json; charset=utf-8" });
res.end(JSON.stringify({
name: "Pi Remote",
short_name: "Pi",
description: "Remote control for Pi sessions",
start_url: "/",
display: "standalone",
background_color: "#0d1117",
theme_color: "#0d1117",
icons: [{ src: "/icon.svg", sizes: "any", type: "image/svg+xml" }],
}));
return;
}
if (pathname === "/icon.svg") {
res.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=86400" });
res.end(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180"><rect width="180" height="180" rx="40" fill="#0d1117"/><text x="90" y="133" font-family="-apple-system,Helvetica,Arial,sans-serif" font-size="110" text-anchor="middle" fill="#3fb950">π</text></svg>`);
return;
}
if (pathname === "/" || pathname === "/index.html") {
// Check authentication
const cookies = parseCookies(req.headers.cookie);
const sc = cookies[SESSION_COOKIE];
const hasValidSession =
sc !== undefined && (validSessions.get(sc) ?? 0) > Date.now();
const providedToken = url.searchParams.get("token");
const hasValidToken =
providedToken && validateToken(providedToken, token);
if (!hasValidSession && !hasValidToken) {
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
res.end(
"Forbidden — valid token required. Use the URL shown in the pi terminal.",
);
return;
}
// If authenticated via token (first visit), issue a session cookie and redirect to clean URL
if (!hasValidSession && hasValidToken) {
pruneExpiredSessions();
const sessionId = generateSessionId();
validSessions.set(sessionId, Date.now() + SESSION_TTL_MS);
res.writeHead(302, {
"Set-Cookie": `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
Location: "/",
});
res.end();
return;
}
// Valid session cookie — serve the page
const nonce = randomBytes(16).toString("base64");
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; manifest-src 'self'; base-uri 'none'`,
});
res.end(buildHTML(nonce));
} else {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not found");
}
});
const wss = new WebSocketServer({ noServer: true });
httpServer.on("error", (err: Error) => {
console.error("[remote-control] httpServer error:", err.message);
});
wss.on("error", (err: Error) => {
console.error("[remote-control] wss error:", err.message);
});
httpServer.on(
"upgrade",
(request: IncomingMessage, socket: Socket, head: Buffer) => {
const url = new URL(request.url, "http://localhost");
if (url.pathname === "/ws") {
// Validate auth: session cookie or token query param
if (!isAuthenticated(request)) {
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws: WsClient) => {
wss.emit("connection", ws, request);
});
} else {
socket.destroy();
}
},
);
wss.on("connection", (ws: WsClient) => {
clients.add(ws);
for (const cb of clientChangeListeners) cb();
// Send full state snapshot to the new client
try {
ws.send(JSON.stringify(buildSyncMessage(ctx)));
} catch {
/* client disconnected before first send */
}
// Per-connection rate limiting: max 30 prompts per 60 seconds
const RATE_WINDOW_MS = 60_000;
const RATE_MAX = 30;
const MAX_MSG_BYTES = 64 * 1024;
const recentPrompts: number[] = [];
ws.on("message", (data: Buffer) => {
if (data.length > MAX_MSG_BYTES) return;
let msg: { type?: string; text?: string };
try {
const parsed: unknown = JSON.parse(data.toString());
if (typeof parsed !== "object" || parsed === null) return;
msg = parsed as { type?: string; text?: string };
} catch {
return;
}
if (msg.type === "stop") {
if (!ctx.isIdle()) {
ctx.abort();
}
return;
}
if (
msg.type === "prompt" &&
typeof msg.text === "string" &&
msg.text.trim()
) {
const text = msg.text.trim();
// Sliding-window rate limit
const now = Date.now();
const cutoff = now - RATE_WINDOW_MS;
while (recentPrompts.length > 0 && recentPrompts[0] < cutoff)
recentPrompts.shift();
if (recentPrompts.length >= RATE_MAX) return;
recentPrompts.push(now);
if (ctx.isIdle()) {
pi.sendUserMessage(text);
} else {
pi.sendUserMessage(text, { deliverAs: "followUp" });
}
}
});
const onClose = () => {
clients.delete(ws);
broadcast({ type: "status", clientCount: clients.size });
for (const cb of clientChangeListeners) cb();
};
ws.on("close", onClose);
ws.on("error", onClose);
});
return new Promise((resolve) => {
httpServer.listen(bindPort, bindHost, () => {
resolve({
broadcast,
sync,
stop: () =>
new Promise<void>((res) => {
// Forcefully kill all WebSocket clients — terminate() sends no
// close frame and doesn't wait for the remote end to acknowledge,
// so it can't hang on an unresponsive client.
for (const client of clients) {
try {
client.terminate();
} catch {
/* ignore */
}
}
clients.clear();
// Safety timeout — if wss/http shutdown callbacks never fire
// (e.g. lingering keep-alive sockets), resolve anyway so the
// session_shutdown handler doesn't block pi from exiting.
const timeout = setTimeout(() => {
httpServer.close(() => {});
httpServer.closeAllConnections?.();
res();
}, 2000);
wss.close(() =>
httpServer.close(() => {
clearTimeout(timeout);
res();
}),
);
}),
clientCount: () => clients.size,
onClientChange: (cb: () => void) => {
clientChangeListeners.push(cb);
},
get port() {
return (httpServer.address() as AddressInfo | null)?.port ?? 0;
},
get token() {
return token;
},
});
});
});
}

View File

@ -0,0 +1,36 @@
/**
* S-08 slash-command registry route.
*
* GET /sessions/:id/commands [{ name, description, args }]
*
* Returns the list of slash commands available in the current pi session.
* Delegates to pi/commands.ts (T-1.4).
*
* Owner: T-1.6
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { getCommands } from "../../pi/commands.js";
import { getSession } from "../../tmux/manager.js";
import { sendJson } from "../util.js";
export async function handleCommands(
_req: IncomingMessage,
res: ServerResponse,
sessionId: string,
pi: ExtensionAPI,
): Promise<void> {
const session = await getSession(sessionId).catch(() => null);
if (!session) {
sendJson(res, 404, { error: "session_not_found" });
return;
}
try {
const commands = await getCommands(pi);
sendJson(res, 200, commands);
} catch (err) {
sendJson(res, 500, { error: "internal_error", message: String(err) });
}
}

View File

@ -0,0 +1,96 @@
/**
* S-12 health endpoint.
*
* GET /health { ok, sessions, bufferBytes, uptime, version }
*
* Also integrates the disk watchdog: on each health call we check free space
* and total buffer size, returning a warning if caps are near.
*
* Owner: T-1.7
*/
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import type { IncomingMessage, ServerResponse } from "node:http";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { listSessions } from "../../tmux/manager.js";
import { sendJson } from "../util.js";
const execFileAsync = promisify(execFile);
const _startedAt = Date.now();
export interface HealthOptions {
stateDir?: string;
}
export async function handleHealth(
_req: IncomingMessage,
res: ServerResponse,
opts: HealthOptions = {},
): Promise<void> {
const stateDir =
opts.stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote");
const [sessions, bufferBytes, freeBytes] = await Promise.all([
listSessions().catch(() => []),
getTotalBufferBytes(stateDir),
getFreeBytes(stateDir),
]);
const freeGb = freeBytes / (1024 * 1024 * 1024);
const bufferMb = bufferBytes / (1024 * 1024);
sendJson(res, 200, {
ok: true,
uptime: Math.floor((Date.now() - _startedAt) / 1000),
sessions: sessions.length,
sessionIds: sessions.map((s) => s.id),
bufferMb: Math.round(bufferMb * 10) / 10,
diskFreeGb: Math.round(freeGb * 10) / 10,
warnings: buildWarnings(freeGb, bufferMb),
});
}
function buildWarnings(freeGb: number, bufferMb: number): string[] {
const warnings: string[] = [];
if (freeGb < 1) warnings.push(`Low disk space: ${freeGb.toFixed(1)} GB free`);
if (bufferMb > 900)
warnings.push(`Buffer near cap: ${bufferMb.toFixed(0)} MB used`);
return warnings;
}
async function getTotalBufferBytes(stateDir: string): Promise<number> {
const bufDir = path.join(stateDir, "buffers");
try {
const entries = await fs.readdir(bufDir, { withFileTypes: true });
let total = 0;
for (const e of entries) {
if (!e.name.endsWith(".buf")) continue;
try {
const stat = await fs.stat(path.join(bufDir, e.name));
total += stat.size;
} catch {
// skip
}
}
return total;
} catch {
return 0;
}
}
async function getFreeBytes(dir: string): Promise<number> {
try {
const { stdout } = await execFileAsync("df", ["-k", dir]);
const lines = stdout.trim().split("\n");
const last = lines[lines.length - 1];
const parts = last.split(/\s+/);
const availKb = parseInt(parts[3], 10);
return availKb * 1024;
} catch {
return Number.POSITIVE_INFINITY; // unknown — don't warn
}
}

View File

@ -0,0 +1,105 @@
/**
* S-03 send-keys input route.
*
* POST /sessions/:id/input
*
* Body (IC-1 ClientToServer subset, but as HTTP POST for non-WS clients):
* { type: "key"; name: string }
* { type: "keys"; data: string }
* { type: "paste"; data: string }
*
* Response: 204 No Content on success, 400 on bad input, 404 if session missing.
*
* Note: the primary path for send-keys is via WS (T-1.5 stream route handles
* key/keys/paste messages inline). This HTTP endpoint is for clients that
* don't have an open stream (e.g. one-shot CLI tools).
*
* Owner: T-1.5
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import { sendKey, sendKeys, sendPaste } from "../../tmux/input.js";
import { getSession } from "../../tmux/manager.js";
import { readBody, sendJson } from "../util.js";
export async function handleInput(
req: IncomingMessage,
res: ServerResponse,
sessionId: string,
): Promise<void> {
const session = await getSession(sessionId).catch(() => null);
if (!session) {
sendJson(res, 404, {
error: "session_not_found",
message: `Session "${sessionId}" not found`,
});
return;
}
let body: unknown;
try {
body = JSON.parse(await readBody(req));
} catch {
sendJson(res, 400, { error: "bad_request", message: "Invalid JSON body" });
return;
}
if (!body || typeof body !== "object") {
sendJson(res, 400, {
error: "bad_request",
message: "Body must be a JSON object",
});
return;
}
const m = body as Record<string, unknown>;
try {
switch (m.type) {
case "key": {
if (typeof m.name !== "string") {
sendJson(res, 400, {
error: "bad_request",
message: "key.name must be a string",
});
return;
}
await sendKey(sessionId, m.name);
break;
}
case "keys": {
if (typeof m.data !== "string") {
sendJson(res, 400, {
error: "bad_request",
message: "keys.data must be a string",
});
return;
}
await sendKeys(sessionId, m.data);
break;
}
case "paste": {
if (typeof m.data !== "string") {
sendJson(res, 400, {
error: "bad_request",
message: "paste.data must be a string",
});
return;
}
await sendPaste(sessionId, m.data);
break;
}
default:
sendJson(res, 400, {
error: "bad_request",
message: `Unknown type: ${String(m.type)}`,
});
return;
}
} catch (err) {
sendJson(res, 500, { error: "internal_error", message: String(err) });
return;
}
res.writeHead(204).end();
}

View File

@ -0,0 +1,168 @@
/**
* S-09 multi-session CRUD routes.
*
* POST /sessions { id, name, state, lastOutputAt }
* GET /sessions [{ id, name, description, state, lastOutputAt }]
* PATCH /sessions/:id updates @description
* DELETE /sessions/:id kills tmux session, optionally clears buffer
* GET /sessions/:id/thumbnail text/plain capture-pane (40×12)
*
* Owner: T-1.6
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import { BufferWriter } from "../../buffer/writer.js";
import {
getSession,
killSession,
listSessions,
setDescription,
spawnSession,
} from "../../tmux/manager.js";
import { captureThumbnail } from "../../tmux/snapshot.js";
import { readBody, sendJson } from "../util.js";
export async function handleSessions(
req: IncomingMessage,
res: ServerResponse,
sessionId?: string,
sub?: string,
): Promise<void> {
const method = req.method?.toUpperCase();
// GET /sessions/:id/thumbnail
if (sessionId && sub === "thumbnail" && method === "GET") {
const session = await getSession(sessionId).catch(() => null);
if (!session) {
sendJson(res, 404, { error: "session_not_found" });
return;
}
const text = await captureThumbnail(sessionId).catch(() => "");
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end(text);
return;
}
// /sessions/:id — PATCH / DELETE
if (sessionId && !sub) {
if (method === "PATCH") {
await handlePatch(req, res, sessionId);
return;
}
if (method === "DELETE") {
await handleDelete(req, res, sessionId);
return;
}
sendJson(res, 405, { error: "method_not_allowed" });
return;
}
// /sessions — GET / POST
if (!sessionId) {
if (method === "GET") {
await handleList(res);
return;
}
if (method === "POST") {
await handleCreate(req, res);
return;
}
sendJson(res, 405, { error: "method_not_allowed" });
return;
}
sendJson(res, 404, { error: "not_found" });
}
async function handleList(res: ServerResponse): Promise<void> {
try {
const sessions = await listSessions();
const payload = sessions.map((s) => ({
id: s.id,
name: s.name,
description: s.description,
state: "idle", // T-1.4 events will feed real state in Phase 2
lastOutputAt: s.lastActivityAt,
}));
sendJson(res, 200, payload);
} catch (err) {
sendJson(res, 500, { error: "internal_error", message: String(err) });
}
}
async function handleCreate(
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
let body: Record<string, unknown> = {};
try {
const raw = await readBody(req);
if (raw.trim()) body = JSON.parse(raw) as Record<string, unknown>;
} catch {
sendJson(res, 400, { error: "bad_request", message: "Invalid JSON" });
return;
}
const name =
typeof body.name === "string" && body.name.trim()
? body.name.trim()
: `session-${Date.now()}`;
try {
const id = await spawnSession({ name });
// Include state + lastOutputAt to match the GET /sessions response shape
// so iOS clients can decode the response with the same type.
sendJson(res, 201, { id, name, state: "idle", lastOutputAt: "" });
} catch (err) {
sendJson(res, 500, { error: "internal_error", message: String(err) });
}
}
async function handlePatch(
req: IncomingMessage,
res: ServerResponse,
sessionId: string,
): Promise<void> {
const session = await getSession(sessionId).catch(() => null);
if (!session) {
sendJson(res, 404, { error: "session_not_found" });
return;
}
let body: Record<string, unknown> = {};
try {
const raw = await readBody(req);
if (raw.trim()) body = JSON.parse(raw) as Record<string, unknown>;
} catch {
sendJson(res, 400, { error: "bad_request", message: "Invalid JSON" });
return;
}
if (typeof body.description === "string") {
await setDescription(sessionId, body.description);
}
sendJson(res, 200, { id: sessionId, description: body.description });
}
async function handleDelete(
_req: IncomingMessage,
res: ServerResponse,
sessionId: string,
): Promise<void> {
const session = await getSession(sessionId).catch(() => null);
if (!session) {
sendJson(res, 404, { error: "session_not_found" });
return;
}
try {
await killSession(sessionId);
// Optionally clear buffer
const writer = new BufferWriter(sessionId);
await writer.delete().catch(() => {}); // best-effort
res.writeHead(204).end();
} catch (err) {
sendJson(res, 500, { error: "internal_error", message: String(err) });
}
}

View File

@ -0,0 +1,119 @@
/**
* S-07 state side-channel route.
*
* WS endpoint: GET /sessions/:id/side
*
* Pushes IC-1 ServerToClient JSON frames (state, session-meta, error).
* Does NOT carry binary output (that's /stream).
* Lightweight channel for UI state updates without full output stream.
*
* Owner: T-1.6
*/
import type { IncomingMessage } from "node:http";
import type { Socket } from "node:net";
import type { StateEvent } from "../../pi/events.js";
import { getSession } from "../../tmux/manager.js";
import type { WsClient, WsServer } from "../types.js";
export interface SideRouteOptions {
wss: WsServer;
isAuthenticated: (req: IncomingMessage) => boolean;
getCurrentState?: () => StateEvent | null;
}
// Subscribers per session: sessionId → set of ws clients
const _subscribers = new Map<string, Set<WsClient>>();
/**
* Broadcast a state event to all side-channel subscribers of a session.
*/
export function broadcastState(sessionId: string, event: StateEvent): void {
const subs = _subscribers.get(sessionId);
if (!subs) return;
const msg = JSON.stringify({
type: "state",
value: event.value,
tool: event.tool,
ts: event.ts,
});
for (const ws of subs) {
if (ws.readyState === 1 /* OPEN */) {
ws.send(msg);
}
}
}
/**
* Handle a WebSocket upgrade for /sessions/:id/side.
*/
export function handleSideUpgrade(
sessionId: string,
request: IncomingMessage,
socket: Socket,
head: Buffer,
opts: SideRouteOptions,
): void {
opts.wss.handleUpgrade(request, socket, head, (ws: WsClient) => {
handleSideConnection(sessionId, ws, opts);
});
}
async function handleSideConnection(
sessionId: string,
ws: WsClient,
opts: SideRouteOptions,
): Promise<void> {
const session = await getSession(sessionId).catch(() => null);
if (!session) {
ws.send(
JSON.stringify({
type: "error",
code: "session_not_found",
message: `Session "${sessionId}" not found`,
}),
);
ws.terminate();
return;
}
// Push session-meta on connect
ws.send(
JSON.stringify({
type: "session-meta",
name: session.name,
description: session.description,
createdAt: session.createdAt,
}),
);
// Push current state
const currentState = opts.getCurrentState?.();
if (currentState) {
ws.send(
JSON.stringify({
type: "state",
value: currentState.value,
tool: currentState.tool,
ts: currentState.ts,
}),
);
}
// Register subscriber
let subs = _subscribers.get(sessionId);
if (!subs) {
subs = new Set();
_subscribers.set(sessionId, subs);
}
subs.add(ws);
ws.on("close", () => {
subs?.delete(ws);
if (subs?.size === 0) _subscribers.delete(sessionId);
});
ws.on("error", () => {
subs?.delete(ws);
});
}

View File

@ -0,0 +1,261 @@
/**
* S-02 binary stream + S-04 sequence cursor resume + S-05 snapshot route.
*
* WS endpoint: GET /sessions/:id/stream
*
* Protocol (IC-1):
* - On connect: client sends { type: "resume"; lastSeq: number | null }
* - Server replays buffer chunks after lastSeq (binary frames with 8-byte seq header)
* - Then live output arrives as binary frames
* - Client may send { type: "snapshot-request" } server sends { type: "snapshot"; ... }
* - State events pushed unsolicited: { type: "state"; ... }
* - Session meta pushed on connect: { type: "session-meta"; ... }
*
* Binary frame format: [seq: 8 bytes BE uint64][data: N bytes]
*
* Owner: T-1.5
*/
import type { IncomingMessage } from "node:http";
import type { Socket } from "node:net";
import { readChunks } from "../../buffer/reader.js";
import type { StateEvent } from "../../pi/events.js";
import { SequenceCounter } from "../../sequence.js";
import { ControlClient } from "../../tmux/control.js";
import { sendKey, sendKeys, sendPaste } from "../../tmux/input.js";
import { resizeSession } from "../../tmux/manager.js";
import { capturePane } from "../../tmux/snapshot.js";
import type { WsClient, WsServer } from "../types.js";
export interface StreamRouteOptions {
wss: WsServer;
isAuthenticated: (req: IncomingMessage) => boolean;
/** Called to get the current agent state for new connections */
getCurrentState?: () => StateEvent | null;
stateDir?: string;
}
// Per-session registry of active ControlClients and sequence counters
const _clients = new Map<
string,
{ control: ControlClient; seq: SequenceCounter }
>();
/**
* Get or create a ControlClient + SequenceCounter for a session.
*/
function getOrCreateSession(sessionId: string): {
control: ControlClient;
seq: SequenceCounter;
} {
const existing = _clients.get(sessionId);
if (existing) return existing;
const seq = new SequenceCounter();
const control = new ControlClient({
session: sessionId,
onClose: (reason) => {
console.error(
`[stream] control client closed for ${sessionId}: ${reason}`,
);
_clients.delete(sessionId);
},
});
control.start();
const entry = { control, seq };
_clients.set(sessionId, entry);
return entry;
}
/**
* Stop and remove a session's ControlClient (call on session kill).
*/
export function stopSession(sessionId: string): void {
const entry = _clients.get(sessionId);
if (entry) {
entry.control.stop();
_clients.delete(sessionId);
}
}
/**
* Handle a WebSocket upgrade for /sessions/:id/stream.
*/
export function handleStreamUpgrade(
sessionId: string,
request: IncomingMessage,
socket: Socket,
head: Buffer,
opts: StreamRouteOptions,
): void {
opts.wss.handleUpgrade(request, socket, head, (ws: WsClient) => {
handleStreamConnection(sessionId, ws, opts);
});
}
function handleStreamConnection(
sessionId: string,
ws: WsClient,
opts: StreamRouteOptions,
): void {
const { control, seq } = getOrCreateSession(sessionId);
let resumed = false;
// Push session-meta immediately
sendJson(ws, {
type: "session-meta",
name: sessionId,
createdAt: new Date().toISOString(),
});
// Push current state if available
const currentState = opts.getCurrentState?.();
if (currentState) {
sendJson(ws, {
type: "state",
value: currentState.value,
tool: currentState.tool,
ts: currentState.ts,
});
}
// Subscribe to live output
const unsubscribe = control.subscribe((chunk: Buffer) => {
if (ws.readyState !== 1 /* OPEN */) return;
const seqNum = seq.next();
const frame = buildBinaryFrame(seqNum, chunk);
sendBinary(ws, frame);
});
// Handle client messages
ws.on("message", (data: Buffer) => {
let msg: unknown;
try {
msg = JSON.parse(data.toString());
} catch {
sendJson(ws, {
type: "error",
code: "bad_message",
message: "Invalid JSON",
});
return;
}
if (!msg || typeof msg !== "object") return;
const m = msg as Record<string, unknown>;
if (m.type === "resume" && !resumed) {
resumed = true;
const lastSeq = typeof m.lastSeq === "number" ? m.lastSeq : 0;
// Replay buffered chunks after lastSeq
const chunks = readChunks(sessionId, {
afterSeq: lastSeq,
cfg: opts.stateDir ? { stateDir: opts.stateDir } : undefined,
});
for (const chunk of chunks) {
if (ws.readyState !== 1) break;
sendBinary(ws, buildBinaryFrame(chunk.seq, chunk.data));
}
} else if (m.type === "resize") {
// IC-1 extension: client reports its actual terminal dimensions.
// Resize the tmux window so line-wrapping matches what the client sees.
const cols = typeof m.cols === "number" ? m.cols : 80;
const rows = typeof m.rows === "number" ? m.rows : 24;
resizeSession(sessionId, cols, rows).catch(() => {});
} else if (m.type === "keys") {
if (typeof m.data !== "string") {
sendJson(ws, {
type: "error",
code: "bad_input",
message: "keys.data must be a string",
});
return;
}
sendKeys(sessionId, m.data).catch((err) => {
sendJson(ws, {
type: "error",
code: "bad_input",
message: `Failed to send keys: ${String(err)}`,
});
});
} else if (m.type === "key") {
if (typeof m.name !== "string") {
sendJson(ws, {
type: "error",
code: "bad_input",
message: "key.name must be a string",
});
return;
}
sendKey(sessionId, m.name).catch((err) => {
sendJson(ws, {
type: "error",
code: "bad_input",
message: `Failed to send key: ${String(err)}`,
});
});
} else if (m.type === "paste") {
if (typeof m.data !== "string") {
sendJson(ws, {
type: "error",
code: "bad_input",
message: "paste.data must be a string",
});
return;
}
sendPaste(sessionId, m.data).catch((err) => {
sendJson(ws, {
type: "error",
code: "bad_input",
message: `Failed to send paste: ${String(err)}`,
});
});
} else if (m.type === "snapshot-request") {
capturePane({ session: sessionId, escapes: true })
.then((text) => {
const data = Buffer.from(text).toString("base64");
const s = seq.next();
sendJson(ws, { type: "snapshot", seq: s, data });
})
.catch(() => {
sendJson(ws, {
type: "error",
code: "snapshot_failed",
message: "Failed to capture pane",
});
});
}
});
ws.on("close", () => {
unsubscribe();
});
ws.on("error", () => {
unsubscribe();
});
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build a binary frame: [seq: 8 bytes BE][data] */
function buildBinaryFrame(seqNum: number, data: Buffer): Buffer {
const header = Buffer.allocUnsafe(8);
header.writeBigUInt64BE(BigInt(seqNum), 0);
return Buffer.concat([header, data]);
}
function sendJson(ws: WsClient, msg: object): void {
if (ws.readyState === 1 /* OPEN */) {
ws.send(JSON.stringify(msg));
}
}
function sendBinary(ws: WsClient, data: Buffer): void {
if (ws.readyState === 1 /* OPEN */) {
(ws as unknown as { send(data: Buffer): void }).send(data);
}
}

View File

@ -0,0 +1,500 @@
/**
* HTTP + WebSocket server bootstrap for remote-control.
*
* Responsible for:
* - Creating and configuring the HTTP server (middleware, TLS in future T-1.3)
* - Wiring up the WebSocket upgrade handler (see upgrade.ts)
* - Serving LEGACY routes for the browser HTML client (html.ts)
* - Managing the connected-client set and the broadcast helper
* - Returning a RemoteServer handle to the extension entry point (index.ts)
*
* LEGACY routes (kept until Phase 2 ships and html.ts is retired):
* GET / serves the browser HTML UI (buildHTML from html.ts)
* GET /index.html same as /
* GET /manifest.json PWA manifest
* GET /icon.svg PWA icon
* WS /ws WebSocket endpoint for the browser client
*
* Future route modules live under routes/ and are wired here once they
* land in T-1.5, T-1.6, T-1.7.
*/
import { randomBytes } from "node:crypto";
import type { IncomingMessage } from "node:http";
import { createServer } from "node:http";
import { createRequire } from "node:module";
import type { AddressInfo } from "node:net";
import type {
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import {
buildPairingUrl,
consumePairingToken,
generatePairingToken,
printPairingQr,
} from "../auth/pairing.js";
import {
createToken,
validateBearer,
validateBearerSync,
warmTokenCache,
} from "../auth/tokens.js";
import {
generateSessionId,
loadOrCreateToken,
parseCookies,
SESSION_COOKIE,
validateToken,
} from "../auth.js";
import { parseBindAddress, readRemoteControlConfig } from "../config.js";
import { buildHTML } from "../html.js"; // LEGACY: browser HTML client
import { buildSyncMessage } from "../messages.js";
import { handleCommands } from "./routes/commands.js";
import { handleHealth } from "./routes/health.js";
import { handleInput } from "./routes/input.js";
import { handleSessions } from "./routes/sessions.js";
import type { RemoteServer, WsClient, WsServer } from "./types.js";
import { createUpgradeHandler } from "./upgrade.js";
import { extractBearer, readBody, sendJson } from "./util.js";
// ---------------------------------------------------------------------------
// Load ws (bundled with pi) without needing @types/ws installed locally
// ---------------------------------------------------------------------------
const _require = createRequire(import.meta.url);
const wsModule = _require("ws") as {
WebSocketServer: new (opts: { noServer: boolean }) => WsServer;
OPEN: number;
};
const { WebSocketServer, OPEN } = wsModule;
// ---------------------------------------------------------------------------
// startServer
// ---------------------------------------------------------------------------
export async function startServer(
pi: ExtensionAPI,
ctx: ExtensionContext,
): Promise<RemoteServer> {
const config = await readRemoteControlConfig();
const bindAddr = config.bindAddress ?? "";
const { host: bindHost, port: bindPort } = bindAddr
? parseBindAddress(bindAddr)
: { host: "127.0.0.1", port: 0 };
const clientChangeListeners: Array<() => void> = [];
const clients = new Set<WsClient>();
const token = await loadOrCreateToken();
await warmTokenCache(); // pre-load multi-tokens into sync cache
// Map of valid session IDs → expiry timestamp (ms since epoch)
const SESSION_TTL_MS = 86_400_000; // 24 h — matches cookie Max-Age
const validSessions = new Map<string, number>();
const pruneExpiredSessions = (): void => {
const now = Date.now();
for (const [id, expiresAt] of validSessions) {
if (expiresAt <= now) validSessions.delete(id);
}
};
/** Check if a request is authenticated.
* Accepts: session cookie | legacy ?token= | Authorization: Bearer <multi-token>
*/
function isAuthenticated(req: IncomingMessage): boolean {
// 1. Session cookie (legacy browser HTML client)
const cookies = parseCookies(req.headers.cookie);
const sessionId = cookies[SESSION_COOKIE];
const sessionExpiry = sessionId ? validSessions.get(sessionId) : undefined;
if (sessionExpiry !== undefined && sessionExpiry > Date.now()) return true;
// 2. Legacy single token via query param (smoke tests, CLI)
const url = new URL(req.url ?? "/", "http://localhost");
const providedToken = url.searchParams.get("token");
if (providedToken && validateToken(providedToken, token)) return true;
// 3. Bearer token via Authorization header or ?token= (iOS app, multi-token)
const bearer = extractBearer(req);
if (bearer) {
if (validateToken(bearer, token)) return true;
if (validateBearerSync(bearer)) return true; // multi-token sync cache
}
return false;
}
/** Async auth check — also validates multi-token bearer from auth/tokens store */
async function isAuthenticatedAsync(req: IncomingMessage): Promise<boolean> {
if (isAuthenticated(req)) return true;
const bearer = extractBearer(req);
if (bearer) {
const entry = await validateBearer(bearer).catch(() => null);
if (entry) return true;
}
return false;
}
function broadcast(msg: object): void {
const data = JSON.stringify(msg);
for (const client of clients) {
if (client.readyState === OPEN) {
try {
client.send(data);
} catch {
/* ignore */
}
}
}
}
function sync(currentCtx: ExtensionContext): void {
broadcast(buildSyncMessage(currentCtx));
}
// ── HTTP server ─────────────────────────────────────────────────────────
const httpServer = createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
const pathname = url.pathname;
// LEGACY: PWA manifest — served for the browser HTML client
if (pathname === "/manifest.json") {
res.writeHead(200, {
"Content-Type": "application/manifest+json; charset=utf-8",
});
res.end(
JSON.stringify({
name: "Pi Remote",
short_name: "Pi",
description: "Remote control for Pi sessions",
start_url: "/",
display: "standalone",
background_color: "#0d1117",
theme_color: "#0d1117",
icons: [{ src: "/icon.svg", sizes: "any", type: "image/svg+xml" }],
}),
);
return;
}
// LEGACY: PWA icon — served for the browser HTML client
if (pathname === "/icon.svg") {
res.writeHead(200, {
"Content-Type": "image/svg+xml",
"Cache-Control": "public, max-age=86400",
});
res.end(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180"><rect width="180" height="180" rx="40" fill="#0d1117"/><text x="90" y="133" font-family="-apple-system,Helvetica,Arial,sans-serif" font-size="110" text-anchor="middle" fill="#3fb950">π</text></svg>`,
);
return;
}
// LEGACY: browser HTML client root route
if (pathname === "/" || pathname === "/index.html") {
const cookies = parseCookies(req.headers.cookie);
const sc = cookies[SESSION_COOKIE];
const hasValidSession =
sc !== undefined && (validSessions.get(sc) ?? 0) > Date.now();
const providedToken = url.searchParams.get("token");
const hasValidToken =
providedToken && validateToken(providedToken, token);
if (!hasValidSession && !hasValidToken) {
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
res.end(
"Forbidden — valid token required. Use the URL shown in the pi terminal.",
);
return;
}
// If authenticated via token (first visit), issue a session cookie and redirect to clean URL
if (!hasValidSession && hasValidToken) {
pruneExpiredSessions();
const sessionId = generateSessionId();
validSessions.set(sessionId, Date.now() + SESSION_TTL_MS);
res.writeHead(302, {
"Set-Cookie": `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
Location: "/",
});
res.end();
return;
}
// Valid session cookie — serve the browser HTML UI
const nonce = randomBytes(16).toString("base64");
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; manifest-src 'self'; base-uri 'none'`,
});
res.end(buildHTML(nonce)); // LEGACY: renders the browser HTML client
return;
}
// New API routes (IC-2) — bearer token auth
const asyncHandler = async (): Promise<boolean> => {
// GET /pair-qr — generates pairing token + prints QR to response
if (pathname === "/pair-qr" && req.method === "GET") {
if (!isAuthenticated(req)) {
sendJson(res, 403, { error: "forbidden" });
return true;
}
const addr = httpServer.address() as {
address: string;
port: number;
} | null;
const port = addr?.port ?? 7777;
const rawHost =
(config.publicBaseUrl ?? config.advertisedBaseUrl)
?.replace(/^https?:\/\//, "")
.replace(/\/$/, "") ??
addr?.address ??
"localhost";
// Strip :port suffix if publicBaseUrl already contains it
const host = rawHost.includes(":")
? rawHost.slice(0, rawHost.lastIndexOf(":"))
: rawHost;
const pairingEntry = generatePairingToken();
const url = buildPairingUrl({
host,
port,
pairingToken: pairingEntry.token,
fingerprint: "NO-TLS-YET-REPLACE-IN-T-1-3",
sidecarName: "pi-remote",
});
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
// Render QR inline
const qrcode = await import("qrcode");
const qr = await qrcode.toString(url, {
type: "terminal",
small: true,
});
res.end(`${qr}\n${url}\n\nExpires in 10 minutes.`);
return true;
}
// POST /pair — unauthenticated, uses one-time pairingToken
if (pathname === "/pair" && req.method === "POST") {
let body: Record<string, unknown> = {};
try {
const raw = await readBody(req);
if (raw.trim()) body = JSON.parse(raw) as Record<string, unknown>;
} catch {
sendJson(res, 400, { error: "bad_request", message: "Invalid JSON" });
return true;
}
const pairingToken =
typeof body.pairingToken === "string" ? body.pairingToken : null;
if (!pairingToken || !consumePairingToken(pairingToken)) {
sendJson(res, 403, {
error: "invalid_pairing_token",
message: "Pairing token invalid or expired",
});
return true;
}
const deviceName =
typeof body.deviceName === "string" ? body.deviceName : "iOS device";
const entry = await createToken(deviceName);
sendJson(res, 200, { bearerToken: entry.token, sidecarId: entry.id });
return true;
}
// All other API routes require auth
const isApiAuthed = await isAuthenticatedAsync(req);
if (!isApiAuthed) return false;
// GET /health
if (pathname === "/health" && req.method === "GET") {
await handleHealth(req, res);
return true;
}
// /sessions — list + create
if (pathname === "/sessions") {
await handleSessions(req, res);
return true;
}
// /sessions/:id — PATCH, DELETE
const sessMatch = pathname.match(/^\/sessions\/([^/]+)$/);
if (sessMatch) {
await handleSessions(req, res, decodeURIComponent(sessMatch[1]));
return true;
}
// /sessions/:id/thumbnail
const thumbMatch = pathname.match(/^\/sessions\/([^/]+)\/thumbnail$/);
if (thumbMatch) {
await handleSessions(
req,
res,
decodeURIComponent(thumbMatch[1]),
"thumbnail",
);
return true;
}
// /sessions/:id/commands
const cmdMatch = pathname.match(/^\/sessions\/([^/]+)\/commands$/);
if (cmdMatch) {
await handleCommands(req, res, decodeURIComponent(cmdMatch[1]), pi);
return true;
}
// /sessions/:id/input
const inputMatch = pathname.match(/^\/sessions\/([^/]+)\/input$/);
if (inputMatch) {
await handleInput(req, res, decodeURIComponent(inputMatch[1]));
return true;
}
return false;
};
asyncHandler()
.then((handled) => {
if (!handled) {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not found");
}
})
.catch((err: Error) => {
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
res.end(err.message);
});
});
// ── WebSocket server ────────────────────────────────────────────────────
const wss = new WebSocketServer({ noServer: true });
httpServer.on("error", (err: Error) => {
console.error("[remote-control] httpServer error:", err.message);
});
wss.on("error", (err: Error) => {
console.error("[remote-control] wss error:", err.message);
});
// Wire upgrade handler (see upgrade.ts for path routing)
httpServer.on("upgrade", createUpgradeHandler(wss, isAuthenticated));
// ── LEGACY: browser HTML client WebSocket connection handling ───────────
wss.on("connection", (ws: WsClient) => {
clients.add(ws);
for (const cb of clientChangeListeners) cb();
// Send full state snapshot to the new client
try {
ws.send(JSON.stringify(buildSyncMessage(ctx)));
} catch {
/* client disconnected before first send */
}
// Per-connection rate limiting: max 30 prompts per 60 seconds
const RATE_WINDOW_MS = 60_000;
const RATE_MAX = 30;
const MAX_MSG_BYTES = 64 * 1024;
const recentPrompts: number[] = [];
ws.on("message", (data: Buffer) => {
if (data.length > MAX_MSG_BYTES) return;
let msg: { type?: string; text?: string };
try {
const parsed: unknown = JSON.parse(data.toString());
if (typeof parsed !== "object" || parsed === null) return;
msg = parsed as { type?: string; text?: string };
} catch {
return;
}
if (msg.type === "stop") {
if (!ctx.isIdle()) {
ctx.abort();
}
return;
}
if (
msg.type === "prompt" &&
typeof msg.text === "string" &&
msg.text.trim()
) {
const text = msg.text.trim();
// Sliding-window rate limit
const now = Date.now();
const cutoff = now - RATE_WINDOW_MS;
while (recentPrompts.length > 0 && recentPrompts[0] < cutoff)
recentPrompts.shift();
if (recentPrompts.length >= RATE_MAX) return;
recentPrompts.push(now);
if (ctx.isIdle()) {
pi.sendUserMessage(text);
} else {
pi.sendUserMessage(text, { deliverAs: "followUp" });
}
}
});
const onClose = (): void => {
clients.delete(ws);
broadcast({ type: "status", clientCount: clients.size });
for (const cb of clientChangeListeners) cb();
};
ws.on("close", onClose);
ws.on("error", onClose);
});
// ── Listen ───────────────────────────────────────────────────────────────
return new Promise((resolve) => {
httpServer.listen(bindPort, bindHost, () => {
resolve({
broadcast,
sync,
stop: () =>
new Promise<void>((res) => {
// Forcefully kill all WebSocket clients — terminate() sends no
// close frame and doesn't wait for the remote end to acknowledge,
// so it can't hang on an unresponsive client.
for (const client of clients) {
try {
client.terminate();
} catch {
/* ignore */
}
}
clients.clear();
// Safety timeout — if wss/http shutdown callbacks never fire
// (e.g. lingering keep-alive sockets), resolve anyway so the
// session_shutdown handler doesn't block pi from exiting.
const timeout = setTimeout(() => {
httpServer.close(() => {});
httpServer.closeAllConnections?.();
res();
}, 2000);
wss.close(() =>
httpServer.close(() => {
clearTimeout(timeout);
res();
}),
);
}),
clientCount: () => clients.size,
onClientChange: (cb: () => void) => {
clientChangeListeners.push(cb);
},
get port() {
return (httpServer.address() as AddressInfo | null)?.port ?? 0;
},
get token() {
return token;
},
});
});
});
}

View File

@ -0,0 +1,51 @@
/**
* Shared type definitions for the remote-control server.
*
* These interfaces describe the WebSocket client/server shapes loaded
* dynamically from the `ws` module bundled with pi, and the public surface
* of the HTTP+WS server returned to callers.
*/
import type { IncomingMessage } from "node:http";
import type { Socket } from "node:net";
// ---------------------------------------------------------------------------
// WebSocket interfaces (ws module loaded via createRequire — no @types/ws)
// ---------------------------------------------------------------------------
export interface WsClient {
readyState: number;
send(data: string): void;
terminate(): void;
on(event: "message", listener: (data: Buffer) => void): void;
on(event: "close" | "error", listener: () => void): void;
}
export interface WsServer {
on(event: "connection", listener: (ws: WsClient) => void): void;
on(event: "error", listener: (err: Error) => void): void;
handleUpgrade(
request: IncomingMessage,
socket: Socket,
head: Buffer,
cb: (ws: WsClient) => void,
): void;
emit(event: string, ...args: unknown[]): void;
close(cb?: () => void): void;
}
// ---------------------------------------------------------------------------
// Public server handle
// ---------------------------------------------------------------------------
export interface RemoteServer {
broadcast: (msg: object) => void;
sync: (
ctx: import("@earendil-works/pi-coding-agent").ExtensionContext,
) => void;
stop: () => Promise<void>;
clientCount: () => number;
onClientChange: (cb: () => void) => void;
port: number;
token: string;
}

View File

@ -0,0 +1,92 @@
/**
* WebSocket upgrade routing.
*
* Routes incoming HTTP Upgrade requests to the appropriate WebSocket handler
* based on the request path and session/topic. Non-matching paths are
* destroyed immediately.
*
* Routes:
* /ws LEGACY browser HTML client WebSocket endpoint
* /sessions/:id/stream binary ANSI stream per tmux session (T-1.5)
*/
import type { IncomingMessage } from "node:http";
import type { Socket } from "node:net";
import { handleSideUpgrade, type SideRouteOptions } from "./routes/side.js";
import {
handleStreamUpgrade,
type StreamRouteOptions,
} from "./routes/stream.js";
import type { WsClient, WsServer } from "./types.js";
export interface UpgradeHandlerOptions {
wss: WsServer;
isAuthenticated: (req: IncomingMessage) => boolean;
stream: Omit<StreamRouteOptions, "wss" | "isAuthenticated">;
side: Omit<SideRouteOptions, "wss" | "isAuthenticated">;
}
/**
* Create the HTTP `upgrade` event handler.
*
* @param opts - Handler options including wss, auth predicate, and stream config.
* @returns A handler suitable for `httpServer.on("upgrade", handler)`.
*/
export function createUpgradeHandler(
wss: WsServer,
isAuthenticated: (req: IncomingMessage) => boolean,
streamOpts?: Omit<StreamRouteOptions, "wss" | "isAuthenticated">,
): (request: IncomingMessage, socket: Socket, head: Buffer) => void {
return (request: IncomingMessage, socket: Socket, head: Buffer): void => {
const url = new URL(request.url ?? "/", "http://localhost");
if (url.pathname === "/ws") {
// LEGACY: browser HTML client WebSocket endpoint — auth guard
if (!isAuthenticated(request)) {
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws: WsClient) => {
wss.emit("connection", ws, request);
});
return;
}
// /sessions/:id/stream
const streamMatch = url.pathname.match(/^\/sessions\/([^/]+)\/stream$/);
if (streamMatch) {
if (!isAuthenticated(request)) {
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
return;
}
const sessionId = decodeURIComponent(streamMatch[1]);
handleStreamUpgrade(sessionId, request, socket, head, {
wss,
isAuthenticated,
...streamOpts,
});
return;
}
// /sessions/:id/side
const sideMatch = url.pathname.match(/^\/sessions\/([^/]+)\/side$/);
if (sideMatch) {
if (!isAuthenticated(request)) {
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
return;
}
const sessionId = decodeURIComponent(sideMatch[1]);
handleSideUpgrade(sessionId, request, socket, head, {
wss,
isAuthenticated,
});
return;
}
// Unknown upgrade path — reject
socket.destroy();
};
}

View File

@ -0,0 +1,47 @@
/**
* Shared HTTP server utilities for route handlers.
* Owner: T-1.5
*/
import type { IncomingMessage, ServerResponse } from "node:http";
/** Read the full request body as a string. */
export function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
/** Send a JSON response. */
export function sendJson(
res: ServerResponse,
status: number,
body: unknown,
): void {
const payload = JSON.stringify(body);
res.writeHead(status, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
});
res.end(payload);
}
/** Extract a path segment by index (0-based, after splitting on '/'). */
export function pathSegment(url: string, index: number): string | undefined {
return url.split("?")[0].split("/").filter(Boolean)[index];
}
/**
* Parse bearer token from Authorization header or ?token= query param.
* Returns the raw token string or null.
*/
export function extractBearer(req: IncomingMessage): string | null {
const auth = req.headers["authorization"];
if (auth?.startsWith("Bearer ")) return auth.slice(7).trim();
if (auth?.startsWith("bearer ")) return auth.slice(7).trim();
const url = new URL(req.url ?? "/", "http://localhost");
return url.searchParams.get("token");
}

View File

@ -1,219 +0,0 @@
#!/usr/bin/env ts-node
/**
* Phase 0.5 Spike tmux Control Mode
*
* This is a throwaway PoC to evaluate tmux control mode as an alternative
* to pipe-pane for streaming pi output.
*
* Usage:
* npm run spike-cc
*
* Then in another terminal:
* tmux attach -t pi-cc # Verify parallel attach works
*/
import { spawn, ChildProcess } from 'child_process';
import * as readline from 'readline';
const SESSION_NAME = 'pi-cc';
interface Stats {
outputEvents: number;
bytesReceived: number;
otherEvents: number;
startTime: number;
}
const stats: Stats = {
outputEvents: 0,
bytesReceived: 0,
otherEvents: 0,
startTime: Date.now(),
};
/**
* Decode tmux's octal-escaped output format.
* Example: "hello\\012world" -> "hello\nworld"
*/
function decodeOctalEscapes(input: string): Buffer {
const chunks: number[] = [];
let i = 0;
while (i < input.length) {
if (input[i] === '\\' && i + 3 < input.length) {
// Check if next 3 chars are octal digits
const octalStr = input.substring(i + 1, i + 4);
if (/^[0-7]{3}$/.test(octalStr)) {
const charCode = parseInt(octalStr, 8);
chunks.push(charCode);
i += 4;
continue;
}
}
// Regular character
chunks.push(input.charCodeAt(i));
i++;
}
return Buffer.from(chunks);
}
/**
* Parse a control mode notification line.
*/
function parseNotification(line: string): { type: string; data: string } | null {
if (!line.startsWith('%')) {
return null;
}
const spaceIdx = line.indexOf(' ');
if (spaceIdx === -1) {
return { type: line.substring(1), data: '' };
}
return {
type: line.substring(1, spaceIdx),
data: line.substring(spaceIdx + 1),
};
}
/**
* Handle a %output notification.
*/
function handleOutputEvent(data: string): void {
// Format: %output %<pane-id> <value>
const parts = data.split(' ', 2);
if (parts.length < 2) {
console.error('[spike-cc] Invalid %output format:', data);
return;
}
const paneId = parts[0]; // e.g., "%1"
const escapedValue = parts[1];
// Decode octal escapes and write to stdout
const decoded = decodeOctalEscapes(escapedValue);
stats.outputEvents++;
stats.bytesReceived += decoded.length;
// Write to stdout (this is our streaming output)
process.stdout.write(decoded);
}
/**
* Main control mode loop.
*/
function runControlMode(): void {
console.error('[spike-cc] Starting tmux control mode spike...');
console.error('[spike-cc] Session name:', SESSION_NAME);
console.error('[spike-cc] Creating detached session first...');
// Step 1: Create detached session with pi
const createSession = spawn('tmux', [
'new-session',
'-d',
'-s', SESSION_NAME,
'-x', '120',
'-y', '40',
'pi'
]);
createSession.on('close', (code) => {
if (code !== 0) {
console.error('[spike-cc] Failed to create session, code:', code);
console.error('[spike-cc] (This is OK if session already exists)');
}
// Step 2: Attach in control mode
console.error('[spike-cc] Attaching in control mode...');
console.error('[spike-cc] ---------------------------------------------');
console.error('[spike-cc] Pi output will stream below (raw bytes).');
console.error('[spike-cc] Stats will be written to stderr every 10s.');
console.error('[spike-cc] To attach normally: tmux attach -t', SESSION_NAME);
console.error('[spike-cc] ---------------------------------------------');
const tmuxControl = spawn('tmux', ['-C', 'attach', '-t', SESSION_NAME], {
stdio: ['pipe', 'pipe', 'pipe'],
});
// Keep stdin open (don't send EOF)
// This prevents tmux from thinking the control client disconnected
// Read control mode output line by line
const rl = readline.createInterface({
input: tmuxControl.stdout,
crlfDelay: Infinity,
});
rl.on('line', (line) => {
const notification = parseNotification(line);
if (!notification) {
// Not a notification, probably command response
return;
}
if (notification.type === 'output') {
handleOutputEvent(notification.data);
} else if (notification.type === 'exit') {
console.error('\n[spike-cc] Received %exit, shutting down...');
process.exit(0);
} else {
// Other events (layout-change, window-add, etc.)
stats.otherEvents++;
// Log first few for debugging
if (stats.otherEvents <= 5) {
console.error(`[spike-cc] Event: %${notification.type}`);
}
}
});
tmuxControl.stderr.on('data', (data) => {
console.error('[spike-cc] tmux stderr:', data.toString());
});
tmuxControl.on('close', (code) => {
console.error('\n[spike-cc] tmux process exited, code:', code);
printFinalStats();
process.exit(code || 0);
});
// Stats reporter
const statsInterval = setInterval(() => {
printStats();
}, 10000);
// Cleanup on exit
process.on('SIGINT', () => {
console.error('\n[spike-cc] Caught SIGINT, cleaning up...');
clearInterval(statsInterval);
tmuxControl.kill();
printFinalStats();
process.exit(0);
});
});
}
function printStats(): void {
const elapsed = ((Date.now() - stats.startTime) / 1000).toFixed(1);
console.error(`\n[spike-cc] === Stats @ ${elapsed}s ===`);
console.error(`[spike-cc] Output events: ${stats.outputEvents}`);
console.error(`[spike-cc] Bytes received: ${stats.bytesReceived}`);
console.error(`[spike-cc] Other events: ${stats.otherEvents}`);
console.error(`[spike-cc] Rate: ${(stats.outputEvents / parseFloat(elapsed)).toFixed(2)} events/s`);
}
function printFinalStats(): void {
const elapsed = ((Date.now() - stats.startTime) / 1000).toFixed(1);
console.error('\n[spike-cc] === Final Stats ===');
console.error(`[spike-cc] Total time: ${elapsed}s`);
console.error(`[spike-cc] Output events: ${stats.outputEvents}`);
console.error(`[spike-cc] Bytes received: ${stats.bytesReceived}`);
console.error(`[spike-cc] Other events: ${stats.otherEvents}`);
}
// Run
runControlMode();

View File

@ -0,0 +1,171 @@
/**
* tmux control-mode client per-session.
*
* Spawns `tmux -C attach -t <session>`, parses `%output` notifications,
* decodes octal-escaped bytes, and broadcasts raw ANSI to subscribers.
*
* Design:
* - One ControlClient instance per tmux session (per-session, not per-server).
* - Subscribers register a callback; each raw Buffer chunk is broadcast.
* - On %exit or process close, all subscribers are notified and removed.
*
* Reference: feat/spike-tmux-cc / spike-cc.ts (Phase 0.5 PoC).
*
* Risk mitigations:
* R4: streaming line-parser, per-line decode, no full-buffer copies.
*
* Owner: T-1.1
*/
import { type ChildProcess, spawn } from "node:child_process";
import { createInterface } from "node:readline";
export type OutputCallback = (chunk: Buffer) => void;
export type CloseCallback = (reason: string) => void;
export interface ControlClientOptions {
session: string;
onClose?: CloseCallback;
}
export class ControlClient {
readonly session: string;
private proc: ChildProcess | null = null;
private subscribers = new Map<symbol, OutputCallback>();
private closed = false;
private onClose?: CloseCallback;
constructor(opts: ControlClientOptions) {
this.session = opts.session;
this.onClose = opts.onClose;
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
start(): void {
if (this.proc) return;
this.closed = false;
// -C = control mode; tmux sends %output events for pane data (do NOT use -CC which bypasses %output)
this.proc = spawn("tmux", ["-C", "attach", "-t", this.session], {
stdio: ["pipe", "pipe", "pipe"],
});
const rl = createInterface({
// biome-ignore lint/style/noNonNullAssertion: stdout is always set when stdio includes 'pipe'
input: this.proc.stdout!,
crlfDelay: Number.POSITIVE_INFINITY,
});
rl.on("line", (line: string) => {
this.parseLine(line);
});
this.proc.stderr?.on("data", (_d: Buffer) => {
// Ignore tmux stderr (status messages). Can log at debug level if needed.
});
this.proc.on("close", (code: number | null) => {
this.closed = true;
this.subscribers.clear();
this.onClose?.(`tmux process exited (code=${code})`);
});
}
stop(): void {
if (this.proc && !this.closed) {
this.proc.kill("SIGTERM");
}
}
get isRunning(): boolean {
return !this.closed && this.proc !== null;
}
// ---------------------------------------------------------------------------
// Subscriptions
// ---------------------------------------------------------------------------
subscribe(cb: OutputCallback): () => void {
const key = Symbol();
this.subscribers.set(key, cb);
return () => this.subscribers.delete(key);
}
// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------
/**
* Parse one line of tmux control-mode output.
*
* Control-mode lines that matter:
* %output %<pane-id> <octal-escaped-bytes>
* %exit [reason]
* Everything else is ignored.
*/
private parseLine(line: string): void {
if (!line.startsWith("%")) return;
const spaceIdx = line.indexOf(" ");
const type = spaceIdx === -1 ? line.slice(1) : line.slice(1, spaceIdx);
const rest = spaceIdx === -1 ? "" : line.slice(spaceIdx + 1);
if (type === "output") {
this.handleOutput(rest);
} else if (type === "exit") {
this.closed = true;
this.subscribers.clear();
this.onClose?.(`%exit ${rest}`);
}
// layout-change, window-add, etc. are ignored
}
/**
* Handle a %output notification.
* Format: %<pane-id> <octal-escaped-value>
*/
private handleOutput(data: string): void {
const spaceIdx = data.indexOf(" ");
if (spaceIdx === -1) return; // malformed, skip
const escapedValue = data.slice(spaceIdx + 1);
const decoded = decodeOctalEscapes(escapedValue);
if (decoded.length === 0) return;
for (const cb of this.subscribers.values()) {
cb(decoded);
}
}
}
// ---------------------------------------------------------------------------
// Octal-escape decoder (from spike-cc.ts, adapted)
// ---------------------------------------------------------------------------
/**
* Decode tmux's octal-escaped output format.
* "hello\\012world" Buffer containing "hello\nworld"
*/
export function decodeOctalEscapes(input: string): Buffer {
// Fast-path: nothing to decode
if (!input.includes("\\")) return Buffer.from(input, "binary");
const bytes: number[] = [];
let i = 0;
while (i < input.length) {
if (input[i] === "\\" && i + 3 < input.length) {
const oct = input.slice(i + 1, i + 4);
if (/^[0-7]{3}$/.test(oct)) {
bytes.push(parseInt(oct, 8));
i += 4;
continue;
}
}
bytes.push(input.charCodeAt(i));
i++;
}
return Buffer.from(bytes);
}

View File

@ -0,0 +1,62 @@
/**
* tmux send-keys input translation.
*
* Translates IC-1 key names (and literal text) into tmux send-keys arguments.
* Used by the input route (T-1.5) to deliver keystrokes to a pane.
*
* Owner: T-1.1
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/** Named keys from IC-1 ClientToServer `{ type: "key"; name: string }`. */
const KEY_MAP: Record<string, string> = {
escape: "Escape",
tab: "Tab",
up: "Up",
down: "Down",
left: "Left",
right: "Right",
enter: "Enter",
"shift-enter": "S-Enter",
backspace: "BSpace",
"ctrl-c": "C-c",
"ctrl-d": "C-d",
"ctrl-z": "C-z",
};
/**
* Send a single named key to a tmux pane.
* Pane defaults to the first pane of the session (session:0.0).
*/
export async function sendKey(session: string, name: string): Promise<void> {
const tmuxKey = KEY_MAP[name.toLowerCase()];
if (!tmuxKey) {
throw new Error(
`Unknown key name: "${name}". Supported: ${Object.keys(KEY_MAP).join(", ")}`,
);
}
// Target just the session — tmux selects the active window/pane automatically.
// Avoids base-index issues (user's tmux.conf may start windows at 1, not 0).
await execFileAsync("tmux", ["send-keys", "-t", session, tmuxKey]);
}
/**
* Send literal text to a tmux pane (IC-1 `{ type: "keys"; data: string }`).
* Uses send-keys -l which sends each character literally.
*/
export async function sendKeys(session: string, data: string): Promise<void> {
await execFileAsync("tmux", ["send-keys", "-t", session, "-l", data]);
}
/**
* Send bracketed-paste to a tmux pane (IC-1 `{ type: "paste"; data: string }`).
* Wraps the data in bracketed-paste sequences then sends literally.
*/
export async function sendPaste(session: string, data: string): Promise<void> {
const wrapped = `\x1b[200~${data}\x1b[201~`;
await execFileAsync("tmux", ["send-keys", "-t", session, "-l", wrapped]);
}

View File

@ -0,0 +1,231 @@
/**
* tmux session manager.
*
* Spawn, list, kill sessions and read metadata stored via tmux @description
* option. Checks tmux version at startup (requires >= 2.5).
*
* Owner: T-1.1
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export interface TmuxSession {
id: string; // tmux session name (used as our ID)
name: string; // human name (same as id for now; T-1.4 may rename via @description)
description?: string; // from tmux @description option
createdAt: string; // ISO string, from tmux session_created_string
lastActivityAt: string; // ISO string, from tmux session_last_attached
width: number;
height: number;
}
// ---------------------------------------------------------------------------
// Version guard
// ---------------------------------------------------------------------------
let versionChecked = false;
export async function checkTmuxVersion(): Promise<void> {
if (versionChecked) return;
const { stdout } = await execFileAsync("tmux", ["-V"]);
const match = stdout.trim().match(/tmux (\d+)\.(\d+)/);
if (!match) throw new Error(`Cannot parse tmux version: ${stdout.trim()}`);
const major = parseInt(match[1], 10);
const minor = parseInt(match[2], 10);
if (major < 2 || (major === 2 && minor < 5)) {
throw new Error(
`tmux >= 2.5 required (found ${stdout.trim()}). Upgrade tmux to use pi-remote-control.`,
);
}
versionChecked = true;
}
// ---------------------------------------------------------------------------
// Session CRUD
// ---------------------------------------------------------------------------
/**
* Spawn a new detached tmux session.
* Returns the session name (used as stable ID).
*/
export async function spawnSession(opts: {
name: string;
width?: number;
height?: number;
command?: string;
}): Promise<string> {
await checkTmuxVersion();
const { name, width = 80, height = 24, command = "" } = opts;
// Set default-terminal globally so programs inside tmux get xterm-256color
// and emit the escape sequences that SwiftTerm / xterm-compatible clients expect.
await execFileAsync("tmux", [
"set-option",
"-g",
"default-terminal",
"xterm-256color",
]).catch(() => {}); // best-effort; older tmux may not support all options
const args = [
"new-session",
"-d",
"-s",
name,
"-x",
String(width),
"-y",
String(height),
];
if (command) args.push(command);
await execFileAsync("tmux", args);
// Mark as sidecar-managed so listSessions() can filter out unrelated
// tmux sessions (e.g. the pi-sidecar launcher session itself).
await execFileAsync("tmux", [
"set-option",
"-t",
name,
"@pi-remote-managed",
"1",
]).catch(() => {});
return name;
}
/**
* Resize an existing session's window.
* Safe to call at any time; silently ignores unknown sessions.
*/
export async function resizeSession(
name: string,
cols: number,
rows: number,
): Promise<void> {
const c = Math.max(1, Math.min(Math.round(cols), 500));
const r = Math.max(1, Math.min(Math.round(rows), 200));
await execFileAsync("tmux", [
"resize-window",
"-t",
name,
"-x",
String(c),
"-y",
String(r),
]).catch(() => {}); // session may not exist yet; ignore
}
/**
* List all tmux sessions with metadata.
*/
export async function listSessions(): Promise<TmuxSession[]> {
await checkTmuxVersion();
// Use a separator that's unlikely to appear in session names
const SEP = "\x1F"; // ASCII unit separator
const fmt = [
"#{session_name}",
"#{session_created_string}",
"#{session_last_attached_string}",
"#{window_width}",
"#{window_height}",
].join(SEP);
let stdout: string;
try {
({ stdout } = await execFileAsync("tmux", ["list-sessions", "-F", fmt]));
} catch (err: unknown) {
// tmux exits 1 when no sessions exist
if (
err &&
typeof err === "object" &&
"code" in err &&
(err as { code: number }).code === 1
) {
return [];
}
throw err;
}
const sessions: TmuxSession[] = [];
for (const line of stdout.trim().split("\n")) {
if (!line) continue;
const [id, createdAt, lastActivityAt, w, h] = line.split(SEP);
// Only include sessions created by the sidecar.
try {
const r = await execFileAsync("tmux", [
"show-options",
"-t",
id,
"-qv",
"@pi-remote-managed",
]);
if (!r.stdout.trim()) continue; // unmanaged session — skip
} catch {
continue; // can't read options → skip
}
// Fetch @description option separately (may not be set)
let description: string | undefined;
try {
const r = await execFileAsync("tmux", [
"show-options",
"-t",
id,
"-qv",
"@description",
]);
const v = r.stdout.trim();
if (v) description = v;
} catch {
// option not set — that's fine
}
sessions.push({
id,
name: id,
description,
createdAt,
lastActivityAt,
width: parseInt(w, 10) || 120,
height: parseInt(h, 10) || 40,
});
}
return sessions;
}
/**
* Get a single session by name. Returns null if not found.
*/
export async function getSession(name: string): Promise<TmuxSession | null> {
const all = await listSessions();
return all.find((s) => s.id === name) ?? null;
}
/**
* Kill a session. Throws if it doesn't exist.
*/
export async function killSession(name: string): Promise<void> {
await checkTmuxVersion();
await execFileAsync("tmux", ["kill-session", "-t", name]);
}
/**
* Set the @description option on a session.
*/
export async function setDescription(
name: string,
description: string,
): Promise<void> {
await execFileAsync("tmux", [
"set-option",
"-t",
name,
"@description",
description,
]);
}

View File

@ -0,0 +1,58 @@
/**
* tmux capture-pane snapshot.
*
* Returns a plain-text snapshot of a pane's visible content.
* Used by the snapshot route (T-1.5) and the /thumbnail endpoint (T-1.6).
*
* Owner: T-1.1
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export interface SnapshotOptions {
/** tmux session name */
session: string;
/** pane index within session (default "0.0") */
pane?: string;
/** capture width (default: actual pane width) */
width?: number;
/** capture height (default: actual pane height) */
height?: number;
/** include escape sequences for colour/style (default: false = plain text) */
escapes?: boolean;
}
/**
* Capture a plain-text (or escape-annotated) snapshot of a tmux pane.
* Returns raw text as a string.
*/
export async function capturePane(opts: SnapshotOptions): Promise<string> {
const { session, pane, escapes = false } = opts;
// Target just the session when no pane specified — avoids base-index issues.
const target = pane ? `${session}:${pane}` : session;
const args = ["capture-pane", "-t", target, "-p"];
if (escapes) args.push("-e"); // include escape sequences
// Note: -S/-E (start/end line) omitted — captures current visible content
const { stdout } = await execFileAsync("tmux", args);
return stdout;
}
/**
* Capture a thumbnail-sized snapshot (40×12) for the REST thumbnail endpoint.
* Returns plain text, trimmed.
*/
export async function captureThumbnail(
session: string,
pane?: string,
): Promise<string> {
// tmux can't resize the capture directly via capture-pane flags, so we
// capture full content and truncate to 40-char wide × 12 lines.
const raw = await capturePane({ session, pane, escapes: false });
const lines = raw.split("\n").slice(0, 12);
return lines.map((l) => l.slice(0, 40)).join("\n");
}

17
package-lock.json generated
View File

@ -15,7 +15,8 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.12", "@biomejs/biome": "^2.4.12",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"husky": "^9.1.7" "husky": "^9.1.7",
"typescript": "^6.0.3"
}, },
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-coding-agent": "*", "@earendil-works/pi-coding-agent": "*",
@ -3970,6 +3971,20 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/uint8array-extras": { "node_modules/uint8array-extras": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",

View File

@ -1,5 +1,6 @@
{ {
"name": "pi-remote-control", "name": "pi-remote-control",
"type": "module",
"version": "1.0.0", "version": "1.0.0",
"description": "Expose a running pi session over HTTP/WebSocket — view and interact from any browser on your network.", "description": "Expose a running pi session over HTTP/WebSocket — view and interact from any browser on your network.",
"keywords": [ "keywords": [
@ -18,11 +19,16 @@
"lint": "biome check --write .", "lint": "biome check --write .",
"lint:check": "biome check .", "lint:check": "biome check .",
"prepare": "node .husky/install.mjs", "prepare": "node .husky/install.mjs",
"spike-cc": "npx -y ts-node extensions/remote-control/spike-cc.ts" "typecheck": "tsc --noEmit",
"smoke": "node --test scripts/smoke/smoke.mjs",
"smoke:stream": "node --test scripts/smoke/stream.test.mjs",
"smoke:pair": "node --test scripts/smoke/pair.test.mjs",
"smoke:all": "node --test scripts/smoke/smoke.mjs scripts/smoke/stream.test.mjs scripts/smoke/pair.test.mjs"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.12", "@biomejs/biome": "^2.4.12",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"husky": "^9.1.7" "husky": "^9.1.7",
"typescript": "^6.0.3"
} }
} }

96
scripts/smoke/README.md Normal file
View File

@ -0,0 +1,96 @@
# Smoke Test Harness
End-to-end smoke tests for the `remote-control` extension. These tests spawn
a real `pi` subprocess with the extension loaded and hit the HTTP/WebSocket
endpoints to verify they respond correctly.
## Running
```sh
npm run smoke
```
Or directly:
```sh
node --test scripts/smoke/smoke.mjs
```
## What is tested (MVP — T-1.0a)
| # | Test | Description |
|---|------|-------------|
| 1 | Server starts | pi spawns with `--remote-control`; TCP port opens within 12 s |
| 2 | `GET /manifest.json` | 200, `application/manifest+json`, parses, has `name`/`short_name`/`start_url` |
| 3 | `GET /icon.svg` | 200, `image/svg+xml`, body starts with `<svg` |
| 4 | `GET /` (with token) | 302→200, `text/html`, contains HTML marker |
| 5 | `GET /` (no token) | 403 Forbidden |
| 6 | `WS /ws` (with token) | 101 upgrade, WebSocket enters OPEN state |
| 7 | Process alive | pi hasn't crashed during the test run |
| T | Teardown | SIGTERM pi, wait for exit, remove temp HOME |
## Port
Default port: **19876**. Override with the `SMOKE_PORT` environment variable:
```sh
SMOKE_PORT=20000 npm run smoke
```
## How it works
The harness creates a temporary `HOME` directory containing:
- `$HOME/.pi/remote-control/config.json``publicBaseUrl` + `bindAddress` pointing at `SMOKE_PORT`
- `$HOME/.pi/remote-control/token` — a known deterministic token for auth
`pi` is spawned with `HOME=<tmpdir>` so **no real `~/.pi` files are read or
written**. The temp directory is deleted in the `after()` teardown hook even
if tests fail.
## Authentication
The server requires either a valid session cookie or `?token=<value>` query
parameter. The smoke tests pre-seed a known token (`smoke-test-token-deterministic-1234`)
and pass it in the `?token=` parameter. No production auth logic is modified.
## Adding new tests
1. Create a new file `scripts/smoke/<feature>.test.mjs`
2. Import helpers from `./helpers.mjs`
3. Import your test file from `smoke.mjs` (or run it standalone with `node --test`)
Example:
```js
// scripts/smoke/stream.test.mjs
import { describe, it } from "node:test";
import { openWebSocket, closeWebSocket } from "./helpers.mjs";
export function registerStreamTests(port, token) {
describe("stream tests", () => {
it("WS /sessions/:id/stream → 101", async () => {
// ...
});
});
}
```
## Flags used
Same flags as `make dev`:
```
pi -nt -ne -ns -np -nc --no-session --offline -e extensions/remote-control --remote-control
```
## Troubleshooting
**Port busy**: if `SMOKE_PORT` is already in use, the test will fail at the
`waitForPort` step. Change the port: `SMOKE_PORT=20001 npm run smoke`.
**pi not found**: ensure `pi` is on your `PATH`. Check with `which pi`.
**Server not starting**: run with verbose output to see what pi logs:
```sh
node --test --reporter spec scripts/smoke/smoke.mjs
```
The port-wait error will include captured stdout/stderr from pi.

267
scripts/smoke/helpers.mjs Normal file
View File

@ -0,0 +1,267 @@
/**
* Smoke-test helpers spawn-pi, wait-for-port, fetch, WebSocket.
*
* Designed to be imported by smoke.mjs and any future test files
* (e.g. scripts/smoke/stream.test.mjs). All helpers are stateless;
* the caller is responsible for lifecycle.
*/
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import net from "node:net";
import os from "node:os";
import path from "node:path";
// ── WebSocket (from project's bundled ws dependency) ─────────────────────────
const _require = createRequire(import.meta.url);
/** @type {import("ws")} */
const wsModule = _require("ws");
const WebSocket = wsModule.WebSocket ?? wsModule.default ?? wsModule;
// ── Constants ─────────────────────────────────────────────────────────────────
/** Milliseconds to wait for the TCP port to open before giving up. */
const PORT_READY_TIMEOUT_MS = 12_000;
/** Milliseconds between TCP-port-ready poll attempts. */
const PORT_POLL_INTERVAL_MS = 100;
// ── Temporary HOME setup ──────────────────────────────────────────────────────
/**
* Create a minimal fake HOME directory that satisfies remote-control's config
* requirements:
* $HOME/.pi/remote-control/config.json publicBaseUrl + bindAddress
* $HOME/.pi/remote-control/token known token for auth
*
* @param {object} opts
* @param {number} opts.port - TCP port to bind to.
* @param {string} opts.token - Auth token to pre-seed.
* @returns {Promise<string>} Path to the temp HOME directory.
*/
export async function createSmokeHome({ port, token }) {
const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), "pi-smoke-"));
const rcDir = path.join(tmpBase, ".pi", "remote-control");
await fs.mkdir(rcDir, { recursive: true });
const config = {
publicBaseUrl: `http://127.0.0.1:${port}`,
bindAddress: `127.0.0.1:${port}`,
};
await fs.writeFile(
path.join(rcDir, "config.json"),
`${JSON.stringify(config, null, 2)}\n`,
"utf8",
);
await fs.writeFile(path.join(rcDir, "token"), token, {
encoding: "utf8",
mode: 0o600,
});
return tmpBase;
}
/**
* Remove the temp HOME directory created by createSmokeHome.
* Silently ignores errors (e.g. already deleted).
*
* @param {string} tmpHome
*/
export async function removeSmokeHome(tmpHome) {
try {
await fs.rm(tmpHome, { recursive: true, force: true });
} catch {
/* ignore */
}
}
// ── Pi subprocess ─────────────────────────────────────────────────────────────
/**
* Spawn a `pi` process with the remote-control extension loaded.
*
* Uses the same flags as `make dev` plus `--remote-control` to auto-start
* the HTTP server. Sets HOME to the given fake home so config/token paths
* resolve correctly without touching the real ~/.pi directory.
*
* Pi is a TUI application that requires a PTY (pseudo-terminal) to enter its
* interactive session mode and fire `session_start`. We use Python's built-in
* `pty.spawn` to allocate a PTY for pi; this is available on all macOS/Linux
* systems without any additional dependencies.
*
* @param {object} opts
* @param {string} opts.extensionPath - Absolute path to the extension dir.
* @param {string} opts.fakeHome - Path to the fake HOME directory.
* @returns {{ proc: import("node:child_process").ChildProcess, logs: string[] }}
*/
export function spawnPi({ extensionPath, fakeHome }) {
const logs = [];
// Python script that allocates a PTY and spawns pi inside it.
// Using pty.spawn() keeps the child alive (no EOF injection like `script` does).
const pyScript = [
"import os, pty",
`os.environ['HOME'] = ${JSON.stringify(fakeHome)}`,
`pty.spawn(['pi', '-nt', '-ne', '-ns', '-np', '-nc', '--no-session', '--offline', '-e', ${JSON.stringify(extensionPath)}, '--remote-control'])`,
].join("\n");
const proc = spawn("python3", ["-c", pyScript], {
env: { ...process.env, HOME: fakeHome },
stdio: ["ignore", "pipe", "pipe"],
});
const capture = (chunk) => {
logs.push(chunk.toString());
};
proc.stdout.on("data", capture);
proc.stderr.on("data", capture);
return { proc, logs };
}
/**
* Kill a pi process and wait for it to exit.
* Sends SIGTERM first; if it doesn't exit within 3 s, sends SIGKILL.
*
* @param {import("node:child_process").ChildProcess} proc
* @returns {Promise<number|null>} The exit code (or null if killed by signal).
*/
export function killPi(proc) {
return new Promise((resolve) => {
if (proc.exitCode !== null) {
resolve(proc.exitCode);
return;
}
let settled = false;
const settle = (code) => {
if (!settled) {
settled = true;
clearTimeout(sigkillTimer);
resolve(code ?? null);
}
};
proc.once("exit", (code) => settle(code));
proc.kill("SIGTERM");
// Safety: escalate to SIGKILL after 3 s
const sigkillTimer = setTimeout(() => {
try {
proc.kill("SIGKILL");
} catch {
/* already dead */
}
}, 3_000);
});
}
// ── Port readiness ────────────────────────────────────────────────────────────
/**
* Poll a TCP port until it accepts connections or the timeout expires.
*
* @param {object} opts
* @param {number} opts.port
* @param {string} [opts.host] - Default "127.0.0.1".
* @param {number} [opts.timeoutMs] - Default PORT_READY_TIMEOUT_MS.
* @param {number} [opts.intervalMs] - Default PORT_POLL_INTERVAL_MS.
* @returns {Promise<void>} Resolves when port is open; rejects on timeout.
*/
export function waitForPort({
port,
host = "127.0.0.1",
timeoutMs = PORT_READY_TIMEOUT_MS,
intervalMs = PORT_POLL_INTERVAL_MS,
}) {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs;
const attempt = () => {
const sock = net.createConnection({ port, host });
sock.once("connect", () => {
sock.destroy();
resolve();
});
sock.once("error", () => {
sock.destroy();
if (Date.now() >= deadline) {
reject(
new Error(
`Port ${host}:${port} did not open within ${timeoutMs} ms`,
),
);
} else {
setTimeout(attempt, intervalMs);
}
});
};
attempt();
});
}
// ── HTTP helpers ──────────────────────────────────────────────────────────────
/**
* Build the base URL for the smoke server.
*
* @param {number} port
* @returns {string}
*/
export function baseUrl(port) {
return `http://127.0.0.1:${port}`;
}
/**
* Fetch a URL and return the response plus text body.
* Throws if the network request itself fails.
*
* @param {string} url
* @param {RequestInit} [init]
* @returns {Promise<{ res: Response; body: string }>}
*/
export async function fetchText(url, init) {
const res = await fetch(url, init);
const body = await res.text();
return { res, body };
}
// ── WebSocket helpers ─────────────────────────────────────────────────────────
/**
* Open a WebSocket connection and wait for either an `open` or `error` event.
* Resolves with the WebSocket instance on success.
* Rejects with the error on failure.
*
* @param {string} url - WebSocket URL (ws:// or wss://).
* @returns {Promise<import("ws").WebSocket>}
*/
export function openWebSocket(url) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.once("open", () => resolve(ws));
ws.once("error", reject);
});
}
/**
* Close a WebSocket and wait for the close event.
*
* @param {import("ws").WebSocket} ws
* @returns {Promise<void>}
*/
export function closeWebSocket(ws) {
return new Promise((resolve) => {
if (ws.readyState === ws.constructor.CLOSED) {
resolve();
return;
}
ws.once("close", () => resolve());
ws.close();
});
}

197
scripts/smoke/pair.test.mjs Normal file
View File

@ -0,0 +1,197 @@
/**
* T-1.3 Integration smoke: POST /pair pairing flow.
*
* Requires a running pi process with remote-control (spawned by the before() hook).
* Re-uses the helpers from helpers.mjs.
*
* Tests:
* 1. GET /pair-qr 200 text/plain containing a pi-remote:// URL
* 2. POST /pair 200 { bearerToken, sidecarId } with valid pairingToken
* 3. GET /sessions?token=<bearerToken> 200 (bearer token authenticates)
* 4. POST /pair (invalid) 403 invalid_pairing_token
*/
import assert from "node:assert/strict";
import path from "node:path";
import { after, before, describe, it } from "node:test";
import { fileURLToPath } from "node:url";
import {
baseUrl,
createSmokeHome,
fetchText,
killPi,
removeSmokeHome,
spawnPi,
waitForPort,
} from "./helpers.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const EXTENSION_PATH = path.resolve(
__dirname,
"../../extensions/remote-control",
);
const SMOKE_PORT = Number(process.env.SMOKE_PORT_PAIR ?? 19878);
const SMOKE_TOKEN = "pair-test-token-deterministic-789";
const BASE = baseUrl(SMOKE_PORT);
const AUTH = `?token=${SMOKE_TOKEN}`;
// ---------------------------------------------------------------------------
// Helper: fetch JSON from a URL
// ---------------------------------------------------------------------------
async function fetchJson(url, init) {
const res = await fetch(url, init);
let body;
try {
body = await res.json();
} catch {
body = null;
}
return { res, body };
}
// ---------------------------------------------------------------------------
// Helper: extract a query-param value from a URL string
// ---------------------------------------------------------------------------
function extractParam(urlStr, param) {
// Find the pi-remote:// URL in a potentially multi-line body
const match = urlStr.match(/pi-remote:\/\/[^\s]+/);
if (!match) return null;
// URLSearchParams doesn't parse custom schemes, so strip the scheme+host
const raw = match[0];
const qIdx = raw.indexOf("?");
if (qIdx === -1) return null;
const params = new URLSearchParams(raw.slice(qIdx + 1));
return params.get(param);
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
let piProc = null;
let tmpHome = null;
describe("T-1.3 pair smoke", () => {
before(async () => {
tmpHome = await createSmokeHome({ port: SMOKE_PORT, token: SMOKE_TOKEN });
const { proc } = spawnPi({
extensionPath: EXTENSION_PATH,
fakeHome: tmpHome,
});
piProc = proc;
await waitForPort({ port: SMOKE_PORT });
});
after(async () => {
if (piProc) await killPi(piProc);
if (tmpHome) await removeSmokeHome(tmpHome);
});
// ── Test 1: GET /pair-qr ─────────────────────────────────────────────────
it("GET /pair-qr returns a pairing URL", async () => {
const { res, body } = await fetchText(`${BASE}/pair-qr${AUTH}`);
assert.equal(res.status, 200, `Expected 200, got ${res.status}: ${body}`);
assert.ok(
body.includes("pi-remote://"),
`Response should contain pi-remote:// URL. Got:\n${body}`,
);
const token = extractParam(body, "pair");
assert.ok(
token && token.length > 0,
`Should be able to extract pair token from URL. Body:\n${body}`,
);
});
// ── Test 2: POST /pair with valid pairingToken ────────────────────────────
it("POST /pair with valid pairingToken returns bearerToken", async () => {
// Step 1: obtain a fresh pairing token via GET /pair-qr
const { res: qrRes, body: qrBody } = await fetchText(
`${BASE}/pair-qr${AUTH}`,
);
assert.equal(
qrRes.status,
200,
`GET /pair-qr failed: ${qrRes.status} ${qrBody}`,
);
const pairingToken = extractParam(qrBody, "pair");
assert.ok(pairingToken, "Could not extract pairingToken from /pair-qr");
// Step 2: exchange pairingToken for bearerToken
const { res, body } = await fetchJson(`${BASE}/pair`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
pairingToken,
deviceToken: "fake-device-token-smoke",
environment: "sandbox",
}),
});
assert.equal(res.status, 200, `Expected 200, got ${res.status}: ${JSON.stringify(body)}`);
assert.ok(body, "Response body should be JSON");
assert.ok(
typeof body.bearerToken === "string" && body.bearerToken.length > 0,
`Response should have bearerToken string. Got: ${JSON.stringify(body)}`,
);
assert.ok(
typeof body.sidecarId === "string" && body.sidecarId.length > 0,
`Response should have sidecarId string. Got: ${JSON.stringify(body)}`,
);
});
// ── Test 3: bearerToken authenticates GET /sessions ──────────────────────
it("bearerToken from pair can authenticate GET /sessions", async () => {
// Obtain pairing token
const { res: qrRes, body: qrBody } = await fetchText(
`${BASE}/pair-qr${AUTH}`,
);
assert.equal(qrRes.status, 200, `GET /pair-qr failed: ${qrRes.status}`);
const pairingToken = extractParam(qrBody, "pair");
assert.ok(pairingToken, "Could not extract pairingToken");
// Exchange for bearer token
const { res: pairRes, body: pairBody } = await fetchJson(`${BASE}/pair`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pairingToken }),
});
assert.equal(
pairRes.status,
200,
`POST /pair failed: ${pairRes.status} ${JSON.stringify(pairBody)}`,
);
const { bearerToken } = pairBody;
assert.ok(bearerToken, "Must have bearerToken to test auth");
// Use bearerToken to call GET /sessions
const { res: sessRes, body: sessBody } = await fetchJson(
`${BASE}/sessions?token=${encodeURIComponent(bearerToken)}`,
);
assert.equal(
sessRes.status,
200,
`GET /sessions with bearerToken should return 200, got ${sessRes.status}: ${JSON.stringify(sessBody)}`,
);
assert.ok(Array.isArray(sessBody), "GET /sessions should return an array");
});
// ── Test 4: POST /pair with invalid pairingToken → 403 ───────────────────
it("POST /pair with invalid pairingToken returns 403", async () => {
const { res, body } = await fetchJson(`${BASE}/pair`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pairingToken: "invalid-xxx" }),
});
assert.equal(
res.status,
403,
`Expected 403 for invalid token, got ${res.status}: ${JSON.stringify(body)}`,
);
assert.ok(body, "Should return JSON error body");
assert.equal(body.error, "invalid_pairing_token");
});
});

249
scripts/smoke/smoke.mjs Normal file
View File

@ -0,0 +1,249 @@
/**
* Smoke test end-to-end validation of the remote-control HTTP/WS server.
*
* Runs with: node --test scripts/smoke/smoke.mjs
* Or: npm run smoke
*
* What is tested (MVP):
* 1. Server starts pi spawns with --remote-control and the HTTP port opens.
* 2. GET /manifest.json 200, application/manifest+json, parses, has name/short_name/start_url.
* 3. GET /icon.svg 200, image/svg+xml, body starts with <svg.
* 4. GET / 200 (with auth token), text/html, contains HTML marker.
* 5. WS /ws successful 101 upgrade (with auth token). Close cleanly.
* 6. Teardown SIGTERM pi, process exits.
*
* Auth strategy:
* The server requires a token on GET / and WS /ws. We pre-seed a known token
* in a temporary HOME directory ($HOME/.pi/remote-control/token) and pass it
* via the ?token= query parameter using the existing mechanism. No production
* code is modified.
*
* Port strategy:
* We write bindAddress + publicBaseUrl to the temporary config so the server
* binds to a deterministic port (SMOKE_PORT env, default 19876). No real
* ~/.pi files are touched.
*/
import assert from "node:assert/strict";
import path from "node:path";
import { after, before, describe, it } from "node:test";
import { fileURLToPath } from "node:url";
import {
baseUrl,
closeWebSocket,
createSmokeHome,
fetchText,
killPi,
openWebSocket,
removeSmokeHome,
spawnPi,
waitForPort,
} from "./helpers.mjs";
// ── Configuration ─────────────────────────────────────────────────────────────
const SMOKE_PORT = Number(process.env.SMOKE_PORT ?? 19876);
const SMOKE_TOKEN = "smoke-test-token-deterministic-1234";
const EXTENSION_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../extensions/remote-control",
);
// ── Suite ─────────────────────────────────────────────────────────────────────
describe("remote-control smoke tests", () => {
/** @type {import("node:child_process").ChildProcess} */
let proc;
/** @type {string} */
let fakeHome;
/** @type {string[]} */
let logs;
before(async () => {
// Create temp HOME with config + token
fakeHome = await createSmokeHome({ port: SMOKE_PORT, token: SMOKE_TOKEN });
// Spawn pi
({ proc, logs } = spawnPi({ extensionPath: EXTENSION_PATH, fakeHome }));
// Wait for the HTTP server to open the TCP port
try {
await waitForPort({ port: SMOKE_PORT });
} catch (err) {
// Attach captured logs to the error for easy debugging
const captured = logs.join("");
throw new Error(
`${err.message}\n\n--- pi stdout/stderr ---\n${captured || "(empty)"}`,
);
}
});
after(async () => {
// Always clean up — kill pi and remove temp HOME
if (proc) {
await killPi(proc);
}
if (fakeHome) {
await removeSmokeHome(fakeHome);
}
});
// ── Test 1: manifest.json ───────────────────────────────────────────────────
it("GET /manifest.json → 200 application/manifest+json with required fields", async () => {
const { res, body } = await fetchText(
`${baseUrl(SMOKE_PORT)}/manifest.json`,
);
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
const ct = res.headers.get("content-type") ?? "";
assert.ok(
ct.includes("application/manifest+json"),
`Expected content-type application/manifest+json, got: ${ct}`,
);
let manifest;
try {
manifest = JSON.parse(body);
} catch {
assert.fail(
`manifest.json body is not valid JSON: ${body.slice(0, 200)}`,
);
}
assert.ok(
typeof manifest.name === "string" && manifest.name.length > 0,
"manifest.name must be a non-empty string",
);
assert.ok(
typeof manifest.short_name === "string" && manifest.short_name.length > 0,
"manifest.short_name must be a non-empty string",
);
assert.ok(
typeof manifest.start_url === "string" && manifest.start_url.length > 0,
"manifest.start_url must be a non-empty string",
);
});
// ── Test 2: icon.svg ────────────────────────────────────────────────────────
it("GET /icon.svg → 200 image/svg+xml, body starts with <svg", async () => {
const { res, body } = await fetchText(`${baseUrl(SMOKE_PORT)}/icon.svg`);
assert.equal(res.status, 200, `Expected 200, got ${res.status}`);
const ct = res.headers.get("content-type") ?? "";
assert.ok(
ct.includes("image/svg+xml"),
`Expected content-type image/svg+xml, got: ${ct}`,
);
assert.ok(
body.trimStart().startsWith("<svg"),
`Expected body to start with <svg, got: ${body.slice(0, 100)}`,
);
});
// ── Test 3: root with auth ──────────────────────────────────────────────────
it("GET / (with token) → 200 text/html containing HTML marker", async () => {
const url = `${baseUrl(SMOKE_PORT)}/?token=${SMOKE_TOKEN}`;
// The server issues a 302 redirect on first token auth (sets session cookie),
// then serves 200 on the redirect target. Follow redirects manually to keep
// cookies across the redirect chain.
const cookieJar = {};
// First request: expect 302
const first = await fetch(url, { redirect: "manual" });
let body;
if (first.status === 302) {
// Extract session cookie
for (const [name, value] of first.headers.entries()) {
if (name.toLowerCase() === "set-cookie") {
const match = value.match(/^([^=]+)=([^;]+)/);
if (match) cookieJar[match[1]] = match[2];
}
}
const location = first.headers.get("location") ?? "/";
const cookieHeader = Object.entries(cookieJar)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
const second = await fetch(`${baseUrl(SMOKE_PORT)}${location}`, {
headers: cookieHeader ? { Cookie: cookieHeader } : {},
});
assert.equal(
second.status,
200,
`Expected 200 on redirect target, got ${second.status}`,
);
const ct = second.headers.get("content-type") ?? "";
assert.ok(
ct.includes("text/html"),
`Expected content-type text/html, got: ${ct}`,
);
body = await second.text();
} else {
assert.equal(first.status, 200, `Expected 200, got ${first.status}`);
const ct = first.headers.get("content-type") ?? "";
assert.ok(
ct.includes("text/html"),
`Expected content-type text/html, got: ${ct}`,
);
body = await first.text();
}
// Check for a marker from html.ts — the title is always present
assert.ok(
body.includes("<!DOCTYPE html>"),
"Expected body to contain <!DOCTYPE html>",
);
assert.ok(
body.includes("remote-control") ||
body.includes("Pi Remote") ||
body.includes("π"),
"Expected body to contain a known HTML marker (remote-control / Pi Remote / π)",
);
});
// ── Test 4: 403 on unauthenticated root ─────────────────────────────────────
it("GET / (no token) → 403 Forbidden", async () => {
const { res } = await fetchText(`${baseUrl(SMOKE_PORT)}/`);
assert.equal(
res.status,
403,
`Expected 403 without token, got ${res.status}`,
);
});
// ── Test 5: WebSocket upgrade ───────────────────────────────────────────────
it("WS /ws (with token) → successful 101 upgrade", async () => {
const wsUrl = `ws://127.0.0.1:${SMOKE_PORT}/ws?token=${SMOKE_TOKEN}`;
// First, get a session cookie via HTTP (WS token auth also works directly
// since isAuthenticated checks URL searchParams on the Upgrade request)
const ws = await openWebSocket(wsUrl);
assert.ok(
ws.readyState === ws.constructor.OPEN,
"WebSocket should be in OPEN state after successful handshake",
);
await closeWebSocket(ws);
});
// ── Test 6: teardown (handled in after(), verified here) ───────────────────
it("pi process is alive during tests", () => {
assert.equal(
proc.exitCode,
null,
"pi process should still be running during tests",
);
});
});

View File

@ -0,0 +1,295 @@
/**
* T-1.8 Integration smoke: stream attach, send-keys, drop + reconnect, delta replay.
*
* Requires a running pi process with remote-control (spawned by the before() hook).
* Re-uses the helpers from helpers.mjs.
*
* Tests:
* 1. POST /sessions creates a tmux session
* 2. WS /sessions/:id/stream attaches, sends { type:"resume"; lastSeq:null }
* 3. Receives at least some binary frames (output from the session)
* 4. Sends { type:"keys"; data:"echo smoke-marker\n" } via HTTP POST /sessions/:id/input
* 5. Observes "smoke-marker" in the stream within timeout
* 6. Notes lastSeq, disconnects
* 7. Reconnects with { type:"resume"; lastSeq } receives only delta frames
* 8. GET /sessions/:id/thumbnail text/plain, non-empty
* 9. DELETE /sessions/:id 204
*/
import assert from "node:assert/strict";
import path from "node:path";
import { after, before, describe, it } from "node:test";
import { fileURLToPath } from "node:url";
import {
baseUrl,
closeWebSocket,
createSmokeHome,
fetchText,
killPi,
openWebSocket,
removeSmokeHome,
spawnPi,
waitForPort,
} from "./helpers.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const EXTENSION_PATH = path.resolve(
__dirname,
"../../extensions/remote-control",
);
const SMOKE_PORT = Number(process.env.SMOKE_PORT_STREAM ?? 19877); // different from smoke.mjs (19876)
const SMOKE_TOKEN = "stream-test-token-deterministic-567";
const BASE = baseUrl(SMOKE_PORT);
const WS_BASE = `ws://127.0.0.1:${SMOKE_PORT}`;
const AUTH = `?token=${SMOKE_TOKEN}`;
let piProc = null;
let tmpHome = null;
let sessionId = null;
describe("T-1.8 stream integration", () => {
before(async () => {
tmpHome = await createSmokeHome({ port: SMOKE_PORT, token: SMOKE_TOKEN });
const { proc } = spawnPi({
extensionPath: EXTENSION_PATH,
fakeHome: tmpHome,
});
piProc = proc;
await waitForPort({ port: SMOKE_PORT });
});
after(async () => {
if (sessionId) {
await fetchText(`${BASE}/sessions/${sessionId}${AUTH}`, {
method: "DELETE",
}).catch(() => {});
}
if (piProc) await killPi(piProc);
if (tmpHome) await removeSmokeHome(tmpHome);
});
it("POST /sessions → 201 with id + name", async () => {
const { res, body } = await fetchText(`${BASE}/sessions${AUTH}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "smoke-stream" }),
});
assert.equal(res.status, 201, `Expected 201, got ${res.status}: ${body}`);
const json = JSON.parse(body);
assert.ok(json.id, "Response should have id");
assert.equal(json.name, "smoke-stream");
sessionId = json.id;
});
it("WS stream: attach, receive frames, send keys, observe output", async () => {
const ws = await openWebSocket(
`${WS_BASE}/sessions/${sessionId}/stream${AUTH}`,
);
// Send resume from scratch
ws.send(JSON.stringify({ type: "resume", lastSeq: null }));
// Collect frames for up to 5 seconds
const frames = [];
let lastSeq = 0;
const receivedMarker = await new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Timed out waiting for output frames")),
5000,
);
ws.on("message", (data) => {
if (Buffer.isBuffer(data) && data.length >= 8) {
// Binary frame: [seq: 8 bytes][data]
const seq = Number(data.readBigUInt64BE(0));
lastSeq = Math.max(lastSeq, seq);
frames.push({ seq, data: data.slice(8) });
clearTimeout(timeout);
resolve(true);
}
});
});
assert.ok(receivedMarker, "Should receive at least one binary frame");
assert.ok(frames.length > 0, "frames array should be non-empty");
assert.ok(lastSeq > 0, "lastSeq should be > 0");
await closeWebSocket(ws);
});
it("POST /sessions/:id/input → send keys, observe in stream", async () => {
const ws = await openWebSocket(
`${WS_BASE}/sessions/${sessionId}/stream${AUTH}`,
);
ws.send(JSON.stringify({ type: "resume", lastSeq: null }));
// Send a distinctive command via HTTP input endpoint
// Send text then Enter as two separate requests (avoids \n in execFile args)
const marker = `smoke-${Date.now()}`;
const { res: r1, body: b1 } = await fetchText(
`${BASE}/sessions/${sessionId}/input${AUTH}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "keys", data: `echo ${marker}` }),
},
);
assert.equal(
r1.status,
204,
`keys POST should return 204, got ${r1.status}. Body: ${b1}`,
);
const { res: r2, body: b2 } = await fetchText(
`${BASE}/sessions/${sessionId}/input${AUTH}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "key", name: "enter" }),
},
);
assert.equal(
r2.status,
204,
`enter POST should return 204, got ${r2.status}: ${b2}`,
);
// Wait for marker in stream output
const found = await new Promise((resolve) => {
const timeout = setTimeout(() => resolve(false), 6000);
let accumulated = "";
ws.on("message", (data) => {
if (Buffer.isBuffer(data) && data.length > 8) {
accumulated += data.slice(8).toString("utf8", 0, 200);
if (accumulated.includes(marker)) {
clearTimeout(timeout);
resolve(true);
}
}
});
});
await closeWebSocket(ws);
assert.ok(found, `Should observe marker "${marker}" in stream output`);
});
it("WS stream: send keys via WS, observe in stream output", async () => {
const ws = await openWebSocket(
`${WS_BASE}/sessions/${sessionId}/stream${AUTH}`,
);
ws.send(JSON.stringify({ type: "resume", lastSeq: null }));
const marker = `ws-smoke-${Date.now()}`;
// Wait a tick to let resume complete, then send keys via WS
await new Promise((r) => setTimeout(r, 200));
ws.send(JSON.stringify({ type: "keys", data: `echo ${marker}` }));
ws.send(JSON.stringify({ type: "key", name: "enter" }));
const found = await new Promise((resolve) => {
const timeout = setTimeout(() => resolve(false), 6000);
let accumulated = "";
ws.on("message", (data) => {
if (Buffer.isBuffer(data) && data.length > 8) {
accumulated += data.slice(8).toString("utf8");
if (accumulated.includes(marker)) {
clearTimeout(timeout);
resolve(true);
}
}
});
});
await closeWebSocket(ws);
assert.ok(
found,
`Should observe marker "${marker}" sent via WS keys message in stream output`,
);
});
it("WS stream: reconnect with lastSeq → receives only delta", async () => {
// First pass: collect frames and note highest seq
const ws1 = await openWebSocket(
`${WS_BASE}/sessions/${sessionId}/stream${AUTH}`,
);
ws1.send(JSON.stringify({ type: "resume", lastSeq: null }));
let highestSeq = 0;
await new Promise((resolve) => {
const timeout = setTimeout(resolve, 2000);
ws1.on("message", (data) => {
if (Buffer.isBuffer(data) && data.length >= 8) {
const seq = Number(data.readBigUInt64BE(0));
highestSeq = Math.max(highestSeq, seq);
clearTimeout(timeout);
setTimeout(resolve, 500); // wait a bit for more frames
}
});
});
await closeWebSocket(ws1);
// Send more output (text + enter separately)
await fetchText(`${BASE}/sessions/${sessionId}/input${AUTH}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "keys", data: "echo delta-check" }),
});
await fetchText(`${BASE}/sessions/${sessionId}/input${AUTH}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "key", name: "enter" }),
});
// Small delay to let output be buffered
await new Promise((r) => setTimeout(r, 500));
// Reconnect with lastSeq = highestSeq → should only get frames after that
const ws2 = await openWebSocket(
`${WS_BASE}/sessions/${sessionId}/stream${AUTH}`,
);
ws2.send(JSON.stringify({ type: "resume", lastSeq: highestSeq }));
const deltaFrames = [];
await new Promise((resolve) => {
const timeout = setTimeout(resolve, 3000);
ws2.on("message", (data) => {
if (Buffer.isBuffer(data) && data.length >= 8) {
const seq = Number(data.readBigUInt64BE(0));
if (seq > highestSeq) deltaFrames.push(seq);
clearTimeout(timeout);
setTimeout(resolve, 500);
}
});
});
await closeWebSocket(ws2);
// Delta frames should all have seq > highestSeq
for (const seq of deltaFrames) {
assert.ok(
seq > highestSeq,
`Delta frame seq ${seq} should be > ${highestSeq}`,
);
}
});
it("GET /sessions/:id/thumbnail → text/plain, non-empty", async () => {
const { res, body } = await fetchText(
`${BASE}/sessions/${sessionId}/thumbnail${AUTH}`,
);
assert.equal(res.status, 200);
assert.ok(
res.headers.get("content-type")?.includes("text/plain"),
"Should be text/plain",
);
// capture-pane may return whitespace-only for a fresh shell — check length not trim
assert.ok(body.length > 0, "Thumbnail response should have content");
});
it("DELETE /sessions/:id → 204", async () => {
const { res } = await fetchText(`${BASE}/sessions/${sessionId}${AUTH}`, {
method: "DELETE",
});
assert.equal(res.status, 204);
sessionId = null; // prevent after() from double-deleting
});
});

View File

@ -1,172 +0,0 @@
#!/bin/bash
# Test Protocol for Phase 0.5 Control Mode Spike
set -e
SESSION="pi-cc"
LOG_FILE="/tmp/spike-cc-test-$(date +%s).log"
DURATION=300 # 5 minutes
echo "=== Phase 0.5 Control Mode Test Protocol ==="
echo "Session: $SESSION"
echo "Log: $LOG_FILE"
echo "Duration: ${DURATION}s (5 minutes)"
echo ""
# Clean up any existing session
tmux kill-session -t "$SESSION" 2>/dev/null || true
echo "[1/6] Starting spike-cc in background..."
cd "$(dirname "$0")"
npm run spike-cc > "$LOG_FILE" 2>&1 &
SPIKE_PID=$!
echo " Spike PID: $SPIKE_PID"
sleep 3
# Check if spike is still running
if ! kill -0 $SPIKE_PID 2>/dev/null; then
echo " ERROR: Spike process died!"
tail -50 "$LOG_FILE"
exit 1
fi
echo " ✓ Spike running"
echo ""
echo "[2/6] Verifying session exists..."
if ! tmux has-session -t "$SESSION" 2>/dev/null; then
echo " ERROR: Session $SESSION not found!"
kill $SPIKE_PID 2>/dev/null
exit 1
fi
echo " ✓ Session exists"
echo ""
echo "[3/6] Testing parallel tmux attach (critical P-2 requirement)..."
echo " Opening a 10-second tmux attach in background..."
(tmux attach -t "$SESSION" 2>&1 | head -20 > /tmp/spike-parallel-attach.log) &
ATTACH_PID=$!
sleep 5
if ! kill -0 $ATTACH_PID 2>/dev/null; then
echo " ✓ Attach completed (expected for non-interactive)"
else
echo " ✓ Attach still running"
kill $ATTACH_PID 2>/dev/null || true
fi
# Check if spike is still running after parallel attach
if ! kill -0 $SPIKE_PID 2>/dev/null; then
echo " ERROR: Spike died during parallel attach!"
tail -50 "$LOG_FILE"
exit 1
fi
echo " ✓ Control mode survived parallel attach"
echo ""
echo "[4/6] Triggering alternate-screen failure mode..."
echo " Sending /settings command..."
tmux send-keys -t "$SESSION" "/settings" Enter
sleep 2
# Navigate in settings (arrow keys)
echo " Navigating in settings menu..."
tmux send-keys -t "$SESSION" Down Down Up
sleep 1
# Exit settings (Escape or q)
echo " Exiting settings..."
tmux send-keys -t "$SESSION" Escape
sleep 2
echo " ✓ Alternate-screen sequence complete"
echo ""
# Check if spike is still receiving output
echo "[5/6] Verifying stream still active after alternate-screen..."
echo " Sending test prompt..."
tmux send-keys -t "$SESSION" "echo 'STREAM_TEST_AFTER_SETTINGS_$(date +%s)'" Enter
sleep 2
# Check recent output for our test string
if tail -100 "$LOG_FILE" | grep -q "STREAM_TEST_AFTER_SETTINGS"; then
echo " ✓ Stream still active! Control mode survived alternate-screen."
else
echo " WARNING: Test string not found in recent output."
echo " This could mean a lag or the test was too fast."
echo " Checking if spike is still running..."
if kill -0 $SPIKE_PID 2>/dev/null; then
echo " ✓ Spike process still alive"
else
echo " ERROR: Spike process died!"
tail -50 "$LOG_FILE"
exit 1
fi
fi
echo ""
echo "[6/6] Running for full 5-minute duration..."
START_TIME=$(date +%s)
NEXT_REPORT=$((START_TIME + 60))
while true; do
CURRENT_TIME=$(date +%s)
ELAPSED=$((CURRENT_TIME - START_TIME))
if [ $ELAPSED -ge $DURATION ]; then
break
fi
# Check if spike is still running
if ! kill -0 $SPIKE_PID 2>/dev/null; then
echo " ERROR: Spike died at ${ELAPSED}s!"
tail -50 "$LOG_FILE"
exit 1
fi
# Report every minute
if [ $CURRENT_TIME -ge $NEXT_REPORT ]; then
REMAINING=$((DURATION - ELAPSED))
echo " Still running... ${ELAPSED}s elapsed, ${REMAINING}s remaining"
NEXT_REPORT=$((NEXT_REPORT + 60))
# Send a keepalive message
tmux send-keys -t "$SESSION" "echo 'keepalive_${ELAPSED}s'" Enter
fi
sleep 5
done
echo " ✓ Completed 5-minute test"
echo ""
echo "=== Test Complete ==="
echo "Stopping spike..."
kill $SPIKE_PID 2>/dev/null || true
wait $SPIKE_PID 2>/dev/null || true
echo ""
echo "=== Results ==="
echo "Log file: $LOG_FILE"
echo ""
echo "Statistics from log:"
grep "\[spike-cc\].*Stats" "$LOG_FILE" | tail -5
echo ""
OUTPUT_EVENTS=$(grep -c "handleOutputEvent\|%output" "$LOG_FILE" || echo "0")
echo "Total output events (approx): $OUTPUT_EVENTS"
echo ""
if [ $OUTPUT_EVENTS -gt 10 ]; then
echo "✅ VERDICT: Control mode appears to work reliably"
echo " - Survived alternate-screen transition"
echo " - Parallel attach did not interfere"
echo " - Ran for full 5-minute duration"
else
echo "⚠️ VERDICT: Insufficient output events captured"
echo " This may indicate a problem with the spike implementation"
fi
echo ""
echo "Full log saved to: $LOG_FILE"
echo "To review: cat $LOG_FILE"

43
tsconfig.json Normal file
View File

@ -0,0 +1,43 @@
{
"compilerOptions": {
// Simulate pi's TypeScript runtime environment
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
// Type-check only, no emit pi handles the actual transpilation
"noEmit": true,
"strict": true,
"exactOptionalPropertyTypes": false,
// Resolve peer deps from pi's global node_modules
"baseUrl": ".",
"paths": {
"@earendil-works/pi-coding-agent": [
"../../../../../../usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.d.ts"
],
"@earendil-works/pi-tui": [
"../../../../../../usr/local/lib/node_modules/@earendil-works/pi-tui/dist/index.d.ts"
]
},
"typeRoots": [
"./node_modules/@types",
"/usr/local/lib/node_modules/@types"
],
// Project uses .js extensions in imports (NodeNext convention)
"allowImportingTsExtensions": false,
// Relax some checks that are impractical without the real pi runtime
"skipLibCheck": true,
"ignoreDeprecations": "6.0"
},
"include": [
"extensions/remote-control/**/*.ts"
],
"exclude": [
"node_modules",
"build"
]
}