zcode-acp-server 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +101 -15
  2. package/README.zh-CN.md +68 -13
  3. package/dist/bin/hub.d.ts +16 -0
  4. package/dist/bin/hub.d.ts.map +1 -0
  5. package/dist/bin/hub.js +41 -0
  6. package/dist/bin/hub.js.map +1 -0
  7. package/dist/handlers/account.d.ts +43 -0
  8. package/dist/handlers/account.d.ts.map +1 -0
  9. package/dist/handlers/account.js +59 -0
  10. package/dist/handlers/account.js.map +1 -0
  11. package/dist/handlers/io.d.ts +20 -1
  12. package/dist/handlers/io.d.ts.map +1 -1
  13. package/dist/handlers/io.js +57 -2
  14. package/dist/handlers/io.js.map +1 -1
  15. package/dist/handlers/replay.d.ts +79 -0
  16. package/dist/handlers/replay.d.ts.map +1 -0
  17. package/dist/handlers/replay.js +256 -0
  18. package/dist/handlers/replay.js.map +1 -0
  19. package/dist/handlers/session.d.ts.map +1 -1
  20. package/dist/handlers/session.js +83 -68
  21. package/dist/handlers/session.js.map +1 -1
  22. package/dist/handlers/slash.d.ts +23 -1
  23. package/dist/handlers/slash.d.ts.map +1 -1
  24. package/dist/handlers/slash.js +67 -6
  25. package/dist/handlers/slash.js.map +1 -1
  26. package/dist/index.js +47 -17
  27. package/dist/index.js.map +1 -1
  28. package/dist/remote/broadcast.d.ts +47 -0
  29. package/dist/remote/broadcast.d.ts.map +1 -0
  30. package/dist/remote/broadcast.js +121 -0
  31. package/dist/remote/broadcast.js.map +1 -0
  32. package/dist/remote/config.d.ts +32 -0
  33. package/dist/remote/config.d.ts.map +1 -0
  34. package/dist/remote/config.js +65 -0
  35. package/dist/remote/config.js.map +1 -0
  36. package/dist/remote/endpoint.d.ts +43 -0
  37. package/dist/remote/endpoint.d.ts.map +1 -0
  38. package/dist/remote/endpoint.js +222 -0
  39. package/dist/remote/endpoint.js.map +1 -0
  40. package/dist/remote/hub-server.d.ts +41 -0
  41. package/dist/remote/hub-server.d.ts.map +1 -0
  42. package/dist/remote/hub-server.js +346 -0
  43. package/dist/remote/hub-server.js.map +1 -0
  44. package/dist/server.d.ts +62 -7
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +99 -11
  47. package/dist/server.js.map +1 -1
  48. package/dist/utils.d.ts +1 -1
  49. package/dist/utils.d.ts.map +1 -1
  50. package/dist/utils.js +17 -1
  51. package/dist/utils.js.map +1 -1
  52. package/docs/ARCHITECTURE.md +47 -15
  53. package/docs/BACKLOG.md +3 -1
  54. package/docs/DEVELOPMENT.md +26 -0
  55. package/docs/PROTOCOL.md +67 -27
  56. package/docs/REMOTE-CLIENTS.md +264 -0
  57. package/docs/REPLAY-GUIDE.md +131 -0
  58. package/docs/TROUBLESHOOTING.md +51 -6
  59. package/docs/adr/0001-bridge-lifetime-follows-primary-client.md +14 -0
  60. package/docs/adr/0002-stateless-hub-over-per-bridge-acp-endpoints.md +23 -0
  61. package/docs/adr/0003-tail-replay-meta-and-cursor-pagination.md +40 -0
  62. package/docs/proposals/0001-tail-session-replay.md +136 -0
  63. package/docs/proposals/0002-plan-quota-usage.md +81 -0
  64. package/package.json +5 -2
