dsh-cherry-bridge 0.1.11 → 0.2.0

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.
@@ -5,6 +5,7 @@ import {
5
5
  remoteFailureSchema,
6
6
  } from '@cherrystudio/remote-protocol'
7
7
  import type { SecureChannel } from '@cherrystudio/remote-transport'
8
+ import { failureText } from '../text.js'
8
9
 
9
10
  /** Default cap on business requests in flight on a single connection. */
10
11
  const DEFAULT_MAX_IN_FLIGHT = 8
@@ -558,7 +559,24 @@ export class JsonRpcClient {
558
559
  try {
559
560
  handler(method, params)
560
561
  } catch (error) {
561
- console.warn(`[cherry-remote] notification handler for "${method}" threw`, error)
562
+ // BOUNDED, and ONE STRING (issue #155; rendered by the shared
563
+ // `failureText`, issue #161). The old form passed the error OBJECT as
564
+ // a second argument, so the console chose the rendering: the stack
565
+ // plus every own property, with nothing bounding it. A handler
566
+ // failure is not a payload, so the line carries what an operator
567
+ // needs and nothing else — the METHOD and the MESSAGE under
568
+ // LOG_TEXT_MAX. The method is already bounded twice over, without
569
+ // clipText: the notification schema caps `method` at 128 characters
570
+ // and `bounded()` (the frame-drop path's older, count-less form,
571
+ // deliberately kept: folding it into clipText would change its bare
572
+ // `…` marker and the caller's asserted text) clips to 120 + an
573
+ // ellipsis.
574
+ // Ceiling: `[cherry-remote] notification handler for "` (42) +
575
+ // bounded method (≤ 121: FRAME_FIELD_LIMIT 120 + the ellipsis) +
576
+ // `" threw: ` (9) + LOG_TEXT_MAX (240) = 412.
577
+ console.warn(
578
+ `[cherry-remote] notification handler for "${bounded(method)}" threw: ${failureText(error)}`,
579
+ )
562
580
  }
563
581
  }
564
582
  return
@@ -1,6 +1,7 @@
1
1
  import { JsonRpcRequestError } from './rpc/jsonRpcClient.js'
2
2
  import type { JsonRpcClient } from './rpc/jsonRpcClient.js'
3
3
  import { DEFAULT_PER_SESSION_CACHE_BYTES } from './defaults.js'
4
+ import { failureText } from './text.js'
4
5
  import type { AgentCursor, AgentEvent } from '@cherrystudio/remote-protocol/agent'
5
6
 
