dsh-cherry-bridge 0.1.8 → 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/README.en.md CHANGED
@@ -38,13 +38,13 @@ flowchart TB
38
38
 
39
39
  ```bash
40
40
  # From npm (recommended, pinned)
41
- npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.8
41
+ npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.9
42
42
 
43
43
  # Install the latest npm release
44
44
  npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
45
45
 
46
46
  # From GitHub source (same commit as the npm release)
47
- npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.8
47
+ npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.9
48
48
  ```
49
49
 
50
50
  The package ships `cordis.patch.yml` (`package.json`'s `dsh.bundle.patch` points
@@ -203,7 +203,7 @@ subscription was lost and could not be rebuilt, so the outcome is
203
203
 
204
204
  The plugin is also a **web client bundle** (`package.json`'s `dsh.client`,
205
205
  exporting `./client`). Through the `settings.section` slot it registers a
206
- **`cherry-remote`** page (nav label “Cherry Studio Bridge”): device cards
206
+ **`cherry-remote`** page (nav label “Cherry Bridge”): device cards
207
207
  (enable/disable/remove), a pairing wizard (paste → 6-digit code → verdict), a
208
208
  scope panel, and live state pushed over the same-origin SSE feed
209
209
  (`/cherry-remote/api/events`).
package/README.md CHANGED
@@ -36,13 +36,13 @@ flowchart TB
36
36
 
37
37
  ```bash
38
38
  # 从 npm 安装(推荐,钉住版本)
39
- npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.8
39
+ npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.9
40
40
 
41
41
  # 安装 npm 最新发布版
42
42
  npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
43
43
 
44
44
  # 从 GitHub 源码安装(与 npm 发布同一提交)
45
- npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.8
45
+ npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.9
46
46
  ```
47
47
 
48
48
  本包自带 `cordis.patch.yml`(`package.json` 的 `dsh.bundle.patch` 指向它),
@@ -159,7 +159,7 @@ cherry_interactions → 看待答的提问/审批卡;cherry_respond 作答
159
159
  ## 设置页与启用范围
160
160
 
161
161
  插件同时是 **web client bundle**(`package.json` 的 `dsh.client`,导出 `./client`),通过
162
- `settings.section` 槽位注册 **`cherry-remote`**(导航标签「Cherry Studio 互联」)页面:
162
+ `settings.section` 槽位注册 **`cherry-remote`**(导航标签「Cherry 互联」)页面:
163
163
  设备卡片(启用/停用/删除)、配对向导(粘贴 → 6 位验证码 → 终态)、启用范围面板,以及经
164
164
  `/cherry-remote/api/events` SSE 推送的实时状态。
165
165
 
package/dist/index.mjs CHANGED
@@ -2695,6 +2695,13 @@ var DeviceConnection = class {
2695
2695
  #transportRetryWaitMs;
2696
2696
  #stateListeners = /* @__PURE__ */ new Set();
2697
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
+ /**
2698
2705
  * Waits parked inside `#awaitReconnect`. close() drains them: a shutdown has
2699
2706
  * no reconnect to wait for, and — the case that matters — a close() followed
2700
2707
  * by a fresh connect() must not let an OLD lifecycle's replay ride the new
@@ -2819,6 +2826,46 @@ var DeviceConnection = class {
2819
2826
  this.#stateListeners.add(cb);
2820
2827
  }
2821
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
+ /**
2822
2869
  * Starts (or joins) the supervised lifecycle. Resolves once this cycle
2823
2870
  * reaches `connected`; rejects with the cycle's failure. Transient
2824
2871
  * failures keep being retried in the background by the backoff loop even
@@ -3308,6 +3355,11 @@ var DeviceConnection = class {
3308
3355
  } catch (error) {
3309
3356
  console.warn(`[cherry-remote] state listener threw for ${this.record.deviceId}`, error);
3310
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
+ }
3311
3363
  }
3312
3364
  };
3313
3365
  //#endregion
@@ -5200,8 +5252,12 @@ var CherryToolEngine = class {
5200
5252
  * ⑤): the candidate list is the answer. A MISS is a verdict too — see
5201
5253
  * {@link CherryDeviceNotFound} — and it carries the ids the caller could have
5202
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).
5203
5259
  */
5204
- #resolve(device) {
5260
+ async #resolve(device) {
5205
5261
  const resolution = this.#deps.store.resolve(device);
5206
5262
  if ("ambiguous" in resolution) return {
5207
5263
  error: "ambiguous-device",
@@ -5211,10 +5267,11 @@ var CherryToolEngine = class {
5211
5267
  if ("notFound" in resolution) return this.#notFoundVerdict(device);
5212
5268
  const connection = this.#deps.connect(resolution.deviceId);
5213
5269
  this.#watchConnection(resolution.deviceId, connection);
5214
- 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 {
5215
5272
  error: "device-unavailable",
5216
5273
  device: resolution.deviceId,
5217
- state: connection.state
5274
+ state
5218
5275
  };
5219
5276
  return {
5220
5277
  record: resolution,
@@ -5250,6 +5307,10 @@ var CherryToolEngine = class {
5250
5307
  /**
5251
5308
  * Resolve-or-verdict: every non-`cherry_devices` tool needs a live link.
5252
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
+ *
5253
5314
  * The failure faces are deliberately DIFFERENT (controller ruling ⑤): an
5254
5315
  * ambiguous selector and a MISS are ANSWERS — the candidate list, or the
5255
5316
  * known ids, returned in band, because the caller can act on them — while a
@@ -5262,8 +5323,8 @@ var CherryToolEngine = class {
5262
5323
  * `cherry_devices` does not come through this method — it goes through
5263
5324
  * {@link #selectRecords}, which returns the verdict in band.
5264
5325
  */
5265
- #requireConnection(device) {
5266
- const resolved = this.#resolve(device);
5326
+ async #requireConnection(device) {
5327
+ const resolved = await this.#resolve(device);
5267
5328
  if (!this.#isVerdict(resolved)) return resolved;
5268
5329
  if (resolved.error === "ambiguous-device") return resolved;
5269
5330
  if (resolved.error === "not-found") {
@@ -5282,10 +5343,10 @@ var CherryToolEngine = class {
5282
5343
  * replace it with a weaker, wrong one — a miss under a READ-ONLY archive
5283
5344
  * would describe a store that still holds a valid token as "no such device".
5284
5345
  */
5285
- #linkOr(device) {
5346
+ async #linkOr(device) {
5286
5347
  const quarantined = this.#quarantineVerdict();
5287
5348
  if (quarantined !== void 0) return { verdict: quarantined };
5288
- const resolved = this.#requireConnection(device);
5349
+ const resolved = await this.#requireConnection(device);
5289
5350
  if ("error" in resolved) return { verdict: resolved };
5290
5351
  return { link: resolved };
5291
5352
  }
@@ -5507,10 +5568,13 @@ var CherryToolEngine = class {
5507
5568
  const selector = optionalString(args["device"]);
5508
5569
  const quarantined = this.#quarantineVerdict();
5509
5570
  if (quarantined !== void 0) return quarantined;
5510
- 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
+ }
5511
5575
  const selected = this.#selectRecords(selector);
5512
5576
  if (!Array.isArray(selected)) return selected;
5513
- return { devices: selected.map((record) => this.#deviceView(record)) };
5577
+ return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) };
5514
5578
  }
5515
5579
  });
5516
5580
  }
@@ -5540,7 +5604,9 @@ var CherryToolEngine = class {
5540
5604
  return [resolution];
5541
5605
  }
5542
5606
  /**
5543
- * 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.
5544
5610
  *
5545
5611
  * A disabled record is authoritative: the pooled connection is constructed
5546
5612
  * with its own `record.enabled` snapshot and reports `disabled` only once
@@ -5548,10 +5614,11 @@ var CherryToolEngine = class {
5548
5614
  * for a device whose connection was never started. Everything else falls back
5549
5615
  * to the connection's own CLOSED state set.
5550
5616
  */
5551
- #deviceView(record) {
5617
+ async #deviceView(record) {
5552
5618
  let state = record.enabled ? "offline" : "disabled";
5553
5619
  if (record.enabled) try {
5554
- 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;
5555
5622
  } catch (error) {
5556
5623
  console.warn(`[cherry-remote] cherry_devices could not read the connection for ${record.deviceId}`, error);
5557
5624
  }
@@ -5587,7 +5654,7 @@ var CherryToolEngine = class {
5587
5654
  additionalProperties: false
5588
5655
  },
5589
5656
  execute: async (args) => {
5590
- const resolved = this.#linkOr(requiredString(args, "device"));
5657
+ const resolved = await this.#linkOr(requiredString(args, "device"));
5591
5658
  if (resolved.link === void 0) return resolved.verdict;
5592
5659
  const { connection, record } = resolved.link;
5593
5660
  const binding = {
@@ -5634,7 +5701,7 @@ var CherryToolEngine = class {
5634
5701
  additionalProperties: false
5635
5702
  },
5636
5703
  execute: async (args) => {
5637
- const resolved = this.#linkOr(requiredString(args, "device"));
5704
+ const resolved = await this.#linkOr(requiredString(args, "device"));
5638
5705
  if (resolved.link === void 0) return resolved.verdict;
5639
5706
  const { connection, record } = resolved.link;
5640
5707
  const params = { agentId: requiredString(args, "agentId") };
@@ -5693,7 +5760,7 @@ var CherryToolEngine = class {
5693
5760
  additionalProperties: false
5694
5761
  },
5695
5762
  execute: async (args) => {
5696
- const resolved = this.#linkOr(requiredString(args, "device"));
5763
+ const resolved = await this.#linkOr(requiredString(args, "device"));
5697
5764
  if (resolved.link === void 0) return resolved.verdict;
5698
5765
  const { connection, record } = resolved.link;
5699
5766
  const agentId = optionalString(args["agentId"]);
@@ -5751,7 +5818,7 @@ var CherryToolEngine = class {
5751
5818
  },
5752
5819
  execute: async (args) => {
5753
5820
  const resume = parseMessageResume(args["messagesResumeCursor"]);
5754
- const resolved = this.#linkOr(requiredString(args, "device"));
5821
+ const resolved = await this.#linkOr(requiredString(args, "device"));
5755
5822
  if (resolved.link === void 0) return resolved.verdict;
5756
5823
  const { connection, record } = resolved.link;
5757
5824
  const sessionId = requiredString(args, "sessionId");
@@ -6171,7 +6238,7 @@ var CherryToolEngine = class {
6171
6238
  additionalProperties: false
6172
6239
  },
6173
6240
  execute: async (args) => {
6174
- const resolved = this.#linkOr(requiredString(args, "device"));
6241
+ const resolved = await this.#linkOr(requiredString(args, "device"));
6175
6242
  if (resolved.link === void 0) return resolved.verdict;
6176
6243
  const { record } = resolved.link;
6177
6244
  const sessionId = requiredString(args, "sessionId");
@@ -6234,7 +6301,7 @@ var CherryToolEngine = class {
6234
6301
  additionalProperties: false
6235
6302
  },
6236
6303
  execute: async (args) => {
6237
- const resolved = this.#linkOr(requiredString(args, "device"));
6304
+ const resolved = await this.#linkOr(requiredString(args, "device"));
6238
6305
  if (resolved.link === void 0) return resolved.verdict;
6239
6306
  const { connection, record } = resolved.link;
6240
6307
  const agentId = requiredString(args, "agentId");
@@ -6319,7 +6386,7 @@ var CherryToolEngine = class {
6319
6386
  additionalProperties: false
6320
6387
  },
6321
6388
  execute: async (args) => {
6322
- const resolved = this.#linkOr(requiredString(args, "device"));
6389
+ const resolved = await this.#linkOr(requiredString(args, "device"));
6323
6390
  if (resolved.link === void 0) return resolved.verdict;
6324
6391
  const { connection, record } = resolved.link;
6325
6392
  const sessionId = requiredString(args, "sessionId");
@@ -6443,7 +6510,7 @@ var CherryToolEngine = class {
6443
6510
  additionalProperties: false
6444
6511
  },
6445
6512
  execute: async (args) => {
6446
- const resolved = this.#linkOr(requiredString(args, "device"));
6513
+ const resolved = await this.#linkOr(requiredString(args, "device"));
6447
6514
  if (resolved.link === void 0) return resolved.verdict;
6448
6515
  const { connection, record } = resolved.link;
6449
6516
  const sessionId = requiredString(args, "sessionId");
@@ -6525,7 +6592,7 @@ var CherryToolEngine = class {
6525
6592
  additionalProperties: false
6526
6593
  },
6527
6594
  execute: async (args) => {
6528
- const resolved = this.#linkOr(requiredString(args, "device"));
6595
+ const resolved = await this.#linkOr(requiredString(args, "device"));
6529
6596
  if (resolved.link === void 0) return resolved.verdict;
6530
6597
  const { connection, record } = resolved.link;
6531
6598
  const sessionId = requiredString(args, "sessionId");
@@ -6625,7 +6692,7 @@ var CherryToolEngine = class {
6625
6692
  additionalProperties: false
6626
6693
  },
6627
6694
  execute: async (args) => {
6628
- const resolved = this.#linkOr(requiredString(args, "device"));
6695
+ const resolved = await this.#linkOr(requiredString(args, "device"));
6629
6696
  if (resolved.link === void 0) return resolved.verdict;
6630
6697
  const { connection, record } = resolved.link;
6631
6698
  const sessionId = requiredString(args, "sessionId");
@@ -6702,7 +6769,7 @@ var CherryToolEngine = class {
6702
6769
  additionalProperties: false
6703
6770
  },
6704
6771
  execute: async (args) => {
6705
- const resolved = this.#linkOr(requiredString(args, "device"));
6772
+ const resolved = await this.#linkOr(requiredString(args, "device"));
6706
6773
  if (resolved.link === void 0) return resolved.verdict;
6707
6774
  const { connection, record } = resolved.link;
6708
6775
  const sessionId = requiredString(args, "sessionId");
@@ -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.8",
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
  }
package/src/defaults.ts CHANGED
@@ -81,6 +81,24 @@ export const DEFAULT_CONTENT_READ_BUDGET_PER_SECOND = 256
81
81
  */
82
82
  export const DEFAULT_TRANSPORT_RETRY_WAIT_MS = 5_000
83
83
 
84
+ /**
85
+ * How long a DEVICE TOOL waits for an establishment attempt that is already in
86
+ * flight (`state === 'connecting'`) before judging the device.
87
+ *
88
+ * WHY THIS EXISTS: the connection pool builds a `DeviceConnection` on FIRST
89
+ * TOUCH and that constructor dials immediately, so every tool called in the
90
+ * same tick as the first touch — and every tool called during a reconnect —
91
+ * observed `connecting` and answered `device-unavailable` about a connection
92
+ * that comes up a moment later. The state is a transient, not a verdict.
93
+ *
94
+ * It is deliberately NOT a reconnect budget: an `offline` device is parked in
95
+ * its backoff, where waiting cannot change anything, so the tools answer at
96
+ * once. Five seconds is ~15x a LAN dial plus handshake; a bound longer than
97
+ * that only delays the unavoidable answer, and the tool call is the caller's
98
+ * unit of patience.
99
+ */
100
+ export const DEVICE_SETTLE_WAIT_MS = 5_000
101
+
84
102
  /** 2 MiB of event text cached per session (spec §3.5). */
85
103
  export const DEFAULT_PER_SESSION_CACHE_BYTES = 2 * 1024 * 1024
86
104
 
package/src/tools.ts CHANGED
@@ -4,6 +4,7 @@ import type { DeviceConnection, DeviceState } from './connection.js'
4
4
  import type { DeviceRecord, DeviceResolution, DeviceStore } from './deviceStore.js'
5
5
  import { QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
6
6
  import {
7
+ DEVICE_SETTLE_WAIT_MS,
7
8
  INTERACTIONS_LIST_PAGE_CLAMP,
8
9
  LIST_PAGE_CLAMP,
9
10
  MAX_RESUME_TOKEN_CHARS,
@@ -1030,6 +1031,14 @@ export interface CherryToolsDeps {
1030
1031
  * this one subscribes the engine to ONE device's transitions.
1031
1032
  */
1032
1033
  watchDeviceState?: (deviceId: string, cb: (state: DeviceState) => void) => void
1034
+ /**
1035
+ * How long a device tool waits for an establishment attempt that is ALREADY
1036
+ * IN FLIGHT (`state === 'connecting'`) before judging the device (issue #99).
1037
+ *
1038
+ * OPTIONAL: production omits it and takes {@link DEVICE_SETTLE_WAIT_MS}; test
1039
+ * rigs drive it down so the bounded case needs no real five seconds.
1040
+ */
1041
+ deviceSettleWaitMs?: number
1033
1042
  }
1034
1043
 
1035
1044
  // ---------------------------------------------------------------------------
@@ -1617,10 +1626,14 @@ class CherryToolEngine {
1617
1626
  * ⑤): the candidate list is the answer. A MISS is a verdict too — see
1618
1627
  * {@link CherryDeviceNotFound} — and it carries the ids the caller could have
1619
1628
  * meant so the answer stays actionable.
1629
+ *
1630
+ * ASYNC because of the `connecting` wait below: the pool dials on first touch
1631
+ * and on every reconnect, so a tool called in that window used to be refused
1632
+ * for a connection that comes up a moment later (issue #99).
1620
1633
  */
1621
- #resolve(
1634
+ async #resolve(
1622
1635
  device: string,
1623
- ): ResolvedDevice | CherryAmbiguousDevice | CherryDeviceNotFound | CherryDeviceUnavailable {
1636
+ ): Promise<ResolvedDevice | CherryAmbiguousDevice | CherryDeviceNotFound | CherryDeviceUnavailable> {
1624
1637
  const resolution: DeviceResolution = this.#deps.store.resolve(device)
1625
1638
  if ('ambiguous' in resolution) {
1626
1639
  return { error: 'ambiguous-device', device, candidates: resolution.ambiguous }
@@ -1634,10 +1647,21 @@ class CherryToolEngine {
1634
1647
  // transition this exists for can land while the device is already offline,
1635
1648
  // and the watch must be in place from the first call either way.
1636
1649
  this.#watchConnection(resolution.deviceId, connection)
1637
- if (connection.state !== 'connected') {
1650
+ // `connecting` is an attempt IN FLIGHT, not a verdict (issue #99): the pool
1651
+ // dials on first touch and on every reconnect, so refusing here used to
1652
+ // report a failure about a connection that comes up a moment later. Wait for
1653
+ // that attempt — bounded, and never starting one — then judge the state it
1654
+ // settled on. Every other non-connected state is answered at once: no wait
1655
+ // can change `offline` (parked in backoff), `disabled` or
1656
+ // `identity-mismatch`, and waiting would only delay the answer.
1657
+ const state =
1658
+ connection.state === 'connecting'
1659
+ ? await connection.waitForSettled(this.#deps.deviceSettleWaitMs ?? DEVICE_SETTLE_WAIT_MS)
1660
+ : connection.state
1661
+ if (state !== 'connected') {
1638
1662
  // The established Task 5 shape: the tool never dials implicitly, and the
1639
1663
  // caller gets the same `{device, state}` verdict the RPC layer throws.
1640
- return { error: 'device-unavailable', device: resolution.deviceId, state: connection.state }
1664
+ return { error: 'device-unavailable', device: resolution.deviceId, state }
1641
1665
  }
1642
1666
  return { record: resolution, connection }
1643
1667
  }
@@ -1675,6 +1699,10 @@ class CherryToolEngine {
1675
1699
  /**
1676
1700
  * Resolve-or-verdict: every non-`cherry_devices` tool needs a live link.
1677
1701
  *
1702
+ * `connecting` is waited out inside {@link #resolve} first, so the
1703
+ * `DeviceUnavailableError` below is only ever raised for a state the wait
1704
+ * could not improve on (bounded by `deviceSettleWaitMs`).
1705
+ *
1678
1706
  * The failure faces are deliberately DIFFERENT (controller ruling ⑤): an
1679
1707
  * ambiguous selector and a MISS are ANSWERS — the candidate list, or the
1680
1708
  * known ids, returned in band, because the caller can act on them — while a
@@ -1687,8 +1715,8 @@ class CherryToolEngine {
1687
1715
  * `cherry_devices` does not come through this method — it goes through
1688
1716
  * {@link #selectRecords}, which returns the verdict in band.
1689
1717
  */
1690
- #requireConnection(device: string): ResolvedDevice | CherryAmbiguousDevice {
1691
- const resolved = this.#resolve(device)
1718
+ async #requireConnection(device: string): Promise<ResolvedDevice | CherryAmbiguousDevice> {
1719
+ const resolved = await this.#resolve(device)
1692
1720
  if (!this.#isVerdict(resolved)) return resolved
1693
1721
  if (resolved.error === 'ambiguous-device') return resolved
1694
1722
  if (resolved.error === 'not-found') {
@@ -1710,14 +1738,13 @@ class CherryToolEngine {
1710
1738
  * replace it with a weaker, wrong one — a miss under a READ-ONLY archive
1711
1739
  * would describe a store that still holds a valid token as "no such device".
1712
1740
  */
1713
- #linkOr(
1714
- device: string,
1715
- ):
1741
+ async #linkOr(device: string): Promise<
1716
1742
  | { link: ResolvedDevice; verdict?: undefined }
1717
- | { link?: undefined; verdict: CherryAmbiguousDevice | CherryArchiveQuarantined } {
1743
+ | { link?: undefined; verdict: CherryAmbiguousDevice | CherryArchiveQuarantined }
1744
+ > {
1718
1745
  const quarantined = this.#quarantineVerdict()
1719
1746
  if (quarantined !== undefined) return { verdict: quarantined }
1720
- const resolved = this.#requireConnection(device)
1747
+ const resolved = await this.#requireConnection(device)
1721
1748
  if ('error' in resolved) return { verdict: resolved }
1722
1749
  return { link: resolved }
1723
1750
  }
@@ -2172,11 +2199,12 @@ class CherryToolEngine {
2172
2199
  const quarantined = this.#quarantineVerdict()
2173
2200
  if (quarantined !== undefined) return quarantined
2174
2201
  if (selector === undefined) {
2175
- return { devices: this.#deps.store.list().map((record) => this.#deviceView(record)) }
2202
+ const records = this.#deps.store.list()
2203
+ return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) }
2176
2204
  }
2177
2205
  const selected = this.#selectRecords(selector)
2178
2206
  if (!Array.isArray(selected)) return selected
2179
- return { devices: selected.map((record) => this.#deviceView(record)) }
2207
+ return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) }
2180
2208
  },
2181
2209
  })
2182
2210
  }
@@ -2208,7 +2236,9 @@ class CherryToolEngine {
2208
2236
  }
2209
2237
 
2210
2238
  /**
2211
- * One device's view WITHOUT dialing.
2239
+ * One device's view WITHOUT dialing — but after waiting out an attempt that is
2240
+ * already in flight, exactly as {@link #resolve} does, so a cold-start row
2241
+ * does not present a transient `connecting` as the device's answer.
2212
2242
  *
2213
2243
  * A disabled record is authoritative: the pooled connection is constructed
2214
2244
  * with its own `record.enabled` snapshot and reports `disabled` only once
@@ -2216,11 +2246,18 @@ class CherryToolEngine {
2216
2246
  * for a device whose connection was never started. Everything else falls back
2217
2247
  * to the connection's own CLOSED state set.
2218
2248
  */
2219
- #deviceView(record: DeviceRecord): CherryDeviceView {
2249
+ async #deviceView(record: DeviceRecord): Promise<CherryDeviceView> {
2220
2250
  let state: DeviceState = record.enabled ? 'offline' : 'disabled'
2221
2251
  if (record.enabled) {
2222
2252
  try {
2223
- state = this.#deps.connect(record.deviceId).state
2253
+ const connection = this.#deps.connect(record.deviceId)
2254
+ // Same transient as {@link #resolve} (issue #99): a row rendered while
2255
+ // the first dial is in flight must not present `connecting` as the
2256
+ // device's answer. Bounded, and only for an attempt already running.
2257
+ state =
2258
+ connection.state === 'connecting'
2259
+ ? await connection.waitForSettled(this.#deps.deviceSettleWaitMs ?? DEVICE_SETTLE_WAIT_MS)
2260
+ : connection.state
2224
2261
  } catch (error) {
2225
2262
  // A device that cannot even be resolved to a connection still deserves a
2226
2263
  // truthful row; `offline` is the fallback verdict.
@@ -2268,7 +2305,7 @@ class CherryToolEngine {
2268
2305
  additionalProperties: false,
2269
2306
  },
2270
2307
  execute: async (args) => {
2271
- const resolved = this.#linkOr(requiredString(args, 'device'))
2308
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
2272
2309
  if (resolved.link === undefined) return resolved.verdict
2273
2310
  const { connection, record } = resolved.link
2274
2311
  const binding: ListResumeBinding = { method: 'agent.agents.list', deviceId: record.deviceId, params: {} }
@@ -2312,7 +2349,7 @@ class CherryToolEngine {
2312
2349
  additionalProperties: false,
2313
2350
  },
2314
2351
  execute: async (args) => {
2315
- const resolved = this.#linkOr(requiredString(args, 'device'))
2352
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
2316
2353
  if (resolved.link === undefined) return resolved.verdict
2317
2354
  const { connection, record } = resolved.link
2318
2355
  const agentId = requiredString(args, 'agentId')
@@ -2367,7 +2404,7 @@ class CherryToolEngine {
2367
2404
  additionalProperties: false,
2368
2405
  },
2369
2406
  execute: async (args) => {
2370
- const resolved = this.#linkOr(requiredString(args, 'device'))
2407
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
2371
2408
  if (resolved.link === undefined) return resolved.verdict
2372
2409
  const { connection, record } = resolved.link
2373
2410
  const agentId = optionalString(args['agentId'])
@@ -2466,7 +2503,7 @@ class CherryToolEngine {
2466
2503
  // a malformed cursor, which would present the newest page again as
2467
2504
  // "continued history" — the refusal has to be ours.
2468
2505
  const resume = parseMessageResume(args['messagesResumeCursor'])
2469
- const resolved = this.#linkOr(requiredString(args, 'device'))
2506
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
2470
2507
  if (resolved.link === undefined) return resolved.verdict
2471
2508
  const { connection, record } = resolved.link
2472
2509
  const sessionId = requiredString(args, 'sessionId')
@@ -3201,7 +3238,7 @@ class CherryToolEngine {
3201
3238
  additionalProperties: false,
3202
3239
  },
3203
3240
  execute: async (args) => {
3204
- const resolved = this.#linkOr(requiredString(args, 'device'))
3241
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
3205
3242
  if (resolved.link === undefined) return resolved.verdict
3206
3243
  const { record } = resolved.link
3207
3244
  const sessionId = requiredString(args, 'sessionId')
@@ -3319,7 +3356,7 @@ class CherryToolEngine {
3319
3356
  additionalProperties: false,
3320
3357
  },
3321
3358
  execute: async (args) => {
3322
- const resolved = this.#linkOr(requiredString(args, 'device'))
3359
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
3323
3360
  if (resolved.link === undefined) return resolved.verdict
3324
3361
  const { connection, record } = resolved.link
3325
3362
 
@@ -3440,7 +3477,7 @@ class CherryToolEngine {
3440
3477
  additionalProperties: false,
3441
3478
  },
3442
3479
  execute: async (args) => {
3443
- const resolved = this.#linkOr(requiredString(args, 'device'))
3480
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
3444
3481
  if (resolved.link === undefined) return resolved.verdict
3445
3482
  const { connection, record } = resolved.link
3446
3483
  const sessionId = requiredString(args, 'sessionId')
@@ -3671,7 +3708,7 @@ class CherryToolEngine {
3671
3708
  additionalProperties: false,
3672
3709
  },
3673
3710
  execute: async (args) => {
3674
- const resolved = this.#linkOr(requiredString(args, 'device'))
3711
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
3675
3712
  if (resolved.link === undefined) return resolved.verdict
3676
3713
  const { connection, record } = resolved.link
3677
3714
  const sessionId = requiredString(args, 'sessionId')
@@ -3835,7 +3872,7 @@ class CherryToolEngine {
3835
3872
  additionalProperties: false,
3836
3873
  },
3837
3874
  execute: async (args) => {
3838
- const resolved = this.#linkOr(requiredString(args, 'device'))
3875
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
3839
3876
  if (resolved.link === undefined) return resolved.verdict
3840
3877
  const { connection, record } = resolved.link
3841
3878
  const sessionId = requiredString(args, 'sessionId')
@@ -3945,7 +3982,7 @@ class CherryToolEngine {
3945
3982
  additionalProperties: false,
3946
3983
  },
3947
3984
  execute: async (args) => {
3948
- const resolved = this.#linkOr(requiredString(args, 'device'))
3985
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
3949
3986
  if (resolved.link === undefined) return resolved.verdict
3950
3987
  const { connection, record } = resolved.link
3951
3988
  const sessionId = requiredString(args, 'sessionId')
@@ -4051,7 +4088,7 @@ class CherryToolEngine {
4051
4088
  additionalProperties: false,
4052
4089
  },
4053
4090
  execute: async (args) => {
4054
- const resolved = this.#linkOr(requiredString(args, 'device'))
4091
+ const resolved = await this.#linkOr(requiredString(args, 'device'))
4055
4092
  if (resolved.link === undefined) return resolved.verdict
4056
4093
  const { connection, record } = resolved.link
4057
4094
  const sessionId = requiredString(args, 'sessionId')
package/ui/index.html CHANGED
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Cherry Studio 互联 — 开发预览</title>
6
+ <title>Cherry 互联 — 开发预览</title>
7
7
  </head>
8
8
  <body>
9
9
  <!--
package/ui/src/i18n.ts CHANGED
@@ -57,7 +57,7 @@ export const LOCALE_IDS: readonly LocaleId[] = ['zh', 'en']
57
57
  */
58
58
  const zh: Dict = {
59
59
  // -- app shell ------------------------------------------------------------
60
- 'app.title': 'Cherry Studio 互联',
60
+ 'app.title': 'Cherry 互联',
61
61
  'app.subtitle':
62
62
  '通过远程传输把本机 DeepSeek Harness 连接到 Cherry Studio 桌面端;设备档案保存在插件数据目录,页面不接触任何凭据。',
63
63
  // The one string that used to reach a Chinese page in English (App.tsx's
@@ -210,7 +210,7 @@ const zh: Dict = {
210
210
  */
211
211
  const en: Dict = {
212
212
  // -- app shell ------------------------------------------------------------
213
- 'app.title': 'Cherry Studio Bridge',
213
+ 'app.title': 'Cherry Bridge',
214
214
  'app.subtitle':
215
215
  '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.',
216
216
  'app.eventSourceUnavailable': 'EventSource is unavailable in this environment',