dsh-cherry-bridge 0.1.5 → 0.1.7

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
@@ -37,14 +37,14 @@ flowchart TB
37
37
  > **DSH version requirement**: `>=0.2.0-rc.2 <0.3.0-0` (declared via `engines.dsh` in `package.json`; when unmet the bundle is skipped and its settings-page row disabled — `dsh plugin allow-version` grants an explicit exemption; 0.3.0 needs a fresh compatibility pass before the range widens).
38
38
 
39
39
  ```bash
40
- # From GitHub (recommended)
41
- npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge
40
+ # From npm (recommended, pinned)
41
+ npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.7
42
42
 
43
- # Pinned to a version tag (recommended for production)
44
- npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.5
43
+ # Install the latest npm release
44
+ npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
45
45
 
46
- # Or from the npm registry
47
- dsh plugin --profile <name> add dsh-cherry-bridge
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
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). |
107
- | `cherry_workspaces` | List an agent's **registered** workspaces, and whether a system workspace exists. |
108
- | `cherry_sessions` | List an agent's sessions, optionally filtered by workspace. |
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
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. |
110
- | `cherry_events` | Read the session's buffered events newer than `cursor`, acking them after delivery. |
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). |
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). |
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. |
@@ -224,7 +224,7 @@ npm run typecheck # server + UI projects
224
224
  npm run build # tsdown → dist/index.mjs + dist/index.d.mts
225
225
  npm run build:client # vite → lib/client.js
226
226
  npm run dev:ui # settings-page dev server (host must run at 127.0.0.1:23023)
227
- npm run prepublishOnly # pre-publish gate: typecheck + test + build + build:client
227
+ npm run prepublishOnly # pre-publish gate: typecheck + test + build + build:client + npm pack --dry-run
228
228
  ```
229
229
 
230
230
  `dist/` and `lib/` are **committed** artifacts: after changing `src/` or `ui/`
package/README.md CHANGED
@@ -35,14 +35,14 @@ flowchart TB
35
35
  > **DSH 版本需求**:`>=0.2.0-rc.2 <0.3.0-0`(由 `package.json` 的 `engines.dsh` 声明;不满足时 bundle 被跳过、设置页里该插件的行被禁用,确需在区间外使用可用 `dsh plugin allow-version` 精确豁免。0.3.0 需重新评估兼容性后再放开)。
36
36
 
37
37
  ```bash
38
- # 从 GitHub 安装(推荐)
39
- npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge
38
+ # 从 npm 安装(推荐,钉住版本)
39
+ npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.7
40
40
 
41
- # 钉住版本标签(生产推荐)
42
- npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.5
41
+ # 安装 npm 最新发布版
42
+ npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
43
43
 
44
- # 或从 npm registry 安装
45
- dsh plugin --profile <name> add dsh-cherry-bridge
44
+ # 从 GitHub 源码安装(与 npm 发布同一提交)
45
+ npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.7
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(分页)。 |
98
- | `cherry_workspaces` | 列出某 agent 的**已注册**工作区,以及是否存在系统工作区。 |
99
- | `cherry_sessions` | 列出某 agent 的会话,可按工作区过滤。 |
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
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:` 锚点在历史里找不到则**报错**,不会返回空结果。 |
101
- | `cherry_events` | 读取该会话缓冲区内比 `cursor` 更新的事件,并在投递后 ack。 |
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` | 列出该会话**待答**的交互卡(提问 / 审批)。 |
105
+ | `cherry_interactions` | 列出该会话**待答**的交互卡(提问 / 审批)。被截断时必带 `truncated`+`truncationReason`,并给出 `resumeCursor`——用 `sessionId` 相同的调用回传为 `cursor` 即可续读(`limit` 计的是过滤**前**的对端条目)。 |
106
106
  | `cherry_respond` | 应答交互卡:提问必须全量作答,审批走 `decision`。 |
107
107
  | `cherry_cancel` | 取消会话在途执行(`expectedExecutionId` 必填)。 |
108
108
  | `cherry_pair` | 用「扫码后页面显示的配对信息」建立与桌面端的配对。 |
@@ -176,7 +176,7 @@ npm run typecheck # 服务端 + UI 两侧
176
176
  npm run build # tsdown → dist/index.mjs + dist/index.d.mts
177
177
  npm run build:client # vite → lib/client.js
178
178
  npm run dev:ui # 设置页 dev server(需宿主在 127.0.0.1:23023 运行)
179
- npm run prepublishOnly # 发布前闸门:typecheck + test + build + build:client
179
+ npm run prepublishOnly # 发布前闸门:typecheck + test + build + build:client + npm pack --dry-run
180
180
  ```
181
181
 
182
182
  `dist/` 与 `lib/` 是**随仓库提交**的产物:改完 `src/` 或 `ui/` 必须重跑
package/dist/index.mjs CHANGED
@@ -2261,6 +2261,21 @@ const RECORD_BUDGET_BYTES = 63488;
2261
2261
  const PARTS_LIST_PAGE_CLAMP = Math.floor(RECORD_BUDGET_BYTES / 16896);
2262
2262
  /** Interactions per interactions.list page: inline input + inline summary. */
2263
2263
  const INTERACTIONS_LIST_PAGE_CLAMP = Math.floor(RECORD_BUDGET_BYTES / 25088);
2264
+ /**
2265
+ * Hard ceiling on ONE resume token (issues #70 and #74), in characters.
2266
+ *
2267
+ * BOTH faces take a token back from the caller — `cursor` on the four list
2268
+ * tools, `messagesResumeCursor` on `cherry_read` — and both tail it with a
2269
+ * peer-supplied position whose length we do not control: the desktop's session
2270
+ * cursor is `entity:<orderKey>:<uuid>` (~57 characters), a message cursor
2271
+ * `<ms>:<uuid>`, the numeric `pageOf` faces a handful of digits, and a future
2272
+ * desktop may use something longer — but nothing legitimate comes near this.
2273
+ * The bound exists so a hand-built or model-mangled string is refused by a
2274
+ * BOUND instead of being split, hashed and carried to the peer, and so both
2275
+ * parameters can declare `maxLength` honestly. ONE number for both faces: it
2276
+ * bounds caller INPUT, not what a token means.
2277
+ */
2278
+ const MAX_RESUME_TOKEN_CHARS = 4096;
2264
2279
  //#endregion
2265
2280
  //#region src/urlHost.ts
2266
2281
  /**
@@ -5445,6 +5460,31 @@ var CherryToolEngine = class {
5445
5460
  ...pageOverflow === void 0 ? {} : { pageOverflow }
5446
5461
  };
5447
5462
  }
5463
+ /** Decode one list `cursor` argument, or undefined when none was given. */
5464
+ #listResume(raw, binding) {
5465
+ if (raw === void 0 || raw === null) return void 0;
5466
+ return {
5467
+ kind: "cursor",
5468
+ cursor: parseListResumeCursor(raw, binding)
5469
+ };
5470
+ }
5471
+ /**
5472
+ * The public shape of a list walk's result: items, the stop verdict, and — only
5473
+ * when the walk really stopped at a peer position — the RESUMABLE token.
5474
+ *
5475
+ * The peer's own position never leaves this method: it is opaque, unusable as
5476
+ * an input on its own, and exposing it invited callers to replay it (issue
5477
+ * #65). What a caller can act on is a token, so that is what is emitted.
5478
+ */
5479
+ #listPage(page, binding) {
5480
+ const position = optionalString(page.nextCursor);
5481
+ return {
5482
+ items: page.items,
5483
+ truncated: page.truncated,
5484
+ ...page.truncationReason === void 0 ? {} : { truncationReason: page.truncationReason },
5485
+ ...page.truncated && position !== void 0 && !mangledRemainder(position) ? { resumeCursor: encodeListResumeCursor(binding, position) } : {}
5486
+ };
5487
+ }
5448
5488
  #devicesTool() {
