# dsh-remote-bridge · Protocol v1 Host Adaptor for DeepSeek Harness (DSH)

English | [中文](README.zh.md)

<p align="center">
  <img src="https://img.shields.io/npm/v/@linxin666/dsh-remote-bridge?style=flat-square" alt="Version">
  &nbsp;
  <img src="https://img.shields.io/badge/DSH-%3E%3D0.2.2--rc.1-4c6ef5?style=flat-square&amp;labelColor=454a54" alt="DSH">
  &nbsp;
  <img src="https://img.shields.io/badge/license-Apache--2.0-blue?style=flat-square" alt="License">
</p>

The sole remote access path in this repository: native phone apps connecting to the host via protocol v1. This host-side adaptor publishes DeepSeek Harness (DSH) itself as protocol v1 over a single WebSocket surface at `/ios/v1` that native phone apps can drive directly without a browser. Browser-based remote control has retired, and its WAN/LAN ingress capabilities have migrated into this package.

The adaptor consumes the harness in-process — the Typert gateway for session work and the approval waterfall for permission requests — and mints its own one-time bootstrap token on a loopback-only route, backed by an independent public relay, Cloudflare tunnels, and LAN ingress stack.

## What it does

The package publishes one protocol and, beside it, one small browser surface. What the host half publishes is a protocol:

- **One surface.** A WebSocket at `/ios/v1`, registered as an exact upgrade route on the host webserver; frames are text, one JSON object each, at most 10 MiB (10485760 bytes).
- **A session surface.** `session.list`, `session.titles`, `session.snapshot`, `session.prompt` and `session.interrupt` cover the roster with its workspace, lineage and archive metadata, a batch of session titles, a full baseline plus a live subscription, a queued prompt with an idempotency key, and turn cancellation.
- **An agent surface.** `presets.list` forwards the host's Agent-preset roster and `session.create` forwards one creation request, so a phone instantiates an Agent from a preset the way the desktop does; neither body is interpreted here.
- **A fork surface.** `session.fork` branches one session into a new one that inherits its context; the fork point and the new session's identity are the host's, so the request and the answer cross verbatim.
- **A command surface.** `session.commands` lists a session's slash commands and skills in one directory and `session.command` runs one draft line, reporting whether the host resolved it; both catalogs are the host's own and nothing here is cached.
- **A usage surface.** `usage.session` answers one session's context window — the host's own number, only when the host broadcast one — beside an estimate of what its next request is made of, and `usage.balance` answers what the account behind one provider route still holds, read from the provider's own balance endpoint at most once a minute and only when the route really points at the official origin. `usage.stats` answers the host's own token ledger — five buckets for today and for the month plus one ascending row per local day — from the `dsh-usage` plugin's own HTTP routes over this machine's loopback origin, at most one read per 30 seconds; a host that never mounted that plugin answers `no-usage-plugin` rather than zeros. A session the host broadcast no window for may also carry `modelContextWindow`, the figure its route's model catalog declares, which is never mixed with the host's own reading.
- **A page handed to the client, and the action that comes back.** `ui_present` takes one whole self-contained HTML document and
  announces it as a `ui.request` event to the clients that declared `ui.v1`; the user's first action inside the page returns as that
  tool call's result, so a chart, a form or a list operation is a real answer instead of a question with options. `ui.event` settles
  it and `ui.list` reconciles the cards a client still holds.
- **A subscription, not a poll.** `session.snapshot` opens the harness follow stream and keeps it, so the baseline and every later increment arrive over the same socket.
- **Approvals reach the client, and a reconnecting phone is handed the ones still waiting.** A tool that asks for permission arrives as an `approval.request` event and is answered with `approval.resolve`; a request nobody answers is rejected, and a connection that declared `approvals.v1` receives every unsettled approval again after its handshake.
- **Pairing.** A one-time bootstrap token plus a P-256 challenge-response mint a revocable device token; an unpaired connection reaches three methods only.
- **A pairing panel.** The same plugin row declares a `dsh.client` browser half: a QR code icon at the bottom of the sidebar (aria-label "Pair an iPhone") that opens a modal displaying a 30 s single-use pairing QR code and a "New code" action.
- **No relay change.** The protocol rides whatever origin the instance already publishes, and the relay hop forwards it exactly as it forwards everything else.

## Transport

| frame | shape |
| --- | --- |
| request | `{"t":"req","id":<uint>,"m":"<method>","p":{...}}` |
| response | `{"t":"res","id":<uint>,"ok":true,"r":{...}}` or `{"t":"res","id":<uint>,"ok":false,"e":{"code":"<str>","msg":"<str>"}}` |
| event | `{"t":"ev","seq":<uint>,"m":"<topic>","p":{...}}` |
| heartbeat | `{"t":"ping","ts":<ms>}` and `{"t":"pong","ts":<ms>}` |

`seq` is per connection and starts at 1: it numbers the events that connection received, not the harness's own history. Frames are text, one JSON object per frame, at most 10 MiB (10485760 bytes). Idle connections are probed every 20 s; a client answers a ping with a pong of its own and a bridge answers the same way. A client that sends no frame at all within 10 s of a ping is closed with code 4000; a pong that answers no ping is still ignored.

## Methods

| method | request `p` | response `r` |
| --- | --- | --- |
| `hello` | `{proto:1, minor:17, client:"ios/0.1", cap:["session.snapshot.trim.v1","subagent.thread.v1"]}` | `{proto:1, minor:21, server:"dsh-remote-bridge", capabilities:["session.snapshot.trim.v1","session.snapshot.page.v1","questions.v1","questions.list.v1","subagent.thread.v1","jobs.v1","agents.v1","delta.v1","session.list.omit-archived.v1","session.deep-history.v1","frame.deflate.v1","session.list.compact.v1","session.snapshot.since.v1","session.fork.v1","commands.v1","workspace.v1","session.admin.v1","squads.v1","push.v1","usage.v1","usage.stats.v1","ui.v1","ui.list.v1"], resume:"snapshot", auth:"paired" or "unpaired"}` |
| `session.list` | `{since?, cwd?, maxRows?, after?}` | `{mode, token, sessions:[{id, title, updatedAt, agentRunning, cwd?, parentSessionId?, origin?, blank, archived?}]}`, or `{mode:"reset", token:"", compact:true, sessions:[...], nextAfter?}` for a compact page |
| `session.titles` | `{ids:[<sessionId>, ...]}` | `{titles:{"<id>":"<title>", ...}}` |
| `session.snapshot` | `{sessionId, sinceSeq?, maxBytes?}` | `{turns:[...], lastSeq:<uint>, hasMore?}`, plus `mode:"since", reset:false` for an increment and `mode:"full", reset:true` for a baseline that replaces the copy a client holds |
| `session.history` | `{sessionId, before?, maxBytes?}` | `{turns:[...], nextBefore?:<uint>, hasMore:<bool>}` |
| `session.history.deep` | `{sessionId, before?, maxBytes?}` | `{turns:[...], nextBefore?:<uint>, hasMore:<bool>}` |
| `session.prompt` | `{sessionId, text, requestId}` | `{accepted:true}` |
| `session.interrupt` | `{sessionId}` | `{ok:true}` |
| `presets.list` | `{}` | the host's `agentPresets/list` body verbatim |
| `session.create` | the host's `SessionCreateRequest` | the host's `session/create` body verbatim |
| `session.fork` | `{sessionId, atSeq?}` | the host's `session/fork` body verbatim, its `SessionForkValue` |
| `session.commands` | `{sessionId}` | `{sessionId, items:[{name, description, kind, line}]}`, every command first and every skill after |
| `session.command` | `{sessionId, line}` | `{matched:<bool>}` — false when the host resolves the line to no command |
| `workspace.list` | `{}` | `{workspaces:[{id, path, title, createdAt?, updatedAt?}], archivedSessionIds:[...], pinnedSessionIds:[...]}` — the registry's own order, both sets always present |
| `session.rename` | `{sessionId, title}` | the host's `session/rename` body verbatim, its `SessionRenameValue` |
| `session.pin` | `{sessionId}` | `{ok:true}` |
| `session.unpin` | `{sessionId}` | `{ok:true}` |
| `session.archive` | `{sessionId, stopActivity?}` | `{ok:true}` |
| `session.unarchive` | `{sessionId}` | `{ok:true}` |
| `squads.state` | `{}` | `{squads:[Squad], runs:[Run], presets?:[string], host:{ownerPid?}}` |
| `squads.list` | `{}` | `{squads:[Squad]}` — the definitions half of one `squads.state` read |
| `squads.runs` | `{}` | `{runs:[Run]}` — the run half of the same read |
| `squads.runMember` | `{squadId, memberId, firstMessage?}` | `{run:Run}`, its `kind` `"member"` |
| `squads.runSquad` | `{squadId, goal?}` | `{run:Run}`, its `kind` `"squad"` |
| `usage.session` | `{sessionId}` | `{contextWindow?, modelContextWindow?, composition?:{system, tools, messages, estimated:true}}`; `{}` when the session exists and neither reading does, `unknown-session` when it does not exist |
| `usage.balance` | `{provider}` | `{provider, currency, total, toppedUp, granted, updatedAt}`, or `{provider, unsupported:"no-credential"|"not-deepseek-route"|"probe-failed", reason?}` |
| `usage.stats` | `{days?}` | `{updatedAt, today:Buckets, month:Buckets, days:[{day:"YYYY-MM-DD", ...Buckets}], observedSpend?:{cny, since}}` with `Buckets` the five token buckets, or `{unsupported:"no-usage-plugin"|"probe-failed", reason?}` |
| `pair.begin` | `{token}` | `{nonce, expiresAt}` |
| `pair.finish` | `{devicePubKey, signature}` | `{deviceToken, deviceId}` |
| `approval.resolve` | `{id, decision}` | `{ok:true}` |
| `ui.event` | `{sessionId, callId, event:{type, payload?}}` | `{ok:true}`; the first one settles that tool call, a later one answers `ui-settled`, one this bridge never claimed answers `ui-unknown`, and a payload over 8 KiB answers `invalid-args` |
| `ui.list` | `{}` | `{cards:[{key, sessionId, state, payload}], complete:true}` — only the cards still held; `key` is `"<sessionId>/<callId>"` and `payload` is that card's `ui.request` payload verbatim, and the read claims nothing, moves no deadline and settles nothing |

