dsh-cherry-bridge 0.1.7 → 0.1.8

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.en.md CHANGED
@@ -38,13 +38,13 @@ flowchart TB
38
38
 
39
39
  ```bash
40
40
  # From npm (recommended, pinned)
41
- npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.7
41
+ npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.8
42
42
 
43
43
  # Install the latest npm release
44
44
  npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
45
45
 
46
46
  # From GitHub source (same commit as the npm release)
47
- npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.7
47
+ npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.8
48
48
  ```
49
49
 
50
50
  The package ships `cordis.patch.yml` (`package.json`'s `dsh.bundle.patch` points
@@ -103,15 +103,15 @@ cherry_interactions → see pending question/approval cards; cherry_respond answ
103
103
  | Tool | What it does |
104
104
  | --- | --- |
105
105
  | `cherry_devices` | List every paired device and its connection state (`connected`/`connecting`/`offline`/`disabled`/`identity-mismatch`). |
106
- | `cherry_agents` | List the agents available on a device (paginated). A truncated answer always carries `truncated` + `truncationReason` (`limit` or `safety-bound`, the latter the 1000-item internal ceiling), plus a `resumeCursor`: replay it as `cursor` with the same device and filters to continue from that stop point (`limit` then bounds the next answer). A token from another tool, device or filter set is refused **locally**, never forwarded. |
107
- | `cherry_workspaces` | List an agent's **registered** workspaces, and whether a system workspace exists. A truncated answer always carries `truncated` + `truncationReason` and a `resumeCursor` — replay it as `cursor` with the same filters to continue. |
108
- | `cherry_sessions` | List an agent's sessions, optionally filtered by workspace. A truncated answer always carries `truncated` + `truncationReason` (`limit` or `safety-bound`) plus a `resumeCursor`: replay it as `cursor` with the same device and **identical filters** to continue (`limit` then bounds the next answer); a token from another tool, device or filter set is refused **locally**. |
109
- | `cherry_read` | Read a session's message history snapshot, **newest-first** (`limit` drops the **oldest** messages). Attachment parts come back as `name`/`mediaType`/`byteLength` with an empty `content` — the bytes are never fetched. A truncated walk always says so (`truncated` + `truncationReason`): `limit` means your own ceiling was reached and raising `limit` helps; `aggregate-bytes` means the answer hit its **output size limit** — independent of `limit` (a larger `limit` only costs more round trips and stops at the same budget). **The older messages are reached by the resume token**: pass the `messagesResumeCursor` a truncated answer returned as this tool's own `messagesResumeCursor` argument to continue from that stop point (`limit` then bounds how many messages come back **from there**), walking a long history in successive answers. Two token forms: `cursor:<peer position>` (the peer page position after a whole window was consumed) and `after:<messageId>` (the walk stopped inside its window; the resume scans from the newest end to that message and returns the older ones). A token this tool did not emit is **refused locally** (never forwarded — the desktop silently falls back to the newest page for one), and an `after:` anchor that is no longer in the history is an **error**, not an empty result. |
106
+ | `cherry_agents` | List the agents available on a device (paginated). A truncated answer always carries `truncated` + `truncationReason` (`limit` or `safety-bound`, the latter the 1000-item internal ceiling); when the stop point can be named it also carries a `resumeCursor`: replay it as `cursor` with the same device and filters to continue from that stop point (`limit` then bounds the next answer). A token from another tool, device or filter set is refused **locally**, never forwarded. |
107
+ | `cherry_workspaces` | List an agent's **registered** workspaces, and whether a system workspace exists. A truncated answer always carries `truncated` + `truncationReason`; when the stop point can be named it also carries a `resumeCursor` — replay it as `cursor` with the same filters to continue. |
108
+ | `cherry_sessions` | List an agent's sessions, optionally filtered by workspace. A truncated answer always carries `truncated` + `truncationReason` (`limit` or `safety-bound`); when the stop point can be named it also carries a `resumeCursor`: replay it as `cursor` with the same device and **identical filters** to continue (`limit` then bounds the next answer); a token from another tool, device or filter set is refused **locally**. |
109
+ | `cherry_read` | Read a session's message history snapshot, **newest-first** (`limit` drops the **oldest** messages). Attachment parts come back as `name`/`mediaType`/`byteLength` with an empty `content` — the bytes are never fetched. A truncated walk always says so (`truncated` + `truncationReason`): `limit` means your own ceiling was reached and raising `limit` helps; `aggregate-bytes` means the answer hit its **output size limit** — independent of `limit` (a larger `limit` only costs more round trips and stops at the same budget). **The older messages are reached by the resume token**: pass the `messagesResumeCursor` a truncated answer returned as this tool's own `messagesResumeCursor` argument to continue from that stop point (`limit` then bounds how many messages come back **from there**), walking a long history in successive answers. When the stop point cannot be named the token is **absent** (`truncated` stays `true`, and the absence does **not** mean the history ended); a wider `limit` is not a general remedy — it moves a `limit` stop only, while an `aggregate-bytes` stop leaves the older messages unreachable in that answer (see [Errors and limits](#errors-and-limits)). Two token forms: `cursor:<peer position>` (the peer page position after a whole window was consumed) and `after:<messageId>` (the walk stopped inside its window; the resume scans from the newest end to that message and returns the older ones). A token this tool did not emit is **refused locally** (never forwarded — the desktop silently falls back to the newest page for one), and an `after:` anchor that is no longer in the history is an **error**, not an empty result. |
110
110
  | `cherry_events` | Read the session's buffered events newer than `cursor`, acking them after delivery. The answer is `{events, nextCursor}`; pass that `nextCursor` back as `cursor` to continue from it. |
111
111
  | `cherry_create` | Create a session in an agent workspace (workspace selection is **required**; identity is read back). |
112
112
  | `cherry_send` | Send a message, by default waiting for a terminal state and returning the assistant text; `wait: false` returns the command receipt immediately. `messagesTruncated`/`messagesTruncationReason` report a **message**-level cut (older messages dropped); `deadline` means the **answer's own assembly budget** ran out (see below), with the content read so far still returned. |
113
113
  | `cherry_wait` | Wait only for the session's in-flight execution to settle (sends nothing). |
114
- | `cherry_interactions` | List the session's **pending** interaction cards (questions / approvals). A truncated answer always carries `truncated` + `truncationReason` and a `resumeCursor` — replay it as `cursor` to continue (`limit` counts the peer's pre-filter rows). |
114
+ | `cherry_interactions` | List the session's **pending** interaction cards (questions / approvals). A truncated answer always carries `truncated` + `truncationReason`; when the stop point can be named it also carries a `resumeCursor` — replay it as `cursor` from the same `sessionId` to continue (`limit` counts the peer's pre-filter rows). |
115
115
  | `cherry_respond` | Answer an interaction card: questions must be answered in full; approvals take `decision`. |
116
116
  | `cherry_cancel` | Cancel the session's in-flight execution (`expectedExecutionId` required). |
117
117
  | `cherry_pair` | Pair with a desktop using the pairing information shown by the scanned page. |
@@ -124,12 +124,12 @@ The `device` argument accepts an **exact `deviceId`**, or a **prefix of a
124
124
  `cherry_send(wait: true)` / `cherry_wait` results carry one of **8 `status`
125
125
  values**: the four protocol terminal states
126
126
  (`completed`/`failed`/`cancelled`/`interrupted`), plus `awaiting-interaction`
127
- (a pending card appeared while the run is still alive), `timeout` (the window ran
128
- out, **not a failure** — partial content plus a stop-point cursor come back),
129
- `idle` (`cherry_wait` met a session with nothing in flight and returned the
130
- snapshot immediately), and `subscription-lost` (the subscription was lost and
131
- could not be rebuilt, so the outcome is **unknowable**; repair guidance ships
132
- with it).
127
+ (a pending card appeared while the run is still alive), `timeout` (the window
128
+ ran out, **not a failure** — partial content comes back, plus a stop-point
129
+ cursor when one can be named), `idle` (`cherry_wait` met a session with nothing
130
+ in flight and returned the snapshot immediately), and `subscription-lost` (the
131
+ subscription was lost and could not be rebuilt, so the outcome is
132
+ **unknowable**; repair guidance ships with it).
133
133
 
134
134
  ## Errors and limits
135
135
 
@@ -152,9 +152,23 @@ with it).
152
152
  - A truncated history walk always says so (`truncationReason`; `limit`,
153
153
  `aggregate-bytes` and `deadline` mean different things, see above) and,
