dsh-cherry-bridge 0.1.8 → 0.1.10
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 +14 -8
- package/README.md +10 -7
- package/cordis.patch.yml +3 -2
- package/dist/index.d.mts +36 -22
- package/dist/index.mjs +365 -60
- package/docs/e2e-checklist.md +8 -8
- package/lib/client.js +2 -2
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
- package/src/connection.ts +213 -21
- package/src/defaults.ts +61 -19
- package/src/diagnostics.ts +17 -10
- package/src/index.ts +7 -3
- package/src/tools.ts +240 -36
- package/ui/index.html +1 -1
- package/ui/src/i18n.ts +2 -2
package/src/tools.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { DeviceConnection, DeviceState } from './connection.js'
|
|
|
4
4
|
import type { DeviceRecord, DeviceResolution, DeviceStore } from './deviceStore.js'
|
|
5
5
|
import { QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
|
|
6
6
|
import {
|
|
7
|
+
DEVICE_SETTLE_WAIT_MS,
|
|
7
8
|
INTERACTIONS_LIST_PAGE_CLAMP,
|
|
8
9
|
LIST_PAGE_CLAMP,
|
|
9
10
|
MAX_RESUME_TOKEN_CHARS,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
} from './defaults.js'
|
|
12
13
|
import { JsonRpcRequestError, RequestTimeoutError } from './rpc/jsonRpcClient.js'
|
|
13
14
|
import { ContentReadGate } from './contentGate.js'
|
|
15
|
+
import { truncateErrorMessage } from './diagnostics.js'
|
|
14
16
|
import type { ReadResult, SessionSubscriptions } from './subscriptions.js'
|
|
15
17
|
import { PairingError } from './pairing.js'
|
|
16
18
|
import type { PairingErrorKind } from './pairing.js'
|
|
@@ -298,12 +300,81 @@ export interface CherryReadPart {
|
|
|
298
300
|
byteLength?: string
|
|
299
301
|
}
|
|
300
302
|
|
|
303
|
+
/**
|
|
304
|
+
* The peer's verdict on a FAILED message, narrowed to what a caller can act on.
|
|
305
|
+
*
|
|
306
|
+
* The wire carries more (`failure.failure.source`, `context.responseBody`, the
|
|
307
|
+
* provider's status code); none of it is projected. `cherry_read` is a text
|
|
308
|
+
* channel for the conversation, and the provider's raw error body is neither
|
|
309
|
+
* conversation nor the caller's business — the peer already classified the
|
|
310
|
+
* cause into `reasonCode`, which is the actionable half.
|
|
311
|
+
*/
|
|
312
|
+
export interface CherryReadFailure {
|
|
313
|
+
/**
|
|
314
|
+
* The peer's classification (`provider_unavailable`, `rate_limit`,
|
|
315
|
+
* `context_length`, … — the protocol's versioned vocabulary).
|
|
316
|
+
*
|
|
317
|
+
* Relayed as a SHAPE-checked string rather than matched against a closed
|
|
318
|
+
* enum: the vocabulary belongs to the peer and grows with it, and relabelling
|
|
319
|
+
* a new code `'unknown'` would hide a real cause behind a value that means
|
|
320
|
+
* "the peer could not classify". Absent or unusable values become
|
|
321
|
+
* `'unknown'`, which is exactly what the contract uses for that case.
|
|
322
|
+
*/
|
|
323
|
+
reasonCode: string
|
|
324
|
+
/** Whether the peer judged a retry of the same work worthwhile. */
|
|
325
|
+
retryable: boolean
|
|
326
|
+
/**
|
|
327
|
+
* The peer's one-line explanation, bounded by the diagnostics error cap
|
|
328
|
+
* (200 characters) with a `…[truncated]` marker when it was cut.
|
|
329
|
+
*/
|
|
330
|
+
message: string
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** `provider_unavailable`-shaped: lowercase words separated by underscores. */
|
|
334
|
+
const REASON_CODE_SHAPE = /^[a-z][a-z0-9_]{1,63}$/
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Projects the wire's `failure` object (present on `status: 'error'` messages)
|
|
338
|
+
* into {@link CherryReadFailure}, or `undefined` when there is none.
|
|
339
|
+
*
|
|
340
|
+
* Deliberately tolerant of a malformed payload: a peer that sends
|
|
341
|
+
* `failure: { retryable: false }` with no nested record still yields a usable
|
|
342
|
+
* verdict (`reasonCode: 'unknown'`, empty message) instead of failing the whole
|
|
343
|
+
* read — the message's TEXT is already in hand, and losing it over a metadata
|
|
344
|
+
* field would trade the content for the annotation. A degenerate object
|
|
345
|
+
* (`{}`, `[]`) projects the same way: the peer DID attach a verdict, just an
|
|
346
|
+
* empty one, and saying so is more honest than reporting "no verdict". Only an
|
|
347
|
+
* ABSENT or non-object `failure` yields `undefined`.
|
|
348
|
+
*/
|
|
349
|
+
export function projectMessageFailure(raw: Record<string, unknown>): CherryReadFailure | undefined {
|
|
350
|
+
const failure = raw['failure']
|
|
351
|
+
if (typeof failure !== 'object' || failure === null) return undefined
|
|
352
|
+
const record = failure as Record<string, unknown>
|
|
353
|
+
const nested = record['failure']
|
|
354
|
+
const detail = typeof nested === 'object' && nested !== null ? (nested as Record<string, unknown>) : {}
|
|
355
|
+
const rawReason = detail['reasonCode']
|
|
356
|
+
const reasonCode =
|
|
357
|
+
typeof rawReason === 'string' && REASON_CODE_SHAPE.test(rawReason) ? rawReason : 'unknown'
|
|
358
|
+
const rawMessage = record['message']
|
|
359
|
+
return {
|
|
360
|
+
reasonCode,
|
|
361
|
+
retryable: record['retryable'] === true,
|
|
362
|
+
message: truncateErrorMessage(typeof rawMessage === 'string' ? rawMessage : ''),
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
301
366
|
/** One message in a `cherry_read` snapshot. */
|
|
302
367
|
export interface CherryReadMessage {
|
|
303
368
|
messageId: string
|
|
304
369
|
revision: string
|
|
305
370
|
role: string
|
|
306
371
|
status: string
|
|
372
|
+
/**
|
|
373
|
+
* Present exactly when the peer sent a `failure` for this message (in
|
|
374
|
+
* practice: `status: 'error'`). A successful message never carries it, so a
|
|
375
|
+
* caller can test for the key rather than compare `status` strings.
|
|
376
|
+
*/
|
|
377
|
+
failure?: CherryReadFailure
|
|
307
378
|
parts: CherryReadPart[]
|
|
308
379
|
/**
|
|
309
380
|
* Present only when this message's parts could NOT all be read: the safety
|
|
@@ -1030,6 +1101,14 @@ export interface CherryToolsDeps {
|
|
|
1030
1101
|
* this one subscribes the engine to ONE device's transitions.
|
|
1031
1102
|
*/
|
|
1032
1103
|
watchDeviceState?: (deviceId: string, cb: (state: DeviceState) => void) => void
|
|
1104
|
+
/**
|
|
1105
|
+
* How long a device tool waits for an establishment attempt that is ALREADY
|
|
1106
|
+
* IN FLIGHT (`state === 'connecting'`) before judging the device (issue #99).
|
|
1107
|
+
*
|
|
1108
|
+
* OPTIONAL: production omits it and takes {@link DEVICE_SETTLE_WAIT_MS}; test
|
|
1109
|
+
* rigs drive it down so the bounded case needs no real five seconds.
|
|
1110
|
+
*/
|
|
1111
|
+
deviceSettleWaitMs?: number
|
|
1033
1112
|
}
|
|
1034
1113
|
|
|
1035
1114
|
// ---------------------------------------------------------------------------
|
|
@@ -1206,6 +1285,87 @@ function withIdempotencyHint(error: unknown): unknown {
|
|
|
1206
1285
|
return error
|
|
1207
1286
|
}
|
|
1208
1287
|
|
|
1288
|
+
/**
|
|
1289
|
+
* The phrase this hint leaves behind, so applying it twice reads once. A phrase
|
|
1290
|
+
* from the ADVICE rather than the advice itself, because the wait clause varies
|
|
1291
|
+
* with the peer's `retryAfterMs`; matching the peer's own text would match every
|
|
1292
|
+
* rate-limit refusal, hinted or not.
|
|
1293
|
+
*/
|
|
1294
|
+
const RATE_LIMIT_HINT_MARK = 'refuses BEFORE dispatch'
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* How long a refused COMMAND was asked to wait, or undefined when the peer sent
|
|
1298
|
+
* nothing a caller can act on.
|
|
1299
|
+
*
|
|
1300
|
+
* `<= 0` counts as absent rather than "retry immediately": a zero delay is
|
|
1301
|
+
* noise in an error message, and the desktop clamps its own value to `>= 1`
|
|
1302
|
+
* (`RemoteAccessService.ts`). CEILED so a fractional remainder is never
|
|
1303
|
+
* reported as a delay shorter than the one the peer asked for.
|
|
1304
|
+
*/
|
|
1305
|
+
function rateLimitedWaitMs(error: Error): number | undefined {
|
|
1306
|
+
if (!(error instanceof JsonRpcRequestError)) return undefined
|
|
1307
|
+
const data = error.data
|
|
1308
|
+
if (!isRecord(data)) return undefined
|
|
1309
|
+
const raw = data['retryAfterMs']
|
|
1310
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) return undefined
|
|
1311
|
+
return Math.ceil(raw)
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* The recovery advice appended to a command's `RATE_LIMITED` refusal (desktop
|
|
1316
|
+
* >= 2.1.7; see the transport-loss section of `connection.ts`), issue #109.
|
|
1317
|
+
*
|
|
1318
|
+
* Two facts decide the retry and NEITHER survives the tool boundary on its own:
|
|
1319
|
+
*
|
|
1320
|
+
* - **THIS attempt was not applied.** The desktop refuses BEFORE dispatch — its
|
|
1321
|
+
* gateway calls `remote.rpc.refuse(input, …)` INSTEAD of `receive(input)` —
|
|
1322
|
+
* so, unlike a reply lost on a dead link, this is a certain non-outcome.
|
|
1323
|
+
* That is what makes a retry safe, and it is why the SAME `commandId` may be
|
|
1324
|
+
* reused: nothing reached the peer's command ledger, so no
|
|
1325
|
+
* `IDEMPOTENCY_CONFLICT` is waiting for the retry. The sentence says THIS
|
|
1326
|
+
* attempt, not "the command", because an earlier attempt under the same
|
|
1327
|
+
* `commandId` may itself have lost its reply after dispatch: the retry advice
|
|
1328
|
+
* is right in that sequence too (the ledger replays the original receipt),
|
|
1329
|
+
* but "never landed" would be a claim about a run this error knows nothing
|
|
1330
|
+
* of.
|
|
1331
|
+
* - **How long the peer asked us to wait** — `retryAfterMs`, derived from its
|
|
1332
|
+
* own 1 s window and already on the error we are holding. The wait clause
|
|
1333
|
+
* comes BEFORE the retry blessing, so a skimming reader cannot take "safe to
|
|
1334
|
+
* retry" and act on it without waiting.
|
|
1335
|
+
*
|
|
1336
|
+
* The cause is named because at the DEFAULT pacing this refusal is unreachable:
|
|
1337
|
+
* the desktop's counter is per SOCKET (locals inside its `run()` loop, one per
|
|
1338
|
+
* accepted connection) and we pace 32/s shared / 256/s content against its
|
|
1339
|
+
* 64-or-256/s shared and 512/s content lines. Seeing one at all therefore
|
|
1340
|
+
* normally means the configured budget is above the peer's allowance — the knob
|
|
1341
|
+
* to look at, not a transient blip — and a caller told to "retry later" without
|
|
1342
|
+
* that pointer would keep retrying into the same wall. Only the 256/s figure is
|
|
1343
|
+
* quoted: this refusal exists only from 2.1.7, so the older 64/s line cannot be
|
|
1344
|
+
* the one an operator has to compare against.
|
|
1345
|
+
*
|
|
1346
|
+
* Like {@link withIdempotencyHint} this only ever EXTENDS `message`: the reason,
|
|
1347
|
+
* the `data` and the `JsonRpcRequestError` identity are the peer's, and one
|
|
1348
|
+
* command is still never replayed (the advice is for the CALLER's retry).
|
|
1349
|
+
*/
|
|
1350
|
+
export function withRateLimitHint(error: unknown): unknown {
|
|
1351
|
+
if (!(error instanceof Error)) return error
|
|
1352
|
+
if (failureReason(error) !== 'RATE_LIMITED') return error
|
|
1353
|
+
if (error.message.includes(RATE_LIMIT_HINT_MARK)) return error
|
|
1354
|
+
const waitMs = rateLimitedWaitMs(error)
|
|
1355
|
+
const advice = [
|
|
1356
|
+
'this attempt was not applied: the desktop refuses BEFORE dispatch, so nothing reached its ' +
|
|
1357
|
+
'command ledger',
|
|
1358
|
+
waitMs === undefined
|
|
1359
|
+
? 'A retry is safe, and it may reuse the SAME commandId'
|
|
1360
|
+
: `Wait about ${waitMs} ms first (the peer's retryAfterMs); a retry is then safe, and it may ` +
|
|
1361
|
+
'reuse the SAME commandId',
|
|
1362
|
+
'The default budget (32/s) does not come close to the desktop\'s allowance, so a refusal ' +
|
|
1363
|
+
'normally means `rateBudgetPerSecond` was raised too far (2.1.7+ allows 256/s shared)',
|
|
1364
|
+
]
|
|
1365
|
+
error.message = `${error.message} — ${advice.join('. ')}.`
|
|
1366
|
+
return error
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1209
1369
|
function optionalString(value: unknown): string | undefined {
|
|
1210
1370
|
return typeof value === 'string' && value !== '' ? value : undefined
|
|
1211
1371
|
}
|
|
@@ -1617,10 +1777,14 @@ class CherryToolEngine {
|
|
|
1617
1777
|
* ⑤): the candidate list is the answer. A MISS is a verdict too — see
|
|
1618
1778
|
* {@link CherryDeviceNotFound} — and it carries the ids the caller could have
|
|
1619
1779
|
* meant so the answer stays actionable.
|
|
1780
|
+
*
|
|
1781
|
+
* ASYNC because of the `connecting` wait below: the pool dials on first touch
|
|
1782
|
+
* and on every reconnect, so a tool called in that window used to be refused
|
|
1783
|
+
* for a connection that comes up a moment later (issue #99).
|
|
1620
1784
|
*/
|
|
1621
|
-
#resolve(
|
|
1785
|
+
async #resolve(
|
|
1622
1786
|
device: string,
|
|
1623
|
-
): ResolvedDevice | CherryAmbiguousDevice | CherryDeviceNotFound | CherryDeviceUnavailable {
|
|
1787
|
+
): Promise<ResolvedDevice | CherryAmbiguousDevice | CherryDeviceNotFound | CherryDeviceUnavailable> {
|
|
1624
1788
|
const resolution: DeviceResolution = this.#deps.store.resolve(device)
|
|
1625
1789
|
if ('ambiguous' in resolution) {
|
|
1626
1790
|
return { error: 'ambiguous-device', device, candidates: resolution.ambiguous }
|
|
@@ -1634,10 +1798,21 @@ class CherryToolEngine {
|
|
|
1634
1798
|
// transition this exists for can land while the device is already offline,
|
|
1635
1799
|
// and the watch must be in place from the first call either way.
|
|
1636
1800
|
this.#watchConnection(resolution.deviceId, connection)
|
|
1637
|
-
|
|
1801
|
+
// `connecting` is an attempt IN FLIGHT, not a verdict (issue #99): the pool
|
|
1802
|
+
// dials on first touch and on every reconnect, so refusing here used to
|
|
1803
|
+
// report a failure about a connection that comes up a moment later. Wait for
|
|
1804
|
+
// that attempt — bounded, and never starting one — then judge the state it
|
|
1805
|
+
// settled on. Every other non-connected state is answered at once: no wait
|
|
1806
|
+
// can change `offline` (parked in backoff), `disabled` or
|
|
1807
|
+
// `identity-mismatch`, and waiting would only delay the answer.
|
|
1808
|
+
const state =
|
|
1809
|
+
connection.state === 'connecting'
|
|
1810
|
+
? await connection.waitForSettled(this.#deps.deviceSettleWaitMs ?? DEVICE_SETTLE_WAIT_MS)
|
|
1811
|
+
: connection.state
|
|
1812
|
+
if (state !== 'connected') {
|
|
1638
1813
|
// The established Task 5 shape: the tool never dials implicitly, and the
|
|
1639
1814
|
// caller gets the same `{device, state}` verdict the RPC layer throws.
|
|
1640
|
-
return { error: 'device-unavailable', device: resolution.deviceId, state
|
|
1815
|
+
return { error: 'device-unavailable', device: resolution.deviceId, state }
|
|
1641
1816
|
}
|
|
1642
1817
|
return { record: resolution, connection }
|
|
1643
1818
|
}
|
|
@@ -1675,6 +1850,10 @@ class CherryToolEngine {
|
|
|
1675
1850
|
/**
|
|
1676
1851
|
* Resolve-or-verdict: every non-`cherry_devices` tool needs a live link.
|
|
1677
1852
|
*
|
|
1853
|
+
* `connecting` is waited out inside {@link #resolve} first, so the
|
|
1854
|
+
* `DeviceUnavailableError` below is only ever raised for a state the wait
|
|
1855
|
+
* could not improve on (bounded by `deviceSettleWaitMs`).
|
|
1856
|
+
*
|
|
1678
1857
|
* The failure faces are deliberately DIFFERENT (controller ruling ⑤): an
|
|
1679
1858
|
* ambiguous selector and a MISS are ANSWERS — the candidate list, or the
|
|
1680
1859
|
* known ids, returned in band, because the caller can act on them — while a
|
|
@@ -1687,8 +1866,8 @@ class CherryToolEngine {
|
|
|
1687
1866
|
* `cherry_devices` does not come through this method — it goes through
|
|
1688
1867
|
* {@link #selectRecords}, which returns the verdict in band.
|
|
1689
1868
|
*/
|
|
1690
|
-
#requireConnection(device: string): ResolvedDevice | CherryAmbiguousDevice {
|
|
1691
|
-
const resolved = this.#resolve(device)
|
|
1869
|
+
async #requireConnection(device: string): Promise<ResolvedDevice | CherryAmbiguousDevice> {
|
|
1870
|
+
const resolved = await this.#resolve(device)
|
|
1692
1871
|
if (!this.#isVerdict(resolved)) return resolved
|
|
1693
1872
|
if (resolved.error === 'ambiguous-device') return resolved
|
|
1694
1873
|
if (resolved.error === 'not-found') {
|
|
@@ -1710,14 +1889,13 @@ class CherryToolEngine {
|
|
|
1710
1889
|
* replace it with a weaker, wrong one — a miss under a READ-ONLY archive
|
|
1711
1890
|
* would describe a store that still holds a valid token as "no such device".
|
|
1712
1891
|
*/
|
|
1713
|
-
#linkOr(
|
|
1714
|
-
device: string,
|
|
1715
|
-
):
|
|
1892
|
+
async #linkOr(device: string): Promise<
|
|
1716
1893
|
| { link: ResolvedDevice; verdict?: undefined }
|
|
1717
|
-
| { link?: undefined; verdict: CherryAmbiguousDevice | CherryArchiveQuarantined }
|
|
1894
|
+
| { link?: undefined; verdict: CherryAmbiguousDevice | CherryArchiveQuarantined }
|
|
1895
|
+
> {
|
|
1718
1896
|
const quarantined = this.#quarantineVerdict()
|
|
1719
1897
|
if (quarantined !== undefined) return { verdict: quarantined }
|
|
1720
|
-
const resolved = this.#requireConnection(device)
|
|
1898
|
+
const resolved = await this.#requireConnection(device)
|
|
1721
1899
|
if ('error' in resolved) return { verdict: resolved }
|
|
1722
1900
|
return { link: resolved }
|
|
1723
1901
|
}
|
|
@@ -2172,11 +2350,12 @@ class CherryToolEngine {
|
|
|
2172
2350
|
const quarantined = this.#quarantineVerdict()
|
|
2173
2351
|
if (quarantined !== undefined) return quarantined
|
|
2174
2352
|
if (selector === undefined) {
|
|
2175
|
-
|
|
2353
|
+
const records = this.#deps.store.list()
|
|
2354
|
+
return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) }
|
|
2176
2355
|
}
|
|
2177
2356
|
const selected = this.#selectRecords(selector)
|
|
2178
2357
|
if (!Array.isArray(selected)) return selected
|
|
2179
|
-
return { devices: selected.map((record) => this.#deviceView(record)) }
|
|
2358
|
+
return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) }
|
|
2180
2359
|
},
|
|
2181
2360
|
})
|
|
2182
2361
|
}
|
|
@@ -2208,7 +2387,9 @@ class CherryToolEngine {
|
|
|
2208
2387
|
}
|
|
2209
2388
|
|
|
2210
2389
|
/**
|
|
2211
|
-
* One device's view WITHOUT dialing
|
|
2390
|
+
* One device's view WITHOUT dialing — but after waiting out an attempt that is
|
|
2391
|
+
* already in flight, exactly as {@link #resolve} does, so a cold-start row
|
|
2392
|
+
* does not present a transient `connecting` as the device's answer.
|
|
2212
2393
|
*
|
|
2213
2394
|
* A disabled record is authoritative: the pooled connection is constructed
|
|
2214
2395
|
* with its own `record.enabled` snapshot and reports `disabled` only once
|
|
@@ -2216,11 +2397,18 @@ class CherryToolEngine {
|
|
|
2216
2397
|
* for a device whose connection was never started. Everything else falls back
|
|
2217
2398
|
* to the connection's own CLOSED state set.
|
|
2218
2399
|
*/
|
|
2219
|
-
#deviceView(record: DeviceRecord): CherryDeviceView {
|
|
2400
|
+
async #deviceView(record: DeviceRecord): Promise<CherryDeviceView> {
|
|
2220
2401
|
let state: DeviceState = record.enabled ? 'offline' : 'disabled'
|
|
2221
2402
|
if (record.enabled) {
|
|
2222
2403
|
try {
|
|
2223
|
-
|
|
2404
|
+
const connection = this.#deps.connect(record.deviceId)
|
|
2405
|
+
// Same transient as {@link #resolve} (issue #99): a row rendered while
|
|
2406
|
+
// the first dial is in flight must not present `connecting` as the
|
|
2407
|
+
// device's answer. Bounded, and only for an attempt already running.
|
|
2408
|
+
state =
|
|
2409
|
+
connection.state === 'connecting'
|
|
2410
|
+
? await connection.waitForSettled(this.#deps.deviceSettleWaitMs ?? DEVICE_SETTLE_WAIT_MS)
|
|
2411
|
+
: connection.state
|
|
2224
2412
|
} catch (error) {
|
|
2225
2413
|
// A device that cannot even be resolved to a connection still deserves a
|
|
2226
2414
|
// truthful row; `offline` is the fallback verdict.
|
|
@@ -2268,7 +2456,7 @@ class CherryToolEngine {
|
|
|
2268
2456
|
additionalProperties: false,
|
|
2269
2457
|
},
|
|
2270
2458
|
execute: async (args) => {
|
|
2271
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
2459
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
2272
2460
|
if (resolved.link === undefined) return resolved.verdict
|
|
2273
2461
|
const { connection, record } = resolved.link
|
|
2274
2462
|
const binding: ListResumeBinding = { method: 'agent.agents.list', deviceId: record.deviceId, params: {} }
|
|
@@ -2312,7 +2500,7 @@ class CherryToolEngine {
|
|
|
2312
2500
|
additionalProperties: false,
|
|
2313
2501
|
},
|
|
2314
2502
|
execute: async (args) => {
|
|
2315
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
2503
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
2316
2504
|
if (resolved.link === undefined) return resolved.verdict
|
|
2317
2505
|
const { connection, record } = resolved.link
|
|
2318
2506
|
const agentId = requiredString(args, 'agentId')
|
|
@@ -2367,7 +2555,7 @@ class CherryToolEngine {
|
|
|
2367
2555
|
additionalProperties: false,
|
|
2368
2556
|
},
|
|
2369
2557
|
execute: async (args) => {
|
|
2370
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
2558
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
2371
2559
|
if (resolved.link === undefined) return resolved.verdict
|
|
2372
2560
|
const { connection, record } = resolved.link
|
|
2373
2561
|
const agentId = optionalString(args['agentId'])
|
|
@@ -2413,7 +2601,11 @@ class CherryToolEngine {
|
|
|
2413
2601
|
'are deliberately not fetched into a text channel. Messages come NEWEST-first, so the ' +
|
|
2414
2602
|
'first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts ' +
|
|
2415
2603
|
'inside a retained message\'s parts; a message whose own parts could not be fully read ' +
|
|
2416
|
-
'carries `incomplete`, and may carry `unavailableParts` when parts were unreadable.
|
|
2604
|
+
'carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. A ' +
|
|
2605
|
+
'failed message (`status: "error"`) may carry `failure: {reasonCode, retryable, ' +
|
|
2606
|
+
'message}` when the peer sent one — its classification, whether it judged a retry ' +
|
|
2607
|
+
'worthwhile, and a bounded summary; the peer\'s `source` and `context.responseBody` ' +
|
|
2608
|
+
'are never projected. The ' +
|
|
2417
2609
|
'answer is ALSO bounded by an output budget: `truncated` with `truncationReason: ' +
|
|
2418
2610
|
'"aggregate-bytes"` means the walk stopped because the result reached its size limit, ' +
|
|
2419
2611
|
'not because `limit` was reached — raising `limit` does NOT reach the messages left out ' +
|
|
@@ -2466,7 +2658,7 @@ class CherryToolEngine {
|
|
|
2466
2658
|
// a malformed cursor, which would present the newest page again as
|
|
2467
2659
|
// "continued history" — the refusal has to be ours.
|
|
2468
2660
|
const resume = parseMessageResume(args['messagesResumeCursor'])
|
|
2469
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
2661
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
2470
2662
|
if (resolved.link === undefined) return resolved.verdict
|
|
2471
2663
|
const { connection, record } = resolved.link
|
|
2472
2664
|
const sessionId = requiredString(args, 'sessionId')
|
|
@@ -2742,11 +2934,13 @@ class CherryToolEngine {
|
|
|
2742
2934
|
// stays absent rather than claiming a zero.
|
|
2743
2935
|
const unavailableParts = parts.filter((part) => part.unavailable !== undefined).length
|
|
2744
2936
|
const incomplete = unavailableParts > 0 || partsTruncated !== undefined
|
|
2937
|
+
const failure = projectMessageFailure(raw)
|
|
2745
2938
|
return {
|
|
2746
2939
|
messageId,
|
|
2747
2940
|
revision,
|
|
2748
2941
|
role: String(raw['role'] ?? ''),
|
|
2749
2942
|
status: String(raw['status'] ?? ''),
|
|
2943
|
+
...(failure === undefined ? {} : { failure }),
|
|
2750
2944
|
parts,
|
|
2751
2945
|
// A circuit-broken part list advertises itself: the caller can see that
|
|
2752
2946
|
// this message is incomplete and that later parts are still unread, so
|
|
@@ -2930,13 +3124,23 @@ class CherryToolEngine {
|
|
|
2930
3124
|
* back a snapshot that looks complete while containing content the peer
|
|
2931
3125
|
* never certified. That is worse than an error: the caller cannot tell it
|
|
2932
3126
|
* apart from a good read. It keeps failing loudly.
|
|
3127
|
+
* - **NOT degradable — a RATE LIMIT.** A `RATE_LIMITED` refusal is
|
|
3128
|
+
* SESSION-wide, not part-specific: the peer refused the arrival, it did not
|
|
3129
|
+
* fail to serve THIS part. Degrading would hand back a "complete" message
|
|
3130
|
+
* whose text is empty for a reason the caller could act on (wait and ask
|
|
3131
|
+
* again) — and if one slice is refused the others will be too, so the
|
|
3132
|
+
* snapshot would be missing precisely what was requested. The connection
|
|
3133
|
+
* already waited out `retryAfterMs` and replayed the slice ONCE, so a
|
|
3134
|
+
* second refusal is a real failure and the whole read fails with it.
|
|
2933
3135
|
*
|
|
2934
3136
|
* The discriminator is the DIGEST CHECK specifically, which is why
|
|
2935
3137
|
* {@link CherryToolEngine.#readExternalized} marks it: matching on message
|
|
2936
|
-
* text would break the moment the wording changed.
|
|
3138
|
+
* text would break the moment the wording changed. The rate-limit arm keys on
|
|
3139
|
+
* the peer's REASON, never on wording.
|
|
2937
3140
|
*/
|
|
2938
3141
|
#degradableReadFailure(error: unknown): boolean {
|
|
2939
|
-
|
|
3142
|
+
if (error instanceof ContentIntegrityError) return false
|
|
3143
|
+
return failureReason(error) !== 'RATE_LIMITED'
|
|
2940
3144
|
}
|
|
2941
3145
|
|
|
2942
3146
|
/**
|
|
@@ -3201,7 +3405,7 @@ class CherryToolEngine {
|
|
|
3201
3405
|
additionalProperties: false,
|
|
3202
3406
|
},
|
|
3203
3407
|
execute: async (args) => {
|
|
3204
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
3408
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
3205
3409
|
if (resolved.link === undefined) return resolved.verdict
|
|
3206
3410
|
const { record } = resolved.link
|
|
3207
3411
|
const sessionId = requiredString(args, 'sessionId')
|
|
@@ -3319,7 +3523,7 @@ class CherryToolEngine {
|
|
|
3319
3523
|
additionalProperties: false,
|
|
3320
3524
|
},
|
|
3321
3525
|
execute: async (args) => {
|
|
3322
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
3526
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
3323
3527
|
if (resolved.link === undefined) return resolved.verdict
|
|
3324
3528
|
const { connection, record } = resolved.link
|
|
3325
3529
|
|
|
@@ -3440,7 +3644,7 @@ class CherryToolEngine {
|
|
|
3440
3644
|
additionalProperties: false,
|
|
3441
3645
|
},
|
|
3442
3646
|
execute: async (args) => {
|
|
3443
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
3647
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
3444
3648
|
if (resolved.link === undefined) return resolved.verdict
|
|
3445
3649
|
const { connection, record } = resolved.link
|
|
3446
3650
|
const sessionId = requiredString(args, 'sessionId')
|
|
@@ -3671,7 +3875,7 @@ class CherryToolEngine {
|
|
|
3671
3875
|
additionalProperties: false,
|
|
3672
3876
|
},
|
|
3673
3877
|
execute: async (args) => {
|
|
3674
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
3878
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
3675
3879
|
if (resolved.link === undefined) return resolved.verdict
|
|
3676
3880
|
const { connection, record } = resolved.link
|
|
3677
3881
|
const sessionId = requiredString(args, 'sessionId')
|
|
@@ -3835,7 +4039,7 @@ class CherryToolEngine {
|
|
|
3835
4039
|
additionalProperties: false,
|
|
3836
4040
|
},
|
|
3837
4041
|
execute: async (args) => {
|
|
3838
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
4042
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
3839
4043
|
if (resolved.link === undefined) return resolved.verdict
|
|
3840
4044
|
const { connection, record } = resolved.link
|
|
3841
4045
|
const sessionId = requiredString(args, 'sessionId')
|
|
@@ -3945,7 +4149,7 @@ class CherryToolEngine {
|
|
|
3945
4149
|
additionalProperties: false,
|
|
3946
4150
|
},
|
|
3947
4151
|
execute: async (args) => {
|
|
3948
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
4152
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
3949
4153
|
if (resolved.link === undefined) return resolved.verdict
|
|
3950
4154
|
const { connection, record } = resolved.link
|
|
3951
4155
|
const sessionId = requiredString(args, 'sessionId')
|
|
@@ -4051,7 +4255,7 @@ class CherryToolEngine {
|
|
|
4051
4255
|
additionalProperties: false,
|
|
4052
4256
|
},
|
|
4053
4257
|
execute: async (args) => {
|
|
4054
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
4258
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
4055
4259
|
if (resolved.link === undefined) return resolved.verdict
|
|
4056
4260
|
const { connection, record } = resolved.link
|
|
4057
4261
|
const sessionId = requiredString(args, 'sessionId')
|
|
@@ -4293,12 +4497,12 @@ class CherryToolEngine {
|
|
|
4293
4497
|
* subscription failures still reject this call: the two surfaces stay
|
|
4294
4498
|
* distinct all the way to the caller (spec §6).
|
|
4295
4499
|
*
|
|
4296
|
-
*
|
|
4500
|
+
* TWO exceptions, and both are HINTS rather than translations: the peer's
|
|
4297
4501
|
* `IDEMPOTENCY_CONFLICT` — the same `commandId` re-used with a DIFFERENT body
|
|
4298
|
-
* —
|
|
4299
|
-
*
|
|
4300
|
-
*
|
|
4301
|
-
* half.
|
|
4502
|
+
* — and its `RATE_LIMITED` refusal (desktop >= 2.1.7) each carry their
|
|
4503
|
+
* recovery advice on the rethrown error (see {@link withIdempotencyHint} /
|
|
4504
|
+
* {@link withRateLimitHint}). The reason, the `data` and the failure currency
|
|
4505
|
+
* are untouched; only the prose a caller reads grows the actionable half.
|
|
4302
4506
|
*/
|
|
4303
4507
|
async #command(
|
|
4304
4508
|
connection: DeviceConnection,
|
|
@@ -4308,7 +4512,7 @@ class CherryToolEngine {
|
|
|
4308
4512
|
try {
|
|
4309
4513
|
return (await connection.rpc.request(method, params)) as Record<string, unknown>
|
|
4310
4514
|
} catch (error) {
|
|
4311
|
-
throw withIdempotencyHint(error)
|
|
4515
|
+
throw withRateLimitHint(withIdempotencyHint(error))
|
|
4312
4516
|
}
|
|
4313
4517
|
}
|
|
4314
4518
|
|
package/ui/index.html
CHANGED
package/ui/src/i18n.ts
CHANGED
|
@@ -57,7 +57,7 @@ export const LOCALE_IDS: readonly LocaleId[] = ['zh', 'en']
|
|
|
57
57
|
*/
|
|
58
58
|
const zh: Dict = {
|
|
59
59
|
// -- app shell ------------------------------------------------------------
|
|
60
|
-
'app.title': 'Cherry
|
|
60
|
+
'app.title': 'Cherry 互联',
|
|
61
61
|
'app.subtitle':
|
|
62
62
|
'通过远程传输把本机 DeepSeek Harness 连接到 Cherry Studio 桌面端;设备档案保存在插件数据目录,页面不接触任何凭据。',
|
|
63
63
|
// The one string that used to reach a Chinese page in English (App.tsx's
|
|
@@ -210,7 +210,7 @@ const zh: Dict = {
|
|
|
210
210
|
*/
|
|
211
211
|
const en: Dict = {
|
|
212
212
|
// -- app shell ------------------------------------------------------------
|
|
213
|
-
'app.title': 'Cherry
|
|
213
|
+
'app.title': 'Cherry Bridge',
|
|
214
214
|
'app.subtitle':
|
|
215
215
|
'Connect this machine’s DeepSeek Harness to a Cherry Studio desktop over the remote transport. Device records live in the plugin data directory; this page never touches a credential.',
|
|
216
216
|
'app.eventSourceUnavailable': 'EventSource is unavailable in this environment',
|