zcode-acp-server 0.1.0 → 0.3.0

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 (223) hide show
  1. package/README.md +192 -9
  2. package/README.zh-CN.md +143 -8
  3. package/dist/backend/client.d.ts +12 -4
  4. package/dist/backend/client.d.ts.map +1 -1
  5. package/dist/backend/client.js +72 -7
  6. package/dist/backend/client.js.map +1 -1
  7. package/dist/backend/credentials.d.ts.map +1 -1
  8. package/dist/backend/credentials.js +2 -1
  9. package/dist/backend/credentials.js.map +1 -1
  10. package/dist/backend/listener.d.ts +12 -4
  11. package/dist/backend/listener.d.ts.map +1 -1
  12. package/dist/backend/listener.js +68 -12
  13. package/dist/backend/listener.js.map +1 -1
  14. package/dist/backend/types.d.ts +1 -1
  15. package/dist/backend/types.d.ts.map +1 -1
  16. package/dist/bin/hub.d.ts +16 -0
  17. package/dist/bin/hub.d.ts.map +1 -0
  18. package/dist/bin/hub.js +41 -0
  19. package/dist/bin/hub.js.map +1 -0
  20. package/dist/bin/quota.d.ts +54 -0
  21. package/dist/bin/quota.d.ts.map +1 -0
  22. package/dist/bin/quota.js +333 -0
  23. package/dist/bin/quota.js.map +1 -0
  24. package/dist/config/auto-compact.d.ts +23 -0
  25. package/dist/config/auto-compact.d.ts.map +1 -0
  26. package/dist/config/auto-compact.js +67 -0
  27. package/dist/config/auto-compact.js.map +1 -0
  28. package/dist/config/mcp-discovery.d.ts +34 -0
  29. package/dist/config/mcp-discovery.d.ts.map +1 -0
  30. package/dist/config/mcp-discovery.js +153 -0
  31. package/dist/config/mcp-discovery.js.map +1 -0
  32. package/dist/config/model-cache.d.ts +12 -1
  33. package/dist/config/model-cache.d.ts.map +1 -1
  34. package/dist/config/model-cache.js +30 -8
  35. package/dist/config/model-cache.js.map +1 -1
  36. package/dist/config/options.d.ts +87 -9
  37. package/dist/config/options.d.ts.map +1 -1
  38. package/dist/config/options.js +248 -45
  39. package/dist/config/options.js.map +1 -1
  40. package/dist/config/plugin-commands.d.ts +24 -0
  41. package/dist/config/plugin-commands.d.ts.map +1 -0
  42. package/dist/config/plugin-commands.js +107 -0
  43. package/dist/config/plugin-commands.js.map +1 -0
  44. package/dist/config/provider-registry.d.ts +60 -0
  45. package/dist/config/provider-registry.d.ts.map +1 -0
  46. package/dist/config/provider-registry.js +128 -0
  47. package/dist/config/provider-registry.js.map +1 -0
  48. package/dist/config/runtime-model.d.ts +54 -18
  49. package/dist/config/runtime-model.d.ts.map +1 -1
  50. package/dist/config/runtime-model.js +105 -61
  51. package/dist/config/runtime-model.js.map +1 -1
  52. package/dist/config/skill-discovery.d.ts +35 -0
  53. package/dist/config/skill-discovery.d.ts.map +1 -0
  54. package/dist/config/skill-discovery.js +188 -0
  55. package/dist/config/skill-discovery.js.map +1 -0
  56. package/dist/handlers/account.d.ts +43 -0
  57. package/dist/handlers/account.d.ts.map +1 -0
  58. package/dist/handlers/account.js +59 -0
  59. package/dist/handlers/account.js.map +1 -0
  60. package/dist/handlers/background-tasks.d.ts +72 -0
  61. package/dist/handlers/background-tasks.d.ts.map +1 -0
  62. package/dist/handlers/background-tasks.js +330 -0
  63. package/dist/handlers/background-tasks.js.map +1 -0
  64. package/dist/handlers/dispatch.d.ts.map +1 -1
  65. package/dist/handlers/dispatch.js +134 -22
  66. package/dist/handlers/dispatch.js.map +1 -1
  67. package/dist/handlers/extensions.d.ts +15 -0
  68. package/dist/handlers/extensions.d.ts.map +1 -1
  69. package/dist/handlers/extensions.js +43 -27
  70. package/dist/handlers/extensions.js.map +1 -1
  71. package/dist/handlers/io.d.ts +31 -3
  72. package/dist/handlers/io.d.ts.map +1 -1
  73. package/dist/handlers/io.js +108 -9
  74. package/dist/handlers/io.js.map +1 -1
  75. package/dist/handlers/replay.d.ts +79 -0
  76. package/dist/handlers/replay.d.ts.map +1 -0
  77. package/dist/handlers/replay.js +252 -0
  78. package/dist/handlers/replay.js.map +1 -0
  79. package/dist/handlers/server-requests.d.ts +20 -4
  80. package/dist/handlers/server-requests.d.ts.map +1 -1
  81. package/dist/handlers/server-requests.js +293 -63
  82. package/dist/handlers/server-requests.js.map +1 -1
  83. package/dist/handlers/session.d.ts +113 -10
  84. package/dist/handlers/session.d.ts.map +1 -1
  85. package/dist/handlers/session.js +932 -277
  86. package/dist/handlers/session.js.map +1 -1
  87. package/dist/handlers/slash.d.ts +37 -1
  88. package/dist/handlers/slash.d.ts.map +1 -1
  89. package/dist/handlers/slash.js +143 -8
  90. package/dist/handlers/slash.js.map +1 -1
  91. package/dist/index.js +76 -12
  92. package/dist/index.js.map +1 -1
  93. package/dist/interaction/adapter.d.ts +19 -33
  94. package/dist/interaction/adapter.d.ts.map +1 -1
  95. package/dist/interaction/adapter.js +80 -79
  96. package/dist/interaction/adapter.js.map +1 -1
  97. package/dist/lazy-sessions.d.ts +35 -0
  98. package/dist/lazy-sessions.d.ts.map +1 -0
  99. package/dist/lazy-sessions.js +98 -0
  100. package/dist/lazy-sessions.js.map +1 -0
  101. package/dist/quota/cache.d.ts +18 -0
  102. package/dist/quota/cache.d.ts.map +1 -0
  103. package/dist/quota/cache.js +32 -0
  104. package/dist/quota/cache.js.map +1 -0
  105. package/dist/quota/client.d.ts +30 -0
  106. package/dist/quota/client.d.ts.map +1 -0
  107. package/dist/quota/client.js +57 -0
  108. package/dist/quota/client.js.map +1 -0
  109. package/dist/quota/color.d.ts +58 -0
  110. package/dist/quota/color.d.ts.map +1 -0
  111. package/dist/quota/color.js +95 -0
  112. package/dist/quota/color.js.map +1 -0
  113. package/dist/quota/combined.d.ts +66 -0
  114. package/dist/quota/combined.d.ts.map +1 -0
  115. package/dist/quota/combined.js +179 -0
  116. package/dist/quota/combined.js.map +1 -0
  117. package/dist/quota/format.d.ts +105 -0
  118. package/dist/quota/format.d.ts.map +1 -0
  119. package/dist/quota/format.js +219 -0
  120. package/dist/quota/format.js.map +1 -0
  121. package/dist/quota/index.d.ts +23 -0
  122. package/dist/quota/index.d.ts.map +1 -0
  123. package/dist/quota/index.js +42 -0
  124. package/dist/quota/index.js.map +1 -0
  125. package/dist/quota/opencode-go/cache.d.ts +17 -0
  126. package/dist/quota/opencode-go/cache.d.ts.map +1 -0
  127. package/dist/quota/opencode-go/cache.js +31 -0
  128. package/dist/quota/opencode-go/cache.js.map +1 -0
  129. package/dist/quota/opencode-go/client.d.ts +28 -0
  130. package/dist/quota/opencode-go/client.d.ts.map +1 -0
  131. package/dist/quota/opencode-go/client.js +48 -0
  132. package/dist/quota/opencode-go/client.js.map +1 -0
  133. package/dist/quota/opencode-go/config.d.ts +37 -0
  134. package/dist/quota/opencode-go/config.d.ts.map +1 -0
  135. package/dist/quota/opencode-go/config.js +58 -0
  136. package/dist/quota/opencode-go/config.js.map +1 -0
  137. package/dist/quota/opencode-go/format.d.ts +36 -0
  138. package/dist/quota/opencode-go/format.d.ts.map +1 -0
  139. package/dist/quota/opencode-go/format.js +87 -0
  140. package/dist/quota/opencode-go/format.js.map +1 -0
  141. package/dist/quota/opencode-go/index.d.ts +30 -0
  142. package/dist/quota/opencode-go/index.d.ts.map +1 -0
  143. package/dist/quota/opencode-go/index.js +108 -0
  144. package/dist/quota/opencode-go/index.js.map +1 -0
  145. package/dist/quota/opencode-go/parse.d.ts +41 -0
  146. package/dist/quota/opencode-go/parse.d.ts.map +1 -0
  147. package/dist/quota/opencode-go/parse.js +75 -0
  148. package/dist/quota/opencode-go/parse.js.map +1 -0
  149. package/dist/quota/opencode-go/types.d.ts +48 -0
  150. package/dist/quota/opencode-go/types.d.ts.map +1 -0
  151. package/dist/quota/opencode-go/types.js +11 -0
  152. package/dist/quota/opencode-go/types.js.map +1 -0
  153. package/dist/quota/parse.d.ts +33 -0
  154. package/dist/quota/parse.d.ts.map +1 -0
  155. package/dist/quota/parse.js +200 -0
  156. package/dist/quota/parse.js.map +1 -0
  157. package/dist/quota/types.d.ts +72 -0
  158. package/dist/quota/types.d.ts.map +1 -0
  159. package/dist/quota/types.js +10 -0
  160. package/dist/quota/types.js.map +1 -0
  161. package/dist/remote/broadcast.d.ts +47 -0
  162. package/dist/remote/broadcast.d.ts.map +1 -0
  163. package/dist/remote/broadcast.js +121 -0
  164. package/dist/remote/broadcast.js.map +1 -0
  165. package/dist/remote/config.d.ts +32 -0
  166. package/dist/remote/config.d.ts.map +1 -0
  167. package/dist/remote/config.js +65 -0
  168. package/dist/remote/config.js.map +1 -0
  169. package/dist/remote/endpoint.d.ts +30 -0
  170. package/dist/remote/endpoint.d.ts.map +1 -0
  171. package/dist/remote/endpoint.js +213 -0
  172. package/dist/remote/endpoint.js.map +1 -0
  173. package/dist/remote/hub-server.d.ts +41 -0
  174. package/dist/remote/hub-server.d.ts.map +1 -0
  175. package/dist/remote/hub-server.js +346 -0
  176. package/dist/remote/hub-server.js.map +1 -0
  177. package/dist/server.d.ts +139 -1
  178. package/dist/server.d.ts.map +1 -1
  179. package/dist/server.js +179 -4
  180. package/dist/server.js.map +1 -1
  181. package/dist/tasks-index.d.ts +14 -4
  182. package/dist/tasks-index.d.ts.map +1 -1
  183. package/dist/tasks-index.js +145 -46
  184. package/dist/tasks-index.js.map +1 -1
  185. package/dist/translators/event-translator.d.ts +33 -0
  186. package/dist/translators/event-translator.d.ts.map +1 -1
  187. package/dist/translators/event-translator.js +101 -0
  188. package/dist/translators/event-translator.js.map +1 -1
  189. package/dist/translators/index.d.ts +1 -1
  190. package/dist/translators/index.d.ts.map +1 -1
  191. package/dist/translators/index.js +1 -1
  192. package/dist/translators/index.js.map +1 -1
  193. package/dist/translators/projection-differ.d.ts +8 -0
  194. package/dist/translators/projection-differ.d.ts.map +1 -1
  195. package/dist/translators/projection-differ.js +19 -9
  196. package/dist/translators/projection-differ.js.map +1 -1
  197. package/dist/translators/tool-helpers.d.ts +28 -0
  198. package/dist/translators/tool-helpers.d.ts.map +1 -1
  199. package/dist/translators/tool-helpers.js +104 -0
  200. package/dist/translators/tool-helpers.js.map +1 -1
  201. package/dist/translators/types.d.ts +38 -1
  202. package/dist/translators/types.d.ts.map +1 -1
  203. package/dist/translators/types.js.map +1 -1
  204. package/dist/utils.d.ts +36 -7
  205. package/dist/utils.d.ts.map +1 -1
  206. package/dist/utils.js +69 -5
  207. package/dist/utils.js.map +1 -1
  208. package/docs/ARCHITECTURE.md +130 -40
  209. package/docs/BACKLOG.md +59 -0
  210. package/docs/DEVELOPMENT.md +31 -0
  211. package/docs/PROTOCOL.md +328 -23
  212. package/docs/REMOTE-CLIENTS.md +260 -0
  213. package/docs/REPLAY-GUIDE.md +131 -0
  214. package/docs/TROUBLESHOOTING.md +131 -14
  215. package/docs/adr/0001-bridge-lifetime-follows-primary-client.md +14 -0
  216. package/docs/adr/0002-stateless-hub-over-per-bridge-acp-endpoints.md +23 -0
  217. package/docs/adr/0003-tail-replay-meta-and-cursor-pagination.md +40 -0
  218. package/docs/agents/domain.md +51 -0
  219. package/docs/agents/issue-tracker.md +22 -0
  220. package/docs/agents/triage-labels.md +15 -0
  221. package/docs/proposals/0001-tail-session-replay.md +136 -0
  222. package/docs/proposals/0002-plan-quota-usage.md +81 -0
  223. package/package.json +10 -6