`hello` is callable without a device token and reports the connection's own `auth` state. `session.snapshot` maps the harness follow stream's opening snapshot into `turns`, reports its cursor as `lastSeq` and then keeps that stream open as this connection's subscription for the session; `session.prompt` ensures the same subscription idempotently, so a client may skip the snapshot. The prompt mode is fixed to queue, `clientTimeZone` is filled server-side with the host's IANA zone, and `requestId` is an idempotency key: the same `requestId` in the same session is sent once, and a repeat answers `{accepted:true}` without sending anything again. `session.interrupt` cancels the turn; it does not drop the subscription.

The two agent methods interpret nothing. `presets.list` answers the host's `agentPresets/list` body as it arrived — that endpoint takes no request, so a preset row is whatever the registry publishes, its plugin list included — and `session.create` sends the client's payload as that endpoint's single `request` argument and answers the host's own reply. The field names, the defaults and the refusal codes are the host's: a client validates a creation request against the host's own `SessionCreateRequest`, never against a copy kept here, and a rejection reaches it as the host's `code` and `msg` — an uncoded failure crosses as this bridge's `internal`, with the host's text. Both methods exist only for a client that declared `agents.v1`: without it each answers `unknown-method` and nothing is sent, and a connection that declared it and never calls them costs the host no read at all.

The fork method interprets nothing either. `session.fork` sends `sessionId` and, only when the client named one, `atSeq` — the source event seq the fork includes — as that endpoint's single `request` argument, and answers the host's own `SessionForkValue`. An omitted `atSeq` stays omitted: the host reads an absent fork point as its newest finished turn, and inventing one here would move the cut silently. A cut that is not a positive integer is refused here as `invalid-args`, because no event log has such a seq; what the host refuses crosses with the host's own `code` and `msg`, and an uncoded failure as this bridge's `internal`. The method exists only for a client that declared `session.fork.v1`: without it the answer is `unknown-method` and the host is asked nothing.

The command channel interprets nothing either. `session.commands` reads both host catalogs for one session and folds them into one directory: `commands/list` is the registry's effective view for that Agent — a command registered through an Agent-scoped child shadows the global one — and `skills/list` is the catalog that session's cwd and preset select, so a phone lists what the desktop composer would offer that same session. Every command comes first and every skill after, each in the host's own order, and no list is re-sorted, de-duplicated or paged. A row is `{name, description, kind, line}`, where `kind` is `command` or `skill` and `line` is the whole draft text that invokes it, separator space included — both host surfaces resolve a bare gesture only when whitespace or the end of the text follows the name, and a skill gesture is `/(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/`. That last rule is also why a skill line is not a command line: a skill is never registered with the command registry, so running one through `session.command` answers `matched: false` and nothing runs. `session.command` sends the line to `commands/execute` exactly as the client wrote it, with an empty attachment list, and answers one decided fact: whether the host resolved the line at all. A line the host resolves runs and answers `matched: true`; a line it resolves to nothing — no leading slash, an unknown name, or a skill's gesture — answers `matched: false` and nothing runs. Nothing is cached and nothing is pushed: both methods read the host on every call, hold no event, and a client that never declared `commands.v1` is answered `unknown-method` with no registry read and no catalog read on its behalf. A host that exports neither endpoint answers this bridge's own `commands-unavailable` rather than a failure per call, while a call the host refuses, and a service that is merely still activating, cross with the failure's own code and text.

The session-admin channel interprets nothing either, and it is two capabilities because reading and writing are two different offers. `workspace.list` answers the host registry's own synchronous projection — one row per registered workspace in registry order, never sorted here, never carrying the workspace's session account — with the archive and pin sets beside it; an archived session is a session-level fact, not an archived workspace, so nothing is filtered out of that list and an archived session keeps its slot, which is where `session.unarchive` puts it back. Pinning is a registry-global set rather than an order inside a workspace: a new pin is prepended, a repeated pin neither writes nor reorders, a pin on an archived session is refused by the host, and archiving drops the pin in the same durable write — the visible order a client shows is therefore its own derivation, exactly as the desktop derives it. Five writes then map one-to-one onto the endpoints that already own their semantics: `session.rename` reaches `session/rename`, which restores a cold session before it writes and answers the title it normalized with the seq that committed it, so a client needs no resume path of its own; `session.pin`, `session.unpin`, `session.archive` and `session.unarchive` reach `workspace/pinSession`, `workspace/unpinSession`, `workspace/archiveSession` and `workspace/unarchiveSession`. No boolean is invented and nothing is branched here: `stopActivity` crosses only when the client sent a boolean, and a session that is still running without it is refused by the host as `workspace/session-active`, which is the refusal that tells a phone to offer the confirmation. This channel pushes no event, because the host announces none of these writes as a cordis event a connection could fold into a delta — a rename appends one `session/title` record and nothing else — so a client re-reads after every write: the pin set through `workspace.list`, the roster through `session.list`, and the new name into its own copy while the title cache expires. The cost is stated rather than hidden: a pin or an archive made on the desktop reaches the phone at its next refresh. A connection that never declared `workspace.v1` or `session.admin.v1` is answered `unknown-method` for the methods behind it, with no registry read and no write on its behalf.

The squads channel is the one that replaces nothing: it reads and drives the agent squads of the `dsh-agents` plugin, and it sits beside `agents.v1` rather than on top of it, so a phone written before it keeps the preset roster and session creation it was built against. Five commands stand behind `squads.v1`. `squads.state` answers `{squads, runs, presets?, host:{ownerPid?}}`; `squads.list` answers `{squads}` and `squads.runs` answers `{runs}`, the two halves of one read cut from the same answer, so a squad listing and a run history can never disagree; `squads.runMember` sends `{squadId, memberId, firstMessage?}` and `squads.runSquad` sends `{squadId, goal?}`, each answering `{run}`. A `Squad` is `{id, name, description?, workspaceId?, leadPreset?, order?, members:[Member]}` and a `Member` is `{id, name, preset?, permission?, model?, reasoningEffort?, brief?}`; a `Run` is `{id, squadId, kind:'squad'|'member', memberId?, sessionId, createdAt, status:'running'|'settled'|'failed', failure?}`, where `kind` is required on this wire and a run recorded before whole-squad runs existed — one carrying no `kind` at all — reads as the member run it is by construction. Nothing the plugin keeps for itself crosses: a definition's `createdAt`/`updatedAt`, a member's `teamId` backlink and the `goal` handed to a Lead stay on the host's side. The domain is the plugin's own, so its refusals are the plugin's own too and cross with the plugin's code and its one bounded sentence — `team-not-found`, `member-not-found`, `preset-registry-unavailable`, `preset-unknown`, `preset-unavailable`, `workspace-unavailable`, `workspace-unknown`, `model-pin-invalid`, `session-create-failed` and their neighbours — while this bridge names only the two conditions the plugin cannot: `squads-unavailable`, for a host that serves no squad domain this bridge can reach or answers a body this build cannot read, and `squads-run-unavailable`, for definitions that are readable while no run route can be reached, which is answered instead of a run that never started. A malformed row is refused rather than repaired and an empty answer is never invented, so a host without the plugin answers an error rather than an empty board. How the domain is reached is this bridge's one dependency-free rule: the plugin's own routes over this machine's loopback origin — `GET /dsh-agents/api/state`, `POST /dsh-agents/api/run`, `POST /dsh-agents/api/squad/run`, addressed at the origin `webServer` already serves and carrying that origin in the `origin` header the plugin's browser fence reads as its same-origin signal — and, when no loopback call can connect at all, the plugin's ledger at `$DSH_HOME/dsh-agents/ledger.json` read read-only under the same rules, with `host.ownerPid` then read out of the plugin's own lock beside it. An answer of any status is not a fallback: it is the plugin speaking, and only a call that never connected reaches the ledger. Nothing is called on a connection's behalf, and a client that never declared `squads.v1` is answered `unknown-method` for all five.

