dsh-cherry-bridge 0.1.7 → 0.1.9

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
@@ -4,6 +4,7 @@ import type { DeviceConnection, DeviceState } from './connection.js'
4
4
  import type { DeviceRecord, DeviceResolution, DeviceStore } from './deviceStore.js'
5
5
  import { QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
6
6
  import {
7
+ DEVICE_SETTLE_WAIT_MS,
7
8
  INTERACTIONS_LIST_PAGE_CLAMP,
8
9
  LIST_PAGE_CLAMP,
9
10
  MAX_RESUME_TOKEN_CHARS,
@@ -391,9 +392,15 @@ export interface CherryReadSnapshot {
391
392
  *
392
393
  * Present exactly when `truncated` is true and a stop point can be named
393
394
  * honestly; because the walk runs newest-first, it marks OLDER messages as
394
- * still unread — the only direction a truncation leaves behind. It is ABSENT
395
- * when the walk reached the peer's last page having returned everything it
396
- * collected: there is nothing older left to name, and no marker is invented.
395
+ * still unread — the only direction a truncation leaves behind.
396
+ *
397
+ * IT IS ABSENT IN TWO OPPOSITE CASES, and `truncated` is what tells them
398
+ * apart: a COMPLETE walk (`truncated: false` — nothing older left), and a
399
+ * TRUNCATED walk whose stop point could not be named (`truncated: true` plus
400
+ * `truncationReason` — older messages still unread). The absence therefore
401
+ * never means "nothing older left" on its own. WHICH stops can be named is
402
+ * decided in ONE place ({@link messageResumeToken}); the refusal cases are not
403
+ * restated here.
397
404
  *
398
405
  * WHAT REACHES THE UNREAD MESSAGES DEPENDS ON THE REASON. A `'limit'` stop was
399
406
  * the caller's own ceiling: raise `limit`, or resume with this token to walk
@@ -403,6 +410,12 @@ export interface CherryReadSnapshot {
403
410
  * messages are reached. From the resume point `limit` bounds how many messages
404
411
  * ONE answer carries, so a long history is walked in successive answers, each
405
412
  * carrying the next stop point.
413
+ *
414
+ * WITH NO TOKEN NAMED, the reason is all the caller has: a `'limit'` stop is
415
+ * still reachable by widening `limit`; a `'safety-bound'` stop is the internal
416
+ * circuit breaker, which no wider `limit` moves; and an `'aggregate-bytes'`
417
+ * stop has no remedy in that answer at all — the budget, not the ceiling, is
418
+ * what stopped it. The loss is stated rather than repaired.
406
419
  */
407
420
  messagesResumeCursor?: string
408
421
  }
@@ -447,10 +460,19 @@ export interface CherryMessageWalkVerdict {
447
460
  * `cursor:<peer position>` when the whole collected window was returned;
448
461
  * `after:<last returned message id>` when the byte stop happened INSIDE that
449
462
  * window, because the peer's position sits past messages this walk trimmed
450
- * and `AgentMessage` carries no `createdAt` to synthesise a closer one. The
451
- * token is OMITTED when nothing older is left to name (the walk reached the
452
- * peer's last page having returned everything it collected) — absence says
453
- * "no marker", `messagesTruncated` still says the walk was cut.
463
+ * and `AgentMessage` carries no `createdAt` to synthesise a closer one.
464
+ *
465
+ * IT IS ABSENT IN TWO OPPOSITE CASES, and `messagesTruncated` is what tells
466
+ * them apart: a COMPLETE walk (`messagesTruncated` absent or false — nothing
467
+ * older left), and a TRUNCATED walk whose stop point could not be named
468
+ * (`messagesTruncated: true` plus `messagesTruncationReason` — older history
469
+ * still unread). The absence never means "nothing older left" on its own.
470
+ * WHICH stops can be named is decided in ONE place
471
+ * ({@link messageResumeToken}) and is the same rule as
472
+ * {@link CherryReadSnapshot.messagesResumeCursor}. THE REMEDY IS NOT THE SAME
473
+ * ON THIS FACE: a `'limit'` stop here is the walk's own fixed message
474
+ * allowance — these tools take no `limit` argument — so the way further is
475
+ * `cherry_read`, which also carries no deadline.
454
476
  */
455
477
  messagesResumeCursor?: string
456
478
  }
@@ -1009,6 +1031,14 @@ export interface CherryToolsDeps {
1009
1031
  * this one subscribes the engine to ONE device's transitions.
1010
1032
  */
1011
1033
  watchDeviceState?: (deviceId: string, cb: (state: DeviceState) => void) => void
1034
+ /**
1035
+ * How long a device tool waits for an establishment attempt that is ALREADY
1036
+ * IN FLIGHT (`state === 'connecting'`) before judging the device (issue #99).
1037
+ *
1038
+ * OPTIONAL: production omits it and takes {@link DEVICE_SETTLE_WAIT_MS}; test
1039
+ * rigs drive it down so the bounded case needs no real five seconds.
1040
+ */
1041
+ deviceSettleWaitMs?: number
1012
1042
  }
1013
1043
 
1014
1044
  // ---------------------------------------------------------------------------
@@ -1596,10 +1626,14 @@ class CherryToolEngine {
1596
1626
  * ⑤): the candidate list is the answer. A MISS is a verdict too — see
1597
1627
  * {@link CherryDeviceNotFound} — and it carries the ids the caller could have
1598
1628
  * meant so the answer stays actionable.
1629
+ *
1630
+ * ASYNC because of the `connecting` wait below: the pool dials on first touch
1631
+ * and on every reconnect, so a tool called in that window used to be refused
1632
+ * for a connection that comes up a moment later (issue #99).
1599
1633
  */
1600
- #resolve(
1634
+ async #resolve(
1601
1635
  device: string,
1602
- ): ResolvedDevice | CherryAmbiguousDevice | CherryDeviceNotFound | CherryDeviceUnavailable {
1636
+ ): Promise<ResolvedDevice | CherryAmbiguousDevice | CherryDeviceNotFound | CherryDeviceUnavailable> {
1603
1637
  const resolution: DeviceResolution = this.#deps.store.resolve(device)
1604
1638
  if ('ambiguous' in resolution) {
1605
1639
  return { error: 'ambiguous-device', device, candidates: resolution.ambiguous }
@@ -1613,10 +1647,21 @@ class CherryToolEngine {
1613
1647
  // transition this exists for can land while the device is already offline,
1614
1648
  // and the watch must be in place from the first call either way.
1615
1649
  this.#watchConnection(resolution.deviceId, connection)
1616
- if (connection.state !== 'connected') {
1650
+ // `connecting` is an attempt IN FLIGHT, not a verdict (issue #99): the pool
1651
+ // dials on first touch and on every reconnect, so refusing here used to
1652
+ // report a failure about a connection that comes up a moment later. Wait for
1653
+ // that attempt — bounded, and never starting one — then judge the state it
1654
+ // settled on. Every other non-connected state is answered at once: no wait
1655
+ // can change `offline` (parked in backoff), `disabled` or
1656
+ // `identity-mismatch`, and waiting would only delay the answer.
1657
+ const state =
1658
+ connection.state === 'connecting'
1659
+ ? await connection.waitForSettled(this.#deps.deviceSettleWaitMs ?? DEVICE_SETTLE_WAIT_MS)
1660
+ : connection.state
1661
+ if (state !== 'connected') {
1617
1662
  // The established Task 5 shape: the tool never dials implicitly, and the
1618
1663
  // caller gets the same `{device, state}` verdict the RPC layer throws.
1619
- return { error: 'device-unavailable', device: resolution.deviceId, state: connection.state }
1664
+ return { error: 'device-unavailable', device: resolution.deviceId, state }
1620
1665
  }
1621
1666
  return { record: resolution, connection }
1622
1667
  }
@@ -1654,6 +1699,10 @@ class CherryToolEngine {
1654
1699
  /**
1655
1700
  * Resolve-or-verdict: every non-`cherry_devices` tool needs a live link.
1656
1701
  *
1702
+ * `connecting` is waited out inside {@link #resolve} first, so the
1703
+ * `DeviceUnavailableError` below is only ever raised for a state the wait
1704
+ * could not improve on (bounded by `deviceSettleWaitMs`).
1705
+ *
1657
1706
  * The failure faces are deliberately DIFFERENT (controller ruling ⑤): an
1658
1707
  * ambiguous selector and a MISS are ANSWERS — the candidate list, or the
1659
1708
  * known ids, returned in band, because the caller can act on them — while a
@@ -1666,8 +1715,8 @@ class CherryToolEngine {
1666
1715
  * `cherry_devices` does not come through this method — it goes through
1667
1716
  * {@link #selectRecords}, which returns the verdict in band.
1668
1717
  */
1669
- #requireConnection(device: string): ResolvedDevice | CherryAmbiguousDevice {
1670
- const resolved = this.#resolve(device)
1718
+ async #requireConnection(device: string): Promise<ResolvedDevice | CherryAmbiguousDevice> {
1719
+ const resolved = await this.#resolve(device)
1671
1720
  if (!this.#isVerdict(resolved)) return resolved
1672
1721
  if (resolved.error === 'ambiguous-device') return resolved
1673
1722
  if (resolved.error === 'not-found') {
@@ -1689,14 +1738,13 @@ class CherryToolEngine {
1689
1738
  * replace it with a weaker, wrong one — a miss under a READ-ONLY archive
1690
1739
  * would describe a store that still holds a valid token as "no such device".
1691
1740
  */
1692
- #linkOr(
1693
- device: string,
1694
- ):
1741
+ async #linkOr(device: string): Promise<
1695
1742
  | { link: ResolvedDevice; verdict?: undefined }
1696
- | { link?: undefined; verdict: CherryAmbiguousDevice | CherryArchiveQuarantined } {
1743
+ | { link?: undefined; verdict: CherryAmbiguousDevice | CherryArchiveQuarantined }
1744
+ > {
1697
1745
  const quarantined = this.#quarantineVerdict()
1698
1746
  if (quarantined !== undefined) return { verdict: quarantined }
1699
- const resolved = this.#requireConnection(device)
1747
+ const resolved = await this.#requireConnection(device)
1700
1748
  if ('error' in resolved) return { verdict: resolved }
1701
1749
  return { link: resolved }
1702
1750
  }
@@ -2100,19 +2148,31 @@ class CherryToolEngine {
2100
2148
  #listPage(page: CherryPage<Record<string, unknown>>, binding: ListResumeBinding): Record<string, unknown> {
2101
2149
  // A position this face would REFUSE is not a position it may name. The
2102
2150
  // 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)
2151
+ // the whole point of the token, so a peer answering `nextCursor: ''`, one
2152
+ // carrying whitespace/control characters, or one shaped like our retired v1
2153
+ // token gets NO token. The absence has TWO opposite cases — a COMPLETE walk,
2154
+ // and a TRUNCATED one whose stop could not be named — and only `truncated`
2155
+ // tells them apart. The remedy follows the REASON: a `'limit'` stop answers
2156
+ // to a wider `limit`; a `'safety-bound'` stop does not, because it is an
2157
+ // internal circuit breaker a caller cannot move. The rule is NOT restated
2158
+ // here or in the message face:
2159
+ // `nameablePeerPosition` is the one place it lives, so the emitters and
2160
+ // their parsers cannot drift (issue #85).
2161
+ const position = nameablePeerPosition(optionalString(page.nextCursor))
2162
+ // The same refusal one dimension further: an encoded token over
2163
+ // {@link MAX_RESUME_TOKEN_CHARS} is one this face would refuse on replay, so
2164
+ // it is not named ({@link withinResumeTokenCeiling}, issue #76). Judged only
2165
+ // on the arm that NAMES it — a complete walk has nothing to name, so it must
2166
+ // not pay for the encoding.
2167
+ const token =
2168
+ !page.truncated || position === undefined
2169
+ ? undefined
2170
+ : withinResumeTokenCeiling(encodeListResumeCursor(binding, position))
2109
2171
  return {
2110
2172
  items: page.items,
2111
2173
  truncated: page.truncated,
2112
2174
  ...(page.truncationReason === undefined ? {} : { truncationReason: page.truncationReason }),
2113
- ...(page.truncated && position !== undefined && !mangledRemainder(position)
2114
- ? { resumeCursor: encodeListResumeCursor(binding, position) }
2115
- : {}),
2175
+ ...(token === undefined ? {} : { resumeCursor: token }),
2116
2176
  }
2117
2177
  }
2118
2178
 
@@ -2139,11 +2199,12 @@ class CherryToolEngine {
2139
2199
  const quarantined = this.#quarantineVerdict()
2140
2200
  if (quarantined !== undefined) return quarantined
2141
2201
  if (selector === undefined) {
2142
- return { devices: this.#deps.store.list().map((record) => this.#deviceView(record)) }
2202
+ const records = this.#deps.store.list()
2203
+ return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) }
2143
2204
  }
2144
2205
  const selected = this.#selectRecords(selector)
2145
2206
  if (!Array.isArray(selected)) return selected
2146
- return { devices: selected.map((record) => this.#deviceView(record)) }
2207
+ return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) }
2147
2208
  },
2148
2209
  })
2149
2210
  }
@@ -2175,7 +2236,9 @@ class CherryToolEngine {
2175
2236
  }
2176
2237
 
2177
2238
  /**
2178
- * One device's view WITHOUT dialing.
2239
+ * One device's view WITHOUT dialing — but after waiting out an attempt that is
2240
+ * already in flight, exactly as {@link #resolve} does, so a cold-start row
2241
+ * does not present a transient `connecting` as the device's answer.
2179
2242
  *
2180
2243
  * A disabled record is authoritative: the pooled connection is constructed
2181
2244
  * with its own `record.enabled` snapshot and reports `disabled` only once
@@ -2183,11 +2246,18 @@ class CherryToolEngine {
2183
2246
  * for a device whose connection was never started. Everything else falls back
2184
2247
  * to the connection's own CLOSED state set.
2185
2248
  */
2186
- #deviceView(record: DeviceRecord): CherryDeviceView {
2249
+ async #deviceView(record: DeviceRecord): Promise<CherryDeviceView> {
2187
2250
  let state: DeviceState = record.enabled ? 'offline' : 'disabled'
2188
2251
  if (record.enabled) {
2189
2252
  try {
2190
- state = this.#deps.connect(record.deviceId).state
2253
+ const connection = this.#deps.connect(record.deviceId)
2254
+ // Same transient as {@link #resolve} (issue #99): a row rendered while
2255
+ // the first dial is in flight must not present `connecting` as the
2256
+ // device's answer. Bounded, and only for an attempt already running.
2257
+ state =
2258
+ connection.state === 'connecting'
2259
+ ? await connection.waitForSettled(this.#deps.deviceSettleWaitMs ?? DEVICE_SETTLE_WAIT_MS)
2260
+ : connection.state
2191
2261
  } catch (error) {
2192
2262
  // A device that cannot even be resolved to a connection still deserves a
2193
2263
  // truthful row; `offline` is the fallback verdict.
@@ -2215,9 +2285,10 @@ class CherryToolEngine {
2215
2285
  'List the Cherry Studio agents available on a device. Paginated: `limit` bounds ' +
2216
2286
  'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
2217
2287
  '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).',
2288
+ 'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the ' +
2289
+ 'stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same ' +
2290
+ 'tool, device and filters to continue from that stop point (`limit` then bounds the next ' +
2291
+ 'answer).',
2221
2292
  parameters: {
2222
2293
  type: 'object',
2223
2294
  properties: {
@@ -2234,7 +2305,7 @@ class CherryToolEngine {
2234
2305
  additionalProperties: false,
2235
2306
  },
2236
2307
  execute: async (args) => {
2237
- const resolved = this.#linkOr(requiredString(args, 'device'))
2308
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
2238
2309
  if (resolved.link === undefined) return resolved.verdict
2239
2310
  const { connection, record } = resolved.link
2240
2311
  const binding: ListResumeBinding = { method: 'agent.agents.list', deviceId: record.deviceId, params: {} }
@@ -2258,8 +2329,9 @@ class CherryToolEngine {
2258
2329
  'List an agent\'s REGISTERED workspaces, plus whether a system workspace exists. ' +
2259
2330
  'The system workspace has no enumerable id: it is not in `items`, and its identity ' +
2260
2331
  '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.',
2332
+ 'answer (`truncated` + `truncationReason`) stopped before the peer ran out; when the stop ' +
2333
+ 'point can be named it carries `resumeCursor` — replay it as `cursor` with the same ' +
2334
+ 'filters to continue.',
2263
2335
  parameters: {
2264
2336
  type: 'object',
2265
2337
  properties: {
@@ -2277,7 +2349,7 @@ class CherryToolEngine {
2277
2349
  additionalProperties: false,
2278
2350
  },
2279
2351
  execute: async (args) => {
2280
- const resolved = this.#linkOr(requiredString(args, 'device'))
2352
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
2281
2353
  if (resolved.link === undefined) return resolved.verdict
2282
2354
  const { connection, record } = resolved.link
2283
2355
  const agentId = requiredString(args, 'agentId')
@@ -2310,9 +2382,10 @@ class CherryToolEngine {
2310
2382
  '`workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds ' +
2311
2383
  'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
2312
2384
  '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).',
2385
+ 'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the ' +
2386
+ 'stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same ' +
2387
+ 'tool, device and filters to continue from that stop point (`limit` then bounds the next ' +
2388
+ 'answer).',
2316
2389
  parameters: {
2317
2390
  type: 'object',
2318
2391
  properties: {
@@ -2331,7 +2404,7 @@ class CherryToolEngine {
2331
2404
  additionalProperties: false,
2332
2405
  },
2333
2406
  execute: async (args) => {
2334
- const resolved = this.#linkOr(requiredString(args, 'device'))
2407
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
2335
2408
  if (resolved.link === undefined) return resolved.verdict
2336
2409
  const { connection, record } = resolved.link
2337
2410
  const agentId = optionalString(args['agentId'])
@@ -2382,8 +2455,9 @@ class CherryToolEngine {
2382
2455
  '"aggregate-bytes"` means the walk stopped because the result reached its size limit, ' +
2383
2456
  'not because `limit` was reached — raising `limit` does NOT reach the messages left out ' +
2384
2457
  '(it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE ' +
2385
- 'REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` whenever a ' +
2386
- 'stop point can be named honestly (it is absent when the walk reached the peer\'s last page), and ' +
2458
+ 'REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` when the ' +
2459
+ 'stop point can be named — absent when it cannot, e.g. an empty or malformed position, a ' +
2460
+ 'token past the length ceiling, or no anchor id — and ' +
2387
2461
  'passing it back as `messagesResumeCursor` continues the walk from there (`limit` then ' +
2388
2462
  'bounds how many messages are returned FROM that point). The two token forms are ' +
2389
2463
  '`"cursor:<peer position>"` (the walk consumed its whole page window) and ' +
@@ -2405,7 +2479,8 @@ class CherryToolEngine {
2405
2479
  description:
2406
2480
  'Maximum number of messages to return in total across pages (default 100). Distinct from ' +
2407
2481
  'the output budget: the answer may stop earlier with `truncationReason: "aggregate-bytes"`, ' +
2408
- 'which a larger `limit` does not extend — the resume token is how older messages are reached.',
2482
+ 'which a larger `limit` does not extend — the resume token, when one is returned, is how ' +
2483
+ 'older messages are reached.',
2409
2484
  },
2410
2485
  messagesResumeCursor: {
2411
2486
  type: 'string',
@@ -2428,7 +2503,7 @@ class CherryToolEngine {
2428
2503
  // a malformed cursor, which would present the newest page again as
2429
2504
  // "continued history" — the refusal has to be ours.
2430
2505
  const resume = parseMessageResume(args['messagesResumeCursor'])
2431
- const resolved = this.#linkOr(requiredString(args, 'device'))
2506
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
2432
2507
  if (resolved.link === undefined) return resolved.verdict
2433
2508
  const { connection, record } = resolved.link
2434
2509
  const sessionId = requiredString(args, 'sessionId')
@@ -2586,11 +2661,30 @@ class CherryToolEngine {
2586
2661
  ...(truncationReason === undefined ? {} : { truncationReason }),
2587
2662
  // Only a TRUNCATED walk has unread history, and the token then marks the
2588
2663
  // stop point (OLDER messages remain unread) —
2589
- // {@link CherryReadSnapshot.messagesResumeCursor}. When the walk's own
2590
- // verdict names no stop (it reached the peer's last page AND returned
2591
- // every message it collected) the key is OMITTED rather than emitted
2592
- // undefined: the absence says "nothing older left", the `truncated` flag
2593
- // still says whether the walk was cut.
2664
+ // {@link CherryReadSnapshot.messagesResumeCursor}. Whether that stop can
2665
+ // be NAMED is not the walk's verdict: it depends on the peer's payload,
2666
+ // and {@link messageResumeToken} is the ONE place that judges it — content
2667
+ // through `nameableRemainder`, length through `withinResumeTokenCeiling`,
2668
+ // and, for a POSITION only, the retired-v1 check
2669
+ // ({@link nameablePeerPosition}; the anchor arm deliberately skips it,
2670
+ // exactly as `parseMessageResume`'s `after:` branch does). The refusal
2671
+ // cases are therefore not enumerated again here.
2672
+ //
2673
+ // The key is OMITTED rather than emitted `undefined`, and its absence only
2674
+ // means something once read together with `truncated`: the two cases have
2675
+ // opposite meanings —
2676
+ //
2677
+ // - a COMPLETE walk (nothing older left): `truncated: false`, no token —
2678
+ // the absence really does say "nothing older left";
2679
+ // - a TRUNCATED walk whose stop point cannot be named: `truncated: true`
2680
+ // plus a `truncationReason`, no token — OLDER MESSAGES REMAIN UNREAD,
2681
+ // and the absence never means they ran out. The remedy follows the
2682
+ // REASON: only a `limit` stop answers to a wider `limit`; a
2683
+ // `safety-bound` stop is the internal circuit breaker, and an
2684
+ // `aggregate-bytes` stop is this answer's own output budget, which a
2685
+ // wider `limit` does not move (more round trips, same budget). A
2686
+ // `deadline` stop is a WAIT face's reason — `cherry_read` carries no
2687
+ // deadline — so it is never this literal's.
2594
2688
  ...(truncated && resumeToken !== undefined ? { messagesResumeCursor: resumeToken } : {}),
2595
2689
  }
2596
2690
  }
@@ -3144,7 +3238,7 @@ class CherryToolEngine {
3144
3238
  additionalProperties: false,
3145
3239
  },
3146
3240
  execute: async (args) => {
3147
- const resolved = this.#linkOr(requiredString(args, 'device'))
3241
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
3148
3242
  if (resolved.link === undefined) return resolved.verdict
3149
3243
  const { record } = resolved.link
3150
3244
  const sessionId = requiredString(args, 'sessionId')
@@ -3262,7 +3356,7 @@ class CherryToolEngine {
3262
3356
  additionalProperties: false,
3263
3357
  },
3264
3358
  execute: async (args) => {
3265
- const resolved = this.#linkOr(requiredString(args, 'device'))
3359
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
3266
3360
  if (resolved.link === undefined) return resolved.verdict
3267
3361
  const { connection, record } = resolved.link
3268
3362
 
@@ -3383,7 +3477,7 @@ class CherryToolEngine {
3383
3477
  additionalProperties: false,
3384
3478
  },
3385
3479
  execute: async (args) => {
3386
- const resolved = this.#linkOr(requiredString(args, 'device'))
3480
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
3387
3481
  if (resolved.link === undefined) return resolved.verdict
3388
3482
  const { connection, record } = resolved.link
3389
3483
  const sessionId = requiredString(args, 'sessionId')
@@ -3614,7 +3708,7 @@ class CherryToolEngine {
3614
3708
  additionalProperties: false,
3615
3709
  },
3616
3710
  execute: async (args) => {
3617
- const resolved = this.#linkOr(requiredString(args, 'device'))
3711
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
3618
3712
  if (resolved.link === undefined) return resolved.verdict
3619
3713
  const { connection, record } = resolved.link
3620
3714
  const sessionId = requiredString(args, 'sessionId')
@@ -3753,8 +3847,8 @@ class CherryToolEngine {
3753
3847
  'pending filter is applied here. Each entry carries its `kind` (question|decision) and a ' +
3754
3848
  'summary; answer questions with cherry_respond\'s `response` form and decisions with ' +
3755
3849
  '`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.',
3850
+ 'peer ran out; when the stop point can be named it carries `resumeCursor` — replay it as ' +
3851
+ '`cursor` from the same `sessionId` to continue.',
3758
3852
  parameters: {
3759
3853
  type: 'object',
3760
3854
  properties: {
@@ -3778,7 +3872,7 @@ class CherryToolEngine {
3778
3872
  additionalProperties: false,
3779
3873
  },
3780
3874
  execute: async (args) => {
3781
- const resolved = this.#linkOr(requiredString(args, 'device'))
3875
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
3782
3876
  if (resolved.link === undefined) return resolved.verdict
3783
3877
  const { connection, record } = resolved.link
3784
3878
  const sessionId = requiredString(args, 'sessionId')
@@ -3888,7 +3982,7 @@ class CherryToolEngine {
3888
3982
  additionalProperties: false,
3889
3983
  },
3890
3984
  execute: async (args) => {
3891
- const resolved = this.#linkOr(requiredString(args, 'device'))
3985
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
3892
3986
  if (resolved.link === undefined) return resolved.verdict
3893
3987
  const { connection, record } = resolved.link
3894
3988
  const sessionId = requiredString(args, 'sessionId')
@@ -3994,7 +4088,7 @@ class CherryToolEngine {
3994
4088
  additionalProperties: false,
3995
4089
  },
3996
4090
  execute: async (args) => {
3997
- const resolved = this.#linkOr(requiredString(args, 'device'))
4091
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
3998
4092
  if (resolved.link === undefined) return resolved.verdict
3999
4093
  const { connection, record } = resolved.link
4000
4094
  const sessionId = requiredString(args, 'sessionId')
@@ -5547,9 +5641,11 @@ class CherryToolEngine {
5547
5641
 
5548
5642
  /**
5549
5643
  * The timeout result: NOT a failure. Partial content read so far +, whenever a
5550
- * stop point can be named honestly, a `messagesResumeCursor` token marking where
5551
- * the unread history begins — absent when the walk reached the peer's last page
5552
- * or its last returned message carried no id.
5644
+ * stop point can be named honestly, a `messagesResumeCursor` token marking
5645
+ * where the unread history begins. The token is missing in TWO OPPOSITE cases
5646
+ * — a COMPLETE walk, or a TRUNCATED one whose stop point could not be named —
5647
+ * and `messagesTruncated` is what tells them apart; see
5648
+ * {@link CherryMessageWalkVerdict.messagesResumeCursor}.
5553
5649
  *
5554
5650
  * The spec's own sentence (§3.6, 2026-10-09 design: 「超时不是失败:返回已收到
5555
5651
  * 的部分内容 + 当前游标,agent 可续查」) is a statement of that contract in
@@ -5824,9 +5920,10 @@ class CherryToolEngine {
5824
5920
  * `messagesResumeCursor`: the read scans from the newest end to the anchor
5825
5921
  * and continues strictly after it.
5826
5922
  *
5827
- * The token is absent only when there is nothing older to point at (the walk
5828
- * reached the peer's last page having returned everything it collected).
5829
- * See {@link CherryMessageWalkVerdict.messagesResumeCursor}.
5923
+ * The token is missing in TWO OPPOSITE cases — a COMPLETE walk, and a
5924
+ * TRUNCATED one whose stop point could not be named — and `messagesTruncated`
5925
+ * is what tells them apart; absence never means "nothing older left" on its
5926
+ * own. See {@link CherryMessageWalkVerdict.messagesResumeCursor}.
5830
5927
  */
5831
5928
  async #readTerminalMessages(
5832
5929
  connection: DeviceConnection,
@@ -6096,28 +6193,111 @@ function parseQuestionInput(input: unknown): string[] | undefined {
6096
6193
  * invented: a wrong anchor is worse than no anchor, and `truncated` still says
6097
6194
  * the walk was cut. Otherwise the whole collected window was consumed, and the
6098
6195
  * peer's page position is exactly where the unread history begins
6099
- * (`cursor:<pos>`) — unless the peer answered an EMPTY position, which names
6100
- * nothing and is therefore not emitted at all.
6196
+ * (`cursor:<pos>`).
6197
+ *
6198
+ * BOTH arms name a remainder only when the PARSER would accept it
6199
+ * ({@link mangledRemainder}): empty, not equal to its own `.trim()` (which is
6200
+ * leading/trailing whitespace), or carrying a control character anywhere is
6201
+ * refused HERE exactly as `parseMessageResume` refuses it on replay. The
6202
+ * emitter must never mint what the parser refuses — a token this tool will not
6203
+ * take back is worse than none, because the refusal then surfaces at the RESUME
6204
+ * call instead of at the answer that produced it. This is the CONTENT
6205
+ * dimension; {@link withinResumeTokenCeiling} is the sibling LENGTH one. The
6206
+ * `after:` anchor is covered by the same rule, not just the `cursor:` position:
6207
+ * a message id is an `opaqueId` too, and padding is schema-valid, so a padded
6208
+ * id really can reach the anchor.
6209
+ *
6210
+ * EXPORTED FOR ITS OWN TESTS. The LENGTH guard on the `after:` arm cannot be
6211
+ * driven through a tool: an anchor is a message ID, and projecting that message
6212
+ * means asking the peer for `agent.parts.list` with it, whose `messageId` is an
6213
+ * `opaqueId` (256 characters). A peer item carrying an id above the token
6214
+ * ceiling therefore fails its parts walk and is never RETURNED, so no walk
6215
+ * reaches the point of naming it. The length guard on that arm is still
6216
+ * required — the emitter must never mint what the parser refuses, whatever the
6217
+ * peer's cap becomes — and this export is how a unit test pins it
6218
+ * (`tests/unit/resumeTokenEmit.test.ts`). The CONTENT guard on both arms IS
6219
+ * reachable, because `opaqueId` allows padding: `tests/integration/
6220
+ * toolsReadResume.test.ts` ⑭d/⑭e drive it through the tools.
6101
6221
  */
6102
- function messageResumeToken(
6222
+ export function messageResumeToken(
6103
6223
  page: CherryPage<unknown>,
6104
6224
  returned: number,
6105
6225
  lastReturnedId: string | undefined,
6106
6226
  ): string | undefined {
6107
- const anchor = optionalString(lastReturnedId)
6227
+ const anchor = nameableRemainder(optionalString(lastReturnedId))
6108
6228
  // TWO forms of "the walk stopped inside what it was given": it returned fewer
6109
6229
  // items than it collected, or the PEER overshot the requested page and items
6110
6230
  // were left in it ({@link CherryPage.pageOverflow}). Either way the page's own
6111
6231
  // position sits past messages this walk never returned, so only the anchor is
6112
6232
  // exact.
6113
6233
  if (returned < page.items.length || page.pageOverflow === true) {
6114
- return anchor === undefined ? undefined : `${RESUME_ANCHOR_PREFIX}${anchor}`
6234
+ // The ANCHOR takes the content predicate only — NOT the v1 detector, and
6235
+ // that is not an oversight: `parseMessageResume`'s `after:` branch runs
6236
+ // `mangledRemainder` and nothing else, so an id-shaped `after:` remainder is
6237
+ // a token it ACCEPTS back. Applying the detector here would be the mirror
6238
+ // image of this issue — refusing to name a token the parser takes.
6239
+ return anchor === undefined ? undefined : withinResumeTokenCeiling(`${RESUME_ANCHOR_PREFIX}${anchor}`)
6115
6240
  }
6116
- // An empty position is not a position: a peer answering `nextCursor: ''` on a
6117
- // non-terminal page would otherwise make this face emit a bare `cursor:`,
6118
- // which the same face REFUSES on the way back (review P3-4).
6119
- const position = optionalString(page.nextCursor)
6120
- return position === undefined ? undefined : `${RESUME_CURSOR_PREFIX}${position}`
6241
+ // A POSITION takes both: the content predicate, and the retired-format
6242
+ // detector its own parser applies before anything else (issue #85).
6243
+ const position = nameablePeerPosition(optionalString(page.nextCursor))
6244
+ return position === undefined ? undefined : withinResumeTokenCeiling(`${RESUME_CURSOR_PREFIX}${position}`)
6245
+ }
6246
+
6247
+ /**
6248
+ * The CONTENT dimension of "a remainder this bridge may name", or `undefined`
6249
+ * when it must not name one.
6250
+ *
6251
+ * The parsers run `mangledRemainder` on every remainder they read, so one that
6252
+ * fails it is a remainder this tool would refuse on replay: naming it hands the
6253
+ * caller a stop point that can never be used, and the refusal then surfaces at
6254
+ * the RESUME call instead of at the answer that produced it (issues #70, #80).
6255
+ * What it rejects, exactly: empty, not equal to its own `.trim()` (leading/
6256
+ * trailing whitespace), or a control character ANYWHERE — an interior plain space
6257
+ * is legal and round-trips.
6258
+ *
6259
+ * This is the parser's own predicate, not a copy of it, so the two sides cannot
6260
+ * drift into two dialects. The sibling dimension is the LENGTH ceiling, judged
6261
+ * on the FINAL token ({@link withinResumeTokenCeiling}).
6262
+ */
6263
+ function nameableRemainder(value: string | undefined): string | undefined {
6264
+ return value === undefined || mangledRemainder(value) ? undefined : value
6265
+ }
6266
+
6267
+ /**
6268
+ * A remainder this bridge may name AS A PAGE POSITION: {@link nameableRemainder}
6269
+ * plus the one refusal that applies to positions and NOT to anchors.
6270
+ *
6271
+ * `looksLikeV1ListToken` exists because a v1 list token (0.1.6: base64url of
6272
+ * `{v,m,d,h,p}`) lives under `cursor:` and is indistinguishable from a peer
6273
+ * position, so both parsers NAME and refuse it rather than forwarding it to a
6274
+ * desktop that would silently serve its FIRST page. An emitter that wrapped such
6275
+ * a position would mint `cursor:<v1 shape>` and then have its own parser answer
6276
+ * "issued by an older plugin version" about a token this tool had just printed —
6277
+ * the same broken round trip as #76/#80, one detector further out (issue #85).
6278
+ *
6279
+ * The `after:` arm deliberately does NOT use this: `parseMessageResume`'s
6280
+ * `after:` branch never consults the detector, so an `after:` remainder of that
6281
+ * shape is a token it accepts.
6282
+ */
6283
+ function nameablePeerPosition(value: string | undefined): string | undefined {
6284
+ const remainder = nameableRemainder(value)
6285
+ return remainder === undefined || looksLikeV1ListToken(remainder) ? undefined : remainder
6286
+ }
6287
+
6288
+ /**
6289
+ * The token ceiling, enforced on the way OUT as well as in. A token this tool
6290
+ * refuses to replay is worse than no token at all: `truncated` already says the
6291
+ * walk was cut, and the remedy then follows the reason — a `'limit'` stop
6292
+ * answers to a wider `limit` only where the caller owns that ceiling — so a
6293
+ * stop point whose token would push past
6294
+ * {@link MAX_RESUME_TOKEN_CHARS} is simply not named. Measured on the FINAL
6295
+ * string, never on a reconstruction of its length, so a format change cannot
6296
+ * leave the guard behind. Exactly at the ceiling is accepted — it is a length
6297
+ * the parser takes back.
6298
+ */
6299
+ function withinResumeTokenCeiling(token: string): string | undefined {
6300
+ return token.length <= MAX_RESUME_TOKEN_CHARS ? token : undefined
6121
6301
  }
6122
6302
 
6123
6303
  /**
@@ -6224,8 +6404,14 @@ function messageIdOf(raw: Record<string, unknown>): string {
6224
6404
  * `undefined`/`null` means "from the newest message", which is the ordinary
6225
6405
  * read. An empty remainder (`'cursor:'`, `'after:'`) is refused like any other
6226
6406
  * foreign token: an empty anchor could never match a message.
6407
+ *
6408
+ * EXPORTED FOR ITS OWN TESTS — specifically for the round-trip property, which
6409
+ * is the invariant this whole file is built around: every token the emitters
6410
+ * NAME must be one this function ACCEPTS. Pinning that with a probe table is
6411
+ * what turns "we mirrored every refusal" from a promise into a check
6412
+ * (`tests/unit/resumeTokenEmit.test.ts`, issue #85).
6227
6413
  */
6228
- function parseMessageResume(value: unknown): MessageResume | undefined {
6414
+ export function parseMessageResume(value: unknown): MessageResume | undefined {
6229
6415
  if (value === undefined || value === null) return undefined
6230
6416
  const refuse = (): never => {
6231
6417
  throw new Error(
package/ui/index.html CHANGED
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Cherry Studio 互联 — 开发预览</title>
6
+ <title>Cherry 互联 — 开发预览</title>
7
7
  </head>
8
8
  <body>
9
9
  <!--
package/ui/src/i18n.ts CHANGED
@@ -57,7 +57,7 @@ export const LOCALE_IDS: readonly LocaleId[] = ['zh', 'en']
57
57
  */
58
58
  const zh: Dict = {
59
59
  // -- app shell ------------------------------------------------------------
60
- 'app.title': 'Cherry Studio 互联',
60
+ 'app.title': 'Cherry 互联',
61
61
  'app.subtitle':
62
62
  '通过远程传输把本机 DeepSeek Harness 连接到 Cherry Studio 桌面端;设备档案保存在插件数据目录,页面不接触任何凭据。',
63
63
  // The one string that used to reach a Chinese page in English (App.tsx's
@@ -210,7 +210,7 @@ const zh: Dict = {
210
210
  */
211
211
  const en: Dict = {
212
212
  // -- app shell ------------------------------------------------------------
213
- 'app.title': 'Cherry Studio Bridge',
213
+ 'app.title': 'Cherry Bridge',
214
214
  'app.subtitle':
215
215
  'Connect this machine’s DeepSeek Harness to a Cherry Studio desktop over the remote transport. Device records live in the plugin data directory; this page never touches a credential.',
216
216
  'app.eventSourceUnavailable': 'EventSource is unavailable in this environment',