dsh-cherry-bridge 0.1.5 → 0.1.6

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.6
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.6
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.6
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.6
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
@@ -5445,6 +5445,30 @@ var CherryToolEngine = class {
5445
5445
  ...pageOverflow === void 0 ? {} : { pageOverflow }
5446
5446
  };
5447
5447
  }
5448
+ /** Decode one list `cursor` argument, or undefined when none was given. */
5449
+ #listResume(raw, binding) {
5450
+ if (raw === void 0 || raw === null) return void 0;
5451
+ return {
5452
+ kind: "cursor",
5453
+ cursor: parseListResumeCursor(raw, binding)
5454
+ };
5455
+ }
5456
+ /**
5457
+ * The public shape of a list walk's result: items, the stop verdict, and — only
5458
+ * when the walk really stopped at a peer position — the RESUMABLE token.
5459
+ *
5460
+ * The peer's own position never leaves this method: it is opaque, unusable as
5461
+ * an input on its own, and exposing it invited callers to replay it (issue
5462
+ * #65). What a caller can act on is a token, so that is what is emitted.
5463
+ */
5464
+ #listPage(page, binding) {
5465
+ return {
5466
+ items: page.items,
5467
+ truncated: page.truncated,
5468
+ ...page.truncationReason === void 0 ? {} : { truncationReason: page.truncationReason },
5469
+ ...page.truncated && page.nextCursor !== void 0 ? { resumeCursor: encodeListResumeCursor(binding, page.nextCursor) } : {}
5470
+ };
5471
+ }
5448
5472
  #devicesTool() {
5449
5473
  return defineCherryTool({
5450
5474
  name: "cherry_devices",
@@ -5521,7 +5545,7 @@ var CherryToolEngine = class {
5521
5545
  #agentsTool() {
5522
5546
  return defineCherryTool({
5523
5547
  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.",
5548
+ 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
5549
  parameters: {
5526
5550
  type: "object",
5527
5551
  properties: {
@@ -5534,7 +5558,8 @@ var CherryToolEngine = class {
5534
5558
  minimum: 1,
5535
5559
  maximum: MAX_AGGREGATE_LIMIT,
5536
5560
  description: "Maximum number of agents to return in total across pages (default 100)."
5537
- }
5561
+ },
5562
+ cursor: LIST_CURSOR_PARAMETER
5538
5563
  },
5539
5564
  required: ["device"],
5540
5565
  additionalProperties: false
@@ -5542,18 +5567,28 @@ var CherryToolEngine = class {
5542
5567
  execute: async (args) => {
5543
5568
  const resolved = this.#linkOr(requiredString(args, "device"));
5544
5569
  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"]) });
5570
+ const { connection, record } = resolved.link;
5571
+ const binding = {
5572
+ method: "agent.agents.list",
5573
+ deviceId: record.deviceId,
5574
+ params: {}
5575
+ };
5576
+ const resume = this.#listResume(args["cursor"], binding);
5577
+ const page = await this.#aggregate(connection, "agent.agents.list", {}, (result) => ({
5578
+ ...arrayOf(result["items"]),
5579
+ nextCursor: result["nextCursor"]
5580
+ }), {
5581
+ limit: aggregateLimit(args["limit"]),
5582
+ ...resume === void 0 ? {} : { resume }
5583
+ });
5584
+ return this.#listPage(page, binding);
5550
5585
  }
5551
5586
  });
5552
5587
  }
5553
5588
  #workspacesTool() {