The usage channel reads two facts that belong to the host rather than to one device, so one instance serves every connection. `usage.session` names one session and answers `{contextWindow?, composition?}`: the window is the harness's own `RequestContext.contextWindow` from the latest `request/context` event this process saw broadcast, and the composition is this bridge's estimate of what the next request is made of, split three ways — `tools` from the raw tools schema of the latest `request/header`, `system` from the latest `system/message` text, and `messages` from the text of every `user/message`, `assistant/message` and `tool/result` seen since — each component the UTF-8 byte count divided by four and floored, always carrying `estimated: true`, because a phone must never read an estimate as the host's own accounting. A key the host never broadcast is absent rather than zero, and a session that exists while neither reading does answers `{}`: existence has three witnesses — the host's own session roster (the same `session/list` the roster method serves, so a session this bridge never observed and that is not live still counts), the live session store, and the events this process folded — and a session id none of the three names is refused `unknown-session`. The roster is read only when the other two answer nothing, and its answer is reused for five seconds so a burst of opens costs one read; a bridge that cannot read a roster at all falls back to the live store and the fold alone. The fold starts when this row mounts and never reads a session log: an event broadcast before it mounted is not a reading, so a session that ran entirely before it answers `{}` rather than a composition of zeros. `usage.balance` names one provider route and answers either `{provider, currency, total, toppedUp, granted, updatedAt}` — the numbers in yuan, taken from `balance_infos[0]` — or `{provider, unsupported, reason?}` with `no-credential`, `not-deepseek-route` or `probe-failed`. The route must be an official DeepSeek id (`deepseek` or `deepseek-official`) AND its configured `baseURL` absent or on `https://api.deepseek.com`: the balance endpoint reports one origin's account, so probing it for a reseller profile that merely reused the `deepseek` id would print the official account's money under that route's name. The credential is resolved the way the harness's own usage plugin resolves it — the pi-ai record `llm-pi-ai/<provider>`, then the profile's `apiKeyEnv`, then the DeepSeek adapter's own reference (default `DEEPSEEK_API_KEY`) — and an OAuth grant is not a credential here, because the official endpoint accepts an API key only. One reading is cached for 60 seconds per provider and credential, concurrent calls merge into one upstream request, and 401, a timeout or an unrecognized body all answer `probe-failed` instead of a previous reading presented as current. A client that never declared `usage.v1` is answered `unknown-method` for both methods, with no event folded and no upstream request made on its behalf. `usage.stats` reads the ledger another plugin keeps, so it is its own capability behind `usage.stats.v1` and its own read path: one `GET /api/dsh-usage/overview` and, only when the requested window reaches deeper than the 30 days that document already carries, one `GET /api/dsh-usage/day?date=YYYY-MM-DD` per day that ledger actually recorded. Days the ledger holds nothing for cross as real zeros — the host answers those dates as zeroed days itself — while a route that is absent (`no-usage-plugin`), unreachable, shaped differently or too slow answers `probe-failed` with a short reason and never a previous reading. One `days` window is cached for 30 seconds on this side and concurrent calls merge into it, so two phones asking at once cost the host one read. `usage.session` gained one optional key beside the window: when the host has broadcast no `request/context` window, the answer may carry `modelContextWindow`, taken from `GET {baseURL}/models` of the session's current route — the first available of `context_length`, `context_window` and `max_input_tokens` for that exact model id, cached for ten minutes per route, and sent with that route's own credential. The two numbers are never mixed: `contextWindow` is always the host's measurement, `modelContextWindow` is always the model's declaration, and the second never appears beside the first.

`hello` also carries the client's capabilities, and a capability is an announcement rather than a negotiation: the handshake answers the same frame whatever `cap` holds, an unknown name is ignored, and the frame cap is one constant in both directions — the 10 MiB that bounds a received frame is the same budget that fits a snapshot and that drops an oversized outbound event. This bridge serves twenty-three capabilities and advertises them in `hello`'s `capabilities`: `session.snapshot.trim.v1`, `session.snapshot.page.v1`, `questions.v1`, `questions.list.v1`, `subagent.thread.v1`, `jobs.v1`, `agents.v1`, `delta.v1`, `session.list.omit-archived.v1`, `session.deep-history.v1`, `frame.deflate.v1`, `session.list.compact.v1`, `session.snapshot.since.v1`, `session.fork.v1`, `commands.v1`, `workspace.v1`, `session.admin.v1`, `squads.v1`, `push.v1`, `usage.v1`, `usage.stats.v1`, `ui.v1` and `ui.list.v1` — a capability a mounted plugin adds is not on that list, because the bridge is not its author. `ui.v1` is the one name here that a bridge can only advertise when it really serves the other half: behind it a page arrives as `ui.request` and the user's action returns with `ui.event`, and a host whose bridge predates the channel sends nothing. `ui.list.v1` is its reconciliation read, the same announcement shape as `questions.list.v1`: only an answer marked `complete` may retire a card a phone carries. Nor is `approvals.v1`, which a client declares and this bridge deliberately never advertises: it gates a replay rather than the channel, so the handshake stays the same frame for every client (see the approval channel below). `questions.list.v1` is the one name here that announces a method rather than changing a channel: `question.list` answers the questions this bridge is still holding, and only an answer marked `complete` may retire a card a phone carries. The first is the trimmed baseline. A client that does not declare it — including every client written before it existed — is served exactly the baseline protocol v1 always served. A client that does declare it is sent a trimmed baseline: inside every turn, only the entries it renders (`user/message`, `assistant/message`, `tool/call`, `tool/result`, `subagent/catalog`) survive, and the harness's own bookkeeping records (`system/message`, `request/header` and the like) are dropped before the frame is measured. Nothing else about the snapshot changes: which turns are kept, their order, and each turn's `seq`, `endSeq`, `reason` and `time` are the projection's own values, a turn whose entries are all bookkeeping stays as a turn with an empty `entries` array, and `lastSeq` is still the harness cursor. The frame budget applies afterwards — entries are trimmed first, then whole oldest turns are dropped, and only then does a snapshot that still does not fit answer `snapshot-too-large`.

A second capability bounds the opening screen itself: `session.snapshot.page.v1`. Without it a snapshot stays the whole opening window, byte for byte, exactly as before. With it, `session.snapshot` answers with the newest page only, measured against `maxBytes` (default 524288 = 512 KiB, and refused rather than clamped when it is not a usable budget), cut by the very same entry-level slice `session.history` uses: a turn's entries may split between this page and the next one while that turn keeps its own fields, an entry larger than the whole budget is a page of its own, and `snapshot-too-large` can no longer answer such a connection. The budget is measured on the whole encoded answer and never per turn, so what it bounds is the page rather than an entry: a page is inside `maxBytes` unless the newest entry alone is past it, and then the page is exactly as large as that entry — a 2.2 MB newest message answers a 2.2 MB page against the 512 KiB default, while a window of ordinary entries answers 513794 bytes. That is the one cost a paged snapshot can still carry. The page carries `lastSeq` and `hasMore`, and the cursor for the next, older page is the `seq` of its own oldest entry — the same value `session.history` takes as `before`, which is why no `nextBefore` field restates it. Opening a session then costs one page, and `session.history` walks the rest of the window back in the same 512 KiB pages. The capability that makes a reconnect cheap is `session.snapshot.since.v1`, and it takes two declarations to change anything: the capability in the handshake AND a `sinceSeq` in the request. A request missing either is answered byte for byte what protocol v1 answered before it existed, with no field added, so a client can adopt the name before it relies on any of it. Behind both, the client names the durable `lastSeq` of a snapshot it already holds — the harness cursor, never the connection's own event seq — and the bridge proves continuity against the current follow window: only when that window's oldest entry sits at or below `sinceSeq + 1`, or the window is empty, is the answer the increment `{turns:[...], lastSeq, mode:"since", reset:false}` carrying nothing but the entries above that watermark, each turn keeping its own `seq`, `endSeq`, `reason` and `time` exactly as the baseline presented it. An entry the client never renders still counts as an entry the window holds, so the proof is read off the untrimmed window and can only be conservative. A window that has scrolled past the watermark cannot prove the chain is unbroken, so there — and when the increment would not fit one 10 MiB frame — the answer is the baseline this connection would have received anyway, carrying `mode:"full"` and `reset:true`, the one answer that tells a client to throw its copy away. Trim, the page budget and the frame cap are untouched: the increment is cut from the same projection, and a reset reply is exactly today's reply plus those two fields.

`frame.deflate.v1` changes the form of a frame rather than its content, and it is the one capability that has to exist at this layer: the 101 response advertises no `Sec-WebSocket-Extensions`, this carrier rejects every RSV bit, and a client whose WebSocket API cannot offer `permessage-deflate` at all has no way to ask for it, so the compression the wire carries is decided here. A client that declares the capability may be answered in WebSocket binary frames whose payload is raw DEFLATE (RFC 1951, no zlib header and no adler32 trailer) of exactly the JSON text the text path would have written: `inflateRaw` of that payload is that frame, byte for byte. Only a frame whose UTF-8 text reaches 1024 bytes is considered at all, a frame that does not get smaller is still sent as text — a binary frame larger than the text it replaces would save nothing — and the direction is one way, because a client keeps sending text frames. The 10 MiB cap is unchanged and still measured on the text, so every budget in this document stays a budget of uncompressed JSON. A client that does not declare the capability receives byte for byte the text frames protocol v1 always wrote, and nothing about the handshake changes either way.

