dsh-cherry-bridge 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -5,8 +5,60 @@ 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 { 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
+ /**
11
+ * Adds the candidate addresses a peer ADVERTISED that this record does not
12
+ * already dial, returning how many hosts were added (issue #143).
13
+ *
14
+ * The rule is ADD-ONLY, deliberately:
15
+ *
16
+ * - a host already in `hosts` is left exactly as it is, INCLUDING its port and
17
+ * its scheme. The peer's snapshot describes where it listens NOW, while a
18
+ * record entry may be an operator's choice (a pinned port, a TLS proxy) that
19
+ * every reconnect must keep honouring; re-pointing it would silently rewrite
20
+ * the operator's address book on a schedule. "Already dials" is judged
21
+ * CASE-INSENSITIVELY: a hostname is case-insensitive, the peer's schema
22
+ * lowercases what it advertises, and a PATCHed record stores what the
23
+ * operator typed — without this, `MyNAS.example` and `mynas.example` would
24
+ * both hold a dial slot. (An IPv6 literal in a different EXPANSION is NOT
25
+ * reconciled here; the PATCH path owns the archive's spelling, and it does
26
+ * not canonicalize either.)
27
+ * - nothing is removed HERE: a host the peer still advertises is re-learned
28
+ * after an operator PATCH drops it, because the peer is the source of truth
29
+ * for where it listens. The operator's answer to that is `forgetEndpoints`
30
+ * (issue #185), whose mark this function honours — addition stays add-only,
31
+ * and forgetting is a separate, explicit decision;
32
+ * - host, port and scheme are appended TOGETHER, which is what keeps the v4
33
+ * one-entry-per-host invariant true. That invariant is enforced on the READ
34
+ * path by `assertValidSecurity`/`assertValidPorts`, and a semantic failure
35
+ * there is NOT a quarantine (that class covers unreadable bytes): the throw
36
+ * escapes `parseArchive` out of `list()`, so a split entry would break every
37
+ * tool until an operator repaired the file;
38
+ * - past {@link MAX_CANDIDATE_HOSTS} nothing is added, and the loop stops
39
+ * rather than scanning the rest.
40
+ *
41
+ * `endpoints` must already have passed the peer's own `advertisedEndpointSchema`
42
+ * (which bounds the port to 1..65535, the scheme to `ws`/`wss`, and rejects a
43
+ * loopback destination), so every value written here is archive-valid without a
44
+ * second validation pass.
45
+ */
46
+ function mergeAdvertisedEndpoints(record, endpoints) {
47
+ const dialed = new Set(record.hosts.map((host) => host.toLowerCase()));
48
+ const forgotten = new Set((record.forgottenHosts ?? []).map((host) => host.toLowerCase()));
49
+ let added = 0;
50
+ for (const endpoint of endpoints) {
51
+ const key = endpoint.host.toLowerCase();
52
+ if (dialed.has(key) || forgotten.has(key)) continue;
53
+ if (record.hosts.length >= 32) break;
54
+ record.hosts.push(endpoint.host);
55
+ record.ports[endpoint.host] = endpoint.port;
56
+ record.security[endpoint.host] = endpoint.security;
57
+ dialed.add(key);
58
+ added += 1;
59
+ }
60
+ return added;
61
+ }
10
62
  const FILE_MODE = 384;
11
63
  /**
12
64
  * Version-to-version migrations, keyed by the version they upgrade from.
@@ -118,6 +170,7 @@ function assertValidRecord(disk) {
118
170
  if (typeof disk.port !== "number" || !Number.isInteger(disk.port) || disk.port < 1 || disk.port > 65535) throw new Error("device archive entry field port must be an integer in 1..65535");
119
171
  assertValidSecurity(disk.hosts, disk.security);
120
172
  assertValidPorts(disk.hosts, disk.ports);
173
+ assertValidForgotten(disk.hosts, disk.forgottenHosts);
121
174
  if (disk.accessToken !== void 0 !== (disk.tokenExpiresAt !== void 0)) throw new Error("device archive entry must carry accessToken and tokenExpiresAt together or not at all");
122
175
  return disk;
123
176
  }
@@ -166,6 +219,60 @@ function assertValidPorts(hosts, ports) {
166
219
  }
167
220
  }
168
221
  /**
222
+ * `forgottenHosts`, when present, must be a list of non-empty strings with no
223
+ * case-variant duplicates and no overlap with `hosts`.
224
+ *
225
+ * Each rule refuses a state whose MEANING is ambiguous: two spellings of one
226
+ * host make "is this address suppressed?" depend on which entry the comparison
227
+ * hit first, and a host that is both dialed and forgotten makes the dial list
228
+ * and the suppression list disagree about the same address. The message names
229
+ * the offending host — these are addresses an operator chose, not secrets.
230
+ */
231
+ function assertValidForgotten(hosts, forgotten) {
232
+ if (forgotten === void 0) return;
233
+ if (!Array.isArray(forgotten)) throw new Error("device archive entry field forgottenHosts must be an array");
234
+ const dialed = new Set(hosts.map((host) => host.toLowerCase()));
235
+ const seen = /* @__PURE__ */ new Set();
236
+ for (const entry of forgotten) {
237
+ if (typeof entry !== "string" || entry.length === 0) throw new Error("device archive entry field forgottenHosts must list non-empty strings");
238
+ const key = entry.toLowerCase();
239
+ if (seen.has(key)) throw new Error(`device archive entry lists host "${entry}" in forgottenHosts more than once`);
240
+ if (dialed.has(key)) throw new Error(`device archive entry names host "${entry}" in both hosts and forgottenHosts`);
241
+ seen.add(key);
242
+ }
243
+ }
244
+ /**
245
+ * Repairs ONE cross-build state on the READ path: a host listed in both
246
+ * `hosts` and `forgottenHosts` (issue #185).
247
+ *
248
+ * A build that predates the field PRESERVES it but cannot honour it, so a
249
+ * downgrade followed by a still-advertising desktop appends the host to
250
+ * `hosts` while the mark stays. Upgrading again then yields a record that says
251
+ * both "dial it" and "never dial it". Refusing it would brick every device tool
252
+ * until an operator hand-edits a 0600 file, so the READ path keeps the DIAL
253
+ * list — what the running build actually did — and drops the mark, with a
254
+ * warning naming the device and host (addresses are not secrets).
255
+ *
256
+ * The WRITE paths stay strict: `upsert`, `forgetEndpoints` and
257
+ * `rememberEndpoints` never produce the overlap, and `assertValidRecord`
258
+ * refuses it. This runs ONLY on the read path, before that assertion.
259
+ */
260
+ function reconcileForgottenMarks(disk) {
261
+ const forgotten = disk.forgottenHosts;
262
+ if (!Array.isArray(forgotten) || !Array.isArray(disk.hosts)) return disk;
263
+ const dialed = new Set(disk.hosts.map((host) => host.toLowerCase()));
264
+ const kept = forgotten.filter((host) => !(typeof host === "string" && dialed.has(host.toLowerCase())));
265
+ if (kept.length === forgotten.length) return disk;
266
+ for (const host of forgotten) {
267
+ if (typeof host !== "string" || !dialed.has(host.toLowerCase())) continue;
268
+ console.warn(`device archive entry ${String(disk.deviceId)} lists host "${host}" as both dialed and forgotten; keeping it dialed and dropping the mark (a plugin downgrade can produce this)`);
269
+ }
270
+ return {
271
+ ...disk,
272
+ forgottenHosts: kept
273
+ };
274
+ }
275
+ /**
169
276
  * Read path, fixed order: loose parse -> completeness gate -> version judgement
170
277
  * -> migration -> assert the required `DeviceRecord` type.
171
278
  *
@@ -193,7 +300,7 @@ function parseRecord(raw, archiveVersion) {
193
300
  if (migrate === void 0) throw new Error(`no migration registered from device archive schemaVersion ${step}`);
194
301
  migrated = migrate(migrated);
195
302
  }
196
- return assertValidRecord(migrated);
303
+ return assertValidRecord(reconcileForgottenMarks(migrated));
197
304
  }
198
305
  function parseArchive(parsed) {
199
306
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("device archive must be a JSON object");
@@ -311,6 +418,26 @@ var QuarantinedArchiveError = class QuarantinedArchiveError extends Error {
311
418
  }
312
419
  };
313
420
  /**
421
+ * Refusal raised by {@link DeviceStore.forgetEndpoints} when the operation
422
+ * would leave a record with NO dial candidate.
423
+ *
424
+ * A record with nothing to dial is worse than the stale address the operator
425
+ * wanted gone: every tool would answer `offline` until a RE-PAIR or a PATCH
426
+ * with a non-empty `hosts` list writes a fresh one, and the archive would look
427
+ * healthy the whole time. The check is ATOMIC — the whole call refuses, so a
428
+ * name for the last host cannot remove a different host on its way to failing.
429
+ * Nothing is written when this is thrown.
430
+ */
431
+ var LastCandidateHostError = class extends Error {
432
+ deviceId;
433
+ constructor(deviceId) {
434
+ super(`device ${deviceId}: refusing to forget the last dial candidate — the record would have nothing to dial. Keep at least one address.`);
435
+ this.name = "LastCandidateHostError";
436
+ this.deviceId = deviceId;
437
+ Object.setPrototypeOf(this, new.target.prototype);
438
+ }
439
+ };
440
+ /**
314
441
  * Preserves an unreadable archive as `<file>.corrupt` (best effort — a failed
315
442
  * copy must not mask the original problem) and returns the path it landed at.
316
443
  */
@@ -477,6 +604,137 @@ var DeviceStore = class {
477
604
  const devices = this.#load().filter((record) => record.deviceId !== deviceId);
478
605
  this.#persist(devices);
479
606
  }
607
+ /**
608
+ * Merges freshly learned addresses into one record and writes ONLY when that
609
+ * record gained a host (issue #143), returning the number added.
610
+ *
611
+ * It exists so the enrichment is an ARCHIVE operation rather than an
612
+ * `upsert` the assembly assembles by hand, and so a merge that adds nothing
613
+ * never rewrites (and fsyncs) the archive: `connection.endpoints` is asked on
614
+ * every connect, and a stable desktop would otherwise pay a full atomic write
615
+ * per reconnect. The merge runs on a CLONE and is committed as a new list,
616
+ * mirroring `upsert`'s discipline — the store never adopts a caller's object
617
+ * graph, and a write refused at {@link #persist}'s own gate cannot have
618
+ * half-edited the record the archive still holds.
619
+ *
620
+ * `desktopIdentity` is the pin the answering connection belongs to: the
621
+ * record must still name that desktop. A RE-PAIR under the same `deviceId`
622
+ * (the desktop regenerated its identity) would otherwise take an address
623
+ * learned from the previous pairing — a stale candidate whose every dial then
624
+ * fails the Noise pin, permanently, because learning never removes.
625
+ *
626
+ * An unknown `deviceId` is a no-op — the record may have been removed (or
627
+ * re-paired) between the RPC and this write.
628
+ */
629
+ learnEndpoints(deviceId, endpoints, desktopIdentity) {
630
+ const devices = this.#load();
631
+ const index = devices.findIndex((record) => record.deviceId === deviceId);
632
+ if (index < 0) return 0;
633
+ if (devices[index].desktopIdentity !== desktopIdentity) return 0;
634
+ const merged = structuredClone(devices[index]);
635
+ const added = mergeAdvertisedEndpoints(merged, endpoints);
636
+ if (added === 0) return 0;
637
+ const next = [...devices];
638
+ next[index] = merged;
639
+ this.#persist(next);
640
+ return added;
641
+ }
642
+ /**
643
+ * FORGETS addresses (issue #185): drops each named host from the dial list —
644
+ * host, scheme and port as one unit — and records it so the next
645
+ * advertisement cannot put it back. Returns the hosts that left the dial list
646
+ * (`removed`) and the marks this call added (`marked`).
647
+ *
648
+ * This is the operation learning cannot provide. `connection.endpoints` is
649
+ * asked on every handshake, so an address the desktop still advertises
650
+ * returns after any removal the operator makes by PATCH; forgetting is the
651
+ * explicit decision that outranks it.
652
+ *
653
+ * - Matching is CASE-INSENSITIVE (a PATCHed record keeps the operator's
654
+ * spelling, the peer's schema lowercases what it advertises), and the mark
655
+ * records the ARCHIVE's spelling when the host was dialed, the caller's
656
+ * otherwise.
657
+ * - Naming an address the record does not dial is legal and still marks it:
658
+ * suppression is the point, and a pre-emptive forget must survive until
659
+ * the peer advertises the address.
660
+ * - Removing the LAST candidate throws {@link LastCandidateHostError} and
661
+ * writes NOTHING — no host, no scheme, no port, no mark.
662
+ * - A call that changes no byte (the host was already forgotten) does not
663
+ * rewrite (or fsync) the archive.
664
+ *
665
+ * The write goes through `#persist` on a CLONE with the result re-validated,
666
+ * so a caller-supplied junk host can never land a record the next read would
667
+ * refuse. An unknown `deviceId` is a no-op, like {@link learnEndpoints}.
668
+ */
669
+ forgetEndpoints(deviceId, hosts) {
670
+ const devices = this.#load();
671
+ const index = devices.findIndex((record) => record.deviceId === deviceId);
672
+ if (index < 0) return {
673
+ removed: [],
674
+ marked: []
675
+ };
676
+ const merged = structuredClone(devices[index]);
677
+ const wanted = new Set(hosts.map((host) => host.toLowerCase()));
678
+ const removed = merged.hosts.filter((host) => wanted.has(host.toLowerCase()));
679
+ if (removed.length > 0 && removed.length === merged.hosts.length) throw new LastCandidateHostError(deviceId);
680
+ for (const host of removed) {
681
+ delete merged.security[host];
682
+ delete merged.ports[host];
683
+ }
684
+ merged.hosts = merged.hosts.filter((host) => !wanted.has(host.toLowerCase()));
685
+ const forgotten = merged.forgottenHosts ?? [];
686
+ const known = new Set(forgotten.map((host) => host.toLowerCase()));
687
+ const marked = [];
688
+ for (const host of hosts) {
689
+ const key = host.toLowerCase();
690
+ if (known.has(key)) continue;
691
+ const spelling = removed.find((entry) => entry.toLowerCase() === key) ?? host;
692
+ forgotten.push(spelling);
693
+ known.add(key);
694
+ marked.push(spelling);
695
+ }
696
+ if (removed.length === 0 && marked.length === 0) return {
697
+ removed: [],
698
+ marked: []
699
+ };
700
+ merged.forgottenHosts = forgotten;
701
+ assertValidRecord(merged);
702
+ const next = [...devices];
703
+ next[index] = merged;
704
+ this.#persist(next);
705
+ return {
706
+ removed,
707
+ marked
708
+ };
709
+ }
710
+ /**
711
+ * Lifts a forget mark (issue #185), returning the marks that were removed.
712
+ *
713
+ * It does NOT dial the host again: it only lets the next handshake's
714
+ * advertisement add it back through the ordinary learning path, with the
715
+ * port and scheme the peer reports at that moment. An operator who wants the
716
+ * address back immediately PATCHes it into `hosts`, which clears the mark on
717
+ * the same write.
718
+ *
719
+ * No-op (and no write) when the device is unknown, nothing is forgotten, or
720
+ * none of the named hosts is marked.
721
+ */
722
+ rememberEndpoints(deviceId, hosts) {
723
+ const devices = this.#load();
724
+ const index = devices.findIndex((record) => record.deviceId === deviceId);
725
+ if (index < 0) return { restored: [] };
726
+ const current = devices[index].forgottenHosts ?? [];
727
+ if (current.length === 0) return { restored: [] };
728
+ const wanted = new Set(hosts.map((host) => host.toLowerCase()));
729
+ const restored = current.filter((host) => wanted.has(host.toLowerCase()));
730
+ if (restored.length === 0) return { restored: [] };
731
+ const merged = structuredClone(devices[index]);
732
+ merged.forgottenHosts = (merged.forgottenHosts ?? []).filter((host) => !wanted.has(host.toLowerCase()));
733
+ const next = [...devices];
734
+ next[index] = merged;
735
+ this.#persist(next);
736
+ return { restored };
737
+ }
480
738
  #load() {
481
739
  if (this.#devices !== void 0) return this.#devices;
482
740
  let archive;
@@ -2156,10 +2414,11 @@ function projectMessageSummaries(raw) {
2156
2414
  return typeof raw === "number" && Number.isFinite(raw) ? { messageSummaries: raw } : {};
2157
2415
  }
2158
2416
  /**
2159
- * Re-projects the four numeric limit keys, dropping any foreign key that a
2160
- * caller's `limits` object might carry. A whitelist inside the whitelist: the
2161
- * shape is rebuilt field by field rather than spread, so a future addition to
2162
- * `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.
2163
2422
  */
2164
2423
  function projectLimits(raw) {
2165
2424
  if (raw === null || typeof raw !== "object") return {};
@@ -2647,6 +2906,33 @@ const DEFAULT_BACKOFF_MAX_MS = 6e4;
2647
2906
  const DEFAULT_REFRESH_GRACE_MS = 3e4;
2648
2907
  const DEFAULT_CONNECT_TIMEOUT_MS$1 = 1e4;
2649
2908
  const DEFAULT_PROTOCOL_VERSIONS = [1];
2909
+ /**
2910
+ * How long the optional `connection.endpoints` enrichment (issue #143) may hold
2911
+ * up ONE handshake before the connection gives up on it.
2912
+ *
2913
+ * The call runs INSIDE the handshake, so without a bound of its own it would
2914
+ * inherit the rpc layer's 60 s default: a peer that advertises
2915
+ * `connectionEndpointsVersion: 1` and then never answers (a wedged API gateway)
2916
+ * would keep the device in `connecting` for a minute, and every reconnect would
2917
+ * pay it again. THIS layer bounds it because the enrichment is worth
2918
+ * milliseconds, not a retry.
2919
+ *
2920
+ * WHY 2 s. The peer's handler is a local computation
2921
+ * (`getRemoteEndpoint()`: `networkInterfaces()` plus two config reads), so a
2922
+ * round trip is sub-millisecond on a LAN and 2 s is ~4 orders of magnitude of
2923
+ * slack.
2924
+ *
2925
+ * The UPPER bound is NOT derived from a claim about the whole handshake: a dial
2926
+ * already burns up to `DEFAULT_CONNECT_TIMEOUT_MS` (10 s) and hello/authenticate
2927
+ * ride the rpc layer's 60 s default, so `connected` can arrive long after a
2928
+ * tool's `DEVICE_SETTLE_WAIT_MS` (5 s) wait whatever this constant is. What is
2929
+ * derived is narrower and true: THIS wait alone must stay below that window, so
2930
+ * a peer that advertises the capability and then hangs on this one call cannot
2931
+ * by itself push the state flip past the window a tool already waited out. A
2932
+ * timeout costs one reconnect's worth of enrichment and says nothing about the
2933
+ * device.
2934
+ */
2935
+ const ENDPOINTS_LEARN_TIMEOUT_MS = 2e3;
2650
2936
  /** Ping-silence multiplier: 2×heartbeatMs without a pong judges the line dead. */
2651
2937
  const SILENCE_FACTOR = 2;
2652
2938
  /** The deliberate local-shutdown face of a disposed client (see the replay path). */
@@ -2735,7 +3021,8 @@ var SecondWindowRateLimiter = class {
2735
3021
  async acquire() {
2736
3022
  let release;
2737
3023
  const ahead = this.#queue;
2738
- this.#queue = ahead.then(() => new Promise((resolve) => release = resolve));
3024
+ const next = () => new Promise((resolve) => release = resolve);
3025
+ this.#queue = ahead.then(next, next);
2739
3026
  await ahead;
2740
3027
  try {
2741
3028
  for (;;) {
@@ -2920,6 +3207,50 @@ function businessWindowFor(reportedInFlightRequests) {
2920
3207
  return Math.max(1, Math.min(BUSINESS_MAX_IN_FLIGHT, Math.floor(reportedInFlightRequests / 2)));
2921
3208
  }
2922
3209
  /**
3210
+ * The destinations a `connection.endpoints` answer may contribute, in the order
3211
+ * the peer listed them (issue #143). Empty when the answer must be discarded.
3212
+ *
3213
+ * TWO answers are refused WHOLE:
3214
+ *
3215
+ * - **the identity pin.** The answer must name the very desktop this record is
3216
+ * pinned to (`DeviceRecord.desktopIdentity`). Noise already pins the peer's
3217
+ * static key, so this is not the adversarial case it protects against — it
3218
+ * is the defence against a CONFUSED answer (a proxy, a desktop whose identity
3219
+ * was regenerated, a future protocol that answers for more than one host):
3220
+ * the list decides where this client dials, so a list that cannot be
3221
+ * attributed to our own desktop may not change it;
3222
+ * - a MISSING or NON-STRING `desktopIdentity`: `undefined !== pin`, and a
3223
+ * number is not the pin either, so both fall out of the same comparison.
3224
+ *
3225
+ * One refusal is per ENTRY: `advertisedEndpointSchema`, the peer's own
3226
+ * destination schema, rejects a LOOPBACK destination (a shared address that
3227
+ * resolves to our own machine would send the next reconnect to the wrong host)
3228
+ * and normalizes the host (case, IPv6), so the archive never stores a spelling
3229
+ * the dial cannot use. One unusable row must not discard its neighbours.
3230
+ *
3231
+ * NOT total on the wire shape, deliberately: a non-object answer or a non-array
3232
+ * `endpoints` throws here, and `#learnEndpoints`' one structural `catch` is what
3233
+ * turns that into the silent no-op the contract promises. Defending each shape
3234
+ * twice would look safer and be worse: the redundancy masks which guard a test
3235
+ * actually pins (issue #114's lesson), and the whole ANSWER is untrusted
3236
+ * anyway, so the boundary belongs at the caller, where the handshake can see
3237
+ * it. `endpoints ?? []` is the one shape that needs no throw: it is the legal
3238
+ * "no addresses right now" answer (the desktop's `addresses` can be empty).
3239
+ *
3240
+ * No length bound is needed here: one JSON-RPC record is capped at 64 KiB by
3241
+ * the transport, and the desktop's own snapshot is `addresses.slice(0, 32)`.
3242
+ */
3243
+ function advertisedEndpoints(result, desktopIdentity) {
3244
+ const answer = result;
3245
+ if (answer.desktopIdentity !== desktopIdentity) return [];
3246
+ const accepted = [];
3247
+ for (const raw of answer.endpoints ?? []) {
3248
+ const parsed = advertisedEndpointSchema.safeParse(raw);
3249
+ if (parsed.success) accepted.push(parsed.data);
3250
+ }
3251
+ return accepted;
3252
+ }
3253
+ /**
2923
3254
  * The desktop's content-read allowance: 512/s, and the SAME in both generations
2924
3255
  * (`RemoteAccessService.ts` counts `agent.content.read` on its own counter,
2925
3256
  * 512 in v2.1.6 and v2.1.7 alike; only the shared line moved, 64 ⇒ 256).
@@ -3000,6 +3331,7 @@ var DeviceConnection = class {
3000
3331
  #protocolVersions;
3001
3332
  #helloProtocolVersions;
3002
3333
  #transportRetryWaitMs;
3334
+ #onAdvertisedEndpoints;
3003
3335
  #stateListeners = /* @__PURE__ */ new Set();
3004
3336
  /**
3005
3337
  * Callers parked in {@link waitForSettled}. Separate from the state listeners
@@ -3062,6 +3394,7 @@ var DeviceConnection = class {
3062
3394
  this.#protocolVersions = opts.protocolVersions ?? DEFAULT_PROTOCOL_VERSIONS;
3063
3395
  this.#helloProtocolVersions = opts.helloProtocolVersions ?? this.#protocolVersions;
3064
3396
  this.#transportRetryWaitMs = opts.transportRetryWaitMs ?? 5e3;
3397
+ this.#onAdvertisedEndpoints = opts.onAdvertisedEndpoints;
3065
3398
  }
3066
3399
  /**
3067
3400
  * The business RPC surface: rate-smoothed and gated on `connected`. Typed
@@ -3372,11 +3705,13 @@ var DeviceConnection = class {
3372
3705
  if (rpcReason$1(error) === "INVALID_CONNECTION_STATE") throw new ProtocolVersionError(String(error));
3373
3706
  throw error instanceof Error ? error : new Error(String(error));
3374
3707
  }
3708
+ const unackedBytes = positive$2(hello.limits?.["unackedBytes"]);
3375
3709
  this.#limits = {
3376
3710
  heartbeatMs: positive$2(hello.heartbeatMs) ?? FALLBACK_LIMITS.heartbeatMs,
3377
3711
  subscriptions: positive$2(hello.limits?.["subscriptions"]) ?? FALLBACK_LIMITS.subscriptions,
3378
3712
  pageItems: positive$2(hello.limits?.["pageItems"]) ?? FALLBACK_LIMITS.pageItems,
3379
3713
  inFlightRequests: positive$2(hello.limits?.["inFlightRequests"]) ?? FALLBACK_LIMITS.inFlightRequests,
3714
+ ...unackedBytes === void 0 ? {} : { unackedBytes },
3380
3715
  ...typeof hello.agentMessageSummariesVersion === "number" ? { messageSummaries: hello.agentMessageSummariesVersion } : {}
3381
3716
  };
3382
3717
  link.client.updateMaxInFlight(businessWindowFor(this.#limits.inFlightRequests));
@@ -3388,6 +3723,7 @@ var DeviceConnection = class {
3388
3723
  if (reason === "GRANT_REVOKED" || reason === "UNAUTHENTICATED") throw new AuthRejectedError(reason);
3389
3724
  throw error instanceof Error ? error : new Error(String(error));
3390
3725
  }
3726
+ await this.#learnEndpoints(link, hello.connectionEndpointsVersion);
3391
3727
  if (epoch !== this.#epoch) {
3392
3728
  client.dispose();
3393
3729
  link.channel.close().catch(noop$1);
@@ -3411,6 +3747,51 @@ var DeviceConnection = class {
3411
3747
  console.info(`[cherry-remote] device ${this.record.deviceId} connected (${digestIdentity(link.channel.remoteIdentity)})`);
3412
3748
  }
3413
3749
  /**
3750
+ * Best-effort enrichment of the dial list from the peer's advertised
3751
+ * endpoints (issue #143).
3752
+ *
3753
+ * WHAT THIS BUYS: candidates the pairing never saw — a VPN or second-NIC
3754
+ * address that appeared after the QR was minted. It does NOT heal a total
3755
+ * address change (the list is captured on the old path) and can never carry
3756
+ * TLS (the desktop's snapshot is `security: 'ws'`), so a `wss` route still
3757
+ * comes from the QR or the operator.
3758
+ *
3759
+ * EVERY failure here is SILENT and non-fatal, because this is an enrichment,
3760
+ * not a verdict: a peer that does not advertise the capability (`connection`
3761
+ * through 2.1.3 has neither the hello bit nor this method), one advertising a
3762
+ * version this client cannot speak (strictly `1` — a higher number is not
3763
+ * evidence that it speaks 1, the same rule the paged-summaries bit took), a
3764
+ * refused call (the desktop throws when its gateway is not running), a wedged
3765
+ * one (bounded by {@link ENDPOINTS_LEARN_TIMEOUT_MS}), an unattributable
3766
+ * answer, or a store that cannot write. None of them is a CONNECTION failure:
3767
+ * the link comes up either way, and only the archive is left without the new
3768
+ * addresses — this instance's own record may already carry them, because the
3769
+ * merge runs before the callback (see the paragraph below).
3770
+ *
3771
+ * The MERGE happens in two places on purpose: here (this instance's record,
3772
+ * so its next reconnect dials the new address) and through the callback (the
3773
+ * assembly's archive, so a restart keeps it). They are the same pure rule
3774
+ * against two copies of the record, and each is idempotent.
3775
+ *
3776
+ * ONE `catch` covers everything after the gate, and that is the contract, not
3777
+ * laziness: a refused call, a wedged one (bounded by
3778
+ * {@link ENDPOINTS_LEARN_TIMEOUT_MS}), an answer of the wrong SHAPE (the
3779
+ * parser throws rather than defending every field twice), a throwing assembly
3780
+ * hook (an unwritable archive), or a bug in the merge must all land here —
3781
+ * none of them may fail the handshake. The observable promise is therefore
3782
+ * structural: this method cannot reject.
3783
+ */
3784
+ async #learnEndpoints(link, capability) {
3785
+ if (capability !== 1) return;
3786
+ try {
3787
+ const endpoints = advertisedEndpoints(await this.#houseCall(link, "connection.endpoints", { domain: "agent" }, ENDPOINTS_LEARN_TIMEOUT_MS), this.record.desktopIdentity);
3788
+ if (endpoints.length === 0) return;
3789
+ const added = mergeAdvertisedEndpoints(this.record, endpoints);
3790
+ if (added > 0) console.info(`[cherry-remote] device ${this.record.deviceId} learned ${added} advertised endpoint(s)`);
3791
+ this.#onAdvertisedEndpoints?.(endpoints);
3792
+ } catch {}
3793
+ }
3794
+ /**
3414
3795
  * authenticate with the stored token; a stale token (TOKEN_EXPIRED /
3415
3796
  * UNAUTHENTICATED) is retried exactly once without it before the pairing
3416
3797
  * is declared invalid.
@@ -4338,6 +4719,26 @@ const DEFAULT_CHECKPOINT_MS = 3e5;
4338
4719
  * not a tuning knob.
4339
4720
  */
4340
4721
  const MAX_RECOVERY_ATTEMPTS = 4;
4722
+ /**
4723
+ * ACK WATERMARK (issue #212). The peer delivers `agent.events` only while the
4724
+ * bytes it has sent since our last `agent.subscriptions.ack` stay inside
4725
+ * `remoteLimits.unackedBytes`; past that it HOLDS the stream — silently, with
4726
+ * the subscription still live — and a held batch can be a run's terminal.
4727
+ *
4728
+ * This layer used to ack ONLY from `cherry_events`, so a waiter (which by
4729
+ * design consumes without acking) parked on a buffer that stopped advancing
4730
+ * just under one window: a real 240 s wait answered `timeout` with the finished
4731
+ * text, its cursor frozen at 666 events ≈ 262144 bytes.
4732
+ *
4733
+ * The layer therefore acks what it has ALREADY BUFFERED (the bytes are in
4734
+ * memory; acking does not lose them) once a quarter of the peer's window has
4735
+ * accumulated. A retry cooldown keeps a refusing peer from turning every batch
4736
+ * into a round trip.
4737
+ */
4738
+ /** First retry delay after a refused auto-ack; doubles per attempt. */
4739
+ const AUTO_ACK_RETRY_MS = 3e3;
4740
+ /** Ceiling for the doubling retry, so a permanently refusing peer is quiet. */
4741
+ const AUTO_ACK_RETRY_MAX_MS = 3e4;
4341
4742
  function positive$1(value, fallback) {
4342
4743
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
4343
4744
  }
@@ -4375,6 +4776,8 @@ var SessionSubscriptions = class {
4375
4776
  #limit;
4376
4777
  #perSessionCacheBytes;
4377
4778
  #checkpointMs;
4779
+ /** Where the peer's window comes from; see {@link SessionSubscriptionsOptions.unackedBytes}. */
4780
+ #unackedSource;
4378
4781
  #sessions = /* @__PURE__ */ new Map();
4379
4782
  /**
4380
4783
  * Insertion order IS the eviction order, and it is SUBSCRIPTION recency, not
@@ -4396,6 +4799,7 @@ var SessionSubscriptions = class {
4396
4799
  this.#limit = Math.max(1, Math.floor(positive$1(opts.subscriptionsLimit, DEFAULT_SUBSCRIPTIONS_LIMIT)));
4397
4800
  this.#perSessionCacheBytes = positive$1(opts.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES);
4398
4801
  this.#checkpointMs = positive$1(opts.checkpointMs, DEFAULT_CHECKPOINT_MS);
4802
+ this.#unackedSource = opts.unackedBytes;
4399
4803
  }
4400
4804
  /**
4401
4805
  * Session ids with a live subscription, LRU-first. Task 10's reconnect
@@ -4495,6 +4899,7 @@ var SessionSubscriptions = class {
4495
4899
  if (existing !== void 0) {
4496
4900
  console.info(`[cherry-remote] re-subscribing session ${sessionId} (replacing its live subscription)`);
4497
4901
  await this.#closeSubscription(existing.subscriptionId);
4902
+ this.#cancelAckRetry(existing);
4498
4903
  this.#rejectWaiters(existing, /* @__PURE__ */ new Error(`session ${sessionId} re-subscribed`));
4499
4904
  this.#sessions.delete(sessionId);
4500
4905
  }
@@ -4570,13 +4975,18 @@ var SessionSubscriptions = class {
4570
4975
  /**
4571
4976
  * Advances the peer's view of this session: `agent.subscriptions.ack` with
4572
4977
  * the delivered cursor. The peer only accepts a cursor inside the window it
4573
- * actually delivered (an out-of-window ack is its CONFLICT), so this is
4574
- * called with the cursor a consumer has durably processed.
4978
+ * actually delivered (an out-of-window ack is its CONFLICT).
4979
+ *
4980
+ * Two callers, one contract — "this position is accounted for, keep sending":
4981
+ * `cherry_events` passes the cursor a CONSUMER has processed, and
4982
+ * {@link #maybeAutoAck} passes the BUFFER frontier because the events are
4983
+ * retained here (see that method for why an ack cannot lose replay).
4575
4984
  */
4576
4985
  async ack(sessionId, cursor) {
4577
4986
  const state = this.#sessions.get(sessionId);
4578
4987
  if (state === void 0) return;
4579
- const applied = cursor ?? state.cursor;
4988
+ const requested = cursor === void 0 ? state.cursor : cursor;
4989
+ const applied = Number(requested.seq) > Number(state.cursor.seq) ? requested : state.cursor;
4580
4990
  await this.#rpc.request("agent.subscriptions.ack", {
4581
4991
  subscriptionId: state.subscriptionId,
4582
4992
  cursor: applied
@@ -4628,6 +5038,7 @@ var SessionSubscriptions = class {
4628
5038
  this.#closed = true;
4629
5039
  const ids = [...this.#sessions.values()].map((state) => state.subscriptionId);
4630
5040
  for (const state of this.#sessions.values()) {
5041
+ this.#cancelAckRetry(state);
4631
5042
  const error = /* @__PURE__ */ new Error("SessionSubscriptions disposed");
4632
5043
  for (const waiter of state.waiters.splice(0)) waiter.reject(error);
4633
5044
  }
@@ -4710,13 +5121,21 @@ var SessionSubscriptions = class {
4710
5121
  */
4711
5122
  #installReplay(sessionId, descriptor) {
4712
5123
  const previous = this.#sessions.get(sessionId);
4713
- if (previous !== void 0) this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} re-subscribed`));
5124
+ if (previous !== void 0) {
5125
+ this.#cancelAckRetry(previous);
5126
+ this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} re-subscribed`));
5127
+ }
4714
5128
  this.#sessions.delete(sessionId);
4715
5129
  this.#sessions.set(sessionId, {
4716
5130
  subscriptionId: descriptor.subscriptionId,
4717
5131
  cursor: descriptor.highWatermark,
4718
5132
  buffer: [],
4719
5133
  bytes: 0,
5134
+ bytesSinceAck: 0,
5135
+ ackInFlight: false,
5136
+ ackRetryAt: 0,
5137
+ ackRetryDelay: AUTO_ACK_RETRY_MS,
5138
+ ackRetryTimer: void 0,
4720
5139
  windowStart: Number(descriptor.highWatermark.seq),
4721
5140
  evicted: false,
4722
5141
  waiters: [],
@@ -4796,13 +5215,21 @@ var SessionSubscriptions = class {
4796
5215
  */
4797
5216
  #installCheckpoint(sessionId, descriptor, items) {
4798
5217
  const previous = this.#sessions.get(sessionId);
4799
- if (previous !== void 0) this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} recovered`));
5218
+ if (previous !== void 0) {
5219
+ this.#cancelAckRetry(previous);
5220
+ this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} recovered`));
5221
+ }
4800
5222
  this.#sessions.delete(sessionId);
4801
5223
  this.#sessions.set(sessionId, {
4802
5224
  subscriptionId: descriptor.subscriptionId,
4803
5225
  cursor: descriptor.checkpoint.cursor,
4804
5226
  buffer: [],
4805
5227
  bytes: 0,
5228
+ bytesSinceAck: 0,
5229
+ ackInFlight: false,
5230
+ ackRetryAt: 0,
5231
+ ackRetryDelay: AUTO_ACK_RETRY_MS,
5232
+ ackRetryTimer: void 0,
4806
5233
  windowStart: Number(descriptor.checkpoint.cursor.seq),
4807
5234
  evicted: false,
4808
5235
  waiters: [],
@@ -4848,6 +5275,7 @@ var SessionSubscriptions = class {
4848
5275
  for (const [sessionId, state] of this.#sessions) {
4849
5276
  if (sessionId === protectedSessionId) continue;
4850
5277
  console.warn(`[cherry-remote] subscription limit (${this.#limit}) reached; evicting least-recently-used session ${sessionId}`);
5278
+ this.#cancelAckRetry(state);
4851
5279
  this.#rejectWaiters(state, /* @__PURE__ */ new Error(`session ${sessionId} evicted by LRU`));
4852
5280
  this.#sessions.delete(sessionId);
4853
5281
  await this.#closeSubscription(state.subscriptionId);
@@ -4929,11 +5357,86 @@ var SessionSubscriptions = class {
4929
5357
  bytes
4930
5358
  });
4931
5359
  state.bytes += bytes;
5360
+ state.bytesSinceAck += bytes;
4932
5361
  state.cursor = cursor;
4933
5362
  appended += 1;
4934
5363
  }
4935
5364
  this.#trimToBudget(sessionId, state);
4936
5365
  if (appended > 0) this.#wakeWaiters(state);
5366
+ if (appended > 0) this.#maybeAutoAck(sessionId, state);
5367
+ }
5368
+ /**
5369
+ * Sends `agent.subscriptions.ack` once the un-acked bytes approach the peer's
5370
+ * window, so the peer keeps delivering (issue #212).
5371
+ *
5372
+ * WHAT IS BEING ACKED is "everything in this layer's buffer", not "everything
5373
+ * a caller has consumed". That is safe against the pinned peer because an ack
5374
+ * does exactly one thing: it frees FLOW CONTROL — the desktop decrements its
5375
+ * un-acked budget and advances `lastAck` (`agentSubscriptions.ts`). Replay
5376
+ * retention lives on the journal (`replayBytes`/`replayMs`) and is not
5377
+ * touched by an ack, so a `read`/`cherry_events`/re-subscribe from a cursor
5378
+ * below the acked position is still served — by the local buffer here and by
5379
+ * the journal there. The ack's own doc comment states the same contract.
5380
+ *
5381
+ * The ack is fire-and-forget with its failure SURFACED (a refused ack must be
5382
+ * visible, not swallowed). A refusal also ARMS A TIMER: once the peer is
5383
+ * holding at its window no further event arrives, so a retry that waited for
5384
+ * traffic would never fire and the wait would time out exactly as in issue
5385
+ * #212.
5386
+ */
5387
+ #maybeAutoAck(sessionId, state) {
5388
+ if (state.bytesSinceAck < this.#ackWatermark()) return;
5389
+ if (state.ackInFlight) return;
5390
+ if (Date.now() < state.ackRetryAt) return;
5391
+ state.ackInFlight = true;
5392
+ const covered = state.bytesSinceAck;
5393
+ this.ack(sessionId).then(() => {
5394
+ state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered);
5395
+ state.ackRetryDelay = AUTO_ACK_RETRY_MS;
5396
+ }).catch((error) => {
5397
+ const delay = state.ackRetryDelay;
5398
+ state.ackRetryAt = Date.now() + delay;
5399
+ state.ackRetryDelay = Math.min(delay * 2, AUTO_ACK_RETRY_MAX_MS);
5400
+ console.warn(`[cherry-remote] auto-ack for session ${sessionId} failed: ${failureText(error)}`);
5401
+ this.#armAckRetry(sessionId, state, delay);
5402
+ }).finally(() => {
5403
+ state.ackInFlight = false;
5404
+ if (this.#closed || this.#sessions.get(sessionId) !== state) return;
5405
+ this.#maybeAutoAck(sessionId, state);
5406
+ });
5407
+ }
5408
+ /**
5409
+ * Quarter of the peer's window, read from the CURRENT source so a layer that
5410
+ * outlives a reconnect follows the new hello instead of its first answer.
5411
+ * Falls back to the pinned protocol constant when nothing is advertised.
5412
+ */
5413
+ #ackWatermark() {
5414
+ const source = this.#unackedSource;
5415
+ const advertised = typeof source === "function" ? source() : source;
5416
+ return Math.max(1, Math.floor(positive$1(advertised, remoteLimits.unackedBytes) / 4));
5417
+ }
5418
+ /** Disarms the retry, if any (idempotent; safe on a state being replaced). */
5419
+ #cancelAckRetry(state) {
5420
+ if (state.ackRetryTimer === void 0) return;
5421
+ clearTimeout(state.ackRetryTimer);
5422
+ state.ackRetryTimer = void 0;
5423
+ }
5424
+ /**
5425
+ * One timer-driven retry after a refused ack. At most one is armed per
5426
+ * session; it no-ops when the state it was armed for is no longer current
5427
+ * (a re-subscribe, a checkpoint recovery or `dispose`).
5428
+ */
5429
+ #armAckRetry(sessionId, state, delayMs) {
5430
+ if (this.#closed) return;
5431
+ if (state.ackRetryTimer !== void 0) return;
5432
+ const timer = setTimeout(() => {
5433
+ state.ackRetryTimer = void 0;
5434
+ if (this.#closed || this.#sessions.get(sessionId) !== state) return;
5435
+ state.ackRetryAt = 0;
5436
+ this.#maybeAutoAck(sessionId, state);
5437
+ }, delayMs);
5438
+ timer.unref?.();
5439
+ state.ackRetryTimer = timer;
4937
5440
  }
4938
5441
  /**
4939
5442
  * `agent.subscriptions.resetRequired`: the peer tore the subscription down
@@ -4952,6 +5455,7 @@ var SessionSubscriptions = class {
4952
5455
  for (const [sessionId, state] of this.#sessions) {
4953
5456
  if (state.subscriptionId !== subscriptionId) continue;
4954
5457
  console.warn(`[cherry-remote] subscription for session ${sessionId} reset by the peer (${String(params["reason"] ?? "unspecified")}); re-subscribe required`);
5458
+ this.#cancelAckRetry(state);
4955
5459
  this.#rejectWaiters(state, /* @__PURE__ */ new Error(`session ${sessionId} reset by the peer`));
4956
5460
  this.#sessions.delete(sessionId);
4957
5461
  return;
@@ -5892,6 +6396,54 @@ function optionalString(value) {
5892
6396
  return typeof value === "string" && value !== "" ? value : void 0;
5893
6397
  }
5894
6398
  /**
6399
+ * The `hosts` argument of `cherry_devices`' write actions (issue #185): a
6400
+ * non-empty list of non-empty host names, with no case-variant duplicate.
6401
+ *
6402
+ * Each rule is a state the store would have to interpret: an empty name can
6403
+ * never match a dialed host, and two spellings of one host would make "is this
6404
+ * address forgotten?" depend on which entry a comparison hit first. Values are
6405
+ * TRIMMED here (the model-facing surface is where a stray space is caught);
6406
+ * the archive then stores the spelling it already had for a dialed host.
6407
+ */
6408
+ function hostListArgument(value) {
6409
+ if (!Array.isArray(value) || value.length === 0) return {
6410
+ error: "invalid-hosts",
6411
+ message: "`hosts` must be a non-empty array of host names"
6412
+ };
6413
+ const hosts = [];
6414
+ const seen = /* @__PURE__ */ new Set();
6415
+ for (const entry of value) {
6416
+ if (typeof entry !== "string" || entry.trim() === "") return {
6417
+ error: "invalid-hosts",
6418
+ message: "`hosts` entries must be non-empty strings"
6419
+ };
6420
+ let host = entry.trim();
6421
+ if (host.startsWith("[") && host.endsWith("]")) {
6422
+ try {
6423
+ new URL(`http://${host}/`);
6424
+ } catch {
6425
+ return {
6426
+ error: "invalid-hosts",
6427
+ message: `\`hosts\` entry "${host}" is not a valid IPv6 literal`
6428
+ };
6429
+ }
6430
+ host = host.slice(1, -1);
6431
+ }
6432
+ if (host.length > 256) return {
6433
+ error: "invalid-hosts",
6434
+ message: "`hosts` entry exceeds 256 characters"
6435
+ };
6436
+ const key = host.toLowerCase();
6437
+ if (seen.has(key)) return {
6438
+ error: "invalid-hosts",
6439
+ message: `\`hosts\` names "${host}" more than once (host names are case-insensitive)`
6440
+ };
6441
+ seen.add(key);
6442
+ hosts.push(host);
6443
+ }
6444
+ return hosts;
6445
+ }
6446
+ /**
5895
6447
  * The peer's `agent.messages.send` refusal for a STALE `expectedIdleRevision`,
5896
6448
  * in the exact shape Cherry Studio answers it: a `rejected` receipt whose
5897
6449
  * `error` is `{reason: 'CONFLICT', message: 'Session is not idle at the
@@ -6572,26 +7124,86 @@ var CherryToolEngine = class {
6572
7124
  #devicesTool() {
6573
7125
  return defineCherryTool({
6574
7126
  name: "cherry_devices",
6575
- description: "List every paired Cherry Studio desktop with its connection state (connected/connecting/offline/disabled/identity-mismatch). An optional `device` narrows the list to one device (display name prefix or deviceId).",
7127
+ description: "List every paired Cherry Studio desktop with its connection state (connected/connecting/offline/disabled/identity-mismatch). An optional `device` narrows the list to one device (display name prefix or deviceId). `action: \"forget\"` removes the named `hosts` from that device's dial candidates AND stops the desktop's advertisements from adding them back; `action: \"remember\"` lifts that mark, so the next handshake may learn them again. Both write actions require `device` and `hosts`.",
6576
7128
  parameters: {
6577
7129
  type: "object",
6578
- properties: { device: {
6579
- type: "string",
6580
- description: "Optional device selector: exact deviceId, or a unique prefix of a deviceId or display name."
6581
- } },
7130
+ properties: {
7131
+ device: {
7132
+ type: "string",
7133
+ description: "Optional device selector: exact deviceId, or a unique prefix of a deviceId or display name."
7134
+ },
7135
+ action: {
7136
+ type: "string",
7137
+ enum: [
7138
+ "list",
7139
+ "forget",
7140
+ "remember"
7141
+ ],
7142
+ description: "What to do: `list` (the default) reads the devices; `forget`/`remember` edit ONE device's forgotten addresses and require `device` and `hosts`."
7143
+ },
7144
+ hosts: {
7145
+ type: "array",
7146
+ items: { type: "string" },
7147
+ description: "For `forget`/`remember`: the host names to act on, matched case-insensitively. `forget` refuses to remove the last remaining address."
7148
+ }
7149
+ },
6582
7150
  additionalProperties: false
6583
7151
  },
6584
7152
  execute: async (args) => {
7153
+ const action = optionalString(args["action"]) ?? "list";
7154
+ if (action !== "list" && action !== "forget" && action !== "remember") return {
7155
+ error: "invalid-action",
7156
+ message: "`action` accepts exactly \"list\", \"forget\" or \"remember\""
7157
+ };
6585
7158
  const selector = optionalString(args["device"]);
7159
+ if (action === "list") {
7160
+ const quarantined = this.#quarantineVerdict();
7161
+ if (quarantined !== void 0) return quarantined;
7162
+ if (selector === void 0) {
7163
+ const records = this.#deps.store.list();
7164
+ return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) };
7165
+ }
7166
+ const selected = this.#selectRecords(selector);
7167
+ if (!Array.isArray(selected)) return selected;
7168
+ return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) };
7169
+ }
7170
+ if (selector === void 0) return {
7171
+ error: "device-required",
7172
+ message: "`action` \"forget\"/\"remember\" needs `device`: a selector that names exactly one device"
7173
+ };
7174
+ const hosts = hostListArgument(args["hosts"]);
7175
+ if (!Array.isArray(hosts)) return hosts;
6586
7176
  const quarantined = this.#quarantineVerdict();
6587
7177
  if (quarantined !== void 0) return quarantined;
6588
- if (selector === void 0) {
6589
- const records = this.#deps.store.list();
6590
- return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) };
6591
- }
6592
7178
  const selected = this.#selectRecords(selector);
6593
7179
  if (!Array.isArray(selected)) return selected;
6594
- return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) };
7180
+ const record = selected[0];
7181
+ let removed = [];
7182
+ let marked = [];
7183
+ let restored = [];
7184
+ try {
7185
+ if (action === "forget") ({removed, marked} = this.#deps.store.forgetEndpoints(record.deviceId, hosts));
7186
+ else ({restored} = this.#deps.store.rememberEndpoints(record.deviceId, hosts));
7187
+ } catch (error) {
7188
+ if (error instanceof LastCandidateHostError) return {
7189
+ error: "last-address",
7190
+ device: record.deviceId,
7191
+ message: error.message
7192
+ };
7193
+ throw error;
7194
+ }
7195
+ if (removed.length > 0 || marked.length > 0 || restored.length > 0) await this.#deps.evict(record.deviceId);
7196
+ const fresh = this.#deps.store.list().find((entry) => entry.deviceId === record.deviceId);
7197
+ if (fresh === void 0) return this.#notFoundVerdict(selector);
7198
+ const device = await this.#deviceView(fresh);
7199
+ return action === "forget" ? {
7200
+ device,
7201
+ removed,
7202
+ marked
7203
+ } : {
7204
+ device,
7205
+ restored
7206
+ };
6595
7207
  }
6596
7208
  });
6597
7209
  }
@@ -6645,7 +7257,8 @@ var CherryToolEngine = class {
6645
7257
  state,
6646
7258
  enabled: record.enabled,
6647
7259
  hosts: record.hosts,
6648
- port: record.port
7260
+ port: record.port,
7261
+ forgottenHosts: record.forgottenHosts ?? []
6649
7262
  };
6650
7263
  }
6651
7264
  #agentsTool() {
@@ -8673,8 +9286,9 @@ var CherryToolEngine = class {
8673
9286
  * dimension and deliberately does NOT settle a wait (Task 13 asserts the two
8674
9287
  * layers separately).
8675
9288
  *
8676
- * Waiters never ack: they consume the projection and leave the peer's view
8677
- * untouched.
9289
+ * Waiters do not ack what they fold. The subscription LAYER acks its own
9290
+ * buffer on a byte watermark (issue #212) so the peer keeps delivering past
9291
+ * its un-acked window; that ack is about flow control, not consumption.
8678
9292
  *
8679
9293
  * A wait can also end because the LINK went away — the plugin was torn down,
8680
9294
  * the device was unpaired, or the connection closed. That is not a timeout and
@@ -10900,7 +11514,12 @@ function parsePatch(body) {
10900
11514
  if (typeof entry !== "string") throw new PatchError("hosts entries must be strings");
10901
11515
  return normalizeHost(entry);
10902
11516
  });
10903
- if (new Set(patch.hosts).size !== patch.hosts.length) throw new PatchError("hosts must not contain duplicates");
11517
+ const seen = /* @__PURE__ */ new Set();
11518
+ for (const host of patch.hosts) {
11519
+ const key = host.toLowerCase();
11520
+ if (seen.has(key)) throw new PatchError("hosts must not contain duplicates");
11521
+ seen.add(key);
11522
+ }
10904
11523
  }
