dsh-cherry-bridge 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -40,13 +40,13 @@ flowchart TB
40
40
 
41
41
  ```bash
42
42
  # From npm (recommended, pinned)
43
- npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.2.1
43
+ npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.2.3
44
44
 
45
45
  # Install the latest npm release
46
46
  npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
47
47
 
48
48
  # From GitHub source (same commit as the npm release)
49
- npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.2.1
49
+ npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.2.3
50
50
  ```
51
51
 
52
52
  The package ships `cordis.patch.yml` (`package.json`'s `dsh.bundle.patch` points
@@ -93,7 +93,7 @@ cherry_interactions → see pending question/approval cards; cherry_respond answ
93
93
  | Key | Type | Default | Notes |
94
94
  | --- | --- | --- | --- |
95
95
  | `dataDir` | string | `$DSH_HOME/cherry-remote` (fallback `~/.dsh/cherry-remote`) | Plugin data directory holding `identity.key` and `devices.json` (both `0600`). |
96
- | `perSessionCacheBytes` | number | `2097152` (2 MiB) | Per-session cap on the **local** subscription event cache; beyond it the oldest events are dropped LRU-style with a `warn`. |
96
+ | `perSessionCacheBytes` | number | `2097152` (2 MiB) | Per-session cap on the **local** subscription event cache; beyond it events are dropped from the oldest end (FIFO) with a `warn`, but the **newest single event is always kept** — even when it alone exceeds the cap (no drop line is logged then, and the window stays over budget). |
97
97
  | `rateBudgetPerSecond` | number | `32` | Per-device smoothed request budget (req/s). The desktop's shared allowance is 64 req/s through 2.1.6 and 256 req/s from 2.1.7, and only the newer generation downgrades an overrun to a single `RATE_LIMITED` refusal (the older one terminates the session with close `1011`) — hence a value sized for the harsher generation. Content reads (`agent.content.read`) derive their own tier at 8:1, **capped at 512/s** (that allowance is the same in both generations). |
98
98
  | `wsTls` | object | — | **Flat** `tls.connect` options for every `wss` dial (`rejectUnauthorized` / `ca` only): operator-supplied trust for a desktop behind a private CA or a self-signed TLS front. Applied to BOTH dial sites — pairing and reconnect. **Never written to the device archive, never logged, never in diagnostics.** The whole object applies as written or is ignored entirely: an unknown key or unusable value warns by name and falls back to Node's default trust store (no partial application). |
99
99
 
package/README.md CHANGED
@@ -38,13 +38,13 @@ flowchart TB
38
38
 
39
39
  ```bash
40
40
  # 从 npm 安装(推荐,钉住版本)
41
- npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.2.1
41
+ npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.2.3
42
42
 
43
43
  # 安装 npm 最新发布版
44
44
  npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
45
45
 
46
46
  # 从 GitHub 源码安装(与 npm 发布同一提交)
47
- npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.2.1
47
+ npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.2.3
48
48
  ```
49
49
 
50
50
  本包自带 `cordis.patch.yml`(`package.json` 的 `dsh.bundle.patch` 指向它),
@@ -85,7 +85,7 @@ cherry_interactions → 看待答的提问/审批卡;cherry_respond 作答
85
85
  | 键 | 类型 | 默认 | 说明 |
86
86
  | --- | --- | --- | --- |
87
87
  | `dataDir` | string | `$DSH_HOME/cherry-remote`(回退 `~/.dsh/cherry-remote`) | 插件数据目录,存放 `identity.key` 与 `devices.json`(均 `0600`)。 |
88
- | `perSessionCacheBytes` | number | `2097152`(2 MiB) | 每个会话的**本地**订阅事件缓存上限,超出按 LRU 丢弃最旧事件并 `warn`。 |
88
+ | `perSessionCacheBytes` | number | `2097152`(2 MiB) | 每个会话的**本地**订阅事件缓存上限;超出后从最旧一端丢弃(FIFO)并 `warn`,但**最新的单条事件总会保留**——即使它自己就超过上限(此时不打丢弃日志,窗口保持超预算)。 |
89
89
  | `rateBudgetPerSecond` | number | `32` | 每设备平滑请求预算(请求/秒)。桌面端共享额度在 ≤2.1.6 是 64 req/s、≥2.1.7 是 256 req/s,且只有新版本才把超限降级为单请求 `RATE_LIMITED`(旧版本直接 close `1011` 终止会话),故按更严格的一代取保守值。内容读取(`agent.content.read`)按 8:1 派生独立一档,且**封顶 512/s**(该额度两代一致)。 |
90
90
  | `wsTls` | object | — | 每次 `wss` 拨号的**扁平** `tls.connect` 选项(只接受 `rejectUnauthorized` / `ca`),供桌面端位于私有 CA 或自签 TLS 前置之后时由运维提供信任;配对与重连**两个拨号点**共用。**不写入设备档案、不写日志、不进诊断报告。** 整个对象要么按原样生效、要么整体忽略:未知键或不可用值按名 `warn` 后回退 Node 默认信任库(不部分应用)。 |
91
91
 
package/dist/index.mjs CHANGED
@@ -5,7 +5,7 @@ import { chmod, copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import { RemoteSocketStream, connectSecureChannel, createDeviceIdentity, deviceIdentityId } from "@cherrystudio/remote-transport";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { WebSocket } from "ws";
8
- import { advertisedEndpointSchema, directEndpointUrl, jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema, pairingQrSchema, remoteFailureSchema } from "@cherrystudio/remote-protocol";
8
+ import { advertisedEndpointSchema, directEndpointUrl, jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema, pairingQrSchema, remoteFailureSchema, remoteLimits } from "@cherrystudio/remote-protocol";
9
9
  import { peerIdFromString } from "@libp2p/peer-id";
10
10
  /**
11
11
  * Adds the candidate addresses a peer ADVERTISED that this record does not
@@ -2414,10 +2414,11 @@ function projectMessageSummaries(raw) {
2414
2414
  return typeof raw === "number" && Number.isFinite(raw) ? { messageSummaries: raw } : {};
2415
2415
  }
2416
2416
  /**
2417
- * Re-projects the four numeric limit keys, dropping any foreign key that a
2418
- * caller's `limits` object might carry. A whitelist inside the whitelist: the
2419
- * shape is rebuilt field by field rather than spread, so a future addition to
2420
- * `EffectiveLimits` cannot arrive here unvetted.
2417
+ * Re-projects the four numeric limit keys this report carries, dropping any
2418
+ * foreign key that a caller's `limits` object might carry — including
2419
+ * `EffectiveLimits`' newer `unackedBytes`, which no diagnostics consumer needs.
2420
+ * A whitelist inside the whitelist: the shape is rebuilt field by field rather
2421
+ * than spread, so a future addition cannot arrive here unvetted.
2421
2422
  */
2422
2423
  function projectLimits(raw) {
2423
2424
  if (raw === null || typeof raw !== "object") return {};
@@ -2432,6 +2433,14 @@ function projectLimits(raw) {
2432
2433
  function numberOr(value, fallback) {
2433
2434
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
2434
2435
  }
2436
+ /**
2437
+ * A delivery-ledger counter (issue #217): a NON-NEGATIVE finite number, or
2438
+ * nothing at all. Absence means "not reported"; a fabricated zero would read as
2439
+ * "nothing was delivered", which is a claim this projection has no evidence for.
2440
+ */
2441
+ function countEntry(key, raw) {
2442
+ return typeof raw === "number" && Number.isFinite(raw) && raw >= 0 ? { [key]: raw } : {};
2443
+ }
2435
2444
  /** The loose-bag view every projection in this module takes of its input. */
2436
2445
  function bag(raw) {
2437
2446
  return raw ?? {};
@@ -2549,7 +2558,10 @@ function sanitizeDiagnostics(source) {
2549
2558
  sessionId: typeof cursor["sessionId"] === "string" ? cursor["sessionId"] : sessionId,
2550
2559
  streamEpoch: typeof cursor["streamEpoch"] === "string" ? cursor["streamEpoch"] : "",
2551
2560
  seq: typeof cursor["seq"] === "string" ? cursor["seq"] : ""
2552
- } }
2561
+ } },
2562
+ ...countEntry("deliveredBytes", value["deliveredBytes"]),
2563
+ ...countEntry("unackedBytes", value["unackedBytes"]),
2564
+ ...countEntry("acks", value["acks"])
2553
2565
  };
2554
2566
  }),