`subagent.thread.v1` makes one delta kind exist: the harness's durable `subagent/catalog` event registers a delegated child, and the bridge publishes `subagent` with `{childId, label?, mode?, callId?}`. `callId` is the `tool/call` that spawned the child, and the spawn's own result is asked first: a `tool/result` that names the child — the activation handle `{"kind":"activation","subagentId":…}` the `subagent` tool returns, alone or nested in a `run_code` answer, or the plain `started subagent …` notice it prints — decides it even in a step that issued several calls or spawned a whole team of children. When no result names the child the pre-existing rule answers: the one call the same turn and step issued, and nothing at all when that step issued several calls or the call already produced another child, because a guessed id would hang the thread on the wrong card. A spawn can be projected before its own result, so a live delta answers from the events this stream has carried so far while a baseline answers from the whole session — one rule and one `SpawnCallIndex` behind both. A `subagent/catalog` entry is therefore one of the entry types a trimmed baseline keeps, and it carries the spawning call in `data.callId`: a phone that opens a session after children were spawned reads their labels, their modes and the call that made them instead of inferring the thread from agent messages. The `actor` of a `user-message` is not gated by any capability: a delta and a snapshot `user/message` entry both carry `{kind, sourceKind, sessionId?, label?}` derived mechanically from the harness's own `source` record — `user` and `user-approval` are `human`, `agent-message` and `subagent-settled` are `agent`, every other source kind is `system` with its own name verbatim — so a message is never dropped for carrying an origin this protocol has not classified, and an event with no readable source kind carries no `actor` at all. Following a session is authorized rather than assumed: a session this connection already opened and an ordinary session the roster names may be followed, and a delegated child only while the parent it hangs off is one of those. A child whose parent no roster resolves answers `session-not-followable` and opens no stream, so relaxing the child gate cannot become a way to read a session the caller was never authorized for.

`session.list.omit-archived.v1` is the one capability here that negotiates nothing. The bridge never transfers an archived session — the host's archive set is read once per listing and every id it names is skipped before that row is built — whether or not a client declares the capability, so a client written before it receives the shorter roster it would have filtered away itself. Behind it, no `session.list` row is a session the archive set names, a surviving row still carries `archived: false` under the unchanged schema, and a listing that cannot read the archive set drops nothing at all. A live child of an archived parent is still a row of its own and still names that parent's id: the parent id is what the child's own row carries, so an archived parent never costs a child its lineage. The roster is also the set the delta machine diffs, so a session archived between two listings arrives as a removal and a restored one as a change, with no special case in either.

`session.snapshot` and `session.history` address a session the way the harness demands. An ordinary session is addressed by its own id; a delegated child — a roster row carrying `origin:"subagent"` — is addressable only through its durable parent, and the harness answers `session/agent-busy` when a child is asked as an ordinary session. The parent comes from the roster, the only endpoint that carries lineage, and the bridge reads it before the first attempt, so a child whose parent the roster names is addressed with `{kind:"subagent", parentSessionId, childSessionId, mode:"unknown"}` straight away and its real history is served. When that read resolved no parent, the harness's own refusal is the signal instead: it retries that one session with the delegated address. A client that listed sessions before opening one pays no extra read, and the roster is read at most once per connection. Roots never pay for this: they open on the first attempt with their own id. A session with nothing to show is not a failure either: one whose log holds only the harness's own bookkeeping answers a real projection with an empty turn, and only a missing opening frame or a failure raised by the harness itself is refused.

`session.history` reads a session's history backwards in pages, and it is a pure read: it never opens, drops or re-addresses a subscription, and the same cursor always answers the same page. It is served from the very opening baseline `session.snapshot` uses, so the two can never disagree about a turn or an entry — `history` only slices that projection. Pages are cut BY ENTRY, not by turn: a page carries the newest entries below the caller's cursor until the encoded page reaches `maxBytes` (default 524288 = 512 KiB), a turn's entries may therefore be split across pages while that turn keeps its own `seq`, `endSeq`, `reason` and `time`, and a single entry larger than the whole budget is a page of its own rather than a refusal. `before` is a durable watermark, not an index: the page carries the entries whose `seq` is strictly below it, so a cursor stays meaningful across connections and across a projection that changed shape under it. Omit `before` for the newest page; `hasMore` says whether anything older remains, and `nextBefore` — the cursor for the next, older page — is present exactly while `hasMore` is true. The trim capability applies here exactly as it does to a snapshot.

`session.list` carries the roster's own fields plus the metadata a phone groups and labels by: `cwd`, `parentSessionId` and `origin` are present only when the host reported them, `blank` is always present, and `archived` is present only when this process can read the host's archive set — a bridge that cannot tell omits the key instead of answering `false`. An archived session is not a row at all: the archive set is read once per listing and every id in it is skipped before its row is built, so the flag that does cross is always `false` and the rows a phone would have hidden cost it nothing. The one read that this makes shorter is the roster: nothing else about the row, the lineage it carries or the delta the connection holds changes. `title` is the name the row itself carries: the host's own `projections.values.title`, which it folds out of its projection cache synchronously for a cold session as well as a live one, and which falls back to whatever this connection has already resolved for that id through `session.titles`, then to `""`. Listing sessions therefore still stays one call and never puts a title read on every row, while `session.titles` remains the live session's immediate source — the one a rename refreshes first — and neither method has to be called for a phone to name a cold session. `session.titles` answers a batch of one to 100 ids out of the host's own title service — the live sessions of this process, never one follow stream per session — and every requested id becomes a key: an id this host does not hold live, or one whose title has not landed yet, answers `""`. One resolved title is served for 10 s before the service is asked again, so a rename can take that long to show. Like every other session method, both answer `unpaired` without a device token.

A compact listing is the roster's other form, and it takes both halves to appear: the `session.list.compact.v1` capability and at least one compact parameter. `cwd` is a boolean that decides whether the absolute directory crosses, `maxRows` cuts the roster into pages of that many rows, and `after` is the id of the last row the previous page carried, so a page is continued rather than re-requested. Rows are ordered most recent first with the id as the tiebreak, which is what makes `after` a cursor instead of a position; `nextAfter` is present exactly while rows remain, and a cursor naming no row of this roster answers a first page again, the way a delta token this window never issued answers a reset. `updatedAt` is present only when this connection has not already sent this client that exact value for that id, so a repeated listing carries none of them; `compact:true` says the rows are in this schema, so an absent `updatedAt` is never mistaken for a value the host never had. A listing that names no compact parameter, and every listing from a client that did not declare the capability, is answered the whole roster under the schema above, byte for byte. A compact page and a `since` token are two different answers to two different questions, and a request naming both is refused with `invalid-args`.

## Events

| topic | payload |
| --- | --- |
| `session.delta` | `{sessionId, kind, ...}` — one increment of a session's turn (kinds below) |
| `approval.request` | `{sessionId, id, summary}` — a tool is waiting for a decision |

### session.delta kinds

| kind | extra fields | meaning |
| --- | --- | --- |
| `text` | `{text, blockIndex}` | assistant text delta |
| `reasoning` | `{text, blockIndex}` | reasoning delta |
| `tool-call` | `{callId, name, argumentsDelta}` | incremental tool call; `argumentsDelta` may be an empty string |
| `tool-result` | `{callId, output, isError}` | tool result |
| `turn-end` | `{reason}` | turn finished; anything other than `completed` is an abnormal end |
| `user-message` | `{text, messageId?, attachments?, actor?}` | a user message; `actor` says where it came from |
| `subagent` | `{childId, label?, mode?, callId?}` | the harness registered a delegated child; capability `subagent.thread.v1` |

An `approval.request` carries a summary taken from the harness's display reason in the client's language, falling back to English and then to the tool name, plus the tool name itself. The client answers it with `approval.resolve`, whose `decision` is either `allowed-once` or `rejected`; an unknown or expired `id` is refused, and a request nobody answers is resolved after two minutes as unavailable, which the harness reads as a rejection. Every paired connection receives the event the moment the tool asks — the first broadcast is behind no capability — and a connection that declared `approvals.v1` in `hello` is handed every still-waiting request again, so a phone that reconnected, or a session screen opened after the fact, still holds a card. Only an unsettled request is replayed: one that was answered, timed out or withdrawn is gone from the claim registry, and re-handing a request never moves its two-minute deadline. The capability gates that second delivery and nothing else, which is why it is absent from `hello`'s `capabilities` and leaves `minor` alone: the handshake answers every client the same frame. The bridge claims an approval request only while a paired client is connected: with none, the request is left to the next answerer instead of waiting out a phone that is not there.