@@ -0,0 +1,260 @@
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
+ - Poll every 3–5s. There is no push notification for registry changes yet.
82
+ - Fields are **additive-only** across releases — ignore fields you don't know.
83
+
84
+ Lifecycle timings: a bridge re-registers every 10s (the registration doubles as
85
+ heartbeat); an instance disappears ~30s after its heartbeats stop; the hub
86
+ exits after ~10 idle minutes with no instances and no proxies, and the next
87
+ bridge re-spawns it on demand.
88
+
89
+ ## Connecting
90
+
91
+ ```text
92
+ ws(s)://<hub-host>/acp?instance=<id>&token=<token>
93
+ ```
94
+
95
+ - Native clients may send `Authorization: Bearer <token>` instead of the query
96
+ parameter; browsers cannot set WS headers, which is why `?token=` exists.
97
+ Prefer the header when you can — it keeps the token out of URLs and logs.
98
+ - Handshake failures (bad token, unknown instance id) destroy the socket
99
+ before open. Treat any non-open outcome as "re-discover, then retry".
100
+ - Framing: one JSON-RPC message per **text** frame. Binary frames are ignored.
101
+ - The hub sends WebSocket pings every 30s on both legs (tunnels drop idle
102
+ links). Browser and native WS stacks answer pongs automatically — nothing to
103
+ implement, but don't disable pongs.
104
+
105
+ ## ACP session flow
106
+
107
+ 1. `initialize` — `protocolVersion` MUST be the **number** `1` (a string is
108
+ rejected). Nothing else may be sent before it.
109
+ 2. Attach or create:
110
+ - `session/load { sessionId, cwd, mcpServers }` with an id from discovery —
111
+ replays the conversation history (text + tool summaries) as
112
+ `session/update`s, so a freshly attached client can render the full
113
+ story. `cwd` and `mcpServers` (even `[]`) are required — the SDK's params
114
+ schema rejects the request without them.
115
+ - `session/new { cwd? }` — a new session on that bridge.
116
+ - `session/list` enumerates the bridge's known sessions.
117
+ 3. Drive: `session/prompt`, `session/cancel`, `session/set_config_option`
118
+ (model / mode / thought level), slash commands in the prompt text —
119
+ see [PROTOCOL.md](PROTOCOL.md).
120
+
121
+ ## Account quota (`account/usage_stats`)
122
+
123
+ Non-standard, additive (Proposal 0002). Plan quota is **account-level**, so it
124
+ is a pull-only request — callable any time after `initialize`, no session
125
+ required. Fetch once after attach and on demand; quota changes are slow, there
126
+ is no push.
127
+
128
+ The response mirrors the `zcode-quota` CLI card's data model — one GLM section
129
+ plus one Opencode Go section — so clients can reproduce the CLI layout
130
+ exactly:
131
+
132
+ ```json
133
+ → { "id": 7, "method": "account/usage_stats", "params": {} }
134
+ ← { "id": 7, "result": {
135
+ "glm": {
136
+ "kind": "success",
137
+ "level": "pro",
138
+ "items": [
139
+ { "key": "token_5h", "label": "5h", "usedPercent": 35,
140
+ "nextResetTime": 1723812000000 },
141
+ { "key": "mcp", "label": "MCP", "usedPercent": 10, "usedCount": 3,
142
+ "totalCount": 30, "nextResetTime": 1723812000000,
143
+ "detail": [{ "modelCode": "search-prime", "usage": 2 }] }
144
+ ]
145
+ },
146
+ "opencode": {
147
+ "kind": "success",
148
+ "windows": [
149
+ { "key": "rolling", "label": "5h", "usagePercent": 5,
150
+ "resetsAt": 1723812000000 },
151
+ { "key": "weekly", "label": "Week", "usagePercent": 25,
152
+ "resetsAt": 1724071200000 }
153
+ ]
154
+ }
155
+ } }
156
+ ```
157
+
158
+ - `glm` (`kind`: `success` | `auth_error` | `rate_limited` | `unavailable`):
159
+ on success, `level` is the plan level and `items` carries one entry per
160
+ window (`5h` / `Week` / `MCP`) with `usedPercent` (0–100) always present;
161
+ `usedCount`/`totalCount`/`nextResetTime` (epoch ms) and the per-model
162
+ `detail` breakdown only when the API reports them.
163
+ - `opencode` (`kind`: `success` | `not_configured` | `auth_error` |
164
+ `unavailable`): on success, `windows` carries the rolling (`5h`) / weekly
165
+ (`Week`) / monthly (`Month`, when exposed) windows; the dashboard's relative
166
+ countdown is resolved to an absolute `resetsAt` (epoch ms). `not_configured`
167
+ means the user never set OpenCode Go credentials — omit the section, like
168
+ the CLI does.
169
+ - Provider failures are per-section `kind` strings, not JSON-RPC errors —
170
+ render the same status line the CLI would (e.g. auth expired) and retry
171
+ later. Only transport-level failures reject the request.
172
+ - Cached ~10s server-side (same caches as the `/quota` command).
173
+
174
+ ## Slash-command handling
175
+
176
+ Only the commands the bridge advertises via `available_commands_update` (plus
177
+ `skill`/`init` and `$`-skills) are treated as commands. Any other `/`-leading
178
+ prompt — e.g. a pasted directory path — is delivered to the model as plain
179
+ text with an invisible zero-width-space prefix; clients see the text verbatim
180
+ in replay and echoes. Clients should not special-case this.
181
+
182
+ ## Tail replay and history pagination
183
+
184
+ Replaying a long session's full history is O(history) on every attach and
185
+ reconnect. The bridge supports tail replay (non-standard, additive — omit
186
+ everything below and you get the full replay):
187
+
188
+ - **Tail limit**: `session/load` with `_meta.zcode.limit` (NOT top-level —
189
+ the SDK's params schema strips unknown top-level keys; `_meta` is the
190
+ preserved extension channel). It counts **messages**, and the replay is
191
+ aligned back to the start of the turn containing the oldest message — never
192
+ a mid-turn cut. `0` attaches with metadata only. Clamped to `[0, 500]`.
193
+ - **`replayMeta`** rides top-level in the result:
194
+
195
+ ```json
196
+ {
197
+ "replayMeta": {
198
+ "cursor": "…",
199
+ "hasMore": true,
200
+ "replayedMessages": 47,
201
+ "replayedTurns": 12,
202
+ "totalMessages": 1893,
203
+ "totalTurns": 412
204
+ }
205
+ }
206
+ ```
207
+
208
+ - **`session/load_earlier`** (`{ sessionId, before, limit }`, limit defaults
209
+ to 50) delivers one page of `session/update`s strictly older than `before`,
210
+ oldest → newest — prepend them. Same `replayMeta` shape in the result;
211
+ `hasMore: false` ends pagination. Requires the session to be attached in
212
+ this bridge; it never triggers an implicit backend resume.
213
+ - **Cursor expiry**: a cursor is valid only while the history it points into
214
+ is unchanged — turns appended after it was minted (the session moved on)
215
+ keep it valid. After the session compacts or truncates, `load_earlier`
216
+ returns a `"cursor expired"` error — the recovery is a fresh `session/load`.
217
+
218
+ While a replay batch is in flight, live updates for the same session queue
219
+ behind it: batches are atomic and never interleave with the live turn.
220
+
221
+ UI-side recipes for consuming all of this — state model, prepend handling,
222
+ scroll pagination, reconnect recovery — live in
223
+ [REPLAY-GUIDE.md](REPLAY-GUIDE.md).
224
+
225
+ ## Multi-client semantics
226
+
227
+ The stdio editor and every remote client are peers on the same sessions:
228
+
229
+ - All agent notifications (`session/update`) are broadcast to every client.
230
+ - Permission and elicitation requests go to **every** client and the **first
231
+ response wins**. Losers receive `$/cancel_request` for the pending request
232
+ id — close the dialog and drop it. Never leave a request unanswered forever.
233
+ - Capabilities are OR-merged across clients: a remote client advertising e.g.
234
+ `elicitation.form` upgrades the shared interaction for the whole bridge.
235
+ - Concurrent prompts for one session are serialized by the bridge — two
236
+ clients prompting at once cannot interleave turns.
237
+
238
+ ## Failure & recovery
239
+
240
+ | Symptom | Cause | Client action |
241
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
242
+ | 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. |
243
+ | Instance missing from `/api/instances` | Heartbeats stopped >30s, or `?probe=1` found the bridge port unreachable | Remove the instance from the UI. |
244
+ | 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. |
245
+ | Disconnect mid-turn | Mobile network flap, background suspension | The turn continues server-side. Reconnect and `session/load` — history replay is the recovery path. |
246
+
247
+ Updates emitted while you are disconnected are not individually re-delivered;
248
+ `session/load` replay is the catch-up mechanism.
249
+
250
+ ## Platform notes
251
+
252
+ - **Browser**: a page served over `https://` can only open `wss://` — take TLS
253
+ from the tunnel. CORS is `*`, so any static host works; the client needs no
254
+ backend of its own.
255
+ - **Mobile**: background suspension kills the socket; on resume, reconnect and
256
+ `session/load` the previously open session. Store hub URL + token locally;
257
+ reconnect with exponential backoff. The 30s hub pings keep NAT mappings warm
258
+ while foregrounded.
259
+ - **CLI / native tools**: prefer the `Authorization` header; a one-shot
260
+ `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).
@@ -5,6 +5,7 @@
5
5
  ### Backend fails to start
6
6
 
7
7
  **Symptom:**
8
+
8
9
  ```
