Compare commits

...

39 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
42 changed files with 4792 additions and 433 deletions

View File

@ -90,6 +90,50 @@ All planning and coordination lives in [`docs/`](./docs/):
---
## 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
- The server only listens on localhost. Remote access depends on your tunnel.

View File

@ -1,8 +1,8 @@
# Next Steps — Resume Pointer
> **Last updated:** 2026-05-15, end of day.
> **Where we are:** Phase 0 + 0.5 done. Phase 1 doc updated for Path B.
> **Where we go next:** Freeze interface contracts, then T-1.0.
> **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.
@ -14,87 +14,64 @@ resuming work. The rest of `docs/` is reference.
| 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**. Branch `feat/spike-tmux-cc` kept. |
| Phase 1 plan | ✅ updated to Path B. T-1.1 now specifies control mode + `%output` parser. Architecture diagram, risks (R4 + R5) added. |
| Interface Contracts (IC-1..IC-4) | ⚠️ **draft**. Need orchestrator sign-off before T-1.5 / fan-out. |
| Phase 1 implementation | ⛔ not started. T-1.0 is the next dispatch. |
| iOS work | blocked, untouched. |
| 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` — has all docs, no implementation changes yet.
- `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.
---
## Orchestrator todo list (in order)
## Open work (in priority order)
These are things only the orchestrator does — not delegatable to a worker
subagent.
### Phase 2 remaining
### 1. Freeze interface contracts
- **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).
Open `PHASE-1-sidecar.md` §Interface Contracts and re-read IC-1..IC-4.
For each: does it still look right after the spikes? Anything to tweak?
### Bug backlog (not regressions, pre-existing)
Things to double-check:
- **IC-1 ClientToServer**: do we want to add `{ type:"resize"; cols:int; rows:int }` so iPad-Landscape can renegotiate tmux pane size? (Spec said fixed 120×40, but if we ever want elastic, this is the place.) Recommendation: defer, fix at 120×40 for v1, revisit only if it bites.
- **IC-1 ServerToClient**: `tree` event — gruppe T is out of iOS scope, so this can be dropped from the contract OR kept as "reserved, server may emit but client ignores". Recommendation: drop to keep contract tight.
- **IC-2 REST shape**: `/sessions/:id/thumbnail` is referenced by iOS-D-01c (Phase 3). Should the endpoint return raw text/plain capture, or a structured JSON `{cols,rows,lines:[…]}`? Recommendation: raw text/plain — simpler, smaller, client parses lines itself.
- **IC-3 pairing**: `deviceToken` and `environment` are now mandatory in Phase 2. Pre-Phase-2 they're optional. Mark accordingly.
- **IC-4 TOML config**: `[apns]` section can have an `environment_default` for testing convenience? Probably no — environment is per-device. Leave as-is.
- **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.
After review:
- Edit `SYNC.md` "Frozen Interface Contracts" table: set Status `frozen` and fill `Frozen at` date.
- Commit on main.
### Phase 3 — not yet in scope
### 2. Dispatch T-1.0 — Server Refactor
Single agent, blocking everyone else. Refactor existing
`extensions/remote-control/server.ts` (335 lines) and friends into the
modular layout from `PHASE-1-sidecar.md` §Architecture Sketch.
Prompt template (adapt with concrete contract numbers):
```
# Task: Phase 1 T-1.0 — Server Refactor
Working dir: /Users/jay/.pi/agent/git/git.vpsj.de/jay/pi-remote-control
Read first:
- docs/NEXT-STEPS.md (state of play)
- docs/PHASE-1-sidecar.md §Architecture Sketch and §Task Breakdown row T-1.0
- docs/SYNC.md (claim the task)
- existing code: extensions/remote-control/{index,server,html,messages,auth,config}.ts
Goal:
- Carve existing server.ts into the server/ + server/routes/ + server/upgrade.ts
structure from the phase doc.
- Keep the LEGACY HTML client (html.ts) working end-to-end. Add comments
tagging these legacy paths.
- No new features. No new endpoints. Just structural refactor + ensure
existing tests / smoke usage still works.
- After your refactor lands, the directory layout matches the Phase-1
Architecture Sketch.
Out of scope: anything touching tmux/, buffer/, pi/, auth/{pairing,tls}.ts,
apns/, cli/. Those are for later T-1.* tasks.
Branch: feat/p1-t1-0-server-refactor
```
After T-1.0 merges into main, the parallel fan-out becomes available.
### 3. Plan the parallel fan-out
Once T-1.0 is in main:
- 5 worker dispatches in parallel: T-1.1, T-1.2, T-1.3, T-1.4, T-1.10.
- Each gets a tight prompt referencing their row in `PHASE-1-sidecar.md`
and the relevant frozen ICs.
- T-1.1 explicitly says: use control mode, reference `spike-cc.ts` on
`feat/spike-tmux-cc`, follow R-CC-1..R-CC-5 expectations.
T-1.5, T-1.6, T-1.7 follow as their deps come in.
Slash palette, voice, themes, search, etc. See `docs/PHASE-3-ios-augmentation.md`.
---
@ -102,26 +79,18 @@ T-1.5, T-1.6, T-1.7 follow as their deps come in.
1. `docs/NEXT-STEPS.md` (this file)
2. `docs/SYNC.md` — current claims, gate, contracts
3. `docs/PHASE-1-sidecar.md` — what to build
4. `docs/reference/PHASE-0-report.md` and `PHASE-0.5-report.md` — why we're
building it this way
5. Spec only if you forget the why: `docs/reference/SPEC-ios-app.md`
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`
---
## Open questions for next session
## Conventions established
- **OQ-1.** Drop the `tree` event from IC-1 ServerToClient, or keep reserved? (Recommendation: drop.)
- **OQ-2.** Resize message in IC-1? (Recommendation: defer.)
- **OQ-3.** Should `tmux/manager.ts` use a single long-lived control-mode connection per **server**, or per **session**? The spike used per-session for simplicity. Per-server scales better but is more code. Decide before T-1.1 starts.
- **OQ-4.** Worker for T-1.0: same `anthropic/claude-sonnet-4-5`, or escalate to a higher-context model since the refactor touches multiple files? (Recommendation: sonnet-4-5 with `context: fresh` is fine for ~500 LoC refactor.)
---
## Don't do tomorrow
- Don't merge the spike branches into main. They're reference, not code.
- Don't start T-1.1 before T-1.0 is in main.
- Don't start any iOS task until Phase 1 is at least feature-complete.
- **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

@ -127,7 +127,8 @@ type ClientToServer =
type ServerToClient =
| { 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: "session-meta"; name: string; description?: string; createdAt: string }
| { type: "error"; code: string; message: string };
@ -159,7 +160,9 @@ QR encodes a `pi-remote://` URL:
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)