5554
5589
  return defineCherryTool({
5555
5590
  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.",
5591
+ 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
5592
  parameters: {
5558
5593
  type: "object",
5559
5594
  properties: {
@@ -5570,7 +5605,8 @@ var CherryToolEngine = class {
5570
5605
  minimum: 1,
5571
5606
  maximum: MAX_AGGREGATE_LIMIT,
5572
5607
  description: "Maximum number of workspaces to return in total across pages (default 100)."
5573
- }
5608
+ },
5609
+ cursor: LIST_CURSOR_PARAMETER
5574
5610
  },
5575
5611
  required: ["device", "agentId"],
5576
5612
  additionalProperties: false
@@ -5578,21 +5614,28 @@ var CherryToolEngine = class {
5578
5614
  execute: async (args) => {
5579
5615
  const resolved = this.#linkOr(requiredString(args, "device"));
5580
5616
  if (resolved.link === void 0) return resolved.verdict;
5581
- const { connection } = resolved.link;
5582
- const agentId = requiredString(args, "agentId");
5617
+ const { connection, record } = resolved.link;
5618
+ const params = { agentId: requiredString(args, "agentId") };
5619
+ const binding = {
5620
+ method: "agent.workspaces.list",
5621
+ deviceId: record.deviceId,
5622
+ params
5623
+ };
5624
+ const resume = this.#listResume(args["cursor"], binding);
5583
5625
  let systemWorkspace = false;
5584
- const page = await this.#aggregate(connection, "agent.workspaces.list", { agentId }, (result) => {
5626
+ const page = await this.#aggregate(connection, "agent.workspaces.list", params, (result) => {
5585
5627
  if (typeof result["systemWorkspace"] === "boolean") systemWorkspace = result["systemWorkspace"];
5586
5628
  return {
5587
5629
  ...arrayOf(result["items"]),
5588
5630
  nextCursor: result["nextCursor"]
5589
5631
  };
5590
- }, { limit: aggregateLimit(args["limit"]) });
5632
+ }, {
5633
+ limit: aggregateLimit(args["limit"]),
5634
+ ...resume === void 0 ? {} : { resume }
5635
+ });
5591
5636
  return {
5592
- items: page.items,
5593
- systemWorkspace,
5594
- truncated: page.truncated,
5595
- ...page.truncationReason === void 0 ? {} : { truncationReason: page.truncationReason }
5637
+ ...this.#listPage(page, binding),
5638
+ systemWorkspace
5596
5639
  };
5597
5640
  }
5598
5641
  });
@@ -5600,7 +5643,7 @@ var CherryToolEngine = class {
5600
5643
  #sessionsTool() {
5601
5644
  return defineCherryTool({
5602
5645
  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.",
5646
+ 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
5647
  parameters: {
5605
5648
  type: "object",
5606
5649
  properties: {
@@ -5621,7 +5664,8 @@ var CherryToolEngine = class {
5621
5664
  minimum: 1,
5622
5665
  maximum: MAX_AGGREGATE_LIMIT,
5623
5666
  description: "Maximum number of sessions to return in total across pages (default 100)."
5624
- }
5667
+ },
5668
+ cursor: LIST_CURSOR_PARAMETER
5625
5669
  },
5626
5670
  required: ["device"],
5627
5671
  additionalProperties: false
@@ -5629,16 +5673,27 @@ var CherryToolEngine = class {
5629
5673
  execute: async (args) => {
5630
5674
  const resolved = this.#linkOr(requiredString(args, "device"));
5631
5675
  if (resolved.link === void 0) return resolved.verdict;
5632
- const { connection } = resolved.link;
5676
+ const { connection, record } = resolved.link;
5633
5677
  const agentId = optionalString(args["agentId"]);
5634
5678
  const workspaceId = optionalString(args["workspaceId"]);
5635
- return this.#aggregate(connection, "agent.sessions.list", {
5679
+ const params = {
5636
5680
  ...agentId === void 0 ? {} : { agentId },
5637
5681
  ...workspaceId === void 0 ? {} : { workspaceId }
5638
- }, (page) => ({
5639
- ...arrayOf(page["items"]),
5640
- nextCursor: page["nextCursor"]
5641
- }), { limit: aggregateLimit(args["limit"]) });
5682
+ };
5683
+ const binding = {
5684
+ method: "agent.sessions.list",
5685
+ deviceId: record.deviceId,
5686
+ params
5687
+ };
5688
+ const resume = this.#listResume(args["cursor"], binding);
5689
+ const page = await this.#aggregate(connection, "agent.sessions.list", params, (result) => ({
5690
+ ...arrayOf(result["items"]),
5691
+ nextCursor: result["nextCursor"]
5692
+ }), {
5693
+ limit: aggregateLimit(args["limit"]),
5694
+ ...resume === void 0 ? {} : { resume }
5695
+ });
5696
+ return this.#listPage(page, binding);
5642
5697
  }
5643
5698
  });
5644
5699
  }