9
10
  [zcode-acp] backend: started zcode app-server (pid=12345)
10
11
  [zcode-acp] backend: reader exited (stdout closed)
@@ -13,12 +14,14 @@
13
14
  **Troubleshooting steps:**
14
15
 
15
16
  1. Check the ZCode CLI version:
17
+
16
18
  ```bash
17
19
  zcode --version
18
20
  ```
19
21
  - Must be >= 0.14.8
20
22
 
21
23
  2. Check whether `zcode` is on PATH:
24
+
22
25
  ```bash
23
26
  which zcode
24
27
  ```
@@ -31,22 +34,60 @@
31
34
  - Confirm a `provider` is enabled
32
35
  - Confirm `models` are defined
33
36
 
37
+ ### Authentication / credential errors (401, provider auth failed)
38
+
39
+ **Symptom:** turns fail with authentication errors (e.g. `401`, `provider auth failed`, `invalid api key`), or `~/.zcode/v2/config.json` is missing.
40
+
41
+ **Why:** This bridge advertises agent-managed auth — it reads the GLM API key from `~/.zcode/v2/config.json` and forwards it to the ZCode subprocess. No editor-side API key or environment variable is involved. If the credentials file is absent, empty, or carries an expired/invalid key, every turn will fail at the first model call.
42
+
43
+ **Troubleshooting steps:**
44
+
45
+ 1. Confirm the credentials file exists and has an enabled provider:
46
+
47
+ ```bash
48
+ cat ~/.zcode/v2/config.json
49
+ ```
50
+ - There must be a `provider` entry with `"enabled": true`
51
+ - Its `options.apiKey` must be present and non-empty
52
+
53
+ 2. If the file is missing or the key is stale, **install and log into the ZCode desktop app** — it writes a fresh `config.json` with a valid enabled provider. There is no manual API-key configuration in the editor.
54
+
55
+ 3. If you need to override the key/base URL without touching `config.json`, set `ZCODE_BASE_URL` and provide the key via the provider config (see `src/backend/credentials.ts` for the merge order).
56
+
34
57
  ### session/subscribe fails
