@blue-soda/dsh-remote 0.4.32 → 0.5.0

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:
@@ -13467,7 +13458,7 @@ function normalizeServerUrl(value) {
13467
13458
  }
13468
13459
 
13469
13460
  // src/version.ts
13470
- var PLUGIN_VERSION = "0.4.32";
13461
+ var PLUGIN_VERSION = "0.5.0";
13471
13462
 
13472
13463
  // src/server-api.ts
13473
13464
  var ENABLED_QR_PROVIDERS = ["github"];
@@ -13479,10 +13470,9 @@ function oauthProviderName(provider) {
13479
13470
  }
13480
13471
  var TERMINAL_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u;
13481
13472
  var HostServerApi = class {
13482
- constructor(serverUrl, store, fetchImplementation = fetch, role = "host") {
13473
+ constructor(serverUrl, store, fetchImplementation = fetch) {
13483
13474
  this.store = store;
13484
13475
  this.fetchImplementation = fetchImplementation;
13485
- this.role = role;
13486
13476
  this.baseUrl = normalizeServerUrl(serverUrl);
13487
13477
  }
13488
13478
  baseUrl;
@@ -13508,11 +13498,33 @@ var HostServerApi = class {
13508
13498
  const identity = this.requireIdentity();
13509
13499
  return await this.store.load(this.baseUrl, identity.deviceId) !== void 0;
13510
13500
  }
13501
+ /**
13502
+ * Drop the in-memory credential cache so the next request reads what is on disk.
13503
+ *
13504
+ * Signing in writes fresh credentials for this device; a long-lived runtime that kept its own copy went on
13505
+ * presenting the rejected one and answered AUTH_INVALID until the process restarted.
13506
+ */
13507
+ reloadCredentials() {
13508
+ this.credentials = void 0;
13509
+ this.credentialsPromise = void 0;
13510
+ }
13511
13511
  async clearAuthorization() {
13512
13512
  this.credentials = void 0;
13513
13513
  this.credentialsPromise = void 0;
13514
13514
  await this.store.clear();
13515
13515
  }
13516
+ /**
13517
+ * Tell the Server whether this device accepts control.
13518
+ *
13519
+ * The flag lives on the Server so it holds across restarts and is visible to clients listing devices;
13520
+ * the local identity and credentials are untouched.
13521
+ */
13522
+ async setHostControl(enabled) {
13523
+ await this.request("/api/v1/devices/self/control", {
13524
+ method: "POST",
13525
+ body: JSON.stringify({ enabled })
13526
+ });
13527
+ }
13516
13528
  async revokeCurrentDevice() {
13517
13529
  const identity = this.requireIdentity();
13518
13530
  if (await this.store.load(this.baseUrl, identity.deviceId) === void 0) {
@@ -13623,9 +13635,6 @@ var HostServerApi = class {
13623
13635
  };
13624
13636
  }
13625
13637
  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
13638
  const registrationCode = code.trim().toUpperCase();
13630
13639
  if (registrationCode.length === 0) {
13631
13640
  throw new ServerApiError("INVALID_MESSAGE", "A Host registration code is required.", false);
@@ -13640,21 +13649,6 @@ var HostServerApi = class {
13640
13649
  });
13641
13650
  return { method: "host_registration_code" };
13642
13651
  }
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
13652
  async authenticate(identity = this.requireIdentity()) {
13659
13653
  this.bindIdentity(identity);
13660
13654
  if (this.credentials !== void 0 && this.credentials.accessTokenExpiresAt > Date.now() + 3e4) {
@@ -13760,11 +13754,10 @@ var HostServerApi = class {
13760
13754
  return {
13761
13755
  deviceId: identity.deviceId,
13762
13756
  name: identity.name,
13763
- role: this.role,
13764
13757
  platform: platform(),
13765
13758
  identityKey: identity.publicKey,
13766
13759
  clientVersion: PLUGIN_VERSION,
13767
- ...this.role === "host" && this.harnessVersion !== void 0 ? { harnessVersion: this.harnessVersion } : {}
13760
+ ...this.harnessVersion === void 0 ? {} : { harnessVersion: this.harnessVersion }
13768
13761
  };
13769
13762
  }
13770
13763
  saveTokens(identity, tokens, authorization) {
@@ -13832,7 +13825,7 @@ function normalizeOAuthScanUrl(value, baseUrl) {
13832
13825
  }
13833
13826
  var ClientServerApi = class extends HostServerApi {
13834
13827
  constructor(serverUrl, store, fetchImplementation = fetch) {
13835
- super(serverUrl, store, fetchImplementation, "client");
13828
+ super(serverUrl, store, fetchImplementation);
13836
13829
  }
13837
13830
  };
13838
13831
  var ServerApiError = class extends Error {
@@ -13882,7 +13875,7 @@ function mapStatus(status2) {
13882
13875
  }
13883
13876
  function parseHostDevice(value) {
13884
13877
  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) {
13878
+ if (typeof item.deviceId !== "string" || typeof item.name !== "string" || typeof item.platform !== "string" || typeof item.membershipId !== "string" || item.membershipId.length === 0) {
13886
13879
  throw new ServerApiError("INVALID_MESSAGE", "The Server returned invalid host device data.", false);
13887
13880
  }
13888
13881
  return {
@@ -13891,6 +13884,7 @@ function parseHostDevice(value) {
13891
13884
  platform: item.platform,
13892
13885
  membershipId: item.membershipId,
13893
13886
  ...typeof item.online === "boolean" ? { online: item.online } : {},
13887
+ ...typeof item.hostControl === "boolean" ? { hostControl: item.hostControl } : {},
13894
13888
  ...typeof item.lastSeenAt === "number" && Number.isSafeInteger(item.lastSeenAt) ? { lastSeenAt: item.lastSeenAt } : {},
13895
13889
  ...typeof item.clientVersion === "string" ? { clientVersion: item.clientVersion } : {},
13896
13890
  ...typeof item.harnessVersion === "string" ? { harnessVersion: item.harnessVersion } : {}
@@ -13898,17 +13892,17 @@ function parseHostDevice(value) {
13898
13892
  }
13899
13893
  function parseAuthorizedPeer(value) {
13900
13894
  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) {
13895
+ 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
13896
  throw new ServerApiError("INVALID_MESSAGE", "The Server returned invalid authorized peer data.", false);
13903
13897
  }
13904
13898
  return {
13905
13899
  deviceId: item.deviceId,
13906
13900
  name: item.name,
13907
- role: item.role,
13908
13901
  platform: item.platform,
13909
13902
  identityKey: item.identityKey,
13910
13903
  membershipId: item.membershipId,
13911
13904
  ...typeof item.online === "boolean" ? { online: item.online } : {},
13905
+ ...typeof item.hostControl === "boolean" ? { hostControl: item.hostControl } : {},
13912
13906
  ...typeof item.lastSeenAt === "number" && Number.isSafeInteger(item.lastSeenAt) ? { lastSeenAt: item.lastSeenAt } : {}
13913
13907
  };
13914
13908
  }
@@ -14139,8 +14133,10 @@ var HostServerConnection = class {
14139
14133
  socket.close(4008, "invalid control frame");
14140
14134
  });
14141
14135
  };
14142
- socket.onerror = () => {
14143
- if (!acknowledged) finish(new ControlConnectionError("CONNECTION_FAILED", "Unable to open the Server WebSocket."));
14136
+ socket.onerror = (event) => {
14137
+ if (!acknowledged) {
14138
+ finish(new ControlConnectionError("CONNECTION_FAILED", `Unable to open the Server WebSocket.${socketFailureDetail(event)}`));
14139
+ }
14144
14140
  };
14145
14141
  socket.onclose = (event) => {
14146
14142
  const close = async () => {
@@ -14246,7 +14242,7 @@ var HostServerConnection = class {
14246
14242
  });
14247
14243
  return;
14248
14244
  }
14249
- if (descriptor.role !== "client" || descriptor.deviceId !== payload.clientDeviceId || descriptor.identityKey !== payload.clientIdentityKey) {
14245
+ if (descriptor.deviceId !== payload.clientDeviceId || descriptor.identityKey !== payload.clientIdentityKey) {
14250
14246
  this.sendControl("connect.rejected", { connectionId: payload.connectionId });
14251
14247
  this.logger.warn("connection rejected by peer identity validation", {
14252
14248
  clientDeviceId: shortId(payload.clientDeviceId)
@@ -14840,6 +14836,21 @@ function connectionFailureDetail(error) {
14840
14836
  ...causeCode === void 0 ? {} : { causeCode }
14841
14837
  };
14842
14838
  }
14839
+ function socketFailureDetail(event) {
14840
+ const parts = [];
14841
+ if (event !== null && typeof event === "object") {
14842
+ const message = "message" in event && typeof event.message === "string" ? event.message : void 0;
14843
+ if (message !== void 0 && message !== "") parts.push(message);
14844
+ const error = "error" in event ? event.error : void 0;
14845
+ if (error !== null && typeof error === "object") {
14846
+ const code = "code" in error && typeof error.code === "string" ? error.code : void 0;
14847
+ const errorMessage = "message" in error && typeof error.message === "string" ? error.message : void 0;
14848
+ if (code !== void 0) parts.push(code);
14849
+ if (errorMessage !== void 0 && errorMessage !== "" && errorMessage !== message) parts.push(errorMessage);
14850
+ }
14851
+ }
14852
+ return parts.length === 0 ? "" : ` (${parts.join(": ")})`;
14853
+ }
14843
14854
  function errorCode2(error) {
14844
14855
  return error instanceof ServerApiError || error instanceof ControlConnectionError ? error.code : "CONNECTION_FAILED";
14845
14856
  }
@@ -15392,11 +15403,30 @@ function createRemoteId() {
15392
15403
  var MAX_DISPLAY_ITEM_TEXT = 256 * 1024;
15393
15404
  var CODEX_IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
15394
15405
  var DATA_IMAGE_URL = /^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/]+={0,2})$/u;
15406
+ var CODEX_CAPABILITY_EVENT = "codex.app.capability";
15395
15407
  var CodexRemoteClient = class {
15396
15408
  core;
15397
15409
  constructor(core) {
15398
15410
  this.core = core;
15399
15411
  }
15412
+ /**
15413
+ * Listen for the Host's Codex availability changes.
15414
+ *
15415
+ * Fires whether or not this client is following any thread, which is what makes "the peer switched Codex off"
15416
+ * reach the session on screen immediately instead of at the next failing call. The Host sends this only on a
15417
+ * real transition, so `available: true` never means "you lost something".
15418
+ * @param handler - called with the new availability.
15419
+ * @returns an unsubscribe function.
15420
+ */
15421
+ watchCapability(handler) {
15422
+ return this.core.onEvent((event) => {
15423
+ if (event.event !== CODEX_CAPABILITY_EVENT)
15424
+ return;
15425
+ if (!isRecord(event.data) || typeof event.data.available !== "boolean")
15426
+ return;
15427
+ handler(event.data.available);
15428
+ });
15429
+ }
15400
15430
  /** Low-level allowlisted request used by the Desktop Web loopback facade. */
15401
15431
  request(method, params, signal) {
15402
15432
  const largeHistory = method === "dsh/sessionHistory" || method === "thread/read" && isRecord(params) && params.includeTurns === true;
@@ -17836,6 +17866,10 @@ function isHostWorkspaceEndpoint(endpoint) {
17836
17866
  function isHostWorkspaceStreamEndpoint(endpoint) {
17837
17867
  return HOST_WORKSPACE_STREAM_ENDPOINTS.has(endpoint);
17838
17868
  }
17869
+ function sameWorkspaceRoot(left, right) {
17870
+ const normalise = (value) => value.trim().replace(/[\\/]+$/, "").replace(/\\/g, "/").toLowerCase();
17871
+ return normalise(left) === normalise(right);
17872
+ }
17839
17873
  function codexProjectWorkspaceId(projectId) {
17840
17874
  return `${CODEX_WORKSPACE_PREFIX}project:${projectId}`;
17841
17875
  }
@@ -17849,6 +17883,9 @@ var CodexVirtualHarness = class _CodexVirtualHarness {
17849
17883
  this.sessionGeneration = sessionGeneration;
17850
17884
  this.hostCarrier = hostCarrier;
17851
17885
  this.api = this.createApiProxy();
17886
+ this.stopCapabilityWatch = this.client.watchCapability?.((available) => {
17887
+ if (!available) this.onSessionLost?.("domain-notice");
17888
+ });
17852
17889
  }
17853
17890
  api;
17854
17891
  catalog;
@@ -17881,16 +17918,33 @@ var CodexVirtualHarness = class _CodexVirtualHarness {
17881
17918
  this.localCarrier = carrier;
17882
17919
  }
17883
17920
  localCarrier;
17921
+ onSessionLost;
17922
+ stopCapabilityWatch;
17923
+ /**
17924
+ * Register what to do when a Codex call fails because the session is gone.
17925
+ *
17926
+ * Set after construction rather than passed in, so neither the constructor nor the static factory signature has to
17927
+ * change: the runtime hooks its reconnect entry point here. The Host replacing or switching off its Codex domain
17928
+ * closes the bridges, and the next call would otherwise just be shown as an error.
17929
+ * @param handler - called once per detected loss, with what detected it, before the failure is returned.
17930
+ * @returns nothing.
17931
+ */
17932
+ watchCodexSessionLost(handler) {
17933
+ this.onSessionLost = handler;
17934
+ }
17884
17935
  static remote(core, host, sessionGeneration = "legacy", hostCarrier) {
17885
17936
  return new _CodexVirtualHarness(new CodexRemoteClient(core), host, sessionGeneration, hostCarrier);
17886
17937
  }
17887
17938
  async workspaces(signal) {
17888
17939
  return (await this.refreshCatalog(signal)).workspaces;
17889
17940
  }
17890
- async selectWorkspace(workspaceId, signal) {
17941
+ async selectWorkspace(workspaceId, signal, rootPath) {
17891
17942
  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.");
17943
+ const workspace = catalog.workspaces.find((item) => item.workspaceId === workspaceId) ?? (rootPath === void 0 ? void 0 : catalog.workspaces.find((item) => sameWorkspaceRoot(item.path, rootPath)));
17944
+ if (workspace === void 0) {
17945
+ const seen = catalog.workspaces.slice(0, 5).map((item) => item.workspaceId + "@" + item.path).join(", ");
17946
+ throw new Error("The selected CodeX workspace is no longer available. (wanted: " + workspaceId + " root: " + String(rootPath) + " catalog: " + seen + ")");
17947
+ }
17894
17948
  this.selectedWorkspaceId = workspace.workspaceId;
17895
17949
  this.catalog = catalog;
17896
17950
  return workspace;
@@ -17907,7 +17961,7 @@ var CodexVirtualHarness = class _CodexVirtualHarness {
17907
17961
  for (const sessionId of candidates) {
17908
17962
  const threadId = nativeThreadId(sessionId);
17909
17963
  try {
17910
- const thread = await this.fetchThread(threadId, signal);
17964
+ await this.fetchThread(threadId, signal);
17911
17965
  return sessionId;
17912
17966
  } catch {
17913
17967
  }
@@ -18021,6 +18075,7 @@ var CodexVirtualHarness = class _CodexVirtualHarness {
18021
18075
  }
18022
18076
  }
18023
18077
  } catch (error) {
18078
+ if (isCodexSessionLoss(error)) this.onSessionLost?.("call-failed");
18024
18079
  return failFrom(error);
18025
18080
  }
18026
18081
  }
@@ -18051,6 +18106,7 @@ var CodexVirtualHarness = class _CodexVirtualHarness {
18051
18106
  async close() {
18052
18107
  if (this.closed) return;
18053
18108
  this.closed = true;
18109
+ this.stopCapabilityWatch?.();
18054
18110
  for (const stream of this.workspaceStreams) stream.close();
18055
18111
  for (const stream of this.controlStreams) stream.close();
18056
18112
  for (const stream of this.eventStreams.values()) stream.close();
@@ -18333,7 +18389,7 @@ var CodexVirtualHarness = class _CodexVirtualHarness {
18333
18389
  threadId,
18334
18390
  (frame) => this.acceptCodexFrame(follow, frame),
18335
18391
  signal,
18336
- () => this.closeFollowAfterRemoteStreamClosed(follow)
18392
+ (reason) => this.noteRemoteStreamClosed(follow, reason)
18337
18393
  );
18338
18394
  follow.close = () => stream.close();
18339
18395
  } catch (error) {
@@ -19107,7 +19163,7 @@ var CodexVirtualHarness = class _CodexVirtualHarness {
19107
19163
  threadId,
19108
19164
  (frame) => this.acceptCodexFrame(follow, frame),
19109
19165
  controller.signal,
19110
- () => this.closeFollowAfterRemoteStreamClosed(follow)
19166
+ (reason) => this.noteRemoteStreamClosed(follow, reason)
19111
19167
  );
19112
19168
  follow.close = async () => {
19113
19169
  controller.abort();
@@ -19129,6 +19185,21 @@ var CodexVirtualHarness = class _CodexVirtualHarness {
19129
19185
  follow.queue.close();
19130
19186
  this.emitRemoteEvent("api-session/status", [follow.sessionId, false]);
19131
19187
  }
19188
+ /**
19189
+ * The Host closed a Codex stream this client was following.
19190
+ *
19191
+ * `failed` and `peer-disconnected` mean the Codex session behind it is gone - the Host switched Codex off,
19192
+ * replaced its domain, or its App Server died. That is a lost session like any other, so the runtime is told
19193
+ * now, which is what puts "reconnecting" on screen immediately instead of leaving the user to discover it as
19194
+ * an error the next time they touch the session. A stream we closed ourselves, or one that simply ended,
19195
+ * says nothing about the session.
19196
+ * @param follow - the projection that was following it.
19197
+ * @param reason - why the peer closed the stream.
19198
+ */
19199
+ noteRemoteStreamClosed(follow, reason) {
19200
+ if (reason === "failed" || reason === "peer-disconnected") this.onSessionLost?.("stream-closed");
19201
+ this.closeFollowAfterRemoteStreamClosed(follow);
19202
+ }
19132
19203
  async refreshAndPublishWorkspaces() {
19133
19204
  const catalog = await this.refreshCatalog().catch(() => void 0);
19134
19205
  if (catalog !== void 0) this.publishWorkspaceBaseline(catalog);
@@ -21596,7 +21667,7 @@ var QUICK_RECONNECT_ATTEMPTS = 2;
21596
21667
  var QUICK_RECONNECT_WINDOW_MS = 1e4;
21597
21668
  var QUICK_RECONNECT_RETRY_DELAY_MS = 5e3;
21598
21669
  var QUICK_RECONNECT_FORCE_RELAY = true;
21599
- var FALLBACK_STEADY_ATTEMPTS = 5;
21670
+ var BACKGROUND_RETRY_DELAYS_MS = [5e3, 1e4, 2e4, 3e4, 3e4, 3e4, 3e4];
21600
21671
  var ClientModeRuntime = class {
21601
21672
  constructor(config, identities, server, apiProxy, typertGateway, logger, host, rtcFactoryProvider = loadNodeRtcFactory, targetStore) {
21602
21673
  this.config = config;
@@ -21613,6 +21684,16 @@ var ClientModeRuntime = class {
21613
21684
  preview;
21614
21685
  identity;
21615
21686
  connected;
21687
+ /**
21688
+ * The connection the Remote panel browses a Host with.
21689
+ *
21690
+ * Deliberately separate from {@link connected}: opening the panel, connecting to a Host and looking at or
21691
+ * picking its workspaces is *not* a remote session. Nothing here may become "what the user is watching",
21692
+ * arm a reconnect, or survive as something a later boot would restore - the user is still in the local
21693
+ * shell until they actually open a workspace. Keeping it in this field is what enforces that: the recovery
21694
+ * path only ever looks at {@link connected}.
21695
+ */
21696
+ browsing;
21616
21697
  /**
21617
21698
  * Set when a remote session dropped and the runtime fell back to local.
21618
21699
  *
@@ -21646,6 +21727,29 @@ var ClientModeRuntime = class {
21646
21727
  * the link is back.
21647
21728
  */
21648
21729
  lastWorkspaceSelection;
21730
+ /**
21731
+ * The last workspaces listed per Host.
21732
+ *
21733
+ * A workspace picked in the panel is opened by id, and a replaced Codex domain hands out new ids for the same
21734
+ * directories. Keeping the previous listing is what lets a stale id be translated back to its root and opened
21735
+ * again instead of failing as "no longer available".
21736
+ */
21737
+ lastCodexListing = /* @__PURE__ */ new Map();
21738
+ /**
21739
+ * Every workspace id this client has seen for a Host, mapped to its root.
21740
+ *
21741
+ * A panel entry can come from a listing made before the Host replaced its Codex domain, so the id is missing from
21742
+ * the latest listing while the directory itself is unchanged. Keeping the whole history is what lets such an id be
21743
+ * translated back to a root and reopened.
21744
+ */
21745
+ codexWorkspaceRoots = /* @__PURE__ */ new Map();
21746
+ /**
21747
+ * Hosts this run has actually opened a Codex workspace on.
21748
+ *
21749
+ * Whether the peer merely never offered Codex or has just switched it off decides the wording the user sees, and
21750
+ * the remembered selection cannot answer that on its own: switching to local clears it by design.
21751
+ */
21752
+ codexHostsSeen = /* @__PURE__ */ new Set();
21649
21753
  codexVirtual;
21650
21754
  proxySwitch;
21651
21755
  gatewaySwitch;
@@ -21667,6 +21771,21 @@ var ClientModeRuntime = class {
21667
21771
  * stops as soon as the user asks for local.
21668
21772
  */
21669
21773
  reconnecting;
21774
+ /**
21775
+ * What the recovery is putting back, for the header.
21776
+ *
21777
+ * The window has to be able to say *which* session is coming back while the carriers are already local - a
21778
+ * Codex recovery that fell back to the local shell used to read "remote DSH, reconnecting", which describes
21779
+ * a different thing than the one being restored. The kind comes from the remembered selection, so it stays
21780
+ * right for the whole window without a second copy of the state to keep in sync.
21781
+ * @returns the payload the status carries for a running recovery.
21782
+ */
21783
+ reconnectState() {
21784
+ const state = this.reconnecting;
21785
+ if (state === void 0) return { targetDeviceId: "", phase: "restore" };
21786
+ const kind = this.sessionTargetFor(state.targetDeviceId)?.kind;
21787
+ return { ...state, ...kind === void 0 ? {} : { kind } };
21788
+ }
21670
21789
  /** Periodic proof of life for the live remote session; absent while nothing is connected. */
21671
21790
  closed = false;
21672
21791
  async start() {
@@ -21682,7 +21801,7 @@ var ClientModeRuntime = class {
21682
21801
  if (this.config.hostControl?.enabled !== false && this.host !== void 0 && this.server.hasStoredAuthorization !== void 0) {
21683
21802
  try {
21684
21803
  if (await this.server.hasStoredAuthorization() && (this.host.hasStoredAuthorization === void 0 || !await this.host.hasStoredAuthorization())) {
21685
- await this.authorizeHostByDefault();
21804
+ this.logger.info("Host credentials are present; the Host connection starts on its own");
21686
21805
  }
21687
21806
  } catch (error) {
21688
21807
  this.logger.warn("automatic Host authorization failed", { code: safeErrorCode(error) });
@@ -21700,20 +21819,6 @@ var ClientModeRuntime = class {
21700
21819
  async hasStoredAuthorization() {
21701
21820
  return this.server.hasStoredAuthorization === void 0 ? false : await this.server.hasStoredAuthorization();
21702
21821
  }
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
21822
  registerControl(connection, webServer) {
21718
21823
  return registerControlRoute(connection, (endpoint, payload, signal) => this.handleControl(endpoint, payload, signal), webServer);
21719
21824
  }
@@ -21728,7 +21833,7 @@ var ClientModeRuntime = class {
21728
21833
  ...targetStatus,
21729
21834
  connected: this.connected !== void 0,
21730
21835
  fellBackToLocal: this.fellBackToLocal,
21731
- ...this.reconnecting === void 0 ? {} : { reconnecting: { ...this.reconnecting } },
21836
+ ...this.reconnecting === void 0 ? {} : { reconnecting: this.reconnectState() },
21732
21837
  transport: this.connected?.client.getStats().mode ?? "Disconnected",
21733
21838
  connectedTargetDeviceId: this.connected?.target.deviceId,
21734
21839
  preferredTransports: this.config.forceRelay ? ["relay"] : ["lan", "p2p", "turn", "relay"],
@@ -21774,7 +21879,7 @@ var ClientModeRuntime = class {
21774
21879
  };
21775
21880
  }
21776
21881
  async devices() {
21777
- this.assertHostAuthorizationForDeviceDiscovery();
21882
+ if (!await this.recoverHostAuthorization()) return [];
21778
21883
  this.requireIdentity();
21779
21884
  const serverDevices = await this.server.listDevices();
21780
21885
  const remoteDevices = serverDevices.filter((device) => device.deviceId !== this.host?.hostStatus().deviceId);
@@ -21785,18 +21890,18 @@ var ClientModeRuntime = class {
21785
21890
  }));
21786
21891
  }
21787
21892
  /**
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.
21893
+ * Whether the Host credential is usable, after one attempt to re-authorize it.
21894
+ *
21895
+ * Device discovery runs through the local app control route and must not leak a device directory for a
21896
+ * Host the Server no longer accepts, but refusing outright was worse than useless: it told a user who was
21897
+ * already signing in to sign out first. Re-authorizing from the stored Client credential is the move that
21898
+ * helps; when that fails too - the device was removed from the account, so there is nothing left to
21899
+ * refresh - discovery reports nothing and the panel's authorization state takes over.
21900
+ * @returns whether the Host credential is usable.
21794
21901
  */
21795
- assertHostAuthorizationForDeviceDiscovery() {
21902
+ async recoverHostAuthorization() {
21796
21903
  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);
21904
+ return status2 === void 0 || status2.error === void 0 || !HOST_AUTHORIZATION_ERRORS.has(status2.error);
21800
21905
  }
21801
21906
  async authorizeClientWithAccount(email, password) {
21802
21907
  let authorization;
@@ -21804,11 +21909,10 @@ var ClientModeRuntime = class {
21804
21909
  authorization = await this.server.authorizeWithAccount(this.requireIdentity(), email, password);
21805
21910
  } catch (error) {
21806
21911
  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);
21912
+ const identity = this.requireIdentity();
21913
+ this.server.bindIdentity(identity);
21914
+ authorization = await this.server.authorizeWithAccount(identity, email, password);
21810
21915
  }
21811
- await this.authorizeHostByDefault();
21812
21916
  this.logger.info("Client account authorized");
21813
21917
  return authorization;
21814
21918
  }
@@ -21817,13 +21921,11 @@ var ClientModeRuntime = class {
21817
21921
  }
21818
21922
  async pollClientOAuthQrLogin(qrId) {
21819
21923
  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;
21924
+ const identity = this.requireIdentity();
21925
+ this.server.bindIdentity(identity);
21926
+ return identity;
21824
21927
  });
21825
21928
  if (result.status === "complete") this.logger.info("Client account authorized with QR login");
21826
- if (result.status === "complete") await this.authorizeHostByDefault();
21827
21929
  return result;
21828
21930
  }
21829
21931
  /**
@@ -21846,6 +21948,7 @@ var ClientModeRuntime = class {
21846
21948
  this.connected = void 0;
21847
21949
  this.connectionProgress = void 0;
21848
21950
  this.pendingWorkspaceSelection = void 0;
21951
+ this.closeBrowsing();
21849
21952
  await this.closeCodexVirtual();
21850
21953
  this.proxySwitch?.selectLocal();
21851
21954
  await this.closePreview();
@@ -21855,14 +21958,39 @@ var ClientModeRuntime = class {
21855
21958
  this.remoteReconnectRun += 1;
21856
21959
  await this.server.clearAuthorization();
21857
21960
  }
21961
+ /**
21962
+ * Revoke this device on the Server, then drop the local authorization.
21963
+ *
21964
+ * Used by the Remote panel's removal action. The device deliberately keeps its identity: signing in
21965
+ * again re-registers the same installation instead of adding another device row, which is what stops
21966
+ * one machine from looking like several over time.
21967
+ */
21968
+ async revokeCurrentDevice() {
21969
+ await this.server.revokeCurrentDevice();
21970
+ this.connected = void 0;
21971
+ this.connectionProgress = void 0;
21972
+ this.closeBrowsing();
21973
+ this.reconnecting = void 0;
21974
+ this.fellBackToLocal = false;
21975
+ this.remoteReconnectRun += 1;
21976
+ }
21977
+ /**
21978
+ * Switch whether this machine accepts control.
21979
+ *
21980
+ * Turning it off takes the Host connection away and tells the Server to refuse a new one, while the
21981
+ * account sign-in, the device identity and the client half stay exactly as they are - so the device
21982
+ * remains signed in and can still control other machines, and turning control back on reconnects
21983
+ * without another sign-in.
21984
+ */
21985
+ /** Re-read this device's credentials from disk, so a sign-in is picked up without a restart. */
21986
+ reloadCredentials() {
21987
+ this.server.reloadCredentials();
21988
+ }
21858
21989
  async setHostAuthorization(enabled) {
21859
21990
  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);
21991
+ await this.host.setHostControl?.(enabled);
21992
+ if (enabled) await this.host.resumeHostConnection?.();
21993
+ else await this.host.pauseHostConnection?.();
21866
21994
  return this.status();
21867
21995
  }
21868
21996
  async setMode(mode, targetDeviceId, signal) {
@@ -21876,6 +22004,7 @@ var ClientModeRuntime = class {
21876
22004
  this.connectionProgress = void 0;
21877
22005
  this.pendingWorkspaceSelection = void 0;
21878
22006
  this.lastWorkspaceSelection = void 0;
22007
+ this.closeBrowsing();
21879
22008
  await this.closeCodexStreams(previous2?.client);
21880
22009
  await previous2?.client.close().catch(() => void 0);
21881
22010
  this.fellBackToLocal = false;
@@ -21888,7 +22017,9 @@ var ClientModeRuntime = class {
21888
22017
  if (targetDeviceId === void 0 || targetDeviceId.length === 0) {
21889
22018
  throw new ClientModeError("INVALID_MESSAGE", "A targetDeviceId is required for remote mode.");
21890
22019
  }
21891
- const next = await this.connect(targetDeviceId, signal);
22020
+ const browsingHere = this.browsing?.target.deviceId === targetDeviceId ? this.browsing : void 0;
22021
+ if (browsingHere !== void 0) this.browsing = void 0;
22022
+ const next = browsingHere ?? await this.connectWithReason(targetDeviceId, signal);
21892
22023
  try {
21893
22024
  this.assertRemoteCompatible(next);
21894
22025
  } catch (error) {
@@ -21903,6 +22034,7 @@ var ClientModeRuntime = class {
21903
22034
  this.pendingWorkspaceSelection = void 0;
21904
22035
  await this.closeCodexVirtual();
21905
22036
  this.selectRemoteTarget(next);
22037
+ this.closeBrowsing();
21906
22038
  this.fellBackToLocal = false;
21907
22039
  this.reconnecting = void 0;
21908
22040
  await this.rememberTarget({ mode: "remote", hostDeviceId: next.target.deviceId, hostName: next.target.name });
@@ -21923,7 +22055,7 @@ var ClientModeRuntime = class {
21923
22055
  const previous = this.connected;
21924
22056
  this.supersededClient = previous?.client;
21925
22057
  try {
21926
- const next = await this.connect(targetDeviceId, void 0, forceRelay);
22058
+ const next = await this.connectWithReason(targetDeviceId, void 0, forceRelay);
21927
22059
  if (previous === void 0) {
21928
22060
  this.supersededClient = void 0;
21929
22061
  await next.client.close().catch(() => void 0);
@@ -21936,13 +22068,12 @@ var ClientModeRuntime = class {
21936
22068
  await this.closeCodexStreams(previous.client);
21937
22069
  await previous.client.close().catch(() => void 0);
21938
22070
  this.supersededClient = void 0;
21939
- this.finishReconnect("link re-established");
21940
22071
  return true;
21941
22072
  } catch (error) {
21942
22073
  this.supersededClient = void 0;
21943
22074
  this.logger.warn("fast reconnect attempt failed", {
21944
22075
  targetDeviceId: shortId2(targetDeviceId),
21945
- code: safeErrorCode(error),
22076
+ code: failureCode(error),
21946
22077
  ...connectionFailureDetail(error)
21947
22078
  });
21948
22079
  return false;
@@ -21951,6 +22082,83 @@ var ClientModeRuntime = class {
21951
22082
  rememberWorkspaceSelection(selection) {
21952
22083
  this.pendingWorkspaceSelection = selection;
21953
22084
  this.lastWorkspaceSelection = { ...selection };
22085
+ const connected = this.connected;
22086
+ if (connected?.target.deviceId === selection.targetDeviceId) {
22087
+ void this.rememberTarget({
22088
+ mode: "remote",
22089
+ hostDeviceId: selection.targetDeviceId,
22090
+ hostName: connected.target.name
22091
+ });
22092
+ }
22093
+ }
22094
+ /**
22095
+ * What the user is watching, reduced to the only thing a recovery has to restore.
22096
+ *
22097
+ * A lost session is always "this device, this kind of workspace, this directory". Deriving that once -
22098
+ * instead of letting each recovery path re-read `lastWorkspaceSelection` and decide for itself - is what
22099
+ * keeps a Codex session from being restored onto the peer's DeepSeek carrier, which is exactly the bug the
22100
+ * reconnect used to produce.
22101
+ * @param targetDeviceId - the Host the lost session belonged to.
22102
+ * @returns the target, or undefined when nothing on that Host is being watched.
22103
+ */
22104
+ sessionTargetFor(targetDeviceId) {
22105
+ const selection = this.lastWorkspaceSelection;
22106
+ if (selection === void 0 || selection.targetDeviceId !== targetDeviceId) return void 0;
22107
+ return {
22108
+ deviceId: targetDeviceId,
22109
+ kind: selection.backend === "codex" ? "codex" : "harness",
22110
+ workspaceId: selection.workspaceId,
22111
+ ...selection.workspacePath === void 0 ? {} : { workspacePath: selection.workspacePath },
22112
+ ...selection.sessionId === void 0 ? {} : { sessionId: selection.sessionId }
22113
+ };
22114
+ }
22115
+ /**
22116
+ * Put the session the user was watching back in place on the current link.
22117
+ *
22118
+ * The one step every recovery path ends with, whichever way the link came back: the retry schedule, the
22119
+ * quick window, or a boot restore. A Codex target rebuilds the Codex carrier - never the peer's Harness
22120
+ * workspace - and a Harness target republishes its workspace selection so the native UI re-reads its list.
22121
+ * @param target - the session to restore.
22122
+ * @param fallbackToLocal - whether a failed Codex rebuild may hand the view to the local shell. The quick
22123
+ * window passes false: its whole point is to keep the user looking at the session while it is rescued.
22124
+ * @returns true when the user is looking at that session again.
22125
+ */
22126
+ async restoreSession(target2, options = {}) {
22127
+ if (target2.kind === "codex") {
22128
+ const restored = await this.restoreCodexCarrier(target2, options.fallbackToLocal !== false);
22129
+ if (restored) {
22130
+ this.logger.info("remote Codex session restored", {
22131
+ targetDeviceId: shortId2(target2.deviceId),
22132
+ kind: "codex",
22133
+ path: target2.workspacePath ?? "(unknown)"
22134
+ });
22135
+ }
22136
+ return restored;
22137
+ }
22138
+ this.restoreWorkspaceSelection(target2.deviceId);
22139
+ this.logger.info("remote DSH session restored", {
22140
+ targetDeviceId: shortId2(target2.deviceId),
22141
+ kind: "harness"
22142
+ });
22143
+ return true;
22144
+ }
22145
+ /**
22146
+ * Clear the recovery flag once the user is back on the session they were watching.
22147
+ *
22148
+ * The header reads this flag to show "reconnecting", so it must be cleared exactly when the recovery
22149
+ * succeeded - not when a transport happened to come back, and not while a Codex workspace is still missing.
22150
+ * @param targetDeviceId - the Host the recovery was about.
22151
+ * @param reason - what brought the session back, for the log.
22152
+ * @param kind - which carrier was restored.
22153
+ */
22154
+ finishRecovery(targetDeviceId, reason, kind) {
22155
+ this.reconnecting = void 0;
22156
+ this.fellBackToLocal = false;
22157
+ this.logger.info("remote session recovered", {
22158
+ targetDeviceId: shortId2(targetDeviceId),
22159
+ kind,
22160
+ reason
22161
+ });
21954
22162
  }
21955
22163
  /**
21956
22164
  * Republish the workspace selection so the native UI re-reads its remote session list.
@@ -21968,12 +22176,110 @@ var ClientModeRuntime = class {
21968
22176
  workspaceId: selection.workspaceId
21969
22177
  });
21970
22178
  }
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 });
22179
+ /**
22180
+ * Put the Codex carrier back onto the link that just replaced the old one.
22181
+ *
22182
+ * A reconnect leaves the carrier holding a transfer channel bound to a transport that no longer exists, and the
22183
+ * first use fails with "remote transport closed" while the UI still looks connected - the reported symptom:
22184
+ * Codex sessions vanish and clicking one errors. Rebuilding the carrier is what lets an open Codex workspace
22185
+ * survive a reconnect.
22186
+ *
22187
+ * The workspace is matched by directory, not by id: a Host that replaced its Codex domain renumbers the very
22188
+ * same directories, so an id that misses is retried once by root before the workspace is given up on.
22189
+ * @param target - the Codex session to restore.
22190
+ * @param fallbackToLocal - whether a failure may hand the view to the client's own shell. The quick window
22191
+ * passes false so the user keeps looking at the session for the whole window.
22192
+ * @returns true when the Codex carrier is back on the given workspace.
22193
+ */
22194
+ async restoreCodexCarrier(target2, fallbackToLocal = true) {
22195
+ try {
22196
+ try {
22197
+ await this.openCodexWorkspace(target2.deviceId, target2.workspaceId);
22198
+ } catch (error) {
22199
+ const root = target2.workspacePath;
22200
+ if (root === void 0) throw error;
22201
+ const recovered = await this.codexWorkspaceIdForPath(target2.deviceId, root);
22202
+ if (recovered === void 0) throw error;
22203
+ if (recovered !== target2.workspaceId) {
22204
+ this.logger.info("CodeX workspace restored under a new id", {
22205
+ targetDeviceId: shortId2(target2.deviceId),
22206
+ path: root
22207
+ });
22208
+ }
22209
+ await this.openCodexWorkspace(target2.deviceId, recovered);
22210
+ }
22211
+ this.restoreWorkspaceSelection(target2.deviceId);
22212
+ return true;
22213
+ } catch (error) {
22214
+ this.logger.warn("CodeX workspace could not be restored", {
22215
+ targetDeviceId: shortId2(target2.deviceId),
22216
+ code: failureCode(error),
22217
+ path: target2.workspacePath ?? "(unknown)",
22218
+ ...connectionFailureDetail(error)
22219
+ });
22220
+ if (fallbackToLocal) await this.fallBackToLocalForCodex();
22221
+ return false;
22222
+ }
22223
+ }
22224
+ /**
22225
+ * The id the Host currently uses for a workspace root.
22226
+ *
22227
+ * @param targetDeviceId - the Host to ask.
22228
+ * @param path - the workspace root as it was remembered.
22229
+ * @returns the current workspace id, or undefined when the Host no longer offers that root.
22230
+ */
22231
+ async codexWorkspaceIdForPath(targetDeviceId, path) {
22232
+ const workspaces = await this.listCodexWorkspaces(targetDeviceId);
22233
+ const match = workspaces.find((candidate) => sameWorkspaceRoot(candidate.path, path));
22234
+ return match?.workspaceId;
22235
+ }
22236
+ /**
22237
+ * Show the client's own shell while a Codex session is unavailable.
22238
+ *
22239
+ * The peer's other carrier must not stand in for the Codex workspace that was lost, and this deliberately does not
22240
+ * touch the reconnect run counter: the retry schedule keeps trying to restore the Codex workspace while the user
22241
+ * works locally.
22242
+ * @returns nothing.
22243
+ */
22244
+ async fallBackToLocalForCodex() {
22245
+ await this.closeCodexVirtual();
22246
+ this.proxySwitch?.selectLocal();
22247
+ this.gatewaySwitch.selectLocal();
22248
+ this.fellBackToLocal = true;
22249
+ }
22250
+ /**
22251
+ * The Codex session is gone while the transport is still up (the Host replaced or switched off its Codex
22252
+ * domain). Deliberately the same entry point as a lost link, so both carriers take one path: quick reconnect,
22253
+ * local fallback and the retry schedule.
22254
+ *
22255
+ * The trigger is logged, because "the banner did not appear" is otherwise indistinguishable between "no
22256
+ * signal arrived" and "the signal arrived but the UI did not show it".
22257
+ * @param trigger - what noticed it.
22258
+ * @returns nothing; the reconnect runs in the background.
22259
+ */
22260
+ noteCodexSessionLost(trigger) {
22261
+ const connected = this.connected;
22262
+ if (connected === void 0 || this.reconnecting !== void 0) {
22263
+ this.logger.info("Codex session loss ignored", {
22264
+ targetDeviceId: connected === void 0 ? void 0 : shortId2(connected.target.deviceId),
22265
+ trigger,
22266
+ reason: connected === void 0 ? "not-connected" : "recovery-already-running"
22267
+ });
22268
+ return;
22269
+ }
22270
+ if (this.sessionTargetFor(connected.target.deviceId)?.kind !== "codex") {
22271
+ this.logger.info("Codex session loss ignored", {
22272
+ targetDeviceId: shortId2(connected.target.deviceId),
22273
+ trigger,
22274
+ reason: "watching-another-session"
22275
+ });
22276
+ return;
22277
+ }
22278
+ this.logger.warn("remote Codex session lost", {
22279
+ targetDeviceId: shortId2(connected.target.deviceId),
22280
+ trigger
22281
+ });
22282
+ void this.handleRemoteTransportLost(connected.client, connected.target.deviceId, "codex-closed");
21977
22283
  }
21978
22284
  /**
21979
22285
  * Check the live session now, outside the cadence.
@@ -21986,34 +22292,56 @@ var ClientModeRuntime = class {
21986
22292
  return this.status();
21987
22293
  }
21988
22294
  /**
21989
- * Rebuild the link inside one quick window, keeping the user's view.
22295
+ * Re-read the connected Host's capabilities.
21990
22296
  *
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.
22297
+ * A Host that switches Codex (or the terminal) on or off changes what it advertises, and the cached feature
22298
+ * set used to be refreshed only when a workspace list happened to be requested - which is why the Remote card
22299
+ * could describe a Host it no longer matched. One round trip, no business payload, so the card can poll it.
22300
+ * @returns the connected Host's fresh feature set, or `{ connected: false }`.
21996
22301
  */
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);
22302
+ async probeRemoteFeatures() {
22303
+ const remote = this.connected;
22304
+ if (remote === void 0) return { connected: false };
22305
+ try {
22306
+ remote.features = await probeRemoteHostFeatures(remote.client, remote.clientVersion);
22307
+ } catch (error) {
22308
+ const code = failureCode(error);
22309
+ this.logger.warn("remote capability probe failed", {
22310
+ targetDeviceId: shortId2(remote.target.deviceId),
22311
+ code
22013
22312
  });
22313
+ return { connected: true, targetDeviceId: remote.target.deviceId, features: remote.features, error: code };
22014
22314
  }
22015
- this.reconnecting = void 0;
22016
- return false;
22315
+ return { connected: true, targetDeviceId: remote.target.deviceId, features: remote.features };
22316
+ }
22317
+ /**
22318
+ * Re-establish the session the user is watching, without changing which session that is.
22319
+ *
22320
+ * What the Remote card's Refresh does for a live session: the transport may have died, the peer's Codex may
22321
+ * have come back, or a previous recovery may have given up. This never selects another Host and never another
22322
+ * kind of workspace - the remembered selection is the only thing it works from.
22323
+ * @returns the status after the attempt.
22324
+ */
22325
+ async recoverCurrentSession() {
22326
+ const selection = this.lastWorkspaceSelection;
22327
+ if (selection === void 0) return this.status();
22328
+ if (this.reconnecting !== void 0) return this.status();
22329
+ const target2 = this.sessionTargetFor(selection.targetDeviceId);
22330
+ const connected = this.connected;
22331
+ if (connected === void 0) {
22332
+ this.reconnecting = { targetDeviceId: selection.targetDeviceId, phase: "fallback" };
22333
+ void this.reconnectRemoteSession(selection.targetDeviceId, target2);
22334
+ return this.status();
22335
+ }
22336
+ if (connected.target.deviceId !== selection.targetDeviceId) return this.status();
22337
+ const probe = await this.probeRemoteFeatures();
22338
+ if (probe.connected === true && probe.error === void 0) {
22339
+ if (target2 !== void 0) await this.restoreSession(target2);
22340
+ return this.status();
22341
+ }
22342
+ if (probe.error === void 0) return this.status();
22343
+ await this.handleRemoteTransportLost(connected.client, connected.target.deviceId, "manual");
22344
+ return this.status();
22017
22345
  }
22018
22346
  /**
22019
22347
  * Shared cleanup for a session whose transport is gone.
@@ -22025,83 +22353,255 @@ var ClientModeRuntime = class {
22025
22353
  * @param client - the client that was connected.
22026
22354
  * @param targetDeviceId - the Host it was bound to.
22027
22355
  */
22356
+ /**
22357
+ * A user's explicit workspace choice supersedes a recovery that is still in flight.
22358
+ *
22359
+ * The retry schedule belongs to the session the user was watching. When they pick another workspace, that
22360
+ * schedule must not land later and move the view again: the reported failure was a Codex ladder restoring its
22361
+ * own workspace over the DSH workspace the user had just opened - "the user's own choice is the only thing
22362
+ * that changes which session is on screen" was broken by a background timer. Only the panel's open endpoints
22363
+ * call this; the recovery restores through the internal open paths, so it never cancels itself.
22364
+ */
22365
+ supersedeRemoteRecovery() {
22366
+ if (this.reconnecting === void 0 && !this.fellBackToLocal) return;
22367
+ this.logger.info("a workspace was opened; stopping the recovery in flight", {
22368
+ phase: this.reconnecting?.phase ?? "fallback"
22369
+ });
22370
+ this.reconnecting = void 0;
22371
+ this.fellBackToLocal = false;
22372
+ this.remoteReconnectRun += 1;
22373
+ }
22374
+ /**
22375
+ * Whether the user still wants the session a recovery is putting back.
22376
+ *
22377
+ * The user's own choice is the record, not a counter: cancelling clears `lastWorkspaceSelection` (and writes
22378
+ * `local` to the target file), and opening another workspace points the selection somewhere else. A recovery is
22379
+ * work the user asked for *earlier*, so it may only adopt its result while that is still what they want. The
22380
+ * reported failure was an attempt that landed after the answer - it reconnected and put the session back on
22381
+ * screen, because nothing re-read the user's intent once the attempt was in flight.
22382
+ * @param targetDeviceId - the Host the recovery is restoring.
22383
+ * @returns true while the remembered target is still that Host.
22384
+ */
22385
+ recoveryStillWanted(targetDeviceId) {
22386
+ if (this.reconnecting === void 0) return false;
22387
+ return this.lastWorkspaceSelection?.targetDeviceId === targetDeviceId;
22388
+ }
22389
+ /**
22390
+ * Undo a recovery the user answered while it was landing: their choice wins over work already in flight.
22391
+ * @param targetDeviceId - the Host the recovery was restoring.
22392
+ */
22393
+ async abandonRecovery(targetDeviceId) {
22394
+ this.logger.info("the user answered while the recovery was landing; returning to the local shell", {
22395
+ targetDeviceId: shortId2(targetDeviceId)
22396
+ });
22397
+ await this.setMode("local");
22398
+ }
22028
22399
  async handleRemoteTransportLost(client, targetDeviceId, reason) {
22400
+ const browsing = this.browsing?.client === client ? this.browsing : void 0;
22401
+ if (browsing !== void 0 && this.connected?.client !== client) {
22402
+ this.browsing = void 0;
22403
+ this.clearConnectionProgress(browsing.progressRunId);
22404
+ this.logger.info("browsing connection closed; the panel will open a new one", {
22405
+ targetDeviceId: shortId2(targetDeviceId),
22406
+ reason
22407
+ });
22408
+ void browsing.client.close().catch(() => void 0);
22409
+ return;
22410
+ }
22029
22411
  if (this.connected?.client !== client) return;
22030
22412
  this.supersededClient = void 0;
22031
22413
  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)
22414
+ const target2 = this.sessionTargetFor(targetDeviceId);
22415
+ const kind = target2?.kind ?? "harness";
22416
+ if (target2 === void 0) {
22417
+ this.logger.info("connection closed with no remote session to recover; staying local", {
22418
+ targetDeviceId: shortId2(targetDeviceId),
22419
+ reason
22035
22420
  });
22421
+ const previous = this.connected;
22422
+ this.connected = void 0;
22423
+ this.connectionProgress = void 0;
22424
+ await this.closeCodexStreams(previous?.client);
22425
+ await previous?.client.close().catch(() => void 0);
22036
22426
  return;
22037
22427
  }
22428
+ const transportLost = reason !== "codex-closed";
22429
+ if (this.reconnecting === void 0) {
22430
+ const run = this.remoteReconnectRun;
22431
+ this.reconnecting = { targetDeviceId, ...targetName === void 0 ? {} : { targetName }, phase: "fast" };
22432
+ this.logger.warn("remote session lost; trying the quick window", {
22433
+ targetDeviceId: shortId2(targetDeviceId),
22434
+ kind,
22435
+ reason,
22436
+ attempts: QUICK_RECONNECT_ATTEMPTS,
22437
+ windowMs: QUICK_RECONNECT_WINDOW_MS
22438
+ });
22439
+ const rescued = await this.quickReconnectSession(targetDeviceId, target2, transportLost, run);
22440
+ if (run !== this.remoteReconnectRun) {
22441
+ this.logger.info("the user answered during the quick window; the recovery stops here", {
22442
+ targetDeviceId: shortId2(targetDeviceId),
22443
+ kind
22444
+ });
22445
+ return;
22446
+ }
22447
+ if (rescued) {
22448
+ this.finishRecovery(targetDeviceId, "quick-window", kind);
22449
+ this.logger.info("remote session kept its view through a quick reconnect", {
22450
+ targetDeviceId: shortId2(targetDeviceId),
22451
+ kind
22452
+ });
22453
+ return;
22454
+ }
22455
+ }
22456
+ await this.fallBackToLocalShell(targetDeviceId, targetName, reason, kind, transportLost);
22457
+ void this.reconnectRemoteSession(targetDeviceId, target2);
22458
+ }
22459
+ /**
22460
+ * The quick window: rescue the same session without disturbing the view.
22461
+ *
22462
+ * At most {@link QUICK_RECONNECT_ATTEMPTS} attempts inside {@link QUICK_RECONNECT_WINDOW_MS}, spaced by
22463
+ * {@link QUICK_RECONNECT_RETRY_DELAY_MS}. Nothing here falls back to the local shell or republishes a
22464
+ * workspace - the user keeps looking at the session they were in, and the caller decides what happens once the
22465
+ * window is spent. A lost transport is rebuilt first, over relay only (the one path measured under five
22466
+ * seconds); a Codex-only loss already has its link, so its attempts only rebuild the carrier.
22467
+ * @param targetDeviceId - the Host the session was bound to.
22468
+ * @param target - the session to put back.
22469
+ * @param transportLost - whether the link itself has to be rebuilt.
22470
+ * @param run - the recovery run this window belongs to; the user answering bumps it.
22471
+ * @returns true when the session is on screen again.
22472
+ */
22473
+ async quickReconnectSession(targetDeviceId, target2, transportLost, run) {
22474
+ let linkUp = !transportLost;
22475
+ const deadline = Date.now() + QUICK_RECONNECT_WINDOW_MS;
22476
+ for (let attempt = 1; attempt <= QUICK_RECONNECT_ATTEMPTS; attempt += 1) {
22477
+ if (!this.recoveryStillWanted(targetDeviceId) || run !== this.remoteReconnectRun) return false;
22478
+ if (!linkUp) linkUp = await this.reestablish(targetDeviceId, QUICK_RECONNECT_FORCE_RELAY);
22479
+ if (!this.recoveryStillWanted(targetDeviceId) || run !== this.remoteReconnectRun) return false;
22480
+ if (linkUp && await this.restoreSession(target2, { fallbackToLocal: false })) {
22481
+ if (!this.recoveryStillWanted(targetDeviceId) || run !== this.remoteReconnectRun) {
22482
+ await this.abandonRecovery(targetDeviceId);
22483
+ return false;
22484
+ }
22485
+ return true;
22486
+ }
22487
+ if (attempt === QUICK_RECONNECT_ATTEMPTS) break;
22488
+ const wait2 = Math.min(QUICK_RECONNECT_RETRY_DELAY_MS, Math.max(0, deadline - Date.now()));
22489
+ if (wait2 === 0) break;
22490
+ await sleep3(wait2);
22491
+ }
22492
+ if (!this.recoveryStillWanted(targetDeviceId) || run !== this.remoteReconnectRun) return false;
22493
+ const remaining = deadline - Date.now();
22494
+ if (remaining > 0) await sleep3(remaining);
22495
+ this.reconnecting = void 0;
22496
+ return false;
22497
+ }
22498
+ /**
22499
+ * Hand the session back to the client's own shell.
22500
+ *
22501
+ * Phase two of the ladder, and the same for every kind of loss. A link that broke is closed here; one that is
22502
+ * still up (a Codex-only loss) is kept, because the retry schedule needs it and dropping a healthy link only to
22503
+ * open it again five seconds later is churn the user pays for. Switching the carriers to local is what makes
22504
+ * the view fall back - the point of this step.
22505
+ * @param targetDeviceId - the Host the session was bound to.
22506
+ * @param targetName - its display name, kept for the header while the reconnect runs.
22507
+ * @param reason - why the session was lost, for the log.
22508
+ * @param kind - which carrier was lost.
22509
+ * @param transportLost - whether the link itself is gone.
22510
+ */
22511
+ async fallBackToLocalShell(targetDeviceId, targetName, reason, kind, transportLost) {
22038
22512
  void this.closePreview();
22039
- this.connected = void 0;
22040
22513
  this.connectionProgress = void 0;
22041
22514
  this.pendingWorkspaceSelection = void 0;
22042
- void this.closeCodexVirtual();
22515
+ await this.closeCodexVirtual();
22043
22516
  this.proxySwitch?.selectLocal();
22044
22517
  this.gatewaySwitch.selectLocal();
22045
22518
  this.fellBackToLocal = true;
22046
22519
  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", {
22520
+ if (transportLost) {
22521
+ const previous = this.connected;
22522
+ this.connected = void 0;
22523
+ await this.closeCodexStreams(previous?.client);
22524
+ void previous?.client.close().catch(() => void 0);
22525
+ }
22526
+ this.logger.warn("remote session lost; falling back to the local shell and retrying", {
22049
22527
  targetDeviceId: shortId2(targetDeviceId),
22528
+ kind,
22050
22529
  reason
22051
22530
  });
22052
- void this.reconnectRemoteSession(targetDeviceId);
22053
22531
  }
22054
22532
  /**
22055
- * Re-establish a remote session whose transport closed.
22533
+ * The background retry ladder, shared by every way a session can be recovered.
22056
22534
  *
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.
22535
+ * It runs after the quick window has been spent (the caller has already fallen back to the local shell), or
22536
+ * straight away for a boot restore, which has no window behind it. Every attempt targets the same session; on
22537
+ * success the workspace and the session data are re-read, and when the ladder is spent the reconnect flag is
22538
+ * cleared so the user is simply local. A loop that is superseded, or one whose session came back another way,
22539
+ * stops quietly.
22540
+ * @param targetDeviceId - the Host the lost session was bound to.
22541
+ * @param target - the session to put back.
22542
+ * @param immediateFirstAttempt - true for a boot restore, which should not wait five seconds to start.
22063
22543
  */
22064
- async reconnectRemoteSession(targetDeviceId) {
22544
+ async reconnectRemoteSession(targetDeviceId, target2 = this.sessionTargetFor(targetDeviceId), immediateFirstAttempt = false) {
22065
22545
  const run = ++this.remoteReconnectRun;
22066
- const delays = [0, 5e3, 5e3, 1e4, 2e4];
22546
+ const kind = target2?.kind ?? "harness";
22547
+ if (target2 === void 0) {
22548
+ this.reconnecting = void 0;
22549
+ this.fellBackToLocal = false;
22550
+ this.logger.info("no remote session to restore; staying in the local shell", {
22551
+ targetDeviceId: shortId2(targetDeviceId)
22552
+ });
22553
+ return;
22554
+ }
22555
+ const delays = immediateFirstAttempt ? [0, ...BACKGROUND_RETRY_DELAYS_MS] : BACKGROUND_RETRY_DELAYS_MS;
22067
22556
  let attempt = 0;
22068
22557
  for (; ; ) {
22069
- if (attempt - delays.length >= FALLBACK_STEADY_ATTEMPTS) {
22558
+ if (attempt >= delays.length) {
22070
22559
  this.reconnecting = void 0;
22071
- this.logger.warn("remote Harness reconnect gave up; staying in the local shell", {
22560
+ this.logger.warn("remote session recovery gave up; staying in the local shell", {
22072
22561
  targetDeviceId: shortId2(targetDeviceId),
22562
+ kind,
22073
22563
  attempts: attempt
22074
22564
  });
22075
22565
  return;
22076
22566
  }
22077
- const wait2 = delays[attempt] ?? 3e4;
22078
- if (wait2 > 0) await new Promise((resolve4) => {
22079
- setTimeout(resolve4, wait2);
22080
- });
22567
+ const wait2 = delays[attempt] ?? BACKGROUND_RETRY_DELAYS_MS[BACKGROUND_RETRY_DELAYS_MS.length - 1];
22568
+ if (wait2 > 0) await sleep3(wait2);
22081
22569
  attempt += 1;
22082
22570
  if (run !== this.remoteReconnectRun) return;
22083
- if (this.connected !== void 0) return;
22571
+ const reconnecting = this.reconnecting;
22572
+ if (reconnecting === void 0) return;
22084
22573
  try {
22085
- await this.setMode("remote", targetDeviceId);
22086
- this.restoreWorkspaceSelection(targetDeviceId);
22087
- this.logger.info("remote Harness session reconnected", { targetDeviceId: shortId2(targetDeviceId) });
22574
+ if (this.connected?.target.deviceId !== targetDeviceId) {
22575
+ await this.setMode("remote", targetDeviceId);
22576
+ this.reconnecting = reconnecting;
22577
+ }
22578
+ if (target2 !== void 0 && !await this.restoreSession(target2)) continue;
22579
+ if (!this.recoveryStillWanted(targetDeviceId)) {
22580
+ await this.abandonRecovery(targetDeviceId);
22581
+ return;
22582
+ }
22583
+ this.finishRecovery(targetDeviceId, "retry-schedule", kind);
22088
22584
  return;
22089
22585
  } 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", {
22586
+ const code = failureCode(error);
22587
+ if (CREDENTIAL_FAILURE_CODES.has(code)) {
22588
+ this.reconnecting = void 0;
22589
+ this.logger.warn("remote session recovery stopped: authorization is required", {
22093
22590
  targetDeviceId: shortId2(targetDeviceId),
22591
+ kind,
22094
22592
  code
22095
22593
  });
22096
- this.reconnecting = void 0;
22097
22594
  return;
22098
22595
  }
22099
22596
  if (attempt < 3 || attempt % 10 === 0) {
22100
- this.logger.warn("remote Harness reconnect attempt failed", {
22597
+ const detail = connectionFailureDetail(error);
22598
+ const systemCode = typeof detail.systemCode === "string" ? detail.systemCode : void 0;
22599
+ this.logger.warn("remote session recovery attempt failed", {
22101
22600
  targetDeviceId: shortId2(targetDeviceId),
22601
+ kind,
22102
22602
  attempt,
22103
- code,
22104
- ...connectionFailureDetail(error)
22603
+ code: systemCode ?? code,
22604
+ ...detail
22105
22605
  });
22106
22606
  }
22107
22607
  }
@@ -22117,10 +22617,19 @@ var ClientModeRuntime = class {
22117
22617
  */
22118
22618
  async rememberTarget(target2) {
22119
22619
  if (this.targetStore === void 0) return;
22620
+ const selection = target2.mode === "remote" && target2.hostDeviceId !== void 0 ? this.sessionTargetFor(target2.hostDeviceId) : void 0;
22120
22621
  try {
22121
22622
  await this.targetStore.save({
22122
22623
  ...target2,
22123
- ...this.config.serverUrl === void 0 ? {} : { serverUrl: this.config.serverUrl }
22624
+ ...this.config.serverUrl === void 0 ? {} : { serverUrl: this.config.serverUrl },
22625
+ ...selection === void 0 ? {} : {
22626
+ workspace: {
22627
+ workspaceId: selection.workspaceId,
22628
+ kind: selection.kind,
22629
+ ...selection.workspacePath === void 0 ? {} : { workspacePath: selection.workspacePath },
22630
+ ...selection.sessionId === void 0 ? {} : { sessionId: selection.sessionId }
22631
+ }
22632
+ }
22124
22633
  });
22125
22634
  } catch (error) {
22126
22635
  this.logger.warn("could not record the remote target", { code: safeErrorCode(error) });
@@ -22146,19 +22655,33 @@ var ClientModeRuntime = class {
22146
22655
  });
22147
22656
  return false;
22148
22657
  }
22658
+ if (record7.workspace === void 0) {
22659
+ this.logger.info("the recorded target has no workspace; staying in the local shell", {
22660
+ targetDeviceId: shortId2(record7.hostDeviceId)
22661
+ });
22662
+ return false;
22663
+ }
22149
22664
  this.reconnecting = {
22150
22665
  targetDeviceId: record7.hostDeviceId,
22151
22666
  ...record7.hostName === void 0 ? {} : { targetName: record7.hostName },
22152
22667
  phase: "restore"
22153
22668
  };
22669
+ this.lastWorkspaceSelection = {
22670
+ targetDeviceId: record7.hostDeviceId,
22671
+ workspaceId: record7.workspace.workspaceId,
22672
+ ...record7.workspace.kind === "codex" ? { backend: "codex" } : {},
22673
+ ...record7.workspace.workspacePath === void 0 ? {} : { workspacePath: record7.workspace.workspacePath },
22674
+ ...record7.workspace.sessionId === void 0 ? {} : { sessionId: record7.workspace.sessionId }
22675
+ };
22154
22676
  this.logger.info("restoring the remote target of the previous run", {
22155
- targetDeviceId: shortId2(record7.hostDeviceId)
22677
+ targetDeviceId: shortId2(record7.hostDeviceId),
22678
+ kind: record7.workspace.kind
22156
22679
  });
22157
- void this.reconnectRemoteSession(record7.hostDeviceId);
22680
+ void this.reconnectRemoteSession(record7.hostDeviceId, this.sessionTargetFor(record7.hostDeviceId), true);
22158
22681
  return true;
22159
22682
  }
22160
22683
  async listRemoteDirectory(targetDeviceId, path, signal) {
22161
- const remote = await this.ensureConnected(targetDeviceId, signal);
22684
+ const remote = await this.ensureBrowsing(targetDeviceId, signal);
22162
22685
  if (remote.features.remoteGateway) {
22163
22686
  const value = await new RemoteTypertGateway2(remote.client).invoke({
22164
22687
  namespace: "directoryPicker",
@@ -22176,7 +22699,7 @@ var ClientModeRuntime = class {
22176
22699
  return unwrapNativeResult(response);
22177
22700
  }
22178
22701
  async listRemoteWorkspaces(targetDeviceId, signal) {
22179
- const remote = await this.ensureConnected(targetDeviceId, signal);
22702
+ const remote = await this.ensureBrowsing(targetDeviceId, signal);
22180
22703
  if (remote.features.remoteGateway) {
22181
22704
  return readRemoteWorkspaceBaseline(new RemoteTypertGateway2(remote.client), signal);
22182
22705
  }
@@ -22215,31 +22738,108 @@ var ClientModeRuntime = class {
22215
22738
  this.logger.info("Remote workspace opened", { targetDeviceId: shortId2(remote.target.deviceId) });
22216
22739
  return { ...this.status(), workspace };
22217
22740
  }
22741
+ /**
22742
+ * The refusal to report when a Host is not offering Codex right now.
22743
+ *
22744
+ * Two different facts hid behind one generic code, so the panel could not tell a user who had switched Codex
22745
+ * off from a Host that never had it. Which one it is comes from this client's own memory: a Host whose Codex
22746
+ * workspace was opened before has Codex, so its absence now means it was switched off (or is still starting);
22747
+ * a Host that never offered one simply does not provide it.
22748
+ *
22749
+ * A Codex that disappeared is also a lost session, so this hands it to the same recovery every other loss
22750
+ * uses before reporting it - a switched-off Codex that is switched back on is then restored on its own.
22751
+ * @param targetDeviceId - the Host the caller was talking to.
22752
+ * @returns the error to throw.
22753
+ */
22754
+ remoteCodexUnavailable(targetDeviceId) {
22755
+ const expected = shouldRestoreCodexCarrier(this.lastWorkspaceSelection, targetDeviceId) || this.codexHostsSeen.has(targetDeviceId);
22756
+ if (expected) this.noteCodexSessionLost("capability-missing");
22757
+ 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.");
22758
+ }
22218
22759
  async listCodexWorkspaces(targetDeviceId, signal) {
22219
- const remote = await this.ensureConnected(targetDeviceId, signal);
22760
+ const remote = await this.ensureBrowsing(targetDeviceId, signal);
22220
22761
  remote.features = await probeRemoteHostFeatures(remote.client, remote.clientVersion);
22221
22762
  if (!remote.features.codex) {
22222
- throw new ClientModeError("FEATURE_NOT_SUPPORTED", "The selected Host does not provide CodeX workspaces.");
22763
+ throw this.remoteCodexUnavailable(targetDeviceId);
22223
22764
  }
22224
- return discoverCodexVirtualWorkspaces(new CodexRemoteClient(remote.client), signal);
22765
+ let workspaces;
22766
+ try {
22767
+ workspaces = await discoverCodexVirtualWorkspaces(new CodexRemoteClient(remote.client), signal);
22768
+ } catch (error) {
22769
+ this.logger.warn("Codex workspaces could not be listed", {
22770
+ targetDeviceId: shortId2(targetDeviceId),
22771
+ code: failureCode(error)
22772
+ });
22773
+ if (isCodexSessionLoss(error)) this.noteCodexSessionLost("list-failed");
22774
+ throw error;
22775
+ }
22776
+ this.lastCodexListing.set(targetDeviceId, workspaces);
22777
+ for (const item of workspaces) {
22778
+ this.codexWorkspaceRoots.set(`${targetDeviceId}|${item.workspaceId}`, item.path);
22779
+ }
22780
+ this.logger.info("Codex workspaces listed", {
22781
+ targetDeviceId: shortId2(targetDeviceId),
22782
+ count: workspaces.length
22783
+ });
22784
+ return workspaces;
22225
22785
  }
22226
22786
  async openCodexWorkspace(targetDeviceId, workspaceId, signal) {
22227
22787
  const remote = await this.ensureConnected(targetDeviceId, signal);
22228
22788
  remote.features = await probeRemoteHostFeatures(remote.client, remote.clientVersion);
22229
22789
  if (!remote.features.codex) {
22230
- throw new ClientModeError("FEATURE_NOT_SUPPORTED", "The selected Host does not provide CodeX workspaces.");
22790
+ throw this.remoteCodexUnavailable(targetDeviceId);
22231
22791
  }
22232
22792
  this.assertLocalHarnessCarrierAvailable();
22233
22793
  const virtual = CodexVirtualHarness.remote(remote.client, {
22234
22794
  deviceId: remote.target.deviceId,
22235
22795
  name: remote.target.name
22236
22796
  }, harnessSessionGeneration(this.host?.localHarnessVersion?.()), new RemoteTypertGateway2(remote.client));
22797
+ virtual.watchCodexSessionLost((trigger) => this.noteCodexSessionLost(trigger));
22237
22798
  let workspace;
22238
22799
  try {
22239
22800
  workspace = await virtual.selectWorkspace(workspaceId, signal);
22240
- } catch {
22801
+ } catch (error) {
22802
+ const code = safeErrorCode(error);
22803
+ const listed = this.lastCodexListing.get(targetDeviceId);
22804
+ const previousRoot = listed?.find((item) => item.workspaceId === workspaceId)?.path ?? this.codexWorkspaceRoots.get(`${targetDeviceId}|${workspaceId}`) ?? this.lastWorkspaceSelection?.workspacePath;
22805
+ const inListing = listed?.some((item) => item.workspaceId === workspaceId) ?? false;
22806
+ if (previousRoot !== void 0) {
22807
+ const byRoot = await virtual.selectWorkspace(workspaceId, signal, previousRoot).catch(() => void 0);
22808
+ if (byRoot !== void 0) {
22809
+ this.logger.info("CodeX workspace opened by root after the id missed", {
22810
+ targetDeviceId: shortId2(targetDeviceId),
22811
+ path: previousRoot
22812
+ });
22813
+ await virtual.close();
22814
+ return this.openCodexWorkspace(targetDeviceId, byRoot.workspaceId, signal);
22815
+ }
22816
+ }
22241
22817
  await virtual.close();
22242
- throw new ClientModeError("WORKSPACE_NOT_FOUND", "The selected CodeX workspace is no longer available.");
22818
+ let currentId;
22819
+ if (previousRoot !== void 0) {
22820
+ currentId = await this.codexWorkspaceIdForPath(targetDeviceId, previousRoot).catch(() => void 0);
22821
+ }
22822
+ if (currentId !== void 0 && currentId !== workspaceId) {
22823
+ this.logger.info("CodeX workspace reopened under a new id", {
22824
+ targetDeviceId: shortId2(targetDeviceId),
22825
+ path: previousRoot
22826
+ });
22827
+ return this.openCodexWorkspace(targetDeviceId, currentId, signal);
22828
+ }
22829
+ const currentListing = this.lastCodexListing.get(targetDeviceId) ?? [];
22830
+ this.logger.warn("selected CodeX workspace could not be opened", {
22831
+ targetDeviceId: shortId2(targetDeviceId),
22832
+ code,
22833
+ inListing,
22834
+ hasRoot: previousRoot !== void 0,
22835
+ roots: [...this.codexWorkspaceRoots.keys()].filter((key) => key.startsWith(`${targetDeviceId}|`)).length,
22836
+ listed: listed?.length ?? -1,
22837
+ // What was asked for against what the Host currently offers, so the next report needs no second round trip.
22838
+ wanted: workspaceId.slice(0, 60),
22839
+ root: previousRoot === void 0 ? "(none)" : previousRoot,
22840
+ current: currentListing.slice(0, 5).map((item) => item.workspaceId + "@" + item.path).join(", ")
22841
+ });
22842
+ throw error instanceof ClientModeError ? error : new ClientModeError("WORKSPACE_NOT_FOUND", "The selected CodeX workspace is no longer available.");
22243
22843
  }
22244
22844
  await this.closeCodexVirtual();
22245
22845
  this.codexVirtual = virtual;
@@ -22249,25 +22849,37 @@ var ClientModeRuntime = class {
22249
22849
  targetDeviceId: remote.target.deviceId,
22250
22850
  workspaceId,
22251
22851
  backend: "codex",
22852
+ ...workspace.path === void 0 ? {} : { workspacePath: workspace.path },
22252
22853
  ...preferredSessionId === void 0 ? {} : { sessionId: preferredSessionId }
22253
22854
  });
22855
+ this.codexHostsSeen.add(remote.target.deviceId);
22254
22856
  this.logger.info("CodeX virtual workspace opened", { targetDeviceId: shortId2(remote.target.deviceId) });
22255
22857
  return { ...this.status(), workspace };
22256
22858
  }
22257
22859
  async createCodexWorkspace(targetDeviceId, path, signal) {
22258
22860
  const trimmedPath = path.trim();
22259
22861
  if (trimmedPath === "") throw new ClientModeError("INVALID_MESSAGE", "A CodeX project directory is required.");
22260
- const remote = await this.ensureConnected(targetDeviceId, signal);
22862
+ const remote = await this.ensureBrowsing(targetDeviceId, signal);
22261
22863
  remote.features = await probeRemoteHostFeatures(remote.client, remote.clientVersion);
22262
22864
  if (!remote.features.codex) {
22263
- throw new ClientModeError("FEATURE_NOT_SUPPORTED", "The selected Host does not provide CodeX workspaces.");
22865
+ throw this.remoteCodexUnavailable(targetDeviceId);
22264
22866
  }
22265
22867
  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));
22868
+ let result;
22869
+ try {
22870
+ result = record4(await new CodexRemoteClient(remote.client).request("project/create", {
22871
+ name: remoteWorkspaceTitle(trimmedPath),
22872
+ roots: [{ path: trimmedPath }],
22873
+ idempotencyKey: uuidV7()
22874
+ }, signal));
22875
+ } catch (error) {
22876
+ this.logger.warn("CodeX project could not be registered", {
22877
+ targetDeviceId: shortId2(targetDeviceId),
22878
+ code: failureCode(error)
22879
+ });
22880
+ if (isCodexSessionLoss(error)) this.noteCodexSessionLost("project-create-failed");
22881
+ throw error;
22882
+ }
22271
22883
  const project = record4(result.project);
22272
22884
  if (typeof project.id !== "string" || project.id.length === 0) {
22273
22885
  throw new ClientModeError("INVALID_MESSAGE", "The Host returned an invalid CodeX project.");
@@ -22291,6 +22903,7 @@ var ClientModeRuntime = class {
22291
22903
  this.gatewaySwitch.selectLocal();
22292
22904
  this.pendingWorkspaceSelection = void 0;
22293
22905
  await this.closeCodexVirtual();
22906
+ this.closeBrowsing();
22294
22907
  await this.closeCodexStreams(this.connected?.client);
22295
22908
  await this.connected?.client.close().catch(() => void 0);
22296
22909
  this.connected = void 0;
@@ -22371,6 +22984,7 @@ var ClientModeRuntime = class {
22371
22984
  return { opened: true, streamId: value.streamId, threadId: value.threadId };
22372
22985
  }
22373
22986
  publishLocalCodexFrame = async (event, data2) => {
22987
+ if (event === "codex.app.capability") return;
22374
22988
  const streamId = data2.streamId;
22375
22989
  const stream = this.codexStreams.get(streamId);
22376
22990
  if (stream === void 0 || stream.target.kind !== "local") return;
@@ -22501,6 +23115,29 @@ var ClientModeRuntime = class {
22501
23115
  "This Client does not provide a compatible Harness carrier for the selected remote workspace."
22502
23116
  );
22503
23117
  }
23118
+ /**
23119
+ * Connect, and report the control switch when that is what refused us.
23120
+ *
23121
+ * A device whose control switch is off has its host connection refused by the Server. Its presence may still read
23122
+ * online, so the failure comes back from whichever transport step gave up first - "adaptive transport has not been
23123
+ * authorized", "the authenticated Noise channel is not connected", "the selected Host is offline" - and none of
23124
+ * them name the switch. The device row does, so a failed attempt is re-read against it; an error that already names
23125
+ * a cause is passed through untouched.
23126
+ * @param targetDeviceId - the Host being connected to.
23127
+ * @param signal - aborts the attempt.
23128
+ * @param forceRelay - skips direct negotiation, as the reconnect window does.
23129
+ * @returns the connected remote session.
23130
+ */
23131
+ async connectWithReason(targetDeviceId, signal, forceRelay = false) {
23132
+ try {
23133
+ return await this.connect(targetDeviceId, signal, forceRelay);
23134
+ } catch (error) {
23135
+ if (safeErrorCode(error) === "CONTROL_DISABLED") throw error;
23136
+ const row = await this.server.listDevices().then((list) => list.find((device) => device.deviceId === targetDeviceId)).catch(() => void 0);
23137
+ if (row?.hostControl === false) throw offlineConnectionError(false);
23138
+ throw error;
23139
+ }
23140
+ }
22504
23141
  async connect(targetDeviceId, signal, forceRelay = false) {
22505
23142
  signal?.throwIfAborted();
22506
23143
  const progressRunId = this.connectionProgressRun + 1;
@@ -22516,7 +23153,7 @@ var ClientModeRuntime = class {
22516
23153
  this.updateConnectionProgress(progressRunId, "authorizing-peer");
22517
23154
  const target2 = await this.authorizeHostPeer(serverDevice);
22518
23155
  const presence = await this.server.presenceFor(targetDeviceId);
22519
- if (!presence.online) throw new ClientModeError("HOST_OFFLINE", "The selected Host is offline.", true);
23156
+ if (!presence.online) throw offlineConnectionError(serverDevice.hostControl);
22520
23157
  const credentials = await this.server.authenticate(identity);
22521
23158
  const rtcFactory = this.config.forceRelay ? void 0 : await this.rtcFactoryProvider({ routeTargets: [this.server.baseUrl] }).catch(() => void 0);
22522
23159
  if (!this.config.forceRelay && rtcFactory === void 0) {
@@ -22629,13 +23266,56 @@ var ClientModeRuntime = class {
22629
23266
  throw error;
22630
23267
  }
22631
23268
  }
23269
+ /**
23270
+ * Connect for a read-only panel request.
23271
+ *
23272
+ * Browsing a Host is not a session. This connection never becomes {@link connected}, so a later drop is
23273
+ * ignored by the recovery path and the window stays where the user left it. The session's own link is
23274
+ * reused when it already points at that Host, because the Server keeps only one control connection per
23275
+ * device and role.
23276
+ * @param targetDeviceId - the Host being browsed.
23277
+ * @param signal - aborts the connection attempt.
23278
+ * @returns the connection to read through.
23279
+ */
23280
+ async ensureBrowsing(targetDeviceId, signal) {
23281
+ if (this.connected?.target.deviceId === targetDeviceId) return this.connected;
23282
+ if (this.browsing?.target.deviceId === targetDeviceId) return this.browsing;
23283
+ const next = await this.connectWithReason(targetDeviceId, signal);
23284
+ const previous = this.browsing;
23285
+ this.browsing = next;
23286
+ this.clearConnectionProgress(next.progressRunId);
23287
+ await previous?.client.close().catch(() => void 0);
23288
+ return next;
23289
+ }
23290
+ /**
23291
+ * Drop the panel's browsing connection.
23292
+ *
23293
+ * Called whenever the user's own decision makes the browse stale: returning to local, signing out, or
23294
+ * opening a session somewhere else. A browsing connection is never kept for later.
23295
+ * @param except - a browsing connection to keep (the one just promoted to a session).
23296
+ */
23297
+ closeBrowsing(except) {
23298
+ const browsing = this.browsing;
23299
+ if (browsing === void 0 || browsing === except) return;
23300
+ this.browsing = void 0;
23301
+ void browsing.client.close().catch(() => void 0);
23302
+ }
22632
23303
  async ensureConnected(targetDeviceId, signal) {
22633
23304
  if (this.connected?.target.deviceId === targetDeviceId) return this.connected;
22634
- const next = await this.connect(targetDeviceId, signal);
22635
23305
  const previous = this.connected;
23306
+ if (this.browsing?.target.deviceId === targetDeviceId) {
23307
+ const next2 = this.browsing;
23308
+ this.browsing = void 0;
23309
+ await this.closePreview();
23310
+ this.connected = next2;
23311
+ await previous?.client.close().catch(() => void 0);
23312
+ return next2;
23313
+ }
23314
+ const next = await this.connectWithReason(targetDeviceId, signal);
22636
23315
  await this.closePreview();
22637
23316
  this.connected = next;
22638
23317
  this.clearConnectionProgress(next.progressRunId);
23318
+ this.closeBrowsing();
22639
23319
  await previous?.client.close().catch(() => void 0);
22640
23320
  return next;
22641
23321
  }
@@ -22668,6 +23348,12 @@ var ClientModeRuntime = class {
22668
23348
  try {
22669
23349
  if (endpoint === "status") return ok2(await this.detailedStatus());
22670
23350
  if (endpoint === "client.connection.verify") return ok2(await this.verifyRemoteConnection());
23351
+ if (endpoint === "remote.features.probe") return ok2(await this.probeRemoteFeatures());
23352
+ if (endpoint === "remote.session.recover") return ok2(await this.recoverCurrentSession());
23353
+ if (endpoint === "client.device.revoke") {
23354
+ await this.revokeCurrentDevice();
23355
+ return ok2(this.status());
23356
+ }
22671
23357
  if (endpoint === "devices") return ok2(await this.devices());
22672
23358
  if (endpoint === "client.account.login") {
22673
23359
  const value = record4(payload);
@@ -22721,6 +23407,7 @@ var ClientModeRuntime = class {
22721
23407
  if (typeof value.targetDeviceId !== "string" || typeof value.path !== "string") {
22722
23408
  throw new ClientModeError("INVALID_MESSAGE", "A Host and working directory are required.");
22723
23409
  }
23410
+ this.supersedeRemoteRecovery();
22724
23411
  return ok2(await this.openRemoteWorkspace(value.targetDeviceId, value.path, signal));
22725
23412
  }
22726
23413
  if (endpoint === "codex.workspace.open") {
@@ -22728,6 +23415,7 @@ var ClientModeRuntime = class {
22728
23415
  if (typeof value.targetDeviceId !== "string" || typeof value.workspaceId !== "string") {
22729
23416
  throw new ClientModeError("INVALID_MESSAGE", "A Host and CodeX Workspace are required.");
22730
23417
  }
23418
+ this.supersedeRemoteRecovery();
22731
23419
  return ok2(await this.openCodexWorkspace(value.targetDeviceId, value.workspaceId, signal));
22732
23420
  }
22733
23421
  if (endpoint === "codex.workspace.create") {
@@ -22843,6 +23531,21 @@ var ClientModeRuntime = class {
22843
23531
  });
22844
23532
  }
22845
23533
  };
23534
+ function offlineConnectionError(hostControl) {
23535
+ return hostControl === false ? new ClientModeError(
23536
+ "CONTROL_DISABLED",
23537
+ 'This device is not accepting control. Turn on "Allow other users to control this device" on that device.',
23538
+ true
23539
+ ) : new ClientModeError("HOST_OFFLINE", "The selected Host is offline.", true);
23540
+ }
23541
+ function isCodexSessionLoss(error) {
23542
+ const own = error !== null && typeof error === "object" && "code" in error ? error.code : void 0;
23543
+ const code = typeof own === "string" ? own : safeErrorCode(error);
23544
+ return code === "CODEX_CONNECTION_CLOSED" || code === "CODEX_CLOSED" || code === "CODEX_UNAVAILABLE";
23545
+ }
23546
+ function shouldRestoreCodexCarrier(selection, targetDeviceId) {
23547
+ return selection !== void 0 && selection.targetDeviceId === targetDeviceId && selection.backend === "codex";
23548
+ }
22846
23549
  var ClientModeError = class extends Error {
22847
23550
  constructor(code, message, retryable = false) {
22848
23551
  super(message);
@@ -22851,7 +23554,7 @@ var ClientModeError = class extends Error {
22851
23554
  }
22852
23555
  };
22853
23556
  function assertAuthorizedHost(listed, descriptor) {
22854
- if (descriptor.role !== "host" || descriptor.deviceId !== listed.deviceId || descriptor.membershipId !== listed.membershipId) {
23557
+ if (descriptor.deviceId !== listed.deviceId || descriptor.membershipId !== listed.membershipId) {
22855
23558
  throw new ClientModeError("PEER_IDENTITY_MISMATCH", "Server Host details do not match the authorized device list.");
22856
23559
  }
22857
23560
  }
@@ -22957,6 +23660,15 @@ function fail2(error) {
22957
23660
  function shortId2(value) {
22958
23661
  return value.length <= 12 ? value : `${value.slice(0, 8)}\u2026${value.slice(-4)}`;
22959
23662
  }
23663
+ function failureCode(error) {
23664
+ const own = error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
23665
+ return own ?? safeErrorCode(error);
23666
+ }
23667
+ function sleep3(ms) {
23668
+ return new Promise((resolve4) => {
23669
+ setTimeout(resolve4, ms);
23670
+ });
23671
+ }
22960
23672
  function remoteHostFeatures(clientVersion) {
22961
23673
  return {
22962
23674
  commandList: isVersionAtLeast(clientVersion, REMOTE_COMMAND_LIST_MIN_VERSION),
@@ -23090,12 +23802,14 @@ var ClientTargetStore = class {
23090
23802
  if (record7.mode === "remote" && (typeof record7.hostDeviceId !== "string" || record7.hostDeviceId.length === 0)) {
23091
23803
  return void 0;
23092
23804
  }
23805
+ const workspace = parseWorkspace(record7.workspace);
23093
23806
  return {
23094
23807
  schemaVersion: 1,
23095
23808
  mode: record7.mode,
23096
23809
  ...typeof record7.serverUrl === "string" && record7.serverUrl.length > 0 ? { serverUrl: record7.serverUrl } : {},
23097
23810
  ...typeof record7.hostDeviceId === "string" && record7.hostDeviceId.length > 0 ? { hostDeviceId: record7.hostDeviceId } : {},
23098
23811
  ...typeof record7.hostName === "string" && record7.hostName.length > 0 ? { hostName: record7.hostName } : {},
23812
+ ...workspace === void 0 ? {} : { workspace },
23099
23813
  savedAt: typeof record7.savedAt === "number" ? record7.savedAt : 0
23100
23814
  };
23101
23815
  } catch {
@@ -23104,12 +23818,14 @@ var ClientTargetStore = class {
23104
23818
  }
23105
23819
  /** Record the current target. Failures reach the caller so a boot can log them. */
23106
23820
  async save(target2) {
23821
+ const workspace = parseWorkspace(target2.workspace);
23107
23822
  const value = {
23108
23823
  schemaVersion: 1,
23109
23824
  mode: target2.mode,
23110
23825
  ...target2.serverUrl === void 0 ? {} : { serverUrl: target2.serverUrl },
23111
23826
  ...target2.hostDeviceId === void 0 ? {} : { hostDeviceId: target2.hostDeviceId },
23112
23827
  ...target2.hostName === void 0 ? {} : { hostName: target2.hostName },
23828
+ ...workspace === void 0 ? {} : { workspace },
23113
23829
  savedAt: Date.now()
23114
23830
  };
23115
23831
  await mkdir2(this.directory, { recursive: true, mode: 448 });
@@ -23121,15 +23837,28 @@ var ClientTargetStore = class {
23121
23837
  await chmod2(this.file, 384);
23122
23838
  }
23123
23839
  };
23840
+ function parseWorkspace(value) {
23841
+ if (typeof value !== "object" || value === null) return void 0;
23842
+ const record7 = value;
23843
+ if (typeof record7.workspaceId !== "string" || record7.workspaceId.length === 0) return void 0;
23844
+ if (record7.kind !== "harness" && record7.kind !== "codex") return void 0;
23845
+ return {
23846
+ workspaceId: record7.workspaceId,
23847
+ kind: record7.kind,
23848
+ ...typeof record7.workspacePath === "string" && record7.workspacePath.length > 0 ? { workspacePath: record7.workspacePath } : {},
23849
+ ...typeof record7.sessionId === "string" && record7.sessionId.length > 0 ? { sessionId: record7.sessionId } : {}
23850
+ };
23851
+ }
23124
23852
 
23125
23853
  // src/control-runtime.ts
23126
23854
  import { hostname as hostname2 } from "node:os";
23127
- import { existsSync as existsSync3 } from "node:fs";
23855
+ import { existsSync as existsSync4 } from "node:fs";
23128
23856
  import { execFileSync } from "node:child_process";
23129
23857
 
23130
23858
  // src/identity-store.ts
23859
+ import { existsSync as existsSync2 } from "node:fs";
23131
23860
  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";
23861
+ 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
23862
  import { homedir } from "node:os";
23134
23863
  import { dirname as dirname4, join as join6 } from "node:path";
23135
23864
  var identitySchema = external_exports.object({
@@ -23263,6 +23992,19 @@ function serverStorageDirectory(root, serverUrl, role) {
23263
23992
  const scope = createHash("sha256").update(origin).digest("hex").slice(0, 24);
23264
23993
  return join6(root, "servers", scope, role);
23265
23994
  }
23995
+ async function ensureDeviceDirectory(root, serverUrl) {
23996
+ const directory = serverStorageDirectory(root, serverUrl, "device");
23997
+ if (existsSync2(directory)) return directory;
23998
+ await mkdir3(dirname4(directory), { recursive: true, mode: 448 });
23999
+ for (const legacy of ["host", "client"]) {
24000
+ const from = serverStorageDirectory(root, serverUrl, legacy);
24001
+ if (!existsSync2(from)) continue;
24002
+ await cp(from, directory, { recursive: true });
24003
+ return directory;
24004
+ }
24005
+ await mkdir3(directory, { recursive: true, mode: 448 });
24006
+ return directory;
24007
+ }
23266
24008
  function fingerprint(publicKey) {
23267
24009
  const compact = createHash("sha256").update(fromBase64Url2(publicKey)).digest("hex").slice(0, 12).toUpperCase();
23268
24010
  return compact.match(/.{1,4}/g).join(" ");
@@ -23449,7 +24191,7 @@ var ControlStatusStream = class {
23449
24191
 
23450
24192
  // src/codex/domain.ts
23451
24193
  import { randomUUID as randomUUID3 } from "node:crypto";
23452
- import { accessSync, constants, existsSync as existsSync2, readFileSync, readdirSync as readdirSync2, statSync as statSync2 } from "node:fs";
24194
+ import { accessSync, constants, existsSync as existsSync3, readFileSync, readdirSync as readdirSync2, statSync as statSync2 } from "node:fs";
23453
24195
  import { readdir as readdir2, realpath, stat as stat4 } from "node:fs/promises";
23454
24196
  import { homedir as homedir2 } from "node:os";
23455
24197
  import { basename as basename2, isAbsolute as isAbsolute2, join as join7, posix, relative, resolve } from "node:path";
@@ -23646,7 +24388,11 @@ var CodexAppServerClient = class {
23646
24388
  const pending = this.takePending(value.id);
23647
24389
  if (pending === void 0) return;
23648
24390
  if ("error" in value && value.error !== void 0) {
23649
- pending.reject(new CodexAppServerError("CODEX_UPSTREAM_ERROR", safeUpstreamError(value.error)));
24391
+ const upstreamCode = safeUpstreamCode(value.error);
24392
+ pending.reject(Object.assign(
24393
+ new CodexAppServerError("CODEX_UPSTREAM_ERROR", safeUpstreamError(value.error)),
24394
+ upstreamCode === void 0 ? {} : { upstreamCode }
24395
+ ));
23650
24396
  } else {
23651
24397
  pending.resolve(value.result);
23652
24398
  }
@@ -23663,7 +24409,13 @@ var CodexAppServerClient = class {
23663
24409
  const child = this.process;
23664
24410
  this.process = void 0;
23665
24411
  child?.kill("SIGTERM");
23666
- this.logger?.warn("Codex App Server communication failed", { code });
24412
+ const failure2 = cause;
24413
+ this.logger?.warn("Codex App Server communication failed", {
24414
+ code,
24415
+ causeCode: failure2.code ?? "none",
24416
+ ...failure2.errno === void 0 ? {} : { errno: failure2.errno },
24417
+ ...failure2.syscall === void 0 ? {} : { syscall: failure2.syscall }
24418
+ });
23667
24419
  if (!this.closed) this.notifyUnavailable(code);
23668
24420
  }
23669
24421
  notifyUnavailable(code) {
@@ -23688,6 +24440,13 @@ function safeUpstreamError(value) {
23688
24440
  if (message.includes("active writer")) return "Codex thread already has an active writer.";
23689
24441
  return message.includes("not initialized") ? "Codex App Server is not initialized." : "Codex App Server rejected the request.";
23690
24442
  }
24443
+ function safeUpstreamCode(value) {
24444
+ if (!isRecord10(value)) return void 0;
24445
+ const raw = isRecord10(value.data) && value.data.code !== void 0 ? value.data.code : value.code;
24446
+ if (typeof raw === "number" && Number.isFinite(raw)) return String(raw);
24447
+ if (typeof raw !== "string") return void 0;
24448
+ return /^[A-Za-z0-9_.:-]{1,48}$/u.test(raw) ? raw : void 0;
24449
+ }
23691
24450
  function isRecord10(value) {
23692
24451
  return typeof value === "object" && value !== null && !Array.isArray(value);
23693
24452
  }
@@ -23901,6 +24660,19 @@ var CodexPeerBridge = class {
23901
24660
  await this.publish("codex.app.frame", data2);
23902
24661
  }
23903
24662
  }
24663
+ /**
24664
+ * Tell this connection whether the Host can serve Codex, and say it only when that actually changed.
24665
+ *
24666
+ * The per-stream closes below only reach a client that is watching a thread. A session parked on its history,
24667
+ * or a workspace with nothing open, heard nothing until its next call failed - which is why switching Codex
24668
+ * off looked like a plain error instead of a lost session. This states the fact rather than the conclusion, so
24669
+ * the client can decide whether the session it is showing is the one affected.
24670
+ * @param available - whether Codex can be served right now.
24671
+ */
24672
+ async notifyCapability(available) {
24673
+ if (this.closed) return;
24674
+ await this.publish("codex.app.capability", { available }).catch(() => void 0);
24675
+ }
23904
24676
  async failStreams(reason = "failed") {
23905
24677
  if (this.closed) return;
23906
24678
  const streamIds = [...this.streams.keys()];
@@ -23971,7 +24743,9 @@ function concatChunks(chunks, totalBytes) {
23971
24743
  // src/codex/domain.ts
23972
24744
  var APPROVAL_TTL_MS = 5 * 6e4;
23973
24745
  var DEFAULT_RESTART_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
23974
- var CODEX_START_BUDGET_MS = 5e3;
24746
+ var CODEX_SLOW_RETRY_MS = 6e4;
24747
+ var ACCOUNT_PROBE_TIMEOUT_MS = 6e4;
24748
+ var CODEX_START_BUDGET_MS = 9e4;
23975
24749
  var CODEX_PAGE_LIMIT2 = 100;
23976
24750
  var MAX_CODEX_PAGES2 = 32;
23977
24751
  var CODEX_HISTORY_PAGE_LIMIT = 25;
@@ -23979,10 +24753,10 @@ var MAX_CODEX_HISTORY_PAGES = 64;
23979
24753
  var CODEX_DIRECTORY_ENTRY_LIMIT = 500;
23980
24754
  var CodexRemoteDomain = class {
23981
24755
  constructor(config, logger, createAppServer = (binary, targetLogger) => new CodexAppServerClient(binary, targetLogger), restartDelaysMs = DEFAULT_RESTART_DELAYS_MS) {
23982
- this.config = config;
23983
24756
  this.logger = logger;
23984
24757
  this.createAppServer = createAppServer;
23985
24758
  this.restartDelaysMs = restartDelaysMs;
24759
+ this.config = config;
23986
24760
  }
23987
24761
  appServer;
23988
24762
  unsubscribeInbound;
@@ -23995,10 +24769,30 @@ var CodexRemoteDomain = class {
23995
24769
  approvalExpiryTimer;
23996
24770
  restartTimer;
23997
24771
  restartAttempt = 0;
24772
+ /** Whether the "fast ladder is spent, retrying slowly" line has been logged for this outage. */
24773
+ slowRestartLogged = false;
23998
24774
  available = false;
23999
24775
  closed = false;
24776
+ /**
24777
+ * The last availability announced to peers, or undefined before the first announcement.
24778
+ *
24779
+ * Announcements are transitions, not states: the Host tells clients *when* Codex came or went, never "your
24780
+ * session is gone". Announcing on every restart made switching Codex **on** read as a loss, and the client
24781
+ * answered a loss with a fresh recovery - which pulled back a view the user had just cancelled.
24782
+ */
24783
+ announcedAvailable;
24784
+ /**
24785
+ * Bumped by every restart() and close().
24786
+ *
24787
+ * A launch that began before the switch was flipped can finish after it, and its completion used to write
24788
+ * `available = true` onto a Codex the user had switched off - which made the next "switch on" look like a
24789
+ * transition out of availability and produced the false loss notice. Work from an older generation is discarded.
24790
+ */
24791
+ generation = 0;
24000
24792
  state = "disabled";
24001
24793
  unavailableCode;
24794
+ /** The live configuration; replaced in place by {@link restart}. */
24795
+ config;
24002
24796
  async start() {
24003
24797
  if (this.closed) throw new RpcError("CODEX_CLOSED", "The Codex Remote domain is closed.");
24004
24798
  if (!this.config.enabled) return;
@@ -24040,6 +24834,10 @@ var CodexRemoteDomain = class {
24040
24834
  }
24041
24835
  createPeer(context, publish) {
24042
24836
  if (!this.config.enabled) return void 0;
24837
+ this.logger.info("Codex client attached", {
24838
+ peerDeviceId: context.peerDeviceId.slice(0, 12),
24839
+ connectionId: context.connectionId.slice(0, 12)
24840
+ });
24043
24841
  const bridge = new CodexPeerBridge(this, context, publish, this.logger);
24044
24842
  this.peers.set(context.connectionId, bridge);
24045
24843
  this.peerDeviceIds.set(context.connectionId, context.peerDeviceId);
@@ -24216,13 +25014,79 @@ var CodexRemoteDomain = class {
24216
25014
  async close() {
24217
25015
  if (this.closed) return;
24218
25016
  this.closed = true;
24219
- this.available = false;
25017
+ this.generation += 1;
25018
+ await this.teardown();
24220
25019
  this.state = this.config.enabled ? "unavailable" : "disabled";
24221
- if (this.restartTimer !== void 0) clearTimeout(this.restartTimer);
24222
- this.restartTimer = void 0;
25020
+ }
25021
+ /**
25022
+ * State the Codex capability to every attached connection - and only when it actually changed.
25023
+ *
25024
+ * This is the fact, not the conclusion: an unavailable Codex means "the session you are watching is gone" to a
25025
+ * client that is watching one, and means nothing to a client that is not. Sending it only on a real transition
25026
+ * is what keeps "switch Codex on" from being reported as a loss.
25027
+ * @returns when every attached connection has been told.
25028
+ */
25029
+ async announceCapability() {
25030
+ const available = this.isAvailable();
25031
+ if (this.announcedAvailable === void 0) {
25032
+ this.announcedAvailable = available;
25033
+ return;
25034
+ }
25035
+ if (available === this.announcedAvailable) return;
25036
+ this.announcedAvailable = available;
25037
+ this.logger.info("Codex capability changed; telling every attached connection", { available });
25038
+ await Promise.all([...this.peers.values()].map((peer) => peer.notifyCapability(available).catch(() => void 0)));
25039
+ }
25040
+ /**
25041
+ * Apply a changed configuration without restarting DSH.
25042
+ *
25043
+ * Deliberately not close()+start(): close() is terminal, so that pair left the domain dead for the rest of the
25044
+ * process (the bug behind "the switch never comes back"). This shares close()'s teardown without setting the
25045
+ * terminal flag, and a disabled configuration simply stops - no launch, state `disabled`.
25046
+ *
25047
+ * The per-connection bridges are deliberately *kept*. They are the attached Remote clients' only handle on
25048
+ * this Host's Codex, and tearing them down here is what left every one of them holding a closed bridge:
25049
+ * the reported "The Codex connection is closed" that no refresh could clear, because nothing short of a full
25050
+ * transport reconnect could get a working bridge again. Everything the App Server owns - live streams, turn
25051
+ * leases, approvals, permission snapshots - is released instead, which is exactly what a crash already did.
25052
+ * @param config - the configuration to apply, when the caller changed it.
25053
+ * @returns when the replacement attempt finished.
25054
+ */
25055
+ async restart(config) {
25056
+ if (this.closed) throw new RpcError("CODEX_CLOSED", "The Codex Remote domain is closed.");
25057
+ if (config !== void 0) this.config = config;
25058
+ this.generation += 1;
25059
+ const peers = [...this.peers.values()];
25060
+ await Promise.all(peers.map((peer) => peer.failStreams("failed")));
25061
+ await this.launching?.catch(() => void 0);
25062
+ await this.releaseAppServer();
25063
+ if (!this.config.enabled) await this.announceCapability();
25064
+ this.unavailableCode = void 0;
25065
+ this.restartAttempt = 0;
25066
+ this.slowRestartLogged = false;
25067
+ if (!this.config.enabled) {
25068
+ this.state = "disabled";
25069
+ return;
25070
+ }
25071
+ await this.start();
25072
+ }
25073
+ /** Release every live resource; shared by close() (terminal) and used by close() alone. */
25074
+ async teardown() {
24223
25075
  for (const bridge of [...this.peers.values()]) await bridge.closeAll();
24224
25076
  this.peers.clear();
24225
25077
  this.peerDeviceIds.clear();
25078
+ await this.releaseAppServer();
25079
+ }
25080
+ /**
25081
+ * Release everything that belongs to the App Server, keeping the per-connection bridges.
25082
+ *
25083
+ * Shared by restart() and by a crash: in both cases the bridges must outlive the process so an attached
25084
+ * client's next call simply reaches the replacement instead of failing for good.
25085
+ */
25086
+ async releaseAppServer() {
25087
+ this.available = false;
25088
+ if (this.restartTimer !== void 0) clearTimeout(this.restartTimer);
25089
+ this.restartTimer = void 0;
24226
25090
  this.turnOwners.clear();
24227
25091
  this.permissionPresets.clear();
24228
25092
  if (this.approvalExpiryTimer !== void 0) clearTimeout(this.approvalExpiryTimer);
@@ -24235,6 +25099,27 @@ var CodexRemoteDomain = class {
24235
25099
  await this.appServer?.close();
24236
25100
  this.appServer = void 0;
24237
25101
  }
25102
+ /**
25103
+ * The launch in flight, if any.
25104
+ *
25105
+ * Two overlapping launches fight over `this.appServer`, and whichever finishes first disposes the other's App
25106
+ * Server - which is answering its account probe at that moment, so the probe fails with `CODEX_CLOSED` and the
25107
+ * domain reports a closed Codex on a machine where nothing was closed. One launch at a time, and
25108
+ * `restart()` waits for it instead of closing it out from under the probe.
25109
+ */
25110
+ launching;
25111
+ /**
25112
+ * Launch once, joining the launch already in flight instead of starting a second one.
25113
+ * @returns when this launch settled (either way).
25114
+ */
25115
+ launchAppServerOnce() {
25116
+ if (this.launching !== void 0) return this.launching;
25117
+ const launch = this.launchAppServer().finally(() => {
25118
+ if (this.launching === launch) this.launching = void 0;
25119
+ });
25120
+ this.launching = launch;
25121
+ return launch;
25122
+ }
24238
25123
  /**
24239
25124
  * Launch the App Server within {@link CODEX_START_BUDGET_MS}.
24240
25125
  *
@@ -24245,7 +25130,7 @@ var CodexRemoteDomain = class {
24245
25130
  */
24246
25131
  async launchWithinBudget() {
24247
25132
  let timer;
24248
- const attempt = this.launchAppServer();
25133
+ const attempt = this.launchAppServerOnce();
24249
25134
  attempt.catch(() => void 0);
24250
25135
  try {
24251
25136
  await Promise.race([
@@ -24262,19 +25147,26 @@ var CodexRemoteDomain = class {
24262
25147
  }
24263
25148
  }
24264
25149
  async launchAppServer() {
25150
+ let firstError;
24265
25151
  let lastError;
25152
+ let index = 0;
24266
25153
  for (const binary of codexBinaryCandidates(this.config.binary)) {
25154
+ index += 1;
24267
25155
  try {
24268
25156
  await this.launchAppServerCandidate(binary);
24269
25157
  return;
24270
25158
  } catch (error) {
25159
+ this.logger.warn("Codex App Server candidate failed", { candidate: index, code: errorCode4(error) });
25160
+ firstError ??= error;
24271
25161
  lastError = error;
24272
25162
  if (!canTryNextBinary(error)) throw error;
24273
25163
  }
24274
25164
  }
24275
- throw lastError ?? new RpcError("CODEX_START_FAILED", "Codex App Server could not be started.");
25165
+ const chosen = errorCode4(lastError) === "CODEX_BINARY_UNAVAILABLE" && firstError !== void 0 ? firstError : lastError;
25166
+ throw chosen ?? new RpcError("CODEX_START_FAILED", "Codex App Server could not be started.");
24276
25167
  }
24277
25168
  async launchAppServerCandidate(binary) {
25169
+ const generation = this.generation;
24278
25170
  const appServer = this.createAppServer(binary, this.logger);
24279
25171
  this.appServer = appServer;
24280
25172
  this.unsubscribeInbound = appServer.onInbound((message) => {
@@ -24289,7 +25181,17 @@ var CodexRemoteDomain = class {
24289
25181
  });
24290
25182
  try {
24291
25183
  await appServer.start();
24292
- const account = await appServer.call("account/read", { refreshToken: false }, 15e3);
25184
+ let account;
25185
+ try {
25186
+ account = await appServer.call("account/read", { refreshToken: false }, ACCOUNT_PROBE_TIMEOUT_MS);
25187
+ } catch (error) {
25188
+ const upstreamCode = error.upstreamCode;
25189
+ this.logger.warn("Codex account probe failed", {
25190
+ code: errorCode4(error),
25191
+ ...upstreamCode === void 0 ? {} : { upstreamCode: String(upstreamCode) }
25192
+ });
25193
+ throw classifyAccountProbeFailure(error);
25194
+ }
24293
25195
  if (!accountCanRun(account)) {
24294
25196
  throw new RpcError("CODEX_AUTH_REQUIRED", "Codex is not signed in on this Host.");
24295
25197
  }
@@ -24297,11 +25199,22 @@ var CodexRemoteDomain = class {
24297
25199
  await appServer.close().catch(() => void 0);
24298
25200
  return;
24299
25201
  }
25202
+ if (generation !== this.generation || !this.config.enabled) {
25203
+ this.logger.info("Codex App Server started after the domain moved on; releasing it", {
25204
+ generation: this.generation - generation,
25205
+ enabled: this.config.enabled
25206
+ });
25207
+ await this.disposeAppServer(appServer);
25208
+ this.available = false;
25209
+ this.state = this.config.enabled ? "restarting" : "disabled";
25210
+ return;
25211
+ }
24300
25212
  this.available = true;
24301
25213
  this.state = "ready";
24302
25214
  this.restartAttempt = 0;
24303
25215
  this.unavailableCode = void 0;
24304
25216
  this.logger.info("Codex Remote domain ready");
25217
+ await this.announceCapability();
24305
25218
  } catch (error) {
24306
25219
  await this.disposeAppServer(appServer);
24307
25220
  throw error;
@@ -24318,14 +25231,27 @@ var CodexRemoteDomain = class {
24318
25231
  if (this.approvalExpiryTimer !== void 0) clearTimeout(this.approvalExpiryTimer);
24319
25232
  this.approvalExpiryTimer = void 0;
24320
25233
  await Promise.all([...this.peers.values()].map((peer) => peer.failStreams("failed")));
25234
+ await this.announceCapability();
24321
25235
  await this.disposeAppServer(appServer);
24322
25236
  this.scheduleRestart();
24323
25237
  }
24324
25238
  scheduleRestart() {
24325
25239
  if (this.closed || this.restartTimer !== void 0) return;
25240
+ if (!this.config.enabled) return;
24326
25241
  if (this.restartAttempt >= this.restartDelaysMs.length) {
24327
25242
  this.state = "unavailable";
24328
- this.logger.warn("Codex App Server restart attempts exhausted", { attempts: this.restartAttempt });
25243
+ this.restartTimer = setTimeout(() => {
25244
+ this.restartTimer = void 0;
25245
+ void this.restartAfterFailure();
25246
+ }, CODEX_SLOW_RETRY_MS);
25247
+ this.restartTimer.unref?.();
25248
+ if (!this.slowRestartLogged) {
25249
+ this.slowRestartLogged = true;
25250
+ this.logger.warn("Codex App Server restart attempts exhausted; retrying every " + String(CODEX_SLOW_RETRY_MS) + "ms", {
25251
+ attempts: this.restartAttempt,
25252
+ code: this.unavailableCode
25253
+ });
25254
+ }
24329
25255
  return;
24330
25256
  }
24331
25257
  const delayMs = Math.max(0, this.restartDelaysMs[this.restartAttempt] ?? 0);
@@ -24339,9 +25265,13 @@ var CodexRemoteDomain = class {
24339
25265
  this.logger.warn("Codex App Server restart scheduled", { attempt: this.restartAttempt, delayMs });
24340
25266
  }
24341
25267
  async restartAfterFailure() {
24342
- if (this.closed) return;
25268
+ if (this.closed || !this.config.enabled) return;
24343
25269
  try {
24344
- await this.launchAppServer();
25270
+ await this.launchAppServerOnce();
25271
+ if (!this.config.enabled) {
25272
+ await this.releaseAppServer();
25273
+ this.state = "disabled";
25274
+ }
24345
25275
  } catch (error) {
24346
25276
  this.available = false;
24347
25277
  this.state = "restarting";
@@ -24669,7 +25599,14 @@ var CodexRemoteDomain = class {
24669
25599
  } catch (error) {
24670
25600
  if (!isProjectListFallbackError(error)) throw error;
24671
25601
  }
24672
- if (roots.length > 0) return { projectIds, roots };
25602
+ if (roots.length > 0) {
25603
+ this.logger.debug("Codex workspace authority resolved from project/list", { projects: projectIds.size, roots: roots.length });
25604
+ return { projectIds, roots };
25605
+ }
25606
+ this.logger.warn("Codex project list yielded no roots; deriving workspaces from thread cwd", {
25607
+ projects: projectIds.size,
25608
+ listedThreads: listedThreads?.length ?? 0
25609
+ });
24673
25610
  const threads = listedThreads === void 0 ? [] : [...listedThreads];
24674
25611
  if (listedThreads === void 0) {
24675
25612
  cursor2 = void 0;
@@ -24814,9 +25751,15 @@ var CodexRemoteDomain = class {
24814
25751
  return paths;
24815
25752
  }
24816
25753
  };
24817
- function codexBinaryCandidates(configured, hostPlatform = process.platform, userHome = homedir2()) {
25754
+ function codexBinaryCandidates(configured, hostPlatform = process.platform, userHome = homedir2(), localAppData = process.env.LOCALAPPDATA) {
24818
25755
  if (configured !== "codex") return [configured];
24819
- if (hostPlatform === "win32") return [...bundledWindowsCodex(userHome), configured];
25756
+ if (hostPlatform === "win32") {
25757
+ return [.../* @__PURE__ */ new Set([
25758
+ ...bundledWindowsCodex(userHome),
25759
+ ...localAppData === void 0 ? [] : bundledWindowsCodexAt(localAppData),
25760
+ configured
25761
+ ])];
25762
+ }
24820
25763
  if (hostPlatform !== "darwin") return [configured];
24821
25764
  const bundledCandidates = [
24822
25765
  "/Applications/ChatGPT.app",
@@ -24829,7 +25772,7 @@ function codexBinaryCandidates(configured, hostPlatform = process.platform, user
24829
25772
  return [];
24830
25773
  }
24831
25774
  const candidate = posix.join(codexCli, manifest.entrypoint);
24832
- if (!existsSync2(candidate)) return [];
25775
+ if (!existsSync3(candidate)) return [];
24833
25776
  accessSync(candidate, constants.X_OK);
24834
25777
  return [candidate];
24835
25778
  } catch {
@@ -24844,9 +25787,12 @@ function codexBinaryCandidates(configured, hostPlatform = process.platform, user
24844
25787
  ])];
24845
25788
  }
24846
25789
  function bundledWindowsCodex(userHome) {
24847
- const bin = join7(userHome, "AppData", "Local", "OpenAI", "Codex", "bin");
25790
+ return bundledWindowsCodexAt(join7(userHome, "AppData", "Local"));
25791
+ }
25792
+ function bundledWindowsCodexAt(localAppData) {
25793
+ const bin = join7(localAppData, "OpenAI", "Codex", "bin");
24848
25794
  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);
25795
+ 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
25796
  const candidate = newest[0]?.candidate;
24851
25797
  return candidate === void 0 ? [] : [candidate];
24852
25798
  } catch {
@@ -24865,6 +25811,16 @@ function parseRespond(input2) {
24865
25811
  }
24866
25812
  return input2;
24867
25813
  }
25814
+ function classifyAccountProbeFailure(error) {
25815
+ const message = error instanceof Error ? error.message : String(error);
25816
+ if (/unsupported_country_region_territory|country, region, or territory|403|forbidden/i.test(message)) {
25817
+ return new RpcError("CODEX_ACCOUNT_UNREACHABLE", "Codex cannot reach the account from this network region.");
25818
+ }
25819
+ if (/timed out|timeout/i.test(message)) {
25820
+ return new RpcError("CODEX_ACCOUNT_UNREACHABLE", "Codex could not reach the account in time.");
25821
+ }
25822
+ return error instanceof Error ? error : new RpcError("CODEX_UPSTREAM_ERROR", "Codex App Server could not complete the request.");
25823
+ }
24868
25824
  function accountCanRun(result) {
24869
25825
  if (!isRecord12(result) || typeof result.requiresOpenaiAuth !== "boolean") return false;
24870
25826
  return result.requiresOpenaiAuth === false || isRecord12(result.account);
@@ -25128,7 +26084,6 @@ var PluginControlRuntime = class {
25128
26084
  if (endpoint === "settings.get") return ok3(await this.settingsView());
25129
26085
  if (endpoint === "settings.configure") return ok3(await this.configure(payload));
25130
26086
  if (endpoint === "settings.server.set") return ok3(await this.setServer(payload));
25131
- if (endpoint === "settings.role.set") return ok3(await this.setRole(payload));
25132
26087
  if (endpoint === "settings.codex.set") return ok3(await this.setCodex(payload));
25133
26088
  if (endpoint === "settings.acp.set") return ok3(await this.setAcp(payload));
25134
26089
  if (endpoint === "settings.acp.add") return ok3(await this.addAcp(payload));
@@ -25183,7 +26138,7 @@ var PluginControlRuntime = class {
25183
26138
  return ok3(await this.host.authorizeHostWithCode(value.code));
25184
26139
  }
25185
26140
  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.");
26141
+ throw new ClientModeError("METHOD_NOT_ALLOWED", "Remote Client mode is unavailable in this profile.");
25187
26142
  } catch (error) {
25188
26143
  return fail3(error);
25189
26144
  }
@@ -25193,22 +26148,20 @@ var PluginControlRuntime = class {
25193
26148
  throw new ClientModeError("SETTINGS_UNAVAILABLE", "DSH user settings are unavailable in this profile.");
25194
26149
  }
25195
26150
  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
26151
  if (typeof value.serverUrl !== "string") {
25200
26152
  throw new ClientModeError("INVALID_MESSAGE", "Server URL is required.");
25201
26153
  }
25202
26154
  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
- });
26155
+ const next = resolveConfig({ ...current, serverUrl: value.serverUrl });
26156
+ const directory = await ensureDeviceDirectory(this.identityDirectory, next.serverUrl);
26157
+ const identities = new IdentityStore({ directory });
25207
26158
  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));
26159
+ const registrationCode = typeof value.registrationCode === "string" ? value.registrationCode.trim() : "";
26160
+ const hasRegistrationCode = registrationCode !== "";
26161
+ const api = hasRegistrationCode ? new HostServerApi(next.serverUrl, new ServerCredentialStore(directory)) : new ClientServerApi(next.serverUrl, new ServerCredentialStore(directory));
25209
26162
  let authorization;
25210
- if (value.role === "host" && typeof value.registrationCode === "string" && value.registrationCode.trim() !== "") {
25211
- authorization = await api.authorizeHostWithCode(identity, value.registrationCode);
26163
+ if (hasRegistrationCode) {
26164
+ authorization = await api.authorizeHostWithCode(identity, registrationCode);
25212
26165
  } else if (value.provider === "deepseek") {
25213
26166
  if (this.deepseekSession === void 0) {
25214
26167
  throw new ClientModeError("METHOD_NOT_ALLOWED", "DeepSeek account sign-in is unavailable in this profile.");
@@ -25239,13 +26192,18 @@ var PluginControlRuntime = class {
25239
26192
  }
25240
26193
  authorization = await api.authorizeWithAccount(identity, value.email, value.password);
25241
26194
  }
25242
- if (value.role === "client" && resolveConfig(this.settings.get()).hostControl?.enabled !== false) {
25243
- await this.client?.authorizeHostByDefault();
26195
+ this.client?.reloadCredentials?.();
26196
+ this.host?.reloadCredentials?.();
26197
+ const hostPaused = resolveConfig(this.settings.get()).hostControl?.paused === true;
26198
+ if (this.host !== void 0 && !hostPaused) {
26199
+ try {
26200
+ this.host.reconnectHost?.();
26201
+ } catch {
26202
+ }
25244
26203
  }
25245
26204
  await this.settings.replace(editableConfig(next));
25246
26205
  return {
25247
26206
  status: "authorized",
25248
- role: value.role,
25249
26207
  ...authorization.account === void 0 ? {} : { account: authorization.account },
25250
26208
  settings: await this.settingsView()
25251
26209
  };
@@ -25263,22 +26221,6 @@ var PluginControlRuntime = class {
25263
26221
  await this.settings.replace(editableConfig(next));
25264
26222
  return this.settingsView();
25265
26223
  }
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
26224
  async setDevelopment(payload) {
25283
26225
  if (this.settings === void 0) throw new ClientModeError("SETTINGS_UNAVAILABLE", "DSH user settings are unavailable in this profile.");
25284
26226
  const value = record5(payload);
@@ -25320,6 +26262,7 @@ var PluginControlRuntime = class {
25320
26262
  }
25321
26263
  });
25322
26264
  await this.settings.replace(editableConfig(next));
26265
+ void this.host?.restartCodex?.(next.codex);
25323
26266
  return this.settingsView();
25324
26267
  }
25325
26268
  async setAcp(payload) {
@@ -25355,18 +26298,6 @@ var PluginControlRuntime = class {
25355
26298
  await this.settings.replace(editableConfig({ ...current, acp: { enabled: current.acp?.enabled ?? true, backends } }));
25356
26299
  return this.settingsView();
25357
26300
  }
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
26301
  async logout() {
25371
26302
  if (this.settings === void 0) {
25372
26303
  throw new ClientModeError("SETTINGS_UNAVAILABLE", "DSH user settings are unavailable in this profile.");
@@ -25379,10 +26310,8 @@ var PluginControlRuntime = class {
25379
26310
  this.client?.clearClientAuthorization(),
25380
26311
  this.host?.clearHostAuthorization()
25381
26312
  ]);
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
- }));
26313
+ const directory = await ensureDeviceDirectory(this.identityDirectory, config.serverUrl);
26314
+ await new ServerCredentialStore(directory).clear();
25386
26315
  }
25387
26316
  let deepseekSignedOut = false;
25388
26317
  if (this.deepseekSession !== void 0) {
@@ -25422,9 +26351,9 @@ var PluginControlRuntime = class {
25422
26351
  async settingsView() {
25423
26352
  const config = this.settings === void 0 ? editableConfig(this.config) : editableConfig(resolveConfig(this.settings.get()));
25424
26353
  const associations = await this.associations(config);
25425
- const role = config.role === "client" ? "client" : "host";
25426
- const association = associations[role];
26354
+ const association = associations.host;
25427
26355
  const discovered = discoveredCodexBinary(config.codex?.binary ?? "codex");
26356
+ const codexStatus = this.host?.codexStatus?.();
25428
26357
  return {
25429
26358
  config,
25430
26359
  deviceName: hostname2(),
@@ -25433,27 +26362,25 @@ var PluginControlRuntime = class {
25433
26362
  associations,
25434
26363
  acpAvailability: Object.fromEntries((config.acp?.backends ?? []).map((item) => [item.id, commandAvailable(item.command ?? "")])),
25435
26364
  ...association === void 0 ? {} : { association },
25436
- ...discovered === void 0 ? {} : { discoveredCodexBinary: discovered }
26365
+ ...discovered === void 0 ? {} : { discoveredCodexBinary: discovered },
26366
+ ...codexStatus === void 0 ? {} : { codexStatus }
25437
26367
  };
25438
26368
  }
25439
26369
  async associations(config) {
25440
26370
  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
- };
26371
+ const association = await this.association(config.serverUrl);
26372
+ if (association === void 0) return {};
26373
+ return { host: association, client: association };
25449
26374
  }
25450
- async association(serverUrl, role) {
26375
+ async association(serverUrl) {
25451
26376
  const identities = new IdentityStore({
25452
- directory: serverStorageDirectory(this.identityDirectory, serverUrl, role)
26377
+ directory: await ensureDeviceDirectory(this.identityDirectory, serverUrl)
25453
26378
  });
25454
26379
  const identity = await identities.loadOrCreate(hostname2());
25455
26380
  const credentials = await new ServerCredentialStore(identities.directory).load(serverUrl, identity.deviceId);
25456
26381
  if (credentials === void 0) return void 0;
26382
+ const hostStatus = this.host?.hostStatus();
26383
+ if (hostStatus?.error !== void 0 && HOST_AUTHORIZATION_ERRORS.has(hostStatus.error)) return void 0;
25457
26384
  return {
25458
26385
  method: credentials.authorizationMethod,
25459
26386
  ...credentials.account === void 0 ? {} : { account: credentials.account }
@@ -25479,14 +26406,13 @@ function commandAvailable(command) {
25479
26406
  }
25480
26407
  function discoveredCodexBinary(configured) {
25481
26408
  for (const candidate of codexBinaryCandidates(configured)) {
25482
- if (candidate !== configured && existsSync3(candidate)) return candidate;
26409
+ if (candidate !== configured && existsSync4(candidate)) return candidate;
25483
26410
  }
25484
26411
  return void 0;
25485
26412
  }
25486
26413
  function editableConfig(config) {
25487
26414
  return {
25488
26415
  enabled: config.enabled,
25489
- role: config.role,
25490
26416
  ...config.serverUrl === void 0 ? {} : { serverUrl: config.serverUrl },
25491
26417
  terminal: config.terminal,
25492
26418
  hostControl: config.hostControl ?? { enabled: true, paused: false },
@@ -25725,10 +26651,6 @@ var ConnectionController = class {
25725
26651
  const peers = this.peerDeviceIds();
25726
26652
  return peers.length === 1 ? peers[0] : void 0;
25727
26653
  }
25728
- connectionMode() {
25729
- const connection = this.active.values().next().value;
25730
- return connection?.channel.mode ?? (connection === void 0 ? "Disconnected" : "Relay");
25731
- }
25732
26654
  async send(message) {
25733
26655
  await Promise.all([...this.active.values()].map((connection) => this.sendConnection(connection, message)));
25734
26656
  }
@@ -26434,7 +27356,6 @@ var API_TRANSFER_IDLE_MS = TRANSFER_IDLE_MS;
26434
27356
  var INLINE_TRANSFER_RESPONSE_BYTES2 = 2 * 1024 * 1024;
26435
27357
  var HarnessApiBridge = class {
26436
27358
  constructor(api, publish, maxStreams = 8, logger, typertGateway, harnessVersion) {
26437
- this.api = api;
26438
27359
  this.publish = publish;
26439
27360
  this.maxStreams = maxStreams;
26440
27361
  this.logger = logger;
@@ -27671,9 +28592,6 @@ var CodexWorkspaceBridge = class {
27671
28592
  terminals;
27672
28593
  ownedSubscribers = /* @__PURE__ */ new Set();
27673
28594
  spawnTerminal;
27674
- isCodeXScope(value) {
27675
- return parseCodexSessionId(value) !== void 0;
27676
- }
27677
28595
  async call(endpoint, payload, signal) {
27678
28596
  const args = argsOf(payload);
27679
28597
  const scope = args.workspaceFileScopeId;
@@ -28113,7 +29031,7 @@ var HostPluginRuntime = class {
28113
29031
  this.harnessVersion,
28114
29032
  new TerminalPolicy(() => this.terminalEnabled, context.peerDeviceId, this.terminalOwners),
28115
29033
  new CodexWorkspaceBridge(
28116
- (threadId, signal) => this.codex.resolveThreadWorkspace(context.connectionId, threadId),
29034
+ (threadId) => this.codex.resolveThreadWorkspace(context.connectionId, threadId),
28117
29035
  () => this.terminalEnabled,
28118
29036
  this.codexWorkspaceState,
28119
29037
  this.terminalSpawner
@@ -28258,9 +29176,15 @@ var HostPluginRuntime = class {
28258
29176
  * user who only wants to stop being remotely reachable would have to authorize
28259
29177
  * again and would consume a device identity. Pausing closes the connection and
28260
29178
  * keeps both.
29179
+ *
29180
+ * The Server's own flag is written here as well, not only by the panel's authorization endpoint:
29181
+ * it is what makes the device read as "not accepting control" to other clients and what makes the
29182
+ * Server refuse the next `role=host` hello. Leaving it to one entry point is how a device could
29183
+ * look online and controlling while its switch said off.
28261
29184
  */
28262
29185
  async pauseHostConnection() {
28263
29186
  this.paused = true;
29187
+ await this.recordHostControl(false);
28264
29188
  await this.serverConnection?.stop();
28265
29189
  this.logger.info("Host connection paused");
28266
29190
  }
@@ -28268,18 +29192,51 @@ var HostPluginRuntime = class {
28268
29192
  async resumeHostConnection() {
28269
29193
  const wasPaused = this.paused;
28270
29194
  this.paused = false;
29195
+ await this.recordHostControl(true);
28271
29196
  this.serverConnection?.resume();
28272
29197
  if (wasPaused) this.logger.info("Host connection resumed");
28273
29198
  }
28274
- isPaused() {
28275
- return this.paused;
29199
+ /**
29200
+ * Mirror the control switch onto the Server.
29201
+ *
29202
+ * A machine that is signed out has no credential to write it with, and that is not an error: the
29203
+ * local pause still holds, and the next sign-in writes the flag from the switch.
29204
+ * @param enabled - whether this machine accepts control.
29205
+ */
29206
+ async recordHostControl(enabled) {
29207
+ try {
29208
+ await this.serverApi?.setHostControl(enabled);
29209
+ } catch (error) {
29210
+ this.logger.warn("could not record the control switch on the Server", { code: errorCode5(error) });
29211
+ }
29212
+ }
29213
+ /** Re-read this device's credentials from disk, so a sign-in is picked up without a restart. */
29214
+ reloadCredentials() {
29215
+ this.serverApi?.reloadCredentials();
29216
+ }
29217
+ /**
29218
+ * Record whether this machine accepts control, on the Server.
29219
+ *
29220
+ * This is the explicit, failing form, used when the panel asks for the switch and must be told if the
29221
+ * Server refused. {@link pauseHostConnection} and {@link resumeHostConnection} write the same flag
29222
+ * best-effort, so the two stay consistent whichever entry point the caller used.
29223
+ */
29224
+ async setHostControl(enabled) {
29225
+ await this.serverApi?.setHostControl(enabled);
29226
+ this.logger.info(enabled ? "Host control enabled" : "Host control disabled");
28276
29227
  }
28277
29228
  reconnectHost() {
28278
29229
  if (this.closed) throw new Error("remote runtime is closed");
28279
29230
  if (this.serverConnection === void 0) {
28280
29231
  throw new ServerApiError("SERVER_NOT_CONFIGURED", "Configure serverUrl before reconnecting.", false);
28281
29232
  }
28282
- this.paused = false;
29233
+ if (this.paused) {
29234
+ throw new ServerApiError(
29235
+ "CONTROL_DISABLED",
29236
+ 'This device is not accepting control. Turn on "Allow control of this device" first.',
29237
+ false
29238
+ );
29239
+ }
28283
29240
  this.serverConnection.reconnect();
28284
29241
  }
28285
29242
  async startHostOAuthQrLogin(provider) {
@@ -28322,26 +29279,6 @@ var HostPluginRuntime = class {
28322
29279
  }
28323
29280
  this.logger.info("Host authorization cleared");
28324
29281
  }
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
29282
  async authorizeHostWithAccount(email, password) {
28346
29283
  if (this.serverApi === void 0) {
28347
29284
  throw new ServerApiError("SERVER_NOT_CONFIGURED", "Configure serverUrl before signing in.", false);
@@ -28365,6 +29302,22 @@ var HostPluginRuntime = class {
28365
29302
  if (revoked) await this.connections.revoke(deviceId);
28366
29303
  return revoked;
28367
29304
  }
29305
+ /**
29306
+ * Restart the Codex connection so a changed setting can apply without restarting DSH.
29307
+ *
29308
+ * The domain is restarted in place, which keeps every connection's Codex bridge alive across the change.
29309
+ * Replacing the instance instead (close the old, build a new one) left each attached client holding a bridge
29310
+ * that belonged to a retired domain: every later Codex call answered "The Codex connection is closed" until
29311
+ * that client happened to reconnect on its own - and nothing in the panel could bring it back.
29312
+ * @returns when the restart attempt finished.
29313
+ */
29314
+ async restartCodex(config) {
29315
+ await this.codex.restart(config);
29316
+ this.logger.info("Codex Remote domain restarted", {
29317
+ enabled: config.enabled,
29318
+ available: this.codex.isAvailable()
29319
+ });
29320
+ }
28368
29321
  codexStatus() {
28369
29322
  return this.codex.status();
28370
29323
  }
@@ -28428,12 +29381,12 @@ var HostPluginRuntime = class {
28428
29381
  }
28429
29382
  async readHarnessVersion() {
28430
29383
  let reportedVersion;
28431
- let errorCode6;
29384
+ let errorCode7;
28432
29385
  try {
28433
29386
  const response = await this.apiProxy?.host.describe({ rpcId: randomUUID4(), payload: {} });
28434
29387
  if (response === void 0) throw new Error("ApiProxy is unavailable");
28435
29388
  if (!response.result.ok) {
28436
- errorCode6 = response.result.error.code;
29389
+ errorCode7 = response.result.error.code;
28437
29390
  } else {
28438
29391
  reportedVersion = normalizeHarnessVersion(response.result.value.version);
28439
29392
  }
@@ -28442,7 +29395,7 @@ var HostPluginRuntime = class {
28442
29395
  const distributionVersion = reportedVersion === void 0 || reportedVersion === "0.0.1" ? await readHarnessDistributionVersion() : void 0;
28443
29396
  const version = selectHarnessVersion(reportedVersion, distributionVersion);
28444
29397
  if (version !== void 0) return version;
28445
- this.logger.warn("Harness version is unavailable", errorCode6 === void 0 ? void 0 : { code: errorCode6 });
29398
+ this.logger.warn("Harness version is unavailable", errorCode7 === void 0 ? void 0 : { code: errorCode7 });
28446
29399
  return void 0;
28447
29400
  }
28448
29401
  hostCapabilities() {
@@ -28493,6 +29446,10 @@ var HostPluginRuntime = class {
28493
29446
  function shortId5(value) {
28494
29447
  return value.length <= 12 ? value : `${value.slice(0, 8)}\u2026${value.slice(-4)}`;
28495
29448
  }
29449
+ function errorCode5(error) {
29450
+ const own = error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
29451
+ return own ?? safeErrorCode(error);
29452
+ }
28496
29453
  function isPlainRecord(value) {
28497
29454
  return typeof value === "object" && value !== null && !Array.isArray(value);
28498
29455
  }
@@ -28819,7 +29776,7 @@ function installTuiRemoteCommand(ctx, resolveTarget) {
28819
29776
  } catch (error) {
28820
29777
  return {
28821
29778
  kind: "error",
28822
- text: `Local Remote Host credentials were cleared, but Server revocation failed (${errorCode5(error)}).`
29779
+ text: `Local Remote Host credentials were cleared, but Server revocation failed (${errorCode6(error)}).`
28823
29780
  };
28824
29781
  }
28825
29782
  }
@@ -28875,7 +29832,7 @@ function registerOptional(ctx, feature, register2) {
28875
29832
  return register2() ?? (() => {
28876
29833
  });
28877
29834
  } catch (error) {
28878
- ctx.logger.warn(`dsh-TUI Remote ${feature} is unavailable`, { code: errorCode5(error) });
29835
+ ctx.logger.warn(`dsh-TUI Remote ${feature} is unavailable`, { code: errorCode6(error) });
28879
29836
  return () => {
28880
29837
  };
28881
29838
  }
@@ -28988,7 +29945,7 @@ var RemoteLoginController = class {
28988
29945
  await this.poll(attempt, provider, session, target2.runtime);
28989
29946
  } catch (error) {
28990
29947
  if (attempt !== this.attempt) return;
28991
- this.update({ phase: "error", provider, error: errorCode5(error) });
29948
+ this.update({ phase: "error", provider, error: errorCode6(error) });
28992
29949
  }
28993
29950
  }
28994
29951
  async poll(attempt, provider, session, runtime) {
@@ -29106,7 +30063,7 @@ function terminalLink2(url) {
29106
30063
  function printableWidth(value) {
29107
30064
  return value.replace(/\u001B\[[0-9;]*m/gu, "").length;
29108
30065
  }
29109
- function errorCode5(error) {
30066
+ function errorCode6(error) {
29110
30067
  if (error instanceof ServerApiError) return error.code;
29111
30068
  if (error instanceof Error && "code" in error && typeof error.code === "string") return error.code;
29112
30069
  return "CONNECTION_FAILED";
@@ -29281,9 +30238,8 @@ async function activate(ctx, readConfig, entryId, tuiBinding) {
29281
30238
  console.error(message);
29282
30239
  }
29283
30240
  }, config.logLevel);
29284
- const hostIdentities = new IdentityStore({
29285
- directory: config.serverUrl === void 0 ? defaultIdentityDirectory : serverStorageDirectory(defaultIdentityDirectory, config.serverUrl, "host")
29286
- });
30241
+ const deviceDirectory = config.serverUrl === void 0 ? defaultIdentityDirectory : await ensureDeviceDirectory(defaultIdentityDirectory, config.serverUrl);
30242
+ const hostIdentities = new IdentityStore({ directory: deviceDirectory });
29287
30243
  const apiProxy = ctx.get("apiProxy");
29288
30244
  const nativeTypertGateway = ctx.get("typertGateway");
29289
30245
  const localTypertGateway = new TypertGatewaySwitch(nativeTypertGateway).local();
@@ -29324,9 +30280,7 @@ async function activate(ctx, readConfig, entryId, tuiBinding) {
29324
30280
  let clientRuntime;
29325
30281
  const hostControl = runtime;
29326
30282
  if (config.serverUrl !== void 0 && connection !== void 0) {
29327
- const clientIdentities = new IdentityStore({
29328
- directory: serverStorageDirectory(defaultIdentityDirectory, config.serverUrl, "client")
29329
- });
30283
+ const clientIdentities = hostIdentities;
29330
30284
  clientRuntime = new ClientModeRuntime(
29331
30285
  config,
29332
30286
  clientIdentities,