6
7
  /**
@@ -760,8 +761,15 @@ export class SessionSubscriptions {
760
761
  await this.#rpc.request('agent.subscriptions.close', { subscriptionId })
761
762
  } catch (error) {
762
763
  // Closing is best-effort: the peer may already have reset it, and the
763
- // caller is about to establish a replacement either way.
764
- console.warn(`[cherry-remote] closing subscription ${subscriptionId} failed`, error)
764
+ // caller is about to establish a replacement either way. ONE bounded
765
+ // STRING (issue #161): the refusal is the PEER's JSON-RPC
766
+ // `error.message` verbatim, so the old object print let the console
767
+ // render peer text with no local bound. Ceiling:
768
+ // `[cherry-remote] closing subscription ` (37) + subscriptionId +
769
+ // ` failed: ` (9) + LOG_TEXT_MAX (240).
770
+ console.warn(
771
+ `[cherry-remote] closing subscription ${subscriptionId} failed: ${failureText(error)}`,
772
+ )
765
773
  }
766
774
  }
767
775
 
package/src/text.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Bounded rendering for peer- and payload-controlled text (issue #155).
3
+ *
4
+ * The ONE helper this audit STANDARDISED ON — not the first attempt at the
5
+ * idea: `src/diagnostics.ts`'s `truncateErrorMessage` (the diagnostics error
6
+ * digest) and `src/rpc/jsonRpcClient.ts`'s `bounded()` (the frame-drop
7
+ * warning's classification fields) predate it and stay where they are, for
8
+ * their own callers. #155 is where the rendering rule was stated once and
9
+ * applied to the SIX sites the audit found — four files: pairing's unknown-key
10
+ * warning and stage verdict, the link-recovery line, the two connection error
11
+ * lines, and the JSON-RPC notification-handler line. Each was unbounded for a
12
+ * different "why not" (a 10 KiB payload cap, a hello-advertised field, the
13
+ * 64 KiB record budget), which is exactly how the class of defect recurs: each
14
+ * site answered the question for itself.
15
+ *
16
+ * The rule these helpers establish is stated once, here: bound the text where
17
+ * it is RENDERED (never where it is carried — an error message is a value
18
+ * until a log line or a verdict makes it text), name the amount removed with
19
+ * the `… (+N chars)` marker, and never drop a list ENTRY silently (the
20
+ * `(+K more)` count is explicit).
21
+ *
22
+ * Issue #161 swept the SAME SHAPE at the sites #155 did not name — a catch
23
+ * handing an error OBJECT to `console.warn`'s second argument — on the paths
24
+ * PEER traffic reaches. One renderer states the answer there, {@link
25
+ * failureText}: the message alone, bounded. A purely LOCAL catch keeps the
26
+ * object print, deliberately: its stack is the useful part and no peer text
27
+ * can reach it (fs quarantine, the host registry, our own listener / settle
28
+ * fan-out, a REST route's synchronous construction). The two earlier forms it
29
+ * did NOT absorb — `truncateErrorMessage` (the diagnostics digest) and
30
+ * `bounded()` (the frame-drop path) — are left alone: each emits a different
31
+ * marker (`…[truncated]`, a bare `…`) that callers and docs depend on, so
32
+ * folding would change output rather than converge on the rule.
33
+ */
34
+
35
+ /**
36
+ * Clips `text` into `max` characters, the `… (+N chars)` marker included in
37
+ * the count. `kept` is a fixed point — the number of removed characters sets
38
+ * the marker's own digits — and it is the LARGEST prefix that fits, so a
39
+ * caller's ceiling is used without ever being exceeded.
40
+ *
41
+ * The final guard never ends the kept prefix between a UTF-16 surrogate pair: a
42
+ * clip there would emit a lone surrogate, which is not valid unicode text (the
43
+ * peer's `unicodeText` params face refuses it) and would travel in a
44
+ * user-visible verdict. Dropping one char pays for a marker that can grow by
45
+ * one digit at a 9→10 boundary, and the marker is re-derived from the new
46
+ * dropped count. Pinned by `tests/unit/text.test.ts` — the branch used to be a
47
+ * declared coverage gap while the clip point was private to pairing.
48
+ */
49
+ export function clipText(text: string, max: number): string {
50
+ if (text.length <= max) return text
51
+ let kept = max
52
+ for (;;) {
53
+ const next = max - `… (+${text.length - kept} chars)`.length
54
+ if (next === kept) break
55
+ // A room too small for the marker cannot carry both; admit the loss with
56
+ // the bare ellipsis rather than overrun the caller's bound.
57
+ if (next < 0) return '…'.slice(0, max)
58
+ kept = next
59
+ }
60
+ const before = text.charCodeAt(kept - 1)
61
+ const after = text.charCodeAt(kept)
62
+ if (kept > 0 && before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) {
63
+ kept -= 1
64
+ }
65
+ return `${text.slice(0, kept)}… (+${text.length - kept} chars)`
66
+ }
67
+
68
+ /**
69
+ * Renders `items` as a comma-joined list under `limits.max`, each item clipped
70
+ * to `limits.itemMax` FIRST. When anything is left out the list says so:
71
+ * ` (+K more)` where K is the number of items NOT named.
72
+ *
73
+ * Caller contract: `itemMax <= max`, so the first item always fits and a list
74
+ * carrying a single over-long entry never renders an empty one. The marker
75
+ * rides ON TOP of `limits.max` — it is a count, not an item, so the named text
76
+ * never has to absorb its length. `[]` renders as `''`.
77
+ *
78
+ * Pairing's progress line (`stageHosts`) deliberately does NOT use this helper:
79
+ * it always shows the FIRST host in full even above its own budget, because
80
+ * that host is the run's identity. That is not an oversight, and it is not a
81
+ * reason to weaken the contract here.
82
+ */
83
+ export function boundedJoin(
84
+ items: readonly string[],
85
+ limits: { max: number; itemMax: number },
86
+ ): string {
87
+ const rendered = items.map((item) => clipText(item, limits.itemMax))
88
+ const first = rendered[0]
89
+ if (first === undefined) return ''
90
+ let text = first
91
+ let shown = 1
92
+ for (; shown < rendered.length; shown += 1) {
93
+ // The separator is charged to the same budget as the item: a list that
94
+ // stops two characters early on paper would still refuse an entry it could
95
+ // have named.
96
+ if (text.length + 2 + rendered[shown]!.length > limits.max) break
97
+ text += `, ${rendered[shown]}`
98
+ }
99
+ const omitted = items.length - shown
100
+ return omitted > 0 ? `${text} (+${omitted} more)` : text
101
+ }
102
+
103
+ /**
104
+ * The ceiling for ONE peer- or payload-controlled detail interpolated into a
105
+ * log line: 240 characters, the `… (+N chars)` marker included.
106
+ *
107
+ * It is deliberately NOT the same budget as pairing's `FAILURE_SEGMENT_MAX`,
108
+ * although both read 240: that one bounds the WHOLE `<label>: <detail>`, so
109
+ * the label eats from it and the detail there is at most 218 (a
110
+ * `pairing hello failed` label — 20 characters — plus its `': '`) and often
111
+ * less. Same number, different quantity: this one is the detail alone.
112
+ *
113
+ * Call sites. Two shapes are rendered with it:
114
+ *
115
+ * - peer-controlled DETAIL through {@link clipText}: `src/connection.ts`'s
116
+ * protocol-version console.error (the cause is the peer's own hello-echo
117
+ * text) and transient-failure console.warn (the transport's own message).
118
+ * - a caught FAILURE through {@link failureText} (issue #161), on the catches
119
+ * peer traffic reaches: `src/index.ts`'s notification routing and
120
+ * subscription recovery, `src/tools.ts`'s ack and both background re-ensure
121
+ * arms plus its `#assemblyFailed` log line, `src/subscriptions.ts`'s
122
+ * subscription-close catch, `src/httpApi.ts`'s `warnConnect` rejection, and
123
+ * the JSON-RPC notification-handler catch in `src/rpc/jsonRpcClient.ts`
124
+ * (#155). None of them passes the error object.
125
+ *
126
+ * Other bounded faces state their own value beside the line they bound
127
+ * (`UNKNOWN_FIELDS_MAX` / `RECOVERY_SESSIONS_MAX`), because what they bound is
128
+ * a rendered LIST or a joined field, not one detail.
129
+ */
130
+ export const LOG_TEXT_MAX = 240
131
+
132
+ /**
133
+ * Renders ONE caught failure for a log line: the message alone, clipped to
134
+ * {@link LOG_TEXT_MAX}.
135
+ *
136
+ * This is the ONE form a caught failure takes in a PEER-TRIGGERED catch (issue
137
+ * #161): a catch reached from peer traffic or a peer-triggered callback. The
138
+ * old shape — `console.warn(prefix, error)` — handed the console the OBJECT.
139
+ * On Node the console prints the error's name, message and stack (own
140
+ * properties only when inspected), so the peer-controlled message and the
141
+ * stack were both unbounded; for a defensive catch the stack is the noise.
142
+ *
143
+ * A caught failure from a purely LOCAL path (an fs copy, the host registry, our
144
+ * own observers) keeps the object print: its stack is the useful part and no
145
+ * peer text can reach it.
146
+ *
147
+ * TOTAL by construction: it runs INSIDE catch blocks, so a captured value whose
148
+ * `toString` (or an Error subclass whose `message` getter) throws must not
149
+ * escape the catch. The extraction is guarded and falls back to the fixed token
150
+ * `<unrenderable failure>`.
151
+ */
152
+ export function failureText(error: unknown): string {
153
+ try {
154
+ return clipText(error instanceof Error ? error.message : String(error), LOG_TEXT_MAX)
155
+ } catch {
156
+ return '<unrenderable failure>'
157
+ }
158
+ }
package/src/tools.ts CHANGED
@@ -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
  /**
@@ -606,6 +607,42 @@ export interface CherryCreateResult {
606
607
 
607
608
  /** The protocol's single-page hard cap, used only as the pre-hello ceiling. */
