@blue-soda/dsh-remote 0.5.2 → 0.5.4

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.md CHANGED
@@ -23,7 +23,7 @@ Continue DeepSeek Harness sessions and experimental Codex workspaces from anothe
23
23
  Add the current package version to the `web` profile, then restart Harness:
24
24
 
25
25
  ```sh
26
- dsh plugin --profile web add -w @blue-soda/dsh-remote@0.5.2
26
+ dsh plugin --profile web add -w @blue-soda/dsh-remote@0.5.4
27
27
  ```
28
28
 
29
29
  ### dsh-TUI Host
@@ -31,7 +31,7 @@ dsh plugin --profile web add -w @blue-soda/dsh-remote@0.5.2
31
31
  Remote can also run as a Host in a terminal-only [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI) profile:
32
32
 
33
33
  ```sh
34
- dsh plugin --profile dsh-tui add -w @blue-soda/dsh-remote@0.5.2
34
+ dsh plugin --profile dsh-tui add -w @blue-soda/dsh-remote@0.5.4
35
35
  ```
36
36
 
37
37
  After starting dsh-TUI, manage Remote with `/remote`, `/remote login`, `/remote status`, and `/remote logout`.
@@ -265,7 +265,7 @@
265
265
  reconnectingAction: "Reconnecting\u2026",
266
266
  reconnectStarted: "Reconnect requested.",
267
267
  connectionAuthorizationExpired: "Authorization expired. Sign in again to authorize this device.",
268
- connectionReplaced: "Another instance is using this Host identity. Stop it or use a separate DSH_HOME before reconnecting.",
268
+ connectionReplaced: "Another instance is using this Host identity. Standing by: retrying every five minutes, and taking over as soon as it leaves.",
269
269
  connectionCredentialsBusy: "Credential refresh is locked. Stop other instances. After a crash, stop all instances before removing server-credentials.json.refresh-lock and authorizing again.",
270
270
  connectionDeviceRevoked: "This device was removed on the Server. Sign in again to add it back.",
271
271
  connectionOwnershipRequired: "The Server no longer recognizes this Host as an owned device.",
@@ -328,12 +328,12 @@
328
328
  exitRemote: "Exit",
329
329
  stopReconnecting: "Stop reconnecting",
330
330
  addRemoteWorkspace: "Add remote workspace",
331
- addCodexWorkspace: "Add CodeX workspace",
332
- noCodexWorkspaces: "No CodeX workspaces yet.",
331
+ addCodexWorkspace: "Add Codex workspace",
332
+ noCodexWorkspaces: "No Codex workspaces yet.",
333
333
  cancelAddWorkspace: "Cancel",
334
334
  confirmAddWorkspace: "Add and open",
335
335
  showAllWorkspaces: "Show all DSH workspaces",
336
- showAllCodexWorkspaces: "Show all CodeX workspaces",
336
+ showAllCodexWorkspaces: "Show all Codex workspaces",
337
337
  remoteModeDsh: "Remote DSH \xB7 ",
338
338
  remoteModeCodex: "Remote Codex \xB7 ",
339
339
  remoteNetworkP2p: "P2P",
@@ -430,7 +430,7 @@
430
430
  accountPasswordLogin: "Password",
431
431
  qrLoginExpired: "This QR code expired. Refresh it to continue.",
432
432
  refreshQrCode: "Refresh QR code",
433
- codexVirtualWorkspace: "CodeX virtual workspace",
433
+ codexVirtualWorkspace: "Codex virtual workspace",
434
434
  codexVirtualSessions: "Sessions"
