dsh-cherry-bridge 0.1.7 → 0.1.9

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
@@ -2274,6 +2274,11 @@ const INTERACTIONS_LIST_PAGE_CLAMP = Math.floor(RECORD_BUDGET_BYTES / 25088);
2274
2274
  * BOUND instead of being split, hashed and carried to the peer, and so both
2275
2275
  * parameters can declare `maxLength` honestly. ONE number for both faces: it
2276
2276
  * bounds caller INPUT, not what a token means.
2277
+ *
2278
+ * The emit side enforces it too (#76): a stop point whose token would exceed
2279
+ * this is not named at all, because a token this tool refuses to replay is
2280
+ * worse than no token — `truncated` still says the walk was cut. One number,
2281
+ * judged in both directions.
2277
2282
  */
2278
2283
  const MAX_RESUME_TOKEN_CHARS = 4096;
2279
2284
  //#endregion
@@ -2690,6 +2695,13 @@ var DeviceConnection = class {
2690
2695
  #transportRetryWaitMs;
2691
2696
  #stateListeners = /* @__PURE__ */ new Set();
2692
2697
  /**
2698
+ * Callers parked in {@link waitForSettled}. Separate from the state listeners
2699
+ * because their lifetime is ONE wait: a listener stays for the lifecycle, a
2700
+ * settle waiter removes itself the moment it is answered (or times out), and
2701
+ * mixing them would leave every answered wait in the multicast set.
2702
+ */
2703
+ #settleWaiters = /* @__PURE__ */ new Set();
2704
+ /**
2693
2705
  * Waits parked inside `#awaitReconnect`. close() drains them: a shutdown has
2694
2706
  * no reconnect to wait for, and — the case that matters — a close() followed
2695
2707
  * by a fresh connect() must not let an OLD lifecycle's replay ride the new
@@ -2814,6 +2826,46 @@ var DeviceConnection = class {
2814
2826
  this.#stateListeners.add(cb);
2815
2827
  }
2816
2828
  /**
2829
+ * Waits for an attempt that is ALREADY IN FLIGHT to settle, bounded by
2830
+ * `timeoutMs`, and answers with the state it ends on.
2831
+ *
2832
+ * The only state this waits on is `connecting`: an attempt is running, and
2833
+ * its outcome is what the caller asked about. `connected` is answered at
2834
+ * once, and so are `offline` (parked in backoff), `disabled` and
2835
+ * `identity-mismatch` — no wait can change those, and waiting on them would
2836
+ * merely delay the caller's answer by the whole bound. `offline` is the
2837
+ * common case for a genuinely powered-off desktop, so it is the one that
2838
+ * matters: the tools stay fail-fast there.
2839
+ *
2840
+ * This NEVER starts an attempt — it observes one the pool already started.
2841
+ * That is the line the tool face has always drawn (a tool must not dial); the
2842
+ * wait only stops the tool from calling a live dial a failure.
2843
+ *
2844
+ * The bound expiring is not an error: the current state (`connecting`) is
2845
+ * returned, and the caller judges it exactly as it would have without the
2846
+ * wait.
2847
+ */
2848
+ async waitForSettled(timeoutMs) {
2849
+ if (this.#state !== "connecting" || timeoutMs <= 0) return this.#state;
2850
+ return new Promise((resolve) => {
2851
+ let settled = false;
2852
+ let timer;
2853
+ const finish = (state) => {
2854
+ if (settled) return;
2855
+ settled = true;
2856
+ if (timer !== void 0) clearTimeout(timer);
2857
+ this.#settleWaiters.delete(onState);
2858
+ resolve(state);
2859
+ };
2860
+ const onState = (state) => {
2861
+ if (state !== "connecting") finish(state);
2862
+ };
2863
+ timer = setTimeout(() => finish(this.#state), timeoutMs);
2864
+ this.#settleWaiters.add(onState);
2865
+ if (this.#state !== "connecting") finish(this.#state);
2866
+ });
2867
+ }
2868
+ /**
2817
2869
  * Starts (or joins) the supervised lifecycle. Resolves once this cycle
2818
2870
  * reaches `connected`; rejects with the cycle's failure. Transient
2819
2871
  * failures keep being retried in the background by the backoff loop even
@@ -3303,6 +3355,11 @@ var DeviceConnection = class {
3303
3355
  } catch (error) {
3304
3356
  console.warn(`[cherry-remote] state listener threw for ${this.record.deviceId}`, error);
3305
3357
  }
3358
+ for (const cb of [...this.#settleWaiters]) try {
3359
+ cb(next);
3360
+ } catch (error) {
3361
+ console.warn(`[cherry-remote] settle waiter threw for ${this.record.deviceId}`, error);
3362
+ }
3306
3363
  }
3307
3364
  };
3308
3365
  //#endregion
@@ -5195,8 +5252,12 @@ var CherryToolEngine = class {
5195
5252
  * ⑤): the candidate list is the answer. A MISS is a verdict too — see
5196
5253
  * {@link CherryDeviceNotFound} — and it carries the ids the caller could have
5197
5254
  * meant so the answer stays actionable.
5255
+ *
5256
+ * ASYNC because of the `connecting` wait below: the pool dials on first touch
5257
+ * and on every reconnect, so a tool called in that window used to be refused
5258
+ * for a connection that comes up a moment later (issue #99).
5198
5259
  */
5199
- #resolve(device) {
5260
+ async #resolve(device) {
5200
5261
  const resolution = this.#deps.store.resolve(device);
5201
5262
  if ("ambiguous" in resolution) return {
5202
5263
  error: "ambiguous-device",
@@ -5206,10 +5267,11 @@ var CherryToolEngine = class {
5206
5267
  if ("notFound" in resolution) return this.#notFoundVerdict(device);
5207
5268
  const connection = this.#deps.connect(resolution.deviceId);
5208
5269
  this.#watchConnection(resolution.deviceId, connection);
5209
- if (connection.state !== "connected") return {
5270
+ const state = connection.state === "connecting" ? await connection.waitForSettled(this.#deps.deviceSettleWaitMs ?? 5e3) : connection.state;
5271
+ if (state !== "connected") return {
5210
5272
  error: "device-unavailable",
5211
5273
  device: resolution.deviceId,
5212
- state: connection.state
5274
+ state
5213
5275
  };
5214
5276
  return {
5215
5277
  record: resolution,
@@ -5245,6 +5307,10 @@ var CherryToolEngine = class {
5245
5307
  /**
5246
5308
  * Resolve-or-verdict: every non-`cherry_devices` tool needs a live link.
5247
5309
  *
5310
+ * `connecting` is waited out inside {@link #resolve} first, so the
5311
+ * `DeviceUnavailableError` below is only ever raised for a state the wait
5312
+ * could not improve on (bounded by `deviceSettleWaitMs`).
5313
+ *
5248
5314
  * The failure faces are deliberately DIFFERENT (controller ruling ⑤): an
5249
5315
  * ambiguous selector and a MISS are ANSWERS — the candidate list, or the
5250
5316
  * known ids, returned in band, because the caller can act on them — while a
@@ -5257,8 +5323,8 @@ var CherryToolEngine = class {
5257
5323
  * `cherry_devices` does not come through this method — it goes through
5258
5324
  * {@link #selectRecords}, which returns the verdict in band.
5259
5325
  */
5260
- #requireConnection(device) {
5261
- const resolved = this.#resolve(device);
5326
+ async #requireConnection(device) {
5327
+ const resolved = await this.#resolve(device);
5262
5328
  if (!this.#isVerdict(resolved)) return resolved;
5263
5329
  if (resolved.error === "ambiguous-device") return resolved;
5264
5330
  if (resolved.error === "not-found") {
@@ -5277,10 +5343,10 @@ var CherryToolEngine = class {
5277
5343
  * replace it with a weaker, wrong one — a miss under a READ-ONLY archive
5278
5344
  * would describe a store that still holds a valid token as "no such device".
5279
5345
  */
5280
- #linkOr(device) {
5346
+ async #linkOr(device) {
5281
5347
  const quarantined = this.#quarantineVerdict();
5282
5348
  if (quarantined !== void 0) return { verdict: quarantined };
5283
- const resolved = this.#requireConnection(device);
5349
+ const resolved = await this.#requireConnection(device);
5284
5350
  if ("error" in resolved) return { verdict: resolved };
5285
5351
  return { link: resolved };
5286
5352
  }
@@ -5477,12 +5543,13 @@ var CherryToolEngine = class {
5477
5543
  * #65). What a caller can act on is a token, so that is what is emitted.
5478
5544
  */
5479
5545
  #listPage(page, binding) {
5480
- const position = optionalString(page.nextCursor);
5546
+ const position = nameablePeerPosition(optionalString(page.nextCursor));
5547
+ const token = !page.truncated || position === void 0 ? void 0 : withinResumeTokenCeiling(encodeListResumeCursor(binding, position));
5481
5548
  return {
5482
5549
  items: page.items,
5483
5550
  truncated: page.truncated,
5484
5551
  ...page.truncationReason === void 0 ? {} : { truncationReason: page.truncationReason },
5485
- ...page.truncated && position !== void 0 && !mangledRemainder(position) ? { resumeCursor: encodeListResumeCursor(binding, position) } : {}
5552
+ ...token === void 0 ? {} : { resumeCursor: token }
5486
5553
  };
5487
5554
  }
5488
5555
  #devicesTool() {
@@ -5501,10 +5568,13 @@ var CherryToolEngine = class {
5501
5568
  const selector = optionalString(args["device"]);
5502
5569
  const quarantined = this.#quarantineVerdict();
5503
5570
  if (quarantined !== void 0) return quarantined;
5504
- if (selector === void 0) return { devices: this.#deps.store.list().map((record) => this.#deviceView(record)) };
5571
+ if (selector === void 0) {
5572
+ const records = this.#deps.store.list();
5573
+ return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) };
5574
+ }
5505
5575
  const selected = this.#selectRecords(selector);
5506
5576
  if (!Array.isArray(selected)) return selected;
5507
- return { devices: selected.map((record) => this.#deviceView(record)) };
5577
+ return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) };
5508
5578
  }
5509
5579
  });
5510
5580
  }
@@ -5534,7 +5604,9 @@ var CherryToolEngine = class {
5534
5604
  return [resolution];
5535
5605
  }
5536
5606
  /**
5537
- * One device's view WITHOUT dialing.
5607
+ * One device's view WITHOUT dialing — but after waiting out an attempt that is
5608
+ * already in flight, exactly as {@link #resolve} does, so a cold-start row
5609
+ * does not present a transient `connecting` as the device's answer.
5538
5610
  *
5539
5611
  * A disabled record is authoritative: the pooled connection is constructed
5540
5612
  * with its own `record.enabled` snapshot and reports `disabled` only once
@@ -5542,10 +5614,11 @@ var CherryToolEngine = class {
5542
5614
  * for a device whose connection was never started. Everything else falls back
5543
5615
  * to the connection's own CLOSED state set.
5544
5616
  */
5545
- #deviceView(record) {
5617
+ async #deviceView(record) {
5546
5618
  let state = record.enabled ? "offline" : "disabled";
5547
5619
  if (record.enabled) try {
5548
- state = this.#deps.connect(record.deviceId).state;
5620
+ const connection = this.#deps.connect(record.deviceId);
5621
+ state = connection.state === "connecting" ? await connection.waitForSettled(this.#deps.deviceSettleWaitMs ?? 5e3) : connection.state;
5549
5622
  } catch (error) {
5550
5623
  console.warn(`[cherry-remote] cherry_devices could not read the connection for ${record.deviceId}`, error);
5551
5624
  }
@@ -5561,7 +5634,7 @@ var CherryToolEngine = class {
5561
5634
  #agentsTool() {
5562
5635
  return defineCherryTool({
5563
5636
  name: "cherry_agents",
5564
- description: "List the Cherry Studio agents available on a device. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). A truncated answer also carries `resumeCursor`: replay it as `cursor` with the same tool, device and filters to continue from that stop point (`limit` then bounds the next answer).",
5637
+ description: "List the Cherry Studio agents available on a device. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same tool, device and filters to continue from that stop point (`limit` then bounds the next answer).",
5565
5638
  parameters: {
5566
5639
  type: "object",
5567
5640
  properties: {
@@ -5581,7 +5654,7 @@ var CherryToolEngine = class {
5581
5654
  additionalProperties: false
5582
5655
  },
5583
5656
  execute: async (args) => {
5584
- const resolved = this.#linkOr(requiredString(args, "device"));
5657
+ const resolved = await this.#linkOr(requiredString(args, "device"));
5585
5658
  if (resolved.link === void 0) return resolved.verdict;
5586
5659
  const { connection, record } = resolved.link;
5587
5660
  const binding = {
@@ -5604,7 +5677,7 @@ var CherryToolEngine = class {
5604
5677
  #workspacesTool() {
5605
5678
  return defineCherryTool({
5606
5679
  name: "cherry_workspaces",
5607
- description: "List an agent's REGISTERED workspaces, plus whether a system workspace exists. The system workspace has no enumerable id: it is not in `items`, and its identity is only observable by creating a session in it and reading it back. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out and carries `resumeCursor`: replay it as `cursor` with the same filters to continue.",
5680
+ description: "List an agent's REGISTERED workspaces, plus whether a system workspace exists. The system workspace has no enumerable id: it is not in `items`, and its identity is only observable by creating a session in it and reading it back. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out; when the stop point can be named it carries `resumeCursor` — replay it as `cursor` with the same filters to continue.",
5608
5681
  parameters: {
5609
5682
  type: "object",
5610
5683
  properties: {
@@ -5628,7 +5701,7 @@ var CherryToolEngine = class {
5628
5701
  additionalProperties: false
5629
5702
  },
5630
5703
  execute: async (args) => {
5631
- const resolved = this.#linkOr(requiredString(args, "device"));
5704
+ const resolved = await this.#linkOr(requiredString(args, "device"));
5632
5705
  if (resolved.link === void 0) return resolved.verdict;
5633
5706
  const { connection, record } = resolved.link;
5634
5707
  const params = { agentId: requiredString(args, "agentId") };
@@ -5659,7 +5732,7 @@ var CherryToolEngine = class {
5659
5732
  #sessionsTool() {
5660
5733
  return defineCherryTool({
5661
5734
  name: "cherry_sessions",
5662
- description: "List an agent's sessions, optionally filtered by workspace. The `agentId` and `workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). A truncated answer also carries `resumeCursor`: replay it as `cursor` with the same tool, device and filters to continue from that stop point (`limit` then bounds the next answer).",
5735
+ description: "List an agent's sessions, optionally filtered by workspace. The `agentId` and `workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same tool, device and filters to continue from that stop point (`limit` then bounds the next answer).",
5663
5736
  parameters: {
5664
5737
  type: "object",
5665
5738
  properties: {
@@ -5687,7 +5760,7 @@ var CherryToolEngine = class {
5687
5760
  additionalProperties: false
5688
5761
  },
5689
5762
  execute: async (args) => {
5690
- const resolved = this.#linkOr(requiredString(args, "device"));
5763
+ const resolved = await this.#linkOr(requiredString(args, "device"));
5691
5764
  if (resolved.link === void 0) return resolved.verdict;
5692
5765
  const { connection, record } = resolved.link;
5693
5766
  const agentId = optionalString(args["agentId"]);
@@ -5716,7 +5789,7 @@ var CherryToolEngine = class {
5716
5789
  #readTool() {
5717
5790
  return defineCherryTool({
5718
5791
  name: "cherry_read",
5719
- description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory. A binary attachment is reported as `kind: \"file\"` with `name`/`mediaType`/`byteLength` and an EMPTY `content` — the bytes are deliberately not fetched into a text channel. Messages come NEWEST-first, so the first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts inside a retained message's parts; a message whose own parts could not be fully read carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. The answer is ALSO bounded by an output budget: `truncated` with `truncationReason: \"aggregate-bytes\"` means the walk stopped because the result reached its size limit, not because `limit` was reached — raising `limit` does NOT reach the messages left out (it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` whenever a stop point can be named honestly (it is absent when the walk reached the peer's last page), and passing it back as `messagesResumeCursor` continues the walk from there (`limit` then bounds how many messages are returned FROM that point). The two token forms are `\"cursor:<peer position>\"` (the walk consumed its whole page window) and `\"after:<messageId>\"` (it stopped inside the window; the read restarts at the newest end and resumes strictly after that message). A token this tool did not emit is refused locally rather than forwarded, and an `after:` anchor that no longer exists in the history is an error, not an empty page. A `truncationReason` of \"limit\" is the caller's own ceiling and is reached by raising `limit`. Either way the newest message is always returned whole.",
5792
+ description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory. A binary attachment is reported as `kind: \"file\"` with `name`/`mediaType`/`byteLength` and an EMPTY `content` — the bytes are deliberately not fetched into a text channel. Messages come NEWEST-first, so the first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts inside a retained message's parts; a message whose own parts could not be fully read carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. The answer is ALSO bounded by an output budget: `truncated` with `truncationReason: \"aggregate-bytes\"` means the walk stopped because the result reached its size limit, not because `limit` was reached — raising `limit` does NOT reach the messages left out (it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` when the stop point can be named — absent when it cannot, e.g. an empty or malformed position, a token past the length ceiling, or no anchor id — and passing it back as `messagesResumeCursor` continues the walk from there (`limit` then bounds how many messages are returned FROM that point). The two token forms are `\"cursor:<peer position>\"` (the walk consumed its whole page window) and `\"after:<messageId>\"` (it stopped inside the window; the read restarts at the newest end and resumes strictly after that message). A token this tool did not emit is refused locally rather than forwarded, and an `after:` anchor that no longer exists in the history is an error, not an empty page. A `truncationReason` of \"limit\" is the caller's own ceiling and is reached by raising `limit`. Either way the newest message is always returned whole.",
5720
5793
  parameters: {
5721
5794
  type: "object",
5722
5795
  properties: {
@@ -5732,7 +5805,7 @@ var CherryToolEngine = class {
5732
5805
  type: "integer",
5733
5806
  minimum: 1,
5734
5807
  maximum: MAX_AGGREGATE_LIMIT,
5735
- description: "Maximum number of messages to return in total across pages (default 100). Distinct from the output budget: the answer may stop earlier with `truncationReason: \"aggregate-bytes\"`, which a larger `limit` does not extend — the resume token is how older messages are reached."
5808
+ description: "Maximum number of messages to return in total across pages (default 100). Distinct from the output budget: the answer may stop earlier with `truncationReason: \"aggregate-bytes\"`, which a larger `limit` does not extend — the resume token, when one is returned, is how older messages are reached."
5736
5809
  },
5737
5810
  messagesResumeCursor: {
5738
5811
  type: "string",
@@ -5745,7 +5818,7 @@ var CherryToolEngine = class {
5745
5818
  },
5746
5819
  execute: async (args) => {
5747
5820
  const resume = parseMessageResume(args["messagesResumeCursor"]);
5748
- const resolved = this.#linkOr(requiredString(args, "device"));
5821
+ const resolved = await this.#linkOr(requiredString(args, "device"));
5749
5822
  if (resolved.link === void 0) return resolved.verdict;
5750
5823
  const { connection, record } = resolved.link;
5751
5824
  const sessionId = requiredString(args, "sessionId");
@@ -6165,7 +6238,7 @@ var CherryToolEngine = class {
6165
6238
  additionalProperties: false
6166
6239
  },
6167
6240
  execute: async (args) => {
6168
- const resolved = this.#linkOr(requiredString(args, "device"));
6241
+ const resolved = await this.#linkOr(requiredString(args, "device"));
6169
6242
  if (resolved.link === void 0) return resolved.verdict;
6170
6243
  const { record } = resolved.link;
6171
6244
  const sessionId = requiredString(args, "sessionId");
@@ -6228,7 +6301,7 @@ var CherryToolEngine = class {
6228
6301
  additionalProperties: false
6229
6302
  },
6230
6303
  execute: async (args) => {
6231
- const resolved = this.#linkOr(requiredString(args, "device"));
6304
+ const resolved = await this.#linkOr(requiredString(args, "device"));
6232
6305
  if (resolved.link === void 0) return resolved.verdict;
6233
6306
  const { connection, record } = resolved.link;
6234
6307
  const agentId = requiredString(args, "agentId");
@@ -6313,7 +6386,7 @@ var CherryToolEngine = class {
6313
6386
  additionalProperties: false
6314
6387
  },
6315
6388
  execute: async (args) => {
6316
- const resolved = this.#linkOr(requiredString(args, "device"));
6389
+ const resolved = await this.#linkOr(requiredString(args, "device"));
6317
6390
  if (resolved.link === void 0) return resolved.verdict;
6318
6391
  const { connection, record } = resolved.link;
6319
6392
  const sessionId = requiredString(args, "sessionId");
@@ -6437,7 +6510,7 @@ var CherryToolEngine = class {
6437
6510
  additionalProperties: false
6438
6511
  },
6439
6512
  execute: async (args) => {
6440
- const resolved = this.#linkOr(requiredString(args, "device"));
6513
+ const resolved = await this.#linkOr(requiredString(args, "device"));
6441
6514
  if (resolved.link === void 0) return resolved.verdict;
6442
6515
  const { connection, record } = resolved.link;
6443
6516
  const sessionId = requiredString(args, "sessionId");
@@ -6491,7 +6564,7 @@ var CherryToolEngine = class {
6491
6564
  #interactionsTool() {
6492
6565
  return defineCherryTool({
6493
6566
  name: "cherry_interactions",
6494
- description: "List the session's PENDING interactions (questions and approval decisions) that need an answer. The peer serves the full four-state set (pending/approved/denied/expired) — the pending filter is applied here. Each entry carries its `kind` (question|decision) and a summary; answer questions with cherry_respond's `response` form and decisions with `decision`. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out and carries `resumeCursor`: replay it as `cursor` with the same filters to continue.",
6567
+ description: "List the session's PENDING interactions (questions and approval decisions) that need an answer. The peer serves the full four-state set (pending/approved/denied/expired) — the pending filter is applied here. Each entry carries its `kind` (question|decision) and a summary; answer questions with cherry_respond's `response` form and decisions with `decision`. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out; when the stop point can be named it carries `resumeCursor` — replay it as `cursor` from the same `sessionId` to continue.",
6495
6568
  parameters: {
6496
6569
  type: "object",
6497
6570
  properties: {
@@ -6519,7 +6592,7 @@ var CherryToolEngine = class {
6519
6592
  additionalProperties: false
6520
6593
  },
6521
6594
  execute: async (args) => {
6522
- const resolved = this.#linkOr(requiredString(args, "device"));
6595
+ const resolved = await this.#linkOr(requiredString(args, "device"));
6523
6596
  if (resolved.link === void 0) return resolved.verdict;
6524
6597
  const { connection, record } = resolved.link;
6525
6598
  const sessionId = requiredString(args, "sessionId");
@@ -6619,7 +6692,7 @@ var CherryToolEngine = class {
6619
6692
  additionalProperties: false
6620
6693
  },
6621
6694
  execute: async (args) => {
6622
- const resolved = this.#linkOr(requiredString(args, "device"));
6695
+ const resolved = await this.#linkOr(requiredString(args, "device"));
6623
6696
  if (resolved.link === void 0) return resolved.verdict;
6624
6697
  const { connection, record } = resolved.link;
6625
6698
  const sessionId = requiredString(args, "sessionId");
@@ -6696,7 +6769,7 @@ var CherryToolEngine = class {
6696
6769
  additionalProperties: false
6697
6770
  },
6698
6771
  execute: async (args) => {
6699
- const resolved = this.#linkOr(requiredString(args, "device"));
6772
+ const resolved = await this.#linkOr(requiredString(args, "device"));
6700
6773
  if (resolved.link === void 0) return resolved.verdict;
6701
6774
  const { connection, record } = resolved.link;
6702
6775
  const sessionId = requiredString(args, "sessionId");
@@ -7674,9 +7747,11 @@ var CherryToolEngine = class {
7674
7747
  }
7675
7748
  /**
7676
7749
  * The timeout result: NOT a failure. Partial content read so far +, whenever a
7677
- * stop point can be named honestly, a `messagesResumeCursor` token marking where
7678
- * the unread history begins — absent when the walk reached the peer's last page
7679
- * or its last returned message carried no id.
7750
+ * stop point can be named honestly, a `messagesResumeCursor` token marking
7751
+ * where the unread history begins. The token is missing in TWO OPPOSITE cases
7752
+ * — a COMPLETE walk, or a TRUNCATED one whose stop point could not be named —
7753
+ * and `messagesTruncated` is what tells them apart; see
7754
+ * {@link CherryMessageWalkVerdict.messagesResumeCursor}.
7680
7755
  *
7681
7756
  * The spec's own sentence (§3.6, 2026-10-09 design: 「超时不是失败:返回已收到
7682
7757
  * 的部分内容 + 当前游标,agent 可续查」) is a statement of that contract in
@@ -7903,9 +7978,10 @@ var CherryToolEngine = class {
7903
7978
  * `messagesResumeCursor`: the read scans from the newest end to the anchor
7904
7979
  * and continues strictly after it.
7905
7980
  *
7906
- * The token is absent only when there is nothing older to point at (the walk
7907
- * reached the peer's last page having returned everything it collected).
7908
- * See {@link CherryMessageWalkVerdict.messagesResumeCursor}.
7981
+ * The token is missing in TWO OPPOSITE cases — a COMPLETE walk, and a
7982
+ * TRUNCATED one whose stop point could not be named — and `messagesTruncated`
7983
+ * is what tells them apart; absence never means "nothing older left" on its
7984
+ * own. See {@link CherryMessageWalkVerdict.messagesResumeCursor}.
7909
7985
  */
7910
7986
  async #readTerminalMessages(connection, sessionId, session, deadline) {
7911
7987
  const historyRevision = optionalString(session["historyRevision"]);
@@ -8135,14 +8211,90 @@ function parseQuestionInput(input) {
8135
8211
  * invented: a wrong anchor is worse than no anchor, and `truncated` still says
8136
8212
  * the walk was cut. Otherwise the whole collected window was consumed, and the
8137
8213
  * peer's page position is exactly where the unread history begins
8138
- * (`cursor:<pos>`) — unless the peer answered an EMPTY position, which names
8139
- * nothing and is therefore not emitted at all.
8214
+ * (`cursor:<pos>`).
8215
+ *
8216
+ * BOTH arms name a remainder only when the PARSER would accept it
8217
+ * ({@link mangledRemainder}): empty, not equal to its own `.trim()` (which is
8218
+ * leading/trailing whitespace), or carrying a control character anywhere is
8219
+ * refused HERE exactly as `parseMessageResume` refuses it on replay. The
8220
+ * emitter must never mint what the parser refuses — a token this tool will not
8221
+ * take back is worse than none, because the refusal then surfaces at the RESUME
8222
+ * call instead of at the answer that produced it. This is the CONTENT
8223
+ * dimension; {@link withinResumeTokenCeiling} is the sibling LENGTH one. The
8224
+ * `after:` anchor is covered by the same rule, not just the `cursor:` position:
8225
+ * a message id is an `opaqueId` too, and padding is schema-valid, so a padded
8226
+ * id really can reach the anchor.
8227
+ *
8228
+ * EXPORTED FOR ITS OWN TESTS. The LENGTH guard on the `after:` arm cannot be
8229
+ * driven through a tool: an anchor is a message ID, and projecting that message
8230
+ * means asking the peer for `agent.parts.list` with it, whose `messageId` is an
8231
+ * `opaqueId` (256 characters). A peer item carrying an id above the token
8232
+ * ceiling therefore fails its parts walk and is never RETURNED, so no walk
8233
+ * reaches the point of naming it. The length guard on that arm is still
8234
+ * required — the emitter must never mint what the parser refuses, whatever the
8235
+ * peer's cap becomes — and this export is how a unit test pins it
8236
+ * (`tests/unit/resumeTokenEmit.test.ts`). The CONTENT guard on both arms IS
8237
+ * reachable, because `opaqueId` allows padding: `tests/integration/
8238
+ * toolsReadResume.test.ts` ⑭d/⑭e drive it through the tools.
8140
8239
  */
8141
8240
  function messageResumeToken(page, returned, lastReturnedId) {
8142
- const anchor = optionalString(lastReturnedId);
8143
- if (returned < page.items.length || page.pageOverflow === true) return anchor === void 0 ? void 0 : `${RESUME_ANCHOR_PREFIX}${anchor}`;
8144
- const position = optionalString(page.nextCursor);
8145
- return position === void 0 ? void 0 : `${RESUME_CURSOR_PREFIX}${position}`;
8241
+ const anchor = nameableRemainder(optionalString(lastReturnedId));
8242
+ if (returned < page.items.length || page.pageOverflow === true) return anchor === void 0 ? void 0 : withinResumeTokenCeiling(`${RESUME_ANCHOR_PREFIX}${anchor}`);
8243
+ const position = nameablePeerPosition(optionalString(page.nextCursor));
8244
+ return position === void 0 ? void 0 : withinResumeTokenCeiling(`${RESUME_CURSOR_PREFIX}${position}`);
8245
+ }
8246
+ /**
8247
+ * The CONTENT dimension of "a remainder this bridge may name", or `undefined`
8248
+ * when it must not name one.
8249
+ *
8250
+ * The parsers run `mangledRemainder` on every remainder they read, so one that
8251
+ * fails it is a remainder this tool would refuse on replay: naming it hands the
8252
+ * caller a stop point that can never be used, and the refusal then surfaces at
8253
+ * the RESUME call instead of at the answer that produced it (issues #70, #80).
8254
+ * What it rejects, exactly: empty, not equal to its own `.trim()` (leading/
8255
+ * trailing whitespace), or a control character ANYWHERE — an interior plain space
8256
+ * is legal and round-trips.
8257
+ *
8258
+ * This is the parser's own predicate, not a copy of it, so the two sides cannot
8259
+ * drift into two dialects. The sibling dimension is the LENGTH ceiling, judged
8260
+ * on the FINAL token ({@link withinResumeTokenCeiling}).
8261
+ */
8262
+ function nameableRemainder(value) {
8263
+ return value === void 0 || mangledRemainder(value) ? void 0 : value;
8264
+ }
8265
+ /**
8266
+ * A remainder this bridge may name AS A PAGE POSITION: {@link nameableRemainder}
8267
+ * plus the one refusal that applies to positions and NOT to anchors.
8268
+ *
8269
+ * `looksLikeV1ListToken` exists because a v1 list token (0.1.6: base64url of
8270
+ * `{v,m,d,h,p}`) lives under `cursor:` and is indistinguishable from a peer
8271
+ * position, so both parsers NAME and refuse it rather than forwarding it to a
8272
+ * desktop that would silently serve its FIRST page. An emitter that wrapped such
8273
+ * a position would mint `cursor:<v1 shape>` and then have its own parser answer
8274
+ * "issued by an older plugin version" about a token this tool had just printed —
8275
+ * the same broken round trip as #76/#80, one detector further out (issue #85).
8276
+ *
8277
+ * The `after:` arm deliberately does NOT use this: `parseMessageResume`'s
8278
+ * `after:` branch never consults the detector, so an `after:` remainder of that
8279
+ * shape is a token it accepts.
8280
+ */
8281
+ function nameablePeerPosition(value) {
8282
+ const remainder = nameableRemainder(value);
8283
+ return remainder === void 0 || looksLikeV1ListToken(remainder) ? void 0 : remainder;
8284
+ }
8285
+ /**
8286
+ * The token ceiling, enforced on the way OUT as well as in. A token this tool
8287
+ * refuses to replay is worse than no token at all: `truncated` already says the
8288
+ * walk was cut, and the remedy then follows the reason — a `'limit'` stop
8289
+ * answers to a wider `limit` only where the caller owns that ceiling — so a
8290
+ * stop point whose token would push past
8291
+ * {@link MAX_RESUME_TOKEN_CHARS} is simply not named. Measured on the FINAL
8292
+ * string, never on a reconstruction of its length, so a format change cannot
8293
+ * leave the guard behind. Exactly at the ceiling is accepted — it is a length
8294
+ * the parser takes back.
8295
+ */
8296
+ function withinResumeTokenCeiling(token) {
8297
+ return token.length <= 4096 ? token : void 0;
8146
8298
  }
8147
8299
  /**
8148
8300
  * The message-walk verdict, projected off an aggregate page.
@@ -8240,6 +8392,12 @@ function messageIdOf(raw) {
8240
8392
  * `undefined`/`null` means "from the newest message", which is the ordinary
8241
8393
  * read. An empty remainder (`'cursor:'`, `'after:'`) is refused like any other
8242
8394
  * foreign token: an empty anchor could never match a message.
8395
+ *
8396
+ * EXPORTED FOR ITS OWN TESTS — specifically for the round-trip property, which
8397
+ * is the invariant this whole file is built around: every token the emitters
8398
+ * NAME must be one this function ACCEPTS. Pinning that with a probe table is
8399
+ * what turns "we mirrored every refusal" from a promise into a check
8400
+ * (`tests/unit/resumeTokenEmit.test.ts`, issue #85).
8243
8401
  */
8244
8402
  function parseMessageResume(value) {
8245
8403
  if (value === void 0 || value === null) return void 0;
@@ -175,7 +175,7 @@ schema 已允许 `interrupted`,所以客户端**必须**处理,但当前服
175
175
  **W1**(步骤 6 会真的把它删掉),其中有一个**保持开着**的会话;③ 一个不勾选的工作区
176
176
  **W2**(含一个会话);④ 一个**没有工作区**的会话——`cwd` 不属于任何已注册工作区,最省事的造法是:
177
177
  注册一个临时工作区 W0 → 在其中建一个会话 → 删掉 W0(该会话转入「未分组」,D7 的判定对象就是它)。
178
- 面板入口:设置页 →「**Cherry Studio 互联**」→「**启用范围**」。`curl -s
178
+ 面板入口:设置页 →「**Cherry 互联**」→「**启用范围**」。`curl -s
179
179
  http://127.0.0.1:23023/cherry-remote/api/scope`(宿主换过端口就换端口;curl 不带 `Origin`,走
180
180
  信任 fence 的放行分支)应返回 `host.state: "ready"`、`host.capable: true`、`host.degraded: []`。
181
181
 
package/lib/client.js CHANGED
@@ -13,7 +13,7 @@ window.__ModuleLoader__.load({
13
13
  */
14
14
  var dictionaries = {
15
15
  zh: {
16
- "app.title": "Cherry Studio 互联",
16
+ "app.title": "Cherry 互联",
17
17
  "app.subtitle": "通过远程传输把本机 DeepSeek Harness 连接到 Cherry Studio 桌面端;设备档案保存在插件数据目录,页面不接触任何凭据。",
18
18
  "app.eventSourceUnavailable": "此环境不支持 EventSource",
19
19
  "app.devices.heading": "已配对设备",
@@ -113,7 +113,7 @@ window.__ModuleLoader__.load({
113
113
  "list.separator": "、"
114
114
  },
115
115
  en: {
116
- "app.title": "Cherry Studio Bridge",
116
+ "app.title": "Cherry Bridge",
117
117
  "app.subtitle": "Connect this machine’s DeepSeek Harness to a Cherry Studio desktop over the remote transport. Device records live in the plugin data directory; this page never touches a credential.",
118
118
  "app.eventSourceUnavailable": "EventSource is unavailable in this environment",
119
119
  "app.devices.heading": "Paired devices",
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "Cherry Studio Bridge",
3
+ "title": "Cherry Bridge",
4
4
  "description": "DeepSeek Harness plugin bridging to Cherry Studio desktop instances over remote transport"
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "Cherry Studio 互联",
3
+ "title": "Cherry 互联",
4
4
  "description": "通过远程传输把本机 DeepSeek Harness 连接到 Cherry Studio 桌面端的插件"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cherry-bridge",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "packageManager": "npm@10.9.9",
package/src/connection.ts CHANGED
@@ -627,6 +627,13 @@ export class DeviceConnection {
627
627
  readonly #helloProtocolVersions: readonly number[]
628
628
  readonly #transportRetryWaitMs: number
629
629
  readonly #stateListeners = new Set<(state: DeviceState) => void>()
630
+ /**
631
+ * Callers parked in {@link waitForSettled}. Separate from the state listeners
632
+ * because their lifetime is ONE wait: a listener stays for the lifecycle, a
633
+ * settle waiter removes itself the moment it is answered (or times out), and
634
+ * mixing them would leave every answered wait in the multicast set.
635
+ */
636
+ readonly #settleWaiters = new Set<(state: DeviceState) => void>()
630
637
  /**
631
638
  * Waits parked inside `#awaitReconnect`. close() drains them: a shutdown has
632
639
  * no reconnect to wait for, and — the case that matters — a close() followed
@@ -783,6 +790,53 @@ export class DeviceConnection {
783
790
  this.#stateListeners.add(cb)
784
791
  }
785
792
 
793
+ /**
794
+ * Waits for an attempt that is ALREADY IN FLIGHT to settle, bounded by
795
+ * `timeoutMs`, and answers with the state it ends on.
796
+ *
797
+ * The only state this waits on is `connecting`: an attempt is running, and
798
+ * its outcome is what the caller asked about. `connected` is answered at
799
+ * once, and so are `offline` (parked in backoff), `disabled` and
800
+ * `identity-mismatch` — no wait can change those, and waiting on them would
801
+ * merely delay the caller's answer by the whole bound. `offline` is the
802
+ * common case for a genuinely powered-off desktop, so it is the one that
803
+ * matters: the tools stay fail-fast there.
804
+ *
805
+ * This NEVER starts an attempt — it observes one the pool already started.
806
+ * That is the line the tool face has always drawn (a tool must not dial); the
807
+ * wait only stops the tool from calling a live dial a failure.
808
+ *
809
+ * The bound expiring is not an error: the current state (`connecting`) is
810
+ * returned, and the caller judges it exactly as it would have without the
811
+ * wait.
812
+ */
813
+ async waitForSettled(timeoutMs: number): Promise<DeviceState> {
814
+ if (this.#state !== 'connecting' || timeoutMs <= 0) return this.#state
815
+ return new Promise<DeviceState>((resolve) => {
816
+ let settled = false
817
+ let timer: ReturnType<typeof setTimeout> | undefined
818
+ const finish = (state: DeviceState): void => {
819
+ if (settled) return
820
+ settled = true
821
+ if (timer !== undefined) clearTimeout(timer)
822
+ this.#settleWaiters.delete(onState)
823
+ resolve(state)
824
+ }
825
+ // The waiter's own filter: a transition to `offline` IS the attempt
826
+ // settling (failed); only a transition INTO `connecting` is not an
827
+ // answer, and `#setState` never reports a state it is already in.
828
+ const onState = (state: DeviceState): void => {
829
+ if (state !== 'connecting') finish(state)
830
+ }
831
+ timer = setTimeout(() => finish(this.#state), timeoutMs)
832
+ this.#settleWaiters.add(onState)
833
+ // The attempt may have settled between the guard above and the
834
+ // registration; that transition is already gone from the multicast, so
835
+ // it has to be observed by hand.
836
+ if (this.#state !== 'connecting') finish(this.#state)
837
+ })
838
+ }
839
+
786
840
  /**
787
841
  * Starts (or joins) the supervised lifecycle. Resolves once this cycle
788
842
  * reaches `connected`; rejects with the cycle's failure. Transient
@@ -1582,5 +1636,12 @@ export class DeviceConnection {
1582
1636
  console.warn(`[cherry-remote] state listener threw for ${this.record.deviceId}`, error)
1583
1637
  }
1584
1638
  }
1639
+ for (const cb of [...this.#settleWaiters]) {
1640
+ try {
1641
+ cb(next)
1642
+ } catch (error) {
1643
+ console.warn(`[cherry-remote] settle waiter threw for ${this.record.deviceId}`, error)
1644
+ }
1645
+ }
1585
1646
  }
1586
1647
  }