608
609
  const PAGE_ITEMS_FALLBACK = 100
610
+ /**
611
+ * The page this bridge asks for when the peer's hello advertised
612
+ * `agentMessageSummariesVersion: 1` (issue #105).
613
+ *
614
+ * WHY IT IS NOT {@link LIST_PAGE_CLAMP} (16): that clamp is metadata-BLIND. It
615
+ * exists because `AgentMessage` carries `partIds` — up to 4,096 entries of
616
+ * schema-capped ids each — so a count-only peer can answer a page the 65,536-byte
617
+ * record cannot hold and the transport kills the connection (`defaults.ts`).
618
+ * What `partsMode: 'paged'` returns is `AgentMessageSummary`: identity,
619
+ * revision, role, status and optional usage/model/failure, and NO `partIds`.
620
+ *
621
+ * WHY THE PAGE BOUND IS THE PEER'S, NOT OURS: the ONLY verified advertiser of
622
+ * the capability (desktop 2.1.8) budgets the ACCUMULATED page — `pageOf`
623
+ * prefix-truncates at `PAGE_ITEM_BYTES` = 62,279 bytes and `queryPage` re-asks
624
+ * at a halved limit, so its reply records stay under `recordBytes` = 65,536
625
+ * whatever our `limit` says, answering with a SHORT page + `nextCursor` (the
626
+ * shape our walk already tolerates) instead of a killed link. The single-item
627
+ * `RESOURCE_EXHAUSTED {ITEM_TOO_LARGE, retryable: false}` refusal is the
628
+ * exception arm, and a schema-valid summary row cannot reach it: measured
629
+ * against the 0.7.0 schema, the worst row (ids at 256 units, `model` fields at
630
+ * 2,048 chars each, 32 `costs` entries, `failure` refined to ≤4,096 bytes) is
631
+ * ≈37 KB — under the 62,279 budget, so the refusal exists for full
632
+ * `partIds`-bearing rows, not summaries.
633
+ *
634
+ * 100 is the protocol's own `pageItems` cap, i.e. the ceiling a walk can use at
635
+ * all: `#aggregate` still intersects this with the peer's NEGOTIATED
636
+ * `pageItems`, so a peer advertising less keeps its size. Residual exposure —
637
+ * a hypothetical peer advertising the capability WITHOUT a page budget —
638
+ * contains no verified desktop (≤2.1.7 never advertises and keeps the clamp),
639
+ * the same "pragmatic, not provable" class `defaults.ts` already scopes.
640
+ *
641
+ * The PARTS walk keeps {@link PARTS_LIST_PAGE_CLAMP} unconditionally: nothing
642
+ * in protocol 0.7.0 proves `agent.parts.list` is byte-aware, and a part is the
643
+ * fattest item there is.
644
+ */
645
+ const MESSAGE_SUMMARIES_PAGE_ITEMS = PAGE_ITEMS_FALLBACK
609
646
  /** The largest MESSAGE aggregate a caller may ask for; the per-page clamp still applies. */