435
435
  }, zh = {
436
436
  terminalRemote: "\u8FDC\u7A0B\u7EC8\u7AEF",
@@ -539,7 +539,7 @@
539
539
  reconnectingAction: "\u6B63\u5728\u91CD\u8FDE\u2026",
540
540
  reconnectStarted: "\u5DF2\u53D1\u8D77\u91CD\u8FDE\u3002",
541
541
  connectionAuthorizationExpired: "\u6388\u6743\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u4EE5\u91CD\u65B0\u6388\u6743\u6B64\u8BBE\u5907\u3002",
542
- connectionReplaced: "\u53E6\u4E00\u5B9E\u4F8B\u6B63\u5728\u4F7F\u7528\u6B64 Host \u8EAB\u4EFD\u3002\u8BF7\u505C\u6B62\u8BE5\u5B9E\u4F8B\uFF0C\u6216\u4F7F\u7528\u72EC\u7ACB\u7684 DSH_HOME \u540E\u91CD\u65B0\u8FDE\u63A5\u3002",
542
+ connectionReplaced: "\u53E6\u4E00\u5B9E\u4F8B\u6B63\u5728\u4F7F\u7528\u6B64 Host \u8EAB\u4EFD\u3002\u672C\u673A\u5DF2\u8FDB\u5165\u5F85\u547D\uFF1A\u6BCF 5 \u5206\u949F\u91CD\u8BD5\u4E00\u6B21\uFF0C\u5BF9\u65B9\u9000\u51FA\u540E\u4F1A\u81EA\u52A8\u63A5\u7BA1\u3002",
543
543
  connectionCredentialsBusy: "\u51ED\u636E\u5237\u65B0\u88AB\u9501\u5B9A\uFF0C\u8BF7\u505C\u6B62\u5176\u4ED6\u5B9E\u4F8B\u3002\u82E5\u6B64\u524D\u5F02\u5E38\u9000\u51FA\uFF0C\u8BF7\u505C\u6B62\u6240\u6709\u5B9E\u4F8B\u540E\u79FB\u9664 server-credentials.json.refresh-lock \u5E76\u91CD\u65B0\u6388\u6743\u3002",
544
544
  connectionDeviceRevoked: "\u6B64\u8BBE\u5907\u5DF2\u5728 Server \u4E0A\u88AB\u79FB\u9664\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u4EE5\u91CD\u65B0\u6DFB\u52A0\u3002",
545
545
  connectionOwnershipRequired: "Server \u5DF2\u4E0D\u518D\u5C06\u6B64 Host \u8BC6\u522B\u4E3A\u5F53\u524D\u8D26\u53F7\u7684\u8BBE\u5907\u3002",
@@ -602,12 +602,12 @@
602
602
  exitRemote: "\u9000\u51FA",
603
603
  stopReconnecting: "\u505C\u6B62\u91CD\u8FDE",
604
604
  addRemoteWorkspace: "\u6DFB\u52A0\u8FDC\u7A0B\u5DE5\u4F5C\u533A",
605
- addCodexWorkspace: "\u6DFB\u52A0 CodeX \u5DE5\u4F5C\u533A",
606
- noCodexWorkspaces: "\u8FD8\u6CA1\u6709 CodeX \u5DE5\u4F5C\u533A\u3002",
605
+ addCodexWorkspace: "\u6DFB\u52A0 Codex \u5DE5\u4F5C\u533A",
606
+ noCodexWorkspaces: "\u8FD8\u6CA1\u6709 Codex \u5DE5\u4F5C\u533A\u3002",
607
607
  cancelAddWorkspace: "\u53D6\u6D88",
608
608
  confirmAddWorkspace: "\u786E\u8BA4\u5E76\u6253\u5F00",
609
609
  showAllWorkspaces: "\u663E\u793A\u5168\u90E8 DSH \u5DE5\u4F5C\u533A",
610
- showAllCodexWorkspaces: "\u663E\u793A\u5168\u90E8 CodeX \u5DE5\u4F5C\u533A",
610
+ showAllCodexWorkspaces: "\u663E\u793A\u5168\u90E8 Codex \u5DE5\u4F5C\u533A",
611
611
  remoteModeDsh: "\u8FDC\u7A0BDSH \xB7 ",
612
612
  remoteModeCodex: "\u8FDC\u7A0BCodex \xB7 ",
613
613
  remoteNetworkP2p: "P2P",
@@ -704,7 +704,7 @@
704
704
  accountPasswordLogin: "\u8D26\u53F7\u5BC6\u7801",
705
705
  qrLoginExpired: "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u5237\u65B0\u540E\u91CD\u8BD5\u3002",
706
706
  refreshQrCode: "\u5237\u65B0\u4E8C\u7EF4\u7801",
707
- codexVirtualWorkspace: "CodeX \u5DE5\u4F5C\u533A",
707
+ codexVirtualWorkspace: "Codex \u5DE5\u4F5C\u533A",
708
708
  codexVirtualSessions: "Sessions"
709
709
  };
710
710
  function codexUnavailableKey(code) {
@@ -1514,7 +1514,12 @@
1514
1514
  targetDeviceId: host.deviceId
1515
1515
  }), codexReason, nextCodexWorkspaces = await props.control("codex.workspaces.list", {
1516
1516
  targetDeviceId: host.deviceId
1517
- }).catch((reason) => (codexReason = remoteErrorMessage(reason, t), [])), nextStatus = await props.control("status").catch(() => {
1517
+ }).catch(async (reason) => {
1518
+ let code = typeof reason?.code == "string" ? String(reason.code) : "";
1519
+ return ["TRANSPORT_CLOSED", "NOT_CONNECTED", "CONNECTION_FAILED", "RPC_TIMEOUT"].includes(code) ? await props.control("codex.workspaces.list", {
1520
+ targetDeviceId: host.deviceId
1521
+ }).catch((again) => (codexReason = remoteErrorMessage(again, t), [])) : (codexReason = remoteErrorMessage(reason, t), []);
1522
+ }), nextStatus = await props.control("status").catch(() => {
1518
1523
  });
1519
1524
  return nextStatus !== void 0 && setStatus(nextStatus), setCodexNotice(codexReason), { workspaces: nextWorkspaces, codexWorkspaces: nextCodexWorkspaces, status: nextStatus };
1520
1525
  },
@@ -2219,7 +2224,7 @@
2219
2224
  )
2220
2225
  ),
2221
2226
  notice === void 0 ? null : React.createElement("p", { className: "dshRemoteNotice", role: "status" }, notice),
2222
- error === void 0 ? null : React.createElement("p", { className: "dshRemoteError", role: "alert" }, error)
2227
+ error === void 0 || error === codexNotice ? null : React.createElement("p", { className: "dshRemoteError", role: "alert" }, error)
2223
2228
  )
2224
2229
  )) : null
2225
2230
  );
package/dist/client.js CHANGED
@@ -265,7 +265,7 @@
265
265
  reconnectingAction: "Reconnecting\u2026",
266
266
  reconnectStarted: "Reconnect requested.",
267
267
  connectionAuthorizationExpired: "Authorization expired. Sign in again to authorize this device.",
268
- connectionReplaced: "Another instance is using this Host identity. Stop it or use a separate DSH_HOME before reconnecting.",
268
+ connectionReplaced: "Another instance is using this Host identity. Standing by: retrying every five minutes, and taking over as soon as it leaves.",
269
269
  connectionCredentialsBusy: "Credential refresh is locked. Stop other instances. After a crash, stop all instances before removing server-credentials.json.refresh-lock and authorizing again.",
270
270
  connectionDeviceRevoked: "This device was removed on the Server. Sign in again to add it back.",
271
271
  connectionOwnershipRequired: "The Server no longer recognizes this Host as an owned device.",
@@ -328,12 +328,12 @@
328
328
  exitRemote: "Exit",
329
329
  stopReconnecting: "Stop reconnecting",
330
330
  addRemoteWorkspace: "Add remote workspace",
331
- addCodexWorkspace: "Add CodeX workspace",
332
- noCodexWorkspaces: "No CodeX workspaces yet.",
331
+ addCodexWorkspace: "Add Codex workspace",
332
+ noCodexWorkspaces: "No Codex workspaces yet.",
333
333
  cancelAddWorkspace: "Cancel",
334
334
  confirmAddWorkspace: "Add and open",
335
335
  showAllWorkspaces: "Show all DSH workspaces",
