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.
@@ -2,6 +2,7 @@ import { JsonRpcRequestError } from './rpc/jsonRpcClient.js'
2
2
  import type { JsonRpcClient } from './rpc/jsonRpcClient.js'
3
3
  import { DEFAULT_PER_SESSION_CACHE_BYTES } from './defaults.js'
4
4
  import { failureText } from './text.js'
5
+ import { remoteLimits } from '@cherrystudio/remote-protocol'
5
6
  import type { AgentCursor, AgentEvent } from '@cherrystudio/remote-protocol/agent'
6
7
 
7
8
  /**
@@ -40,6 +41,27 @@ const DEFAULT_CHECKPOINT_MS = 300_000
40
41
  */
41
42
  const MAX_RECOVERY_ATTEMPTS = 4
42
43
 
44
+ /**
45
+ * ACK WATERMARK (issue #212). The peer delivers `agent.events` only while the
46
+ * bytes it has sent since our last `agent.subscriptions.ack` stay inside
47
+ * `remoteLimits.unackedBytes`; past that it HOLDS the stream — silently, with
48
+ * the subscription still live — and a held batch can be a run's terminal.
49
+ *
50
+ * This layer used to ack ONLY from `cherry_events`, so a waiter (which by
51
+ * design consumes without acking) parked on a buffer that stopped advancing
52
+ * just under one window: a real 240 s wait answered `timeout` with the finished
53
+ * text, its cursor frozen at 666 events ≈ 262144 bytes.
54
+ *
55
+ * The layer therefore acks what it has ALREADY BUFFERED (the bytes are in
56
+ * memory; acking does not lose them) once a quarter of the peer's window has
57
+ * accumulated. A retry cooldown keeps a refusing peer from turning every batch
58
+ * into a round trip.
59
+ */
60
+ /** First retry delay after a refused auto-ack; doubles per attempt. */
61
+ const AUTO_ACK_RETRY_MS = 3_000
62
+ /** Ceiling for the doubling retry, so a permanently refusing peer is quiet. */
63
+ const AUTO_ACK_RETRY_MAX_MS = 30_000
64
+
43
65
  export type ReadResult = { events: AgentEvent[]; nextCursor: AgentCursor } | { error: 'cursor-out-of-window' }
44
66
 
45
67
  export interface SessionSubscriptionsOptions {
@@ -60,6 +82,20 @@ export interface SessionSubscriptionsOptions {
60
82
  * default 300_000). Only bounds recovery retries; the peer is authoritative.
61
83
  */
62
84
  checkpointMs?: number
85
+ /**
86
+ * The peer's hello-advertised un-acked delivery window in bytes (protocol
87
+ * `remoteLimits.unackedBytes`, 262144 in the pinned package). Past that much
88
+ * delivered-but-unacknowledged traffic the desktop HOLDS its event stream
89
+ * (issue #212), so the layer acks its buffer at a quarter of this value.
90
+ *
91
+ * Omitted falls back to the pinned protocol constant. A peer that does not
92
+ * advertise the key is treated as the pinned generation — the same
93
+ * conservative rule the other negotiated limits follow.
94
+ *
95
+ * A FUNCTION is read at every watermark check, so a layer that outlives a
96
+ * reconnect picks up the new hello's value instead of pinning the first one.
97
+ */
98
+ unackedBytes?: number | (() => number | undefined)
63
99
  }
64
100
 
65
101
  /** Peer-side subscription phases (mirrors the real peer's internal state). */
