dsh-cherry-bridge 0.1.9 → 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/dist/index.mjs CHANGED
@@ -1737,11 +1737,11 @@ const CONTRACT_DIFFERENCES = [
1737
1737
  },
1738
1738
  {
1739
1739
  id: "rate-limit-terminates-session",
1740
- title: "Over-limit rate limiting terminates the whole session (close 1011)",
1741
- realPeer: "Exceeding the desktop's per-socket budget (64 req/s in the current implementation, a server value with no negotiation channel) terminates the ENTIRE session with close code 1011 — it is not a per-request `RATE_LIMITED` rejection the caller can retry in place.",
1742
- fakePeer: "The fake closes the session with 1011 once `rateLimitPerSecond` is exceeded, so the client's offline→backoff→reconnect path is exercised rather than a request-level refusal.",
1743
- schemaAnchor: "remoteLimits + peer close code 1011 (implementation value, not a protocol promise)",
1744
- checklistStep: "rate-limit-terminates-session: against a real desktop, drive a burst well above the smooth budget and confirm the socket is closed with 1011 (session gone, state offline, backoff restart) rather than a single structured per-request rejection."
1740
+ title: "Over-limit rate limiting is generation-dependent: a whole-session 1011 close (≤2.1.6) or a per-request refusal (≥2.1.7)",
1741
+ realPeer: "Through 2.1.6, exceeding the desktop's per-socket budget (64 req/s shared, 512 req/s content; implementation values with no negotiation channel) terminated the ENTIRE session with close code 1011. From 2.1.7 (PR #21687) the shared allowance is 256 req/s and an over-budget request is answered with ONE structured `RATE_LIMITED` failure carrying `retryAfterMs`, while the session survives.",
1742
+ fakePeer: "The fake models BOTH generations: `rateLimitMode` `close` (the ≤2.1.6 default) closes the session with 1011 once `rateLimitPerSecond` is exceeded, exercising the offline→backoff→reconnect path; `refuse` answers one structured `RATE_LIMITED` failure with `retryAfterMs` and keeps the session, exercising the bounded replay.",
1743
+ schemaAnchor: "remoteLimits + peer close code 1011 (≤2.1.6) / failure data reason RATE_LIMITED + retryAfterMs (≥2.1.7); implementation values, not protocol promises",
1744
+ checklistStep: "rate-limit-terminates-session: on a ≥2.1.7 desktop, raise `rateBudgetPerSecond` (or use an unpaced probe) and drive a burst above the allowance; confirm each over-budget request is refused with `RATE_LIMITED` + `retryAfterMs` while the session stays live, a refused READ succeeds after ONE replay, and a refused COMMAND surfaces as a verdict and is not replayed. On ≤2.1.6, the same burst closes the socket with 1011 (state offline, backoff restart)."
1745
1745
  },