@@ -0,0 +1,264 @@
1
+ # Remote Clients — Integration Guide
2
+
3
+ How to attach any out-of-editor client — browser SPA, mobile app, CLI, desktop
4
+ tool — to bridge sessions over the network. This document IS the contract:
5
+ everything here is implemented by `zcode-acp-hub` and the bridge's remote
6
+ endpoint; anything not written here is not part of the contract.
7
+
8
+ ACP method semantics are defined by the [ACP spec](https://agentclientprotocol.com);
9
+ this guide covers only the transport, discovery, and the multi-client behaviors
10
+ on top of it. For how ACP methods map to the ZCode backend, see
11
+ [PROTOCOL.md](PROTOCOL.md).
12
+
13
+ ## Topology
14
+
15
+ ```text
16
+ remote client ──WS── tunnel ── hub (single entry, one mapped port)
17
+ │ byte-level proxy, no ACP semantics
18
+
19
+ bridge ACP endpoint (loopback, never exposed)
20
+ │ same AgentApp as stdio
21
+ ACP editor ────── stdio ──────────┘
22
+ ```
23
+
24
+ - The hub is the **only** public entry. It does token auth, instance discovery,
25
+ and byte-level WebSocket proxying — no session state, no ACP semantics
26
+ (ADR-0002). The bridge endpoint is loopback-only; nothing dials it but the
27
+ hub.
28
+ - One WS connection is bound to **one bridge instance** for its whole lifetime.
29
+ Switching instances means opening a new connection.
30
+ - The bridge process lives and dies with the editor that spawned it (ADR-0001):
31
+ close the editor and every remote attachment drops. There is no standalone
32
+ server that outlives the editor.
33
+
34
+ ## Security model
35
+
36
+ - One shared bearer token (`ZCODE_ACP_REMOTE_TOKEN`) guards both the discovery
37
+ API and the ACP WebSocket. Possession of the token equals **full control of
38
+ every agent session** — prompting, answering permissions, tool-driven file
39
+ writes. Treat it like a password: long, random, never committed.
40
+ - The hub speaks plain HTTP/WS. TLS is expected from the tunnel in front
41
+ (Cloudflare Tunnel terminates it; with frp, terminate TLS in front or keep
42
+ the network trusted). The token on cleartext HTTP over an untrusted network
43
+ is a credential leak.
44
+ - `/api/*` responses carry `Access-Control-Allow-Origin: *` — the token is the
45
+ security boundary; there is no origin restriction.
46
+
47
+ ## Discovery API
48
+
49
+ | Endpoint | Auth | Purpose |
50
+ | -------------------- | -------- | ------------------------------------------------------------ |
51
+ | `GET /api/health` | none | Liveness probe; `200` body `ok`. |
52
+ | `GET /api/instances` | required | Registered bridge instances. Add `?probe=1` to verify first. |
53
+
54
+ HTTP auth: `Authorization: Bearer <token>` or `?token=<token>`.
55
+
56
+ `/api/instances` returns a JSON array (sorted by start time):
57
+
58
+ ```json
59
+ [
60
+ {
61
+ "id": "72341",
62
+ "port": 8378,
63
+ "pid": 72341,
64
+ "startedAt": 1723800000000,
65
+ "workspace": "/Users/me/proj",
66
+ "sessions": [{ "sessionId": "5f0c…", "title": "Fix login bug", "updatedAt": 1723800012000 }]
67
+ }
68
+ ]
69
+ ```
70
+
71
+ - `id` is the bridge process id — stable for that editor window's lifetime,
72
+ unique per window.
73
+ - **On refresh, call `/api/instances?probe=1`**: the hub TCP-probes each
74
+ registered bridge's loopback port and prunes unreachable ones before
75
+ answering. A plain `GET` returns the heartbeat-based view, which can list a
76
+ hard-killed bridge for up to the 30s heartbeat TTL.
77
+ - `sessions[].sessionId` is the ACP session id: pass it to `session/load`
78
+ after connecting. `title` is adopted from the backend for resumed sessions
79
+ and set after a fresh session's first turn — it can still be absent for a
80
+ session that has never completed a turn.
81
+ - `sessions` only lists sessions with real interaction. Editors restart into a
82
+ stored placeholder and materialize an empty backend session — those stay
83
+ hidden and appear within one heartbeat (~10s) after their first prompt (or
84
+ a titled resume/load).
85
+ - Poll every 3–5s. There is no push notification for registry changes yet.
86
+ - Fields are **additive-only** across releases — ignore fields you don't know.
87
+
88
+ Lifecycle timings: a bridge re-registers every 10s (the registration doubles as
89
+ heartbeat); an instance disappears ~30s after its heartbeats stop; the hub
90
+ exits after ~10 idle minutes with no instances and no proxies, and the next
91
+ bridge re-spawns it on demand.
92
+
93
+ ## Connecting
94
+
95
+ ```text
96
+ ws(s)://<hub-host>/acp?instance=<id>&token=<token>
97
+ ```
98
+
99
+ - Native clients may send `Authorization: Bearer <token>` instead of the query
100
+ parameter; browsers cannot set WS headers, which is why `?token=` exists.
101
+ Prefer the header when you can — it keeps the token out of URLs and logs.
102
+ - Handshake failures (bad token, unknown instance id) destroy the socket
103
+ before open. Treat any non-open outcome as "re-discover, then retry".
104
+ - Framing: one JSON-RPC message per **text** frame. Binary frames are ignored.
105
+ - The hub sends WebSocket pings every 30s on both legs (tunnels drop idle
106
+ links). Browser and native WS stacks answer pongs automatically — nothing to
107
+ implement, but don't disable pongs.
108
+
109
+ ## ACP session flow
110
+
111
+ 1. `initialize` — `protocolVersion` MUST be the **number** `1` (a string is
112
+ rejected). Nothing else may be sent before it.
113
+ 2. Attach or create:
114
+ - `session/load { sessionId, cwd, mcpServers }` with an id from discovery —
115
+ replays the conversation history (text + tool summaries) as
116
+ `session/update`s, so a freshly attached client can render the full
117
+ story. `cwd` and `mcpServers` (even `[]`) are required — the SDK's params
118
+ schema rejects the request without them.
119
+ - `session/new { cwd? }` — a new session on that bridge.
120
+ - `session/list` enumerates the bridge's known sessions.
121
+ 3. Drive: `session/prompt`, `session/cancel`, `session/set_config_option`
122
+ (model / mode / thought level), slash commands in the prompt text —
123
+ see [PROTOCOL.md](PROTOCOL.md).
124
+
125
+ ## Account quota (`account/usage_stats`)
126
+
127
+ Non-standard, additive (Proposal 0002). Plan quota is **account-level**, so it
128
+ is a pull-only request — callable any time after `initialize`, no session
129
+ required. Fetch once after attach and on demand; quota changes are slow, there
130
+ is no push.
131
+
132
+ The response mirrors the `zcode-quota` CLI card's data model — one GLM section
133
+ plus one Opencode Go section — so clients can reproduce the CLI layout
134
+ exactly:
135
+
136
+ ```json
137
+ → { "id": 7, "method": "account/usage_stats", "params": {} }
138
+ ← { "id": 7, "result": {
139
+ "glm": {
140
+ "kind": "success",
141
+ "level": "pro",
142
+ "items": [
143
+ { "key": "token_5h", "label": "5h", "usedPercent": 35,
144
+ "nextResetTime": 1723812000000 },
145
+ { "key": "mcp", "label": "MCP", "usedPercent": 10, "usedCount": 3,
146
+ "totalCount": 30, "nextResetTime": 1723812000000,
147
+ "detail": [{ "modelCode": "search-prime", "usage": 2 }] }
148
+ ]
149
+ },
150
+ "opencode": {
151
+ "kind": "success",
152
+ "windows": [
153
+ { "key": "rolling", "label": "5h", "usagePercent": 5,
154
+ "resetsAt": 1723812000000 },
155
+ { "key": "weekly", "label": "Week", "usagePercent": 25,
156
+ "resetsAt": 1724071200000 }
157
+ ]
158
+ }
159
+ } }
160
+ ```
161
+
162
+ - `glm` (`kind`: `success` | `auth_error` | `rate_limited` | `unavailable`):
163
+ on success, `level` is the plan level and `items` carries one entry per
164
+ window (`5h` / `Week` / `MCP`) with `usedPercent` (0–100) always present;
165
+ `usedCount`/`totalCount`/`nextResetTime` (epoch ms) and the per-model
166
+ `detail` breakdown only when the API reports them.
167
+ - `opencode` (`kind`: `success` | `not_configured` | `auth_error` |
168
+ `unavailable`): on success, `windows` carries the rolling (`5h`) / weekly
169
+ (`Week`) / monthly (`Month`, when exposed) windows; the dashboard's relative
170
+ countdown is resolved to an absolute `resetsAt` (epoch ms). `not_configured`
171
+ means the user never set OpenCode Go credentials — omit the section, like
172
+ the CLI does.
173
+ - Provider failures are per-section `kind` strings, not JSON-RPC errors —
174
+ render the same status line the CLI would (e.g. auth expired) and retry
175
+ later. Only transport-level failures reject the request.
176
+ - Cached ~10s server-side (same caches as the `/quota` command).
177
+
178
+ ## Slash-command handling
179
+
180
+ Only the commands the bridge advertises via `available_commands_update` (plus
181
+ `skill`/`init` and `$`-skills) are treated as commands. Any other `/`-leading
182
+ prompt — e.g. a pasted directory path — is delivered to the model as plain
183
+ text with an invisible zero-width-space prefix; clients see the text verbatim
184
+ in replay and echoes. Clients should not special-case this.
185
+
186
+ ## Tail replay and history pagination
187
+
188
+ Replaying a long session's full history is O(history) on every attach and
189
+ reconnect. The bridge supports tail replay (non-standard, additive — omit
190
+ everything below and you get the full replay):
191
+
192
+ - **Tail limit**: `session/load` with `_meta.zcode.limit` (NOT top-level —
193
+ the SDK's params schema strips unknown top-level keys; `_meta` is the
194
+ preserved extension channel). It counts **messages**, and the replay is
195
+ aligned back to the start of the turn containing the oldest message — never
196
+ a mid-turn cut. `0` attaches with metadata only. Clamped to `[0, 500]`.
197
+ - **`replayMeta`** rides top-level in the result:
198
+
199
+ ```json
200
+ {
201
+ "replayMeta": {
202
+ "cursor": "…",
203
+ "hasMore": true,
204
+ "replayedMessages": 47,
205
+ "replayedTurns": 12,
206
+ "totalMessages": 1893,
207
+ "totalTurns": 412
208
+ }
209
+ }
210
+ ```
211
+
212
+ - **`session/load_earlier`** (`{ sessionId, before, limit }`, limit defaults
213
+ to 50) delivers one page of `session/update`s strictly older than `before`,
214
+ oldest → newest — prepend them. Same `replayMeta` shape in the result;
215
+ `hasMore: false` ends pagination. Requires the session to be attached in
216
+ this bridge; it never triggers an implicit backend resume.
217
+ - **Cursor expiry**: a cursor is valid only while the history it points into
218
+ is unchanged — turns appended after it was minted (the session moved on)
219
+ keep it valid. After the session compacts or truncates, `load_earlier`
220
+ returns a `"cursor expired"` error — the recovery is a fresh `session/load`.
221
+
222
+ While a replay batch is in flight, live updates for the same session queue
223
+ behind it: batches are atomic and never interleave with the live turn.
224
+
225
+ UI-side recipes for consuming all of this — state model, prepend handling,
226
+ scroll pagination, reconnect recovery — live in
227
+ [REPLAY-GUIDE.md](REPLAY-GUIDE.md).
228
+
229
+ ## Multi-client semantics
230
+
231
+ The stdio editor and every remote client are peers on the same sessions:
232
+
233
+ - All agent notifications (`session/update`) are broadcast to every client.
234
+ - Permission and elicitation requests go to **every** client and the **first
235
+ response wins**. Losers receive `$/cancel_request` for the pending request
236
+ id — close the dialog and drop it. Never leave a request unanswered forever.
237
+ - Capabilities are OR-merged across clients: a remote client advertising e.g.
238
+ `elicitation.form` upgrades the shared interaction for the whole bridge.
239
+ - Concurrent prompts for one session are serialized by the bridge — two
240
+ clients prompting at once cannot interleave turns.
241
+
242
+ ## Failure & recovery
243
+
244
+ | Symptom | Cause | Client action |
245
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
246
+ | WS closes | bridge exited (editor closed) or network drop | Poll `/api/instances`; if the instance is gone, its sessions are gone too — drop it from the UI. |
247
+ | Instance missing from `/api/instances` | Heartbeats stopped >30s, or `?probe=1` found the bridge port unreachable | Remove the instance from the UI. |
248
+ | Connect fails for a while | Hub process died; a bridge re-spawns it on the next heartbeat (typically ≤10s, worst case ~1min under the spawn throttle). Also expected for a few seconds after a bridge upgrade: the hub notices a newer bridge, restarts, and is re-spawned from the upgraded install | Retry with backoff. |
249
+ | Disconnect mid-turn | Mobile network flap, background suspension | The turn continues server-side. Reconnect and `session/load` — history replay is the recovery path. |
250
+
251
+ Updates emitted while you are disconnected are not individually re-delivered;
252
+ `session/load` replay is the catch-up mechanism.
253
+
254
+ ## Platform notes
255
+
256
+ - **Browser**: a page served over `https://` can only open `wss://` — take TLS
257
+ from the tunnel. CORS is `*`, so any static host works; the client needs no
258
+ backend of its own.
259
+ - **Mobile**: background suspension kills the socket; on resume, reconnect and
260
+ `session/load` the previously open session. Store hub URL + token locally;
261
+ reconnect with exponential backoff. The 30s hub pings keep NAT mappings warm
262
+ while foregrounded.
263
+ - **CLI / native tools**: prefer the `Authorization` header; a one-shot
264
+ `session/prompt` + update stream is a perfectly fine first client.
@@ -0,0 +1,131 @@
1
+ # Replay guide — building a client UI on tail replay
2
+
3
+ Audience: frontend implementors (web, mobile, CLI TUI) of any ACP client for
4
+ this bridge. The **wire contract** (field names, errors, framing) lives in
5
+ [REMOTE-CLIENTS.md](REMOTE-CLIENTS.md) — this guide does not repeat it; it
6
+ shows how to _consume_ it: the UI state model, scroll-up pagination, and
7
+ reconnect recovery.
8
+
9
+ ## What changed and why you care
10
+
11
+ Before tail replay, every `session/load` (initial attach AND every reconnect)
12
+ replayed the **entire** history as `session/update` notifications. A measured
13
+ 280-message session cost ~800 notifications; sessions only grow. With tail
14
+ replay the same attach ships only the visible tail and older history arrives
15
+ on demand. Live numbers from the reference e2e run (471-message / 82-turn
16
+ session, `limit: 30`):
17
+
18
+ - `session/load` replayed 36 messages (30 requested, aligned to a turn start)
19
+ as 118 notifications, and returned `replayMeta` — projected full replay for
20
+ that session is ~1350 notifications (~91% cut).
21
+ - A follow-up `session/load_earlier` page delivered 30 more messages / 10
22
+ turns as 51 notifications.
23
+
24
+ Everything is additive: omit `_meta.zcode.limit` and you get the old
25
+ full-replay behavior unchanged.
26
+
27
+ One more replay-only behavior: harness-injected `<system-reminder>` blocks
28
+ (TodoWrite nudges, context handoffs) that the runtime appends to user turns
29
+ are stripped before replay, and user messages that contained nothing else are
30
+ dropped entirely. You never receive them as `user_message_chunk`, so there is
31
+ nothing to filter client-side — the user's transcript shows only what they
32
+ actually typed.
33
+
34
+ ## Attach strategy
35
+
36
+ Pick the limit from your UI budget, not from the history size:
37
+
38
+ - `limit: 0` — metadata-only attach. You get `replayMeta`
39
+ (`totalMessages`, `totalTurns`, `hasMore: true`, cursor at the end of
40
+ history) and zero replayed messages. Render an empty/"load older" state.
41
+ - `limit: N` — replay at most the last N **messages**, aligned back to the
42
+ start of the turn containing the oldest one. Expect
43
+ `replayedMessages ≥ N` when alignment extends the batch (the e2e run asked
44
+ for 30 and got 36). Never a mid-turn cut: a tool call always arrives with
45
+ its updates.
46
+ - No `_meta` — full replay (legacy/Zed path).
47
+
48
+ The response always carries `replayMeta`. `hasMore: false` means the whole
49
+ history is already in front of you — hide the "load older" affordance.
50
+
51
+ ## UI state model
52
+
53
+ Three id kinds arrive in `session/update` notifications; each kind merges
54
+ differently:
55
+
56
+ | Update kind | Id field | Merge rule |
57
+ | -------------------------------------------- | ------------ | ----------------------------------------------------------------------- |
58
+ | `user_message_chunk` / `agent_message_chunk` | `messageId` | append text to that message's bubble |
59
+ | `agent_thought_chunk` | `messageId` | append; ids carry a `thought_` prefix, so thoughts are their own stream |
60
+ | `tool_call` / `tool_call_update` | `toolCallId` | first `tool_call` creates the card, later updates mutate it |
61
+
62
+ - `messageId`s are the backend's stable message ids (the e2e run saw zero
63
+ fallback ids across hundreds of messages) — key your message list by them
64
+ and dedupe on every insert.
65
+ - One message = several chunks (text, thoughts, tool calls). Group chunks by
66
+ `messageId`/`toolCallId`, not by arrival order alone.
67
+ - Ordering rule: replay batches and `load_earlier` pages arrive **oldest →
68
+ newest and must be prepended**; live-turn updates arrive newest-last and
69
+ append. The bridge serializes a replay batch against the live turn for the
70
+ same session (they never interleave), so you can apply live updates while a
71
+ pagination page is in flight without ordering races.
72
+ - `usage_update` / `available_commands_update` are session-level metadata,
73
+ not list items.
74
+
75
+ ## Scroll-up pagination
76
+
77
+ ```
78
+ state: cursor = attachResult.replayMeta.cursor
79
+ hasMore = attachResult.replayMeta.hasMore
80
+
81
+ onScrolledNearTop():
82
+ if !hasMore or requestInFlight: return
83
+ res = request("session/load_earlier", { sessionId, before: cursor, limit: 50 })
84
+ prependUpdates(res.deliveredSessionUpdates) // keep the user's scroll anchor
85
+ cursor = res.replayMeta.cursor
86
+ hasMore = res.replayMeta.hasMore
87
+ ```
88
+
89
+ - `limit` defaults to 50; clamp is `[0, 500]`.
90
+ - `hasMore: false` ends the loop. A redundant extra call is harmless: it
91
+ returns an empty page with `hasMore: false`.
92
+ - Keep a scroll anchor when prepending, or every page will yank the viewport
93
+ to the top.
94
+
95
+ ## Cursor expiry — the one error to handle
96
+
97
+ A cursor dies only when the history **shrank** (compaction, truncation):
98
+ `session/load_earlier` then fails with `-32602 "cursor expired"`. Turns
99
+ **appended** after the cursor was minted (the conversation moved on) keep it
100
+ valid — you do NOT need to refresh the cursor after every live turn.
101
+
102
+ Recovery for `"cursor expired"`: re-run `session/load` with your tail limit
103
+ and rebuild the visible list from its `replayMeta`; deeper history comes back
104
+ through normal pagination. Treat it as a rare event, not a flow.
105
+
106
+ Never parse the cursor — it is opaque. (For the curious it round-trips
107
+ `{ v, index, totalTurns, id? }`, but the shape may change without notice.)
108
+
109
+ ## Reconnect recipe
110
+
111
+ 1. Re-discover the instance (`/api/instances`) — the bridge pid changes on
112
+ editor restart. Then `initialize` (`protocolVersion`: the number `1`),
113
+ then `session/load { sessionId, cwd, mcpServers: [] }` — `cwd` and
114
+ `mcpServers` are required even when empty.
115
+ 2. Attach with `limit` = your viewport budget, not what the user had scrolled
116
+ to. Diff against your cached messages by `messageId` (ids are stable
117
+ across restarts of both bridge and backend).
118
+ 3. Live updates fill the tail from here. If the user scrolls into history you
119
+ no longer have, `load_earlier` from the new cursor refetches just those
120
+ pages — do not try to restore the full old scroll depth on reconnect.
121
+
122
+ ## Checklist
123
+
124
+ - [ ] `limit` rides in `_meta.zcode.limit` (top-level unknown keys are
125
+ stripped by the SDK schema — silently).
126
+ - [ ] `session/load` params include `cwd` and `mcpServers` (even `[]`).
127
+ - [ ] Message list keyed/deduped by `messageId`; tool cards by `toolCallId`.
128
+ - [ ] Pagination pages prepended, live updates appended.
129
+ - [ ] `"cursor expired"` handled by full re-attach.
130
+ - [ ] Cursor stored per session, never parsed, never persisted across app
131
+ runs (it is only meaningful to the bridge that minted it).
@@ -67,13 +67,13 @@ a hardcoded version string — read the message text to identify the root cause.
67
67
 