@@ -108,6 +144,31 @@ interface SessionState {
108
144
  cursor: AgentCursor
109
145
  buffer: CachedEvent[]
110
146
  bytes: number
147
+ /**
148
+ * Bytes appended since the last ack we sent (issue #212). Drives
149
+ * {@link SessionSubscriptions.#maybeAutoAck}; kept separate from `bytes`,
150
+ * which measures what the CACHE holds rather than what the peer may still
151
+ * consider un-acknowledged.
152
+ */
153
+ bytesSinceAck: number
154
+ /** One auto-ack in flight at a time; a second would only duplicate a cursor. */
155
+ ackInFlight: boolean
156
+ /** Cooldown after a refused auto-ack (`AUTO_ACK_RETRY_MS`, doubling). */
157
+ ackRetryAt: number
158
+ /**
159
+ * Current retry delay: `AUTO_ACK_RETRY_MS` after the first refusal, doubled
160
+ * on every further one up to `AUTO_ACK_RETRY_MAX_MS`. Reset on a success, so
161
+ * a peer that recovers is back to prompt retries.
162
+ */
163
+ ackRetryDelay: number
164
+ /**
165
+ * The ARMED retry behind `ackRetryAt`. An ack failure is the one case where
166
+ * no further event will arrive to re-trigger the watermark check — the peer
167
+ * is holding at its window — so the retry cannot wait for traffic (issue
168
+ * #212's own failure mode, one ack later). Cleared by {@link #installReplay},
169
+ * {@link #installCheckpoint} and {@link SessionSubscriptions.dispose}.
170
+ */
171
+ ackRetryTimer?: ReturnType<typeof setTimeout>
111
172
  /**
112
173
  * The oldest stream position this session still TRACKS: the baseline the
113
174
  * subscription started from, advanced whenever old events are dropped. It is
@@ -189,6 +250,8 @@ export class SessionSubscriptions {
189
250
  readonly #limit: number
190
251
  readonly #perSessionCacheBytes: number
191
252
  readonly #checkpointMs: number
253
+ /** Where the peer's window comes from; see {@link SessionSubscriptionsOptions.unackedBytes}. */
254
+ readonly #unackedSource: number | (() => number | undefined) | undefined
192
255
  readonly #sessions = new Map<string, SessionState>()
193
256
  /**
194
257
  * Insertion order IS the eviction order, and it is SUBSCRIPTION recency, not
@@ -211,6 +274,7 @@ export class SessionSubscriptions {
211
274
  this.#limit = Math.max(1, Math.floor(positive(opts.subscriptionsLimit, DEFAULT_SUBSCRIPTIONS_LIMIT)))
212
275
  this.#perSessionCacheBytes = positive(opts.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES)
213
276
  this.#checkpointMs = positive(opts.checkpointMs, DEFAULT_CHECKPOINT_MS)
277
+ this.#unackedSource = opts.unackedBytes
214
278
  }
215
279
 
216
280
  /**
@@ -316,6 +380,7 @@ export class SessionSubscriptions {
316
380
  // session that will not stay subscribed.
317
381
  console.info(`[cherry-remote] re-subscribing session ${sessionId} (replacing its live subscription)`)
318
382
  await this.#closeSubscription(existing.subscriptionId)
383
+ this.#cancelAckRetry(existing)
319
384
  this.#rejectWaiters(existing, new Error(`session ${sessionId} re-subscribed`))
320
385
  this.#sessions.delete(sessionId)
321
386
  }
@@ -424,13 +489,24 @@ export class SessionSubscriptions {
424
489
  /**
425
490
  * Advances the peer's view of this session: `agent.subscriptions.ack` with
426
491
  * the delivered cursor. The peer only accepts a cursor inside the window it
427
- * actually delivered (an out-of-window ack is its CONFLICT), so this is
428
- * called with the cursor a consumer has durably processed.
492
+ * actually delivered (an out-of-window ack is its CONFLICT).
493
+ *
494
+ * Two callers, one contract — "this position is accounted for, keep sending":
495
+ * `cherry_events` passes the cursor a CONSUMER has processed, and
496
+ * {@link #maybeAutoAck} passes the BUFFER frontier because the events are
497
+ * retained here (see that method for why an ack cannot lose replay).
429
498
  */
430
499
  async ack(sessionId: string, cursor?: AgentCursor): Promise<void> {
431
500
  const state = this.#sessions.get(sessionId)
432
501
  if (state === undefined) return
433
- const applied = cursor ?? state.cursor
502
+ // COALESCED TO THE FRONTIER. The peer keeps ONE `lastAck` per subscription
503
+ // and refuses a lower position, so an explicit ack carrying what a consumer
504
+ // has processed would lose a race against the layer's own ack and be
505
+ // answered CONFLICT — which the caller treats as a broken subscription.
506
+ // The buffer already holds everything up to `state.cursor`, so acking the
507
+ // frontier is both safe (an ack only frees flow control) and monotone.
508
+ const requested = cursor === undefined ? state.cursor : cursor
509
+ const applied = Number(requested.seq) > Number(state.cursor.seq) ? requested : state.cursor
434
510
  await this.#rpc.request('agent.subscriptions.ack', {
435
511
  subscriptionId: state.subscriptionId,
436
512
  cursor: applied,
@@ -484,6 +560,7 @@ export class SessionSubscriptions {
484
560
  this.#closed = true
485
561
  const ids = [...this.#sessions.values()].map((state) => state.subscriptionId)
486
562
  for (const state of this.#sessions.values()) {
563
+ this.#cancelAckRetry(state)
487
564
  const error = new Error('SessionSubscriptions disposed')
488
565
  for (const waiter of state.waiters.splice(0)) waiter.reject(error)
489
566
  }
@@ -590,7 +667,10 @@ export class SessionSubscriptions {
590
667
  */
591
668
  #installReplay(sessionId: string, descriptor: Extract<SubscribeDescriptor, { mode: 'replay' }>): void {
592
669
  const previous = this.#sessions.get(sessionId)
593
- if (previous !== undefined) this.#rejectWaiters(previous, new Error(`session ${sessionId} re-subscribed`))
670
+ if (previous !== undefined) {
671
+ this.#cancelAckRetry(previous)
672
+ this.#rejectWaiters(previous, new Error(`session ${sessionId} re-subscribed`))
673
+ }
594
674
  this.#sessions.delete(sessionId)
595
675
  // Re-inserting moves the session to the MRU end of the Map's order.
596
676
  this.#sessions.set(sessionId, {
@@ -598,6 +678,11 @@ export class SessionSubscriptions {
598
678
  cursor: descriptor.highWatermark,
599
679
  buffer: [],
600
680
  bytes: 0,
681
+ bytesSinceAck: 0,
682
+ ackInFlight: false,
683
+ ackRetryAt: 0,
684
+ ackRetryDelay: AUTO_ACK_RETRY_MS,
685
+ ackRetryTimer: undefined,
601
686
  windowStart: Number(descriptor.highWatermark.seq),
602
687
  // A fresh window: nothing has been dropped from it, so the baseline is a
603
688
  // position this layer may serve (issue #2).
@@ -723,13 +808,21 @@ export class SessionSubscriptions {
723
808
  items: Array<{ kind: string; value: unknown }>,
724
809
  ): void {
725
810
  const previous = this.#sessions.get(sessionId)
726
- if (previous !== undefined) this.#rejectWaiters(previous, new Error(`session ${sessionId} recovered`))
811
+ if (previous !== undefined) {
812
+ this.#cancelAckRetry(previous)
813
+ this.#rejectWaiters(previous, new Error(`session ${sessionId} recovered`))
814
+ }
727
815
  this.#sessions.delete(sessionId)
728
816
  this.#sessions.set(sessionId, {
729
817
  subscriptionId: descriptor.subscriptionId,
730
818
  cursor: descriptor.checkpoint.cursor,
731
819
  buffer: [],
732
820
  bytes: 0,
821
+ bytesSinceAck: 0,
822
+ ackInFlight: false,
823
+ ackRetryAt: 0,
824
+ ackRetryDelay: AUTO_ACK_RETRY_MS,
825
+ ackRetryTimer: undefined,
733
826
  windowStart: Number(descriptor.checkpoint.cursor.seq),
734
827
  // A fresh window: nothing has been dropped from it, so the baseline is a
735
828
  // position this layer may serve (issue #2).
@@ -801,6 +894,7 @@ export class SessionSubscriptions {
801
894
  `${sessionId}`,
802
895
  )
803
896
  // The entry is removed outright, so no `live` flag needs clearing.
897
+ this.#cancelAckRetry(state)
804
898
  this.#rejectWaiters(state, new Error(`session ${sessionId} evicted by LRU`))
805
899
  this.#sessions.delete(sessionId)
806
900
  // AWAITED: the peer must observe the close before the next subscribe.
@@ -907,6 +1001,7 @@ export class SessionSubscriptions {
907
1001
  const bytes = eventBytes(event)
908
1002
  state.buffer.push({ cursor, event, bytes })
909
1003
  state.bytes += bytes
1004
+ state.bytesSinceAck += bytes
910
1005
  state.cursor = cursor
911
1006
  appended += 1
912
1007
  }
@@ -915,6 +1010,106 @@ export class SessionSubscriptions {
915
1010
  // Waking on a wholly stale batch would make a parked waiter re-judge state
916
1011
  // that has not moved; only real progress is a wake.
917
1012
  if (appended > 0) this.#wakeWaiters(state)
1013
+ // The wake comes FIRST: a waiter that can settle from what just arrived
1014
+ // must not wait behind an ack round trip.
1015
+ if (appended > 0) this.#maybeAutoAck(sessionId, state)
1016
+ }
1017
+
1018
+ /**
1019
+ * Sends `agent.subscriptions.ack` once the un-acked bytes approach the peer's
1020
+ * window, so the peer keeps delivering (issue #212).
1021
+ *
1022
+ * WHAT IS BEING ACKED is "everything in this layer's buffer", not "everything
1023
+ * a caller has consumed". That is safe against the pinned peer because an ack
1024
+ * does exactly one thing: it frees FLOW CONTROL — the desktop decrements its
1025
+ * un-acked budget and advances `lastAck` (`agentSubscriptions.ts`). Replay
1026
+ * retention lives on the journal (`replayBytes`/`replayMs`) and is not
1027
+ * touched by an ack, so a `read`/`cherry_events`/re-subscribe from a cursor
1028
+ * below the acked position is still served — by the local buffer here and by
1029
+ * the journal there. The ack's own doc comment states the same contract.
1030
+ *
1031
+ * The ack is fire-and-forget with its failure SURFACED (a refused ack must be
1032
+ * visible, not swallowed). A refusal also ARMS A TIMER: once the peer is
1033
+ * holding at its window no further event arrives, so a retry that waited for
1034
+ * traffic would never fire and the wait would time out exactly as in issue
1035
+ * #212.
1036
+ */
1037
+ #maybeAutoAck(sessionId: string, state: SessionState): void {
1038
+ if (state.bytesSinceAck < this.#ackWatermark()) return
1039
+ if (state.ackInFlight) return
1040
+ if (Date.now() < state.ackRetryAt) return
1041
+ state.ackInFlight = true
1042
+ // The bytes covered by THIS ack. Events that arrive while it is in flight
1043
+ // stay counted, so the next window starts from the right place.
1044
+ const covered = state.bytesSinceAck
1045
+ void this.ack(sessionId)
1046
+ .then(() => {
1047
+ state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered)
1048
+ // A peer that answers ack again gets prompt retries next time.
1049
+ state.ackRetryDelay = AUTO_ACK_RETRY_MS
1050
+ })
1051
+ .catch((error: unknown) => {
1052
+ // THIS attempt's delay, then the next one doubles: a peer that refuses
1053
+ // every ack (a wedged controller, a link about to drop) must not log a
1054
+ // line every few seconds for the life of the layer.
1055
+ const delay = state.ackRetryDelay
1056
+ state.ackRetryAt = Date.now() + delay
1057
+ state.ackRetryDelay = Math.min(delay * 2, AUTO_ACK_RETRY_MAX_MS)
1058
+ console.warn(`[cherry-remote] auto-ack for session ${sessionId} failed: ${failureText(error)}`)
1059
+ // ARMED HERE, not on the next batch: a peer holding at its window sends
1060
+ // nothing more, so traffic-driven retry is no retry at all.
1061
+ this.#armAckRetry(sessionId, state, delay)
1062
+ })
1063
+ .finally(() => {
1064
+ state.ackInFlight = false
1065
+ // RE-JUDGE: batches can arrive WHILE the ack was in flight (the peer
1066
+ // flushes on the ack, so this is the common case), and those batches
1067
+ // were refused a trigger by `ackInFlight`. Without this second look the
1068
+ // layer could sit with bytes over the watermark and no event left to
1069
+ // trigger it — the peer holding, exactly as in issue #212.
1070
+ if (this.#closed || this.#sessions.get(sessionId) !== state) return
1071
+ this.#maybeAutoAck(sessionId, state)
1072
+ })
1073
+ }
1074
+
1075
+ /**
1076
+ * Quarter of the peer's window, read from the CURRENT source so a layer that
1077
+ * outlives a reconnect follows the new hello instead of its first answer.
1078
+ * Falls back to the pinned protocol constant when nothing is advertised.
1079
+ */
1080
+ #ackWatermark(): number {
1081
+ const source = this.#unackedSource
1082
+ const advertised = typeof source === 'function' ? source() : source
1083
+ return Math.max(1, Math.floor(positive(advertised, remoteLimits.unackedBytes) / 4))
1084
+ }
1085
+
1086
+ /** Disarms the retry, if any (idempotent; safe on a state being replaced). */
1087
+ #cancelAckRetry(state: SessionState): void {
1088
+ if (state.ackRetryTimer === undefined) return
1089
+ clearTimeout(state.ackRetryTimer)
1090
+ state.ackRetryTimer = undefined
1091
+ }
1092
+
1093
+ /**
1094
+ * One timer-driven retry after a refused ack. At most one is armed per
1095
+ * session; it no-ops when the state it was armed for is no longer current
1096
+ * (a re-subscribe, a checkpoint recovery or `dispose`).
1097
+ */
1098
+ #armAckRetry(sessionId: string, state: SessionState, delayMs: number): void {
1099
+ if (this.#closed) return
1100
+ if (state.ackRetryTimer !== undefined) return
1101
+ const timer = setTimeout(() => {
1102
+ state.ackRetryTimer = undefined
1103
+ if (this.#closed || this.#sessions.get(sessionId) !== state) return
1104
+ // CLEARED HERE, not left to the wall clock: a clock stepped backwards
1105
+ // would otherwise make `#maybeAutoAck` return early and arm nothing — a
1106
+ // retry silently lost while the peer holds, which is #212 again.
1107
+ state.ackRetryAt = 0
1108
+ this.#maybeAutoAck(sessionId, state)
1109
+ }, delayMs)
1110
+ // Never hold the process open on a retry that is only about flow control.
1111
+ timer.unref?.()
1112
+ state.ackRetryTimer = timer
918
1113
  }
919
1114
 
920
1115
  /**
@@ -937,6 +1132,7 @@ export class SessionSubscriptions {
937
1132
  `[cherry-remote] subscription for session ${sessionId} reset by the peer ` +
938
1133
  `(${String(params['reason'] ?? 'unspecified')}); re-subscribe required`,
939
1134
  )
1135
+ this.#cancelAckRetry(state)
940
1136
  this.#rejectWaiters(state, new Error(`session ${sessionId} reset by the peer`))
941
1137
  this.#sessions.delete(sessionId)
942
1138
  return
package/src/tools.ts CHANGED
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'
2
2
  import { DeviceUnavailableError, RpcDeadlineExceededError } from './connection.js'
3
3
  import type { DeviceConnection, DeviceState } from './connection.js'
4
4
  import type { DeviceRecord, DeviceResolution, DeviceStore } from './deviceStore.js'
5
- import { QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
5
+ import { LastCandidateHostError, QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
6
6
  import {
7
7
  DEVICE_SETTLE_WAIT_MS,
8
8
  INTERACTIONS_LIST_PAGE_CLAMP,
@@ -111,6 +111,13 @@ export interface CherryDeviceView {
111
111
  enabled: boolean
112
112
  hosts: string[]
113
113
  port: number
114
+ /**
115
+ * The addresses the operator has FORGOTTEN (issue #185): not dialed, and not
116
+ * re-learned from the desktop's advertisements until they are remembered.
117
+ * Always present on a view (empty when nothing was ever forgotten) so a
118
+ * caller never has to distinguish "absent" from "none".
119
+ */
120
+ forgottenHosts: string[]
114
121
  }
115
122
 
116
123
  /** The shared `{device, state}` verdict (spec §6: 设备离线). */
@@ -1216,8 +1223,11 @@ export interface CherryToolsDeps {
1216
1223
  * `DeviceConnection` captures its record at construction, so a pooled
1217
1224
  * instance can never observe an edited address or a flipped `enabled` flag.
1218
1225
  *
1219
- * `cherry_unpair` is the other caller: it evicts (drop + close) and then
1220
- * removes the archive entry, so no live socket outlives its record.
1226
+ * `cherry_unpair` evicts (drop + close) and then removes the archive entry,
1227
+ * so no live socket outlives its record, and `cherry_devices`' `forget` /
1228
+ * `remember` actions evict ON A CHANGE for the same reason: a pooled
1229
+ * connection captured the pre-write record, so a forget that only rewrote the
1230
+ * archive would keep dialing the address (and locally re-learning it).
1221
1231
  */
1222
1232
  evict(deviceId: string): void | Promise<void>
1223
1233
  /**
@@ -1642,6 +1652,59 @@ function optionalString(value: unknown): string | undefined {
1642
1652
  return typeof value === 'string' && value !== '' ? value : undefined
1643
1653
  }
1644
1654
 
1655
+ /**
1656
+ * The `hosts` argument of `cherry_devices`' write actions (issue #185): a
1657
+ * non-empty list of non-empty host names, with no case-variant duplicate.
1658
+ *
1659
+ * Each rule is a state the store would have to interpret: an empty name can
1660
+ * never match a dialed host, and two spellings of one host would make "is this
1661
+ * address forgotten?" depend on which entry a comparison hit first. Values are
1662
+ * TRIMMED here (the model-facing surface is where a stray space is caught);
1663
+ * the archive then stores the spelling it already had for a dialed host.
1664
+ */
1665
+ function hostListArgument(value: unknown): string[] | Record<string, unknown> {
1666
+ if (!Array.isArray(value) || value.length === 0) {
1667
+ return { error: 'invalid-hosts', message: '`hosts` must be a non-empty array of host names' }
1668
+ }
1669
+ const hosts: string[] = []
1670
+ const seen = new Set<string>()
1671
+ for (const entry of value) {
1672
+ if (typeof entry !== 'string' || entry.trim() === '') {
1673
+ return { error: 'invalid-hosts', message: '`hosts` entries must be non-empty strings' }
1674
+ }
1675
+ let host = entry.trim()
1676
+ // The archive stores BARE hosts, and the PATCH path unwraps a bracketed
1677
+ // IPv6 literal (`normalizeHost`). This face must agree: a mark for
1678
+ // `[fd00::1]` would never match the `fd00::1` a dial or an advertisement
1679
+ // carries, so it would be a mark with no effect. The ceiling mirrors the
1680
+ // PATCH path's `HOST_MAX`.
1681
+ if (host.startsWith('[') && host.endsWith(']')) {
1682
+ try {
1683
+ new URL(`http://${host}/`)
1684
+ } catch {
1685
+ return {
1686
+ error: 'invalid-hosts',
1687
+ message: `\`hosts\` entry "${host}" is not a valid IPv6 literal`,
1688
+ }
1689
+ }
1690
+ host = host.slice(1, -1)
1691
+ }
1692
+ if (host.length > 256) {
1693
+ return { error: 'invalid-hosts', message: '`hosts` entry exceeds 256 characters' }
1694
+ }
1695
+ const key = host.toLowerCase()
1696
+ if (seen.has(key)) {
1697
+ return {
1698
+ error: 'invalid-hosts',
1699
+ message: `\`hosts\` names "${host}" more than once (host names are case-insensitive)`,
1700
+ }
1701
+ }
1702
+ seen.add(key)
1703
+ hosts.push(host)
1704
+ }
1705
+ return hosts
1706
+ }
1707
+
1645
1708
  /**
1646
1709
  * The peer's `agent.messages.send` refusal for a STALE `expectedIdleRevision`,
1647
1710
  * in the exact shape Cherry Studio answers it: a `rejected` receipt whose
@@ -2729,25 +2792,100 @@ export class CherryToolEngine {
2729
2792
  description:
2730
2793
  'List every paired Cherry Studio desktop with its connection state ' +
2731
2794
  '(connected/connecting/offline/disabled/identity-mismatch). An optional `device` ' +
2732
- 'narrows the list to one device (display name prefix or deviceId).',
2795
+ 'narrows the list to one device (display name prefix or deviceId). ' +
2796
+ '`action: "forget"` removes the named `hosts` from that device\'s dial candidates AND ' +
2797
+ 'stops the desktop\'s advertisements from adding them back; `action: "remember"` lifts ' +
2798
+ 'that mark, so the next handshake may learn them again. Both write actions require ' +
2799
+ '`device` and `hosts`.',
2733
2800
  parameters: {
2734
2801
  type: 'object',
2735
2802
  properties: {
2736
2803
  device: { type: 'string', description: 'Optional device selector: exact deviceId, or a unique prefix of a deviceId or display name.' },
2804
+ action: {
2805
+ type: 'string',
2806
+ enum: ['list', 'forget', 'remember'],
2807
+ description:
2808
+ 'What to do: `list` (the default) reads the devices; `forget`/`remember` edit ONE ' +
2809
+ "device's forgotten addresses and require `device` and `hosts`.",
2810
+ },
2811
+ hosts: {
2812
+ type: 'array',
2813
+ items: { type: 'string' },
2814
+ description:
2815
+ 'For `forget`/`remember`: the host names to act on, matched case-insensitively. ' +
2816
+ '`forget` refuses to remove the last remaining address.',
2817
+ },
2737
2818
  },
2738
2819
  additionalProperties: false,
2739
2820
  },
2740
2821
  execute: async (args) => {
2822
+ const action = optionalString(args['action']) ?? 'list'
2823
+ if (action !== 'list' && action !== 'forget' && action !== 'remember') {
2824
+ return {
2825
+ error: 'invalid-action',
2826
+ message: '`action` accepts exactly "list", "forget" or "remember"',
2827
+ } satisfies Record<string, unknown>
2828
+ }
2741
2829
  const selector = optionalString(args['device'])
2742
- const quarantined = this.#quarantineVerdict()
2743
- if (quarantined !== undefined) return quarantined
2830
+ if (action === 'list') {
2831
+ const quarantined = this.#quarantineVerdict()
2832
+ if (quarantined !== undefined) return quarantined
2833
+ if (selector === undefined) {
2834
+ const records = this.#deps.store.list()
2835
+ return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) }
2836
+ }
2837
+ const selected = this.#selectRecords(selector)
2838
+ if (!Array.isArray(selected)) return selected
2839
+ return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) }
2840
+ }
2744
2841
  if (selector === undefined) {
2745
- const records = this.#deps.store.list()
2746
- return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) }
2842
+ return {
2843
+ error: 'device-required',
2844
+ message:
2845
+ '`action` "forget"/"remember" needs `device`: a selector that names exactly one device',
2846
+ } satisfies Record<string, unknown>
2747
2847
  }
2848
+ const hosts = hostListArgument(args['hosts'])
2849
+ if (!Array.isArray(hosts)) return hosts
2850
+ const quarantined = this.#quarantineVerdict()
2851
+ if (quarantined !== undefined) return quarantined
2748
2852
  const selected = this.#selectRecords(selector)
2749
2853
  if (!Array.isArray(selected)) return selected
2750
- return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) }
2854
+ const record = selected[0]!
2855
+ let removed: string[] = []
2856
+ let marked: string[] = []
2857
+ let restored: string[] = []
2858
+ try {
2859
+ if (action === 'forget') {
2860
+ ;({ removed, marked } = this.#deps.store.forgetEndpoints(record.deviceId, hosts))
2861
+ } else {
2862
+ ;({ restored } = this.#deps.store.rememberEndpoints(record.deviceId, hosts))
2863
+ }
2864
+ } catch (error) {
2865
+ if (error instanceof LastCandidateHostError) {
2866
+ return {
2867
+ error: 'last-address',
2868
+ device: record.deviceId,
2869
+ message: error.message,
2870
+ } satisfies Record<string, unknown>
2871
+ }
2872
+ throw error
2873
+ }
2874
+ const changed = removed.length > 0 || marked.length > 0 || restored.length > 0
2875
+ // ADDRESS MAINTENANCE reaches the LIVE connection, exactly like the HTTP
2876
+ // PATCH's eviction: a pooled `DeviceConnection` captures its record at
2877
+ // construction, so an instance built before this write would keep
2878
+ // dialing the forgotten host and locally re-adding it on every
2879
+ // reconnect. Evicting drops that socket and makes the next `connect()`
2880
+ // rebuild from the archive we just wrote.
2881
+ if (changed) await this.#deps.evict(record.deviceId)
2882
+ // The resolved record is STALE (hosts/forgottenHosts moved), so the
2883
+ // view is built from the archive's current row, never from the object
2884
+ // the selector handed us.
2885
+ const fresh = this.#deps.store.list().find((entry) => entry.deviceId === record.deviceId)
2886
+ if (fresh === undefined) return this.#notFoundVerdict(selector)
2887
+ const device = await this.#deviceView(fresh)
2888
+ return action === 'forget' ? { device, removed, marked } : { device, restored }
2751
2889
  },
2752
2890
  })
2753
2891
  }
@@ -2814,6 +2952,7 @@ export class CherryToolEngine {
2814
2952
  enabled: record.enabled,
2815
2953
  hosts: record.hosts,
2816
2954
  port: record.port,
2955
+ forgottenHosts: record.forgottenHosts ?? [],
2817
2956
  }
2818
2957
  }
2819
2958
 
@@ -3938,9 +4077,13 @@ export class CherryToolEngine {
3938
4077
  } satisfies CherryCursorOutOfWindow
3939
4078
  }
3940
4079
 
3941
- // ACK AFTER DELIVERY (controller ruling ④): the peer may only advance
3942
- // its view once the caller actually holds these events. A failing ack
3943
- // is logged, never allowed to swallow the delivered events.
4080
+ // ACK AFTER DELIVERY (controller ruling ④): this EXPLICIT ack reports
4081
+ // the position the caller has just been handed. The layer ALSO acks its
4082
+ // own buffer on a byte watermark (issue #212, subscriptions
4083
+ // `#maybeAutoAck`), because the peer holds the stream past its un-acked
4084
+ // window. The peer keeps ONE `lastAck` and refuses a lower position, so
4085
+ // both sites coalesce to the buffer frontier — see `ack()`.
4086
+ // A failing ack is logged, never allowed to swallow the delivered events.
3944
4087
  // A FAILING ACK ALSO RE-ENSURES THE SESSION (v0.1.3 UltraReview P3-3).
3945
4088
  // The ack is the peer's verdict on the SUBSCRIPTION, not on the
3946
4089
  // delivery: a refused ack says the cursor is outside the window this
@@ -5902,8 +6045,9 @@ export class CherryToolEngine {
5902
6045
  * dimension and deliberately does NOT settle a wait (Task 13 asserts the two
5903
6046
  * layers separately).
5904
6047
  *
5905
- * Waiters never ack: they consume the projection and leave the peer's view
5906
- * untouched.
6048
+ * Waiters do not ack what they fold. The subscription LAYER acks its own
6049
+ * buffer on a byte watermark (issue #212) so the peer keeps delivering past
6050
+ * its un-acked window; that ack is about flow control, not consumption.
5907
6051
  *
5908
6052
  * A wait can also end because the LINK went away — the plugin was torn down,
5909
6053
  * the device was unpaired, or the connection closed. That is not a timeout and
package/ui/AGENTS.md CHANGED
@@ -18,6 +18,27 @@ with its source. Loaded automatically when a file tool touches `ui/**`.
18
18
  - **HTTP lives in `api.ts`** (same-origin `/cherry-remote/api`); a component
19
19
  never fetches directly. Both seams are injected for tests
20
20
  (`eventSourceFactory`, `scopeClient`), so no test stands up a server.
21
+ - **A run always ends, and the deadline is not a verdict.** The server publishes
22
+ a terminal stage on every outcome, but the stream has no replay, so one lost
23
+ frame would otherwise park the wizard on 「配对进行中」 with the submit button
24
+ disabled and no cancel. The run deadline (`runLifetimeMs`, same injection
25
+ style as `codeLifetimeMs`) restores the form and re-reads the device list;
26
+ its copy points at that list instead of claiming failure, and it keeps
27
+ `claimId` so a terminal frame that arrives late still wins. Arm it on the
28
+ `waiting` phase only — never at mount.
29
+ - **The bundle carries the SOURCE COMMENTS.** `ui/vite.config.ts` sets
30
+ `minify: false`, so the docblocks that hold most of this page's reasoning ship
31
+ inside `lib/client.js`: a comment-only edit in `ui/src/**` changes the
32
+ artifact's bytes and needs the rebuild in the same commit. Two follow-ons from
33
+ that: `artifactFreshness` compares against `git show HEAD:<path>`, so it only
34
+ goes green once the rebuild is COMMITTED — running the full suite before
35
+ committing reports it red for the same reason its sibling case ("neither check
36
+ mutates the committed artifacts") is red. Neither red is a real failure.
37
+ - **A per-host action is INERT while a whole-list editor is open.** The address
38
+ editor's draft is sacred (the card does not reseed it while an editor is
39
+ open), so a 忘记/恢复 chosen under it would be re-sent by the next 保存地址 — and
40
+ that PATCH clears the mark for any host it names. Hence
41
+ `disabled={busy || editor === 'address'}` on both controls, pinned by `#204m`.
21
42
  - **jsdom only.** `tests/unit/ui.test.ts` and `uiScopePanel.test.ts` render the
22
43
  page; a browser-only API needs a fake in the test, not a guard in the page.
23
44