10905
11524
  const port = body["port"];
10906
11525
  if (port !== void 0) {
@@ -11256,6 +11875,23 @@ async function handleRequest(req, res, route) {
11256
11875
  writeError(res, 405, "method-not-allowed", "DELETE or PATCH only");
11257
11876
  return;
11258
11877
  }
11878
+ if (first === "devices" && match.segments.length === 3 && second !== void 0) {
11879
+ const deviceId = decodeSegment(second);
11880
+ if (deviceId === void 0) {
11881
+ writeError(res, 400, "invalid-request", "device id is not valid URL encoding");
11882
+ return;
11883
+ }
11884
+ const action = match.segments[2];
11885
+ if (action !== "forget" && action !== "remember") {
11886
+ writeError(res, 404, "not-found", "unknown route");
11887
+ return;
11888
+ }
11889
+ if (method !== "POST") {
11890
+ writeError(res, 405, "method-not-allowed", "POST only");
11891
+ return;
11892
+ }
11893
+ return await changeEndpoints(req, res, route, deviceId, action);
11894
+ }
11259
11895
  if (first === "pair" && match.segments.length === 1) {
11260
11896
  if (method !== "POST") {
11261
11897
  writeError(res, 405, "method-not-allowed", "POST only");
@@ -11293,7 +11929,18 @@ async function handleRequest(req, res, route) {
11293
11929
  }
11294
11930
  }
11295
11931
  function listDevices(res, route) {
11296
- writeJson(res, 200, route.deps.store.list().map((device) => ({
11932
+ writeJson(res, 200, route.deps.store.list().map((device) => deviceSummary(route, device)));
11933
+ }
11934
+ /**
11935
+ * One device as the browser sees it: the public archive projection, never a
11936
+ * credential.
11937
+ *
11938
+ * ONE builder, because FOUR faces answer this shape — `GET /devices`, the
11939
+ * `PATCH`, and the two endpoint actions of issue #204 — and a hand-written copy
11940
+ * per face is how a field ends up on one and not the others.
11941
+ */
11942
+ function deviceSummary(route, device) {
11943
+ return {
11297
11944
  deviceId: device.deviceId,
11298
11945
  displayName: device.displayName,
11299
11946
  state: projectState(device, route.deps.connect),
@@ -11301,8 +11948,9 @@ function listDevices(res, route) {
11301
11948
  hosts: [...device.hosts],
11302
11949
  port: device.port,
11303
11950
  security: { ...device.security },
11304
- ports: { ...device.ports }
11305
- })));
11951
+ ports: { ...device.ports },
11952
+ forgottenHosts: [...device.forgottenHosts ?? []]
11953
+ };
11306
11954
  }
11307
11955
  /**
11308
11956
  * The `GET /diagnostics` body (spec §5).
@@ -11530,8 +12178,10 @@ async function patchDevice(req, res, route, deviceId) {
11530
12178
  const previousSecurity = existing.security;
11531
12179
  const previousPorts = existing.ports;
11532
12180
  const nextPorts = patch.port !== void 0 && patch.port !== previousPort ? portsFor(patch.hosts ?? previousHosts, {}, patch.port) : patch.hosts === void 0 ? previousPorts : portsFor(patch.hosts, previousPorts, previousPort);
12181
+ const nextForgotten = patch.hosts === void 0 || existing.forgottenHosts === void 0 ? void 0 : existing.forgottenHosts.filter((host) => !patch.hosts.some((nextHost) => nextHost.toLowerCase() === host.toLowerCase()));
11533
12182
  const next = {
11534
12183
  ...existing,
12184
+ ...nextForgotten === void 0 ? {} : { forgottenHosts: nextForgotten },
11535
12185
  ...patch.displayName === void 0 ? {} : { displayName: patch.displayName },
11536
12186
  ...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
11537
12187
  ...patch.hosts === void 0 ? {} : {
@@ -11557,21 +12207,12 @@ async function patchDevice(req, res, route, deviceId) {
11557
12207
  } catch (error) {
11558
12208
  console.warn(`[cherry-remote] device ${deviceId} reconnect failed`, error);
11559
12209
  }
11560
- const state = projectState(next, route.deps.connect);
12210
+ const summary = deviceSummary(route, next);
11561
12211
  if (patch.enabled !== void 0 && patch.enabled !== previousEnabled) route.hub.publish("device-state", {
11562
12212
  deviceId,
11563
- state
11564
- });
11565
- writeJson(res, 200, {
11566
- deviceId: next.deviceId,
11567
- displayName: next.displayName,
11568
- state,
11569
- enabled: next.enabled,
11570
- hosts: [...next.hosts],
11571
- port: next.port,
11572
- security: { ...next.security },
11573
- ports: { ...next.ports }
12213
+ state: summary.state
11574
12214
  });
12215
+ writeJson(res, 200, summary);
11575
12216
  }
11576
12217
  function warnConnect(deviceId) {
11577
12218
  return (error) => {
@@ -11583,14 +12224,128 @@ function sameHosts(a, b) {
11583
12224
  return a.length === b.length && a.every((host, index) => host === b[index]);
11584
12225
  }
11585
12226
  /**
12227
+ * `POST /devices/:id/forget` and `POST /devices/:id/remember` (issue #204): the
12228
+ * settings page's door onto #185's endpoint maintenance, which until now only
12229
+ * the `cherry_devices` tool could open.
12230
+ *
12231
+ * One action per request, and the action IS the request: there is no composite
12232
+ * "patch the record AND forget a host" body whose ordering would have to be
12233
+ * defined. Every judgement is the store's — the last-candidate refusal, the
12234
+ * case-insensitive matching, the disjointness of `hosts`/`forgottenHosts` — so
12235
+ * the two doors share ONE set of STORE rules; this route only maps the verdicts
12236
+ * onto HTTP and answers the projection the PATCH answers. (The BODY PARSERS do
12237
+ * differ on purpose — see `parseEndpointHosts`.)
12238
+ *
12239
+ * EVICTION on a change, exactly like the tool's own arm and a PATCH's address
12240
+ * maintenance, and for a DIFFERENT reason on each action — which is why both
12241
+ * are stated here rather than waved through as "parity":
12242
+ *
12243
+ * - FORGET: a pooled connection CAPTURES its record at construction, so the
12244
+ * socket built before this write would keep dialing the host we just forgot.
12245
+ * (The mark itself is honoured by the store's learn merge, so a reconnect is
12246
+ * about the DIAL LIST, not about suppressing the address.)
12247
+ * - REMEMBER: learning happens only INSIDE a handshake, against a clone of the
12248
+ * stored record. Without the eviction a desktop that stays connected would
12249
+ * not re-handshake for as long as the link holds, so the restored address
12250
+ * would come back only after some later flap — the button would look inert.
12251
+ *
12252
+ * The cost is real and inherited from the tool's arm: an eviction disposes the
12253
+ * subscription layer and closes a healthy socket, so a write can drop an
12254
+ * in-flight command's link. One operator click now reaches that churn; that is
12255
+ * the price of "the archive is the truth" for a live connection.
12256
+ */
12257
+ async function changeEndpoints(req, res, route, deviceId, action) {
12258
+ const existing = route.deps.store.list().find((device) => device.deviceId === deviceId);
12259
+ if (existing === void 0) {
12260
+ writeError(res, 404, "not-found", `no device ${deviceId}`);
12261
+ return;
12262
+ }
12263
+ let hosts;
12264
+ try {
12265
+ hosts = parseEndpointHosts(await readJsonBody(req));
12266
+ } catch (error) {
12267
+ writeError(res, 400, "invalid-request", error instanceof Error ? error.message : String(error));
12268
+ return;
12269
+ }
12270
+ try {
12271
+ if (action === "forget") {
12272
+ const { removed, marked } = route.deps.store.forgetEndpoints(deviceId, hosts);
12273
+ if (removed.length > 0 || marked.length > 0) evict(route, deviceId);
12274
+ } else {
12275
+ const { restored } = route.deps.store.rememberEndpoints(deviceId, hosts);
12276
+ if (restored.length > 0) evict(route, deviceId);
12277
+ }
12278
+ } catch (error) {
12279
+ if (writeQuarantineRefusal(res, route, error)) return;
12280
+ if (error instanceof LastCandidateHostError) {
12281
+ writeError(res, 400, "last-address", error.message);
12282
+ return;
12283
+ }
12284
+ writeError(res, 400, "invalid-device", error instanceof Error ? error.message : String(error));
12285
+ return;
12286
+ }
12287
+ writeJson(res, 200, deviceSummary(route, route.deps.store.list().find((device) => device.deviceId === deviceId) ?? existing));
12288
+ }
12289
+ /**
12290
+ * The body of an endpoint action: `{hosts: [...]}`, non-empty, every entry
12291
+ * normalized the same way the PATCH normalizes an address.
12292
+ *
12293
+ * The normalization is not cosmetic: an IPv6 literal is stored bare but a
12294
+ * browser copy-pastes it bracketed, and the store matches by host spelling —
12295
+ * so `[::1]` would be treated as an address the record does not dial and merely
12296
+ * marked, leaving the real `::1` in `hosts`. The PATCH's rule is reused for the
12297
+ * spelling (same `normalizeHost`), and deliberately NOT for its duplicate
12298
+ * refusal: a repeated host in an action body names one host twice, which the
12299
+ * store's own sets collapse, while the PATCH's duplicates would build an
12300
+ * ambiguous record (`hosts`, `security` and `ports` are keyed by host).
12301
+ */
12302
+ function parseEndpointHosts(body) {
12303
+ const hosts = body["hosts"];
12304
+ if (!Array.isArray(hosts) || hosts.length === 0) throw new PatchError("hosts must be a non-empty array");
12305
+ return hosts.map((entry) => {
12306
+ if (typeof entry !== "string") throw new PatchError("hosts entries must be strings");
12307
+ return normalizeHost(entry);
12308
+ });
12309
+ }
12310
+ /**
11586
12311
  * The scheme map for a WHOLE-REPLACE host list (issue #126): each named host
11587
12312
  * keeps the scheme it was dialed with, a host the list just gained defaults to
11588
12313
  * `ws`, and hosts the list dropped leave no entry behind. An edit is an
11589
12314
  * address edit, not a scheme edit — re-typing the same host must not silently
11590
12315
  * demote a TLS-proxied desktop to a plain dial.
12316
+ *
12317
+ * "The same host" is CASE-INSENSITIVE (issue #189), the same comparison the
12318
+ * learn merge, the forget/remember actions and the mark clearing already use:
12319
+ * `NAS.example` and `nas.example` are one address, so a PATCH that re-types
12320
+ * the case keeps the remembered scheme instead of resetting it to `ws`.
12321
+ * `sameHosts`/`sameSecurity` stay EXACT on purpose — they decide whether an
12322
+ * address edit happened at all, and a new spelling IS a change to persist.
11591
12323
  */
11592
12324
  function securityFor(hosts, previous) {
11593
- return Object.fromEntries(hosts.map((host) => [host, Object.hasOwn(previous, host) ? previous[host] : "ws"]));
12325
+ const remembered = indexByHost(previous);
12326
+ return Object.fromEntries(hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? "ws"]));
12327
+ }
12328
+ /**
12329
+ * A case-insensitive index over a per-host map (issue #189).
12330
+ *
12331
+ * FIRST spelling wins on a collision. A hand-edited archive CAN hold two
12332
+ * spellings of one host (the store does not reject it, and this face does not
12333
+ * touch `previous`), so callers look their exact spelling up FIRST and consult
12334
+ * this index only as the fallback: a surviving spelling therefore keeps its
12335
+ * OWN entry, and the collision rule only decides what a THIRD spelling
12336
+ * inherits.
12337
+ */
12338
+ function indexByHost(previous) {
12339
+ const index = /* @__PURE__ */ new Map();
12340
+ for (const [host, value] of Object.entries(previous)) {
12341
+ const key = host.toLowerCase();
12342
+ if (!index.has(key)) index.set(key, value);
12343
+ }
12344
+ return index;
12345
+ }
12346
+ /** The exact spelling's entry, else the case-insensitive one, else nothing. */
12347
+ function exactOrRemembered(previous, remembered, host) {
12348
+ return Object.hasOwn(previous, host) ? previous[host] : remembered.get(host.toLowerCase());
11594
12349
  }
11595
12350
  /**
11596
12351
  * The port map for a host list (issue #135): each named host keeps the port it
@@ -11599,11 +12354,13 @@ function securityFor(hosts, previous) {
11599
12354
  *
11600
12355
  * `previous` is empty when the caller is re-pointing EVERY host (a CHANGED
11601
12356
  * `port` patch): there is no remembered entry to keep, by design — one port
11602
- * field on this face must move the whole list. Same `Object.hasOwn` rule as
11603
- * `securityFor`: an inherited function is not a remembered port.
12357
+ * field on this face must move the whole list. Same `Object.hasOwn`/
12358
+ * case-insensitive rules as `securityFor` (issue #189): a re-typed spelling
12359
+ * keeps its own port, and an inherited function is never a remembered port.
11604
12360
  */
11605
12361
  function portsFor(hosts, previous, fallback) {
11606
- return Object.fromEntries(hosts.map((host) => [host, Object.hasOwn(previous, host) ? previous[host] : fallback]));
12362
+ const remembered = indexByHost(previous);
12363
+ return Object.fromEntries(hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? fallback]));
11607
12364
  }