336
- showAllCodexWorkspaces: "Show all CodeX workspaces",
336
+ showAllCodexWorkspaces: "Show all Codex workspaces",
337
337
  remoteModeDsh: "Remote DSH \xB7 ",
338
338
  remoteModeCodex: "Remote Codex \xB7 ",
339
339
  remoteNetworkP2p: "P2P",
@@ -430,7 +430,7 @@
430
430
  accountPasswordLogin: "Password",
431
431
  qrLoginExpired: "This QR code expired. Refresh it to continue.",
432
432
  refreshQrCode: "Refresh QR code",
433
- codexVirtualWorkspace: "CodeX virtual workspace",
433
+ codexVirtualWorkspace: "Codex virtual workspace",
434
434
  codexVirtualSessions: "Sessions"
435
435
  }, zh = {
436
436
  terminalRemote: "\u8FDC\u7A0B\u7EC8\u7AEF",
@@ -539,7 +539,7 @@
539
539
  reconnectingAction: "\u6B63\u5728\u91CD\u8FDE\u2026",
540
540
  reconnectStarted: "\u5DF2\u53D1\u8D77\u91CD\u8FDE\u3002",
541
541
  connectionAuthorizationExpired: "\u6388\u6743\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u4EE5\u91CD\u65B0\u6388\u6743\u6B64\u8BBE\u5907\u3002",
542
- connectionReplaced: "\u53E6\u4E00\u5B9E\u4F8B\u6B63\u5728\u4F7F\u7528\u6B64 Host \u8EAB\u4EFD\u3002\u8BF7\u505C\u6B62\u8BE5\u5B9E\u4F8B\uFF0C\u6216\u4F7F\u7528\u72EC\u7ACB\u7684 DSH_HOME \u540E\u91CD\u65B0\u8FDE\u63A5\u3002",
542
+ connectionReplaced: "\u53E6\u4E00\u5B9E\u4F8B\u6B63\u5728\u4F7F\u7528\u6B64 Host \u8EAB\u4EFD\u3002\u672C\u673A\u5DF2\u8FDB\u5165\u5F85\u547D\uFF1A\u6BCF 5 \u5206\u949F\u91CD\u8BD5\u4E00\u6B21\uFF0C\u5BF9\u65B9\u9000\u51FA\u540E\u4F1A\u81EA\u52A8\u63A5\u7BA1\u3002",
543
543
  connectionCredentialsBusy: "\u51ED\u636E\u5237\u65B0\u88AB\u9501\u5B9A\uFF0C\u8BF7\u505C\u6B62\u5176\u4ED6\u5B9E\u4F8B\u3002\u82E5\u6B64\u524D\u5F02\u5E38\u9000\u51FA\uFF0C\u8BF7\u505C\u6B62\u6240\u6709\u5B9E\u4F8B\u540E\u79FB\u9664 server-credentials.json.refresh-lock \u5E76\u91CD\u65B0\u6388\u6743\u3002",
544
544
  connectionDeviceRevoked: "\u6B64\u8BBE\u5907\u5DF2\u5728 Server \u4E0A\u88AB\u79FB\u9664\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u4EE5\u91CD\u65B0\u6DFB\u52A0\u3002",
545
545
  connectionOwnershipRequired: "Server \u5DF2\u4E0D\u518D\u5C06\u6B64 Host \u8BC6\u522B\u4E3A\u5F53\u524D\u8D26\u53F7\u7684\u8BBE\u5907\u3002",
@@ -602,12 +602,12 @@
602
602
  exitRemote: "\u9000\u51FA",
603
603
  stopReconnecting: "\u505C\u6B62\u91CD\u8FDE",
604
604
  addRemoteWorkspace: "\u6DFB\u52A0\u8FDC\u7A0B\u5DE5\u4F5C\u533A",
605
- addCodexWorkspace: "\u6DFB\u52A0 CodeX \u5DE5\u4F5C\u533A",
606
- noCodexWorkspaces: "\u8FD8\u6CA1\u6709 CodeX \u5DE5\u4F5C\u533A\u3002",
605
+ addCodexWorkspace: "\u6DFB\u52A0 Codex \u5DE5\u4F5C\u533A",
606
+ noCodexWorkspaces: "\u8FD8\u6CA1\u6709 Codex \u5DE5\u4F5C\u533A\u3002",
607
607
  cancelAddWorkspace: "\u53D6\u6D88",
608
608
  confirmAddWorkspace: "\u786E\u8BA4\u5E76\u6253\u5F00",
609
609
  showAllWorkspaces: "\u663E\u793A\u5168\u90E8 DSH \u5DE5\u4F5C\u533A",
610
- showAllCodexWorkspaces: "\u663E\u793A\u5168\u90E8 CodeX \u5DE5\u4F5C\u533A",
610
+ showAllCodexWorkspaces: "\u663E\u793A\u5168\u90E8 Codex \u5DE5\u4F5C\u533A",
611
611
  remoteModeDsh: "\u8FDC\u7A0BDSH \xB7 ",
612
612
  remoteModeCodex: "\u8FDC\u7A0BCodex \xB7 ",
613
613
  remoteNetworkP2p: "P2P",
@@ -704,7 +704,7 @@
704
704
  accountPasswordLogin: "\u8D26\u53F7\u5BC6\u7801",
705
705
  qrLoginExpired: "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u5237\u65B0\u540E\u91CD\u8BD5\u3002",
706
706
  refreshQrCode: "\u5237\u65B0\u4E8C\u7EF4\u7801",
707
- codexVirtualWorkspace: "CodeX \u5DE5\u4F5C\u533A",
707
+ codexVirtualWorkspace: "Codex \u5DE5\u4F5C\u533A",
708
708
  codexVirtualSessions: "Sessions"
709
709
  };
