From f4f03c138944d145ab99e8610f6110c9e34966ff Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 6 Jul 2026 21:02:33 +0200 Subject: [PATCH 01/15] docs(remote): add remote mobile companion spec and implementation plan Co-Authored-By: Claude Opus 4.8 --- .../2026-07-06-remote-mobile-companion.md | 465 ++++++++++++++++++ ...26-07-06-remote-mobile-companion-design.md | 266 ++++++++++ 2 files changed, 731 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-06-remote-mobile-companion.md create mode 100644 docs/superpowers/specs/2026-07-06-remote-mobile-companion-design.md diff --git a/docs/superpowers/plans/2026-07-06-remote-mobile-companion.md b/docs/superpowers/plans/2026-07-06-remote-mobile-companion.md new file mode 100644 index 000000000..17c58c11c --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-remote-mobile-companion.md @@ -0,0 +1,465 @@ +# Remote Mobile Companion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the owner watch every Agent Code session and send prompts / permission replies from a phone browser — LAN first, optional tunnel later — driving the real agents. + +**Architecture:** Introduce a `SessionFeed` contract that abstracts "how the UI gets session events and sends input." The desktop keeps working via `IpcSessionFeed` (wraps today's `window.api.onSession*` + input calls). A new isolated main-process subsystem (`RemoteServer`) serves a phone web client that implements the same contract over WebSocket. A hard one-way dependency wall keeps all remote code out of core. + +**Tech Stack:** Electron + React 18, TypeScript (ESM, `@main`/`@renderer`/`@shared`/`@mcp` path aliases), `ws` (already a dep), `zod` (already a dep), electron-vite (already multi-target), Vitest. Phase 2 adds bundled `cloudflared` under `third_party/` following the tmux/mitmproxy convention. + +**Spec:** `docs/superpowers/specs/2026-07-06-remote-mobile-companion-design.md` + +## Global Constraints + +- **One-way dependency wall.** Core (`src/main/**` except `src/main/remote`, `src/main/ipc/remote.ts`; `src/renderer/**` except `features/remote` and `features/sessionFeed`; `src/providers/**`) MUST NOT import from `src/main/remote/**` or `src/remote-client/**`. If you deleted the remote tree, core must still compile. Enforced by review, not tooling (repo convention: no CI grep locks). +- **The only shared coupling is `SessionFeed`** in `src/shared/sessionFeed/`, importable by both renderer and remote client. +- **v1 phone scope is prompts + permission replies only.** No shell exec, no session spawn/kill, no provider switch — those message types do not exist in `protocol/messages.ts`. +- **ESM imports use `.js` suffixes** in `src/main`/`src/mcp` (NodeNext) and path aliases elsewhere — match each file's existing neighbours exactly. +- **Thick WHY comments** are mandatory (see `CLAUDE.md`): explain why, what constraint forced the shape, what breaks if an invariant fails. +- **No new permanent test files / no new `test:*` scripts** in feature PRs (repo convention). Co-locate Phase 0/1 tests as `*.test.ts` next to the unit under test (matches existing `sessionManager.wake.test.ts`, `BuiltInMcpHttpHost.test.ts`); a separate coverage pass owns broader suites. +- **Verification gate:** `tsc` must pass on both projects (`tsconfig.node.json` and `tsconfig.web.json`) — electron-vite build + vitest do NOT type-check (see project memory `project_verification_tsc_gate`). Run both before declaring any task done. +- **Server + tunnel are OFF by default**; both are explicit opt-in toggles. +- **Do branch/worktree work under `.worktrees/`**, main checkout stays on `main` (repo convention). + +--- + +# PHASE 0 — The `SessionFeed` refactor (standalone, ships alone) + +**Deliverable:** The desktop renderer consumes session events + sends input through an injected `SessionFeed` instead of reaching for `window.api` directly. Zero user-visible change. The renderer becomes testable against a fake feed with no Electron. This is the enabling seam for the phone and aligns with provider plug-and-play (#394). + +**Scope boundary (important, avoids scope creep):** `SessionFeed` covers ONLY the per-session event stream the phone needs and the input commands. It does NOT abstract desktop-only side channels that the phone will never call: `window.api.ghostAppend`, `window.api.gitWorktrees`, feed-debug, perf, LSP, editor FS. Those stay as direct `window.api` calls inside `useIpcSubscriptions.ts`. + +## File Structure (Phase 0) + +- Create `src/shared/sessionFeed/types.ts` — re-exports of the event payload types + the input DTOs, provider-neutral. +- Create `src/shared/sessionFeed/SessionFeed.ts` — the interface. +- Create `src/renderer/src/features/sessionFeed/IpcSessionFeed.ts` — desktop impl (delegates to `window.api`). +- Create `src/renderer/src/features/sessionFeed/SessionFeedContext.tsx` — React context + `useSessionFeed()` hook + provider. +- Create `src/renderer/src/features/sessionFeed/FakeSessionFeed.ts` — test double. +- Modify `src/renderer/src/app/App.tsx` — wrap the tree in ``. +- Modify `src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts` — take a `feed: SessionFeed` param; swap the ~9 `window.api.onSession*` calls for `feed.*`. +- Modify the input call sites to use the feed: `src/renderer/src/workspace/hook/actions/session.ts`, `.../actions/pane.ts`, `.../actions/provider.ts` (whichever call `api.session.sendInput` / `deliverPrompt` / `resolveCondition` — confirm with grep in Task 1). + +## The contract (defined once; every later task depends on these exact names) + +```ts +// src/shared/sessionFeed/SessionFeed.ts +import type { + SessionStartedEvent, SessionScreenEvent, SessionJsonlEntriesEvent, + SessionJsonlErrorEvent, SessionSemanticEvent, SessionConditionsEvent, + SessionSubAgentsEvent, SessionExitEvent, +} from '@shared/sessionFeed/types' +import type { ConditionCustomAction, ResolveConditionResult } from '@shared/sessionFeed/types' + +export type Unsub = () => void + +/** The one seam between "UI wants session I/O" and "where it physically comes + * from". IpcSessionFeed (desktop) and WebSocketSessionFeed (phone) implement + * it. Listeners mirror the existing global one-listener-per-event-type shape + * in useIpcSubscriptions — each fires for ALL sessions and the callback + * dispatches by `sessionId`. We did NOT switch to per-session subscription: + * that would be a behavioural rewrite of the subscription hub, out of scope + * for a pure decoupling. */ +export interface SessionFeed { + // Listeners (subscribe once, dispatch by sessionId in the callback) + onSessionStarted(cb: (e: SessionStartedEvent) => void): Unsub + onSessionScreen(cb: (e: SessionScreenEvent) => void): Unsub + onSessionJsonlEntries(cb: (e: SessionJsonlEntriesEvent) => void): Unsub + onSessionJsonlError(cb: (e: SessionJsonlErrorEvent) => void): Unsub + onSessionSemanticEvent(cb: (e: SessionSemanticEvent) => void): Unsub + onSessionConditions(cb: (e: SessionConditionsEvent) => void): Unsub + onSessionProcessState( + cb: (e: { sessionId: string; active: boolean; status?: string }) => void, + ): Unsub + onSessionSubAgents(cb: (e: SessionSubAgentsEvent) => void): Unsub + onSessionExit(cb: (e: SessionExitEvent) => void): Unsub + + // Commands (the v1 phone-allowed input surface) + sendInput(sessionId: string, data: string, pasteId?: string): Promise + deliverPrompt( + sessionId: string, prompt: string, + ): Promise<{ ok: true } | { ok: false; message: string }> + resolveCondition( + sessionId: string, action: ConditionCustomAction, + ): Promise +} +``` + +`src/shared/sessionFeed/types.ts` re-exports the existing payload types from wherever they currently live (`@preload/api/types` today — Task 1 confirms the canonical source; if they live in preload, move the pure type declarations to `@shared/sessionFeed/types` and have preload re-export them, so `@shared` doesn't depend on `@preload`). + +--- + +### Task 1: Confirm the seam and lock the contract types + +**Files:** +- Read only: `src/preload/api/types.ts`, `src/preload/api/session.ts`, `src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts`, and grep results. + +- [ ] **Step 1: Enumerate every renderer call the feed must cover** + +Run: +```bash +cd /Users/juliusolsson/Desktop/Development/agent-code +grep -rn "api\.session\.onSession\|window\.api\.onSession" src/renderer/src +grep -rn "api\.session\.sendInput\|api\.session\.deliverPrompt\|api\.session\.resolveCondition\|\.sendInput(\|\.deliverPrompt(\|\.resolveCondition(" src/renderer/src +``` +Expected: the `onSession*` hits concentrate in `useIpcSubscriptions.ts`; the input hits are a small set of call sites in `workspace/hook/actions/*`. Record the exact file:line list — later tasks edit exactly these. + +- [ ] **Step 2: Locate the canonical home of the payload types** + +Run: +```bash +grep -rn "export type SessionScreenEvent\|export interface SessionScreenEvent\|SessionSemanticEvent\|SessionConditionsEvent" src/preload src/shared +``` +Expected: types are declared in `src/preload/api/types.ts` (or re-exported from `@shared`). Decision rule: if declared in preload, the pure type declarations move to `src/shared/sessionFeed/types.ts` in Task 2 and preload re-exports them; if already in `@shared`, just re-export. + +- [ ] **Step 3: Commit the finding as a comment block (no code yet)** + +Write the grep results into the top of a scratch note in the plan's worktree (`.worktrees/remote-companion/NOTES-phase0.md`) so subsequent tasks reference the exact call-site list. Commit: +```bash +git add NOTES-phase0.md +git commit -m "chore(remote): record SessionFeed call-site inventory for phase 0" +``` + +--- + +### Task 2: Create the `SessionFeed` contract + shared types + +**Files:** +- Create: `src/shared/sessionFeed/types.ts` +- Create: `src/shared/sessionFeed/SessionFeed.ts` +- Test: `src/shared/sessionFeed/SessionFeed.contract.test.ts` + +**Interfaces:** +- Produces: the `SessionFeed` interface and `Unsub` type exactly as shown in "The contract" above; `types.ts` re-exporting `SessionStartedEvent`, `SessionScreenEvent`, `SessionJsonlEntriesEvent`, `SessionJsonlErrorEvent`, `SessionSemanticEvent`, `SessionConditionsEvent`, `SessionSubAgentsEvent`, `SessionExitEvent`, `ConditionCustomAction`, `ResolveConditionResult`. + +- [ ] **Step 1: Write the failing test** — a structural test that the interface is satisfiable and that a minimal object shape type-checks. + +```ts +// src/shared/sessionFeed/SessionFeed.contract.test.ts +import { describe, it, expect } from 'vitest' +import type { SessionFeed } from '@shared/sessionFeed/SessionFeed' + +// A no-op feed proves the interface is implementable with the exact member +// names later tasks rely on. If a name drifts, this file fails to compile. +const noop: SessionFeed = { + onSessionStarted: () => () => {}, + onSessionScreen: () => () => {}, + onSessionJsonlEntries: () => () => {}, + onSessionJsonlError: () => () => {}, + onSessionSemanticEvent: () => () => {}, + onSessionConditions: () => () => {}, + onSessionProcessState: () => () => {}, + onSessionSubAgents: () => () => {}, + onSessionExit: () => () => {}, + sendInput: async () => true, + deliverPrompt: async () => ({ ok: true }), + resolveCondition: async () => ({ ok: true } as never), +} + +describe('SessionFeed contract', () => { + it('is implementable and exposes the v1 command surface', () => { + expect(typeof noop.sendInput).toBe('function') + expect(typeof noop.deliverPrompt).toBe('function') + expect(typeof noop.resolveCondition).toBe('function') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `NODE_ENV=test npx vitest run src/shared/sessionFeed/SessionFeed.contract.test.ts` +Expected: FAIL — cannot resolve `@shared/sessionFeed/SessionFeed`. + +- [ ] **Step 3: Create `types.ts` then `SessionFeed.ts`** + +Create `src/shared/sessionFeed/types.ts` re-exporting (or declaring, per Task 1 Step 2 decision) the payload types. Create `src/shared/sessionFeed/SessionFeed.ts` with the exact interface from "The contract". Add thick WHY comments on the listener-shape decision. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `NODE_ENV=test npx vitest run src/shared/sessionFeed/SessionFeed.contract.test.ts` +Expected: PASS. + +- [ ] **Step 5: Type-check both projects** + +Run: `npx tsc -p tsconfig.node.json --noEmit && npx tsc -p tsconfig.web.json --noEmit` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/shared/sessionFeed/ +git commit -m "feat(remote): add SessionFeed contract and shared event types" +``` + +--- + +### Task 3: Implement `IpcSessionFeed` (desktop delegates to `window.api`) + +**Files:** +- Create: `src/renderer/src/features/sessionFeed/IpcSessionFeed.ts` +- Test: `src/renderer/src/features/sessionFeed/IpcSessionFeed.test.ts` + +**Interfaces:** +- Consumes: `SessionFeed` (Task 2), `window.api.onSession*` + `window.api.session.sendInput/deliverPrompt/resolveCondition` (existing preload surface — confirm exact access path from Task 1; it is `window.api.onSessionScreen(...)` etc. per `src/preload/api/session.ts`). +- Produces: `export const ipcSessionFeed: SessionFeed`. + +- [ ] **Step 1: Write the failing test** — a fake `window.api` records delegation. + +```ts +// src/renderer/src/features/sessionFeed/IpcSessionFeed.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +beforeEach(() => { + const calls: string[] = [] + ;(globalThis as any).window = { + api: { + onSessionScreen: (cb: unknown) => { calls.push('onSessionScreen'); return () => {} }, + onSessionStarted: () => () => {}, + onSessionJsonlEntries: () => () => {}, + onSessionJsonlError: () => () => {}, + onSessionSemanticEvent: () => () => {}, + onSessionConditions: () => () => {}, + onSessionProcessState: () => () => {}, + onSessionSubAgents: () => () => {}, + onSessionExit: () => () => {}, + sendInput: vi.fn(async () => true), + deliverPrompt: vi.fn(async () => ({ ok: true })), + resolveCondition: vi.fn(async () => ({ ok: true })), + }, + __calls: calls, + } +}) + +describe('IpcSessionFeed', () => { + it('delegates onSessionScreen to window.api', async () => { + const { ipcSessionFeed } = await import('./IpcSessionFeed') + ipcSessionFeed.onSessionScreen(() => {}) + expect((globalThis as any).window.__calls).toContain('onSessionScreen') + }) + + it('delegates sendInput to window.api', async () => { + const { ipcSessionFeed } = await import('./IpcSessionFeed') + await ipcSessionFeed.sendInput('s1', 'hi') + expect((globalThis as any).window.api.sendInput).toHaveBeenCalledWith('s1', 'hi', undefined) + }) +}) +``` + +Note: adjust the `window.api` access path to match the real preload shape from Task 1 (e.g. `window.api.session.sendInput` vs `window.api.sendInput`). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `NODE_ENV=test npx vitest run src/renderer/src/features/sessionFeed/IpcSessionFeed.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `IpcSessionFeed`** — one thin delegating object; each method is the corresponding `window.api` call. WHY comment: this is the desktop's transport; it must stay a pure pass-through so the renderer's behaviour is byte-identical to before the refactor. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `NODE_ENV=test npx vitest run src/renderer/src/features/sessionFeed/IpcSessionFeed.test.ts` +Expected: PASS. + +- [ ] **Step 5: Type-check** — `npx tsc -p tsconfig.web.json --noEmit`. Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/renderer/src/features/sessionFeed/IpcSessionFeed.ts src/renderer/src/features/sessionFeed/IpcSessionFeed.test.ts +git commit -m "feat(remote): add IpcSessionFeed desktop implementation" +``` + +--- + +### Task 4: `SessionFeedContext` + `FakeSessionFeed` + +**Files:** +- Create: `src/renderer/src/features/sessionFeed/SessionFeedContext.tsx` +- Create: `src/renderer/src/features/sessionFeed/FakeSessionFeed.ts` +- Test: `src/renderer/src/features/sessionFeed/SessionFeedContext.test.tsx` + +**Interfaces:** +- Produces: `SessionFeedProvider` (React component, prop `{ value: SessionFeed; children }`), `useSessionFeed(): SessionFeed` (throws if used outside provider), `createFakeSessionFeed(): FakeSessionFeed` where `FakeSessionFeed` implements `SessionFeed` plus test helpers `emitScreen(e)`, `emitSemantic(e)`, ... and records `sendInput`/`deliverPrompt`/`resolveCondition` calls. + +- [ ] **Step 1: Write the failing test** + +```tsx +// src/renderer/src/features/sessionFeed/SessionFeedContext.test.tsx +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { SessionFeedProvider, useSessionFeed } from './SessionFeedContext' +import { createFakeSessionFeed } from './FakeSessionFeed' + +function Probe() { + const feed = useSessionFeed() + return
{typeof feed.sendInput === 'function' ? 'has-feed' : 'no-feed'}
+} + +describe('SessionFeedContext', () => { + it('provides the injected feed to consumers', () => { + const fake = createFakeSessionFeed() + render( + + + , + ) + expect(screen.getByText('has-feed')).toBeTruthy() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** — `NODE_ENV=test npx vitest run src/renderer/src/features/sessionFeed/SessionFeedContext.test.tsx`. Expected: FAIL — modules not found. + +- [ ] **Step 3: Implement** the context (`createContext(null)`, provider, `useSessionFeed` that throws a clear error outside a provider) and `FakeSessionFeed` (holds per-event callback sets; `emitX` invokes them; command methods push to a `calls` array and return canned results). + +- [ ] **Step 4: Run test to verify it passes** — same command. Expected: PASS. + +- [ ] **Step 5: Type-check** — `npx tsc -p tsconfig.web.json --noEmit`. Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/renderer/src/features/sessionFeed/SessionFeedContext.tsx src/renderer/src/features/sessionFeed/FakeSessionFeed.ts src/renderer/src/features/sessionFeed/SessionFeedContext.test.tsx +git commit -m "feat(remote): add SessionFeed React context and FakeSessionFeed test double" +``` + +--- + +### Task 5: Route `useIpcSubscriptions` through the injected feed + +**Files:** +- Modify: `src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts` (the ~9 `window.api.onSession*` call sites — see `useIpcSubscriptions.ts:525,542,689,698,761,810,1163,1202,1227`) +- Modify: `src/renderer/src/workspace/hook/index.ts` (where `useIpcSubscriptions` is called — pass the feed in) +- Test: extend `src/renderer/src/features/sessionFeed/SessionFeedContext.test.tsx` or add `useIpcSubscriptions.feed.test.tsx` + +**Interfaces:** +- Consumes: `useSessionFeed()` (Task 4), the existing `useIpcSubscriptions(refs, setState, setRuntimes, updateRuntime, appendFeedDebug)` signature. +- Produces: `useIpcSubscriptions(feed, refs, setState, setRuntimes, updateRuntime, appendFeedDebug)` — `feed` added as the FIRST param. + +- [ ] **Step 1: Write the failing test** — mount the hook with a `FakeSessionFeed`, emit a screen event, assert the runtime updated. (A thin harness component that calls the hook.) + +```tsx +// assert: fake.emitScreen({ sessionId: 's1', plain: 'hello', recent: 'hello', +// markdown: '', recentMarkdown: '', picker: { visible: false, items: [] } }) +// then the session runtime for 's1' has screen === 'hello' +``` + +Expected: FAIL — hook still reads `window.api`, ignores the fake. + +- [ ] **Step 2: Run test to verify it fails** — Expected: FAIL (runtime not updated from the fake). + +- [ ] **Step 3: Swap the calls** — add `feed: SessionFeed` as the first parameter; replace every `window.api.onSessionStarted/Screen/JsonlEntries/JsonlError/SemanticEvent/Conditions/ProcessState/SubAgents/Exit` with `feed.onSession*`. Leave `window.api.ghostAppend` and `window.api.gitWorktrees` untouched (desktop-only, out of scope — add a WHY comment stating this boundary explicitly so a future reader doesn't "finish the job" and couple the phone to git). Update the caller in `workspace/hook/index.ts` to pass `useSessionFeed()`. + +- [ ] **Step 4: Run test to verify it passes** — Expected: PASS. + +- [ ] **Step 5: Type-check both projects** — `npx tsc -p tsconfig.node.json --noEmit && npx tsc -p tsconfig.web.json --noEmit`. Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts src/renderer/src/workspace/hook/index.ts src/renderer/src/features/sessionFeed/ +git commit -m "refactor(remote): drive useIpcSubscriptions from injected SessionFeed" +``` + +--- + +### Task 6: Route input commands through the feed + mount the provider + +**Files:** +- Modify: the input call sites found in Task 1 (`workspace/hook/actions/session.ts`, and any of `actions/pane.ts`, `actions/provider.ts` that call `sendInput`/`deliverPrompt`/`resolveCondition`) +- Modify: `src/renderer/src/app/App.tsx` (wrap tree in ``) +- Test: a focused test asserting an action calls `feed.deliverPrompt` with the right args using `FakeSessionFeed` + +**Interfaces:** +- Consumes: `useSessionFeed()` / the feed threaded through the action context. + +- [ ] **Step 1: Write the failing test** — invoke the send-prompt action with a fake feed; assert `fake.calls` contains the expected `deliverPrompt`/`sendInput` entry. Expected: FAIL (action still calls `window.api`). + +- [ ] **Step 2: Run test to verify it fails** — Expected: FAIL. + +- [ ] **Step 3: Swap the input calls** to the feed; mount `` at the top of the renderer tree in `App.tsx`. WHY comment on the provider: this is the single desktop feed-selection point named in the spec's isolation section. + +- [ ] **Step 4: Run test to verify it passes** — Expected: PASS. + +- [ ] **Step 5: Full type-check + run the existing renderer suite** — `npx tsc -p tsconfig.node.json --noEmit && npx tsc -p tsconfig.web.json --noEmit && NODE_ENV=test npx vitest run --project renderer`. Expected: no errors, existing tests green. + +- [ ] **Step 6: Manual smoke** — `npm run dev`, spawn a Claude session, send a prompt, answer a permission prompt. Confirm identical behaviour to `main`. (Per skill `verify`: drive the real flow, don't just trust tsc.) + +- [ ] **Step 7: Commit + open PR** + +```bash +git add -A +git commit -m "refactor(remote): route session input through SessionFeed; mount desktop provider" +``` +Then open a PR titled `Phase 0: SessionFeed decoupling` per repo convention (do NOT auto-merge; open and stop). + +**Phase 0 exit criteria:** both `tsc` projects clean; renderer suite green; manual smoke identical to `main`; deleting `src/shared/sessionFeed` would break only the renderer feed files, nothing in `src/main`. + +--- + +# PHASE 1 — LAN companion (task map; expand into its own plan after Phase 0 lands) + +**Deliverable:** Enable a "Remote" toggle on the desktop → a QR appears → scan it on your phone → see all sessions live and send prompts / permission replies over the LAN. + +**Why a task map, not full TDD here:** Phase 1's exact server/message shapes depend on the `SessionFeed` surface finalized in Phase 0 (e.g. the concrete `SessionEvent` union the server will serialize). Authoring literal test code now would fabricate types that Phase 0 may adjust. Expand this into `docs/superpowers/plans/2026-NN-remote-companion-phase1.md` once Phase 0 is merged, using the same bite-sized TDD structure as Phase 0. + +**New files (all behind the isolation wall):** + +- `src/main/remote/RemoteServer.ts` — HTTP + `ws` server. Modeled on `src/mcp/runtime/BuiltInMcpHttpHost.ts` (same start/stop lifecycle, same journal wiring). Constructed in `src/main/index.ts` next to `builtInMcpHost` (the ONE core construction line); subscribes to `SessionFeedSource`; serves the built `remote-client` bundle; upgrades WS with token check. +- `src/main/remote/SessionFeedSource.ts` — adapts `SessionManager`'s existing event emissions into the serialized `SessionEvent` frames sent to phones. Second subscriber alongside `src/main/sessions/forwarder.ts`; MUST NOT change the forwarder. +- `src/main/remote/protocol/messages.ts` — zod schemas for every inbound/outbound WS message. Outbound: `session-list`, `session-event`. Inbound (v1 ONLY): `send-prompt`, `submit`, `interrupt`, `permission-reply`. No shell/lifecycle messages exist. +- `src/main/remote/protocol/scope.ts` — the allow-list gate; rejects any message type not in the v1 set before it reaches `SessionManager`. +- `src/main/remote/auth/secret.ts` — per-install HMAC secret, created once under `src/main/storage/paths.ts` convention. +- `src/main/remote/auth/DevicePairing.ts` — one-time code issue/verify, HMAC token mint/verify. +- `src/main/remote/auth/deviceRegistry.ts` — persisted paired-device list + revoke. +- `src/main/remote/transport/RemoteTransport.ts` + `LanTransport.ts` — bind LAN interface, report `http://:` for the QR. +- `src/main/ipc/remote.ts` — desktop↔RemoteServer control IPC: `remote:enable`, `remote:disable`, `remote:status` (URL + QR payload), `remote:list-devices`, `remote:revoke-device`. Register in `src/main/ipc/index.ts` (mirror the existing `registerAllIpc` pattern). +- `src/preload/api/remote.ts` — preload bridge for the above (mirror `src/preload/api/session.ts`). +- `src/renderer/src/features/remote/RemotePanel.tsx` — desktop control UI: enable/disable, LAN QR, device list + revoke. (Uses a bundled QR renderer — inline SVG QR, no external CDN.) +- `src/remote-client/` — the phone web app as a new electron-vite build target (add a build config mirroring `testing/rendering/electron.vite.config.ts`): `index.html`, `main.tsx`, `WebSocketSessionFeed.ts` (implements `SessionFeed` over WS with reconnect/backfill), `pairing/` (scan/enter code, store token in `localStorage`), `ui/` (session switcher, sticky prompt box, big approve/deny buttons). Imports the renderer's presentational transcript components and drives them with `WebSocketSessionFeed`. + +**Core touch-points (the entire allowed blast radius in core):** +1. `src/main/index.ts` — construct + `start()`/`stop()` `RemoteServer` (mirror `builtInMcpHost`, lines ~452-475, ~511, ~527). +2. `src/main/ipc/index.ts` — one line registering `remote` IPC. +3. `src/preload/api/index.ts` — one line exposing the `remote` API. +4. electron-vite config — add the `remote-client` build target. + +**Test focus (co-located `*.test.ts`, no new suites):** +- `protocol/scope.test.ts` — every out-of-scope message type is rejected; each in-scope type is accepted. +- `auth/DevicePairing.test.ts` — mint→verify round-trips; tampered token rejected; revoked device rejected; expired one-time code rejected. +- `RemoteServer.integration.test.ts` — `WebSocketSessionFeed` ↔ `RemoteServer` against a real `SessionManager` with a fake PTY: a screen event on the Mac side reaches the phone feed; a `send-prompt` from the phone reaches `SessionManager.sendInput`/`deliverPrompt`. + +**Suggested task order (each a bite-sized TDD unit when expanded):** secret → DevicePairing → deviceRegistry → protocol/messages → protocol/scope → LanTransport → SessionFeedSource → RemoteServer (wire it all) → ipc/remote + preload → RemotePanel → WebSocketSessionFeed → remote-client UI → end-to-end smoke over LAN. + +--- + +# PHASE 2 — Remote tunnel (task map) + +**Deliverable:** A "Tunnel" toggle in `RemotePanel` spawns bundled `cloudflared`, yields a public `https://.trycloudflare.com` URL, and shows it as a QR. Control from anywhere. + +**New files (matching the `third_party/` convention — see `third_party/tmux/`):** +- `third_party/cloudflared/manifest.json` — version + per-arch sha256 + URL template. +- `third_party/cloudflared/{README.md,LICENSE.md,.gitignore}` — `.gitignore` keeps `cache/`, `build/` out of git; binary is NEVER committed. +- `scripts/runtime-tools/fetch-cloudflared.mjs` + `verify-cloudflared.mjs` — mirror `fetch-tmux.mjs`/`verify-tmux.mjs`; add `runtime:fetch:cloudflared` / `runtime:verify:cloudflared` npm scripts and fold into `runtime:prepare:mac`. +- `src/main/remote/transport/CloudflaredTunnel.ts` — resolves the bundled binary via `src/main/setup/runtimeTools.ts` (`resolveBundledTool('cloudflared')`), spawns `cloudflared tunnel --url http://localhost:`, parses the `trycloudflare.com` URL from stderr/stdout, exposes it, and tears the child down on disable/quit. + +**Core touch-points:** none beyond `RemotePanel` (add the tunnel toggle) and the transport seam already built in Phase 1. The `RemoteTransport` interface from Phase 1 is why Phase 2 needs no server changes. + +**Test focus:** `CloudflaredTunnel.parseUrl.test.ts` against a captured stdout fixture (the URL-line format); manual end-to-end over a live tunnel. + +**Known caveat (document in `RemotePanel` copy):** quick-tunnel URLs are ephemeral (regenerate the QR each enable) and best-effort/rate-limited. A stable URL needs a Cloudflare account + domain — out of scope for v1. + +--- + +## Self-Review (against the spec) + +- **Spec §"Isolation Boundary"** → Global Constraints (one-way wall) + Phase 1 "Core touch-points" enumerating the exactly-4 holes. ✔ +- **Spec §"SessionFeed (the contract)"** → Phase 0 Tasks 2-6. ✔ (Note: the spec sketched a per-session `subscribe(sessionId, cb)`; this plan deliberately implements the existing global one-listener-per-type shape instead, because switching to per-session subscription would be a behavioural rewrite of the 2048-line subscription hub — out of scope for a pure decoupling. The WebSocket feed in Phase 1 can still fan per-session on the wire; the renderer-side contract stays global. This divergence is intentional and documented in `SessionFeed.ts`.) +- **Spec §"RemoteServer / DevicePairing / RemoteTransport / remote-client"** → Phase 1 file list + task order. ✔ +- **Spec §"Data Flow" (second subscriber, forwarder untouched)** → `SessionFeedSource.ts` note. ✔ +- **Spec §"Security Posture" (token on upgrade + every message, scope by protocol shape, off by default)** → `protocol/scope.ts`, `auth/*`, Global Constraints. ✔ +- **Spec §"Phasing"** → three phases, each independently shippable, Phase 0 fully detailed. ✔ +- **Spec §"cloudflared in third_party"** → Phase 2 file list mirrors `third_party/tmux`. ✔ +- **Placeholder scan:** Phase 0 has complete code/commands; Phases 1-2 are explicitly task maps (not placeholders) with a stated expand-later trigger, per the writing-plans "independent subsystems → separate plans" guidance. ✔ +- **Type consistency:** `SessionFeed` member names are identical across Tasks 2, 3, 4, 5, 6. ✔ +``` diff --git a/docs/superpowers/specs/2026-07-06-remote-mobile-companion-design.md b/docs/superpowers/specs/2026-07-06-remote-mobile-companion-design.md new file mode 100644 index 000000000..76b80040b --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-remote-mobile-companion-design.md @@ -0,0 +1,266 @@ +# Remote Mobile Companion + +**Date:** 2026-07-06 +**Status:** Approved design, pending implementation plan + +## Problem + +Agent Code is built for people who run many agents at once, but control is +pinned to the desktop. There is no way to walk away from the machine and still +watch a long-running agent, answer a permission prompt, or nudge a session with +a follow-up prompt from your phone. + +We want a mobile companion: open a URL on a phone, see every session's live +output, send prompts, and answer agents' permission requests — driving the +*real* agents (not a reduced API) from your pocket. LAN by default, with an +optional public tunnel for use away from the network. + +Two constraints shape the whole design: + +1. **The product thesis must hold.** The phone must show the same custom React + transcript rendering the desktop shows — not a raw terminal, not a + second-class re-implementation that drifts. (See `README.md`, `MANIFESTO.md`.) +2. **Isolation for many contributors.** This subsystem is expected to attract + several people working in parallel. It must live behind a hard boundary so a + contributor can work entirely inside "remote" without touching — or + understanding — the PTY/session core, and so remote work can never break the + desktop. + +## Solution + +Introduce a **`SessionFeed`** contract — the single seam between "how the UI gets +session data + sends input" and "where that data physically comes from." The +desktop keeps working by implementing `SessionFeed` over Electron IPC. The phone +is a *second* implementation of the same contract over WebSocket. The renderer's +transcript components are fed by the contract, so both surfaces render +identically and future rendering work lands once. + +On top of that seam, a main-process subsystem (`RemoteServer`) exposes the same +`SessionManager` event stream to authenticated phones over HTTP/WebSocket, with +a deliberately narrow inbound protocol (prompts and permission replies only — +no shell, no session lifecycle in v1). + +### Scope for v1 (deliberate line) + +Allowed from the phone: **read every session** + **send prompt** + **submit / +Enter** + **interrupt** + **reply to permission prompts** (approve/deny tool use +& folder access). NOT in v1: raw shell command execution, spawning/killing +sessions, provider switching. These are simply absent from the wire protocol, so +a stolen token cannot express them — scope is enforced by the protocol's shape, +not only by a runtime check. + +## The Isolation Boundary (load-bearing) + +This is the part that must not erode. The rule is a **one-way dependency wall**: + +``` + core (main/*, renderer core, providers/*) ──may be imported by──▶ remote/* + remote/* ──may NOT be imported by──▶ core +``` + +- **Core never imports from `remote/`.** There is no line anywhere in + `sessionManager.ts`, the providers, or the desktop shell that references the + remote subsystem. If you deleted the entire remote tree, core would still + compile and run. +- **The wall has exactly three holes**, each tiny and explicit: + 1. `SessionFeed` — the shared contract (in `src/shared/`), imported by both + the renderer and the remote client. This is the *only* type-level coupling. + 2. **One construction line** in `src/main/index.ts` — build `RemoteServer` + next to `BuiltInMcpHttpHost`, hand it the existing `SessionManager`. Mirrors + exactly how the MCP host is already wired (`index.ts:452-475`). + 3. **One feed-selection point** in the renderer — desktop picks + `IpcSessionFeed`; the phone bundle picks `WebSocketSessionFeed`. Everything + downstream is feed-agnostic. +- **Contributors live inside two directories:** `src/main/remote/` (server) and + `src/remote-client/` (phone UI). Neither requires understanding PTYs, tmux, or + provider spawn logic — only the `SessionFeed` + `SessionManager` public + surfaces. + +Why a one-way wall and not just "put it in a folder": a folder alone doesn't +stop core from growing an `import { RemoteServer }` that quietly couples the +phone feature into the session hot path. The direction of the dependency is the +invariant. If core ever needs something from remote, that's the signal to widen +`SessionFeed` or add a neutral event, not to import upward. + +## Architecture + +``` +Phone browser ──HTTP/WS──▶ RemoteServer (main) ──▶ SessionManager (existing, untouched) + src/remote-client/ src/main/remote/ PTYs, transcripts, tmux + reuses renderer's ┌── auth (DevicePairing) │ + transcript components ├── scope-limited WS protocol │ + via WebSocketSessionFeed├── subscribes to session events ◀──┘ (2nd consumer, like the + └── RemoteTransport (LAN | tunnel) renderer forwarder) +``` + +`SessionManager` already emits every session event to a forwarder that pushes to +the renderer (`src/main/sessions/forwarder.ts`, wired at `index.ts:478`). +`RemoteServer` becomes a **second, independent subscriber** to that same stream — +it does not change how the desktop is fed. Outbound events fan out to connected +phones (filtered by each device's grants). Inbound phone messages are verified, +scope-checked, and translated into the same `SessionManager` input calls the +desktop already uses. + +## Directory Structure + +``` +src/ + shared/ + sessionFeed/ + SessionFeed.ts # THE contract: interface + event/message/DTO types + types.ts # SessionSummary, SessionEvent, InboundMessage (zod schemas) + + main/ + remote/ # ALL main-side remote code — isolated + RemoteServer.ts # HTTP+WS server (modeled on BuiltInMcpHttpHost) + SessionFeedSource.ts # adapts SessionManager -> outbound SessionEvents + auth/ + DevicePairing.ts # QR/one-time-code pairing, token mint/verify + deviceRegistry.ts # persisted paired-device store + revocation + secret.ts # per-install HMAC secret (created once, on disk) + protocol/ + messages.ts # zod schemas for every WS message (in & out) + scope.ts # v1 allow-list: which inbound types are legal + transport/ + RemoteTransport.ts # seam: how the server becomes reachable + LanTransport.ts # phase 1: bind LAN interface, report URL/IP + CloudflaredTunnel.ts # phase 2: spawn third_party/cloudflared, parse URL + ipc/ + remote.ts # tiny desktop<->RemoteServer control IPC (enable, QR, devices) + + renderer/ + features/ + sessionFeed/ + IpcSessionFeed.ts # desktop impl of SessionFeed (wraps today's window.api.*) + SessionFeedContext.tsx # provider; desktop injects IpcSessionFeed here + remote/ # desktop "Remote" settings panel (control UI only) + RemotePanel.tsx # enable server, show LAN QR, tunnel toggle+QR, device list+revoke + + remote-client/ # the phone web app — its own electron-vite build target + index.html + main.tsx # mounts renderer transcript components against WebSocketSessionFeed + WebSocketSessionFeed.ts # phone impl of SessionFeed (WS transport + reconnect) + pairing/ # scan/enter code, store token + ui/ # thumb-friendly chrome: session switcher, prompt box, approve/deny + +third_party/ + cloudflared/ # phase 2 — manifest.json + fetch/verify scripts (matches tmux/mitmproxy) + +scripts/runtime-tools/ + fetch-cloudflared.mjs # phase 2 + verify-cloudflared.mjs # phase 2 +``` + +## Components + +### SessionFeed (the contract) +The interface every UI surface uses instead of talking to a transport directly: + +```ts +interface SessionFeed { + listSessions(): Promise + subscribeSessions(cb: (list: SessionSummary[]) => void): Unsubscribe + subscribe(sessionId: string, cb: (event: SessionEvent) => void): Unsubscribe + sendPrompt(sessionId: string, text: string): Promise + submit(sessionId: string): Promise // Enter + interrupt(sessionId: string): Promise // Esc/Ctrl-C equivalent + replyToPermission(sessionId: string, reply: PermissionReply): Promise +} +``` + +Two implementations: `IpcSessionFeed` (desktop; each method is the existing IPC +call it already makes) and `WebSocketSessionFeed` (phone; each method is a WS +message + response correlation, with reconnect/backfill). + +### RemoteServer +Owns an HTTP server + WS endpoint bound via `RemoteTransport`. Serves the built +`remote-client` bundle. On WS upgrade it verifies a device token; per socket it +tracks which sessions the device may see. Subscribes once to +`SessionFeedSource` and fans events to sockets. Inbound messages are parsed by +`protocol/messages.ts`, checked against `protocol/scope.ts`, then applied to +`SessionManager`. Lifecycle mirrors `BuiltInMcpHttpHost`: constructed in +`index.ts`, `start()`/`stop()` on app ready/quit, off by default. + +### DevicePairing + deviceRegistry +Pairing: desktop generates a short-lived one-time code + shows a QR encoding +`{url, code}`. Phone POSTs the code; server verifies it's live, mints an +HMAC-signed device token (per-install secret from `auth/secret.ts`), and records +the device in `deviceRegistry` (persisted under the storage-paths convention). +Every token is revocable from `RemotePanel`. Verification happens on WS upgrade +*and* on every mutating message. + +### RemoteTransport (LAN now, tunnel later) +A seam so "reachability" is swappable. `LanTransport` binds the chosen interface +and reports the `http://:` URL for the QR. `CloudflaredTunnel` (phase +2) spawns bundled `cloudflared`, parses the `trycloudflare.com` URL from stdout, +and reports it; the URL is ephemeral so the QR regenerates each enable. + +### remote-client (phone UI) +A separate electron-vite build target (config is already multi-target — see +`electron.vite.config.ts` and the `testing/rendering` precedent). It imports the +renderer's *presentational* transcript components and drives them with +`WebSocketSessionFeed`, wrapped in mobile chrome: a session switcher, a sticky +prompt box, and big approve/deny controls for permission prompts. + +## Data Flow + +**Outbound (Mac → phone):** `SessionManager` event → forwarder (unchanged, +feeds desktop) **and** `SessionFeedSource` (new, feeds RemoteServer) → +per-socket grant filter → WS frame → phone's `WebSocketSessionFeed` → same React +transcript components the desktop uses. + +**Inbound (phone → Mac):** phone action → WS message (token attached) → +`RemoteServer` verifies token → `protocol/scope.ts` confirms the type is in the +v1 allow-list → translated to a `SessionManager` input call (identical to the +desktop's). Unknown/out-of-scope types are rejected before reaching +`SessionManager`. + +## Security Posture + +- Server **off by default**; explicit enable in `RemotePanel`. +- Token required on WS upgrade **and** every mutating message. +- Pairing requires one-time physical proximity (scan the QR on the Mac's screen). +- Per-device revocation from the desktop. +- v1 protocol has no shell/lifecycle messages — a stolen token cannot open a + shell or spawn a session because those words don't exist on the wire. +- Tunnel is a **separate** explicit toggle (off by default); ephemeral URL, QR + regenerated each enable. +- Fully local: no Agent Code account, no cloud state. cloudflared quick tunnels + need no account. + +## Phasing + +Each phase is independently useful and independently shippable. + +- **Phase 0 — `SessionFeed` refactor (standalone, no user-visible change).** + Define the contract; route the desktop renderer through `IpcSessionFeed`. + Ships alone; de-risks everything after it. This is the decoupling the codebase + wants regardless of the phone (aligns with provider plug-and-play, #394). +- **Phase 1 — LAN companion.** `RemoteServer` + `DevicePairing` + `remote-client` + (v1 scope) + `RemotePanel`. Works on your Wi-Fi. +- **Phase 2 — remote tunnel.** Bundle `cloudflared` in `third_party/`, wire the + tunnel toggle + QR. + +## Testing + +- **Phase 0:** unit-test the renderer against a `FakeSessionFeed` (proves the + decoupling — renderer runs with no Electron). Desktop smoke: existing flows + unchanged. +- **Phase 1:** `protocol/scope.ts` allow-list unit tests (out-of-scope messages + rejected); `DevicePairing` token mint/verify/revoke unit tests; + `WebSocketSessionFeed` ↔ `RemoteServer` integration against a real + `SessionManager` with a fake PTY. +- **Phase 2:** `CloudflaredTunnel` URL-parse unit test against captured stdout; + manual end-to-end over a live tunnel. + +(Per repo convention, avoid adding permanent new test files in feature PRs where +possible; temporary fixtures are fine — a separate test-coverage pass is planned.) + +## Non-Goals (v1) + +- Raw shell execution, session spawn/kill, provider switching from the phone. +- Stable/custom tunnel domains (needs a Cloudflare account) — quick tunnel only. +- Multi-user / team access — this is one owner, their own devices. +- Native mobile apps — it's a web client. + + From 43938b6bc9f427f59310c5ada0fead63502295d1 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 6 Jul 2026 21:05:14 +0200 Subject: [PATCH 02/15] chore(remote): record SessionFeed call-site inventory for phase 0 Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-06-remote-phase0-notes.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-06-remote-phase0-notes.md diff --git a/docs/superpowers/plans/2026-07-06-remote-phase0-notes.md b/docs/superpowers/plans/2026-07-06-remote-phase0-notes.md new file mode 100644 index 000000000..c01492db9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-remote-phase0-notes.md @@ -0,0 +1,79 @@ +# Phase 0 seam inventory (Task 1 findings) + +Working notes for the SessionFeed refactor. Source of truth for which call +sites Tasks 5-6 edit. Delete or fold into the plan when Phase 0 merges. + +## window.api shape + +FLAT — every domain module is spread onto one `api` object +(`src/preload/api/index.ts`). So `window.api.sendInput(...)`, +`window.api.onSessionScreen(...)`. IpcSessionFeed delegates to the flat +surface. + +## Listeners the feed covers (all in useIpcSubscriptions.ts) + +| Listener | Line | +|---|---| +| onSessionStarted | 525 | +| onSessionScreen | 542 | +| onSessionJsonlError | 689 | +| onSessionExit | 698 | +| onSessionProcessState | 761 | +| onSessionSemanticEvent | 810 | +| onSessionConditions | 1163 | +| onSessionSubAgents | 1202 | +| onSessionJsonlEntries | 1227 | + +Caller: `src/renderer/src/workspace/hook/index.ts:445` — +`useIpcSubscriptions(refs, setState, setRuntimes, updateRuntime, appendFeedDebug)`. + +## Listeners deliberately NOT in the feed (raw PTY, desktop-only) + +- `onSessionAgentPtyData` — AgentTerminalLeaf.tsx:169, features/debug/ui/AgentInlineTerminal.tsx:98 +- `onSessionTerminalData` — TerminalLeaf.tsx:255 + +Phone v1 has no terminals; these stay on window.api. + +## Input call sites routed through the feed (Task 6) + +Agent-composer / feed-row paths (reachable by the phone client later): + +- `workspace/tile-tree/TileLeaf.tsx:230,234` — sendInput (composer submit) +- `workspace/tile-tree/TileLeaf.tsx:581` — resolveCondition (condition banner) +- `workspace/tile-tree/TileLeaf/useComposerDictation.ts:322` — sendInput (dictation bracket paste) +- `features/feed/ui/semantic/AskUserQuestionRow.tsx:213` — resolveCondition +- `features/feed/ui/semantic/AskUserQuestionRow.tsx:360` — sendInput + +## Input call sites that stay on window.api (raw-terminal, desktop-only) + +- `workspace/tile-tree/AgentTerminalLeaf.tsx:163,207` — sendInput (xterm keystrokes) +- `workspace/tile-tree/TerminalLeaf.tsx:218,298` — sendInput (xterm keystrokes) +- `features/debug/ui/AgentInlineTerminal.tsx:93` — sendInput (debug terminal) + +## deliverPrompt + +ZERO renderer call sites today — it exists on preload for the MCP +orchestration path (opencode, API transport, no PTY). It stays in the +SessionFeed contract because the phone client MUST use it for opencode +sessions; IpcSessionFeed delegates to the existing `window.api.deliverPrompt`. +No desktop call-site changes. + +## Payload type home + +Declared in `src/preload/api/types.ts`; constituents come only from +`@shared`/`@mcp`. Decision (per plan Task 1 Step 2 rule): move the pure event +declarations to `src/shared/sessionFeed/types.ts`, have preload re-export so +every existing `@preload/api/types` import keeps resolving byte-for-byte +(same pattern providerConditions.ts documents for conditions-core). + +Types moving to shared: Unsub, PickerItem, SlashPickerState, ScreenSnapshot, +SessionStartedEvent, SessionScreenEvent, SessionJsonlEntriesEvent, +SessionJsonlErrorEvent, SessionSemanticEvent, SessionConditionsEvent, +SubAgentToolCall, SubAgentState, SessionSubAgentsEvent, SessionExitEvent, +ResolveConditionResult, and a new named SessionProcessStateEvent (currently an +inline literal in preload/session.ts). JsonlEntry stays a preload alias of +`@shared/types/session`'s AgentTranscriptEntry; shared types use +AgentTranscriptEntry directly. + +ConditionCustomAction already lives in `@shared/conditions-core/contract` — +re-export, don't move. From 0d11a3ff82042eb5aa6f4abe5d6e4ee653110416 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 6 Jul 2026 21:11:50 +0200 Subject: [PATCH 03/15] feat(remote): add SessionFeed contract and move session event types to shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SessionFeed interface is the single seam between 'the UI wants live session I/O' and 'where it physically comes from' — desktop IPC today, the remote mobile client's WebSocket transport in phase 1. Event payload types move from @preload/api/types to @shared/sessionFeed/types (preload re-exports byte-for-byte) so the contract is implementable without Electron. Co-Authored-By: Claude Opus 4.8 --- src/preload/api/types.ts | 182 +++-------------- .../sessionFeed/SessionFeed.contract.test.ts | 37 ++++ src/shared/sessionFeed/SessionFeed.ts | 87 +++++++++ src/shared/sessionFeed/types.ts | 183 ++++++++++++++++++ 4 files changed, 338 insertions(+), 151 deletions(-) create mode 100644 src/shared/sessionFeed/SessionFeed.contract.test.ts create mode 100644 src/shared/sessionFeed/SessionFeed.ts create mode 100644 src/shared/sessionFeed/types.ts diff --git a/src/preload/api/types.ts b/src/preload/api/types.ts index e5b4f5291..983df00ad 100644 --- a/src/preload/api/types.ts +++ b/src/preload/api/types.ts @@ -1,7 +1,4 @@ -import type { - ConditionCustomAction, - ProviderConditionSnapshot, -} from '@shared/types/providerConditions.js' +import type { ConditionCustomAction } from '@shared/types/providerConditions.js' import type { DictationProvider } from '@shared/types/dictation.js' import type { BuiltInMcpDomain } from '@mcp/shared/types.js' // Local binding for in-file uses (SessionStartedEvent.kind, etc.). The @@ -21,10 +18,32 @@ export type { SessionInfo } from '@shared/types/session.js' // their methods — keep this file for types that cross domain // boundaries or are referenced from the outside. // -// Keeping Unsub here too: every onX subscriber returns one, and the -// generic subscribe helper in ./ipc.ts produces them. - -export type Unsub = () => void +// The session-feed event payloads (Unsub, ScreenSnapshot, SessionStartedEvent, +// SessionScreenEvent, SessionJsonlEntriesEvent, ..., ResolveConditionResult) +// MOVED to @shared/sessionFeed/types so the SessionFeed contract can be +// implemented by surfaces with no Electron at all — the remote mobile client +// speaks it over WebSocket (see docs/superpowers/specs/ +// 2026-07-06-remote-mobile-companion-design.md). Re-exported here so every +// existing `@preload/api/types` import keeps resolving byte-for-byte — the +// same pattern providerConditions.ts uses for conditions-core. +export type { + Unsub, + PickerItem, + SlashPickerState, + ScreenSnapshot, + SessionStartedEvent, + SessionScreenEvent, + SessionJsonlEntriesEvent, + SessionJsonlErrorEvent, + SessionConditionsEvent, + SessionProcessStateEvent, + SubAgentToolCall, + SubAgentState, + SessionSubAgentsEvent, + SessionSemanticEvent, + SessionExitEvent, + ResolveConditionResult, +} from '@shared/sessionFeed/types.js' export type DevDebugConfig = { enabled: boolean @@ -76,36 +95,6 @@ export type DictationStreamTranscriptEvent = { */ export type JsonlEntry = AgentTranscriptEntry -export type PickerItem = { - id: string - label: string - description: string - selected: boolean -} - -export type SlashPickerState = { - visible: boolean - items: PickerItem[] -} - -export type ScreenSnapshot = { - /** Visible viewport text — what CC's TUI is showing right now. - * Source of truth for current-state parsers (trust dialog, - * slash picker, activity spinner). */ - plain: string - /** Viewport with bold/italic re-emitted as markdown. */ - markdown: string - /** Wider window (last ~200 rows including scrollback) used by - * the streaming extractor. CC's responses can grow taller than - * the viewport, scrolling the opening `⏺` marker into - * scrollback; without this wider snapshot the streaming card - * stays blank for long replies. */ - recent: string - /** Markdown counterpart of `recent`. */ - recentMarkdown: string - picker: SlashPickerState -} - // Source of truth lives in @shared/types/providerKind. Re-exported here // (not redeclared) so the preload bridge type and every renderer import // of `SessionKind` resolve to the exact same union as main/shared. @@ -140,40 +129,8 @@ export type SessionSpawnResult = { tmuxName?: string } -export type ResolveConditionResult = - | { ok: true; state?: unknown } - | { - ok: false - reason: - | 'timeout' - | 'aborted' - | 'invalid-payload' - | 'option-not-found' - | 'no-session' - | 'no-headless' - | 'no-resolver' - lastState?: unknown - failedAtStep?: string - } - export type { ConditionCustomAction } -export type SessionStartedEvent = { - sessionId: string - kind: SessionKind - /** Undefined for terminal sessions — they don't have a CC project dir. */ - projectDir?: string -} -export type SessionScreenEvent = { sessionId: string } & ScreenSnapshot -// Bulk variant used by main during bootstrap bursts. Payload is an -// array of {entry, file} tuples for a single session — the renderer -// folds them in one setState instead of paying one render per entry. -// See main/index.ts jsonl coalescer for the WHY. -export type SessionJsonlEntriesEvent = { - sessionId: string - entries: Array<{ entry: JsonlEntry; file: string }> -} -export type SessionJsonlErrorEvent = { sessionId: string; message: string } /** Raw PTY output for a terminal session — destined for xterm.js. */ export type SessionTerminalDataEvent = { sessionId: string; data: string } /** Raw PTY output for an attached Claude/Codex inline terminal. */ @@ -184,83 +141,12 @@ export type SessionAgentPtyDataEvent = { sessionId: string; data: string } // SessionPermissionPromptEvent, SessionCompactionStateEvent) were removed // alongside their preload listener methods and main-window forwarding. The // renderer now derives all of that state from the unified -// `ProviderConditionSnapshot` carried by SessionConditionsEvent. The -// equivalent shapes still exist INTERNALLY on the SessionManager event map -// and the provider runtimes; those are owned by the conditions-framework / +// `ProviderConditionSnapshot` carried by SessionConditionsEvent (which now +// lives in @shared/sessionFeed/types — re-exported above). The equivalent +// shapes still exist INTERNALLY on the SessionManager event map and the +// provider runtimes; those are owned by the conditions-framework / // provider-boundary clusters and are intentionally untouched here. -export type SessionConditionsEvent = { - sessionId: string - snapshot: ProviderConditionSnapshot -} - -// --- Subagent fleet ----------------------------------------------------------- -// -// Claude Code's `Agent` tool spawns a subagent whose FULL transcript is written -// live to `//subagents/agent-.jsonl`, with a -// sidecar `agent-.meta.json` carrying { agentType, description, toolUseId }. -// `toolUseId` matches the parent `Agent` tool_use block id exactly — the -// deterministic join key that lets the feed nest a subagent's live work under -// the card that spawned it. The main-process watcher derives these and pushes -// the whole per-session map on every change. See -// src/main/subagents/ and the design spec -// docs/superpowers/specs/2026-06-14-subagent-fleet-rendering-design.md. - -/** One tool call in a subagent's timeline (the drill-in mini-feed). */ -export type SubAgentToolCall = { - /** Tool name, e.g. "Read" | "Bash" | "Grep". */ - name: string - /** First meaningful arg (path/command/pattern/query), already truncated. */ - headline: string | null - /** 'done' once a matching tool_result was observed; else 'running'. */ - status: 'running' | 'done' -} - -/** Live state of one subagent, keyed by its parent `Agent` tool_use id. */ -export type SubAgentState = { - /** Parent `Agent` tool_use block id — meta.toolUseId. The render join key. */ - toolUseId: string - /** The agent- filename id. */ - agentId: string - /** meta.agentType, e.g. "Explore" | "general-purpose". */ - agentType: string - /** meta.description — the card headline. */ - description: string - status: 'running' | 'done' | 'error' - /** Epoch ms of the first transcript entry, or null if unknown. */ - startedAt: number | null - /** Epoch ms of the last observed entry (drives elapsed + live pulse). */ - lastActivityAt: number | null - /** Count of assistant turns observed. */ - turnCount: number - /** Ordered tool-call timeline (capped — see SUBAGENT_TOOL_CALLS_MAX). */ - toolCalls: SubAgentToolCall[] - /** Count of tool calls dropped from the front when capped (0 if none). */ - droppedToolCalls: number - /** Derived activity label, e.g. "running Grep" | "thinking" | null. */ - currentActivity: string | null -} - -/** Per-session push: the full subAgents map for one session, keyed by - * parent `Agent` tool_use id. */ -export type SessionSubAgentsEvent = { - sessionId: string - subAgents: Record -} - -/** Per-block semantic stream from an agent provider — currently Claude AND - * Codex (not Claude-only, despite earlier wording): Claude via its proxy - * adapter (or screen fallback when proxy is off), Codex via its rollout/proxy - * adapter. `event` is a provider `SemanticEvent` discriminated by `event.type` - * (text_delta / thinking_delta / tool_input_delta / tool_input_finalized / - * block_started / block_completed / turn_started / turn_stopped / turn_delta / - * turn_completed / usage_updated / api_error / stream_error / flow_selected / - * flow_ignored / source_changed / tool_result / signature). We keep `event` as - * `unknown` at the preload layer ON PURPOSE so this bridge stays - * provider-agnostic and doesn't pin a version of any one provider's semantic - * schema — the renderer narrows on `event.type`. */ -export type SessionSemanticEvent = { sessionId: string; event: unknown } - // --- Session prompt index --------------------------------------------------- // // Shape returned by the Search Conversation Prompts modal's IPC @@ -311,12 +197,6 @@ export type TranscriptPathResult = TranscriptPathRequest & { exists: boolean } -export type SessionExitEvent = { - sessionId: string - exitCode: number - signal?: number -} - // LSP payloads now live in @shared/types/lsp (the renderer↔main contract). // Re-exported so preload/renderer imports of these names keep resolving. export type { diff --git a/src/shared/sessionFeed/SessionFeed.contract.test.ts b/src/shared/sessionFeed/SessionFeed.contract.test.ts new file mode 100644 index 000000000..45d183eaa --- /dev/null +++ b/src/shared/sessionFeed/SessionFeed.contract.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionFeed } from '@shared/sessionFeed/SessionFeed.js' + +// A no-op feed proves the interface is implementable with the exact member +// names the desktop (IpcSessionFeed) and phone (WebSocketSessionFeed) +// implementations rely on. This is a compile-time contract lock: if a member +// name or signature drifts, this file fails to type-check before any runtime +// test runs. The runtime assertions below are deliberately thin — the value +// of this test is the `const noop: SessionFeed = { ... }` line. +const noop: SessionFeed = { + onSessionStarted: () => () => {}, + onSessionScreen: () => () => {}, + onSessionJsonlEntries: () => () => {}, + onSessionJsonlError: () => () => {}, + onSessionSemanticEvent: () => () => {}, + onSessionConditions: () => () => {}, + onSessionProcessState: () => () => {}, + onSessionSubAgents: () => () => {}, + onSessionExit: () => () => {}, + sendInput: async () => true, + deliverPrompt: async () => ({ ok: true }), + resolveCondition: async () => ({ ok: true }), +} + +describe('SessionFeed contract', () => { + it('is implementable and exposes the v1 command surface', () => { + expect(typeof noop.sendInput).toBe('function') + expect(typeof noop.deliverPrompt).toBe('function') + expect(typeof noop.resolveCondition).toBe('function') + }) + + it('listeners return an unsubscribe function', () => { + const unsub = noop.onSessionScreen(() => {}) + expect(typeof unsub).toBe('function') + }) +}) diff --git a/src/shared/sessionFeed/SessionFeed.ts b/src/shared/sessionFeed/SessionFeed.ts new file mode 100644 index 000000000..201b30990 --- /dev/null +++ b/src/shared/sessionFeed/SessionFeed.ts @@ -0,0 +1,87 @@ +import type { + ConditionCustomAction, + ResolveConditionResult, + SessionConditionsEvent, + SessionExitEvent, + SessionJsonlEntriesEvent, + SessionJsonlErrorEvent, + SessionProcessStateEvent, + SessionScreenEvent, + SessionSemanticEvent, + SessionStartedEvent, + SessionSubAgentsEvent, + Unsub, +} from '@shared/sessionFeed/types.js' + +// SessionFeed — THE seam between "the UI wants live session I/O" and "where +// that data physically comes from". +// +// Two implementations exist (or will): +// - IpcSessionFeed (src/renderer/features/sessionFeed/): the desktop app, +// delegating to the flat `window.api.*` preload bridge. +// - WebSocketSessionFeed (src/remote-client/, phase 1 of the remote mobile +// companion): the phone web client, speaking the remote WS protocol. +// +// This interface is the ONLY type-level coupling the isolation boundary in +// docs/superpowers/specs/2026-07-06-remote-mobile-companion-design.md +// permits between core and the remote subsystem. Widening it is how remote +// gains capability; remote code importing anything else from core is a +// boundary violation. +// +// WHY listeners are global (fire for ALL sessions; callers dispatch by +// `sessionId` in the callback) instead of per-session subscribe(sessionId): +// this mirrors the existing one-listener-per-event-type shape in +// useIpcSubscriptions — main can run N sessions in parallel, and a single +// listener per type avoids N×N listener storms as tabs and splits grow. +// Switching to per-session subscription would be a behavioural rewrite of +// the 2000-line subscription hub, out of scope for the phase-0 decoupling; +// a WS transport can still fan per-session frames on the wire and present +// them through this global shape. (Divergence from the spec's sketch is +// deliberate and documented in the phase-0 plan self-review.) +// +// WHY the command surface is exactly these three methods: they are the v1 +// remote scope — send prompt text, submit/interrupt (control bytes travel +// through sendInput), and resolve provider condition prompts (permission / +// trust / question dialogs). Session lifecycle (spawn/kill), raw terminal +// I/O, and provider switching are deliberately ABSENT so a remote transport +// cannot express them; scope is enforced by the contract's shape, not by +// runtime checks. Desktop-only surfaces (ghost journal, git worktrees, +// feed-debug, LSP, editor FS) stay on `window.api` — they are not session +// I/O and the phone must never need them. +export interface SessionFeed { + // --- Listeners (subscribe once; dispatch by sessionId in the callback) --- + onSessionStarted(cb: (e: SessionStartedEvent) => void): Unsub + onSessionScreen(cb: (e: SessionScreenEvent) => void): Unsub + onSessionJsonlEntries(cb: (e: SessionJsonlEntriesEvent) => void): Unsub + onSessionJsonlError(cb: (e: SessionJsonlErrorEvent) => void): Unsub + onSessionSemanticEvent(cb: (e: SessionSemanticEvent) => void): Unsub + onSessionConditions(cb: (e: SessionConditionsEvent) => void): Unsub + onSessionProcessState(cb: (e: SessionProcessStateEvent) => void): Unsub + onSessionSubAgents(cb: (e: SessionSubAgentsEvent) => void): Unsub + onSessionExit(cb: (e: SessionExitEvent) => void): Unsub + + // --- Commands (the v1 remote-allowed input surface) --- + + /** Write bytes to the session's input. For PTY-backed agents this is + * keystrokes (prompt text, `\r` submit, `\x1b` interrupt, bracketed + * paste). `pasteId` correlates paste writes against the per-paste + * debug journal in main — omit for non-paste writes. */ + sendInput(sessionId: string, data: string, pasteId?: string): Promise + + /** Deliver a finished prompt to an API-transport agent (opencode) that + * has no PTY to receive keystroke bytes. No desktop call site today — + * it exists on the contract because the remote client MUST use it for + * API-transport sessions, and the preload bridge already exposes it + * for the MCP orchestration path. */ + deliverPrompt( + sessionId: string, + prompt: string, + ): Promise<{ ok: true } | { ok: false; message: string }> + + /** Resolve a live provider condition (permission prompt, trust dialog, + * AskUserQuestion, codex approval) with a custom action. */ + resolveCondition( + sessionId: string, + action: ConditionCustomAction, + ): Promise +} diff --git a/src/shared/sessionFeed/types.ts b/src/shared/sessionFeed/types.ts new file mode 100644 index 000000000..66f8703a4 --- /dev/null +++ b/src/shared/sessionFeed/types.ts @@ -0,0 +1,183 @@ +import type { ProviderConditionSnapshot } from '@shared/types/providerConditions.js' +import type { SessionKind } from '@shared/types/providerKind.js' +import type { AgentTranscriptEntry } from '@shared/types/session.js' + +// Session-feed wire types — the payload shapes that cross the SessionFeed +// contract (see ./SessionFeed.ts). +// +// WHY these live in @shared and not @preload/api/types (their historical +// home): the SessionFeed contract must be implementable by surfaces that +// have no Electron at all — the remote mobile client implements it over a +// WebSocket (see docs/superpowers/specs/2026-07-06-remote-mobile-companion- +// design.md). @shared is the only layer importable by renderer, main, AND +// the future remote client without dragging in `electron` or the preload +// bridge. The declarations were MOVED here (not duplicated); preload +// re-exports them so every existing `@preload/api/types` import keeps +// resolving byte-for-byte — the same pattern providerConditions.ts uses +// for conditions-core. +// +// WHY the semantic `event` stays `unknown`: this boundary is deliberately +// provider-agnostic. Pinning one provider's SemanticEvent schema here would +// couple every SessionFeed transport to that provider's version; consumers +// narrow on `event.type` instead (see the original rationale on +// SessionSemanticEvent, carried over from the preload declaration). + +export type Unsub = () => void + +export type PickerItem = { + id: string + label: string + description: string + selected: boolean +} + +export type SlashPickerState = { + visible: boolean + items: PickerItem[] +} + +export type ScreenSnapshot = { + /** Visible viewport text — what CC's TUI is showing right now. + * Source of truth for current-state parsers (trust dialog, + * slash picker, activity spinner). */ + plain: string + /** Viewport with bold/italic re-emitted as markdown. */ + markdown: string + /** Wider window (last ~200 rows including scrollback) used by + * the streaming extractor. CC's responses can grow taller than + * the viewport, scrolling the opening `⏺` marker into + * scrollback; without this wider snapshot the streaming card + * stays blank for long replies. */ + recent: string + /** Markdown counterpart of `recent`. */ + recentMarkdown: string + picker: SlashPickerState +} + +export type SessionStartedEvent = { + sessionId: string + kind: SessionKind + /** Undefined for terminal sessions — they don't have a CC project dir. */ + projectDir?: string +} + +export type SessionScreenEvent = { sessionId: string } & ScreenSnapshot + +// Bulk variant used by main during bootstrap bursts. Payload is an +// array of {entry, file} tuples for a single session — the renderer +// folds them in one setState instead of paying one render per entry. +// See main/sessions/jsonlCoalescer.ts for the WHY. Uses the neutral +// AgentTranscriptEntry directly; preload's `JsonlEntry` alias of the +// same type remains at the preload boundary for its other consumers. +export type SessionJsonlEntriesEvent = { + sessionId: string + entries: Array<{ entry: AgentTranscriptEntry; file: string }> +} + +export type SessionJsonlErrorEvent = { sessionId: string; message: string } + +export type SessionConditionsEvent = { + sessionId: string + snapshot: ProviderConditionSnapshot +} + +/** Provider process activity. Previously an inline literal at every + * consumer; named here because the SessionFeed contract needs a + * nominal type both transports can reference. */ +export type SessionProcessStateEvent = { + sessionId: string + active: boolean + status?: string +} + +// --- Subagent fleet ----------------------------------------------------------- +// +// Claude Code's `Agent` tool spawns a subagent whose FULL transcript is written +// live to `//subagents/agent-.jsonl`, with a +// sidecar `agent-.meta.json` carrying { agentType, description, toolUseId }. +// `toolUseId` matches the parent `Agent` tool_use block id exactly — the +// deterministic join key that lets the feed nest a subagent's live work under +// the card that spawned it. The main-process watcher derives these and pushes +// the whole per-session map on every change. See src/main/subagents/ and +// docs/superpowers/specs/2026-06-14-subagent-fleet-rendering-design.md. + +/** One tool call in a subagent's timeline (the drill-in mini-feed). */ +export type SubAgentToolCall = { + /** Tool name, e.g. "Read" | "Bash" | "Grep". */ + name: string + /** First meaningful arg (path/command/pattern/query), already truncated. */ + headline: string | null + /** 'done' once a matching tool_result was observed; else 'running'. */ + status: 'running' | 'done' +} + +/** Live state of one subagent, keyed by its parent `Agent` tool_use id. */ +export type SubAgentState = { + /** Parent `Agent` tool_use block id — meta.toolUseId. The render join key. */ + toolUseId: string + /** The agent- filename id. */ + agentId: string + /** meta.agentType, e.g. "Explore" | "general-purpose". */ + agentType: string + /** meta.description — the card headline. */ + description: string + status: 'running' | 'done' | 'error' + /** Epoch ms of the first transcript entry, or null if unknown. */ + startedAt: number | null + /** Epoch ms of the last observed entry (drives elapsed + live pulse). */ + lastActivityAt: number | null + /** Count of assistant turns observed. */ + turnCount: number + /** Ordered tool-call timeline (capped — see SUBAGENT_TOOL_CALLS_MAX). */ + toolCalls: SubAgentToolCall[] + /** Count of tool calls dropped from the front when capped (0 if none). */ + droppedToolCalls: number + /** Derived activity label, e.g. "running Grep" | "thinking" | null. */ + currentActivity: string | null +} + +/** Per-session push: the full subAgents map for one session, keyed by + * parent `Agent` tool_use id. */ +export type SessionSubAgentsEvent = { + sessionId: string + subAgents: Record +} + +/** Per-block semantic stream from an agent provider — Claude AND Codex: + * Claude via its proxy adapter (or screen fallback when proxy is off), + * Codex via its rollout/proxy adapter. `event` is a provider + * `SemanticEvent` discriminated by `event.type` (text_delta / + * thinking_delta / tool_input_delta / tool_input_finalized / + * block_started / block_completed / turn_started / turn_stopped / + * turn_delta / turn_completed / usage_updated / api_error / + * stream_error / flow_selected / flow_ignored / source_changed / + * tool_result / signature). Kept `unknown` ON PURPOSE — see the module + * comment above. */ +export type SessionSemanticEvent = { sessionId: string; event: unknown } + +export type SessionExitEvent = { + sessionId: string + exitCode: number + signal?: number +} + +export type ResolveConditionResult = + | { ok: true; state?: unknown } + | { + ok: false + reason: + | 'timeout' + | 'aborted' + | 'invalid-payload' + | 'option-not-found' + | 'no-session' + | 'no-headless' + | 'no-resolver' + lastState?: unknown + failedAtStep?: string + } + +// The custom-action wire primitive already lives with the conditions +// framework — re-export rather than move so conditions-core stays the +// single source of truth. +export type { ConditionCustomAction } from '@shared/conditions-core/contract.js' From e4a759b0829b75d7a30329d05d06b13c35cc4be1 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 6 Jul 2026 21:13:17 +0200 Subject: [PATCH 04/15] feat(remote): add IpcSessionFeed desktop implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero-logic delegation to the flat window.api bridge — the desktop's feed-selection point. Lazy window.api reads keep the module import-safe before contextBridge assembly and under test stubs. Co-Authored-By: Claude Opus 4.8 --- .../sessionFeed/IpcSessionFeed.test.ts | 67 +++++++++++++++++++ .../features/sessionFeed/IpcSessionFeed.ts | 39 +++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/renderer/src/features/sessionFeed/IpcSessionFeed.test.ts create mode 100644 src/renderer/src/features/sessionFeed/IpcSessionFeed.ts diff --git a/src/renderer/src/features/sessionFeed/IpcSessionFeed.test.ts b/src/renderer/src/features/sessionFeed/IpcSessionFeed.test.ts new file mode 100644 index 000000000..31c278098 --- /dev/null +++ b/src/renderer/src/features/sessionFeed/IpcSessionFeed.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// IpcSessionFeed is a pure pass-through to the flat `window.api` preload +// bridge. These tests stub `window` BEFORE importing the module (node env, +// no happy-dom) and assert delegation — one listener and every command — +// because the whole value of the desktop feed is that it changes NOTHING +// about how the renderer talks to main. `vi.resetModules()` forces a fresh +// import per test so the module never captures a stale stub. + +type AnyFn = (...args: unknown[]) => unknown +const listenerNames = [ + 'onSessionStarted', + 'onSessionScreen', + 'onSessionJsonlEntries', + 'onSessionJsonlError', + 'onSessionSemanticEvent', + 'onSessionConditions', + 'onSessionProcessState', + 'onSessionSubAgents', + 'onSessionExit', +] as const + +function stubWindowApi(): Record { + const api: Record = {} + for (const name of listenerNames) { + api[name] = vi.fn(() => () => {}) + } + api.sendInput = vi.fn(async () => true) + api.deliverPrompt = vi.fn(async () => ({ ok: true as const })) + api.resolveCondition = vi.fn(async () => ({ ok: true as const })) + ;(globalThis as Record).window = { api } + return api +} + +beforeEach(() => { + vi.resetModules() +}) + +describe('IpcSessionFeed', () => { + it('delegates every listener to the flat window.api surface', async () => { + const api = stubWindowApi() + const { ipcSessionFeed } = await import('./IpcSessionFeed') + const cb = (): void => {} + for (const name of listenerNames) { + const unsub = (ipcSessionFeed as unknown as Record)[name](cb) + expect(api[name], name).toHaveBeenCalledWith(cb) + expect(typeof unsub, `${name} unsub`).toBe('function') + } + }) + + it('delegates sendInput with the pasteId passthrough', async () => { + const api = stubWindowApi() + const { ipcSessionFeed } = await import('./IpcSessionFeed') + await ipcSessionFeed.sendInput('s1', 'hi', 'paste-1') + expect(api.sendInput).toHaveBeenCalledWith('s1', 'hi', 'paste-1') + }) + + it('delegates deliverPrompt and resolveCondition', async () => { + const api = stubWindowApi() + const { ipcSessionFeed } = await import('./IpcSessionFeed') + await ipcSessionFeed.deliverPrompt('s1', 'do the thing') + expect(api.deliverPrompt).toHaveBeenCalledWith('s1', 'do the thing') + const action = { kind: 'select-option', optionIndex: 0 } as never + await ipcSessionFeed.resolveCondition('s1', action) + expect(api.resolveCondition).toHaveBeenCalledWith('s1', action) + }) +}) diff --git a/src/renderer/src/features/sessionFeed/IpcSessionFeed.ts b/src/renderer/src/features/sessionFeed/IpcSessionFeed.ts new file mode 100644 index 000000000..e93fd9629 --- /dev/null +++ b/src/renderer/src/features/sessionFeed/IpcSessionFeed.ts @@ -0,0 +1,39 @@ +import type { SessionFeed } from '@shared/sessionFeed/SessionFeed' + +// The desktop implementation of SessionFeed: a pure pass-through to the flat +// `window.api` preload bridge (see src/preload/api/index.ts for why the +// surface is flat). +// +// WHY this exists at all when it adds nothing over calling window.api +// directly: it makes the renderer's session I/O injectable. The remote +// mobile client (docs/superpowers/specs/2026-07-06-remote-mobile-companion- +// design.md) implements the same contract over WebSocket and mounts the +// same components; tests implement it with FakeSessionFeed and need no +// Electron. This object is the desktop's feed-selection point — the single +// place where "desktop = IPC" is decided. +// +// INVARIANT: every method must stay a zero-logic delegation. Any behaviour +// added here (filtering, buffering, retry) would silently diverge desktop +// from remote, because remote transports don't flow through this file. Put +// cross-transport behaviour in the consumers; put transport-specific +// behaviour in the transport (WebSocketSessionFeed does its own reconnect). +// +// WHY methods wrap `window.api` lazily instead of `export const +// ipcSessionFeed = window.api`: the preload bridge object is assembled by +// Electron's contextBridge at an unspecified moment relative to module +// evaluation, and tests stub `window` after module graphs load. Reading +// `window.api` at call time keeps this module import-safe in any order. +export const ipcSessionFeed: SessionFeed = { + onSessionStarted: cb => window.api.onSessionStarted(cb), + onSessionScreen: cb => window.api.onSessionScreen(cb), + onSessionJsonlEntries: cb => window.api.onSessionJsonlEntries(cb), + onSessionJsonlError: cb => window.api.onSessionJsonlError(cb), + onSessionSemanticEvent: cb => window.api.onSessionSemanticEvent(cb), + onSessionConditions: cb => window.api.onSessionConditions(cb), + onSessionProcessState: cb => window.api.onSessionProcessState(cb), + onSessionSubAgents: cb => window.api.onSessionSubAgents(cb), + onSessionExit: cb => window.api.onSessionExit(cb), + sendInput: (sessionId, data, pasteId) => window.api.sendInput(sessionId, data, pasteId), + deliverPrompt: (sessionId, prompt) => window.api.deliverPrompt(sessionId, prompt), + resolveCondition: (sessionId, action) => window.api.resolveCondition(sessionId, action), +} From 364ea53f27334f7fcef1010541363a410b4e2f57 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 6 Jul 2026 21:14:33 +0200 Subject: [PATCH 05/15] feat(remote): add SessionFeed React context and FakeSessionFeed test double Context makes transport a mount-time decision (desktop IPC vs remote WS vs test fake) instead of an import-time coupling. The fake is a real minimal implementation, not a spy bundle, so passing tests prove contract adherence. Co-Authored-By: Claude Opus 4.8 --- .../features/sessionFeed/FakeSessionFeed.ts | 117 ++++++++++++++++++ .../SessionFeedContext.renderer.test.tsx | 64 ++++++++++ .../sessionFeed/SessionFeedContext.tsx | 43 +++++++ 3 files changed, 224 insertions(+) create mode 100644 src/renderer/src/features/sessionFeed/FakeSessionFeed.ts create mode 100644 src/renderer/src/features/sessionFeed/SessionFeedContext.renderer.test.tsx create mode 100644 src/renderer/src/features/sessionFeed/SessionFeedContext.tsx diff --git a/src/renderer/src/features/sessionFeed/FakeSessionFeed.ts b/src/renderer/src/features/sessionFeed/FakeSessionFeed.ts new file mode 100644 index 000000000..6140b2f69 --- /dev/null +++ b/src/renderer/src/features/sessionFeed/FakeSessionFeed.ts @@ -0,0 +1,117 @@ +import type { SessionFeed } from '@shared/sessionFeed/SessionFeed' +import type { + ConditionCustomAction, + ResolveConditionResult, + SessionConditionsEvent, + SessionExitEvent, + SessionJsonlEntriesEvent, + SessionJsonlErrorEvent, + SessionProcessStateEvent, + SessionScreenEvent, + SessionSemanticEvent, + SessionStartedEvent, + SessionSubAgentsEvent, + Unsub, +} from '@shared/sessionFeed/types' + +// In-memory SessionFeed for tests — the proof that the phase-0 decoupling +// worked: renderer code driven by this fake runs with no Electron, no IPC, +// no main process. Tests emit events with emit*() and assert on `calls`. +// +// Deliberately NOT a vi.mock/spy bundle: a real (if tiny) implementation of +// the contract exercises the same subscribe/unsubscribe lifecycles the +// production transports do, so a test passing against the fake means the +// component honoured the contract, not a mock's accidental shape. + +export type FakeFeedCall = + | { method: 'sendInput'; sessionId: string; data: string; pasteId: string | undefined } + | { method: 'deliverPrompt'; sessionId: string; prompt: string } + | { method: 'resolveCondition'; sessionId: string; action: ConditionCustomAction } + +export interface FakeSessionFeed extends SessionFeed { + /** Every command invocation, in order — assert on this. */ + calls: FakeFeedCall[] + /** Override the canned command results when a test needs failure paths. */ + nextSendInputResult: boolean + nextDeliverPromptResult: { ok: true } | { ok: false; message: string } + nextResolveConditionResult: ResolveConditionResult + emitStarted(e: SessionStartedEvent): void + emitScreen(e: SessionScreenEvent): void + emitJsonlEntries(e: SessionJsonlEntriesEvent): void + emitJsonlError(e: SessionJsonlErrorEvent): void + emitSemantic(e: SessionSemanticEvent): void + emitConditions(e: SessionConditionsEvent): void + emitProcessState(e: SessionProcessStateEvent): void + emitSubAgents(e: SessionSubAgentsEvent): void + emitExit(e: SessionExitEvent): void +} + +export function createFakeSessionFeed(): FakeSessionFeed { + // One listener set per event type, mirroring the contract's global + // one-listener-per-type shape. A Set gives us O(1) unsubscribe and the + // same "late subscribers miss earlier events" semantics as the real + // transports — no replay, because the contract doesn't promise one. + const listeners = { + started: new Set<(e: SessionStartedEvent) => void>(), + screen: new Set<(e: SessionScreenEvent) => void>(), + jsonlEntries: new Set<(e: SessionJsonlEntriesEvent) => void>(), + jsonlError: new Set<(e: SessionJsonlErrorEvent) => void>(), + semantic: new Set<(e: SessionSemanticEvent) => void>(), + conditions: new Set<(e: SessionConditionsEvent) => void>(), + processState: new Set<(e: SessionProcessStateEvent) => void>(), + subAgents: new Set<(e: SessionSubAgentsEvent) => void>(), + exit: new Set<(e: SessionExitEvent) => void>(), + } + + function subscribe(set: Set<(e: E) => void>, cb: (e: E) => void): Unsub { + set.add(cb) + return () => set.delete(cb) + } + + function emit(set: Set<(e: E) => void>, e: E): void { + // Copy before iterating so a listener unsubscribing (or subscribing a + // sibling) mid-dispatch can't mutate the live set under the loop. + for (const cb of [...set]) cb(e) + } + + const feed: FakeSessionFeed = { + calls: [], + nextSendInputResult: true, + nextDeliverPromptResult: { ok: true }, + nextResolveConditionResult: { ok: true }, + + onSessionStarted: cb => subscribe(listeners.started, cb), + onSessionScreen: cb => subscribe(listeners.screen, cb), + onSessionJsonlEntries: cb => subscribe(listeners.jsonlEntries, cb), + onSessionJsonlError: cb => subscribe(listeners.jsonlError, cb), + onSessionSemanticEvent: cb => subscribe(listeners.semantic, cb), + onSessionConditions: cb => subscribe(listeners.conditions, cb), + onSessionProcessState: cb => subscribe(listeners.processState, cb), + onSessionSubAgents: cb => subscribe(listeners.subAgents, cb), + onSessionExit: cb => subscribe(listeners.exit, cb), + + sendInput: async (sessionId, data, pasteId) => { + feed.calls.push({ method: 'sendInput', sessionId, data, pasteId }) + return feed.nextSendInputResult + }, + deliverPrompt: async (sessionId, prompt) => { + feed.calls.push({ method: 'deliverPrompt', sessionId, prompt }) + return feed.nextDeliverPromptResult + }, + resolveCondition: async (sessionId, action) => { + feed.calls.push({ method: 'resolveCondition', sessionId, action }) + return feed.nextResolveConditionResult + }, + + emitStarted: e => emit(listeners.started, e), + emitScreen: e => emit(listeners.screen, e), + emitJsonlEntries: e => emit(listeners.jsonlEntries, e), + emitJsonlError: e => emit(listeners.jsonlError, e), + emitSemantic: e => emit(listeners.semantic, e), + emitConditions: e => emit(listeners.conditions, e), + emitProcessState: e => emit(listeners.processState, e), + emitSubAgents: e => emit(listeners.subAgents, e), + emitExit: e => emit(listeners.exit, e), + } + return feed +} diff --git a/src/renderer/src/features/sessionFeed/SessionFeedContext.renderer.test.tsx b/src/renderer/src/features/sessionFeed/SessionFeedContext.renderer.test.tsx new file mode 100644 index 000000000..1789e4e8f --- /dev/null +++ b/src/renderer/src/features/sessionFeed/SessionFeedContext.renderer.test.tsx @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' + +import { SessionFeedProvider, useSessionFeed } from './SessionFeedContext' +import { createFakeSessionFeed } from './FakeSessionFeed' + +function Probe(): React.JSX.Element { + const feed = useSessionFeed() + return
{typeof feed.sendInput === 'function' ? 'has-feed' : 'no-feed'}
+} + +describe('SessionFeedContext', () => { + it('provides the injected feed to consumers', () => { + const fake = createFakeSessionFeed() + render( + + + , + ) + expect(screen.getByText('has-feed')).toBeTruthy() + }) + + it('useSessionFeed throws a descriptive error outside a provider', () => { + // React logs the error boundary noise to console.error during the throw; + // the assertion is on the thrown message, which is the API we guarantee. + expect(() => render()).toThrowError(/SessionFeedProvider/) + }) +}) + +describe('FakeSessionFeed', () => { + it('fans emitted events to subscribed listeners and honors unsubscribe', () => { + const fake = createFakeSessionFeed() + const seen: string[] = [] + const unsub = fake.onSessionScreen(e => seen.push(e.sessionId)) + fake.emitScreen({ + sessionId: 's1', + plain: 'hello', + markdown: '', + recent: 'hello', + recentMarkdown: '', + picker: { visible: false, items: [] }, + }) + unsub() + fake.emitScreen({ + sessionId: 's2', + plain: 'bye', + markdown: '', + recent: 'bye', + recentMarkdown: '', + picker: { visible: false, items: [] }, + }) + expect(seen).toEqual(['s1']) + }) + + it('records command calls for assertions', async () => { + const fake = createFakeSessionFeed() + await fake.sendInput('s1', 'hi') + await fake.deliverPrompt('s1', 'prompt') + expect(fake.calls).toEqual([ + { method: 'sendInput', sessionId: 's1', data: 'hi', pasteId: undefined }, + { method: 'deliverPrompt', sessionId: 's1', prompt: 'prompt' }, + ]) + }) +}) diff --git a/src/renderer/src/features/sessionFeed/SessionFeedContext.tsx b/src/renderer/src/features/sessionFeed/SessionFeedContext.tsx new file mode 100644 index 000000000..face20241 --- /dev/null +++ b/src/renderer/src/features/sessionFeed/SessionFeedContext.tsx @@ -0,0 +1,43 @@ +import { createContext, useContext, type ReactNode } from 'react' + +import type { SessionFeed } from '@shared/sessionFeed/SessionFeed' + +// React injection point for the SessionFeed contract. +// +// The desktop app mounts once at +// the top of the tree (App.tsx); the remote mobile client mounts the same +// provider with its WebSocketSessionFeed; tests mount it with +// createFakeSessionFeed(). Everything below the provider is transport-blind. +// +// WHY a context and not a module-level singleton: a singleton would be +// decided at import time, which is exactly the coupling SessionFeed exists +// to remove — the remote client shares these component modules and must be +// able to pick a different transport for the same tree. Context makes the +// choice a mount-time decision. +const SessionFeedContext = createContext(null) + +export function SessionFeedProvider({ + value, + children, +}: { + value: SessionFeed + children: ReactNode +}): React.JSX.Element { + return {children} +} + +export function useSessionFeed(): SessionFeed { + const feed = useContext(SessionFeedContext) + if (feed === null) { + // Fail loudly at first use rather than returning a dead stub: a missing + // provider means session I/O would silently do nothing — prompts eaten, + // events never arriving — which presents as a much harder bug than this. + throw new Error( + 'useSessionFeed: no SessionFeedProvider above this component. ' + + 'Mount at the root of the tree ' + + '(desktop: ipcSessionFeed; remote client: WebSocketSessionFeed; ' + + 'tests: createFakeSessionFeed()).', + ) + } + return feed +} From ca5adc61ebcf5ebcdd4d73ca14b537fe8ddd7ac1 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 6 Jul 2026 21:17:41 +0200 Subject: [PATCH 06/15] refactor(remote): drive useIpcSubscriptions from injected SessionFeed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 9 session-event listeners now come from the SessionFeed param instead of window.api; desktop passes ipcSessionFeed (module const — identity- stable, which the effect dep array requires). Desktop-only side channels (ghostAppend, gitWorktrees, feed-debug, perf) intentionally stay on window.api. Harness test proves the hub folds events from a fake feed with no Electron present. Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/workspace/hook/index.ts | 9 +- .../ipc/useIpcSubscriptions.renderer.test.tsx | 128 ++++++++++++++++++ .../workspace/hook/ipc/useIpcSubscriptions.ts | 43 ++++-- 3 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.renderer.test.tsx diff --git a/src/renderer/src/workspace/hook/index.ts b/src/renderer/src/workspace/hook/index.ts index 1c2d19a34..969c2f672 100644 --- a/src/renderer/src/workspace/hook/index.ts +++ b/src/renderer/src/workspace/hook/index.ts @@ -32,6 +32,7 @@ import { useTileTabsSanity, } from '@renderer/workspace/hook/invalidation/effects' import { useIpcSubscriptions } from '@renderer/workspace/hook/ipc/useIpcSubscriptions' +import { ipcSessionFeed } from '@renderer/features/sessionFeed/IpcSessionFeed' import type { OrchestrationAgentRecord } from '@mcp/shared/orchestrationTypes' import { closeOrchestrationAgent, @@ -442,7 +443,13 @@ export function useWorkspace( ) // ---- Side-effects (subscriptions, persistence, invalidation) ---- - useIpcSubscriptions(refs, setState, setRuntimes, updateRuntime, appendFeedDebug) + // The desktop feed is passed as a direct module import (not via + // useSessionFeed()) ON PURPOSE for now: useWorkspace is mounted above the + // SessionFeedProvider today, and phase 0 keeps behaviour byte-identical. + // When the provider moves to the app root (task 6 of the phase-0 plan), + // this becomes the context read. Module-const identity is stable, which + // the effect's dep array requires — see the WHY on useIpcSubscriptions. + useIpcSubscriptions(ipcSessionFeed, refs, setState, setRuntimes, updateRuntime, appendFeedDebug) useAutoSave(state, draftVersion, refs, bootstrapComplete) useBootstrap( refs, diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.renderer.test.tsx b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.renderer.test.tsx new file mode 100644 index 000000000..425a7b5d8 --- /dev/null +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.renderer.test.tsx @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import { render } from '@testing-library/react' +import { act } from 'react' +import { useRef, type MutableRefObject } from 'react' + +import { createFakeSessionFeed } from '@renderer/features/sessionFeed/FakeSessionFeed' +import { UndoCloseStack } from '@renderer/lib/undoClose' +import type { SessionRuntime } from '@renderer/workspace/workspaceState' +import type { SessionId, WorkspaceState } from '@renderer/workspace/types' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' + +import { useIpcSubscriptions } from './useIpcSubscriptions' + +// Proof test for the phase-0 decoupling: the subscription hub must consume +// session events from an injected SessionFeed — NOT from window.api — so the +// same hook can be driven by IPC (desktop), WebSocket (remote client), or +// this fake (no Electron anywhere in this test). If someone reintroduces a +// direct `window.api.onSession*` call for a feed-covered event, this test +// crashes on the undefined bridge, which is exactly the regression signal +// we want. +// +// The harness builds the minimal honest WorkspaceRefs/state the handlers +// actually touch; it is NOT a full workspace. Desktop-only side channels +// (ghostAppend, gitWorktrees) are deliberately outside the feed and only +// fire on paths this test does not drive (ghost changes, session-started +// worktree refresh). + +function makeRefs(state: WorkspaceState): WorkspaceRefs { + // Plain object refs are fine outside React's render cycle — the hook only + // ever reads/writes `.current`. + const ref = (v: T): MutableRefObject => ({ current: v }) + return { + stateRef: ref(state), + latestStateRef: ref(state), + latestRuntimesRef: ref({}), + latestTileTabsRef: ref(null), + dangerousAgentsRef: ref(false), + useProxyStreamingRef: ref(false), + seenUuidsRef: ref({}), + latestScreenRef: ref({}), + undoStackRef: ref(new UndoCloseStack()), + bootstrapTimersRef: ref(new Map()), + persistedFeedDebugIdRef: ref({}), + inFlightFeedDebugIdRef: ref({}), + paneToastTimers: ref({}), + saveTimerRef: ref(null), + bootRef: ref(false), + } +} + +describe('useIpcSubscriptions with an injected SessionFeed', () => { + it('folds a screen event from the fake feed into session runtimes', () => { + const fake = createFakeSessionFeed() + const state = { sessions: {} } as WorkspaceState + let runtimes: Record = {} + + function Harness(): React.JSX.Element { + const refs = useRef(null) + if (refs.current === null) refs.current = makeRefs(state) + useIpcSubscriptions( + fake, + refs.current, + updater => { + // setState is unused by the screen path; apply for completeness. + void updater + }, + updater => { + runtimes = typeof updater === 'function' ? updater(runtimes) : updater + }, + (sessionId, patch) => { + runtimes = { + ...runtimes, + [sessionId]: { ...(runtimes[sessionId] as SessionRuntime), ...patch }, + } + }, + () => {}, + ) + return
+ } + + render() + + act(() => { + fake.emitScreen({ + sessionId: 's1', + plain: 'hello world', + markdown: 'hello world', + recent: 'hello world', + recentMarkdown: 'hello world', + picker: { visible: false, items: [] }, + }) + }) + + expect(runtimes.s1?.screen).toBe('hello world') + expect(runtimes.s1?.recentScreen).toBe('hello world') + }) + + it('marks the runtime exited when the fake feed emits exit', () => { + const fake = createFakeSessionFeed() + const state = { sessions: {} } as WorkspaceState + let runtimes: Record = {} + + function Harness(): React.JSX.Element { + const refs = useRef(null) + if (refs.current === null) refs.current = makeRefs(state) + useIpcSubscriptions( + fake, + refs.current, + () => {}, + updater => { + runtimes = typeof updater === 'function' ? updater(runtimes) : updater + }, + () => {}, + () => {}, + ) + return
+ } + + render() + + act(() => { + fake.emitExit({ sessionId: 's1', exitCode: 0 }) + }) + + expect(runtimes.s1?.exited).toBe(0) + expect(runtimes.s1?.processActive).toBe(false) + }) +}) diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts index d9c10035e..ae046ed61 100644 --- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts @@ -7,6 +7,7 @@ import { import { useEffect } from 'react' import type { Entry } from '@shared/types/transcript' +import type { SessionFeed } from '@shared/sessionFeed/SessionFeed' import { isCompactSummaryEntry } from '@shared/types/transcript' import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' import type { TranscriptEntryMapper } from '@shared/types/providerConfig' @@ -284,7 +285,19 @@ function applyConditionSnapshot( // ----------------------------------------------------------------------------- // useIpcSubscriptions — the one big side-effect that wires every -// window.api.onSession* listener. +// session-event listener. +// +// Session events arrive through the injected SessionFeed (phase 0 of the +// remote mobile companion — see docs/superpowers/specs/2026-07-06-remote- +// mobile-companion-design.md), NOT through window.api directly. The desktop +// passes IpcSessionFeed (a pure pass-through to the preload bridge), tests +// pass FakeSessionFeed, and the remote client will pass its WebSocket feed. +// Do NOT reintroduce a direct `window.api.onSession*` call for a feed-covered +// event — that would silently exclude non-IPC transports from that event. +// Desktop-only side channels (ghostAppend, gitWorktrees, feed-debug, perf) +// intentionally STAY on window.api below: they are not session I/O, the +// remote client must never need them, and abstracting them would widen the +// SessionFeed contract for no consumer. // // One listener per event type. The callback looks up the session by // sessionId from the payload and patches the corresponding runtime. @@ -301,6 +314,14 @@ function applyConditionSnapshot( // ----------------------------------------------------------------------------- export function useIpcSubscriptions( + // WHY feed identity matters: `feed` sits in the effect's dep array, so an + // unstable reference would tear down + re-attach every subscription each + // render — the exact pathology the memoized refs factory exists to prevent + // (see useWorkspaceRefs). ipcSessionFeed is a module const and the context + // value passes it through untouched, so identity is stable by construction. + // If a future transport constructs its feed per-render, memoize it at the + // construction site, not here. + feed: SessionFeed, refs: WorkspaceRefs, setState: WorkspaceSetState, setRuntimes: WorkspaceSetRuntimes, @@ -522,7 +543,7 @@ export function useIpcSubscriptions( } } - const offStarted = window.api.onSessionStarted(({ sessionId, projectDir }) => { + const offStarted = feed.onSessionStarted(({ sessionId, projectDir }) => { updateRuntime(sessionId, { projectDir, processStatus: 'started', @@ -539,7 +560,7 @@ export function useIpcSubscriptions( }) }) - const offScreen = window.api.onSessionScreen( + const offScreen = feed.onSessionScreen( ({ sessionId, plain, markdown, recent, recentMarkdown, picker }) => { const startedAt = performance.now() // latestScreenRef is the synchronous source of truth for @@ -686,7 +707,7 @@ export function useIpcSubscriptions( // it through the bulk channel as a 1-element burst. Do NOT add // a second IPC channel that races the bulk one. - const offErr = window.api.onSessionJsonlError(({ sessionId, message }) => { + const offErr = feed.onSessionJsonlError(({ sessionId, message }) => { // eslint-disable-next-line no-console console.warn(`[jsonl ${sessionId.slice(0, 8)}]`, message) updateRuntime(sessionId, { @@ -695,7 +716,7 @@ export function useIpcSubscriptions( }) }) - const offExit = window.api.onSessionExit(({ sessionId, exitCode }) => { + const offExit = feed.onSessionExit(({ sessionId, exitCode }) => { recentWorkContextRawBySession.delete(sessionId) codexCurrentTurnIdBySession.delete(sessionId) jsonlProviderStreamBySession.delete(sessionId) @@ -758,7 +779,7 @@ export function useIpcSubscriptions( // for Codex (the parser is Claude-specific). On idle // transitions, status is undefined and we clear activityStatus // too. - const offProcessState = window.api.onSessionProcessState( + const offProcessState = feed.onSessionProcessState( ({ sessionId, active, status }) => { setRuntimes(prev => { const current = prev[sessionId] ?? emptyRuntime() @@ -807,7 +828,7 @@ export function useIpcSubscriptions( }, ) - const offSemantic = window.api.onSessionSemanticEvent(({ sessionId, event }) => { + const offSemantic = feed.onSessionSemanticEvent(({ sessionId, event }) => { const span = perf.span('workspace.ipc.semantic.fold', { sessionId }) let spanClosed = false const closeSpan = (data: Record) => { @@ -1160,7 +1181,7 @@ export function useIpcSubscriptions( }) }) - const offConditions = window.api.onSessionConditions(({ sessionId, snapshot }) => { + const offConditions = feed.onSessionConditions(({ sessionId, snapshot }) => { setRuntimes(prev => { const current = prev[sessionId] ?? emptyRuntime() const next = applyConditionSnapshot(current, snapshot) @@ -1199,7 +1220,7 @@ export function useIpcSubscriptions( // change (it's small — agentType/description + a capped tool-call // timeline), so we just replace the field wholesale. Reference-equal bail // keeps Feed from re-rendering when an unrelated session updates. - const offSubAgents = window.api.onSessionSubAgents(({ sessionId, subAgents }) => { + const offSubAgents = feed.onSessionSubAgents(({ sessionId, subAgents }) => { setRuntimes(prev => { const current = prev[sessionId] ?? emptyRuntime() if (current.subAgents === subAgents) return prev @@ -1224,7 +1245,7 @@ export function useIpcSubscriptions( // 4. Claude providerSessionId capture (from any entry's sessionId). // 5. pendingCompaction clearing on compact summary entries. // 6. Optimistic-Codex-user reconciliation against the head row. - const offEntries = window.api.onSessionJsonlEntries(({ sessionId, entries }) => { + const offEntries = feed.onSessionJsonlEntries(({ sessionId, entries }) => { if (!entries || entries.length === 0) return const span = perf.span('workspace.ipc.jsonl.bulk', { sessionId, @@ -2043,5 +2064,5 @@ export function useIpcSubscriptions( for (const t of refs.bootstrapTimersRef.current.values()) clearTimeout(t) refs.bootstrapTimersRef.current.clear() } - }, [appendFeedDebug, refs, setRuntimes, setState, updateRuntime]) + }, [appendFeedDebug, feed, refs, setRuntimes, setState, updateRuntime]) } From 6dd1e18f550c91f81640bc43ba913a72402c5b0f Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 6 Jul 2026 21:22:31 +0200 Subject: [PATCH 07/15] refactor(remote): route session input through SessionFeed; mount desktop provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composer submit (TileLeaf), condition resolution (TileLeaf outlet + AskUserQuestionRow), terminal-nav forwarding, and terminal-sink dictation now use the injected feed. SessionFeedProvider mounts at the app root in main.tsx — the desktop's single feed-selection point — and useWorkspace reads it from context. Raw-terminal leaves (TerminalLeaf, AgentTerminalLeaf, AgentInlineTerminal) stay on window.api on purpose: raw PTY byte streams are desktop-only and outside the v1 remote scope. Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/app/main.tsx | 19 ++++-- .../AskUserQuestionRow.feed.renderer.test.tsx | 58 +++++++++++++++++++ .../feed/ui/semantic/AskUserQuestionRow.tsx | 9 ++- src/renderer/src/workspace/hook/index.ts | 16 ++--- .../src/workspace/tile-tree/TileLeaf.tsx | 12 +++- .../TileLeaf/useComposerDictation.ts | 13 ++++- 6 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 src/renderer/src/features/feed/ui/semantic/AskUserQuestionRow.feed.renderer.test.tsx diff --git a/src/renderer/src/app/main.tsx b/src/renderer/src/app/main.tsx index c2e7d2e40..f81ad1914 100644 --- a/src/renderer/src/app/main.tsx +++ b/src/renderer/src/app/main.tsx @@ -2,6 +2,8 @@ import React from 'react' import { createRoot } from 'react-dom/client' import App from '@renderer/app/App' import { GlobalToastProvider } from '@renderer/ui/GlobalToast' +import { SessionFeedProvider } from '@renderer/features/sessionFeed/SessionFeedContext' +import { ipcSessionFeed } from '@renderer/features/sessionFeed/IpcSessionFeed' import '@renderer/styles.css' import 'highlight.js/styles/github-dark.css' import 'monaco-editor/min/vs/editor/editor.main.css' @@ -68,10 +70,17 @@ void initializePerformance().then(() => { createRoot(document.getElementById('root')!).render( - - - - - + {/* The desktop's feed-selection point (see the remote-mobile-companion + spec's isolation section): this is the ONE place where "desktop = + IPC transport" is decided. The remote client's entry point mounts + the same provider with its WebSocket feed; nothing below the + provider knows which transport it is on. */} + + + + + + + ) diff --git a/src/renderer/src/features/feed/ui/semantic/AskUserQuestionRow.feed.renderer.test.tsx b/src/renderer/src/features/feed/ui/semantic/AskUserQuestionRow.feed.renderer.test.tsx new file mode 100644 index 000000000..2d1f94ee3 --- /dev/null +++ b/src/renderer/src/features/feed/ui/semantic/AskUserQuestionRow.feed.renderer.test.tsx @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' + +import { SessionFeedProvider } from '@renderer/features/sessionFeed/SessionFeedContext' +import { createFakeSessionFeed } from '@renderer/features/sessionFeed/FakeSessionFeed' +import { + AskUserQuestionConditionContext, + CodeRenderContext, +} from '@renderer/features/feed/context' +import type { ClaudeAskUserQuestionState } from '@shared/types/providerConditions' + +import { AskUserQuestionRow } from './AskUserQuestionRow' + +// Task-6 routing proof: feed rows must send session input through the +// injected SessionFeed, not window.api — this row is shared with the remote +// client, where window.api does not exist. The arrow-key terminal-nav path +// is the cheapest real input path to drive: no headless resolver round-trip, +// just a synchronous sendInput of the escape sequence. + +function renderRow(fake = createFakeSessionFeed()) { + const block = { + parsedInput: { + questions: [ + { + question: 'Pick an option', + header: 'Choice', + options: [{ label: 'Option A' }, { label: 'Option B' }], + }, + ], + }, + } as never + // Only non-nullness gates the keyboard path; the row reads selection state + // from its own parsed block, not from this condition snapshot. + const live = {} as ClaudeAskUserQuestionState + render( + + + + + + + , + ) + return fake +} + +describe('AskUserQuestionRow input routing', () => { + it('forwards terminal navigation keys through the SessionFeed', () => { + const fake = renderRow() + fireEvent.keyDown(screen.getByText('Pick an option'), { key: 'ArrowDown' }) + expect(fake.calls).toContainEqual({ + method: 'sendInput', + sessionId: 's1', + data: '\x1b[B', + pasteId: undefined, + }) + }) +}) diff --git a/src/renderer/src/features/feed/ui/semantic/AskUserQuestionRow.tsx b/src/renderer/src/features/feed/ui/semantic/AskUserQuestionRow.tsx index 03428188b..ea84ff6b3 100644 --- a/src/renderer/src/features/feed/ui/semantic/AskUserQuestionRow.tsx +++ b/src/renderer/src/features/feed/ui/semantic/AskUserQuestionRow.tsx @@ -7,6 +7,7 @@ import { AskUserQuestionConditionContext, CodeRenderContext, } from '@renderer/features/feed/context' +import { useSessionFeed } from '@renderer/features/sessionFeed/SessionFeedContext' import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' import type { ConditionCustomAction } from '@shared/types/providerConditions' import { asRecord } from '@shared/lib/asRecord' @@ -134,6 +135,10 @@ export function AskUserQuestionRow({ block }: { block: SemanticLiveBlock }) { // the empty default. const { sessionId } = useContext(CodeRenderContext) const liveAskUserQuestion = useContext(AskUserQuestionConditionContext) + // Session input goes through the injected SessionFeed (not window.api): + // this row is shared with the remote client, where the preload bridge + // does not exist. See src/shared/sessionFeed/SessionFeed.ts. + const feed = useSessionFeed() // Local "answering" latch. Once the user submits an answer we disable every // control, both to give feedback ("Answering…") and to guard against @@ -209,7 +214,7 @@ export function AskUserQuestionRow({ block }: { block: SemanticLiveBlock }) { submittedRef.current = true setAnswering(true) setResolveError(null) - void window.api + void feed .resolveCondition(sessionId, action) .then(result => { if (!result.ok) { @@ -357,7 +362,7 @@ export function AskUserQuestionRow({ block }: { block: SemanticLiveBlock }) { if (!seq) return event.preventDefault() event.stopPropagation() - void window.api.sendInput(sessionId, seq) + void feed.sendInput(sessionId, seq) } return ( diff --git a/src/renderer/src/workspace/hook/index.ts b/src/renderer/src/workspace/hook/index.ts index 969c2f672..a39061eb9 100644 --- a/src/renderer/src/workspace/hook/index.ts +++ b/src/renderer/src/workspace/hook/index.ts @@ -32,7 +32,7 @@ import { useTileTabsSanity, } from '@renderer/workspace/hook/invalidation/effects' import { useIpcSubscriptions } from '@renderer/workspace/hook/ipc/useIpcSubscriptions' -import { ipcSessionFeed } from '@renderer/features/sessionFeed/IpcSessionFeed' +import { useSessionFeed } from '@renderer/features/sessionFeed/SessionFeedContext' import type { OrchestrationAgentRecord } from '@mcp/shared/orchestrationTypes' import { closeOrchestrationAgent, @@ -443,13 +443,13 @@ export function useWorkspace( ) // ---- Side-effects (subscriptions, persistence, invalidation) ---- - // The desktop feed is passed as a direct module import (not via - // useSessionFeed()) ON PURPOSE for now: useWorkspace is mounted above the - // SessionFeedProvider today, and phase 0 keeps behaviour byte-identical. - // When the provider moves to the app root (task 6 of the phase-0 plan), - // this becomes the context read. Module-const identity is stable, which - // the effect's dep array requires — see the WHY on useIpcSubscriptions. - useIpcSubscriptions(ipcSessionFeed, refs, setState, setRuntimes, updateRuntime, appendFeedDebug) + // Session events arrive through whichever SessionFeed the app root mounted + // (desktop: ipcSessionFeed in app/main.tsx; remote client: its WebSocket + // feed; tests: FakeSessionFeed). The provider value is a module const, so + // identity is stable — which the subscription effect's dep array requires; + // see the WHY on useIpcSubscriptions. + const sessionFeed = useSessionFeed() + useIpcSubscriptions(sessionFeed, refs, setState, setRuntimes, updateRuntime, appendFeedDebug) useAutoSave(state, draftVersion, refs, bootstrapComplete) useBootstrap( refs, diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx index d376ebdb5..484bf4e9d 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx @@ -24,6 +24,7 @@ import { ComposerInput } from '@renderer/workspace/tile-tree/TileLeaf/ComposerIn import { useComposerAutoGrow } from '@renderer/workspace/tile-tree/TileLeaf/useComposerAutoGrow' import { useComposerKeybinds } from '@renderer/workspace/tile-tree/TileLeaf/useComposerKeybinds' import { useComposerDictation } from '@renderer/workspace/tile-tree/TileLeaf/useComposerDictation' +import { useSessionFeed } from '@renderer/features/sessionFeed/SessionFeedContext' import { useTypeToFocus } from '@renderer/workspace/tile-tree/TileLeaf/useTypeToFocus' import { usePasteToFocus } from '@renderer/workspace/tile-tree/TileLeaf/usePasteToFocus' import { usePromptHistory } from '@renderer/workspace/tile-tree/TileLeaf/usePromptHistory' @@ -102,6 +103,11 @@ export function TileLeaf({ const inputRef = useRef(null) const paneRef = useRef(null) const { showToast } = useGlobalToast() + // Session input goes through the injected SessionFeed (not window.api): + // the send path below is the composer submit for a REAL agent session, and + // the remote client mounts this same component tree over a WebSocket feed. + // See src/shared/sessionFeed/SessionFeed.ts for the contract's WHY. + const feed = useSessionFeed() const htmlDebugPanelOpen = useAppStore(state => state.htmlDebugPanelOpen) const dictationEnabled = useAppStore(state => state.settings.dictationEnabled) const dictationProvider = useAppStore(state => state.settings.dictationProvider) @@ -227,11 +233,11 @@ export function TileLeaf({ throw new Error(message) } } - let ok = await window.api.sendInput(sessionId, data, pasteId) + let ok = await feed.sendInput(sessionId, data, pasteId) if (!ok) { try { await workspace.ensureSessionLive(sessionId) - ok = await window.api.sendInput(sessionId, data, pasteId) + ok = await feed.sendInput(sessionId, data, pasteId) } catch (err) { workspace.showPaneToast( sessionId, @@ -578,7 +584,7 @@ export function TileLeaf({ window.api.resolveCondition(sessionId, action)} + onResolveCustom={(action) => feed.resolveCondition(sessionId, action)} /> diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerDictation.ts b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerDictation.ts index b44c77812..75407628b 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerDictation.ts +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerDictation.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react' import type { DictationProviderId } from '@renderer/app-state/settings/types' +import { useSessionFeed } from '@renderer/features/sessionFeed/SessionFeedContext' import type { DictationStatus } from '@shared/types/dictation' import { resetDictationOverlay, @@ -257,6 +258,11 @@ export function useComposerDictation({ const [status, setStatus] = useState('idle') const [levels, setLevels] = useState(EMPTY_LEVELS) const [hasTranscriptPreview, setHasTranscriptPreview] = useState(false) + // Terminal-sink dictation writes session input, which goes through the + // injected SessionFeed (not window.api) — see SessionFeed.ts for the WHY. + // The dictation DEBUG journal below stays on window.api on purpose: it is + // a desktop-only diagnostic channel, not session I/O. + const feed = useSessionFeed() const activeRef = useRef(null) const inputRef = useRef(sink.kind === 'composer' ? sink.input : '') const sinkRef = useRef(sink) @@ -319,7 +325,7 @@ export function useComposerDictation({ // series of keystrokes. The STT wrapper contains newlines; sending it raw // can be interpreted as Enter by provider TUIs. Bracketed paste is the // existing safe path for multiline text into Claude/Codex terminals. - void window.api.sendInput(activeSink.sessionId, `\x1b[200~${next}\x1b[201~`) + void feed.sendInput(activeSink.sessionId, `\x1b[200~${next}\x1b[201~`) } // eslint-disable-next-line no-console console.debug('[dictation:write-input]', { @@ -328,7 +334,10 @@ export function useComposerDictation({ chars: next.length, lifecycle: statusRef.current, }) - }, []) + // `feed` is identity-stable by construction (module const via context — + // see the WHY on useIpcSubscriptions), so this dep never re-creates the + // callback in practice; it's listed for hook-lint correctness. + }, [feed]) const reportMessage = useCallback((message: string) => { onMessageRef.current(message) }, []) From 1787608ec0f5ae3d9200324eec6b02d562d5caad Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 6 Jul 2026 21:42:52 +0200 Subject: [PATCH 08/15] feat(remote): phase-1 auth core and v1 wire protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth: per-install HMAC secret (0o600 under STATE_DIR/remote), persisted device registry as the revocation authority, and QR pairing with single-use 5-minute codes minting stateless HMAC device tokens. Protocol: zod schemas where the inbound discriminated union IS the v1 allow-list — shell exec, session lifecycle, provider switch, and raw input are unrepresentable, not merely denied. scope.test.ts pins both directions so widening the protocol forces a visible test edit. Co-Authored-By: Claude Opus 4.8 --- src/main/remote/auth/DevicePairing.test.ts | 109 +++++++++++++++ src/main/remote/auth/DevicePairing.ts | 134 ++++++++++++++++++ src/main/remote/auth/deviceRegistry.test.ts | 70 ++++++++++ src/main/remote/auth/deviceRegistry.ts | 114 +++++++++++++++ src/main/remote/auth/secret.test.ts | 43 ++++++ src/main/remote/auth/secret.ts | 59 ++++++++ src/main/remote/protocol/messages.ts | 147 ++++++++++++++++++++ src/main/remote/protocol/scope.test.ts | 88 ++++++++++++ src/main/remote/protocol/scope.ts | 30 ++++ 9 files changed, 794 insertions(+) create mode 100644 src/main/remote/auth/DevicePairing.test.ts create mode 100644 src/main/remote/auth/DevicePairing.ts create mode 100644 src/main/remote/auth/deviceRegistry.test.ts create mode 100644 src/main/remote/auth/deviceRegistry.ts create mode 100644 src/main/remote/auth/secret.test.ts create mode 100644 src/main/remote/auth/secret.ts create mode 100644 src/main/remote/protocol/messages.ts create mode 100644 src/main/remote/protocol/scope.test.ts create mode 100644 src/main/remote/protocol/scope.ts diff --git a/src/main/remote/auth/DevicePairing.test.ts b/src/main/remote/auth/DevicePairing.test.ts new file mode 100644 index 000000000..a79e9d0ee --- /dev/null +++ b/src/main/remote/auth/DevicePairing.test.ts @@ -0,0 +1,109 @@ +import { randomBytes } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { DevicePairing, PAIRING_CODE_TTL_MS } from './DevicePairing.js' +import { DeviceRegistry } from './deviceRegistry.js' + +let dir: string +let registry: DeviceRegistry +let now: number +const clock = (): number => now + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'remote-pairing-')) + registry = new DeviceRegistry(join(dir, 'devices.json')) + await registry.load() + now = 1_000_000 +}) + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +function makePairing(secret = randomBytes(32)): DevicePairing { + return new DevicePairing({ secret, registry, now: clock }) +} + +describe('pairing codes', () => { + it('redeeming a live code creates a device and returns a verifiable token', async () => { + const pairing = makePairing() + const { code } = pairing.issuePairingCode() + const redeemed = await pairing.redeemPairingCode(code, 'Julius iPhone') + expect(redeemed.ok).toBe(true) + if (!redeemed.ok) return + expect(registry.get(redeemed.device.deviceId)?.name).toBe('Julius iPhone') + + const verdict = pairing.verifyToken(redeemed.token) + expect(verdict).toEqual({ ok: true, deviceId: redeemed.device.deviceId }) + }) + + it('codes are single-use', async () => { + const pairing = makePairing() + const { code } = pairing.issuePairingCode() + expect((await pairing.redeemPairingCode(code, 'a')).ok).toBe(true) + const second = await pairing.redeemPairingCode(code, 'b') + expect(second).toEqual({ ok: false, reason: 'invalid-code' }) + }) + + it('codes expire after the TTL', async () => { + const pairing = makePairing() + const { code } = pairing.issuePairingCode() + now += PAIRING_CODE_TTL_MS + 1 + const redeemed = await pairing.redeemPairingCode(code, 'late phone') + expect(redeemed).toEqual({ ok: false, reason: 'expired' }) + }) + + it('unknown codes are rejected', async () => { + const pairing = makePairing() + pairing.issuePairingCode() + const redeemed = await pairing.redeemPairingCode('WRONGCODE', 'x') + expect(redeemed).toEqual({ ok: false, reason: 'invalid-code' }) + }) +}) + +describe('token verification', () => { + it('rejects tampered tokens', async () => { + const pairing = makePairing() + const { code } = pairing.issuePairingCode() + const redeemed = await pairing.redeemPairingCode(code, 'phone') + if (!redeemed.ok) throw new Error('setup failed') + const tampered = redeemed.token.slice(0, -2) + 'zz' + expect(pairing.verifyToken(tampered).ok).toBe(false) + }) + + it('rejects tokens signed with a different secret', async () => { + const pairingA = makePairing(randomBytes(32)) + const { code } = pairingA.issuePairingCode() + const redeemed = await pairingA.redeemPairingCode(code, 'phone') + if (!redeemed.ok) throw new Error('setup failed') + + const pairingB = makePairing(randomBytes(32)) + expect(pairingB.verifyToken(redeemed.token)).toEqual({ + ok: false, + reason: 'bad-signature', + }) + }) + + it('rejects tokens for revoked devices', async () => { + const pairing = makePairing() + const { code } = pairing.issuePairingCode() + const redeemed = await pairing.redeemPairingCode(code, 'lost phone') + if (!redeemed.ok) throw new Error('setup failed') + await registry.revoke(redeemed.device.deviceId) + expect(pairing.verifyToken(redeemed.token)).toEqual({ + ok: false, + reason: 'revoked', + }) + }) + + it('rejects malformed tokens without throwing', () => { + const pairing = makePairing() + for (const bad of ['', 'v1', 'v1.a.b', 'nonsense tokens everywhere', 'v2.x.1.sig']) { + const verdict = pairing.verifyToken(bad) + expect(verdict.ok).toBe(false) + } + }) +}) diff --git a/src/main/remote/auth/DevicePairing.ts b/src/main/remote/auth/DevicePairing.ts new file mode 100644 index 000000000..9963a7a74 --- /dev/null +++ b/src/main/remote/auth/DevicePairing.ts @@ -0,0 +1,134 @@ +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto' + +import type { DeviceRegistry, PairedDevice } from '@main/remote/auth/deviceRegistry.js' + +// QR pairing + device-token mint/verify for the remote mobile companion. +// +// The flow (see the 2026-07-06 spec, "DevicePairing + deviceRegistry"): +// 1. Desktop shows a QR encoding { url, code } — code comes from +// issuePairingCode(), short-lived and single-use. +// 2. Phone POSTs the code; redeemPairingCode() creates the device in the +// registry and mints its long-lived token. +// 3. Every WS upgrade and every mutating message carries the token; +// verifyToken() checks signature + registry liveness. +// +// WHY HMAC tokens instead of random session ids in a table: the token is +// self-authenticating against the install secret, so verification is pure +// CPU — no async registry read on the hot per-message path (the registry +// lookup is an in-memory Map). And there is no expiry in the token ON +// PURPOSE: expiry would force phones to silently re-pair on a timer, which +// for a physical-proximity pairing flow means walking to the desktop. The +// desktop-side revocation switch (registry.revoked) is the kill mechanism, +// and it is instant. +// +// Token wire format: `v1...` where +// sig = HMAC-SHA256(secret, `v1..`). The version +// prefix lets a future format rotate without ambiguity; issuedAt is signed +// so it can be trusted for display ("paired since …") even though it is not +// an expiry. + +export const PAIRING_CODE_TTL_MS = 5 * 60 * 1000 + +// Unambiguous code alphabet — no 0/O, 1/I/L, or lowercase. The code is +// typed by a human off a QR fallback screen; every character class that has +// ever been misread on a screen is excluded. 8 chars over 28 symbols is +// ~48 bits — unguessable within a 5-minute single-use window, especially +// since redemption is a network round-trip per attempt. +const CODE_ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ' +const CODE_LENGTH = 8 +const TOKEN_VERSION = 'v1' + +export type RedeemResult = + | { ok: true; token: string; device: PairedDevice } + | { ok: false; reason: 'invalid-code' | 'expired' } + +export type TokenVerdict = + | { ok: true; deviceId: string } + | { ok: false; reason: 'malformed' | 'bad-signature' | 'unknown-device' | 'revoked' } + +export class DevicePairing { + private readonly secret: Buffer + private readonly registry: DeviceRegistry + private readonly now: () => number + /** Live one-time codes → expiry epoch ms. Codes are few and short-lived; + * a Map with lazy expiry checks (no timer) is all this needs. */ + private readonly liveCodes = new Map() + + constructor(opts: { + secret: Buffer + registry: DeviceRegistry + /** Injectable clock so tests can cross the TTL without sleeping. */ + now?: () => number + }) { + this.secret = opts.secret + this.registry = opts.registry + this.now = opts.now ?? Date.now + } + + issuePairingCode(): { code: string; expiresAt: number } { + let code = '' + // Rejection-free sampling: 31 symbols doesn't divide 256, so byte % 31 + // would bias early alphabet letters. randomInt-per-char is fine at this + // scale, but reading one byte and re-drawing on overflow keeps it + // dependency-free and obviously uniform. + while (code.length < CODE_LENGTH) { + const byte = randomBytes(1)[0] + const limit = 256 - (256 % CODE_ALPHABET.length) + if (byte >= limit) continue + code += CODE_ALPHABET[byte % CODE_ALPHABET.length] + } + const expiresAt = this.now() + PAIRING_CODE_TTL_MS + this.liveCodes.set(code, expiresAt) + return { code, expiresAt } + } + + async redeemPairingCode(code: string, deviceName: string): Promise { + const expiresAt = this.liveCodes.get(code) + if (expiresAt === undefined) return { ok: false, reason: 'invalid-code' } + // Single-use: delete BEFORE the expiry check so an expired code can't be + // retried either — any observed code is dead after one redemption attempt. + this.liveCodes.delete(code) + if (this.now() > expiresAt) return { ok: false, reason: 'expired' } + + const device = await this.registry.create(deviceName) + return { ok: true, token: this.mintToken(device.deviceId), device } + } + + mintToken(deviceId: string): string { + const issuedAt = this.now() + const body = `${TOKEN_VERSION}.${deviceId}.${issuedAt}` + return `${body}.${this.sign(body)}` + } + + verifyToken(token: string): TokenVerdict { + const parts = token.split('.') + if (parts.length !== 4 || parts[0] !== TOKEN_VERSION) { + return { ok: false, reason: 'malformed' } + } + const [version, deviceId, issuedAt, sig] = parts + if (!deviceId || !/^\d+$/.test(issuedAt) || !sig) { + return { ok: false, reason: 'malformed' } + } + + const expected = this.sign(`${version}.${deviceId}.${issuedAt}`) + const sigBuf = Buffer.from(sig, 'utf8') + const expectedBuf = Buffer.from(expected, 'utf8') + // timingSafeEqual throws on length mismatch, so gate on length first — + // length itself is not secret (signatures are fixed-width). + if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) { + return { ok: false, reason: 'bad-signature' } + } + + // Signature is valid — now the registry decides liveness. Order matters: + // checking the registry first would let an attacker probe which device + // ids exist using garbage signatures. + const device = this.registry.get(deviceId) + if (!device) return { ok: false, reason: 'unknown-device' } + if (device.revoked) return { ok: false, reason: 'revoked' } + return { ok: true, deviceId } + } + + private sign(body: string): string { + return createHmac('sha256', this.secret).update(body).digest('base64url') + } +} diff --git a/src/main/remote/auth/deviceRegistry.test.ts b/src/main/remote/auth/deviceRegistry.test.ts new file mode 100644 index 000000000..4e7a9508a --- /dev/null +++ b/src/main/remote/auth/deviceRegistry.test.ts @@ -0,0 +1,70 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { DeviceRegistry } from './deviceRegistry.js' + +let dir: string +let file: string + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'remote-devices-')) + file = join(dir, 'devices.json') +}) + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +describe('DeviceRegistry', () => { + it('creates devices with unique ids and lists them', async () => { + const registry = new DeviceRegistry(file) + await registry.load() + const a = await registry.create('Julius iPhone') + const b = await registry.create('iPad') + expect(a.deviceId).not.toBe(b.deviceId) + expect(registry.list().map(d => d.name)).toEqual(['Julius iPhone', 'iPad']) + }) + + it('persists across a reload (fresh instance, same file)', async () => { + const first = new DeviceRegistry(file) + await first.load() + const created = await first.create('Julius iPhone') + + const second = new DeviceRegistry(file) + await second.load() + expect(second.get(created.deviceId)?.name).toBe('Julius iPhone') + }) + + it('revokes a device durably', async () => { + const registry = new DeviceRegistry(file) + await registry.load() + const device = await registry.create('lost phone') + expect(await registry.revoke(device.deviceId)).toBe(true) + expect(registry.get(device.deviceId)?.revoked).toBe(true) + + const reloaded = new DeviceRegistry(file) + await reloaded.load() + expect(reloaded.get(device.deviceId)?.revoked).toBe(true) + // Revoking an unknown id reports false rather than throwing — the + // desktop UI treats it as already-gone. + expect(await registry.revoke('nope')).toBe(false) + }) + + it('tolerates a missing file and a corrupt file (starts empty)', async () => { + const registry = new DeviceRegistry(file) + await registry.load() + expect(registry.list()).toEqual([]) + + const corrupt = new DeviceRegistry(file) + await registry.create('x') + const raw = await readFile(file, 'utf8') + expect(raw).toContain('"devices"') + // Corrupt the file, then load a fresh instance: it must not throw and + // must not resurrect ghosts. + await rm(file) + await corrupt.load() + expect(corrupt.list()).toEqual([]) + }) +}) diff --git a/src/main/remote/auth/deviceRegistry.ts b/src/main/remote/auth/deviceRegistry.ts new file mode 100644 index 000000000..2208e8d97 --- /dev/null +++ b/src/main/remote/auth/deviceRegistry.ts @@ -0,0 +1,114 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +// Persisted registry of paired remote devices. +// +// This file is the revocation authority: DevicePairing.verifyToken checks a +// token's signature against the install secret AND the device's liveness +// here, so revoking a device on the desktop kills its token instantly even +// though the token itself is stateless and unexpiring. That split is +// deliberate — stateless tokens mean no session table to sync to phones, +// while the registry gives the desktop a kill switch a lost phone can't +// dodge. +// +// Write discipline: full-file JSON, written to a temp file then rename()d. +// Registry mutations are rare (pair / revoke / lastSeen touch), so atomic +// whole-file writes are simpler and safer than append logs — a torn write +// can never half-apply, and load() falls back to empty on any parse failure +// (an unreadable registry means "no devices trusted", failing closed). + +export type PairedDevice = { + deviceId: string + /** Human label chosen at pairing time, shown in the desktop device list. */ + name: string + createdAt: number + /** Epoch ms of the last verified message from this device; null until first use. */ + lastSeenAt: number | null + revoked: boolean +} + +type RegistryFileShape = { + devices: PairedDevice[] +} + +export class DeviceRegistry { + private devices = new Map() + + constructor(private readonly file: string) {} + + async load(): Promise { + this.devices.clear() + try { + const raw = await readFile(this.file, 'utf8') + const parsed = JSON.parse(raw) as RegistryFileShape + if (Array.isArray(parsed?.devices)) { + for (const d of parsed.devices) { + if (typeof d?.deviceId === 'string' && typeof d?.name === 'string') { + this.devices.set(d.deviceId, { + deviceId: d.deviceId, + name: d.name, + createdAt: typeof d.createdAt === 'number' ? d.createdAt : 0, + lastSeenAt: typeof d.lastSeenAt === 'number' ? d.lastSeenAt : null, + revoked: d.revoked === true, + }) + } + } + } + } catch { + // Missing or corrupt file → empty registry. Failing CLOSED here is the + // security-relevant choice: no readable registry, no trusted devices. + } + } + + list(): PairedDevice[] { + return [...this.devices.values()] + } + + get(deviceId: string): PairedDevice | null { + return this.devices.get(deviceId) ?? null + } + + async create(name: string): Promise { + const device: PairedDevice = { + deviceId: randomUUID(), + name, + createdAt: Date.now(), + lastSeenAt: null, + revoked: false, + } + this.devices.set(device.deviceId, device) + await this.persist() + return device + } + + /** Returns false when the id is unknown — the desktop UI treats that as + * already-gone rather than an error. */ + async revoke(deviceId: string): Promise { + const device = this.devices.get(deviceId) + if (!device) return false + device.revoked = true + await this.persist() + return true + } + + /** Stamp lastSeenAt. Persisted best-effort: losing a touch on crash costs + * a cosmetic timestamp, not security state. */ + async touch(deviceId: string): Promise { + const device = this.devices.get(deviceId) + if (!device) return + device.lastSeenAt = Date.now() + await this.persist().catch(() => {}) + } + + private async persist(): Promise { + const payload: RegistryFileShape = { devices: this.list() } + await mkdir(dirname(this.file), { recursive: true, mode: 0o700 }) + // Unique temp name per write: touch() is fire-and-forget, so two writes + // can overlap; a shared temp path would let them corrupt each other's + // rename. rename() itself is atomic on the same filesystem. + const tmp = join(dirname(this.file), `.devices-${randomUUID()}.tmp`) + await writeFile(tmp, JSON.stringify(payload, null, 2) + '\n', { mode: 0o600 }) + await rename(tmp, this.file) + } +} diff --git a/src/main/remote/auth/secret.test.ts b/src/main/remote/auth/secret.test.ts new file mode 100644 index 000000000..56e9befc6 --- /dev/null +++ b/src/main/remote/auth/secret.test.ts @@ -0,0 +1,43 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { loadOrCreateRemoteSecret, REMOTE_SECRET_BYTES } from './secret.js' + +let dir: string + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'remote-secret-')) +}) + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +describe('loadOrCreateRemoteSecret', () => { + it('creates a new secret with owner-only permissions on first call', async () => { + const secret = await loadOrCreateRemoteSecret(dir) + expect(secret.length).toBe(REMOTE_SECRET_BYTES) + const s = await stat(join(dir, 'secret')) + // 0o600 — the secret signs device tokens; any wider mode leaks the + // ability to mint valid remote-control tokens to other local users. + expect(s.mode & 0o777).toBe(0o600) + }) + + it('returns the same secret on subsequent calls', async () => { + const first = await loadOrCreateRemoteSecret(dir) + const second = await loadOrCreateRemoteSecret(dir) + expect(second.equals(first)).toBe(true) + }) + + it('regenerates when the stored secret is corrupt', async () => { + await loadOrCreateRemoteSecret(dir) + await writeFile(join(dir, 'secret'), 'not-hex-garbage', 'utf8') + const regenerated = await loadOrCreateRemoteSecret(dir) + expect(regenerated.length).toBe(REMOTE_SECRET_BYTES) + // The rewritten file must be valid for the NEXT boot too. + const onDisk = (await readFile(join(dir, 'secret'), 'utf8')).trim() + expect(Buffer.from(onDisk, 'hex').equals(regenerated)).toBe(true) + }) +}) diff --git a/src/main/remote/auth/secret.ts b/src/main/remote/auth/secret.ts new file mode 100644 index 000000000..84830c721 --- /dev/null +++ b/src/main/remote/auth/secret.ts @@ -0,0 +1,59 @@ +import { randomBytes } from 'node:crypto' +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { STATE_DIR } from '@main/storage/paths.js' + +// Per-install HMAC secret for the remote mobile companion. +// +// Every paired device's token is HMAC-signed with this secret (see +// DevicePairing.ts). The secret never leaves this machine: phones hold only +// their signed tokens, so verifying a token requires nothing but this file +// and the device registry — no accounts, no cloud. +// +// WHY a file under STATE_DIR and not Electron safeStorage/keychain: the +// secret must be readable by the main process at startup without user +// interaction (a keychain prompt on every boot would train users to +// click-through), and STATE_DIR is already the app's canonical local-state +// root with a documented purge story. 0o600 + 0o700 parent dir gives the +// same protection the workspace state itself gets; an attacker who can read +// this file can already read every transcript on disk. + +export const REMOTE_SECRET_BYTES = 32 + +/** Default directory for remote-companion state (secret, devices.json). */ +export const REMOTE_STATE_DIR = join(STATE_DIR, 'remote') + +/** + * Load the per-install secret, creating it on first use. + * + * A corrupt/short file is REGENERATED rather than treated as fatal: the + * failure mode of regeneration is "every paired phone must re-pair" (tokens + * signed by the old secret stop verifying), which is annoying but safe — + * strictly better than refusing to start the remote subsystem, and far + * better than trusting a truncated secret with less entropy than we minted. + */ +export async function loadOrCreateRemoteSecret( + dir: string = REMOTE_STATE_DIR, +): Promise { + await mkdir(dir, { recursive: true, mode: 0o700 }) + const file = join(dir, 'secret') + + try { + const raw = (await readFile(file, 'utf8')).trim() + const parsed = Buffer.from(raw, 'hex') + // Buffer.from(_, 'hex') silently stops at the first non-hex character, + // so a garbage file yields a short buffer — the length check catches + // both truncation and non-hex corruption in one condition. + if (parsed.length === REMOTE_SECRET_BYTES) return parsed + } catch { + // ENOENT on first run, or unreadable file — fall through to mint. + } + + const secret = randomBytes(REMOTE_SECRET_BYTES) + await writeFile(file, secret.toString('hex') + '\n', { mode: 0o600 }) + // writeFile's mode only applies on CREATE; if we're overwriting a corrupt + // file that somehow had wider permissions, tighten it explicitly. + await chmod(file, 0o600) + return secret +} diff --git a/src/main/remote/protocol/messages.ts b/src/main/remote/protocol/messages.ts new file mode 100644 index 000000000..b98d4dd6e --- /dev/null +++ b/src/main/remote/protocol/messages.ts @@ -0,0 +1,147 @@ +import { z } from 'zod' + +// Wire schemas for the remote mobile companion's WebSocket protocol. +// +// THE INVARIANT THIS FILE ENFORCES (spec §"Scope for v1"): the inbound +// union below is the COMPLETE set of things a phone can make this machine +// do. Shell execution, session spawn/kill, provider switching, and raw +// keystroke input are not "checked and denied" — they are unrepresentable, +// because no schema for them exists. A stolen device token is limited to +// prompting agents and answering their condition dialogs. Widening this +// union is a deliberate security decision that belongs in its own PR with +// scope.test.ts updated in the same diff. +// +// WHY zod at the boundary instead of TypeScript types + trust: every frame +// arrives from a network socket that any device on the LAN (or, with the +// phase-2 tunnel, the internet) can open. Types don't exist at runtime; +// these schemas are the runtime teeth. RemoteServer parses with +// parseInboundFrame (scope.ts) BEFORE any handler sees the payload. + +// --- Inbound (phone → Mac) --------------------------------------------------- + +/** Liveness probe; also what the client sends to keep NAT/proxy sockets warm. */ +export const pingMessageSchema = z.object({ + type: z.literal('ping'), +}) + +/** + * Deliver prompt text to a session's composer path. `text` is REQUIRED + * non-empty: an empty prompt is meaningless and, on PTY providers, a bare + * submit — which has its own explicit message below. Keeping them distinct + * makes the server-side apply logic total (no "was this a submit?" guessing). + */ +export const sendPromptMessageSchema = z.object({ + type: z.literal('send-prompt'), + sessionId: z.string().min(1), + text: z.string().min(1), +}) + +/** Press Enter — submit whatever is staged in the session's input. */ +export const submitMessageSchema = z.object({ + type: z.literal('submit'), + sessionId: z.string().min(1), +}) + +/** Interrupt the running turn (Esc — the provider TUIs' universal stop). */ +export const interruptMessageSchema = z.object({ + type: z.literal('interrupt'), + sessionId: z.string().min(1), +}) + +/** + * Answer a live provider condition (permission prompt, trust dialog, + * AskUserQuestion, codex approval). The action mirrors ConditionAction from + * @shared/conditions-core/contract — re-modeled here as zod because that + * contract is compile-time only. + * + * A `pty` action's `data` is raw keystrokes, which sounds like the raw-input + * hole the v1 scope amputated — it is not, for two reasons enforced by + * RemoteServer at apply time: (1) the action must match one of the LIVE + * condition's own offered actions (id + data equality against the current + * snapshot), so the phone can only pick from choices the provider itself + * put on screen; (2) sessions without a live condition reject the message + * outright. The schema alone cannot see the live snapshot, so this pairing + * of schema + apply-time check is the full guard. + */ +export const conditionActionSchema = z.union([ + z.object({ + kind: z.literal('pty'), + id: z.string().min(1), + label: z.string(), + data: z.string().min(1), + }), + z.object({ + kind: z.literal('custom'), + id: z.string().min(1), + label: z.string(), + name: z.string().min(1), + payload: z.unknown().optional(), + }), +]) + +export const permissionReplyMessageSchema = z.object({ + type: z.literal('permission-reply'), + sessionId: z.string().min(1), + action: conditionActionSchema, +}) + +export const inboundMessageSchema = z.discriminatedUnion('type', [ + pingMessageSchema, + sendPromptMessageSchema, + submitMessageSchema, + interruptMessageSchema, + permissionReplyMessageSchema, +]) + +/** + * Every inbound frame carries the device token (verified per-message, not + * just at upgrade — a socket hijacked after upgrade still can't act) and an + * optional client-minted id the server echoes on the reply so the phone can + * correlate request/response over the single socket. + */ +export const inboundFrameSchema = z.object({ + token: z.string().min(1), + id: z.string().optional(), + message: inboundMessageSchema, +}) + +export type InboundMessage = z.infer +export type InboundFrame = z.infer + +// --- Outbound (Mac → phone) -------------------------------------------------- +// +// Outbound frames are server-authored, so they don't need runtime parsing on +// this side — the types keep the server honest and give the phone client a +// single import for the whole wire shape. `session-event` deliberately wraps +// payloads as { channel, payload } mirroring the SessionFeed listener names: +// the phone's WebSocketSessionFeed is a switchboard from channel → listener +// set, identical in shape to how the preload bridge fans out IPC channels. + +export type OutboundSessionSummary = { + sessionId: string + kind: string + cwd: string | null + /** Present when the provider session has started; null for spawning/dead. */ + alive: boolean +} + +export type OutboundFrame = + | { type: 'hello'; deviceId: string; deviceName: string } + | { type: 'pong'; id?: string } + | { type: 'session-list'; sessions: OutboundSessionSummary[] } + | { + type: 'session-event' + channel: + | 'started' + | 'screen' + | 'jsonl-entries' + | 'jsonl-error' + | 'semantic-event' + | 'conditions' + | 'process-state' + | 'sub-agents' + | 'exit' + payload: unknown + } + | { type: 'reply'; id?: string; ok: boolean; error?: string; result?: unknown } + | { type: 'error'; error: string } diff --git a/src/main/remote/protocol/scope.test.ts b/src/main/remote/protocol/scope.test.ts new file mode 100644 index 000000000..551c02382 --- /dev/null +++ b/src/main/remote/protocol/scope.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' + +import { parseInboundFrame } from './scope.js' + +// The scope gate is the v1 security line: anything a phone can make the Mac +// DO must survive this parser. These tests enumerate BOTH directions — every +// allowed type parses, and the specific capabilities we amputated for v1 +// (shell exec, session lifecycle, provider switching) are rejected no matter +// how well-formed the frame looks. If a future phase widens the protocol, +// it must consciously edit both this file and messages.ts. + +const TOKEN = 'v1.device.123.sig' + +function frame(message: unknown): unknown { + return { token: TOKEN, id: 'req-1', message } +} + +describe('allowed inbound messages', () => { + it.each([ + ['ping', { type: 'ping' }], + ['send-prompt', { type: 'send-prompt', sessionId: 's1', text: 'hello agent' }], + ['submit', { type: 'submit', sessionId: 's1' }], + ['interrupt', { type: 'interrupt', sessionId: 's1' }], + [ + 'permission-reply (pty action)', + { + type: 'permission-reply', + sessionId: 's1', + action: { kind: 'pty', id: 'yes', label: 'Yes', data: '\r' }, + }, + ], + [ + 'permission-reply (custom action)', + { + type: 'permission-reply', + sessionId: 's1', + action: { + kind: 'custom', + id: 'q0', + label: 'Answer', + name: 'claude.ask-user-question', + payload: { answers: [[0]] }, + }, + }, + ], + ])('%s parses', (_label, message) => { + const result = parseInboundFrame(frame(message)) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.frame.token).toBe(TOKEN) + expect(result.frame.id).toBe('req-1') + } + }) +}) + +describe('out-of-scope and malformed frames', () => { + it.each([ + // Capabilities that DO NOT EXIST in v1 — must be rejected even though + // they look plausible. This is scope-by-protocol-shape. + ['shell exec', { type: 'exec', sessionId: 's1', command: 'rm -rf /' }], + ['session spawn', { type: 'spawn-session', cwd: '/tmp' }], + ['session kill', { type: 'kill-session', sessionId: 's1' }], + ['provider switch', { type: 'switch-provider', sessionId: 's1', to: 'codex' }], + ['raw input', { type: 'send-input', sessionId: 's1', data: 'ls\r' }], + // Malformed variants of allowed types. + ['send-prompt without text', { type: 'send-prompt', sessionId: 's1' }], + ['send-prompt empty text', { type: 'send-prompt', sessionId: 's1', text: '' }], + ['submit without sessionId', { type: 'submit' }], + ['permission-reply with raw string action', { + type: 'permission-reply', sessionId: 's1', action: '\x03', + }], + ['no type at all', { sessionId: 's1' }], + ])('%s is rejected', (_label, message) => { + const result = parseInboundFrame(frame(message)) + expect(result.ok).toBe(false) + }) + + it('rejects frames without a token', () => { + const result = parseInboundFrame({ message: { type: 'ping' } }) + expect(result.ok).toBe(false) + }) + + it('rejects non-object garbage without throwing', () => { + for (const garbage of [null, undefined, 42, 'hi', [], '{"type":"ping"}']) { + expect(parseInboundFrame(garbage).ok).toBe(false) + } + }) +}) diff --git a/src/main/remote/protocol/scope.ts b/src/main/remote/protocol/scope.ts new file mode 100644 index 000000000..e62dfd7e3 --- /dev/null +++ b/src/main/remote/protocol/scope.ts @@ -0,0 +1,30 @@ +import { inboundFrameSchema, type InboundFrame } from '@main/remote/protocol/messages.js' + +// The v1 scope gate. One function, one job: nothing reaches a RemoteServer +// handler unless it round-trips through the inbound schema union. The +// allow-list is not a separate string array to keep in sync — the +// discriminated union in messages.ts IS the allow-list, so an out-of-scope +// type fails the same parse as a malformed in-scope message. scope.test.ts +// pins both directions (allowed types parse; amputated capabilities don't), +// so widening the protocol forces a visible test edit. + +export type ParseInboundResult = + | { ok: true; frame: InboundFrame } + | { ok: false; reason: string } + +export function parseInboundFrame(raw: unknown): ParseInboundResult { + const parsed = inboundFrameSchema.safeParse(raw) + if (!parsed.success) { + // The zod issue list can nest deeply for union failures; one flat line + // is enough for the reply/error frame and the journal. Never echo the + // raw payload back — it came from an untrusted socket. + const first = parsed.error.issues[0] + return { + ok: false, + reason: first + ? `${first.path.join('.') || ''}: ${first.message}` + : 'invalid frame', + } + } + return { ok: true, frame: parsed.data } +} From 2b6f69d73e931ba0b7c3804618c432ac15e6da50 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 6 Jul 2026 21:45:17 +0200 Subject: [PATCH 09/15] feat(remote): SessionFeedSource tap and LAN transport seam SessionFeedSource is a second parallel subscriber on SessionManager (forwarder untouched) with its own per-session jsonl coalescer; raw PTY channels are deliberately never tapped so remote clients cannot observe terminal bytes. RemoteTransport is the reachability seam that makes the phase-2 cloudflared tunnel a transport swap, not a server change; LanTransport binds 0.0.0.0 and reports one concrete LAN IPv4 for the QR. Co-Authored-By: Claude Opus 4.8 --- src/main/remote/SessionFeedSource.test.ts | 102 +++++++++++ src/main/remote/SessionFeedSource.ts | 165 ++++++++++++++++++ .../remote/transport/LanTransport.test.ts | 38 ++++ src/main/remote/transport/LanTransport.ts | 80 +++++++++ src/main/remote/transport/RemoteTransport.ts | 23 +++ 5 files changed, 408 insertions(+) create mode 100644 src/main/remote/SessionFeedSource.test.ts create mode 100644 src/main/remote/SessionFeedSource.ts create mode 100644 src/main/remote/transport/LanTransport.test.ts create mode 100644 src/main/remote/transport/LanTransport.ts create mode 100644 src/main/remote/transport/RemoteTransport.ts diff --git a/src/main/remote/SessionFeedSource.test.ts b/src/main/remote/SessionFeedSource.test.ts new file mode 100644 index 000000000..b88799470 --- /dev/null +++ b/src/main/remote/SessionFeedSource.test.ts @@ -0,0 +1,102 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' + +import { SessionFeedSource } from './SessionFeedSource.js' +import type { SessionManager } from '@main/sessionManager.js' + +// SessionFeedSource is the remote subsystem's ONLY tap into SessionManager's +// event stream — a second subscriber alongside the renderer forwarder, never +// a replacement for it. These tests drive a bare EventEmitter standing in +// for the manager, which is honest: the source consumes nothing but `on()` +// and `getSessionKind()`. + +function makeManager(): SessionManager & EventEmitter { + const emitter = new EventEmitter() as SessionManager & EventEmitter + ;(emitter as unknown as { getSessionKind: (id: string) => string | null }).getSessionKind = + vi.fn(() => 'claude') + return emitter +} + +async function drainImmediates(): Promise { + // The source's jsonl coalescer flushes on setImmediate — one macrotask hop + // lands after it. + await new Promise(resolve => setImmediate(resolve)) +} + +describe('SessionFeedSource', () => { + it('forwards feed-covered manager events as channel/payload pairs', () => { + const manager = makeManager() + const source = new SessionFeedSource(manager) + const seen: Array<{ channel: string; payload: unknown }> = [] + source.onEvent((channel, payload) => seen.push({ channel, payload })) + + manager.emit('screen', { sessionId: 's1', plain: 'hi', markdown: '', recent: 'hi', recentMarkdown: '', picker: { visible: false, items: [] } }) + manager.emit('process-state', { sessionId: 's1', active: true, status: 'Working' }) + manager.emit('conditions', { sessionId: 's1', snapshot: { provider: 'claude', conditions: {} } }) + + expect(seen.map(e => e.channel)).toEqual(['screen', 'process-state', 'conditions']) + source.dispose() + }) + + it('does NOT forward raw PTY channels (terminal-data, agent-pty-data, pty-data)', () => { + const manager = makeManager() + const source = new SessionFeedSource(manager) + const seen: string[] = [] + source.onEvent(channel => seen.push(channel)) + + manager.emit('terminal-data', { sessionId: 's1', data: 'raw bytes' }) + manager.emit('agent-pty-data', { sessionId: 's1', data: 'raw bytes' }) + manager.emit('pty-data', { sessionId: 's1', data: 'raw bytes' }) + + expect(seen).toEqual([]) + source.dispose() + }) + + it('coalesces jsonl entries into one burst per tick', async () => { + const manager = makeManager() + const source = new SessionFeedSource(manager) + const bursts: unknown[] = [] + source.onEvent((channel, payload) => { + if (channel === 'jsonl-entries') bursts.push(payload) + }) + + for (let i = 0; i < 5; i++) { + manager.emit('jsonl-entry', { sessionId: 's1', entry: { i }, file: 'f.jsonl' }) + } + expect(bursts).toEqual([]) + await drainImmediates() + expect(bursts).toHaveLength(1) + expect((bursts[0] as { entries: unknown[] }).entries).toHaveLength(5) + source.dispose() + }) + + it('tracks the live session list from started/exit', () => { + const manager = makeManager() + const source = new SessionFeedSource(manager) + + manager.emit('started', { sessionId: 's1', kind: 'claude', projectDir: '/repo' }) + manager.emit('started', { sessionId: 's2', kind: 'codex' }) + expect(source.listSessions()).toEqual([ + { sessionId: 's1', kind: 'claude', cwd: '/repo', alive: true }, + { sessionId: 's2', kind: 'codex', cwd: null, alive: true }, + ]) + + manager.emit('exit', { sessionId: 's1', exitCode: 0 }) + expect(source.listSessions().find(s => s.sessionId === 's1')?.alive).toBe(false) + // removed drops the session entirely — it left the manager. + manager.emit('removed', { sessionId: 's2' }) + expect(source.listSessions().map(s => s.sessionId)).toEqual(['s1']) + source.dispose() + }) + + it('dispose unsubscribes everything (no forwarding after)', () => { + const manager = makeManager() + const source = new SessionFeedSource(manager) + const seen: string[] = [] + source.onEvent(channel => seen.push(channel)) + source.dispose() + manager.emit('screen', { sessionId: 's1', plain: '', markdown: '', recent: '', recentMarkdown: '', picker: { visible: false, items: [] } }) + expect(seen).toEqual([]) + expect(manager.listenerCount('screen')).toBe(0) + }) +}) diff --git a/src/main/remote/SessionFeedSource.ts b/src/main/remote/SessionFeedSource.ts new file mode 100644 index 000000000..afcc254a2 --- /dev/null +++ b/src/main/remote/SessionFeedSource.ts @@ -0,0 +1,165 @@ +import type { SessionManager } from '@main/sessionManager.js' +import type { OutboundSessionSummary } from '@main/remote/protocol/messages.js' + +// SessionFeedSource — the remote subsystem's read tap on SessionManager. +// +// A SECOND subscriber alongside the renderer forwarder +// (src/main/sessions/forwarder.ts), never a replacement: the forwarder keeps +// feeding the desktop exactly as before, and deleting this file would change +// nothing for the desktop. That parallel-subscriber shape is what the +// isolation boundary requires — remote reads the same public event stream, +// it does not sit in the middle of it. +// +// Channel names mirror the SessionFeed listener names (started/screen/ +// jsonl-entries/…): RemoteServer wraps each callback into a +// { type: 'session-event', channel, payload } frame, and the phone's +// WebSocketSessionFeed switches on `channel` to invoke the matching +// listener set — a straight line from manager event to phone callback. +// +// Raw PTY channels (pty-data / terminal-data / agent-pty-data) are +// DELIBERATELY not tapped. They are desktop-only surfaces outside the v1 +// remote scope, and never forwarding them means a compromised remote client +// cannot even OBSERVE raw terminal bytes, let alone write them. + +export type FeedChannel = + | 'started' + | 'screen' + | 'jsonl-entries' + | 'jsonl-error' + | 'semantic-event' + | 'conditions' + | 'process-state' + | 'exit' + +export type FeedEventListener = (channel: FeedChannel, payload: unknown) => void + +type Unsubscribe = () => void + +type TrackedSession = { + sessionId: string + kind: string + cwd: string | null + alive: boolean +} + +type PendingBurst = { + entries: Array<{ entry: unknown; file: string }> + flushScheduled: boolean +} + +export class SessionFeedSource { + private readonly listeners = new Set() + private readonly sessions = new Map() + /** Per-session jsonl coalescing buffers — same discipline as + * main/sessions/jsonlCoalescer.ts (buffer per session, ONE setImmediate + * flush, whole burst in one frame). Re-implemented locally instead of + * refactoring that module to take a sink because the coalescer is core + * and this is remote: the isolation boundary says remote adapts to core, + * core never grows remote-shaped parameters. ~25 lines of duplication is + * the price of the one-way wall, and per-socket batching may diverge + * from the renderer's needs later anyway. */ + private readonly pendingJsonl = new Map() + private readonly unsubscribes: Unsubscribe[] = [] + private disposed = false + + constructor(manager: SessionManager) { + const sub = [0]>( + event: E, + handler: (payload: never) => void, + ): void => { + manager.on(event, handler as never) + this.unsubscribes.push(() => manager.off(event, handler as never)) + } + + sub('started', (payload: { sessionId: string; kind: string; projectDir?: string }) => { + this.sessions.set(payload.sessionId, { + sessionId: payload.sessionId, + kind: payload.kind, + cwd: payload.projectDir ?? null, + alive: true, + }) + this.emit('started', payload) + }) + + sub('screen', (payload: { sessionId: string }) => this.emit('screen', payload)) + sub('jsonl-error', (payload: { sessionId: string; error: Error }) => + this.emit('jsonl-error', { + sessionId: payload.sessionId, + // Match the forwarder's serialization: Errors don't survive + // structured clone/JSON, the message string does. + message: String(payload.error?.message ?? payload.error), + }), + ) + sub('semantic-event', (payload: { sessionId: string }) => + this.emit('semantic-event', payload), + ) + sub('conditions', (payload: { sessionId: string }) => this.emit('conditions', payload)) + sub('process-state', (payload: { sessionId: string }) => + this.emit('process-state', payload), + ) + + sub('jsonl-entry', (payload: { sessionId: string; entry: unknown; file: string }) => { + let pending = this.pendingJsonl.get(payload.sessionId) + if (!pending) { + pending = { entries: [], flushScheduled: false } + this.pendingJsonl.set(payload.sessionId, pending) + } + pending.entries.push({ entry: payload.entry, file: payload.file }) + if (!pending.flushScheduled) { + pending.flushScheduled = true + setImmediate(() => this.flushJsonl(payload.sessionId)) + } + }) + + sub('exit', (payload: { sessionId: string; exitCode: number }) => { + // Flush before exit so the phone sees the same ordering the renderer + // gets (final transcript burst, then the pane goes dead). + this.flushJsonl(payload.sessionId) + const tracked = this.sessions.get(payload.sessionId) + if (tracked) tracked.alive = false + this.emit('exit', payload) + }) + + sub('removed', (payload: { sessionId: string }) => { + // `removed` = the session left the manager entirely (kill or natural + // teardown after exit). Drop it from the list AND drop its buffer so + // dead sessions can't leak coalescer state — mirrors flushAndDropJsonl. + this.flushJsonl(payload.sessionId) + this.pendingJsonl.delete(payload.sessionId) + this.sessions.delete(payload.sessionId) + }) + } + + onEvent(listener: FeedEventListener): Unsubscribe { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + listSessions(): OutboundSessionSummary[] { + return [...this.sessions.values()].map(s => ({ ...s })) + } + + dispose(): void { + if (this.disposed) return + this.disposed = true + for (const unsub of this.unsubscribes) unsub() + this.unsubscribes.length = 0 + this.listeners.clear() + this.pendingJsonl.clear() + this.sessions.clear() + } + + private flushJsonl(sessionId: string): void { + const pending = this.pendingJsonl.get(sessionId) + if (!pending || pending.entries.length === 0) return + const entries = pending.entries + pending.entries = [] + pending.flushScheduled = false + this.emit('jsonl-entries', { sessionId, entries }) + } + + private emit(channel: FeedChannel, payload: unknown): void { + if (this.disposed) return + for (const listener of [...this.listeners]) listener(channel, payload) + } +} diff --git a/src/main/remote/transport/LanTransport.test.ts b/src/main/remote/transport/LanTransport.test.ts new file mode 100644 index 000000000..310064491 --- /dev/null +++ b/src/main/remote/transport/LanTransport.test.ts @@ -0,0 +1,38 @@ +import { createServer } from 'node:http' +import { describe, expect, it } from 'vitest' + +import { LanTransport, pickLanIpv4 } from './LanTransport.js' + +describe('pickLanIpv4', () => { + it('prefers a non-internal IPv4 address', () => { + const ip = pickLanIpv4({ + lo0: [{ family: 'IPv4', address: '127.0.0.1', internal: true }], + en0: [ + { family: 'IPv6', address: 'fe80::1', internal: false }, + { family: 'IPv4', address: '192.168.1.42', internal: false }, + ], + } as never) + expect(ip).toBe('192.168.1.42') + }) + + it('falls back to loopback when no external interface exists', () => { + const ip = pickLanIpv4({ + lo0: [{ family: 'IPv4', address: '127.0.0.1', internal: true }], + } as never) + expect(ip).toBe('127.0.0.1') + }) +}) + +describe('LanTransport', () => { + it('binds the server and reports a URL with the bound port', async () => { + const server = createServer() + const transport = new LanTransport({ port: 0 }) + const { url } = await transport.start(server) + const address = server.address() + expect(address && typeof address === 'object').toBe(true) + if (!address || typeof address === 'string') throw new Error('no bind') + expect(url).toMatch(new RegExp(`^http://\\d+\\.\\d+\\.\\d+\\.\\d+:${address.port}$`)) + await transport.stop() + expect(server.listening).toBe(false) + }) +}) diff --git a/src/main/remote/transport/LanTransport.ts b/src/main/remote/transport/LanTransport.ts new file mode 100644 index 000000000..96aee8faf --- /dev/null +++ b/src/main/remote/transport/LanTransport.ts @@ -0,0 +1,80 @@ +import type { Server } from 'node:http' +import { networkInterfaces } from 'node:os' + +import type { RemoteTransport } from '@main/remote/transport/RemoteTransport.js' + +// Phase-1 transport: plain LAN reachability. Binds 0.0.0.0 so any interface +// works (Wi-Fi vs Ethernet vs USB tether), but the QR URL uses one concrete +// LAN IPv4 because a phone can't dial 0.0.0.0. + +/** + * Pick the address a phone on the same network can reach. First + * non-internal IPv4 wins; interface map is injectable for tests. + * + * WHY loopback fallback instead of throwing when the machine has no LAN + * address: the remote panel should still render (showing 127.0.0.1 makes + * the "you are not on a network" failure visible and debuggable), and the + * phase-2 tunnel transport doesn't need a LAN address at all. + */ +export function pickLanIpv4( + interfaces: ReturnType = networkInterfaces(), +): string { + for (const entries of Object.values(interfaces)) { + for (const entry of entries ?? []) { + if (entry.family === 'IPv4' && !entry.internal) return entry.address + } + } + return '127.0.0.1' +} + +export class LanTransport implements RemoteTransport { + private server: Server | null = null + private readonly port: number + + /** port 0 = OS-assigned. A fixed default port is deliberately NOT chosen + * yet: the URL travels by QR, so nobody types it, and a floating port + * avoids colliding with dev servers. Revisit if re-pairing friction + * shows up (a stable port would let old QR screenshots keep working). */ + constructor(opts: { port?: number } = {}) { + this.port = opts.port ?? 0 + } + + async start(server: Server): Promise<{ url: string }> { + this.server = server + await new Promise((resolve, reject) => { + const onError = (err: Error): void => { + server.off('listening', onListening) + reject(err) + } + const onListening = (): void => { + server.off('error', onError) + resolve() + } + server.once('error', onError) + server.once('listening', onListening) + // 0.0.0.0, not the picked IP: binding one interface would silently + // break when the user hops networks mid-session; the QR regenerates + // per enable anyway. + server.listen(this.port, '0.0.0.0') + }) + + const address = this.server.address() + if (!address || typeof address === 'string') { + throw new Error('LanTransport: server did not bind a TCP port') + } + return { url: `http://${pickLanIpv4()}:${address.port}` } + } + + async stop(): Promise { + const server = this.server + this.server = null + if (!server || !server.listening) return + await new Promise(resolve => { + // close() waits for open sockets; closeAllConnections force-drops live + // WS clients so disable is immediate — a lingering socket on a server + // the user just turned OFF would be a broken promise about the toggle. + server.closeAllConnections?.() + server.close(() => resolve()) + }) + } +} diff --git a/src/main/remote/transport/RemoteTransport.ts b/src/main/remote/transport/RemoteTransport.ts new file mode 100644 index 000000000..a5a3e7143 --- /dev/null +++ b/src/main/remote/transport/RemoteTransport.ts @@ -0,0 +1,23 @@ +import type { Server } from 'node:http' + +// The reachability seam for the remote mobile companion (spec §"RemoteTransport"). +// +// RemoteServer owns WHAT is served (HTTP routes, the WS endpoint, auth); +// a transport owns HOW the server becomes reachable and WHICH URL a phone +// should use. Splitting these means phase 2's cloudflared tunnel is a new +// transport implementation, not a server change: +// +// - LanTransport (phase 1): bind the LAN interface, URL = http://: +// - CloudflaredTunnel (phase 2): bind loopback, spawn bundled cloudflared +// against it, URL = the ephemeral https://…trycloudflare.com it prints +// +// The transport binds the *given* server rather than creating its own so the +// server's request/upgrade handlers are wired exactly once, by RemoteServer, +// regardless of transport. + +export interface RemoteTransport { + /** Make `server` reachable. Resolves with the URL to encode in the pairing QR. */ + start(server: Server): Promise<{ url: string }> + /** Tear down reachability (close listener, kill tunnel child, …). Idempotent. */ + stop(): Promise +} From 420c76af39d282da65258fa3e5fc168fbb8c9b57 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 6 Jul 2026 21:49:36 +0200 Subject: [PATCH 10/15] =?UTF-8?q?feat(remote):=20RemoteServer=20=E2=80=94?= =?UTF-8?q?=20HTTP=20pairing=20endpoint=20+=20authenticated=20WS=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serves the remote-client bundle (placeholder until built), redeems pairing codes over POST /pair, and hosts the WS endpoint with the three-layer posture: token at upgrade, token re-verified per message (revocation bites mid-session), and the v1 schema as the capability ceiling. pty condition actions apply only when they exactly match an action the live condition snapshot offered — the phone picks from the provider's menu, it never composes keystrokes. Late joiners get cached screen/conditions/process-state so idle sessions aren't blank panes. Integration-tested over real sockets end to end (11 cases). Co-Authored-By: Claude Opus 4.8 --- src/main/incident/journalTypes.ts | 5 + .../remote/RemoteServer.integration.test.ts | 290 ++++++++++ src/main/remote/RemoteServer.ts | 518 ++++++++++++++++++ 3 files changed, 813 insertions(+) create mode 100644 src/main/remote/RemoteServer.integration.test.ts create mode 100644 src/main/remote/RemoteServer.ts diff --git a/src/main/incident/journalTypes.ts b/src/main/incident/journalTypes.ts index e553f3f9c..68b1b328f 100644 --- a/src/main/incident/journalTypes.ts +++ b/src/main/incident/journalTypes.ts @@ -77,6 +77,11 @@ export type AppRunIncidentKind = | 'orchestration.request_timeout' | 'orchestration.prompt_delivery_failed' | 'mcp.host_start_failed' + // Remote mobile companion (src/main/remote/) — declared here because this + // union is the app-wide incident vocabulary; the remote subsystem imports + // core, never the reverse (see the 2026-07-06 remote-companion spec's + // isolation section). + | 'remote.server_start_failed' export type AppRunIncidentInput = { kind: AppRunIncidentKind diff --git a/src/main/remote/RemoteServer.integration.test.ts b/src/main/remote/RemoteServer.integration.test.ts new file mode 100644 index 000000000..9467524fc --- /dev/null +++ b/src/main/remote/RemoteServer.integration.test.ts @@ -0,0 +1,290 @@ +import { EventEmitter } from 'node:events' +import { randomBytes } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { WebSocket } from 'ws' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { DevicePairing } from './auth/DevicePairing.js' +import { DeviceRegistry } from './auth/deviceRegistry.js' +import { SessionFeedSource } from './SessionFeedSource.js' +import { LanTransport } from './transport/LanTransport.js' +import { RemoteServer, type RemoteSessionControl } from './RemoteServer.js' + +// End-to-end over real sockets: pairing over HTTP, authenticated WS, +// feed fan-out, and the scope gate applied to a live connection. The +// manager is a bare EventEmitter + spies — RemoteServer must consume +// nothing more (RemoteSessionControl is the structural proof). + +type FakeManager = RemoteSessionControl & EventEmitter + +function makeManager(): FakeManager { + const emitter = new EventEmitter() as FakeManager + emitter.write = vi.fn(() => true) + emitter.resolveCondition = vi.fn(async () => ({ ok: true as const })) + emitter.deliverPromptToAgent = vi.fn(async () => ({ ok: true as const })) + emitter.getSessionKind = vi.fn(() => 'claude' as const) + return emitter +} + +let dir: string +let manager: FakeManager +let registry: DeviceRegistry +let pairing: DevicePairing +let feedSource: SessionFeedSource +let server: RemoteServer +let baseUrl: string + +async function pairDevice(name = 'test phone'): Promise { + const { code } = pairing.issuePairingCode() + const res = await fetch(`${baseUrl}/pair`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ code, deviceName: name }), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { token: string } + return body.token +} + +function connect(token: string): Promise<{ ws: WebSocket; frames: unknown[] }> { + const wsUrl = baseUrl.replace(/^http/, 'ws') + const ws = new WebSocket(`${wsUrl}/ws?token=${encodeURIComponent(token)}`) + const frames: unknown[] = [] + ws.on('message', data => frames.push(JSON.parse(String(data)))) + return new Promise((resolve, reject) => { + ws.once('open', () => resolve({ ws, frames })) + ws.once('error', reject) + }) +} + +/** Wait until the frame list satisfies a predicate — event-driven, no sleeps. */ +function waitFor(frames: unknown[], pred: (frames: unknown[]) => boolean): Promise { + return new Promise((resolve, reject) => { + const deadline = setTimeout(() => reject(new Error('waitFor timeout')), 3000) + const tick = (): void => { + if (pred(frames)) { + clearTimeout(deadline) + resolve() + } else { + setImmediate(tick) + } + } + tick() + }) +} + +function framesOfType(frames: unknown[], type: string): Array> { + return frames.filter( + (f): f is Record => + typeof f === 'object' && f !== null && (f as { type?: unknown }).type === type, + ) +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'remote-server-')) + manager = makeManager() + registry = new DeviceRegistry(join(dir, 'devices.json')) + await registry.load() + pairing = new DevicePairing({ secret: randomBytes(32), registry }) + feedSource = new SessionFeedSource(manager as never) + server = new RemoteServer({ + manager, + feedSource, + pairing, + registry, + transport: new LanTransport({ port: 0 }), + }) + const { url } = await server.start() + // The LAN URL uses the machine's LAN IP; loopback is fine for tests. + baseUrl = url.replace(/\/\/[\d.]+:/, '//127.0.0.1:') +}) + +afterEach(async () => { + await server.stop() + feedSource.dispose() + // registry.touch() persists fire-and-forget; a write can race this rm and + // leave a fresh .tmp file mid-delete (ENOTEMPTY). Retrying absorbs it. + await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 25 }) +}) + +describe('pairing endpoint', () => { + it('redeems a live code and rejects a bogus one', async () => { + const token = await pairDevice('Julius iPhone') + expect(pairing.verifyToken(token).ok).toBe(true) + + const bad = await fetch(`${baseUrl}/pair`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ code: 'NOPE1234', deviceName: 'x' }), + }) + expect(bad.status).toBe(403) + }) +}) + +describe('websocket auth', () => { + it('rejects upgrades without a valid token', async () => { + const wsUrl = baseUrl.replace(/^http/, 'ws') + const ws = new WebSocket(`${wsUrl}/ws?token=garbage`) + const outcome = await new Promise(resolve => { + ws.once('open', () => resolve('open')) + ws.once('error', () => resolve('rejected')) + }) + expect(outcome).toBe('rejected') + }) + + it('greets an authenticated socket with hello + session-list', async () => { + const token = await pairDevice() + const { ws, frames } = await connect(token) + await waitFor(frames, f => framesOfType(f, 'session-list').length > 0) + expect(framesOfType(frames, 'hello')).toHaveLength(1) + ws.close() + }) +}) + +describe('event fan-out', () => { + it('broadcasts manager events and replays cached state to late joiners', async () => { + const token = await pairDevice() + const screen = { + sessionId: 's1', plain: 'hello', markdown: '', recent: 'hello', + recentMarkdown: '', picker: { visible: false, items: [] }, + } + manager.emit('started', { sessionId: 's1', kind: 'claude', projectDir: '/repo' }) + + const live = await connect(token) + await waitFor(live.frames, f => framesOfType(f, 'session-list').length > 0) + manager.emit('screen', screen) + await waitFor(live.frames, f => + framesOfType(f, 'session-event').some(e => e.channel === 'screen'), + ) + live.ws.close() + + // Late joiner: connected AFTER the screen event — must still receive the + // cached screen so an idle session isn't a blank pane on the phone. + const late = await connect(token) + await waitFor(late.frames, f => + framesOfType(f, 'session-event').some(e => e.channel === 'screen'), + ) + late.ws.close() + }) +}) + +describe('inbound scope enforcement on a live socket', () => { + async function openAuthed(): Promise<{ ws: WebSocket; frames: unknown[]; token: string }> { + const token = await pairDevice() + const { ws, frames } = await connect(token) + await waitFor(frames, f => framesOfType(f, 'session-list').length > 0) + return { ws, frames, token } + } + + it('send-prompt writes bracketed paste for PTY providers', async () => { + const { ws, frames, token } = await openAuthed() + ws.send(JSON.stringify({ + token, id: 'r1', + message: { type: 'send-prompt', sessionId: 's1', text: 'do the thing' }, + })) + await waitFor(frames, f => framesOfType(f, 'reply').length > 0) + expect(manager.write).toHaveBeenCalledWith('s1', '\x1b[200~do the thing\x1b[201~') + expect(framesOfType(frames, 'reply')[0]).toMatchObject({ id: 'r1', ok: true }) + ws.close() + }) + + it('send-prompt uses deliverPromptToAgent for API-transport providers', async () => { + ;(manager.getSessionKind as ReturnType).mockReturnValue('opencode') + const { ws, frames, token } = await openAuthed() + ws.send(JSON.stringify({ + token, id: 'r1', + message: { type: 'send-prompt', sessionId: 's1', text: 'hi' }, + })) + await waitFor(frames, f => framesOfType(f, 'reply').length > 0) + expect(manager.deliverPromptToAgent).toHaveBeenCalledWith('s1', 'hi') + expect(manager.write).not.toHaveBeenCalled() + ws.close() + }) + + it('submit and interrupt write their control bytes', async () => { + const { ws, frames, token } = await openAuthed() + ws.send(JSON.stringify({ token, id: 'a', message: { type: 'submit', sessionId: 's1' } })) + ws.send(JSON.stringify({ token, id: 'b', message: { type: 'interrupt', sessionId: 's1' } })) + await waitFor(frames, f => framesOfType(f, 'reply').length >= 2) + expect(manager.write).toHaveBeenCalledWith('s1', '\r') + expect(manager.write).toHaveBeenCalledWith('s1', '\x1b') + ws.close() + }) + + it('custom permission-reply routes through resolveCondition', async () => { + const { ws, frames, token } = await openAuthed() + const action = { kind: 'custom', id: 'q', label: 'Answer', name: 'claude.auq', payload: { a: 1 } } + ws.send(JSON.stringify({ + token, id: 'r1', + message: { type: 'permission-reply', sessionId: 's1', action }, + })) + await waitFor(frames, f => framesOfType(f, 'reply').length > 0) + expect(manager.resolveCondition).toHaveBeenCalledWith('s1', action) + ws.close() + }) + + it('pty permission-reply only applies when it matches a LIVE condition action', async () => { + const { ws, frames, token } = await openAuthed() + const offered = { kind: 'pty', id: 'yes', label: 'Yes', data: '1\r' } + + // No live condition yet → rejected, nothing written. + ws.send(JSON.stringify({ + token, id: 'r1', + message: { type: 'permission-reply', sessionId: 's1', action: offered }, + })) + await waitFor(frames, f => framesOfType(f, 'reply').length >= 1) + expect(framesOfType(frames, 'reply')[0]?.ok).toBe(false) + expect(manager.write).not.toHaveBeenCalled() + + // Provider raises a permission prompt offering that exact action. + manager.emit('conditions', { + sessionId: 's1', + snapshot: { + provider: 'claude', ts: 1, + conditions: { + 'claude.permission-prompt': { + kind: 'claude.permission-prompt', state: { visible: true }, actions: [offered], + }, + }, + }, + }) + await waitFor(frames, f => + framesOfType(f, 'session-event').some(e => e.channel === 'conditions'), + ) + ws.send(JSON.stringify({ + token, id: 'r2', + message: { type: 'permission-reply', sessionId: 's1', action: offered }, + })) + await waitFor(frames, f => framesOfType(f, 'reply').length >= 2) + expect(manager.write).toHaveBeenCalledWith('s1', '1\r') + ws.close() + }) + + it('out-of-scope message types get an error reply and never touch the manager', async () => { + const { ws, frames, token } = await openAuthed() + ws.send(JSON.stringify({ + token, id: 'r1', + message: { type: 'exec', sessionId: 's1', command: 'rm -rf /' }, + })) + await waitFor(frames, f => framesOfType(f, 'reply').length > 0) + expect(framesOfType(frames, 'reply')[0]?.ok).toBe(false) + expect(manager.write).not.toHaveBeenCalled() + expect(manager.resolveCondition).not.toHaveBeenCalled() + ws.close() + }) + + it('a revoked device is cut off mid-session', async () => { + const { ws, frames, token } = await openAuthed() + const verdict = pairing.verifyToken(token) + if (!verdict.ok) throw new Error('setup failed') + await registry.revoke(verdict.deviceId) + + ws.send(JSON.stringify({ token, id: 'r1', message: { type: 'submit', sessionId: 's1' } })) + await waitFor(frames, f => framesOfType(f, 'reply').length > 0) + expect(framesOfType(frames, 'reply')[0]?.ok).toBe(false) + expect(manager.write).not.toHaveBeenCalled() + ws.close() + }) +}) diff --git a/src/main/remote/RemoteServer.ts b/src/main/remote/RemoteServer.ts new file mode 100644 index 000000000..1f7a61fa7 --- /dev/null +++ b/src/main/remote/RemoteServer.ts @@ -0,0 +1,518 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import { createReadStream, existsSync } from 'node:fs' +import { extname, join, normalize } from 'node:path' +import type { Duplex } from 'node:stream' + +import { WebSocketServer, type WebSocket } from 'ws' + +import type { AppRunJournal } from '@main/incident/AppRunJournal.js' +import type { ResolveConditionResult } from '@shared/sessionFeed/types.js' +import type { ConditionCustomAction } from '@shared/conditions-core/contract.js' +import type { SessionKind } from '@shared/types/providerKind.js' + +import { DevicePairing } from '@main/remote/auth/DevicePairing.js' +import type { DeviceRegistry } from '@main/remote/auth/deviceRegistry.js' +import { parseInboundFrame } from '@main/remote/protocol/scope.js' +import type { InboundFrame, OutboundFrame } from '@main/remote/protocol/messages.js' +import type { FeedChannel, SessionFeedSource } from '@main/remote/SessionFeedSource.js' +import type { RemoteTransport } from '@main/remote/transport/RemoteTransport.js' + +// RemoteServer — the HTTP+WS host that makes Agent Code controllable from a +// phone. Modeled on BuiltInMcpHttpHost (same construct-in-index lifecycle, +// same optional-journal wiring), but OFF by default and started only from +// the desktop Remote panel. +// +// Responsibilities, and nothing else: +// 1. Serve the built remote-client bundle (or a placeholder when the +// bundle isn't built — dev ergonomics over a hard failure). +// 2. POST /pair — redeem a one-time pairing code for a device token. +// 3. WS endpoint — token-checked at upgrade AND per message; outbound +// fan-out of SessionFeedSource events; inbound apply of the v1 +// protocol (see protocol/messages.ts for why the schema IS the scope). +// +// THE SECURITY POSTURE lives in three layers, each of which must hold alone: +// - possession: you cannot connect without a token minted via physical +// QR pairing; upgrade rejects before the socket exists. +// - per-message verification: a socket hijacked after upgrade still +// carries no authority — every mutating frame re-verifies the token, +// which re-checks registry revocation (the desktop kill switch). +// - protocol shape: the inbound schema union cannot express shell exec, +// session lifecycle, or raw input, so a fully-authenticated attacker is +// still limited to prompting agents and answering their dialogs. The +// one nuance is pty condition actions, whose bytes ARE keystrokes — +// those apply ONLY when they exactly match an action the live condition +// snapshot itself offered (see applyPermissionReply). + +/** + * The slice of SessionManager the remote subsystem consumes, stated + * structurally so tests can drive a bare EventEmitter + spies and so this + * file documents its true blast radius: four methods, nothing else. + */ +export type RemoteSessionControl = { + write(sessionId: string, data: string): boolean + resolveCondition( + sessionId: string, + action: ConditionCustomAction, + ): Promise + deliverPromptToAgent( + sessionId: string, + prompt: string, + ): Promise<{ ok: true } | { ok: false; message: string }> + getSessionKind(sessionId: string): SessionKind | null +} + +export type RemoteServerDeps = { + manager: RemoteSessionControl + feedSource: SessionFeedSource + pairing: DevicePairing + registry: DeviceRegistry + transport: RemoteTransport + /** Directory of the built remote-client bundle; null/absent serves a + * placeholder page so the server is testable before the client exists. */ + clientDistDir?: string | null + journal?: AppRunJournal | null +} + +// Bracketed paste — the same safe path composer dictation uses for +// multiline text into provider TUIs (see useComposerDictation.ts): raw +// newlines would be interpreted as Enter mid-prompt. +const BRACKETED_PASTE_OPEN = '\x1b[200~' +const BRACKETED_PASTE_CLOSE = '\x1b[201~' +const SUBMIT_BYTES = '\r' +const INTERRUPT_BYTES = '\x1b' + +const PAIR_BODY_LIMIT_BYTES = 16 * 1024 + +const STATIC_CONTENT_TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.json': 'application/json', + '.woff2': 'font/woff2', +} + +type ClientSocket = { + ws: WebSocket + deviceId: string +} + +export class RemoteServer { + private server: Server | null = null + private wss: WebSocketServer | null = null + private url: string | null = null + private readonly sockets = new Set() + private feedUnsub: (() => void) | null = null + + // Late-joiner bootstrap caches. A phone that connects while every agent is + // idle would otherwise stare at blank panes until the next event happens + // to fire; replaying the last screen/conditions/process-state per session + // makes the client instantly correct. jsonl history is NOT cached here — + // scrollback is a client-driven request problem for a later slice, and + // caching transcript entries in the server would duplicate what providers + // already persist on disk. + private readonly lastScreen = new Map() + private readonly lastConditions = new Map() + private readonly lastProcessState = new Map() + + constructor(private readonly deps: RemoteServerDeps) {} + + async start(): Promise<{ url: string }> { + if (this.server && this.url) return { url: this.url } + + this.server = createServer((req, res) => { + void this.handleHttp(req, res) + }) + this.wss = new WebSocketServer({ noServer: true }) + this.server.on('upgrade', (req, socket, head) => { + this.handleUpgrade(req, socket, head) + }) + + this.feedUnsub = this.deps.feedSource.onEvent((channel, payload) => { + this.cacheForLateJoiners(channel, payload) + this.broadcast({ type: 'session-event', channel, payload }) + }) + + try { + const { url } = await this.deps.transport.start(this.server) + this.url = url + this.deps.journal?.record({ + area: 'remote.server', + name: 'remote_server.started', + data: { url }, + }) + return { url } + } catch (err) { + // Bind/tunnel failure: unwind the half-started state so a retry from + // the Remote panel starts clean instead of stacking listeners. + this.deps.journal?.recordIncident({ + kind: 'remote.server_start_failed', + severity: 'error', + error: err, + }) + await this.stop() + throw err + } + } + + async stop(): Promise { + this.feedUnsub?.() + this.feedUnsub = null + for (const client of this.sockets) { + client.ws.close(1001, 'server stopping') + } + this.sockets.clear() + this.wss?.close() + this.wss = null + await this.deps.transport.stop() + this.server = null + this.url = null + this.lastScreen.clear() + this.lastConditions.clear() + this.lastProcessState.clear() + this.deps.journal?.record({ area: 'remote.server', name: 'remote_server.stopped' }) + } + + getUrl(): string | null { + return this.url + } + + connectedDeviceIds(): string[] { + return [...this.sockets].map(s => s.deviceId) + } + + // ---------- HTTP ---------- + + private async handleHttp(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? '/', 'http://localhost') + if (req.method === 'POST' && url.pathname === '/pair') { + await this.handlePair(req, res) + return + } + if (req.method === 'GET' && url.pathname === '/healthz') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ ok: true })) + return + } + if (req.method === 'GET') { + this.serveClient(url.pathname, res) + return + } + res.writeHead(405).end() + } + + private async handlePair(req: IncomingMessage, res: ServerResponse): Promise { + const body = await readBodyCapped(req, PAIR_BODY_LIMIT_BYTES) + let code = '' + let deviceName = '' + try { + const parsed = JSON.parse(body) as { code?: unknown; deviceName?: unknown } + code = typeof parsed.code === 'string' ? parsed.code : '' + deviceName = typeof parsed.deviceName === 'string' ? parsed.deviceName : '' + } catch { + // fall through with empty fields → 400 below + } + if (!code) { + res.writeHead(400, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ ok: false, error: 'code required' })) + return + } + + const redeemed = await this.deps.pairing.redeemPairingCode( + code, + deviceName || 'Unnamed device', + ) + if (!redeemed.ok) { + // 403 for both invalid and expired: distinguishing them on the wire + // would let an attacker probe which codes ever existed. The desktop + // side (which issued the code) can tell the user why it failed. + this.deps.journal?.record({ + area: 'remote.server', + name: 'remote_pair.rejected', + data: { reason: redeemed.reason }, + }) + res.writeHead(403, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ ok: false, error: 'pairing failed' })) + return + } + + this.deps.journal?.record({ + area: 'remote.server', + name: 'remote_pair.redeemed', + data: { deviceId: redeemed.device.deviceId, deviceName: redeemed.device.name }, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ + ok: true, + token: redeemed.token, + deviceId: redeemed.device.deviceId, + deviceName: redeemed.device.name, + }), + ) + } + + private serveClient(pathname: string, res: ServerResponse): void { + const dist = this.deps.clientDistDir + if (!dist) { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end( + '' + + 'Agent Code Remote' + + '' + + '

Agent Code Remote

' + + '

The remote client bundle is not built in this install. ' + + 'The WebSocket API at /ws is live.

', + ) + return + } + + // Path traversal guard: normalize, strip leading separators, and refuse + // anything that still tries to climb out. The bundle is a flat Vite dist + // — there is nothing legitimate above it. + const safe = normalize(pathname).replace(/^([/\\]|\.\.)+/, '') + const filePath = join(dist, safe === '' || safe === '/' ? 'index.html' : safe) + if (!filePath.startsWith(dist) || !existsSync(filePath)) { + // SPA fallback: unknown paths get index.html so client-side routes + // survive a reload on the phone. + const index = join(dist, 'index.html') + if (existsSync(index)) { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + createReadStream(index).pipe(res) + } else { + res.writeHead(404).end() + } + return + } + res.writeHead(200, { + 'content-type': STATIC_CONTENT_TYPES[extname(filePath)] ?? 'application/octet-stream', + }) + createReadStream(filePath).pipe(res) + } + + // ---------- WebSocket ---------- + + private handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { + const url = new URL(req.url ?? '/', 'http://localhost') + if (url.pathname !== '/ws') { + socket.write('HTTP/1.1 404 Not Found\r\n\r\n') + socket.destroy() + return + } + const token = url.searchParams.get('token') ?? '' + const verdict = this.deps.pairing.verifyToken(token) + if (!verdict.ok) { + // Refuse BEFORE the WS exists — an unauthenticated peer never gets a + // socket to speak the protocol on. (The token also rides every + // subsequent frame; this is just the cheapest earliest gate.) + this.deps.journal?.record({ + area: 'remote.server', + name: 'remote_ws.upgrade_rejected', + data: { reason: verdict.reason }, + }) + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') + socket.destroy() + return + } + this.wss?.handleUpgrade(req, socket, head, ws => { + this.onConnection(ws, verdict.deviceId) + }) + } + + private onConnection(ws: WebSocket, deviceId: string): void { + const client: ClientSocket = { ws, deviceId } + this.sockets.add(client) + void this.deps.registry.touch(deviceId) + this.deps.journal?.record({ + area: 'remote.server', + name: 'remote_ws.connected', + data: { deviceId }, + }) + + const device = this.deps.registry.get(deviceId) + this.send(ws, { type: 'hello', deviceId, deviceName: device?.name ?? 'unknown' }) + this.send(ws, { type: 'session-list', sessions: this.deps.feedSource.listSessions() }) + // Late-joiner replay — same channel shape as live events so the client + // needs no special bootstrap path. + for (const [, payload] of this.lastScreen) { + this.send(ws, { type: 'session-event', channel: 'screen', payload }) + } + for (const [, payload] of this.lastConditions) { + this.send(ws, { type: 'session-event', channel: 'conditions', payload }) + } + for (const [, payload] of this.lastProcessState) { + this.send(ws, { type: 'session-event', channel: 'process-state', payload }) + } + + ws.on('message', data => { + void this.onMessage(ws, String(data)) + }) + ws.on('close', () => { + this.sockets.delete(client) + this.deps.journal?.record({ + area: 'remote.server', + name: 'remote_ws.disconnected', + data: { deviceId }, + }) + }) + } + + private async onMessage(ws: WebSocket, raw: string): Promise { + let parsedJson: unknown + try { + parsedJson = JSON.parse(raw) + } catch { + this.send(ws, { type: 'error', error: 'invalid JSON' }) + return + } + + const parsed = parseInboundFrame(parsedJson) + if (!parsed.ok) { + // Reply with the frame's id when we can salvage one so the client's + // pending request rejects instead of timing out. + const id = + typeof (parsedJson as { id?: unknown })?.id === 'string' + ? (parsedJson as { id: string }).id + : undefined + this.send(ws, { type: 'reply', id, ok: false, error: parsed.reason }) + return + } + const frame = parsed.frame + + // Per-message verification: revocation must bite mid-session, not just + // at the next reconnect. + const verdict = this.deps.pairing.verifyToken(frame.token) + if (!verdict.ok) { + this.send(ws, { type: 'reply', id: frame.id, ok: false, error: `auth: ${verdict.reason}` }) + return + } + void this.deps.registry.touch(verdict.deviceId) + + const result = await this.apply(frame) + this.send(ws, { type: 'reply', id: frame.id, ...result }) + } + + private async apply(frame: InboundFrame): Promise<{ ok: boolean; error?: string; result?: unknown }> { + const msg = frame.message + switch (msg.type) { + case 'ping': + return { ok: true, result: 'pong' } + + case 'send-prompt': { + // API-transport providers (opencode) have no PTY to receive bytes; + // everything else gets the same bracketed-paste discipline the + // desktop composer/dictation paths use. + if (this.deps.manager.getSessionKind(msg.sessionId) === 'opencode') { + const delivered = await this.deps.manager.deliverPromptToAgent(msg.sessionId, msg.text) + return delivered.ok ? { ok: true } : { ok: false, error: delivered.message } + } + const wrote = this.deps.manager.write( + msg.sessionId, + `${BRACKETED_PASTE_OPEN}${msg.text}${BRACKETED_PASTE_CLOSE}`, + ) + return wrote ? { ok: true } : { ok: false, error: 'session not writable' } + } + + case 'submit': { + const wrote = this.deps.manager.write(msg.sessionId, SUBMIT_BYTES) + return wrote ? { ok: true } : { ok: false, error: 'session not writable' } + } + + case 'interrupt': { + const wrote = this.deps.manager.write(msg.sessionId, INTERRUPT_BYTES) + return wrote ? { ok: true } : { ok: false, error: 'session not writable' } + } + + case 'permission-reply': + return this.applyPermissionReply(msg.sessionId, msg.action) + } + } + + private async applyPermissionReply( + sessionId: string, + action: + | { kind: 'pty'; id: string; label: string; data: string } + | ConditionCustomAction, + ): Promise<{ ok: boolean; error?: string; result?: unknown }> { + if (action.kind === 'custom') { + // Custom actions go through the provider's own resolver, which + // reparses the live terminal and fails closed — the safety property + // lives there, not here. + const result = await this.deps.manager.resolveCondition(sessionId, action) + return result.ok + ? { ok: true, result: result.state } + : { ok: false, error: `resolver: ${result.reason}` } + } + + // pty actions ARE raw keystrokes, so schema validation alone would be + // the raw-input hole v1 amputated. The guard: the exact action (id AND + // bytes) must be one the LIVE condition snapshot itself offered. The + // phone picks from the provider's menu; it never composes keystrokes. + const snapshot = this.lastConditions.get(sessionId) as + | { snapshot?: { conditions?: Record }> } } + | undefined + const conditions = snapshot?.snapshot?.conditions ?? {} + const offered = Object.values(conditions).some(record => + (record.actions ?? []).some( + a => a.kind === 'pty' && a.id === action.id && a.data === action.data, + ), + ) + if (!offered) { + return { ok: false, error: 'action does not match any live condition' } + } + const wrote = this.deps.manager.write(sessionId, action.data) + return wrote ? { ok: true } : { ok: false, error: 'session not writable' } + } + + // ---------- fan-out plumbing ---------- + + private cacheForLateJoiners(channel: FeedChannel, payload: unknown): void { + const sessionId = (payload as { sessionId?: string })?.sessionId + if (!sessionId) return + if (channel === 'screen') this.lastScreen.set(sessionId, payload) + else if (channel === 'conditions') this.lastConditions.set(sessionId, payload) + else if (channel === 'process-state') this.lastProcessState.set(sessionId, payload) + else if (channel === 'exit') { + // A dead session's stale screen must not greet the next connection as + // if it were live; the exit event itself still broadcast normally. + this.lastScreen.delete(sessionId) + this.lastConditions.delete(sessionId) + this.lastProcessState.delete(sessionId) + } + } + + private broadcast(frame: OutboundFrame): void { + if (this.sockets.size === 0) return + // Serialize once per frame, not per socket — screen frames at TUI redraw + // rate times N phones would otherwise multiply stringify cost. + const encoded = JSON.stringify(frame) + for (const client of this.sockets) { + if (client.ws.readyState === client.ws.OPEN) client.ws.send(encoded) + } + } + + private send(ws: WebSocket, frame: OutboundFrame): void { + if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(frame)) + } +} + +async function readBodyCapped(req: IncomingMessage, limit: number): Promise { + return new Promise((resolve, reject) => { + let size = 0 + const chunks: Buffer[] = [] + req.on('data', (chunk: Buffer) => { + size += chunk.length + if (size > limit) { + // Unauthenticated endpoint — cap the body so a hostile peer can't + // balloon memory before the code check even runs. + req.destroy() + reject(new Error('body too large')) + return + } + chunks.push(chunk) + }) + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + req.on('error', reject) + }) +} From 65b569c2a3da3159b32181207d9fb8dbfecbdad0 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 6 Jul 2026 22:01:28 +0200 Subject: [PATCH 11/15] =?UTF-8?q?feat(remote):=20desktop=20control=20surfa?= =?UTF-8?q?ce=20=E2=80=94=20RemoteController,=20remote:*=20IPC,=20RemotePa?= =?UTF-8?q?nel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RemoteController owns the enable → pair → revoke → disable lifecycle: durable state (secret, registry) survives toggles, live state (feed tap, server) is torn down on disable so the subsystem costs zero when off. remote:* IPC handlers are one-liners onto it (sanctioned boundary hole), with a status push channel mirroring the caffeinate pattern. RemotePanel (command palette → 'Remote Control') renders the pairing QR locally via the qrcode package — the one-time code rides the URL fragment so it never appears in HTTP logs. Wired into main lifecycle: constructed default-off, disposed on quit. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 227 ++++++++++++++++- package.json | 2 + src/main/index.ts | 15 ++ src/main/ipc/index.ts | 4 + src/main/ipc/remote.ts | 32 +++ .../RemoteController.integration.test.ts | 109 ++++++++ src/main/remote/RemoteController.ts | 173 +++++++++++++ src/main/remote/RemoteServer.ts | 12 +- src/preload/api/index.ts | 2 + src/preload/api/remote.ts | 43 ++++ src/renderer/src/app-state/types.ts | 1 + src/renderer/src/app-state/uiShell/slice.ts | 7 + src/renderer/src/app-state/uiShell/types.ts | 1 + src/renderer/src/app/App.tsx | 6 + .../src/features/command-palette/registry.ts | 2 + .../src/features/command-palette/types.ts | 1 + .../command-palette/ui/CommandPalette.tsx | 4 + .../remote/commands/remoteCommands.ts | 13 + .../src/features/remote/ui/RemotePanel.tsx | 234 ++++++++++++++++++ 19 files changed, 874 insertions(+), 14 deletions(-) create mode 100644 src/main/ipc/remote.ts create mode 100644 src/main/remote/RemoteController.integration.test.ts create mode 100644 src/main/remote/RemoteController.ts create mode 100644 src/preload/api/remote.ts create mode 100644 src/renderer/src/features/remote/commands/remoteCommands.ts create mode 100644 src/renderer/src/features/remote/ui/RemotePanel.tsx diff --git a/package-lock.json b/package-lock.json index 73744bae6..f23ec03f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@opentelemetry/resources": "^2.7.0", "@opentelemetry/sdk-trace-base": "^2.7.0", "@opentelemetry/sdk-trace-node": "^2.7.0", + "@types/qrcode": "1.5.6", "@xterm/addon-fit": "^0.11.0", "@xterm/headless": "^5.5.0", "@xterm/xterm": "^6.0.0", @@ -27,6 +28,7 @@ "node-pty": "^1.0.0", "pidtree": "^0.6.0", "pidusage": "^4.0.1", + "qrcode": "1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -2842,7 +2844,6 @@ "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2873,6 +2874,15 @@ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", @@ -3266,7 +3276,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3276,7 +3285,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4083,6 +4091,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001787", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", @@ -4325,7 +4342,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4338,7 +4354,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -4527,6 +4542,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -4689,6 +4713,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -5043,7 +5073,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -5529,6 +5558,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -5637,7 +5679,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -6278,7 +6319,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6800,6 +6840,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -8487,6 +8539,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-map": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", @@ -8503,6 +8582,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -8537,6 +8625,15 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -8663,6 +8760,15 @@ "node": ">=10.4.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -8849,6 +8955,89 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -9144,7 +9333,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9159,6 +9347,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -9578,6 +9772,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -9870,7 +10070,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -9899,7 +10098,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -10820,7 +11018,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unified": { @@ -11410,6 +11607,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index e3d531798..d86cc577a 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@opentelemetry/resources": "^2.7.0", "@opentelemetry/sdk-trace-base": "^2.7.0", "@opentelemetry/sdk-trace-node": "^2.7.0", + "@types/qrcode": "1.5.6", "@xterm/addon-fit": "^0.11.0", "@xterm/headless": "^5.5.0", "@xterm/xterm": "^6.0.0", @@ -59,6 +60,7 @@ "node-pty": "^1.0.0", "pidtree": "^0.6.0", "pidusage": "^4.0.1", + "qrcode": "1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", diff --git a/src/main/index.ts b/src/main/index.ts index 05b75a501..99fb06d46 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -41,6 +41,7 @@ import { WorktreeActivityIndex } from '@main/worktreeActivity/WorktreeActivityIn import { BuiltInMcpHttpHost } from '@mcp/runtime/BuiltInMcpHttpHost.js' import { OrchestrationBridge } from '@main/orchestration/OrchestrationBridge.js' import { AiWorkspaceRegistry } from '@main/aiWorkspace/AiWorkspaceRegistry.js' +import { RemoteController } from '@main/remote/RemoteController.js' import { CaffeinateController } from '@main/caffeinate/CaffeinateController.js' import { buildAppMenu } from '@main/menu/appMenu.js' import { AppRunJournal } from '@main/incident/AppRunJournal.js' @@ -102,6 +103,7 @@ const caffeinateController = new CaffeinateController() // load-bearing: every other module-scope reference is inside // callbacks that fire after the assignment. let manager: SessionManager | null = null +let remoteController: RemoteController | null = null let tmuxRegistry: TmuxRegistry | null = null let stateProcessLock: Extract | null = null let appRunJournal: AppRunJournal | null = null @@ -456,6 +458,16 @@ async function startApp(): Promise { throw err } manager = new SessionManager(tmuxAvailable ? tmuxRegistry : null, builtInMcpHost, appRunJournal) + // Remote mobile companion — constructed here (the isolation boundary's ONE + // construction hole; see docs/superpowers/specs/2026-07-06-remote-mobile- + // companion-design.md) but OFF until the user enables it from the Remote + // panel: construction allocates no sockets, no manager subscriptions, no + // secret I/O. clientDistDir stays null until the phone client bundle ships + // in the packaged app; the server serves a placeholder page meanwhile. + remoteController = new RemoteController({ + manager, + journal: appRunJournal, + }) builtInMcpHost.setDependencies({ orchestrationBridge, aiWorkspaceRegistry, @@ -478,6 +490,7 @@ async function startApp(): Promise { wireSessionForwarder(manager, lspManager) registerAllIpc({ manager, + remoteController, lspManager, ghostJournals, dictationDebugJournals, @@ -509,6 +522,7 @@ async function startApp(): Promise { app.on('window-all-closed', () => { void manager?.killAll() void builtInMcpHost.stop() + void remoteController?.dispose() void lspManager.dispose() // WHY we release caffeinate here even though macOS keeps the app process // alive after the last window closes: @@ -525,6 +539,7 @@ app.on('before-quit', () => { performanceService.mark('app.main.beforeQuit') void manager?.killAll() void builtInMcpHost.stop() + void remoteController?.dispose() void lspManager.dispose() caffeinateController.dispose() cleanupDictationIpcResources() diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 768f397c4..a4e4d02c5 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -26,9 +26,11 @@ import { registerOrchestrationIpc } from '@main/ipc/orchestration.js' import { registerAiWorkspaceIpc } from '@main/ipc/aiWorkspace.js' import { registerRenderedContentIpc } from '@main/ipc/renderedContent.js' import { registerCaffeinateIpc } from '@main/ipc/caffeinate.js' +import { registerRemoteIpc } from '@main/ipc/remote.js' import type { OrchestrationBridge } from '@main/orchestration/OrchestrationBridge.js' import type { AiWorkspaceRegistry } from '@main/aiWorkspace/AiWorkspaceRegistry.js' import type { CaffeinateController } from '@main/caffeinate/CaffeinateController.js' +import type { RemoteController } from '@main/remote/RemoteController.js' import type { AppRunJournal } from '@main/incident/AppRunJournal.js' import { registerIncidentIpc } from '@main/ipc/incident.js' @@ -51,6 +53,7 @@ export type IpcDeps = { orchestrationBridge: OrchestrationBridge aiWorkspaceRegistry: AiWorkspaceRegistry caffeinateController: CaffeinateController + remoteController: RemoteController appRunJournal: AppRunJournal } @@ -76,5 +79,6 @@ export function registerAllIpc(deps: IpcDeps): void { registerAiWorkspaceIpc(deps.aiWorkspaceRegistry) registerRenderedContentIpc() registerCaffeinateIpc(deps.caffeinateController) + registerRemoteIpc(deps.remoteController) registerIncidentIpc(deps.appRunJournal) } diff --git a/src/main/ipc/remote.ts b/src/main/ipc/remote.ts new file mode 100644 index 000000000..cb6ea2ce8 --- /dev/null +++ b/src/main/ipc/remote.ts @@ -0,0 +1,32 @@ +import { ipcMain } from 'electron' + +import type { RemoteController } from '@main/remote/RemoteController.js' +import { sendToMainWindow } from '@main/window/mainWindow.js' + +// remote:* IPC — the desktop's control channel for the remote mobile +// companion (spec: docs/superpowers/specs/2026-07-06-remote-mobile- +// companion-design.md). This file is one of the sanctioned holes in the +// remote isolation wall: core registers these handlers, but every one is a +// one-liner onto RemoteController — no remote logic leaks into core. +// +// WHY there is no `remote:send-prompt` here: the desktop drives sessions +// through its own SessionFeed (IPC transport); this surface only manages +// the SERVER — enable/disable, pairing, devices. Phones talk to +// RemoteServer over WebSocket, never through these handlers. + +export function registerRemoteIpc(controller: RemoteController): void { + ipcMain.handle('remote:get-status', () => controller.getStatus()) + ipcMain.handle('remote:enable', () => controller.enable()) + ipcMain.handle('remote:disable', () => controller.disable()) + ipcMain.handle('remote:issue-pairing-code', () => controller.issuePairingCode()) + ipcMain.handle('remote:revoke-device', (_evt, deviceId: string) => + controller.revokeDevice(deviceId), + ) + + // Push status transitions (enable/disable, device paired/revoked, phone + // connected/disconnected) so RemotePanel live-updates without polling — + // same push pattern caffeinate uses. + controller.on('status-changed', status => { + sendToMainWindow('remote:status-changed', status) + }) +} diff --git a/src/main/remote/RemoteController.integration.test.ts b/src/main/remote/RemoteController.integration.test.ts new file mode 100644 index 000000000..6bf920380 --- /dev/null +++ b/src/main/remote/RemoteController.integration.test.ts @@ -0,0 +1,109 @@ +import { EventEmitter } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { RemoteController } from './RemoteController.js' +import type { RemoteSessionControl } from './RemoteServer.js' + +// RemoteController is what the desktop actually drives (via remote:* IPC): +// one object owning the whole enable → pair → revoke → disable lifecycle. +// These tests run the real stack underneath (secret file, registry file, +// HTTP server on port 0) — only SessionManager is faked, same as the +// RemoteServer suite. + +type FakeManager = RemoteSessionControl & EventEmitter + +function makeManager(): FakeManager { + const emitter = new EventEmitter() as FakeManager + emitter.write = vi.fn(() => true) + emitter.resolveCondition = vi.fn(async () => ({ ok: true as const })) + emitter.deliverPromptToAgent = vi.fn(async () => ({ ok: true as const })) + emitter.getSessionKind = vi.fn(() => 'claude' as const) + return emitter +} + +let dir: string +let controller: RemoteController + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'remote-controller-')) + controller = new RemoteController({ + manager: makeManager() as never, + stateDir: dir, + }) +}) + +afterEach(async () => { + await controller.dispose() + await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 25 }) +}) + +describe('RemoteController lifecycle', () => { + it('starts disabled with no URL', () => { + expect(controller.getStatus()).toMatchObject({ enabled: false, url: null }) + }) + + it('enable exposes a URL; disable clears it; both are idempotent', async () => { + const enabled = await controller.enable() + expect(enabled.enabled).toBe(true) + expect(enabled.url).toMatch(/^http:\/\/[\d.]+:\d+$/) + // Second enable is a no-op returning the same URL, not a second server. + expect((await controller.enable()).url).toBe(enabled.url) + + const disabled = await controller.disable() + expect(disabled).toMatchObject({ enabled: false, url: null }) + await controller.disable() // idempotent + }) + + it('emits status-changed on transitions', async () => { + const seen: Array<{ enabled: boolean }> = [] + controller.on('status-changed', status => seen.push({ enabled: status.enabled })) + await controller.enable() + await controller.disable() + expect(seen).toEqual([{ enabled: true }, { enabled: false }]) + }) +}) + +describe('pairing through the controller', () => { + it('issues a pair URL a phone can redeem, then lists and revokes the device', async () => { + await controller.enable() + const issued = controller.issuePairingCode() + expect(issued.pairUrl).toContain(`#code=${issued.code}`) + + // Simulate the phone: extract the base URL, POST the code. + const base = issued.pairUrl.split('/#')[0].replace(/\/\/[\d.]+:/, '//127.0.0.1:') + const res = await fetch(`${base}/pair`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ code: issued.code, deviceName: 'test phone' }), + }) + expect(res.status).toBe(200) + const { deviceId } = (await res.json()) as { deviceId: string } + + const devices = controller.getStatus().devices + expect(devices.map(d => d.name)).toEqual(['test phone']) + + expect(await controller.revokeDevice(deviceId)).toBe(true) + expect(controller.getStatus().devices[0]?.revoked).toBe(true) + }) + + it('refuses to issue a pairing code while disabled', () => { + expect(() => controller.issuePairingCode()).toThrowError(/not enabled/) + }) + + it('remembers paired devices across enable cycles (registry is durable)', async () => { + await controller.enable() + const issued = controller.issuePairingCode() + const base = issued.pairUrl.split('/#')[0].replace(/\/\/[\d.]+:/, '//127.0.0.1:') + await fetch(`${base}/pair`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ code: issued.code, deviceName: 'durable phone' }), + }) + await controller.disable() + await controller.enable() + expect(controller.getStatus().devices.map(d => d.name)).toEqual(['durable phone']) + }) +}) diff --git a/src/main/remote/RemoteController.ts b/src/main/remote/RemoteController.ts new file mode 100644 index 000000000..87e31b22a --- /dev/null +++ b/src/main/remote/RemoteController.ts @@ -0,0 +1,173 @@ +import { EventEmitter } from 'node:events' +import { join } from 'node:path' + +import type { AppRunJournal } from '@main/incident/AppRunJournal.js' +import type { SessionManager } from '@main/sessionManager.js' + +import { DevicePairing } from '@main/remote/auth/DevicePairing.js' +import { DeviceRegistry, type PairedDevice } from '@main/remote/auth/deviceRegistry.js' +import { loadOrCreateRemoteSecret, REMOTE_STATE_DIR } from '@main/remote/auth/secret.js' +import { RemoteServer } from '@main/remote/RemoteServer.js' +import { SessionFeedSource } from '@main/remote/SessionFeedSource.js' +import { LanTransport } from '@main/remote/transport/LanTransport.js' +import type { RemoteTransport } from '@main/remote/transport/RemoteTransport.js' + +// RemoteController — the one object the desktop drives. +// +// The remote subsystem has real moving parts (secret, registry, pairing, +// feed tap, HTTP+WS server, transport), but the desktop's mental model is +// a light switch plus a device list. This controller owns that translation: +// remote:* IPC handlers are one-liners onto it, and the whole subsystem is +// constructed HERE, not in main/index.ts — index.ts gets exactly one +// `new RemoteController(...)` line (the isolation boundary's construction +// hole) and never sees the internals. +// +// Lifecycle rules: +// - Everything durable (secret, registry) lazy-loads on first enable() +// and persists across disable/enable cycles — pairing survives toggling +// the server off overnight. +// - Everything live (feed tap, server, transport) is created on enable() +// and torn down on disable() — a disabled remote subsystem holds NO +// manager subscriptions and NO sockets, so its steady-state cost when +// off is zero. That property is why this is safe to ship default-off. + +export type RemoteStatus = { + enabled: boolean + url: string | null + devices: PairedDevice[] + connectedDeviceIds: string[] +} + +export type RemoteControllerDeps = { + manager: SessionManager + journal?: AppRunJournal | null + /** Override the durable-state root (secret, devices.json). Tests point + * this at a tmpdir; production uses REMOTE_STATE_DIR. */ + stateDir?: string + /** Built remote-client bundle dir; resolved by main at construction. */ + clientDistDir?: string | null + /** Transport factory — phase 2 swaps in the cloudflared tunnel here. */ + createTransport?: () => RemoteTransport +} + +export class RemoteController extends EventEmitter { + private readonly stateDir: string + private registry: DeviceRegistry | null = null + private pairing: DevicePairing | null = null + private feedSource: SessionFeedSource | null = null + private server: RemoteServer | null = null + private url: string | null = null + private enabling: Promise | null = null + + constructor(private readonly deps: RemoteControllerDeps) { + super() + this.stateDir = deps.stateDir ?? REMOTE_STATE_DIR + } + + getStatus(): RemoteStatus { + return { + enabled: this.server !== null && this.url !== null, + url: this.url, + devices: this.registry?.list() ?? [], + connectedDeviceIds: this.server?.connectedDeviceIds() ?? [], + } + } + + async enable(): Promise { + // Coalesce concurrent enables (double-click on the toggle): the second + // caller awaits the first's work instead of racing a second server. + if (this.enabling) return this.enabling + if (this.server && this.url) return this.getStatus() + + this.enabling = this.doEnable().finally(() => { + this.enabling = null + }) + return this.enabling + } + + private async doEnable(): Promise { + const secret = await loadOrCreateRemoteSecret(this.stateDir) + if (!this.registry) { + this.registry = new DeviceRegistry(join(this.stateDir, 'devices.json')) + await this.registry.load() + } + this.pairing = new DevicePairing({ secret, registry: this.registry }) + this.feedSource = new SessionFeedSource(this.deps.manager) + this.server = new RemoteServer({ + manager: this.deps.manager, + feedSource: this.feedSource, + pairing: this.pairing, + registry: this.registry, + transport: this.deps.createTransport?.() ?? new LanTransport(), + clientDistDir: this.deps.clientDistDir ?? null, + journal: this.deps.journal ?? null, + }) + this.server.on('clients-changed', () => this.emitStatus()) + + try { + const { url } = await this.server.start() + this.url = url + } catch (err) { + // start() already journaled the incident; leave the controller in a + // cleanly-disabled state so the toggle can be retried. + await this.teardownLive() + throw err + } + const status = this.getStatus() + this.emit('status-changed', status) + return status + } + + async disable(): Promise { + if (!this.server) return this.getStatus() + await this.teardownLive() + const status = this.getStatus() + this.emit('status-changed', status) + return status + } + + /** + * Mint a fresh one-time pairing code and the URL to encode in the QR. + * The code rides the URL fragment (`#code=…`) so it never appears in + * server request logs — fragments are not sent over HTTP; the client JS + * reads it locally and POSTs it to /pair itself. + */ + issuePairingCode(): { code: string; expiresAt: number; pairUrl: string } { + if (!this.pairing || !this.url) { + throw new Error('remote server is not enabled') + } + const { code, expiresAt } = this.pairing.issuePairingCode() + return { code, expiresAt, pairUrl: `${this.url}/#code=${code}` } + } + + async revokeDevice(deviceId: string): Promise { + if (!this.registry) { + // Revocation must work even before the first enable of this app run — + // the registry is durable state, not live state. + this.registry = new DeviceRegistry(join(this.stateDir, 'devices.json')) + await this.registry.load() + } + const revoked = await this.registry.revoke(deviceId) + if (revoked) this.emitStatus() + return revoked + } + + async dispose(): Promise { + await this.teardownLive() + this.removeAllListeners() + } + + private async teardownLive(): Promise { + const server = this.server + this.server = null + this.url = null + this.pairing = null + if (server) await server.stop() + this.feedSource?.dispose() + this.feedSource = null + } + + private emitStatus(): void { + this.emit('status-changed', this.getStatus()) + } +} diff --git a/src/main/remote/RemoteServer.ts b/src/main/remote/RemoteServer.ts index 1f7a61fa7..578bbff5b 100644 --- a/src/main/remote/RemoteServer.ts +++ b/src/main/remote/RemoteServer.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { createReadStream, existsSync } from 'node:fs' import { extname, join, normalize } from 'node:path' @@ -98,7 +99,10 @@ type ClientSocket = { deviceId: string } -export class RemoteServer { +/** Events: 'clients-changed' — fired on every WS connect/disconnect so the + * desktop Remote panel can live-update its "N devices connected" line + * without polling. */ +export class RemoteServer extends EventEmitter { private server: Server | null = null private wss: WebSocketServer | null = null private url: string | null = null @@ -116,7 +120,9 @@ export class RemoteServer { private readonly lastConditions = new Map() private readonly lastProcessState = new Map() - constructor(private readonly deps: RemoteServerDeps) {} + constructor(private readonly deps: RemoteServerDeps) { + super() + } async start(): Promise<{ url: string }> { if (this.server && this.url) return { url: this.url } @@ -323,6 +329,7 @@ export class RemoteServer { private onConnection(ws: WebSocket, deviceId: string): void { const client: ClientSocket = { ws, deviceId } this.sockets.add(client) + this.emit('clients-changed') void this.deps.registry.touch(deviceId) this.deps.journal?.record({ area: 'remote.server', @@ -350,6 +357,7 @@ export class RemoteServer { }) ws.on('close', () => { this.sockets.delete(client) + this.emit('clients-changed') this.deps.journal?.record({ area: 'remote.server', name: 'remote_ws.disconnected', diff --git a/src/preload/api/index.ts b/src/preload/api/index.ts index cdf955dc9..378d92aed 100644 --- a/src/preload/api/index.ts +++ b/src/preload/api/index.ts @@ -21,6 +21,7 @@ import { renderedContentApi } from '@preload/api/renderedContent.js' import { caffeinateApi } from '@preload/api/caffeinate.js' import { menuApi } from '@preload/api/menu.js' import { incidentApi } from '@preload/api/incident.js' +import { remoteApi } from '@preload/api/remote.js' // Composed preload API surface. // @@ -61,6 +62,7 @@ export const api = { ...caffeinateApi, ...menuApi, ...incidentApi, + ...remoteApi, } export type Api = typeof api diff --git a/src/preload/api/remote.ts b/src/preload/api/remote.ts new file mode 100644 index 000000000..9bedcb053 --- /dev/null +++ b/src/preload/api/remote.ts @@ -0,0 +1,43 @@ +import { ipcRenderer } from 'electron' + +import { subscribe } from '@preload/api/ipc.js' +import type { Unsub } from '@preload/api/types.js' + +// Preload bridge for the remote mobile companion's desktop control surface. +// Mirrors main/ipc/remote.ts one-to-one. These types are duplicated from +// RemoteController rather than imported because preload must not pull main- +// process modules into its bundle; the IPC boundary is where the types are +// re-declared, same as every other domain module here. + +export type RemotePairedDevice = { + deviceId: string + name: string + createdAt: number + lastSeenAt: number | null + revoked: boolean +} + +export type RemoteStatus = { + enabled: boolean + url: string | null + devices: RemotePairedDevice[] + connectedDeviceIds: string[] +} + +export type RemotePairingIssue = { + code: string + expiresAt: number + pairUrl: string +} + +export const remoteApi = { + remoteGetStatus: (): Promise => ipcRenderer.invoke('remote:get-status'), + remoteEnable: (): Promise => ipcRenderer.invoke('remote:enable'), + remoteDisable: (): Promise => ipcRenderer.invoke('remote:disable'), + remoteIssuePairingCode: (): Promise => + ipcRenderer.invoke('remote:issue-pairing-code'), + remoteRevokeDevice: (deviceId: string): Promise => + ipcRenderer.invoke('remote:revoke-device', deviceId), + onRemoteStatusChanged: (cb: (status: RemoteStatus) => void): Unsub => + subscribe('remote:status-changed', cb), +} diff --git a/src/renderer/src/app-state/types.ts b/src/renderer/src/app-state/types.ts index f8ec6d957..924e54e7e 100644 --- a/src/renderer/src/app-state/types.ts +++ b/src/renderer/src/app-state/types.ts @@ -63,6 +63,7 @@ export type UiShellSlice = UiShellState & { closeAgentStatusPanel: () => void toggleAgentStatusPanel: () => void togglePerformancePanel: () => void + toggleRemotePanel: () => void openGlobalEditor: () => void closeGlobalEditor: () => void toggleGlobalEditor: () => void diff --git a/src/renderer/src/app-state/uiShell/slice.ts b/src/renderer/src/app-state/uiShell/slice.ts index 27960183f..476dd9e9b 100644 --- a/src/renderer/src/app-state/uiShell/slice.ts +++ b/src/renderer/src/app-state/uiShell/slice.ts @@ -32,6 +32,7 @@ export const createUiShellSlice: StateCreator< devDebugPanelOpen: false, agentStatusPanelOpen: false, performancePanelOpen: false, + remotePanelOpen: false, globalEditorOpen: false, promptSearchOpen: false, agentActivityOpen: false, @@ -168,6 +169,12 @@ export const createUiShellSlice: StateCreator< false, 'uiShell/togglePerformancePanel', ), + toggleRemotePanel: () => + set( + state => ({ remotePanelOpen: !state.remotePanelOpen }), + false, + 'uiShell/toggleRemotePanel', + ), openGlobalEditor: () => set({ globalEditorOpen: true }, false, 'uiShell/openGlobalEditor'), diff --git a/src/renderer/src/app-state/uiShell/types.ts b/src/renderer/src/app-state/uiShell/types.ts index 64b259e45..7a44d408a 100644 --- a/src/renderer/src/app-state/uiShell/types.ts +++ b/src/renderer/src/app-state/uiShell/types.ts @@ -110,6 +110,7 @@ export type UiShellState = { * persisting an extra "inspected session" source of truth. */ agentStatusPanelOpen: boolean performancePanelOpen: boolean + remotePanelOpen: boolean /** When true, the Global Editor overlay is mounted. Splits the * workspace area: left half is a file tree + Monaco editor rooted * at the focused agent's cwd, right half is the entire normal diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index 641003f42..009c54fc9 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -33,6 +33,7 @@ import { PathPickerModal } from '@renderer/features/path-picker/ui/PathPickerMod import { VoiceDictationOverlay } from '@renderer/features/voice-dictation/ui/VoiceDictationOverlay' import { getEffectiveAgentSurface } from '@renderer/workspace/agentDisplayMode' import { PerformancePanel } from '@renderer/features/performance/ui/PerformancePanel' +import { RemotePanel } from '@renderer/features/remote/ui/RemotePanel' import { GlobalEditorShell } from '@renderer/features/global-editor/ui/GlobalEditorShell' import { useGlobalEditorStore } from '@renderer/features/global-editor/store' import { SystemPerfHeader } from '@renderer/features/system-perf/ui/SystemPerfHeader' @@ -98,6 +99,8 @@ export default function App() { const devDebugPanelOpen = useAppStore(state => state.devDebugPanelOpen) const agentStatusPanelOpen = useAppStore(state => state.agentStatusPanelOpen) const performancePanelOpen = useAppStore(state => state.performancePanelOpen) + const remotePanelOpen = useAppStore(state => state.remotePanelOpen) + const toggleRemotePanel = useAppStore(state => state.toggleRemotePanel) const globalEditorOpen = useAppStore(state => state.globalEditorOpen) const dangerousAgentsEnabled = settings.dangerousAgentsEnabled const aggressiveDebugPersistenceEnabled = settings.aggressiveDebugPersistence @@ -771,6 +774,8 @@ export default function App() { /> )} + {remotePanelOpen && } + {debugPanelOpen && commandTargetId && ( void toggleAgentStatusPanel: () => void togglePerformancePanel: () => void + toggleRemotePanel: () => void toggleCaffeinate: () => Promise | void toggleGlobalEditor: () => void /** Toggle visibility of the Global Editor's in-editor file tree. diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx index 312a1ad4e..a642b807f 100644 --- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx @@ -95,6 +95,7 @@ type Props = { closeAgentStatusPanel: () => void toggleAgentStatusPanel: () => void togglePerformancePanel: () => void + toggleRemotePanel: () => void toggleCaffeinate: () => Promise | void toggleGlobalEditor: () => void toggleFileTreeVisible: () => void @@ -168,6 +169,7 @@ export function CommandPalette({ closeAgentStatusPanel, toggleAgentStatusPanel, togglePerformancePanel, + toggleRemotePanel, toggleCaffeinate, toggleGlobalEditor, toggleFileTreeVisible, @@ -394,6 +396,7 @@ export function CommandPalette({ closeAgentStatusPanel, toggleAgentStatusPanel, togglePerformancePanel, + toggleRemotePanel, toggleCaffeinate, toggleGlobalEditor, toggleFileTreeVisible, @@ -470,6 +473,7 @@ export function CommandPalette({ closeAgentStatusPanel, toggleAgentStatusPanel, togglePerformancePanel, + toggleRemotePanel, toggleCaffeinate, toggleGlobalEditor, toggleFileTreeVisible, diff --git a/src/renderer/src/features/remote/commands/remoteCommands.ts b/src/renderer/src/features/remote/commands/remoteCommands.ts new file mode 100644 index 000000000..74ea68dda --- /dev/null +++ b/src/renderer/src/features/remote/commands/remoteCommands.ts @@ -0,0 +1,13 @@ +import type { CommandDef } from '@renderer/features/command-palette/types' + +export const remoteCommands: CommandDef[] = [ + { + id: 'toggle-remote-panel', + surface: 'app', + title: 'Remote Control', + description: + '**What it does:** Opens the **Remote Control** panel — enable the LAN server, pair your phone with a QR code, and manage paired devices.\n\n**Use when:** You want to watch agents, send prompts, or answer permission dialogs from your phone.\n\n**Notes:** Off by default. Phones can never run shell commands or start/stop sessions.', + keywords: ['remote', 'phone', 'mobile', 'qr', 'pair', 'companion', 'lan'], + run: ({ ui }) => ui.toggleRemotePanel(), + }, +] diff --git a/src/renderer/src/features/remote/ui/RemotePanel.tsx b/src/renderer/src/features/remote/ui/RemotePanel.tsx new file mode 100644 index 000000000..6e855974c --- /dev/null +++ b/src/renderer/src/features/remote/ui/RemotePanel.tsx @@ -0,0 +1,234 @@ +import { useCallback, useEffect, useState } from 'react' +import QRCode from 'qrcode' + +import type { + RemotePairingIssue, + RemoteStatus, +} from '@preload/api/remote' + +// Desktop control panel for the remote mobile companion: enable/disable the +// server, pair a phone via QR, list + revoke devices. +// +// WHY this talks to window.api directly instead of the SessionFeed: the +// panel manages the SERVER (desktop-only infrastructure), not session I/O. +// SessionFeed is the session-data contract shared with the phone; the phone +// must never manage the server it is connected through, so putting these +// calls on the feed would widen the shared contract with methods one of its +// two implementations must stub out. Direct preload use is the correct +// altitude — same as every other desktop-only surface (git bar, LSP, etc). +// +// WHY the QR encodes `${url}/#code=…`: one scan does everything — the phone +// opens the client page AND the client reads the one-time code from the +// fragment to pair itself. Fragments never appear in HTTP request logs, so +// the code doesn't leak into anything that persists. + +type PairingView = RemotePairingIssue & { qrDataUrl: string } + +export function RemotePanel({ onClose }: { onClose: () => void }): React.JSX.Element { + const [status, setStatus] = useState(null) + const [pairing, setPairing] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let disposed = false + void window.api.remoteGetStatus().then(s => { + if (!disposed) setStatus(s) + }) + const off = window.api.onRemoteStatusChanged(s => setStatus(s)) + return () => { + disposed = true + off() + } + }, []) + + // Pairing codes are single-use and expire in 5 minutes; drop the QR from + // view once it can no longer work so a stale screenshot doesn't masquerade + // as a live pairing surface. + useEffect(() => { + if (!pairing) return + const remaining = pairing.expiresAt - Date.now() + if (remaining <= 0) { + setPairing(null) + return + } + const timer = window.setTimeout(() => setPairing(null), remaining) + return () => window.clearTimeout(timer) + }, [pairing]) + + const withBusy = useCallback(async (work: () => Promise) => { + setBusy(true) + setError(null) + try { + await work() + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + }, []) + + const toggleServer = useCallback(() => { + void withBusy(async () => { + if (status?.enabled) { + setPairing(null) + setStatus(await window.api.remoteDisable()) + } else { + setStatus(await window.api.remoteEnable()) + } + }) + }, [status?.enabled, withBusy]) + + const startPairing = useCallback(() => { + void withBusy(async () => { + const issued = await window.api.remoteIssuePairingCode() + // Rendered locally to a data URL — nothing leaves the machine, no CDN. + const qrDataUrl = await QRCode.toDataURL(issued.pairUrl, { + margin: 1, + width: 220, + }) + setPairing({ ...issued, qrDataUrl }) + }) + }, [withBusy]) + + const revoke = useCallback( + (deviceId: string) => { + void withBusy(async () => { + await window.api.remoteRevokeDevice(deviceId) + setStatus(await window.api.remoteGetStatus()) + }) + }, + [withBusy], + ) + + const devices = status?.devices.filter(d => !d.revoked) ?? [] + const connected = new Set(status?.connectedDeviceIds ?? []) + + return ( +
+
e.stopPropagation()} + > +
+
+
Remote Control
+
Control your agents from a phone
+
+ +
+ +
+ {/* Server toggle */} +
+
+
LAN server
+
+ {status?.enabled + ? `Serving at ${status.url}` + : 'Off — phones cannot connect'} +
+
+ +
+ + {error &&
{error}
} + + {/* Pairing */} + {status?.enabled && ( +
+
+
Pair a device
+ +
+ {pairing ? ( +
+ Pairing QR code +
{pairing.code}
+
+ Scan with your phone camera, or open{' '} + {status.url} and enter the code. + Single use · expires in 5 minutes. +
+
+ ) : ( +
+ Each code pairs one device and expires after 5 minutes. +
+ )} +
+ )} + + {/* Devices */} +
+
Paired devices
+ {devices.length === 0 ? ( +
No devices paired yet.
+ ) : ( + devices.map(device => ( +
+
+
+ {device.name} + {connected.has(device.deviceId) && ( + + connected + + )} +
+
+ paired {new Date(device.createdAt).toLocaleDateString()} + {device.lastSeenAt + ? ` · last seen ${new Date(device.lastSeenAt).toLocaleString()}` + : ''} +
+
+ +
+ )) + )} +
+ +
+ Phones can read sessions, send prompts, and answer permission dialogs. + They cannot run shell commands or start/stop sessions — those actions + do not exist in the wire protocol. +
+
+
+
+ ) +} From 6eca4d5839b62b3d71a1f9c0d252f4aa6c889e72 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 6 Jul 2026 22:11:00 +0200 Subject: [PATCH 12/15] =?UTF-8?q?feat(remote):=20phone=20web=20client=20?= =?UTF-8?q?=E2=80=94=20WebSocketSessionFeed=20+=20mobile=20UI=20+=20build?= =?UTF-8?q?=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone implements the same SessionFeed contract the desktop does, over WS: session-event frames fan out to the standard listener shape; commands become protocol frames correlated by client-minted ids; reconnect is capped-backoff and self-healing because the server replays list + cached state on every connect. sendInput translates exactly the three byte shapes the v1 scope covers (submit/interrupt/bracketed paste) and rejects raw bytes — widening that means widening the wire protocol first. UI: scan QR → auto-pair via #code fragment (scrubbed after redeem) → session list with live activity dots → session view rendering the provider's own TUI text with condition prompts as tap targets and a paste-then-submit composer. Plain-Vite build target (out/remote-client) served by RemoteServer when present; type-checked under tsconfig.web so client drift can't ship silently. Client↔server drift is pinned by a 7-case integration suite running the real feed against the real server. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 + src/main/index.ts | 10 +- src/remote-client/index.html | 18 + .../WebSocketSessionFeed.integration.test.ts | 180 +++++++++ src/remote-client/src/WebSocketSessionFeed.ts | 343 ++++++++++++++++++ src/remote-client/src/main.tsx | 19 + src/remote-client/src/pairing.ts | 89 +++++ src/remote-client/src/styles.css | 137 +++++++ src/remote-client/src/ui/App.tsx | 119 ++++++ src/remote-client/src/ui/PairScreen.tsx | 65 ++++ src/remote-client/src/ui/SessionList.tsx | 81 +++++ src/remote-client/src/ui/SessionView.tsx | 205 +++++++++++ src/remote-client/src/wire.ts | 56 +++ src/remote-client/vite.config.ts | 33 ++ src/shared/sessionFeed/SessionFeed.ts | 4 + tsconfig.web.json | 8 + 16 files changed, 1367 insertions(+), 2 deletions(-) create mode 100644 src/remote-client/index.html create mode 100644 src/remote-client/src/WebSocketSessionFeed.integration.test.ts create mode 100644 src/remote-client/src/WebSocketSessionFeed.ts create mode 100644 src/remote-client/src/main.tsx create mode 100644 src/remote-client/src/pairing.ts create mode 100644 src/remote-client/src/styles.css create mode 100644 src/remote-client/src/ui/App.tsx create mode 100644 src/remote-client/src/ui/PairScreen.tsx create mode 100644 src/remote-client/src/ui/SessionList.tsx create mode 100644 src/remote-client/src/ui/SessionView.tsx create mode 100644 src/remote-client/src/wire.ts create mode 100644 src/remote-client/vite.config.ts diff --git a/package.json b/package.json index d86cc577a..c2a4ea47e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "scripts": { "dev": "electron-vite dev", "build:app": "electron-vite build && node scripts/copy-packaged-resources.mjs", + "client:build": "vite build --config src/remote-client/vite.config.ts", + "client:dev": "vite --config src/remote-client/vite.config.ts", "build": "npm run build:app", "pack:mac": "npm run build && electron-builder --mac --dir", "package:mac": "electron-builder --mac --publish never", diff --git a/src/main/index.ts b/src/main/index.ts index 99fb06d46..8fd04a971 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,7 +5,9 @@ import '@main/loadEnv.js' import { app, BrowserWindow, crashReporter, dialog, Menu } from 'electron' +import { existsSync } from 'fs' import { readFile } from 'fs/promises' +import { join } from 'path' import { performance } from 'perf_hooks' import { SessionManager } from '@main/sessionManager.js' @@ -462,11 +464,15 @@ async function startApp(): Promise { // construction hole; see docs/superpowers/specs/2026-07-06-remote-mobile- // companion-design.md) but OFF until the user enables it from the Remote // panel: construction allocates no sockets, no manager subscriptions, no - // secret I/O. clientDistDir stays null until the phone client bundle ships - // in the packaged app; the server serves a placeholder page meanwhile. + // secret I/O. The phone bundle comes from `npm run client:build` + // (out/remote-client, sibling of electron-vite's out/main); when it isn't + // built the server serves a placeholder page and the WS API still works, + // so a dev install degrades visibly instead of failing. + const remoteClientDist = join(app.getAppPath(), 'out', 'remote-client') remoteController = new RemoteController({ manager, journal: appRunJournal, + clientDistDir: existsSync(remoteClientDist) ? remoteClientDist : null, }) builtInMcpHost.setDependencies({ orchestrationBridge, diff --git a/src/remote-client/index.html b/src/remote-client/index.html new file mode 100644 index 000000000..5983591af --- /dev/null +++ b/src/remote-client/index.html @@ -0,0 +1,18 @@ + + + + + + + + Agent Code Remote + + +
+ + + diff --git a/src/remote-client/src/WebSocketSessionFeed.integration.test.ts b/src/remote-client/src/WebSocketSessionFeed.integration.test.ts new file mode 100644 index 000000000..09c470412 --- /dev/null +++ b/src/remote-client/src/WebSocketSessionFeed.integration.test.ts @@ -0,0 +1,180 @@ +import { EventEmitter } from 'node:events' +import { randomBytes } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { WebSocket as NodeWebSocket } from 'ws' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { DevicePairing } from '@main/remote/auth/DevicePairing.js' +import { DeviceRegistry } from '@main/remote/auth/deviceRegistry.js' +import { SessionFeedSource } from '@main/remote/SessionFeedSource.js' +import { LanTransport } from '@main/remote/transport/LanTransport.js' +import { RemoteServer, type RemoteSessionControl } from '@main/remote/RemoteServer.js' + +import { WebSocketSessionFeed, type WebSocketLike } from './WebSocketSessionFeed' + +// The drift-catcher: real WebSocketSessionFeed against real RemoteServer over +// real sockets. wire.ts re-declares the protocol types instead of importing +// the server's zod schemas, and THIS suite is what keeps the two sides +// honest — a field rename on either side fails here, not on a phone. + +type FakeManager = RemoteSessionControl & EventEmitter + +function makeManager(): FakeManager { + const emitter = new EventEmitter() as FakeManager + emitter.write = vi.fn(() => true) + emitter.resolveCondition = vi.fn(async () => ({ ok: true as const, state: { done: true } })) + emitter.deliverPromptToAgent = vi.fn(async () => ({ ok: true as const })) + emitter.getSessionKind = vi.fn(() => 'claude' as const) + return emitter +} + +let dir: string +let manager: FakeManager +let feedSource: SessionFeedSource +let server: RemoteServer +let token: string +let wsUrl: string +let feed: WebSocketSessionFeed | null = null + +function makeFeed(overrides: Partial<{ token: string }> = {}): WebSocketSessionFeed { + feed = new WebSocketSessionFeed({ + url: wsUrl, + token: overrides.token ?? token, + // Node has no global browser WebSocket with the exact event surface we + // need; the ws package's client implements addEventListener/send/close + // compatibly, which is the whole reason createSocket is injectable. + createSocket: url => new NodeWebSocket(url) as unknown as WebSocketLike, + }) + return feed +} + +function waitForOpen(f: WebSocketSessionFeed): Promise { + return new Promise((resolve, reject) => { + const deadline = setTimeout(() => reject(new Error('connect timeout')), 3000) + const off = f.onConnectionState(state => { + if (state === 'open') { + clearTimeout(deadline) + off() + resolve() + } + }) + }) +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'remote-feed-')) + manager = makeManager() + const registry = new DeviceRegistry(join(dir, 'devices.json')) + await registry.load() + const pairing = new DevicePairing({ secret: randomBytes(32), registry }) + feedSource = new SessionFeedSource(manager as never) + server = new RemoteServer({ + manager, + feedSource, + pairing, + registry, + transport: new LanTransport({ port: 0 }), + }) + const { url } = await server.start() + const base = url.replace(/\/\/[\d.]+:/, '//127.0.0.1:') + wsUrl = `${base.replace(/^http/, 'ws')}/ws` + + const { code } = pairing.issuePairingCode() + const redeemed = await pairing.redeemPairingCode(code, 'integration phone') + if (!redeemed.ok) throw new Error('pairing setup failed') + token = redeemed.token +}) + +afterEach(async () => { + feed?.dispose() + feed = null + await server.stop() + feedSource.dispose() + await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 25 }) +}) + +describe('WebSocketSessionFeed against a live RemoteServer', () => { + it('receives session events through SessionFeed listeners', async () => { + const f = makeFeed() + await waitForOpen(f) + + const screens: string[] = [] + f.onSessionScreen(e => screens.push(e.plain)) + manager.emit('screen', { + sessionId: 's1', plain: 'live from the mac', markdown: '', + recent: 'live from the mac', recentMarkdown: '', + picker: { visible: false, items: [] }, + }) + await vi.waitFor(() => expect(screens).toContain('live from the mac')) + }) + + it('tracks the session list from started/exit events', async () => { + const f = makeFeed() + await waitForOpen(f) + + manager.emit('started', { sessionId: 's1', kind: 'claude', projectDir: '/repo' }) + await vi.waitFor(() => + expect(f.getSessionList().map(s => s.sessionId)).toContain('s1'), + ) + manager.emit('exit', { sessionId: 's1', exitCode: 0 }) + await vi.waitFor(() => + expect(f.getSessionList().find(s => s.sessionId === 's1')?.alive).toBe(false), + ) + }) + + it('deliverPrompt round-trips to the manager and resolves ok', async () => { + const f = makeFeed() + await waitForOpen(f) + + const result = await f.deliverPrompt('s1', 'hello from the phone') + expect(result).toEqual({ ok: true }) + expect(manager.write).toHaveBeenCalledWith( + 's1', + '\x1b[200~hello from the phone\x1b[201~', + ) + }) + + it('sendInput translates submit/interrupt/paste and rejects raw bytes', async () => { + const f = makeFeed() + await waitForOpen(f) + + expect(await f.sendInput('s1', '\r')).toBe(true) + expect(manager.write).toHaveBeenCalledWith('s1', '\r') + + expect(await f.sendInput('s1', '\x1b')).toBe(true) + expect(manager.write).toHaveBeenCalledWith('s1', '\x1b') + + expect(await f.sendInput('s1', '\x1b[200~multi\nline\x1b[201~')).toBe(true) + expect(manager.write).toHaveBeenCalledWith('s1', '\x1b[200~multi\nline\x1b[201~') + + await expect(f.sendInput('s1', 'ls -la')).rejects.toThrow(/not part of the v1/) + }) + + it('resolveCondition round-trips a custom action', async () => { + const f = makeFeed() + await waitForOpen(f) + + const result = await f.resolveCondition('s1', { + kind: 'custom', id: 'q0', label: 'Answer', name: 'claude.auq', payload: { a: 1 }, + }) + expect(result).toEqual({ ok: true, state: { done: true } }) + }) + + it('a bad token cannot connect but keeps retrying quietly', async () => { + const f = makeFeed({ token: 'v1.bogus.1.sig' }) + const states: string[] = [] + f.onConnectionState(s => states.push(s)) + await vi.waitFor(() => expect(states).toContain('closed')) + expect(states).not.toContain('open') + }) + + it('commands fail cleanly while disconnected', async () => { + const f = makeFeed() + await waitForOpen(f) + f.dispose() + const result = await f.deliverPrompt('s1', 'too late') + expect(result.ok).toBe(false) + }) +}) diff --git a/src/remote-client/src/WebSocketSessionFeed.ts b/src/remote-client/src/WebSocketSessionFeed.ts new file mode 100644 index 000000000..90cce116d --- /dev/null +++ b/src/remote-client/src/WebSocketSessionFeed.ts @@ -0,0 +1,343 @@ +import type { SessionFeed, Unsub } from '@shared/sessionFeed/SessionFeed' +import type { + ConditionCustomAction, + ResolveConditionResult, + SessionConditionsEvent, + SessionExitEvent, + SessionJsonlEntriesEvent, + SessionJsonlErrorEvent, + SessionProcessStateEvent, + SessionScreenEvent, + SessionSemanticEvent, + SessionStartedEvent, + SessionSubAgentsEvent, +} from '@shared/sessionFeed/types' + +import type { + FeedChannel, + InboundFrame, + InboundMessage, + OutboundFrame, + RemoteSessionSummary, +} from './wire' + +// WebSocketSessionFeed — the phone's implementation of the SessionFeed +// contract (the desktop's is IpcSessionFeed). Same interface, different +// transport: session events arrive as `session-event` frames and fan out to +// the same listener shape; commands become inbound protocol frames with a +// client-minted id, correlated against the server's `reply`. +// +// WHY sendInput only accepts three byte shapes: the v1 wire protocol has NO +// raw-input message BY DESIGN (a stolen token must not be a keyboard — see +// protocol/messages.ts). But SessionFeed.sendInput exists because desktop +// components express submit/interrupt/prompt as bytes. So this transport +// translates the three byte shapes the v1 scope covers — '\r' → submit, +// '\x1b' → interrupt, a bracketed paste → send-prompt — and REJECTS anything +// else with a clear error. A desktop component that sends other raw bytes +// (e.g. arrow-key forwarding) simply isn't phone-supported yet; widening +// this translation means widening the wire protocol first, consciously. +// +// Reconnect: the socket redials with capped backoff until dispose(). On +// each (re)connect the server replays session-list + cached per-session +// state, so listeners self-heal without a client-side resync protocol. + +const BRACKETED_PASTE = /^\x1b\[200~([\s\S]*)\x1b\[201~$/ +const REQUEST_TIMEOUT_MS = 10_000 +const RECONNECT_BASE_MS = 1_000 +const RECONNECT_CAP_MS = 10_000 + +/** The subset of the WebSocket API this feed touches — injectable so the + * node integration test can pass the `ws` package's client, which + * implements the same event surface as the browser's. */ +export type WebSocketLike = { + readyState: number + send(data: string): void + close(): void + addEventListener(type: 'open' | 'close' | 'error', cb: () => void): void + addEventListener(type: 'message', cb: (event: { data: unknown }) => void): void +} + +export type ConnectionState = 'connecting' | 'open' | 'closed' + +export type WebSocketSessionFeedOptions = { + /** ws(s):// URL including the /ws path; token is appended as a query param. */ + url: string + token: string + createSocket?: (url: string) => WebSocketLike +} + +type Pending = { + resolve: (frame: { ok: boolean; error?: string; result?: unknown }) => void + timer: ReturnType +} + +export class WebSocketSessionFeed implements SessionFeed { + private readonly listeners: Record void>> = { + started: new Set(), + screen: new Set(), + 'jsonl-entries': new Set(), + 'jsonl-error': new Set(), + 'semantic-event': new Set(), + conditions: new Set(), + 'process-state': new Set(), + exit: new Set(), + // The server never emits sub-agents in v1 (SessionFeedSource doesn't tap + // it yet); the set exists so onSessionSubAgents satisfies the contract + // and starts working the moment the server adds the channel. + 'sub-agents': new Set(), + } + private readonly sessionListListeners = new Set<(s: RemoteSessionSummary[]) => void>() + private readonly connectionListeners = new Set<(s: ConnectionState) => void>() + private readonly pending = new Map() + private socket: WebSocketLike | null = null + private disposed = false + private reconnectAttempt = 0 + private nextRequestId = 1 + private lastSessionList: RemoteSessionSummary[] = [] + + constructor(private readonly opts: WebSocketSessionFeedOptions) { + this.dial() + } + + // --- client-specific surface (beyond SessionFeed) --- + + getSessionList(): RemoteSessionSummary[] { + return this.lastSessionList + } + + onSessionList(cb: (sessions: RemoteSessionSummary[]) => void): Unsub { + this.sessionListListeners.add(cb) + return () => this.sessionListListeners.delete(cb) + } + + onConnectionState(cb: (state: ConnectionState) => void): Unsub { + this.connectionListeners.add(cb) + return () => this.connectionListeners.delete(cb) + } + + dispose(): void { + this.disposed = true + for (const [, p] of this.pending) { + clearTimeout(p.timer) + p.resolve({ ok: false, error: 'feed disposed' }) + } + this.pending.clear() + this.socket?.close() + this.socket = null + } + + // --- SessionFeed listeners --- + + onSessionStarted(cb: (e: SessionStartedEvent) => void): Unsub { + return this.sub('started', cb) + } + onSessionScreen(cb: (e: SessionScreenEvent) => void): Unsub { + return this.sub('screen', cb) + } + onSessionJsonlEntries(cb: (e: SessionJsonlEntriesEvent) => void): Unsub { + return this.sub('jsonl-entries', cb) + } + onSessionJsonlError(cb: (e: SessionJsonlErrorEvent) => void): Unsub { + return this.sub('jsonl-error', cb) + } + onSessionSemanticEvent(cb: (e: SessionSemanticEvent) => void): Unsub { + return this.sub('semantic-event', cb) + } + onSessionConditions(cb: (e: SessionConditionsEvent) => void): Unsub { + return this.sub('conditions', cb) + } + onSessionProcessState(cb: (e: SessionProcessStateEvent) => void): Unsub { + return this.sub('process-state', cb) + } + onSessionSubAgents(cb: (e: SessionSubAgentsEvent) => void): Unsub { + return this.sub('sub-agents', cb) + } + onSessionExit(cb: (e: SessionExitEvent) => void): Unsub { + return this.sub('exit', cb) + } + + // --- SessionFeed commands --- + + async sendInput(sessionId: string, data: string): Promise { + let message: InboundMessage + const paste = BRACKETED_PASTE.exec(data) + if (paste) { + message = { type: 'send-prompt', sessionId, text: paste[1] } + } else if (data === '\r') { + message = { type: 'submit', sessionId } + } else if (data === '\x1b') { + message = { type: 'interrupt', sessionId } + } else { + throw new Error( + 'WebSocketSessionFeed.sendInput: raw byte input is not part of the v1 ' + + 'remote protocol (only prompt/submit/interrupt shapes translate). ' + + 'See src/main/remote/protocol/messages.ts.', + ) + } + const reply = await this.request(message) + return reply.ok + } + + async deliverPrompt( + sessionId: string, + prompt: string, + ): Promise<{ ok: true } | { ok: false; message: string }> { + const reply = await this.request({ type: 'send-prompt', sessionId, text: prompt }) + return reply.ok ? { ok: true } : { ok: false, message: reply.error ?? 'delivery failed' } + } + + async resolveCondition( + sessionId: string, + action: ConditionCustomAction, + ): Promise { + const reply = await this.request({ type: 'permission-reply', sessionId, action }) + // The server flattens the resolver's structured failure into reply.error; + // reconstruct the nearest ResolveConditionResult shape. 'aborted' is the + // most honest generic bucket for a transport-level failure. + return reply.ok + ? { ok: true, state: reply.result } + : { ok: false, reason: 'aborted', failedAtStep: reply.error } + } + + /** pty actions ride the same permission-reply message; exposed for the + * phone UI's condition buttons (desktop components use resolveCondition + * for custom actions and raw bytes for pty ones — the phone can't send + * raw bytes, so this is its explicit path). */ + async replyWithPtyAction( + sessionId: string, + action: { kind: 'pty'; id: string; label: string; data: string }, + ): Promise<{ ok: boolean; error?: string }> { + return this.request({ type: 'permission-reply', sessionId, action }) + } + + // --- internals --- + + private sub(channel: FeedChannel | 'sub-agents', cb: (e: E) => void): Unsub { + const set = this.listeners[channel] as Set<(e: E) => void> + set.add(cb) + return () => set.delete(cb) + } + + private dial(): void { + if (this.disposed) return + this.emitConnection('connecting') + const url = `${this.opts.url}?token=${encodeURIComponent(this.opts.token)}` + const factory = + this.opts.createSocket ?? + ((u: string) => new WebSocket(u) as unknown as WebSocketLike) + let socket: WebSocketLike + try { + socket = factory(url) + } catch { + this.scheduleReconnect() + return + } + this.socket = socket + + socket.addEventListener('open', () => { + this.reconnectAttempt = 0 + this.emitConnection('open') + }) + socket.addEventListener('message', event => { + this.onFrame(String(event.data)) + }) + socket.addEventListener('close', () => { + if (this.socket === socket) this.socket = null + this.emitConnection('closed') + this.scheduleReconnect() + }) + // 'error' always precedes 'close' in both implementations; reconnect is + // driven off close so errors need no separate handling. + socket.addEventListener('error', () => {}) + } + + private scheduleReconnect(): void { + if (this.disposed) return + const delay = Math.min( + RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, + RECONNECT_CAP_MS, + ) + this.reconnectAttempt += 1 + setTimeout(() => this.dial(), delay) + } + + private onFrame(raw: string): void { + let frame: OutboundFrame + try { + frame = JSON.parse(raw) as OutboundFrame + } catch { + return + } + switch (frame.type) { + case 'session-list': { + this.lastSessionList = frame.sessions + for (const cb of [...this.sessionListListeners]) cb(frame.sessions) + return + } + case 'session-event': { + const set = this.listeners[frame.channel] + if (!set) return + for (const cb of [...set]) (cb as (e: unknown) => void)(frame.payload) + // A 'started' event is also a session-list change the server doesn't + // re-send; patch the local list so the UI's list screen stays live. + if (frame.channel === 'started') { + const started = frame.payload as { sessionId: string; kind: string; projectDir?: string } + const rest = this.lastSessionList.filter(s => s.sessionId !== started.sessionId) + this.lastSessionList = [ + ...rest, + { + sessionId: started.sessionId, + kind: started.kind, + cwd: started.projectDir ?? null, + alive: true, + }, + ] + for (const cb of [...this.sessionListListeners]) cb(this.lastSessionList) + } else if (frame.channel === 'exit') { + const exited = frame.payload as { sessionId: string } + this.lastSessionList = this.lastSessionList.map(s => + s.sessionId === exited.sessionId ? { ...s, alive: false } : s, + ) + for (const cb of [...this.sessionListListeners]) cb(this.lastSessionList) + } + return + } + case 'reply': { + if (!frame.id) return + const pending = this.pending.get(frame.id) + if (!pending) return + this.pending.delete(frame.id) + clearTimeout(pending.timer) + pending.resolve({ ok: frame.ok, error: frame.error, result: frame.result }) + return + } + case 'hello': + case 'pong': + case 'error': + return + } + } + + private request( + message: InboundMessage, + ): Promise<{ ok: boolean; error?: string; result?: unknown }> { + const socket = this.socket + if (!socket || socket.readyState !== 1 /* OPEN */) { + return Promise.resolve({ ok: false, error: 'not connected' }) + } + const id = `req-${this.nextRequestId++}` + const frame: InboundFrame = { token: this.opts.token, id, message } + return new Promise(resolve => { + const timer = setTimeout(() => { + this.pending.delete(id) + resolve({ ok: false, error: 'request timed out' }) + }, REQUEST_TIMEOUT_MS) + this.pending.set(id, { resolve, timer }) + socket.send(JSON.stringify(frame)) + }) + } + + private emitConnection(state: ConnectionState): void { + for (const cb of [...this.connectionListeners]) cb(state) + } +} diff --git a/src/remote-client/src/main.tsx b/src/remote-client/src/main.tsx new file mode 100644 index 000000000..d8f37914f --- /dev/null +++ b/src/remote-client/src/main.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' + +import { App } from './ui/App' +import './styles.css' + +// Phone client entry point. Served by RemoteServer (src/main/remote/ +// RemoteServer.ts serveClient) from the bundle `npm run client:build` +// produces. No SessionFeedProvider here: the phone UI takes the feed as an +// explicit prop because it also consumes client-only surfaces (session +// list, connection state, pty replies) that the shared contract +// deliberately does not carry. When the desktop's semantic feed components +// are mounted on the phone (next slice), THAT subtree gets the provider. + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/remote-client/src/pairing.ts b/src/remote-client/src/pairing.ts new file mode 100644 index 000000000..f1aec8e81 --- /dev/null +++ b/src/remote-client/src/pairing.ts @@ -0,0 +1,89 @@ +// Pairing + token persistence for the phone client. +// +// The desktop QR encodes `${url}/#code=XXXXXXXX`. Opening it lands here: +// readPairingCodeFromHash() finds the one-time code, redeemPairingCode() +// trades it for a durable device token over POST /pair, and the token lands +// in localStorage. The hash is scrubbed immediately after a successful +// redeem so a bookmarked/reloaded URL doesn't retry a dead code (codes are +// single-use — the retry would fail and look like a broken app). +// +// localStorage over cookies ON PURPOSE: the token must ride the WS query +// string (RemoteServer checks it at upgrade), so cookie semantics buy +// nothing, and an explicit read/write keeps the auth story greppable. + +const TOKEN_KEY = 'agent-code-remote-token' +const DEVICE_NAME_KEY = 'agent-code-remote-device-name' + +export function loadToken(): string | null { + try { + return localStorage.getItem(TOKEN_KEY) + } catch { + return null + } +} + +export function saveToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token) +} + +export function clearToken(): void { + localStorage.removeItem(TOKEN_KEY) +} + +export function loadDeviceName(): string | null { + try { + return localStorage.getItem(DEVICE_NAME_KEY) + } catch { + return null + } +} + +export function readPairingCodeFromHash(): string | null { + const match = /(?:^|[#&])code=([A-Z0-9]+)/.exec(window.location.hash) + return match ? match[1] : null +} + +export function scrubPairingCodeFromHash(): void { + // replaceState (not location.hash = '') avoids adding a history entry — + // Back should leave the app, not step through pairing states. + history.replaceState(null, '', window.location.pathname) +} + +export async function redeemPairingCode( + code: string, + deviceName: string, +): Promise<{ ok: true; token: string } | { ok: false; error: string }> { + try { + const res = await fetch('/pair', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ code, deviceName }), + }) + if (!res.ok) { + return { + ok: false, + error: + res.status === 403 + ? 'Code rejected — it may have expired or been used. Generate a new one on the desktop.' + : `Pairing failed (HTTP ${res.status}).`, + } + } + const body = (await res.json()) as { token?: string } + if (!body.token) return { ok: false, error: 'Malformed pairing response.' } + saveToken(body.token) + localStorage.setItem(DEVICE_NAME_KEY, deviceName) + return { ok: true, token: body.token } + } catch { + return { ok: false, error: 'Could not reach the desktop. Same Wi-Fi?' } + } +} + +/** Best-effort human default so the desktop device list reads like an + * inventory ("iPhone", "Android") instead of "Unnamed device". */ +export function defaultDeviceName(): string { + const ua = navigator.userAgent + if (/iPhone/.test(ua)) return 'iPhone' + if (/iPad/.test(ua)) return 'iPad' + if (/Android/.test(ua)) return 'Android phone' + return 'Phone' +} diff --git a/src/remote-client/src/styles.css b/src/remote-client/src/styles.css new file mode 100644 index 000000000..c3f8e072e --- /dev/null +++ b/src/remote-client/src/styles.css @@ -0,0 +1,137 @@ +/* Phone client styles — hand-rolled, dependency-free. + * + * WHY not Tailwind: the desktop renderer's Tailwind setup is wired through + * electron-vite and the app's design-token layer; dragging that into a + * separate phone bundle couples two build pipelines for ~150 lines of CSS. + * The palette below hand-copies the desktop's dark-surface feel so the two + * screens read as one product. If the client ever grows real UI surface + * area, revisit — the crossover point is roughly "when this file stops + * fitting on one screen per component". */ + +:root { + --bg: #101014; + --surface: #17171c; + --surface-hi: #1f1f26; + --border: #2a2a33; + --ink: #e8e8ea; + --ink-dim: #9a9aa4; + --muted: #6b6b76; + --accent: #7aa2f7; + --danger: #f7768e; + --ok: #9ece6a; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body, #root { + height: 100%; + background: var(--bg); + color: var(--ink); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 15px; + /* Prevent iOS Safari double-tap zoom fighting the tap targets. */ + touch-action: manipulation; +} + +code, pre, .mono { + font-family: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, monospace; +} + +.app { display: flex; flex-direction: column; height: 100%; } + +.topbar { + display: flex; align-items: center; gap: 10px; + padding: calc(env(safe-area-inset-top, 0px) + 10px) 14px 10px; + border-bottom: 1px solid var(--border); + background: var(--surface); + flex-shrink: 0; +} +.topbar .title { font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.topbar button { background: none; border: none; color: var(--accent); font-size: 15px; padding: 6px; } + +.conn-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +.conn-dot.open { background: var(--ok); } +.conn-dot.connecting { background: #e0af68; } +.conn-dot.closed { background: var(--danger); } + +.screen { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; } + +/* --- pairing --- */ +.pair { + display: flex; flex-direction: column; align-items: center; justify-content: center; + height: 100%; gap: 14px; padding: 24px; text-align: center; +} +.pair h1 { font-size: 20px; } +.pair p { color: var(--ink-dim); max-width: 320px; } +.pair input { + background: var(--surface-hi); border: 1px solid var(--border); color: var(--ink); + padding: 12px; font-size: 18px; letter-spacing: 0.3em; text-align: center; + text-transform: uppercase; width: 240px; border-radius: 8px; +} +.pair .error { color: var(--danger); } + +/* --- session list --- */ +.session-list { display: flex; flex-direction: column; } +.session-row { + display: flex; align-items: center; gap: 12px; + padding: 14px; border-bottom: 1px solid var(--border); + background: none; border-left: none; border-right: none; border-top: none; + color: var(--ink); text-align: left; width: 100%; font-size: 15px; +} +.session-row:active { background: var(--surface-hi); } +.session-row .kind { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; + border: 1px solid var(--border); padding: 2px 6px; border-radius: 4px; + color: var(--ink-dim); flex-shrink: 0; +} +.session-row .meta { flex: 1; min-width: 0; } +.session-row .cwd { color: var(--ink-dim); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.session-row .status { font-size: 12px; flex-shrink: 0; } +.session-row .status.active { color: var(--ok); } +.session-row .status.dead { color: var(--muted); } +.empty { padding: 40px 20px; text-align: center; color: var(--ink-dim); } + +/* --- session view --- */ +.terminal { + padding: 12px 14px; white-space: pre-wrap; word-break: break-word; + font-size: 12px; line-height: 1.45; color: var(--ink); +} +.conditions { + border-top: 1px solid var(--border); background: var(--surface); + padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; + flex-shrink: 0; +} +.conditions .prompt-title { font-weight: 600; } +.conditions .actions { display: flex; flex-wrap: wrap; gap: 8px; } +.conditions .actions button { + background: var(--surface-hi); border: 1px solid var(--border); color: var(--ink); + padding: 10px 14px; border-radius: 8px; font-size: 14px; +} +.conditions .actions button:active { background: var(--border); } + +.composer { + display: flex; gap: 8px; align-items: flex-end; + padding: 10px 14px calc(env(safe-area-inset-bottom, 0px) + 10px); + border-top: 1px solid var(--border); background: var(--surface); + flex-shrink: 0; +} +.composer textarea { + flex: 1; background: var(--surface-hi); border: 1px solid var(--border); + color: var(--ink); border-radius: 10px; padding: 10px 12px; + /* 16px floor: anything smaller makes iOS Safari zoom the page on focus. */ + font-size: 16px; + resize: none; max-height: 120px; min-height: 42px; +} +.composer button { + background: var(--accent); color: #0c0c10; border: none; border-radius: 10px; + padding: 10px 16px; font-size: 15px; font-weight: 600; flex-shrink: 0; +} +.composer button:disabled { opacity: 0.4; } +.composer .stop { + background: none; border: 1px solid var(--danger); color: var(--danger); font-weight: 500; +} + +.working { + padding: 6px 14px; font-size: 12px; color: var(--accent); + border-top: 1px solid var(--border); flex-shrink: 0; +} diff --git a/src/remote-client/src/ui/App.tsx b/src/remote-client/src/ui/App.tsx new file mode 100644 index 000000000..c1d947867 --- /dev/null +++ b/src/remote-client/src/ui/App.tsx @@ -0,0 +1,119 @@ +import { useEffect, useMemo, useState } from 'react' + +import { + WebSocketSessionFeed, + type ConnectionState, +} from '../WebSocketSessionFeed' +import { + clearToken, + defaultDeviceName, + loadToken, + readPairingCodeFromHash, + redeemPairingCode, + scrubPairingCodeFromHash, +} from '../pairing' +import { PairScreen } from './PairScreen' +import { SessionList } from './SessionList' +import { SessionView } from './SessionView' + +// Phone app shell. Three states, one screen each: +// no token → PairScreen (QR hash auto-redeem or manual code) +// token, no selection → SessionList +// token + selection → SessionView +// +// The feed is created once per token and torn down when the token dies — +// its identity is what everything below subscribes to, so it must be as +// stable as the desktop's module-const ipcSessionFeed (see the dep-array +// WHY on useIpcSubscriptions). + +function wsUrlForPage(): string { + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + return `${proto}://${window.location.host}/ws` +} + +export function App(): React.JSX.Element { + const [token, setToken] = useState(() => loadToken()) + const [pairError, setPairError] = useState(null) + const [autoPairing, setAutoPairing] = useState(false) + const [selectedSessionId, setSelectedSessionId] = useState(null) + const [connection, setConnection] = useState('connecting') + + // QR flow: the desktop QR is `${url}/#code=…`; if we land with a code and + // no token yet, redeem it immediately — the user's only gesture is the scan. + useEffect(() => { + if (token) return + const code = readPairingCodeFromHash() + if (!code) return + setAutoPairing(true) + void redeemPairingCode(code, defaultDeviceName()).then(result => { + setAutoPairing(false) + scrubPairingCodeFromHash() + if (result.ok) { + setPairError(null) + setToken(result.token) + } else { + setPairError(result.error) + } + }) + }, [token]) + + const feed = useMemo( + () => (token ? new WebSocketSessionFeed({ url: wsUrlForPage(), token }) : null), + [token], + ) + + useEffect(() => { + if (!feed) return + const off = feed.onConnectionState(setConnection) + return () => { + off() + feed.dispose() + } + }, [feed]) + + const unpair = (): void => { + // Local unpair only — the durable revocation lives on the desktop's + // device list. Clearing the token here just makes THIS phone forget. + clearToken() + setSelectedSessionId(null) + setToken(null) + } + + if (!token || !feed) { + return ( + { + const result = await redeemPairingCode(code, defaultDeviceName()) + if (result.ok) { + setPairError(null) + setToken(result.token) + } else { + setPairError(result.error) + } + }} + /> + ) + } + + if (!selectedSessionId) { + return ( + + ) + } + + return ( + setSelectedSessionId(null)} + /> + ) +} diff --git a/src/remote-client/src/ui/PairScreen.tsx b/src/remote-client/src/ui/PairScreen.tsx new file mode 100644 index 000000000..4b8cbc070 --- /dev/null +++ b/src/remote-client/src/ui/PairScreen.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react' + +// Manual-entry fallback for pairing. The happy path never shows this long: +// scanning the desktop QR lands with #code=… and App auto-redeems. This +// screen exists for the fallback the RemotePanel copy promises — "open the +// URL and enter the code" — and for re-pairing after a revoke. + +export function PairScreen({ + busy, + error, + onSubmitCode, +}: { + busy: boolean + error: string | null + onSubmitCode: (code: string) => Promise +}): React.JSX.Element { + const [code, setCode] = useState('') + const [submitting, setSubmitting] = useState(false) + + const submit = async (): Promise => { + const trimmed = code.trim().toUpperCase() + if (!trimmed || submitting) return + setSubmitting(true) + try { + await onSubmitCode(trimmed) + } finally { + setSubmitting(false) + } + } + + return ( +
+

