dsh-cherry-bridge 0.1.11 → 0.2.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/src/AGENTS.md ADDED
@@ -0,0 +1,949 @@
1
+ # AGENTS.md — `src/`
2
+
3
+ The server half of the bridge: the connection pool, the JSON-RPC client, pairing,
4
+ the fourteen tools, the HTTP face and the settings bundle's API.
5
+
6
+ Loaded automatically when a file tool touches `src/**` (a `bash`-only turn does
7
+ not load it). It holds the CONTRACTS — the protocol basis, the invariants each
8
+ one defends, and the upstream facts they rest on. The flow, the build facts and
9
+ the release steps are in the root [`AGENTS.md`](../AGENTS.md).
10
+
11
+ ---
12
+
13
+ ## Protocol basis
14
+
15
+ Pinned at **exact versions**, no `^`, with contract assertions on the pins:
16
+
17
+ | Package | Version |
18
+ | --- | --- |
19
+ | `@cherrystudio/remote-protocol` | `0.7.0` |
20
+ | `@cherrystudio/remote-transport` | `0.2.5` |
21
+ | `ws` | `8.22.0` |
22
+ | `zod` | `4.3.4` |
23
+
24
+ These are the versions Cherry Studio **desktop 2.1.8** ships, and 2.1.9/2.1.10
25
+ still ship the same pair (verified at tags `v2.1.8`, `v2.1.9`, `v2.1.10`).
26
+ Upgrading either protocol package means re-running
27
+ `tests/contract/contract.test.ts` against schema drift — the suite models the
28
+ **six behavioral differences** between our loopback fake peer and a real
29
+ desktop, each mapped to a manual step
30
+ in [`docs/e2e-checklist.md`](../docs/e2e-checklist.md).
31
+
32
+ **Wire budget (from `remoteLimits` in the protocol package):**
33
+
34
+ | Limit | Value | Consequence in this plugin |
35
+ | --- | --- | --- |
36
+ | `recordBytes` | 65,536 (64 KiB) | one JSON-RPC record above it **aborts the connection** — the transport answers by killing it, so every in-flight request fails |
37
+ | `contentBytes` | 24,576 (24 KiB) | the slice size for one `agent.content.read` |
38
+ | `pageItems` | 100 | the protocol's single-page hard cap; `hello` may negotiate smaller |
39
+
40
+ Through 2.1.7 the desktop's `pageOf` pages by **count alone** (2.1.8's byte
41
+ budget aside, still count-paged): `pageItems: 100` fat parts produced a record
42
+ the transport killed — reading a complex session reset the connection. The
43
+ byte-safe clamps (`defaults.ts`: a 4-bytes-per-char worst case over the peer's
44
+ 4,096-char `INLINE_TEXT_LIMIT`, +2 KiB margin) fix it; the effective page size
45
+ is `min(negotiated pageItems, clamp)`.
46
+
47
+ **Upstream context (#21854, shipped in 2.1.8).** A `PAGE_ITEM_BYTES` byte budget
48
+ (62,279) was added to every paged `agent.*.list` face (`pageOf` truncates,
49
+ `queryPage` re-queries a keyset page at a halved limit); an item that cannot fit
50
+ raises `RESOURCE_EXHAUSTED`. No release through 2.1.7 carries it, so deployed
51
+ peers mostly page by count alone and the clamps stay.
52
+
53
+ Follow-up [`#21951`](https://github.com/CherryHQ/cherry-studio/issues/21951):
54
+ an item above the budget **dead-ends the cursor** — it and everything behind it
55
+ become unreachable via a schema-valid record (`partIds` has no bound) —
56
+ `RESOURCE_EXHAUSTED` presents that permanent condition as retryable, and
57
+ `queryPage`'s halving wastes up to half the budget. A peer may therefore
58
+ come back with a page **shorter** than requested while still promising more,
59
+ which our walk already follows (`toolsListWalkCompat.test.ts` T2); it must
60
+ never come back **longer** (`CherryPage.pageOverflow`).
61
+
62
+ **Paged summaries (#105).** The hello `agentMessageSummariesVersion` bit is
63
+ stored beside `limits` per hello (absent when unadvertised — no reconnect
64
+ inherits it); ONE helper (`messageListRequest`) serves both message walks and
65
+ sends `partsMode: 'paged'` only at exactly `1`. Parts keep their clamp; only
66
+ the message page widens (to the negotiated `pageItems`); a
67
+ `RESOURCE_EXHAUSTED` with `details.retryable: false` says retrying cannot help.
68
+
69
+ **Advertised endpoints (#143).** The hello `connectionEndpointsVersion` bit
70
+ (strictly `1`; absent through 2.1.3 — 2.1.4 already answers both the bit and
71
+ the method) unlocks ONE best-effort
72
+ `connection.endpoints` (`{domain:'agent'}` → `{desktopIdentity, endpoints}`) per
73
+ handshake, awaited INSIDE it after authenticate and bounded by
74
+ `ENDPOINTS_LEARN_TIMEOUT_MS` (2 s). That bound is derived in ONE narrow sense:
75
+ this wait alone stays below `DEVICE_SETTLE_WAIT_MS` (5 s), so a peer that hangs
76
+ on this one call cannot by itself outlive the window a tool waits for
77
+ `connecting` — the handshake's other waits (a 10 s dial, hello/authenticate on
78
+ the 60 s rpc default) already can, and are not this constant's business. The
79
+ desktop gates the call on the authenticated session and can refuse it (gateway
80
+ not running), so EVERY failure is silent — structurally: `#learnEndpoints` wraps
81
+ everything after the capability gate in one `catch` (a refused or wedged call, a
82
+ malformed answer, a throwing assembly hook), and cannot reject. The answer is discarded
83
+ WHOLE when it does not name the pinned `desktopIdentity`, and each entry is
84
+ filtered by the peer's own `advertisedEndpointSchema` (rejects loopback,
85
+ normalizes the host). Learned hosts join the archive as candidates — see
86
+ [Device archive](#device-archive-schemeport-per-host).
87
+
88
+ **Pacing: two tiers against the desktop's allowances.**
89
+
90
+ | Tier | Methods | Desktop allowance | Default |
91
+ | --- | --- | --- | --- |
92
+ | `shared` | every business RPC but content reads | 64 req/s (≤2.1.6), **256 req/s** (≥2.1.7) | `rateBudgetPerSecond` = **32** |
93
+ | `content` | `agent.content.read` alone | 512 req/s (both) | `DEFAULT_CONTENT_READ_BUDGET_PER_SECOND` = **256** |
94
+
95
+ - **An over-budget request's verdict is generation-dependent**: through 2.1.6
96
+ the desktop closes the whole session (`1011`); from 2.1.7 (PR #21687) it
97
+ refuses that one request (`RATE_LIMITED` + `retryAfterMs`) and keeps the
98
+ session. The generations cannot be told apart before one answers, so the
99
+ default is sized for the harsher: 32/s is 2× under 64, 8× under 256; the
100
+ content tier is 2× under 512.
101
+ - The tier is chosen by `classifyRateTier`, an **exact match**, not a prefix:
102
+ widening it gives a shared-tier method the content budget silently.
103
+ - The content tier is **not a config key** — it derives from the one injected
104
+ shared budget at 8:1, so lowering `rateBudgetPerSecond` lowers both, and is
105
+ **capped at the peer's 512/s** (both generations), so a shared budget above 64
106
+ stops scaling it.
107
+ - Since 2.1.6 the desktop externalizes every part over **4,096 characters**,
108
+ making multi-slice content walks the common case — the reason the content
109
+ tier exists.
110
+
111
+ ## Design decisions and invariants
112
+
113
+ These are load-bearing; the code comments are the long version.
114
+
115
+ ### Transport loss: reads replay once, commands never
116
+
117
+ - A **read** method that loses its link mid-flight is replayed **once**, after a
118
+ reconnect and through a **fresh** rate-limit admission (no replay storm). A
119
+ second loss, or a torn-down connection lifecycle, yields a structured lost
120
+ error (`TransportLostError`, retryable) instead of an endless retry. The
121
+ replay waits at most `transportRetryWaitMs` (default 5 s).
122
+ - A **command** (`cherry_send` / `cherry_create` / `cherry_respond` /
123
+ `cherry_cancel`) is **never replayed**. A lost reply is not a verdict — the
124
+ command stays uncertain and is recovered by **receipt**: the caller retries
125
+ with the **original `commandId`**, and the desktop's command ledger replays
126
+ the original receipt. Only the same id with a *different* body is an
127
+ idempotency conflict.
128
+
129
+ ### The `connecting` window: waited out, then judged
130
+
131
+ - The pool builds a `DeviceConnection` on **first touch** and dials it at once
132
+ (`connectionFor`), so a tool called in the same tick — or during a reconnect —
133
+ observes `connecting`: **an attempt in flight, not a verdict**. It used to be
134
+ answered `device-unavailable` about a connection that came up a moment later.
135
+ - `#resolve` (the eleven device tools; `cherry_devices` and `cherry_unpair` take
136
+ their own paths) and `cherry_devices`' rows wait for that
137
+ attempt, bounded by `DEVICE_SETTLE_WAIT_MS` = 5 s: the wait ends on the first
138
+ transition out of `connecting`, or on the bound with the state it finds, so the
139
+ error face is the same — just later, after the attempt had its chance (before
140
+ any wait answer's `AssemblyDeadline`).
141
+ - **Only `connecting` is waited on.** `offline` (parked in backoff), `disabled`
142
+ and `identity-mismatch` answer at once: no wait can change them, and a
143
+ powered-off desktop would pay the bound on every call. Nothing here starts a
144
+ dial — the pool already did — so tools still never dial implicitly; kicking a
145
+ device out of backoff is separate work.
146
+
147
+ ### Memory: 32 MiB per ref, ~3× assembly, 4 refs at once
148
+
149
+ - `MAX_EXTERNALIZED_BYTES` = 32 MiB caps **one `{ref}`**, the same ceiling the
150
+ official client applies. It is a refusal to start a walk that could never
151
+ finish (a peer declaring 4 GiB would mean ~170,000 slices), not a proof about
152
+ the content. A part above it is `unavailable`.
153
+ - Assembling one ref's content holds roughly **3× its size** at peak: the
154
+ decoded slices, the single concatenation the digest and decoder need, and
155
+ the decoded string. That is the price of never hashing or decoding a buffer a
156
+ caller could mutate.
157
+ - An aggregate reads refs **sequentially**, so its own peak is one ref's 3×.
158
+ Across calls, ONE process-wide gate (`ContentReadGate`, issue #39) bounds how
159
+ many refs are being assembled at once: `DEFAULT_CONTENT_READ_CONCURRENCY` = 4,
160
+ the official client's read-concurrency policy. The whole engine — every tool
161
+ call, every device — shares it, so the ASSEMBLY peak is
162
+ **4 × 3 × 32 MiB = 384 MiB worst case**, and in ordinary traffic
163
+ `4 × 3 × (the declared size)`. It bounds the COUNT, not bytes: a byte budget
164
+ must reserve each ref's declared size (optimistic) or its cap when unusable
165
+ (serializing all behind one slot) — deliberately not what landed.
166
+ It bounds what is being ASSEMBLED, not everything resident: finished content
167
+ is retained by its caller OUTSIDE this product (up to one whole message per
168
+ concurrent read, because `AGGREGATE_BYTES` is judged only between messages),
169
+ plus the parts cache's own budgeted bytes (32 MiB per device).
170
+ - **The gate's boundary with the deadline (issue #21).** While a caller is
171
+ QUEUED it honours its budget: at the deadline it leaves the queue and fails
172
+ (`ContentGateTimeoutError`, which the projection degrades to one part's
173
+ `unavailable`). A slot that frees after the deadline but before that timer
174
+ runs is RE-CHECKED and passed on, never spent. Once a walk is RUNNING the gate
175
+ does not interrupt it — its per-slice requests carry the deadline to the
176
+ transport, which is where the I/O stops. Interrupting in both places would
177
+ make "cancelled but still reading" an unexplainable state.
178
+
179
+ ### Read cache: the parts-list RESULT, fenced by the peer's own revision
180
+
181
+ `cherry_read` and the terminal/timeout/idle aggregates share ONE projection
182
+ (`#projectMessage`), which runs in two steps: obtain the parts RECORDS (one
183
+ `agent.parts.list` walk), then project them (one `agent.content.read` walk per
184
+ externalized `{ref}`, digest-verified). The cache sits BETWEEN the steps —
185
+ lookup before the list, store after it — and holds the LIST, never the
186
+ projection. An unchanged message costs no repeat `agent.parts.list`
187
+ RPC on send → wait → read rounds, while every read still re-fetches and
188
+ sha256-verifies every `{ref}`'s bytes (a corrupted ref behind an unchanged
189
+ revision fails with `ContentIntegrityError`; `toolsReadCache.test.ts` T1b pins
190
+ this beside the cache). A hit is still not a zero-RPC read: 2.1.6+ externalizes
191
+ every part over 4,096 characters.
192
+
193
+ - **Entry & key**: the `agent.parts.list` result — `{revision, page}` — keyed
194
+ `<deviceId>\0<messageId>` (device-scoped: a message id is the peer's; two
195
+ desktops can issue the same one). The revision is the FENCE, not part of the
196
+ key, and it covers parts RECORDS only: the cache inherits the peer's own
197
+ `REVISION_EXPIRED` staleness class and adds none — and no more strength: the
198
+ revision is a millisecond wall clock, so two writes inside one millisecond
199
+ can keep it equal while the parts differ — a hit would then serve the older
200
+ list for that revision's lifetime, and reproducing it needs a read between
201
+ the two same-millisecond writes (a shared property of the scheme, not one
202
+ the cache invented).
203
+ - **Never cached**: a row with an EMPTY revision (no fence — defensive only,
204
+ `messageRevision` is a strict decimal, so no test drives it), a TRUNCATED
205
+ parts page (a short list
206
+ from a transient fault is retried, never remembered; the refusal is
207
+ reason-blind so a future reason cannot reintroduce the bug), and a page above
208
+ `MESSAGE_CACHE_ENTRY_BYTES` (1 MiB; walked fresh rather than allowed to crowd
209
+ out a device's working set).
210
+ - **A failed part does NOT forget the list**: degradation happens in
211
+ projection; the held records stay the peer's complete answer and every part
212
+ is re-attempted next read.
213
+ - **Sizing**: `JSON.stringify(page.items).length` (the `AGGREGATE_BYTES`
214
+ proxy). It excludes `{ref}` text (the multi-megabyte half) but INCLUDES
215
+ inline text — up to the peer's 4,096-char `INLINE_TEXT_LIMIT` per part, so a
216
+ few hundred fat inline parts can legitimately reach the 1 MiB entry cap.
217
+ `MESSAGE_CACHE_BYTES_PER_DEVICE` = 32 MiB. Two bounds judged INDEPENDENTLY:
218
+ an entry exceeding the entry cap **or** the device budget is refused — an
219
+ entry the device can never fit must not evict it wholesale to store itself.
220
+ - **Eviction is FIFO** — `Map` order IS the policy (the same choice `#remember`
221
+ documents). A **refresh keeps the key's insertion position** (a rewrite, not
222
+ a use; delete-then-set would silently make it LRU). Room is made BEFORE a
223
+ write for the write's **growth** (`bytes - previous`, zero when it shrinks),
224
+ so a device never passes budget on either path — the refresh-that-grew case
225
+ is the one that used to slip through. `#dropCachedMessage` is the ONE delete
226
+ path (totals cannot drift from the map); the loop skips the key being written
227
+ (self-eviction would reorder it). A drop warns once per insert that dropped
228
+ anything: device and count only, never a message id.
229
+ - **A hit hands the PAGE out BY REFERENCE** — safe while projection only reads
230
+ it; a future mutator must copy first.
231
+ - **Invalidation** rides `#forgetDevice` with the three session caches;
232
+ `#watchConnection` routes `offline` / `identity-mismatch` there. An ordinary
233
+ link loss also publishes `offline`, so a reconnect drops the cache — one
234
+ re-walk per session, the documented cost of the seam.
235
+
236
+ ### Walks: what is tolerated, what is refused
237
+
238
+ - **Empty pages.** The desktop's pinned-list walk legally answers
239
+ `{items: [], nextCursor: 'entity:'}` once per walk (the pin→entity section
240
+ transition). Tolerance is **2 consecutive** advancing empty pages; the **3rd**
241
+ refuses. Any non-empty page resets the count.
242
+ - **A page whose `items` is not an array** (wrong type, or absent) is a
243
+ PROTOCOL violation, refused on EVERY page, terminal or not: `pageOf` types the
244
+ field `array(item)`, so a page with no rows says `items: []`. Read as an empty
245
+ page it loses a whole page silently — on a terminal page even as
246
+ `truncated: false`. The throw names the shape it got (`missing`, `null`,
247
+ `string`, …) and never the payload, and it sits **before** the dropped-item
248
+ bookkeeping (no array, no drop count to report).
249
+ - **Cursors.** A cursor that does not advance is refused, and so is a cursor
250
+ that **loops back** to one already served (a `seen` set per walk) — never spun
251
+ on. `MAX_AGGREGATE_PAGES` (10,000) is the backstop for every other
252
+ non-terminating shape.
253
+ - **Ordering of guards.** The stuck-cursor check runs **before** the empty-page
254
+ tolerance: tolerating empty pages must not blunt the no-progress guard.
255
+ - A truncated walk is **always surfaced** (a `truncationReason` plus a
256
+ stop-point token), never a silent short result. Hitting a safety bound fails
257
+ or reports; it never returns a partial as if it were complete.
258
+ - **An overshooting page is absorbed, not refused.** `limit` is a MAXIMUM on a
259
+ list method, so a page carrying more items than were asked for is a peer fault
260
+ — but nothing is lost yet, and throwing would make the list unreadable (the
261
+ worse failure). The walk takes what its ceiling allows, stops with the
262
+ caller's own attribution, and **re-uses the position it SENT** for that page:
263
+ the page's own `nextCursor` sits past items the walk never collected, so a
264
+ resume from it would skip them, and a terminal overshooting page would
265
+ otherwise be reported complete while dropping its tail. `CherryPage.pageOverflow`
266
+ records the case and `messageResumeToken` then prefers the exact `after:`
267
+ anchor.
268
+ - A **resume** (an `after:` anchor scan) obeys every guard above unchanged —
269
+ including the stuck-cursor and cycle checks, which are pinned during a scan by
270
+ their own tests. Its two failures are its own: an anchor that is not on any
271
+ page up to the peer's last one, and a scan longer than
272
+ `MAX_ANCHOR_SCAN_PAGES`. Both throw. A scan asks for a **full page**
273
+ regardless of the caller's `limit`: its items are discarded either way, and
274
+ paging at `limit: 1` would spend the page budget on a history a full page
275
+ reaches in dozens.
276
+
277
+ ### Wait answers: one absolute deadline
278
+
279
+ The aggregate behind `cherry_send(wait: true)` / `cherry_wait` (the terminal,
280
+ timeout and idle faces) runs AFTER the caller's wait window, so the window
281
+ cannot bound it. It once had no time bound at all: since #12 a read whose link
282
+ dies waits up to `transportRetryWaitMs` (5 s), so under a flapping link each of
283
+ ~30 requests could absorb that wait — a 1 s window answering ~150 s later, the
284
+ shape #3 fixed with a message ceiling and a byte budget (issue #21).
285
+
286
+ - **One instant, derived once.** `AssemblyDeadline` = `startedAt + window +
287
+ clamp(window, WAIT_ANSWER_TAIL_FLOOR_MS, WAIT_ANSWER_TAIL_MS)` — one more
288
+ window, floored at 1 s (the tool's window has a 1 ms minimum, and a 1 ms
289
+ budget would return the stub receipt for a reply one round trip away) and
290
+ capped at 15 s (the largest single item in the walk is one 32 MiB `{ref}` at
291
+ ~1,365 sequential slices, single-digit seconds on a LAN; the rest of the walk
292
+ is bounded to 20 messages). It is built on `performance.now()`, the same
293
+ monotonic clock as the waiter's own deadline.
294
+ - **Every wait is charged to it, where the wait happens.** The rpc layer clamps
295
+ a request's own timeout to `min(timeoutMs, remaining)` (so a budget farther
296
+ away never lengthens one, and one stuck request cannot eat a whole answer);
297
+ the connection refuses a request — and a replay — once it has expired
298
+ (`RpcDeadlineExceededError`), caps the reconnect wait by the remaining budget,
299
+ and judges the PACING ADMISSION before taking it (a spent budget buys no
300
+ slot) as well as after (taking one can consume the last of the budget). The
301
+ walk re-judges the deadline BEFORE every request so a spent budget never
302
+ spends an admission and a round trip, and between messages so one message's
303
+ parts walk cannot start with no time left. One bound is left over: a request
304
+ ALREADY queued for its pacing slot is released by the limiter, so it can be
305
+ refused up to one `#minGapMs` late (36 ms shared, 5 ms content). Refusing it
306
+ earlier needs deadline-aware pacing — a separate piece of work.
307
+ - **A spent budget is a STOP, not a failure.** The message walk and the parts
308
+ walk stop where they are and report `'deadline'` — the third
309
+ `truncationReason`, beside `'limit'` and `'aggregate-bytes'` — and the content
310
+ already read is returned (spec §3.6: a timeout answer exists to hand back
311
+ partial content). A message whose own parts were cut carries
312
+ `partsTruncated: 'deadline'` + `incomplete`. A CONTENT slice has no verdict of
313
+ its own to report: it is one part inside a message, so the part is marked
314
+ `unavailable` (the degradable class — the bytes could not be verified) and the
315
+ message's ordinary projection flags it `incomplete`. **Only** the two
316
+ time-exhaustion classes are converted: a peer refusal, an offline device or an
317
+ integrity failure after the deadline still surfaces as itself, because
318
+ reporting it as `'deadline'` would send the caller looking at their own clock.
319
+ - **`clampedByDeadline` is a flag, not a clock test.** A timer may fire a
320
+ millisecond early, so "has the deadline passed by the time I handle the
321
+ rejection" is a race; `RequestTimeoutError` records whether its timeout was
322
+ DERIVED from the deadline, which is a fact about how the error was produced.
323
+ A plain client timeout (the 60 s default) is not the budget and is not
324
+ converted.
325
+ - **The snapshot is inside the budget and inside the degrade arm — on the
326
+ terminal and timeout faces.** Both start with `agent.sessions.get` (it carries
327
+ `historyRevision`); it rides the same deadline, and its failure degrades to
328
+ the stub receipt (`aggregateError` + `guidance`) exactly like a failed walk —
329
+ the #3 invariant: a run's verdict and its identifiers survive a content
330
+ failure. Before #21 a dead link there made `cherry_send` reject.
331
+ The IDLE face is different, and it is no longer a gap (issue #51): its
332
+ `agent.sessions.get` is the §6 PRECONDITION that decides whether the session
333
+ is idle at all, so there is nothing to degrade to — but it now rides an
334
+ `AssemblyDeadline` of its own, anchored at TOOL ENTRY with the caller's window.
335
+ A spent budget FAILS the call with a guidance error: nothing was learned about
336
+ the run, so a retry is the recovery, and the caller is never handed a verdict
337
+ the snapshot never produced. Before this it was the one wait-answer request
338
+ with no absolute bound, and a flapping link could put 5–10 s of reconnect +
339
+ replay behind a 500 ms window. The idle answer's HISTORY walk keeps its own
340
+ anchor AFTER the snapshot, deliberately: a snapshot that was slow but
341
+ succeeded must not leave the walk with nothing, so the two phases are bounded
342
+ separately (a pathological call can spend both).
343
+
344
+ What this is NOT: a global `cherry_read`/list-tool budget — they take no
345
+ deadline; their bounds are `limit`, the safety breaker and the byte budgets.
346
+
347
+ ### Stop-point cursors (the teaching case)
348
+
349
+ `messagesResumeCursor` is a stop point **and** an input: `cherry_read` takes it
350
+ back as its own `messagesResumeCursor` argument (#26). The marker a walk emits
351
+ is the token a walk accepts — **round-trip symmetry** is the invariant, and both
352
+ message-walk faces (the read face and the terminal/timeout/idle aggregate)
353
+ derive it from the SAME helper, `messageResumeToken`, so they cannot drift into
354
+ two dialects.
355
+
356
+ Three `truncationReason` values mean different things:
357
+
358
+ - **`limit`** — the caller's own ceiling was reached with more available. Fix:
359
+ raise `limit`, or resume with the token and keep walking in another answer.
360
+ - **`aggregate-bytes`** — the answer reached its **output budget** with the walk
361
+ still having messages. This is **independent of `limit`**: raising `limit` only
362
+ costs more round trips and stops at the same budget. The **resume token** is
363
+ how the older messages are reached; from a resume point `limit` bounds how
364
+ many messages one answer carries, so a long history is walked in successive
365
+ answers, each carrying the next stop point.
366
+ - **`deadline`** — the WAIT ANSWER's own assembly budget ran out (see
367
+ [Wait answers](#wait-answers-one-absolute-deadline)). Neither the caller nor
368
+ the peer did anything wrong, so neither raising `limit` nor resuming helps by
369
+ itself: the content that was read is returned (with the token when one can be
370
+ named), and the rest is reached with `cherry_read`, which has no deadline.
371
+
372
+ Keep this distinction in the README. Collapsing `aggregate-bytes` into "raise
373
+ `limit`" was a field defect (a caller paid every round trip and still never saw
374
+ the cut-off history), and a marker with no consumer states a loss nothing can
375
+ act on.
376
+
377
+ **Two token forms, both prefixed** — the prefix is ours, the rest is opaque:
378
+
379
+ - `cursor:<peerPosition>` — the walk consumed its whole collected window, so the
380
+ peer's page position is exactly where the unread history begins. The resume
381
+ read sends it as the **first** request's `cursor`.
382
+ - `after:<messageId>` — the walk stopped **inside** its collected window (a byte
383
+ stop), so the peer's position sits past messages the walk already collected
384
+ and would skip them. `AgentMessage` carries no `createdAt`, so no peer cursor
385
+ can be synthesised for that boundary either. The anchor is the **last returned
386
+ message**; the resume read starts cursorless, scans from the newest end until
387
+ it has met the anchor (discarding pages whole), and returns what follows it.
388
+
389
+ **The peer's cursor format is never parsed or validated.** The real desktop's is
390
+ an opaque keyset token, the loopback peer's is a numeric offset, and a future
391
+ desktop may change it. Every token we emit is
392
+ therefore self-describing, and every token we accept must carry one of our two
393
+ prefixes; anything else is refused **before any device work**. This is not
394
+ pedantry: a malformed cursor handed to the real desktop is **silently swallowed**
395
+ (it falls back to the newest page), so a forwarded foreign string would come
396
+ back as the newest page presented as "continued history".
397
+
398
+ **An anchor the walk cannot find is loud.** If the peer's terminal page arrives
399
+ with the anchor still unfound, the read throws naming the message id (it is
400
+ probably gone: deleted, or the session replaced), rather than returning an empty
401
+ page that reads as "no older history" — the opposite of what happened. A scan
402
+ past `MAX_ANCHOR_SCAN_PAGES` (500 pages) throws for the same reason: a session
403
+ that deep is not one this tool should chase.
404
+
405
+ **Every existing guard stays in force during a scan** — the stuck-cursor check,
406
+ the cursor-cycle `seen` set, the consecutive-empty-page run and
407
+ `MAX_AGGREGATE_PAGES`. The empty-page run counts the **raw** page the peer
408
+ served, never what the scan kept: a non-empty page nobody wanted is still
409
+ progress. The collected-item ceiling counts **returned** items, so a scan's
410
+ discarded pages do not spend the caller's `limit`.
411
+
412
+ **The LIST face (issue #65) speaks the same vocabulary with one addition.** A
413
+ list walk is scoped by (method, device, filters), and the peer's page position is
414
+ opaque: replaying one from a different query is a perfectly valid number that
415
+ reads the wrong slice with no error anywhere. So a truncated list answers
416
+ `resumeCursor: "list:<opaque>"` — the position PLUS a fingerprint of that
417
+ binding — and the token is refused locally (no dial, no page) when replayed
418
+ against a different tool, device or filter set. The bare peer position never
419
+ leaves the tool: `nextCursor` is internal now. The `after:` anchor form stays
420
+ message-only (a list walk never trims a page, so a stop it can name always has a
421
+ peer position). From a resume point `limit` bounds THAT answer; a page the peer
422
+ overshoots is re-read from the position the walk sent (duplicates, never a gap),
423
+ and `cherry_interactions` counts pre-filter rows against `limit`.
424
+
425
+ **The token itself is compact and checksummed (issue #70):**
426
+
427
+ list:v2:<tool>:<device12>:<filters8>:<checksum8>:<peer position>
428
+
429
+ - One character per tool (`a`/`w`/`s`/`i`) instead of the method name, a 12-hex
430
+ **digest** of the deviceId instead of the 36-character UUID, an 8-hex filter
431
+ fingerprint, and an 8-hex checksum. The peer position goes **last and
432
+ verbatim** (it may contain the delimiter — the desktop's session cursor is
433
+ `entity:<orderKey>:<uuid>`), so the split stops at a fixed field count. The
434
+ three binding verdicts keep their own wording; `MAX_RESUME_TOKEN_CHARS` (4,096,
435
+ shared with `cherry_read`'s `messagesResumeCursor`) is enforced by the parser and
436
+ declared as the parameter's `maxLength`.
437
+ - **The checksum is the load-bearing half of that change, not the length.** The
438
+ position is the one field no structural check can validate: measured over
439
+ 20,000 random single-character corruptions of the old base64-JSON token,
440
+ **28.7% were accepted with a different position** — and the desktop's cursor
441
+ decoders (`decodePinnedListCursor`, `decodeListCursor`) warn and fall back to
442
+ the FIRST page for a position they cannot parse, so "continue from here"
443
+ silently became the newest page presented as older history. Every
444
+ single-character corruption is now refused locally (a test walks every
445
+ position of a real token), and the token is 225 → ~100 characters on the
446
+ sessions worst case, ~150 → ~44 on the numeric faces.
447
+ - **Two faces, two prefixes (issue #72).** The message face owns `cursor:` /
448
+ `after:`; the list face owns `list:`. They must stay disjoint: while they
449
+ shared one prefix, a list token replayed as `messagesResumeCursor` was a legal
450
+ message cursor and was forwarded to the desktop, which answers an unparseable
451
+ position by serving its FIRST page — the silent "newest page as older history"
452
+ again. Each face now refuses the other's prefix with a message naming the
453
+ mistake where the prefix identifies the other face (a bare `after:` on a list
454
+ tool gets the generic "must be a `list:` token" verdict).
455
+ The two exceptions are about OUR OWN retired formats, never about the peer's:
456
+ a v1 list token is recognised by DECODING it (it lived under `cursor:` and its
457
+ payload is colon-free base64url, so the message face would otherwise forward
458
+ it), and it is named as an older format FIRST. A peer position is never
459
+ inspected for a marker: `cursor:list:20` is still a message cursor whose
460
+ `list:20` position travels verbatim, because the peer's format is opaque.
461
+ - A v1 token (base64 JSON, `{v,m,d,h,p}`) is refused by name — a plugin upgrade
462
+ invalidates a held token, and "start the walk over" is the actionable answer,
463
+ so that refusal does NOT reuse the generic "…or replay the token" tail: a v1
464
+ token can never be replayed, whoever holds it.
465
+ - **Both faces name only a remainder their own parser would accept, and the rule
466
+ lives in ONE pair of helpers.** `nameableRemainder` is the content dimension —
467
+ `mangledRemainder` itself, the predicate `parseMessageResume` and
468
+ `parseListResumeCursor` apply on replay — so the two sides cannot drift into
469
+ two dialects. It refuses an empty remainder, one that is not equal to its own
470
+ `.trim()` (leading/trailing whitespace), and one carrying a control character
471
+ ANYWHERE; an interior plain space is legal and round-trips (`a b`).
472
+ `nameablePeerPosition` adds the one refusal that applies to a POSITION and not
473
+ to an anchor: `looksLikeV1ListToken`, because our retired 0.1.6 token
474
+ (colon-free base64url of `{v,m,d,h,p}`) is indistinguishable from a peer
475
+ position and both parsers name it. The `after:` arm deliberately does NOT take
476
+ that second check — `parseMessageResume`'s `after:` branch never consults the
477
+ detector, so applying it there would be the same bug mirrored: refusing to name
478
+ a token the parser takes.
479
+ **The round trip itself is a test, not a promise.** `parseMessageResume` is
480
+ exported for one property: every token these emitters NAME is one it ACCEPTS.
481
+ It is asserted over a varied probe table (numeric, keyset, unicode, interior
482
+ space, base64url-shaped, the retired v1 payload) rather than per-clause, so a
483
+ refusal added to the parser WITHOUT mirroring it here turns red for any probe
484
+ that reaches it — the compensations are that a clause no probe reaches still
485
+ slips through, and that over-refusal (naming less than the parser would take)
486
+ is allowed by design, since it costs resumability rather than correctness.
487
+ The failure mode all of this prevents is the emitter minting a stop point the
488
+ same tool then refuses: the caller holds a token that can never be used, and
489
+ the refusal surfaces at the resume call instead of at the answer that produced
490
+ it. A refused remainder yields no token at all — `truncated` already says the
491
+ walk was cut, and the remedy then follows the REASON rather than the absence:
492
+ a `'limit'` stop answers to a wider `limit` only where the caller owns that
493
+ ceiling (`cherry_read` and the four list tools — a WAIT answer's `'limit'` is
494
+ its own fixed message allowance, so `cherry_read` is the way further), an
495
+ `'aggregate-bytes'` stop is that answer's own output budget (more round trips,
496
+ same budget), a `'safety-bound'` stop is an internal circuit breaker a caller
497
+ cannot move, and a `'deadline'` stop — a wait answer's reason — is continued
498
+ with `cherry_read`, which carries no deadline. The message face applies the
499
+ content rule to the `after:` anchor as well as the `cursor:` position: a
500
+ message id is an `opaqueId` too, so a padded id is schema-valid and really does
501
+ reach the anchor arm. The LENGTH ceiling is the sibling guard in the next
502
+ bullet.
503
+ - **The ceiling is enforced on the way OUT too — the same refusal in one more
504
+ dimension.** `MAX_RESUME_TOKEN_CHARS` is judged by both parsers, so a token
505
+ over it is one this tool would refuse on replay, and emitting it would hand
506
+ the caller a stop point that can never be used. Both emitters run the FINAL
507
+ token string through `withinResumeTokenCeiling` — the message face's
508
+ `cursor:` and `after:` arms alike, and the list face's encoded token — and
509
+ name nothing when it is over the bound: `truncated` still says the walk was
510
+ cut, and the remedy follows the reason as set out above. The comparison is
511
+ `<=`, so exactly 4,096 characters is still emitted. The guard measures the
512
+ string it is about to hand out, never a reconstruction of its length, so a
513
+ format change cannot leave it behind. This is about what the tool ACCEPTS; a
514
+ well-behaved peer is far below it (the protocol's own `opaqueId` caps a cursor
515
+ at 256 characters).
516
+ - **The `after:` arm's LENGTH case is pinned on the function; its CONTENT case
517
+ runs through the tool.** An anchor is a message ID, and projecting that
518
+ message sends the same id to `agent.parts.list`, whose `messageId` is an
519
+ `opaqueId`. A peer item carrying an id above the token ceiling therefore
520
+ fails its parts walk and is never RETURNED, so the over-long state is
521
+ unreachable through the protocol, not merely untested; that arm is pinned by
522
+ driving `messageResumeToken` directly (`tests/unit/resumeTokenEmit.test.ts`,
523
+ both arms, both sides of the boundary), and the guard stays regardless,
524
+ because the emitter must never mint what the parser refuses, whatever the
525
+ peer's cap becomes. The CONTENT refusal IS reachable: `opaqueId` allows
526
+ padding, so the parts walk for `' m-0 '` succeeds and that message becomes
527
+ the anchor — `tests/integration/toolsReadResume.test.ts` ⑭d/⑭e/⑭f drive both
528
+ arms through the tools. The `cursor:` arm and the list face keep their
529
+ end-to-end tests as well (`toolsReadResume.test.ts` ⑭a/⑭b,
530
+ `toolsRead.test.ts` ⑦h), which is why that helper is exported.
531
+ - **The tool DESCRIPTION is part of the contract, and it is pinned.** The guards
532
+ above bound what a tool ACCEPTS and NAMES, so a description promising a token
533
+ unconditionally is a claim the tool contradicts — and it is the claim a model
534
+ reads first, more consequentially than the READMEs. Every TOP-LEVEL description
535
+ that mentions a resume token says "when the stop point can be named", and none
536
+ names the wrong absent case ("reached the peer's last page" was the stale one:
537
+ a TRUNCATED walk by definition never reaches it). `cherry_read` states the
538
+ exceptions, and it states them as EXAMPLES, deliberately: an exhaustive list is
539
+ a promise that the next guard will falsify.
540
+ `tests/unit/toolSchemas.test.ts` asserts the clause on every description that
541
+ mentions the token (naming the tools would let a future one slip in) and the
542
+ retired wording across all fourteen, so reverting one to the old text is a red
543
+ test rather than a review catch.
544
+
545
+ ### `AGGREGATE_BYTES`: one budget, two faces
546
+
547
+ `AGGREGATE_BYTES` = 256 KiB bounds a **projected message aggregate** (measured
548
+ with `JSON.stringify(...).length` as a cheap approximation of wire cost). Two
549
+ faces share it and one vocabulary:
550
+
551
+ - the terminal/timeout/idle aggregate returned by `cherry_send(wait: true)` /
552
+ `cherry_wait` (message ceiling 20, `TERMINAL_AGGREGATE_MESSAGES` — ten runs'
553
+ slack, newest-first), and
554
+ - `cherry_read`.
555
+
556
+ The constraint that failed in the field is the **MCP output boundary**: it does
557
+ not care which tool produced an oversized answer ("value is not lossless JSON"
558
+ loses the verdict *along with* the content). One budget, one reason word; what
559
+ differs between the faces is the **message ceiling**, not the cost of a message.
560
+ A second byte value would drift silently.
561
+
562
+ The budget is checked only **between** messages and the first message is always
563
+ taken before any check: the **newest message is always returned whole**, and a
564
+ stop only ever drops **older** messages.
565
+
566
+ ### Truncation has two carriers; never conflate them
567
+
568
+ - **Message-walk truncation** (`truncated` + `truncationReason` +
569
+ `messagesResumeCursor`) drops the **older** messages. It never cuts inside a
570
+ returned message's parts.
571
+ - **Parts-walk truncation** (`partsTruncated` + `partsResumeCursor`) drops that
572
+ one message's **later** parts; parts are read oldest-first. The reason is
573
+ `'safety-bound'` for the local circuit breaker at `DEFAULT_PARTS_SAFETY_BOUND`
574
+ = 10,000 (the protocol caps nothing there) and `'deadline'` when a wait
575
+ answer's budget cut the walk (see
576
+ [Wait answers](#wait-answers-one-absolute-deadline)); the parts walk has no
577
+ byte budget of its own.
578
+
579
+ A returned message missing its own content carries `incomplete: true`, plus
580
+ `unavailableParts` when parts were unreadable — a content verdict on the
581
+ message, unrelated to either walk. **`unavailableParts` absent ≠ 0**: it is
582
+ absent when nothing was counted as unavailable (e.g. a pure parts-walk
583
+ circuit-break with no unreadable part).
584
+
585
+ ### `byteLength` tolerance is a deliberate divergence
586
+
587
+ The official client refuses to read a ref whose `byteLength` is not a safe
588
+ integer ≤ 32 MiB (`RESOURCE_UNAVAILABLE`). We are **more tolerant**: a missing or
589
+ unusable `byteLength` falls back to a slice bound (`DEFAULT_CONTENT_SLICE_BOUND`
590
+ = 4,096 slices), and the **cumulative bytes-read arm** still caps the walk, so a
591
+ peer that serves more than it declared is stopped by bytes actually read.
592
+ It is commented and covered by a test (a peer serving more than it declared);
593
+ do not "align" it without a reason.
594
+
595
+ ### Integrity: what degrades, what is fatal
596
+
597
+ Content the peer **would not or could not** serve (a `NOT_FOUND` pin, a
598
+ transient RPC failure, a non-advancing cursor) **degrades**: the part is marked
599
+ `unavailable` and the rest of the snapshot is still exactly what the peer said.
600
+
601
+ Anything that certifies **wrong or unverifiable** bytes does not degrade:
602
+
603
+ - A **digest mismatch** (reassembled bytes ≠ the ref's advertised sha256) raises
604
+ `ContentIntegrityError` and fails the **whole read**. Returning it as a
605
+ partial part would look like a complete snapshot containing content the peer
606
+ never certified — worse than an error, because the caller cannot tell.
607
+ - **Two sources, one digest.** The ref's declared sha256 and the slices' own
608
+ sha256 must **agree** when both are present; neither can certify the other. A
609
+ disagreement means no single certified value exists (degradable: the part
610
+ loses its text). Every slice is also compared against the ref as it arrives —
611
+ a final honest digest must not launder earlier dishonest ones.
612
+ - Bytes must be reassembled **before** decoding: slicing at 24 KiB splits
613
+ multi-byte characters, and per-slice decoding turns both halves into U+FFFD
614
+ while the byte digest still matches. The walk accumulates `Buffer`s, verifies
615
+ once, then decodes **once**.
616
+ - The decode is **fatal UTF-8**: a digest certifies bytes, not their legality.
617
+ Bytes that hash correctly but are not legal UTF-8 mark the part `unavailable`
618
+ rather than handing the model substitution characters as if they were text.
619
+ - A walk that never reaches `eof`, or that is stopped by any arm of the 32 MiB
620
+ cap, is a failure — never a "partial" value.
621
+
622
+ ### `cherry_read` facts worth keeping in mind
623
+
624
+ - Two-level baseline: `agent.sessions.get` for the history revision,
625
+ `agent.messages.list` for messages, and **each message's own revision** for
626
+ its parts.
627
+ - Messages are **newest-first**; `limit` drops the **oldest**.
628
+ - A failed message may carry `failure: {reasonCode, retryable, message}`
629
+ (peer classification, retry-worthiness, bounded summary); only its
630
+ `source`/`context` are withheld.
631
+ - Attachments — `kind: 'file'`, and legacy `kind: 'data'` named `file` whose
632
+ `{mediaType, filename}` is JSON in `content.text` — report `name`/`mediaType`
633
+ (`byteLength` from a ref only), no `unavailable` (bytes not fetched), and
634
+ `content` **empty** unless the record has a key we skip (then raw text,
635
+ lossless).
636
+ - A terminal aggregate that cannot assemble is a **content** problem, not a
637
+ verdict — the status stands; guidance points at `cherry_read`.
638
+
639
+ ### Device archive: scheme+port per host
640
+
641
+ `DeviceRecord` (v4) stores host-keyed `security` (`'ws'|'wss'`) and `ports`
642
+ (`number`) maps — one per candidate host. A stored v4 record
643
+ carries every entry (missing/unknown hosts, junk values rejected); the DIAL's
644
+ `?.` fallback (`ws`/`port`) serves only an in-memory pre-validation record.
645
+ `port` is the winner's at pairing, the operator's after a PATCH, and the
646
+ fallback. The steady-state dial uses `directEndpointUrl` + both maps (`??`):
647
+ `wss`→`wss` (#126), non-winner host→own port (#135); pairing dials the QR
648
+ candidate's own host/port/scheme, never the record. v2→v3 backfills `ws`,
649
+ v3→v4 `ports` from `port`, atomic on read. Scheme loss breaks a TLS proxy;
650
+ port loss dials the right scheme at the wrong port. Re-typing keeps scheme
651
+ (`securityFor`)/port (`portsFor`) CASE-INSENSITIVELY — a re-typed spelling is
652
+ the same address (#189), and a PATCH refuses a list holding two spellings of
653
+ one host; whole-replace `hosts` defaults new hosts to
654
+ `ws`/port; a CHANGED `port` re-points EVERY final host, the current value a map
655
+ no-op (the settings UI sends both on save). `GET /devices` reports both maps and
656
+ `forgottenHosts` (addresses and intent, not credentials); tools/diagnostics show
657
+ only `port`. The settings
658
+ page shows the per-host ports only when they differ from the record-level one.
659
+ TLS trust is operator config (`wsTls`: FLAT `tls.connect` `rejectUnauthorized`/
660
+ `ca` only), forwarded to both dial sites (pool, pairing). `resolveBridgeConfig`
661
+ validates it: unknown key or unusable value warns by FIELD NAME, WHOLE object
662
+ ignored (half-applied trust reaches some peers, not others). Never persisted,
663
+ logged or reported; `ca` is PEM text.
664
+
665
+ Addresses a peer ADVERTISES (`connection.endpoints`, #143) are ADD-ONLY
666
+ (`mergeAdvertisedEndpoints`): only hosts the record does not already dial are
667
+ appended, each with its OWN port and scheme (the one-entry-per-host invariant is
668
+ never split), capped at `MAX_CANDIDATE_HOSTS` = 32 — what a pairing QR can
669
+ produce (BOTH arms cap at 32), so learning cannot build a record no pairing
670
+ could have. An existing entry — the operator's port, a TLS proxy — is never
671
+ re-pointed and never removed by LEARNING; comparison is CASE-INSENSITIVE (a
672
+ PATCHed record keeps the operator's spelling), and an IPv6 literal in another
673
+ expansion is not reconciled — the PATCH path owns that spelling. A PATCH that
674
+ drops a host therefore still lets a later handshake re-learn it (the peer is the
675
+ source of truth for its own addresses); the operator's answer is **forgetting**
676
+ (#185): `DeviceStore.forgetEndpoints` (through `cherry_devices`
677
+ `action: "forget"`) removes each named host from `hosts`/`security`/`ports` as
678
+ one unit and records it in the record's OPTIONAL `forgottenHosts`, which the
679
+ merge skips in every case — so a desktop that keeps advertising the address
680
+ cannot put it back. `action: "remember"` lifts the mark (the next handshake may
681
+ then re-add it), and a PATCH whose `hosts` names a forgotten host clears that
682
+ host's mark in the same write: an explicit operator list is intent to dial, and
683
+ `hosts`/`forgottenHosts` are disjoint (`assertValidRecord` refuses the overlap).
684
+ Forgetting the LAST candidate is refused (`LastCandidateHostError`) and writes
685
+ nothing — a record with nothing to dial answers `offline` until a re-pair or a
686
+ PATCH writes a fresh `hosts` list.
687
+ `forgottenHosts` is optional and versionless on purpose: absence means "nothing
688
+ forgotten", an older build ignores the key instead of refusing the archive, and
689
+ no migration (or `schemaVersion` 5) was needed. TWO consequences of that
690
+ compatibility are stated, not implied: a RE-PAIR replaces the record wholesale,
691
+ so it clears every mark (a fresh pairing is a fresh decision — the QR's own
692
+ candidates may reintroduce the address), and a DOWNGRADE can produce the overlap
693
+ the write paths refuse, because the older build preserves the key but cannot
694
+ honour it — the READ path repairs that one state
695
+ (`reconcileForgottenMarks`: keeps the dial list, drops the mark, warns), since
696
+ refusing it would brick every device tool until a 0600 file was hand-edited,
697
+ while `upsert`/`forgetEndpoints`/`rememberEndpoints` stay strict. Both write
698
+ actions EVICT the pooled connection ON A CHANGE, exactly like a PATCH's address
699
+ maintenance, because a `DeviceConnection` captures its record at construction.
700
+ `GET /devices` reports `forgottenHosts` beside the address maps, and since issue
701
+ #204 the settings page shows the marks with a 恢复 each, plus a 忘记 beside every
702
+ dialed host: both ride the two POST routes in the table, call the SAME store
703
+ methods the `cherry_devices` action does, and answer the PATCH's projection. A
704
+ PATCH that includes a marked host still clears its mark, so re-admitting an
705
+ address immediately stays the PATCH's job (恢复 only lifts the mark).
706
+ The write goes through
707
+ `DeviceStore.learnEndpoints`, which merges a clone of the STORED record (never
708
+ the connection's: that one mirrors the live `accessToken`, which this archive
709
+ deliberately does not keep), refuses when the record's `desktopIdentity` is no
710
+ longer the pin the answer was validated against (a re-pair), and writes only
711
+ when a host was added, so a stable desktop costs no archive write per
712
+ reconnect.
713
+
714
+ ### Pairing: one dial budget across candidates
715
+
716
+ `desktopIdentity` is decoded at PARSE time with the transport's own
717
+ `peerIdFromString`: a pin that does not DECODE is `invalid-payload` before any
718
+ socket; a decodable non-Ed25519 pin still fails post-open (`channel:`).
719
+ ONE aggregate `dialBudgetMs` deadline (30 s) bounds the walk; unreached
720
+ candidates are named. Claim/poll re-anchors a FRESH 120 s race guard at the
721
+ claim (not the invitation's birth); peer `expired` is the real bound.
722
+ A failed PRE-OPEN dial is prefixed `timeout:` or `websocket:` (the `ws` code is kept); a POST-OPEN channel failure IN THE DIAL is `channel:` (a CLAIM failure rides an established channel and is not tagged).
723
+ Each segment is capped: `FAILURE_LABEL_MAX` = 128 clips an over-long host
724
+ (keeping its port) and `FAILURE_SEGMENT_MAX` = 240 caps the whole
725
+ `<label>: <detail>` (marker `… (+N chars)` included), so 32 candidates join to
726
+ ≤ 7,742 characters.
727
+ The progress line is bounded (first host always, 160 chars of host names,
728
+ `(+K more)`); a stage-failure verdict is ONE error, not a join, capped at
729
+ `FAILURE_SEGMENT_MAX`.
730
+
731
+ ### Device selector
732
+
733
+ `DeviceStore.resolve` = exact `deviceId` → the **union** of `deviceId` prefixes
734
+ and display-name prefixes (one filter, store order, deduplicated by `deviceId`)
735
+ → exactly one hit, `{notFound: true}` (+ `known`), or an ambiguity (+
736
+ `candidates`). `cherry_devices` returns either verdict as a structured result;
737
+ every other tool **throws** when nothing matches. A quarantined archive
738
+ (`archive-quarantined`) outranks all of this. `tests/unit/toolDeviceDocSync.test.ts`
739
+ pins the selector clause in every tool schema — 13 tools take a `device`
740
+ selector, `cherry_pair` does not.
741
+
742
+ ### Scope: hiding, not refusing
743
+
744
+ - The 14 tools are decided **per workspace**; the default is `selected` + an
745
+ empty list, so **nobody** has them (fail-closed: a corrupt/invalid/unreadable
746
+ document lands on the stricter side; no "unreadable → global" escape).
747
+ - A save re-installs or releases the restriction layer for **every live agent**
748
+ and takes effect on the **next turn** — no restart, no new session.
749
+ - Scope governs **tools only**; the settings page and `/cherry-remote/api` are
750
+ never scope-gated, so an excluded workspace can rescue itself.
751
+ - Capability missing is never papered over: `pending` is a neutral wait,
752
+ `unsupported` is a read-only warning with the real cause and save disabled;
753
+ under both the tools stay **unregistered**.
754
+ - `visibility-only` degradation means the execution-side backstop (`tools.guard`)
755
+ is gone but `restrict` still removes the tools from the table — a visibility
756
+ guarantee, not an execution boundary. The panel only ever claims "this plugin
757
+ does not shadow them"; upstream `allow`-shaped filtering can still hide them.
758
+ - The scope document lives at `<dataDir>/scope.json` (0600, atomic, monotonic
759
+ `revision`), **not** in profile YAML. The one exception: a document that fails
760
+ to parse or has invalid fields is rebuilt on the stricter side with `revision`
761
+ reset to 0, and stays writable. `PUT` is read-modify-write with a required
762
+ `expectedRevision`; a `PUT` prune is the only path that clears stale ids.
763
+
764
+ ### HTTP face: endpoints, scope errors and the diagnostics body
765
+
766
+ Mounted under the host web server's `/cherry-remote/api` prefix — **no extra
767
+ listener, no LAN exposure, no separate credentials**:
768
+
769
+ | Method & path | Purpose |
770
+ | --- | --- |
771
+ | `GET /devices` | Device list. |
772
+ | `PATCH /devices/:id` | Update display name / enabled / addresses / port (`hosts` is **whole-replace**). |
773
+ | `POST /devices/:id/forget` | Drop these `hosts` and record the forget marks (the `cherry_devices` action's face, #204). |
774
+ | `POST /devices/:id/remember` | Lift these marks; `hosts` is untouched, so the address returns on the next advertisement. |
775
+ | `DELETE /devices/:id` | Remove the device and tear down its connection. |
776
+ | `GET /scope` | The whole scope state in one body (see below). |
777
+ | `PUT /scope` | Save the scope — read-modify-write, `expectedRevision` required. |
778
+ | `POST /pair` | Start a pairing run; returns `202 {claimId}` at once, progress over SSE. |
779
+ | `GET /events` | SSE: `device-state` / `pairing-progress`. |
780
+ | `GET /diagnostics` | Operations report (below). |
781
+
782
+ Device-route refusals (`{error}`; the page branches on the CODE): `forbidden` 403
783
+ (the fence, before routing) · `invalid-request` 400 (malformed body, or a device id
784
+ that is not valid URL encoding) · `last-address` 400 (the action would leave nothing
785
+ to dial; nothing written) · `invalid-device` 400 (the store refused) · `not-found`
786
+ 404 (unknown device OR action — also what a QUARANTINED archive answers, its view
787
+ being empty) · `method-not-allowed` 405 (before the body) · `archive-quarantined`
788
+ 503 (`{file, corrupt, recovery}`; a damaged archive empties its view and 404s first,
789
+ so this arm only covers a store that refuses after that read — unreachable for the
790
+ shipped store, kept so the arm exists); anything else is the generic 500
791
+ `internal-error`.
792
+
793
+ > The minimal `pairing-progress` frame shape is `{stage, detail?}`; this plugin
794
+ > **additionally** attaches the optional `claimId` (a cross-run isolation
795
+ > extension, not in the minimal set) — consumers must tolerate its absence.
796
+ > The heartbeat is a payload-free comment line.
797
+
798
+ `GET /scope` returns ONE body: `scope` (the document's `mode`/`workspaceIds`/
799
+ `revision` — `version`/`updatedAt` stay behind), `workspaces` (the registry
800
+ projected to `id`/`title`/`path` only), `staleWorkspaceIds` (ticked ids the
801
+ registry no longer has — a `PUT` prune is the only path that clears them),
802
+ `pathUnmatched` (`agentId` + `cwd` for sessions whose cwd belongs to no
803
+ registered workspace — treated as workspace-less, **not** a degradation), and
804
+ `host` (`state` / `capable` / `reason` / `degraded`). `staleWorkspaceIds` and
805
+ `pathUnmatched` are two separate channels and neither derives from the other.
806
+ `degraded` is an array of `{source, reason}`, one latest entry per source, over
807
+ exactly five closed sources — `install`, `workspace-list`, `visibility-only`,
808
+ `scope-version`, `path-normalization` (an empty array is the honest "no
809
+ degradation"). `host` and `pathUnmatched` come from the same scope read the
810
+ diagnostics report uses, so the two faces cannot drift.
811
+
812
+ `PUT /scope` failures return `{error, message?}` with a stable code:
813
+
814
+ | HTTP | `error` | Meaning |
815
+ | --- | --- | --- |
816
+ | 400 | `invalid-scope` | `mode` is not `global`/`selected`, or `workspaceIds` is not a string array. |
817
+ | 400 | `missing-expected-revision` | The baseline is absent **or not a finite number** (one guard judges both). |
818
+ | 409 | `scope-revision-conflict` | The baseline `revision` is stale; `message` carries the current one. |
819
+ | 503 | `workspace-list-unavailable` | The registry is unreadable: write refused, stored ticks and `mode` move not one bit. |
820
+ | 503 | `scope-version-unsupported` | The document's `version` disagrees with the supported one (either direction): read-only, writes locked. |
821
+
822
+ A body that cannot be read at all (invalid JSON) is the generic `400
823
+ invalid-request` — not a scope-field verdict. Success is `200 {scope, pruned}`;
824
+ the returned `revision` is the next write's baseline.
825
+
826
+ ### Diagnostics redaction is a whitelist
827
+
828
+ `GET /cherry-remote/api/diagnostics` aggregates device states, connection
829
+ statistics and a recent-error digest (`ERROR_MESSAGE_MAX` = 200 characters on
830
+ the error message).
831
+
832
+ - **May be emitted** (`sanitizeDevice`): `deviceId`, `displayName`, `state`,
833
+ `enabled`, `hosts`, `port`, a **12-char digest** of `desktopIdentity`, the
834
+ hello-negotiated `limits` (`heartbeatMs`/`subscriptions`/`pageItems`/
835
+ `inFlightRequests` — **only with a live connection**; the endpoint never
836
+ dials and never wakes a device to fetch fallbacks), `messageSummaries` (the
837
+ hello capability bit, absent when not advertised), `sessionId` and session
838
+ cursors (unsubscribed sessions have none), the `stats` counters
839
+ (`connections`/`sockets`/`connectionsBuilt`/`subscriptions`), the two resolved
840
+ `config` keys, and error `reason`/`message`.
841
+ - **Never emitted**: `accessToken`, `tokenExpiresAt`, `invitationSecret`, the
842
+ raw pinned `desktopIdentity`, message text and part text.
843
+ - The report is built field by field, so a later credential is excluded **by
844
+ construction**, not by a deny-list. `limits` is re-projected from its four
845
+ keys: a whitelist inside the whitelist.
846
+ - `stats.connectionsBuilt` counts connections built since the last `apply`, not
847
+ RPC requests — those belong to the transport; a same-named metric would be
848
+ misread. Counters and the error ring are zeroed on every `apply`: the
849
+ statistics describe **only** the current assembly.
850
+
851
+ ### Logging: levels, identifiers, and what never appears
852
+
853
+ Levels: **error** = connection / authentication / protocol failure; **warn** =
854
+ reconnect backoff / cache eviction / subscription LRU eviction; **info** =
855
+ pairing progress / subscription establish & recovery / device-state
856
+ transitions. Info logs the **stage name and identifiers only** (`deviceId`,
857
+ `sessionId`, pairing stage) — never payloads. Sensitive fields (tokens,
858
+ `invitationSecret`, `accessToken`, message and part text) **never enter any
859
+ log**; `deviceId`, the `desktopIdentity` digest and `sessionId` may. A
860
+ contract test drives pair → connect → send → interact and asserts sentinel
861
+ strings never reach a console channel (`contract.test.ts` — “leaks no secret
862
+ to the log”). **Peer-controlled text in a log line or a pairing verdict is
863
+ clipped where it is RENDERED**: `clipText` (one detail, `… (+N chars)`) or
864
+ `boundedJoin` (a list, `(+K more)`) in `src/text.ts`, per-site ceilings;
865
+ `deviceId` is exempt, and `bounded()` is the frame-drop path's older
866
+ count-less form.
867
+
868
+ **A CAUGHT failure renders as ONE bounded string** — `failureText` (the message,
869
+ `LOG_TEXT_MAX`) — in every catch that PEER traffic reaches: the notification
870
+ router, a background re-ensure, an ack, a best-effort close, the connect
871
+ rejection. Never hand the error OBJECT to `console.*`: it prints the name, the
872
+ message and the whole stack (own properties too, once a devtools inspect
873
+ expands it), so peer text arrives unbounded. A purely LOCAL catch keeps the
874
+ object print on purpose — no peer text can reach it and the stack is the useful
875
+ part: an fs quarantine, the host registry, our own listener/settle fan-out, a
876
+ REST route's synchronous construction. `failureText` cannot throw, because it
877
+ runs inside those catches. A TOOL RESULT is deliberately NOT bounded: it is the
878
+ model's signal, and the peer is the user's own Noise-authenticated desktop.
879
+
880
+ ### Trust fence
881
+
882
+ Every `/cherry-remote/api` route re-checks same-origin before acting, in order:
883
+ (1) non-loopback undeclared `Host` → refuse; (2) `sec-fetch-site: cross-site` →
884
+ refuse; (3) `Origin` present and not same-origin with `Host` → refuse;
885
+ (4) `Origin` absent → **allow** (curl, not a cross-site page).
886
+
887
+ - **A folded bind is normalized to an AUTHORITY.** `webStartup.host` is a bare
888
+ hostname, but every `trustedHosts` entry is parsed with `new URL('http://' +
889
+ entry)`, where an IPv6 literal MUST be bracketed. So an IPv6 `--host` is
890
+ bracketed when folded (`fe80::1` → `[fe80::1]`) and an already-bracketed value
891
+ is left alone (`[[fe80::2]]` parses to nothing). Without this an IPv6 LAN bind
892
+ contributed no usable entry and only loopback stayed reachable — fail-closed,
893
+ but an availability gap (issue #9). Zone-scoped literals (`fe80::1%eth0`, with
894
+ or without `%25`) still parse to nothing — this runtime rejects zone ids in an
895
+ IPv6 literal — and a browser sends no zone id. A bind with a PORT
896
+ (`example.com:8080`) is out of contract for `webStartup.host` and is dropped
897
+ rather than trusted, the fail-closed direction.
898
+
899
+ ## Upstream references
900
+
901
+ Neither reference is published with this repository.
902
+
903
+ - **`.scratch-csa`** (gitignored) — the official Cherry Studio **app**
904
+ (`CherryHQ/cherry-studio-app` @ v0.5.2, read 2026-10-31), the **same role we
905
+ play** (protocol *client* against the desktop's server). Its remote layer is
906
+ `src/backend/services/remoteAgent/`; "what the official client does" comes
907
+ from there. v0.5.0 moved onto the pinned generation, whose one behavioural
908
+ change is the versioned pairing QR; v0.5.1 and v0.5.2 changed NOTHING on that
909
+ face — `git rev-parse <tag>:src/backend/services/remoteAgent` is
910
+ `16e505fe…` at all three tags, and the pins are still 0.7.0 / 0.2.5. Check
911
+ that tree hash before believing a new app version changed the wire.
912
+ - **`_research/cherry-studio`** — the **desktop** source, the protocol **server**
913
+ of record: a shallow `blob:none` clone carrying tags `v2.1.6` (`90b234e`)
914
+ through `v2.1.10` (`c60cd41a6`). Diff TREES between tags; attribute a change
915
+ with the GitHub API, not `git log` (the graft hides the range). The faces we
916
+ depend on are `src/main/services/remoteAccess/` and
917
+ `packages/remote-{protocol,transport}`; `RemoteAccessService.ts` carries both
918
+ rate lines. Gitignored; treated as read-only research.
919
+ - `node_modules/@cherrystudio/remote-protocol` / `remote-transport` are the
920
+ protocol schema source of truth for what we send and decode.
921
+
922
+ **Verified desktop range: 2.1.4 → 2.1.10.** mDNS, protocol versions (`[1]`) and
923
+ Noise saw **zero changes**; pairing only ADDED the v3 QR arm (below). Notable
924
+ faces:
925
+
926
+ | Upstream change | Impact |
927
+ | --- | --- |
928
+ | 2.1.5 `connection.hello` adds `agentUploadsVersion` / `agentUploadChecksum` / `agentAttachmentSelections` and 5 `agentUpload*` limits | Additive: hello has no strict validation, limits are read by name |
929
+ | 2.1.5 record framing adds a binary marker (first byte `1`) for upload chunks | Additive: JSON record encoding is unchanged; we neither send nor receive binary |
930
+ | 2.1.5 attachments project as `{kind: 'file', name, ref}` | Metadata only — bytes are never fetched |
931
+ | 2.1.5 `agent.messages.send` requires non-empty trimmed `text` | `cherry_send` rejects blank text locally as `text-empty` |
932
+ | `agent.content.read` relaxed to 512/s | Our 32/s shared budget is unchanged; the content tier exists for throughput, not for the ceiling |
933
+ | 2.1.5 keychain-less Linux stores the identity as plaintext 0600; identity is never regenerated | Upgrades need **no re-pairing** |
934
+ | 2.1.7 raises the shared allowance to 256/s and refuses an over-budget request (`RATE_LIMITED` + `retryAfterMs`) instead of closing 1011 | No schema change; our 32/s default already sits far below it |
935
+ | 2.1.8: v3 pairing QR (`bc570d2b9`, PR #21387) — `pairingQrSchema` v2|v3 with custom host/port/security `endpoints`, tightened IPv6; paged message summaries (`54736dc9`/`8bf2fe28d`, PR #21992) advertised by the `agentMessageSummariesVersion` hello capability; server `PAGE_ITEM_BYTES = 62_279` per-item budget | Pairing consumes the published union (#124); summaries are consumed behind the hello capability (#105); a byte-budgeted page may come back SHORTER, which our walk already follows |
936
+ | 2.1.9/2.1.10: **only** `RemoteConnection.send()`'s write chain changed (`16d9487a9`, PR #22593) — `this.writes` keeps its links resolved, so one rejected send no longer makes every later send skip `channel.write`; every other remote-access file and both protocol packages are byte-identical to 2.1.8 | No client change: our answers never depend on the notification stream (list snapshots are the authority), and our own serialized queue (`SecondWindowRateLimiter`) cannot reject |
937
+ | `5db8ab7` (agent runtime DSH → Pi, deleting `packages/dsh-bridge`) | The **opposite** bridge (Cherry driving DSH); unrelated to the remote-access face |
938
+
939
+ **Traps in the official app (a cautionary list, not a spec).**
940
+
941
+ - Its `RemoteAgentError` marks those four retryable, but its scope layer DOES
942
+ honour `details.retryable: false`; it has no adaptive page size, so a
943
+ byte-budget `RESOURCE_EXHAUSTED` still does not converge at a fixed limit. Our
944
+ answer is the byte-safe page **clamp**: never ask for a page that cannot fit.
945
+ - Its read path refuses an unusable `byteLength`; we fall back to a slice bound
946
+ plus the cumulative arm.
947
+ - Its read path never replays a read; we replay reads **once** (deliberate
948
+ enhancement) while both sides keep commands receipt-recovered.
949
+