The question channel is the one claim that outlives the socket. A question arrives as a `question.request` event under the host's own call id, or under one this bridge mints when a blocking ask carries none, and `question.answer` settles the host's ask: while that ask is still open the answer resolves the promise the bridge holds, and once the host's deadline has released the model a late answer takes the host's Remote `answer` path instead. `question.list` answers the questions the bridge is still holding, which is what a phone reconciles the cards it carries against. With no paired connection on the wire, but a device on the roster that registered for push, the bridge claims the question for that phone and the alert is its delivery: for one bridge-owned window the question exists only on the phone's side, and when the window ends it goes back to the host's own answerer chain. The window is `OFFLINE_QUESTION_CLAIM_TTL_MS`, ten minutes, overridable with `DSH_REMOTE_BRIDGE_OFFLINE_CLAIM_TTL_MS`. Inside it a phone that reconnects and declares `questions.v1` is handed the live claim and the window ends there; a phone that answers settles it, and the host's chain is then never started; and a hello from a paired connection that did not declare the channel releases the claim at once, because a claim nobody on the wire can receive is not kept. A blocking ask — no call id, no deadline — is claimed the same way, and its cost is stated rather than hidden: the agent behind it waits at most that window. Approvals keep the stricter rule above, where nothing is ever held for a phone that is not on the wire.

The interactive-reply channel is the one place a page travels instead of a question. `ui_present` is this bridge's own model tool: the agent writes one whole self-contained document (`<!doctype html>` first, at most 256 KiB of UTF-8) and it crosses as a `ui.request` event under the host's own `ToolCallId`, so the card hangs on the same tool-call row the transcript already draws. The call then blocks until the user's FIRST action inside the page arrives, and that action — `{type, payload?}` with `type` the page's own closed set and `payload` at most 8 KiB — is the tool call's result: one card settles one action, and a page that should keep taking them is presented again. The card is announced over as `state:"settled"` to every client that declared `ui.v1`, including the one that acted, so a second tap on the phone and a second phone both learn it is over. `ui.list` answers the cards this bridge still holds, which is what a phone reconciles against after a restart, and only a complete answer may retire one. A card never carries `expiresAt`: the tool schema declares no `timeout`, so this bridge invents no deadline, and it takes the same grace window as a question instead — a page nobody can answer any more is released to the agent as a timeout when the window the last paired device left behind runs out, and a phone that declares the channel inside that window is handed the live card and cancels the release. With no paired connection that declared `ui.v1`, `ui_present` answers `{ok:false, reason:"unavailable"}` at once instead of holding the agent. Every result of a page that did reach the wire carries the presentation payload as `meta.ui` — `{key, title?, html, height?, state}` with `key` spelled `"<sessionId>/<callId>"` — for a client that renders the card out of the transcript and holds no `ui.request` socket; the body of the result stays one human sentence.

## Extension seam

The package publishes one generic seam and puts no vocabulary of its own in it. Mounted plugins resolve the cordis service `remoteBridge` and add methods and events to protocol v1 without the bridge learning what they mean:

| member | contract |
| --- | --- |
| `registerMethods({capability?, methods})` | adds a table of client-callable methods and returns the disposer that withdraws them; a name inside `KNOWN_METHODS`, an empty name or a name another table already owns is refused, and the whole table is refused with it |
| `broadcast(topic, payload, capability?)` | pushes one event to the connections that are paired — and, when a capability is named, declared it — and returns how many took the frame |

A registered method is dispatched after both protocol gates: a name no plugin registered answers `unknown-method` before anything else runs, a client without a live device token answers `unpaired` exactly as it does for a protocol method, and a client that never declared the table's capability is told `unknown-method` — the method is absent from its protocol rather than forbidden to it. The handler receives the request payload and the calling connection's id, and answers `{ok:true, r}` or `{ok:false, code, msg}`; the bridge forwards either verbatim, because refusal codes belong to the registering plugin. A handler that throws, or answers neither shape, crosses as the bridge's own `internal`.

`hello` answers `minor:21`, since the interactive-reply channel shipped: it adds two capability names (`ui.v1`, `ui.list.v1`), one topic (`ui.request`), two methods (`ui.event`, `ui.list`) and one model tool (`ui_present`), so a client reads two new names, a new event and two new reply bodies off the wire, and no existing frame, method or key changes shape. `minor:20` was when the usage stats channel shipped: behind `usage.stats.v1` a client may call `usage.stats` and read the host's own ledger, and `usage.session` may answer `modelContextWindow` from the route's model catalog, so it reads one new reply body and one new optional key off the wire and has to read the name before it asks — a bridge below this one answers `unknown-method` and omits the key, and no frame, topic or existing method changes shape. `minor:19` was when the usage channel shipped: behind `usage.v1` a client may call `usage.session` and `usage.balance`, so it read two new reply bodies off the wire and had to read the name before it asked — a bridge below that one answers `unknown-method` for both. `minor:18` was when one lock-screen card learned to lay out several sessions: `content-state` may now carry `rows`, at most three `{name, status, phase}` items, ordered by the bridge — the sessions waiting on the user first, in the order their questions arrived, then the sessions running now, most recently updated first — so no method, no capability name and no existing key changes shape, and the minor rises by one. A row's `name` is the host's own title with its trailing path group taken off, because the wire carries no paths. `minor:17` was when the live-activity channel shipped: the lock-screen card is driven by the registration surface `push.v1` already carries, and this bridge gained only the sending path — a `liveactivity` push of one session's own state. `minor:16` was when the notification channel shipped: behind `push.v1` a client may call `push.register` and have the device table remember an APNs token, so a phone learns a turn ended — or that a question is waiting — while its own socket is not open. `minor:15` was when the squads channel shipped: behind `squads.v1` a client may call `squads.state`, `squads.list`, `squads.runs`, `squads.runMember` and `squads.runSquad`, so it reads five new reply bodies off the wire and drives the host's squad domain instead of deriving agents from anything else. `minor:14` was when the session-admin channel shipped: behind `workspace.v1` a client may call `workspace.list` and read the host's workspaces with both registry-global session sets, and behind `session.admin.v1` it may rename, pin, unpin, archive and unarchive a session, so it reads six new reply bodies off the wire. `minor:13` was when the question list shipped: behind `questions.list.v1` a client may call `question.list` and be answered the questions this bridge is still holding — the set a phone reconciles the cards it holds against, and the only way it learns that a question ended while it was away, because the bridge withdraws every claim when the last paired device leaves. `minor:12` was when the command channel shipped: behind `commands.v1` a client may call `session.commands` and `session.command`, so it reads a new reply body off the wire. `minor:11` was when the fork channel shipped, one step above the snapshot-`since` channel that landed on the integration branch first: behind `session.fork.v1` a client may call `session.fork` and be answered the host's own fork result. `minor:10` was when the snapshot became incremental on request: behind `session.snapshot.since.v1` a `session.snapshot` that names `sinceSeq` is answered either the entries above that watermark or a full baseline carrying `mode` and `reset`, so the number rises for a reply shape a client reads off the wire. `minor:9` was when the compact roster shipped: behind `session.list.compact.v1` a listing that names `cwd`, `maxRows` or `after` is answered a shorter row that may be paged, so the number rises for a row schema a client reads off the wire. `minor:8` was when the outbound frame form became a capability: behind `frame.deflate.v1` a declaring client may be answered in binary frames carrying raw DEFLATE of the text, so the number is raised for something a client reads off the wire. `minor:7` was when the two channels met; `minor:6` was either channel on its own — the agent channel on dev and durable history paging on its branch; `minor:5` was when the roster stopped carrying archived sessions. Behind `agents.v1`, `presets.list` and `session.create` forward the host's preset roster and one creation request; behind `session.deep-history.v1`, `session.history.deep` pages a session's durable store back to its first entry; behind `session.fork.v1`, `session.fork` branches one session into a new one that inherits it. `minor:4` added byte-level increments, `minor:3` the background-job channel, `minor:2` the subagent thread, and `minor:1` was this seam. Major 1 and every frozen field, method name and topic name are unchanged by all of them. The one body that shrank for every client rather than only for those that declare a capability is the roster itself, which no longer names an archived session; every other answer a client that declares nothing receives is protocol v1's own.

## Error codes

| code | meaning |
| --- | --- |
| `unpaired` | the connection has no live device token and the method is outside the unpaired whitelist |
| `snapshot-unavailable` | the session's opening frame could not be read — the follow stream ended without one, or none arrived within 10 s. A failure the harness itself raised crosses with its own code instead |
| `token-invalid` | the bootstrap token is unknown, already spent or older than 30 s |
| `nonce-invalid` | `pair.finish` used a nonce that was never issued, already used or expired |
| `signature-invalid` | the ES256 signature does not verify against the offered public key and nonce |
| `approval-unknown` | `approval.resolve` named an approval id that is unknown or has expired |
| `unknown-method` | the method name is not part of protocol v1 |
| `invalid-args` | the payload does not match the method's shape |
| `proto-unsupported` | the `hello` handshake named a protocol version this bridge does not speak |
| `snapshot-too-large` | the opening snapshot cannot be shrunk to one frame: even a single turn does not fit |
| `squads-unavailable` | this host serves no `dsh-agents` squad domain this bridge can reach: no answer on the plugin's loopback route, no readable ledger beside it, or a body this build cannot read as one domain. A squads read is never answered with an empty board, and a refusal the plugin itself raised crosses with the plugin's own code instead |
| `squads-run-unavailable` | the definitions are readable but the two run commands cannot be served: no loopback run route could be reached, and the read-only ledger cannot launch a session. Answering a run that never started is the one thing this channel will not do |
| `internal` | the bridge failed while serving the call |
| `frame-too-large` | the frame exceeded the 10 MiB cap and was not parsed |
| `malformed` | the frame is not one JSON object with a recognizable shape |
| `direction-violation` | the client sent a `res` or `ev` frame, which only the host sends |