68
68
  **Common causes:**
69
69
 
70
- | Message fragment | Cause |
71
- | -------------------------------- | -------------------------------------------------------------------------- |
72
- | `reader exited (backend dead)` | The zcode subprocess crashed/exited. Restart the editor session. |
70
+ | Message fragment | Cause |
71
+ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
72
+ | `reader exited (backend dead)` | The zcode subprocess crashed/exited. Restart the editor session. |
73
73
  | `timeout` | The per-attempt 10s subscribe deadline elapsed. The bridge retries transient timeouts up to 3× (the backend can be briefly busy finalising a cancelled turn after a preempt / `session/stop`); if all retries fail, the backend was unresponsive for ~30s. |
74
- | `pipe broken` | The stdin pipe to the zcode subprocess broke (process died mid-write). |
75
- | `method not found (code -32601)` | The CLI genuinely is too old (< 0.14.8). Upgrade. |
76
- | session-level business error | The target session no longer exists or was evicted. |
74
+ | `pipe broken` | The stdin pipe to the zcode subprocess broke (process died mid-write). |
75
+ | `method not found (code -32601)` | The CLI genuinely is too old (< 0.14.8). Upgrade. |
76
+ | session-level business error | The target session no longer exists or was evicted. |
77
77
 
78
78
  **Troubleshooting steps:**
