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/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
- if (connection.state !== 'connected') {
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: connection.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
- return { devices: this.#deps.store.list().map((record) => this.#deviceView(record)) }
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
- state = this.#deps.connect(record.deviceId).state
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. The ' +
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
- return !(error instanceof ContentIntegrityError)
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
- * ONE exception, and it is a HINT rather than a translation: the peer's
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
- * — carries the §6 recovery advice on the rethrown error (see
4299
- * {@link withIdempotencyHint}). The reason, the `data` and the failure
4300
- * currency are untouched; only the prose a caller reads grows the actionable
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
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Cherry Studio 互联 — 开发预览</title>
6
+ <title>Cherry 互联 — 开发预览</title>
7
7
  </head>
8
8
  <body>
9
9
  <!--
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 Studio 互联',
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 Studio Bridge',
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',