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/README.en.md +15 -8
- package/README.md +13 -6
- package/dist/index.d.mts +32 -3
- package/dist/index.mjs +1563 -168
- package/docs/e2e-checklist.md +50 -2
- package/lib/client.js +311 -31
- package/package.json +4 -3
- package/src/AGENTS.md +949 -0
- package/src/connection.ts +287 -11
- package/src/deviceStore.ts +518 -6
- package/src/diagnostics.ts +30 -0
- package/src/httpApi.ts +351 -23
- package/src/index.ts +203 -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 +329 -23
- package/ui/AGENTS.md +44 -0
- package/ui/src/App.tsx +26 -10
- package/ui/src/DeviceCard.tsx +141 -4
- package/ui/src/PairingWizard.tsx +80 -5
- package/ui/src/ToolScopePanel.tsx +19 -6
- package/ui/src/api.ts +111 -17
- package/ui/src/i18n.ts +49 -0
- package/ui/src/styles.css +24 -0
- package/ui/src/types.ts +16 -1
- package/src/urlHost.ts +0 -15
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`
|
|
1156
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
2613
|
-
|
|
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
|
-
|
|
2616
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
|
6581
|
-
*
|
|
6582
|
-
*
|
|
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 (${
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
{
|
|
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">
|