79
79
 
@@ -308,6 +308,51 @@ and check the provider endpoint reachability).
308
308
  - Table name: `tasks`
309
309
  - Fields: workspace_key, task_id, title, task_status, ...
310
310
 
311
+ ### Remote access: hub unreachable / 401
312
+
313
+ **Symptom:** A remote client cannot list instances or connect; `curl
314
+ http://127.0.0.1:<hub-port>/api/health` fails, or `/api/*` returns 401.
315
+
316
+ **Troubleshooting steps:**
317
+
318
+ 1. 401 means a token mismatch — `ZCODE_ACP_REMOTE_TOKEN` must be identical in
319
+ the bridge env, the hub env (if run manually), and the client request.
320
+ 2. A dead hub self-heals: the next bridge heartbeat (≤10s; worst ~1min under
321
+ the spawn throttle) re-spawns `zcode-acp-hub`. Retry with backoff rather
322
+ than restarting anything by hand.
323
+ 3. Confirm the ports match: the client must reach `ZCODE_ACP_HUB_PORT`
324
+ (default 8377) through the tunnel, and the tunnel maps exactly that one
325
+ port.
326
+ 4. Remote silently disabled? `ZCODE_ACP_REMOTE=1` without a token logs a
327
+ warning and leaves the bridge stdio-only by design.
328
+
329
+ ### Remote access: stale instance in the list / connect fails
330
+
331
+ **Symptom:** `/api/instances` lists a workspace whose editor is already gone,
332
+ or a WS connect to it fails.
333
+
334
+ **Troubleshooting steps:**
335
+
336
+ 1. Hard-killed bridges (Zed force-kill, crash) never unregister — the hub's
337
+ heartbeat TTL drops them within ~30s.
338
+ 2. For an immediately-honest list, call `GET /api/instances?probe=1`: the hub
339
+ TCP-probes each registered port and prunes unreachable bridges first.
340
+ Clients should use this on refresh.
341
+ 3. A few-seconds outage after upgrading the package is expected: a newer
342
+ bridge triggers the hub's version-handshake restart, then re-spawns it.
343
+
344
+ ### Remote access: a conversation opens empty
345
+
346
+ **Symptom:** one session (typically an older one) opens EMPTY on a remote
347
+ client while other sessions show content.
348
+
349
+ **Cause:** the backend subprocess only serves `session/messages` for sessions
350
+ it has loaded via `session/create`/`session/resume`. Older bridges trusted the
351
+ in-memory id mapping as "live" and skipped the resume RPC — a mapping
352
+ re-registered from the durable store without a resume (or left behind by a
353
+ failed one) therefore replayed nothing. Fixed by explicit backend-loaded
354
+ tracking; the backend also logs a warning now when `session/messages` errors.
355
+
311
356
  ## Log Debugging