2555
2567
  recentErrors: (source.recentErrors ?? []).map((raw) => {
@@ -2610,7 +2622,10 @@ function buildDiagnostics(sources, identity = DEFAULT_IDENTITY) {
2610
2622
  sessionId: entry.sessionId,
2611
2623
  deviceId: entry.deviceId,
2612
2624
  subscriptions: entry.subscriptions,
2613
- ...entry.cursor === void 0 ? {} : { cursor: { ...entry.cursor } }
2625
+ ...entry.cursor === void 0 ? {} : { cursor: { ...entry.cursor } },
2626
+ ...entry.deliveredBytes === void 0 ? {} : { deliveredBytes: entry.deliveredBytes },
2627
+ ...entry.unackedBytes === void 0 ? {} : { unackedBytes: entry.unackedBytes },
2628
+ ...entry.acks === void 0 ? {} : { acks: entry.acks }
2614
2629
  });
2615
2630
  } catch {}
2616
2631
  let recentErrors = [];
@@ -3379,6 +3394,29 @@ var DeviceConnection = class {
3379
3394
  #pendingPings = /* @__PURE__ */ new Set();
3380
3395
  #token;
3381
3396
  #tokenExpiresAt;
3397
+ /**
3398
+ * Hosts whose LAST attempt failed for that ADDRESS's own reason (issue #220).
3399
+ * A pass dials the hosts it has not failed yet FIRST, so a stale candidate
3400
+ * sitting first in `record.hosts` cannot cost its dial timeout on every
3401
+ * reconnect — the rule the official client states as "failed candidates
3402
+ * rotate behind untried addresses across retries, so the round budget cannot
3403
+ * permanently starve a later route"
3404
+ * (`.scratch-csa/docs/references/remote-access/connectivity.md` §Connection
3405
+ * lifetime; the tree is gitignored, and its remote-agent directory is the
3406
+ * same `16e505fe` at app tags v0.5.2 and v0.5.3, so this is not a rule that
3407
+ * arrived with a newer app).
3408
+ *
3409
+ * Demotion is ORDER, never exclusion: with every host marked the list falls
3410
+ * back to record order, so a record whose only address is marked still dials
3411
+ * it. A mark is cleared when that host itself connects, so a repaired address
3412
+ * returns to the front on its own — no expiry timer, no persistence, no
3413
+ * config key. A pairing-level verdict and a cancellation never mark (they are
3414
+ * not this address's fault: the line `isPerHostFailure` draws for the
3415
+ * handshake face, mirrored explicitly in the dial face, where only
3416
+ * `IdentityMismatchError` can arise today), and `close()` keeps the marks
3417
+ * because they describe addresses, not this lifecycle.
3418
+ */
3419
+ #demotedHosts = /* @__PURE__ */ new Set();
3382
3420
  #wsOptions;
3383
3421
  constructor(opts) {
3384
3422
  this.record = opts.record;
@@ -3613,7 +3651,7 @@ var DeviceConnection = class {
3613
3651
  this.#setState("connecting");
3614
3652
  if (this.record.hosts.length === 0) throw new AllHostsUnreachableError(this.record.deviceId, this.record.hosts, /* @__PURE__ */ new Error("the device record has no candidate hosts"));
3615
3653
  let lastError = /* @__PURE__ */ new Error(`device ${this.record.deviceId}: all candidate hosts unreachable`);
3616
- for (const host of this.record.hosts) {
3654
+ for (const host of this.#dialOrder()) {
3617
3655
  if (!this.#running || epoch !== this.#epoch) throw new CancelledError();
3618
3656
  let link;
3619
3657
  try {
@@ -3621,16 +3659,20 @@ var DeviceConnection = class {
3621
3659
  link = await this.#dial(host, port, epoch);
3622
3660
  } catch (error) {
3623
3661
  if (error instanceof IdentityMismatchError) throw error;
3662
+ if (error instanceof CancelledError) throw error;
3624
3663
  lastError = error instanceof Error ? error : new Error(String(error));
3664
+ this.#demotedHosts.add(host);
3625
3665
  continue;
3626
3666
  }
3627
3667
  this.#link = link;
3628
3668
  try {
3629
3669
  await this.#handshake(link, epoch);
3670
+ this.#demotedHosts.delete(host);
3630
3671
  return;
3631
3672
  } catch (error) {
3632
3673
  if (error instanceof CancelledError || !this.#running || epoch !== this.#epoch) throw new CancelledError();
3633
3674
  if (!isPerHostFailure(error)) throw error;
3675
+ this.#demotedHosts.add(host);
3634
3676
  this.#teardownLink();
3635
3677
  lastError = error instanceof Error ? error : new Error(String(error));
3636
3678
  continue;
@@ -3638,6 +3680,24 @@ var DeviceConnection = class {
3638
3680
  }
3639
3681
  throw new AllHostsUnreachableError(this.record.deviceId, this.record.hosts, lastError);
3640
3682
  }
3683
+ /**
3684
+ * The pass's dial order (issue #220): hosts this connection has NOT failed
3685
+ * yet, in record order, then the demoted ones, in record order.
3686
+ *
3687
+ * Record order is preserved INSIDE each half, so an ordinary single-address
3688
+ * record, a fresh record and an all-demoted record all dial exactly the list
3689
+ * the record carries — the demotion can only move an address that failed
3690
+ * behind addresses that did not, and can never drop one.
3691
+ */
3692
+ #dialOrder() {
3693
+ const hosts = this.record.hosts;
3694
+ if (this.#demotedHosts.size === 0) return hosts;
3695
+ const untried = [];
3696
+ const demoted = [];
3697
+ for (const host of hosts) if (this.#demotedHosts.has(host)) demoted.push(host);
3698
+ else untried.push(host);
3699
+ return [...untried, ...demoted];
3700
+ }
3641
3701
  async #dial(host, port, epoch) {
3642
3702
  const ws = new WebSocket(directEndpointUrl({
3643
3703
  host,
@@ -3704,11 +3764,13 @@ var DeviceConnection = class {
3704
3764
  if (rpcReason$1(error) === "INVALID_CONNECTION_STATE") throw new ProtocolVersionError(String(error));
3705
3765
  throw error instanceof Error ? error : new Error(String(error));
3706
3766
  }
3767
+ const unackedBytes = positive$2(hello.limits?.["unackedBytes"]);
3707
3768
  this.#limits = {
3708
3769
  heartbeatMs: positive$2(hello.heartbeatMs) ?? FALLBACK_LIMITS.heartbeatMs,
3709
3770
  subscriptions: positive$2(hello.limits?.["subscriptions"]) ?? FALLBACK_LIMITS.subscriptions,
3710
3771
  pageItems: positive$2(hello.limits?.["pageItems"]) ?? FALLBACK_LIMITS.pageItems,
3711
3772
  inFlightRequests: positive$2(hello.limits?.["inFlightRequests"]) ?? FALLBACK_LIMITS.inFlightRequests,
3773
+ ...unackedBytes === void 0 ? {} : { unackedBytes },
3712
3774
  ...typeof hello.agentMessageSummariesVersion === "number" ? { messageSummaries: hello.agentMessageSummariesVersion } : {}
3713
3775
  };
3714
3776
  link.client.updateMaxInFlight(businessWindowFor(this.#limits.inFlightRequests));
@@ -4716,6 +4778,26 @@ const DEFAULT_CHECKPOINT_MS = 3e5;
4716
4778
  * not a tuning knob.
4717
4779
  */
4718
4780
  const MAX_RECOVERY_ATTEMPTS = 4;
4781
+ /**
4782
+ * ACK WATERMARK (issue #212). The peer delivers `agent.events` only while the
4783
+ * bytes it has sent since our last `agent.subscriptions.ack` stay inside
4784
+ * `remoteLimits.unackedBytes`; past that it HOLDS the stream — silently, with
4785
+ * the subscription still live — and a held batch can be a run's terminal.
4786
+ *
4787
+ * This layer used to ack ONLY from `cherry_events`, so a waiter (which by
4788
+ * design consumes without acking) parked on a buffer that stopped advancing
4789
+ * just under one window: a real 240 s wait answered `timeout` with the finished
4790
+ * text, its cursor frozen at 666 events ≈ 262144 bytes.
4791
+ *
4792
+ * The layer therefore acks what it has ALREADY BUFFERED (the bytes are in
4793
+ * memory; acking does not lose them) once a quarter of the peer's window has
4794
+ * accumulated. A retry cooldown keeps a refusing peer from turning every batch
4795
+ * into a round trip.
4796
+ */
4797
+ /** First retry delay after a refused auto-ack; doubles per attempt. */
4798
+ const AUTO_ACK_RETRY_MS = 3e3;
4799
+ /** Ceiling for the doubling retry, so a permanently refusing peer is quiet. */
4800
+ const AUTO_ACK_RETRY_MAX_MS = 3e4;
4719
4801
  function positive$1(value, fallback) {
4720
4802
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
4721
4803
  }
@@ -4753,6 +4835,8 @@ var SessionSubscriptions = class {
4753
4835
  #limit;
4754
4836
  #perSessionCacheBytes;
4755
4837
  #checkpointMs;
4838
+ /** Where the peer's window comes from; see {@link SessionSubscriptionsOptions.unackedBytes}. */
4839
+ #unackedSource;
4756
4840
  #sessions = /* @__PURE__ */ new Map();
4757
4841
  /**
4758
4842
  * Insertion order IS the eviction order, and it is SUBSCRIPTION recency, not
@@ -4774,6 +4858,7 @@ var SessionSubscriptions = class {
4774
4858
  this.#limit = Math.max(1, Math.floor(positive$1(opts.subscriptionsLimit, DEFAULT_SUBSCRIPTIONS_LIMIT)));
4775
4859
  this.#perSessionCacheBytes = positive$1(opts.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES);
4776
4860
  this.#checkpointMs = positive$1(opts.checkpointMs, DEFAULT_CHECKPOINT_MS);
4861
+ this.#unackedSource = opts.unackedBytes;
4777
4862
  }
4778
4863
  /**
4779
4864
  * Session ids with a live subscription, LRU-first. Task 10's reconnect
@@ -4816,6 +4901,28 @@ var SessionSubscriptions = class {
4816
4901
  return this.#sessions.get(sessionId)?.cursor;
4817
4902
  }
4818
4903
  /**
4904
+ * The DELIVERY LEDGER of one subscription (issue #217): the payload bytes it
4905
+ * has received since it was installed, how much of that the peer may still
4906
+ * count as un-acknowledged, and how many acks it has taken. Undefined for a
4907
+ * session this layer does not hold — never a fabricated zero.
4908
+ *
4909
+ * Diagnostics is the caller: these three numbers are what turns 「the stream
4910
+ * exceeded the peer's window」 from an extrapolation into a measurement.
4911
+ *
4912
+ * A CLIENT-SIDE ledger, never the peer's: `unackedBytes` is this layer's
4913
+ * model of what the peer may still count (bytes since its last accepted
4914
+ * ack), not a reading of the peer's own window or ack position.
4915
+ */
4916
+ deliveryOf(sessionId) {
4917
+ const state = this.#sessions.get(sessionId);
4918
+ if (state === void 0) return void 0;
4919
+ return {
4920
+ deliveredBytes: state.deliveredBytes,
4921
+ unackedBytes: state.bytesSinceAck,
4922
+ acks: state.acks
4923
+ };
4924
+ }
4925
+ /**
4819
4926
  * The session's cache WINDOW START — the retention BOUNDARY of this layer's
4820
4927
  * cache: the oldest position it still tracks — or undefined when the session
4821
4928
  * is untracked.
@@ -4873,6 +4980,7 @@ var SessionSubscriptions = class {
4873
4980
  if (existing !== void 0) {
4874
4981
  console.info(`[cherry-remote] re-subscribing session ${sessionId} (replacing its live subscription)`);
4875
4982
  await this.#closeSubscription(existing.subscriptionId);
4983
+ this.#cancelAckRetry(existing);
4876
4984
  this.#rejectWaiters(existing, /* @__PURE__ */ new Error(`session ${sessionId} re-subscribed`));
4877
4985
  this.#sessions.delete(sessionId);
4878
4986
  }
@@ -4948,17 +5056,25 @@ var SessionSubscriptions = class {
4948
5056
  /**
4949
5057
  * Advances the peer's view of this session: `agent.subscriptions.ack` with
4950
5058
  * the delivered cursor. The peer only accepts a cursor inside the window it
4951
- * actually delivered (an out-of-window ack is its CONFLICT), so this is
4952
- * called with the cursor a consumer has durably processed.
5059
+ * actually delivered (an out-of-window ack is its CONFLICT).
5060
+ *
5061
+ * Two callers, one contract — "this position is accounted for, keep sending":
5062
+ * `cherry_events` passes the cursor a CONSUMER has processed, and
5063
+ * {@link #maybeAutoAck} passes the BUFFER frontier because the events are
5064
+ * retained here (see that method for why an ack cannot lose replay).
4953
5065
  */
4954
5066
  async ack(sessionId, cursor) {
4955
5067
  const state = this.#sessions.get(sessionId);
4956
5068
  if (state === void 0) return;
4957
- const applied = cursor ?? state.cursor;
5069
+ const requested = cursor === void 0 ? state.cursor : cursor;
5070
+ const applied = Number(requested.seq) > Number(state.cursor.seq) ? requested : state.cursor;
5071
+ const covered = state.bytesSinceAck;
4958
5072
  await this.#rpc.request("agent.subscriptions.ack", {
4959
5073
  subscriptionId: state.subscriptionId,
4960
5074
  cursor: applied
4961
5075
  });
5076
+ state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered);
5077
+ state.acks += 1;
4962
5078
  }
4963
5079
  /**
4964
5080
  * Parks a resolver that settles when the session buffer advances past
@@ -5006,6 +5122,7 @@ var SessionSubscriptions = class {
5006
5122
  this.#closed = true;
5007
5123
  const ids = [...this.#sessions.values()].map((state) => state.subscriptionId);
5008
5124
  for (const state of this.#sessions.values()) {
5125
+ this.#cancelAckRetry(state);
5009
5126
  const error = /* @__PURE__ */ new Error("SessionSubscriptions disposed");
5010
5127
  for (const waiter of state.waiters.splice(0)) waiter.reject(error);
5011
5128
  }
@@ -5088,13 +5205,23 @@ var SessionSubscriptions = class {
5088
5205
  */
5089
5206
  #installReplay(sessionId, descriptor) {
5090
5207
  const previous = this.#sessions.get(sessionId);
5091
- if (previous !== void 0) this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} re-subscribed`));
5208
+ if (previous !== void 0) {
5209
+ this.#cancelAckRetry(previous);
5210
+ this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} re-subscribed`));
5211
+ }
5092
5212
  this.#sessions.delete(sessionId);
5093
5213
  this.#sessions.set(sessionId, {
5094
5214
  subscriptionId: descriptor.subscriptionId,
5095
5215
  cursor: descriptor.highWatermark,
5096
5216
  buffer: [],
5097
5217
  bytes: 0,
5218
+ bytesSinceAck: 0,
5219
+ deliveredBytes: 0,
5220
+ acks: 0,
5221
+ ackInFlight: false,
5222
+ ackRetryAt: 0,
5223
+ ackRetryDelay: AUTO_ACK_RETRY_MS,
5224
+ ackRetryTimer: void 0,
5098
5225
  windowStart: Number(descriptor.highWatermark.seq),
5099
5226
  evicted: false,
5100
5227
  waiters: [],
@@ -5174,13 +5301,23 @@ var SessionSubscriptions = class {
5174
5301
  */
5175
5302
  #installCheckpoint(sessionId, descriptor, items) {
5176
5303
  const previous = this.#sessions.get(sessionId);
5177
- if (previous !== void 0) this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} recovered`));
5304
+ if (previous !== void 0) {
5305
+ this.#cancelAckRetry(previous);
5306
+ this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} recovered`));
5307
+ }
5178
5308
  this.#sessions.delete(sessionId);
5179
5309
  this.#sessions.set(sessionId, {
5180
5310
  subscriptionId: descriptor.subscriptionId,
5181
5311
  cursor: descriptor.checkpoint.cursor,
5182
5312
  buffer: [],
5183
5313
  bytes: 0,
5314
+ bytesSinceAck: 0,
5315
+ deliveredBytes: 0,
5316
+ acks: 0,
5317
+ ackInFlight: false,
5318
+ ackRetryAt: 0,
5319
+ ackRetryDelay: AUTO_ACK_RETRY_MS,
5320
+ ackRetryTimer: void 0,
5184
5321
  windowStart: Number(descriptor.checkpoint.cursor.seq),
5185
5322
  evicted: false,
5186
5323
  waiters: [],
@@ -5226,6 +5363,7 @@ var SessionSubscriptions = class {
5226
5363
  for (const [sessionId, state] of this.#sessions) {
5227
5364
  if (sessionId === protectedSessionId) continue;
5228
5365
  console.warn(`[cherry-remote] subscription limit (${this.#limit}) reached; evicting least-recently-used session ${sessionId}`);
5366
+ this.#cancelAckRetry(state);
5229
5367
  this.#rejectWaiters(state, /* @__PURE__ */ new Error(`session ${sessionId} evicted by LRU`));
5230
5368
  this.#sessions.delete(sessionId);
5231
5369
  await this.#closeSubscription(state.subscriptionId);
@@ -5239,7 +5377,9 @@ var SessionSubscriptions = class {
5239
5377
  }
5240
5378
  /**
5241
5379
  * `agent.events`: append to the session buffer, enforce the byte budget by
5242
- * dropping the OLDEST events (logged), advance the cursor, then wake waiters.
5380
+ * dropping the OLDEST events (logged, and never the last remaining one — see
5381
+ * `#trimToBudget`'s floor, issue #221), advance the cursor, then wake
5382
+ * waiters.
5243
5383
  *
5244
5384
  * SEQ MONOTONICITY IS ENFORCED HERE, per (sessionId, streamEpoch). The peer is
5245
5385
  * supposed to deliver each stream position exactly once and in order, and the
@@ -5307,11 +5447,85 @@ var SessionSubscriptions = class {
5307
5447
  bytes
5308
5448
  });
5309
5449
  state.bytes += bytes;
5450
+ state.bytesSinceAck += bytes;
5451
+ state.deliveredBytes += bytes;
5310
5452
  state.cursor = cursor;
5311
5453
  appended += 1;
5312
5454
  }
5313
5455
  this.#trimToBudget(sessionId, state);
5314
5456
  if (appended > 0) this.#wakeWaiters(state);
5457
+ if (appended > 0) this.#maybeAutoAck(sessionId, state);
5458
+ }
5459
+ /**
5460
+ * Sends `agent.subscriptions.ack` once the un-acked bytes approach the peer's
5461
+ * window, so the peer keeps delivering (issue #212).
5462
+ *
5463
+ * WHAT IS BEING ACKED is "everything in this layer's buffer", not "everything
5464
+ * a caller has consumed". That is safe against the pinned peer because an ack
5465
+ * does exactly one thing: it frees FLOW CONTROL — the desktop decrements its
5466
+ * un-acked budget and advances `lastAck` (`agentSubscriptions.ts`). Replay
5467
+ * retention lives on the journal (`replayBytes`/`replayMs`) and is not
5468
+ * touched by an ack, so a `read`/`cherry_events`/re-subscribe from a cursor
5469
+ * below the acked position is still served — by the local buffer here and by
5470
+ * the journal there. The ack's own doc comment states the same contract.
5471
+ *
5472
+ * The ack is fire-and-forget with its failure SURFACED (a refused ack must be
5473
+ * visible, not swallowed). A refusal also ARMS A TIMER: once the peer is
5474
+ * holding at its window no further event arrives, so a retry that waited for
5475
+ * traffic would never fire and the wait would time out exactly as in issue
5476
+ * #212.
5477
+ */
5478
+ #maybeAutoAck(sessionId, state) {
5479
+ if (state.bytesSinceAck < this.#ackWatermark()) return;
5480
+ if (state.ackInFlight) return;
5481
+ if (Date.now() < state.ackRetryAt) return;
5482
+ state.ackInFlight = true;
5483
+ this.ack(sessionId).then(() => {
5484
+ state.ackRetryDelay = AUTO_ACK_RETRY_MS;
5485
+ }).catch((error) => {
5486
+ const delay = state.ackRetryDelay;
5487
+ state.ackRetryAt = Date.now() + delay;
5488
+ state.ackRetryDelay = Math.min(delay * 2, AUTO_ACK_RETRY_MAX_MS);
5489
+ console.warn(`[cherry-remote] auto-ack for session ${sessionId} failed: ${failureText(error)}`);
5490
+ this.#armAckRetry(sessionId, state, delay);
5491
+ }).finally(() => {
5492
+ state.ackInFlight = false;
5493
+ if (this.#closed || this.#sessions.get(sessionId) !== state) return;
5494
+ this.#maybeAutoAck(sessionId, state);
5495
+ });
5496
+ }
5497
+ /**
5498
+ * Quarter of the peer's window, read from the CURRENT source so a layer that
5499
+ * outlives a reconnect follows the new hello instead of its first answer.
5500
+ * Falls back to the pinned protocol constant when nothing is advertised.
5501
+ */
5502
+ #ackWatermark() {
5503
+ const source = this.#unackedSource;
5504
+ const advertised = typeof source === "function" ? source() : source;
5505
+ return Math.max(1, Math.floor(positive$1(advertised, remoteLimits.unackedBytes) / 4));
5506
+ }
5507
+ /** Disarms the retry, if any (idempotent; safe on a state being replaced). */
5508
+ #cancelAckRetry(state) {
5509
+ if (state.ackRetryTimer === void 0) return;
5510
+ clearTimeout(state.ackRetryTimer);
5511
+ state.ackRetryTimer = void 0;
5512
+ }
5513
+ /**
5514
+ * One timer-driven retry after a refused ack. At most one is armed per
5515
+ * session; it no-ops when the state it was armed for is no longer current
5516
+ * (a re-subscribe, a checkpoint recovery or `dispose`).
5517
+ */
5518
+ #armAckRetry(sessionId, state, delayMs) {
5519
+ if (this.#closed) return;
5520
+ if (state.ackRetryTimer !== void 0) return;
5521
+ const timer = setTimeout(() => {
5522
+ state.ackRetryTimer = void 0;
5523
+ if (this.#closed || this.#sessions.get(sessionId) !== state) return;
5524
+ state.ackRetryAt = 0;
5525
+ this.#maybeAutoAck(sessionId, state);
5526
+ }, delayMs);
5527
+ timer.unref?.();
5528
+ state.ackRetryTimer = timer;
5315
5529
  }
5316
5530
  /**
5317
5531
  * `agent.subscriptions.resetRequired`: the peer tore the subscription down
@@ -5330,22 +5544,37 @@ var SessionSubscriptions = class {
5330
5544
  for (const [sessionId, state] of this.#sessions) {
5331
5545
  if (state.subscriptionId !== subscriptionId) continue;
5332
5546
  console.warn(`[cherry-remote] subscription for session ${sessionId} reset by the peer (${String(params["reason"] ?? "unspecified")}); re-subscribe required`);
5547
+ this.#cancelAckRetry(state);
5333
5548
  this.#rejectWaiters(state, /* @__PURE__ */ new Error(`session ${sessionId} reset by the peer`));
5334
5549
  this.#sessions.delete(sessionId);
5335
5550
  return;
5336
5551
  }
5337
5552
  }
5338
- /** Drops the OLDEST buffered events until the session fits its byte budget. */
5553
+ /**
5554
+ * Drops the OLDEST buffered events until the session fits its byte budget.
5555
+ *
5556
+ * The loop stops at ONE remaining event (issue #221): the budget governs the
5557
+ * window's TAIL, never its head. A single event above the budget — only
5558
+ * reachable when `perSessionCacheBytes` is set below one record's size, since
5559
+ * the transport caps a record at 65,536 bytes — used to be evicted too,
5560
+ * leaving an EMPTY window whose `windowStart` had advanced past it: the event
5561
+ * that just happened, the one thing a reader wants, became unreachable and
5562
+ * every read from the baseline answered `cursor-out-of-window` about content
5563
+ * this layer had held a moment earlier. Keeping it is also the honest
5564
+ * reading of `evicted`: nothing was discarded, so the baseline stays a
5565
+ * servable resume point.
5566
+ */
5339
5567
  #trimToBudget(sessionId, state) {
5340
5568
  if (state.bytes <= this.#perSessionCacheBytes) return;
5341
5569
  let dropped = 0;
5342
- while (state.bytes > this.#perSessionCacheBytes && state.buffer.length > 0) {
5570
+ while (state.bytes > this.#perSessionCacheBytes && state.buffer.length > 1) {
5343
5571
  const removed = state.buffer.shift();
5344
5572
  state.bytes -= removed.bytes;
5345
5573
  dropped += 1;
5346
5574
  state.windowStart = Math.max(state.windowStart, Number(removed.cursor.seq));
5347
5575
  state.evicted = true;
5348
5576
  }
5577
+ if (dropped === 0) return;
5349
5578
  console.warn(`[cherry-remote] session ${sessionId} cache exceeded ${this.#perSessionCacheBytes} bytes; dropped ${dropped} oldest event(s)`);
5350
5579
  }
5351
5580
  /** Coalesced wake: each parked waiter settles exactly once. */
@@ -9160,8 +9389,9 @@ var CherryToolEngine = class {
9160
9389
  * dimension and deliberately does NOT settle a wait (Task 13 asserts the two
9161
9390
  * layers separately).
9162
9391
  *
9163
- * Waiters never ack: they consume the projection and leave the peer's view
9164
- * untouched.
9392
+ * Waiters do not ack what they fold. The subscription LAYER acks its own
9393
+ * buffer on a byte watermark (issue #212) so the peer keeps delivering past
9394
+ * its un-acked window; that ack is about flow control, not consumption.
9165
9395
  *
9166
9396
  * A wait can also end because the LINK went away — the plugin was torn down,
9167
9397
  * the device was unpaired, or the connection closed. That is not a timeout and
@@ -12671,7 +12901,8 @@ function subsFor(deviceId) {
12671
12901
  const connection = connectionFor(deviceId);
12672
12902
  const created = new SessionSubscriptions(connection.rpc, {
12673
12903
  subscriptionsLimit: connection.limits.subscriptions,
12674
- perSessionCacheBytes: state.perSessionCacheBytes
12904
+ perSessionCacheBytes: state.perSessionCacheBytes,
12905
+ unackedBytes: () => connection.limits.unackedBytes
12675
12906
  });
12676
12907
  state.subscriptions.set(deviceId, created);
12677
12908
  console.info(`[cherry-remote] subscription layer established for ${deviceId} (peer limit ${connection.limits.subscriptions})`);
@@ -12796,11 +13027,13 @@ function assemblyDeps() {
12796
13027
  }),
12797
13028
  diagnosticsSessions: () => [...state.subscriptions.entries()].flatMap(([deviceId, layer]) => layer.activeSessions().map((sessionId) => {
12798
13029
  const cursor = layer.cursorOf(sessionId);
13030
+ const delivery = layer.deliveryOf(sessionId);
12799
13031
  return {
12800
13032
  deviceId,
12801
13033
  sessionId,
12802
13034
  subscriptions: 1,
12803
- ...cursor === void 0 ? {} : { cursor }
13035
+ ...cursor === void 0 ? {} : { cursor },
13036
+ ...delivery === void 0 ? {} : delivery
12804
13037
  };
12805
13038
  })),
12806
13039
  diagnosticsErrors: () => [...state.recentErrors],
@@ -137,6 +137,7 @@ schema 已允许 `interrupted`,所以客户端**必须**处理,但当前服
137
137
  - [ ] 长回复(>4096 单元)经 `{ref}` 外置,聚合结果仍包含完整文本。
138
138
  - [ ] `cherry_read` 能按 messageId 读到该轮消息与 parts。
139
139
  - [ ] `wait: false` 立即返回命令回执,随后 `cherry_wait` / `cherry_events` 能续查到同一终态。
140
+ - [ ] 长回答(事件流超过 256 KiB,约 30 小节的一次长输出)时 `wait: true` 仍在终态后立即返回 `completed`;期间每 5 s 采一次 `GET /diagnostics` 的 `sessions[].cursor.seq`,确认它没有停住(修复前会冻结在窗口处,最终报 `timeout`)。
140
141
  - [ ] 同 `commandId` 同参重发 → 返回**同一回执**(幂等);异参重发 → `IDEMPOTENCY_CONFLICT` 透传。
141
142
 
142
143
  ### C. 交互应答
@@ -337,3 +338,48 @@ A–G 各节要真人 + Electron 端批准,无法被 CI 化;下表只记录
337
338
  被测设备是单主机同端口;需要造一条混合端口记录才能走。
338
339
  - **#161 `failureText`**:要制造一个 peer 可达的 caught failure(如 ack 失败)才会出现,
339
340
  本轮 `recentErrors` 为空。
341
+
342
+ ### 0.2.1(2026-11-01 · DSH profile `web` · 桌面端 ISV-ZYX 2.1.10)
343
+
344
+ | 项 | 结果 | 观测 |
345
+ | --- | --- | --- |
346
+ | 装机生效 | 通过 | 版本 `0.2.1`;包 mtime `23:19:54` 早于宿主 PID 1 启动 `23:20:24`;装机 `dist/index.mjs`、`dist/index.d.mts`、`lib/client.js`、`locale/zh.json` 与 npm `dsh-cherry-bridge@0.2.1` tarball(shasum `ae7ed02b…`)sha256 全同 |
347
+ | 档案无迁移 | 通过 | `devices.json` 仍是 `schemaVersion: 4`(本轮没有触发任何迁移);`forgottenHosts` 是**可选键、不升版本**(代码依据:档案校验器与视图),首次 `forget` 后出现在文件里。注:捕获里**没有 forget 之前的档案快照**,所以「之前不在文件里」不算本轮观测 |
348
+ | `GET /cherry-remote/api/devices` 新键 | 通过 | 每台设备多出 `forgottenHosts`(无标记时是空数组 `[]`,不是缺键);本机设备 `hosts` 两条、`security` / `ports` 各两条 |
349
+ | 忘记一条非末位地址(#185 / #204) | 通过 | `POST …/devices/<id>/forget {"hosts":["172.17.14.36"]}` → 200;同一响应里 `hosts` 少一条、`forgottenHosts` 多一条、`state` 变 `connecting`(驱逐),`security` / `ports` 同步删掉该键;档案已落盘。注:forget 之前的「两条地址」由收尾/live 的原状锚定,捕获的第一条事件就是 forget |
350
+ | **重连后不会学回来** | 通过 | 21 s 后 `state: connected`,`hosts` **仍是单条**(观测)。**推断**:桌面一直在广播两条、学习合并跳过了被标记者 —— 依据是下一行「恢复只摘标记」之后 18 s 地址就自己回来了,中间没有别的写入 |
351
+ | 恢复只摘标记 | 通过 | `POST …/remember` → 200,`forgottenHosts: []` 而 `hosts` **未变**;再 18 s 后 `hosts` 回到两条,`security` / `ports` 里重新出现该键(`ws` / 23333,这两个值是否对端所报见下方「未覆盖」) |
352
+ | 拒绝忘记最后一条 | 通过 | 只剩一条时 `forget` → `400` `{"error":"last-address","message":"… refusing to forget the last dial candidate — the record would have nothing to dial. Keep at least one address."}`;档案**未变**(`hosts` 与标记都保持原样;代码在持久化**之前**抛出) |
353
+ | 驱逐的代价可观测 | 通过 | 四次改动 = 四次重建:`stats.connectionsBuilt` 收尾 **5**,两次改动后读到 **3**(故基线推为 1,`1` 本身没有直接读到);`connections` / `sockets` 两次读到 1/1(23:25:00 那次没取到 `stats`);`recentErrors` 恰好 **4** 条 `CONNECTION_OFFLINE`(`device transitioned to offline`,每次驱逐一条,无其他;捕获里第 4 条被截断,`/diagnostics` 现读为 4);收尾回到原状(两条地址、无标记、`connected`) |
354
+
355
+ **本轮未覆盖**(**不是通过**):
356
+
357
+ - **恢复后的端口 / 协议是否真来自对端**:本环境桌面广播的就是记录级默认(`ws` / 23333),
358
+ 观测无法区分「对端说的」与「默认值」;由 `DeviceStore.learnEndpoints` 与
359
+ `advertisedEndpoints` 的单测覆盖(每个端点自带 port/scheme)。
360
+ - **设置页那对按钮的点击**:需要能打开宿主的浏览器;本机渲染由 `tests/unit/ui.test.ts`
361
+ 覆盖,地址编辑器打开时两个控件冻结由 `#204m` 覆盖。本轮走的是页面调用的同两条 HTTP 路由。
362
+ - **驱逐时 in-flight 命令丢失**:需要一个正在执行的 command 才会出现,本轮没有。
363
+
364
+ ### 0.2.2(2026-11-03 · DSH profile `web` · 桌面端 ISV-ZYX)
365
+
366
+ | 项 | 结果 | 观测 |
367
+ | --- | --- | --- |
368
+ | 装机生效 | 通过 | 包版本 `0.2.2`;`dist/index.mjs` mtime `00:13:42` 早于宿主 PID 1 启动 `00:13:54`;装机 `dist/index.mjs`、`dist/index.d.mts`、`lib/client.js`、`locale/zh.json`、`locale/en.json` 与发行 tarball(sha1 `1ac80762…`,等于 registry 的 `dist.shasum`)逐文件 sha256 **全同** |
369
+ | 超窗长回答 `wait: true` 仍返回 `completed`(§B 新增项) | 通过 | 同一会话(`dsh-bridge 2.1.10 smoke`)连发两轮 30 节长回答,会话 `seq` 从 341 推到 **1374**、再从 1374 推到 **2204**;两轮 `cherry_send` 都返回 `status: "completed"` 并带回助手全文。对端提交时刻是 `00:18:05.797` / `00:19:18.597`,而提交后 1.4 s 的首个采样点已经读到终值 1374 / 2204;第三轮的下一次工具调用发生在提交后 6.6 s,故返回延迟的上界是 6.6 s(含 agent 侧一次调度) |
370
+ | 窗口不冻结(§B 新增项) | 通过 | 每 5 s 采一次 `GET …/diagnostics` 的 `sessions[].cursor.seq`:`00:17:32→00:18:07` 为 346→**918**→993→1074→1153→1227→1308→**1374**,`00:18:49→00:19:19` 为 1374→**1854**→1930→1994→2065→2144→**2204**;两段单调推进、终值后保持,**没有任何一段平台期**(修复前同一会话在整个 240 s 等待窗口里冻结在 666,见 v0.2.2 release 正文) |
371
+
372
+ **本轮未覆盖**(**不是通过**):
373
+
374
+ - **字节级的窗口证据**:`GET …/diagnostics` 按设计只带四个数字 limit 键(`projectLimits`
375
+ 的白名单刻意剔掉 `unackedBytes`),也不报 ack 次数,所以「事件流累计 > 262144 B」是
376
+ **外推**:同一会话修复前的实测是 666 个事件到窗口(≈394 B/event),本轮 1374 / 2204 事件
377
+ 对应约 0.54 / 0.87 MB。要直接测字节,得先给诊断报告加字段。
378
+ - **ack 是否从未失败**:失败只会 `console.warn`,不进 `recentErrors`;本环境的宿主日志不可读
379
+ (`/proc/1/fd/1` 拒读)。本轮能断言的是「没有出现冻结与告警级停滞」,不是「ack 零失败」。
380
+ - **桌面端版本号**:本轮没有重读(上一轮记录为 2.1.10);本客户端不从握手读桌面端版本号
381
+ (`EffectiveLimits` 只有心跳/订阅/分页/并发/未确认字节五个窗口参数加 `messageSummaries`),`/devices`
382
+ 与 `/diagnostics` 也都不带。
383
+ - **`wait: true` 的毫秒级时延**:只有上界,没有工具级计时器。
384
+ - 第二部分其余小节(配对、交互应答、断连恢复、多设备、`/scope`、语言切换)本轮没有重跑:
385
+ 它们不在 0.2.2 的改动面上。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cherry-bridge",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "packageManager": "npm@10.9.9",