dsh-cherry-bridge 0.1.11 → 0.2.1

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
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'
2
2
  import { DeviceUnavailableError, RpcDeadlineExceededError } from './connection.js'
3
3
  import type { DeviceConnection, DeviceState } from './connection.js'
4
4
  import type { DeviceRecord, DeviceResolution, DeviceStore } from './deviceStore.js'
5
- import { QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
5
+ import { LastCandidateHostError, QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
6
6
  import {
7
7
  DEVICE_SETTLE_WAIT_MS,
8
8
  INTERACTIONS_LIST_PAGE_CLAMP,
@@ -16,6 +16,7 @@ import { truncateErrorMessage } from './diagnostics.js'
16
16
  import type { ReadResult, SessionSubscriptions } from './subscriptions.js'
17
17
  import { PairingError } from './pairing.js'
18
18
  import type { PairingErrorKind } from './pairing.js'
19
+ import { failureText } from './text.js'
19
20
  import type { AgentCursor } from '@cherrystudio/remote-protocol/agent'
20
21
 
21
22
  /**
@@ -110,6 +111,13 @@ export interface CherryDeviceView {
110
111
  enabled: boolean
111
112
  hosts: string[]
112
113
  port: number
114
+ /**
115
+ * The addresses the operator has FORGOTTEN (issue #185): not dialed, and not
116
+ * re-learned from the desktop's advertisements until they are remembered.
117
+ * Always present on a view (empty when nothing was ever forgotten) so a
118
+ * caller never has to distinguish "absent" from "none".
119
+ */
120
+ forgottenHosts: string[]
113
121
  }
114
122
 
115
123
  /** The shared `{device, state}` verdict (spec §6: 设备离线). */
@@ -606,6 +614,42 @@ export interface CherryCreateResult {
606
614
 
607
615
  /** The protocol's single-page hard cap, used only as the pre-hello ceiling. */
608
616
  const PAGE_ITEMS_FALLBACK = 100
617
+ /**
618
+ * The page this bridge asks for when the peer's hello advertised
619
+ * `agentMessageSummariesVersion: 1` (issue #105).
620
+ *
621
+ * WHY IT IS NOT {@link LIST_PAGE_CLAMP} (16): that clamp is metadata-BLIND. It
622
+ * exists because `AgentMessage` carries `partIds` — up to 4,096 entries of
623
+ * schema-capped ids each — so a count-only peer can answer a page the 65,536-byte
624
+ * record cannot hold and the transport kills the connection (`defaults.ts`).
625
+ * What `partsMode: 'paged'` returns is `AgentMessageSummary`: identity,
626
+ * revision, role, status and optional usage/model/failure, and NO `partIds`.
627
+ *
628
+ * WHY THE PAGE BOUND IS THE PEER'S, NOT OURS: the ONLY verified advertiser of
629
+ * the capability (desktop 2.1.8) budgets the ACCUMULATED page — `pageOf`
630
+ * prefix-truncates at `PAGE_ITEM_BYTES` = 62,279 bytes and `queryPage` re-asks
631
+ * at a halved limit, so its reply records stay under `recordBytes` = 65,536
632
+ * whatever our `limit` says, answering with a SHORT page + `nextCursor` (the
633
+ * shape our walk already tolerates) instead of a killed link. The single-item
634
+ * `RESOURCE_EXHAUSTED {ITEM_TOO_LARGE, retryable: false}` refusal is the
635
+ * exception arm, and a schema-valid summary row cannot reach it: measured
636
+ * against the 0.7.0 schema, the worst row (ids at 256 units, `model` fields at
637
+ * 2,048 chars each, 32 `costs` entries, `failure` refined to ≤4,096 bytes) is
638
+ * ≈37 KB — under the 62,279 budget, so the refusal exists for full
639
+ * `partIds`-bearing rows, not summaries.
640
+ *
641
+ * 100 is the protocol's own `pageItems` cap, i.e. the ceiling a walk can use at
642
+ * all: `#aggregate` still intersects this with the peer's NEGOTIATED
643
+ * `pageItems`, so a peer advertising less keeps its size. Residual exposure —
644
+ * a hypothetical peer advertising the capability WITHOUT a page budget —
645
+ * contains no verified desktop (≤2.1.7 never advertises and keeps the clamp),
646
+ * the same "pragmatic, not provable" class `defaults.ts` already scopes.
647
+ *
648
+ * The PARTS walk keeps {@link PARTS_LIST_PAGE_CLAMP} unconditionally: nothing
649
+ * in protocol 0.7.0 proves `agent.parts.list` is byte-aware, and a part is the
650
+ * fattest item there is.
651
+ */
652
+ const MESSAGE_SUMMARIES_PAGE_ITEMS = PAGE_ITEMS_FALLBACK
609
653
  /** The largest MESSAGE aggregate a caller may ask for; the per-page clamp still applies. */
610
654
  const MAX_AGGREGATE_LIMIT = 1000
611
655
  /**
@@ -751,6 +795,33 @@ interface AggregateWalk<T> {
751
795
  deadline?: AssemblyDeadline
752
796
  }
753
797
 
798
+ /**
799
+ * The `agent.messages.list` request for ONE message walk, and the page size to
800
+ * ask for — the ONE place the paged-summaries dialect is decided (issue #105).
801
+ *
802
+ * THE INVARIANT: `partsMode` is sent ONLY to a peer whose own hello advertised
803
+ * `agentMessageSummariesVersion: 1`. 0.5.0's params schema — what every desktop
804
+ * through 2.1.7 ships — is a STRICT object with no such key, so sending it
805
+ * there is a protocol violation, not a wasted field. Both message-walk faces
806
+ * (`cherry_read` and the terminal/timeout/idle aggregate) come through HERE for
807
+ * the same reason the resume token has one helper: two call sites spelling the
808
+ * dialect would eventually disagree about which peer may hear it.
809
+ *
810
+ * With the capability, the page size is {@link MESSAGE_SUMMARIES_PAGE_ITEMS} —
811
+ * summary rows are metadata, so the byte-safe clamp no longer bounds anything
812
+ * they can carry. Without it, the clamp stays exactly as it was: the
813
+ * count-paged peer still returns `partIds`-bearing rows.
814
+ */
815
+ function messageListRequest(connection: DeviceConnection): {
816
+ params: Record<string, unknown>
817
+ maxPerPage: number
818
+ } {
819
+ if (connection.limits.messageSummaries === 1) {
820
+ return { params: { partsMode: 'paged' }, maxPerPage: MESSAGE_SUMMARIES_PAGE_ITEMS }
821
+ }
822
+ return { params: {}, maxPerPage: LIST_PAGE_CLAMP }
823
+ }
824
+
754
825
  /** Token prefix for a peer page position; the one `#aggregate` can send back. */
755
826
  const RESUME_CURSOR_PREFIX = 'cursor:'
756
827
  /** Token prefix for "strictly older than this message id". */
@@ -1152,8 +1223,11 @@ export interface CherryToolsDeps {
1152
1223
  * `DeviceConnection` captures its record at construction, so a pooled
1153
1224
  * instance can never observe an edited address or a flipped `enabled` flag.
1154
1225
  *
1155
- * `cherry_unpair` is the other caller: it evicts (drop + close) and then
1156
- * removes the archive entry, so no live socket outlives its record.
1226
+ * `cherry_unpair` evicts (drop + close) and then removes the archive entry,
1227
+ * so no live socket outlives its record, and `cherry_devices`' `forget` /
1228
+ * `remember` actions evict ON A CHANGE for the same reason: a pooled
1229
+ * connection captured the pre-write record, so a forget that only rewrote the
1230
+ * archive would keep dialing the address (and locally re-learning it).
1157
1231
  */
1158
1232
  evict(deviceId: string): void | Promise<void>
1159
1233
  /**
@@ -1295,6 +1369,55 @@ function failureReason(error: unknown): string | undefined {
1295
1369
  return undefined
1296
1370
  }
1297
1371
 
1372
+ /**
1373
+ * The phrase this hint leaves behind, so applying it twice reads once.
1374
+ */
1375
+ const PEER_REFUSAL_FINAL_MARK = 'retrying the same request cannot help'
1376
+
1377
+ /**
1378
+ * The recovery advice a PERMANENT peer refusal grows — `RESOURCE_EXHAUSTED`
1379
+ * carrying `details.retryable === false`, which is the desktop's per-item byte
1380
+ * budget (`PAGE_ITEM_BYTES` = 62,279; `details.kind` is `ITEM_TOO_LARGE` with
1381
+ * `itemBytes` / `budgetBytes`), issue #105.
1382
+ *
1383
+ * WHY THE WARNING HAS TO BE ADDED: the official client marks every
1384
+ * `RESOURCE_EXHAUSTED` RETRYABLE and re-asks at the same page size on an
1385
+ * exponential backoff, so a byte-budget refusal never converges — and the
1386
+ * condition really is permanent: the same item over the same budget will be
1387
+ * over it again. A caller (or a model reading the error) that retries learns
1388
+ * nothing but spends the peer's rate window.
1389
+ *
1390
+ * The sentence is deliberately narrower than the reason: only the
1391
+ * `retryable === false` shape grows it. Every other `RESOURCE_EXHAUSTED` —
1392
+ * the upload window, the subscription cap, a legacy refusal with no `details` —
1393
+ * keeps its own text, because some of those ARE worth a later retry.
1394
+ *
1395
+ * Like {@link withIdempotencyHint} this EXTENDS `message` only: the reason, the
1396
+ * structured `data` and the {@link JsonRpcRequestError} identity stay the
1397
+ * peer's, so branching callers see exactly what it sent.
1398
+ */
1399
+ export function withPeerRefusalHint(error: unknown): unknown {
1400
+ if (!(error instanceof JsonRpcRequestError)) return error
1401
+ if (failureReason(error) !== 'RESOURCE_EXHAUSTED') return error
1402
+ const data = error.data
1403
+ if (!isRecord(data) || !isRecord(data['details']) || data['details']['retryable'] !== false) return error
1404
+ if (error.message.includes(PEER_REFUSAL_FINAL_MARK)) return error
1405
+ const kind = optionalString(data['details']['kind'])
1406
+ // The two permanent shapes need OPPOSITE advice. ITEM_TOO_LARGE means ONE
1407
+ // item alone exceeds the per-item budget: no page size fits it — that item
1408
+ // and everything behind it are unreachable (the upstream #21951 dead-end),
1409
+ // so suggesting a smaller `limit` would burn round trips learning nothing.
1410
+ const cause = kind === 'ITEM_TOO_LARGE' ? 'an ITEM_TOO_LARGE page item over its byte budget' : 'the same item'
1411
+ const tail =
1412
+ kind === 'ITEM_TOO_LARGE'
1413
+ ? 'the item ITSELF exceeds the peer\u2019s per-item budget, so no page size or request shape can fetch it (nor the items behind it — upstream #21951)'
1414
+ : 'ask for FEWER items per page (a smaller `limit`), which is the only change that can fit the budget'
1415
+ error.message =
1416
+ `${error.message} — the peer marked this refusal permanent (\`details.retryable: false\`: ${cause}); ` +
1417
+ `retrying the same request cannot help — ${tail}`
1418
+ return error
1419
+ }
1420
+
1298
1421
  /**
1299
1422
  * One `{ref}`'s content could not be TRUSTED: the bytes served do not match the
1300
1423
  * digest the ref advertises (or there was no digest to check against).
@@ -1529,6 +1652,59 @@ function optionalString(value: unknown): string | undefined {
1529
1652
  return typeof value === 'string' && value !== '' ? value : undefined
1530
1653
  }
1531
1654
 
1655
+ /**
1656
+ * The `hosts` argument of `cherry_devices`' write actions (issue #185): a
1657
+ * non-empty list of non-empty host names, with no case-variant duplicate.
1658
+ *
1659
+ * Each rule is a state the store would have to interpret: an empty name can
1660
+ * never match a dialed host, and two spellings of one host would make "is this
1661
+ * address forgotten?" depend on which entry a comparison hit first. Values are
1662
+ * TRIMMED here (the model-facing surface is where a stray space is caught);
1663
+ * the archive then stores the spelling it already had for a dialed host.
1664
+ */
1665
+ function hostListArgument(value: unknown): string[] | Record<string, unknown> {
1666
+ if (!Array.isArray(value) || value.length === 0) {
1667
+ return { error: 'invalid-hosts', message: '`hosts` must be a non-empty array of host names' }
1668
+ }
1669
+ const hosts: string[] = []
1670
+ const seen = new Set<string>()
1671
+ for (const entry of value) {
1672
+ if (typeof entry !== 'string' || entry.trim() === '') {
1673
+ return { error: 'invalid-hosts', message: '`hosts` entries must be non-empty strings' }
1674
+ }
1675
+ let host = entry.trim()
1676
+ // The archive stores BARE hosts, and the PATCH path unwraps a bracketed
1677
+ // IPv6 literal (`normalizeHost`). This face must agree: a mark for
1678
+ // `[fd00::1]` would never match the `fd00::1` a dial or an advertisement
1679
+ // carries, so it would be a mark with no effect. The ceiling mirrors the
1680
+ // PATCH path's `HOST_MAX`.
1681
+ if (host.startsWith('[') && host.endsWith(']')) {
1682
+ try {
1683
+ new URL(`http://${host}/`)
1684
+ } catch {
1685
+ return {
1686
+ error: 'invalid-hosts',
1687
+ message: `\`hosts\` entry "${host}" is not a valid IPv6 literal`,
1688
+ }
1689
+ }
1690
+ host = host.slice(1, -1)
1691
+ }
1692
+ if (host.length > 256) {
1693
+ return { error: 'invalid-hosts', message: '`hosts` entry exceeds 256 characters' }
1694
+ }
1695
+ const key = host.toLowerCase()
1696
+ if (seen.has(key)) {
1697
+ return {
1698
+ error: 'invalid-hosts',
1699
+ message: `\`hosts\` names "${host}" more than once (host names are case-insensitive)`,
1700
+ }
1701
+ }
1702
+ seen.add(key)
1703
+ hosts.push(host)
1704
+ }
1705
+ return hosts
1706
+ }
1707
+
1532
1708
  /**
1533
1709
  * The peer's `agent.messages.send` refusal for a STALE `expectedIdleRevision`,
1534
1710
  * in the exact shape Cherry Studio answers it: a `rejected` receipt whose
@@ -1757,7 +1933,19 @@ export function registerCherryToolsWithEngine(
1757
1933
  }
1758
1934
  }
1759
1935
 
1760
- /** One tool declaration, in the exact shape the host registry consumes. */
1936
+ /**
1937
+ * One tool declaration, in the exact shape the host registry consumes.
1938
+ *
1939
+ * This helper adds no result WRAPPER and this ticket adds no result-side
1940
+ * truncation (issue #161): a tool result is the MODEL's signal, and the peer is
1941
+ * the user's own Noise-authenticated desktop, so shortening the peer's
1942
+ * explanation costs the caller the text it acts on. The two result-text clamps
1943
+ * that DO exist are per-field product choices, not a log rule — `aggregateError`
1944
+ * (`message.slice(0, 200)` in `#assemblyFailed`) and `truncateErrorMessage`'s
1945
+ * 200-character diagnostics digest used by `projectMessageFailure`. The LOG
1946
+ * face is where boundedness is required (`failureText` / `clipText` in `src/text.ts`);
1947
+ * this ticket's sweep does not change tool output.
1948
+ */
1761
1949
  function defineCherryTool(definition: {
1762
1950
  name: string
1763
1951
  description: string
@@ -2399,7 +2587,12 @@ export class CherryToolEngine {
2399
2587
  if (deadline !== undefined && deadlineExhaustion(error)) {
2400
2588
  return this.#truncated(items, 'deadline', cursor)
2401
2589
  }
2402
- throw error
2590
+ // A PERMANENT peer refusal (issue #105) grows its advice HERE, at the
2591
+ // one seam every paged walk's refusals pass through, and ONLY the
2592
+ // `details.retryable: false` shape: a walk has no retry of its own to
2593
+ // reason about, and the caller is the party that must change the
2594
+ // request. The error object, its reason and its `data` are unchanged.
2595
+ throw withPeerRefusalHint(error)
2403
2596
  }
2404
2597
  const extracted = extract(result)
2405
2598
  // THE PAGE'S OWN SHAPE (issue #46), judged on EVERY page — terminal or
@@ -2599,25 +2792,100 @@ export class CherryToolEngine {
2599
2792
  description:
2600
2793
  'List every paired Cherry Studio desktop with its connection state ' +
2601
2794
  '(connected/connecting/offline/disabled/identity-mismatch). An optional `device` ' +
2602
- 'narrows the list to one device (display name prefix or deviceId).',
2795
+ 'narrows the list to one device (display name prefix or deviceId). ' +
2796
+ '`action: "forget"` removes the named `hosts` from that device\'s dial candidates AND ' +
2797
+ 'stops the desktop\'s advertisements from adding them back; `action: "remember"` lifts ' +
2798
+ 'that mark, so the next handshake may learn them again. Both write actions require ' +
2799
+ '`device` and `hosts`.',
2603
2800
  parameters: {
2604
2801
  type: 'object',
2605
2802
  properties: {
2606
2803
  device: { type: 'string', description: 'Optional device selector: exact deviceId, or a unique prefix of a deviceId or display name.' },
2804
+ action: {
2805
+ type: 'string',
2806
+ enum: ['list', 'forget', 'remember'],
2807
+ description:
2808
+ 'What to do: `list` (the default) reads the devices; `forget`/`remember` edit ONE ' +
2809
+ "device's forgotten addresses and require `device` and `hosts`.",
2810
+ },
2811
+ hosts: {
2812
+ type: 'array',
2813
+ items: { type: 'string' },
2814
+ description:
2815
+ 'For `forget`/`remember`: the host names to act on, matched case-insensitively. ' +
2816
+ '`forget` refuses to remove the last remaining address.',
2817
+ },
2607
2818
  },
2608
2819
  additionalProperties: false,
2609
2820
  },
2610
2821
  execute: async (args) => {
2822
+ const action = optionalString(args['action']) ?? 'list'
2823
+ if (action !== 'list' && action !== 'forget' && action !== 'remember') {
2824
+ return {
2825
+ error: 'invalid-action',
2826
+ message: '`action` accepts exactly "list", "forget" or "remember"',
2827
+ } satisfies Record<string, unknown>
2828
+ }
2611
2829
  const selector = optionalString(args['device'])
2612
- const quarantined = this.#quarantineVerdict()
2613
- if (quarantined !== undefined) return quarantined
2830
+ if (action === 'list') {
2831
+ const quarantined = this.#quarantineVerdict()
2832
+ if (quarantined !== undefined) return quarantined
2833
+ if (selector === undefined) {
2834
+ const records = this.#deps.store.list()
2835
+ return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) }
2836
+ }
2837
+ const selected = this.#selectRecords(selector)
2838
+ if (!Array.isArray(selected)) return selected
2839
+ return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) }
2840
+ }
2614
2841
  if (selector === undefined) {
2615
- const records = this.#deps.store.list()
2616
- return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) }
2842
+ return {
2843
+ error: 'device-required',
2844
+ message:
2845
+ '`action` "forget"/"remember" needs `device`: a selector that names exactly one device',
2846
+ } satisfies Record<string, unknown>
2617
2847
  }
2848
+ const hosts = hostListArgument(args['hosts'])
2849
+ if (!Array.isArray(hosts)) return hosts
2850
+ const quarantined = this.#quarantineVerdict()
2851
+ if (quarantined !== undefined) return quarantined
2618
2852
  const selected = this.#selectRecords(selector)
2619
2853
  if (!Array.isArray(selected)) return selected
2620
- return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) }
2854
+ const record = selected[0]!
2855
+ let removed: string[] = []
2856
+ let marked: string[] = []
2857
+ let restored: string[] = []
2858
+ try {
2859
+ if (action === 'forget') {
2860
+ ;({ removed, marked } = this.#deps.store.forgetEndpoints(record.deviceId, hosts))
2861
+ } else {
2862
+ ;({ restored } = this.#deps.store.rememberEndpoints(record.deviceId, hosts))
2863
+ }
2864
+ } catch (error) {
2865
+ if (error instanceof LastCandidateHostError) {
2866
+ return {
2867
+ error: 'last-address',
2868
+ device: record.deviceId,
2869
+ message: error.message,
2870
+ } satisfies Record<string, unknown>
2871
+ }
2872
+ throw error
2873
+ }
2874
+ const changed = removed.length > 0 || marked.length > 0 || restored.length > 0
2875
+ // ADDRESS MAINTENANCE reaches the LIVE connection, exactly like the HTTP
2876
+ // PATCH's eviction: a pooled `DeviceConnection` captures its record at
2877
+ // construction, so an instance built before this write would keep
2878
+ // dialing the forgotten host and locally re-adding it on every
2879
+ // reconnect. Evicting drops that socket and makes the next `connect()`
2880
+ // rebuild from the archive we just wrote.
2881
+ if (changed) await this.#deps.evict(record.deviceId)
2882
+ // The resolved record is STALE (hosts/forgottenHosts moved), so the
2883
+ // view is built from the archive's current row, never from the object
2884
+ // the selector handed us.
2885
+ const fresh = this.#deps.store.list().find((entry) => entry.deviceId === record.deviceId)
2886
+ if (fresh === undefined) return this.#notFoundVerdict(selector)
2887
+ const device = await this.#deviceView(fresh)
2888
+ return action === 'forget' ? { device, removed, marked } : { device, restored }
2621
2889
  },
2622
2890
  })
