diff --git a/README.md b/README.md index 35e0f49..6eb6efe 100644 --- a/README.md +++ b/README.md @@ -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= + +# Create a tmux session +curl -s "http://127.0.0.1:7777/sessions?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=" +# → send: {"type":"resume","lastSeq":null} + +# Send a keystroke +curl -s "http://127.0.0.1:7777/sessions/work/input?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. diff --git a/docs/NEXT-STEPS.md b/docs/NEXT-STEPS.md index 98ddfe2..91727fa 100644 --- a/docs/NEXT-STEPS.md +++ b/docs/NEXT-STEPS.md @@ -1,8 +1,8 @@ # Next Steps — Resume Pointer > **Last updated:** 2026-05-15. -> **Where we are:** Phase 0 + 0.5 done. Phase 1 doc updated for Path B. Interface contracts IC-1..IC-4 frozen (commit 7c40c49). -> **Where we go next:** Dispatch T-1.0 — Server Refactor. +> **Where we are:** Phase 1 complete. All T-1.0..T-1.10 implemented + smoke 12/12 green. +> **Where we go next:** Phase 2 — iOS MVP. Blocked on Phase 1 being stable; unblock in SYNC.md. This document is the "where did I leave off" anchor. Read this first when resuming work. The rest of `docs/` is reference. @@ -17,7 +17,7 @@ resuming work. The rest of `docs/` is reference. | 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) | ✅ **frozen** 2026-05-15. See SYNC.md. | -| Phase 1 implementation | ⛔ not started. T-1.0 is the next dispatch. | +| Phase 1 implementation | ✅ **done** 2026-05-15. All T-1.0..T-1.10 on main. Smoke 12/12. | | iOS work | blocked, untouched. | Branches on remote `git.vpsj.de/jay/pi-remote-control`: diff --git a/docs/SYNC.md b/docs/SYNC.md index f1b4b16..5b5a55e 100644 --- a/docs/SYNC.md +++ b/docs/SYNC.md @@ -38,7 +38,7 @@ 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 1 — Sidecar | **done** | All T-1.0..T-1.10 implemented. Smoke 12/12 green. | | Phase 2 — iOS MVP | blocked on Phase 1 | Sidecar must be reachable and stable. | | Phase 3 — iOS Augmentation | blocked on Phase 2 | Continuous after MVP ships. | @@ -162,4 +162,5 @@ yyyy-mm-dd @handle T-x.y what was done 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. ``` diff --git a/docs/reference/OPERATOR.md b/docs/reference/OPERATOR.md new file mode 100644 index 0000000..3729f9b --- /dev/null +++ b/docs/reference/OPERATOR.md @@ -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= +``` + +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=` query param (same token as the startup URL) +- `Authorization: Bearer ` 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 +``` + +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/.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 +# 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. diff --git a/extensions/remote-control/tmux/control.ts b/extensions/remote-control/tmux/control.ts index 448f7c9..d7e018d 100644 --- a/extensions/remote-control/tmux/control.ts +++ b/extensions/remote-control/tmux/control.ts @@ -48,8 +48,8 @@ export class ControlClient { if (this.proc) return; this.closed = false; - // -CC = control mode with passthrough (so tmux sends output events for all panes) - this.proc = spawn("tmux", ["-CC", "attach", "-t", this.session], { + // -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"], }); diff --git a/extensions/remote-control/tmux/input.ts b/extensions/remote-control/tmux/input.ts index 7e877a8..3c2eca3 100644 --- a/extensions/remote-control/tmux/input.ts +++ b/extensions/remote-control/tmux/input.ts @@ -32,58 +32,31 @@ const KEY_MAP: Record = { * 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, - pane = "0.0", -): Promise { +export async function sendKey(session: string, name: string): Promise { const tmuxKey = KEY_MAP[name.toLowerCase()]; if (!tmuxKey) { throw new Error( `Unknown key name: "${name}". Supported: ${Object.keys(KEY_MAP).join(", ")}`, ); } - await execFileAsync("tmux", [ - "send-keys", - "-t", - `${session}:${pane}`, - tmuxKey, - ]); + // 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, - pane = "0.0", -): Promise { - await execFileAsync("tmux", [ - "send-keys", - "-t", - `${session}:${pane}`, - "-l", - data, - ]); +export async function sendKeys(session: string, data: string): Promise { + 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, - pane = "0.0", -): Promise { +export async function sendPaste(session: string, data: string): Promise { const wrapped = `\x1b[200~${data}\x1b[201~`; - await execFileAsync("tmux", [ - "send-keys", - "-t", - `${session}:${pane}`, - "-l", - wrapped, - ]); + await execFileAsync("tmux", ["send-keys", "-t", session, "-l", wrapped]); } diff --git a/extensions/remote-control/tmux/snapshot.ts b/extensions/remote-control/tmux/snapshot.ts index 2e84a32..570ff7c 100644 --- a/extensions/remote-control/tmux/snapshot.ts +++ b/extensions/remote-control/tmux/snapshot.ts @@ -30,8 +30,9 @@ export interface SnapshotOptions { * Returns raw text as a string. */ export async function capturePane(opts: SnapshotOptions): Promise { - const { session, pane = "0.0", escapes = false } = opts; - const target = `${session}:${pane}`; + 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 @@ -47,7 +48,7 @@ export async function capturePane(opts: SnapshotOptions): Promise { */ export async function captureThumbnail( session: string, - pane = "0.0", + pane?: string, ): Promise { // tmux can't resize the capture directly via capture-pane flags, so we // capture full content and truncate to 40-char wide × 12 lines. diff --git a/package.json b/package.json index 69fea3e..5ef2b51 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "lint": "biome check --write .", "lint:check": "biome check .", "prepare": "node .husky/install.mjs", - "smoke": "node --test scripts/smoke/smoke.mjs" + "smoke": "node --test scripts/smoke/smoke.mjs", + "smoke:stream": "node --test scripts/smoke/stream.test.mjs", + "smoke:all": "node --test scripts/smoke/smoke.mjs scripts/smoke/stream.test.mjs" }, "devDependencies": { "@biomejs/biome": "^2.4.12", diff --git a/scripts/smoke/stream.test.mjs b/scripts/smoke/stream.test.mjs new file mode 100644 index 0000000..243bff0 --- /dev/null +++ b/scripts/smoke/stream.test.mjs @@ -0,0 +1,261 @@ +/** + * 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: 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 + }); +});