154
154
  whenever a stop point can be named honestly, carries a `messagesResumeCursor`
155
- resume token (absent when the walk reached the peer's last page); a message
156
- whose own parts could not be fully read carries `incomplete: true` (inside a
157
- wait answer, a message cut by the deadline carries
155
+ resume token. **When it cannot be named the token is absent**: the peer's
156
+ position is empty, carries leading/trailing whitespace or a control character,
157
+ looks like a retired v1 token, the token would exceed the 4,096-character
158
+ ceiling, or there is no message id to anchor on. The absence does **not** mean
159
+ the history ended (`truncated` stays `true`); the remedy follows the reason —
160
+ a `limit` stop answers to a wider `limit` only where the caller owns that
161
+ ceiling (`cherry_read` and the four list tools; a **wait answer's** `limit` is
162
+ its own fixed allowance, so read further with `cherry_read`), an
163
+ `aggregate-bytes` stop does not (nothing reaches past it in that answer), a
164
+ `safety-bound` stop is an internal circuit breaker no caller can move, and a
165
+ wait answer cut by its deadline is continued with `cherry_read`, which carries
166
+ no deadline. **Both faces obey one rule**:
167
+ the message face's `messagesResumeCursor` and the four list tools'
168
+ `resumeCursor` are emitted only when this tool would accept them back; an
169
+ interior plain space is legal and is not a reason to withhold one.
170
+ A message whose own parts could not be fully read carries `incomplete: true`
171
+ (inside a wait answer, a message cut by the deadline carries
158
172
  `partsTruncated: "deadline"`).
159
173
  - **A wait answer has an assembly budget of its own.** After the window ends,
160
174
  `cherry_send(wait: true)` / `cherry_wait` still read some history (the message
package/README.md CHANGED
@@ -36,13 +36,13 @@ flowchart TB
36
36
 
37
37
  ```bash
38
38
  # 从 npm 安装(推荐,钉住版本)
39
- npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.7
39
+ npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.8
40
40
 
41
41
  # 安装 npm 最新发布版
42
42
  npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
43
43
 
44
44
  # 从 GitHub 源码安装(与 npm 发布同一提交)
45
- npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.7
45
+ npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.8
46
46
  ```
47
47
 
48
48
  本包自带 `cordis.patch.yml`(`package.json` 的 `dsh.bundle.patch` 指向它),
@@ -94,15 +94,15 @@ cherry_interactions → 看待答的提问/审批卡;cherry_respond 作答
94
94
  | 工具 | 作用 |
95
95
  | --- | --- |
96
96
  | `cherry_devices` | 列出已配对设备及连接状态(`connected`/`connecting`/`offline`/`disabled`/`identity-mismatch`)。 |
97
- | `cherry_agents` | 列出某设备上可用的 agent(分页)。被截断时必带 `truncated`+`truncationReason`(`limit` 或 `safety-bound`,后者是 1000 条内部上限),并给出 `resumeCursor`——用同一 device 与过滤条件把它回传为 `cursor` 即可从停点继续(此时 `limit` 约束的是下一次回答);跨工具/设备/过滤条件的令牌会被**本地拒绝**,不会转发给对端。 |
98
- | `cherry_workspaces` | 列出某 agent 的**已注册**工作区,以及是否存在系统工作区。被截断时必带 `truncated`+`truncationReason`,并给出 `resumeCursor`——用相同过滤条件回传为 `cursor` 即可续读。 |
99
- | `cherry_sessions` | 列出某 agent 的会话,可按工作区过滤。被截断时必带 `truncated`+`truncationReason`(`limit` 或 `safety-bound`),并给出 `resumeCursor`——用同一 device 与**相同过滤条件**把它回传为 `cursor` 即可从停点继续(此时 `limit` 约束的是下一次回答);跨工具/设备/过滤条件的令牌会被**本地拒绝**。 |
100
- | `cherry_read` | 读会话消息历史快照,**从新到旧**返回(`limit` 丢掉的是**最旧**的消息)。附件 part 只回 `name`/`mediaType`/`byteLength`,`content` 为空——字节不取。被截断时 `truncated` + `truncationReason` 一定说明:`limit` 表示你设的条数到了,提高 `limit` 即可;`aggregate-bytes` 表示单次回答达到了**输出大小上限**——与 `limit` 无关,提高 `limit` 只会多付往返、仍停在同一预算。**被挡下的更旧消息用 `messagesResumeCursor` 续读**:把本次返回的 `messagesResumeCursor` 原样传给下一次调用的 `messagesResumeCursor` 参数即可从停点继续(`limit` 此时约束的是**从停点起**本次最多返回多少条),长历史分几次回答读完。两种令牌:`cursor:<peer position>`(整窗读完后的 peer 分页位置)与 `after:<messageId>`(停在窗内,续读从最新端扫到该消息、返回它更旧的那些)。不是本工具发出的令牌会被**本地拒绝**(不转发给桌面端——桌面端会静默回退到最新页);`after:` 锚点在历史里找不到则**报错**,不会返回空结果。 |
97
+ | `cherry_agents` | 列出某设备上可用的 agent(分页)。被截断时必带 `truncated`+`truncationReason`(`limit` 或 `safety-bound`,后者是 1000 条内部上限);停点可命名时还给出 `resumeCursor`——用同一 device 与过滤条件把它回传为 `cursor` 即可从停点继续(此时 `limit` 约束的是下一次回答);跨工具/设备/过滤条件的令牌会被**本地拒绝**,不会转发给对端。 |
98
+ | `cherry_workspaces` | 列出某 agent 的**已注册**工作区,以及是否存在系统工作区。被截断时必带 `truncated`+`truncationReason`;停点可命名时还给出 `resumeCursor`——用相同过滤条件回传为 `cursor` 即可续读。 |
99
+ | `cherry_sessions` | 列出某 agent 的会话,可按工作区过滤。被截断时必带 `truncated`+`truncationReason`(`limit` 或 `safety-bound`),停点可命名时还给出 `resumeCursor`——用同一 device 与**相同过滤条件**把它回传为 `cursor` 即可从停点继续(此时 `limit` 约束的是下一次回答);跨工具/设备/过滤条件的令牌会被**本地拒绝**。 |
100
+ | `cherry_read` | 读会话消息历史快照,**从新到旧**返回(`limit` 丢掉的是**最旧**的消息)。附件 part 只回 `name`/`mediaType`/`byteLength`,`content` 为空——字节不取。被截断时 `truncated` + `truncationReason` 一定说明:`limit` 表示你设的条数到了,提高 `limit` 即可;`aggregate-bytes` 表示单次回答达到了**输出大小上限**——与 `limit` 无关,提高 `limit` 只会多付往返、仍停在同一预算。**被挡下的更旧消息用 `messagesResumeCursor` 续读**:把本次返回的 `messagesResumeCursor` 原样传给下一次调用的 `messagesResumeCursor` 参数即可从停点继续(`limit` 此时约束的是**从停点起**本次最多返回多少条),长历史分几次回答读完。停点不可命名时**没有**这个令牌(`truncated` 仍为 `true`,**缺失本身不代表历史已到底**;补救按 `truncationReason` 分:`limit` 可以提高 `limit`,`aggregate-bytes` 不行——被挡住的旧消息在这一回答里拿不到,详见[错误与限制](#错误与限制))。两种令牌:`cursor:<peer position>`(整窗读完后的 peer 分页位置)与 `after:<messageId>`(停在窗内,续读从最新端扫到该消息、返回它更旧的那些)。不是本工具发出的令牌会被**本地拒绝**(不转发给桌面端——桌面端会静默回退到最新页);`after:` 锚点在历史里找不到则**报错**,不会返回空结果。 |
101
101
  | `cherry_events` | 读取该会话缓冲区内比 `cursor` 更新的事件,并在投递后 ack。返回 `{events, nextCursor}`;把 `nextCursor` 作为下次的 `cursor` 传回即可续读。 |
102
102
  | `cherry_create` | 在 agent 工作区创建会话(工作区选择**必填**;创建后回读身份)。 |
103
103
  | `cherry_send` | 向会话发消息,默认等待运行到终态并返回助手文本;`wait: false` 立即返回命令回执。内容被截断时 `messagesTruncated`/`messagesTruncationReason` 说的是**消息**被截断(丢更旧的);`deadline` 表示**回答自身的装配时限**到了(见下),已读到的内容照常返回。 |
104
104
  | `cherry_wait` | 只等待该会话在途执行收敛(不发送新消息)。 |
105
- | `cherry_interactions` | 列出该会话**待答**的交互卡(提问 / 审批)。被截断时必带 `truncated`+`truncationReason`,并给出 `resumeCursor`——用 `sessionId` 相同的调用回传为 `cursor` 即可续读(`limit` 计的是过滤**前**的对端条目)。 |
105
+ | `cherry_interactions` | 列出该会话**待答**的交互卡(提问 / 审批)。被截断时必带 `truncated`+`truncationReason`;停点可命名时还给出 `resumeCursor`——用 `sessionId` 相同的调用回传为 `cursor` 即可续读(`limit` 计的是过滤**前**的对端条目)。 |
106
106
  | `cherry_respond` | 应答交互卡:提问必须全量作答,审批走 `decision`。 |
107
107
  | `cherry_cancel` | 取消会话在途执行(`expectedExecutionId` 必填)。 |
108
108
  | `cherry_pair` | 用「扫码后页面显示的配对信息」建立与桌面端的配对。 |
@@ -113,7 +113,7 @@ cherry_interactions → 看待答的提问/审批卡;cherry_respond 作答
113
113
 
114
114
  `cherry_send(wait: true)` / `cherry_wait` 的 `status` 有 **8 个取值**:四个协议终态
115
115
  (`completed`/`failed`/`cancelled`/`interrupted`),加上 `awaiting-interaction`(出现待答
116
- 交互卡,运行仍活着)、`timeout`(窗口用尽,**不是失败**,随附部分内容与停点游标)、
116
+ 交互卡,运行仍活着)、`timeout`(窗口用尽,**不是失败**,随附部分内容,停点可命名时再带游标)、
117
117
  `idle`(`cherry_wait` 遇到没有在途执行的会话,立即返回快照)、`subscription-lost`
118
118
  (事件订阅丢失且重建失败,运行结局**不可知**,随附修复指引)。
119
119
 
@@ -128,9 +128,17 @@ cherry_interactions → 看待答的提问/审批卡;cherry_respond 作答
128
128
  不在这个乘积里(每次并发读最多再带一整条消息,因为输出预算只在消息之间检查),parts 缓存另计。
129
129
  等待槽位也计入等待类回答的时限:排到时限就放弃并把这一 part 标 `unavailable`。
130
130
  - 历史走查被截断时一定说明(`truncationReason`,`limit` / `aggregate-bytes` / `deadline` 三种含义不同,见上),
131
- 并在能诚实标出停点时给出 `messagesResumeCursor` 续读令牌(走到对端末页时没有可标的位置,就不给);
132
- 某条消息自身 parts 没读全时带 `incomplete: true`(wait 类回答里被时限截断的那条消息会带
133
- `partsTruncated: "deadline"`)。
131
+ 并在停点可命名时给出 `messagesResumeCursor` 续读令牌。**停点不可命名时就不给**:对端位置为空、
132
+ 带首尾空白或任意控制字符、形如已停用的 v1 令牌、令牌会超过 4096 字符上限、或没有可当锚点的
133
+ 消息 id——此时 `truncated` 仍为 `true`,**缺失本身不代表历史已到底**。补救按 `truncationReason`
134
+ 分:`limit` 停点只有在调用方自己握着上限时才靠更宽的 `limit`(`cherry_read` 与四个列表工具);
135
+ **wait 类回答的 `limit` 是它自己的固定条数额度**,更旧的历史要用 `cherry_read` 去读。
136
+ `aggregate-bytes` 停点不行——预算是它停下的原因,更宽的 `limit` 只会多付往返,被挡住的旧消息
137
+ 在这一回答里拿不到;`safety-bound` 停点是内部熔断,调用方动不了它;`deadline`(只出现在 wait
138
+ 类回答)同样改用 `cherry_read` 续读,它没有时限。
139
+ **两个面是同一条规则**:消息面的 `messagesResumeCursor` 与四个列表工具的
140
+ `resumeCursor` 都只发出本工具会收回的令牌;位置内部的普通空格合法,不会因此被拒。
141
+ 某条消息自身 parts 没读全时带 `incomplete: true`(wait 类回答里被时限截断的那条消息会带 `partsTruncated: "deadline"`)。
134
142
  - **等待类回答有自己的装配时限**:`cherry_send(wait: true)` / `cherry_wait` 在窗口结束后仍要读一段历史
135
143
  (消息页 + 每条消息的 parts + 每个外置 `{ref}` 内容),这一步现在是**有时限的**——截至「你的窗口 +
136
144
  同样长度的时间」,下限 1 秒、上限 15 秒(即窗口 1 秒 → 最多再等 1 秒;窗口 20 秒 → 最多再等 15 秒)。
package/dist/index.mjs CHANGED
@@ -2274,6 +2274,11 @@ const INTERACTIONS_LIST_PAGE_CLAMP = Math.floor(RECORD_BUDGET_BYTES / 25088);
2274
2274
  * BOUND instead of being split, hashed and carried to the peer, and so both
2275
2275
  * parameters can declare `maxLength` honestly. ONE number for both faces: it
2276
2276
  * bounds caller INPUT, not what a token means.
2277
+ *
2278
+ * The emit side enforces it too (#76): a stop point whose token would exceed
2279
+ * this is not named at all, because a token this tool refuses to replay is
2280
+ * worse than no token — `truncated` still says the walk was cut. One number,
2281
+ * judged in both directions.
2277
2282
  */
2278
2283
  const MAX_RESUME_TOKEN_CHARS = 4096;
2279
2284
  //#endregion
@@ -5477,12 +5482,13 @@ var CherryToolEngine = class {
5477
5482
  * #65). What a caller can act on is a token, so that is what is emitted.
5478
5483
  */
5479
5484
  #listPage(page, binding) {
5480
- const position = optionalString(page.nextCursor);
5485
+ const position = nameablePeerPosition(optionalString(page.nextCursor));
5486
+ const token = !page.truncated || position === void 0 ? void 0 : withinResumeTokenCeiling(encodeListResumeCursor(binding, position));
5481
5487
  return {
5482
5488
  items: page.items,
5483
5489
  truncated: page.truncated,
5484
5490
  ...page.truncationReason === void 0 ? {} : { truncationReason: page.truncationReason },
5485
- ...page.truncated && position !== void 0 && !mangledRemainder(position) ? { resumeCursor: encodeListResumeCursor(binding, position) } : {}
5491
+ ...token === void 0 ? {} : { resumeCursor: token }
5486
5492
  };
5487
5493
  }
5488
5494
  #devicesTool() {
@@ -5561,7 +5567,7 @@ var CherryToolEngine = class {
5561
5567
  #agentsTool() {
5562
5568
  return defineCherryTool({
5563
5569
  name: "cherry_agents",
5564
- description: "List the Cherry Studio agents available on a device. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). A truncated answer also carries `resumeCursor`: replay it as `cursor` with the same tool, device and filters to continue from that stop point (`limit` then bounds the next answer).",
5570
+ description: "List the Cherry Studio agents available on a device. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same tool, device and filters to continue from that stop point (`limit` then bounds the next answer).",
5565
5571
  parameters: {
5566
5572
  type: "object",
5567
5573
  properties: {
@@ -5604,7 +5610,7 @@ var CherryToolEngine = class {
5604
5610
  #workspacesTool() {
5605
5611
  return defineCherryTool({
5606
5612
  name: "cherry_workspaces",
5607
- description: "List an agent's REGISTERED workspaces, plus whether a system workspace exists. The system workspace has no enumerable id: it is not in `items`, and its identity is only observable by creating a session in it and reading it back. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out and carries `resumeCursor`: replay it as `cursor` with the same filters to continue.",
5613
+ description: "List an agent's REGISTERED workspaces, plus whether a system workspace exists. The system workspace has no enumerable id: it is not in `items`, and its identity is only observable by creating a session in it and reading it back. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out; when the stop point can be named it carries `resumeCursor` — replay it as `cursor` with the same filters to continue.",
5608
5614
  parameters: {
5609
5615
  type: "object",
5610
5616
  properties: {
@@ -5659,7 +5665,7 @@ var CherryToolEngine = class {
5659
5665
  #sessionsTool() {
5660
5666
  return defineCherryTool({
5661
5667
  name: "cherry_sessions",
5662
- description: "List an agent's sessions, optionally filtered by workspace. The `agentId` and `workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). A truncated answer also carries `resumeCursor`: replay it as `cursor` with the same tool, device and filters to continue from that stop point (`limit` then bounds the next answer).",
5668
+ description: "List an agent's sessions, optionally filtered by workspace. The `agentId` and `workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same tool, device and filters to continue from that stop point (`limit` then bounds the next answer).",
5663
5669
  parameters: {
5664
5670
  type: "object",
5665
5671
  properties: {
@@ -5716,7 +5722,7 @@ var CherryToolEngine = class {
5716
5722
  #readTool() {
5717
5723
  return defineCherryTool({
5718
5724
  name: "cherry_read",
5719
- description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory. A binary attachment is reported as `kind: \"file\"` with `name`/`mediaType`/`byteLength` and an EMPTY `content` — the bytes are deliberately not fetched into a text channel. Messages come NEWEST-first, so the first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts inside a retained message's parts; a message whose own parts could not be fully read carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. The answer is ALSO bounded by an output budget: `truncated` with `truncationReason: \"aggregate-bytes\"` means the walk stopped because the result reached its size limit, not because `limit` was reached — raising `limit` does NOT reach the messages left out (it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` whenever a stop point can be named honestly (it is absent when the walk reached the peer's last page), and passing it back as `messagesResumeCursor` continues the walk from there (`limit` then bounds how many messages are returned FROM that point). The two token forms are `\"cursor:<peer position>\"` (the walk consumed its whole page window) and `\"after:<messageId>\"` (it stopped inside the window; the read restarts at the newest end and resumes strictly after that message). A token this tool did not emit is refused locally rather than forwarded, and an `after:` anchor that no longer exists in the history is an error, not an empty page. A `truncationReason` of \"limit\" is the caller's own ceiling and is reached by raising `limit`. Either way the newest message is always returned whole.",
5725
+ description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory. A binary attachment is reported as `kind: \"file\"` with `name`/`mediaType`/`byteLength` and an EMPTY `content` — the bytes are deliberately not fetched into a text channel. Messages come NEWEST-first, so the first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts inside a retained message's parts; a message whose own parts could not be fully read carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. The answer is ALSO bounded by an output budget: `truncated` with `truncationReason: \"aggregate-bytes\"` means the walk stopped because the result reached its size limit, not because `limit` was reached — raising `limit` does NOT reach the messages left out (it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` when the stop point can be named — absent when it cannot, e.g. an empty or malformed position, a token past the length ceiling, or no anchor id — and passing it back as `messagesResumeCursor` continues the walk from there (`limit` then bounds how many messages are returned FROM that point). The two token forms are `\"cursor:<peer position>\"` (the walk consumed its whole page window) and `\"after:<messageId>\"` (it stopped inside the window; the read restarts at the newest end and resumes strictly after that message). A token this tool did not emit is refused locally rather than forwarded, and an `after:` anchor that no longer exists in the history is an error, not an empty page. A `truncationReason` of \"limit\" is the caller's own ceiling and is reached by raising `limit`. Either way the newest message is always returned whole.",
5720
5726
  parameters: {
5721
5727
  type: "object",
5722
5728
  properties: {
@@ -5732,7 +5738,7 @@ var CherryToolEngine = class {
5732
5738
  type: "integer",
5733
5739
  minimum: 1,
5734
5740
  maximum: MAX_AGGREGATE_LIMIT,
5735
- description: "Maximum number of messages to return in total across pages (default 100). Distinct from the output budget: the answer may stop earlier with `truncationReason: \"aggregate-bytes\"`, which a larger `limit` does not extend — the resume token is how older messages are reached."
5741
+ description: "Maximum number of messages to return in total across pages (default 100). Distinct from the output budget: the answer may stop earlier with `truncationReason: \"aggregate-bytes\"`, which a larger `limit` does not extend — the resume token, when one is returned, is how older messages are reached."
5736
5742
  },
5737
5743
  messagesResumeCursor: {
5738
5744
  type: "string",
@@ -6491,7 +6497,7 @@ var CherryToolEngine = class {
6491
6497
  #interactionsTool() {
6492
6498
  return defineCherryTool({
6493
6499
  name: "cherry_interactions",
6494
- description: "List the session's PENDING interactions (questions and approval decisions) that need an answer. The peer serves the full four-state set (pending/approved/denied/expired) — the pending filter is applied here. Each entry carries its `kind` (question|decision) and a summary; answer questions with cherry_respond's `response` form and decisions with `decision`. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out and carries `resumeCursor`: replay it as `cursor` with the same filters to continue.",
6500
+ description: "List the session's PENDING interactions (questions and approval decisions) that need an answer. The peer serves the full four-state set (pending/approved/denied/expired) — the pending filter is applied here. Each entry carries its `kind` (question|decision) and a summary; answer questions with cherry_respond's `response` form and decisions with `decision`. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out; when the stop point can be named it carries `resumeCursor` — replay it as `cursor` from the same `sessionId` to continue.",
6495
6501
  parameters: {
6496
6502
  type: "object",
6497
6503
  properties: {
@@ -7674,9 +7680,11 @@ var CherryToolEngine = class {
7674
7680
  }
7675
7681
  /**
7676
7682
  * The timeout result: NOT a failure. Partial content read so far +, whenever a
7677
- * stop point can be named honestly, a `messagesResumeCursor` token marking where
7678
- * the unread history begins — absent when the walk reached the peer's last page
7679
- * or its last returned message carried no id.
7683
+ * stop point can be named honestly, a `messagesResumeCursor` token marking
7684
+ * where the unread history begins. The token is missing in TWO OPPOSITE cases
7685
+ * — a COMPLETE walk, or a TRUNCATED one whose stop point could not be named —
7686
+ * and `messagesTruncated` is what tells them apart; see
7687
+ * {@link CherryMessageWalkVerdict.messagesResumeCursor}.
7680
7688
  *
7681
7689
  * The spec's own sentence (§3.6, 2026-10-09 design: 「超时不是失败:返回已收到
7682
7690
  * 的部分内容 + 当前游标,agent 可续查」) is a statement of that contract in
@@ -7903,9 +7911,10 @@ var CherryToolEngine = class {
7903
7911
  * `messagesResumeCursor`: the read scans from the newest end to the anchor
7904
7912
  * and continues strictly after it.
7905
7913
  *
7906
- * The token is absent only when there is nothing older to point at (the walk
7907
- * reached the peer's last page having returned everything it collected).
7908
- * See {@link CherryMessageWalkVerdict.messagesResumeCursor}.
7914
+ * The token is missing in TWO OPPOSITE cases — a COMPLETE walk, and a
7915
+ * TRUNCATED one whose stop point could not be named — and `messagesTruncated`
7916
+ * is what tells them apart; absence never means "nothing older left" on its
7917
+ * own. See {@link CherryMessageWalkVerdict.messagesResumeCursor}.
7909
7918
  */
7910
7919
  async #readTerminalMessages(connection, sessionId, session, deadline) {
7911
7920
  const historyRevision = optionalString(session["historyRevision"]);
@@ -8135,14 +8144,90 @@ function parseQuestionInput(input) {
8135
8144
  * invented: a wrong anchor is worse than no anchor, and `truncated` still says
8136
8145
  * the walk was cut. Otherwise the whole collected window was consumed, and the
8137
8146
  * peer's page position is exactly where the unread history begins
8138
- * (`cursor:<pos>`) — unless the peer answered an EMPTY position, which names
8139
- * nothing and is therefore not emitted at all.
8147
+ * (`cursor:<pos>`).
8148
+ *
8149
+ * BOTH arms name a remainder only when the PARSER would accept it
8150
+ * ({@link mangledRemainder}): empty, not equal to its own `.trim()` (which is
8151
+ * leading/trailing whitespace), or carrying a control character anywhere is
8152
+ * refused HERE exactly as `parseMessageResume` refuses it on replay. The
8153
+ * emitter must never mint what the parser refuses — a token this tool will not
8154
+ * take back is worse than none, because the refusal then surfaces at the RESUME
8155
+ * call instead of at the answer that produced it. This is the CONTENT
8156
+ * dimension; {@link withinResumeTokenCeiling} is the sibling LENGTH one. The
8157
+ * `after:` anchor is covered by the same rule, not just the `cursor:` position:
8158
+ * a message id is an `opaqueId` too, and padding is schema-valid, so a padded
8159
+ * id really can reach the anchor.
8160
+ *
8161
+ * EXPORTED FOR ITS OWN TESTS. The LENGTH guard on the `after:` arm cannot be
8162
+ * driven through a tool: an anchor is a message ID, and projecting that message
8163
+ * means asking the peer for `agent.parts.list` with it, whose `messageId` is an
8164
+ * `opaqueId` (256 characters). A peer item carrying an id above the token
8165
+ * ceiling therefore fails its parts walk and is never RETURNED, so no walk
8166
+ * reaches the point of naming it. The length guard on that arm is still
8167
+ * required — the emitter must never mint what the parser refuses, whatever the
8168
+ * peer's cap becomes — and this export is how a unit test pins it
8169
+ * (`tests/unit/resumeTokenEmit.test.ts`). The CONTENT guard on both arms IS
8170
+ * reachable, because `opaqueId` allows padding: `tests/integration/
8171
+ * toolsReadResume.test.ts` ⑭d/⑭e drive it through the tools.
8140
8172
  */
8141
8173
  function messageResumeToken(page, returned, lastReturnedId) {
8142
- const anchor = optionalString(lastReturnedId);
8143
- if (returned < page.items.length || page.pageOverflow === true) return anchor === void 0 ? void 0 : `${RESUME_ANCHOR_PREFIX}${anchor}`;
8144
- const position = optionalString(page.nextCursor);
8145
- return position === void 0 ? void 0 : `${RESUME_CURSOR_PREFIX}${position}`;
8174
+ const anchor = nameableRemainder(optionalString(lastReturnedId));
8175
+ if (returned < page.items.length || page.pageOverflow === true) return anchor === void 0 ? void 0 : withinResumeTokenCeiling(`${RESUME_ANCHOR_PREFIX}${anchor}`);
8176
+ const position = nameablePeerPosition(optionalString(page.nextCursor));
8177
+ return position === void 0 ? void 0 : withinResumeTokenCeiling(`${RESUME_CURSOR_PREFIX}${position}`);
8178
+ }
8179
+ /**
8180
+ * The CONTENT dimension of "a remainder this bridge may name", or `undefined`
8181
+ * when it must not name one.
8182
+ *
8183
+ * The parsers run `mangledRemainder` on every remainder they read, so one that
8184
+ * fails it is a remainder this tool would refuse on replay: naming it hands the
8185
+ * caller a stop point that can never be used, and the refusal then surfaces at
8186
+ * the RESUME call instead of at the answer that produced it (issues #70, #80).
8187
+ * What it rejects, exactly: empty, not equal to its own `.trim()` (leading/
8188
+ * trailing whitespace), or a control character ANYWHERE — an interior plain space
8189
+ * is legal and round-trips.
8190
+ *
8191
+ * This is the parser's own predicate, not a copy of it, so the two sides cannot
8192
+ * drift into two dialects. The sibling dimension is the LENGTH ceiling, judged
8193
+ * on the FINAL token ({@link withinResumeTokenCeiling}).
8194
+ */
8195
+ function nameableRemainder(value) {
8196
+ return value === void 0 || mangledRemainder(value) ? void 0 : value;
8197
+ }
8198
+ /**
8199
+ * A remainder this bridge may name AS A PAGE POSITION: {@link nameableRemainder}
8200
+ * plus the one refusal that applies to positions and NOT to anchors.
8201
+ *
8202
+ * `looksLikeV1ListToken` exists because a v1 list token (0.1.6: base64url of
8203
+ * `{v,m,d,h,p}`) lives under `cursor:` and is indistinguishable from a peer
8204
+ * position, so both parsers NAME and refuse it rather than forwarding it to a
8205
+ * desktop that would silently serve its FIRST page. An emitter that wrapped such
8206
+ * a position would mint `cursor:<v1 shape>` and then have its own parser answer
8207
+ * "issued by an older plugin version" about a token this tool had just printed —
8208
+ * the same broken round trip as #76/#80, one detector further out (issue #85).
8209
+ *
8210
+ * The `after:` arm deliberately does NOT use this: `parseMessageResume`'s
8211
+ * `after:` branch never consults the detector, so an `after:` remainder of that
8212
+ * shape is a token it accepts.
8213
+ */
8214
+ function nameablePeerPosition(value) {
8215
+ const remainder = nameableRemainder(value);
8216
+ return remainder === void 0 || looksLikeV1ListToken(remainder) ? void 0 : remainder;
8217
+ }
8218
+ /**
8219
+ * The token ceiling, enforced on the way OUT as well as in. A token this tool
8220
+ * refuses to replay is worse than no token at all: `truncated` already says the
8221
+ * walk was cut, and the remedy then follows the reason — a `'limit'` stop
8222
+ * answers to a wider `limit` only where the caller owns that ceiling — so a
8223
+ * stop point whose token would push past
8224
+ * {@link MAX_RESUME_TOKEN_CHARS} is simply not named. Measured on the FINAL
8225
+ * string, never on a reconstruction of its length, so a format change cannot
8226
+ * leave the guard behind. Exactly at the ceiling is accepted — it is a length
8227
+ * the parser takes back.
8228
+ */
8229
+ function withinResumeTokenCeiling(token) {
8230
+ return token.length <= 4096 ? token : void 0;
8146
8231
  }
8147
8232
  /**
8148
8233
  * The message-walk verdict, projected off an aggregate page.
@@ -8240,6 +8325,12 @@ function messageIdOf(raw) {
8240
8325
  * `undefined`/`null` means "from the newest message", which is the ordinary
8241
8326
  * read. An empty remainder (`'cursor:'`, `'after:'`) is refused like any other
8242
8327
  * foreign token: an empty anchor could never match a message.
8328
+ *
8329
+ * EXPORTED FOR ITS OWN TESTS — specifically for the round-trip property, which
8330
+ * is the invariant this whole file is built around: every token the emitters
8331
+ * NAME must be one this function ACCEPTS. Pinning that with a probe table is
8332
+ * what turns "we mirrored every refusal" from a promise into a check
8333
+ * (`tests/unit/resumeTokenEmit.test.ts`, issue #85).
8243
8334
  */
8244
8335
  function parseMessageResume(value) {
8245
8336
  if (value === void 0 || value === null) return void 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cherry-bridge",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "packageManager": "npm@10.9.9",
package/src/defaults.ts CHANGED
@@ -151,5 +151,10 @@ export const LIST_PAGE_CLAMP = 16
151
151
  * BOUND instead of being split, hashed and carried to the peer, and so both
152
152
  * parameters can declare `maxLength` honestly. ONE number for both faces: it
153
153
  * bounds caller INPUT, not what a token means.
154
+ *
155
+ * The emit side enforces it too (#76): a stop point whose token would exceed
156
+ * this is not named at all, because a token this tool refuses to replay is
157
+ * worse than no token — `truncated` still says the walk was cut. One number,
158
+ * judged in both directions.
154
159
  */
155
160
  export const MAX_RESUME_TOKEN_CHARS = 4_096
package/src/tools.ts CHANGED
@@ -391,9 +391,15 @@ export interface CherryReadSnapshot {
391
391
  *
392
392
  * Present exactly when `truncated` is true and a stop point can be named
393
393
  * honestly; because the walk runs newest-first, it marks OLDER messages as
394
- * still unread — the only direction a truncation leaves behind. It is ABSENT
395
- * when the walk reached the peer's last page having returned everything it
396
- * collected: there is nothing older left to name, and no marker is invented.
394
+ * still unread — the only direction a truncation leaves behind.
395
+ *
396
+ * IT IS ABSENT IN TWO OPPOSITE CASES, and `truncated` is what tells them
397
+ * apart: a COMPLETE walk (`truncated: false` — nothing older left), and a
398
+ * TRUNCATED walk whose stop point could not be named (`truncated: true` plus
399
+ * `truncationReason` — older messages still unread). The absence therefore
400
+ * never means "nothing older left" on its own. WHICH stops can be named is
401
+ * decided in ONE place ({@link messageResumeToken}); the refusal cases are not
402
+ * restated here.
397
403
  *
398
404
  * WHAT REACHES THE UNREAD MESSAGES DEPENDS ON THE REASON. A `'limit'` stop was
399
405
  * the caller's own ceiling: raise `limit`, or resume with this token to walk
@@ -403,6 +409,12 @@ export interface CherryReadSnapshot {
403
409
  * messages are reached. From the resume point `limit` bounds how many messages
404
410
  * ONE answer carries, so a long history is walked in successive answers, each
405
411
  * carrying the next stop point.
412
+ *
413
+ * WITH NO TOKEN NAMED, the reason is all the caller has: a `'limit'` stop is
414
+ * still reachable by widening `limit`; a `'safety-bound'` stop is the internal
415
+ * circuit breaker, which no wider `limit` moves; and an `'aggregate-bytes'`
416
+ * stop has no remedy in that answer at all — the budget, not the ceiling, is
417
+ * what stopped it. The loss is stated rather than repaired.
406
418
  */
407
419
  messagesResumeCursor?: string
408
420
  }
@@ -447,10 +459,19 @@ export interface CherryMessageWalkVerdict {
447
459
  * `cursor:<peer position>` when the whole collected window was returned;
448
460
  * `after:<last returned message id>` when the byte stop happened INSIDE that
449
461
  * window, because the peer's position sits past messages this walk trimmed
450
- * and `AgentMessage` carries no `createdAt` to synthesise a closer one. The
451
- * token is OMITTED when nothing older is left to name (the walk reached the
452
- * peer's last page having returned everything it collected) — absence says
453
- * "no marker", `messagesTruncated` still says the walk was cut.
462
+ * and `AgentMessage` carries no `createdAt` to synthesise a closer one.
463
+ *
464
+ * IT IS ABSENT IN TWO OPPOSITE CASES, and `messagesTruncated` is what tells
465
+ * them apart: a COMPLETE walk (`messagesTruncated` absent or false — nothing
466
+ * older left), and a TRUNCATED walk whose stop point could not be named
467
+ * (`messagesTruncated: true` plus `messagesTruncationReason` — older history
468
+ * still unread). The absence never means "nothing older left" on its own.
469
+ * WHICH stops can be named is decided in ONE place
470
+ * ({@link messageResumeToken}) and is the same rule as
471
+ * {@link CherryReadSnapshot.messagesResumeCursor}. THE REMEDY IS NOT THE SAME
472
+ * ON THIS FACE: a `'limit'` stop here is the walk's own fixed message
473
+ * allowance — these tools take no `limit` argument — so the way further is
474
+ * `cherry_read`, which also carries no deadline.
454
475
  */
455
476
  messagesResumeCursor?: string
456
477
  }
@@ -2100,19 +2121,31 @@ class CherryToolEngine {
2100
2121
  #listPage(page: CherryPage<Record<string, unknown>>, binding: ListResumeBinding): Record<string, unknown> {
2101
2122
  // A position this face would REFUSE is not a position it may name. The
2102
2123
  // round-trip symmetry (a token a tool emits is a token that tool accepts) is
2103
- // the whole point of the token, so a peer answering `nextCursor: ''` — or a
2104
- // position carrying whitespace/control characters, which `mangledRemainder`
2105
- // rejects on the way back — gets NO token; `truncated` still says the walk
2106
- // was cut and a wider `limit` is the remedy. `messageResumeToken` guards the
2107
- // same hole for the message face.
2108
- const position = optionalString(page.nextCursor)
2124
+ // the whole point of the token, so a peer answering `nextCursor: ''`, one
2125
+ // carrying whitespace/control characters, or one shaped like our retired v1
2126
+ // token gets NO token. The absence has TWO opposite cases — a COMPLETE walk,
2127
+ // and a TRUNCATED one whose stop could not be named — and only `truncated`
2128
+ // tells them apart. The remedy follows the REASON: a `'limit'` stop answers
2129
+ // to a wider `limit`; a `'safety-bound'` stop does not, because it is an
2130
+ // internal circuit breaker a caller cannot move. The rule is NOT restated
2131
+ // here or in the message face:
2132
+ // `nameablePeerPosition` is the one place it lives, so the emitters and
2133
+ // their parsers cannot drift (issue #85).
2134
+ const position = nameablePeerPosition(optionalString(page.nextCursor))
2135
+ // The same refusal one dimension further: an encoded token over
2136
+ // {@link MAX_RESUME_TOKEN_CHARS} is one this face would refuse on replay, so
2137
+ // it is not named ({@link withinResumeTokenCeiling}, issue #76). Judged only
2138
+ // on the arm that NAMES it — a complete walk has nothing to name, so it must
2139
+ // not pay for the encoding.
2140
+ const token =
2141
+ !page.truncated || position === undefined
2142
+ ? undefined
2143
+ : withinResumeTokenCeiling(encodeListResumeCursor(binding, position))
2109
2144
  return {
2110
2145
  items: page.items,
2111
2146
  truncated: page.truncated,
2112
2147
  ...(page.truncationReason === undefined ? {} : { truncationReason: page.truncationReason }),
2113
- ...(page.truncated && position !== undefined && !mangledRemainder(position)
2114
- ? { resumeCursor: encodeListResumeCursor(binding, position) }
2115
- : {}),
2148
+ ...(token === undefined ? {} : { resumeCursor: token }),
2116
2149
  }
2117
2150
  }
2118
2151
 
@@ -2215,9 +2248,10 @@ class CherryToolEngine {
2215
2248
  'List the Cherry Studio agents available on a device. Paginated: `limit` bounds ' +
2216
2249
  'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
2217
2250
  'A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran ' +
2218
- 'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). A truncated ' +
2219
- 'answer also carries `resumeCursor`: replay it as `cursor` with the same tool, device and ' +
2220
- 'filters to continue from that stop point (`limit` then bounds the next answer).',
2251
+ 'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the ' +
2252
+ 'stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same ' +
2253
+ 'tool, device and filters to continue from that stop point (`limit` then bounds the next ' +
2254
+ 'answer).',
2221
2255
  parameters: {
2222
2256
  type: 'object',
2223
2257
  properties: {
@@ -2258,8 +2292,9 @@ class CherryToolEngine {
2258
2292
  'List an agent\'s REGISTERED workspaces, plus whether a system workspace exists. ' +
2259
2293
  'The system workspace has no enumerable id: it is not in `items`, and its identity ' +
2260
2294
  'is only observable by creating a session in it and reading it back. A truncated ' +
2261
- 'answer (`truncated` + `truncationReason`) stopped before the peer ran out and carries ' +
2262
- '`resumeCursor`: replay it as `cursor` with the same filters to continue.',
2295
+ 'answer (`truncated` + `truncationReason`) stopped before the peer ran out; when the stop ' +
2296
+ 'point can be named it carries `resumeCursor` — replay it as `cursor` with the same ' +
2297
+ 'filters to continue.',
2263
2298
  parameters: {
2264
2299
  type: 'object',
2265
2300
  properties: {
@@ -2310,9 +2345,10 @@ class CherryToolEngine {
2310
2345
  '`workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds ' +
2311
2346
  'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
2312
2347
  'A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran ' +
2313
- 'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). A truncated ' +
2314
- 'answer also carries `resumeCursor`: replay it as `cursor` with the same tool, device and ' +
2315
- 'filters to continue from that stop point (`limit` then bounds the next answer).',
2348
+ 'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the ' +
2349
+ 'stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same ' +
2350
+ 'tool, device and filters to continue from that stop point (`limit` then bounds the next ' +
2351
+ 'answer).',
2316
2352
  parameters: {
2317
2353
  type: 'object',
2318
2354
  properties: {
@@ -2382,8 +2418,9 @@ class CherryToolEngine {
2382
2418
  '"aggregate-bytes"` means the walk stopped because the result reached its size limit, ' +
2383
2419
  'not because `limit` was reached — raising `limit` does NOT reach the messages left out ' +
2384
2420
  '(it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE ' +
2385
- 'REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` whenever a ' +
2386
- 'stop point can be named honestly (it is absent when the walk reached the peer\'s last page), and ' +
2421
+ 'REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` when the ' +
2422
+ 'stop point can be named — absent when it cannot, e.g. an empty or malformed position, a ' +
2423
+ 'token past the length ceiling, or no anchor id — and ' +
2387
2424
  'passing it back as `messagesResumeCursor` continues the walk from there (`limit` then ' +
2388
2425
  'bounds how many messages are returned FROM that point). The two token forms are ' +
2389
2426
  '`"cursor:<peer position>"` (the walk consumed its whole page window) and ' +
@@ -2405,7 +2442,8 @@ class CherryToolEngine {
2405
2442
  description:
2406
2443
  'Maximum number of messages to return in total across pages (default 100). Distinct from ' +
2407
2444
  'the output budget: the answer may stop earlier with `truncationReason: "aggregate-bytes"`, ' +
2408
- 'which a larger `limit` does not extend — the resume token is how older messages are reached.',
2445
+ 'which a larger `limit` does not extend — the resume token, when one is returned, is how ' +
2446
+ 'older messages are reached.',
2409
2447
  },
2410
2448
  messagesResumeCursor: {
2411
2449
  type: 'string',
@@ -2586,11 +2624,30 @@ class CherryToolEngine {
2586
2624
  ...(truncationReason === undefined ? {} : { truncationReason }),
2587
2625
  // Only a TRUNCATED walk has unread history, and the token then marks the
2588
2626
  // stop point (OLDER messages remain unread) —
2589
- // {@link CherryReadSnapshot.messagesResumeCursor}. When the walk's own
2590
- // verdict names no stop (it reached the peer's last page AND returned
2591
- // every message it collected) the key is OMITTED rather than emitted
2592
- // undefined: the absence says "nothing older left", the `truncated` flag
2593
- // still says whether the walk was cut.
2627
+ // {@link CherryReadSnapshot.messagesResumeCursor}. Whether that stop can
2628
+ // be NAMED is not the walk's verdict: it depends on the peer's payload,
2629
+ // and {@link messageResumeToken} is the ONE place that judges it — content
2630
+ // through `nameableRemainder`, length through `withinResumeTokenCeiling`,
2631
+ // and, for a POSITION only, the retired-v1 check
2632
+ // ({@link nameablePeerPosition}; the anchor arm deliberately skips it,
2633
+ // exactly as `parseMessageResume`'s `after:` branch does). The refusal
2634
+ // cases are therefore not enumerated again here.
2635
+ //
2636
+ // The key is OMITTED rather than emitted `undefined`, and its absence only
2637
+ // means something once read together with `truncated`: the two cases have
2638
+ // opposite meanings —
2639
+ //
2640
+ // - a COMPLETE walk (nothing older left): `truncated: false`, no token —
2641
+ // the absence really does say "nothing older left";
2642
+ // - a TRUNCATED walk whose stop point cannot be named: `truncated: true`
2643
+ // plus a `truncationReason`, no token — OLDER MESSAGES REMAIN UNREAD,
2644
+ // and the absence never means they ran out. The remedy follows the
2645
+ // REASON: only a `limit` stop answers to a wider `limit`; a
2646
+ // `safety-bound` stop is the internal circuit breaker, and an
2647
+ // `aggregate-bytes` stop is this answer's own output budget, which a
2648
+ // wider `limit` does not move (more round trips, same budget). A
2649
+ // `deadline` stop is a WAIT face's reason — `cherry_read` carries no
2650
+ // deadline — so it is never this literal's.
2594
2651
  ...(truncated && resumeToken !== undefined ? { messagesResumeCursor: resumeToken } : {}),
2595
2652
  }
2596
2653
  }
@@ -3753,8 +3810,8 @@ class CherryToolEngine {
3753
3810
  'pending filter is applied here. Each entry carries its `kind` (question|decision) and a ' +
3754
3811
  'summary; answer questions with cherry_respond\'s `response` form and decisions with ' +
3755
3812
  '`decision`. A truncated answer (`truncated` + `truncationReason`) stopped before the ' +
3756
- 'peer ran out and carries `resumeCursor`: replay it as `cursor` with the same filters to ' +
3757
- 'continue.',
3813
+ 'peer ran out; when the stop point can be named it carries `resumeCursor` — replay it as ' +
3814
+ '`cursor` from the same `sessionId` to continue.',
3758
3815
  parameters: {
3759
3816
  type: 'object',
3760
3817
  properties: {
@@ -5547,9 +5604,11 @@ class CherryToolEngine {
5547
5604
 
5548
5605
  /**
5549
5606
  * The timeout result: NOT a failure. Partial content read so far +, whenever a
5550
- * stop point can be named honestly, a `messagesResumeCursor` token marking where
5551
- * the unread history begins — absent when the walk reached the peer's last page
5552
- * or its last returned message carried no id.
5607
+ * stop point can be named honestly, a `messagesResumeCursor` token marking
5608
+ * where the unread history begins. The token is missing in TWO OPPOSITE cases
5609
+ * — a COMPLETE walk, or a TRUNCATED one whose stop point could not be named —
5610
+ * and `messagesTruncated` is what tells them apart; see
5611
+ * {@link CherryMessageWalkVerdict.messagesResumeCursor}.
5553
5612
  *
5554
5613
  * The spec's own sentence (§3.6, 2026-10-09 design: 「超时不是失败:返回已收到
5555
5614
  * 的部分内容 + 当前游标,agent 可续查」) is a statement of that contract in
@@ -5824,9 +5883,10 @@ class CherryToolEngine {
5824
5883
  * `messagesResumeCursor`: the read scans from the newest end to the anchor
5825
5884
  * and continues strictly after it.
5826
5885
  *
5827
- * The token is absent only when there is nothing older to point at (the walk
5828
- * reached the peer's last page having returned everything it collected).
5829
- * See {@link CherryMessageWalkVerdict.messagesResumeCursor}.
5886
+ * The token is missing in TWO OPPOSITE cases — a COMPLETE walk, and a
5887
+ * TRUNCATED one whose stop point could not be named — and `messagesTruncated`
5888
+ * is what tells them apart; absence never means "nothing older left" on its
5889
+ * own. See {@link CherryMessageWalkVerdict.messagesResumeCursor}.
5830
5890
  */
5831
5891
  async #readTerminalMessages(
5832
5892
  connection: DeviceConnection,
@@ -6096,28 +6156,111 @@ function parseQuestionInput(input: unknown): string[] | undefined {
6096
6156
  * invented: a wrong anchor is worse than no anchor, and `truncated` still says
6097
6157
  * the walk was cut. Otherwise the whole collected window was consumed, and the
6098
6158
  * peer's page position is exactly where the unread history begins
6099
- * (`cursor:<pos>`) — unless the peer answered an EMPTY position, which names
6100
- * nothing and is therefore not emitted at all.
6159
+ * (`cursor:<pos>`).
6160
+ *
6161
+ * BOTH arms name a remainder only when the PARSER would accept it
6162
+ * ({@link mangledRemainder}): empty, not equal to its own `.trim()` (which is
6163
+ * leading/trailing whitespace), or carrying a control character anywhere is
6164
+ * refused HERE exactly as `parseMessageResume` refuses it on replay. The
6165
+ * emitter must never mint what the parser refuses — a token this tool will not
6166
+ * take back is worse than none, because the refusal then surfaces at the RESUME
6167
+ * call instead of at the answer that produced it. This is the CONTENT
6168
+ * dimension; {@link withinResumeTokenCeiling} is the sibling LENGTH one. The
6169
+ * `after:` anchor is covered by the same rule, not just the `cursor:` position:
6170
+ * a message id is an `opaqueId` too, and padding is schema-valid, so a padded
6171
+ * id really can reach the anchor.
6172
+ *
6173
+ * EXPORTED FOR ITS OWN TESTS. The LENGTH guard on the `after:` arm cannot be
6174
+ * driven through a tool: an anchor is a message ID, and projecting that message
6175
+ * means asking the peer for `agent.parts.list` with it, whose `messageId` is an
6176
+ * `opaqueId` (256 characters). A peer item carrying an id above the token
6177
+ * ceiling therefore fails its parts walk and is never RETURNED, so no walk
6178
+ * reaches the point of naming it. The length guard on that arm is still
6179
+ * required — the emitter must never mint what the parser refuses, whatever the
6180
+ * peer's cap becomes — and this export is how a unit test pins it
6181
+ * (`tests/unit/resumeTokenEmit.test.ts`). The CONTENT guard on both arms IS
6182
+ * reachable, because `opaqueId` allows padding: `tests/integration/
6183
+ * toolsReadResume.test.ts` ⑭d/⑭e drive it through the tools.
6101
6184
  */
6102
- function messageResumeToken(
6185
+ export function messageResumeToken(
6103
6186
  page: CherryPage<unknown>,
6104
6187
  returned: number,
6105
6188
  lastReturnedId: string | undefined,
6106
6189
  ): string | undefined {
6107
- const anchor = optionalString(lastReturnedId)
6190
+ const anchor = nameableRemainder(optionalString(lastReturnedId))
6108
6191
  // TWO forms of "the walk stopped inside what it was given": it returned fewer
6109
6192
  // items than it collected, or the PEER overshot the requested page and items
6110
6193
  // were left in it ({@link CherryPage.pageOverflow}). Either way the page's own
6111
6194
  // position sits past messages this walk never returned, so only the anchor is
6112
6195
  // exact.
6113
6196
  if (returned < page.items.length || page.pageOverflow === true) {
6114
- return anchor === undefined ? undefined : `${RESUME_ANCHOR_PREFIX}${anchor}`
6197
+ // The ANCHOR takes the content predicate only — NOT the v1 detector, and
6198
+ // that is not an oversight: `parseMessageResume`'s `after:` branch runs
6199
+ // `mangledRemainder` and nothing else, so an id-shaped `after:` remainder is
6200
+ // a token it ACCEPTS back. Applying the detector here would be the mirror
6201
+ // image of this issue — refusing to name a token the parser takes.
6202
+ return anchor === undefined ? undefined : withinResumeTokenCeiling(`${RESUME_ANCHOR_PREFIX}${anchor}`)
6115
6203
  }
6116
- // An empty position is not a position: a peer answering `nextCursor: ''` on a
6117
- // non-terminal page would otherwise make this face emit a bare `cursor:`,
6118
- // which the same face REFUSES on the way back (review P3-4).
6119
- const position = optionalString(page.nextCursor)
6120
- return position === undefined ? undefined : `${RESUME_CURSOR_PREFIX}${position}`
6204
+ // A POSITION takes both: the content predicate, and the retired-format
6205
+ // detector its own parser applies before anything else (issue #85).
6206
+ const position = nameablePeerPosition(optionalString(page.nextCursor))
6207
+ return position === undefined ? undefined : withinResumeTokenCeiling(`${RESUME_CURSOR_PREFIX}${position}`)
6208
+ }
6209
+
6210
+ /**
6211
+ * The CONTENT dimension of "a remainder this bridge may name", or `undefined`
6212
+ * when it must not name one.
6213
+ *
6214
+ * The parsers run `mangledRemainder` on every remainder they read, so one that
6215
+ * fails it is a remainder this tool would refuse on replay: naming it hands the
6216
+ * caller a stop point that can never be used, and the refusal then surfaces at
6217
+ * the RESUME call instead of at the answer that produced it (issues #70, #80).
6218
+ * What it rejects, exactly: empty, not equal to its own `.trim()` (leading/
6219
+ * trailing whitespace), or a control character ANYWHERE — an interior plain space
6220
+ * is legal and round-trips.
6221
+ *
6222
+ * This is the parser's own predicate, not a copy of it, so the two sides cannot
6223
+ * drift into two dialects. The sibling dimension is the LENGTH ceiling, judged
6224
+ * on the FINAL token ({@link withinResumeTokenCeiling}).
6225
+ */
6226
+ function nameableRemainder(value: string | undefined): string | undefined {
6227
+ return value === undefined || mangledRemainder(value) ? undefined : value
6228
+ }
6229
+
6230
+ /**
6231
+ * A remainder this bridge may name AS A PAGE POSITION: {@link nameableRemainder}
6232
+ * plus the one refusal that applies to positions and NOT to anchors.
6233
+ *
6234
+ * `looksLikeV1ListToken` exists because a v1 list token (0.1.6: base64url of
6235
+ * `{v,m,d,h,p}`) lives under `cursor:` and is indistinguishable from a peer
6236
+ * position, so both parsers NAME and refuse it rather than forwarding it to a
6237
+ * desktop that would silently serve its FIRST page. An emitter that wrapped such
6238
+ * a position would mint `cursor:<v1 shape>` and then have its own parser answer
6239
+ * "issued by an older plugin version" about a token this tool had just printed —
6240
+ * the same broken round trip as #76/#80, one detector further out (issue #85).
6241
+ *
6242
+ * The `after:` arm deliberately does NOT use this: `parseMessageResume`'s
6243
+ * `after:` branch never consults the detector, so an `after:` remainder of that
6244
+ * shape is a token it accepts.
6245
+ */
6246
+ function nameablePeerPosition(value: string | undefined): string | undefined {
6247
+ const remainder = nameableRemainder(value)
6248
+ return remainder === undefined || looksLikeV1ListToken(remainder) ? undefined : remainder
6249
+ }
6250
+
6251
+ /**
6252
+ * The token ceiling, enforced on the way OUT as well as in. A token this tool
6253
+ * refuses to replay is worse than no token at all: `truncated` already says the
6254
+ * walk was cut, and the remedy then follows the reason — a `'limit'` stop
6255
+ * answers to a wider `limit` only where the caller owns that ceiling — so a
6256
+ * stop point whose token would push past
6257
+ * {@link MAX_RESUME_TOKEN_CHARS} is simply not named. Measured on the FINAL
6258
+ * string, never on a reconstruction of its length, so a format change cannot
6259
+ * leave the guard behind. Exactly at the ceiling is accepted — it is a length
6260
+ * the parser takes back.
6261
+ */
6262
+ function withinResumeTokenCeiling(token: string): string | undefined {
6263
+ return token.length <= MAX_RESUME_TOKEN_CHARS ? token : undefined
6121
6264
  }
6122
6265
 
6123
6266
  /**
@@ -6224,8 +6367,14 @@ function messageIdOf(raw: Record<string, unknown>): string {
6224
6367
  * `undefined`/`null` means "from the newest message", which is the ordinary
6225
6368
  * read. An empty remainder (`'cursor:'`, `'after:'`) is refused like any other
6226
6369
  * foreign token: an empty anchor could never match a message.
6370
+ *
6371
+ * EXPORTED FOR ITS OWN TESTS — specifically for the round-trip property, which
6372
+ * is the invariant this whole file is built around: every token the emitters
6373
+ * NAME must be one this function ACCEPTS. Pinning that with a probe table is
6374
+ * what turns "we mirrored every refusal" from a promise into a check
6375
+ * (`tests/unit/resumeTokenEmit.test.ts`, issue #85).
6227
6376
  */
6228
- function parseMessageResume(value: unknown): MessageResume | undefined {
6377
+ export function parseMessageResume(value: unknown): MessageResume | undefined {
6229
6378
  if (value === undefined || value === null) return undefined
6230
6379
  const refuse = (): never => {
6231
6380
  throw new Error(