2623
2891
  }
@@ -2684,6 +2952,7 @@ export class CherryToolEngine {
2684
2952
  enabled: record.enabled,
2685
2953
  hosts: record.hosts,
2686
2954
  port: record.port,
2955
+ forgottenHosts: record.forgottenHosts ?? [],
2687
2956
  }
2688
2957
  }
2689
2958
 
@@ -3018,15 +3287,18 @@ export class CherryToolEngine {
3018
3287
  throw new Error(`agent.sessions.get returned no historyRevision for session ${sessionId}`)
3019
3288
  }
3020
3289
 
3290
+ // The paged-summaries dialect (issue #105) from the ONE helper both message
3291
+ // faces share: `partsMode` only for a peer whose hello advertised it.
3292
+ const messageList = messageListRequest(connection)
3021
3293
  const page = await this.#aggregate(
3022
3294
  connection,
3023
3295
  'agent.messages.list',
3024
- { sessionId, historyRevision },
3296
+ { sessionId, historyRevision, ...messageList.params },
3025
3297
  (result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
3026
3298
  {
3027
3299
  limit,
3028
3300
  safetyBound: MAX_AGGREGATE_LIMIT,
3029
- maxPerPage: LIST_PAGE_CLAMP,
3301
+ maxPerPage: messageList.maxPerPage,
3030
3302
  resume:
3031
3303
  resume === undefined
3032
3304
  ? undefined
@@ -3348,7 +3620,7 @@ export class CherryToolEngine {
3348
3620
  * '{\"mediaType\":…,\"filename\":…}'}}` — the metadata SERIALIZED AS JSON in
3349
3621
  * `content.text`, observed on the wire (2.1.7 desktop, image upload,
3350
3622
  * 2026-10-22). An OBJECT inside `content` is not producible by a conforming
3351
- * peer at all: the pinned protocol 0.5.0 types `content` as a strict
3623
+ * peer at all: the pinned protocol 0.7.0 types `content` as a strict
3352
3624
  * `{text}` | `{ref}` union, so the old documentation described a shape no
3353
3625
  * peer can emit — and the real one was never lifted, which is how an image
3354
3626
  * upload reached a model as a JSON blob.
@@ -3825,7 +4097,16 @@ export class CherryToolEngine {
3825
4097
  try {
3826
4098
  await subscriptions.ack(sessionId, result.nextCursor)
3827
4099
  } catch (error) {
3828
- console.warn(`[cherry-remote] acknowledging session ${sessionId} at ${result.nextCursor.seq} failed`, error)
4100
+ // ONE bounded STRING (issue #161): the ack refusal is the PEER's
4101
+ // JSON-RPC `error.message` verbatim (a CONFLICT from an out-of-window
4102
+ // cursor, a reset race), so the old object print let the console
4103
+ // render peer text with no local bound. Ceiling:
4104
+ // `[cherry-remote] acknowledging session ` (38) + sessionId +
4105
+ // ` at ` (4) + seq + ` failed: ` (9) + LOG_TEXT_MAX (240).
4106
+ console.warn(
4107
+ `[cherry-remote] acknowledging session ${sessionId} at ${result.nextCursor.seq} failed: ` +
4108
+ failureText(error),
4109
+ )
3829
4110
  this.#reEnsureInBackground(record.deviceId, sessionId)
3830
4111
  }
3831
4112
  return { events: result.events, nextCursor: result.nextCursor }
@@ -6577,15 +6858,25 @@ export class CherryToolEngine {
6577
6858
  * identity object: the verdict is already known and true, and only the
6578
6859
  * content behind it failed.
6579
6860
  *
6580
- * `aggregateError` is BOUNDED like every other peer-echoed string in a result
6581
- * (the full text goes to the log): the reason is actionable at 200 characters
6582
- * and the log is where the operator reads the rest.
6861
+ * `aggregateError` is a TOOL RESULT, not a log line — see
6862
+ * {@link defineCherryTool} for the product decision (issue #161): a result is
6863
+ * the MODEL's signal, so it is not bounded by the logging rule, and the
6864
+ * 200-character clip here is this field's own choice.
6865
+ *
6866
+ * The LOG line IS the logging face, and it renders the failure as ONE bounded
6867
+ * string through {@link failureText} (issue #161). An assembly failure is
6868
+ * usually a peer RPC refusal — its `error.message` is the peer's own text,
6869
+ * capped at 512 characters by the failure schema — and the old line
6870
+ * interpolated it whole: one 512-char refusal made a 654-character line for
6871
+ * the `timeout` / `s-1` fixture (83 fixed prefix + 512 + 59 suffix). The
6872
+ * RESULT keeps the raw message's first 200 characters; a bound on the log
6873
+ * does not change what the model receives.
6583
6874
  */
6584
6875
  #assemblyFailed(identity: Record<string, unknown>, error: unknown): Record<string, unknown> {
6585
6876
  const message = error instanceof Error ? error.message : String(error)
6586
6877
  console.warn(
6587
6878
  `[cherry-remote] the ${String(identity['status'])} answer for session ${String(identity['sessionId'])} ` +
6588
- `could not assemble its content (${message}); returning the verdict with the assembly failure reported`,
6879
+ `could not assemble its content (${failureText(error)}); returning the verdict with the assembly failure reported`,
6589
6880
  )
6590
6881
  return {
6591
6882
  ...identity,
@@ -6681,12 +6972,14 @@ export class CherryToolEngine {
6681
6972
  const historyRevision = optionalString(session['historyRevision'])
6682
6973
  if (historyRevision === undefined) return { messages: [] }
6683
6974
 
6975
+ // The SAME dialect `cherry_read` sends, from the same helper (issue #105).
6976
+ const messageList = messageListRequest(connection)
6684
6977
  const page = await this.#aggregate(
6685
6978
  connection,
6686
6979
  'agent.messages.list',
6687
- { sessionId, historyRevision },
6980
+ { sessionId, historyRevision, ...messageList.params },
6688
6981
  (result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
6689
- { limit: TERMINAL_AGGREGATE_MESSAGES, deadline },
6982
+ { limit: TERMINAL_AGGREGATE_MESSAGES, deadline, maxPerPage: messageList.maxPerPage },
6690
6983
  )
6691
6984
 
6692
6985
  const messages: CherryReadMessage[] = []
@@ -6761,11 +7054,24 @@ export class CherryToolEngine {
6761
7054
  try {
6762
7055
  layer = this.#deps.subs(deviceId)
6763
7056
  } catch (error) {
6764
- console.warn(`[cherry-remote] could not re-ensure session ${sessionId} of ${deviceId}`, error)
7057
+ // ONE bounded STRING (issue #161). This accessor's throw is LOCAL (no
7058
+ // record for the device, or the identity never loaded), but the arm is
7059
+ // converted with its sibling so the background re-ensure has ONE failure
7060
+ // shape and no branch can hand the console an unbounded object.
7061
+ console.warn(
7062
+ `[cherry-remote] could not re-ensure session ${sessionId} of ${deviceId}: ${failureText(error)}`,
7063
+ )
6765
7064
  return
6766
7065
  }
6767
7066
  void layer.ensure(sessionId).catch((error: unknown) => {
6768
- console.warn(`[cherry-remote] re-ensuring session ${sessionId} of ${deviceId} failed`, error)
7067
+ // ONE bounded STRING (issue #161): `ensure` reaches the peer, so the
7068
+ // rejection message is the peer's JSON-RPC `error.message` verbatim (up
7069
+ // to the 512-char failure schema cap) or a transport message. The old
7070
+ // form passed the error OBJECT, so the console printed its stack plus
7071
+ // every own property unbounded.
7072
+ console.warn(
7073
+ `[cherry-remote] re-ensuring session ${sessionId} of ${deviceId} failed: ${failureText(error)}`,
7074
+ )
6769
7075
  })
6770
7076
  }
6771
7077
 
package/ui/AGENTS.md ADDED
@@ -0,0 +1,44 @@
1
+ # AGENTS.md — `ui/`
2
+
3
+ The settings page: pairing wizard, device cards and the tool-scope panel. It
4
+ builds to `lib/client.js` (`npm run build:client`) and that bundle is COMMITTED
5
+ with its source. Loaded automatically when a file tool touches `ui/**`.
6
+
7
+ - **One view, no router.** `App.tsx` owns the two things that outlive both
8
+ children: the SSE stream and the device map.
9
+ - **The stream opens ONCE on mount and closes on unmount, in that order.** The
10
+ verification code exists only as a frame with no replay, so the stream must
11
+ exist before the wizard can `POST /pair` — which is why the submit button
12
+ stays disabled until `subscribed` flips true.
13
+ - **`device-state` is a FIRST-FRAME contract.** The REST list supplies the
14
+ static fields; a card's light shows only a state the server actually sent.
15
+ - **Every user-visible string goes through `t()`** (`i18n.ts`, zh and en in the
16
+ same change). A token that must never render — the missing-`EventSource`
17
+ marker — is a constant, not a sentence, and the component translates it.
18
+ - **HTTP lives in `api.ts`** (same-origin `/cherry-remote/api`); a component
19
+ never fetches directly. Both seams are injected for tests
20
+ (`eventSourceFactory`, `scopeClient`), so no test stands up a server.
21
+ - **A run always ends, and the deadline is not a verdict.** The server publishes
22
+ a terminal stage on every outcome, but the stream has no replay, so one lost
23
+ frame would otherwise park the wizard on 「配对进行中」 with the submit button
24
+ disabled and no cancel. The run deadline (`runLifetimeMs`, same injection
25
+ style as `codeLifetimeMs`) restores the form and re-reads the device list;
26
+ its copy points at that list instead of claiming failure, and it keeps
27
+ `claimId` so a terminal frame that arrives late still wins. Arm it on the
28
+ `waiting` phase only — never at mount.
29
+ - **The bundle carries the SOURCE COMMENTS.** `ui/vite.config.ts` sets
30
+ `minify: false`, so the docblocks that hold most of this page's reasoning ship
31
+ inside `lib/client.js`: a comment-only edit in `ui/src/**` changes the
32
+ artifact's bytes and needs the rebuild in the same commit. Two follow-ons from
33
+ that: `artifactFreshness` compares against `git show HEAD:<path>`, so it only
34
+ goes green once the rebuild is COMMITTED — running the full suite before
35
+ committing reports it red for the same reason its sibling case ("neither check
36
+ mutates the committed artifacts") is red. Neither red is a real failure.
37
+ - **A per-host action is INERT while a whole-list editor is open.** The address
38
+ editor's draft is sacred (the card does not reseed it while an editor is
39
+ open), so a 忘记/恢复 chosen under it would be re-sent by the next 保存地址 — and
40
+ that PATCH clears the mark for any host it names. Hence
41
+ `disabled={busy || editor === 'address'}` on both controls, pinned by `#204m`.
42
+ - **jsdom only.** `tests/unit/ui.test.ts` and `uiScopePanel.test.ts` render the
43
+ page; a browser-only API needs a fake in the test, not a guard in the page.
44
+
package/ui/src/App.tsx CHANGED
@@ -20,8 +20,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
20
20
  import { DeviceCard } from './DeviceCard'
21
21
  import { PairingWizard } from './PairingWizard'
22
22
  import { ToolScopePanel } from './ToolScopePanel'
23
- import { listDevices } from './api'
23
+ import { REQUEST_TIMEOUT, listDevices } from './api'
24
24
  import { useT } from './i18n'
25
+ import type { T } from './i18n'
25
26
  import { API_BASE } from './types'
26
27
  import type {
27
28
  DeviceState,
@@ -40,6 +41,8 @@ export interface AppProps {
40
41
  eventSourceFactory?: EventSourceFactory
41
42
  /** Transient verification-code lifetime, forwarded to the wizard. */
42
43
  codeLifetimeMs?: number
44
+ /** Run deadline (the exit for a lost terminal frame), forwarded to the wizard. */
45
+ runLifetimeMs?: number
43
46
  /**
44
47
  * The scope panel's HTTP seam. Defaults to the real same-origin client;
45
48
  * injected by tests in the same style as `eventSourceFactory`, so no test has
@@ -69,6 +72,19 @@ const defaultEventSourceFactory: EventSourceFactory = (url) => {
69
72
  */
70
73
  const EVENT_SOURCE_UNAVAILABLE = 'event-source-unavailable'
71
74
 
75
+ /**
76
+ * One description for a cause, shared by the two catches below so they cannot
77
+ * drift. Two markers get the dictionary's own sentence — the missing
78
+ * `EventSource` and a request that never answered (`api.ts`'s ceiling) — because
79
+ * both are INTERNAL tokens that must never render; everything else is the
80
+ * foreign message, interpolated verbatim as data.
81
+ */
82
+ function describeCause(t: T, cause: unknown): string {
83
+ if (cause instanceof Error && cause.message === EVENT_SOURCE_UNAVAILABLE) return t('app.eventSourceUnavailable')
84
+ if (cause instanceof Error && cause.message === REQUEST_TIMEOUT) return t('app.error.timeout')
85
+ return cause instanceof Error ? cause.message : String(cause)
86
+ }
87
+
72
88
  /** Parses one SSE frame payload, ignoring a malformed one rather than crashing. */
73
89
  function parseFrame<T>(data: string): T | undefined {
74
90
  try {
@@ -81,6 +97,7 @@ function parseFrame<T>(data: string): T | undefined {
81
97
  export function App({
82
98
  eventSourceFactory = defaultEventSourceFactory,
83
99
  codeLifetimeMs,
100
+ runLifetimeMs,
84
101
  scopeClient,
85
102
  }: AppProps): ReturnType<typeof Object> {
86
103
  const t = useT()
@@ -101,13 +118,7 @@ export function App({
101
118
  // The opener's own token means "no EventSource here", which has a
102
119
  // sentence of its own in both dictionaries; anything else is a foreign
103
120
  // message and is interpolated verbatim (data, not copy).
104
- setError(
105
- cause instanceof Error && cause.message === EVENT_SOURCE_UNAVAILABLE
106
- ? t('app.eventSourceUnavailable')
107
- : cause instanceof Error
108
- ? cause.message
109
- : String(cause),
110
- )
121
+ setError(describeCause(t, cause))
111
122
  return undefined
112
123
  }
113
124
 
@@ -142,7 +153,7 @@ export function App({
142
153
  setError(undefined)
143
154
  },
144
155
  (cause: unknown) => {
145
- setError(cause instanceof Error ? cause.message : String(cause))
156
+ setError(describeCause(t, cause))
146
157
  setListed(true)
147
158
  },
148
159
  )
@@ -211,6 +222,7 @@ export function App({
211
222
  subscribed={subscribed}
212
223
  progress={progress}
213
224
  {...(codeLifetimeMs === undefined ? {} : { codeLifetimeMs })}
225
+ {...(runLifetimeMs === undefined ? {} : { runLifetimeMs })}
214
226
  onRunStart={startRun}
215
227
  onSettled={onSettled}
216
228
  />
@@ -221,7 +233,11 @@ export function App({
221
233
 
222
234
  <section className="cherry-panel" aria-labelledby="cherry-devices-title">
223
235
  <h3 id="cherry-devices-title">{t('app.devices.heading')}</h3>
224
- {sorted.length === 0 && (
236
+ {/* Three states, not two: the list is EMPTY only once a read said so.
237
+ After a failed read it is UNKNOWN, and claiming 「暂无已配对设备」 beside
238
+ the error that says the read failed would contradict that error — so the
239
+ error line speaks alone. */}
240
+ {sorted.length === 0 && error === undefined && (
225
241
  <p className="cherry-hint">{listed ? t('app.devices.empty') : t('app.devices.loading')}</p>
226
242
  )}
227
243
  <ul className="cherry-cards">