A failure raised inside DSH is not re-labelled either: it crosses with the gateway's own code, such as `service-unavailable`, `invocation-unavailable`, `definition-unavailable` or `arguments-invalid`. A refusal the `dsh-agents` plugin raises crosses the same way: its own stable code — `team-not-found`, `member-not-found`, `preset-unknown`, `workspace-unavailable` and their neighbours — with its own one bounded sentence. Only the service-activation window is retried (five tries, two seconds apart), because that is the one failure that succeeds moments later.

## Authentication

The device token rides the WebSocket upgrade as `Authorization: Bearer <deviceToken>` — the preferred carrier — and as `?token=<deviceToken>` for clients that cannot set headers. The comparison is constant length and does not short-circuit on a mismatched size. A connection without a live device token is allowed exactly three methods — `hello`, `pair.begin`, `pair.finish` — and everything else answers `unpaired` before any session work happens.

### Bootstrap token

The pairing entry is a QR code icon at the bottom of the sidebar, with the accessible label "Pair an iPhone". Clicking it opens the pairing panel. The panel is strictly available on localhost: its token route `GET /remote-bridge/pair/token` is guarded by a two-condition loopback gate where both the socket address and the `Host` header must name this machine (remote or tunneled requests return 403).

The pairing code is single-use and valid for 30 seconds (`BOOTSTRAP_TOKEN_TTL_MS = 30_000`), consumed immediately upon the first `pair.begin`; clicking "New code" in the panel voids the previous code and mints a fresh one. There is no "Stop" button and no action to revoke all connected devices. The machine-local link is printed to the host log as `pair a device with <ws link>`, and that line is followed only by the kind of phone-facing address: `(phone: lan)` when the host calls the origin a LAN address or the literal itself answers only inside the network this page is already on, `(phone: public)` in every other case, and no suffix when the host found no address at all. The code itself renders in the pairing panel for scanning.

```sh
curl -s http://127.0.0.1:3080/remote-bridge/pair/token
# {"token":"...","expiresAt":1760000030000,"url":"ws://127.0.0.1:3080/ios/v1?token=..."}
```

When this machine can determine a public or LAN origin — from live relay lease/tunnel state, `DSH_REMOTE_PUBLIC_BASE_URL`, or a listening LAN forwarder — the same answer carries the address half:

```json
{
  "token": "...",
  "expiresAt": 1760000030000,
  "url": "ws://127.0.0.1:3080/ios/v1?token=...",
  "origin": "wss://<id>.dsh-market.com",
  "originSource": "realtime",
  "pairingUrl": "wss://<id>.dsh-market.com/ios/v1?token=..."
}
```

The QR code encodes a `{token, url}` document where `url` takes the form `wss://<origin>/ios/v1?token=...` (derived via `pairingDialUrlOf`, converting https to wss and http to ws). Scanning with the mobile phone app acquires both the address and token at once without manual entry. With no discoverable origin those fields are absent rather than guessed, the panel falls back to a bare-token code, and the phone keeps whatever origin it already has.

### Device token

`pair.begin` answers with a nonce and its expiry; the client signs the nonce's raw bytes with a P-256 key and calls `pair.finish` with the base64url SPKI public key and the base64url ES256 signature. The signature must be the raw 64-byte `r||s` form that WebCrypto and CryptoKit produce, not a DER wrapper: the host checks the length before it verifies, so a re-encoded signature is refused. The host answers with a 32-byte random device token, base64url encoded, and its device id. The server keeps only the SHA-256 hash of that token, so a stolen roster file does not yield a usable credential.

## Device roster

The roster lives in `$DSH_HOME/remote-bridge/devices.json`, a versioned document with one row per paired device:

```json
{
  "version": 1,
  "devices": [
    {
      "deviceId": "dev_9Qm2r0Tn1Zk4",
      "pubKey": "<base64url SPKI, P-256>",
      "fingerprint": "<sha256 of the SPKI>",
      "createdAt": 1760000000000,
      "lastSeenAt": 1760000000000,
      "tokenHash": "<sha256 of the bearer token>"
    }
  ]
}
```

Nothing in that file is a usable credential in the clear: a row keeps the device's public key, its fingerprint and the SHA-256 of the token it was issued, never the token. The directory is 0700 and the file 0600; a write goes to a temporary file, is flushed and renamed into place, so a crash never leaves a half-written roster, and a file that does not parse is moved aside with a `.corrupt-<timestamp>` suffix instead of being treated as an empty roster. Protocol v1 has no revoke method, so revoking a device is removing its row (or deleting the file) and restarting the host: the roster is read once per process, and from that start on the device's connection is unpaired. One row is one phone and one APNs token is delivered to once: pairing again replaces the bearer token on the row the device already has — its id, its creation time and its notification registration stay — and a table that still carries several rows for one fingerprint, or one token on several rows, is compacted when it is read, so a re-pairing is a new credential rather than a new device and one event never rings the same lock screen twice.

## Relay

The relay that publishes a private instance on the public internet is a full reverse proxy: it looks the instance's current tunnel target up, rewrites only the `Host` header, and returns the origin's response verbatim — the one path-specific rule is immutable caching for content-hashed `/assets/*` files. A WebSocket upgrade rides that same single hop, because the Workers runtime upgrades a `fetch` when the client sends an `Upgrade` request with a null body. Protocol v1 is therefore another path on the same origin: no Worker change, no second tunnel, no extra origin to publish.

Two consequences follow from that hop. It sees the request URL, so a `?token=` connection hands the device token to the relay, the Cloudflare edge and the origin access log — prefer the header. And `GET /remote-bridge/pair/token` is forwarded like any other path, but its loopback gate rejects the forwarded request, so pairing can only be started from the machine itself.

## Ingress stack and security model

`@linxin666/dsh-remote-bridge` owns the complete public and local network ingress stack for remote access:
- **Relay identity and leases**: `relay-registry` persists the relay ID and secret under `$DSH_HOME/remote-web-ui-registry/<profile>.json`; changing relay config remounts the plugin row. When no tunnel is configured (`tunnel.mode === 'off'`) and no remote device is paired yet, the bridge stays idle at startup without minting an identity or claiming a relay lease; lease claiming is armed on demand once the roster loads a paired device, a local pairing token is requested, a device finishes pairing, or a tunnel is enabled (while suppressing transient `idle`/`claiming`/`dialing` state logs and deduplicating repeated identical failure lines). When mounting with `relay=false` and an on-disk identity already exists, it unregisters the previous registration row and releases its lease once via `pair.release()` (best-effort, logging any errors; unmounting neither releases nor unregisters, preserving bookmarked origins for server-side GC; mounting with `relay=false` never mints a new identity and has zero side effects if no identity file exists).
- **Relay connectors**: `relay-connector` manages quick and named tunnel connectors side by side, and `relay-bridge-connector` connects to the Cloudflare Durable Object meeting point over WSS, recording connection state in `$DSH_HOME/remote-web-ui-relay-bridge.json` (state file writing is verified behaviorally, defaulting to `$DSH_HOME` when unspecified, not checked via source text assertions).
- **Tunnel lifecycle**: `tunnel`, `tunnel-plan`, and `tunnel-orphan` manage platform `cloudflared` child processes with exponential backoff and reap orphaned processes left behind by crashes.
- **Ingress sync**: `ingress-sync` converges Cloudflare named tunnel ingress rules to the local gate port when the host listens on a different port.
- **LAN forwarder and firewall**: `lan`, `lan-bind`, `lan-bind-plan`, `lan-forward`, `host-bind-capability`, and `firewall` manage the LAN-edge forwarder and OS firewall rules (Windows Defender and Linux firewalld/ufw/iptables) while keeping the host webserver bound strictly to loopback.