1746
1746
  {
1747
1747
  id: "execution-terminal-no-interrupted",
@@ -2176,18 +2176,21 @@ function buildDiagnostics(sources, identity = DEFAULT_IDENTITY) {
2176
2176
  * This is the SHARED tier: it covers every business RPC EXCEPT the content-read
2177
2177
  * tier below (see {@link DEFAULT_CONTENT_READ_BUDGET_PER_SECOND}).
2178
2178
  *
2179
- * WHY 32: the desktop's own allowance for "other" RPCs is 64/s
2180
- * (`RemoteAccessService.ts:223-225`; the official client paces them at 40/s,
2181
- * `DesktopSession.ts:242-244`), and exceeding the allowance is not a refusal
2182
- * but a whole-session close (1011). Half the peer's line leaves room for the
2183
- * housekeeping traffic that shares the socket (hello/authenticate/ping/refresh
2184
- * bypass pacing by design) and for arrival compression between our admission
2185
- * times and the peer's arrival times.
2179
+ * WHY 32: the desktop's allowance for "other" RPCs is 64/s through 2.1.6 and
2180
+ * 256/s from 2.1.7 (`RemoteAccessService.ts`; the official client paces them at
2181
+ * 40/s, `DesktopSession.ts:36-37`). What an over-budget request does also
2182
+ * changed: through 2.1.6 it closes the WHOLE session (1011), while 2.1.7 (PR
2183
+ * #21687) refuses that one request (`RATE_LIMITED` + `retryAfterMs`) and keeps
2184
+ * the session. The generations cannot be told apart before one answers, so the
2185
+ * default is sized for the older, harsher peer: 32 is half of 64 and an eighth
2186
+ * of 256, leaving room for the housekeeping traffic that shares the socket
2187
+ * (hello/authenticate/ping/refresh bypass pacing by design) and for arrival
2188
+ * compression between our admission times and the peer's arrival times.
2186
2189
  *
2187
2190
  * SOURCES (neither is in this repository): the desktop lines are
2188
- * `_research/cherry-studio` @ v2.1.6 (90b234e); the official-client lines are
2189
- * CherryHQ/cherry-studio-app @ main 18329fe (app 0.3.0), read on 2026-10-18 —
2190
- * recorded in `.superpowers/review-v013-r1/context.md`.
2191
+ * `_research/cherry-studio` @ v2.1.6 (90b234e) and CherryHQ/cherry-studio @
2192
+ * v2.1.7; the official-client lines are CherryHQ/cherry-studio-app @ v0.4.0,
2193
+ * both read on 2026-10-22 (issue #102).
2191
2194
  */
2192
2195
  const DEFAULT_RATE_BUDGET_PER_SECOND = 32;
2193
2196
  /**
@@ -2203,9 +2206,10 @@ const DEFAULT_RATE_BUDGET_PER_SECOND = 32;
2203
2206
  * (`RemoteAccessService.ts:223-225`) and the official client issues them at
2204
2207
  * 480/s (`DesktopSession.ts:33-37`). Pacing them with the 32/s shared tier
2205
2208
  * bound a multi-slice walk to ~670 KiB/s and queued it behind unrelated RPCs;
2206
- * 256 is half the peer's own kill line — the same 2× margin the shared tier
2207
- * keeps on 64/s — and lifts the bound to the multi-MiB/s range of the official
2208
- * client while leaving the desktop a session-sized headroom.
2209
+ * 256 is half the 512/s content allowance in BOTH desktop generations (the
2210
+ * shared-tier allowance changed in 2.1.7; this one did not) and lifts the bound
2211
+ * to the multi-MiB/s range of the official client while leaving the desktop a
2212
+ * session-sized headroom.
2209
2213
  *
2210
2214
  * NOT a plugin-config key: the two tiers derive from ONE injected shared budget
2211
2215
  * at the ratio below, so an operator who lowers `rateBudgetPerSecond` lowers
@@ -2214,14 +2218,33 @@ const DEFAULT_RATE_BUDGET_PER_SECOND = 32;
2214
2218
  *
2215
2219
  * THE MARGINS ARE A DEFAULT-INJECTION CLAIM: the 2× headroom above holds at
2216
2220
  * the default injection (32/s), where this tier is exactly 256/s. An injected
2217
- * budget consumes BOTH tiers' headroom in proportion — at 64/s the shared tier
2218
- * is at the peer's 64/s line and this tier derives to 512/s, i.e. nominal
2219
- * saturation of both allowances. The limiter's integer gap means the ACHIEVED
2220
- * rate stays below the nominal one in that case (≤ 334/s on this tier, 55.6/s
2221
- * on the shared one; see `SecondWindowRateLimiter` in `connection.ts`), which
2222
- * is margin by accident rather than by design.
2221
+ * budget consumes BOTH tiers' headroom up to the point where this tier reaches
2222
+ * the PEER'S CEILING — at 64/s the shared tier sits on the ≤2.1.6 line (the
2223
+ * harsher generation, where overrun still closes the session) and this tier
2224
+ * derives to exactly 512/s, the content allowance BOTH generations share. Above
2225
+ * 64/s the clamp holds it at 512/s: `contentBudgetFor` (issue #111) still
2226
+ * derives the ratio, then bounds it — because a knob copied from 2.1.7's shared
2227
+ * figure (256)
2228
+ * would otherwise derive 2,048/s and spend the tier being refused. The
2229
+ * limiter's integer gap means the ACHIEVED rate stays below the nominal one in
2230
+ * that case (≤ 334/s on this tier, 55.6/s on the shared one; see
2231
+ * `SecondWindowRateLimiter` in `connection.ts`), which is margin by accident
2232
+ * rather than by design.
2223
2233
  */
2224
2234
  const DEFAULT_CONTENT_READ_BUDGET_PER_SECOND = 256;
2235
+ /**
2236
+ * The longest a READ may wait for the peer's rate window to clear before its
2237
+ * ONE bounded replay (`RATE_LIMITED` + `retryAfterMs`, desktop >= 2.1.7).
2238
+ *
2239
+ * Why the peer's own window and not a separate budget: the desktop derives
2240
+ * `retryAfterMs` from its 1 s accounting window, so a larger value cannot come
2241
+ * from the peer, and waiting longer would hold a tool call for no reason. The
2242
+ * caller's own deadline (`JsonRpcRequestOptions.deadline`) still clamps it, and
2243
+ * a wait answer's `AssemblyDeadline` is what makes a refusal near the budget a
2244
+ * `'deadline'` stop rather than an overrun. A COMMAND never takes this wait: its
2245
+ * refusal is a verdict on that arrival, and commands are never replayed.
2246
+ */
2247
+ const RATE_LIMIT_MAX_WAIT_MS = 1e3;
2225
2248
  /** 2 MiB of event text cached per session (spec §3.5). */
2226
2249
  const DEFAULT_PER_SESSION_CACHE_BYTES = 2097152;
2227
2250
  /**
@@ -2399,6 +2422,29 @@ const SILENCE_FACTOR = 2;
2399
2422
  /** The deliberate local-shutdown face of a disposed client (see the replay path). */
2400
2423
  const DISPOSED_RE = /^JSON-RPC client disposed$/;
2401
2424
  /**
2425
+ * The peer's structured over-budget refusal (desktop >= 2.1.7), or undefined.
2426
+ *
2427
+ * 2.1.7 answers an over-budget request with ONE `RATE_LIMITED` failure carrying
2428
+ * `retryAfterMs` and keeps the session; <=2.1.6 instead closed the whole socket
2429
+ * with 1011, which is the transport-loss path and never reaches this check.
2430
+ */
2431
+ function rateLimitRefusal(error) {
2432
+ if (!(error instanceof JsonRpcRequestError)) return void 0;
2433
+ const data = error.data;
2434
+ if (typeof data !== "object" || data === null) return void 0;
2435
+ return data["reason"] === "RATE_LIMITED" ? error : void 0;
2436
+ }
2437
+ /**
2438
+ * How long a refusal asks the caller to wait, bounded by
2439
+ * {@link RATE_LIMIT_MAX_WAIT_MS}. An absent or unusable value is 0: retry at
2440
+ * the next pacing slot rather than invent a delay the peer never asked for.
2441
+ */
2442
+ function rateLimitWaitMs(refusal) {
2443
+ const raw = refusal.data["retryAfterMs"];
2444
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) return 0;
2445
+ return Math.min(raw, RATE_LIMIT_MAX_WAIT_MS);
2446
+ }
2447
+ /**
2402
2448
  * The read methods whose transport loss may be replayed automatically.
2403
2449
  *
2404
2450
  * Two entries are DEAD today and are kept deliberately, so a future call site
@@ -2483,9 +2529,9 @@ var SecondWindowRateLimiter = class {
2483
2529
  * Which tier paces `method`. The rule is EXACT-MATCH: `agent.content.read` is
2484
2530
  * the only method on the content tier, and every other method — a command, a
2485
2531
  * list read, a different `agent.*.read` — keeps the shared tier. A prefix or
2486
- * suffix match would silently widen the 256/s tier to methods whose peer-side
2487
- * allowance is the 64/s one, which is a session-killing mistake (close 1011),
2488
- * so a new content-plane method must be named here deliberately.
2532
+ * suffix match would silently widen the content tier to methods whose peer-side
2533
+ * allowance is the shared one — 64/s through 2.1.6, where exceeding it closes
2534
+ * the SESSION (1011) — so a new content-plane method must be named deliberately.
2489
2535
  */
2490
2536
  function classifyRateTier(method) {
2491
2537
  return method === "agent.content.read" ? "content" : "shared";
@@ -2644,6 +2690,27 @@ function businessWindowFor(reportedInFlightRequests) {
2644
2690
  return Math.max(1, Math.min(BUSINESS_MAX_IN_FLIGHT, Math.floor(reportedInFlightRequests / 2)));
2645
2691
  }
2646
2692
  /**
2693
+ * The desktop's content-read allowance: 512/s, and the SAME in both generations
2694
+ * (`RemoteAccessService.ts` counts `agent.content.read` on its own counter,
2695
+ * 512 in v2.1.6 and v2.1.7 alike; only the shared line moved, 64 ⇒ 256).
2696
+ *
2697
+ * A CEILING on the derived tier, not a third knob. The 8 : 1 ratio exists so an
2698
+ * operator who LOWERS the shared budget lowers both tiers; upward, the ratio
2699
+ * would spend headroom the peer does not have — `rateBudgetPerSecond: 256`
2700
+ * (2.1.7's SHARED figure, an easy number to copy) derives 2,048/s, which the
2701
+ * limiter's 1 ms gap compresses to ~1,000/s against this allowance, so roughly
2702
+ * every other content read is refused. On >=2.1.7 each refusal costs up to a
2703
+ * second plus one replay and a second refusal fails the whole read; on <=2.1.6
2704
+ * an overrun closes the SESSION (1011). Because this line is
2705
+ * generation-INDEPENDENT, bounding it needs no generation detection — the one
2706
+ * bound in this file that does not.
2707
+ *
2708
+ * 512 is the ceiling and 333/s is what the limiter then ACHIEVES from it
2709
+ * (`(1,000 + 150) / 512` ceils to a 3 ms gap), so the saturated tier still
2710
+ * keeps a 1.5x margin on arrival times.
2711
+ */
2712
+ const CONTENT_BUDGET_CEILING_PER_SECOND = 512;
2713
+ /**
2647
2714
  * The content-read tier's budget for a given SHARED budget.
2648
2715
  *
2649
2716
  * ONE injected knob, two tiers: an operator who lowers `rateBudgetPerSecond`
@@ -2651,9 +2718,19 @@ function businessWindowFor(reportedInFlightRequests) {
2651
2718
  * them rather than staying pinned at 256 while the shared tier shrinks. An
2652
2719
  * absent injection is the production default (which yields exactly
2653
2720
  * `DEFAULT_CONTENT_READ_BUDGET_PER_SECOND`).
2721
+ *
2722
+ * Upward the ratio stops at {@link CONTENT_BUDGET_CEILING_PER_SECOND}: past a
2723
+ * shared budget of 64 the derived value would exceed an allowance that never
2724
+ * changed, so it is clamped instead of trying to pace past it.
2725
+ *
2726
+ * Exported for `tests/unit/contentBudgetCeiling.test.ts`, the same reason
2727
+ * `reconnectWait` is: the ceiling is a property of this mapping, and asserting
2728
+ * it through the limiter's timing would be a slower, flakier version of the
2729
+ * same claim.
2654
2730
  */
2655
2731
  function contentBudgetFor(sharedBudgetPerSecond) {
2656
- return Math.ceil((sharedBudgetPerSecond ?? 32) * 8);
2732
+ const derived = Math.ceil((sharedBudgetPerSecond ?? 32) * 8);
2733
+ return Math.min(CONTENT_BUDGET_CEILING_PER_SECOND, derived);
2657
2734
  }
2658
2735
  function defer() {
2659
2736
  let resolve;
@@ -3265,6 +3342,8 @@ var DeviceConnection = class {
3265
3342
  try {
3266
3343
  return await link.client.request(method, params, opts);
3267
3344
  } catch (error) {
3345
+ const refusal = rateLimitRefusal(error);
3346
+ if (refusal !== void 0) return await this.#replayAfterRateLimit(refusal, method, params, opts);
3268
3347
  if (!this.#isTransportLoss(error)) throw error;
3269
3348
  const lost = new TransportLostError(this.record.deviceId, "connected", method, { cause: error });
3270
3349
  if (!RETRYABLE_READ_METHODS.has(method) || !this.#running) throw lost;
@@ -3291,6 +3370,51 @@ var DeviceConnection = class {
3291
3370
  }
3292
3371
  }
3293
3372
  }
3373
+ /**
3374
+ * ONE bounded replay for a READ the peer refused because the socket is over
3375
+ * its rate window.
3376
+ *
3377
+ * 2.1.7 answers that request with a structured `RATE_LIMITED` failure and a
3378
+ * `retryAfterMs` instead of closing the session (see `rateLimitRefusal`), so
3379
+ * the caller no longer learns it was throttled by losing its connection:
3380
+ * without this, a transient refusal became a failed tool call whose part text
3381
+ * the projection would even degrade.
3382
+ *
3383
+ * The shape mirrors the transport-loss replay, for the same reasons:
3384
+ *
3385
+ * - only a READ is replayed (a command's refusal is a verdict on that
3386
+ * arrival, and commands are never replayed — receipts recover them);
3387
+ * - the wait is charged to the caller's deadline and never extends it, so a
3388
+ * wait answer's budget turns a refusal near its bound into a `'deadline'`
3389
+ * stop instead of an overrun;
3390
+ * - the replay takes a FRESH pacing admission: it is a new arrival at the
3391
+ * peer, and skipping the admission would replay into the same full window.
3392
+ *
3393
+ * A SECOND refusal is a real failure and propagates as itself (the fresh
3394
+ * `retryAfterMs` is the more useful one); a link lost under the replay is
3395
+ * reported as the classified `TransportLostError`, never as bare text.
3396
+ */
3397
+ async #replayAfterRateLimit(refusal, method, params, opts) {
3398
+ if (!RETRYABLE_READ_METHODS.has(method) || !this.#running) throw refusal;
3399
+ const remaining = this.#remainingMs(opts);
3400
+ if (remaining !== void 0 && remaining <= 0) throw new RpcDeadlineExceededError(this.record.deviceId, method);
3401
+ const requested = rateLimitWaitMs(refusal);
3402
+ const waitMs = Math.min(requested, remaining ?? Number.POSITIVE_INFINITY);
3403
+ const deadlineSpent = remaining !== void 0 && requested >= remaining;
3404
+ if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs));
3405
+ if (!this.#running || this.#state !== "connected") throw refusal;
3406
+ if (deadlineSpent || this.#deadlinePassed(opts)) throw new RpcDeadlineExceededError(this.record.deviceId, method);
3407
+ await this.#limiter.acquire(method);
3408
+ const link = this.#link;
3409
+ if (!this.#running || this.#state !== "connected" || link === void 0) throw refusal;
3410
+ if (this.#deadlinePassed(opts)) throw new RpcDeadlineExceededError(this.record.deviceId, method);
3411
+ try {
3412
+ return await link.client.request(method, params, opts);
3413
+ } catch (error) {
3414
+ if (this.#isTransportLoss(error)) throw new TransportLostError(this.record.deviceId, "connected", method, { cause: error });
3415
+ throw error;
3416
+ }
3417
+ }
3294
3418
  /** Whether a rejection is the dead channel, as opposed to a peer's own answer. */
3295
3419
  #isTransportLoss(error) {
3296
3420
  return error instanceof Error && !(error instanceof JsonRpcRequestError) && isChannelClosedError(error);
@@ -4518,6 +4642,35 @@ var ContentReadGate = class {
4518
4642
  };
4519
4643
  //#endregion
4520
4644
  //#region src/tools.ts
4645
+ /** `provider_unavailable`-shaped: lowercase words separated by underscores. */
4646
+ const REASON_CODE_SHAPE = /^[a-z][a-z0-9_]{1,63}$/;
4647
+ /**
4648
+ * Projects the wire's `failure` object (present on `status: 'error'` messages)
4649
+ * into {@link CherryReadFailure}, or `undefined` when there is none.
4650
+ *
4651
+ * Deliberately tolerant of a malformed payload: a peer that sends
4652
+ * `failure: { retryable: false }` with no nested record still yields a usable
4653
+ * verdict (`reasonCode: 'unknown'`, empty message) instead of failing the whole
4654
+ * read — the message's TEXT is already in hand, and losing it over a metadata
4655
+ * field would trade the content for the annotation. A degenerate object
4656
+ * (`{}`, `[]`) projects the same way: the peer DID attach a verdict, just an
4657
+ * empty one, and saying so is more honest than reporting "no verdict". Only an
4658
+ * ABSENT or non-object `failure` yields `undefined`.
4659
+ */
4660
+ function projectMessageFailure(raw) {
4661
+ const failure = raw["failure"];
4662
+ if (typeof failure !== "object" || failure === null) return void 0;
4663
+ const record = failure;
4664
+ const nested = record["failure"];
4665
+ const rawReason = (typeof nested === "object" && nested !== null ? nested : {})["reasonCode"];
4666
+ const reasonCode = typeof rawReason === "string" && REASON_CODE_SHAPE.test(rawReason) ? rawReason : "unknown";
4667
+ const rawMessage = record["message"];
4668
+ return {
4669
+ reasonCode,
4670
+ retryable: record["retryable"] === true,
4671
+ message: truncateErrorMessage(typeof rawMessage === "string" ? rawMessage : "")
4672
+ };
4673
+ }
4521
4674
  /** The four terminal execution statuses the waiter predicate covers. */
4522
4675
  const TERMINAL_EXECUTION_STATUSES = [
4523
4676
  "completed",
@@ -4957,6 +5110,79 @@ function withIdempotencyHint(error) {
4957
5110
  error.message = `${error.message} — ${IDEMPOTENCY_HINT}`;
4958
5111
  return error;
4959
5112
  }
5113
+ /**
5114
+ * The phrase this hint leaves behind, so applying it twice reads once. A phrase
5115
+ * from the ADVICE rather than the advice itself, because the wait clause varies
5116
+ * with the peer's `retryAfterMs`; matching the peer's own text would match every
5117
+ * rate-limit refusal, hinted or not.
5118
+ */
5119
+ const RATE_LIMIT_HINT_MARK = "refuses BEFORE dispatch";
5120
+ /**
5121
+ * How long a refused COMMAND was asked to wait, or undefined when the peer sent
5122
+ * nothing a caller can act on.
5123
+ *
5124
+ * `<= 0` counts as absent rather than "retry immediately": a zero delay is
5125
+ * noise in an error message, and the desktop clamps its own value to `>= 1`
5126
+ * (`RemoteAccessService.ts`). CEILED so a fractional remainder is never
5127
+ * reported as a delay shorter than the one the peer asked for.
5128
+ */
5129
+ function rateLimitedWaitMs(error) {
5130
+ if (!(error instanceof JsonRpcRequestError)) return void 0;
5131
+ const data = error.data;
5132
+ if (!isRecord(data)) return void 0;
5133
+ const raw = data["retryAfterMs"];
5134
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) return void 0;
5135
+ return Math.ceil(raw);
5136
+ }
5137
+ /**
5138
+ * The recovery advice appended to a command's `RATE_LIMITED` refusal (desktop
5139
+ * >= 2.1.7; see the transport-loss section of `connection.ts`), issue #109.
5140
+ *
5141
+ * Two facts decide the retry and NEITHER survives the tool boundary on its own:
5142
+ *
5143
+ * - **THIS attempt was not applied.** The desktop refuses BEFORE dispatch — its
5144
+ * gateway calls `remote.rpc.refuse(input, …)` INSTEAD of `receive(input)` —
5145
+ * so, unlike a reply lost on a dead link, this is a certain non-outcome.
5146
+ * That is what makes a retry safe, and it is why the SAME `commandId` may be
5147
+ * reused: nothing reached the peer's command ledger, so no
5148
+ * `IDEMPOTENCY_CONFLICT` is waiting for the retry. The sentence says THIS
5149
+ * attempt, not "the command", because an earlier attempt under the same
5150
+ * `commandId` may itself have lost its reply after dispatch: the retry advice
5151
+ * is right in that sequence too (the ledger replays the original receipt),
5152
+ * but "never landed" would be a claim about a run this error knows nothing
5153
+ * of.
5154
+ * - **How long the peer asked us to wait** — `retryAfterMs`, derived from its
5155
+ * own 1 s window and already on the error we are holding. The wait clause
5156
+ * comes BEFORE the retry blessing, so a skimming reader cannot take "safe to
5157
+ * retry" and act on it without waiting.
5158
+ *
5159
+ * The cause is named because at the DEFAULT pacing this refusal is unreachable:
5160
+ * the desktop's counter is per SOCKET (locals inside its `run()` loop, one per
5161
+ * accepted connection) and we pace 32/s shared / 256/s content against its
5162
+ * 64-or-256/s shared and 512/s content lines. Seeing one at all therefore
5163
+ * normally means the configured budget is above the peer's allowance — the knob
5164
+ * to look at, not a transient blip — and a caller told to "retry later" without
5165
+ * that pointer would keep retrying into the same wall. Only the 256/s figure is
5166
+ * quoted: this refusal exists only from 2.1.7, so the older 64/s line cannot be
5167
+ * the one an operator has to compare against.
5168
+ *
5169
+ * Like {@link withIdempotencyHint} this only ever EXTENDS `message`: the reason,
5170
+ * the `data` and the `JsonRpcRequestError` identity are the peer's, and one
5171
+ * command is still never replayed (the advice is for the CALLER's retry).
5172
+ */
5173
+ function withRateLimitHint(error) {
5174
+ if (!(error instanceof Error)) return error;
5175
+ if (failureReason(error) !== "RATE_LIMITED") return error;
5176
+ if (error.message.includes(RATE_LIMIT_HINT_MARK)) return error;
5177
+ const waitMs = rateLimitedWaitMs(error);
5178
+ const advice = [
5179
+ "this attempt was not applied: the desktop refuses BEFORE dispatch, so nothing reached its command ledger",
5180
+ waitMs === void 0 ? "A retry is safe, and it may reuse the SAME commandId" : `Wait about ${waitMs} ms first (the peer's retryAfterMs); a retry is then safe, and it may reuse the SAME commandId`,
5181
+ "The default budget (32/s) does not come close to the desktop's allowance, so a refusal normally means `rateBudgetPerSecond` was raised too far (2.1.7+ allows 256/s shared)"
5182
+ ];
5183
+ error.message = `${error.message} — ${advice.join(". ")}.`;
5184
+ return error;
5185
+ }
4960
5186
  function optionalString(value) {
4961
5187
  return typeof value === "string" && value !== "" ? value : void 0;
4962
5188
  }
@@ -5789,7 +6015,7 @@ var CherryToolEngine = class {
5789
6015
  #readTool() {
5790
6016
  return defineCherryTool({
5791
6017
  name: "cherry_read",
5792
- description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory. A binary attachment is reported as `kind: \"file\"` with `name`/`mediaType`/`byteLength` and an EMPTY `content` — the bytes are deliberately not fetched into a text channel. Messages come NEWEST-first, so the first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts inside a retained message's parts; a message whose own parts could not be fully read carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. The answer is ALSO bounded by an output budget: `truncated` with `truncationReason: \"aggregate-bytes\"` means the walk stopped because the result reached its size limit, not because `limit` was reached — raising `limit` does NOT reach the messages left out (it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` when the stop point can be named — absent when it cannot, e.g. an empty or malformed position, a token past the length ceiling, or no anchor id — and passing it back as `messagesResumeCursor` continues the walk from there (`limit` then bounds how many messages are returned FROM that point). The two token forms are `\"cursor:<peer position>\"` (the walk consumed its whole page window) and `\"after:<messageId>\"` (it stopped inside the window; the read restarts at the newest end and resumes strictly after that message). A token this tool did not emit is refused locally rather than forwarded, and an `after:` anchor that no longer exists in the history is an error, not an empty page. A `truncationReason` of \"limit\" is the caller's own ceiling and is reached by raising `limit`. Either way the newest message is always returned whole.",
6018
+ description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory. A binary attachment is reported as `kind: \"file\"` with `name`/`mediaType`/`byteLength` and an EMPTY `content` — the bytes are deliberately not fetched into a text channel. Messages come NEWEST-first, so the first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts inside a retained message's parts; a message whose own parts could not be fully read carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. A failed message (`status: \"error\"`) may carry `failure: {reasonCode, retryable, message}` when the peer sent one — its classification, whether it judged a retry worthwhile, and a bounded summary; the peer's `source` and `context.responseBody` are never projected. The answer is ALSO bounded by an output budget: `truncated` with `truncationReason: \"aggregate-bytes\"` means the walk stopped because the result reached its size limit, not because `limit` was reached — raising `limit` does NOT reach the messages left out (it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` when the stop point can be named — absent when it cannot, e.g. an empty or malformed position, a token past the length ceiling, or no anchor id — and passing it back as `messagesResumeCursor` continues the walk from there (`limit` then bounds how many messages are returned FROM that point). The two token forms are `\"cursor:<peer position>\"` (the walk consumed its whole page window) and `\"after:<messageId>\"` (it stopped inside the window; the read restarts at the newest end and resumes strictly after that message). A token this tool did not emit is refused locally rather than forwarded, and an `after:` anchor that no longer exists in the history is an error, not an empty page. A `truncationReason` of \"limit\" is the caller's own ceiling and is reached by raising `limit`. Either way the newest message is always returned whole.",
5793
6019
  parameters: {
5794
6020
  type: "object",
5795
6021
  properties: {
@@ -5987,11 +6213,13 @@ var CherryToolEngine = class {
5987
6213
  const partsTruncated = page.truncated ? page.truncationReason ?? "safety-bound" : void 0;
5988
6214
  const unavailableParts = parts.filter((part) => part.unavailable !== void 0).length;
5989
6215
  const incomplete = unavailableParts > 0 || partsTruncated !== void 0;
6216
+ const failure = projectMessageFailure(raw);
5990
6217
  return {
5991
6218
  messageId,
5992
6219
  revision,
5993
6220
  role: String(raw["role"] ?? ""),
5994
6221
  status: String(raw["status"] ?? ""),
6222
+ ...failure === void 0 ? {} : { failure },
5995
6223
  parts,
5996
6224
  ...partsTruncated === void 0 ? {} : {
5997
6225
  partsTruncated,
@@ -6104,13 +6332,23 @@ var CherryToolEngine = class {
6104
6332
  * back a snapshot that looks complete while containing content the peer
6105
6333
  * never certified. That is worse than an error: the caller cannot tell it
6106
6334
  * apart from a good read. It keeps failing loudly.
6335
+ * - **NOT degradable — a RATE LIMIT.** A `RATE_LIMITED` refusal is
6336
+ * SESSION-wide, not part-specific: the peer refused the arrival, it did not
6337
+ * fail to serve THIS part. Degrading would hand back a "complete" message
6338
+ * whose text is empty for a reason the caller could act on (wait and ask
6339
+ * again) — and if one slice is refused the others will be too, so the
6340
+ * snapshot would be missing precisely what was requested. The connection
6341
+ * already waited out `retryAfterMs` and replayed the slice ONCE, so a
6342
+ * second refusal is a real failure and the whole read fails with it.
6107
6343
  *
6108
6344
  * The discriminator is the DIGEST CHECK specifically, which is why
6109
6345
  * {@link CherryToolEngine.#readExternalized} marks it: matching on message
6110
- * text would break the moment the wording changed.
6346
+ * text would break the moment the wording changed. The rate-limit arm keys on
6347
+ * the peer's REASON, never on wording.
6111
6348
  */
6112
6349
  #degradableReadFailure(error) {
6113
- return !(error instanceof ContentIntegrityError);
6350
+ if (error instanceof ContentIntegrityError) return false;
6351
+ return failureReason(error) !== "RATE_LIMITED";
6114
6352
  }
6115
6353
  /**
6116
6354
  * The pre-flight refusal of a `{ref}` whose DECLARED size is above the cap —
@@ -6912,18 +7150,18 @@ var CherryToolEngine = class {
6912
7150
  * subscription failures still reject this call: the two surfaces stay
6913
7151
  * distinct all the way to the caller (spec §6).
6914
7152
  *
6915
- * ONE exception, and it is a HINT rather than a translation: the peer's
7153
+ * TWO exceptions, and both are HINTS rather than translations: the peer's
6916
7154
  * `IDEMPOTENCY_CONFLICT` — the same `commandId` re-used with a DIFFERENT body
6917
- * — carries the §6 recovery advice on the rethrown error (see
6918
- * {@link withIdempotencyHint}). The reason, the `data` and the failure
6919
- * currency are untouched; only the prose a caller reads grows the actionable
6920
- * half.
7155
+ * — and its `RATE_LIMITED` refusal (desktop >= 2.1.7) each carry their
7156
+ * recovery advice on the rethrown error (see {@link withIdempotencyHint} /
7157
+ * {@link withRateLimitHint}). The reason, the `data` and the failure currency
7158
+ * are untouched; only the prose a caller reads grows the actionable half.
6921
7159
  */
6922
7160
  async #command(connection, method, params) {
6923
7161
  try {
6924
7162
  return await connection.rpc.request(method, params);
6925
7163
  } catch (error) {
6926
- throw withIdempotencyHint(error);
7164
+ throw withRateLimitHint(withIdempotencyHint(error));
6927
7165
  }
6928
7166
  }
6929
7167
  /**
@@ -55,17 +55,17 @@
55
55
 
56
56
  ---
57
57
 
58
- ### 3. `rate-limit-terminates-session` — Over-limit rate limiting terminates the whole session (close 1011)
58
+ ### 3. `rate-limit-terminates-session` — Over-limit rate limiting is generation-dependent: a whole-session 1011 close (≤2.1.6) or a per-request refusal (≥2.1.7)
59
59
 
60
- **真实对端行为**:超过桌面每套接字预算(当前实现值 64 req/s,无协商通道)会以 close
61
- code **1011 终止整条会话**——不是可原地重试的**单请求** `RATE_LIMITED` 拒绝。
60
+ **真实对端行为**:≤2.1.6 超过桌面每套接字预算(64 req/s 共享、512 req/s 内容)会以 close
61
+ code **1011 终止整条会话**;≥2.1.7(PR #21687)共享额度改为 256 req/s,超限只拒绝**该条**
62
+ 请求(`RATE_LIMITED` + `retryAfterMs`),会话存活。
62
63
 
63
- **手动核验步骤**(与 `CONTRACT_DIFFERENCES[2].checklistStep` 同源):
64
+ **手动核验步骤**(与 `CONTRACT_DIFFERENCES[2].checklistStep` 同源,逐字一致):
64
65
 
65
- - [ ] rate-limit-terminates-session: against a real desktop, drive a burst well above the smooth budget and confirm the socket is closed with 1011 (session gone, state offline, backoff restart) rather than a single structured per-request rejection.
66
+ - [ ] rate-limit-terminates-session: on a ≥2.1.7 desktop, raise `rateBudgetPerSecond` (or use an unpaced probe) and drive a burst above the allowance; confirm each over-budget request is refused with `RATE_LIMITED` + `retryAfterMs` while the session stays live, a refused READ succeeds after ONE replay, and a refused COMMAND surfaces as a verdict and is not replayed. On ≤2.1.6, the same burst closes the socket with 1011 (state offline, backoff restart).
66
67
 
67
- **期望**:套接字被 1011 关闭 → 设备状态转 `offline` → 进入退避重启;
68
- **不出现**「某一条请求被单独拒绝、连接仍存活」这一形态。
68
+ **期望**:按对端版本二选一命中;两种形态都**不出现**未分类的 bare channel-closed 文本。
69
69
 
70
70
  ---
71
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cherry-bridge",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "packageManager": "npm@10.9.9",