@@ -6061,7 +6116,7 @@ var CherryToolEngine = class {
6061
6116
  #eventsTool() {
6062
6117
  return defineCherryTool({
6063
6118
  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.",
6119
+ 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
6120
  parameters: {
6066
6121
  type: "object",
6067
6122
  properties: {
@@ -6419,7 +6474,7 @@ var CherryToolEngine = class {
6419
6474
  #interactionsTool() {
6420
6475
  return defineCherryTool({
6421
6476
  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`.",
6477
+ 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
6478
  parameters: {
6424
6479
  type: "object",
6425
6480
  properties: {
@@ -6440,7 +6495,8 @@ var CherryToolEngine = class {
6440
6495
  minimum: 1,
6441
6496
  maximum: MAX_AGGREGATE_LIMIT,
6442
6497
  description: "Maximum number of interactions to return in total across pages (default 100)."
6443
- }
6498
+ },
6499
+ cursor: LIST_CURSOR_PARAMETER
6444
6500
  },
6445
6501
  required: ["device", "sessionId"],
6446
6502
  additionalProperties: false
@@ -6450,12 +6506,20 @@ var CherryToolEngine = class {
6450
6506
  if (resolved.link === void 0) return resolved.verdict;
6451
6507
  const { connection, record } = resolved.link;
6452
6508
  const sessionId = requiredString(args, "sessionId");
6509
+ const params = { sessionId };
6510
+ const binding = {
6511
+ method: "agent.interactions.list",
6512
+ deviceId: record.deviceId,
6513
+ params
6514
+ };
6515
+ const resume = this.#listResume(args["cursor"], binding);
6453
6516
  await this.#ensureSubscription(record.deviceId, sessionId);
6454
- const page = await this.#aggregate(connection, "agent.interactions.list", { sessionId }, (result) => ({
6517
+ const page = await this.#aggregate(connection, "agent.interactions.list", params, (result) => ({
6455
6518
  ...arrayOf(result["items"]),
6456
6519
  nextCursor: result["nextCursor"]
6457
6520
  }), {
6458
6521
  limit: aggregateLimit(args["limit"]),
6522
+ ...resume === void 0 ? {} : { resume },
6459
6523
  safetyBound: MAX_AGGREGATE_LIMIT,
6460
6524
  maxPerPage: INTERACTIONS_LIST_PAGE_CLAMP
6461
6525
  });
@@ -6463,9 +6527,8 @@ var CherryToolEngine = class {
6463
6527
  const items = page.items.filter((item) => includeAnswered || item["status"] === "pending").map((item) => this.#interactionView(item));
6464
6528
  for (const item of page.items) this.#rememberInteraction(record.deviceId, sessionId, item);
6465
6529
  return {
6466
- items,
6467
- truncated: page.truncated,
6468
- ...page.truncationReason === void 0 ? {} : { truncationReason: page.truncationReason }
6530
+ ...this.#listPage(page, binding),
6531
+ items
6469
6532
  };
6470
6533
  }
6471
6534
  });
@@ -8187,6 +8250,69 @@ function parseMessageResume(value) {
8187
8250
  return refuse();
8188
8251
  }
8189
8252
  /**
8253
+ * The LIST face's stop point (issue #65): a self-describing token that the SAME
8254
+ * tool accepts back, bound to the query that produced it.
8255
+ *
8256
+ * The message face wraps the peer position in `cursor:` / `after:` and is done,
8257
+ * because a message walk is scoped by its session. A list walk is scoped by
8258
+ * (method, device, filters), and the peer position is opaque: a position from
8259
+ * another query is a perfectly valid number that reads the WRONG slice with no
8260
+ * error anywhere. So this token carries a fingerprint of the binding and a
8261
+ * mismatched replay is refused HERE — before any RPC.
8262
+ */
8263
+ const LIST_RESUME_PREFIX = "cursor:";
8264
+ /** The `cursor` parameter every list tool shares (issue #65). */
8265
+ const LIST_CURSOR_PARAMETER = {
8266
+ type: "string",
8267
+ 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."
8268
+ };
8269
+ /** Canonical, key-sorted rendering of the caller's filters (undefined keys dropped). */
8270
+ function listParamsFingerprint(params) {
8271
+ const entries = Object.keys(params).sort().filter((key) => params[key] !== void 0).map((key) => [key, params[key]]);
8272
+ return createHash("sha256").update(JSON.stringify(entries)).digest("hex").slice(0, 16);
8273
+ }
8274
+ function encodeListResumeCursor(binding, peerPosition) {
8275
+ const payload = {
8276
+ v: 1,
8277
+ m: binding.method,
8278
+ d: binding.deviceId,
8279
+ h: listParamsFingerprint(binding.params),
8280
+ p: peerPosition
8281
+ };
8282
+ return LIST_RESUME_PREFIX + Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
8283
+ }
8284
+ /**
8285
+ * Decode a list token, refusing anything that is not one this tool emitted for
8286
+ * THIS binding. The refusal names the dimension that differs (tool / device /
8287
+ * filters) and never echoes the peer position, which is peer-supplied data.
8288
+ *
8289
+ * An `after:` token is refused by the prefix check: list walks have no anchor
8290
+ * form (they never trim a page, so a peer position always exists for a stop the
8291
+ * peer reported).
8292
+ */
8293
+ function parseListResumeCursor(value, binding) {
8294
+ const refuse = (why) => {
8295
+ throw new Error(`cursor ${why}; omit \`cursor\` to read from the start, or replay the token with the same tool, device and filters.`);
8296
+ };
8297
+ if (typeof value !== "string" || !value.startsWith(LIST_RESUME_PREFIX)) refuse("must be a `cursor:` token this tool emitted");
8298
+ const raw = value.slice(7);
8299
+ let decoded;
8300
+ try {
8301
+ decoded = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
8302
+ } catch {
8303
+ refuse("is not a token this tool emitted");
8304
+ }
8305
+ if (typeof decoded !== "object" || decoded === null) refuse("is not a token this tool emitted");
8306
+ const payload = decoded;
8307
+ if (payload["v"] !== 1 || typeof payload["m"] !== "string" || typeof payload["d"] !== "string" || typeof payload["h"] !== "string" || typeof payload["p"] !== "string") refuse("is not a token this tool emitted");
8308
+ if (payload["m"] !== binding.method) refuse("was issued for a different tool");
8309
+ if (payload["d"] !== binding.deviceId) refuse("was issued for a different device");
8310
+ if (payload["h"] !== listParamsFingerprint(binding.params)) refuse("was issued for a different filter set");
8311
+ const peerPosition = payload["p"];
8312
+ if (mangledRemainder(peerPosition)) refuse("is not a token this tool emitted");
8313
+ return peerPosition;
8314
+ }
8315
+ /**
8190
8316
  * The assistant text a terminal aggregate reports: the awaited RUN's assistant
8191
8317
  * message text parts, joined.
8192
8318
  *
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.6",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "packageManager": "npm@10.9.9",
package/src/tools.ts CHANGED
@@ -2082,6 +2082,31 @@ class CherryToolEngine {
2082
2082
  }
2083
2083
  }
2084
2084
 
2085
+ /** Decode one list `cursor` argument, or undefined when none was given. */
2086
+ #listResume(raw: unknown, binding: ListResumeBinding): { kind: 'cursor'; cursor: string } | undefined {
2087
+ if (raw === undefined || raw === null) return undefined
2088
+ return { kind: 'cursor', cursor: parseListResumeCursor(raw, binding) }
2089
+ }
2090
+
2091
+ /**
2092
+ * The public shape of a list walk's result: items, the stop verdict, and — only
2093
+ * when the walk really stopped at a peer position — the RESUMABLE token.
2094
+ *
2095
+ * The peer's own position never leaves this method: it is opaque, unusable as
2096
+ * an input on its own, and exposing it invited callers to replay it (issue
2097
+ * #65). What a caller can act on is a token, so that is what is emitted.
2098
+ */
2099
+ #listPage(page: CherryPage<Record<string, unknown>>, binding: ListResumeBinding): Record<string, unknown> {
2100
+ return {
2101
+ items: page.items,
2102
+ truncated: page.truncated,
2103
+ ...(page.truncationReason === undefined ? {} : { truncationReason: page.truncationReason }),
2104
+ ...(page.truncated && page.nextCursor !== undefined
2105
+ ? { resumeCursor: encodeListResumeCursor(binding, page.nextCursor) }
2106
+ : {}),
2107
+ }
2108
+ }
2109
+
2085
2110
  // -------------------------------------------------------------------------
2086
2111
  // ① cherry_devices
2087
2112
  // -------------------------------------------------------------------------
@@ -2179,7 +2204,11 @@ class CherryToolEngine {
2179
2204
  name: 'cherry_agents',
2180
2205
  description:
2181
2206
  '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.',
2207
+ 'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
2208
+ 'A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran ' +
2209
+ 'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). A truncated ' +
2210
+ 'answer also carries `resumeCursor`: replay it as `cursor` with the same tool, device and ' +
2211
+ 'filters to continue from that stop point (`limit` then bounds the next answer).',
2183
2212
  parameters: {
2184
2213
  type: 'object',
2185
2214
  properties: {
@@ -2190,6 +2219,7 @@ class CherryToolEngine {
2190
2219
  maximum: MAX_AGGREGATE_LIMIT,
2191
2220
  description: 'Maximum number of agents to return in total across pages (default 100).',
2192
2221
  },
2222
+ cursor: LIST_CURSOR_PARAMETER,
2193
2223
  },
2194
2224
  required: ['device'],
2195
2225
  additionalProperties: false,
@@ -2197,14 +2227,17 @@ class CherryToolEngine {
2197
2227
  execute: async (args) => {
2198
2228
  const resolved = this.#linkOr(requiredString(args, 'device'))
2199
2229
  if (resolved.link === undefined) return resolved.verdict
2200
- const { connection } = resolved.link
2201
- return this.#aggregate(
2230
+ const { connection, record } = resolved.link
2231
+ const binding: ListResumeBinding = { method: 'agent.agents.list', deviceId: record.deviceId, params: {} }
2232
+ const resume = this.#listResume(args['cursor'], binding)
2233
+ const page = await this.#aggregate(
2202
2234
  connection,
2203
2235
  'agent.agents.list',
2204
2236
  {},
2205
- (page) => ({ ...arrayOf(page['items']), nextCursor: page['nextCursor'] }),
2206
- { limit: aggregateLimit(args['limit']) },
2237
+ (result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
2238
+ { limit: aggregateLimit(args['limit']), ...(resume === undefined ? {} : { resume }) },
2207
2239
  )
2240
+ return this.#listPage(page, binding)
2208
2241
  },
2209
2242
  })
2210
2243
  }
@@ -2215,7 +2248,9 @@ class CherryToolEngine {
2215
2248
  description:
2216
2249
  'List an agent\'s REGISTERED workspaces, plus whether a system workspace exists. ' +
2217
2250
  '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.',
2251
+ 'is only observable by creating a session in it and reading it back. A truncated ' +
2252
+ 'answer (`truncated` + `truncationReason`) stopped before the peer ran out and carries ' +
2253
+ '`resumeCursor`: replay it as `cursor` with the same filters to continue.',
2219
2254
  parameters: {
2220
2255
  type: 'object',
2221
2256
  properties: {
@@ -2227,6 +2262,7 @@ class CherryToolEngine {
2227
2262
  maximum: MAX_AGGREGATE_LIMIT,
2228
2263
  description: 'Maximum number of workspaces to return in total across pages (default 100).',
2229
2264
  },
2265
+ cursor: LIST_CURSOR_PARAMETER,
2230
2266
  },
2231
2267
  required: ['device', 'agentId'],
2232
2268
  additionalProperties: false,
@@ -2234,27 +2270,25 @@ class CherryToolEngine {
2234
2270
  execute: async (args) => {
2235
2271
  const resolved = this.#linkOr(requiredString(args, 'device'))
2236
2272
  if (resolved.link === undefined) return resolved.verdict
2237
- const { connection } = resolved.link
2273
+ const { connection, record } = resolved.link
2238
2274
  const agentId = requiredString(args, 'agentId')
2275
+ const params = { agentId }
2276
+ const binding: ListResumeBinding = { method: 'agent.workspaces.list', deviceId: record.deviceId, params }
2277
+ const resume = this.#listResume(args['cursor'], binding)
2239
2278
  let systemWorkspace = false
2240
2279
  const page = await this.#aggregate(
2241
2280
  connection,
2242
2281
  'agent.workspaces.list',
2243
- { agentId },
2282
+ params,
2244
2283
  (result) => {
2245
2284
  // The flag rides on every page; the last page read wins, and the
2246
2285
  // real peer always sends it.
2247
2286
  if (typeof result['systemWorkspace'] === 'boolean') systemWorkspace = result['systemWorkspace']
2248
2287
  return { ...arrayOf(result['items']), nextCursor: result['nextCursor'] }
2249
2288
  },
2250
- { limit: aggregateLimit(args['limit']) },
2289
+ { limit: aggregateLimit(args['limit']), ...(resume === undefined ? {} : { resume }) },
2251
2290
  )
2252
- return {
2253
- items: page.items,
2254
- systemWorkspace,
2255
- truncated: page.truncated,
2256
- ...(page.truncationReason === undefined ? {} : { truncationReason: page.truncationReason }),
2257
- }
2291
+ return { ...this.#listPage(page, binding), systemWorkspace }
2258
2292
  },
2259
2293
  })
2260
2294
  }
@@ -2265,7 +2299,11 @@ class CherryToolEngine {
2265
2299
  description:
2266
2300
  'List an agent\'s sessions, optionally filtered by workspace. The `agentId` and ' +
2267
2301
  '`workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds ' +
2268
- 'the TOTAL across pages (default 100, max 1000) and is aggregated internally.',
2302
+ 'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
2303
+ 'A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran ' +
2304
+ 'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). A truncated ' +
2305
+ 'answer also carries `resumeCursor`: replay it as `cursor` with the same tool, device and ' +
2306
+ 'filters to continue from that stop point (`limit` then bounds the next answer).',
2269
2307
  parameters: {
2270
2308
  type: 'object',
2271
2309
  properties: {
@@ -2278,6 +2316,7 @@ class CherryToolEngine {
2278
2316
  maximum: MAX_AGGREGATE_LIMIT,
2279
2317
  description: 'Maximum number of sessions to return in total across pages (default 100).',
2280
2318
  },
2319
+ cursor: LIST_CURSOR_PARAMETER,
2281
2320
  },
2282
2321
  required: ['device'],
2283
2322
  additionalProperties: false,
@@ -2285,21 +2324,27 @@ class CherryToolEngine {
2285
2324
  execute: async (args) => {
2286
2325
  const resolved = this.#linkOr(requiredString(args, 'device'))
2287
2326
  if (resolved.link === undefined) return resolved.verdict
2288
- const { connection } = resolved.link
2327
+ const { connection, record } = resolved.link
2289
2328
  const agentId = optionalString(args['agentId'])
2290
2329
  const workspaceId = optionalString(args['workspaceId'])
2291
2330
  // Filter passthrough: the params carry the caller's filters verbatim,
2292
- // and `#aggregate` adds only `limit`/`cursor`.
2293
- return this.#aggregate(
2331
+ // and `#aggregate` adds only `limit`/`cursor`. The SAME object is the
2332
+ // token's binding, so a replay with other filters cannot read another
2333
+ // query's page position.
2334
+ const params = {
2335
+ ...(agentId === undefined ? {} : { agentId }),
2336
+ ...(workspaceId === undefined ? {} : { workspaceId }),
2337
+ }
2338
+ const binding: ListResumeBinding = { method: 'agent.sessions.list', deviceId: record.deviceId, params }
2339
+ const resume = this.#listResume(args['cursor'], binding)
2340
+ const page = await this.#aggregate(
2294
2341
  connection,
2295
2342
  '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']) },
2343
+ params,
2344
+ (result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
2345
+ { limit: aggregateLimit(args['limit']), ...(resume === undefined ? {} : { resume }) },
2302
2346
  )
2347
+ return this.#listPage(page, binding)
2303
2348
  },
2304
2349
  })
2305
2350
  }