5449
5489
  return defineCherryTool({
5450
5490
  name: "cherry_devices",
@@ -5521,7 +5561,7 @@ var CherryToolEngine = class {
5521
5561
  #agentsTool() {
5522
5562
  return defineCherryTool({
5523
5563
  name: "cherry_agents",
5524
- 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.",
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).",
5525
5565
  parameters: {
5526
5566
  type: "object",
5527
5567
  properties: {
@@ -5534,7 +5574,8 @@ var CherryToolEngine = class {
5534
5574
  minimum: 1,
5535
5575
  maximum: MAX_AGGREGATE_LIMIT,
5536
5576
  description: "Maximum number of agents to return in total across pages (default 100)."
5537
- }
5577
+ },
5578
+ cursor: LIST_CURSOR_PARAMETER
5538
5579
  },
5539
5580
  required: ["device"],
5540
5581
  additionalProperties: false
@@ -5542,18 +5583,28 @@ var CherryToolEngine = class {
5542
5583
  execute: async (args) => {
5543
5584
  const resolved = this.#linkOr(requiredString(args, "device"));
5544
5585
  if (resolved.link === void 0) return resolved.verdict;
5545
- const { connection } = resolved.link;
5546
- return this.#aggregate(connection, "agent.agents.list", {}, (page) => ({
5547
- ...arrayOf(page["items"]),
5548
- nextCursor: page["nextCursor"]
5549
- }), { limit: aggregateLimit(args["limit"]) });
5586
+ const { connection, record } = resolved.link;
5587
+ const binding = {
5588
+ method: "agent.agents.list",
5589
+ deviceId: record.deviceId,
5590
+ params: {}
5591
+ };
5592
+ const resume = this.#listResume(args["cursor"], binding);
5593
+ const page = await this.#aggregate(connection, "agent.agents.list", {}, (result) => ({
5594
+ ...arrayOf(result["items"]),
5595
+ nextCursor: result["nextCursor"]
5596
+ }), {
5597
+ limit: aggregateLimit(args["limit"]),
5598
+ ...resume === void 0 ? {} : { resume }
5599
+ });
5600
+ return this.#listPage(page, binding);
5550
5601
  }
5551
5602
  });
5552
5603
  }
5553
5604
  #workspacesTool() {
5554
5605
  return defineCherryTool({
5555
5606
  name: "cherry_workspaces",
5556
- 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.",
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.",
5557
5608
  parameters: {
5558
5609
  type: "object",
5559
5610
  properties: {
@@ -5570,7 +5621,8 @@ var CherryToolEngine = class {
5570
5621
  minimum: 1,
5571
5622
  maximum: MAX_AGGREGATE_LIMIT,
5572
5623
  description: "Maximum number of workspaces to return in total across pages (default 100)."
5573
- }
5624
+ },
5625
+ cursor: LIST_CURSOR_PARAMETER
5574
5626
  },
5575
5627
  required: ["device", "agentId"],
5576
5628
  additionalProperties: false
@@ -5578,21 +5630,28 @@ var CherryToolEngine = class {
5578
5630
  execute: async (args) => {
5579
5631
  const resolved = this.#linkOr(requiredString(args, "device"));
5580
5632
  if (resolved.link === void 0) return resolved.verdict;
5581
- const { connection } = resolved.link;
5582
- const agentId = requiredString(args, "agentId");
5633
+ const { connection, record } = resolved.link;
5634
+ const params = { agentId: requiredString(args, "agentId") };
5635
+ const binding = {
5636
+ method: "agent.workspaces.list",
5637
+ deviceId: record.deviceId,
5638
+ params
5639
+ };
5640
+ const resume = this.#listResume(args["cursor"], binding);
5583
5641
  let systemWorkspace = false;
5584
- const page = await this.#aggregate(connection, "agent.workspaces.list", { agentId }, (result) => {
5642
+ const page = await this.#aggregate(connection, "agent.workspaces.list", params, (result) => {
5585
5643
  if (typeof result["systemWorkspace"] === "boolean") systemWorkspace = result["systemWorkspace"];
5586
5644
  return {
5587
5645
  ...arrayOf(result["items"]),
5588
5646
  nextCursor: result["nextCursor"]
5589
5647
  };
5590
- }, { limit: aggregateLimit(args["limit"]) });
5648
+ }, {
5649
+ limit: aggregateLimit(args["limit"]),
5650
+ ...resume === void 0 ? {} : { resume }
5651
+ });
5591
5652
  return {
5592
- items: page.items,
5593
- systemWorkspace,
5594
- truncated: page.truncated,
5595
- ...page.truncationReason === void 0 ? {} : { truncationReason: page.truncationReason }
5653
+ ...this.#listPage(page, binding),
5654
+ systemWorkspace
5596
5655
  };
5597
5656
  }
5598
5657
  });
@@ -5600,7 +5659,7 @@ var CherryToolEngine = class {
5600
5659
  #sessionsTool() {
5601
5660
  return defineCherryTool({
5602
5661
  name: "cherry_sessions",
5603
- 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.",
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).",
5604
5663
  parameters: {
5605
5664
  type: "object",
5606
5665
  properties: {
@@ -5621,7 +5680,8 @@ var CherryToolEngine = class {
5621
5680
  minimum: 1,
5622
5681
  maximum: MAX_AGGREGATE_LIMIT,
5623
5682
  description: "Maximum number of sessions to return in total across pages (default 100)."
5624
- }
5683
+ },
5684
+ cursor: LIST_CURSOR_PARAMETER
5625
5685
  },
5626
5686
  required: ["device"],
5627
5687
  additionalProperties: false
@@ -5629,16 +5689,27 @@ var CherryToolEngine = class {
5629
5689
  execute: async (args) => {
5630
5690
  const resolved = this.#linkOr(requiredString(args, "device"));
5631
5691
  if (resolved.link === void 0) return resolved.verdict;
5632
- const { connection } = resolved.link;
5692
+ const { connection, record } = resolved.link;
5633
5693
  const agentId = optionalString(args["agentId"]);
5634
5694
  const workspaceId = optionalString(args["workspaceId"]);
5635
- return this.#aggregate(connection, "agent.sessions.list", {
5695
+ const params = {
5636
5696
  ...agentId === void 0 ? {} : { agentId },
5637
5697
  ...workspaceId === void 0 ? {} : { workspaceId }
5638
- }, (page) => ({
5639
- ...arrayOf(page["items"]),
5640
- nextCursor: page["nextCursor"]
5641
- }), { limit: aggregateLimit(args["limit"]) });
5698
+ };
5699
+ const binding = {
5700
+ method: "agent.sessions.list",
5701
+ deviceId: record.deviceId,
5702
+ params
5703
+ };
5704
+ const resume = this.#listResume(args["cursor"], binding);
5705
+ const page = await this.#aggregate(connection, "agent.sessions.list", params, (result) => ({
5706
+ ...arrayOf(result["items"]),
5707
+ nextCursor: result["nextCursor"]
5708
+ }), {
5709
+ limit: aggregateLimit(args["limit"]),
5710
+ ...resume === void 0 ? {} : { resume }
5711
+ });
5712
+ return this.#listPage(page, binding);
5642
5713
  }
5643
5714
  });
5644
5715
  }
@@ -5665,7 +5736,8 @@ var CherryToolEngine = class {
5665
5736
  },
5666
5737
  messagesResumeCursor: {
5667
5738
  type: "string",
5668
- description: "A `messagesResumeCursor` a previous truncated read (this tool, or cherry_send/cherry_wait) returned: `\"cursor:<peer position>\"` or `\"after:<messageId>\"`. Continues the walk at that stop point instead of at the newest message; `limit` bounds what comes back from there. Omit it to read from the newest message."
5739
+ maxLength: MAX_RESUME_TOKEN_CHARS,
5740
+ description: `A \`messagesResumeCursor\` a previous truncated read (this tool, or cherry_send/cherry_wait) returned: \`"cursor:<peer position>"\` or \`"after:<messageId>"\`. Continues the walk at that stop point instead of at the newest message; \`limit\` bounds what comes back from there. Omit it to read from the newest message. A token this tool did not emit is refused locally, and ${MAX_RESUME_TOKEN_CHARS} characters is the ceiling.`
5669
5741
  }
5670
5742
  },
5671
5743
  required: ["device", "sessionId"],