710
710
  function codexUnavailableKey(code) {
@@ -1514,7 +1514,12 @@
1514
1514
  targetDeviceId: host.deviceId
1515
1515
  }), codexReason, nextCodexWorkspaces = await props.control("codex.workspaces.list", {
1516
1516
  targetDeviceId: host.deviceId
1517
- }).catch((reason) => (codexReason = remoteErrorMessage(reason, t), [])), nextStatus = await props.control("status").catch(() => {
1517
+ }).catch(async (reason) => {
1518
+ let code = typeof reason?.code == "string" ? String(reason.code) : "";
1519
+ return ["TRANSPORT_CLOSED", "NOT_CONNECTED", "CONNECTION_FAILED", "RPC_TIMEOUT"].includes(code) ? await props.control("codex.workspaces.list", {
1520
+ targetDeviceId: host.deviceId
1521
+ }).catch((again) => (codexReason = remoteErrorMessage(again, t), [])) : (codexReason = remoteErrorMessage(reason, t), []);
1522
+ }), nextStatus = await props.control("status").catch(() => {
1518
1523
  });
1519
1524
  return nextStatus !== void 0 && setStatus(nextStatus), setCodexNotice(codexReason), { workspaces: nextWorkspaces, codexWorkspaces: nextCodexWorkspaces, status: nextStatus };
1520
1525
  },
@@ -2219,7 +2224,7 @@
2219
2224
  )
2220
2225
  ),
2221
2226
  notice === void 0 ? null : React.createElement("p", { className: "dshRemoteNotice", role: "status" }, notice),
2222
- error === void 0 ? null : React.createElement("p", { className: "dshRemoteError", role: "alert" }, error)
2227
+ error === void 0 || error === codexNotice ? null : React.createElement("p", { className: "dshRemoteError", role: "alert" }, error)
2223
2228
  )
2224
2229
  )) : null
2225
2230
  );