35
58
 
36
59
  **Symptom:**
60
+
37
61
  ```
38
- session/subscribe failed (ZCode CLI 0.14.8+ required)
62
+ session/subscribe failed: <backend error message> [(code <N>)]
39
63
  ```
40
64
 
65
+ The error message carries the backend's real failure reason. It is **no longer**
66
+ a hardcoded version string — read the message text to identify the root cause.
67
+
68
+ **Common causes:**
69
+
70
+ | Message fragment | Cause |
71
+ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
72
+ | `reader exited (backend dead)` | The zcode subprocess crashed/exited. Restart the editor session. |
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. |
77
+
41
78
  **Troubleshooting steps:**
42
79
 
43
- 1. Confirm ZCode CLI >= 0.14.8:
80
+ 1. **Read the error message** — the fragment identifies the cause (table above).
81
+
82
+ 2. If the message indicates `method not found`, confirm ZCode CLI >= 0.14.8:
83
+
44
84
  ```bash
45
85
  zcode --version
46
86
  ```
47
87
 
48
- 2. If the version is correct but it still fails, check whether the zcode
88
+ 3. If the version is correct but it still fails, check whether the zcode
49
89
  app-server supports subscribe:
90
+
50
91
  ```bash
51
92
  cd /path/to/project
52
93
  zcode app-server --stdio
@@ -54,7 +95,7 @@ session/subscribe failed (ZCode CLI 0.14.8+ required)
54
95
  { "id": 1, "method": "session/subscribe", "params": { "sessionId": "test", "deliveryKind": "desktop-continuous", "includeSnapshot": true, "afterSeq": 0 } }
55
96
  ```