11608
12365
  /** Order-insensitive comparison for two scheme maps. */
11609
12366
  function sameSecurity(a, b) {
@@ -11933,7 +12690,10 @@ function connectionFor(deviceId) {
11933
12690
  identity: requireIdentity(),
11934
12691
  record,
11935
12692
  rateBudgetPerSecond: state.rateBudgetPerSecond,
11936
- ...state.wsTls === void 0 ? {} : { wsOptions: state.wsTls }
12693
+ ...state.wsTls === void 0 ? {} : { wsOptions: state.wsTls },
12694
+ onAdvertisedEndpoints: (endpoints) => {
12695
+ state.store.learnEndpoints(deviceId, endpoints, record.desktopIdentity);
12696
+ }
11937
12697
  });
11938
12698
  state.connectionsBuilt += 1;
11939
12699
  connection.onState((deviceState) => {
@@ -12038,7 +12798,8 @@ function subsFor(deviceId) {
12038
12798
  const connection = connectionFor(deviceId);
12039
12799
  const created = new SessionSubscriptions(connection.rpc, {
12040
12800
  subscriptionsLimit: connection.limits.subscriptions,
12041
- perSessionCacheBytes: state.perSessionCacheBytes
12801
+ perSessionCacheBytes: state.perSessionCacheBytes,
12802
+ unackedBytes: () => connection.limits.unackedBytes
12042
12803
  });
12043
12804
  state.subscriptions.set(deviceId, created);
12044
12805
  console.info(`[cherry-remote] subscription layer established for ${deviceId} (peer limit ${connection.limits.subscriptions})`);