View File

@ -38,8 +38,8 @@ The point: no central scheduler is required. A short structured edit on
|---|---|---|
| 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 | done | ✅ VERDICT: Path B (control mode) recommended. See `reference/PHASE-0.5-report.md`. |
| Phase 1 — Sidecar | ready to start | Streaming path decided: tmux control mode (Path B). |
| Phase 2 — iOS MVP | blocked on Phase 1 | Sidecar must be reachable and stable. |
| 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 | **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. |
Update the **Status** column when a phase transitions. Allowed states:
@ -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/cli/**` | T-1.3, 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/PHASE-*.md` | nobody once a phase has started (frozen plan) — open a CCR to amend |
| `docs/reference/**` | nobody during implementation — archival |
@ -99,10 +100,10 @@ Phase 2 kicks off.
| ID | Defined in | Status | Frozen at | Owner of changes |
|---|---|---|---|---|
| IC-1 — WebSocket frame protocol | `PHASE-1-sidecar.md` §Interface Contracts | **draft** — needs orchestrator sign-off before T-1.5 starts | — | 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 | **draft** | — | T-1.5..T-1.7 leads |
| IC-3 — Pairing payload | `PHASE-1-sidecar.md` §Interface Contracts | **draft** | — | T-1.3 lead |
| IC-4 — Config TOML schema | `PHASE-1-sidecar.md` §Interface Contracts | **draft** | — | T-1.7 lead |
| 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 | **frozen** | 2026-05-15 | T-1.5..T-1.7 leads |
| 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 | **frozen** | 2026-05-15 | T-1.7 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
@ -159,4 +160,8 @@ 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.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

@ -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);
});
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);
});

View File

@ -58,8 +58,8 @@ export function serializeMessage(
const toolCalls = (msg.content as RawContent[])
.filter((c) => c.type === "toolCall")
.map((c) => ({
id: c.id,
name: c.name,
id: c.id ?? "",
name: c.name ?? "",
args: JSON.stringify(c.arguments, null, 2),
}));
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
* for real-time message streaming between the pi session and browser clients.
* The server implementation has been moved to server/ sub-modules:
* 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";
import type { IncomingMessage } from "node:http";
import { createServer } from "node:http";
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;
},
});
});
});
}
export { startServer } from "./server/server.js";
// LEGACY: re-exports for backward-compatibility
export type { RemoteServer } from "./server/types.js";

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

@ -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": {
"@biomejs/biome": "^2.4.12",
"@types/qrcode": "^1.5.6",
"husky": "^9.1.7"
"husky": "^9.1.7",
"typescript": "^6.0.3"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
@ -3970,6 +3971,20 @@
"license": "MIT",
"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": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",

View File

@ -1,5 +1,6 @@
{
"name": "pi-remote-control",
"type": "module",
"version": "1.0.0",
"description": "Expose a running pi session over HTTP/WebSocket — view and interact from any browser on your network.",
"keywords": [
@ -17,11 +18,17 @@
"scripts": {
"lint": "biome check --write .",
"lint:check": "biome check .",
"prepare": "node .husky/install.mjs"
"prepare": "node .husky/install.mjs",
"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": {
"@biomejs/biome": "^2.4.12",
"@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
});
});

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"
]
}