56
97
 
57
- 3. Check whether other zcode processes are running:
98
+ 4. Check whether other zcode processes are running:
58
99
  ```bash
59
100
  ps aux | grep zcode
60
101
  killall -9 zcode # caution: this kills all zcode processes
@@ -67,6 +108,7 @@ session/subscribe failed (ZCode CLI 0.14.8+ required)
67
108
  **Troubleshooting steps:**
68
109
 
69
110
  1. Confirm the client declares `terminal_output`:
111
+
70
112
  ```typescript
71
113
  // should be present in clientCapabilities:
72
114
  { "_meta": { "terminal_output": true } }
@@ -87,9 +129,11 @@ session/subscribe failed (ZCode CLI 0.14.8+ required)
87
129
  **Troubleshooting steps:**
88
130
 
89
131
  1. Check whether `EventStreamListener` subscribed successfully:
132
+
90
133
  ```
91
134
  [zcode-acp] backend: started zcode app-server (pid=12345)
92
135
  ```
136
+
93
137
  If this log is missing, the backend did not start.
94
138
 
95
139
  2. Check whether `session/event` pushes arrive:
@@ -106,6 +150,7 @@ session/subscribe failed (ZCode CLI 0.14.8+ required)
106
150
  **Troubleshooting steps:**
107
151
 
