@blue-soda/dsh-remote 0.5.3 → 0.5.5

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.js CHANGED
@@ -13480,7 +13480,12 @@ function resolveConfig(input2 = {}, env = process.env) {
13480
13480
  enabled: parsed.reconnect !== false,
13481
13481
  initialDelayMs,
13482
13482
  maxDelayMs,
13483
- 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)
13484
13489
  },
13485
13490
  codex: {
13486
13491
  // Off by default (2026-10-14). Carrying another agent's workspaces is a capability the user opts into, not
@@ -13516,7 +13521,7 @@ function normalizeServerUrl(value) {
13516
13521
  }
13517
13522
 
13518
13523
  // src/version.ts
13519
- var PLUGIN_VERSION = "0.5.3";
13524
+ var PLUGIN_VERSION = "0.5.5";
13520
13525
 
13521
13526
  // src/server-api.ts
13522
13527
  var ENABLED_QR_PROVIDERS = ["github"];
@@ -14080,15 +14085,21 @@ var HostServerConnection = class {
14080
14085
  }
14081
14086
  async run() {
14082
14087
  let delayMs = this.config.reconnect.initialDelayMs;
14088
+ let replacedRetryMs;
14089
+ let replacedAttempts = 0;
14083
14090
  this.authRecoveryAttempted = false;
14084
14091
  while (!this.stopped) {
14085
14092
  try {
14086
14093
  await this.connectOnce();
14087
14094
  delayMs = this.config.reconnect.initialDelayMs;
14095
+ replacedRetryMs = void 0;
14096
+ replacedAttempts = 0;
14088
14097
  } catch (error) {
14089
14098
  const code = errorCode2(error);
14090
14099
  if (code === "CREDENTIALS_REFRESHED") continue;
14091
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;
14092
14103
  this.logger.warn("server control connection failed", {
14093
14104
  code,
14094
14105
  retryable: isRetryable(error),
@@ -14096,9 +14107,9 @@ var HostServerConnection = class {
14096
14107
  ...connectionFailureDetail(error)
14097
14108
  });
14098
14109
  if (TERMINAL_AUTH_ERRORS.has(code)) {
14099
- 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.");
14100
14111
  }
14101
- if (code === "DEVICE_REVOKED") {
14112
+ if (code === "DEVICE_REVOKED" || code === "AUTH_INVALID") {
14102
14113
  try {
14103
14114
  await this.api.clearAuthorization();
14104
14115
  } catch (clearError) {
@@ -14114,7 +14125,7 @@ var HostServerConnection = class {
14114
14125
  continue;
14115
14126
  }
14116
14127
  if (!this.config.reconnect.enabled) return;
14117
- await this.waitBeforeRetry(delayMs);
14128
+ await (replacedRetryMs === void 0 ? this.waitBeforeRetry(delayMs) : this.waitBeforeRetry(replacedRetryMs, true));
14118
14129
  if (this.reconnectRequested) {
14119
14130
  this.reconnectRequested = false;
14120
14131
  delayMs = this.config.reconnect.initialDelayMs;
@@ -14678,8 +14689,8 @@ var HostServerConnection = class {
14678
14689
  tunnel.noise.destroy();
14679
14690
  }
14680
14691
  }
14681
- waitBeforeRetry(baseDelay) {
14682
- const spread = baseDelay * this.config.reconnect.jitter;
14692
+ waitBeforeRetry(baseDelay, exact = false) {
14693
+ const spread = exact ? 0 : baseDelay * this.config.reconnect.jitter;
14683
14694
  const delay = Math.max(0, Math.round(baseDelay - spread + Math.random() * spread * 2));
14684
14695
  return new Promise((resolve4) => {
14685
14696
  const timer = setTimeout(() => {
@@ -14693,8 +14704,8 @@ var HostServerConnection = class {
14693
14704
  });
14694
14705
  }
14695
14706
  };
14707
+ var REPLACED_FIRST_RETRY_MS = 65e3;
14696
14708
  var TERMINAL_AUTH_ERRORS = /* @__PURE__ */ new Set([
14697
- "CONNECTION_REPLACED",
14698
14709
  "SERVER_CREDENTIALS_BUSY",
14699
14710
  "ACCOUNT_AUTH_REQUIRED",
14700
14711
  "AUTH_INVALID",
@@ -21909,10 +21920,16 @@ var ClientModeRuntime = class {
21909
21920
  registerControl(connection, webServer) {
21910
21921
  return registerControlRoute(connection, (endpoint, payload, signal) => this.handleControl(endpoint, payload, signal), webServer);
21911
21922
  }
21923
+ /** True while the plugin is exchanging DSH's DeepSeek credential for this device's Server credentials. */
21924
+ authorizingWithDeepSeek = false;
21925
+ /** Whether DSH held a DeepSeek session at the last reconciliation. */
21926
+ hasDeepSeekSession = false;
21912
21927
  status() {
21913
21928
  const targetStatus = this.gatewaySwitch.supportsCarrier() ? this.gatewaySwitch.status() : this.proxySwitch?.status() ?? this.gatewaySwitch.status();
21914
21929
  return {
21915
21930
  available: this.config.serverUrl !== void 0,
21931
+ authorizing: this.authorizingWithDeepSeek,
21932
+ hasDeepSeekSession: this.hasDeepSeekSession,
21916
21933
  identityReady: this.identity !== void 0,
21917
21934
  deviceId: this.identity?.deviceId,
21918
21935
  deviceName: this.identity?.name,
@@ -22075,11 +22092,33 @@ var ClientModeRuntime = class {
22075
22092
  reloadCredentials() {
22076
22093
  this.server.reloadCredentials();
22077
22094
  }
22095
+ /** Whether this device holds Server credentials, i.e. the panel shows it as signed in. */
22096
+ async hasServerAuthorization() {
22097
+ if (this.server.hasStoredAuthorization === void 0) return false;
22098
+ return await this.server.hasStoredAuthorization().catch(() => false);
22099
+ }
22078
22100
  async setHostAuthorization(enabled) {
22079
22101
  if (this.host === void 0) throw new ClientModeError("METHOD_NOT_ALLOWED", "This plugin is not running as a Host.");
22080
- await this.host.setHostControl?.(enabled);
22081
- if (enabled) await this.host.resumeHostConnection?.();
22082
- else await this.host.pauseHostConnection?.();
22102
+ if (!enabled) {
22103
+ await this.host.setHostControl?.(false);
22104
+ await this.host.pauseHostConnection?.();
22105
+ return this.status();
22106
+ }
22107
+ let flagError;
22108
+ try {
22109
+ await this.host.setHostControl?.(true);
22110
+ } catch (error) {
22111
+ flagError = error;
22112
+ }
22113
+ await this.host.resumeHostConnection?.();
22114
+ try {
22115
+ await this.host.setHostControl?.(true);
22116
+ } catch (error) {
22117
+ this.logger.warn("could not tell the Server that this Host accepts control; retrying when it reconnects", {
22118
+ code: safeErrorCode(error),
22119
+ ...flagError === void 0 ? {} : { firstCode: safeErrorCode(flagError) }
22120
+ });
22121
+ }
22083
22122
  return this.status();
22084
22123
  }
22085
22124
  async setMode(mode, targetDeviceId, signal) {
@@ -22515,6 +22554,22 @@ var ClientModeRuntime = class {
22515
22554
  return;
22516
22555
  }
22517
22556
  const transportLost = reason !== "codex-closed";
22557
+ if (String(reason) === "CONNECTION_REPLACED" && this.reconnecting === void 0) {
22558
+ const run = this.remoteReconnectRun;
22559
+ this.reconnecting = { targetDeviceId, ...targetName === void 0 ? {} : { targetName }, phase: "fast" };
22560
+ this.logger.info("the link was closed by this client's own connection replacement; retrying in place", {
22561
+ targetDeviceId: shortId2(targetDeviceId),
22562
+ kind
22563
+ });
22564
+ const rescued = await this.quickReconnectSession(targetDeviceId, target2, transportLost, run);
22565
+ if (run !== this.remoteReconnectRun) return;
22566
+ if (rescued) {
22567
+ this.finishRecovery(targetDeviceId, "replacement", kind);
22568
+ return;
22569
+ }
22570
+ void this.reconnectRemoteSession(targetDeviceId, target2);
22571
+ return;
22572
+ }
22518
22573
  if (this.reconnecting === void 0) {
22519
22574
  const run = this.remoteReconnectRun;
22520
22575
  this.reconnecting = { targetDeviceId, ...targetName === void 0 ? {} : { targetName }, phase: "fast" };
@@ -23227,7 +23282,26 @@ var ClientModeRuntime = class {
23227
23282
  throw error;
23228
23283
  }
23229
23284
  }
23285
+ /**
23286
+ * One attempt at a time per target: the panel's refresh and the automatic reconnect can ask at the same moment, and
23287
+ * two control connections in flight make the Server close the older one with 4003 (`CONNECTION_REPLACED`), which
23288
+ * also kills the link that was being built on it - what the user saw as being kicked out of the card.
23289
+ */
23290
+ inFlightConnect;
23230
23291
  async connect(targetDeviceId, signal, forceRelay = false) {
23292
+ const key = `${targetDeviceId}|${forceRelay ? "relay" : "auto"}`;
23293
+ const inFlight = this.inFlightConnect;
23294
+ if (inFlight?.key === key) {
23295
+ signal?.throwIfAborted();
23296
+ return await inFlight.promise;
23297
+ }
23298
+ const promise = this.connectNow(targetDeviceId, signal, forceRelay).finally(() => {
23299
+ if (this.inFlightConnect?.promise === promise) this.inFlightConnect = void 0;
23300
+ });
23301
+ this.inFlightConnect = { key, promise };
23302
+ return await promise;
23303
+ }
23304
+ async connectNow(targetDeviceId, signal, forceRelay = false) {
23231
23305
  signal?.throwIfAborted();
23232
23306
  const progressRunId = this.connectionProgressRun + 1;
23233
23307
  this.connectionProgressRun = progressRunId;
@@ -24951,12 +25025,24 @@ var CodexRemoteDomain = class {
24951
25025
  idempotencyKey: call.params.idempotencyKey
24952
25026
  }));
24953
25027
  }
25028
+ if (isRecord12(call.params) && typeof call.params.threadId === "string") {
25029
+ const rowKey = call.params.threadId;
25030
+ const separator = rowKey.indexOf("~");
25031
+ if (separator > 0) call.params.threadId = rowKey.slice(0, separator);
25032
+ }
24954
25033
  if (call.method === "thread/list") {
24955
25034
  const result = sanitizeThreadList(await this.callUpstream(call.method, call.params));
24956
- return filterThreadListByWorkspaceAuthority(
25035
+ const authorized = filterThreadListByWorkspaceAuthority(
24957
25036
  result,
24958
25037
  await this.readWorkspaceAuthority(result.data)
24959
25038
  );
25039
+ return {
25040
+ ...authorized,
25041
+ data: authorized.data.map((row, index) => ({
25042
+ ...row,
25043
+ id: `${String(row.id)}~${String(row.createdAt ?? "")}~${index}`
25044
+ }))
25045
+ };
24960
25046
  }
24961
25047
  if (call.method === "thread/start") {
24962
25048
  const cwd = await this.requireCodexWorkspacePath(call.params.cwd);
@@ -26165,7 +26251,6 @@ var PluginControlRuntime = class {
26165
26251
  *
26166
26252
  * `undefined` means no sign-in is in flight, which is also the state after a completed authorization.
26167
26253
  */
26168
- deepSeekSignIn;
26169
26254
  register(connection, webServer) {
26170
26255
  const statusStream = new ControlStatusStream(() => this.streamStatus());
26171
26256
  return registerControlRoute(
@@ -26274,15 +26359,13 @@ var PluginControlRuntime = class {
26274
26359
  throw new ClientModeError("METHOD_NOT_ALLOWED", "DeepSeek account sign-in is unavailable in this profile.");
26275
26360
  }
26276
26361
  const session = await this.deepseekSession.read();
26277
- const counted = session !== void 0 && this.deepSeekSignIn !== void 0;
26278
- if (!counted) {
26362
+ if (session === void 0) {
26279
26363
  const started = await this.deepseekSession.startSignIn({
26280
26364
  callbackOrigin: typeof value.callbackOrigin === "string" ? value.callbackOrigin : "",
26281
26365
  loginSource: value.loginSource === "desktop" ? "desktop" : "web",
26282
26366
  locale: typeof value.locale === "string" ? value.locale : "en",
26283
26367
  timezoneOffsetSeconds: typeof value.timezoneOffsetSeconds === "number" ? value.timezoneOffsetSeconds : 0
26284
26368
  });
26285
- this.deepSeekSignIn = true;
26286
26369
  return {
26287
26370
  status: "deepseek-sign-in-required",
26288
26371
  ...started.authorizeUrl === void 0 ? {} : { authorizeUrl: started.authorizeUrl }
@@ -26295,7 +26378,6 @@ var PluginControlRuntime = class {
26295
26378
  this.verifiedDeepSeekToken = session.token;
26296
26379
  this.verifiedDeepSeekAuthorization = authorization;
26297
26380
  }
26298
- this.deepSeekSignIn = void 0;
26299
26381
  } else {
26300
26382
  if (typeof value.email !== "string" || typeof value.password !== "string") {
26301
26383
  throw new ClientModeError("INVALID_MESSAGE", "Email and password are required for account authorization.");
@@ -26308,7 +26390,8 @@ var PluginControlRuntime = class {
26308
26390
  if (this.host !== void 0 && !hostPaused) {
26309
26391
  try {
26310
26392
  this.host.reconnectHost?.();
26311
- } catch {
26393
+ } catch (error) {
26394
+ console.error(`[dsh-remote] reconcile failed ${JSON.stringify({ code: String(error?.code ?? "") })}`);
26312
26395
  }
26313
26396
  }
26314
26397
  await this.settings.replace(editableConfig(next));
@@ -26408,14 +26491,13 @@ var PluginControlRuntime = class {
26408
26491
  await this.settings.replace(editableConfig({ ...current, acp: { enabled: current.acp?.enabled ?? true, backends } }));
26409
26492
  return this.settingsView();
26410
26493
  }
26411
- async logout() {
26494
+ async logout(options = {}) {
26412
26495
  if (this.settings === void 0) {
26413
26496
  throw new ClientModeError("SETTINGS_UNAVAILABLE", "DSH user settings are unavailable in this profile.");
26414
26497
  }
26415
26498
  const config = resolveConfig(this.settings.get());
26416
26499
  this.verifiedDeepSeekToken = void 0;
26417
26500
  this.verifiedDeepSeekAuthorization = void 0;
26418
- this.deepSeekSignIn = void 0;
26419
26501
  if (config.serverUrl !== void 0) {
26420
26502
  await Promise.all([
26421
26503
  this.client?.clearClientAuthorization(),
@@ -26424,11 +26506,13 @@ var PluginControlRuntime = class {
26424
26506
  const directory = await ensureDeviceDirectory(this.identityDirectory, config.serverUrl);
26425
26507
  await new ServerCredentialStore(directory).clear();
26426
26508
  }
26509
+ if (this.client !== void 0) this.client.hasDeepSeekSession = false;
26427
26510
  let deepseekSignedOut = false;
26428
26511
  if (this.deepseekSession !== void 0) {
26429
26512
  try {
26430
- deepseekSignedOut = await this.deepseekSession.signOut();
26431
- } catch {
26513
+ deepseekSignedOut = options.releaseDeepSeek === false ? false : await this.deepseekSession.signOut();
26514
+ } catch (error) {
26515
+ console.error(`[dsh-remote] reconcile failed ${JSON.stringify({ code: String(error?.code ?? "") })}`);
26432
26516
  deepseekSignedOut = false;
26433
26517
  }
26434
26518
  }
@@ -26459,7 +26543,49 @@ var PluginControlRuntime = class {
26459
26543
  }
26460
26544
  return this.client === void 0 ? this.hostOnlyStatus() : await this.client.status();
26461
26545
  }
26546
+ /** Consecutive reconciliations without a DSH session: DSH refreshes its grant, so one empty read is not a sign-out. */
26547
+ /** Guards against re-entry through configure(), which reads the settings view back. */
26548
+ reconcilingWithDeepSeek = false;
26549
+ /**
26550
+ * Keep this device's Server credentials in step with DSH's DeepSeek session - that session is the credential, and
26551
+ * the Server verifies it before issuing this device its own tokens.
26552
+ *
26553
+ * Three cases: DSH signed in and this device not authorized -> authorize; DSH signed out and this device authorized
26554
+ * -> sign out; otherwise nothing. At most once per ten seconds, from the settings poll the panel already makes.
26555
+ */
26556
+ async reconcileWithDeepSeek() {
26557
+ if (this.deepseekSession === void 0 || this.reconcilingWithDeepSeek) return;
26558
+ this.reconcilingWithDeepSeek = true;
26559
+ try {
26560
+ const session = await this.deepseekSession.read();
26561
+ if (this.client !== void 0) this.client.hasDeepSeekSession = session !== void 0;
26562
+ const authorized = this.client === void 0 ? false : await this.client.hasServerAuthorization();
26563
+ console.error(`[dsh-remote] reconcile ${JSON.stringify({ hasSession: session !== void 0, authorized })}`);
26564
+ if (session === void 0 && authorized) {
26565
+ await this.logout({ releaseDeepSeek: false });
26566
+ return;
26567
+ }
26568
+ if (session === void 0 || authorized) return;
26569
+ if (this.client !== void 0) this.client.authorizingWithDeepSeek = true;
26570
+ void (async () => {
26571
+ try {
26572
+ await this.configure({ provider: "deepseek", serverUrl: this.config.serverUrl });
26573
+ } catch (error) {
26574
+ console.error(`[dsh-remote] reconcile failed ${JSON.stringify({ code: String(error?.code ?? "") })}`);
26575
+ } finally {
26576
+ if (this.client !== void 0) this.client.authorizingWithDeepSeek = false;
26577
+ }
26578
+ })();
26579
+ } finally {
26580
+ this.reconcilingWithDeepSeek = false;
26581
+ }
26582
+ }
26583
+ /**
26584
+ * Keeps the reconciliation alive without the panel: DSH can be signed in or out while the card is closed, and the
26585
+ * plugin has to follow. Only local reads - the exchange itself is throttled to one per ten seconds.
26586
+ */
26462
26587
  async settingsView() {
26588
+ await this.reconcileWithDeepSeek();
26463
26589
  const config = this.settings === void 0 ? editableConfig(this.config) : editableConfig(resolveConfig(this.settings.get()));
26464
26590
  const associations = await this.associations(config);
26465
26591
  const association = associations.host;
@@ -29994,7 +30120,7 @@ function remoteStatusLines(target2) {
29994
30120
  `Authorization: ${status2.authorized ? status2.account === void 0 ? "logged in" : `logged in (${status2.account})` : "logged out"}`,
29995
30121
  `Server connection: ${connection}`,
29996
30122
  ...status2.error === void 0 ? [] : [`Connection error: ${status2.error}`],
29997
- ...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]."] : [],
30123
+ ...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]."] : [],
29998
30124
  `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"}`,
29999
30125
  `Remote clients: ${diagnostics.activeConnections}`,
30000
30126
  `Codex Remote: ${codex.enabled ? codex.state : "disabled"}`,