610
647
  const MAX_AGGREGATE_LIMIT = 1000
611
648
  /**
@@ -751,6 +788,33 @@ interface AggregateWalk<T> {
751
788
  deadline?: AssemblyDeadline
752
789
  }
753
790
 
791
+ /**
792
+ * The `agent.messages.list` request for ONE message walk, and the page size to
793
+ * ask for — the ONE place the paged-summaries dialect is decided (issue #105).
794
+ *
795
+ * THE INVARIANT: `partsMode` is sent ONLY to a peer whose own hello advertised
796
+ * `agentMessageSummariesVersion: 1`. 0.5.0's params schema — what every desktop
797
+ * through 2.1.7 ships — is a STRICT object with no such key, so sending it
798
+ * there is a protocol violation, not a wasted field. Both message-walk faces
799
+ * (`cherry_read` and the terminal/timeout/idle aggregate) come through HERE for
800
+ * the same reason the resume token has one helper: two call sites spelling the
801
+ * dialect would eventually disagree about which peer may hear it.
802
+ *
803
+ * With the capability, the page size is {@link MESSAGE_SUMMARIES_PAGE_ITEMS} —
804
+ * summary rows are metadata, so the byte-safe clamp no longer bounds anything
805
+ * they can carry. Without it, the clamp stays exactly as it was: the
806
+ * count-paged peer still returns `partIds`-bearing rows.
807
+ */
808
+ function messageListRequest(connection: DeviceConnection): {
809
+ params: Record<string, unknown>
810
+ maxPerPage: number
811
+ } {
812
+ if (connection.limits.messageSummaries === 1) {
813
+ return { params: { partsMode: 'paged' }, maxPerPage: MESSAGE_SUMMARIES_PAGE_ITEMS }
814
+ }
815
+ return { params: {}, maxPerPage: LIST_PAGE_CLAMP }
816
+ }
817
+
754
818
  /** Token prefix for a peer page position; the one `#aggregate` can send back. */
755
819
  const RESUME_CURSOR_PREFIX = 'cursor:'
756
820
  /** Token prefix for "strictly older than this message id". */
@@ -1295,6 +1359,55 @@ function failureReason(error: unknown): string | undefined {
1295
1359
  return undefined
1296
1360
  }
1297
1361
 