@@ -3064,7 +3109,9 @@ class CherryToolEngine {
3064
3109
  '`cherry_send`/`cherry_wait` report on an empty window. Once the window DISCARDS events, ' +
3065
3110
  'the boundary it reports is not: that boundary and everything below it answer a structured ' +
3066
3111
  '`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.',
3112
+ 'the baseline with `cherry_read` and resume from that instead. The answer is ' +
3113
+ '`{events, nextCursor}`; pass that `nextCursor` back as `cursor` on the next call to ' +
3114
+ 'continue from it.',
3068
3115
  parameters: {
3069
3116
  type: 'object',
3070
3117
  properties: {
@@ -3694,7 +3741,9 @@ class CherryToolEngine {
3694
3741
  'answer. The peer serves the full four-state set (pending/approved/denied/expired) — the ' +
3695
3742
  'pending filter is applied here. Each entry carries its `kind` (question|decision) and a ' +
3696
3743
  'summary; answer questions with cherry_respond\'s `response` form and decisions with ' +
3697
- '`decision`.',
3744
+ '`decision`. A truncated answer (`truncated` + `truncationReason`) stopped before the ' +
3745
+ 'peer ran out and carries `resumeCursor`: replay it as `cursor` with the same filters to ' +
3746
+ 'continue.',
3698
3747
  parameters: {
3699
3748
  type: 'object',
3700
3749
  properties: {
@@ -3712,6 +3761,7 @@ class CherryToolEngine {
3712
3761
  maximum: MAX_AGGREGATE_LIMIT,
3713
3762
  description: 'Maximum number of interactions to return in total across pages (default 100).',
3714
3763
  },
3764
+ cursor: LIST_CURSOR_PARAMETER,
3715
3765
  },
3716
3766
  required: ['device', 'sessionId'],
3717
3767
  additionalProperties: false,
@@ -3721,6 +3771,9 @@ class CherryToolEngine {
3721
3771
  if (resolved.link === undefined) return resolved.verdict
3722
3772
  const { connection, record } = resolved.link
3723
3773
  const sessionId = requiredString(args, 'sessionId')
3774
+ const params = { sessionId }
3775
+ const binding: ListResumeBinding = { method: 'agent.interactions.list', deviceId: record.deviceId, params }
3776
+ const resume = this.#listResume(args['cursor'], binding)
3724
3777
 
3725
3778
  await this.#ensureSubscription(record.deviceId, sessionId)
3726
3779
 
@@ -3730,10 +3783,11 @@ class CherryToolEngine {
3730
3783
  const page = await this.#aggregate(
3731
3784
  connection,
3732
3785
  'agent.interactions.list',
3733
- { sessionId },
3786
+ params,
3734
3787
  (result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
3735
3788
  {
3736
3789
  limit: aggregateLimit(args['limit']),
3790
+ ...(resume === undefined ? {} : { resume }),
3737
3791
  safetyBound: MAX_AGGREGATE_LIMIT,
3738
3792
  // An interaction record carries an inline input AND an inline
3739
3793
  // summary, the fattest item of the plain lists.
@@ -3752,11 +3806,7 @@ class CherryToolEngine {
3752
3806
  // docblock): priming from it is what makes every listed card answerable.
3753
3807
  for (const item of page.items) this.#rememberInteraction(record.deviceId, sessionId, item)
3754
3808
 
3755
- return {
3756
- items,
3757
- truncated: page.truncated,
3758
- ...(page.truncationReason === undefined ? {} : { truncationReason: page.truncationReason }),
3759
- }
3809
+ return { ...this.#listPage(page, binding), items }
3760
3810
  },
3761
3811
  })
3762
3812
  }
@@ -6187,6 +6237,100 @@ function parseMessageResume(value: unknown): MessageResume | undefined {
6187
6237
  return refuse()
6188
6238
  }
6189
6239
 
6240
+ /**
6241
+ * The LIST face's stop point (issue #65): a self-describing token that the SAME
6242
+ * tool accepts back, bound to the query that produced it.
6243
+ *
6244
+ * The message face wraps the peer position in `cursor:` / `after:` and is done,
6245
+ * because a message walk is scoped by its session. A list walk is scoped by
6246
+ * (method, device, filters), and the peer position is opaque: a position from
6247
+ * another query is a perfectly valid number that reads the WRONG slice with no
6248
+ * error anywhere. So this token carries a fingerprint of the binding and a
6249
+ * mismatched replay is refused HERE — before any RPC.
6250
+ */
6251
+ const LIST_RESUME_PREFIX = 'cursor:'
6252
+
6253
+ /** The `cursor` parameter every list tool shares (issue #65). */
6254
+ const LIST_CURSOR_PARAMETER = {
6255
+ type: 'string',
6256
+ description:
6257
+ 'A `resumeCursor` a previous TRUNCATED answer of this tool returned: replay it with the SAME ' +
6258
+ 'device and filters to continue from that stop point (`limit` then bounds this answer). A token ' +
6259
+ 'from another tool, device or filter set is refused locally.',
6260
+ } as const
6261
+
6262
+ /** What a list token is valid for. `params` are the CALLER's filters, not the wire page params. */
6263
+ interface ListResumeBinding {
6264
+ method: string
6265
+ deviceId: string
6266
+ params: Record<string, unknown>
6267
+ }
6268
+
6269
+ /** Canonical, key-sorted rendering of the caller's filters (undefined keys dropped). */
6270
+ function listParamsFingerprint(params: Record<string, unknown>): string {
6271
+ const entries = Object.keys(params)
6272
+ .sort()
6273
+ .filter((key) => params[key] !== undefined)
6274
+ .map((key) => [key, params[key]] as const)
6275
+ return createHash('sha256').update(JSON.stringify(entries)).digest('hex').slice(0, 16)
6276
+ }
6277
+
6278
+ function encodeListResumeCursor(binding: ListResumeBinding, peerPosition: string): string {
6279
+ const payload = {
6280
+ v: 1,
6281
+ m: binding.method,
6282
+ d: binding.deviceId,
6283
+ h: listParamsFingerprint(binding.params),
6284
+ p: peerPosition,
6285
+ }
6286
+ return LIST_RESUME_PREFIX + Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
6287
+ }
6288
+
6289
+ /**
6290
+ * Decode a list token, refusing anything that is not one this tool emitted for
6291
+ * THIS binding. The refusal names the dimension that differs (tool / device /
6292
+ * filters) and never echoes the peer position, which is peer-supplied data.
6293
+ *
6294
+ * An `after:` token is refused by the prefix check: list walks have no anchor
6295
+ * form (they never trim a page, so a peer position always exists for a stop the
6296
+ * peer reported).
6297
+ */
6298
+ function parseListResumeCursor(value: unknown, binding: ListResumeBinding): string {
6299
+ const refuse = (why: string): never => {
6300
+ throw new Error(
6301
+ `cursor ${why}; omit \`cursor\` to read from the start, or replay the token with the same tool, ` +
6302
+ 'device and filters.',
6303
+ )
6304
+ }
6305
+ if (typeof value !== 'string' || !value.startsWith(LIST_RESUME_PREFIX)) {
6306
+ refuse('must be a `cursor:` token this tool emitted')
6307
+ }
6308
+ const raw = (value as string).slice(LIST_RESUME_PREFIX.length)
6309
+ let decoded: unknown
6310
+ try {
6311
+ decoded = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'))
6312
+ } catch {
6313
+ refuse('is not a token this tool emitted')
6314
+ }
6315
+ if (typeof decoded !== 'object' || decoded === null) refuse('is not a token this tool emitted')
6316
+ const payload = decoded as Record<string, unknown>
6317
+ if (
6318
+ payload['v'] !== 1 ||
6319
+ typeof payload['m'] !== 'string' ||
6320
+ typeof payload['d'] !== 'string' ||
6321
+ typeof payload['h'] !== 'string' ||
6322
+ typeof payload['p'] !== 'string'
6323
+ ) {
6324
+ refuse('is not a token this tool emitted')
6325
+ }
6326
+ if (payload['m'] !== binding.method) refuse('was issued for a different tool')
6327
+ if (payload['d'] !== binding.deviceId) refuse('was issued for a different device')
6328
+ if (payload['h'] !== listParamsFingerprint(binding.params)) refuse('was issued for a different filter set')
6329
+ const peerPosition = payload['p'] as string
6330
+ if (mangledRemainder(peerPosition)) refuse('is not a token this tool emitted')
6331
+ return peerPosition
6332
+ }
6333
+
6190
6334
  /**
6191
6335
  * The assistant text a terminal aggregate reports: the awaited RUN's assistant
6192
6336
  * message text parts, joined.