The security model enforces strict whitelisting across all external ingress paths:
- **Internal ingress gate (`src/host/ingress-gate.ts`)**: binds exclusively to `127.0.0.1` and forwards only WebSocket upgrades to `/ios/v1` to the host's own protocol port, rewriting the `Host` header to the loopback authority. Every other plain HTTP request and non-whitelisted upgrade is answered immediately with `404 {"error":"not-found"}` and closed without opening any upstream connection.
- **Tunnel target is the ingress gate, not the webServer**: cloudflared quick and named tunnels target the dynamic loopback port bound by `ingress-gate`, never the harness `webServer` port. A request routed through the tunnel cannot reach webServer routes, internal APIs, or other plugins.
- **Relay registration and public exposure**: the relay registration row only writes the quick tunnel URL, which points to the ingress gate, so the public internet still only sees /ios/v1. When the plan is quick and the tunnel is running, the gate URL is announced to the relay Worker (proxying the stable subdomain to this machine; endpoints and suffix are determined by configuration, defaulting to `<id>.dsh-market.com`); plugin disposal clears local references without withdrawing the row. QR public address selection is decided by `livePublicOriginOf`: bridge or named leases prioritize the stable subdomain; quick leases fall back to the tunnel running URL while registration is unconfirmed; no lease uses the tunnel URL; none returns no address. Fallbacks then check `DSH_REMOTE_PUBLIC_BASE_URL`, and the LAN fallback provides an address only when the LAN forwarder is actually listening using its snapshot's bound address and port (never guessing the first IP in the address list, returning no address when stopped; the disk registry row fallback has been completely removed, eliminating the last data dependency on legacy data files).
- **LAN forwarder target is the ingress gate**: the LAN forwarder listens on the machine's LAN IPv4 address and re-issues traffic exclusively to the `ingress-gate` port, so local network reachability is also constrained strictly to `/ios/v1`.
- **Relay bridge connector whitelist**: the Durable Object multiplexer in `relay-bridge-connector.ts` applies the same whitelist. Non-`/ios/v1` HTTP streams receive `404 {"error":"not-found"}`, and non-whitelisted WebSocket upgrades return `ws-fail 404 {"error":"not-found"}`, establishing no upstream connection.
- **Historical spelling retention and coexistence caveat**: the registry directory `$DSH_HOME/remote-web-ui-registry/` and state file `remote-web-ui-relay-bridge.json` intentionally retain their historical names so existing profiles maintain identity continuity and previously paired devices keep their credentials across upgrades. **Known risk and coexistence caveat**: never enable relay concurrently on both this plugin and the deprecated `dsh-remote-web-ui` in the same profile — both share the same identity file per profile; mounting the bridge with `relay=false` withdraws the registration row and releases the lease for that shared identity, breaking the legacy plugin's active relay, while running both with relay enabled causes bridge connectors to compete over the same Durable Object connection (or quick tunnel registrations to overwrite each other). This cleanup behavior is intentional.
- **No browser pairing surface**: this package does not include browser pairing cookie gates, `/api/pair` endpoints, `/remote` proxy routes, or mobile web touch CSS adaptation layers.

## Configuration

The plugin is configured through profile YAML (`cordis.patch.yml` or profile configuration files); it provides no settings UI. Unconfigured fields fall back to their built-in defaults:

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `relay` | boolean | `true` | Whether to claim relay leases. Enabled by default; when no tunnel is configured, leases are claimed on demand once a device is paired or pairing is requested. |
| `relayControlUrl` | string | `'https://dsh-market.com/api/relay'` | Relay control-plane base URL; claim, release, register, and unregister endpoints are all derived from it. |
| `relayPublicSuffix` | string | `'.dsh-market.com'` | Stable hostname suffix. |
| `autoTunnel` | boolean | `false` | Whether to start a Cloudflare quick tunnel targeting the ingress gate. |
| `lanBind` | boolean | `undefined` (off) | Whether to start the LAN-edge forwarder listening on the machine's local IPv4 address. |
| `tunnelToken` | string | `undefined` | Cloudflare named tunnel token for running named tunnels. |
| `publicBaseUrl` | string | `undefined` | Fixed public HTTPS base URL for named tunnels. |
| `ingressSync` | boolean | `false` | Whether to sync named tunnel ingress service ports with Cloudflare. |
| `cfApiToken` | string | `undefined` | Account-level Cloudflare API token for ingress sync. |
| `cfAccountId` | string | `undefined` | Cloudflare account ID for ingress sync. |
| `cfTunnelId` | string | `undefined` | Cloudflare named tunnel ID for ingress sync. |
| `profile` | string | `'desktop'` | Managed profile name (defaults to `$DSH_PROFILE` or `'desktop'`). |

## Web GUI pairing panel

The browser half exists so that an operator who never opens a terminal still has a way to start pairing. It follows the family's sidebar-foot convention: the trigger registers into the official `sidebar.footer.action` list slot (declared by ui-sidebar, sharing this list slot with `dsh-update`'s updater) and opens the official `Modal`, whose headless card this package fills. Every control it renders is a library primitive — the dialog, the three actions and their glyphs — so the mask, the card, the focus trap, Escape, focus restoration and each hover/focus/disabled state are the shell's behavior rather than a copy of it. It is delivered by the same plugin row: `exports["./client"]` is the artifact and `dsh.client` declares the client modules that must be live first.

The panel has one job — put the live one-time code in front of the operator, in one reading order:

- **Header and subtitle set the scope.** Title "iOS app pairing"; subtitle "Scan the QR code with the iOS app (beta) to connect an iPhone to this computer. The code is shown only on this machine.", stating the target and security boundary.
- **The QR symbol is the code.** It encodes either the bare 43-character base64url token or, when the host can name its own public origin, the `{token,url}` document one scan turns into a fully configured iPhone. A URL the host did not vouch for would let the app's own parser rewrite its origin onto this machine's loopback address, which is precisely the address an iPhone cannot reach. The encoded string is mirrored once, as the `data-dsh-qr-value` attribute on the symbol's part, so a UI check can compare it without decoding anything; it is never rendered as text.
- **One status line under the symbol.** A live code counts its 30 s window down beside a progress bar; an expired code states the expiry in the same fixed box, so only the sentence changes.
- **One address line.** A located origin is stated as a kind rather than printed: "The QR code carries the public address: the iPhone connects to this computer as soon as it scans". The one-time code burns 30 seconds after it appears, while a durable public hostname, once rendered as text, outlives every screenshot it appears in; the symbol already carries the address the phone needs, and Copy hands over the same `{token,url}` string either way. A LAN literal is not that fact, and the line names it — "The QR code points at the LAN address {origin}: the iPhone must be on the same network" — when the host calls the origin a LAN address or when the literal itself answers only inside the network this page is already on. With no located address the line says outright "No public address found: set the connection address in the app to your public relay first, then scan. {host} (this page) is reachable only from this machine."
- **One single-use note.** The code is single-use: pairing spends it, and a new code voids the previous one.
- **One action row.** Copy puts the very string the symbol encodes on the clipboard, and **New code** mints a fresh token and voids the previous one; both are one library control at one size, so their states never diverge. Once the code expires, mint-again is the only way back to a scannable symbol: copy is disabled and the symbol is dimmed in place instead of resized or removed.
- **One beta entry.** Provides "[The iOS app is in beta. Come talk to us on Discord](https://discord.gg/nBy28kjun)", clickable across the entire line.
- **A refusal is never silent.** The read is the same loopback-only route an operator would curl: a `403` renders as "this page is not open on this machine" and offers nothing to press, a request that never arrives renders as its own unreachable state with a retry, and an unexpected answer names the status it got.

Opening and closing are one motion in both directions: the card stays mounted and plays the dialog's own enter fade backwards, and the reduced-motion path keeps that lifecycle as one short opacity fade rather than dropping the exit. Geometry is held across every state — the symbol's box, the status row, the address line and the copy label keep their height and width — so a countdown, an expiry and a copy confirmation never move their neighbours.

