dsh-cherry-bridge 0.1.9 → 0.1.11

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
@@ -12,6 +12,7 @@ import {
12
12
  } from './defaults.js'
13
13
  import { JsonRpcRequestError, RequestTimeoutError } from './rpc/jsonRpcClient.js'
14
14
  import { ContentReadGate } from './contentGate.js'
15
+ import { truncateErrorMessage } from './diagnostics.js'
15
16
  import type { ReadResult, SessionSubscriptions } from './subscriptions.js'
16
17
  import { PairingError } from './pairing.js'
17
18
  import type { PairingErrorKind } from './pairing.js'
@@ -290,21 +291,92 @@ export interface CherryReadPart {
290
291
  toolName?: string
291
292
  toolCallId?: string
292
293
  /**
293
- * The DESCRIPTIVE face of a binary attachment (`kind: 'file'`): what the file
294
- * is, never what it holds. `content` stays empty for such a part by design —
295
- * see {@link CherryToolEngine.#attachmentFace}.
294
+ * The DESCRIPTIVE face of an attachment — a `kind: 'file'` part, or the
295
+ * legacy `kind: 'data'` part the desktop labels `name: 'file'` and whose
296
+ * metadata is JSON in `content.text`: what the file is, never what it holds.
297
+ * `content` stays empty for such a part unless its record carries a key this
298
+ * face does not project — see {@link CherryToolEngine.#attachmentFace}.
296
299
  */
297
300
  name?: string
298
301
  mediaType?: string
299
302
  byteLength?: string
300
303
  }
301
304
 
305
+ /**
306
+ * The peer's verdict on a FAILED message, narrowed to what a caller can act on.
307
+ *
308
+ * The wire carries more (`failure.failure.source`, `context.responseBody`, the
309
+ * provider's status code); none of it is projected. `cherry_read` is a text
310
+ * channel for the conversation, and the provider's raw error body is neither
311
+ * conversation nor the caller's business — the peer already classified the
312
+ * cause into `reasonCode`, which is the actionable half.
313
+ */
314
+ export interface CherryReadFailure {
315
+ /**
316
+ * The peer's classification (`provider_unavailable`, `rate_limit`,
317
+ * `context_length`, … — the protocol's versioned vocabulary).
318
+ *
319
+ * Relayed as a SHAPE-checked string rather than matched against a closed
320
+ * enum: the vocabulary belongs to the peer and grows with it, and relabelling
321
+ * a new code `'unknown'` would hide a real cause behind a value that means
322
+ * "the peer could not classify". Absent or unusable values become
323
+ * `'unknown'`, which is exactly what the contract uses for that case.
324
+ */
325
+ reasonCode: string
326
+ /** Whether the peer judged a retry of the same work worthwhile. */
327
+ retryable: boolean
328
+ /**
329
+ * The peer's one-line explanation, bounded by the diagnostics error cap
330
+ * (200 characters) with a `…[truncated]` marker when it was cut.
331
+ */
332
+ message: string
333
+ }
334
+
335
+ /** `provider_unavailable`-shaped: lowercase words separated by underscores. */
336
+ const REASON_CODE_SHAPE = /^[a-z][a-z0-9_]{1,63}$/
337
+
338
+ /**
339
+ * Projects the wire's `failure` object (present on `status: 'error'` messages)
340
+ * into {@link CherryReadFailure}, or `undefined` when there is none.
341
+ *
342
+ * Deliberately tolerant of a malformed payload: a peer that sends
343
+ * `failure: { retryable: false }` with no nested record still yields a usable
344
+ * verdict (`reasonCode: 'unknown'`, empty message) instead of failing the whole
345
+ * read — the message's TEXT is already in hand, and losing it over a metadata
346
+ * field would trade the content for the annotation. A degenerate object
347
+ * (`{}`, `[]`) projects the same way: the peer DID attach a verdict, just an
348
+ * empty one, and saying so is more honest than reporting "no verdict". Only an
349
+ * ABSENT or non-object `failure` yields `undefined`.
350
+ */
351
+ export function projectMessageFailure(raw: Record<string, unknown>): CherryReadFailure | undefined {
352
+ const failure = raw['failure']
353
+ if (typeof failure !== 'object' || failure === null) return undefined
354
+ const record = failure as Record<string, unknown>
355
+ const nested = record['failure']
356
+ const detail = typeof nested === 'object' && nested !== null ? (nested as Record<string, unknown>) : {}
357
+ const rawReason = detail['reasonCode']
358
+ const reasonCode =
359
+ typeof rawReason === 'string' && REASON_CODE_SHAPE.test(rawReason) ? rawReason : 'unknown'
360
+ const rawMessage = record['message']
361
+ return {
362
+ reasonCode,
363
+ retryable: record['retryable'] === true,
364
+ message: truncateErrorMessage(typeof rawMessage === 'string' ? rawMessage : ''),
365
+ }
366
+ }
367
+
302
368
  /** One message in a `cherry_read` snapshot. */