@@ -6061,7 +6133,7 @@ var CherryToolEngine = class {
6061
6133
  #eventsTool() {
6062
6134
  return defineCherryTool({
6063
6135
  name: "cherry_events",
6064
- description: "Read the session's buffered events newer than `cursor`, delivering them and then acknowledging the advanced cursor to the peer. While the window has dropped no events, every cursor this plugin handed out is servable, including the baseline `cherry_send`/`cherry_wait` report on an empty window. Once the window DISCARDS events, the boundary it reports is not: that boundary and everything below it answer a structured `cursor-out-of-window` verdict, and only positions strictly past it are served — rebuild the baseline with `cherry_read` and resume from that instead.",
6136
+ description: "Read the session's buffered events newer than `cursor`, delivering them and then acknowledging the advanced cursor to the peer. While the window has dropped no events, every cursor this plugin handed out is servable, including the baseline `cherry_send`/`cherry_wait` report on an empty window. Once the window DISCARDS events, the boundary it reports is not: that boundary and everything below it answer a structured `cursor-out-of-window` verdict, and only positions strictly past it are served — rebuild the baseline with `cherry_read` and resume from that instead. The answer is `{events, nextCursor}`; pass that `nextCursor` back as `cursor` on the next call to continue from it.",
6065
6137
  parameters: {
6066
6138
  type: "object",
6067
6139
  properties: {
@@ -6419,7 +6491,7 @@ var CherryToolEngine = class {
6419
6491
  #interactionsTool() {
6420
6492
  return defineCherryTool({
6421
6493
  name: "cherry_interactions",
6422
- 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`.",
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.",
6423
6495
  parameters: {
6424
6496
  type: "object",
6425
6497
  properties: {
@@ -6440,7 +6512,8 @@ var CherryToolEngine = class {
6440
6512
  minimum: 1,
6441
6513
  maximum: MAX_AGGREGATE_LIMIT,
6442
6514
  description: "Maximum number of interactions to return in total across pages (default 100)."
6443
- }
6515
+ },
6516
+ cursor: LIST_CURSOR_PARAMETER
6444
6517
  },
6445
6518
  required: ["device", "sessionId"],
6446
6519
  additionalProperties: false
@@ -6450,12 +6523,20 @@ var CherryToolEngine = class {
6450
6523
  if (resolved.link === void 0) return resolved.verdict;
6451
6524
  const { connection, record } = resolved.link;
6452
6525
  const sessionId = requiredString(args, "sessionId");
6526
+ const params = { sessionId };
6527
+ const binding = {
6528
+ method: "agent.interactions.list",
6529
+ deviceId: record.deviceId,
6530
+ params
6531
+ };
6532
+ const resume = this.#listResume(args["cursor"], binding);
6453
6533
  await this.#ensureSubscription(record.deviceId, sessionId);
6454
- const page = await this.#aggregate(connection, "agent.interactions.list", { sessionId }, (result) => ({
6534
+ const page = await this.#aggregate(connection, "agent.interactions.list", params, (result) => ({
6455
6535
  ...arrayOf(result["items"]),
6456
6536
  nextCursor: result["nextCursor"]
6457
6537
  }), {
6458
6538
  limit: aggregateLimit(args["limit"]),
6539
+ ...resume === void 0 ? {} : { resume },
6459
6540
  safetyBound: MAX_AGGREGATE_LIMIT,
6460
6541
  maxPerPage: INTERACTIONS_LIST_PAGE_CLAMP
6461
6542
  });
@@ -6463,9 +6544,8 @@ var CherryToolEngine = class {
6463
6544
  const items = page.items.filter((item) => includeAnswered || item["status"] === "pending").map((item) => this.#interactionView(item));
6464
6545
  for (const item of page.items) this.#rememberInteraction(record.deviceId, sessionId, item);
6465
6546
  return {
6466
- items,
6467
- truncated: page.truncated,
6468
- ...page.truncationReason === void 0 ? {} : { truncationReason: page.truncationReason }
6547
+ ...this.#listPage(page, binding),
6548
+ items
6469
6549
  };
6470
6550
  }
6471
6551
  });
@@ -8168,9 +8248,11 @@ function parseMessageResume(value) {
8168
8248
  };
8169
8249
  if (typeof value !== "string") refuse();
8170
8250
  const token = value;
8251
+ if (token.length > 4096) throw new Error(`cherry_read: messagesResumeCursor is ${token.length} characters long, over the ${MAX_RESUME_TOKEN_CHARS}-character ceiling for a resume token; omit the argument to read from the newest message.`);
8171
8252
  if (token.startsWith(RESUME_CURSOR_PREFIX)) {
8172
8253
  const cursor = token.slice(7);
8173
8254
  if (mangledRemainder(cursor)) refuse();
8255
+ if (looksLikeV1ListToken(cursor)) throw new Error("cherry_read: messagesResumeCursor is a v1 LIST `resumeCursor` (issued by an older plugin version, before list tokens were compact): it resumes a list walk, not a message history, and it can no longer be verified. Omit the argument to read from the newest message, or pass the token this tool returned.");
8174
8256
  return {
8175
8257
  kind: "cursor",
8176
8258
  cursor
@@ -8184,9 +8266,170 @@ function parseMessageResume(value) {
8184
8266
  messageId
8185
8267
  };
8186
8268
  }
8269
+ if (token.startsWith(LIST_RESUME_PREFIX)) throw new Error("cherry_read: messagesResumeCursor is a LIST `resumeCursor` (`list:…`) — it resumes a list walk (cherry_agents / cherry_workspaces / cherry_sessions / cherry_interactions), not a message history. Omit the argument to read from the newest message, or pass the token this tool returned.");
8187
8270
  return refuse();
8188
8271
  }
8189
8272
  /**
8273
+ * The LIST face's stop point (issue #65): a self-describing token that the SAME
8274
+ * tool accepts back, bound to the query that produced it.
8275
+ *
8276
+ * The message face wraps the peer position in `cursor:` / `after:` and is done,
8277
+ * because a message walk is scoped by its session. A list walk is scoped by
8278
+ * (method, device, filters), and the peer position is opaque: a position from
8279
+ * another query is a perfectly valid number that reads the WRONG slice with no
8280
+ * error anywhere. So this token carries a fingerprint of the binding and a
8281
+ * mismatched replay is refused HERE — before any RPC.
8282
+ *
8283
+ * Wire format (issue #70):
8284
+ * `list:v2:<tool>:<device12>:<filters8>:<checksum8>:<peer position>` — the
8285
+ * position verbatim and LAST. It replaced a base64 JSON blob (`{v,m,d,h,p}`)
8286
+ * that was ~2.3x longer and had no integrity check: measured over 20,000 random
8287
+ * single-character corruptions, 28.7% of the old tokens were ACCEPTED with a
8288
+ * different position, because the position is the one field a structural check
8289
+ * cannot validate. The compact form plus the checksum makes that class empty.
8290
+ *
8291
+ * The prefix is `list:` and NOT `cursor:` (issue #72): the message face already
8292
+ * owns `cursor:` / `after:`, and while the two faces shared a prefix a list
8293
+ * token was a legal `messagesResumeCursor` payload — it was forwarded to the
8294
+ * desktop, which answers an unparseable position by serving its FIRST page, so
8295
+ * "continue from here" silently re-read the newest page. Two faces, two
8296
+ * prefixes: the refusal is a PREFIX check on either side, never an inspection
8297
+ * of the peer's position (a position that merely starts with `list:` still
8298
+ * travels verbatim inside its own `cursor:` token).
8299
+ */
8300
+ const LIST_RESUME_PREFIX = "list:";
8301
+ /**
8302
+ * The token's format marker (issue #70). It is a FIELD, not part of the prefix,
8303
+ * so a token from the PREVIOUS format is recognised and refused by name
8304
+ * ({@link looksLikeV1ListToken}) instead of getting the generic shape error: a
8305
+ * resume token outlives a plugin upgrade (the caller is holding it), and "start
8306
+ * the walk over" is actionable where "not a token this tool emitted" invites
8307
+ * one more attempt with the same string.
8308
+ *
8309
+ * A format NEWER than this one has no detector and falls through to the
8310
+ * generic refusal, which is the honest verdict: this release cannot know that a
8311
+ * future format is a resume token at all.
8312
+ */
8313
+ const LIST_TOKEN_VERSION = "v2";
8314
+ /**
8315
+ * One character per list tool, replacing the full method name (19 characters of
8316
+ * a 225-character token). Every method a list tool can issue MUST appear here:
8317
+ * {@link encodeListResumeCursor} throws on an unknown one rather than emit a
8318
+ * token the matching parser could never accept.
8319
+ */
8320
+ const LIST_TOOL_CODES = {
8321
+ "agent.agents.list": "a",
8322
+ "agent.workspaces.list": "w",
8323
+ "agent.sessions.list": "s",
8324
+ "agent.interactions.list": "i"
8325
+ };
8326
+ /** The inverse of {@link LIST_TOOL_CODES}, for {@link parseListResumeCursor}. */
8327
+ const LIST_CODE_METHODS = Object.fromEntries(Object.entries(LIST_TOOL_CODES).map(([method, code]) => [code, method]));
8328
+ /**
8329
+ * The fields after the version marker, BEFORE the peer position: tool code,
8330
+ * device digest, filter fingerprint and checksum. The position is the rest of
8331
+ * the token — it is peer-supplied and may contain the delimiter itself (the
8332
+ * desktop's session cursor is `entity:<orderKey>:<uuid>`), so the split stops
8333
+ * at a fixed count and never consumes it.
8334
+ */
8335
+ const LIST_TOKEN_FIELDS = 4;
8336
+ /** The `cursor` parameter every list tool shares (issues #65 and #70). */
8337
+ const LIST_CURSOR_PARAMETER = {
8338
+ type: "string",
8339
+ maxLength: MAX_RESUME_TOKEN_CHARS,
8340
+ description: `A \`resumeCursor\` a previous TRUNCATED answer of this tool returned: replay it with the SAME device and filters to continue from that stop point (\`limit\` then bounds this answer). A token from another tool, device or filter set is refused locally, and so is a token this account cannot have received intact (the token carries a checksum; ${MAX_RESUME_TOKEN_CHARS} characters is the ceiling).`
8341
+ };
8342
+ /** Canonical, key-sorted rendering of the caller's filters (undefined keys dropped). */
8343
+ function listParamsFingerprint(params) {
8344
+ const entries = Object.keys(params).sort().filter((key) => params[key] !== void 0).map((key) => [key, params[key]]);
8345
+ return createHash("sha256").update(JSON.stringify(entries)).digest("hex").slice(0, 8);
8346
+ }
8347
+ /**
8348
+ * The device half of the binding, as a digest rather than the id itself: a
8349
+ * `deviceId` is a 36-character UUID, and the token only ever has to answer "was
8350
+ * this issued for the device I just resolved?", never "which device was it?".
8351
+ * 12 hex characters (48 bits) make an accidental collision between two paired
8352
+ * desktops a non-event, where the old field was a third of the token.
8353
+ */
8354
+ function listDeviceFingerprint(deviceId) {
8355
+ return createHash("sha256").update(deviceId).digest("hex").slice(0, 12);
8356
+ }
8357
+ /**
8358
+ * The token's integrity check (issue #70). It covers the peer position, which
8359
+ * is the ONE field no structural check can validate: a substitution there used
8360
+ * to decode as a perfectly well-formed token and was forwarded, and the desktop
8361
+ * silently falls back to its FIRST page for a position it cannot parse — so the
8362
+ * caller's "continue from here" came back as the newest page presented as older
8363
+ * history. 8 hex characters (32 bits) turn every single-character corruption
8364
+ * into a local refusal; the token is not a security boundary, so a MAC would
8365
+ * only make it longer.
8366
+ */
8367
+ function listTokenChecksum(body, peerPosition) {
8368
+ return createHash("sha256").update(`${body}\u0000${peerPosition}`).digest("hex").slice(0, 8);
8369
+ }
8370
+ function encodeListResumeCursor(binding, peerPosition) {
8371
+ const code = LIST_TOOL_CODES[binding.method];
8372
+ if (code === void 0) throw new Error(`${binding.method}: no list resume token code`);
8373
+ const body = `${LIST_TOKEN_VERSION}:${code}:${listDeviceFingerprint(binding.deviceId)}:${listParamsFingerprint(binding.params)}`;
8374
+ return `${LIST_RESUME_PREFIX}${body}:${listTokenChecksum(body, peerPosition)}:${peerPosition}`;
8375
+ }
8376
+ /**
8377
+ * Whether a `cursor:` remainder is a token from the base64-JSON era (issue #65,
8378
+ * `v: 1`). Only used to explain a refusal: the payload is never replayed.
8379
+ */
8380
+ function looksLikeV1ListToken(raw) {
8381
+ if (raw === "" || raw.includes(":")) return false;
8382
+ try {
8383
+ const decoded = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
8384
+ return typeof decoded === "object" && decoded !== null && decoded["v"] === 1;
8385
+ } catch {
8386
+ return false;
8387
+ }
8388
+ }
8389
+ /**
8390
+ * Decode a list token, refusing anything that is not one this tool emitted for
8391
+ * THIS binding. The refusal names the dimension that differs (tool / device /
8392
+ * filters) and never echoes the peer position, which is peer-supplied data.
8393
+ *
8394
+ * An `after:` token is refused by the prefix check: list walks have no anchor
8395
+ * form (they never trim a page, so a peer position always exists for a stop the
8396
+ * peer reported).
8397
+ */
8398
+ function parseListResumeCursor(value, binding) {
8399
+ const refuse = (why) => {
8400
+ throw new Error(`cursor ${why}; omit \`cursor\` to read from the start, or replay the token with the same tool, device and filters.`);
8401
+ };
8402
+ if (typeof value !== "string") refuse("must be a `list:` token this tool emitted");
8403
+ const token = value;
8404
+ if (token.length > 4096) throw new Error(`cursor is ${token.length} characters long, over the ${MAX_RESUME_TOKEN_CHARS}-character ceiling for a resume token; omit \`cursor\` to read from the start.`);
8405
+ if (token.startsWith(RESUME_CURSOR_PREFIX)) {
8406
+ if (looksLikeV1ListToken(token.slice(7))) throw new Error("cursor was issued by an older plugin version (a v1 list token, from before list tokens were compact and checksummed), so it can no longer be verified; start this walk over with `cursor` omitted.");
8407
+ refuse("is a MESSAGE-face token (`cursor:<peer position>`, i.e. a `messagesResumeCursor`), which a list walk never issues — a list token starts with `list:`");
8408
+ }
8409
+ if (!token.startsWith(LIST_RESUME_PREFIX)) refuse("must be a `list:` token this tool emitted");
8410
+ const raw = token.slice(5);
8411
+ if (!raw.startsWith(`${LIST_TOKEN_VERSION}:`)) {
8412
+ if (looksLikeV1ListToken(raw)) throw new Error("cursor was issued by an older plugin version (a v1 token, from before list tokens were compact and checksummed), so it can no longer be verified; start this walk over with `cursor` omitted.");
8413
+ refuse("is not a token this tool emitted");
8414
+ }
8415
+ let rest = raw.slice(3);
8416
+ const fields = [];
8417
+ for (let index = 0; index < LIST_TOKEN_FIELDS; index += 1) {
8418
+ const separator = rest.indexOf(":");
8419
+ if (separator < 0) refuse("is not a token this tool emitted");
8420
+ fields.push(rest.slice(0, separator));
8421
+ rest = rest.slice(separator + 1);
8422
+ }
8423
+ const [code, device, filters, checksum] = fields;
8424
+ const peerPosition = rest;
8425
+ if (checksum !== listTokenChecksum(`${LIST_TOKEN_VERSION}:${code}:${device}:${filters}`, peerPosition)) refuse("is not a token this tool emitted (its checksum does not match, so it was altered in transit)");
8426
+ if (LIST_CODE_METHODS[code] !== binding.method) refuse("was issued for a different tool");
8427
+ if (device !== listDeviceFingerprint(binding.deviceId)) refuse("was issued for a different device");
8428
+ if (filters !== listParamsFingerprint(binding.params)) refuse("was issued for a different filter set");
8429
+ if (mangledRemainder(peerPosition)) refuse("is not a token this tool emitted");
8430
+ return peerPosition;
8431
+ }
8432
+ /**
8190
8433
  * The assistant text a terminal aggregate reports: the awaited RUN's assistant
8191
8434
  * message text parts, joined.
8192
8435
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cherry-bridge",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "packageManager": "npm@10.9.9",
package/src/defaults.ts CHANGED
@@ -137,3 +137,19 @@ export const INTERACTIONS_LIST_PAGE_CLAMP = Math.floor(
137
137
 
138
138
  /** Items per every other aggregated list page (agents/sessions/messages). */
139
139
  export const LIST_PAGE_CLAMP = 16
140
+
141
+ /**
142
+ * Hard ceiling on ONE resume token (issues #70 and #74), in characters.
143
+ *
144
+ * BOTH faces take a token back from the caller — `cursor` on the four list
145
+ * tools, `messagesResumeCursor` on `cherry_read` — and both tail it with a
146
+ * peer-supplied position whose length we do not control: the desktop's session
147
+ * cursor is `entity:<orderKey>:<uuid>` (~57 characters), a message cursor
148
+ * `<ms>:<uuid>`, the numeric `pageOf` faces a handful of digits, and a future
149
+ * desktop may use something longer — but nothing legitimate comes near this.
150
+ * The bound exists so a hand-built or model-mangled string is refused by a
151
+ * BOUND instead of being split, hashed and carried to the peer, and so both
152
+ * parameters can declare `maxLength` honestly. ONE number for both faces: it
153
+ * bounds caller INPUT, not what a token means.
154
+ */
155
+ export const MAX_RESUME_TOKEN_CHARS = 4_096
package/src/tools.ts CHANGED
@@ -6,6 +6,7 @@ import { QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
6
6
  import {
7
7
  INTERACTIONS_LIST_PAGE_CLAMP,
8
8
  LIST_PAGE_CLAMP,
9
+ MAX_RESUME_TOKEN_CHARS,
9
10
  PARTS_LIST_PAGE_CLAMP,
10
11
  } from './defaults.js'
11
12
  import { JsonRpcRequestError, RequestTimeoutError } from './rpc/jsonRpcClient.js'
@@ -2082,6 +2083,39 @@ class CherryToolEngine {
2082
2083
  }
2083
2084
  }
2084
2085
 
2086
+ /** Decode one list `cursor` argument, or undefined when none was given. */
2087
+ #listResume(raw: unknown, binding: ListResumeBinding): { kind: 'cursor'; cursor: string } | undefined {
2088
+ if (raw === undefined || raw === null) return undefined
2089
+ return { kind: 'cursor', cursor: parseListResumeCursor(raw, binding) }
2090
+ }
2091
+
2092
+ /**
2093
+ * The public shape of a list walk's result: items, the stop verdict, and — only
2094
+ * when the walk really stopped at a peer position — the RESUMABLE token.
2095
+ *
2096
+ * The peer's own position never leaves this method: it is opaque, unusable as
2097
+ * an input on its own, and exposing it invited callers to replay it (issue
2098
+ * #65). What a caller can act on is a token, so that is what is emitted.
2099
+ */
2100
+ #listPage(page: CherryPage<Record<string, unknown>>, binding: ListResumeBinding): Record<string, unknown> {
2101
+ // A position this face would REFUSE is not a position it may name. The
2102
+ // 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)
2109
+ return {
2110
+ items: page.items,
2111
+ truncated: page.truncated,
2112
+ ...(page.truncationReason === undefined ? {} : { truncationReason: page.truncationReason }),
2113
+ ...(page.truncated && position !== undefined && !mangledRemainder(position)
2114
+ ? { resumeCursor: encodeListResumeCursor(binding, position) }
2115
+ : {}),
2116
+ }
2117
+ }
2118
+
2085
2119
  // -------------------------------------------------------------------------
2086
2120
  // ① cherry_devices
2087
2121
  // -------------------------------------------------------------------------
@@ -2179,7 +2213,11 @@ class CherryToolEngine {
2179
2213
  name: 'cherry_agents',
2180
2214
  description:
2181
2215
  'List the Cherry Studio agents available on a device. Paginated: `limit` bounds ' +
2182
- 'the TOTAL across pages (default 100, max 1000) and is aggregated internally.',
2216
+ 'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
2217
+ '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).',
2183
2221
  parameters: {
2184
2222
  type: 'object',
2185
2223
  properties: {
@@ -2190,6 +2228,7 @@ class CherryToolEngine {
2190
2228
  maximum: MAX_AGGREGATE_LIMIT,
2191
2229
  description: 'Maximum number of agents to return in total across pages (default 100).',
2192
2230
  },
2231
+ cursor: LIST_CURSOR_PARAMETER,
2193
2232
  },
2194
2233
  required: ['device'],
2195
2234
  additionalProperties: false,
@@ -2197,14 +2236,17 @@ class CherryToolEngine {
2197
2236
  execute: async (args) => {
2198
2237
  const resolved = this.#linkOr(requiredString(args, 'device'))
2199
2238
  if (resolved.link === undefined) return resolved.verdict
2200
- const { connection } = resolved.link
2201
- return this.#aggregate(
2239
+ const { connection, record } = resolved.link
2240
+ const binding: ListResumeBinding = { method: 'agent.agents.list', deviceId: record.deviceId, params: {} }
2241
+ const resume = this.#listResume(args['cursor'], binding)
2242
+ const page = await this.#aggregate(
2202
2243
  connection,
2203
2244
  'agent.agents.list',
2204
2245
  {},
2205
- (page) => ({ ...arrayOf(page['items']), nextCursor: page['nextCursor'] }),
2206
- { limit: aggregateLimit(args['limit']) },
2246
+ (result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
2247
+ { limit: aggregateLimit(args['limit']), ...(resume === undefined ? {} : { resume }) },
2207
2248
  )
2249
+ return this.#listPage(page, binding)
2208
2250
  },
2209
2251
  })
2210
2252
  }
@@ -2215,7 +2257,9 @@ class CherryToolEngine {
2215
2257
  description:
2216
2258
  'List an agent\'s REGISTERED workspaces, plus whether a system workspace exists. ' +
2217
2259
  'The system workspace has no enumerable id: it is not in `items`, and its identity ' +
2218
- 'is only observable by creating a session in it and reading it back.',
2260
+ '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.',
2219
2263
  parameters: {
2220
2264
  type: 'object',
2221
2265
  properties: {
@@ -2227,6 +2271,7 @@ class CherryToolEngine {
2227
2271
  maximum: MAX_AGGREGATE_LIMIT,
2228
2272
  description: 'Maximum number of workspaces to return in total across pages (default 100).',
2229
2273
  },
2274
+ cursor: LIST_CURSOR_PARAMETER,
2230
2275
  },
2231
2276
  required: ['device', 'agentId'],
2232
2277
  additionalProperties: false,
@@ -2234,27 +2279,25 @@ class CherryToolEngine {
2234
2279
  execute: async (args) => {
2235
2280
  const resolved = this.#linkOr(requiredString(args, 'device'))
2236
2281
  if (resolved.link === undefined) return resolved.verdict
2237
- const { connection } = resolved.link
2282
+ const { connection, record } = resolved.link
2238
2283
  const agentId = requiredString(args, 'agentId')
2284
+ const params = { agentId }
2285
+ const binding: ListResumeBinding = { method: 'agent.workspaces.list', deviceId: record.deviceId, params }
2286
+ const resume = this.#listResume(args['cursor'], binding)
2239
2287
  let systemWorkspace = false
2240
2288
  const page = await this.#aggregate(
2241
2289
  connection,
2242
2290
  'agent.workspaces.list',
2243
- { agentId },
2291
+ params,
2244
2292
  (result) => {
2245
2293
  // The flag rides on every page; the last page read wins, and the
2246
2294
  // real peer always sends it.
2247
2295
  if (typeof result['systemWorkspace'] === 'boolean') systemWorkspace = result['systemWorkspace']
2248
2296
  return { ...arrayOf(result['items']), nextCursor: result['nextCursor'] }
2249
2297
  },
2250
- { limit: aggregateLimit(args['limit']) },
2298
+ { limit: aggregateLimit(args['limit']), ...(resume === undefined ? {} : { resume }) },
2251
2299
  )
2252
- return {
2253
- items: page.items,
2254
- systemWorkspace,
2255
- truncated: page.truncated,
2256
- ...(page.truncationReason === undefined ? {} : { truncationReason: page.truncationReason }),
2257
- }
2300
+ return { ...this.#listPage(page, binding), systemWorkspace }
2258
2301
  },
2259
2302
  })
2260
2303
  }
@@ -2265,7 +2308,11 @@ class CherryToolEngine {
2265
2308
  description:
2266
2309
  'List an agent\'s sessions, optionally filtered by workspace. The `agentId` and ' +
2267
2310
  '`workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds ' +
2268
- 'the TOTAL across pages (default 100, max 1000) and is aggregated internally.',
2311
+ 'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
2312
+ '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).',
2269
2316
  parameters: {
2270
2317
  type: 'object',
2271
2318
  properties: {
@@ -2278,6 +2325,7 @@ class CherryToolEngine {
2278
2325
  maximum: MAX_AGGREGATE_LIMIT,
2279
2326
  description: 'Maximum number of sessions to return in total across pages (default 100).',
2280
2327
  },
2328
+ cursor: LIST_CURSOR_PARAMETER,
2281
2329
  },
2282
2330
  required: ['device'],
2283
2331
  additionalProperties: false,
@@ -2285,21 +2333,27 @@ class CherryToolEngine {
2285
2333
  execute: async (args) => {
2286
2334
  const resolved = this.#linkOr(requiredString(args, 'device'))
2287
2335
  if (resolved.link === undefined) return resolved.verdict
2288
- const { connection } = resolved.link
2336
+ const { connection, record } = resolved.link
2289
2337
  const agentId = optionalString(args['agentId'])
2290
2338
  const workspaceId = optionalString(args['workspaceId'])
2291
2339
  // Filter passthrough: the params carry the caller's filters verbatim,
2292
- // and `#aggregate` adds only `limit`/`cursor`.
2293
- return this.#aggregate(
2340
+ // and `#aggregate` adds only `limit`/`cursor`. The SAME object is the
2341
+ // token's binding, so a replay with other filters cannot read another
2342
+ // query's page position.
2343
+ const params = {
2344
+ ...(agentId === undefined ? {} : { agentId }),
2345
+ ...(workspaceId === undefined ? {} : { workspaceId }),
2346
+ }
2347
+ const binding: ListResumeBinding = { method: 'agent.sessions.list', deviceId: record.deviceId, params }
2348
+ const resume = this.#listResume(args['cursor'], binding)
2349
+ const page = await this.#aggregate(
2294
2350
  connection,
2295
2351
  'agent.sessions.list',
2296
- {
2297
- ...(agentId === undefined ? {} : { agentId }),
2298
- ...(workspaceId === undefined ? {} : { workspaceId }),
2299
- },
2300
- (page) => ({ ...arrayOf(page['items']), nextCursor: page['nextCursor'] }),
2301
- { limit: aggregateLimit(args['limit']) },
2352
+ params,
2353
+ (result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
2354
+ { limit: aggregateLimit(args['limit']), ...(resume === undefined ? {} : { resume }) },
2302
2355
  )
2356
+ return this.#listPage(page, binding)
2303
2357
  },
2304
2358
  })
2305
2359
  }
@@ -2355,11 +2409,13 @@ class CherryToolEngine {
2355
2409
  },
2356
2410
  messagesResumeCursor: {
2357
2411
  type: 'string',
2412
+ maxLength: MAX_RESUME_TOKEN_CHARS,
2358
2413
  description:
2359
2414
  'A `messagesResumeCursor` a previous truncated read (this tool, or cherry_send/cherry_wait) ' +
2360
2415
  'returned: `"cursor:<peer position>"` or `"after:<messageId>"`. Continues the walk at that ' +
2361
2416
  'stop point instead of at the newest message; `limit` bounds what comes back from there. ' +
2362
- 'Omit it to read from the newest message.',
2417
+ 'Omit it to read from the newest message. A token this tool did not emit is refused locally, ' +
2418
+ `and ${MAX_RESUME_TOKEN_CHARS} characters is the ceiling.`,
2363
2419
  },
2364
2420
  },
2365
2421
  required: ['device', 'sessionId'],
@@ -3064,7 +3120,9 @@ class CherryToolEngine {
3064
3120
  '`cherry_send`/`cherry_wait` report on an empty window. Once the window DISCARDS events, ' +
3065
3121
  'the boundary it reports is not: that boundary and everything below it answer a structured ' +
3066
3122
  '`cursor-out-of-window` verdict, and only positions strictly past it are served — rebuild ' +
3067
- 'the baseline with `cherry_read` and resume from that instead.',
3123
+ 'the baseline with `cherry_read` and resume from that instead. The answer is ' +
3124
+ '`{events, nextCursor}`; pass that `nextCursor` back as `cursor` on the next call to ' +
3125
+ 'continue from it.',
3068
3126
  parameters: {
3069
3127
  type: 'object',
3070
3128
  properties: {
@@ -3694,7 +3752,9 @@ class CherryToolEngine {
3694
3752
  'answer. The peer serves the full four-state set (pending/approved/denied/expired) — the ' +
3695
3753
  'pending filter is applied here. Each entry carries its `kind` (question|decision) and a ' +
3696
3754
  'summary; answer questions with cherry_respond\'s `response` form and decisions with ' +
3697
- '`decision`.',
3755
+ '`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.',
3698
3758
  parameters: {
3699
3759
  type: 'object',
3700
3760
  properties: {
@@ -3712,6 +3772,7 @@ class CherryToolEngine {
3712
3772
  maximum: MAX_AGGREGATE_LIMIT,
3713
3773
  description: 'Maximum number of interactions to return in total across pages (default 100).',
3714
3774
  },
3775
+ cursor: LIST_CURSOR_PARAMETER,
3715
3776
  },
3716
3777
  required: ['device', 'sessionId'],
3717
3778
  additionalProperties: false,
@@ -3721,6 +3782,9 @@ class CherryToolEngine {
3721
3782
  if (resolved.link === undefined) return resolved.verdict
3722
3783
  const { connection, record } = resolved.link
3723
3784
  const sessionId = requiredString(args, 'sessionId')
3785
+ const params = { sessionId }
3786
+ const binding: ListResumeBinding = { method: 'agent.interactions.list', deviceId: record.deviceId, params }
3787
+ const resume = this.#listResume(args['cursor'], binding)
3724
3788
 
3725
3789
  await this.#ensureSubscription(record.deviceId, sessionId)
3726
3790
 
@@ -3730,10 +3794,11 @@ class CherryToolEngine {
3730
3794
  const page = await this.#aggregate(
3731
3795
  connection,
3732
3796
  'agent.interactions.list',
3733
- { sessionId },
3797
+ params,
3734
3798
  (result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
3735
3799
  {
3736
3800
  limit: aggregateLimit(args['limit']),
3801
+ ...(resume === undefined ? {} : { resume }),
3737
3802
  safetyBound: MAX_AGGREGATE_LIMIT,
3738
3803
  // An interaction record carries an inline input AND an inline
3739
3804
  // summary, the fattest item of the plain lists.
@@ -3752,11 +3817,7 @@ class CherryToolEngine {
3752
3817
  // docblock): priming from it is what makes every listed card answerable.
3753
3818
  for (const item of page.items) this.#rememberInteraction(record.deviceId, sessionId, item)
3754
3819
 
3755
- return {
3756
- items,
3757
- truncated: page.truncated,
3758
- ...(page.truncationReason === undefined ? {} : { truncationReason: page.truncationReason }),
3759
- }
3820
+ return { ...this.#listPage(page, binding), items }
3760
3821
  },
3761
3822
  })
3762
3823
  }
@@ -6174,9 +6235,36 @@ function parseMessageResume(value: unknown): MessageResume | undefined {
6174
6235
  }
6175
6236
  if (typeof value !== 'string') refuse()
6176
6237
  const token = value as string
6238
+ // Issue #74: the ceiling is judged on the WHOLE token, before either form's
6239
+ // prefix — so `cursor:` and `after:` get the same verdict, and no decoding,
6240
+ // scanning or dial happens for a string that cannot be one of ours. It is the
6241
+ // same bound the list face enforces ({@link MAX_RESUME_TOKEN_CHARS}).
6242
+ if (token.length > MAX_RESUME_TOKEN_CHARS) {
6243
+ throw new Error(
6244
+ `cherry_read: messagesResumeCursor is ${token.length} characters long, over the ` +
6245
+ `${MAX_RESUME_TOKEN_CHARS}-character ceiling for a resume token; omit the argument to read from the ` +
6246
+ 'newest message.',
6247
+ )
6248
+ }
6177
6249
  if (token.startsWith(RESUME_CURSOR_PREFIX)) {
6178
6250
  const cursor = token.slice(RESUME_CURSOR_PREFIX.length)
6179
6251
  if (mangledRemainder(cursor)) refuse()
6252
+ // Issue #72, the RELEASED half: a v1 list token (0.1.6 — `cursor:` +
6253
+ // base64url of `{v,m,d,h,p}`) lives under this prefix and its payload is
6254
+ // colon-free base64url, so it arrives here as a perfectly ordinary
6255
+ // position and used to be forwarded to the desktop, which answers a
6256
+ // position it cannot parse by serving its FIRST page. The detector fires
6257
+ // only for a colon-free remainder that base64-decodes to a `{v:1,…}`
6258
+ // object: every real peer position is `<ms>:<id>` (has a colon) or a
6259
+ // numeric offset, so none can be mistaken for one.
6260
+ if (looksLikeV1ListToken(cursor)) {
6261
+ throw new Error(
6262
+ 'cherry_read: messagesResumeCursor is a v1 LIST `resumeCursor` (issued by an older plugin version, ' +
6263
+ 'before list tokens were compact): it resumes a list walk, not a message history, and it can no ' +
6264
+ 'longer be verified. Omit the argument to read from the newest message, or pass the token this ' +
6265
+ 'tool returned.',
6266
+ )
6267
+ }
6180
6268
  return { kind: 'cursor', cursor }
6181
6269
  }
6182
6270
  if (token.startsWith(RESUME_ANCHOR_PREFIX)) {
@@ -6184,9 +6272,261 @@ function parseMessageResume(value: unknown): MessageResume | undefined {
6184
6272
  if (mangledRemainder(messageId)) refuse()
6185
6273
  return { kind: 'anchor', messageId }
6186
6274
  }
6275
+ if (token.startsWith(LIST_RESUME_PREFIX)) {
6276
+ // Issue #72, the other direction: a token the four list tools minted. The
6277
+ // two faces are told apart by PREFIX, never by scanning a position for our
6278
+ // own marker — `cursor:list:20` above is still a message cursor. (The branch
6279
+ // above is the one exception, and it is not about the peer's format: it
6280
+ // DECODES our own retired v1 list token, whose payload happens to be
6281
+ // indistinguishable from a position.) Forwarding a list token would hand
6282
+ // the desktop a position it cannot parse, and it answers that by serving
6283
+ // its FIRST page as if it were the continuation.
6284
+ throw new Error(
6285
+ 'cherry_read: messagesResumeCursor is a LIST `resumeCursor` (`list:…`) — it resumes a list walk ' +
6286
+ '(cherry_agents / cherry_workspaces / cherry_sessions / cherry_interactions), not a message history. ' +
6287
+ 'Omit the argument to read from the newest message, or pass the token this tool returned.',
6288
+ )
6289
+ }
6187
6290
  return refuse()
6188
6291
  }
6189
6292
 
6293
+ /**
6294
+ * The LIST face's stop point (issue #65): a self-describing token that the SAME
6295
+ * tool accepts back, bound to the query that produced it.
6296
+ *
6297
+ * The message face wraps the peer position in `cursor:` / `after:` and is done,
6298
+ * because a message walk is scoped by its session. A list walk is scoped by
6299
+ * (method, device, filters), and the peer position is opaque: a position from
6300
+ * another query is a perfectly valid number that reads the WRONG slice with no
6301
+ * error anywhere. So this token carries a fingerprint of the binding and a
6302
+ * mismatched replay is refused HERE — before any RPC.
6303
+ *
6304
+ * Wire format (issue #70):
6305
+ * `list:v2:<tool>:<device12>:<filters8>:<checksum8>:<peer position>` — the
6306
+ * position verbatim and LAST. It replaced a base64 JSON blob (`{v,m,d,h,p}`)
6307
+ * that was ~2.3x longer and had no integrity check: measured over 20,000 random
6308
+ * single-character corruptions, 28.7% of the old tokens were ACCEPTED with a
6309
+ * different position, because the position is the one field a structural check
6310
+ * cannot validate. The compact form plus the checksum makes that class empty.
6311
+ *
6312
+ * The prefix is `list:` and NOT `cursor:` (issue #72): the message face already
6313
+ * owns `cursor:` / `after:`, and while the two faces shared a prefix a list
6314
+ * token was a legal `messagesResumeCursor` payload — it was forwarded to the
6315
+ * desktop, which answers an unparseable position by serving its FIRST page, so
6316
+ * "continue from here" silently re-read the newest page. Two faces, two
6317
+ * prefixes: the refusal is a PREFIX check on either side, never an inspection
6318
+ * of the peer's position (a position that merely starts with `list:` still
6319
+ * travels verbatim inside its own `cursor:` token).
6320
+ */
6321
+ const LIST_RESUME_PREFIX = 'list:'
6322
+
6323
+ /**
6324
+ * The token's format marker (issue #70). It is a FIELD, not part of the prefix,
6325
+ * so a token from the PREVIOUS format is recognised and refused by name
6326
+ * ({@link looksLikeV1ListToken}) instead of getting the generic shape error: a
6327
+ * resume token outlives a plugin upgrade (the caller is holding it), and "start
6328
+ * the walk over" is actionable where "not a token this tool emitted" invites
6329
+ * one more attempt with the same string.
6330
+ *
6331
+ * A format NEWER than this one has no detector and falls through to the
6332
+ * generic refusal, which is the honest verdict: this release cannot know that a
6333
+ * future format is a resume token at all.
6334
+ */
6335
+ const LIST_TOKEN_VERSION = 'v2'
6336
+
6337
+ /**
6338
+ * One character per list tool, replacing the full method name (19 characters of
6339
+ * a 225-character token). Every method a list tool can issue MUST appear here:
6340
+ * {@link encodeListResumeCursor} throws on an unknown one rather than emit a
6341
+ * token the matching parser could never accept.
6342
+ */
6343
+ const LIST_TOOL_CODES: Readonly<Record<string, string>> = {
6344
+ 'agent.agents.list': 'a',
6345
+ 'agent.workspaces.list': 'w',
6346
+ 'agent.sessions.list': 's',
6347
+ 'agent.interactions.list': 'i',
6348
+ }
6349
+
6350
+ /** The inverse of {@link LIST_TOOL_CODES}, for {@link parseListResumeCursor}. */
6351
+ const LIST_CODE_METHODS: Readonly<Record<string, string>> = Object.fromEntries(
6352
+ Object.entries(LIST_TOOL_CODES).map(([method, code]) => [code, method]),
6353
+ )
6354
+
6355
+ /**
6356
+ * The fields after the version marker, BEFORE the peer position: tool code,
6357
+ * device digest, filter fingerprint and checksum. The position is the rest of
6358
+ * the token — it is peer-supplied and may contain the delimiter itself (the
6359
+ * desktop's session cursor is `entity:<orderKey>:<uuid>`), so the split stops
6360
+ * at a fixed count and never consumes it.
6361
+ */
6362
+ const LIST_TOKEN_FIELDS = 4
6363
+
6364
+ /** The `cursor` parameter every list tool shares (issues #65 and #70). */
6365
+ const LIST_CURSOR_PARAMETER = {
6366
+ type: 'string',
6367
+ maxLength: MAX_RESUME_TOKEN_CHARS,
6368
+ description:
6369
+ 'A `resumeCursor` a previous TRUNCATED answer of this tool returned: replay it with the SAME ' +
6370
+ 'device and filters to continue from that stop point (`limit` then bounds this answer). A token ' +
6371
+ 'from another tool, device or filter set is refused locally, and so is a token this account ' +
6372
+ `cannot have received intact (the token carries a checksum; ${MAX_RESUME_TOKEN_CHARS} characters ` +
6373
+ 'is the ceiling).',
6374
+ } as const
6375
+
6376
+ /** What a list token is valid for. `params` are the CALLER's filters, not the wire page params. */
6377
+ interface ListResumeBinding {
6378
+ method: string
6379
+ deviceId: string
6380
+ params: Record<string, unknown>
6381
+ }
6382
+
6383
+ /** Canonical, key-sorted rendering of the caller's filters (undefined keys dropped). */
6384
+ function listParamsFingerprint(params: Record<string, unknown>): string {
6385
+ const entries = Object.keys(params)
6386
+ .sort()
6387
+ .filter((key) => params[key] !== undefined)
6388
+ .map((key) => [key, params[key]] as const)
6389
+ return createHash('sha256').update(JSON.stringify(entries)).digest('hex').slice(0, 8)
6390
+ }
6391
+
6392
+ /**
6393
+ * The device half of the binding, as a digest rather than the id itself: a
6394
+ * `deviceId` is a 36-character UUID, and the token only ever has to answer "was
6395
+ * this issued for the device I just resolved?", never "which device was it?".
6396
+ * 12 hex characters (48 bits) make an accidental collision between two paired
6397
+ * desktops a non-event, where the old field was a third of the token.
6398
+ */
6399
+ function listDeviceFingerprint(deviceId: string): string {
6400
+ return createHash('sha256').update(deviceId).digest('hex').slice(0, 12)
6401
+ }
6402
+
6403
+ /**
6404
+ * The token's integrity check (issue #70). It covers the peer position, which
6405
+ * is the ONE field no structural check can validate: a substitution there used
6406
+ * to decode as a perfectly well-formed token and was forwarded, and the desktop
6407
+ * silently falls back to its FIRST page for a position it cannot parse — so the
6408
+ * caller's "continue from here" came back as the newest page presented as older
6409
+ * history. 8 hex characters (32 bits) turn every single-character corruption
6410
+ * into a local refusal; the token is not a security boundary, so a MAC would
6411
+ * only make it longer.
6412
+ */
6413
+ function listTokenChecksum(body: string, peerPosition: string): string {
6414
+ return createHash('sha256')
6415
+ .update(`${body}\u0000${peerPosition}`)
6416
+ .digest('hex')
6417
+ .slice(0, 8)
6418
+ }
6419
+
6420
+ function encodeListResumeCursor(binding: ListResumeBinding, peerPosition: string): string {
6421
+ const code = LIST_TOOL_CODES[binding.method]
6422
+ if (code === undefined) throw new Error(`${binding.method}: no list resume token code`)
6423
+ const body = `${LIST_TOKEN_VERSION}:${code}:${listDeviceFingerprint(binding.deviceId)}:${listParamsFingerprint(
6424
+ binding.params,
6425
+ )}`
6426
+ return `${LIST_RESUME_PREFIX}${body}:${listTokenChecksum(body, peerPosition)}:${peerPosition}`
6427
+ }
6428
+
6429
+ /**
6430
+ * Whether a `cursor:` remainder is a token from the base64-JSON era (issue #65,
6431
+ * `v: 1`). Only used to explain a refusal: the payload is never replayed.
6432
+ */
6433
+ function looksLikeV1ListToken(raw: string): boolean {
6434
+ if (raw === '' || raw.includes(':')) return false
6435
+ try {
6436
+ const decoded: unknown = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'))
6437
+ return typeof decoded === 'object' && decoded !== null && (decoded as Record<string, unknown>)['v'] === 1
6438
+ } catch {
6439
+ return false
6440
+ }
6441
+ }
6442
+
6443
+ /**
6444
+ * Decode a list token, refusing anything that is not one this tool emitted for
6445
+ * THIS binding. The refusal names the dimension that differs (tool / device /
6446
+ * filters) and never echoes the peer position, which is peer-supplied data.
6447
+ *
6448
+ * An `after:` token is refused by the prefix check: list walks have no anchor
6449
+ * form (they never trim a page, so a peer position always exists for a stop the
6450
+ * peer reported).
6451
+ */
6452
+ function parseListResumeCursor(value: unknown, binding: ListResumeBinding): string {
6453
+ const refuse = (why: string): never => {
6454
+ throw new Error(
6455
+ `cursor ${why}; omit \`cursor\` to read from the start, or replay the token with the same tool, ` +
6456
+ 'device and filters.',
6457
+ )
6458
+ }
6459
+ if (typeof value !== 'string') refuse('must be a `list:` token this tool emitted')
6460
+ const token = value as string
6461
+ // Length first: the bound is about CALLER INPUT and must not depend on which
6462
+ // face the string appears to belong to.
6463
+ if (token.length > MAX_RESUME_TOKEN_CHARS) {
6464
+ throw new Error(
6465
+ `cursor is ${token.length} characters long, over the ${MAX_RESUME_TOKEN_CHARS}-character ceiling for a ` +
6466
+ 'resume token; omit `cursor` to read from the start.',
6467
+ )
6468
+ }
6469
+ if (token.startsWith(RESUME_CURSOR_PREFIX)) {
6470
+ // Issue #72. Two faces, two prefixes: a token starting with `cursor:` was
6471
+ // minted by `cherry_read`'s message face, and replaying it here would ask a
6472
+ // LIST method for a slice at an offset taken from a MESSAGE history — a
6473
+ // perfectly valid number that reads the wrong rows with no error anywhere.
6474
+ // Until #70 the list face shared this prefix, so an old token is named
6475
+ // FIRST: "start the walk over" is the only thing that works there, and it
6476
+ // is not what this branch's other verdict says.
6477
+ if (looksLikeV1ListToken(token.slice(RESUME_CURSOR_PREFIX.length))) {
6478
+ throw new Error(
6479
+ 'cursor was issued by an older plugin version (a v1 list token, from before list tokens were ' +
6480
+ 'compact and checksummed), so it can no longer be verified; start this walk over with `cursor` omitted.',
6481
+ )
6482
+ }
6483
+ refuse(
6484
+ 'is a MESSAGE-face token (`cursor:<peer position>`, i.e. a `messagesResumeCursor`), which a list walk ' +
6485
+ 'never issues — a list token starts with `list:`',
6486
+ )
6487
+ }
6488
+ if (!token.startsWith(LIST_RESUME_PREFIX)) {
6489
+ refuse('must be a `list:` token this tool emitted')
6490
+ }
6491
+ const raw = token.slice(LIST_RESUME_PREFIX.length)
6492
+ if (!raw.startsWith(`${LIST_TOKEN_VERSION}:`)) {
6493
+ if (looksLikeV1ListToken(raw)) {
6494
+ // A v1 token cannot be replayed at ALL: the format changed, so "the same
6495
+ // tool, device and filters" would fail again. The generic tail would
6496
+ // therefore tell the caller to do something that can never work — the one
6497
+ // thing this version marker exists to avoid.
6498
+ throw new Error(
6499
+ 'cursor was issued by an older plugin version (a v1 token, from before list tokens were compact ' +
6500
+ 'and checksummed), so it can no longer be verified; start this walk over with `cursor` omitted.',
6501
+ )
6502
+ }
6503
+ refuse('is not a token this tool emitted')
6504
+ }
6505
+ let rest = raw.slice(LIST_TOKEN_VERSION.length + 1)
6506
+ const fields: string[] = []
6507
+ for (let index = 0; index < LIST_TOKEN_FIELDS; index += 1) {
6508
+ const separator = rest.indexOf(':')
6509
+ if (separator < 0) refuse('is not a token this tool emitted')
6510
+ fields.push(rest.slice(0, separator))
6511
+ rest = rest.slice(separator + 1)
6512
+ }
6513
+ // What is left is the peer position, verbatim — colons and all.
6514
+ const [code, device, filters, checksum] = fields as [string, string, string, string]
6515
+ const peerPosition = rest
6516
+ const body = `${LIST_TOKEN_VERSION}:${code}:${device}:${filters}`
6517
+ // The checksum is judged BEFORE the binding: a corrupted token is not a
6518
+ // statement about the caller's filters, and reporting it as one would send
6519
+ // the caller looking for a filter they never changed.
6520
+ if (checksum !== listTokenChecksum(body, peerPosition)) {
6521
+ refuse('is not a token this tool emitted (its checksum does not match, so it was altered in transit)')
6522
+ }
6523
+ if (LIST_CODE_METHODS[code] !== binding.method) refuse('was issued for a different tool')
6524
+ if (device !== listDeviceFingerprint(binding.deviceId)) refuse('was issued for a different device')
6525
+ if (filters !== listParamsFingerprint(binding.params)) refuse('was issued for a different filter set')
6526
+ if (mangledRemainder(peerPosition)) refuse('is not a token this tool emitted')
6527
+ return peerPosition
6528
+ }
6529
+
6190
6530
  /**
6191
6531
  * The assistant text a terminal aggregate reports: the awaited RUN's assistant
6192
6532
  * message text parts, joined.