1362
+ /**
1363
+ * The phrase this hint leaves behind, so applying it twice reads once.
1364
+ */
1365
+ const PEER_REFUSAL_FINAL_MARK = 'retrying the same request cannot help'
1366
+
1367
+ /**
1368
+ * The recovery advice a PERMANENT peer refusal grows — `RESOURCE_EXHAUSTED`
1369
+ * carrying `details.retryable === false`, which is the desktop's per-item byte
1370
+ * budget (`PAGE_ITEM_BYTES` = 62,279; `details.kind` is `ITEM_TOO_LARGE` with
1371
+ * `itemBytes` / `budgetBytes`), issue #105.
1372
+ *
1373
+ * WHY THE WARNING HAS TO BE ADDED: the official client marks every
1374
+ * `RESOURCE_EXHAUSTED` RETRYABLE and re-asks at the same page size on an
1375
+ * exponential backoff, so a byte-budget refusal never converges — and the
1376
+ * condition really is permanent: the same item over the same budget will be
1377
+ * over it again. A caller (or a model reading the error) that retries learns
1378
+ * nothing but spends the peer's rate window.
1379
+ *
1380
+ * The sentence is deliberately narrower than the reason: only the
1381
+ * `retryable === false` shape grows it. Every other `RESOURCE_EXHAUSTED` —
1382
+ * the upload window, the subscription cap, a legacy refusal with no `details` —
1383
+ * keeps its own text, because some of those ARE worth a later retry.
1384
+ *
1385
+ * Like {@link withIdempotencyHint} this EXTENDS `message` only: the reason, the
1386
+ * structured `data` and the {@link JsonRpcRequestError} identity stay the
1387
+ * peer's, so branching callers see exactly what it sent.
1388
+ */
1389
+ export function withPeerRefusalHint(error: unknown): unknown {
1390
+ if (!(error instanceof JsonRpcRequestError)) return error
1391
+ if (failureReason(error) !== 'RESOURCE_EXHAUSTED') return error
1392
+ const data = error.data
1393
+ if (!isRecord(data) || !isRecord(data['details']) || data['details']['retryable'] !== false) return error
1394
+ if (error.message.includes(PEER_REFUSAL_FINAL_MARK)) return error
1395
+ const kind = optionalString(data['details']['kind'])
1396
+ // The two permanent shapes need OPPOSITE advice. ITEM_TOO_LARGE means ONE
1397
+ // item alone exceeds the per-item budget: no page size fits it — that item
1398
+ // and everything behind it are unreachable (the upstream #21951 dead-end),
1399
+ // so suggesting a smaller `limit` would burn round trips learning nothing.
1400
+ const cause = kind === 'ITEM_TOO_LARGE' ? 'an ITEM_TOO_LARGE page item over its byte budget' : 'the same item'
1401
+ const tail =
1402
+ kind === 'ITEM_TOO_LARGE'
1403
+ ? '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)'
1404
+ : 'ask for FEWER items per page (a smaller `limit`), which is the only change that can fit the budget'
1405
+ error.message =
1406
+ `${error.message} — the peer marked this refusal permanent (\`details.retryable: false\`: ${cause}); ` +
1407
+ `retrying the same request cannot help — ${tail}`
1408
+ return error
1409
+ }
1410
+
1298
1411
  /**
1299
1412
  * One `{ref}`'s content could not be TRUSTED: the bytes served do not match the
1300
1413
  * digest the ref advertises (or there was no digest to check against).
@@ -1757,7 +1870,19 @@ export function registerCherryToolsWithEngine(
1757
1870
  }
1758
1871
  }
1759
1872
 
1760
- /** One tool declaration, in the exact shape the host registry consumes. */
1873
+ /**
1874
+ * One tool declaration, in the exact shape the host registry consumes.
1875
+ *
1876
+ * This helper adds no result WRAPPER and this ticket adds no result-side
1877
+ * truncation (issue #161): a tool result is the MODEL's signal, and the peer is
1878
+ * the user's own Noise-authenticated desktop, so shortening the peer's
1879
+ * explanation costs the caller the text it acts on. The two result-text clamps
1880
+ * that DO exist are per-field product choices, not a log rule — `aggregateError`
1881
+ * (`message.slice(0, 200)` in `#assemblyFailed`) and `truncateErrorMessage`'s
1882
+ * 200-character diagnostics digest used by `projectMessageFailure`. The LOG
1883
+ * face is where boundedness is required (`failureText` / `clipText` in `src/text.ts`);
1884
+ * this ticket's sweep does not change tool output.
1885
+ */
1761
1886
  function defineCherryTool(definition: {
1762
1887
  name: string
1763
1888
  description: string
@@ -2399,7 +2524,12 @@ export class CherryToolEngine {
2399
2524
  if (deadline !== undefined && deadlineExhaustion(error)) {
2400
2525
  return this.#truncated(items, 'deadline', cursor)
2401
2526
  }
2402
- throw error
2527
+ // A PERMANENT peer refusal (issue #105) grows its advice HERE, at the
2528
+ // one seam every paged walk's refusals pass through, and ONLY the
2529
+ // `details.retryable: false` shape: a walk has no retry of its own to
2530
+ // reason about, and the caller is the party that must change the
2531
+ // request. The error object, its reason and its `data` are unchanged.
2532
+ throw withPeerRefusalHint(error)
2403
2533
  }
2404
2534
  const extracted = extract(result)
2405
2535
  // THE PAGE'S OWN SHAPE (issue #46), judged on EVERY page — terminal or
@@ -3018,15 +3148,18 @@ export class CherryToolEngine {
3018
3148
  throw new Error(`agent.sessions.get returned no historyRevision for session ${sessionId}`)
3019
3149
  }
3020
3150
 
3151
+ // The paged-summaries dialect (issue #105) from the ONE helper both message
3152
+ // faces share: `partsMode` only for a peer whose hello advertised it.
3153
+ const messageList = messageListRequest(connection)
3021
3154
  const page = await this.#aggregate(
3022
3155
  connection,
3023
3156
  'agent.messages.list',
3024
- { sessionId, historyRevision },
3157
+ { sessionId, historyRevision, ...messageList.params },
3025
3158
  (result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
3026
3159
  {
3027
3160
  limit,
3028
3161
  safetyBound: MAX_AGGREGATE_LIMIT,
3029
- maxPerPage: LIST_PAGE_CLAMP,
3162
+ maxPerPage: messageList.maxPerPage,
3030
3163
  resume:
3031
3164
  resume === undefined
3032
3165
  ? undefined
@@ -3348,7 +3481,7 @@ export class CherryToolEngine {
3348
3481
  * '{\"mediaType\":…,\"filename\":…}'}}` — the metadata SERIALIZED AS JSON in
3349
3482
  * `content.text`, observed on the wire (2.1.7 desktop, image upload,
3350
3483
  * 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
3484
+ * peer at all: the pinned protocol 0.7.0 types `content` as a strict
3352
3485
  * `{text}` | `{ref}` union, so the old documentation described a shape no
3353
3486
  * peer can emit — and the real one was never lifted, which is how an image
3354
3487
  * upload reached a model as a JSON blob.
@@ -3825,7 +3958,16 @@ export class CherryToolEngine {
3825
3958
  try {
3826
3959
  await subscriptions.ack(sessionId, result.nextCursor)
3827
3960
  } catch (error) {
3828
- console.warn(`[cherry-remote] acknowledging session ${sessionId} at ${result.nextCursor.seq} failed`, error)
3961
+ // ONE bounded STRING (issue #161): the ack refusal is the PEER's
3962
+ // JSON-RPC `error.message` verbatim (a CONFLICT from an out-of-window
3963
+ // cursor, a reset race), so the old object print let the console
3964
+ // render peer text with no local bound. Ceiling:
3965
+ // `[cherry-remote] acknowledging session ` (38) + sessionId +
3966
+ // ` at ` (4) + seq + ` failed: ` (9) + LOG_TEXT_MAX (240).
3967
+ console.warn(
3968
+ `[cherry-remote] acknowledging session ${sessionId} at ${result.nextCursor.seq} failed: ` +
3969
+ failureText(error),
3970
+ )
3829
3971
  this.#reEnsureInBackground(record.deviceId, sessionId)
3830
3972
  }
3831
3973
  return { events: result.events, nextCursor: result.nextCursor }
@@ -6577,15 +6719,25 @@ export class CherryToolEngine {
6577
6719
  * identity object: the verdict is already known and true, and only the
6578
6720
  * content behind it failed.
6579
6721
  *
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.
6722
+ * `aggregateError` is a TOOL RESULT, not a log line — see
6723
+ * {@link defineCherryTool} for the product decision (issue #161): a result is
6724
+ * the MODEL's signal, so it is not bounded by the logging rule, and the
6725
+ * 200-character clip here is this field's own choice.
6726
+ *
6727
+ * The LOG line IS the logging face, and it renders the failure as ONE bounded
6728
+ * string through {@link failureText} (issue #161). An assembly failure is
6729
+ * usually a peer RPC refusal — its `error.message` is the peer's own text,
6730
+ * capped at 512 characters by the failure schema — and the old line
6731
+ * interpolated it whole: one 512-char refusal made a 654-character line for
6732
+ * the `timeout` / `s-1` fixture (83 fixed prefix + 512 + 59 suffix). The
6733
+ * RESULT keeps the raw message's first 200 characters; a bound on the log
6734
+ * does not change what the model receives.
6583
6735
  */
6584
6736
  #assemblyFailed(identity: Record<string, unknown>, error: unknown): Record<string, unknown> {
6585
6737
  const message = error instanceof Error ? error.message : String(error)
6586
6738
  console.warn(
6587
6739
  `[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`,
6740
+ `could not assemble its content (${failureText(error)}); returning the verdict with the assembly failure reported`,
6589
6741
  )
6590
6742
  return {
6591
6743
  ...identity,
@@ -6681,12 +6833,14 @@ export class CherryToolEngine {
6681
6833
  const historyRevision = optionalString(session['historyRevision'])
6682
6834
  if (historyRevision === undefined) return { messages: [] }
6683
6835
 
6836
+ // The SAME dialect `cherry_read` sends, from the same helper (issue #105).
6837
+ const messageList = messageListRequest(connection)
6684
6838
  const page = await this.#aggregate(
6685
6839
  connection,
6686
6840
  'agent.messages.list',
6687
- { sessionId, historyRevision },
6841
+ { sessionId, historyRevision, ...messageList.params },
6688
6842
  (result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
6689
- { limit: TERMINAL_AGGREGATE_MESSAGES, deadline },
6843
+ { limit: TERMINAL_AGGREGATE_MESSAGES, deadline, maxPerPage: messageList.maxPerPage },
6690
6844
  )
6691
6845
 
6692
6846
  const messages: CherryReadMessage[] = []
@@ -6761,11 +6915,24 @@ export class CherryToolEngine {
6761
6915
  try {
6762
6916
  layer = this.#deps.subs(deviceId)
6763
6917
  } catch (error) {
6764
- console.warn(`[cherry-remote] could not re-ensure session ${sessionId} of ${deviceId}`, error)
6918
+ // ONE bounded STRING (issue #161). This accessor's throw is LOCAL (no
6919
+ // record for the device, or the identity never loaded), but the arm is
6920
+ // converted with its sibling so the background re-ensure has ONE failure
6921
+ // shape and no branch can hand the console an unbounded object.
6922
+ console.warn(
6923
+ `[cherry-remote] could not re-ensure session ${sessionId} of ${deviceId}: ${failureText(error)}`,
6924
+ )
6765
6925
  return
6766
6926
  }
6767
6927
  void layer.ensure(sessionId).catch((error: unknown) => {
6768
- console.warn(`[cherry-remote] re-ensuring session ${sessionId} of ${deviceId} failed`, error)
6928
+ // ONE bounded STRING (issue #161): `ensure` reaches the peer, so the
6929
+ // rejection message is the peer's JSON-RPC `error.message` verbatim (up
6930
+ // to the 512-char failure schema cap) or a transport message. The old
6931
+ // form passed the error OBJECT, so the console printed its stack plus
6932
+ // every own property unbounded.
6933
+ console.warn(
6934
+ `[cherry-remote] re-ensuring session ${sessionId} of ${deviceId} failed: ${failureText(error)}`,
6935
+ )
6769
6936
  })
6770
6937
  }
6771
6938
 
package/ui/AGENTS.md ADDED
@@ -0,0 +1,23 @@
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
+ - **jsdom only.** `tests/unit/ui.test.ts` and `uiScopePanel.test.ts` render the
22
+ page; a browser-only API needs a fake in the test, not a guard in the page.
23
+
@@ -42,6 +42,22 @@ function parseHosts(raw: string): string[] {
42
42
  .filter((entry) => entry !== '')
43
43
  }
44
44
 
45
+ /** The dial port for one host: its own stored port, else the record-level one. */
46
+ function dialPort(device: DeviceSummary, host: string): number {
47
+ return device.ports?.[host] ?? device.port
48
+ }
49
+
50
+ /**
51
+ * `host:port` as one readable address.
52
+ *
53
+ * An IPv6 literal contains colons, so `fe80::1:4343` reads as an ambiguous
54
+ * host; stored hosts are bare (`normalizeHost` strips the brackets), so the
55
+ * brackets are added here, for display only.
56
+ */
57
+ function formatAddress(host: string, port: number): string {
58
+ return host.includes(':') ? `[${host}]:${port}` : `${host}:${port}`
59
+ }
60
+
45
61
  export function DeviceCard({ device, state, onSaved, onRemoved }: DeviceCardProps): ReturnType<typeof Object> {
46
62
  const t = useT()
47
63
  const [editor, setEditor] = useState<Editor>('none')
@@ -141,6 +157,13 @@ export function DeviceCard({ device, state, onSaved, onRemoved }: DeviceCardProp
141
157
  })
142
158
  }
143
159
 
160
+ // The record-level `port` alone would claim every host dials it, which is
161
+ // false for a record whose map disagrees. `mixedPorts` decides whether the
162
+ // card has anything to say beyond that single number; `addressList` is the
163
+ // per-host truth (map value, record port as the fallback).
164
+ const mixedPorts = device.hosts.some((host) => dialPort(device, host) !== device.port)
165
+ const addressList = device.hosts.map((host) => formatAddress(host, dialPort(device, host))).join(', ')
166
+
144
167
  // `?? state` keeps an unknown future state transparent — the enum is the only
145
168
  // honest thing left to show for one this build has no copy for — while every
146
169
  // state it DOES know renders through the dictionary.
@@ -183,10 +206,18 @@ export function DeviceCard({ device, state, onSaved, onRemoved }: DeviceCardProp
183
206
  <dd>{device.hosts.join(', ')}</dd>
184
207
  <dt>{t('device.field.port')}</dt>
185
208
  <dd>{device.port}</dd>
209
+ {mixedPorts && (
210
+ <>
211
+ <dt>{t('device.field.ports')}</dt>
212
+ <dd className="cherry-mono">{addressList}</dd>
213
+ </>
214
+ )}
186
215
  <dt>{t('device.field.id')}</dt>
187
216
  <dd className="cherry-mono">{device.deviceId}</dd>
188
217
  </dl>
189
218
 
219
+ {mixedPorts && <p className="cherry-hint">{t('device.ports.hint')}</p>}
220
+
190
221
  {editor === 'rename' && (
191
222
  <form className="cherry-inline-form" onSubmit={saveName}>
192
223
  <label htmlFor={`name-${device.deviceId}`}>{t('device.field.name')}</label>
@@ -210,6 +241,9 @@ export function DeviceCard({ device, state, onSaved, onRemoved }: DeviceCardProp
210
241
  onChange={(event: ChangeEvent<HTMLInputElement>) => setHostsText(event.target.value)}
211
242
  />
212
243
  <span className="cherry-hint">{t('device.hosts.hint')}</span>
244
+ <span className="cherry-hint">
245
+ {t('device.ports.current')} <span className="cherry-mono">{addressList}</span>
246
+ </span>
213
247
  <label htmlFor={`port-${device.deviceId}`}>{t('device.field.port')}</label>
214
248
  <input
215
249
  id={`port-${device.deviceId}`}
@@ -217,6 +251,7 @@ export function DeviceCard({ device, state, onSaved, onRemoved }: DeviceCardProp
217
251
  value={portText}
218
252
  onChange={(event: ChangeEvent<HTMLInputElement>) => setPortText(event.target.value)}
219
253
  />
254
+ <span className="cherry-hint">{t('device.port.hint')}</span>
220
255
  <button type="submit" disabled={busy}>
221
256
  {t('device.save.addresses')}
222
257
  </button>
package/ui/src/i18n.ts CHANGED
@@ -86,6 +86,10 @@ const zh: Dict = {
86
86
  'device.field.name': '设备名称',
87
87
  'device.field.hosts': '主机地址',
88
88
  'device.hosts.hint': '多个候选地址用逗号分隔(整体替换)',
89
+ 'device.field.ports': '每地址端口',
90
+ 'device.ports.hint': '部分候选地址的端口与上方的端口不同;在「地址维护」里改动端口会把所有候选地址改为同一端口。',
91
+ 'device.ports.current': '当前端口映射:',
92
+ 'device.port.hint': '保持当前值会保留各地址自己的端口;改成新值会把所有候选地址重指到该端口。',
89
93
  'device.save.name': '保存名称',
90
94
  'device.save.addresses': '保存地址',
91
95
  'device.remove.confirm': '确认移除',
@@ -236,6 +240,12 @@ const en: Dict = {
236
240
  'device.field.name': 'Device name',
237
241
  'device.field.hosts': 'Host addresses',
238
242
  'device.hosts.hint': 'Separate candidate addresses with commas (the whole list is replaced)',
243
+ 'device.field.ports': 'Ports per address',
244
+ 'device.ports.hint':
245
+ 'Some candidate addresses dial a different port than the one above; changing the port in address maintenance moves every candidate address to that one port.',
246
+ 'device.ports.current': 'Current port map:',
247
+ 'device.port.hint':
248
+ 'Keeping the current value preserves each address’s own port; a new value re-points every candidate address.',
239
249
  'device.save.name': 'Save name',
240
250
  'device.save.addresses': 'Save addresses',
241
251
  'device.remove.confirm': 'Confirm removal',
package/ui/src/types.ts CHANGED
@@ -23,7 +23,14 @@ export const API_BASE = '/cherry-remote/api'
23
23
  /** The five device states the connection layer reports. */
24
24
  export type DeviceState = 'connected' | 'connecting' | 'offline' | 'disabled' | 'identity-mismatch'
25
25
 
26
- /** One device exactly as `GET /devices` returns it. Never carries a credential. */
26
+ /**
27
+ * One device exactly as `GET /devices` returns it. Never carries a credential.
28
+ *
29
+ * `ports` is the per-HOST dial port map: each candidate address dials its own
30
+ * port, and the record-level `port` is the fallback for a host the map does not
31
+ * carry (a stored v4 record has an entry for every host, so the card's `??` arm
32
+ * only fires on a malformed map). Required — `GET /devices` always emits it.
33
+ */
27
34
  export interface DeviceSummary {
28
35
  deviceId: string
29
36
  displayName: string
@@ -31,6 +38,7 @@ export interface DeviceSummary {
31
38
  enabled: boolean
32
39
  hosts: string[]
33
40
  port: number
41
+ ports: Record<string, number>
34
42
  }
35
43
 
36
44
  /** A `PATCH /devices/:id` body; every field optional, `hosts` is whole-replace. */