303
369
  export interface CherryReadMessage {
304
370
  messageId: string
305
371
  revision: string
306
372
  role: string
307
373
  status: string
374
+ /**
375
+ * Present exactly when the peer sent a `failure` for this message (in
376
+ * practice: `status: 'error'`). A successful message never carries it, so a
377
+ * caller can test for the key rather than compare `status` strings.
378
+ */
379
+ failure?: CherryReadFailure
308
380
  parts: CherryReadPart[]
309
381
  /**
310
382
  * Present only when this message's parts could NOT all be read: the safety
@@ -792,6 +864,80 @@ const MESSAGE_CACHE_ENTRY_BYTES = 1_048_576
792
864
  */
793
865
  const MESSAGE_CACHE_BYTES_PER_DEVICE = 33_554_432
794
866
 
867
+ /**
868
+ * One command the engine issued under a CALLER-SUPPLIED `commandId`, kept so a
869
+ * retry can replay it by receipt (issues #115, #118).
870
+ *
871
+ * The three fields answer the three questions a retry raises:
872
+ *
873
+ * - `method` — is this id being replayed by the tool that minted it? An id
874
+ * remembered for a send says nothing about a respond retry, and the reverse;
875
+ * - `fingerprint` — did the caller ask for the SAME command? The caller-owned
876
+ * arguments only, compared with {@link sameCommandValue}; the DERIVED body
877
+ * fields are deliberately excluded, because they are exactly what moved;
878
+ * - `body` — what the peer's ledger recorded, reproduced verbatim.
879
+ */
880
+ interface RememberedCommand {
881
+ method: string
882
+ fingerprint: unknown
883
+ body: Record<string, unknown>
884
+ }
885
+
886
+ /**
887
+ * How many command bodies the engine remembers PER DEVICE, keyed by the
888
+ * CALLER-SUPPLIED `commandId` (issues #115, #118).
889
+ *
890
+ * Why a memory exists at all: a command's recovery path is RECEIPT. A reply
891
+ * lost on the wire is answered by re-sending the ORIGINAL body under the
892
+ * original id, and the desktop's ledger replays the recorded outcome. That
893
+ * promise was impossible for `cherry_send`, whose body carries a DERIVED field
894
+ * (`expectedIdleRevision`) that moves with every successful send: the retry
895
+ * quoted a fresh baseline, the body differed from the recorded one, and the peer
896
+ * answered IDEMPOTENCY_CONFLICT — on the one path where the caller cannot tell
897
+ * what already landed. So the engine remembers the EXACT body it emitted, per
898
+ * (device, caller id, method), and a same-id retry replays it verbatim. The
899
+ * same defect had a harder shape in `cherry_respond` / `cherry_cancel` (issue
900
+ * #118): their DERIVED fields were re-derived, and the retry usually met a
901
+ * pre-check (`unknown-interaction`, `no-active-execution`) before the body was
902
+ * ever compared — the memory is consulted before those, which is why it holds
903
+ * the whole issued body rather than a way to rebuild one.
904
+ *
905
+ * What an entry holds is one short body: a session id, the caller's text (at
906
+ * most the protocol's 32,768 characters, pre-validated before it is ever
907
+ * stored), one revision string — or, for respond/cancel, the interaction's
908
+ * staleness triple / the execution to abort, plus the caller-owned arguments
909
+ * the fingerprint compares. 128 is "more than any live retry window needs,
910
+ * small enough to be trivial": a caller retries a handful of command ids around
911
+ * a lost reply, and each is recoverable while it stays in this FIFO.
912
+ * Eviction costs a REQUEST, never correctness — an evicted id has NO entry, so
913
+ * a same-id retry re-derives a fresh baseline. For a SEND the re-derived body
914
+ * reaches the peer, which refuses the differing body exactly as it would
915
+ * without this memory; for a RESPOND or CANCEL the retry usually dies earlier,
916
+ * at the LOCAL pre-checks (`unknown-interaction`, `no-active-execution`,
917
+ * `execution-mismatch`) — the pre-fix failure mode, which names real state.
918
+ *
919
+ * PER DEVICE, like every engine cache: the id is the caller's, but the peer
920
+ * that settled it is not, and a second desktop must never be sent a body minted
921
+ * against the first.
922
+ *
923
+ * INJECTABLE for tests (`commandBodyEntriesPerDevice`), because the production
924
+ * value needs 129 live command ids before it evicts.
925
+ */
926
+ const COMMAND_BODY_ENTRIES_PER_DEVICE = 128
927
+
928
+ /**
929
+ * The three command methods whose bodies the engine remembers (see
930
+ * {@link COMMAND_BODY_ENTRIES_PER_DEVICE}).
931
+ *
932
+ * Named once, because they are used twice each by design: as the remembered
933
+ * entry's `method`, and as the method the entry is compared against on lookup.
934
+ * Spelling them at both ends is how a lookup could start matching the wrong
935
+ * tool without any type noticing.
936
+ */
937
+ const SEND_METHOD = 'agent.messages.send'
938
+ const RESPOND_METHOD = 'agent.interactions.respond'
939
+ const CANCEL_METHOD = 'agent.executions.cancel'
940
+
795
941
  /**
796
942
  * EMPTY-PAGE RUN limit for every aggregate walk. The desktop's pinned-list walk
797
943
  * legally answers ONE empty page with an advancing cursor (the pin→entity
@@ -1196,6 +1342,89 @@ const IDEMPOTENCY_HINT =
1196
1342
  'a retry whose parameters changed — never reuse an id with a different body (re-send the ' +
1197
1343
  'ORIGINAL body to replay the original receipt).'
1198
1344
 
1345
+ /**
1346
+ * The peer's own wording for `IDEMPOTENCY_CONFLICT` (its `agentHandlers` throws
1347
+ * exactly this), used when the ENGINE is the one that knows the body differs —
1348
+ * a caller-supplied `commandId` it has already emitted with other arguments
1349
+ * (issue #115).
1350
+ *
1351
+ * The refusal is LOCAL because the outcome is already decided: the peer's ledger
1352
+ * holds the recorded body, so a forwarded body would come back as this same
1353
+ * conflict, one round trip later, at the cost of a wire call that can never
1354
+ * succeed. Reusing the sentence keeps ONE vocabulary for one condition; callers
1355
+ * (and the existing conflict tests) match on the prefix.
1356
+ */
1357
+ const COMMAND_BODY_DIFFERS_MESSAGE = 'Command body differs from the recorded command'
1358
+
1359
+ /**
1360
+ * The JSON-RPC error code the transport reserves for protocol failures (the
1361
+ * server answers every peer-refused request with it). Used for the LOCAL
1362
+ * {@link COMMAND_BODY_DIFFERS_MESSAGE} refusal so it is the same currency a
1363
+ * forwarded one would have been: a `JsonRpcRequestError` carrying
1364
+ * `{reason: 'IDEMPOTENCY_CONFLICT'}`, distinguishable only by the fact that no
1365
+ * request was ever sent.
1366
+ *
1367
+ * A LOCAL twin of the literal `src/rpc/jsonRpcClient.ts` documents in its own
1368
+ * `JsonRpcRequestError` docblock: that module exports no code constant, and
1369
+ * adding one only to name this number would widen the published surface for it.
1370
+ */
1371
+ const JSON_RPC_PROTOCOL_ERROR_CODE = 1000
1372
+
1373
+ /**
1374
+ * The LOCAL {@link COMMAND_BODY_DIFFERS_MESSAGE} refusal — the peer's own
1375
+ * reason and sentence, wrapped with the same hints a forwarded conflict earns,
1376
+ * and never sent anywhere.
1377
+ *
1378
+ * ONE constructor for every command that remembers its body (send, respond,
1379
+ * cancel): the caller must not have to learn a second vocabulary for one
1380
+ * condition, and a test matches the prefix `Command body differs` whichever
1381
+ * tool produced it.
1382
+ */
1383
+ function commandBodyDiffers(): Error {
1384
+ const conflict = new JsonRpcRequestError(JSON_RPC_PROTOCOL_ERROR_CODE, COMMAND_BODY_DIFFERS_MESSAGE, {
1385
+ reason: 'IDEMPOTENCY_CONFLICT',
1386
+ message: COMMAND_BODY_DIFFERS_MESSAGE,
1387
+ })
1388
+ // The two hint helpers pass an error through UNCHANGED (they only append to
1389
+ // a conflict's / a rate-limit's prose), so the identity is the object built
1390
+ // above; the return type keeps the `JsonRpcRequestError` the callers branch
1391
+ // on (the engine's command-failure currency).
1392
+ withRateLimitHint(withIdempotencyHint(conflict))
1393
+ return conflict
1394
+ }
1395
+
1396
+ /**
1397
+ * DEEP structural equality, for the caller-owned half of a remembered command
1398
+ * body (issue #118).
1399
+ *
1400
+ * `cherry_respond`'s `response.answers` is a RECORD: two callers sending the
1401
+ * same answers in a different KEY ORDER are sending the SAME answer, and a
1402
+ * plain `!==` (or a JSON string comparison) would refuse the retry as a
1403
+ * different body — turning the receipt recovery into a local conflict on the
1404
+ * exact shapes it exists for. Arrays stay ORDERED: `questions` is a list, and
1405
+ * reordering it really is a different value.
1406
+ *
1407
+ * Deliberately small: the values compared here are tool arguments decoded from
1408
+ * JSON, so only JSON's two container kinds, the primitives, and `undefined`
1409
+ * (an omitted optional argument) need to be understood.
1410
+ */
1411
+ function sameCommandValue(left: unknown, right: unknown): boolean {
1412
+ if (left === right) return true
1413
+ if (Array.isArray(left) || Array.isArray(right)) {
1414
+ if (!Array.isArray(left) || !Array.isArray(right)) return false
1415
+ if (left.length !== right.length) return false
1416
+ return left.every((value, index) => sameCommandValue(value, right[index]))
1417
+ }
1418
+ if (isRecord(left) && isRecord(right)) {
1419
+ const leftKeys = Object.keys(left)
1420
+ if (leftKeys.length !== Object.keys(right).length) return false
1421
+ return leftKeys.every(
1422
+ (key) => Object.hasOwn(right, key) && sameCommandValue(left[key], right[key]),
1423
+ )
1424
+ }
1425
+ return false
1426
+ }
1427
+
1199
1428
  /**
1200
1429
  * Passes a command failure through UNCHANGED, except that `IDEMPOTENCY_CONFLICT`
1201
1430
  * gets {@link IDEMPOTENCY_HINT} appended to its message.
@@ -1215,6 +1444,87 @@ function withIdempotencyHint(error: unknown): unknown {
1215
1444
  return error
1216
1445
  }
1217
1446
 
1447
+ /**
1448
+ * The phrase this hint leaves behind, so applying it twice reads once. A phrase
1449
+ * from the ADVICE rather than the advice itself, because the wait clause varies
1450
+ * with the peer's `retryAfterMs`; matching the peer's own text would match every
1451
+ * rate-limit refusal, hinted or not.
1452
+ */
1453
+ const RATE_LIMIT_HINT_MARK = 'refuses BEFORE dispatch'
1454
+
1455
+ /**
1456
+ * How long a refused COMMAND was asked to wait, or undefined when the peer sent
1457
+ * nothing a caller can act on.
1458
+ *
1459
+ * `<= 0` counts as absent rather than "retry immediately": a zero delay is
1460
+ * noise in an error message, and the desktop clamps its own value to `>= 1`
1461
+ * (`RemoteAccessService.ts`). CEILED so a fractional remainder is never
1462
+ * reported as a delay shorter than the one the peer asked for.
1463
+ */
1464
+ function rateLimitedWaitMs(error: Error): number | undefined {
1465
+ if (!(error instanceof JsonRpcRequestError)) return undefined
1466
+ const data = error.data
1467
+ if (!isRecord(data)) return undefined
1468
+ const raw = data['retryAfterMs']
1469
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) return undefined
1470
+ return Math.ceil(raw)
1471
+ }
1472
+
1473
+ /**
1474
+ * The recovery advice appended to a command's `RATE_LIMITED` refusal (desktop
1475
+ * >= 2.1.7; see the transport-loss section of `connection.ts`), issue #109.
1476
+ *
1477
+ * Two facts decide the retry and NEITHER survives the tool boundary on its own:
1478
+ *
1479
+ * - **THIS attempt was not applied.** The desktop refuses BEFORE dispatch — its
1480
+ * gateway calls `remote.rpc.refuse(input, …)` INSTEAD of `receive(input)` —
1481
+ * so, unlike a reply lost on a dead link, this is a certain non-outcome.
1482
+ * That is what makes a retry safe, and it is why the SAME `commandId` may be
1483
+ * reused: nothing reached the peer's command ledger, so no
1484
+ * `IDEMPOTENCY_CONFLICT` is waiting for the retry. The sentence says THIS
1485
+ * attempt, not "the command", because an earlier attempt under the same
1486
+ * `commandId` may itself have lost its reply after dispatch: the retry advice
1487
+ * is right in that sequence too (the ledger replays the original receipt),
1488
+ * but "never landed" would be a claim about a run this error knows nothing
1489
+ * of.
1490
+ * - **How long the peer asked us to wait** — `retryAfterMs`, derived from its
1491
+ * own 1 s window and already on the error we are holding. The wait clause
1492
+ * comes BEFORE the retry blessing, so a skimming reader cannot take "safe to
1493
+ * retry" and act on it without waiting.
1494
+ *
1495
+ * The cause is named because at the DEFAULT pacing this refusal is unreachable:
1496
+ * the desktop's counter is per SOCKET (locals inside its `run()` loop, one per
1497
+ * accepted connection) and we pace 32/s shared / 256/s content against its
1498
+ * 64-or-256/s shared and 512/s content lines. Seeing one at all therefore
1499
+ * normally means the configured budget is above the peer's allowance — the knob
1500
+ * to look at, not a transient blip — and a caller told to "retry later" without
1501
+ * that pointer would keep retrying into the same wall. Only the 256/s figure is
1502
+ * quoted: this refusal exists only from 2.1.7, so the older 64/s line cannot be
1503
+ * the one an operator has to compare against.
1504
+ *
1505
+ * Like {@link withIdempotencyHint} this only ever EXTENDS `message`: the reason,
1506
+ * the `data` and the `JsonRpcRequestError` identity are the peer's, and one
1507
+ * command is still never replayed (the advice is for the CALLER's retry).
1508
+ */
1509
+ export function withRateLimitHint(error: unknown): unknown {
1510
+ if (!(error instanceof Error)) return error
1511
+ if (failureReason(error) !== 'RATE_LIMITED') return error
1512
+ if (error.message.includes(RATE_LIMIT_HINT_MARK)) return error
1513
+ const waitMs = rateLimitedWaitMs(error)
1514
+ const advice = [
1515
+ 'this attempt was not applied: the desktop refuses BEFORE dispatch, so nothing reached its ' +
1516
+ 'command ledger',
1517
+ waitMs === undefined
1518
+ ? 'A retry is safe, and it may reuse the SAME commandId'
1519
+ : `Wait about ${waitMs} ms first (the peer's retryAfterMs); a retry is then safe, and it may ` +
1520
+ 'reuse the SAME commandId',
1521
+ 'The default budget (32/s) does not come close to the desktop\'s allowance, so a refusal ' +
1522
+ 'normally means `rateBudgetPerSecond` was raised too far (2.1.7+ allows 256/s shared)',
1523
+ ]
1524
+ error.message = `${error.message} — ${advice.join('. ')}.`
1525
+ return error
1526
+ }
1527
+
1218
1528
  function optionalString(value: unknown): string | undefined {
1219
1529
  return typeof value === 'string' && value !== '' ? value : undefined
1220
1530
  }
