dsh-remote-bridge · Protocol v1 Host Adaptor for DeepSeek Harness (DSH)
English | 中文
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 |
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 — , , , addressed at the origin already serves and carrying that origin in the 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 read read-only under the same rules, with 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 is answered 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 , then the DeepSeek adapter's own reference (default ) — 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 instead of a previous reading presented as current. A client that never declared is answered for both methods, with no event folded and no upstream request made on its behalf. reads the ledger another plugin keeps, so it is its own capability behind and its own read path: one and, only when the requested window reaches deeper than the 30 days that document already carries, one 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 (), unreachable, shaped differently or too slow answers with a short reason and never a previous reading. One 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. gained one optional key beside the window: when the host has broadcast no window, the answer may carry , taken from of the session's current route — the first available of , and 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: is always the host's measurement, 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, , ) survive, and the harness's own bookkeeping records (, 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 , , and are the projection's own values, a turn whose entries are all bookkeeping stays as a turn with an empty array, and 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 .
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 and , 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. was when the session-admin channel shipped: behind a client may call and read the host's workspaces with both registry-global session sets, and behind it may rename, pin, unpin, archive and unarchive a session, so it reads six new reply bodies off the wire. was when the question list shipped: behind a client may call 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. was when the command channel shipped: behind a client may call and , so it reads a new reply body off the wire. was when the fork channel shipped, one step above the snapshot- channel that landed on the integration branch first: behind a client may call and be answered the host's own fork result. was when the snapshot became incremental on request: behind a that names is answered either the entries above that watermark or a full baseline carrying and , so the number rises for a reply shape a client reads off the wire. was when the compact roster shipped: behind a listing that names , or is answered a shorter row that may be paged, so the number rises for a row schema a client reads off the wire. was when the outbound frame form became a capability: behind 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. was when the two channels met; was either channel on its own — the agent channel on dev and durable history paging on its branch; was when the roster stopped carrying archived sessions. Behind , and forward the host's preset roster and one creation request; behind , pages a session's durable store back to its first entry; behind , branches one session into a new one that inherits it. added byte-level increments, the background-job channel, the subagent thread, and 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 |
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 link is printed to the host log, while the code itself renders in the pairing panel for scanning.
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:
{
"token": "...",
"expiresAt": 1760000030000,
"url": "ws://127.0.0.1:3080/ios/v1?token=...",
"origin": "wss://<id>.dsh-market.com",
"originSource": "registry",
"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:
{
"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. : never enable relay concurrently on both this plugin and the deprecated in the same profile — both share the same identity file per profile; mounting the bridge with 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.
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. When the host located its relay origin, the line states "The iPhone will dial {origin}"; otherwise it 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", 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.
# 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
dsh plugin --profile web add @linxin666/dsh-remote-bridge@latest
From the repository
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 same link is written to the host log — treat that log line as a live secret.
- 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.
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 is the harness's answer about anything older, and the bridge does not fetch beyond it.