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