@@ -1327,6 +1637,15 @@ export interface CherryToolEngineOptions {
1327
1637
  * which no loopback suite should spend its run on.
1328
1638
  */
1329
1639
  messageCacheBytesPerDevice?: number
1640
+ /**
1641
+ * Cap on the remembered `cherry_send` bodies for one device — issue #115.
1642
+ * Omitted uses {@link COMMAND_BODY_ENTRIES_PER_DEVICE}.
1643
+ *
1644
+ * Injectable so a test can drive the bound to one and observe the FIFO
1645
+ * arithmetic: at the production 128 a proof needs 129 live command ids, which
1646
+ * no loopback suite should spend its run on.
1647
+ */
1648
+ commandBodyEntriesPerDevice?: number
1330
1649
  /**
1331
1650
  * How many `{ref}` contents may be assembled at once across the WHOLE engine
1332
1651
  * — every tool call, every device. Omitted uses
@@ -1337,6 +1656,20 @@ export interface CherryToolEngineOptions {
1337
1656
  * property under test ("no more than the limit at once") is the same at 1.
1338
1657
  */
1339
1658
  contentReadConcurrency?: number
1659
+ /**
1660
+ * The `maxBytes` every `agent.content.read` slice asks for. Omitted uses
1661
+ * {@link CONTENT_READ_MAX_BYTES}, the protocol's `contentBytes`.
1662
+ *
1663
+ * TEST-ONLY, and it exists because the loopback fan-out caps a record at
1664
+ * 32 KiB: at the production 24 KiB no honest ref fits in TWO slices (one
1665
+ * full slice plus the `< 24 KiB` remainder), so a walk's SECOND slice — the
1666
+ * one that proves the ref's digest describes the whole of what is served,
1667
+ * rather than merely its first slice — is unreachable through the fake. A
1668
+ * smaller slice size makes any multi-slice behaviour testable. Never lowered
1669
+ * in production: the peer caps the slice anyway, and asking for less only
1670
+ * costs round trips.
1671
+ */
1672
+ contentReadSliceBytes?: number
1340
1673
  }
1341
1674
 