package/dist/index.js CHANGED
@@ -13162,7 +13162,7 @@ function fromBase64Url2(value) {
13162
13162
  import { platform } from "node:os";
13163
13163
 
13164
13164
  // src/server-credentials.ts
13165
- import { chmod, mkdir, readFile, rm as rm2, stat as stat2, writeFile } from "node:fs/promises";
13165
+ import { chmod, mkdir, readFile, rename as rename2, rm as rm2, stat as stat2, writeFile } from "node:fs/promises";
13166
13166
  import { dirname, join as join2 } from "node:path";
13167
13167
 
13168
13168
  // src/atomic-file.ts
@@ -13237,6 +13237,11 @@ function uuidV7(now = Date.now()) {
13237
13237
  }
13238
13238
 
13239
13239
  // src/server-credentials.ts
13240
+ var LOCK_ABANDONED_MS = 10 * 6e4;
13241
+ var lockOwnerSchema = external_exports.object({
13242
+ pid: external_exports.number().int().positive(),
13243
+ at: external_exports.number().int().nonnegative()
13244
+ });
13240
13245
  var credentialSchema = external_exports.object({
13241
13246
  schemaVersion: external_exports.literal(1),
13242
13247
  serverUrl: external_exports.string().url(),
@@ -13254,8 +13259,13 @@ var ServerCredentialStore = class {
13254
13259
  this.path = join2(directory, "server-credentials.json");
13255
13260
  }
13256
13261
  /** Serialize the complete read/refresh/write transaction across processes.
13257
- * Never steal an old lock: a suspended owner may still consume a one-use token.
13258
- * After a crash, stop all instances before removing the orphaned lock.
13262
+ *
13263
+ * A lock left by a process that no longer exists can never be released by that process, and being killed
13264
+ * mid-refresh is routine on a phone (the app is backgrounded or swept away) - the old rule then left the user
13265
+ * with `SERVER_CREDENTIALS_BUSY` forever and told them to delete a file they cannot reach. So the lock now
13266
+ * records its owner and a contender reclaims it when that owner is **gone**. Liveness is what is checked, not
13267
+ * age: an owner that is merely suspended still protects its one-use refresh token, which is exactly what the
13268
+ * old unconditional rule was guarding against.
13259
13269
  */
13260
13270
  async withRefreshLock(operation) {
13261
13271
  const lock = `${this.path}.refresh-lock`;
@@ -13263,10 +13273,11 @@ var ServerCredentialStore = class {
13263
13273
  const deadline = Date.now() + 15e3;
13264
13274
  for (; ; ) {
13265
13275
  try {
13266
- await mkdir(lock, { mode: 448 });
13276
+ await claimRefreshLock(lock);
13267
13277
  break;
13268
13278
  } catch (error) {
13269
13279
  if (!(error instanceof Error) || !("code" in error) || error.code !== "EEXIST") throw error;
13280
+ if (await reclaimAbandonedRefreshLock(lock)) continue;
13270
13281
  if (Date.now() >= deadline) throw new ServerCredentialsBusyError();
13271
13282
  await new Promise((resolve4) => setTimeout(resolve4, 50));
13272
13283
  }
@@ -13274,7 +13285,7 @@ var ServerCredentialStore = class {
13274
13285
  try {
13275
13286
  return await operation();
13276
13287
  } finally {
13277
- await rm2(lock, { recursive: true });
13288
+ await rm2(lock, { recursive: true, force: true });
13278
13289
  }
13279
13290
  }
13280
13291
  async load(serverUrl, deviceId) {
@@ -13307,6 +13318,49 @@ var ServerCredentialsBusyError = class extends Error {
13307
13318
  super("Credential refresh is locked. Stop other instances; after a crash, stop all instances before removing server-credentials.json.refresh-lock and authorizing again.");
13308
13319
  }
13309
13320
  };
13321
+ async function claimRefreshLock(lock) {
13322
+ await mkdir(lock, { mode: 448 });
13323
+ await writeFile(join2(lock, "owner.json"), `${JSON.stringify({ pid: process.pid, at: Date.now() })}
13324
+ `, {
13325
+ encoding: "utf8",
13326
+ mode: 384
13327
+ });
13328
+ }
13329
+ async function reclaimAbandonedRefreshLock(lock) {
13330
+ const owner = await readRefreshLockOwner(lock);
13331
+ const abandoned = owner === void 0 ? await olderThan(lock, LOCK_ABANDONED_MS) : !processAlive(owner.pid);
13332
+ if (!abandoned) return false;
13333
+ const tomb = `${lock}.${process.pid}.${uuidV7()}.abandoned`;
13334
+ try {
13335
+ await rename2(lock, tomb);
13336
+ } catch {
13337
+ return false;
13338
+ }
13339
+ await rm2(tomb, { recursive: true, force: true });
13340
+ return true;
13341
+ }
13342
+ async function readRefreshLockOwner(lock) {
13343
+ try {
13344
+ return lockOwnerSchema.parse(JSON.parse(await readFile(join2(lock, "owner.json"), "utf8")));
13345
+ } catch {
13346
+ return void 0;
13347
+ }
13348
+ }
13349
+ function processAlive(pid) {
13350
+ try {
13351
+ process.kill(pid, 0);
13352
+ return true;
13353
+ } catch (error) {
13354
+ return !(error instanceof Error && "code" in error && error.code === "ESRCH");
13355
+ }
13356
+ }
13357
+ async function olderThan(path, ageMs) {
13358
+ try {
13359
+ return Date.now() - (await stat2(path)).mtimeMs > ageMs;
13360
+ } catch {
13361
+ return false;
13362
+ }
13363
+ }
13310
13364
  async function atomicWrite(path, contents) {
13311
13365
  const directory = dirname(path);
13312
13366
  await mkdir(directory, { recursive: true, mode: 448 });
@@ -13426,7 +13480,12 @@ function resolveConfig(input2 = {}, env = process.env) {
13426
13480
  enabled: parsed.reconnect !== false,
13427
13481
  initialDelayMs,
13428
13482
  maxDelayMs,
13429
- jitter: reconnect.jitter ?? 0.2
13483
+ jitter: reconnect.jitter ?? 0.2,
13484
+ // A displaced connection is a standby: another instance holds this device identity, so it waits five
13485
+ // minutes - well outside the Server's 40-65 s reap window - and then tries again, taking over as soon
13486
+ // as that instance leaves. Giving up for good is how the device stayed offline after the winner left.
13487
+ // The fixture matrix needs a short interval to watch a standby take over, so an operator can override it.
13488
+ replacedRetryMs: reconnect.replacedRetryMs ?? (Number.isFinite(Number(env.DSH_REMOTE_RECONNECT_REPLACED_RETRY_MS)) ? Number(env.DSH_REMOTE_RECONNECT_REPLACED_RETRY_MS) : 3e5)
13430
13489
  },
13431
13490
  codex: {
13432
13491
  // Off by default (2026-10-14). Carrying another agent's workspaces is a capability the user opts into, not
@@ -13462,7 +13521,7 @@ function normalizeServerUrl(value) {
13462
13521
  }
13463
13522
 
13464
13523
  // src/version.ts
13465
- var PLUGIN_VERSION = "0.5.2";
13524
+ var PLUGIN_VERSION = "0.5.4";
13466
13525
 
13467
13526
  // src/server-api.ts
13468
13527
  var ENABLED_QR_PROVIDERS = ["github"];
@@ -14026,15 +14085,21 @@ var HostServerConnection = class {
14026
14085
  }
14027
14086
  async run() {
14028
14087
  let delayMs = this.config.reconnect.initialDelayMs;
14088
+ let replacedRetryMs;
14089
+ let replacedAttempts = 0;
14029
14090
  this.authRecoveryAttempted = false;
14030
14091
  while (!this.stopped) {
14031
14092
  try {
14032
14093
  await this.connectOnce();
14033
14094
  delayMs = this.config.reconnect.initialDelayMs;
14095
+ replacedRetryMs = void 0;
14096
+ replacedAttempts = 0;
14034
14097
  } catch (error) {
14035
14098
  const code = errorCode2(error);
14036
14099
  if (code === "CREDENTIALS_REFRESHED") continue;
14037
14100
  this.terminalError = code;
14101
+ replacedRetryMs = code === "CONNECTION_REPLACED" ? replacedAttempts === 0 ? Math.min(this.config.reconnect.replacedRetryMs, REPLACED_FIRST_RETRY_MS) : this.config.reconnect.replacedRetryMs : void 0;
14102
+ replacedAttempts = code === "CONNECTION_REPLACED" ? replacedAttempts + 1 : 0;
14038
14103
  this.logger.warn("server control connection failed", {
14039
14104
  code,
14040
14105
  retryable: isRetryable(error),
@@ -14042,7 +14107,7 @@ var HostServerConnection = class {
14042
14107
  ...connectionFailureDetail(error)
14043
14108
  });
14044
14109
  if (TERMINAL_AUTH_ERRORS.has(code)) {
14045
- this.logger.warn(code === "CONNECTION_REPLACED" ? "Another instance is using this Host identity. Stop it or use a separate DSH_HOME; automatic reconnect is paused." : code === "SERVER_CREDENTIALS_BUSY" ? "Credential refresh is locked. Stop other instances; after a crash, stop all instances before removing server-credentials.json.refresh-lock and authorizing again." : "Host authorization failed. Run /remote login or authorize this Host again in Remote settings.");
14110
+ this.logger.warn(code === "CONNECTION_REPLACED" ? "Another instance is using this Host identity. Standing by: retrying in five minutes, and taking over as soon as it leaves." : code === "SERVER_CREDENTIALS_BUSY" ? "Credential refresh is locked. Stop other instances; after a crash, stop all instances before removing server-credentials.json.refresh-lock and authorizing again." : "Host authorization failed. Run /remote login or authorize this Host again in Remote settings.");
14046
14111
  }
14047
14112
  if (code === "DEVICE_REVOKED") {
14048
14113
  try {
@@ -14060,7 +14125,7 @@ var HostServerConnection = class {
14060
14125
  continue;
14061
14126
  }
14062
14127
  if (!this.config.reconnect.enabled) return;
14063
- await this.waitBeforeRetry(delayMs);
14128
+ await (replacedRetryMs === void 0 ? this.waitBeforeRetry(delayMs) : this.waitBeforeRetry(replacedRetryMs, true));
14064
14129
  if (this.reconnectRequested) {
14065
14130
  this.reconnectRequested = false;
14066
14131
  delayMs = this.config.reconnect.initialDelayMs;
@@ -14624,8 +14689,8 @@ var HostServerConnection = class {
14624
14689
  tunnel.noise.destroy();
14625
14690
  }
14626
14691
  }
14627
- waitBeforeRetry(baseDelay) {
14628
- const spread = baseDelay * this.config.reconnect.jitter;
14692
+ waitBeforeRetry(baseDelay, exact = false) {
14693
+ const spread = exact ? 0 : baseDelay * this.config.reconnect.jitter;
14629
14694
  const delay = Math.max(0, Math.round(baseDelay - spread + Math.random() * spread * 2));
14630
14695
  return new Promise((resolve4) => {
14631
14696
  const timer = setTimeout(() => {
@@ -14639,8 +14704,8 @@ var HostServerConnection = class {
14639
14704
  });
14640
14705
  }
14641
14706
  };
14707
+ var REPLACED_FIRST_RETRY_MS = 65e3;
14642
14708
  var TERMINAL_AUTH_ERRORS = /* @__PURE__ */ new Set([
14643
- "CONNECTION_REPLACED",
14644
14709
  "SERVER_CREDENTIALS_BUSY",
14645
14710
  "ACCOUNT_AUTH_REQUIRED",
14646
14711
  "AUTH_INVALID",
@@ -20291,8 +20356,12 @@ var TypertGatewaySwitch = class {
20291
20356
  installed = false;
20292
20357
  /** Defaults to reachable so a caller that never wires this keeps the old behaviour. */
20293
20358
  remoteAvailability = () => true;
20294
- constructor(gateway) {
20359
+ /** Supplies the peer carrier calls speak for; see {@link ownerPeer}. */
20360
+ peerFor;
20361
+ peer;
20362
+ constructor(gateway, options = {}) {
20295
20363
  this.runtime = gateway;
20364
+ this.peerFor = options.peer;
20296
20365
  this.originalInvoke = gateway.invoke;
20297
20366
  this.localInvoke = this.originalInvoke.bind(gateway);
20298
20367
  this.originalStream = gateway.stream;
@@ -20300,18 +20369,42 @@ var TypertGatewaySwitch = class {
20300
20369
  this.originalDispatch = this.runtime.dispatchRpc;
20301
20370
  this.localDispatch = this.runtime.dispatchRpc?.bind(gateway);
20302
20371
  this.originalOpen = this.runtime.openWireStream;
20303
- this.localOpen = createLocalOpen(gateway, this.runtime);
20372
+ this.localOpen = createLocalOpen(gateway, this.runtime, () => this.ownerPeer());
20373
+ }
20374
+ /**
20375
+ * The peer a carrier call speaks for, created once and reused.
20376
+ *
20377
+ * dsh 0.2.1-rc.2 reads `peer.signal` on every carrier call and rejects one that arrives without a peer; a remote
20378
+ * client runs with the Host owner's authority (decided 2026-10-19), which is the same principal dsh gives its own
20379
+ * browser peer, so the plugin serves remote calls as the owner. Releases before rc.2 ignore the argument, and a
20380
+ * profile without a `connection` service simply has no peer to offer.
20381
+ */
20382
+ ownerPeer() {
20383
+ if (this.peer !== void 0) return this.peer;
20384
+ this.peer = this.peerFor?.();
20385
+ return this.peer;
20386
+ }
20387
+ /** Trailing argument the carrier wants when the caller did not supply one. */
20388
+ peerArguments() {
20389
+ const peer = this.ownerPeer();
20390
+ return peer === void 0 ? [] : [peer];
20304
20391
  }
20305
20392
  /** Original local dispatcher, used by the Host bridge without switch recursion. */
20306
20393
  local() {
20307
- const dispatch = this.localDispatch ?? (async (endpoint, payload, signal) => {
20394
+ const bare = this.localDispatch;
20395
+ const dispatch = bare === void 0 ? (async (endpoint, payload, signal) => {
20308
20396
  try {
20309
20397
  const request = requestFromCarrier(endpoint, payload, signal);
20310
20398
  return { ok: true, value: await this.localInvoke(request) };
20311
20399
  } catch (error) {
20312
20400
  return { ok: false, error: this.failure(error) };
20313
20401
  }
20314
- });
20402
+ }) : (endpoint, payload, signal, ...rest) => Promise.resolve(bare(
20403
+ endpoint,
20404
+ payload,
20405
+ signal,
20406
+ ...rest.length === 0 ? this.peerArguments() : rest
20407
+ ));
20315
20408
  const open = this.localOpen ?? (async (endpoint, payload, signal) => {
20316
20409
  if (this.localStream === void 0) throw new Error("The local Harness Gateway does not support Remote streams.");
20317
20410
  return this.localStream(requestFromCarrier(endpoint, payload, signal));
@@ -20346,17 +20439,17 @@ var TypertGatewaySwitch = class {
20346
20439
  );
20347
20440
  }
20348
20441
  if (previousDispatch !== void 0) {
20349
- this.runtime.dispatchRpc = (endpoint, payload, signal) => forwards(endpoint) ? Promise.resolve(previousDispatch(endpoint, payload, signal)).catch((error) => {
20442
+ this.runtime.dispatchRpc = (endpoint, payload, signal, ...rest) => forwards(endpoint) ? Promise.resolve(previousDispatch(endpoint, payload, signal, ...rest)).catch((error) => {
20350
20443
  console.warn("[dsh-remote] local call failed with no remote target selected", {
20351
20444
  endpoint,
20352
20445
  code: typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0,
20353
20446
  message: error instanceof Error ? error.message : String(error)
20354
20447
  });
20355
20448
  throw error;
20356
- }) : !this.routesToRemote(endpoint) ? this.localDispatch(endpoint, payload, signal) : this.withLocalFallback(
20449
+ }) : !this.routesToRemote(endpoint) ? this.localDispatch(endpoint, payload, signal, ...rest) : this.withLocalFallback(
20357
20450
  endpoint,
20358
20451
  () => Promise.resolve(this.remoteTarget.dispatch(endpoint, payload, signal)),
20359
- () => this.localDispatch(endpoint, payload, signal)
20452
+ () => this.localDispatch(endpoint, payload, signal, ...rest)
20360
20453
  ).then((result) => normalizeByteResult3(endpoint, result));
20361
20454
  }
20362
20455
  if (previousOpen !== void 0) {
@@ -20495,17 +20588,17 @@ function linkControl(signal) {
20495
20588
  else signal.addEventListener("abort", () => control.abort(signal.reason), { once: true });
20496
20589
  return control;
20497
20590
  }
20498
- function createLocalOpen(gateway, runtime) {
20591
+ function createLocalOpen(gateway, runtime, peer = () => void 0) {
20499
20592
  const open = runtime.openWireStream;
20500
20593
  if (open !== void 0) {
20501
- return usesRc1Arity(open) ? (endpoint, payload, signal) => open.call(runtime, endpoint, payload, endedUplink(), void 0, signal, linkControl(signal)) : (endpoint, payload, signal) => open.call(runtime, endpoint, payload, signal);
20594
+ return usesRc1Arity(open) ? (endpoint, payload, signal) => open.call(runtime, endpoint, payload, endedUplink(), peer(), signal, linkControl(signal)) : (endpoint, payload, signal) => open.call(runtime, endpoint, payload, signal);
20502
20595
  }
20503
20596
  const wire = gateway.wireStream;
20504
20597
  if (wire === void 0) return void 0;
20505
20598
  const wireOpen = wire.open;
20506
20599
  if (usesRc1Arity(wireOpen)) {
20507
20600
  const rc1 = wireOpen;
20508
- return (endpoint, payload, signal) => rc1.call(wire, endpoint, payload, endedUplink(), void 0, signal);
20601
+ return (endpoint, payload, signal) => rc1.call(wire, endpoint, payload, endedUplink(), peer(), signal);
20509
20602
  }
20510
20603
  const legacy = wireOpen;
20511
20604
  return (endpoint, payload, signal) => legacy.call(wire, endpoint, payload, signal);
@@ -21673,7 +21766,7 @@ var QUICK_RECONNECT_RETRY_DELAY_MS = 5e3;
21673
21766
  var QUICK_RECONNECT_FORCE_RELAY = true;
21674
21767
  var BACKGROUND_RETRY_DELAYS_MS = [5e3, 1e4, 2e4, 3e4, 3e4, 3e4, 3e4];
21675
21768
  var ClientModeRuntime = class {
21676
- constructor(config, identities, server, apiProxy, typertGateway, logger, host, rtcFactoryProvider = loadNodeRtcFactory, targetStore) {
21769
+ constructor(config, identities, server, apiProxy, typertGateway, logger, host, rtcFactoryProvider = loadNodeRtcFactory, targetStore, peer) {
21677
21770
  this.config = config;
21678
21771
  this.identities = identities;
21679
21772
  this.server = server;
@@ -21681,8 +21774,9 @@ var ClientModeRuntime = class {
21681
21774
  this.host = host;
21682
21775
  this.rtcFactoryProvider = rtcFactoryProvider;
21683
21776
  this.targetStore = targetStore;
21777
+ this.peer = peer;
21684
21778
  this.proxySwitch = apiProxy === void 0 ? void 0 : new ApiProxySwitch(apiProxy);
21685
- this.gatewaySwitch = new TypertGatewaySwitch(typertGateway);
21779
+ this.gatewaySwitch = new TypertGatewaySwitch(typertGateway, { peer: this.peer });
21686
21780
  this.gatewaySwitch.setRemoteAvailability(() => this.connected !== void 0);
21687
21781
  }
21688
21782
  preview;
@@ -21994,9 +22088,26 @@ var ClientModeRuntime = class {
21994
22088
  }
21995
22089
  async setHostAuthorization(enabled) {
21996
22090
  if (this.host === void 0) throw new ClientModeError("METHOD_NOT_ALLOWED", "This plugin is not running as a Host.");
21997
- await this.host.setHostControl?.(enabled);
21998
- if (enabled) await this.host.resumeHostConnection?.();
21999
- else await this.host.pauseHostConnection?.();
22091
+ if (!enabled) {
22092
+ await this.host.setHostControl?.(false);
22093
+ await this.host.pauseHostConnection?.();
22094
+ return this.status();
22095
+ }
22096
+ let flagError;
22097
+ try {
22098
+ await this.host.setHostControl?.(true);
22099
+ } catch (error) {
22100
+ flagError = error;
22101
+ }
22102
+ await this.host.resumeHostConnection?.();
22103
+ try {
22104
+ await this.host.setHostControl?.(true);
22105
+ } catch (error) {
22106
+ this.logger.warn("could not tell the Server that this Host accepts control; retrying when it reconnects", {
22107
+ code: safeErrorCode(error),
22108
+ ...flagError === void 0 ? {} : { firstCode: safeErrorCode(flagError) }
22109
+ });
22110
+ }
22000
22111
  return this.status();
22001
22112
  }
22002
22113
  async setMode(mode, targetDeviceId, signal) {
@@ -22432,6 +22543,22 @@ var ClientModeRuntime = class {
22432
22543
  return;
22433
22544
  }
22434
22545
  const transportLost = reason !== "codex-closed";
22546
+ if (String(reason) === "CONNECTION_REPLACED" && this.reconnecting === void 0) {
22547
+ const run = this.remoteReconnectRun;
22548
+ this.reconnecting = { targetDeviceId, ...targetName === void 0 ? {} : { targetName }, phase: "fast" };
22549
+ this.logger.info("the link was closed by this client's own connection replacement; retrying in place", {
22550
+ targetDeviceId: shortId2(targetDeviceId),
22551
+ kind
22552
+ });
22553
+ const rescued = await this.quickReconnectSession(targetDeviceId, target2, transportLost, run);
22554
+ if (run !== this.remoteReconnectRun) return;
22555
+ if (rescued) {
22556
+ this.finishRecovery(targetDeviceId, "replacement", kind);
22557
+ return;
22558
+ }
22559
+ void this.reconnectRemoteSession(targetDeviceId, target2);
22560
+ return;
22561
+ }
22435
22562
  if (this.reconnecting === void 0) {
22436
22563
  const run = this.remoteReconnectRun;
22437
22564
  this.reconnecting = { targetDeviceId, ...targetName === void 0 ? {} : { targetName }, phase: "fast" };
@@ -23144,7 +23271,26 @@ var ClientModeRuntime = class {
23144
23271
  throw error;
23145
23272
  }
23146
23273
  }
23274
+ /**
23275
+ * One attempt at a time per target: the panel's refresh and the automatic reconnect can ask at the same moment, and
23276
+ * two control connections in flight make the Server close the older one with 4003 (`CONNECTION_REPLACED`), which
23277
+ * also kills the link that was being built on it - what the user saw as being kicked out of the card.
23278
+ */
23279
+ inFlightConnect;
23147
23280
  async connect(targetDeviceId, signal, forceRelay = false) {
23281
+ const key = `${targetDeviceId}|${forceRelay ? "relay" : "auto"}`;
23282
+ const inFlight = this.inFlightConnect;
23283
+ if (inFlight?.key === key) {
23284
+ signal?.throwIfAborted();
23285
+ return await inFlight.promise;
23286
+ }
23287
+ const promise = this.connectNow(targetDeviceId, signal, forceRelay).finally(() => {
23288
+ if (this.inFlightConnect?.promise === promise) this.inFlightConnect = void 0;
23289
+ });
23290
+ this.inFlightConnect = { key, promise };
23291
+ return await promise;
23292
+ }
23293
+ async connectNow(targetDeviceId, signal, forceRelay = false) {
23148
23294
  signal?.throwIfAborted();
23149
23295
  const progressRunId = this.connectionProgressRun + 1;
23150
23296
  this.connectionProgressRun = progressRunId;
@@ -29911,7 +30057,7 @@ function remoteStatusLines(target2) {
29911
30057
  `Authorization: ${status2.authorized ? status2.account === void 0 ? "logged in" : `logged in (${status2.account})` : "logged out"}`,
29912
30058
  `Server connection: ${connection}`,
29913
30059
  ...status2.error === void 0 ? [] : [`Connection error: ${status2.error}`],
29914
- ...status2.error === "CONNECTION_REPLACED" ? ["Another instance is using this Host identity. Stop it or use a separate DSH_HOME before reconnecting."] : status2.error === "SERVER_CREDENTIALS_BUSY" ? ["Credential refresh is locked. Stop all instances before removing an orphaned server-credentials.json.refresh-lock and authorizing again."] : status2.accountRequired ? ["Authorize again with /remote login [github|zhihu]."] : [],
30060
+ ...status2.error === "CONNECTION_REPLACED" ? ["Another instance is using this Host identity. Standing by: retrying every five minutes, and taking over as soon as it leaves."] : status2.error === "SERVER_CREDENTIALS_BUSY" ? ["Credential refresh is locked. Stop all instances before removing an orphaned server-credentials.json.refresh-lock and authorizing again."] : status2.accountRequired ? ["Authorize again with /remote login [github|zhihu]."] : [],
29915
30061
  `Harness Remote API: ${capabilities.has("harness.api.v1") ? "available (ApiProxy)" : capabilities.has("harness.remote.v3") ? "available (Typert Remote Session V3)" : capabilities.has("harness.remote.v1") ? "available (Typert Remote)" : "unavailable"}`,
29916
30062
  `Remote clients: ${diagnostics.activeConnections}`,
29917
30063
  `Codex Remote: ${codex.enabled ? codex.state : "disabled"}`,
@@ -30269,8 +30415,18 @@ async function activate(ctx, readConfig, entryId, tuiBinding) {
30269
30415
  const deviceDirectory = config.serverUrl === void 0 ? defaultIdentityDirectory : await ensureDeviceDirectory(defaultIdentityDirectory, config.serverUrl);
30270
30416
  const hostIdentities = new IdentityStore({ directory: deviceDirectory });
30271
30417
  const apiProxy = ctx.get("apiProxy");
30418
+ const connectionService = ctx.get("connection", false);
30419
+ let ownerPeerResolved = false;
30420
+ let ownerPeer;
30421
+ const peerFor = () => {
30422
+ if (!ownerPeerResolved) {
30423
+ ownerPeerResolved = true;
30424
+ ownerPeer = connectionService?.createPeer?.({ principal: { kind: "owner" } });
30425
+ }
30426
+ return ownerPeer;
30427
+ };
30272
30428
  const nativeTypertGateway = ctx.get("typertGateway");
30273
- const localTypertGateway = new TypertGatewaySwitch(nativeTypertGateway).local();
30429
+ const localTypertGateway = new TypertGatewaySwitch(nativeTypertGateway, { peer: peerFor }).local();
30274
30430
  const subprocess = ctx.get("subprocess", false);
30275
30431
  const deepseekAccount = ctx.get("deepseekAccount", false);
30276
30432
  const deepseekSession = deepseekAccount === void 0 ? void 0 : {
@@ -30319,7 +30475,8 @@ async function activate(ctx, readConfig, entryId, tuiBinding) {
30319
30475
  hostControl,
30320
30476
  // The RTC factory keeps its default; the target store lives beside the plugin state.
30321
30477
  void 0,
30322
- new ClientTargetStore(defaultIdentityDirectory)
30478
+ new ClientTargetStore(defaultIdentityDirectory),
30479
+ peerFor
30323
30480
  );
30324
30481
  }
30325
30482
  const controlRuntime = connection === void 0 ? void 0 : new PluginControlRuntime(config, defaultIdentityDirectory, settingsBinding, clientRuntime, hostControl, deepseekSession);