The panel is display-only for the native path. It never spends the token (`pair.begin` stays the phone's call), it never touches the roster, and it adds no wire surface — the envelope, the method names, the topic names and the events are unchanged. The package provides no browser pairing surface or cookie gate.

## Interactive cards in the transcript

`ui_present` hands one whole self-contained page into the session, and the browser half draws it inside the official tool row: the presentation is one keyed `tool.call.toolview` registration under the wire tool name `ui_present`, so the row keeps the call identity, the timing and the dispatch tree the shell already renders, and no Chat Node renderer is shadowed. This is the desktop landing point of the interactive reply extension; a client that composes no Tool layer has no keyed cell, and the card does not appear there.

The row reads its card from two sources and keeps both:

- **While the call runs** the arguments are the only material on the wire, so the page comes out of the call's own `html` argument and the header marks the card as waiting for the operator.
- **Once the result lands** the presentation payload `meta.ui` is the card's own record — its `key`, `title`, `html`, `height` and `state` — and the header marks it settled. A result that carries no payload falls back to the argument page and still reads as settled, because the stage, not the absent field, decides that.
- **The frame is `srcdoc` under the one sandbox value the extension allows.** It is `allow-scripts` and never `allow-same-origin`, so the page has an opaque origin and cannot reach the GUI's storage, cookies or DOM; top-level navigation, popups and forms are not granted, and camera, microphone, location and clipboard reads are denied by an explicit permissions policy.
- **The desktop renders and does not answer.** An action the page posts arrives as one visible sentence telling the operator to answer on the phone; nothing is forwarded, stored or requested, so this half adds no wire surface at all.
- **Height and theme come from the outside in.** The presenter's `height` covers the first paint, an injected reporter posts the document's own content height and the measurement replaces it; the theme's semantic tokens are read off the GUI root and handed to the page, so a card follows the running light or dark scheme, and a theme that cannot be read costs the variables rather than the card.
- **A refusal is visible.** A page over 256 KiB is never handed to a frame and states that it was not rendered, and a call that carries no page keeps a neutral line instead of an empty row.


## Client script

`scripts/client.mjs` is a dependency-free protocol v1 client — Node 22 globals only, no package dependency — used to check a live bridge and to pair a test machine that has no Secure Enclave. It runs the fixed sequence once, prints every frame with its direction and numbers the events in arrival order, so a transcript can be pasted into a report; it never calls a model itself.

```sh
# check a paired connection: hello -> session.list -> session.snapshot -> session.prompt -> session.delta
BRIDGE_URL=ws://127.0.0.1:3080/ios/v1 BRIDGE_TOKEN=<deviceToken> node scripts/client.mjs

# pair this machine with a throwaway P-256 key and print the device token
node scripts/client.mjs --pair --pair-base http://127.0.0.1:3080
```

## Install

### From npm

```sh
dsh plugin --profile web add @linxin666/dsh-remote-bridge@latest
```

### From the repository

```sh
git clone https://github.com/zhu1090093659/dsh-web.git
cd dsh-web
pnpm install && pnpm -r build
dsh plugin --profile web add link:$(pwd)/packages/dsh-remote-bridge
```

This plugin is a family member: the aggregate bundle `@linxin666/dsh-web-all` installs it as its `remote-bridge` row together with the rest of the family, so adding the bundle is enough. Installing this package on its own stays supported, and remains the route for following this plugin's own version instead of the bundle's. Both routes load the same npm package, so a profile that carries both rows at once still registers the host half exactly once — `/ios/v1` and the token route belong to whichever source mounts first, and the family's single-instance guard replays a refused mount when that owner is torn down while the host keeps running. Restart `dsh web` after installing or updating — the host half registers its routes when the plugin row mounts, so the running host is what serves them — then reload the Web GUI page, which is when the browser half's sidebar-foot trigger appears.

## Configuration

The bridge is mounted like any other host plugin row. It carries no user-facing options in v1: the values in this document — the 30 s one-time bootstrap token, the loopback-only token endpoint, the 10 MiB frame cap, the fixed `/ios/v1` path — are protocol constants rather than settings. Pairing and revocation are not configuration either; they are per-device facts in the roster. The one environment fact the bridge depends on is `$DSH_HOME`, since the roster lives under it.

## Security model

- **Pairing is a full-control credential.** A paired device reaches the harness in-process: sessions, prompts, approvals. Only pair devices you control, and revoke by removing the roster row and restarting the host.
- **In-process calls speak for the owner, only behind this bridge's own gate.** Since DSH 0.2.1 the harness gateway requires every call to name the peer it speaks for, and the bridge answers with one in-process owner peer (`{kind:'owner'}`) that it mints itself at mount and attaches to every gateway call. That claim is safe because it is exercised only after the bridge's own pairing check — an unpaired connection is refused before any gateway call exists — and no field of a protocol frame can set a principal: a client that names one in its payload is ignored. The peer takes no forwarded-event interactions, so it never receives forwarded Host events either.
- **The token endpoint is loopback-only, on two conditions.** The socket address must be loopback and the `Host` header must name this machine. A tunneled request is terminated by the tunnel client on this same host, so the socket address alone reads `127.0.0.1` for a request the whole internet can reach, while the relay and a plain tunnel rewrite `Host` to the public name; failing either condition is a 403. The token is memory-only, expires in 30 s, is spent by one `pair.begin`, and the machine-local link is written to the host log — treat that log line as a live secret. The phone's side of that line is the kind of address alone (`(phone: public)` or `(phone: lan)`, no suffix when there is none): this pairing line is the one an operator pastes elsewhere, and a public hostname or a LAN literal in it would outlive the code it belongs to.
- **The pairing panel renders a live secret and nothing more.** It reads the same loopback-gated route over a same-origin request, so it is reachable exactly where the `curl` form is, and nowhere else. It cannot mint a device token, spend one, list the roster or revoke a device: `pair.begin` stays the phone's call, and revocation stays a roster edit plus a host restart. The QR carries either the bare token or the host's own `{token,url}` answer, so the page's `127.0.0.1` origin is never handed to the phone.
- **An unpaired connection has a three-method whitelist.** `hello`, `pair.begin` and `pair.finish`; every other method answers `unpaired` before dispatch, so no session, approval or roster surface is reachable without a device token.
- **Device tokens are stored as hashes only.** The roster keeps the public key and its SHA-256 fingerprint, never the token, so the file is not a credential store. A lost, rotated or rejected token means pairing again — a routine flow, not an exception path (a fresh bootstrap link, or a re-scan of the desktop pairing flow), and it never requires editing the roster by hand.
- **The token comparison does not leak length.** It is a fixed-length comparison rather than an early-exit one, so a wrong token cannot be narrowed byte by byte.
- **Approvals fail closed while a client is connected.** The bridge claims a tool's permission request only while at least one paired client is connected; with none, the request goes to the next answerer rather than stalling. A claimed request that nobody answers normalizes to `unavailable`, which the harness treats as a rejection: silence from the phone is never consent.
- **The header is the safer carrier.** `Authorization: Bearer <deviceToken>` keeps the credential out of the URL; `?token=` exists for clients that cannot set handshake headers, and the relay, the edge and the access log all see a URL that authenticates as that device.
- **Relay tier routing and grant credential boundaries.** The device roster (`devices.json`) schema upgrades from 3 to 4, adding an optional `relay: {grant, receivedAt}` to each device row. That `grant` is a 30-day HS256 credential signed by the account service, handed from the phone to the host over an **authenticated, paired connection**; the host merely passes it verbatim in the claim request body. The host therefore still does not know who the account is or what tier it holds: it only knows whether it holds a lease admitted by the relay. Tier evaluation occurs in a single loopback request between the self-hosted relay and the account service; the relay writes quotas into the lease row without recording any user identifier. The `hello.relay` announcement, capability identifier `relay.tier.v1`, and the `relay.grant` frame **exist only when a second meeting point is configured**; without configuration, the handshake is byte-for-byte identical to today, and that frame answers `unknown-method`. The two configuration keys are `relaySecondaryControlUrl` and `relaySecondaryPublicSuffix`, both unconfigured by default.

## Known limitations

- **`session.titles` answers live sessions only.** It reads the titles of the sessions this host process holds live, so an id the host does not hold live answers `""` there; the roster's own rows carry their projected title, cold sessions included, and are named without that call.
- **A served title can be up to 10 s old.** One resolved title is served for 10 s before the title service is asked again, so a rename or a newly generated title can take that long to appear.
- **`archived` is absent when the archive set cannot be read.** The flag comes from the host's workspace registry; when that service is not mounted, every row omits the key instead of answering `false`, and nothing is dropped — a bridge that cannot tell does not answer a shorter roster.
- **An archived parent authorizes nothing.** A live child of an archived parent is still listed and still names that parent, but following the child answers `session-not-followable` until the parent is restored: the roster is what authorizes a session, and an archived one is no longer on it.
- **`lastSeq` and `seq` are different counters.** `session.snapshot`'s `lastSeq` is the harness stream cursor; the event `seq` is this connection's own event number. They must not be compared, and resume state belongs to the client.
- **A delta exists only where continuity is provable.** The proof is the current follow window's oldest entry, so a reconnect into a window that has already scrolled past `sinceSeq` — or a delta that would not fit one frame — is answered the full baseline with `reset:true`. The bridge still buffers nothing for a disconnected client: it reads the same window it would have opened anyway.
- **A delegated child costs one roster read per connection at the most.** A child's durable parent is only published by `session.list`, so the first child a connection opens without a prior listing pays for one roster read; every later child of any parent is free, and a client that lists before it opens pays nothing. A roster that resolves no parent for the child answers `session-not-followable` instead of inventing an address, and a child whose parent is not a session this connection is authorized for answers the same.
- **`session.history` cannot reach below the opening window.** Pages walk the same baseline `session.snapshot` serves, so the oldest reachable entry is the oldest entry of that window — bounded by the harness's own `maxMessages` window (200 messages). The opening frame's `hasMore` is the harness's answer about anything older, and the bridge does not fetch beyond it.
- **An entry larger than one frame cannot be made to fit.** The paged first screen and `session.history` both give such an entry a page of its own rather than refusing, so that one page can exceed the 10 MiB frame cap; every other page stays inside its budget.
- **Tool-call arguments arrive as deltas.** A `tool-call` delta may carry an empty `argumentsDelta`, and the client assembles the arguments; a whole call is never re-sent.
- **A subscription outlives an interrupt.** `session.interrupt` cancels the turn only; the subscription ends when the connection closes, and one connection aborts all of its follow streams together.
- **`clientTimeZone` is host-side.** The bridge fills it from the host's IANA zone; a client cannot pin its own zone in v1.
- **Roster edits apply on the next host start.** The roster is read once per process, so removing a device takes effect when `dsh web` restarts.
- **The panel shows one code and nothing else.** It cannot list, name or revoke paired devices, and it never spends a code — pairing happens on the phone, and revocation stays a roster edit plus a host restart.
- **The panel is only as fresh as its last read.** It reads a code when it opens and on every **New code** press; a code that expires while the panel stays open flips to the expiry state instead of silently renewing itself.
- **`scripts/client.mjs` is a check harness, not a client SDK.** It runs the fixed sequence once, keeps no state and has no reconnect logic.
- **A card's own subframes are not blocked on the desktop.** The phone refuses a nested `<iframe>` inside a card as a cross-document jump; a sandboxed frame here has no equivalent interception point, so a page's own subframe renders while every frame keeps its opaque origin.
- **The keyed registration reaches its slot through a local seam.** The SDK package that declares `tool.call.toolview` is not a dependency of this package, so the slot is reached through a narrowed `ctx.slots` port that mirrors the owner's stage union; taking that package as a devDependency replaces the seam with the SDK's own types.