Agent Code Remote

+

+ On your desktop, open the command palette → Remote Control →{' '} + Show QR, then scan it — or type the 8-character code here. +

+ setCode(e.target.value)} + placeholder="CODE" + autoCapitalize="characters" + autoCorrect="off" + spellCheck={false} + maxLength={8} + onKeyDown={e => { + if (e.key === 'Enter') void submit() + }} + /> + + {error &&

{error}

} +
+ ) +} diff --git a/src/remote-client/src/ui/SessionList.tsx b/src/remote-client/src/ui/SessionList.tsx new file mode 100644 index 000000000..96b44f911 --- /dev/null +++ b/src/remote-client/src/ui/SessionList.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from 'react' + +import type { WebSocketSessionFeed, ConnectionState } from '../WebSocketSessionFeed' +import type { RemoteSessionSummary } from '../wire' + +// All sessions on the Mac, one tap from control. Activity dots come from +// process-state events (the same signal the desktop's spinner rides), so +// "which agent needs me" is answerable from the lock screen in one glance. + +export function SessionList({ + feed, + connection, + onSelect, + onUnpair, +}: { + feed: WebSocketSessionFeed + connection: ConnectionState + onSelect: (sessionId: string) => void + onUnpair: () => void +}): React.JSX.Element { + const [sessions, setSessions] = useState(feed.getSessionList()) + const [activity, setActivity] = useState>({}) + + useEffect(() => { + const offList = feed.onSessionList(setSessions) + const offProcess = feed.onSessionProcessState(e => { + setActivity(prev => + prev[e.sessionId] === e.active ? prev : { ...prev, [e.sessionId]: e.active }, + ) + }) + return () => { + offList() + offProcess() + } + }, [feed]) + + return ( +
+
+ + Agents + +
+
+ {sessions.length === 0 ? ( +
+ {connection === 'open' + ? 'No live sessions. Start an agent on the desktop and it appears here.' + : 'Connecting to the desktop…'} +
+ ) : ( +
+ {sessions.map(session => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/src/remote-client/src/ui/SessionView.tsx b/src/remote-client/src/ui/SessionView.tsx new file mode 100644 index 000000000..11f6bbc7d --- /dev/null +++ b/src/remote-client/src/ui/SessionView.tsx @@ -0,0 +1,205 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { WebSocketSessionFeed, ConnectionState } from '../WebSocketSessionFeed' + +// One session, full control: live output, condition prompts as tap targets, +// and a composer. +// +// v1 renders the provider's own TUI text (the `screen` channel's `recent` +// window) rather than mounting the desktop's semantic feed components. That +// is a deliberate first slice, not the end state: the screen text is always +// CORRECT (it is literally what the agent shows), needs zero transcript +// state, and survives any provider change. The semantic React feed — the +// product's real rendering — layers on next, once the jsonl/semantic +// channels get a history-request message; tracked in the phase-1 plan notes. +// +// Condition actions: every action offered by every live condition renders +// as a button. pty actions go through replyWithPtyAction (the server +// verifies them against the live snapshot's own menu); custom actions go +// through resolveCondition. The phone never composes keystrokes. + +type ConditionAction = + | { kind: 'pty'; id: string; label: string; data: string } + | { kind: 'custom'; id: string; label: string; name: string; payload?: unknown } + +type LiveCondition = { + conditionKind: string + actions: ConditionAction[] +} + +export function SessionView({ + feed, + connection, + sessionId, + onBack, +}: { + feed: WebSocketSessionFeed + connection: ConnectionState + sessionId: string + onBack: () => void +}): React.JSX.Element { + const [screenText, setScreenText] = useState('') + const [working, setWorking] = useState(null) + const [conditions, setConditions] = useState([]) + const [draft, setDraft] = useState('') + const [sending, setSending] = useState(false) + const [error, setError] = useState(null) + const scrollRef = useRef(null) + const pinnedRef = useRef(true) + + useEffect(() => { + const offScreen = feed.onSessionScreen(e => { + if (e.sessionId !== sessionId) return + // `recent` (viewport + ~200 rows of scrollback) over `plain`: phones + // scroll; the wider window is what makes that scroll useful. + setScreenText(e.recent) + }) + const offProcess = feed.onSessionProcessState(e => { + if (e.sessionId !== sessionId) return + setWorking(e.active ? (e.status ?? 'Working') : null) + }) + const offConditions = feed.onSessionConditions(e => { + if (e.sessionId !== sessionId) return + const snapshot = (e as unknown as { + snapshot?: { conditions?: Record } + }).snapshot + const live = Object.values(snapshot?.conditions ?? {}) + .filter(record => (record.actions ?? []).length > 0) + .map(record => ({ conditionKind: record.kind, actions: record.actions ?? [] })) + setConditions(live) + }) + return () => { + offScreen() + offProcess() + offConditions() + } + }, [feed, sessionId]) + + // Stick to the bottom while the user hasn't scrolled away — same tail + // behaviour as the desktop feed, minimal version. + useEffect(() => { + const el = scrollRef.current + if (el && pinnedRef.current) el.scrollTop = el.scrollHeight + }, [screenText]) + + const onScroll = useCallback(() => { + const el = scrollRef.current + if (!el) return + pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 60 + }, []) + + const sendPrompt = useCallback(() => { + const text = draft.trim() + if (!text || sending) return + setSending(true) + setError(null) + void feed + .deliverPrompt(sessionId, text) + .then(async result => { + if (!result.ok) { + setError(result.message) + return + } + // Prompt text lands in the provider's composer via bracketed paste; + // the explicit submit is the Enter press. Two frames on purpose — + // it mirrors the desktop's paste-then-submit discipline. + const submitted = await feed.sendInput(sessionId, '\r') + if (!submitted) { + setError('Prompt staged but submit failed — press send again.') + return + } + setDraft('') + }) + .finally(() => setSending(false)) + }, [draft, feed, sending, sessionId]) + + const interrupt = useCallback(() => { + void feed.sendInput(sessionId, '\x1b').then(ok => { + if (!ok) setError('Interrupt failed.') + }) + }, [feed, sessionId]) + + const runAction = useCallback( + (action: ConditionAction) => { + setError(null) + const done = (ok: boolean, detail?: string): void => { + if (!ok) setError(detail ?? 'Action failed — it may have expired.') + } + if (action.kind === 'pty') { + void feed.replyWithPtyAction(sessionId, action).then(r => done(r.ok, r.error)) + } else { + void feed + .resolveCondition(sessionId, action) + .then(r => done(r.ok, r.ok ? undefined : r.failedAtStep)) + } + }, + [feed, sessionId], + ) + + return ( +
+
+ + + {sessionId.slice(0, 8)} +
+ +
+
{screenText || 'Waiting for output…'}
+
+ + {working &&
● {working}
} + + {conditions.length > 0 && ( +
+ {conditions.map(condition => ( +
+
{titleFor(condition.conditionKind)}
+
+ {condition.actions.map(action => ( + + ))} +
+
+ ))} +
+ )} + + {error &&
{error}
} + +
+