@blue-soda/dsh-remote 0.4.32 → 0.5.1

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
@@ -4249,27 +4249,15 @@ var identityKeySchema = external_exports.string().regex(/^[A-Za-z0-9_-]{42}[AEIM
4249
4249
  var deviceVersionSchema = boundedUtf8Schema(1, MAX_DEVICE_VERSION_LENGTH);
4250
4250
  var authTokenSchema = boundedUtf8Schema(16, MAX_AUTH_TOKEN_LENGTH);
4251
4251
  var expiresAtSchema = external_exports.number().int().positive().safe();
4252
- var hostDeviceDescriptorSchema = external_exports.object({
4252
+ var accountDeviceDescriptorSchema = external_exports.object({
4253
4253
  deviceId: deviceIdSchema,
4254
4254
  name: deviceNameSchema,
4255
- role: external_exports.literal("host"),
4256
4255
  platform: devicePlatformSchema,
4257
4256
  identityKey: identityKeySchema,
4258
4257
  clientVersion: deviceVersionSchema,
4259
4258
  harnessVersion: deviceVersionSchema.optional()
4260
- }).strict();
4261
- var clientDeviceDescriptorSchema = external_exports.object({
4262
- deviceId: deviceIdSchema,
4263
- name: deviceNameSchema,
4264
- role: external_exports.literal("client"),
4265
- platform: devicePlatformSchema,
4266
- identityKey: identityKeySchema,
4267
- clientVersion: deviceVersionSchema
4268
- }).strict();
4269
- var accountDeviceDescriptorSchema = external_exports.union([
4270
- hostDeviceDescriptorSchema,
4271
- clientDeviceDescriptorSchema
4272
- ]);
4259
+ });
4260
+ var hostDeviceDescriptorSchema = accountDeviceDescriptorSchema;
4273
4261
  var deviceRegistrationRequestSchema = external_exports.object({
4274
4262
  v: external_exports.literal(PROTOCOL_VERSION),
4275
4263
  device: accountDeviceDescriptorSchema
@@ -13357,6 +13345,7 @@ var DEFAULT_REMOTE_SERVER_URL = "https://sakakibara.ink:8443";
13357
13345
  // src/config.ts
13358
13346
  var entryConfigSchema = s.object({
13359
13347
  enabled: s.boolean(),
13348
+ // Accepted and ignored, for the same reason as in `configSchema`: an upgrade must not brick a profile.
13360
13349
  role: s.union(["host", "client", "both"]),
13361
13350
  serverUrl: s.string(),
13362
13351
  deviceName: s.string(),
@@ -13394,6 +13383,9 @@ var reconnectSchema = external_exports.union([
13394
13383
  ]);
13395
13384
  var configSchema = external_exports.object({
13396
13385
  enabled: external_exports.boolean().optional(),
13386
+ // Older patches - including the plugin's own shipped `cordis.patch.yml` - seed `role`. It means nothing
13387
+ // now: a device has one identity and the control switch decides whether it accepts control. It must stay
13388
+ // *accepted* all the same, or upgrading would fail to activate the plugin on every existing installation.
13397
13389
  role: external_exports.enum(["host", "client", "both"]).optional(),
13398
13390
  serverUrl: external_exports.string().url().optional(),
13399
13391
  deviceName: external_exports.string().trim().min(1).max(80).optional(),
@@ -13421,7 +13413,6 @@ function resolveConfig(input2 = {}, env = process.env) {
13421
13413
  }
13422
13414
  return {
13423
13415
  enabled: parsed.enabled ?? true,
13424
- role: parsed.role ?? "host",
13425
13416
  ...serverUrl === void 0 ? {} : { serverUrl },
13426
13417
  deviceName: parsed.deviceName ?? hostname(),
13427
13418
  // `paused` keeps this machine unreachable without releasing its credentials:
@@ -13438,7 +13429,11 @@ function resolveConfig(input2 = {}, env = process.env) {
13438
13429
  jitter: reconnect.jitter ?? 0.2
13439
13430
  },
13440
13431
  codex: {
13441
- enabled: parsed.codex?.enabled ?? true,
13432
+ // Off by default (2026-10-14). Carrying another agent's workspaces is a capability the user opts into, not
13433
+ // one that quietly starts a second process on their machine: the domain launches `codex app-server`, probes
13434
+ // an account and, while unavailable, retries every minute. The bundled profile patch says the same thing, so
13435
+ // an install that never touches the switch has it off.
13436
+ enabled: parsed.codex?.enabled ?? false,
13442
13437
  binary: parsed.codex?.binary ?? "codex"
13443
13438
  },
13444
13439
  acp: { enabled: parsed.acp?.enabled ?? true, backends: [.../* @__PURE__ */ new Set(["codex", "cursor", "kimi", ...parsed.acp?.backends?.map((item) => item.id) ?? []])].map((id4) => {
@@ -13467,7 +13462,7 @@ function normalizeServerUrl(value) {
13467
13462
  }
13468
13463
 
13469
13464
  // src/version.ts
13470
- var PLUGIN_VERSION = "0.4.32";
13465
+ var PLUGIN_VERSION = "0.5.1";
13471
13466
 
13472
13467
  // src/server-api.ts
13473
13468
  var ENABLED_QR_PROVIDERS = ["github"];
@@ -13479,10 +13474,9 @@ function oauthProviderName(provider) {
13479
13474
  }
13480
13475
  var TERMINAL_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u;
13481
13476
  var HostServerApi = class {
13482
- constructor(serverUrl, store, fetchImplementation = fetch, role = "host") {
13477
+ constructor(serverUrl, store, fetchImplementation = fetch) {
13483
13478
  this.store = store;
13484
13479
  this.fetchImplementation = fetchImplementation;
13485
- this.role = role;
13486
13480
  this.baseUrl = normalizeServerUrl(serverUrl);
13487
13481
  }
13488
13482
  baseUrl;
@@ -13508,11 +13502,33 @@ var HostServerApi = class {
13508
13502
  const identity = this.requireIdentity();
13509
13503
  return await this.store.load(this.baseUrl, identity.deviceId) !== void 0;
13510
13504
  }
13505
+ /**
13506
+ * Drop the in-memory credential cache so the next request reads what is on disk.
13507
+ *
13508
+ * Signing in writes fresh credentials for this device; a long-lived runtime that kept its own copy went on
13509
+ * presenting the rejected one and answered AUTH_INVALID until the process restarted.
13510
+ */
13511
+ reloadCredentials() {
13512
+ this.credentials = void 0;
13513
+ this.credentialsPromise = void 0;
13514
+ }
13511
13515
  async clearAuthorization() {
13512
13516
  this.credentials = void 0;
13513
13517
  this.credentialsPromise = void 0;
13514
13518
  await this.store.clear();
13515
13519
  }
13520
+ /**
13521
+ * Tell the Server whether this device accepts control.
13522
+ *
13523
+ * The flag lives on the Server so it holds across restarts and is visible to clients listing devices;
13524
+ * the local identity and credentials are untouched.
13525
+ */
13526
+ async setHostControl(enabled) {
13527
+ await this.request("/api/v1/devices/self/control", {
13528
+ method: "POST",
13529
+ body: JSON.stringify({ enabled })
13530
+ });
13531
+ }
13516
13532
  async revokeCurrentDevice() {
13517
13533
  const identity = this.requireIdentity();
13518
13534
  if (await this.store.load(this.baseUrl, identity.deviceId) === void 0) {
@@ -13623,9 +13639,6 @@ var HostServerApi = class {
13623
13639
  };
13624
13640
  }
13625
13641
  async authorizeHostWithCode(identity, code) {
13626
- if (this.role !== "host") {
13627
- throw new ServerApiError("METHOD_NOT_ALLOWED", "Host registration codes can only authorize a Host device.", false);
13628
- }
13629
13642
  const registrationCode = code.trim().toUpperCase();
13630
13643
  if (registrationCode.length === 0) {
13631
13644
  throw new ServerApiError("INVALID_MESSAGE", "A Host registration code is required.", false);
@@ -13640,21 +13653,6 @@ var HostServerApi = class {
13640
13653
  });
13641
13654
  return { method: "host_registration_code" };
13642
13655
  }
13643
- async authorizeOwnedRole(identity, authorizingAccessToken, account) {
13644
- this.bindIdentity(identity);
13645
- const tokens = await this.publicRequest("/api/v1/devices/register-owned-role", {
13646
- method: "POST",
13647
- body: JSON.stringify({ v: 1, device: this.deviceDescriptor(identity) })
13648
- }, authorizingAccessToken);
13649
- this.credentials = await this.saveTokens(identity, validateTokens(tokens), {
13650
- authorizationMethod: "owned_device",
13651
- ...account === void 0 ? {} : { account }
13652
- });
13653
- return {
13654
- method: "owned_device",
13655
- ...account === void 0 ? {} : { account }
13656
- };
13657
- }
13658
13656
  async authenticate(identity = this.requireIdentity()) {
13659
13657
  this.bindIdentity(identity);
13660
13658
  if (this.credentials !== void 0 && this.credentials.accessTokenExpiresAt > Date.now() + 3e4) {
@@ -13760,11 +13758,10 @@ var HostServerApi = class {
13760
13758
  return {
13761
13759
  deviceId: identity.deviceId,
13762
13760
  name: identity.name,
13763
- role: this.role,
13764
13761
  platform: platform(),
13765
13762
  identityKey: identity.publicKey,
13766
13763
  clientVersion: PLUGIN_VERSION,
13767
- ...this.role === "host" && this.harnessVersion !== void 0 ? { harnessVersion: this.harnessVersion } : {}
13764
+ ...this.harnessVersion === void 0 ? {} : { harnessVersion: this.harnessVersion }
13768
13765
  };
13769
13766
  }
13770
13767
  saveTokens(identity, tokens, authorization) {
@@ -13832,7 +13829,7 @@ function normalizeOAuthScanUrl(value, baseUrl) {
13832
13829
  }
13833
13830
  var ClientServerApi = class extends HostServerApi {
13834
13831
  constructor(serverUrl, store, fetchImplementation = fetch) {
13835
- super(serverUrl, store, fetchImplementation, "client");
13832
+ super(serverUrl, store, fetchImplementation);
13836
13833
  }
13837
13834
  };
13838
13835
  var ServerApiError = class extends Error {
@@ -13882,7 +13879,7 @@ function mapStatus(status2) {
13882
13879
  }
13883
13880
  function parseHostDevice(value) {
13884
13881
  const item = requireRecord(value, "host device");
13885
- if (item.role !== "host" || typeof item.deviceId !== "string" || typeof item.name !== "string" || typeof item.platform !== "string" || typeof item.membershipId !== "string" || item.membershipId.length === 0) {
13882
+ if (typeof item.deviceId !== "string" || typeof item.name !== "string" || typeof item.platform !== "string" || typeof item.membershipId !== "string" || item.membershipId.length === 0) {
13886
13883
  throw new ServerApiError("INVALID_MESSAGE", "The Server returned invalid host device data.", false);
13887
13884
  }
13888
13885
  return {
@@ -13891,6 +13888,7 @@ function parseHostDevice(value) {
13891
13888
  platform: item.platform,
13892
13889
  membershipId: item.membershipId,
13893
13890
  ...typeof item.online === "boolean" ? { online: item.online } : {},
13891
+ ...typeof item.hostControl === "boolean" ? { hostControl: item.hostControl } : {},
13894
13892
  ...typeof item.lastSeenAt === "number" && Number.isSafeInteger(item.lastSeenAt) ? { lastSeenAt: item.lastSeenAt } : {},
13895
13893
  ...typeof item.clientVersion === "string" ? { clientVersion: item.clientVersion } : {},
13896
13894
  ...typeof item.harnessVersion === "string" ? { harnessVersion: item.harnessVersion } : {}
@@ -13898,17 +13896,17 @@ function parseHostDevice(value) {
13898
13896
  }
13899
13897
  function parseAuthorizedPeer(value) {
13900
13898
  const item = requireRecord(value, "authorized peer");
13901
- if (item.role !== "host" && item.role !== "client" || typeof item.deviceId !== "string" || item.deviceId.length === 0 || typeof item.name !== "string" || item.name.length === 0 || typeof item.platform !== "string" || item.platform.length === 0 || typeof item.identityKey !== "string" || !isIdentityKey(item.identityKey) || typeof item.membershipId !== "string" || item.membershipId.length === 0) {
13899
+ if (typeof item.deviceId !== "string" || item.deviceId.length === 0 || typeof item.name !== "string" || item.name.length === 0 || typeof item.platform !== "string" || item.platform.length === 0 || typeof item.identityKey !== "string" || !isIdentityKey(item.identityKey) || typeof item.membershipId !== "string" || item.membershipId.length === 0) {
13902
13900
  throw new ServerApiError("INVALID_MESSAGE", "The Server returned invalid authorized peer data.", false);
13903
13901
  }
13904
13902
  return {
13905
13903
  deviceId: item.deviceId,
13906
13904
  name: item.name,
13907
- role: item.role,
13908
13905
  platform: item.platform,
13909
13906
  identityKey: item.identityKey,
13910
13907
  membershipId: item.membershipId,
13911
13908
  ...typeof item.online === "boolean" ? { online: item.online } : {},
13909
+ ...typeof item.hostControl === "boolean" ? { hostControl: item.hostControl } : {},
13912
13910
  ...typeof item.lastSeenAt === "number" && Number.isSafeInteger(item.lastSeenAt) ? { lastSeenAt: item.lastSeenAt } : {}
13913
13911
  };
13914
13912
  }
@@ -14139,8 +14137,10 @@ var HostServerConnection = class {
14139
14137
  socket.close(4008, "invalid control frame");
14140
14138
  });
14141
14139
  };
14142
- socket.onerror = () => {
14143
- if (!acknowledged) finish(new ControlConnectionError("CONNECTION_FAILED", "Unable to open the Server WebSocket."));
14140
+ socket.onerror = (event) => {
14141
+ if (!acknowledged) {
14142
+ finish(new ControlConnectionError("CONNECTION_FAILED", `Unable to open the Server WebSocket.${socketFailureDetail(event)}`));
14143
+ }
14144
14144
  };
14145
14145
  socket.onclose = (event) => {
14146
14146
  const close = async () => {
@@ -14246,7 +14246,7 @@ var HostServerConnection = class {
14246
14246
  });
14247
14247
  return;
14248
14248
  }
14249
- if (descriptor.role !== "client" || descriptor.deviceId !== payload.clientDeviceId || descriptor.identityKey !== payload.clientIdentityKey) {
14249
+ if (descriptor.deviceId !== payload.clientDeviceId || descriptor.identityKey !== payload.clientIdentityKey) {
14250
14250
  this.sendControl("connect.rejected", { connectionId: payload.connectionId });
14251
14251
  this.logger.warn("connection rejected by peer identity validation", {
14252
14252
  clientDeviceId: shortId(payload.clientDeviceId)
@@ -14840,6 +14840,21 @@ function connectionFailureDetail(error) {
14840
14840
  ...causeCode === void 0 ? {} : { causeCode }
14841
14841
  };
14842
14842
  }
14843
+ function socketFailureDetail(event) {
14844
+ const parts = [];
14845
+ if (event !== null && typeof event === "object") {
14846
+ const message = "message" in event && typeof event.message === "string" ? event.message : void 0;
14847
+ if (message !== void 0 && message !== "") parts.push(message);
14848
+ const error = "error" in event ? event.error : void 0;
14849
+ if (error !== null && typeof error === "object") {
14850
+ const code = "code" in error && typeof error.code === "string" ? error.code : void 0;
14851
+ const errorMessage = "message" in error && typeof error.message === "string" ? error.message : void 0;
14852
+ if (code !== void 0) parts.push(code);
14853
+ if (errorMessage !== void 0 && errorMessage !== "" && errorMessage !== message) parts.push(errorMessage);
14854
+ }
14855
+ }
14856
+ return parts.length === 0 ? "" : ` (${parts.join(": ")})`;
14857
+ }
14843
14858
  function errorCode2(error) {
14844
14859
  return error instanceof ServerApiError || error instanceof ControlConnectionError ? error.code : "CONNECTION_FAILED";
14845
14860
  }
@@ -15392,11 +15407,30 @@ function createRemoteId() {
15392
15407
  var MAX_DISPLAY_ITEM_TEXT = 256 * 1024;
15393
15408
  var CODEX_IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
15394
15409
  var DATA_IMAGE_URL = /^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/]+={0,2})$/u;
15410
+ var CODEX_CAPABILITY_EVENT = "codex.app.capability";
15395
15411
  var CodexRemoteClient = class {
15396
15412
  core;
15397
15413
  constructor(core) {
15398
15414
  this.core = core;
15399
15415
  }
15416
+ /**
15417
+ * Listen for the Host's Codex availability changes.
15418
+ *
15419
+ * Fires whether or not this client is following any thread, which is what makes "the peer switched Codex off"
15420
+ * reach the session on screen immediately instead of at the next failing call. The Host sends this only on a
15421
+ * real transition, so `available: true` never means "you lost something".
15422
+ * @param handler - called with the new availability.
15423
+ * @returns an unsubscribe function.
15424
+ */
15425
+ watchCapability(handler) {
15426
+ return this.core.onEvent((event) => {
15427
+ if (event.event !== CODEX_CAPABILITY_EVENT)
15428
+ return;
15429
+ if (!isRecord(event.data) || typeof event.data.available !== "boolean")
15430
+ return;
15431
+ handler(event.data.available);
15432
+ });
15433
+ }
15400
15434
  /** Low-level allowlisted request used by the Desktop Web loopback facade. */
15401
15435
  request(method, params, signal) {
15402
15436
  const largeHistory = method === "dsh/sessionHistory" || method === "thread/read" && isRecord(params) && params.includeTurns === true;
@@ -17836,6 +17870,10 @@ function isHostWorkspaceEndpoint(endpoint) {
17836
17870
  function isHostWorkspaceStreamEndpoint(endpoint) {
17837
17871
  return HOST_WORKSPACE_STREAM_ENDPOINTS.has(endpoint);
17838
17872
  }
17873
+ function sameWorkspaceRoot(left, right) {
17874
+ const normalise = (value) => value.trim().replace(/[\\/]+$/, "").replace(/\\/g, "/").toLowerCase();
17875
+ return normalise(left) === normalise(right);
17876
+ }
17839
17877
  function codexProjectWorkspaceId(projectId) {
17840
17878
  return `${CODEX_WORKSPACE_PREFIX}project:${projectId}`;
17841
17879
  }
@@ -17849,6 +17887,9 @@ var CodexVirtualHarness = class _CodexVirtualHarness {
17849
17887
  this.sessionGeneration = sessionGeneration;
17850
17888
  this.hostCarrier = hostCarrier;
17851
17889
  this.api = this.createApiProxy();
17890
+ this.stopCapabilityWatch = this.client.watchCapability?.((available) => {
17891
+ if (!available) this.onSessionLost?.("domain-notice");
17892
+ });
17852
17893
  }
17853
17894
  api;
17854
17895
  catalog;
@@ -17881,16 +17922,33 @@ var CodexVirtualHarness = class _CodexVirtualHarness {
17881
17922
  this.localCarrier = carrier;
17882
17923
  }
17883
17924
  localCarrier;
17925
+ onSessionLost;
17926
+ stopCapabilityWatch;
17927
+ /**
17928
+ * Register what to do when a Codex call fails because the session is gone.
17929
+ *
17930
+ * Set after construction rather than passed in, so neither the constructor nor the static factory signature has to
17931
+ * change: the runtime hooks its reconnect entry point here. The Host replacing or switching off its Codex domain
17932
+ * closes the bridges, and the next call would otherwise just be shown as an error.
17933
+ * @param handler - called once per detected loss, with what detected it, before the failure is returned.
17934
+ * @returns nothing.
17935
+ */
17936
+ watchCodexSessionLost(handler) {
17937
+ this.onSessionLost = handler;
17938
+ }
17884
17939
  static remote(core, host, sessionGeneration = "legacy", hostCarrier) {
17885
17940
  return new _CodexVirtualHarness(new CodexRemoteClient(core), host, sessionGeneration, hostCarrier);
17886
17941
  }
17887
17942
  async workspaces(signal) {
17888
17943
  return (await this.refreshCatalog(signal)).workspaces;
17889
17944
  }
17890
- async selectWorkspace(workspaceId, signal) {
17945
+ async selectWorkspace(workspaceId, signal, rootPath) {
17891
17946
  const catalog = await loadCatalog(this.client, signal);
17892
- const workspace = catalog.workspaces.find((item) => item.workspaceId === workspaceId);
17893
- if (workspace === void 0) throw new Error("The selected CodeX workspace is no longer available.");
17947
+ const workspace = catalog.workspaces.find((item) => item.workspaceId === workspaceId) ?? (rootPath === void 0 ? void 0 : catalog.workspaces.find((item) => sameWorkspaceRoot(item.path, rootPath)));
17948
+ if (workspace === void 0) {
17949
+ const seen = catalog.workspaces.slice(0, 5).map((item) => item.workspaceId + "@" + item.path).join(", ");
17950
+ throw new Error("The selected CodeX workspace is no longer available. (wanted: " + workspaceId + " root: " + String(rootPath) + " catalog: " + seen + ")");
17951
+ }
17894
17952
  this.selectedWorkspaceId = workspace.workspaceId;
17895
17953
  this.catalog = catalog;
17896
17954
  return workspace;
@@ -17907,7 +17965,7 @@ var CodexVirtualHarness = class _CodexVirtualHarness {
17907
17965
  for (const sessionId of candidates) {
17908
17966
  const threadId = nativeThreadId(sessionId);
17909
17967
  try {
17910
- const thread = await this.fetchThread(threadId, signal);
17968
+ await this.fetchThread(threadId, signal);
17911
17969
  return sessionId;
17912
17970
  } catch {
17913
17971
  }
@@ -18021,6 +18079,7 @@ var CodexVirtualHarness = class _CodexVirtualHarness {
18021
18079
  }
18022
18080
  }
18023
18081
  } catch (error) {
18082
+ if (isCodexSessionLoss(error)) this.onSessionLost?.("call-failed");
18024
18083
  return failFrom(error);
18025
18084
  }
18026
18085
  }
@@ -18051,6 +18110,7 @@ var CodexVirtualHarness = class _CodexVirtualHarness {
18051
18110
  async close() {
18052
18111
  if (this.closed) return;
18053
18112
  this.closed = true;
18113
+ this.stopCapabilityWatch?.();
18054
18114
  for (const stream of this.workspaceStreams) stream.close();
18055
18115
  for (const stream of this.controlStreams) stream.close();
18056
18116
  for (const stream of this.eventStreams.values()) stream.close();
@@ -18333,7 +18393,7 @@ var CodexVirtualHarness = class _CodexVirtualHarness {
18333
18393
  threadId,
18334
18394
  (frame) => this.acceptCodexFrame(follow, frame),
18335
18395
  signal,
18336
- () => this.closeFollowAfterRemoteStreamClosed(follow)
18396
+ (reason) => this.noteRemoteStreamClosed(follow, reason)
18337
18397
  );
18338
18398
  follow.close = () => stream.close();
18339
18399
  } catch (error) {
@@ -19107,7 +19167,7 @@ var CodexVirtualHarness = class _CodexVirtualHarness {
19107
19167
  threadId,
19108
19168
  (frame) => this.acceptCodexFrame(follow, frame),
19109
19169
  controller.signal,
19110
- () => this.closeFollowAfterRemoteStreamClosed(follow)
19170
+ (reason) => this.noteRemoteStreamClosed(follow, reason)
19111
19171
  );
19112
19172
  follow.close = async () => {
19113
19173
  controller.abort();
@@ -19129,6 +19189,21 @@ var CodexVirtualHarness = class _CodexVirtualHarness {
19129
19189
  follow.queue.close();
19130
19190
  this.emitRemoteEvent("api-session/status", [follow.sessionId, false]);
19131
19191
  }
19192
+ /**
19193
+ * The Host closed a Codex stream this client was following.
19194
+ *
19195
+ * `failed` and `peer-disconnected` mean the Codex session behind it is gone - the Host switched Codex off,
19196
+ * replaced its domain, or its App Server died. That is a lost session like any other, so the runtime is told
19197
+ * now, which is what puts "reconnecting" on screen immediately instead of leaving the user to discover it as
19198
+ * an error the next time they touch the session. A stream we closed ourselves, or one that simply ended,
19199
+ * says nothing about the session.
19200
+ * @param follow - the projection that was following it.
19201
+ * @param reason - why the peer closed the stream.
19202
+ */
19203
+ noteRemoteStreamClosed(follow, reason) {
19204
+ if (reason === "failed" || reason === "peer-disconnected") this.onSessionLost?.("stream-closed");
19205
+ this.closeFollowAfterRemoteStreamClosed(follow);
19206
+ }
19132
19207
  async refreshAndPublishWorkspaces() {
19133
19208
  const catalog = await this.refreshCatalog().catch(() => void 0);
19134
19209
  if (catalog !== void 0) this.publishWorkspaceBaseline(catalog);
@@ -21596,7 +21671,7 @@ var QUICK_RECONNECT_ATTEMPTS = 2;
21596
21671
  var QUICK_RECONNECT_WINDOW_MS = 1e4;
21597
21672
  var QUICK_RECONNECT_RETRY_DELAY_MS = 5e3;
21598
21673
  var QUICK_RECONNECT_FORCE_RELAY = true;
21599
- var FALLBACK_STEADY_ATTEMPTS = 5;
21674
+ var BACKGROUND_RETRY_DELAYS_MS = [5e3, 1e4, 2e4, 3e4, 3e4, 3e4, 3e4];
21600
21675
  var ClientModeRuntime = class {
21601
21676
  constructor(config, identities, server, apiProxy, typertGateway, logger, host, rtcFactoryProvider = loadNodeRtcFactory, targetStore) {
21602
21677
  this.config = config;
@@ -21613,6 +21688,16 @@ var ClientModeRuntime = class {
21613
21688
  preview;
21614
21689
  identity;
21615
21690
  connected;
21691
+ /**
21692
+ * The connection the Remote panel browses a Host with.
21693
+ *
21694
+ * Deliberately separate from {@link connected}: opening the panel, connecting to a Host and looking at or
21695
+ * picking its workspaces is *not* a remote session. Nothing here may become "what the user is watching",
21696
+ * arm a reconnect, or survive as something a later boot would restore - the user is still in the local
21697
+ * shell until they actually open a workspace. Keeping it in this field is what enforces that: the recovery
21698
+ * path only ever looks at {@link connected}.
21699
+ */
21700
+ browsing;
21616
21701
  /**
21617
21702
  * Set when a remote session dropped and the runtime fell back to local.
21618
21703
  *
@@ -21646,6 +21731,29 @@ var ClientModeRuntime = class {
21646
21731
  * the link is back.
21647
21732
  */
21648
21733
  lastWorkspaceSelection;
21734
+ /**
21735
+ * The last workspaces listed per Host.
21736
+ *
21737
+ * A workspace picked in the panel is opened by id, and a replaced Codex domain hands out new ids for the same
21738
+ * directories. Keeping the previous listing is what lets a stale id be translated back to its root and opened
21739
+ * again instead of failing as "no longer available".
21740
+ */
21741
+ lastCodexListing = /* @__PURE__ */ new Map();
21742
+ /**
21743
+ * Every workspace id this client has seen for a Host, mapped to its root.
21744
+ *
21745
+ * A panel entry can come from a listing made before the Host replaced its Codex domain, so the id is missing from
21746
+ * the latest listing while the directory itself is unchanged. Keeping the whole history is what lets such an id be
21747
+ * translated back to a root and reopened.
21748
+ */
21749
+ codexWorkspaceRoots = /* @__PURE__ */ new Map();
21750
+ /**
21751
+ * Hosts this run has actually opened a Codex workspace on.
21752
+ *
21753
+ * Whether the peer merely never offered Codex or has just switched it off decides the wording the user sees, and
21754
+ * the remembered selection cannot answer that on its own: switching to local clears it by design.
21755
+ */
21756
+ codexHostsSeen = /* @__PURE__ */ new Set();
21649
21757
  codexVirtual;
21650
21758
  proxySwitch;
21651
21759
  gatewaySwitch;
@@ -21667,6 +21775,21 @@ var ClientModeRuntime = class {
21667
21775
  * stops as soon as the user asks for local.
21668
21776
  */
21669
21777
  reconnecting;
21778
+ /**
21779
+ * What the recovery is putting back, for the header.
21780
+ *
21781
+ * The window has to be able to say *which* session is coming back while the carriers are already local - a
21782
+ * Codex recovery that fell back to the local shell used to read "remote DSH, reconnecting", which describes
21783
+ * a different thing than the one being restored. The kind comes from the remembered selection, so it stays
21784
+ * right for the whole window without a second copy of the state to keep in sync.
21785
+ * @returns the payload the status carries for a running recovery.
21786
+ */
21787
+ reconnectState() {
21788
+ const state = this.reconnecting;
21789
+ if (state === void 0) return { targetDeviceId: "", phase: "restore" };
21790
+ const kind = this.sessionTargetFor(state.targetDeviceId)?.kind;
21791
+ return { ...state, ...kind === void 0 ? {} : { kind } };
21792
+ }
21670
21793
  /** Periodic proof of life for the live remote session; absent while nothing is connected. */
21671
21794
  closed = false;
21672
21795
  async start() {
@@ -21682,7 +21805,7 @@ var ClientModeRuntime = class {
21682
21805
  if (this.config.hostControl?.enabled !== false && this.host !== void 0 && this.server.hasStoredAuthorization !== void 0) {
21683
21806
  try {
21684
21807
  if (await this.server.hasStoredAuthorization() && (this.host.hasStoredAuthorization === void 0 || !await this.host.hasStoredAuthorization())) {
21685
- await this.authorizeHostByDefault();
21808
+ this.logger.info("Host credentials are present; the Host connection starts on its own");
21686
21809
  }
21687
21810
  } catch (error) {
21688
21811
  this.logger.warn("automatic Host authorization failed", { code: safeErrorCode(error) });
@@ -21700,20 +21823,6 @@ var ClientModeRuntime = class {
21700
21823
  async hasStoredAuthorization() {
21701
21824
  return this.server.hasStoredAuthorization === void 0 ? false : await this.server.hasStoredAuthorization();
21702
21825
  }
21703
- async authorizeHostByDefault() {
21704
- try {
21705
- if (this.host === void 0) return;
21706
- if (this.config.hostControl?.enabled === false) return;
21707
- const status2 = this.host.hostStatus();
21708
- if (status2.authorized) return;
21709
- const rejected = status2.error !== void 0 && HOST_AUTHORIZATION_ERRORS.has(status2.error);
21710
- if (!rejected && this.host.hasStoredAuthorization !== void 0 && await this.host.hasStoredAuthorization()) return;
21711
- const credentials = await this.server.authenticate(this.requireIdentity());
21712
- await this.host.authorizeHostAsOwned(credentials.accessToken, credentials.account);
21713
- } catch (error) {
21714
- this.logger.warn("automatic Host authorization failed", { code: safeErrorCode(error) });
21715
- }
21716
- }
21717
21826
  registerControl(connection, webServer) {
21718
21827
  return registerControlRoute(connection, (endpoint, payload, signal) => this.handleControl(endpoint, payload, signal), webServer);
21719
21828
  }
@@ -21728,7 +21837,7 @@ var ClientModeRuntime = class {
21728
21837
  ...targetStatus,
21729
21838
  connected: this.connected !== void 0,
21730
21839
  fellBackToLocal: this.fellBackToLocal,
21731
- ...this.reconnecting === void 0 ? {} : { reconnecting: { ...this.reconnecting } },
21840
+ ...this.reconnecting === void 0 ? {} : { reconnecting: this.reconnectState() },
21732
21841
  transport: this.connected?.client.getStats().mode ?? "Disconnected",
21733
21842
  connectedTargetDeviceId: this.connected?.target.deviceId,
21734
21843
  preferredTransports: this.config.forceRelay ? ["relay"] : ["lan", "p2p", "turn", "relay"],
@@ -21774,7 +21883,9 @@ var ClientModeRuntime = class {
21774
21883
  };
21775
21884
  }
21776
21885
  async devices() {
21777
- this.assertHostAuthorizationForDeviceDiscovery();
21886
+ if (!await this.recoverHostAuthorization()) {
21887
+ throw new ClientModeError("AUTH_REQUIRED", "This device is not authorized on the Server yet.");
21888
+ }
21778
21889
  this.requireIdentity();
21779
21890
  const serverDevices = await this.server.listDevices();
21780
21891
  const remoteDevices = serverDevices.filter((device) => device.deviceId !== this.host?.hostStatus().deviceId);
@@ -21785,18 +21896,18 @@ var ClientModeRuntime = class {
21785
21896
  }));
21786
21897
  }
21787
21898
  /**
21788
- * Device discovery is exposed through the local app control route. When this
21789
- * installation also runs a Host, keep that route closed after the Host's
21790
- * Server credential has become terminally invalid. The Client credential can
21791
- * remain usable for a short time after a revoke, so checking only
21792
- * `ClientServerApi.listDevices()` would otherwise leak the device directory
21793
- * from a Host that the user has already been told to re-authorize.
21899
+ * Whether the Host credential is usable, after one attempt to re-authorize it.
21900
+ *
21901
+ * Device discovery runs through the local app control route and must not leak a device directory for a
21902
+ * Host the Server no longer accepts, but refusing outright was worse than useless: it told a user who was
21903
+ * already signing in to sign out first. Re-authorizing from the stored Client credential is the move that
21904
+ * helps; when that fails too - the device was removed from the account, so there is nothing left to
21905
+ * refresh - discovery reports nothing and the panel's authorization state takes over.
21906
+ * @returns whether the Host credential is usable.
21794
21907
  */
21795
- assertHostAuthorizationForDeviceDiscovery() {
21908
+ async recoverHostAuthorization() {
21796
21909
  const status2 = this.host?.hostStatus();
21797
- if (status2 === void 0 || status2.error === void 0 || !HOST_AUTHORIZATION_ERRORS.has(status2.error)) return;
21798
- const message = status2.error === "DEVICE_REVOKED" ? "The local Host was revoked on the Server. Sign out and authorize this Host again." : "The local Host authorization is no longer valid. Sign out and authorize this Host again.";
21799
- throw new ClientModeError(status2.error, message);
21910
+ return status2 === void 0 || status2.error === void 0 || !HOST_AUTHORIZATION_ERRORS.has(status2.error);
21800
21911
  }
21801
21912
  async authorizeClientWithAccount(email, password) {
21802
21913
  let authorization;
@@ -21804,11 +21915,10 @@ var ClientModeRuntime = class {
21804
21915
  authorization = await this.server.authorizeWithAccount(this.requireIdentity(), email, password);
21805
21916
  } catch (error) {
21806
21917
  if (!(error instanceof ServerApiError) || error.code !== "DEVICE_REVOKED") throw error;
21807
- this.identity = await this.identities.reset(this.config.deviceName);
21808
- this.server.bindIdentity(this.identity);
21809
- authorization = await this.server.authorizeWithAccount(this.identity, email, password);
21918
+ const identity = this.requireIdentity();
21919
+ this.server.bindIdentity(identity);
21920
+ authorization = await this.server.authorizeWithAccount(identity, email, password);
21810
21921
  }
21811
- await this.authorizeHostByDefault();
21812
21922
  this.logger.info("Client account authorized");
21813
21923
  return authorization;
21814
21924
  }
@@ -21817,13 +21927,11 @@ var ClientModeRuntime = class {
21817
21927
  }
21818
21928
  async pollClientOAuthQrLogin(qrId) {
21819
21929
  const result = await this.server.pollOAuthQrLogin(this.requireIdentity(), qrId, async () => {
21820
- this.identity = await this.identities.reset(this.config.deviceName);
21821
- this.server.bindIdentity(this.identity);
21822
- this.logger.info("Rotated revoked Client identity before QR authorization retry");
21823
- return this.identity;
21930
+ const identity = this.requireIdentity();
21931
+ this.server.bindIdentity(identity);
21932
+ return identity;
21824
21933
  });
21825
21934
  if (result.status === "complete") this.logger.info("Client account authorized with QR login");
21826
- if (result.status === "complete") await this.authorizeHostByDefault();
21827
21935
  return result;
21828
21936
  }
21829
21937
  /**
@@ -21846,6 +21954,7 @@ var ClientModeRuntime = class {
21846
21954
  this.connected = void 0;
21847
21955
  this.connectionProgress = void 0;
21848
21956
  this.pendingWorkspaceSelection = void 0;
21957
+ this.closeBrowsing();
21849
21958
  await this.closeCodexVirtual();
21850
21959
  this.proxySwitch?.selectLocal();
21851
21960
  await this.closePreview();
@@ -21855,14 +21964,39 @@ var ClientModeRuntime = class {
21855
21964
  this.remoteReconnectRun += 1;
21856
21965
  await this.server.clearAuthorization();
21857
21966
  }
21967
+ /**
21968
+ * Revoke this device on the Server, then drop the local authorization.
21969
+ *
21970
+ * Used by the Remote panel's removal action. The device deliberately keeps its identity: signing in
21971
+ * again re-registers the same installation instead of adding another device row, which is what stops
21972
+ * one machine from looking like several over time.
21973
+ */
21974
+ async revokeCurrentDevice() {
21975
+ await this.server.revokeCurrentDevice();
21976
+ this.connected = void 0;
21977
+ this.connectionProgress = void 0;
21978
+ this.closeBrowsing();
21979
+ this.reconnecting = void 0;
21980
+ this.fellBackToLocal = false;
21981
+ this.remoteReconnectRun += 1;
21982
+ }
21983
+ /**
21984
+ * Switch whether this machine accepts control.
21985
+ *
21986
+ * Turning it off takes the Host connection away and tells the Server to refuse a new one, while the
21987
+ * account sign-in, the device identity and the client half stay exactly as they are - so the device
21988
+ * remains signed in and can still control other machines, and turning control back on reconnects
21989
+ * without another sign-in.
21990
+ */
21991
+ /** Re-read this device's credentials from disk, so a sign-in is picked up without a restart. */
21992
+ reloadCredentials() {
21993
+ this.server.reloadCredentials();
21994
+ }
21858
21995
  async setHostAuthorization(enabled) {
21859
21996
  if (this.host === void 0) throw new ClientModeError("METHOD_NOT_ALLOWED", "This plugin is not running as a Host.");
21860
- if (!enabled) {
21861
- await this.host.clearHostAuthorization();
21862
- return this.status();
21863
- }
21864
- const credentials = await this.server.authenticate(this.requireIdentity());
21865
- await this.host.authorizeHostAsOwned(credentials.accessToken, credentials.account);
21997
+ await this.host.setHostControl?.(enabled);
21998
+ if (enabled) await this.host.resumeHostConnection?.();
21999
+ else await this.host.pauseHostConnection?.();
21866
22000
  return this.status();
21867
22001
  }
21868
22002
  async setMode(mode, targetDeviceId, signal) {
@@ -21876,6 +22010,7 @@ var ClientModeRuntime = class {
21876
22010
  this.connectionProgress = void 0;
21877
22011
  this.pendingWorkspaceSelection = void 0;
21878
22012
  this.lastWorkspaceSelection = void 0;
22013
+ this.closeBrowsing();
21879
22014
  await this.closeCodexStreams(previous2?.client);
21880
22015
  await previous2?.client.close().catch(() => void 0);
21881
22016
  this.fellBackToLocal = false;
@@ -21888,7 +22023,9 @@ var ClientModeRuntime = class {
21888
22023
  if (targetDeviceId === void 0 || targetDeviceId.length === 0) {
21889
22024
  throw new ClientModeError("INVALID_MESSAGE", "A targetDeviceId is required for remote mode.");
21890
22025
  }
21891
- const next = await this.connect(targetDeviceId, signal);
22026
+ const browsingHere = this.browsing?.target.deviceId === targetDeviceId ? this.browsing : void 0;
22027
+ if (browsingHere !== void 0) this.browsing = void 0;
22028
+ const next = browsingHere ?? await this.connectWithReason(targetDeviceId, signal);
21892
22029
  try {
21893
22030
  this.assertRemoteCompatible(next);
21894
22031
  } catch (error) {
@@ -21903,6 +22040,7 @@ var ClientModeRuntime = class {
21903
22040
  this.pendingWorkspaceSelection = void 0;
21904
22041
  await this.closeCodexVirtual();
21905
22042
  this.selectRemoteTarget(next);
22043
+ this.closeBrowsing();
21906
22044
  this.fellBackToLocal = false;
21907
22045
  this.reconnecting = void 0;
21908
22046
  await this.rememberTarget({ mode: "remote", hostDeviceId: next.target.deviceId, hostName: next.target.name });
@@ -21923,7 +22061,7 @@ var ClientModeRuntime = class {
21923
22061
  const previous = this.connected;
21924
22062
  this.supersededClient = previous?.client;
21925
22063
  try {
21926
- const next = await this.connect(targetDeviceId, void 0, forceRelay);
22064
+ const next = await this.connectWithReason(targetDeviceId, void 0, forceRelay);
21927
22065
  if (previous === void 0) {
21928
22066
  this.supersededClient = void 0;
21929
22067
  await next.client.close().catch(() => void 0);
@@ -21936,13 +22074,12 @@ var ClientModeRuntime = class {
21936
22074
  await this.closeCodexStreams(previous.client);
21937
22075
  await previous.client.close().catch(() => void 0);
21938
22076
  this.supersededClient = void 0;
21939
- this.finishReconnect("link re-established");
21940
22077
  return true;
21941
22078
  } catch (error) {
21942
22079
  this.supersededClient = void 0;
21943
22080
  this.logger.warn("fast reconnect attempt failed", {
21944
22081
  targetDeviceId: shortId2(targetDeviceId),
21945
- code: safeErrorCode(error),
22082
+ code: failureCode(error),
21946
22083
  ...connectionFailureDetail(error)
21947
22084
  });
21948
22085
  return false;
@@ -21951,6 +22088,83 @@ var ClientModeRuntime = class {
21951
22088
  rememberWorkspaceSelection(selection) {
21952
22089
  this.pendingWorkspaceSelection = selection;
21953
22090
  this.lastWorkspaceSelection = { ...selection };
22091
+ const connected = this.connected;
22092
+ if (connected?.target.deviceId === selection.targetDeviceId) {
22093
+ void this.rememberTarget({
22094
+ mode: "remote",
22095
+ hostDeviceId: selection.targetDeviceId,
22096
+ hostName: connected.target.name
22097
+ });
22098
+ }
22099
+ }
22100
+ /**
22101
+ * What the user is watching, reduced to the only thing a recovery has to restore.
22102
+ *
22103
+ * A lost session is always "this device, this kind of workspace, this directory". Deriving that once -
22104
+ * instead of letting each recovery path re-read `lastWorkspaceSelection` and decide for itself - is what
22105
+ * keeps a Codex session from being restored onto the peer's DeepSeek carrier, which is exactly the bug the
22106
+ * reconnect used to produce.
22107
+ * @param targetDeviceId - the Host the lost session belonged to.
22108
+ * @returns the target, or undefined when nothing on that Host is being watched.
22109
+ */
22110
+ sessionTargetFor(targetDeviceId) {
22111
+ const selection = this.lastWorkspaceSelection;
22112
+ if (selection === void 0 || selection.targetDeviceId !== targetDeviceId) return void 0;
22113
+ return {
22114
+ deviceId: targetDeviceId,
22115
+ kind: selection.backend === "codex" ? "codex" : "harness",
22116
+ workspaceId: selection.workspaceId,
22117
+ ...selection.workspacePath === void 0 ? {} : { workspacePath: selection.workspacePath },
22118
+ ...selection.sessionId === void 0 ? {} : { sessionId: selection.sessionId }
22119
+ };
22120
+ }
22121
+ /**
22122
+ * Put the session the user was watching back in place on the current link.
22123
+ *
22124
+ * The one step every recovery path ends with, whichever way the link came back: the retry schedule, the
22125
+ * quick window, or a boot restore. A Codex target rebuilds the Codex carrier - never the peer's Harness
22126
+ * workspace - and a Harness target republishes its workspace selection so the native UI re-reads its list.
22127
+ * @param target - the session to restore.
22128
+ * @param fallbackToLocal - whether a failed Codex rebuild may hand the view to the local shell. The quick
22129
+ * window passes false: its whole point is to keep the user looking at the session while it is rescued.
22130
+ * @returns true when the user is looking at that session again.
22131
+ */
22132
+ async restoreSession(target2, options = {}) {
22133
+ if (target2.kind === "codex") {
22134
+ const restored = await this.restoreCodexCarrier(target2, options.fallbackToLocal !== false);
22135
+ if (restored) {
22136
+ this.logger.info("remote Codex session restored", {
22137
+ targetDeviceId: shortId2(target2.deviceId),
22138
+ kind: "codex",
22139
+ path: target2.workspacePath ?? "(unknown)"
22140
+ });
22141
+ }
22142
+ return restored;
22143
+ }
22144
+ this.restoreWorkspaceSelection(target2.deviceId);
22145
+ this.logger.info("remote DSH session restored", {
22146
+ targetDeviceId: shortId2(target2.deviceId),
22147
+ kind: "harness"
22148
+ });
22149
+ return true;
22150
+ }
22151
+ /**
22152
+ * Clear the recovery flag once the user is back on the session they were watching.
22153
+ *
22154
+ * The header reads this flag to show "reconnecting", so it must be cleared exactly when the recovery
22155
+ * succeeded - not when a transport happened to come back, and not while a Codex workspace is still missing.
22156
+ * @param targetDeviceId - the Host the recovery was about.
22157
+ * @param reason - what brought the session back, for the log.
22158
+ * @param kind - which carrier was restored.
22159
+ */
22160
+ finishRecovery(targetDeviceId, reason, kind) {
22161
+ this.reconnecting = void 0;
22162
+ this.fellBackToLocal = false;
22163
+ this.logger.info("remote session recovered", {
22164
+ targetDeviceId: shortId2(targetDeviceId),
22165
+ kind,
22166
+ reason
22167
+ });
21954
22168
  }
21955
22169
  /**
21956
22170
  * Republish the workspace selection so the native UI re-reads its remote session list.
@@ -21968,12 +22182,110 @@ var ClientModeRuntime = class {
21968
22182
  workspaceId: selection.workspaceId
21969
22183
  });
21970
22184
  }
21971
- finishReconnect(reason) {
21972
- const target2 = this.reconnecting?.targetDeviceId;
21973
- if (target2 === void 0) return;
21974
- this.reconnecting = void 0;
21975
- this.restoreWorkspaceSelection(target2);
21976
- this.logger.info("remote Harness reconnect finished", { targetDeviceId: shortId2(target2), reason });
22185
+ /**
22186
+ * Put the Codex carrier back onto the link that just replaced the old one.
22187
+ *
22188
+ * A reconnect leaves the carrier holding a transfer channel bound to a transport that no longer exists, and the
22189
+ * first use fails with "remote transport closed" while the UI still looks connected - the reported symptom:
22190
+ * Codex sessions vanish and clicking one errors. Rebuilding the carrier is what lets an open Codex workspace
22191
+ * survive a reconnect.
22192
+ *
22193
+ * The workspace is matched by directory, not by id: a Host that replaced its Codex domain renumbers the very
22194
+ * same directories, so an id that misses is retried once by root before the workspace is given up on.
22195
+ * @param target - the Codex session to restore.
22196
+ * @param fallbackToLocal - whether a failure may hand the view to the client's own shell. The quick window
22197
+ * passes false so the user keeps looking at the session for the whole window.
22198
+ * @returns true when the Codex carrier is back on the given workspace.
22199
+ */
22200
+ async restoreCodexCarrier(target2, fallbackToLocal = true) {
22201
+ try {
22202
+ try {
22203
+ await this.openCodexWorkspace(target2.deviceId, target2.workspaceId);
22204
+ } catch (error) {
22205
+ const root = target2.workspacePath;
22206
+ if (root === void 0) throw error;
22207
+ const recovered = await this.codexWorkspaceIdForPath(target2.deviceId, root);
22208
+ if (recovered === void 0) throw error;
22209
+ if (recovered !== target2.workspaceId) {
22210
+ this.logger.info("CodeX workspace restored under a new id", {
22211
+ targetDeviceId: shortId2(target2.deviceId),
22212
+ path: root
22213
+ });
22214
+ }
22215
+ await this.openCodexWorkspace(target2.deviceId, recovered);
22216
+ }
22217
+ this.restoreWorkspaceSelection(target2.deviceId);
22218
+ return true;
22219
+ } catch (error) {
22220
+ this.logger.warn("CodeX workspace could not be restored", {
22221
+ targetDeviceId: shortId2(target2.deviceId),
22222
+ code: failureCode(error),
22223
+ path: target2.workspacePath ?? "(unknown)",
22224
+ ...connectionFailureDetail(error)
22225
+ });
22226
+ if (fallbackToLocal) await this.fallBackToLocalForCodex();
22227
+ return false;
22228
+ }
22229
+ }
22230
+ /**
22231
+ * The id the Host currently uses for a workspace root.
22232
+ *
22233
+ * @param targetDeviceId - the Host to ask.
22234
+ * @param path - the workspace root as it was remembered.
22235
+ * @returns the current workspace id, or undefined when the Host no longer offers that root.
22236
+ */
22237
+ async codexWorkspaceIdForPath(targetDeviceId, path) {
22238
+ const workspaces = await this.listCodexWorkspaces(targetDeviceId);
22239
+ const match = workspaces.find((candidate) => sameWorkspaceRoot(candidate.path, path));
22240
+ return match?.workspaceId;
22241
+ }
22242
+ /**
22243
+ * Show the client's own shell while a Codex session is unavailable.
22244
+ *
22245
+ * The peer's other carrier must not stand in for the Codex workspace that was lost, and this deliberately does not
22246
+ * touch the reconnect run counter: the retry schedule keeps trying to restore the Codex workspace while the user
22247
+ * works locally.
22248
+ * @returns nothing.
22249
+ */
22250
+ async fallBackToLocalForCodex() {
22251
+ await this.closeCodexVirtual();
22252
+ this.proxySwitch?.selectLocal();
22253
+ this.gatewaySwitch.selectLocal();
22254
+ this.fellBackToLocal = true;
22255
+ }
22256
+ /**
22257
+ * The Codex session is gone while the transport is still up (the Host replaced or switched off its Codex
22258
+ * domain). Deliberately the same entry point as a lost link, so both carriers take one path: quick reconnect,
22259
+ * local fallback and the retry schedule.
22260
+ *
22261
+ * The trigger is logged, because "the banner did not appear" is otherwise indistinguishable between "no
22262
+ * signal arrived" and "the signal arrived but the UI did not show it".
22263
+ * @param trigger - what noticed it.
22264
+ * @returns nothing; the reconnect runs in the background.
22265
+ */
22266
+ noteCodexSessionLost(trigger) {
22267
+ const connected = this.connected;
22268
+ if (connected === void 0 || this.reconnecting !== void 0) {
22269
+ this.logger.info("Codex session loss ignored", {
22270
+ targetDeviceId: connected === void 0 ? void 0 : shortId2(connected.target.deviceId),
22271
+ trigger,
22272
+ reason: connected === void 0 ? "not-connected" : "recovery-already-running"
22273
+ });
22274
+ return;
22275
+ }
22276
+ if (this.sessionTargetFor(connected.target.deviceId)?.kind !== "codex") {
22277
+ this.logger.info("Codex session loss ignored", {
22278
+ targetDeviceId: shortId2(connected.target.deviceId),
22279
+ trigger,
22280
+ reason: "watching-another-session"
22281
+ });
22282
+ return;
22283
+ }
22284
+ this.logger.warn("remote Codex session lost", {
22285
+ targetDeviceId: shortId2(connected.target.deviceId),
22286
+ trigger
22287
+ });
22288
+ void this.handleRemoteTransportLost(connected.client, connected.target.deviceId, "codex-closed");
21977
22289
  }
21978
22290
  /**
21979
22291
  * Check the live session now, outside the cadence.
@@ -21986,34 +22298,56 @@ var ClientModeRuntime = class {
21986
22298
  return this.status();
21987
22299
  }
21988
22300
  /**
21989
- * Rebuild the link inside one quick window, keeping the user's view.
22301
+ * Re-read the connected Host's capabilities.
21990
22302
  *
21991
- * The session is left exactly as it is - same Workspace, same Session list, no reload - while the link
21992
- * is rebuilt over relay. Only the Session data is refreshed once it is back, and the caller falls back
21993
- * to the local shell when the window runs out.
21994
- * @param targetDeviceId - the Host to rebuild the link to.
21995
- * @returns whether a session is in place again.
22303
+ * A Host that switches Codex (or the terminal) on or off changes what it advertises, and the cached feature
22304
+ * set used to be refreshed only when a workspace list happened to be requested - which is why the Remote card
22305
+ * could describe a Host it no longer matched. One round trip, no business payload, so the card can poll it.
22306
+ * @returns the connected Host's fresh feature set, or `{ connected: false }`.
21996
22307
  */
21997
- async quickReconnect(targetDeviceId) {
21998
- if (this.reconnecting !== void 0) return false;
21999
- this.reconnecting = { targetDeviceId, phase: "fast" };
22000
- this.logger.warn("remote Harness link needs rebuilding; trying a quick reconnect", {
22001
- targetDeviceId: shortId2(targetDeviceId),
22002
- attempts: QUICK_RECONNECT_ATTEMPTS,
22003
- windowMs: QUICK_RECONNECT_WINDOW_MS
22004
- });
22005
- const deadline = Date.now() + QUICK_RECONNECT_WINDOW_MS;
22006
- for (let attempt = 1; attempt <= QUICK_RECONNECT_ATTEMPTS; attempt += 1) {
22007
- if (await this.reestablish(targetDeviceId, QUICK_RECONNECT_FORCE_RELAY)) return true;
22008
- if (attempt === QUICK_RECONNECT_ATTEMPTS) break;
22009
- const wait2 = Math.min(QUICK_RECONNECT_RETRY_DELAY_MS, Math.max(0, deadline - Date.now()));
22010
- if (wait2 === 0) break;
22011
- await new Promise((resolve4) => {
22012
- setTimeout(resolve4, wait2);
22308
+ async probeRemoteFeatures() {
22309
+ const remote = this.connected;
22310
+ if (remote === void 0) return { connected: false };
22311
+ try {
22312
+ remote.features = await probeRemoteHostFeatures(remote.client, remote.clientVersion);
22313
+ } catch (error) {
22314
+ const code = failureCode(error);
22315
+ this.logger.warn("remote capability probe failed", {
22316
+ targetDeviceId: shortId2(remote.target.deviceId),
22317
+ code
22013
22318
  });
22319
+ return { connected: true, targetDeviceId: remote.target.deviceId, features: remote.features, error: code };
22014
22320
  }
22015
- this.reconnecting = void 0;
22016
- return false;
22321
+ return { connected: true, targetDeviceId: remote.target.deviceId, features: remote.features };
22322
+ }
22323
+ /**
22324
+ * Re-establish the session the user is watching, without changing which session that is.
22325
+ *
22326
+ * What the Remote card's Refresh does for a live session: the transport may have died, the peer's Codex may
22327
+ * have come back, or a previous recovery may have given up. This never selects another Host and never another
22328
+ * kind of workspace - the remembered selection is the only thing it works from.
22329
+ * @returns the status after the attempt.
22330
+ */
22331
+ async recoverCurrentSession() {
22332
+ const selection = this.lastWorkspaceSelection;
22333
+ if (selection === void 0) return this.status();
22334
+ if (this.reconnecting !== void 0) return this.status();
22335
+ const target2 = this.sessionTargetFor(selection.targetDeviceId);
22336
+ const connected = this.connected;
22337
+ if (connected === void 0) {
22338
+ this.reconnecting = { targetDeviceId: selection.targetDeviceId, phase: "fallback" };
22339
+ void this.reconnectRemoteSession(selection.targetDeviceId, target2);
22340
+ return this.status();
22341
+ }
22342
+ if (connected.target.deviceId !== selection.targetDeviceId) return this.status();
22343
+ const probe = await this.probeRemoteFeatures();
22344
+ if (probe.connected === true && probe.error === void 0) {
22345
+ if (target2 !== void 0) await this.restoreSession(target2);
22346
+ return this.status();
22347
+ }
22348
+ if (probe.error === void 0) return this.status();
22349
+ await this.handleRemoteTransportLost(connected.client, connected.target.deviceId, "manual");
22350
+ return this.status();
22017
22351
  }
22018
22352
  /**
22019
22353
  * Shared cleanup for a session whose transport is gone.
@@ -22025,83 +22359,255 @@ var ClientModeRuntime = class {
22025
22359
  * @param client - the client that was connected.
22026
22360
  * @param targetDeviceId - the Host it was bound to.
22027
22361
  */
22362
+ /**
22363
+ * A user's explicit workspace choice supersedes a recovery that is still in flight.
22364
+ *
22365
+ * The retry schedule belongs to the session the user was watching. When they pick another workspace, that
22366
+ * schedule must not land later and move the view again: the reported failure was a Codex ladder restoring its
22367
+ * own workspace over the DSH workspace the user had just opened - "the user's own choice is the only thing
22368
+ * that changes which session is on screen" was broken by a background timer. Only the panel's open endpoints
22369
+ * call this; the recovery restores through the internal open paths, so it never cancels itself.
22370
+ */
22371
+ supersedeRemoteRecovery() {
22372
+ if (this.reconnecting === void 0 && !this.fellBackToLocal) return;
22373
+ this.logger.info("a workspace was opened; stopping the recovery in flight", {
22374
+ phase: this.reconnecting?.phase ?? "fallback"
22375
+ });
22376
+ this.reconnecting = void 0;
22377
+ this.fellBackToLocal = false;
22378
+ this.remoteReconnectRun += 1;
22379
+ }
22380
+ /**
22381
+ * Whether the user still wants the session a recovery is putting back.
22382
+ *
22383
+ * The user's own choice is the record, not a counter: cancelling clears `lastWorkspaceSelection` (and writes
22384
+ * `local` to the target file), and opening another workspace points the selection somewhere else. A recovery is
22385
+ * work the user asked for *earlier*, so it may only adopt its result while that is still what they want. The
22386
+ * reported failure was an attempt that landed after the answer - it reconnected and put the session back on
22387
+ * screen, because nothing re-read the user's intent once the attempt was in flight.
22388
+ * @param targetDeviceId - the Host the recovery is restoring.
22389
+ * @returns true while the remembered target is still that Host.
22390
+ */
22391
+ recoveryStillWanted(targetDeviceId) {
22392
+ if (this.reconnecting === void 0) return false;
22393
+ return this.lastWorkspaceSelection?.targetDeviceId === targetDeviceId;
22394
+ }
22395
+ /**
22396
+ * Undo a recovery the user answered while it was landing: their choice wins over work already in flight.
22397
+ * @param targetDeviceId - the Host the recovery was restoring.
22398
+ */
22399
+ async abandonRecovery(targetDeviceId) {
22400
+ this.logger.info("the user answered while the recovery was landing; returning to the local shell", {
22401
+ targetDeviceId: shortId2(targetDeviceId)
22402
+ });
22403
+ await this.setMode("local");
22404
+ }
22028
22405
  async handleRemoteTransportLost(client, targetDeviceId, reason) {
22406
+ const browsing = this.browsing?.client === client ? this.browsing : void 0;
22407
+ if (browsing !== void 0 && this.connected?.client !== client) {
22408
+ this.browsing = void 0;
22409
+ this.clearConnectionProgress(browsing.progressRunId);
22410
+ this.logger.info("browsing connection closed; the panel will open a new one", {
22411
+ targetDeviceId: shortId2(targetDeviceId),
22412
+ reason
22413
+ });
22414
+ void browsing.client.close().catch(() => void 0);
22415
+ return;
22416
+ }
22029
22417
  if (this.connected?.client !== client) return;
22030
22418
  this.supersededClient = void 0;
22031
22419
  const targetName = this.connected.target.name;
22032
- if (this.reconnecting === void 0 && await this.quickReconnect(targetDeviceId)) {
22033
- this.logger.info("remote Harness session kept its view through a quick reconnect", {
22034
- targetDeviceId: shortId2(targetDeviceId)
22420
+ const target2 = this.sessionTargetFor(targetDeviceId);
22421
+ const kind = target2?.kind ?? "harness";
22422
+ if (target2 === void 0) {
22423
+ this.logger.info("connection closed with no remote session to recover; staying local", {
22424
+ targetDeviceId: shortId2(targetDeviceId),
22425
+ reason
22035
22426
  });
22427
+ const previous = this.connected;
22428
+ this.connected = void 0;
22429
+ this.connectionProgress = void 0;
22430
+ await this.closeCodexStreams(previous?.client);
22431
+ await previous?.client.close().catch(() => void 0);
22036
22432
  return;
22037
22433
  }
22434
+ const transportLost = reason !== "codex-closed";
22435
+ if (this.reconnecting === void 0) {
22436
+ const run = this.remoteReconnectRun;
22437
+ this.reconnecting = { targetDeviceId, ...targetName === void 0 ? {} : { targetName }, phase: "fast" };
22438
+ this.logger.warn("remote session lost; trying the quick window", {
22439
+ targetDeviceId: shortId2(targetDeviceId),
22440
+ kind,
22441
+ reason,
22442
+ attempts: QUICK_RECONNECT_ATTEMPTS,
22443
+ windowMs: QUICK_RECONNECT_WINDOW_MS
22444
+ });
22445
+ const rescued = await this.quickReconnectSession(targetDeviceId, target2, transportLost, run);
22446
+ if (run !== this.remoteReconnectRun) {
22447
+ this.logger.info("the user answered during the quick window; the recovery stops here", {
22448
+ targetDeviceId: shortId2(targetDeviceId),
22449
+ kind
22450
+ });
22451
+ return;
22452
+ }
22453
+ if (rescued) {
22454
+ this.finishRecovery(targetDeviceId, "quick-window", kind);
22455
+ this.logger.info("remote session kept its view through a quick reconnect", {
22456
+ targetDeviceId: shortId2(targetDeviceId),
22457
+ kind
22458
+ });
22459
+ return;
22460
+ }
22461
+ }
22462
+ await this.fallBackToLocalShell(targetDeviceId, targetName, reason, kind, transportLost);
22463
+ void this.reconnectRemoteSession(targetDeviceId, target2);
22464
+ }
22465
+ /**
22466
+ * The quick window: rescue the same session without disturbing the view.
22467
+ *
22468
+ * At most {@link QUICK_RECONNECT_ATTEMPTS} attempts inside {@link QUICK_RECONNECT_WINDOW_MS}, spaced by
22469
+ * {@link QUICK_RECONNECT_RETRY_DELAY_MS}. Nothing here falls back to the local shell or republishes a
22470
+ * workspace - the user keeps looking at the session they were in, and the caller decides what happens once the
22471
+ * window is spent. A lost transport is rebuilt first, over relay only (the one path measured under five
22472
+ * seconds); a Codex-only loss already has its link, so its attempts only rebuild the carrier.
22473
+ * @param targetDeviceId - the Host the session was bound to.
22474
+ * @param target - the session to put back.
22475
+ * @param transportLost - whether the link itself has to be rebuilt.
22476
+ * @param run - the recovery run this window belongs to; the user answering bumps it.
22477
+ * @returns true when the session is on screen again.
22478
+ */
22479
+ async quickReconnectSession(targetDeviceId, target2, transportLost, run) {
22480
+ let linkUp = !transportLost;
22481
+ const deadline = Date.now() + QUICK_RECONNECT_WINDOW_MS;
22482
+ for (let attempt = 1; attempt <= QUICK_RECONNECT_ATTEMPTS; attempt += 1) {
22483
+ if (!this.recoveryStillWanted(targetDeviceId) || run !== this.remoteReconnectRun) return false;
22484
+ if (!linkUp) linkUp = await this.reestablish(targetDeviceId, QUICK_RECONNECT_FORCE_RELAY);
22485
+ if (!this.recoveryStillWanted(targetDeviceId) || run !== this.remoteReconnectRun) return false;
22486
+ if (linkUp && await this.restoreSession(target2, { fallbackToLocal: false })) {
22487
+ if (!this.recoveryStillWanted(targetDeviceId) || run !== this.remoteReconnectRun) {
22488
+ await this.abandonRecovery(targetDeviceId);
22489
+ return false;
22490
+ }
22491
+ return true;
22492
+ }
22493
+ if (attempt === QUICK_RECONNECT_ATTEMPTS) break;
22494
+ const wait2 = Math.min(QUICK_RECONNECT_RETRY_DELAY_MS, Math.max(0, deadline - Date.now()));
22495
+ if (wait2 === 0) break;
22496
+ await sleep3(wait2);
22497
+ }
22498
+ if (!this.recoveryStillWanted(targetDeviceId) || run !== this.remoteReconnectRun) return false;
22499
+ const remaining = deadline - Date.now();
22500
+ if (remaining > 0) await sleep3(remaining);
22501
+ this.reconnecting = void 0;
22502
+ return false;
22503
+ }
22504
+ /**
22505
+ * Hand the session back to the client's own shell.
22506
+ *
22507
+ * Phase two of the ladder, and the same for every kind of loss. A link that broke is closed here; one that is
22508
+ * still up (a Codex-only loss) is kept, because the retry schedule needs it and dropping a healthy link only to
22509
+ * open it again five seconds later is churn the user pays for. Switching the carriers to local is what makes
22510
+ * the view fall back - the point of this step.
22511
+ * @param targetDeviceId - the Host the session was bound to.
22512
+ * @param targetName - its display name, kept for the header while the reconnect runs.
22513
+ * @param reason - why the session was lost, for the log.
22514
+ * @param kind - which carrier was lost.
22515
+ * @param transportLost - whether the link itself is gone.
22516
+ */
22517
+ async fallBackToLocalShell(targetDeviceId, targetName, reason, kind, transportLost) {
22038
22518
  void this.closePreview();
22039
- this.connected = void 0;
22040
22519
  this.connectionProgress = void 0;
22041
22520
  this.pendingWorkspaceSelection = void 0;
22042
- void this.closeCodexVirtual();
22521
+ await this.closeCodexVirtual();
22043
22522
  this.proxySwitch?.selectLocal();
22044
22523
  this.gatewaySwitch.selectLocal();
22045
22524
  this.fellBackToLocal = true;
22046
22525
  this.reconnecting = { targetDeviceId, ...targetName === void 0 ? {} : { targetName }, phase: "fallback" };
22047
- void client.close().catch(() => void 0);
22048
- this.logger.warn("remote Harness transport lost; reconnecting", {
22526
+ if (transportLost) {
22527
+ const previous = this.connected;
22528
+ this.connected = void 0;
22529
+ await this.closeCodexStreams(previous?.client);
22530
+ void previous?.client.close().catch(() => void 0);
22531
+ }
22532
+ this.logger.warn("remote session lost; falling back to the local shell and retrying", {
22049
22533
  targetDeviceId: shortId2(targetDeviceId),
22534
+ kind,
22050
22535
  reason
22051
22536
  });
22052
- void this.reconnectRemoteSession(targetDeviceId);
22053
22537
  }
22054
22538
  /**
22055
- * Re-establish a remote session whose transport closed.
22539
+ * The background retry ladder, shared by every way a session can be recovered.
22056
22540
  *
22057
- * The UI keeps rendering the remote session after the transport is gone, so
22058
- * without this the user faces a session that silently ignores everything and has
22059
- * to exit and pick the Host again — which is what a suspended and resumed client
22060
- * used to require every time. Retry the same Host with backoff; a loop that is
22061
- * superseded, or one whose session came back another way, stops quietly.
22062
- * @param targetDeviceId - the Host the dropped session was bound to.
22541
+ * It runs after the quick window has been spent (the caller has already fallen back to the local shell), or
22542
+ * straight away for a boot restore, which has no window behind it. Every attempt targets the same session; on
22543
+ * success the workspace and the session data are re-read, and when the ladder is spent the reconnect flag is
22544
+ * cleared so the user is simply local. A loop that is superseded, or one whose session came back another way,
22545
+ * stops quietly.
22546
+ * @param targetDeviceId - the Host the lost session was bound to.
22547
+ * @param target - the session to put back.
22548
+ * @param immediateFirstAttempt - true for a boot restore, which should not wait five seconds to start.
22063
22549
  */
22064
- async reconnectRemoteSession(targetDeviceId) {
22550
+ async reconnectRemoteSession(targetDeviceId, target2 = this.sessionTargetFor(targetDeviceId), immediateFirstAttempt = false) {
22065
22551
  const run = ++this.remoteReconnectRun;
22066
- const delays = [0, 5e3, 5e3, 1e4, 2e4];
22552
+ const kind = target2?.kind ?? "harness";
22553
+ if (target2 === void 0) {
22554
+ this.reconnecting = void 0;
22555
+ this.fellBackToLocal = false;
22556
+ this.logger.info("no remote session to restore; staying in the local shell", {
22557
+ targetDeviceId: shortId2(targetDeviceId)
22558
+ });
22559
+ return;
22560
+ }
22561
+ const delays = immediateFirstAttempt ? [0, ...BACKGROUND_RETRY_DELAYS_MS] : BACKGROUND_RETRY_DELAYS_MS;
22067
22562
  let attempt = 0;
22068
22563
  for (; ; ) {
22069
- if (attempt - delays.length >= FALLBACK_STEADY_ATTEMPTS) {
22564
+ if (attempt >= delays.length) {
22070
22565
  this.reconnecting = void 0;
22071
- this.logger.warn("remote Harness reconnect gave up; staying in the local shell", {
22566
+ this.logger.warn("remote session recovery gave up; staying in the local shell", {
22072
22567
  targetDeviceId: shortId2(targetDeviceId),
22568
+ kind,
22073
22569
  attempts: attempt
22074
22570
  });
22075
22571
  return;
22076
22572
  }
22077
- const wait2 = delays[attempt] ?? 3e4;
22078
- if (wait2 > 0) await new Promise((resolve4) => {
22079
- setTimeout(resolve4, wait2);
22080
- });
22573
+ const wait2 = delays[attempt] ?? BACKGROUND_RETRY_DELAYS_MS[BACKGROUND_RETRY_DELAYS_MS.length - 1];
22574
+ if (wait2 > 0) await sleep3(wait2);
22081
22575
  attempt += 1;
22082
22576
  if (run !== this.remoteReconnectRun) return;
22083
- if (this.connected !== void 0) return;
22577
+ const reconnecting = this.reconnecting;
22578
+ if (reconnecting === void 0) return;
22084
22579
  try {
22085
- await this.setMode("remote", targetDeviceId);
22086
- this.restoreWorkspaceSelection(targetDeviceId);
22087
- this.logger.info("remote Harness session reconnected", { targetDeviceId: shortId2(targetDeviceId) });
22580
+ if (this.connected?.target.deviceId !== targetDeviceId) {
22581
+ await this.setMode("remote", targetDeviceId);
22582
+ this.reconnecting = reconnecting;
22583
+ }
22584
+ if (target2 !== void 0 && !await this.restoreSession(target2)) continue;
22585
+ if (!this.recoveryStillWanted(targetDeviceId)) {
22586
+ await this.abandonRecovery(targetDeviceId);
22587
+ return;
22588
+ }
22589
+ this.finishRecovery(targetDeviceId, "retry-schedule", kind);
22088
22590
  return;
22089
22591
  } catch (error) {
22090
- const code = safeErrorCode(error);
22091
- if (code !== void 0 && CREDENTIAL_FAILURE_CODES.has(code)) {
22092
- this.logger.warn("remote Harness reconnect stopped: authorization is required", {
22592
+ const code = failureCode(error);
22593
+ if (CREDENTIAL_FAILURE_CODES.has(code)) {
22594
+ this.reconnecting = void 0;
22595
+ this.logger.warn("remote session recovery stopped: authorization is required", {
22093
22596
  targetDeviceId: shortId2(targetDeviceId),
22597
+ kind,
22094
22598
  code
22095
22599
  });
22096
- this.reconnecting = void 0;
22097
22600
  return;
22098
22601
  }
22099
22602
  if (attempt < 3 || attempt % 10 === 0) {
22100
- this.logger.warn("remote Harness reconnect attempt failed", {
22603
+ const detail = connectionFailureDetail(error);
22604
+ const systemCode = typeof detail.systemCode === "string" ? detail.systemCode : void 0;
22605
+ this.logger.warn("remote session recovery attempt failed", {
22101
22606
  targetDeviceId: shortId2(targetDeviceId),
22607
+ kind,
22102
22608
  attempt,
22103
- code,
22104
- ...connectionFailureDetail(error)
22609
+ code: systemCode ?? code,
22610
+ ...detail
22105
22611
  });
22106
22612
  }
22107
22613
  }
@@ -22117,10 +22623,19 @@ var ClientModeRuntime = class {
22117
22623
  */
22118
22624
  async rememberTarget(target2) {
22119
22625
  if (this.targetStore === void 0) return;
22626
+ const selection = target2.mode === "remote" && target2.hostDeviceId !== void 0 ? this.sessionTargetFor(target2.hostDeviceId) : void 0;
22120
22627
  try {
22121
22628
  await this.targetStore.save({
22122
22629
  ...target2,
22123
- ...this.config.serverUrl === void 0 ? {} : { serverUrl: this.config.serverUrl }
22630
+ ...this.config.serverUrl === void 0 ? {} : { serverUrl: this.config.serverUrl },
22631
+ ...selection === void 0 ? {} : {
22632
+ workspace: {
22633
+ workspaceId: selection.workspaceId,
22634
+ kind: selection.kind,
22635
+ ...selection.workspacePath === void 0 ? {} : { workspacePath: selection.workspacePath },
22636
+ ...selection.sessionId === void 0 ? {} : { sessionId: selection.sessionId }
22637
+ }
22638
+ }
22124
22639
  });
22125
22640
  } catch (error) {
22126
22641
  this.logger.warn("could not record the remote target", { code: safeErrorCode(error) });
@@ -22146,19 +22661,33 @@ var ClientModeRuntime = class {
22146
22661
  });
22147
22662
  return false;
22148
22663
  }
22664
+ if (record7.workspace === void 0) {
22665
+ this.logger.info("the recorded target has no workspace; staying in the local shell", {
22666
+ targetDeviceId: shortId2(record7.hostDeviceId)
22667
+ });
22668
+ return false;
22669
+ }
22149
22670
  this.reconnecting = {
22150
22671
  targetDeviceId: record7.hostDeviceId,
22151
22672
  ...record7.hostName === void 0 ? {} : { targetName: record7.hostName },
22152
22673
  phase: "restore"
22153
22674
  };
22675
+ this.lastWorkspaceSelection = {
22676
+ targetDeviceId: record7.hostDeviceId,
22677
+ workspaceId: record7.workspace.workspaceId,
22678
+ ...record7.workspace.kind === "codex" ? { backend: "codex" } : {},
22679
+ ...record7.workspace.workspacePath === void 0 ? {} : { workspacePath: record7.workspace.workspacePath },
22680
+ ...record7.workspace.sessionId === void 0 ? {} : { sessionId: record7.workspace.sessionId }
22681
+ };
22154
22682
  this.logger.info("restoring the remote target of the previous run", {
22155
- targetDeviceId: shortId2(record7.hostDeviceId)
22683
+ targetDeviceId: shortId2(record7.hostDeviceId),
22684
+ kind: record7.workspace.kind
22156
22685
  });
22157
- void this.reconnectRemoteSession(record7.hostDeviceId);
22686
+ void this.reconnectRemoteSession(record7.hostDeviceId, this.sessionTargetFor(record7.hostDeviceId), true);
22158
22687
  return true;
22159
22688
  }
22160
22689
  async listRemoteDirectory(targetDeviceId, path, signal) {
22161
- const remote = await this.ensureConnected(targetDeviceId, signal);
22690
+ const remote = await this.ensureBrowsing(targetDeviceId, signal);
22162
22691
  if (remote.features.remoteGateway) {
22163
22692
  const value = await new RemoteTypertGateway2(remote.client).invoke({
22164
22693
  namespace: "directoryPicker",
@@ -22176,7 +22705,7 @@ var ClientModeRuntime = class {
22176
22705
  return unwrapNativeResult(response);
22177
22706
  }
22178
22707
  async listRemoteWorkspaces(targetDeviceId, signal) {
22179
- const remote = await this.ensureConnected(targetDeviceId, signal);
22708
+ const remote = await this.ensureBrowsing(targetDeviceId, signal);
22180
22709
  if (remote.features.remoteGateway) {
22181
22710
  return readRemoteWorkspaceBaseline(new RemoteTypertGateway2(remote.client), signal);
22182
22711
  }
@@ -22215,31 +22744,108 @@ var ClientModeRuntime = class {
22215
22744
  this.logger.info("Remote workspace opened", { targetDeviceId: shortId2(remote.target.deviceId) });
22216
22745
  return { ...this.status(), workspace };
22217
22746
  }
22747
+ /**
22748
+ * The refusal to report when a Host is not offering Codex right now.
22749
+ *
22750
+ * Two different facts hid behind one generic code, so the panel could not tell a user who had switched Codex
22751
+ * off from a Host that never had it. Which one it is comes from this client's own memory: a Host whose Codex
22752
+ * workspace was opened before has Codex, so its absence now means it was switched off (or is still starting);
22753
+ * a Host that never offered one simply does not provide it.
22754
+ *
22755
+ * A Codex that disappeared is also a lost session, so this hands it to the same recovery every other loss
22756
+ * uses before reporting it - a switched-off Codex that is switched back on is then restored on its own.
22757
+ * @param targetDeviceId - the Host the caller was talking to.
22758
+ * @returns the error to throw.
22759
+ */
22760
+ remoteCodexUnavailable(targetDeviceId) {
22761
+ const expected = shouldRestoreCodexCarrier(this.lastWorkspaceSelection, targetDeviceId) || this.codexHostsSeen.has(targetDeviceId);
22762
+ if (expected) this.noteCodexSessionLost("capability-missing");
22763
+ return expected ? new ClientModeError("CODEX_DISABLED", "Codex is switched off or unavailable on the selected Host.") : new ClientModeError("CODEX_NOT_PROVIDED", "The selected Host does not provide CodeX workspaces.");
22764
+ }
22218
22765
  async listCodexWorkspaces(targetDeviceId, signal) {
22219
- const remote = await this.ensureConnected(targetDeviceId, signal);
22766
+ const remote = await this.ensureBrowsing(targetDeviceId, signal);
22220
22767
  remote.features = await probeRemoteHostFeatures(remote.client, remote.clientVersion);
22221
22768
  if (!remote.features.codex) {
22222
- throw new ClientModeError("FEATURE_NOT_SUPPORTED", "The selected Host does not provide CodeX workspaces.");
22769
+ throw this.remoteCodexUnavailable(targetDeviceId);
22223
22770
  }
22224
- return discoverCodexVirtualWorkspaces(new CodexRemoteClient(remote.client), signal);
22771
+ let workspaces;
22772
+ try {
22773
+ workspaces = await discoverCodexVirtualWorkspaces(new CodexRemoteClient(remote.client), signal);
22774
+ } catch (error) {
22775
+ this.logger.warn("Codex workspaces could not be listed", {
22776
+ targetDeviceId: shortId2(targetDeviceId),
22777
+ code: failureCode(error)
22778
+ });
22779
+ if (isCodexSessionLoss(error)) this.noteCodexSessionLost("list-failed");
22780
+ throw error;
22781
+ }
22782
+ this.lastCodexListing.set(targetDeviceId, workspaces);
22783
+ for (const item of workspaces) {
22784
+ this.codexWorkspaceRoots.set(`${targetDeviceId}|${item.workspaceId}`, item.path);
22785
+ }
22786
+ this.logger.info("Codex workspaces listed", {
22787
+ targetDeviceId: shortId2(targetDeviceId),
22788
+ count: workspaces.length
22789
+ });
22790
+ return workspaces;
22225
22791
  }
22226
22792
  async openCodexWorkspace(targetDeviceId, workspaceId, signal) {
22227
22793
  const remote = await this.ensureConnected(targetDeviceId, signal);
22228
22794
  remote.features = await probeRemoteHostFeatures(remote.client, remote.clientVersion);
22229
22795
  if (!remote.features.codex) {
22230
- throw new ClientModeError("FEATURE_NOT_SUPPORTED", "The selected Host does not provide CodeX workspaces.");
22796
+ throw this.remoteCodexUnavailable(targetDeviceId);
22231
22797
  }
22232
22798
  this.assertLocalHarnessCarrierAvailable();
22233
22799
  const virtual = CodexVirtualHarness.remote(remote.client, {
22234
22800
  deviceId: remote.target.deviceId,
22235
22801
  name: remote.target.name
22236
22802
  }, harnessSessionGeneration(this.host?.localHarnessVersion?.()), new RemoteTypertGateway2(remote.client));
22803
+ virtual.watchCodexSessionLost((trigger) => this.noteCodexSessionLost(trigger));
22237
22804
  let workspace;
22238
22805
  try {
22239
22806
  workspace = await virtual.selectWorkspace(workspaceId, signal);
22240
- } catch {
22807
+ } catch (error) {
22808
+ const code = safeErrorCode(error);
22809
+ const listed = this.lastCodexListing.get(targetDeviceId);
22810
+ const previousRoot = listed?.find((item) => item.workspaceId === workspaceId)?.path ?? this.codexWorkspaceRoots.get(`${targetDeviceId}|${workspaceId}`) ?? this.lastWorkspaceSelection?.workspacePath;
22811
+ const inListing = listed?.some((item) => item.workspaceId === workspaceId) ?? false;
22812
+ if (previousRoot !== void 0) {
22813
+ const byRoot = await virtual.selectWorkspace(workspaceId, signal, previousRoot).catch(() => void 0);
22814
+ if (byRoot !== void 0) {
22815
+ this.logger.info("CodeX workspace opened by root after the id missed", {
22816
+ targetDeviceId: shortId2(targetDeviceId),
22817
+ path: previousRoot
22818
+ });
22819
+ await virtual.close();
22820
+ return this.openCodexWorkspace(targetDeviceId, byRoot.workspaceId, signal);
22821
+ }
22822
+ }
22241
22823
  await virtual.close();
22242
- throw new ClientModeError("WORKSPACE_NOT_FOUND", "The selected CodeX workspace is no longer available.");
22824
+ let currentId;
22825
+ if (previousRoot !== void 0) {
22826
+ currentId = await this.codexWorkspaceIdForPath(targetDeviceId, previousRoot).catch(() => void 0);
22827
+ }
22828
+ if (currentId !== void 0 && currentId !== workspaceId) {
22829
+ this.logger.info("CodeX workspace reopened under a new id", {
22830
+ targetDeviceId: shortId2(targetDeviceId),
22831
+ path: previousRoot
22832
+ });
22833
+ return this.openCodexWorkspace(targetDeviceId, currentId, signal);
22834
+ }
22835
+ const currentListing = this.lastCodexListing.get(targetDeviceId) ?? [];
22836
+ this.logger.warn("selected CodeX workspace could not be opened", {
22837
+ targetDeviceId: shortId2(targetDeviceId),
22838
+ code,
22839
+ inListing,
22840
+ hasRoot: previousRoot !== void 0,
22841
+ roots: [...this.codexWorkspaceRoots.keys()].filter((key) => key.startsWith(`${targetDeviceId}|`)).length,
22842
+ listed: listed?.length ?? -1,
22843
+ // What was asked for against what the Host currently offers, so the next report needs no second round trip.
22844
+ wanted: workspaceId.slice(0, 60),
22845
+ root: previousRoot === void 0 ? "(none)" : previousRoot,
22846
+ current: currentListing.slice(0, 5).map((item) => item.workspaceId + "@" + item.path).join(", ")
22847
+ });
22848
+ throw error instanceof ClientModeError ? error : new ClientModeError("WORKSPACE_NOT_FOUND", "The selected CodeX workspace is no longer available.");
22243
22849
  }
22244
22850
  await this.closeCodexVirtual();
22245
22851
  this.codexVirtual = virtual;
@@ -22249,25 +22855,37 @@ var ClientModeRuntime = class {
22249
22855
  targetDeviceId: remote.target.deviceId,
22250
22856
  workspaceId,
22251
22857
  backend: "codex",
22858
+ ...workspace.path === void 0 ? {} : { workspacePath: workspace.path },
22252
22859
  ...preferredSessionId === void 0 ? {} : { sessionId: preferredSessionId }
22253
22860
  });
22861
+ this.codexHostsSeen.add(remote.target.deviceId);
22254
22862
  this.logger.info("CodeX virtual workspace opened", { targetDeviceId: shortId2(remote.target.deviceId) });
22255
22863
  return { ...this.status(), workspace };
22256
22864
  }
22257
22865
  async createCodexWorkspace(targetDeviceId, path, signal) {
22258
22866
  const trimmedPath = path.trim();
22259
22867
  if (trimmedPath === "") throw new ClientModeError("INVALID_MESSAGE", "A CodeX project directory is required.");
22260
- const remote = await this.ensureConnected(targetDeviceId, signal);
22868
+ const remote = await this.ensureBrowsing(targetDeviceId, signal);
22261
22869
  remote.features = await probeRemoteHostFeatures(remote.client, remote.clientVersion);
22262
22870
  if (!remote.features.codex) {
22263
- throw new ClientModeError("FEATURE_NOT_SUPPORTED", "The selected Host does not provide CodeX workspaces.");
22871
+ throw this.remoteCodexUnavailable(targetDeviceId);
22264
22872
  }
22265
22873
  this.assertLocalHarnessCarrierAvailable();
22266
- const result = record4(await new CodexRemoteClient(remote.client).request("project/create", {
22267
- name: remoteWorkspaceTitle(trimmedPath),
22268
- roots: [{ path: trimmedPath }],
22269
- idempotencyKey: uuidV7()
22270
- }, signal));
22874
+ let result;
22875
+ try {
22876
+ result = record4(await new CodexRemoteClient(remote.client).request("project/create", {
22877
+ name: remoteWorkspaceTitle(trimmedPath),
22878
+ roots: [{ path: trimmedPath }],
22879
+ idempotencyKey: uuidV7()
22880
+ }, signal));
22881
+ } catch (error) {
22882
+ this.logger.warn("CodeX project could not be registered", {
22883
+ targetDeviceId: shortId2(targetDeviceId),
22884
+ code: failureCode(error)
22885
+ });
22886
+ if (isCodexSessionLoss(error)) this.noteCodexSessionLost("project-create-failed");
22887
+ throw error;
22888
+ }
22271
22889
  const project = record4(result.project);
22272
22890
  if (typeof project.id !== "string" || project.id.length === 0) {
22273
22891
  throw new ClientModeError("INVALID_MESSAGE", "The Host returned an invalid CodeX project.");
@@ -22291,6 +22909,7 @@ var ClientModeRuntime = class {
22291
22909
  this.gatewaySwitch.selectLocal();
22292
22910
  this.pendingWorkspaceSelection = void 0;
22293
22911
  await this.closeCodexVirtual();
22912
+ this.closeBrowsing();
22294
22913
  await this.closeCodexStreams(this.connected?.client);
22295
22914
  await this.connected?.client.close().catch(() => void 0);
22296
22915
  this.connected = void 0;
@@ -22371,6 +22990,7 @@ var ClientModeRuntime = class {
22371
22990
  return { opened: true, streamId: value.streamId, threadId: value.threadId };
22372
22991
  }
22373
22992
  publishLocalCodexFrame = async (event, data2) => {
22993
+ if (event === "codex.app.capability") return;
22374
22994
  const streamId = data2.streamId;
22375
22995
  const stream = this.codexStreams.get(streamId);
22376
22996
  if (stream === void 0 || stream.target.kind !== "local") return;
@@ -22501,6 +23121,29 @@ var ClientModeRuntime = class {
22501
23121
  "This Client does not provide a compatible Harness carrier for the selected remote workspace."
22502
23122
  );
22503
23123
  }
23124
+ /**
23125
+ * Connect, and report the control switch when that is what refused us.
23126
+ *
23127
+ * A device whose control switch is off has its host connection refused by the Server. Its presence may still read
23128
+ * online, so the failure comes back from whichever transport step gave up first - "adaptive transport has not been
23129
+ * authorized", "the authenticated Noise channel is not connected", "the selected Host is offline" - and none of
23130
+ * them name the switch. The device row does, so a failed attempt is re-read against it; an error that already names
23131
+ * a cause is passed through untouched.
23132
+ * @param targetDeviceId - the Host being connected to.
23133
+ * @param signal - aborts the attempt.
23134
+ * @param forceRelay - skips direct negotiation, as the reconnect window does.
23135
+ * @returns the connected remote session.
23136
+ */
23137
+ async connectWithReason(targetDeviceId, signal, forceRelay = false) {
23138
+ try {
23139
+ return await this.connect(targetDeviceId, signal, forceRelay);
23140
+ } catch (error) {
23141
+ if (safeErrorCode(error) === "CONTROL_DISABLED") throw error;
23142
+ const row = await this.server.listDevices().then((list) => list.find((device) => device.deviceId === targetDeviceId)).catch(() => void 0);
23143
+ if (row?.hostControl === false) throw offlineConnectionError(false);
23144
+ throw error;
23145
+ }
23146
+ }
22504
23147
  async connect(targetDeviceId, signal, forceRelay = false) {
22505
23148
  signal?.throwIfAborted();
22506
23149
  const progressRunId = this.connectionProgressRun + 1;
@@ -22516,7 +23159,7 @@ var ClientModeRuntime = class {
22516
23159
  this.updateConnectionProgress(progressRunId, "authorizing-peer");
22517
23160
  const target2 = await this.authorizeHostPeer(serverDevice);
22518
23161
  const presence = await this.server.presenceFor(targetDeviceId);
22519
- if (!presence.online) throw new ClientModeError("HOST_OFFLINE", "The selected Host is offline.", true);
23162
+ if (!presence.online) throw offlineConnectionError(serverDevice.hostControl);
22520
23163
  const credentials = await this.server.authenticate(identity);
22521
23164
  const rtcFactory = this.config.forceRelay ? void 0 : await this.rtcFactoryProvider({ routeTargets: [this.server.baseUrl] }).catch(() => void 0);
22522
23165
  if (!this.config.forceRelay && rtcFactory === void 0) {
@@ -22629,13 +23272,56 @@ var ClientModeRuntime = class {
22629
23272
  throw error;
22630
23273
  }
22631
23274
  }
23275
+ /**
23276
+ * Connect for a read-only panel request.
23277
+ *
23278
+ * Browsing a Host is not a session. This connection never becomes {@link connected}, so a later drop is
23279
+ * ignored by the recovery path and the window stays where the user left it. The session's own link is
23280
+ * reused when it already points at that Host, because the Server keeps only one control connection per
23281
+ * device and role.
23282
+ * @param targetDeviceId - the Host being browsed.
23283
+ * @param signal - aborts the connection attempt.
23284
+ * @returns the connection to read through.
23285
+ */
23286
+ async ensureBrowsing(targetDeviceId, signal) {
23287
+ if (this.connected?.target.deviceId === targetDeviceId) return this.connected;
23288
+ if (this.browsing?.target.deviceId === targetDeviceId) return this.browsing;
23289
+ const next = await this.connectWithReason(targetDeviceId, signal);
23290
+ const previous = this.browsing;
23291
+ this.browsing = next;
23292
+ this.clearConnectionProgress(next.progressRunId);
23293
+ await previous?.client.close().catch(() => void 0);
23294
+ return next;
23295
+ }
23296
+ /**
23297
+ * Drop the panel's browsing connection.
23298
+ *
23299
+ * Called whenever the user's own decision makes the browse stale: returning to local, signing out, or
23300
+ * opening a session somewhere else. A browsing connection is never kept for later.
23301
+ * @param except - a browsing connection to keep (the one just promoted to a session).
23302
+ */
23303
+ closeBrowsing(except) {
23304
+ const browsing = this.browsing;
23305
+ if (browsing === void 0 || browsing === except) return;
23306
+ this.browsing = void 0;
23307
+ void browsing.client.close().catch(() => void 0);
23308
+ }
22632
23309
  async ensureConnected(targetDeviceId, signal) {
22633
23310
  if (this.connected?.target.deviceId === targetDeviceId) return this.connected;
22634
- const next = await this.connect(targetDeviceId, signal);
22635
23311
  const previous = this.connected;
23312
+ if (this.browsing?.target.deviceId === targetDeviceId) {
23313
+ const next2 = this.browsing;
23314
+ this.browsing = void 0;
23315
+ await this.closePreview();
23316
+ this.connected = next2;
23317
+ await previous?.client.close().catch(() => void 0);
23318
+ return next2;
23319
+ }
23320
+ const next = await this.connectWithReason(targetDeviceId, signal);
22636
23321
  await this.closePreview();
22637
23322
  this.connected = next;
22638
23323
  this.clearConnectionProgress(next.progressRunId);
23324
+ this.closeBrowsing();
22639
23325
  await previous?.client.close().catch(() => void 0);
22640
23326
  return next;
22641
23327
  }
@@ -22668,6 +23354,12 @@ var ClientModeRuntime = class {
22668
23354
  try {
22669
23355
  if (endpoint === "status") return ok2(await this.detailedStatus());
22670
23356
  if (endpoint === "client.connection.verify") return ok2(await this.verifyRemoteConnection());
23357
+ if (endpoint === "remote.features.probe") return ok2(await this.probeRemoteFeatures());
23358
+ if (endpoint === "remote.session.recover") return ok2(await this.recoverCurrentSession());
23359
+ if (endpoint === "client.device.revoke") {
23360
+ await this.revokeCurrentDevice();
23361
+ return ok2(this.status());
23362
+ }
22671
23363
  if (endpoint === "devices") return ok2(await this.devices());
22672
23364
  if (endpoint === "client.account.login") {
22673
23365
  const value = record4(payload);
@@ -22721,6 +23413,7 @@ var ClientModeRuntime = class {
22721
23413
  if (typeof value.targetDeviceId !== "string" || typeof value.path !== "string") {
22722
23414
  throw new ClientModeError("INVALID_MESSAGE", "A Host and working directory are required.");
22723
23415
  }
23416
+ this.supersedeRemoteRecovery();
22724
23417
  return ok2(await this.openRemoteWorkspace(value.targetDeviceId, value.path, signal));
22725
23418
  }
22726
23419
  if (endpoint === "codex.workspace.open") {
@@ -22728,6 +23421,7 @@ var ClientModeRuntime = class {
22728
23421
  if (typeof value.targetDeviceId !== "string" || typeof value.workspaceId !== "string") {
22729
23422
  throw new ClientModeError("INVALID_MESSAGE", "A Host and CodeX Workspace are required.");
22730
23423
  }
23424
+ this.supersedeRemoteRecovery();
22731
23425
  return ok2(await this.openCodexWorkspace(value.targetDeviceId, value.workspaceId, signal));
22732
23426
  }
22733
23427
  if (endpoint === "codex.workspace.create") {
@@ -22843,6 +23537,21 @@ var ClientModeRuntime = class {
22843
23537
  });
22844
23538
  }
22845
23539
  };
23540
+ function offlineConnectionError(hostControl) {
23541
+ return hostControl === false ? new ClientModeError(
23542
+ "CONTROL_DISABLED",
23543
+ 'This device is not accepting control. Turn on "Allow other users to control this device" on that device.',
23544
+ true
23545
+ ) : new ClientModeError("HOST_OFFLINE", "The selected Host is offline.", true);
23546
+ }
23547
+ function isCodexSessionLoss(error) {
23548
+ const own = error !== null && typeof error === "object" && "code" in error ? error.code : void 0;
23549
+ const code = typeof own === "string" ? own : safeErrorCode(error);
23550
+ return code === "CODEX_CONNECTION_CLOSED" || code === "CODEX_CLOSED" || code === "CODEX_UNAVAILABLE";
23551
+ }
23552
+ function shouldRestoreCodexCarrier(selection, targetDeviceId) {
23553
+ return selection !== void 0 && selection.targetDeviceId === targetDeviceId && selection.backend === "codex";
23554
+ }
22846
23555
  var ClientModeError = class extends Error {
22847
23556
  constructor(code, message, retryable = false) {
22848
23557
  super(message);
@@ -22851,7 +23560,7 @@ var ClientModeError = class extends Error {
22851
23560
  }
22852
23561
  };
22853
23562
  function assertAuthorizedHost(listed, descriptor) {
22854
- if (descriptor.role !== "host" || descriptor.deviceId !== listed.deviceId || descriptor.membershipId !== listed.membershipId) {
23563
+ if (descriptor.deviceId !== listed.deviceId || descriptor.membershipId !== listed.membershipId) {
22855
23564
  throw new ClientModeError("PEER_IDENTITY_MISMATCH", "Server Host details do not match the authorized device list.");
22856
23565
  }
22857
23566
  }
@@ -22957,6 +23666,15 @@ function fail2(error) {
22957
23666
  function shortId2(value) {
22958
23667
  return value.length <= 12 ? value : `${value.slice(0, 8)}\u2026${value.slice(-4)}`;
22959
23668
  }
23669
+ function failureCode(error) {
23670
+ const own = error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
23671
+ return own ?? safeErrorCode(error);
23672
+ }
23673
+ function sleep3(ms) {
23674
+ return new Promise((resolve4) => {
23675
+ setTimeout(resolve4, ms);
23676
+ });
23677
+ }
22960
23678
  function remoteHostFeatures(clientVersion) {
22961
23679
  return {
22962
23680
  commandList: isVersionAtLeast(clientVersion, REMOTE_COMMAND_LIST_MIN_VERSION),
@@ -23090,12 +23808,14 @@ var ClientTargetStore = class {
23090
23808
  if (record7.mode === "remote" && (typeof record7.hostDeviceId !== "string" || record7.hostDeviceId.length === 0)) {
23091
23809
  return void 0;
23092
23810
  }
23811
+ const workspace = parseWorkspace(record7.workspace);
23093
23812
  return {
23094
23813
  schemaVersion: 1,
23095
23814
  mode: record7.mode,
23096
23815
  ...typeof record7.serverUrl === "string" && record7.serverUrl.length > 0 ? { serverUrl: record7.serverUrl } : {},
23097
23816
  ...typeof record7.hostDeviceId === "string" && record7.hostDeviceId.length > 0 ? { hostDeviceId: record7.hostDeviceId } : {},
23098
23817
  ...typeof record7.hostName === "string" && record7.hostName.length > 0 ? { hostName: record7.hostName } : {},
23818
+ ...workspace === void 0 ? {} : { workspace },
23099
23819
  savedAt: typeof record7.savedAt === "number" ? record7.savedAt : 0
23100
23820
  };
23101
23821
  } catch {
@@ -23104,12 +23824,14 @@ var ClientTargetStore = class {
23104
23824
  }
23105
23825
  /** Record the current target. Failures reach the caller so a boot can log them. */
23106
23826
  async save(target2) {
23827
+ const workspace = parseWorkspace(target2.workspace);
23107
23828
  const value = {
23108
23829
  schemaVersion: 1,
23109
23830
  mode: target2.mode,
23110
23831
  ...target2.serverUrl === void 0 ? {} : { serverUrl: target2.serverUrl },
23111
23832
  ...target2.hostDeviceId === void 0 ? {} : { hostDeviceId: target2.hostDeviceId },
23112
23833
  ...target2.hostName === void 0 ? {} : { hostName: target2.hostName },
23834
+ ...workspace === void 0 ? {} : { workspace },
23113
23835
  savedAt: Date.now()
23114
23836
  };
23115
23837
  await mkdir2(this.directory, { recursive: true, mode: 448 });
@@ -23121,15 +23843,28 @@ var ClientTargetStore = class {
23121
23843
  await chmod2(this.file, 384);
23122
23844
  }
23123
23845
  };
23846
+ function parseWorkspace(value) {
23847
+ if (typeof value !== "object" || value === null) return void 0;
23848
+ const record7 = value;
23849
+ if (typeof record7.workspaceId !== "string" || record7.workspaceId.length === 0) return void 0;
23850
+ if (record7.kind !== "harness" && record7.kind !== "codex") return void 0;
23851
+ return {
23852
+ workspaceId: record7.workspaceId,
23853
+ kind: record7.kind,
23854
+ ...typeof record7.workspacePath === "string" && record7.workspacePath.length > 0 ? { workspacePath: record7.workspacePath } : {},
23855
+ ...typeof record7.sessionId === "string" && record7.sessionId.length > 0 ? { sessionId: record7.sessionId } : {}
23856
+ };
23857
+ }
23124
23858
 
23125
23859
  // src/control-runtime.ts
23126
23860
  import { hostname as hostname2 } from "node:os";
23127
- import { existsSync as existsSync3 } from "node:fs";
23861
+ import { existsSync as existsSync4 } from "node:fs";
23128
23862
  import { execFileSync } from "node:child_process";
23129
23863
 
23130
23864
  // src/identity-store.ts
23865
+ import { existsSync as existsSync2 } from "node:fs";
23131
23866
  import { createHash } from "node:crypto";
23132
- import { chmod as chmod3, mkdir as mkdir3, readFile as readFile4, rm as rm3, stat as stat3, writeFile as writeFile3 } from "node:fs/promises";
23867
+ import { chmod as chmod3, cp, mkdir as mkdir3, readFile as readFile4, rm as rm3, stat as stat3, writeFile as writeFile3 } from "node:fs/promises";
23133
23868
  import { homedir } from "node:os";
23134
23869
  import { dirname as dirname4, join as join6 } from "node:path";
23135
23870
  var identitySchema = external_exports.object({
@@ -23263,6 +23998,19 @@ function serverStorageDirectory(root, serverUrl, role) {
23263
23998
  const scope = createHash("sha256").update(origin).digest("hex").slice(0, 24);
23264
23999
  return join6(root, "servers", scope, role);
23265
24000
  }
24001
+ async function ensureDeviceDirectory(root, serverUrl) {
24002
+ const directory = serverStorageDirectory(root, serverUrl, "device");
24003
+ if (existsSync2(directory)) return directory;
24004
+ await mkdir3(dirname4(directory), { recursive: true, mode: 448 });
24005
+ for (const legacy of ["host", "client"]) {
24006
+ const from = serverStorageDirectory(root, serverUrl, legacy);
24007
+ if (!existsSync2(from)) continue;
24008
+ await cp(from, directory, { recursive: true });
24009
+ return directory;
24010
+ }
24011
+ await mkdir3(directory, { recursive: true, mode: 448 });
24012
+ return directory;
24013
+ }
23266
24014
  function fingerprint(publicKey) {
23267
24015
  const compact = createHash("sha256").update(fromBase64Url2(publicKey)).digest("hex").slice(0, 12).toUpperCase();
23268
24016
  return compact.match(/.{1,4}/g).join(" ");
@@ -23449,7 +24197,7 @@ var ControlStatusStream = class {
23449
24197
 
23450
24198
  // src/codex/domain.ts
23451
24199
  import { randomUUID as randomUUID3 } from "node:crypto";
23452
- import { accessSync, constants, existsSync as existsSync2, readFileSync, readdirSync as readdirSync2, statSync as statSync2 } from "node:fs";
24200
+ import { accessSync, constants, existsSync as existsSync3, readFileSync, readdirSync as readdirSync2, statSync as statSync2 } from "node:fs";
23453
24201
  import { readdir as readdir2, realpath, stat as stat4 } from "node:fs/promises";
23454
24202
  import { homedir as homedir2 } from "node:os";
23455
24203
  import { basename as basename2, isAbsolute as isAbsolute2, join as join7, posix, relative, resolve } from "node:path";
@@ -23646,7 +24394,11 @@ var CodexAppServerClient = class {
23646
24394
  const pending = this.takePending(value.id);
23647
24395
  if (pending === void 0) return;
23648
24396
  if ("error" in value && value.error !== void 0) {
23649
- pending.reject(new CodexAppServerError("CODEX_UPSTREAM_ERROR", safeUpstreamError(value.error)));
24397
+ const upstreamCode = safeUpstreamCode(value.error);
24398
+ pending.reject(Object.assign(
24399
+ new CodexAppServerError("CODEX_UPSTREAM_ERROR", safeUpstreamError(value.error)),
24400
+ upstreamCode === void 0 ? {} : { upstreamCode }
24401
+ ));
23650
24402
  } else {
23651
24403
  pending.resolve(value.result);
23652
24404
  }
@@ -23663,7 +24415,13 @@ var CodexAppServerClient = class {
23663
24415
  const child = this.process;
23664
24416
  this.process = void 0;
23665
24417
  child?.kill("SIGTERM");
23666
- this.logger?.warn("Codex App Server communication failed", { code });
24418
+ const failure2 = cause;
24419
+ this.logger?.warn("Codex App Server communication failed", {
24420
+ code,
24421
+ causeCode: failure2.code ?? "none",
24422
+ ...failure2.errno === void 0 ? {} : { errno: failure2.errno },
24423
+ ...failure2.syscall === void 0 ? {} : { syscall: failure2.syscall }
24424
+ });
23667
24425
  if (!this.closed) this.notifyUnavailable(code);
23668
24426
  }
23669
24427
  notifyUnavailable(code) {
@@ -23688,6 +24446,13 @@ function safeUpstreamError(value) {
23688
24446
  if (message.includes("active writer")) return "Codex thread already has an active writer.";
23689
24447
  return message.includes("not initialized") ? "Codex App Server is not initialized." : "Codex App Server rejected the request.";
23690
24448
  }
24449
+ function safeUpstreamCode(value) {
24450
+ if (!isRecord10(value)) return void 0;
24451
+ const raw = isRecord10(value.data) && value.data.code !== void 0 ? value.data.code : value.code;
24452
+ if (typeof raw === "number" && Number.isFinite(raw)) return String(raw);
24453
+ if (typeof raw !== "string") return void 0;
24454
+ return /^[A-Za-z0-9_.:-]{1,48}$/u.test(raw) ? raw : void 0;
24455
+ }
23691
24456
  function isRecord10(value) {
23692
24457
  return typeof value === "object" && value !== null && !Array.isArray(value);
23693
24458
  }
@@ -23901,6 +24666,19 @@ var CodexPeerBridge = class {
23901
24666
  await this.publish("codex.app.frame", data2);
23902
24667
  }
23903
24668
  }
24669
+ /**
24670
+ * Tell this connection whether the Host can serve Codex, and say it only when that actually changed.
24671
+ *
24672
+ * The per-stream closes below only reach a client that is watching a thread. A session parked on its history,
24673
+ * or a workspace with nothing open, heard nothing until its next call failed - which is why switching Codex
24674
+ * off looked like a plain error instead of a lost session. This states the fact rather than the conclusion, so
24675
+ * the client can decide whether the session it is showing is the one affected.
24676
+ * @param available - whether Codex can be served right now.
24677
+ */
24678
+ async notifyCapability(available) {
24679
+ if (this.closed) return;
24680
+ await this.publish("codex.app.capability", { available }).catch(() => void 0);
24681
+ }
23904
24682
  async failStreams(reason = "failed") {
23905
24683
  if (this.closed) return;
23906
24684
  const streamIds = [...this.streams.keys()];
@@ -23971,7 +24749,9 @@ function concatChunks(chunks, totalBytes) {
23971
24749
  // src/codex/domain.ts
23972
24750
  var APPROVAL_TTL_MS = 5 * 6e4;
23973
24751
  var DEFAULT_RESTART_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
23974
- var CODEX_START_BUDGET_MS = 5e3;
24752
+ var CODEX_SLOW_RETRY_MS = 6e4;
24753
+ var ACCOUNT_PROBE_TIMEOUT_MS = 6e4;
24754
+ var CODEX_START_BUDGET_MS = 9e4;
23975
24755
  var CODEX_PAGE_LIMIT2 = 100;
23976
24756
  var MAX_CODEX_PAGES2 = 32;
23977
24757
  var CODEX_HISTORY_PAGE_LIMIT = 25;
@@ -23979,10 +24759,10 @@ var MAX_CODEX_HISTORY_PAGES = 64;
23979
24759
  var CODEX_DIRECTORY_ENTRY_LIMIT = 500;
23980
24760
  var CodexRemoteDomain = class {
23981
24761
  constructor(config, logger, createAppServer = (binary, targetLogger) => new CodexAppServerClient(binary, targetLogger), restartDelaysMs = DEFAULT_RESTART_DELAYS_MS) {
23982
- this.config = config;
23983
24762
  this.logger = logger;
23984
24763
  this.createAppServer = createAppServer;
23985
24764
  this.restartDelaysMs = restartDelaysMs;
24765
+ this.config = config;
23986
24766
  }
23987
24767
  appServer;
23988
24768
  unsubscribeInbound;
@@ -23995,10 +24775,30 @@ var CodexRemoteDomain = class {
23995
24775
  approvalExpiryTimer;
23996
24776
  restartTimer;
23997
24777
  restartAttempt = 0;
24778
+ /** Whether the "fast ladder is spent, retrying slowly" line has been logged for this outage. */
24779
+ slowRestartLogged = false;
23998
24780
  available = false;
23999
24781
  closed = false;
24782
+ /**
24783
+ * The last availability announced to peers, or undefined before the first announcement.
24784
+ *
24785
+ * Announcements are transitions, not states: the Host tells clients *when* Codex came or went, never "your
24786
+ * session is gone". Announcing on every restart made switching Codex **on** read as a loss, and the client
24787
+ * answered a loss with a fresh recovery - which pulled back a view the user had just cancelled.
24788
+ */
24789
+ announcedAvailable;
24790
+ /**
24791
+ * Bumped by every restart() and close().
24792
+ *
24793
+ * A launch that began before the switch was flipped can finish after it, and its completion used to write
24794
+ * `available = true` onto a Codex the user had switched off - which made the next "switch on" look like a
24795
+ * transition out of availability and produced the false loss notice. Work from an older generation is discarded.
24796
+ */
24797
+ generation = 0;
24000
24798
  state = "disabled";
24001
24799
  unavailableCode;
24800
+ /** The live configuration; replaced in place by {@link restart}. */
24801
+ config;
24002
24802
  async start() {
24003
24803
  if (this.closed) throw new RpcError("CODEX_CLOSED", "The Codex Remote domain is closed.");
24004
24804
  if (!this.config.enabled) return;
@@ -24040,6 +24840,10 @@ var CodexRemoteDomain = class {
24040
24840
  }
24041
24841
  createPeer(context, publish) {
24042
24842
  if (!this.config.enabled) return void 0;
24843
+ this.logger.info("Codex client attached", {
24844
+ peerDeviceId: context.peerDeviceId.slice(0, 12),
24845
+ connectionId: context.connectionId.slice(0, 12)
24846
+ });
24043
24847
  const bridge = new CodexPeerBridge(this, context, publish, this.logger);
24044
24848
  this.peers.set(context.connectionId, bridge);
24045
24849
  this.peerDeviceIds.set(context.connectionId, context.peerDeviceId);
@@ -24216,13 +25020,79 @@ var CodexRemoteDomain = class {
24216
25020
  async close() {
24217
25021
  if (this.closed) return;
24218
25022
  this.closed = true;
24219
- this.available = false;
25023
+ this.generation += 1;
25024
+ await this.teardown();
24220
25025
  this.state = this.config.enabled ? "unavailable" : "disabled";
24221
- if (this.restartTimer !== void 0) clearTimeout(this.restartTimer);
24222
- this.restartTimer = void 0;
25026
+ }
25027
+ /**
25028
+ * State the Codex capability to every attached connection - and only when it actually changed.
25029
+ *
25030
+ * This is the fact, not the conclusion: an unavailable Codex means "the session you are watching is gone" to a
25031
+ * client that is watching one, and means nothing to a client that is not. Sending it only on a real transition
25032
+ * is what keeps "switch Codex on" from being reported as a loss.
25033
+ * @returns when every attached connection has been told.
25034
+ */
25035
+ async announceCapability() {
25036
+ const available = this.isAvailable();
25037
+ if (this.announcedAvailable === void 0) {
25038
+ this.announcedAvailable = available;
25039
+ return;
25040
+ }
25041
+ if (available === this.announcedAvailable) return;
25042
+ this.announcedAvailable = available;
25043
+ this.logger.info("Codex capability changed; telling every attached connection", { available });
25044
+ await Promise.all([...this.peers.values()].map((peer) => peer.notifyCapability(available).catch(() => void 0)));
25045
+ }
25046
+ /**
25047
+ * Apply a changed configuration without restarting DSH.
25048
+ *
25049
+ * Deliberately not close()+start(): close() is terminal, so that pair left the domain dead for the rest of the
25050
+ * process (the bug behind "the switch never comes back"). This shares close()'s teardown without setting the
25051
+ * terminal flag, and a disabled configuration simply stops - no launch, state `disabled`.
25052
+ *
25053
+ * The per-connection bridges are deliberately *kept*. They are the attached Remote clients' only handle on
25054
+ * this Host's Codex, and tearing them down here is what left every one of them holding a closed bridge:
25055
+ * the reported "The Codex connection is closed" that no refresh could clear, because nothing short of a full
25056
+ * transport reconnect could get a working bridge again. Everything the App Server owns - live streams, turn
25057
+ * leases, approvals, permission snapshots - is released instead, which is exactly what a crash already did.
25058
+ * @param config - the configuration to apply, when the caller changed it.
25059
+ * @returns when the replacement attempt finished.
25060
+ */
25061
+ async restart(config) {
25062
+ if (this.closed) throw new RpcError("CODEX_CLOSED", "The Codex Remote domain is closed.");
25063
+ if (config !== void 0) this.config = config;
25064
+ this.generation += 1;
25065
+ const peers = [...this.peers.values()];
25066
+ await Promise.all(peers.map((peer) => peer.failStreams("failed")));
25067
+ await this.launching?.catch(() => void 0);
25068
+ await this.releaseAppServer();
25069
+ if (!this.config.enabled) await this.announceCapability();
25070
+ this.unavailableCode = void 0;
25071
+ this.restartAttempt = 0;
25072
+ this.slowRestartLogged = false;
25073
+ if (!this.config.enabled) {
25074
+ this.state = "disabled";
25075
+ return;
25076
+ }
25077
+ await this.start();
25078
+ }
25079
+ /** Release every live resource; shared by close() (terminal) and used by close() alone. */
25080
+ async teardown() {
24223
25081
  for (const bridge of [...this.peers.values()]) await bridge.closeAll();
24224
25082
  this.peers.clear();
24225
25083
  this.peerDeviceIds.clear();
25084
+ await this.releaseAppServer();
25085
+ }
25086
+ /**
25087
+ * Release everything that belongs to the App Server, keeping the per-connection bridges.
25088
+ *
25089
+ * Shared by restart() and by a crash: in both cases the bridges must outlive the process so an attached
25090
+ * client's next call simply reaches the replacement instead of failing for good.
25091
+ */
25092
+ async releaseAppServer() {
25093
+ this.available = false;
25094
+ if (this.restartTimer !== void 0) clearTimeout(this.restartTimer);
25095
+ this.restartTimer = void 0;
24226
25096
  this.turnOwners.clear();
24227
25097
  this.permissionPresets.clear();
24228
25098
  if (this.approvalExpiryTimer !== void 0) clearTimeout(this.approvalExpiryTimer);
@@ -24235,6 +25105,27 @@ var CodexRemoteDomain = class {
24235
25105
  await this.appServer?.close();
24236
25106
  this.appServer = void 0;
24237
25107
  }
25108
+ /**
25109
+ * The launch in flight, if any.
25110
+ *
25111
+ * Two overlapping launches fight over `this.appServer`, and whichever finishes first disposes the other's App
25112
+ * Server - which is answering its account probe at that moment, so the probe fails with `CODEX_CLOSED` and the
25113
+ * domain reports a closed Codex on a machine where nothing was closed. One launch at a time, and
25114
+ * `restart()` waits for it instead of closing it out from under the probe.
25115
+ */
25116
+ launching;
25117
+ /**
25118
+ * Launch once, joining the launch already in flight instead of starting a second one.
25119
+ * @returns when this launch settled (either way).
25120
+ */
25121
+ launchAppServerOnce() {
25122
+ if (this.launching !== void 0) return this.launching;
25123
+ const launch = this.launchAppServer().finally(() => {
25124
+ if (this.launching === launch) this.launching = void 0;
25125
+ });
25126
+ this.launching = launch;
25127
+ return launch;
25128
+ }
24238
25129
  /**
24239
25130
  * Launch the App Server within {@link CODEX_START_BUDGET_MS}.
24240
25131
  *
@@ -24245,7 +25136,7 @@ var CodexRemoteDomain = class {
24245
25136
  */
24246
25137
  async launchWithinBudget() {
24247
25138
  let timer;
24248
- const attempt = this.launchAppServer();
25139
+ const attempt = this.launchAppServerOnce();
24249
25140
  attempt.catch(() => void 0);
24250
25141
  try {
24251
25142
  await Promise.race([
@@ -24262,19 +25153,26 @@ var CodexRemoteDomain = class {
24262
25153
  }
24263
25154
  }
24264
25155
  async launchAppServer() {
25156
+ let firstError;
24265
25157
  let lastError;
25158
+ let index = 0;
24266
25159
  for (const binary of codexBinaryCandidates(this.config.binary)) {
25160
+ index += 1;
24267
25161
  try {
24268
25162
  await this.launchAppServerCandidate(binary);
24269
25163
  return;
24270
25164
  } catch (error) {
25165
+ this.logger.warn("Codex App Server candidate failed", { candidate: index, code: errorCode4(error) });
25166
+ firstError ??= error;
24271
25167
  lastError = error;
24272
25168
  if (!canTryNextBinary(error)) throw error;
24273
25169
  }
24274
25170
  }
24275
- throw lastError ?? new RpcError("CODEX_START_FAILED", "Codex App Server could not be started.");
25171
+ const chosen = errorCode4(lastError) === "CODEX_BINARY_UNAVAILABLE" && firstError !== void 0 ? firstError : lastError;
25172
+ throw chosen ?? new RpcError("CODEX_START_FAILED", "Codex App Server could not be started.");
24276
25173
  }
24277
25174
  async launchAppServerCandidate(binary) {
25175
+ const generation = this.generation;
24278
25176
  const appServer = this.createAppServer(binary, this.logger);
24279
25177
  this.appServer = appServer;
24280
25178
  this.unsubscribeInbound = appServer.onInbound((message) => {
@@ -24289,7 +25187,17 @@ var CodexRemoteDomain = class {
24289
25187
  });
24290
25188
  try {
24291
25189
  await appServer.start();
24292
- const account = await appServer.call("account/read", { refreshToken: false }, 15e3);
25190
+ let account;
25191
+ try {
25192
+ account = await appServer.call("account/read", { refreshToken: false }, ACCOUNT_PROBE_TIMEOUT_MS);
25193
+ } catch (error) {
25194
+ const upstreamCode = error.upstreamCode;
25195
+ this.logger.warn("Codex account probe failed", {
25196
+ code: errorCode4(error),
25197
+ ...upstreamCode === void 0 ? {} : { upstreamCode: String(upstreamCode) }
25198
+ });
25199
+ throw classifyAccountProbeFailure(error);
25200
+ }
24293
25201
  if (!accountCanRun(account)) {
24294
25202
  throw new RpcError("CODEX_AUTH_REQUIRED", "Codex is not signed in on this Host.");
24295
25203
  }
@@ -24297,11 +25205,22 @@ var CodexRemoteDomain = class {
24297
25205
  await appServer.close().catch(() => void 0);
24298
25206
  return;
24299
25207
  }
25208
+ if (generation !== this.generation || !this.config.enabled) {
25209
+ this.logger.info("Codex App Server started after the domain moved on; releasing it", {
25210
+ generation: this.generation - generation,
25211
+ enabled: this.config.enabled
25212
+ });
25213
+ await this.disposeAppServer(appServer);
25214
+ this.available = false;
25215
+ this.state = this.config.enabled ? "restarting" : "disabled";
25216
+ return;
25217
+ }
24300
25218
  this.available = true;
24301
25219
  this.state = "ready";
24302
25220
  this.restartAttempt = 0;
24303
25221
  this.unavailableCode = void 0;
24304
25222
  this.logger.info("Codex Remote domain ready");
25223
+ await this.announceCapability();
24305
25224
  } catch (error) {
24306
25225
  await this.disposeAppServer(appServer);
24307
25226
  throw error;
@@ -24318,14 +25237,27 @@ var CodexRemoteDomain = class {
24318
25237
  if (this.approvalExpiryTimer !== void 0) clearTimeout(this.approvalExpiryTimer);
24319
25238
  this.approvalExpiryTimer = void 0;
24320
25239
  await Promise.all([...this.peers.values()].map((peer) => peer.failStreams("failed")));
25240
+ await this.announceCapability();
24321
25241
  await this.disposeAppServer(appServer);
24322
25242
  this.scheduleRestart();
24323
25243
  }
24324
25244
  scheduleRestart() {
24325
25245
  if (this.closed || this.restartTimer !== void 0) return;
25246
+ if (!this.config.enabled) return;
24326
25247
  if (this.restartAttempt >= this.restartDelaysMs.length) {
24327
25248
  this.state = "unavailable";
24328
- this.logger.warn("Codex App Server restart attempts exhausted", { attempts: this.restartAttempt });
25249
+ this.restartTimer = setTimeout(() => {
25250
+ this.restartTimer = void 0;
25251
+ void this.restartAfterFailure();
25252
+ }, CODEX_SLOW_RETRY_MS);
25253
+ this.restartTimer.unref?.();
25254
+ if (!this.slowRestartLogged) {
25255
+ this.slowRestartLogged = true;
25256
+ this.logger.warn("Codex App Server restart attempts exhausted; retrying every " + String(CODEX_SLOW_RETRY_MS) + "ms", {
25257
+ attempts: this.restartAttempt,
25258
+ code: this.unavailableCode
25259
+ });
25260
+ }
24329
25261
  return;
24330
25262
  }
24331
25263
  const delayMs = Math.max(0, this.restartDelaysMs[this.restartAttempt] ?? 0);
@@ -24339,9 +25271,13 @@ var CodexRemoteDomain = class {
24339
25271
  this.logger.warn("Codex App Server restart scheduled", { attempt: this.restartAttempt, delayMs });
24340
25272
  }
24341
25273
  async restartAfterFailure() {
24342
- if (this.closed) return;
25274
+ if (this.closed || !this.config.enabled) return;
24343
25275
  try {
24344
- await this.launchAppServer();
25276
+ await this.launchAppServerOnce();
25277
+ if (!this.config.enabled) {
25278
+ await this.releaseAppServer();
25279
+ this.state = "disabled";
25280
+ }
24345
25281
  } catch (error) {
24346
25282
  this.available = false;
24347
25283
  this.state = "restarting";
@@ -24669,7 +25605,14 @@ var CodexRemoteDomain = class {
24669
25605
  } catch (error) {
24670
25606
  if (!isProjectListFallbackError(error)) throw error;
24671
25607
  }
24672
- if (roots.length > 0) return { projectIds, roots };
25608
+ if (roots.length > 0) {
25609
+ this.logger.debug("Codex workspace authority resolved from project/list", { projects: projectIds.size, roots: roots.length });
25610
+ return { projectIds, roots };
25611
+ }
25612
+ this.logger.warn("Codex project list yielded no roots; deriving workspaces from thread cwd", {
25613
+ projects: projectIds.size,
25614
+ listedThreads: listedThreads?.length ?? 0
25615
+ });
24673
25616
  const threads = listedThreads === void 0 ? [] : [...listedThreads];
24674
25617
  if (listedThreads === void 0) {
24675
25618
  cursor2 = void 0;
@@ -24814,9 +25757,15 @@ var CodexRemoteDomain = class {
24814
25757
  return paths;
24815
25758
  }
24816
25759
  };
24817
- function codexBinaryCandidates(configured, hostPlatform = process.platform, userHome = homedir2()) {
25760
+ function codexBinaryCandidates(configured, hostPlatform = process.platform, userHome = homedir2(), localAppData = process.env.LOCALAPPDATA) {
24818
25761
  if (configured !== "codex") return [configured];
24819
- if (hostPlatform === "win32") return [...bundledWindowsCodex(userHome), configured];
25762
+ if (hostPlatform === "win32") {
25763
+ return [.../* @__PURE__ */ new Set([
25764
+ ...bundledWindowsCodex(userHome),
25765
+ ...localAppData === void 0 ? [] : bundledWindowsCodexAt(localAppData),
25766
+ configured
25767
+ ])];
25768
+ }
24820
25769
  if (hostPlatform !== "darwin") return [configured];
24821
25770
  const bundledCandidates = [
24822
25771
  "/Applications/ChatGPT.app",
@@ -24829,7 +25778,7 @@ function codexBinaryCandidates(configured, hostPlatform = process.platform, user
24829
25778
  return [];
24830
25779
  }
24831
25780
  const candidate = posix.join(codexCli, manifest.entrypoint);
24832
- if (!existsSync2(candidate)) return [];
25781
+ if (!existsSync3(candidate)) return [];
24833
25782
  accessSync(candidate, constants.X_OK);
24834
25783
  return [candidate];
24835
25784
  } catch {
@@ -24844,9 +25793,12 @@ function codexBinaryCandidates(configured, hostPlatform = process.platform, user
24844
25793
  ])];
24845
25794
  }
24846
25795
  function bundledWindowsCodex(userHome) {
24847
- const bin = join7(userHome, "AppData", "Local", "OpenAI", "Codex", "bin");
25796
+ return bundledWindowsCodexAt(join7(userHome, "AppData", "Local"));
25797
+ }
25798
+ function bundledWindowsCodexAt(localAppData) {
25799
+ const bin = join7(localAppData, "OpenAI", "Codex", "bin");
24848
25800
  try {
24849
- const newest = readdirSync2(bin, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join7(bin, entry.name, "codex.exe")).filter((candidate2) => existsSync2(candidate2)).map((candidate2) => ({ candidate: candidate2, modified: statSync2(candidate2).mtimeMs })).sort((left, right) => right.modified - left.modified);
25801
+ const newest = readdirSync2(bin, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join7(bin, entry.name, "codex.exe")).filter((candidate2) => existsSync3(candidate2)).map((candidate2) => ({ candidate: candidate2, modified: statSync2(candidate2).mtimeMs })).sort((left, right) => right.modified - left.modified);
24850
25802
  const candidate = newest[0]?.candidate;
24851
25803
  return candidate === void 0 ? [] : [candidate];
24852
25804
  } catch {
@@ -24865,6 +25817,16 @@ function parseRespond(input2) {
24865
25817
  }
24866
25818
  return input2;
24867
25819
  }
25820
+ function classifyAccountProbeFailure(error) {
25821
+ const message = error instanceof Error ? error.message : String(error);
25822
+ if (/unsupported_country_region_territory|country, region, or territory|403|forbidden/i.test(message)) {
25823
+ return new RpcError("CODEX_ACCOUNT_UNREACHABLE", "Codex cannot reach the account from this network region.");
25824
+ }
25825
+ if (/timed out|timeout/i.test(message)) {
25826
+ return new RpcError("CODEX_ACCOUNT_UNREACHABLE", "Codex could not reach the account in time.");
25827
+ }
25828
+ return error instanceof Error ? error : new RpcError("CODEX_UPSTREAM_ERROR", "Codex App Server could not complete the request.");
25829
+ }
24868
25830
  function accountCanRun(result) {
24869
25831
  if (!isRecord12(result) || typeof result.requiresOpenaiAuth !== "boolean") return false;
24870
25832
  return result.requiresOpenaiAuth === false || isRecord12(result.account);
@@ -25103,6 +26065,24 @@ var PluginControlRuntime = class {
25103
26065
  */
25104
26066
  verifiedDeepSeekToken;
25105
26067
  verifiedDeepSeekAuthorization;
26068
+ /**
26069
+ * Whether a sign-in the user asked for is in flight.
26070
+ *
26071
+ * Only a sign-in the user started may register this device. DSH's own platform session is not consent on its
26072
+ * own: accepting it without a click silently signed a new device into whichever DeepSeek account DSH happened
26073
+ * to hold, and with account creation enabled on the Server that produced a brand-new *empty* account - the
26074
+ * "new device lands in an empty account" report.
26075
+ *
26076
+ * The grant itself is deliberately not compared against what DSH held when the flow started. The user asked for
26077
+ * this sign-in and completed it in the browser, so the account DSH holds afterwards is the one they authorized;
26078
+ * when the platform hands back the same grant - re-authorizing as the same account does exactly that - refusing
26079
+ * it would only loop the user through the browser again. The cost is that a browser page the user opens and
26080
+ * then abandons also ends in an authorization, with the account DSH already held; they still asked for it, and
26081
+ * the alternative was the silent-consent bug above.
26082
+ *
26083
+ * `undefined` means no sign-in is in flight, which is also the state after a completed authorization.
26084
+ */
26085
+ deepSeekSignIn;
25106
26086
  register(connection, webServer) {
25107
26087
  const statusStream = new ControlStatusStream(() => this.streamStatus());
25108
26088
  return registerControlRoute(
@@ -25128,7 +26108,6 @@ var PluginControlRuntime = class {
25128
26108
  if (endpoint === "settings.get") return ok3(await this.settingsView());
25129
26109
  if (endpoint === "settings.configure") return ok3(await this.configure(payload));
25130
26110
  if (endpoint === "settings.server.set") return ok3(await this.setServer(payload));
25131
- if (endpoint === "settings.role.set") return ok3(await this.setRole(payload));
25132
26111
  if (endpoint === "settings.codex.set") return ok3(await this.setCodex(payload));
25133
26112
  if (endpoint === "settings.acp.set") return ok3(await this.setAcp(payload));
25134
26113
  if (endpoint === "settings.acp.add") return ok3(await this.addAcp(payload));
@@ -25183,7 +26162,7 @@ var PluginControlRuntime = class {
25183
26162
  return ok3(await this.host.authorizeHostWithCode(value.code));
25184
26163
  }
25185
26164
  if (endpoint === "mode.set" && record5(payload).mode === "local") return ok3(this.hostOnlyStatus());
25186
- throw new ClientModeError("METHOD_NOT_ALLOWED", "Remote Client mode is disabled by the plugin role.");
26165
+ throw new ClientModeError("METHOD_NOT_ALLOWED", "Remote Client mode is unavailable in this profile.");
25187
26166
  } catch (error) {
25188
26167
  return fail3(error);
25189
26168
  }
@@ -25193,34 +26172,34 @@ var PluginControlRuntime = class {
25193
26172
  throw new ClientModeError("SETTINGS_UNAVAILABLE", "DSH user settings are unavailable in this profile.");
25194
26173
  }
25195
26174
  const value = record5(payload);
25196
- if (value.role !== "host" && value.role !== "client") {
25197
- throw new ClientModeError("INVALID_MESSAGE", "Role must be Host or Client.");
25198
- }
25199
26175
  if (typeof value.serverUrl !== "string") {
25200
26176
  throw new ClientModeError("INVALID_MESSAGE", "Server URL is required.");
25201
26177
  }
25202
26178
  const current = editableConfig(resolveConfig(this.settings.get()));
25203
- const next = resolveConfig({ ...current, role: value.role, serverUrl: value.serverUrl });
25204
- const identities = new IdentityStore({
25205
- directory: serverStorageDirectory(this.identityDirectory, next.serverUrl, value.role)
25206
- });
26179
+ const next = resolveConfig({ ...current, serverUrl: value.serverUrl });
26180
+ const directory = await ensureDeviceDirectory(this.identityDirectory, next.serverUrl);
26181
+ const identities = new IdentityStore({ directory });
25207
26182
  const identity = await identities.loadOrCreate(hostname2());
25208
- const api = value.role === "host" ? new HostServerApi(next.serverUrl, new ServerCredentialStore(identities.directory)) : new ClientServerApi(next.serverUrl, new ServerCredentialStore(identities.directory));
26183
+ const registrationCode = typeof value.registrationCode === "string" ? value.registrationCode.trim() : "";
26184
+ const hasRegistrationCode = registrationCode !== "";
26185
+ const api = hasRegistrationCode ? new HostServerApi(next.serverUrl, new ServerCredentialStore(directory)) : new ClientServerApi(next.serverUrl, new ServerCredentialStore(directory));
25209
26186
  let authorization;
25210
- if (value.role === "host" && typeof value.registrationCode === "string" && value.registrationCode.trim() !== "") {
25211
- authorization = await api.authorizeHostWithCode(identity, value.registrationCode);
26187
+ if (hasRegistrationCode) {
26188
+ authorization = await api.authorizeHostWithCode(identity, registrationCode);
25212
26189
  } else if (value.provider === "deepseek") {
25213
26190
  if (this.deepseekSession === void 0) {
25214
26191
  throw new ClientModeError("METHOD_NOT_ALLOWED", "DeepSeek account sign-in is unavailable in this profile.");
25215
26192
  }
25216
26193
  const session = await this.deepseekSession.read();
25217
- if (session === void 0) {
26194
+ const counted = session !== void 0 && this.deepSeekSignIn !== void 0;
26195
+ if (!counted) {
25218
26196
  const started = await this.deepseekSession.startSignIn({
25219
26197
  callbackOrigin: typeof value.callbackOrigin === "string" ? value.callbackOrigin : "",
25220
26198
  loginSource: value.loginSource === "desktop" ? "desktop" : "web",
25221
26199
  locale: typeof value.locale === "string" ? value.locale : "en",
25222
26200
  timezoneOffsetSeconds: typeof value.timezoneOffsetSeconds === "number" ? value.timezoneOffsetSeconds : 0
25223
26201
  });
26202
+ this.deepSeekSignIn = true;
25224
26203
  return {
25225
26204
  status: "deepseek-sign-in-required",
25226
26205
  ...started.authorizeUrl === void 0 ? {} : { authorizeUrl: started.authorizeUrl }
@@ -25233,19 +26212,25 @@ var PluginControlRuntime = class {
25233
26212
  this.verifiedDeepSeekToken = session.token;
25234
26213
  this.verifiedDeepSeekAuthorization = authorization;
25235
26214
  }
26215
+ this.deepSeekSignIn = void 0;
25236
26216
  } else {
25237
26217
  if (typeof value.email !== "string" || typeof value.password !== "string") {
25238
26218
  throw new ClientModeError("INVALID_MESSAGE", "Email and password are required for account authorization.");
25239
26219
  }
25240
26220
  authorization = await api.authorizeWithAccount(identity, value.email, value.password);
25241
26221
  }
25242
- if (value.role === "client" && resolveConfig(this.settings.get()).hostControl?.enabled !== false) {
25243
- await this.client?.authorizeHostByDefault();
26222
+ this.client?.reloadCredentials?.();
26223
+ this.host?.reloadCredentials?.();
26224
+ const hostPaused = resolveConfig(this.settings.get()).hostControl?.paused === true;
26225
+ if (this.host !== void 0 && !hostPaused) {
26226
+ try {
26227
+ this.host.reconnectHost?.();
26228
+ } catch {
26229
+ }
25244
26230
  }
25245
26231
  await this.settings.replace(editableConfig(next));
25246
26232
  return {
25247
26233
  status: "authorized",
25248
- role: value.role,
25249
26234
  ...authorization.account === void 0 ? {} : { account: authorization.account },
25250
26235
  settings: await this.settingsView()
25251
26236
  };
@@ -25263,22 +26248,6 @@ var PluginControlRuntime = class {
25263
26248
  await this.settings.replace(editableConfig(next));
25264
26249
  return this.settingsView();
25265
26250
  }
25266
- async setRole(payload) {
25267
- if (this.settings === void 0) {
25268
- throw new ClientModeError("SETTINGS_UNAVAILABLE", "DSH user settings are unavailable in this profile.");
25269
- }
25270
- const role = record5(payload).role;
25271
- if (role !== "host" && role !== "client") {
25272
- throw new ClientModeError("INVALID_MESSAGE", "Role must be Host or Client.");
25273
- }
25274
- const current = editableConfig(resolveConfig(this.settings.get()));
25275
- const currentRole = current.role === "client" ? "client" : "host";
25276
- if (role !== currentRole && current.serverUrl !== void 0 && await this.association(current.serverUrl, role) === void 0) {
25277
- await this.authorizeOwnedRole(current.serverUrl, currentRole, role);
25278
- }
25279
- await this.settings.replace({ ...current, role });
25280
- return this.settingsView();
25281
- }
25282
26251
  async setDevelopment(payload) {
25283
26252
  if (this.settings === void 0) throw new ClientModeError("SETTINGS_UNAVAILABLE", "DSH user settings are unavailable in this profile.");
25284
26253
  const value = record5(payload);
@@ -25320,6 +26289,7 @@ var PluginControlRuntime = class {
25320
26289
  }
25321
26290
  });
25322
26291
  await this.settings.replace(editableConfig(next));
26292
+ void this.host?.restartCodex?.(next.codex);
25323
26293
  return this.settingsView();
25324
26294
  }
25325
26295
  async setAcp(payload) {
@@ -25355,18 +26325,6 @@ var PluginControlRuntime = class {
25355
26325
  await this.settings.replace(editableConfig({ ...current, acp: { enabled: current.acp?.enabled ?? true, backends } }));
25356
26326
  return this.settingsView();
25357
26327
  }
25358
- async authorizeOwnedRole(serverUrl, sourceRole, targetRole) {
25359
- const sourceDirectory = serverStorageDirectory(this.identityDirectory, serverUrl, sourceRole);
25360
- const sourceIdentity = await new IdentityStore({ directory: sourceDirectory }).loadOrCreate(hostname2());
25361
- const sourceStore = new ServerCredentialStore(sourceDirectory);
25362
- if (await sourceStore.load(serverUrl, sourceIdentity.deviceId) === void 0) return;
25363
- const sourceApi = sourceRole === "host" ? new HostServerApi(serverUrl, sourceStore) : new ClientServerApi(serverUrl, sourceStore);
25364
- const sourceCredentials = await sourceApi.authenticate(sourceIdentity);
25365
- const targetDirectory = serverStorageDirectory(this.identityDirectory, serverUrl, targetRole);
25366
- const targetIdentity = await new IdentityStore({ directory: targetDirectory }).loadOrCreate(hostname2());
25367
- const targetApi = targetRole === "host" ? new HostServerApi(serverUrl, new ServerCredentialStore(targetDirectory)) : new ClientServerApi(serverUrl, new ServerCredentialStore(targetDirectory));
25368
- await targetApi.authorizeOwnedRole(targetIdentity, sourceCredentials.accessToken, sourceCredentials.account);
25369
- }
25370
26328
  async logout() {
25371
26329
  if (this.settings === void 0) {
25372
26330
  throw new ClientModeError("SETTINGS_UNAVAILABLE", "DSH user settings are unavailable in this profile.");
@@ -25374,15 +26332,14 @@ var PluginControlRuntime = class {
25374
26332
  const config = resolveConfig(this.settings.get());
25375
26333
  this.verifiedDeepSeekToken = void 0;
25376
26334
  this.verifiedDeepSeekAuthorization = void 0;
26335
+ this.deepSeekSignIn = void 0;
25377
26336
  if (config.serverUrl !== void 0) {
25378
26337
  await Promise.all([
25379
26338
  this.client?.clearClientAuthorization(),
25380
26339
  this.host?.clearHostAuthorization()
25381
26340
  ]);
25382
- await Promise.all(["host", "client"].map(async (role) => {
25383
- const directory = serverStorageDirectory(this.identityDirectory, config.serverUrl, role);
25384
- await new ServerCredentialStore(directory).clear();
25385
- }));
26341
+ const directory = await ensureDeviceDirectory(this.identityDirectory, config.serverUrl);
26342
+ await new ServerCredentialStore(directory).clear();
25386
26343
  }
25387
26344
  let deepseekSignedOut = false;
25388
26345
  if (this.deepseekSession !== void 0) {
@@ -25422,9 +26379,9 @@ var PluginControlRuntime = class {
25422
26379
  async settingsView() {
25423
26380
  const config = this.settings === void 0 ? editableConfig(this.config) : editableConfig(resolveConfig(this.settings.get()));
25424
26381
  const associations = await this.associations(config);
25425
- const role = config.role === "client" ? "client" : "host";
25426
- const association = associations[role];
26382
+ const association = associations.host;
25427
26383
  const discovered = discoveredCodexBinary(config.codex?.binary ?? "codex");
26384
+ const codexStatus = this.host?.codexStatus?.();
25428
26385
  return {
25429
26386
  config,
25430
26387
  deviceName: hostname2(),
@@ -25433,27 +26390,25 @@ var PluginControlRuntime = class {
25433
26390
  associations,
25434
26391
  acpAvailability: Object.fromEntries((config.acp?.backends ?? []).map((item) => [item.id, commandAvailable(item.command ?? "")])),
25435
26392
  ...association === void 0 ? {} : { association },
25436
- ...discovered === void 0 ? {} : { discoveredCodexBinary: discovered }
26393
+ ...discovered === void 0 ? {} : { discoveredCodexBinary: discovered },
26394
+ ...codexStatus === void 0 ? {} : { codexStatus }
25437
26395
  };
25438
26396
  }
25439
26397
  async associations(config) {
25440
26398
  if (config.serverUrl === void 0) return {};
25441
- const [host, client] = await Promise.all([
25442
- this.association(config.serverUrl, "host"),
25443
- this.association(config.serverUrl, "client")
25444
- ]);
25445
- return {
25446
- ...host === void 0 ? {} : { host },
25447
- ...client === void 0 ? {} : { client }
25448
- };
26399
+ const association = await this.association(config.serverUrl);
26400
+ if (association === void 0) return {};
26401
+ return { host: association, client: association };
25449
26402
  }
25450
- async association(serverUrl, role) {
26403
+ async association(serverUrl) {
25451
26404
  const identities = new IdentityStore({
25452
- directory: serverStorageDirectory(this.identityDirectory, serverUrl, role)
26405
+ directory: await ensureDeviceDirectory(this.identityDirectory, serverUrl)
25453
26406
  });
25454
26407
  const identity = await identities.loadOrCreate(hostname2());
25455
26408
  const credentials = await new ServerCredentialStore(identities.directory).load(serverUrl, identity.deviceId);
25456
26409
  if (credentials === void 0) return void 0;
26410
+ const hostStatus = this.host?.hostStatus();
26411
+ if (hostStatus?.error !== void 0 && HOST_AUTHORIZATION_ERRORS.has(hostStatus.error)) return void 0;
25457
26412
  return {
25458
26413
  method: credentials.authorizationMethod,
25459
26414
  ...credentials.account === void 0 ? {} : { account: credentials.account }
@@ -25479,14 +26434,13 @@ function commandAvailable(command) {
25479
26434
  }
25480
26435
  function discoveredCodexBinary(configured) {
25481
26436
  for (const candidate of codexBinaryCandidates(configured)) {
25482
- if (candidate !== configured && existsSync3(candidate)) return candidate;
26437
+ if (candidate !== configured && existsSync4(candidate)) return candidate;
25483
26438
  }
25484
26439
  return void 0;
25485
26440
  }
25486
26441
  function editableConfig(config) {
25487
26442
  return {
25488
26443
  enabled: config.enabled,
25489
- role: config.role,
25490
26444
  ...config.serverUrl === void 0 ? {} : { serverUrl: config.serverUrl },
25491
26445
  terminal: config.terminal,
25492
26446
  hostControl: config.hostControl ?? { enabled: true, paused: false },
@@ -25725,10 +26679,6 @@ var ConnectionController = class {
25725
26679
  const peers = this.peerDeviceIds();
25726
26680
  return peers.length === 1 ? peers[0] : void 0;
25727
26681
  }
25728
- connectionMode() {
25729
- const connection = this.active.values().next().value;
25730
- return connection?.channel.mode ?? (connection === void 0 ? "Disconnected" : "Relay");
25731
- }
25732
26682
  async send(message) {
25733
26683
  await Promise.all([...this.active.values()].map((connection) => this.sendConnection(connection, message)));
25734
26684
  }
@@ -26434,7 +27384,6 @@ var API_TRANSFER_IDLE_MS = TRANSFER_IDLE_MS;
26434
27384
  var INLINE_TRANSFER_RESPONSE_BYTES2 = 2 * 1024 * 1024;
26435
27385
  var HarnessApiBridge = class {
26436
27386
  constructor(api, publish, maxStreams = 8, logger, typertGateway, harnessVersion) {
26437
- this.api = api;
26438
27387
  this.publish = publish;
26439
27388
  this.maxStreams = maxStreams;
26440
27389
  this.logger = logger;
@@ -27671,9 +28620,6 @@ var CodexWorkspaceBridge = class {
27671
28620
  terminals;
27672
28621
  ownedSubscribers = /* @__PURE__ */ new Set();
27673
28622
  spawnTerminal;
27674
- isCodeXScope(value) {
27675
- return parseCodexSessionId(value) !== void 0;
27676
- }
27677
28623
  async call(endpoint, payload, signal) {
27678
28624
  const args = argsOf(payload);
27679
28625
  const scope = args.workspaceFileScopeId;
@@ -28113,7 +29059,7 @@ var HostPluginRuntime = class {
28113
29059
  this.harnessVersion,
28114
29060
  new TerminalPolicy(() => this.terminalEnabled, context.peerDeviceId, this.terminalOwners),
28115
29061
  new CodexWorkspaceBridge(
28116
- (threadId, signal) => this.codex.resolveThreadWorkspace(context.connectionId, threadId),
29062
+ (threadId) => this.codex.resolveThreadWorkspace(context.connectionId, threadId),
28117
29063
  () => this.terminalEnabled,
28118
29064
  this.codexWorkspaceState,
28119
29065
  this.terminalSpawner
@@ -28258,9 +29204,15 @@ var HostPluginRuntime = class {
28258
29204
  * user who only wants to stop being remotely reachable would have to authorize
28259
29205
  * again and would consume a device identity. Pausing closes the connection and
28260
29206
  * keeps both.
29207
+ *
29208
+ * The Server's own flag is written here as well, not only by the panel's authorization endpoint:
29209
+ * it is what makes the device read as "not accepting control" to other clients and what makes the
29210
+ * Server refuse the next `role=host` hello. Leaving it to one entry point is how a device could
29211
+ * look online and controlling while its switch said off.
28261
29212
  */
28262
29213
  async pauseHostConnection() {
28263
29214
  this.paused = true;
29215
+ await this.recordHostControl(false);
28264
29216
  await this.serverConnection?.stop();
28265
29217
  this.logger.info("Host connection paused");
28266
29218
  }
@@ -28268,18 +29220,51 @@ var HostPluginRuntime = class {
28268
29220
  async resumeHostConnection() {
28269
29221
  const wasPaused = this.paused;
28270
29222
  this.paused = false;
29223
+ await this.recordHostControl(true);
28271
29224
  this.serverConnection?.resume();
28272
29225
  if (wasPaused) this.logger.info("Host connection resumed");
28273
29226
  }
28274
- isPaused() {
28275
- return this.paused;
29227
+ /**
29228
+ * Mirror the control switch onto the Server.
29229
+ *
29230
+ * A machine that is signed out has no credential to write it with, and that is not an error: the
29231
+ * local pause still holds, and the next sign-in writes the flag from the switch.
29232
+ * @param enabled - whether this machine accepts control.
29233
+ */
29234
+ async recordHostControl(enabled) {
29235
+ try {
29236
+ await this.serverApi?.setHostControl(enabled);
29237
+ } catch (error) {
29238
+ this.logger.warn("could not record the control switch on the Server", { code: errorCode5(error) });
29239
+ }
29240
+ }
29241
+ /** Re-read this device's credentials from disk, so a sign-in is picked up without a restart. */
29242
+ reloadCredentials() {
29243
+ this.serverApi?.reloadCredentials();
29244
+ }
29245
+ /**
29246
+ * Record whether this machine accepts control, on the Server.
29247
+ *
29248
+ * This is the explicit, failing form, used when the panel asks for the switch and must be told if the
29249
+ * Server refused. {@link pauseHostConnection} and {@link resumeHostConnection} write the same flag
29250
+ * best-effort, so the two stay consistent whichever entry point the caller used.
29251
+ */
29252
+ async setHostControl(enabled) {
29253
+ await this.serverApi?.setHostControl(enabled);
29254
+ this.logger.info(enabled ? "Host control enabled" : "Host control disabled");
28276
29255
  }
28277
29256
  reconnectHost() {
28278
29257
  if (this.closed) throw new Error("remote runtime is closed");
28279
29258
  if (this.serverConnection === void 0) {
28280
29259
  throw new ServerApiError("SERVER_NOT_CONFIGURED", "Configure serverUrl before reconnecting.", false);
28281
29260
  }
28282
- this.paused = false;
29261
+ if (this.paused) {
29262
+ throw new ServerApiError(
29263
+ "CONTROL_DISABLED",
29264
+ 'This device is not accepting control. Turn on "Allow control of this device" first.',
29265
+ false
29266
+ );
29267
+ }
28283
29268
  this.serverConnection.reconnect();
28284
29269
  }
28285
29270
  async startHostOAuthQrLogin(provider) {
@@ -28322,26 +29307,6 @@ var HostPluginRuntime = class {
28322
29307
  }
28323
29308
  this.logger.info("Host authorization cleared");
28324
29309
  }
28325
- async authorizeHostAsOwned(accessToken, account) {
28326
- if (this.serverApi === void 0) {
28327
- throw new ServerApiError("SERVER_NOT_CONFIGURED", "Configure serverUrl before enabling Host access.", false);
28328
- }
28329
- let result;
28330
- try {
28331
- result = await this.serverApi.authorizeOwnedRole(this.currentIdentity(), accessToken, account);
28332
- } catch (error) {
28333
- if (!(error instanceof ServerApiError) || error.code !== "DEVICE_REVOKED") throw error;
28334
- await this.serverConnection?.stop();
28335
- this.identity = await this.identities.reset(this.config.deviceName);
28336
- this.serverApi.bindIdentity(this.identity);
28337
- this.serverConnection = this.createServerConnection(this.identity);
28338
- result = await this.serverApi.authorizeOwnedRole(this.identity, accessToken, account);
28339
- this.logger.info("Rotated revoked Host identity before owned-device authorization");
28340
- }
28341
- if (!this.paused) this.serverConnection?.resume();
28342
- this.logger.info("Host authorized as an owned device");
28343
- return result;
28344
- }
28345
29310
  async authorizeHostWithAccount(email, password) {
28346
29311
  if (this.serverApi === void 0) {
28347
29312
  throw new ServerApiError("SERVER_NOT_CONFIGURED", "Configure serverUrl before signing in.", false);
@@ -28365,6 +29330,22 @@ var HostPluginRuntime = class {
28365
29330
  if (revoked) await this.connections.revoke(deviceId);
28366
29331
  return revoked;
28367
29332
  }
29333
+ /**
29334
+ * Restart the Codex connection so a changed setting can apply without restarting DSH.
29335
+ *
29336
+ * The domain is restarted in place, which keeps every connection's Codex bridge alive across the change.
29337
+ * Replacing the instance instead (close the old, build a new one) left each attached client holding a bridge
29338
+ * that belonged to a retired domain: every later Codex call answered "The Codex connection is closed" until
29339
+ * that client happened to reconnect on its own - and nothing in the panel could bring it back.
29340
+ * @returns when the restart attempt finished.
29341
+ */
29342
+ async restartCodex(config) {
29343
+ await this.codex.restart(config);
29344
+ this.logger.info("Codex Remote domain restarted", {
29345
+ enabled: config.enabled,
29346
+ available: this.codex.isAvailable()
29347
+ });
29348
+ }
28368
29349
  codexStatus() {
28369
29350
  return this.codex.status();
28370
29351
  }
@@ -28428,12 +29409,12 @@ var HostPluginRuntime = class {
28428
29409
  }
28429
29410
  async readHarnessVersion() {
28430
29411
  let reportedVersion;
28431
- let errorCode6;
29412
+ let errorCode7;
28432
29413
  try {
28433
29414
  const response = await this.apiProxy?.host.describe({ rpcId: randomUUID4(), payload: {} });
28434
29415
  if (response === void 0) throw new Error("ApiProxy is unavailable");
28435
29416
  if (!response.result.ok) {
28436
- errorCode6 = response.result.error.code;
29417
+ errorCode7 = response.result.error.code;
28437
29418
  } else {
28438
29419
  reportedVersion = normalizeHarnessVersion(response.result.value.version);
28439
29420
  }
@@ -28442,7 +29423,7 @@ var HostPluginRuntime = class {
28442
29423
  const distributionVersion = reportedVersion === void 0 || reportedVersion === "0.0.1" ? await readHarnessDistributionVersion() : void 0;
28443
29424
  const version = selectHarnessVersion(reportedVersion, distributionVersion);
28444
29425
  if (version !== void 0) return version;
28445
- this.logger.warn("Harness version is unavailable", errorCode6 === void 0 ? void 0 : { code: errorCode6 });
29426
+ this.logger.warn("Harness version is unavailable", errorCode7 === void 0 ? void 0 : { code: errorCode7 });
28446
29427
  return void 0;
28447
29428
  }
28448
29429
  hostCapabilities() {
@@ -28493,6 +29474,10 @@ var HostPluginRuntime = class {
28493
29474
  function shortId5(value) {
28494
29475
  return value.length <= 12 ? value : `${value.slice(0, 8)}\u2026${value.slice(-4)}`;
28495
29476
  }
29477
+ function errorCode5(error) {
29478
+ const own = error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
29479
+ return own ?? safeErrorCode(error);
29480
+ }
28496
29481
  function isPlainRecord(value) {
28497
29482
  return typeof value === "object" && value !== null && !Array.isArray(value);
28498
29483
  }
@@ -28819,7 +29804,7 @@ function installTuiRemoteCommand(ctx, resolveTarget) {
28819
29804
  } catch (error) {
28820
29805
  return {
28821
29806
  kind: "error",
28822
- text: `Local Remote Host credentials were cleared, but Server revocation failed (${errorCode5(error)}).`
29807
+ text: `Local Remote Host credentials were cleared, but Server revocation failed (${errorCode6(error)}).`
28823
29808
  };
28824
29809
  }
28825
29810
  }
@@ -28875,7 +29860,7 @@ function registerOptional(ctx, feature, register2) {
28875
29860
  return register2() ?? (() => {
28876
29861
  });
28877
29862
  } catch (error) {
28878
- ctx.logger.warn(`dsh-TUI Remote ${feature} is unavailable`, { code: errorCode5(error) });
29863
+ ctx.logger.warn(`dsh-TUI Remote ${feature} is unavailable`, { code: errorCode6(error) });
28879
29864
  return () => {
28880
29865
  };
28881
29866
  }
@@ -28988,7 +29973,7 @@ var RemoteLoginController = class {
28988
29973
  await this.poll(attempt, provider, session, target2.runtime);
28989
29974
  } catch (error) {
28990
29975
  if (attempt !== this.attempt) return;
28991
- this.update({ phase: "error", provider, error: errorCode5(error) });
29976
+ this.update({ phase: "error", provider, error: errorCode6(error) });
28992
29977
  }
28993
29978
  }
28994
29979
  async poll(attempt, provider, session, runtime) {
@@ -29106,7 +30091,7 @@ function terminalLink2(url) {
29106
30091
  function printableWidth(value) {
29107
30092
  return value.replace(/\u001B\[[0-9;]*m/gu, "").length;
29108
30093
  }
29109
- function errorCode5(error) {
30094
+ function errorCode6(error) {
29110
30095
  if (error instanceof ServerApiError) return error.code;
29111
30096
  if (error instanceof Error && "code" in error && typeof error.code === "string") return error.code;
29112
30097
  return "CONNECTION_FAILED";
@@ -29281,9 +30266,8 @@ async function activate(ctx, readConfig, entryId, tuiBinding) {
29281
30266
  console.error(message);
29282
30267
  }
29283
30268
  }, config.logLevel);
29284
- const hostIdentities = new IdentityStore({
29285
- directory: config.serverUrl === void 0 ? defaultIdentityDirectory : serverStorageDirectory(defaultIdentityDirectory, config.serverUrl, "host")
29286
- });
30269
+ const deviceDirectory = config.serverUrl === void 0 ? defaultIdentityDirectory : await ensureDeviceDirectory(defaultIdentityDirectory, config.serverUrl);
30270
+ const hostIdentities = new IdentityStore({ directory: deviceDirectory });
29287
30271
  const apiProxy = ctx.get("apiProxy");
29288
30272
  const nativeTypertGateway = ctx.get("typertGateway");
29289
30273
  const localTypertGateway = new TypertGatewaySwitch(nativeTypertGateway).local();
@@ -29324,9 +30308,7 @@ async function activate(ctx, readConfig, entryId, tuiBinding) {
29324
30308
  let clientRuntime;
29325
30309
  const hostControl = runtime;
29326
30310
  if (config.serverUrl !== void 0 && connection !== void 0) {
29327
- const clientIdentities = new IdentityStore({
29328
- directory: serverStorageDirectory(defaultIdentityDirectory, config.serverUrl, "client")
29329
- });
30311
+ const clientIdentities = hostIdentities;
29330
30312
  clientRuntime = new ClientModeRuntime(
29331
30313
  config,
29332
30314
  clientIdentities,