dsh-cherry-bridge 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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'
@@ -390,9 +391,15 @@ export interface CherryReadSnapshot {
390
391
  *
391
392
  * Present exactly when `truncated` is true and a stop point can be named
392
393
  * honestly; because the walk runs newest-first, it marks OLDER messages as
393
- * still unread — the only direction a truncation leaves behind. It is ABSENT
394
- * when the walk reached the peer's last page having returned everything it
395
- * collected: there is nothing older left to name, and no marker is invented.
394
+ * still unread — the only direction a truncation leaves behind.
395
+ *
396
+ * IT IS ABSENT IN TWO OPPOSITE CASES, and `truncated` is what tells them
397
+ * apart: a COMPLETE walk (`truncated: false` — nothing older left), and a
398
+ * TRUNCATED walk whose stop point could not be named (`truncated: true` plus
399
+ * `truncationReason` — older messages still unread). The absence therefore
400
+ * never means "nothing older left" on its own. WHICH stops can be named is
401
+ * decided in ONE place ({@link messageResumeToken}); the refusal cases are not
402
+ * restated here.
396
403
  *
397
404
  * WHAT REACHES THE UNREAD MESSAGES DEPENDS ON THE REASON. A `'limit'` stop was
398
405
  * the caller's own ceiling: raise `limit`, or resume with this token to walk
@@ -402,6 +409,12 @@ export interface CherryReadSnapshot {
402
409
  * messages are reached. From the resume point `limit` bounds how many messages
403
410
  * ONE answer carries, so a long history is walked in successive answers, each
404
411
  * carrying the next stop point.
412
+ *
413
+ * WITH NO TOKEN NAMED, the reason is all the caller has: a `'limit'` stop is
414
+ * still reachable by widening `limit`; a `'safety-bound'` stop is the internal
415
+ * circuit breaker, which no wider `limit` moves; and an `'aggregate-bytes'`
416
+ * stop has no remedy in that answer at all — the budget, not the ceiling, is
417
+ * what stopped it. The loss is stated rather than repaired.
405
418
  */
406
419
  messagesResumeCursor?: string
407
420
  }
@@ -446,10 +459,19 @@ export interface CherryMessageWalkVerdict {
446
459
  * `cursor:<peer position>` when the whole collected window was returned;
447
460
  * `after:<last returned message id>` when the byte stop happened INSIDE that
448
461
  * window, because the peer's position sits past messages this walk trimmed
449
- * and `AgentMessage` carries no `createdAt` to synthesise a closer one. The
450
- * token is OMITTED when nothing older is left to name (the walk reached the
451
- * peer's last page having returned everything it collected) — absence says
452
- * "no marker", `messagesTruncated` still says the walk was cut.
462
+ * and `AgentMessage` carries no `createdAt` to synthesise a closer one.
463
+ *
464
+ * IT IS ABSENT IN TWO OPPOSITE CASES, and `messagesTruncated` is what tells
465
+ * them apart: a COMPLETE walk (`messagesTruncated` absent or false — nothing
466
+ * older left), and a TRUNCATED walk whose stop point could not be named
467
+ * (`messagesTruncated: true` plus `messagesTruncationReason` — older history
468
+ * still unread). The absence never means "nothing older left" on its own.
469
+ * WHICH stops can be named is decided in ONE place
470
+ * ({@link messageResumeToken}) and is the same rule as
471
+ * {@link CherryReadSnapshot.messagesResumeCursor}. THE REMEDY IS NOT THE SAME
472
+ * ON THIS FACE: a `'limit'` stop here is the walk's own fixed message
473
+ * allowance — these tools take no `limit` argument — so the way further is
474
+ * `cherry_read`, which also carries no deadline.
453
475
  */
454
476
  messagesResumeCursor?: string
455
477
  }
@@ -2097,13 +2119,33 @@ class CherryToolEngine {
2097
2119
  * #65). What a caller can act on is a token, so that is what is emitted.
2098
2120
  */
2099
2121
  #listPage(page: CherryPage<Record<string, unknown>>, binding: ListResumeBinding): Record<string, unknown> {
2122
+ // A position this face would REFUSE is not a position it may name. The
2123
+ // round-trip symmetry (a token a tool emits is a token that tool accepts) is
2124
+ // the whole point of the token, so a peer answering `nextCursor: ''`, one
2125
+ // carrying whitespace/control characters, or one shaped like our retired v1
2126
+ // token gets NO token. The absence has TWO opposite cases — a COMPLETE walk,
2127
+ // and a TRUNCATED one whose stop could not be named — and only `truncated`
2128
+ // tells them apart. The remedy follows the REASON: a `'limit'` stop answers
2129
+ // to a wider `limit`; a `'safety-bound'` stop does not, because it is an
2130
+ // internal circuit breaker a caller cannot move. The rule is NOT restated
2131
+ // here or in the message face:
2132
+ // `nameablePeerPosition` is the one place it lives, so the emitters and
2133
+ // their parsers cannot drift (issue #85).
2134
+ const position = nameablePeerPosition(optionalString(page.nextCursor))
2135
+ // The same refusal one dimension further: an encoded token over
2136
+ // {@link MAX_RESUME_TOKEN_CHARS} is one this face would refuse on replay, so
2137
+ // it is not named ({@link withinResumeTokenCeiling}, issue #76). Judged only
2138
+ // on the arm that NAMES it — a complete walk has nothing to name, so it must
2139
+ // not pay for the encoding.
2140
+ const token =
2141
+ !page.truncated || position === undefined
2142
+ ? undefined
2143
+ : withinResumeTokenCeiling(encodeListResumeCursor(binding, position))
2100
2144
  return {
2101
2145
  items: page.items,
2102
2146
  truncated: page.truncated,
2103
2147
  ...(page.truncationReason === undefined ? {} : { truncationReason: page.truncationReason }),
2104
- ...(page.truncated && page.nextCursor !== undefined
2105
- ? { resumeCursor: encodeListResumeCursor(binding, page.nextCursor) }
2106
- : {}),
2148
+ ...(token === undefined ? {} : { resumeCursor: token }),
2107
2149
  }
2108
2150
  }
2109
2151
 
@@ -2206,9 +2248,10 @@ class CherryToolEngine {
2206
2248
  'List the Cherry Studio agents available on a device. Paginated: `limit` bounds ' +
2207
2249
  'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
2208
2250
  '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).',
2251
+ 'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the ' +
2252
+ 'stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same ' +
2253
+ 'tool, device and filters to continue from that stop point (`limit` then bounds the next ' +
2254
+ 'answer).',
2212
2255
  parameters: {
2213
2256
  type: 'object',
2214
2257
  properties: {
@@ -2249,8 +2292,9 @@ class CherryToolEngine {
2249
2292
  'List an agent\'s REGISTERED workspaces, plus whether a system workspace exists. ' +
2250
2293
  'The system workspace has no enumerable id: it is not in `items`, and its identity ' +
2251
2294
  '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.',
2295
+ 'answer (`truncated` + `truncationReason`) stopped before the peer ran out; when the stop ' +
2296
+ 'point can be named it carries `resumeCursor` — replay it as `cursor` with the same ' +
2297
+ 'filters to continue.',
2254
2298
  parameters: {
2255
2299
  type: 'object',
2256
2300
  properties: {
@@ -2301,9 +2345,10 @@ class CherryToolEngine {
2301
2345
  '`workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds ' +
2302
2346
  'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
2303
2347
  '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).',
2348
+ 'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the ' +
2349
+ 'stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same ' +
2350
+ 'tool, device and filters to continue from that stop point (`limit` then bounds the next ' +
2351
+ 'answer).',
2307
2352
  parameters: {
2308
2353
  type: 'object',
2309
2354
  properties: {
@@ -2373,8 +2418,9 @@ class CherryToolEngine {
2373
2418
  '"aggregate-bytes"` means the walk stopped because the result reached its size limit, ' +
2374
2419
  'not because `limit` was reached — raising `limit` does NOT reach the messages left out ' +
2375
2420
  '(it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE ' +
2376
- 'REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` whenever a ' +
2377
- 'stop point can be named honestly (it is absent when the walk reached the peer\'s last page), and ' +
2421
+ 'REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` when the ' +
2422
+ 'stop point can be named — absent when it cannot, e.g. an empty or malformed position, a ' +
2423
+ 'token past the length ceiling, or no anchor id — and ' +
2378
2424
  'passing it back as `messagesResumeCursor` continues the walk from there (`limit` then ' +
2379
2425
  'bounds how many messages are returned FROM that point). The two token forms are ' +
2380
2426
  '`"cursor:<peer position>"` (the walk consumed its whole page window) and ' +
@@ -2396,15 +2442,18 @@ class CherryToolEngine {
2396
2442
  description:
2397
2443
  'Maximum number of messages to return in total across pages (default 100). Distinct from ' +
2398
2444
  'the output budget: the answer may stop earlier with `truncationReason: "aggregate-bytes"`, ' +
2399
- 'which a larger `limit` does not extend — the resume token is how older messages are reached.',
2445
+ 'which a larger `limit` does not extend — the resume token, when one is returned, is how ' +
2446
+ 'older messages are reached.',
2400
2447
  },
2401
2448
  messagesResumeCursor: {
2402
2449
  type: 'string',
2450
+ maxLength: MAX_RESUME_TOKEN_CHARS,
2403
2451
  description:
2404
2452
  'A `messagesResumeCursor` a previous truncated read (this tool, or cherry_send/cherry_wait) ' +
2405
2453
  'returned: `"cursor:<peer position>"` or `"after:<messageId>"`. Continues the walk at that ' +
2406
2454
  'stop point instead of at the newest message; `limit` bounds what comes back from there. ' +
2407
- 'Omit it to read from the newest message.',
2455
+ 'Omit it to read from the newest message. A token this tool did not emit is refused locally, ' +
2456
+ `and ${MAX_RESUME_TOKEN_CHARS} characters is the ceiling.`,
2408
2457
  },
2409
2458
  },
2410
2459
  required: ['device', 'sessionId'],
@@ -2575,11 +2624,30 @@ class CherryToolEngine {
2575
2624
  ...(truncationReason === undefined ? {} : { truncationReason }),
2576
2625
  // Only a TRUNCATED walk has unread history, and the token then marks the
2577
2626
  // stop point (OLDER messages remain unread) —
2578
- // {@link CherryReadSnapshot.messagesResumeCursor}. When the walk's own
2579
- // verdict names no stop (it reached the peer's last page AND returned
2580
- // every message it collected) the key is OMITTED rather than emitted
2581
- // undefined: the absence says "nothing older left", the `truncated` flag
2582
- // still says whether the walk was cut.
2627
+ // {@link CherryReadSnapshot.messagesResumeCursor}. Whether that stop can
2628
+ // be NAMED is not the walk's verdict: it depends on the peer's payload,
2629
+ // and {@link messageResumeToken} is the ONE place that judges it — content
2630
+ // through `nameableRemainder`, length through `withinResumeTokenCeiling`,
2631
+ // and, for a POSITION only, the retired-v1 check
2632
+ // ({@link nameablePeerPosition}; the anchor arm deliberately skips it,
2633
+ // exactly as `parseMessageResume`'s `after:` branch does). The refusal
2634
+ // cases are therefore not enumerated again here.
2635
+ //
2636
+ // The key is OMITTED rather than emitted `undefined`, and its absence only
2637
+ // means something once read together with `truncated`: the two cases have
2638
+ // opposite meanings —
2639
+ //
2640
+ // - a COMPLETE walk (nothing older left): `truncated: false`, no token —
2641
+ // the absence really does say "nothing older left";
2642
+ // - a TRUNCATED walk whose stop point cannot be named: `truncated: true`
2643
+ // plus a `truncationReason`, no token — OLDER MESSAGES REMAIN UNREAD,
2644
+ // and the absence never means they ran out. The remedy follows the
2645
+ // REASON: only a `limit` stop answers to a wider `limit`; a
2646
+ // `safety-bound` stop is the internal circuit breaker, and an
2647
+ // `aggregate-bytes` stop is this answer's own output budget, which a
2648
+ // wider `limit` does not move (more round trips, same budget). A
2649
+ // `deadline` stop is a WAIT face's reason — `cherry_read` carries no
2650
+ // deadline — so it is never this literal's.
2583
2651
  ...(truncated && resumeToken !== undefined ? { messagesResumeCursor: resumeToken } : {}),
2584
2652
  }
2585
2653
  }
@@ -3742,8 +3810,8 @@ class CherryToolEngine {
3742
3810
  'pending filter is applied here. Each entry carries its `kind` (question|decision) and a ' +
3743
3811
  'summary; answer questions with cherry_respond\'s `response` form and decisions with ' +
3744
3812
  '`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.',
3813
+ 'peer ran out; when the stop point can be named it carries `resumeCursor` — replay it as ' +
3814
+ '`cursor` from the same `sessionId` to continue.',
3747
3815
  parameters: {
3748
3816
  type: 'object',
3749
3817
  properties: {
@@ -5536,9 +5604,11 @@ class CherryToolEngine {
5536
5604
 
5537
5605
  /**
5538
5606
  * The timeout result: NOT a failure. Partial content read so far +, whenever a
5539
- * stop point can be named honestly, a `messagesResumeCursor` token marking where
5540
- * the unread history begins — absent when the walk reached the peer's last page
5541
- * or its last returned message carried no id.
5607
+ * stop point can be named honestly, a `messagesResumeCursor` token marking
5608
+ * where the unread history begins. The token is missing in TWO OPPOSITE cases
5609
+ * — a COMPLETE walk, or a TRUNCATED one whose stop point could not be named —
5610
+ * and `messagesTruncated` is what tells them apart; see
5611
+ * {@link CherryMessageWalkVerdict.messagesResumeCursor}.
5542
5612
  *
5543
5613
  * The spec's own sentence (§3.6, 2026-10-09 design: 「超时不是失败:返回已收到
5544
5614
  * 的部分内容 + 当前游标,agent 可续查」) is a statement of that contract in
@@ -5813,9 +5883,10 @@ class CherryToolEngine {
5813
5883
  * `messagesResumeCursor`: the read scans from the newest end to the anchor
5814
5884
  * and continues strictly after it.
5815
5885
  *
5816
- * The token is absent only when there is nothing older to point at (the walk
5817
- * reached the peer's last page having returned everything it collected).
5818
- * See {@link CherryMessageWalkVerdict.messagesResumeCursor}.
5886
+ * The token is missing in TWO OPPOSITE cases — a COMPLETE walk, and a
5887
+ * TRUNCATED one whose stop point could not be named — and `messagesTruncated`
5888
+ * is what tells them apart; absence never means "nothing older left" on its
5889
+ * own. See {@link CherryMessageWalkVerdict.messagesResumeCursor}.
5819
5890
  */
5820
5891
  async #readTerminalMessages(
5821
5892
  connection: DeviceConnection,
@@ -6085,28 +6156,111 @@ function parseQuestionInput(input: unknown): string[] | undefined {
6085
6156
  * invented: a wrong anchor is worse than no anchor, and `truncated` still says
6086
6157
  * the walk was cut. Otherwise the whole collected window was consumed, and the
6087
6158
  * peer's page position is exactly where the unread history begins
6088
- * (`cursor:<pos>`) — unless the peer answered an EMPTY position, which names
6089
- * nothing and is therefore not emitted at all.
6159
+ * (`cursor:<pos>`).
6160
+ *
6161
+ * BOTH arms name a remainder only when the PARSER would accept it
6162
+ * ({@link mangledRemainder}): empty, not equal to its own `.trim()` (which is
6163
+ * leading/trailing whitespace), or carrying a control character anywhere is
6164
+ * refused HERE exactly as `parseMessageResume` refuses it on replay. The
6165
+ * emitter must never mint what the parser refuses — a token this tool will not
6166
+ * take back is worse than none, because the refusal then surfaces at the RESUME
6167
+ * call instead of at the answer that produced it. This is the CONTENT
6168
+ * dimension; {@link withinResumeTokenCeiling} is the sibling LENGTH one. The
6169
+ * `after:` anchor is covered by the same rule, not just the `cursor:` position:
6170
+ * a message id is an `opaqueId` too, and padding is schema-valid, so a padded
6171
+ * id really can reach the anchor.
6172
+ *
6173
+ * EXPORTED FOR ITS OWN TESTS. The LENGTH guard on the `after:` arm cannot be
6174
+ * driven through a tool: an anchor is a message ID, and projecting that message
6175
+ * means asking the peer for `agent.parts.list` with it, whose `messageId` is an
6176
+ * `opaqueId` (256 characters). A peer item carrying an id above the token
6177
+ * ceiling therefore fails its parts walk and is never RETURNED, so no walk
6178
+ * reaches the point of naming it. The length guard on that arm is still
6179
+ * required — the emitter must never mint what the parser refuses, whatever the
6180
+ * peer's cap becomes — and this export is how a unit test pins it
6181
+ * (`tests/unit/resumeTokenEmit.test.ts`). The CONTENT guard on both arms IS
6182
+ * reachable, because `opaqueId` allows padding: `tests/integration/
6183
+ * toolsReadResume.test.ts` ⑭d/⑭e drive it through the tools.
6090
6184
  */
6091
- function messageResumeToken(
6185
+ export function messageResumeToken(
6092
6186
  page: CherryPage<unknown>,
6093
6187
  returned: number,
6094
6188
  lastReturnedId: string | undefined,
6095
6189
  ): string | undefined {
6096
- const anchor = optionalString(lastReturnedId)
6190
+ const anchor = nameableRemainder(optionalString(lastReturnedId))
6097
6191
  // TWO forms of "the walk stopped inside what it was given": it returned fewer
6098
6192
  // items than it collected, or the PEER overshot the requested page and items
6099
6193
  // were left in it ({@link CherryPage.pageOverflow}). Either way the page's own
6100
6194
  // position sits past messages this walk never returned, so only the anchor is
6101
6195
  // exact.
6102
6196
  if (returned < page.items.length || page.pageOverflow === true) {
6103
- return anchor === undefined ? undefined : `${RESUME_ANCHOR_PREFIX}${anchor}`
6197
+ // The ANCHOR takes the content predicate only — NOT the v1 detector, and
6198
+ // that is not an oversight: `parseMessageResume`'s `after:` branch runs
6199
+ // `mangledRemainder` and nothing else, so an id-shaped `after:` remainder is
6200
+ // a token it ACCEPTS back. Applying the detector here would be the mirror
6201
+ // image of this issue — refusing to name a token the parser takes.
6202
+ return anchor === undefined ? undefined : withinResumeTokenCeiling(`${RESUME_ANCHOR_PREFIX}${anchor}`)
6104
6203
  }
6105
- // An empty position is not a position: a peer answering `nextCursor: ''` on a
6106
- // non-terminal page would otherwise make this face emit a bare `cursor:`,
6107
- // which the same face REFUSES on the way back (review P3-4).
6108
- const position = optionalString(page.nextCursor)
6109
- return position === undefined ? undefined : `${RESUME_CURSOR_PREFIX}${position}`
6204
+ // A POSITION takes both: the content predicate, and the retired-format
6205
+ // detector its own parser applies before anything else (issue #85).
6206
+ const position = nameablePeerPosition(optionalString(page.nextCursor))
6207
+ return position === undefined ? undefined : withinResumeTokenCeiling(`${RESUME_CURSOR_PREFIX}${position}`)
6208
+ }
6209
+
6210
+ /**
6211
+ * The CONTENT dimension of "a remainder this bridge may name", or `undefined`
6212
+ * when it must not name one.
6213
+ *
6214
+ * The parsers run `mangledRemainder` on every remainder they read, so one that
6215
+ * fails it is a remainder this tool would refuse on replay: naming it hands the
6216
+ * caller a stop point that can never be used, and the refusal then surfaces at
6217
+ * the RESUME call instead of at the answer that produced it (issues #70, #80).
6218
+ * What it rejects, exactly: empty, not equal to its own `.trim()` (leading/
6219
+ * trailing whitespace), or a control character ANYWHERE — an interior plain space
6220
+ * is legal and round-trips.
6221
+ *
6222
+ * This is the parser's own predicate, not a copy of it, so the two sides cannot
6223
+ * drift into two dialects. The sibling dimension is the LENGTH ceiling, judged
6224
+ * on the FINAL token ({@link withinResumeTokenCeiling}).
6225
+ */
6226
+ function nameableRemainder(value: string | undefined): string | undefined {
6227
+ return value === undefined || mangledRemainder(value) ? undefined : value
6228
+ }
6229
+
6230
+ /**
6231
+ * A remainder this bridge may name AS A PAGE POSITION: {@link nameableRemainder}
6232
+ * plus the one refusal that applies to positions and NOT to anchors.
6233
+ *
6234
+ * `looksLikeV1ListToken` exists because a v1 list token (0.1.6: base64url of
6235
+ * `{v,m,d,h,p}`) lives under `cursor:` and is indistinguishable from a peer
6236
+ * position, so both parsers NAME and refuse it rather than forwarding it to a
6237
+ * desktop that would silently serve its FIRST page. An emitter that wrapped such
6238
+ * a position would mint `cursor:<v1 shape>` and then have its own parser answer
6239
+ * "issued by an older plugin version" about a token this tool had just printed —
6240
+ * the same broken round trip as #76/#80, one detector further out (issue #85).
6241
+ *
6242
+ * The `after:` arm deliberately does NOT use this: `parseMessageResume`'s
6243
+ * `after:` branch never consults the detector, so an `after:` remainder of that
6244
+ * shape is a token it accepts.
6245
+ */
6246
+ function nameablePeerPosition(value: string | undefined): string | undefined {
6247
+ const remainder = nameableRemainder(value)
6248
+ return remainder === undefined || looksLikeV1ListToken(remainder) ? undefined : remainder
6249
+ }
6250
+
6251
+ /**
6252
+ * The token ceiling, enforced on the way OUT as well as in. A token this tool
6253
+ * refuses to replay is worse than no token at all: `truncated` already says the
6254
+ * walk was cut, and the remedy then follows the reason — a `'limit'` stop
6255
+ * answers to a wider `limit` only where the caller owns that ceiling — so a
6256
+ * stop point whose token would push past
6257
+ * {@link MAX_RESUME_TOKEN_CHARS} is simply not named. Measured on the FINAL
6258
+ * string, never on a reconstruction of its length, so a format change cannot
6259
+ * leave the guard behind. Exactly at the ceiling is accepted — it is a length
6260
+ * the parser takes back.
6261
+ */
6262
+ function withinResumeTokenCeiling(token: string): string | undefined {
6263
+ return token.length <= MAX_RESUME_TOKEN_CHARS ? token : undefined
6110
6264
  }
6111
6265
 
6112
6266
  /**
@@ -6213,8 +6367,14 @@ function messageIdOf(raw: Record<string, unknown>): string {
6213
6367
  * `undefined`/`null` means "from the newest message", which is the ordinary
6214
6368
  * read. An empty remainder (`'cursor:'`, `'after:'`) is refused like any other
6215
6369
  * foreign token: an empty anchor could never match a message.
6370
+ *
6371
+ * EXPORTED FOR ITS OWN TESTS — specifically for the round-trip property, which
6372
+ * is the invariant this whole file is built around: every token the emitters
6373
+ * NAME must be one this function ACCEPTS. Pinning that with a probe table is
6374
+ * what turns "we mirrored every refusal" from a promise into a check
6375
+ * (`tests/unit/resumeTokenEmit.test.ts`, issue #85).
6216
6376
  */
6217
- function parseMessageResume(value: unknown): MessageResume | undefined {
6377
+ export function parseMessageResume(value: unknown): MessageResume | undefined {
6218
6378
  if (value === undefined || value === null) return undefined
6219
6379
  const refuse = (): never => {
6220
6380
  throw new Error(
@@ -6224,9 +6384,36 @@ function parseMessageResume(value: unknown): MessageResume | undefined {
6224
6384
  }
6225
6385
  if (typeof value !== 'string') refuse()
6226
6386
  const token = value as string
6387
+ // Issue #74: the ceiling is judged on the WHOLE token, before either form's
6388
+ // prefix — so `cursor:` and `after:` get the same verdict, and no decoding,
6389
+ // scanning or dial happens for a string that cannot be one of ours. It is the
6390
+ // same bound the list face enforces ({@link MAX_RESUME_TOKEN_CHARS}).
6391
+ if (token.length > MAX_RESUME_TOKEN_CHARS) {
6392
+ throw new Error(
6393
+ `cherry_read: messagesResumeCursor is ${token.length} characters long, over the ` +
6394
+ `${MAX_RESUME_TOKEN_CHARS}-character ceiling for a resume token; omit the argument to read from the ` +
6395
+ 'newest message.',
6396
+ )
6397
+ }
6227
6398
  if (token.startsWith(RESUME_CURSOR_PREFIX)) {
6228
6399
  const cursor = token.slice(RESUME_CURSOR_PREFIX.length)
6229
6400
  if (mangledRemainder(cursor)) refuse()
6401
+ // Issue #72, the RELEASED half: a v1 list token (0.1.6 — `cursor:` +
6402
+ // base64url of `{v,m,d,h,p}`) lives under this prefix and its payload is
6403
+ // colon-free base64url, so it arrives here as a perfectly ordinary
6404
+ // position and used to be forwarded to the desktop, which answers a
6405
+ // position it cannot parse by serving its FIRST page. The detector fires
6406
+ // only for a colon-free remainder that base64-decodes to a `{v:1,…}`
6407
+ // object: every real peer position is `<ms>:<id>` (has a colon) or a
6408
+ // numeric offset, so none can be mistaken for one.
6409
+ if (looksLikeV1ListToken(cursor)) {
6410
+ throw new Error(
6411
+ 'cherry_read: messagesResumeCursor is a v1 LIST `resumeCursor` (issued by an older plugin version, ' +
6412
+ 'before list tokens were compact): it resumes a list walk, not a message history, and it can no ' +
6413
+ 'longer be verified. Omit the argument to read from the newest message, or pass the token this ' +
6414
+ 'tool returned.',
6415
+ )
6416
+ }
6230
6417
  return { kind: 'cursor', cursor }
6231
6418
  }
6232
6419
  if (token.startsWith(RESUME_ANCHOR_PREFIX)) {
@@ -6234,6 +6421,21 @@ function parseMessageResume(value: unknown): MessageResume | undefined {
6234
6421
  if (mangledRemainder(messageId)) refuse()
6235
6422
  return { kind: 'anchor', messageId }
6236
6423
  }
6424
+ if (token.startsWith(LIST_RESUME_PREFIX)) {
6425
+ // Issue #72, the other direction: a token the four list tools minted. The
6426
+ // two faces are told apart by PREFIX, never by scanning a position for our
6427
+ // own marker — `cursor:list:20` above is still a message cursor. (The branch
6428
+ // above is the one exception, and it is not about the peer's format: it
6429
+ // DECODES our own retired v1 list token, whose payload happens to be
6430
+ // indistinguishable from a position.) Forwarding a list token would hand
6431
+ // the desktop a position it cannot parse, and it answers that by serving
6432
+ // its FIRST page as if it were the continuation.
6433
+ throw new Error(
6434
+ 'cherry_read: messagesResumeCursor is a LIST `resumeCursor` (`list:…`) — it resumes a list walk ' +
6435
+ '(cherry_agents / cherry_workspaces / cherry_sessions / cherry_interactions), not a message history. ' +
6436
+ 'Omit the argument to read from the newest message, or pass the token this tool returned.',
6437
+ )
6438
+ }
6237
6439
  return refuse()
6238
6440
  }
6239
6441
 
@@ -6247,16 +6449,77 @@ function parseMessageResume(value: unknown): MessageResume | undefined {
6247
6449
  * another query is a perfectly valid number that reads the WRONG slice with no
6248
6450
  * error anywhere. So this token carries a fingerprint of the binding and a
6249
6451
  * mismatched replay is refused HERE — before any RPC.
6452
+ *
6453
+ * Wire format (issue #70):
6454
+ * `list:v2:<tool>:<device12>:<filters8>:<checksum8>:<peer position>` — the
6455
+ * position verbatim and LAST. It replaced a base64 JSON blob (`{v,m,d,h,p}`)
6456
+ * that was ~2.3x longer and had no integrity check: measured over 20,000 random
6457
+ * single-character corruptions, 28.7% of the old tokens were ACCEPTED with a
6458
+ * different position, because the position is the one field a structural check
6459
+ * cannot validate. The compact form plus the checksum makes that class empty.
6460
+ *
6461
+ * The prefix is `list:` and NOT `cursor:` (issue #72): the message face already
6462
+ * owns `cursor:` / `after:`, and while the two faces shared a prefix a list
6463
+ * token was a legal `messagesResumeCursor` payload — it was forwarded to the
6464
+ * desktop, which answers an unparseable position by serving its FIRST page, so
6465
+ * "continue from here" silently re-read the newest page. Two faces, two
6466
+ * prefixes: the refusal is a PREFIX check on either side, never an inspection
6467
+ * of the peer's position (a position that merely starts with `list:` still
6468
+ * travels verbatim inside its own `cursor:` token).
6250
6469
  */
6251
- const LIST_RESUME_PREFIX = 'cursor:'
6470
+ const LIST_RESUME_PREFIX = 'list:'
6252
6471
 
6253
- /** The `cursor` parameter every list tool shares (issue #65). */
6472
+ /**
6473
+ * The token's format marker (issue #70). It is a FIELD, not part of the prefix,
6474
+ * so a token from the PREVIOUS format is recognised and refused by name
6475
+ * ({@link looksLikeV1ListToken}) instead of getting the generic shape error: a
6476
+ * resume token outlives a plugin upgrade (the caller is holding it), and "start
6477
+ * the walk over" is actionable where "not a token this tool emitted" invites
6478
+ * one more attempt with the same string.
6479
+ *
6480
+ * A format NEWER than this one has no detector and falls through to the
6481
+ * generic refusal, which is the honest verdict: this release cannot know that a
6482
+ * future format is a resume token at all.
6483
+ */
6484
+ const LIST_TOKEN_VERSION = 'v2'
6485
+
6486
+ /**
6487
+ * One character per list tool, replacing the full method name (19 characters of
6488
+ * a 225-character token). Every method a list tool can issue MUST appear here:
6489
+ * {@link encodeListResumeCursor} throws on an unknown one rather than emit a
6490
+ * token the matching parser could never accept.
6491
+ */
6492
+ const LIST_TOOL_CODES: Readonly<Record<string, string>> = {
6493
+ 'agent.agents.list': 'a',
6494
+ 'agent.workspaces.list': 'w',
6495
+ 'agent.sessions.list': 's',
6496
+ 'agent.interactions.list': 'i',
6497
+ }
6498
+
6499
+ /** The inverse of {@link LIST_TOOL_CODES}, for {@link parseListResumeCursor}. */
6500
+ const LIST_CODE_METHODS: Readonly<Record<string, string>> = Object.fromEntries(
6501
+ Object.entries(LIST_TOOL_CODES).map(([method, code]) => [code, method]),
6502
+ )
6503
+
6504
+ /**
6505
+ * The fields after the version marker, BEFORE the peer position: tool code,
6506
+ * device digest, filter fingerprint and checksum. The position is the rest of
6507
+ * the token — it is peer-supplied and may contain the delimiter itself (the
6508
+ * desktop's session cursor is `entity:<orderKey>:<uuid>`), so the split stops
6509
+ * at a fixed count and never consumes it.
6510
+ */
6511
+ const LIST_TOKEN_FIELDS = 4
6512
+
6513
+ /** The `cursor` parameter every list tool shares (issues #65 and #70). */
6254
6514
  const LIST_CURSOR_PARAMETER = {
6255
6515
  type: 'string',
6516
+ maxLength: MAX_RESUME_TOKEN_CHARS,
6256
6517
  description:
6257
6518
  'A `resumeCursor` a previous TRUNCATED answer of this tool returned: replay it with the SAME ' +
6258
6519
  '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.',
6520
+ 'from another tool, device or filter set is refused locally, and so is a token this account ' +
6521
+ `cannot have received intact (the token carries a checksum; ${MAX_RESUME_TOKEN_CHARS} characters ` +
6522
+ 'is the ceiling).',
6260
6523
  } as const
6261
6524
 
6262
6525
  /** What a list token is valid for. `params` are the CALLER's filters, not the wire page params. */
@@ -6272,18 +6535,58 @@ function listParamsFingerprint(params: Record<string, unknown>): string {
6272
6535
  .sort()
6273
6536
  .filter((key) => params[key] !== undefined)
6274
6537
  .map((key) => [key, params[key]] as const)
6275
- return createHash('sha256').update(JSON.stringify(entries)).digest('hex').slice(0, 16)
6538
+ return createHash('sha256').update(JSON.stringify(entries)).digest('hex').slice(0, 8)
6539
+ }
6540
+
6541
+ /**
6542
+ * The device half of the binding, as a digest rather than the id itself: a
6543
+ * `deviceId` is a 36-character UUID, and the token only ever has to answer "was
6544
+ * this issued for the device I just resolved?", never "which device was it?".
6545
+ * 12 hex characters (48 bits) make an accidental collision between two paired
6546
+ * desktops a non-event, where the old field was a third of the token.
6547
+ */
6548
+ function listDeviceFingerprint(deviceId: string): string {
6549
+ return createHash('sha256').update(deviceId).digest('hex').slice(0, 12)
6550
+ }
6551
+
6552
+ /**
6553
+ * The token's integrity check (issue #70). It covers the peer position, which
6554
+ * is the ONE field no structural check can validate: a substitution there used
6555
+ * to decode as a perfectly well-formed token and was forwarded, and the desktop
6556
+ * silently falls back to its FIRST page for a position it cannot parse — so the
6557
+ * caller's "continue from here" came back as the newest page presented as older
6558
+ * history. 8 hex characters (32 bits) turn every single-character corruption
6559
+ * into a local refusal; the token is not a security boundary, so a MAC would
6560
+ * only make it longer.
6561
+ */
6562
+ function listTokenChecksum(body: string, peerPosition: string): string {
6563
+ return createHash('sha256')
6564
+ .update(`${body}\u0000${peerPosition}`)
6565
+ .digest('hex')
6566
+ .slice(0, 8)
6276
6567
  }
6277
6568
 
6278
6569
  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,
6570
+ const code = LIST_TOOL_CODES[binding.method]
6571
+ if (code === undefined) throw new Error(`${binding.method}: no list resume token code`)
6572
+ const body = `${LIST_TOKEN_VERSION}:${code}:${listDeviceFingerprint(binding.deviceId)}:${listParamsFingerprint(
6573
+ binding.params,
6574
+ )}`
6575
+ return `${LIST_RESUME_PREFIX}${body}:${listTokenChecksum(body, peerPosition)}:${peerPosition}`
6576
+ }
6577
+
6578
+ /**
6579
+ * Whether a `cursor:` remainder is a token from the base64-JSON era (issue #65,
6580
+ * `v: 1`). Only used to explain a refusal: the payload is never replayed.
6581
+ */
6582
+ function looksLikeV1ListToken(raw: string): boolean {
6583
+ if (raw === '' || raw.includes(':')) return false
6584
+ try {
6585
+ const decoded: unknown = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'))
6586
+ return typeof decoded === 'object' && decoded !== null && (decoded as Record<string, unknown>)['v'] === 1
6587
+ } catch {
6588
+ return false
6285
6589
  }
6286
- return LIST_RESUME_PREFIX + Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
6287
6590
  }
6288
6591
 
6289
6592
  /**
@@ -6302,31 +6605,73 @@ function parseListResumeCursor(value: unknown, binding: ListResumeBinding): stri
6302
6605
  'device and filters.',
6303
6606
  )
6304
6607
  }
6305
- if (typeof value !== 'string' || !value.startsWith(LIST_RESUME_PREFIX)) {
6306
- refuse('must be a `cursor:` token this tool emitted')
6608
+ if (typeof value !== 'string') refuse('must be a `list:` token this tool emitted')
6609
+ const token = value as string
6610
+ // Length first: the bound is about CALLER INPUT and must not depend on which
6611
+ // face the string appears to belong to.
6612
+ if (token.length > MAX_RESUME_TOKEN_CHARS) {
6613
+ throw new Error(
6614
+ `cursor is ${token.length} characters long, over the ${MAX_RESUME_TOKEN_CHARS}-character ceiling for a ` +
6615
+ 'resume token; omit `cursor` to read from the start.',
6616
+ )
6307
6617
  }
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')
6618
+ if (token.startsWith(RESUME_CURSOR_PREFIX)) {
6619
+ // Issue #72. Two faces, two prefixes: a token starting with `cursor:` was
6620
+ // minted by `cherry_read`'s message face, and replaying it here would ask a
6621
+ // LIST method for a slice at an offset taken from a MESSAGE history — a
6622
+ // perfectly valid number that reads the wrong rows with no error anywhere.
6623
+ // Until #70 the list face shared this prefix, so an old token is named
6624
+ // FIRST: "start the walk over" is the only thing that works there, and it
6625
+ // is not what this branch's other verdict says.
6626
+ if (looksLikeV1ListToken(token.slice(RESUME_CURSOR_PREFIX.length))) {
6627
+ throw new Error(
6628
+ 'cursor was issued by an older plugin version (a v1 list token, from before list tokens were ' +
6629
+ 'compact and checksummed), so it can no longer be verified; start this walk over with `cursor` omitted.',
6630
+ )
6631
+ }
6632
+ refuse(
6633
+ 'is a MESSAGE-face token (`cursor:<peer position>`, i.e. a `messagesResumeCursor`), which a list walk ' +
6634
+ 'never issues — a list token starts with `list:`',
6635
+ )
6314
6636
  }
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
- ) {
6637
+ if (!token.startsWith(LIST_RESUME_PREFIX)) {
6638
+ refuse('must be a `list:` token this tool emitted')
6639
+ }
6640
+ const raw = token.slice(LIST_RESUME_PREFIX.length)
6641
+ if (!raw.startsWith(`${LIST_TOKEN_VERSION}:`)) {
6642
+ if (looksLikeV1ListToken(raw)) {
6643
+ // A v1 token cannot be replayed at ALL: the format changed, so "the same
6644
+ // tool, device and filters" would fail again. The generic tail would
6645
+ // therefore tell the caller to do something that can never work — the one
6646
+ // thing this version marker exists to avoid.
6647
+ throw new Error(
6648
+ 'cursor was issued by an older plugin version (a v1 token, from before list tokens were compact ' +
6649
+ 'and checksummed), so it can no longer be verified; start this walk over with `cursor` omitted.',
6650
+ )
6651
+ }
6324
6652
  refuse('is not a token this tool emitted')
6325
6653
  }
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
6654
+ let rest = raw.slice(LIST_TOKEN_VERSION.length + 1)
6655
+ const fields: string[] = []
6656
+ for (let index = 0; index < LIST_TOKEN_FIELDS; index += 1) {
6657
+ const separator = rest.indexOf(':')
6658
+ if (separator < 0) refuse('is not a token this tool emitted')
6659
+ fields.push(rest.slice(0, separator))
6660
+ rest = rest.slice(separator + 1)
6661
+ }
6662
+ // What is left is the peer position, verbatim — colons and all.
6663
+ const [code, device, filters, checksum] = fields as [string, string, string, string]
6664
+ const peerPosition = rest
6665
+ const body = `${LIST_TOKEN_VERSION}:${code}:${device}:${filters}`
6666
+ // The checksum is judged BEFORE the binding: a corrupted token is not a
6667
+ // statement about the caller's filters, and reporting it as one would send
6668
+ // the caller looking for a filter they never changed.
6669
+ if (checksum !== listTokenChecksum(body, peerPosition)) {
6670
+ refuse('is not a token this tool emitted (its checksum does not match, so it was altered in transit)')
6671
+ }
6672
+ if (LIST_CODE_METHODS[code] !== binding.method) refuse('was issued for a different tool')
6673
+ if (device !== listDeviceFingerprint(binding.deviceId)) refuse('was issued for a different device')
6674
+ if (filters !== listParamsFingerprint(binding.params)) refuse('was issued for a different filter set')
6330
6675
  if (mangledRemainder(peerPosition)) refuse('is not a token this tool emitted')
6331
6676
  return peerPosition
6332
6677
  }