1342
1675
  /**
@@ -1385,18 +1718,42 @@ export function registerCherryTools(
1385
1718
  deps: CherryToolsDeps,
1386
1719
  options: CherryToolEngineOptions = {},
1387
1720
  ): () => void {
1721
+ return registerCherryToolsWithEngine(ctx, deps, options).dispose
1722
+ }
1723
+
1724
+ /**
1725
+ * The same registration, with the ENGINE it built handed back.
1726
+ *
1727
+ * Exists for TESTS: the engine's memory bookkeeping (the remembered command
1728
+ * bodies, the bounds they obey) is private to it on purpose, and the only
1729
+ * honest way to pin "bounded" and "dropped with the device" is to observe it
1730
+ * through this one seam instead of inferring it from retry outcomes that are
1731
+ * identical for an evicted entry and for one that never existed.
1732
+ *
1733
+ * Both this function and `export class CherryToolEngine` are SHIPPED names
1734
+ * (`src` is published on purpose, see AGENTS.md → Build facts), so they are a
1735
+ * compatibility commitment — which is why this seam is kept minimal.
1736
+ */
1737
+ export function registerCherryToolsWithEngine(
1738
+ ctx: CherryToolsContext,
1739
+ deps: CherryToolsDeps,
1740
+ options: CherryToolEngineOptions = {},
1741
+ ): { dispose: () => void; engine: CherryToolEngine | undefined } {
1388
1742
  const tools = ctx.tools
1389
1743
  if (tools === undefined) {
1390
1744
  // Headless deployments have no tool registry. Degrade to a warning rather
1391
1745
  // than throwing: the assembly's HTTP/UI half must still come up.
1392
1746
  console.warn('[cherry-remote] ctx.tools is unavailable; the cherry_* tools were not registered')
1393
- return () => {}
1747
+ return { dispose: () => {}, engine: undefined }
1394
1748
  }
1395
1749
 
1396
1750
  const engine = new CherryToolEngine(deps, options)
1397
1751
  const disposers = engine.definitions().map((definition) => tools.register(definition))
1398
- return () => {
1399
- for (const dispose of disposers) dispose()
1752
+ return {
1753
+ engine,
1754
+ dispose: () => {
1755
+ for (const dispose of disposers) dispose()
1756
+ },
1400
1757
  }
1401
1758
  }
1402
1759
 
@@ -1423,7 +1780,7 @@ function defineCherryTool(definition: {
1423
1780
  * share. Kept as one class (rather than per-face closures) so the per-session
1424
1781
  * and per-device state the later faces add has an obvious home.
1425
1782
  */
1426
- class CherryToolEngine {
1783
+ export class CherryToolEngine {
1427
1784
  readonly #deps: CherryToolsDeps
1428
1785
  /**
1429
1786
  * Circuit-breaker for the parts walk. A LOCAL policy value, injectable so a
@@ -1498,6 +1855,12 @@ class CherryToolEngine {
1498
1855
  * being read, shared by every tool call and every device this engine serves.
1499
1856
  */
1500
1857
  readonly #contentGate: ContentReadGate
1858
+ /**
1859
+ * The `maxBytes` one `agent.content.read` slice asks for
1860
+ * ({@link CONTENT_READ_MAX_BYTES} in production; injectable so a test can
1861
+ * reach a walk's second slice, see `CherryToolEngineOptions`).
1862
+ */
1863
+ readonly #contentReadSliceBytes: number
1501
1864
  /**
1502
1865
  * The parts pages already walked, keyed `<deviceId>\0<messageId>`.
1503
1866
  *
@@ -1568,7 +1931,49 @@ class CherryToolEngine {
1568
1931
  * not pinned by bookkeeping.
1569
1932
  */
1570
1933
  readonly #watchedConnections = new WeakSet<DeviceConnection>()
1571
-
1934
+ /**
1935
+ * The EXACT command body emitted for each (device, caller id, method), so a
1936
+ * lost reply can be recovered by receipt (issues #115, #118).
1937
+ *
1938
+ * Keyed `<deviceId>\0<commandId>`, exactly like every other engine cache. The
1939
+ * value is a {@link RememberedCommand}: the METHOD it was minted for, the
1940
+ * caller-owned FINGERPRINT a retry must match, and the BODY the retry
1941
+ * replays verbatim. The fingerprint is what must MATCH — a same-id call whose
1942
+ * arguments differ is not a retry of this command at all, and is refused
1943
+ * locally rather than sent. The body is what must be REPLAYED, derived
1944
+ * fields included: that is the whole point, because those fields are the ones
1945
+ * a retry cannot re-derive the same way.
1946
+ *
1947
+ * Three tools remember: `agent.messages.send` (the baseline it quoted),
1948
+ * `agent.interactions.respond` (the staleness triple) and
1949
+ * `agent.executions.cancel` (the execution it was told to abort). Two of
1950
+ * them run PRE-CHECKS before composing a body — respond's
1951
+ * `unknown-interaction`, cancel's `no-active-execution`/`execution-mismatch` —
1952
+ * and after a lost reply those pre-checks are usually the first thing the
1953
+ * retry meets, because the first attempt is what SETTLED the interaction and
1954
+ * ENDED the run. So a caller-supplied id is consulted BEFORE any derivation:
1955
+ * a remembered retry is an answer, not a fresh call.
1956
+ *
1957
+ * WHAT IS REMEMBERED: only CALLER-SUPPLIED ids. An auto-generated uuid is a
1958
+ * one-shot identity — nobody can retry it, so an entry for it is pure leak.
1959
+ * The entry is written the moment the command is ISSUED, before the reply is
1960
+ * awaited: a reply lost on the wire must not cost the only copy of the bytes
1961
+ * the peer's ledger will accept.
1962
+ *
1963
+ * Which body an id holds: the LATEST attempt issued for it — the first
1964
+ * attempt, or the self-heal's fresh-uuid retry when one ran. After a heal, a
1965
+ * same-id retry is therefore answered with the peer's conflict, deliberately:
1966
+ * replaying the ledger's rejected receipt would re-run the healed admission
1967
+ * and duplicate the run, and a conflict is the safer failure.
1968
+ *
1969
+ * Bounded FIFO, per device; dropped when the PAIRING is invalidated (unpair,
1970
+ * identity-mismatch) but KEPT across an ordinary reconnect — see
1971
+ * {@link COMMAND_BODY_ENTRIES_PER_DEVICE} and
1972
+ * {@link CherryToolEngine.#forgetDevice}.
1973
+ */
1974
+ readonly #commandBodies = new Map<string, RememberedCommand>()
1975
+ /** The per-device cap on {@link CherryToolEngine.#commandBodies}. */
1976
+ readonly #commandBodyEntriesPerDevice: number
1572
1977
  constructor(deps: CherryToolsDeps, options: CherryToolEngineOptions = {}) {
1573
1978
  this.#deps = deps
1574
1979
  this.#partsSafetyBound = Math.max(
@@ -1592,7 +1997,15 @@ class CherryToolEngine {
1592
1997
  1,
1593
1998
  Math.floor(options.messageCacheBytesPerDevice ?? MESSAGE_CACHE_BYTES_PER_DEVICE),
1594
1999
  )
2000
+ this.#commandBodyEntriesPerDevice = Math.max(
2001
+ 1,
2002
+ Math.floor(options.commandBodyEntriesPerDevice ?? COMMAND_BODY_ENTRIES_PER_DEVICE),
2003
+ )
1595
2004
  this.#contentGate = new ContentReadGate(options.contentReadConcurrency ?? DEFAULT_CONTENT_READ_CONCURRENCY)
2005
+ this.#contentReadSliceBytes = Math.max(
2006
+ 1,
2007
+ Math.floor(options.contentReadSliceBytes ?? CONTENT_READ_MAX_BYTES),
2008
+ )
1596
2009
  }
1597
2010
 
1598
2011
  definitions(): CherryToolDefinition[] {
@@ -2445,12 +2858,19 @@ class CherryToolEngine {
2445
2858
  'sha256-verified through `agent.content.read`. An unchanged message (same message ' +
2446
2859
  'revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a ' +
2447
2860
  'quiet session costs no `agent.parts.list` RPC; externalized content is still fetched ' +
2448
- 'and sha256-verified on every read, so unverified bytes are never served from memory. A binary attachment is reported as ' +
2449
- '`kind: "file"` with `name`/`mediaType`/`byteLength` and an EMPTY `content` — the bytes ' +
2861
+ 'and sha256-verified on every read, so unverified bytes are never served from memory. An ' +
2862
+ 'attachment is reported as METADATA, never as its bytes: a `kind: "file"` part, or a ' +
2863
+ 'legacy `data` part named `file` (the metadata as JSON in `content.text`), comes back as ' +
2864
+ '`name`/`mediaType` (plus `byteLength` for the `file` kind) with `content` emptied — or ' +
2865
+ 'kept verbatim when that record carries a key this projection does not report. The bytes ' +
2450
2866
  'are deliberately not fetched into a text channel. Messages come NEWEST-first, so the ' +
2451
2867
  'first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts ' +
2452
2868
  'inside a retained message\'s parts; a message whose own parts could not be fully read ' +
2453
- 'carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. The ' +
2869
+ 'carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. A ' +
2870
+ 'failed message (`status: "error"`) may carry `failure: {reasonCode, retryable, ' +
2871
+ 'message}` when the peer sent one — its classification, whether it judged a retry ' +
2872
+ 'worthwhile, and a bounded summary; the peer\'s `source` and `context.responseBody` ' +
2873
+ 'are never projected. The ' +
2454
2874
  'answer is ALSO bounded by an output budget: `truncated` with `truncationReason: ' +
2455
2875
  '"aggregate-bytes"` means the walk stopped because the result reached its size limit, ' +
2456
2876
  'not because `limit` was reached — raising `limit` does NOT reach the messages left out ' +
@@ -2779,11 +3199,13 @@ class CherryToolEngine {
2779
3199
  // stays absent rather than claiming a zero.
2780
3200
  const unavailableParts = parts.filter((part) => part.unavailable !== undefined).length
2781
3201
  const incomplete = unavailableParts > 0 || partsTruncated !== undefined
3202
+ const failure = projectMessageFailure(raw)
2782
3203
  return {
2783
3204
  messageId,
2784
3205
  revision,
2785
3206
  role: String(raw['role'] ?? ''),
2786
3207
  status: String(raw['status'] ?? ''),
3208
+ ...(failure === undefined ? {} : { failure }),
2787
3209
  parts,
2788
3210
  // A circuit-broken part list advertises itself: the caller can see that
2789
3211
  // this message is incomplete and that later parts are still unread, so
@@ -2896,52 +3318,152 @@ class CherryToolEngine {
2896
3318
  text = ''
2897
3319
  }
2898
3320
 
3321
+ // A LEGACY ATTACHMENT is a `data` part whose metadata is JSON TEXT, not an
3322
+ // object (see {@link CherryToolEngine.#attachmentFace}). The face is lifted
3323
+ // here, from the ALREADY-RESOLVED text — the same text a `{ref}` payload
3324
+ // resolves to — so an externalized metadata record lifts identically.
3325
+ const legacy = this.#legacyAttachment(kind, raw, text)
2899
3326
  return {
2900
3327
  partId,
2901
3328
  kind,
2902
3329
  revision,
2903
- content: text,
2904
- // Pre-2.1.5 peers (2.1.4 is inside the verified range) projected a file
2905
- // as a `data` part whose metadata sits INSIDE `content`; lifting it here
2906
- // is what keeps an attachment from degrading into an anonymous empty
2907
- // part across the peer upgrade.
2908
- ...this.#attachmentFace(kind, raw, content),
3330
+ // THE CONTENT RULE: the JSON text is emptied only when EVERY key of the
3331
+ // record is one the face projects; a record carrying keys we do not
3332
+ // project keeps its raw text, so nothing is silently dropped.
3333
+ content: legacy === undefined ? text : legacy.remainder,
3334
+ ...(legacy === undefined
3335
+ ? this.#attachmentFace(kind, raw, content)
3336
+ : this.#attachmentFace(kind, raw, content, legacy.record)),
2909
3337
  ...(typeof raw['state'] === 'string' ? { state: raw['state'] } : {}),
2910
3338
  ...(typeof raw['toolName'] === 'string' ? { toolName: raw['toolName'] } : {}),
2911
3339
  ...(typeof raw['toolCallId'] === 'string' ? { toolCallId: raw['toolCallId'] } : {}),
2912
3340
  }
2913
3341
  }
2914
3342
 
3343
+ /**
3344
+ * The ONE place a legacy `data` part is recognised as an attachment: the
3345
+ * structural discriminator, the JSON parse, and the lossless CONTENT rule.
3346
+ *
3347
+ * The legal legacy face is `{kind: 'data', name: 'file', content: {text:
3348
+ * '{\"mediaType\":…,\"filename\":…}'}}` — the metadata SERIALIZED AS JSON in
3349
+ * `content.text`, observed on the wire (2.1.7 desktop, image upload,
3350
+ * 2026-10-22). An OBJECT inside `content` is not producible by a conforming
3351
+ * peer at all: the pinned protocol 0.5.0 types `content` as a strict
3352
+ * `{text}` | `{ref}` union, so the old documentation described a shape no
3353
+ * peer can emit — and the real one was never lifted, which is how an image
3354
+ * upload reached a model as a JSON blob.
3355
+ *
3356
+ * WHY THE NAME IS THE DISCRIMINATOR, and why it is not a heuristic: the
3357
+ * desktop labels every `data` part with its data KIND in `name` — an
3358
+ * attachment is `'file'`, a task event is `'data-compaction-anchor'`, an
3359
+ * error is `'data-error'` — and the protocol types that field as an
3360
+ * `opaqueId`. This lift therefore REQUIRES the label to be the literal
3361
+ * `'file'` rather than ASSUMING every peer sets one: a `data` part that
3362
+ * carries no name reads as no name, is not lifted, and passes through
3363
+ * untouched. Several of those payloads are JSON too, so "the text parses"
3364
+ * proves nothing; `'file'` is the one label that means "this is an
3365
+ * attachment".
3366
+ *
3367
+ * NOTHING IS EVER DEGRADED: a text that does not parse, parses to a
3368
+ * non-record, or carries neither `mediaType` nor `filename` as a string is
3369
+ * not an attachment, and the part passes through COMPLETELY unchanged
3370
+ * (`remainder` is the raw text, no field is lifted) — this runs on every
3371
+ * `data` part of every read, so a throw here would take down the whole
3372
+ * snapshot.
3373
+ *
3374
+ * THE CONTENT RULE: `remainder` is EMPTY when every key of the record is one
3375
+ * this face projects (`mediaType`, `filename`) — the metadata is fully
3376
+ * represented, and a model sees structure rather than a blob. A record
3377
+ * carrying ANY other key keeps its raw text, so a field we do not project is
3378
+ * never silently dropped (lossless by default). That promise is about KEYS: a
3379
+ * key we DO project whose value is not a string (say `mediaType: 123`) is
3380
+ * counted as represented — the key survives — and reports no field, because
3381
+ * this face lifts strings only. Emptied text therefore means "every key was
3382
+ * one of ours", not "every value produced a field".
3383
+ */
3384
+ #legacyAttachment(
3385
+ kind: string,
3386
+ raw: Record<string, unknown>,
3387
+ text: string,
3388
+ ): { record: Record<string, unknown>; remainder: string } | undefined {
3389
+ if (kind !== 'data' || optionalString(raw['name']) !== 'file') return undefined
3390
+ let parsed: unknown
3391
+ try {
3392
+ parsed = JSON.parse(text) as unknown
3393
+ } catch {
3394
+ return undefined
3395
+ }
3396
+ if (!isRecord(parsed)) return undefined
3397
+ // At least ONE projected key must be a string: a record naming neither
3398
+ // `mediaType` nor `filename` says nothing about an attachment, whatever
3399
+ // its name label claims.
3400
+ if (optionalString(parsed['filename']) === undefined && optionalString(parsed['mediaType']) === undefined) {
3401
+ return undefined
3402
+ }
3403
+ const everythingLifted = Object.keys(parsed).every((key) => key === 'mediaType' || key === 'filename')
3404
+ return { record: parsed, remainder: everythingLifted ? '' : text }
3405
+ }
3406
+
2915
3407
  /**
2916
3408
  * The descriptive metadata of an attachment part — and deliberately NOT a
2917
3409
  * way to reach its bytes.
2918
3410
  *
2919
- * Two peer generations are covered, because both are inside the verified
2920
- * range and they disagree about where the metadata lives:
3411
+ * Two legal faces are covered, because both are inside the verified range and
3412
+ * they disagree about where the metadata lives:
2921
3413
  *
2922
3414
  * - **Cherry ≥ 2.1.5** — `{kind: 'file', name, ref}`, ref at the TOP level.
2923
- * - **Cherry 2.1.4** — `{kind: 'data', name, content: {mediaType, filename}}`.
3415
+ * - **legacy** — `{kind: 'data', name: 'file', content: {text: '<JSON>'}}`,
3416
+ * the metadata as JSON TEXT. {@link CherryToolEngine.#legacyAttachment}
3417
+ * parses it and passes the record in here.
3418
+ *
3419
+ * A THIRD arm — a `data` part carrying `{mediaType, filename}` as an OBJECT
3420
+ * inside `content` — is schema-ILLEGAL under the pinned protocol (see the
3421
+ * docblock above) and stays as a DEFENSIVE lift for a peer that violates it.
3422
+ * It is not the 2.1.4 projection: that story was wrong, and a misbehaving
3423
+ * peer's parts are not re-validated client-side, so the guarded arm costs
3424
+ * nothing and covers exactly the peer it was written for.
2924
3425
  *
2925
3426
  * Only string fields are lifted: a null `filename` (the peer's explicit "no
2926
3427
  * name") stays absent rather than becoming the string "null". Anything a
2927
3428
  * caller needs beyond this — the bytes — stays behind `agent.content.read`,
2928
3429
  * which this plugin does not call for a file part.
3430
+ *
3431
+ * WHEN A PARSED `metadata` RECORD IS PRESENT IT IS AUTHORITATIVE. A legacy
3432
+ * attachment's record can arrive externalized as `{ref}`, and that ref is the
3433
+ * peer's serialization ENVELOPE, not the attachment's description: its
3434
+ * `mediaType` is the literal `'text/plain'` the envelope is labelled with and
3435
+ * its `byteLength` is the JSON metadata's own byte count. Letting either
3436
+ * outrank the record would report `text/plain` as an image's media type and a
3437
+ * size that is not the file's — so the record wins the media type, and this
3438
+ * face reports NO `byteLength` at all (a legacy `data` part carries none). The
3439
+ * `file` arm is untouched: there the ref IS the file's own description.
2929
3440
  */
2930
3441
  #attachmentFace(
2931
3442
  kind: string,
2932
3443
  raw: Record<string, unknown>,
2933
3444
  content: Record<string, unknown>,
3445
+ metadata?: Record<string, unknown>,
2934
3446
  ): { name?: string; mediaType?: string; byteLength?: string } {
2935
3447
  if (kind !== 'file' && kind !== 'data') return {}
2936
3448
  const ref = isRecord(raw['ref']) ? raw['ref'] : isRecord(content['ref']) ? content['ref'] : {}
2937
3449
  // WHERE the name lives is part of the shape, not a matter of preference: on
2938
3450
  // a `file` part `name` IS the filename, while a `data` part's `name` is the
2939
- // data KIND label — the 2.1.4 projection passes the literal `'file'` there —
2940
- // so the filename can only come from `content.filename`. Reading `name` for
2941
- // the data kind would report "file" as the attachment's name.
2942
- const name = kind === 'file' ? optionalString(raw['name']) : optionalString(content['filename'])
2943
- const mediaType = optionalString(ref['mediaType']) ?? optionalString(content['mediaType'])
2944
- const byteLength = optionalString(ref['byteLength'])
3451
+ // data KIND label (the attachment's is the literal `'file'`), so a legacy
3452
+ // filename can only come from the metadata record's `filename`. Reading
3453
+ // `name` for the data kind would report "file" as the attachment's name.
3454
+ const name =
3455
+ kind === 'file'
3456
+ ? optionalString(raw['name'])
3457
+ : (optionalString(metadata?.['filename']) ?? optionalString(content['filename']))
3458
+ const mediaType =
3459
+ kind === 'data' && metadata !== undefined
3460
+ ? (optionalString(metadata['mediaType']) ?? optionalString(content['mediaType']))
3461
+ : (optionalString(ref['mediaType']) ??
3462
+ optionalString(metadata?.['mediaType']) ??
3463
+ optionalString(content['mediaType']))
3464
+ // A legacy `data` part carries no `byteLength`: the only ref it can have is
3465
+ // the metadata envelope's, whose length describes the RECORD.
3466
+ const byteLength = kind === 'file' ? optionalString(ref['byteLength']) : undefined
2945
3467
  return {
2946
3468
  ...(name === undefined ? {} : { name }),
2947
3469
  ...(mediaType === undefined ? {} : { mediaType }),
@@ -2967,13 +3489,23 @@ class CherryToolEngine {
2967
3489
  * back a snapshot that looks complete while containing content the peer
2968
3490
  * never certified. That is worse than an error: the caller cannot tell it
2969
3491
  * apart from a good read. It keeps failing loudly.
3492
+ * - **NOT degradable — a RATE LIMIT.** A `RATE_LIMITED` refusal is
3493
+ * SESSION-wide, not part-specific: the peer refused the arrival, it did not
3494
+ * fail to serve THIS part. Degrading would hand back a "complete" message
3495
+ * whose text is empty for a reason the caller could act on (wait and ask
3496
+ * again) — and if one slice is refused the others will be too, so the
3497
+ * snapshot would be missing precisely what was requested. The connection
3498
+ * already waited out `retryAfterMs` and replayed the slice ONCE, so a
3499
+ * second refusal is a real failure and the whole read fails with it.
2970
3500
  *
2971
3501
  * The discriminator is the DIGEST CHECK specifically, which is why
2972
3502
  * {@link CherryToolEngine.#readExternalized} marks it: matching on message
2973
- * text would break the moment the wording changed.
3503
+ * text would break the moment the wording changed. The rate-limit arm keys on
3504
+ * the peer's REASON, never on wording.
2974
3505
  */
2975
3506
  #degradableReadFailure(error: unknown): boolean {
2976
- return !(error instanceof ContentIntegrityError)
3507
+ if (error instanceof ContentIntegrityError) return false
3508
+ return failureReason(error) !== 'RATE_LIMITED'
2977
3509
  }
2978
3510
 
2979
3511
  /**
@@ -3042,7 +3574,7 @@ class CherryToolEngine {
3042
3574
  const refusal = this.#externalizedRefusal(ref)
3043
3575
  if (refusal !== undefined) throw refusal
3044
3576
  const maxSlices = Number.isSafeInteger(declaredBytes)
3045
- ? Math.max(1, Math.ceil(declaredBytes / CONTENT_READ_MAX_BYTES))
3577
+ ? Math.max(1, Math.ceil(declaredBytes / this.#contentReadSliceBytes))
3046
3578
  : DEFAULT_CONTENT_SLICE_BOUND
3047
3579
 
3048
3580
  let offset = '0'
@@ -3071,7 +3603,9 @@ class CherryToolEngine {
3071
3603
  contentId,
3072
3604
  revision,
3073
3605
  offset,
3074
- maxBytes: CONTENT_READ_MAX_BYTES,
3606
+ // The protocol's slice size in production; injectable for tests that
3607
+ // must reach a walk's SECOND slice (see the option's docblock).
3608
+ maxBytes: this.#contentReadSliceBytes,
3075
3609
  },
3076
3610
  deadline === undefined ? undefined : { deadline: deadline.instant },
3077
3611
  )) as Record<string, unknown>
@@ -3607,12 +4141,37 @@ class CherryToolEngine {
3607
4141
  request: { args: Record<string, unknown>; text: string },
3608
4142
  ): Promise<{ receipt: Record<string, unknown>; baselineSeq: number }> {
3609
4143
  const { args, text } = request
4144
+ const callerId = optionalString(args['commandId'])
3610
4145
  const attempt = async (
3611
4146
  commandId: string,
3612
4147
  revisionOverride?: string,
3613
4148
  ): Promise<{ receipt: Record<string, unknown>; baselineSeq: number }> => {
4149
+ // The memory belongs to the id it was MINTED for, so the lookup is gated
4150
+ // on `commandId === callerId`: the self-heal's fresh-uuid attempt derives
4151
+ // its own baseline instead of inheriting the caller's.
4152
+ const remembered =
4153
+ callerId === undefined || commandId !== callerId
4154
+ ? undefined
4155
+ : this.#recallCommandBody(deviceId, callerId)
4156
+ // A remembered id that belongs to ANOTHER method is a mismatch, not a
4157
+ // miss: refusing locally keeps the decision off the wire.
4158
+ if (remembered !== undefined && remembered.method !== SEND_METHOD) throw commandBodyDiffers()
4159
+ // A same-id call whose caller-owned arguments differ from the remembered
4160
+ // body is not a retry of that command: the peer holds the original body,
4161
+ // so the conflict is already decided and is answered LOCALLY, in the
4162
+ // peer's own words, with no wire call.
4163
+ const rememberedBody = remembered?.body
4164
+ const mismatched =
4165
+ remembered !== undefined &&
4166
+ (rememberedBody?.['sessionId'] !== sessionId || rememberedBody['text'] !== text)
4167
+ if (mismatched) throw commandBodyDiffers()
4168
+ // THE REMEMBERED BODY IS REPLAYED, derived field included. Rebuilding it
4169
+ // from a derived `expectedIdleRevision` instead is what #115's memory
4170
+ // exists to stop, so the value is read back rather than recomputed.
3614
4171
  const expectedIdleRevision =
3615
- revisionOverride ?? (await this.#idleRevision(connection, subscriptions, deviceId, sessionId))
4172
+ optionalString(rememberedBody?.['expectedIdleRevision']) ??
4173
+ revisionOverride ??
4174
+ (await this.#idleRevision(connection, subscriptions, deviceId, sessionId))
3616
4175
  // THE BASELINE IS TAKEN BEFORE THE COMMAND IS ISSUED, and that ordering
3617
4176
  // is what makes a fast run observable at all. The waiter's baseline is
3618
4177
  // the cut that decides which events belong to THIS run; taking it after
@@ -3624,7 +4183,16 @@ class CherryToolEngine {
3624
4183
  // Nothing before this point can emit this run's events: the command has
3625
4184
  // not been sent yet.
3626
4185
  const baselineSeq = this.#newestSeq(subscriptions, sessionId)
3627
- const receipt = await this.#command(connection, 'agent.messages.send', {
4186
+ // The body is remembered the moment it is ISSUED — a reply lost on the wire
4187
+ // must not cost the only copy of the bytes the peer's ledger will accept.
4188
+ if (callerId !== undefined) {
4189
+ this.#rememberCommandBody(deviceId, callerId, {
4190
+ method: SEND_METHOD,
4191
+ fingerprint: { sessionId, text },
4192
+ body: { commandId, sessionId, text, expectedIdleRevision },
4193
+ })
4194
+ }
4195
+ const receipt = await this.#command(connection, SEND_METHOD, {
3628
4196
  commandId,
3629
4197
  sessionId,
3630
4198
  text,
@@ -4020,6 +4588,26 @@ class CherryToolEngine {
4020
4588
  }
4021
4589
  }
4022
4590
 
4591
+ const callerId = optionalString(args['commandId'])
4592
+ const answer: Record<string, unknown> = response === undefined ? { decision } : { response }
4593
+ // A RETRY IS ANSWERED BEFORE ANYTHING IS DERIVED. The staleness triple
4594
+ // below is derived from what the plugin has observed, and after a lost
4595
+ // reply that observation has MOVED (the first answer settled the card)
4596
+ // or vanished (the peer no longer serves it). Re-deriving would either
4597
+ // quote a body the ledger never recorded or — the usual case — stop at
4598
+ // the `unknown-interaction` verdict below, before the body is ever
4599
+ // compared. So a caller-supplied id is consulted FIRST: same id, same
4600
+ // caller-owned arguments and same method replays the remembered body
4601
+ // verbatim, and the snapshot is not asked at all.
4602
+ const remembered =
4603
+ callerId === undefined ? undefined : this.#recallCommandBody(record.deviceId, callerId)
4604
+ if (remembered !== undefined && remembered.method !== RESPOND_METHOD) throw commandBodyDiffers()
4605
+ if (remembered !== undefined) {
4606
+ const fingerprint = { sessionId, interactionId, ...answer }
4607
+ if (!sameCommandValue(remembered.fingerprint, fingerprint)) throw commandBodyDiffers()
4608
+ return this.#command(connection, RESPOND_METHOD, remembered.body)
4609
+ }
4610
+
4023
4611
  await this.#ensureSubscription(record.deviceId, sessionId)
4024
4612
  const subscriptions = this.#deps.subs(record.deviceId)
4025
4613
 
@@ -4042,15 +4630,27 @@ class CherryToolEngine {
4042
4630
  } satisfies Record<string, unknown>
4043
4631
  }
4044
4632
 
4045
- return this.#command(connection, 'agent.interactions.respond', {
4046
- commandId: this.#commandId(args),
4633
+ const commandId = this.#commandId(args)
4634
+ const body = {
4635
+ commandId,
4047
4636
  sessionId,
4048
4637
  interactionId,
4049
4638
  expectedRevision: snapshot.revision,
4050
4639
  expectedExecutionId: snapshot.executionId,
4051
4640
  inputDigest: snapshot.inputDigest,
4052
- ...(response === undefined ? { decision } : { response }),
4053
- })
4641
+ ...answer,
4642
+ }
4643
+ // ISSUE-TIME WRITE: the body is fully composed here, so it is remembered
4644
+ // before the reply is awaited — a reply lost on the wire must not cost
4645
+ // the only copy of the bytes the peer's ledger will accept.
4646
+ if (callerId !== undefined) {
4647
+ this.#rememberCommandBody(record.deviceId, callerId, {
4648
+ method: RESPOND_METHOD,
4649
+ fingerprint: { sessionId, interactionId, ...answer },
4650
+ body,
4651
+ })
4652
+ }
4653
+ return this.#command(connection, RESPOND_METHOD, body)
4054
4654
  },
4055
4655
  })
4056
4656
  }
@@ -4092,6 +4692,27 @@ class CherryToolEngine {
4092
4692
  if (resolved.link === undefined) return resolved.verdict
4093
4693
  const { connection, record } = resolved.link
4094
4694
  const sessionId = requiredString(args, 'sessionId')
4695
+ const callerId = optionalString(args['commandId'])
4696
+ // The caller's own value is a PRE-CHECK, not a body field — but it is
4697
+ // part of what the caller ASKED FOR, so it belongs in the fingerprint: a
4698
+ // retry asserting a different execution is not a retry of this cancel.
4699
+ const supplied = optionalString(args['expectedExecutionId'])
4700
+
4701
+ // A RETRY IS ANSWERED BEFORE THE PRE-CHECKS — the same ordering (and
4702
+ // the same reason) as `agent.interactions.respond`'s consult above;
4703
+ // what differs here is only which verdicts would otherwise eat the
4704
+ // retry: `no-active-execution` and `execution-mismatch` describe the
4705
+ // state RIGHT NOW, and a lost reply left the run the first attempt
4706
+ // cancelled already finished.
4707
+ const remembered =
4708
+ callerId === undefined ? undefined : this.#recallCommandBody(record.deviceId, callerId)
4709
+ if (remembered !== undefined && remembered.method !== CANCEL_METHOD) throw commandBodyDiffers()
4710
+ if (remembered !== undefined) {
4711
+ if (!sameCommandValue(remembered.fingerprint, { sessionId, expectedExecutionId: supplied })) {
4712
+ throw commandBodyDiffers()
4713
+ }
4714
+ return this.#command(connection, CANCEL_METHOD, remembered.body)
4715
+ }
4095
4716
 
4096
4717
  await this.#ensureSubscription(record.deviceId, sessionId)
4097
4718
  const subscriptions = this.#deps.subs(record.deviceId)
@@ -4107,7 +4728,6 @@ class CherryToolEngine {
4107
4728
  message: `session ${sessionId} has no in-flight execution to cancel`,
4108
4729
  } satisfies Record<string, unknown>
4109
4730
  }
4110
- const supplied = optionalString(args['expectedExecutionId'])
4111
4731
  if (supplied !== undefined && supplied !== expectedExecutionId) {
4112
4732
  return {
4113
4733
  error: 'execution-mismatch',
@@ -4119,11 +4739,18 @@ class CherryToolEngine {
4119
4739
  } satisfies Record<string, unknown>
4120
4740
  }
4121
4741
 
4122
- return this.#command(connection, 'agent.executions.cancel', {
4123
- commandId: this.#commandId(args),
4124
- sessionId,
4125
- expectedExecutionId,
4126
- })
4742
+ const commandId = this.#commandId(args)
4743
+ const body = { commandId, sessionId, expectedExecutionId }
4744
+ // ISSUE-TIME WRITE, as respond's: the derived execution id is exactly
4745
+ // the field a retry cannot re-derive once the run has ended.
4746
+ if (callerId !== undefined) {
4747
+ this.#rememberCommandBody(record.deviceId, callerId, {
4748
+ method: CANCEL_METHOD,
4749
+ fingerprint: { sessionId, expectedExecutionId: supplied },
4750
+ body,
4751
+ })
4752
+ }
4753
+ return this.#command(connection, CANCEL_METHOD, body)
4127
4754
  },
4128
4755
  })
4129
4756
  }
@@ -4284,7 +4911,7 @@ class CherryToolEngine {
4284
4911
  // pairing's idle revisions and execution ids, and would grow the maps
4285
4912
  // for the lifetime of the process. The store record is untouched by
4286
4913
  // this — the caches are pure derived state and can always be rebuilt.
4287
- this.#forgetDevice(record.deviceId)
4914
+ this.#forgetDevice(record.deviceId, { dropCommandBodies: true })
4288
4915
  await this.#deps.evict(record.deviceId)
4289
4916
  // The remove is GUARDED, not merely ordered. `#selectRecords` already
4290
4917
  // rejects a quarantined store before a record can be named, so reaching
@@ -4330,12 +4957,12 @@ class CherryToolEngine {
4330
4957
  * subscription failures still reject this call: the two surfaces stay
4331
4958
  * distinct all the way to the caller (spec §6).
4332
4959
  *
4333
- * ONE exception, and it is a HINT rather than a translation: the peer's
4960
+ * TWO exceptions, and both are HINTS rather than translations: the peer's
4334
4961
  * `IDEMPOTENCY_CONFLICT` — the same `commandId` re-used with a DIFFERENT body
4335
- * — carries the §6 recovery advice on the rethrown error (see
4336
- * {@link withIdempotencyHint}). The reason, the `data` and the failure
4337
- * currency are untouched; only the prose a caller reads grows the actionable
4338
- * half.
4962
+ * — and its `RATE_LIMITED` refusal (desktop >= 2.1.7) each carry their
4963
+ * recovery advice on the rethrown error (see {@link withIdempotencyHint} /
4964
+ * {@link withRateLimitHint}). The reason, the `data` and the failure currency
4965
+ * are untouched; only the prose a caller reads grows the actionable half.
4339
4966
  */
4340
4967
  async #command(
4341
4968
  connection: DeviceConnection,
@@ -4345,7 +4972,7 @@ class CherryToolEngine {
4345
4972
  try {
4346
4973
  return (await connection.rpc.request(method, params)) as Record<string, unknown>
4347
4974
  } catch (error) {
4348
- throw withIdempotencyHint(error)
4975
+ throw withRateLimitHint(withIdempotencyHint(error))
4349
4976
  }
4350
4977
  }
4351
4978
 
@@ -4645,7 +5272,8 @@ class CherryToolEngine {
4645
5272
  * the stored credential no longer describes a valid pairing. A transient
4646
5273
  * network drop ALSO lands here and clears the caches unnecessarily; that
4647
5274
  * is accepted, because the cost is one round trip per session on reuse and
4648
- * the alternative is keeping snapshots across an identity change.
5275
+ * the alternative is keeping snapshots across an identity change. (The
5276
+ * remembered command BODIES are the one exception: see `#forgetDevice`.)
4649
5277
  * - `identity-mismatch` — the peer's pinned key changed: the pairing is over.
4650
5278
  *
4651
5279
  * {@link CherryToolEngine.#resolve} installs the watch on every tool call that
@@ -4673,7 +5301,8 @@ class CherryToolEngine {
4673
5301
  this.#watchedConnections.add(connection)
4674
5302
  watch(deviceId, (state) => {
4675
5303
  if (state !== 'offline' && state !== 'identity-mismatch') return
4676
- this.#forgetDevice(deviceId)
5304
+ // A plain `offline` keeps the remembered command bodies: see `#forgetDevice`.
5305
+ this.#forgetDevice(deviceId, { dropCommandBodies: state === 'identity-mismatch' })
4677
5306
  })
4678
5307
  }
4679
5308
 
@@ -4686,8 +5315,16 @@ class CherryToolEngine {
4686
5315
  * leaves the archive (`cherry_unpair`), so a re-paired device cannot inherit
4687
5316
  * the previous pairing's snapshots and a long-lived process does not
4688
5317
  * accumulate entries for devices that no longer exist.
5318
+ *
5319
+ * The remembered command bodies obey a NARROWER seam than the read caches:
5320
+ * `dropCommandBodies` is passed by the callers that invalidate the PAIRING
5321
+ * (unpair, identity-mismatch), never by an ordinary `offline`. What a body
5322
+ * replays against — the peer's command ledger — survives a reconnect, and the
5323
+ * body is the only local copy of the exact bytes that ledger will accept, so
5324
+ * a link drop that emptied it would cost the recovery itself, where the read
5325
+ * caches merely pay one re-walk.
4689
5326
  */
4690
- #forgetDevice(deviceId: string): void {
5327
+ #forgetDevice(deviceId: string, options: { dropCommandBodies?: boolean } = {}): void {
4691
5328
  const prefix = `${deviceId}\u0000`
4692
5329
  for (const key of [...this.#revisionCache.keys()]) {
4693
5330
  if (key.startsWith(prefix)) this.#forget(this.#revisionCache, key)
@@ -4708,6 +5345,116 @@ class CherryToolEngine {
4708
5345
  for (const key of [...this.#messageCache.keys()]) {
4709
5346
  if (key.startsWith(prefix)) this.#dropCachedMessage(key)
4710
5347
  }
5348
+ // The remembered command bodies (issues #115/#118) differ from every cache above:
5349
+ // what they replay against is the peer's command LEDGER, and that survives
5350
+ // an ordinary reconnect — so they are dropped only when the PAIRING is
5351
+ // invalidated (`dropCommandBodies`: unpair, identity-mismatch), because a
5352
+ // body minted against one pairing must not be replayed to whatever desktop
5353
+ // answers under this device id next.
5354
+ //
5355
+ // The residual, stated rather than closed: GRANT_REVOKED also surfaces as
5356
+ // `offline`, so a voided pairing's entries survive until the re-pair path
5357
+ // (unpair / identity-mismatch) or the 128-entry FIFO retires them; a same-id
5358
+ // retry across a silent re-pair replays a body the new peer admits as a NEW
5359
+ // command — and the caller asked for that send.
5360
+ if (options.dropCommandBodies === true) {
5361
+ for (const key of [...this.#commandBodies.keys()]) {
5362
+ if (key.startsWith(prefix)) this.#forget(this.#commandBodies, key)
5363
+ }
5364
+ }
5365
+ }
5366
+
5367
+ /**
5368
+ * The command remembered for one caller-supplied `commandId`, keyed
5369
+ * `<deviceId>\0<commandId>` — or `undefined` when this device has never
5370
+ * emitted one (a first attempt, or an entry the FIFO evicted).
5371
+ *
5372
+ * The METHOD is the caller's to judge, and it is a THREE-WAY decision, never
5373
+ * a miss:
5374
+ *
5375
+ * - no entry → a fresh call;
5376
+ * - an entry under the caller's own method → match the fingerprint, then
5377
+ * replay or refuse;
5378
+ * - an entry under a DIFFERENT method → a MISMATCH, refused locally. An id
5379
+ * minted for `agent.messages.send` is not a respond retry, and collapsing
5380
+ * that case into "no entry" would send a respond under a send's id and
5381
+ * let the PEER answer the conflict one round trip later.
5382
+ */
5383
+ #recallCommandBody(deviceId: string, commandId: string): RememberedCommand | undefined {
5384
+ return this.#commandBodies.get(this.#cacheKey(deviceId, commandId))
5385
+ }
5386
+
5387
+ /**
5388
+ * TEST SEAM — the remembered bodies, as one `commandId` per device.
5389
+ *
5390
+ * The memory itself is PRIVATE, and stays that way for callers. What a test
5391
+ * needs to assert is its bookkeeping — that the bound evicts the oldest entry
5392
+ * and that {@link CherryToolEngine.#forgetDevice} drops a device's entries —
5393
+ * and neither can be observed through a real retry: a retry of an evicted id
5394
+ * is indistinguishable from a retry of an absent one, because both are refused
5395
+ * locally by the same rule. Keys are rendered WITHOUT the device component so
5396
+ * a test reads ids, not cache internals.
5397
+ */
5398
+ rememberedCommandIds(deviceId: string): string[] {
5399
+ const prefix = `${deviceId}${CACHE_KEY_SEPARATOR}`
5400
+ return [...this.#commandBodies.keys()]
5401
+ .filter((key) => key.startsWith(prefix))
5402
+ .map((key) => key.slice(prefix.length))
5403
+ }
5404
+
5405
+ /**
5406
+ * TEST SEAM — fill the memory the way a long-lived process would: one entry
5407
+ * per id, WITHOUT spending a wire call on each. The bound and the FIFO order
5408
+ * are the engine's own ({@link CherryToolEngine.#rememberCommandBody}), which
5409
+ * is what makes this a test of the real policy rather than of a mock of it.
5410
+ */
5411
+ rememberCommandBodyForTest(deviceId: string, commandId: string): void {
5412
+ this.#rememberCommandBody(deviceId, commandId, {
5413
+ method: 'agent.messages.send',
5414
+ fingerprint: { sessionId: 'session-for-test', text: 'text-for-test' },
5415
+ body: {
5416
+ commandId,
5417
+ sessionId: 'session-for-test',
5418
+ text: 'text-for-test',
5419
+ expectedIdleRevision: '0',
5420
+ },
5421
+ })
5422
+ }
5423
+
5424
+ /**
5425
+ * Remembers the exact body one caller-supplied `commandId` was ISSUED with,
5426
+ * evicting this device's oldest entry when it is at the bound.
5427
+ *
5428
+ * FIFO, per device: `Map` insertion order IS the policy, the same deliberate
5429
+ * choice the session caches document, and a `set` on an EXISTING key keeps its
5430
+ * position — a refresh is a rewrite, not a use, so the policy stays
5431
+ * insert-order rather than becoming LRU. One line per eviction, naming the
5432
+ * device and the bound only.
5433
+ */
5434
+ #rememberCommandBody(
5435
+ deviceId: string,
5436
+ commandId: string,
5437
+ remembered: RememberedCommand,
5438
+ ): void {
5439
+ const key = this.#cacheKey(deviceId, commandId)
5440
+ if (!this.#commandBodies.has(key)) {
5441
+ let own = 0
5442
+ let oldest: string | undefined
5443
+ for (const candidate of this.#commandBodies.keys()) {
5444
+ if (!candidate.startsWith(`${deviceId}\u0000`)) continue
5445
+ own += 1
5446
+ if (oldest === undefined) oldest = candidate
5447
+ }
5448
+ if (own >= this.#commandBodyEntriesPerDevice && oldest !== undefined) {
5449
+ this.#commandBodies.delete(oldest)
5450
+ console.warn(
5451
+ `[cherry-remote] remembered command bodies for device ${deviceId} reached their ` +
5452
+ `${this.#commandBodyEntriesPerDevice}-commandId bound; evicted the oldest (a retry of ` +
5453
+ 'that commandId now re-derives its body, and the peer decides whether it replays)',
5454
+ )
5455
+ }
5456
+ }
5457
+ this.#commandBodies.set(key, remembered)
4711
5458
  }
4712
5459
 
4713
5460
  /**