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.
- package/README.md +101 -15
- package/README.zh-CN.md +68 -13
- package/dist/bin/hub.d.ts +16 -0
- package/dist/bin/hub.d.ts.map +1 -0
- package/dist/bin/hub.js +41 -0
- package/dist/bin/hub.js.map +1 -0
- package/dist/handlers/account.d.ts +43 -0
- package/dist/handlers/account.d.ts.map +1 -0
- package/dist/handlers/account.js +59 -0
- package/dist/handlers/account.js.map +1 -0
- package/dist/handlers/io.d.ts +20 -1
- package/dist/handlers/io.d.ts.map +1 -1
- package/dist/handlers/io.js +57 -2
- package/dist/handlers/io.js.map +1 -1
- package/dist/handlers/replay.d.ts +79 -0
- package/dist/handlers/replay.d.ts.map +1 -0
- package/dist/handlers/replay.js +256 -0
- package/dist/handlers/replay.js.map +1 -0
- package/dist/handlers/session.d.ts.map +1 -1
- package/dist/handlers/session.js +83 -68
- package/dist/handlers/session.js.map +1 -1
- package/dist/handlers/slash.d.ts +23 -1
- package/dist/handlers/slash.d.ts.map +1 -1
- package/dist/handlers/slash.js +67 -6
- package/dist/handlers/slash.js.map +1 -1
- package/dist/index.js +47 -17
- package/dist/index.js.map +1 -1
- package/dist/remote/broadcast.d.ts +47 -0
- package/dist/remote/broadcast.d.ts.map +1 -0
- package/dist/remote/broadcast.js +121 -0
- package/dist/remote/broadcast.js.map +1 -0
- package/dist/remote/config.d.ts +32 -0
- package/dist/remote/config.d.ts.map +1 -0
- package/dist/remote/config.js +65 -0
- package/dist/remote/config.js.map +1 -0
- package/dist/remote/endpoint.d.ts +43 -0
- package/dist/remote/endpoint.d.ts.map +1 -0
- package/dist/remote/endpoint.js +222 -0
- package/dist/remote/endpoint.js.map +1 -0
- package/dist/remote/hub-server.d.ts +41 -0
- package/dist/remote/hub-server.d.ts.map +1 -0
- package/dist/remote/hub-server.js +346 -0
- package/dist/remote/hub-server.js.map +1 -0
- package/dist/server.d.ts +62 -7
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +99 -11
- package/dist/server.js.map +1 -1
- package/dist/utils.d.ts +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +17 -1
- package/dist/utils.js.map +1 -1
- package/docs/ARCHITECTURE.md +47 -15
- package/docs/BACKLOG.md +3 -1
- package/docs/DEVELOPMENT.md +26 -0
- package/docs/PROTOCOL.md +67 -27
- package/docs/REMOTE-CLIENTS.md +264 -0
- package/docs/REPLAY-GUIDE.md +131 -0
- package/docs/TROUBLESHOOTING.md +51 -6
- package/docs/adr/0001-bridge-lifetime-follows-primary-client.md +14 -0
- package/docs/adr/0002-stateless-hub-over-per-bridge-acp-endpoints.md +23 -0
- package/docs/adr/0003-tail-replay-meta-and-cursor-pagination.md +40 -0
- package/docs/proposals/0001-tail-session-replay.md +136 -0
- package/docs/proposals/0002-plan-quota-usage.md +81 -0
- 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).
|
package/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -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.
|