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.
- package/README.en.md +11 -4
- package/README.md +10 -4
- package/dist/index.d.mts +31 -3
- package/dist/index.mjs +906 -144
- package/docs/e2e-checklist.md +2 -2
- package/lib/client.js +47 -0
- package/package.json +4 -3
- package/src/AGENTS.md +860 -0
- package/src/connection.ts +114 -10
- package/src/deviceStore.ts +204 -5
- package/src/diagnostics.ts +30 -0
- package/src/httpApi.ts +129 -8
- package/src/index.ts +190 -8
- package/src/pairing.ts +504 -112
- package/src/rpc/jsonRpcClient.ts +19 -1
- package/src/subscriptions.ts +10 -2
- package/src/text.ts +158 -0
- package/src/tools.ts +181 -14
- package/ui/AGENTS.md +23 -0
- package/ui/src/DeviceCard.tsx +35 -0
- package/ui/src/i18n.ts +10 -0
- package/ui/src/types.ts +9 -1
- package/src/urlHost.ts +0 -15
package/src/rpc/jsonRpcClient.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/subscriptions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
|
6581
|
-
*
|
|
6582
|
-
*
|
|
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 (${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
|
package/ui/src/DeviceCard.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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. */
|