108
152
  1. Check whether zcode sent an interaction request:
153
+
109
154
  ```
110
155
  [zcode-acp] -> interaction/requestPermission (zcode_id=100)
111
156
  ```
@@ -120,6 +165,43 @@ session/subscribe failed (ZCode CLI 0.14.8+ required)
120
165
  - `⚠ elicitation/create failed: ...` → the client does not support it or the request failed
121
166
  - `⚠ request_permission failed: ...` → the fallback path failed
122
167
 
168
+ ### Turn fails with `model_request_failed` / network error
169
+
170
+ **Symptom:** A turn ends with an error like `model_request_failed` / "Network
171
+ connection failed for the provider request" / "Turn execution failed". Instead
172
+ of stopping the session, the bridge retries transient failures automatically.
173
+
174
+ **What happens:**
175
+
176
+ When the ZCode backend emits `turn.failed` with a transient cause (provider
177
+ network blip, rate limit, brief outage), the bridge retries the turn up to
178
+ **5 times** (6 total attempts) with exponential backoff capped at 4s
179
+ (1s / 2s / 4s / 4s / 4s). Each retry re-sends the prompt and surfaces a
180
+ `[网络异常,正在重试 (n/5)…]` hint so the user knows the turn is being retried
181
+ rather than hanging.
182
+
183
+ Transient errors are identified by the nested `error.cause.code`
184
+ (`model_request_failed`, `provider_not_configured`, `rate_limit`, `timeout`,
185
+ `ECONNRESET`, etc.) or by network/connection/timeout keywords in
186
+ `error.cause.message`. Non-transient failures (e.g. `prompt is running`) still
187
+ surface as hard errors immediately.
188
+
189
+ After retries are exhausted, the bridge **degrades gracefully**: it emits a
190
+ user-visible `[请求失败:…。会话仍可用,请重新发送消息重试。]` message and returns
191
+ `end_turn`, so the session stays usable — resend the message to try again.
192
+
193
+ **Debugging:**
194
+
195
+ ```
196
+ ZCODE_ACP_DEBUG=1
197
+ ```
198
+
199
+ Look for `[retry] transient turn failed, re-sending (attempt N/6)` lines to
200
+ confirm the retry path is active. If transient failures persist across all
201
+ retries, the underlying provider/network issue needs investigation (see
202
+ [Authentication / credential errors](#authentication--credential-errors-401-provider-auth-failed)
203
+ and check the provider endpoint reachability).
204
+
123
205
  ### `/` completion menu is empty
124
206
 
125
207
  **Symptom:** Typing `/` shows no command completion.
@@ -147,6 +229,7 @@ session/subscribe failed (ZCode CLI 0.14.8+ required)
147
229
  - backend reader death
148
230
 
149
231
  2. Manually inspect zombie processes:
232
+
150
233
  ```bash
151
234
  ps aux | grep zcode
152
235
  killall -9 zcode # cleanup
@@ -197,6 +280,7 @@ session/subscribe failed (ZCode CLI 0.14.8+ required)
197
280
 
198
281
  2. If the lock is still stuck on an older bridge, the zcode subprocess must be
199
282
  killed manually:
283
+
200
284
  ```bash
201
285
  ps aux | grep zcode
202
286
  killall -9 zcode # caution: kills all zcode processes
@@ -224,6 +308,39 @@ session/subscribe failed (ZCode CLI 0.14.8+ required)
224
308
  - Table name: `tasks`
225
309
  - Fields: workspace_key, task_id, title, task_status, ...
226
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
+
227
344
  ## Log Debugging
228
345
 
229
346
  ### Enable verbose logging
@@ -239,13 +356,13 @@ export function log(msg: string): void {
239
356
 
240
357
  ### Common log patterns
241
358
 
242
- | Log | Meaning |
243
- |------|------|
244
- | `backend: started zcode app-server (pid=...)` | Backend started successfully |
245
- | `backend: reader exited (...)` | Backend reader exited (backend may have crashed) |
246
- | `session/new -> sess_xxx` | New session created successfully |
247
- | `[event] turn.started` | Turn started |
248
- | `[event] turn.completed (resultType=...)` | Turn completed |
249
- | `-> interaction/... (zcode_id=...)` | Interaction request received |
250
- | `<- replied to zcode (N request(s))` | Interaction request replied |
251
- | `⚠ ...` | Warning / error |
359
+ | Log | Meaning |
360
+ | --------------------------------------------- | ------------------------------------------------ |
361
+ | `backend: started zcode app-server (pid=...)` | Backend started successfully |
362
+ | `backend: reader exited (...)` | Backend reader exited (backend may have crashed) |
363
+ | `session/new -> sess_xxx` | New session created successfully |
364
+ | `[event] turn.started` | Turn started |
365
+ | `[event] turn.completed (resultType=...)` | Turn completed |
366
+ | `-> interaction/... (zcode_id=...)` | Interaction request received |
367
+ | `<- replied to zcode (N request(s))` | Interaction request replied |
368
+ | `⚠ ...` | Warning / error |
@@ -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.