312
357
 
313
358
  ### Enable verbose logging
@@ -0,0 +1,14 @@
1
+ # Bridge lifetime follows the Primary Client
2
+
3
+ When remote access is enabled, remote clients attach to a bridge that the
4
+ Primary Client (the editor over stdio) spawned. We decided the bridge process
5
+ lives and dies with its Primary Client: when the editor disconnects, the
6
+ bridge (and every remote attachment) exits, even if remote clients are
7
+ mid-turn.
8
+
9
+ Rationale: session authority lives inside the bridge process. Keeping a
10
+ bridge alive after the editor leaves means the editor's reconnect spawns a
11
+ second bridge with its own backend subprocess, and both compete for the same
12
+ ZCode session files. Tying lifetime to the Primary Client matches the
13
+ existing mental model — the editor owns the session, remote clients are a
14
+ live window onto it.
@@ -0,0 +1,23 @@
1
+ # Remote access: stateless hub over per-bridge ACP endpoints
2
+
3
+ Remote clients must reach any active bridge through a single tunneled port
4
+ (Cloudflare Tunnel / frp); exposing one port per bridge does not survive that
5
+ constraint. We decided each bridge serves its own ACP endpoint on loopback
6
+ only (auto-incrementing ports from 8378), and a machine-singleton hub
7
+ (`zcode-acp-hub`, fixed port 8377, auto-spawned detached by bridges, idle-exits
8
+ after ~10 minutes without registrations) does exactly three things: token
9
+ authentication, instance discovery, and byte-level WebSocket proxying
10
+ (`WS /acp?instance=<id>` → the chosen bridge).
11
+
12
+ The hub has no ACP semantics and no business state; session authority stays
13
+ in the bridges. A remote connection binds to one instance for its whole
14
+ lifetime; switching instances means opening a new connection. We rejected a
15
+ globally-routing gateway that aggregates sessions across bridges and speaks
16
+ ACP itself — it would re-create session management outside the bridges, which
17
+ is the "fat hub" design this project deliberately avoids.
18
+
19
+ The hub is the only public entry point, so it is the only place that enforces
20
+ the token; the tunnel maps exactly this one port. WebSocket ping/pong
21
+ heartbeat (~30s) is mandatory on both hub and proxy connections because ACP
22
+ streams are silent when idle and proxy layers (notably Cloudflare) drop idle
23
+ connections.
@@ -0,0 +1,40 @@
1
+ # Tail replay: extension params ride in _meta, history pages by cursor
2
+
3
+ `session/load` replays full history to attaching clients, so attach and
4
+ reconnect cost grow with session age (Proposal 0001). Extending the protocol
5
+ for tail replay required three decisions that are now wire contract and hard
6
+ to reverse.
7
+
8
+ **Extension parameters on spec methods ride in `_meta.zcode`, not top-level.**
9
+ The ACP SDK registers spec methods like `session/load` with a zod
10
+ `z.object` params schema (`zLoadSessionRequest`), and zod's default behavior
11
+ strips unknown keys during `.parse()` — a top-level `limit` would be silently
12
+ removed before our handler ever sees it. `_meta` is the one channel the schema
13
+ preserves (`record(string, unknown)`), and it is also where the ACP spec
14
+ points extension payloads. Responses need no escape hatch: the SDK's response
15
+ mapping for `session/load` is a passthrough, so `replayMeta` rides top-level
16
+ in the result. Our own non-standard method `session/load_earlier` takes a
17
+ bridge-provided parser, so its params stay top-level — the asymmetry is
18
+ intentional and documented in REMOTE-CLIENTS.md.
19
+
20
+ **`limit` counts messages, aligned back to turn boundaries.** Clients render
21
+ messages (the app shows the last ~30), but turns are the atomic semantic unit
22
+ (a cut must not orphan a tool_call from its updates). The bridge replays at
23
+ most the last `limit` messages, extended backwards to the start of the turn
24
+ containing the oldest one; `limit: 0` attaches with metadata only. A turn
25
+ spans from a user message to the next; leading non-user messages belong to the
26
+ first turn. We rejected turn-count limits — tool-heavy turns make them
27
+ unpredictable for UI budgets.
28
+
29
+ **Cursor pagination over a full fetch, with expiry.** The backend's
30
+ `session/messages` has no pagination, but the fetch is local stdio IPC — the
31
+ expensive part is the wire to the client, so the bridge fetches all, slices in
32
+ memory, and ships only the tail. The cursor is an opaque base64 of
33
+ `{ id?, index, totalTurns }`: it validates only while the history it points
34
+ into is unchanged (compaction/truncation expires it). An expired or unknown
35
+ cursor returns a fixed `"cursor expired"` error the client maps to a full
36
+ re-`session/load` — we rejected a push-only update log with id-gap fill as
37
+ heavier machinery for the same result. During any replay batch the bridge
38
+ holds a per-session replay lock (patterned on `preemptLocks`) that live-turn
39
+ dispatch for the same session also acquires, so a batch is never interleaved
40
+ with live updates.