@unlinearity/cli-wechat-bridge 1.1.3 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +110 -140
  2. package/bin/wechat-claude.mjs +1 -1
  3. package/bin/wechat-codex.mjs +1 -1
  4. package/bin/wechat-opencode.mjs +1 -1
  5. package/bin/wechat-pi.mjs +1 -1
  6. package/dist/bridge/bridge-adapters.claude.js +448 -11
  7. package/dist/bridge/bridge-adapters.codex.js +262 -11
  8. package/dist/bridge/bridge-adapters.core.js +76 -4
  9. package/dist/bridge/bridge-adapters.js +0 -3
  10. package/dist/bridge/bridge-adapters.opencode.js +497 -49
  11. package/dist/bridge/bridge-adapters.pi.js +44 -8
  12. package/dist/bridge/bridge-adapters.shared.js +13 -100
  13. package/dist/bridge/bridge-controller.js +3 -0
  14. package/dist/bridge/bridge-final-reply.js +17 -2
  15. package/dist/bridge/bridge-process-reaper.js +14 -4
  16. package/dist/bridge/bridge-session-resume.js +151 -0
  17. package/dist/bridge/bridge-state.js +41 -6
  18. package/dist/bridge/bridge-utils.js +63 -229
  19. package/dist/bridge/claude-hooks.js +1 -0
  20. package/dist/bridge/wechat-bridge.js +93 -140
  21. package/dist/bridge/wechat-forwarding.js +58 -0
  22. package/dist/companion/codex-remote-client.js +309 -36
  23. package/dist/companion/codex-visible-client-link.js +122 -0
  24. package/dist/companion/local-companion-link.js +33 -2
  25. package/dist/companion/local-companion-start.js +44 -13
  26. package/dist/companion/local-companion.js +6 -3
  27. package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
  28. package/dist/companion/pi-tui-bridge-extension.js +42 -3
  29. package/dist/daemon/daemon-link.js +25 -3
  30. package/dist/daemon/wechat-daemon.js +168 -56
  31. package/dist/i18n/index.js +0 -3
  32. package/dist/i18n/messages-en.js +2 -3
  33. package/dist/i18n/messages-zh.js +2 -3
  34. package/dist/utils/atomic-file.js +15 -0
  35. package/dist/utils/doctor.js +23 -11
  36. package/dist/wechat/channel-config.js +0 -2
  37. package/dist/wechat/setup.js +5 -11
  38. package/dist/wechat/wechat-transport.js +14 -8
  39. package/package.json +5 -12
  40. package/scripts/ensure-node-pty-permissions.mjs +73 -0
  41. package/bin/wechat-bridge-claude.mjs +0 -5
  42. package/bin/wechat-bridge-codex.mjs +0 -5
  43. package/bin/wechat-bridge-opencode.mjs +0 -5
  44. package/bin/wechat-bridge-pi.mjs +0 -5
  45. package/bin/wechat-bridge-shell.mjs +0 -5
  46. package/bin/wechat-bridge.mjs +0 -5
  47. package/bin/wechat-claude-start.mjs +0 -5
  48. package/bin/wechat-codex-start.mjs +0 -5
  49. package/bin/wechat-opencode-start.mjs +0 -5
  50. package/bin/wechat-pi-start.mjs +0 -5
  51. package/dist/bridge/bridge-adapters.shell.js +0 -363
@@ -1,15 +1,22 @@
1
1
  #!/usr/bin/env node
2
+ import crypto from "node:crypto";
2
3
  import { spawn } from "node:child_process";
4
+ import net from "node:net";
3
5
  import path from "node:path";
4
6
  import { assertNoReservedExtraCliArgs, buildCliEnvironment, buildCodexCliArgs, resolveSpawnTarget, } from "../bridge/bridge-adapters.shared.js";
5
- import { clearLocalCompanionOccupancy, readLocalCompanionEndpoint, updateLocalCompanionOccupancy, } from "./local-companion-link.js";
7
+ import { killProcessTreeSync } from "../bridge/bridge-process-reaper.js";
8
+ import { CODEX_VISIBLE_CONTROL_HOST, parseCodexVisibleSwitchRequest, sendCodexVisibleSwitchResponse, } from "./codex-visible-client-link.js";
9
+ import { clearLocalCompanionOccupancy, readLocalCompanionEndpoint, updateCodexVisibleControl, updateLocalCompanionOccupancy, } from "./local-companion-link.js";
6
10
  import { migrateLegacyChannelFiles } from "../wechat/channel-config.js";
7
11
  import { CODEX_REMOTE_AUTH_TOKEN_ENV } from "../runtime/runtime-types.js";
12
+ const CODEX_VISIBLE_SWITCH_SETTLE_MS = 500;
13
+ const CODEX_VISIBLE_CHILD_STOP_TIMEOUT_MS = 2_000;
8
14
  function log(message) {
9
15
  process.stderr.write(`[codex-remote-client] ${message}\n`);
10
16
  }
11
17
  export function parseCliArgs(argv) {
12
18
  let cwd = process.cwd();
19
+ let sessionStartMode;
13
20
  const cliArgs = [];
14
21
  for (let i = 0; i < argv.length; i += 1) {
15
22
  const arg = argv[i];
@@ -19,9 +26,10 @@ export function parseCliArgs(argv) {
19
26
  const next = argv[i + 1];
20
27
  if (arg === "--help" || arg === "-h") {
21
28
  process.stdout.write([
22
- "Usage: wechat-codex [--cwd <path>] [...codex args]",
29
+ "Internal Codex client usage: npm run codex:panel -- [--cwd <path>] [--session-start-mode <restore|new>] [...codex args]",
23
30
  "",
24
- 'Starts the visible native Codex client and connects it to the running "wechat-bridge-codex" instance for the current directory.',
31
+ "Connects the visible native Codex client to the bridge runtime for the current directory.",
32
+ "Use --session-start-mode new to open the client without resuming the shared thread.",
25
33
  "Unknown arguments are forwarded to the Codex client.",
26
34
  "",
27
35
  ].join("\n"));
@@ -35,17 +43,25 @@ export function parseCliArgs(argv) {
35
43
  i += 1;
36
44
  continue;
37
45
  }
46
+ if (arg === "--session-start-mode") {
47
+ if (next !== "restore" && next !== "new") {
48
+ throw new Error(`Invalid --session-start-mode value: ${next ?? "(missing)"}`);
49
+ }
50
+ sessionStartMode = next;
51
+ i += 1;
52
+ continue;
53
+ }
38
54
  cliArgs.push(arg);
39
55
  }
40
- return { cwd, cliArgs };
56
+ return { cwd, cliArgs, sessionStartMode };
41
57
  }
42
58
  export function readCodexRuntimeEndpoint(cwd) {
43
59
  const endpoint = readLocalCompanionEndpoint(cwd, { adapter: "codex" });
44
60
  if (!endpoint || endpoint.kind !== "codex") {
45
- throw new Error(`No active Codex bridge endpoint was found for ${cwd}. Start "wechat-bridge-codex" in that directory first.`);
61
+ throw new Error(`No active Codex runtime was found for ${cwd}. Run "wechat-codex" in that directory to recreate it.`);
46
62
  }
47
63
  if (endpoint.runtimeKind !== "codex_runtime_host" || (!endpoint.serverUrl && !endpoint.serverPort)) {
48
- throw new Error(`The running Codex bridge for ${cwd} is using an older local companion protocol. Restart "wechat-bridge-codex" in that directory first.`);
64
+ throw new Error(`The running Codex runtime for ${cwd} uses an older local client protocol. Run "wechat-codex" in that directory to replace it.`);
49
65
  }
50
66
  return endpoint;
51
67
  }
@@ -55,7 +71,11 @@ export function buildRemoteCodexClientArgs(endpoint, options = {}) {
55
71
  const remoteUrl = endpoint.serverUrl ?? `ws://127.0.0.1:${endpoint.serverPort ?? endpoint.port}`;
56
72
  const args = buildCodexCliArgs(remoteUrl, {
57
73
  profile: endpoint.profile,
58
- resumeThreadId: endpoint.sharedThreadId,
74
+ // "new" skips resume so the visible client opens a fresh Codex session
75
+ // instead of rejoining the shared thread.
76
+ resumeThreadId: options.sessionStartMode === "new"
77
+ ? undefined
78
+ : options.resumeThreadId ?? endpoint.sharedThreadId,
59
79
  });
60
80
  const tokenEnvName = endpoint.remoteAuthTokenEnv ?? CODEX_REMOTE_AUTH_TOKEN_ENV;
61
81
  return [...args, "--remote-auth-token-env", tokenEnvName, ...extraCliArgs];
@@ -66,47 +86,300 @@ export function buildRemoteCodexClientEnv(endpoint, env = process.env) {
66
86
  nextEnv[tokenEnvName] = endpoint.token;
67
87
  return nextEnv;
68
88
  }
69
- export async function runCodexRemoteClientFromEndpoint(endpoint, options = {}) {
70
- const spawnTarget = resolveSpawnTarget(endpoint.command, "codex");
71
- const args = buildRemoteCodexClientArgs(endpoint, {
72
- extraCliArgs: options.extraCliArgs,
73
- });
74
- const env = buildRemoteCodexClientEnv(endpoint);
75
- return await new Promise((resolve, reject) => {
76
- const child = spawn(spawnTarget.file, [...spawnTarget.args, ...args], {
77
- cwd: endpoint.cwd,
78
- env,
89
+ export class CodexVisibleClientSupervisor {
90
+ endpoint;
91
+ options;
92
+ spawnProcess;
93
+ stopProcessTree;
94
+ switchSettleMs;
95
+ controlToken = crypto.randomBytes(24).toString("hex");
96
+ childExitPromises = new WeakMap();
97
+ child = null;
98
+ currentThreadId;
99
+ controlServer = null;
100
+ controlPort = null;
101
+ switching = false;
102
+ switchChain = Promise.resolve();
103
+ runSettled = false;
104
+ resolveRun = null;
105
+ rejectRun = null;
106
+ constructor(endpoint, options = {}, dependencies = {}) {
107
+ this.endpoint = endpoint;
108
+ this.options = options;
109
+ this.spawnProcess = dependencies.spawnProcess ?? spawn;
110
+ this.stopProcessTree = dependencies.stopProcessTree ?? killProcessTreeSync;
111
+ this.switchSettleMs = dependencies.switchSettleMs ?? CODEX_VISIBLE_SWITCH_SETTLE_MS;
112
+ this.currentThreadId =
113
+ options.sessionStartMode === "new" ? undefined : endpoint.sharedThreadId;
114
+ }
115
+ async run() {
116
+ await this.startControlServer();
117
+ this.publishControlMetadata();
118
+ try {
119
+ return await new Promise((resolve, reject) => {
120
+ this.resolveRun = resolve;
121
+ this.rejectRun = reject;
122
+ try {
123
+ this.spawnChild(this.currentThreadId);
124
+ }
125
+ catch (error) {
126
+ this.finishRun(error instanceof Error ? error : new Error(String(error)));
127
+ }
128
+ });
129
+ }
130
+ finally {
131
+ await this.closeControlServer();
132
+ updateCodexVisibleControl(this.endpoint.cwd, {
133
+ codexControlPort: undefined,
134
+ codexControlToken: undefined,
135
+ codexVisibleThreadId: undefined,
136
+ }, this.endpoint.instanceId);
137
+ clearLocalCompanionOccupancy(this.endpoint.cwd, this.endpoint.instanceId, {
138
+ adapter: "codex",
139
+ });
140
+ }
141
+ }
142
+ async switchThread(threadId) {
143
+ const targetThreadId = threadId.trim();
144
+ if (!targetThreadId) {
145
+ throw new Error("A Codex thread id is required.");
146
+ }
147
+ const run = this.switchChain.then(() => this.performThreadSwitch(targetThreadId));
148
+ this.switchChain = run.catch(() => undefined);
149
+ await run;
150
+ }
151
+ async performThreadSwitch(targetThreadId) {
152
+ if (this.runSettled) {
153
+ throw new Error("The visible Codex client has already stopped.");
154
+ }
155
+ const endpoint = readLocalCompanionEndpoint(this.endpoint.cwd, { adapter: "codex" });
156
+ const previousThreadId = endpoint?.sharedThreadId ?? this.currentThreadId;
157
+ const previousChild = this.child;
158
+ this.switching = true;
159
+ try {
160
+ if (previousChild) {
161
+ await this.stopChild(previousChild);
162
+ }
163
+ const nextChild = this.spawnChild(targetThreadId);
164
+ await this.waitForStableChild(nextChild, targetThreadId);
165
+ this.currentThreadId = targetThreadId;
166
+ this.publishControlMetadata();
167
+ }
168
+ catch (error) {
169
+ const switchError = error instanceof Error ? error : new Error(String(error));
170
+ if (previousThreadId && previousThreadId !== targetThreadId) {
171
+ try {
172
+ const rollbackChild = this.spawnChild(previousThreadId);
173
+ await this.waitForStableChild(rollbackChild, previousThreadId);
174
+ this.currentThreadId = previousThreadId;
175
+ this.publishControlMetadata();
176
+ }
177
+ catch (rollbackError) {
178
+ throw new Error(`${switchError.message} Failed to restore the previous visible Codex thread: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, { cause: rollbackError });
179
+ }
180
+ }
181
+ throw switchError;
182
+ }
183
+ finally {
184
+ this.switching = false;
185
+ }
186
+ }
187
+ spawnChild(resumeThreadId) {
188
+ const spawnTarget = resolveSpawnTarget(this.endpoint.command, "codex");
189
+ const args = buildRemoteCodexClientArgs(this.endpoint, {
190
+ extraCliArgs: this.options.extraCliArgs,
191
+ sessionStartMode: resumeThreadId ? "restore" : this.options.sessionStartMode,
192
+ resumeThreadId,
193
+ });
194
+ const child = this.spawnProcess(spawnTarget.file, [...spawnTarget.args, ...args], {
195
+ cwd: this.endpoint.cwd,
196
+ env: buildRemoteCodexClientEnv(this.endpoint),
79
197
  stdio: "inherit",
80
198
  windowsHide: false,
81
199
  });
82
- if (typeof child.pid === "number") {
83
- updateLocalCompanionOccupancy(endpoint.cwd, {
84
- companionPid: child.pid,
85
- companionConnectedAt: new Date().toISOString(),
86
- }, endpoint.instanceId, { adapter: "codex" });
87
- }
88
- child.once("error", (error) => {
89
- clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId, {
90
- adapter: "codex",
91
- });
92
- reject(error);
200
+ this.child = child;
201
+ const exitPromise = new Promise((resolve) => {
202
+ let settled = false;
203
+ const finish = (result) => {
204
+ if (settled) {
205
+ return;
206
+ }
207
+ settled = true;
208
+ resolve(result);
209
+ };
210
+ child.once("error", (error) => finish({ code: null, signal: null, error }));
211
+ child.once("exit", (code, signal) => finish({ code, signal }));
93
212
  });
94
- child.once("exit", (code, signal) => {
95
- clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId, {
96
- adapter: "codex",
97
- });
98
- if (signal) {
99
- process.kill(process.pid, signal);
213
+ this.childExitPromises.set(child, exitPromise);
214
+ void exitPromise.then((result) => {
215
+ if (this.child !== child || this.switching) {
216
+ return;
217
+ }
218
+ this.child = null;
219
+ if (result.error) {
220
+ this.finishRun(result.error);
100
221
  return;
101
222
  }
102
- resolve(code ?? 0);
223
+ this.finishRun(undefined, result.code ?? (result.signal ? 1 : 0));
103
224
  });
104
- });
225
+ return child;
226
+ }
227
+ async stopChild(child) {
228
+ if (this.child === child) {
229
+ this.child = null;
230
+ }
231
+ try {
232
+ if (typeof child.pid === "number") {
233
+ this.stopProcessTree(child.pid);
234
+ }
235
+ else {
236
+ child.kill();
237
+ }
238
+ }
239
+ catch {
240
+ // The child may have already exited between the switch request and cleanup.
241
+ }
242
+ const exitPromise = this.childExitPromises.get(child);
243
+ if (!exitPromise) {
244
+ return;
245
+ }
246
+ await Promise.race([
247
+ exitPromise.then(() => undefined),
248
+ new Promise((resolve) => {
249
+ const timer = setTimeout(resolve, CODEX_VISIBLE_CHILD_STOP_TIMEOUT_MS);
250
+ timer.unref?.();
251
+ }),
252
+ ]);
253
+ }
254
+ async waitForStableChild(child, threadId) {
255
+ const exitPromise = this.childExitPromises.get(child);
256
+ const result = await Promise.race([
257
+ new Promise((resolve) => {
258
+ const timer = setTimeout(() => resolve(null), this.switchSettleMs);
259
+ timer.unref?.();
260
+ }),
261
+ exitPromise?.then((exit) => exit) ?? Promise.resolve({
262
+ code: null,
263
+ signal: null,
264
+ error: new Error("The visible Codex process did not expose an exit state."),
265
+ }),
266
+ ]);
267
+ if (result === null) {
268
+ return;
269
+ }
270
+ if (this.child === child) {
271
+ this.child = null;
272
+ }
273
+ const detail = result.error
274
+ ? result.error.message
275
+ : result.signal
276
+ ? `signal ${result.signal}`
277
+ : `code ${result.code ?? "unknown"}`;
278
+ throw new Error(`The visible Codex client exited while opening ${threadId} (${detail}).`);
279
+ }
280
+ async startControlServer() {
281
+ const server = net.createServer((socket) => {
282
+ let buffer = "";
283
+ socket.setEncoding("utf8");
284
+ socket.setTimeout(20_000);
285
+ socket.on("data", (chunk) => {
286
+ buffer += chunk;
287
+ if (buffer.length > 65_536) {
288
+ socket.destroy();
289
+ return;
290
+ }
291
+ const newlineIndex = buffer.indexOf("\n");
292
+ if (newlineIndex < 0) {
293
+ return;
294
+ }
295
+ const line = buffer.slice(0, newlineIndex).trim();
296
+ let parsed;
297
+ try {
298
+ parsed = JSON.parse(line);
299
+ }
300
+ catch {
301
+ socket.destroy();
302
+ return;
303
+ }
304
+ const request = parseCodexVisibleSwitchRequest(parsed);
305
+ if (!request || request.token !== this.controlToken) {
306
+ socket.destroy();
307
+ return;
308
+ }
309
+ void this.switchThread(request.threadId).then(() => {
310
+ sendCodexVisibleSwitchResponse(socket, {
311
+ type: "switch_thread_result",
312
+ id: request.id,
313
+ ok: true,
314
+ threadId: request.threadId,
315
+ });
316
+ }, (error) => {
317
+ sendCodexVisibleSwitchResponse(socket, {
318
+ type: "switch_thread_result",
319
+ id: request.id,
320
+ ok: false,
321
+ error: error instanceof Error ? error.message : String(error),
322
+ });
323
+ });
324
+ });
325
+ socket.once("timeout", () => socket.destroy());
326
+ });
327
+ await new Promise((resolve, reject) => {
328
+ server.once("error", reject);
329
+ server.listen(0, CODEX_VISIBLE_CONTROL_HOST, () => {
330
+ server.off("error", reject);
331
+ resolve();
332
+ });
333
+ });
334
+ const address = server.address();
335
+ if (!address || typeof address === "string") {
336
+ server.close();
337
+ throw new Error("Could not create the visible Codex control endpoint.");
338
+ }
339
+ this.controlServer = server;
340
+ this.controlPort = address.port;
341
+ }
342
+ async closeControlServer() {
343
+ const server = this.controlServer;
344
+ this.controlServer = null;
345
+ this.controlPort = null;
346
+ if (!server) {
347
+ return;
348
+ }
349
+ await new Promise((resolve) => server.close(() => resolve()));
350
+ }
351
+ publishControlMetadata() {
352
+ updateLocalCompanionOccupancy(this.endpoint.cwd, {
353
+ companionPid: process.pid,
354
+ companionConnectedAt: new Date().toISOString(),
355
+ }, this.endpoint.instanceId, { adapter: "codex" });
356
+ updateCodexVisibleControl(this.endpoint.cwd, {
357
+ codexControlPort: this.controlPort ?? undefined,
358
+ codexControlToken: this.controlToken,
359
+ codexVisibleThreadId: this.currentThreadId,
360
+ }, this.endpoint.instanceId);
361
+ }
362
+ finishRun(error, exitCode = 0) {
363
+ if (this.runSettled) {
364
+ return;
365
+ }
366
+ this.runSettled = true;
367
+ if (error) {
368
+ this.rejectRun?.(error);
369
+ }
370
+ else {
371
+ this.resolveRun?.(exitCode);
372
+ }
373
+ }
374
+ }
375
+ export async function runCodexRemoteClientFromEndpoint(endpoint, options = {}) {
376
+ return await new CodexVisibleClientSupervisor(endpoint, options).run();
105
377
  }
106
378
  export async function runCodexRemoteClient(options) {
107
379
  const endpoint = readCodexRuntimeEndpoint(options.cwd);
108
380
  return await runCodexRemoteClientFromEndpoint(endpoint, {
109
381
  extraCliArgs: options.cliArgs,
382
+ sessionStartMode: options.sessionStartMode,
110
383
  });
111
384
  }
112
385
  async function main() {
@@ -0,0 +1,122 @@
1
+ import crypto from "node:crypto";
2
+ import net from "node:net";
3
+ import { readLocalCompanionEndpoint, } from "./local-companion-link.js";
4
+ export const CODEX_VISIBLE_CONTROL_HOST = "127.0.0.1";
5
+ export const CODEX_VISIBLE_SWITCH_TIMEOUT_MS = 15_000;
6
+ function isRecord(value) {
7
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
8
+ }
9
+ export function parseCodexVisibleSwitchRequest(value) {
10
+ if (!isRecord(value)) {
11
+ return null;
12
+ }
13
+ if (value.type !== "switch_thread" ||
14
+ typeof value.id !== "string" ||
15
+ typeof value.token !== "string" ||
16
+ typeof value.threadId !== "string") {
17
+ return null;
18
+ }
19
+ return {
20
+ type: "switch_thread",
21
+ id: value.id,
22
+ token: value.token,
23
+ threadId: value.threadId,
24
+ };
25
+ }
26
+ export function sendCodexVisibleSwitchResponse(socket, response) {
27
+ if (socket.destroyed || socket.writableEnded) {
28
+ return;
29
+ }
30
+ socket.end(`${JSON.stringify(response)}\n`);
31
+ }
32
+ function resolveCodexControlEndpoint(cwd, instanceId) {
33
+ const endpoint = readLocalCompanionEndpoint(cwd, { adapter: "codex" });
34
+ if (!endpoint || endpoint.kind !== "codex" || endpoint.instanceId !== instanceId) {
35
+ throw new Error(`The visible Codex client for ${cwd} is no longer attached to this runtime.`);
36
+ }
37
+ if (!endpoint.codexControlPort || !endpoint.codexControlToken) {
38
+ throw new Error('The visible Codex client does not support remote thread switching. Close it and run "wechat-codex" again.');
39
+ }
40
+ return endpoint;
41
+ }
42
+ export async function requestCodexVisibleThreadSwitch(params) {
43
+ const endpoint = resolveCodexControlEndpoint(params.cwd, params.instanceId);
44
+ const requestId = crypto.randomUUID();
45
+ const timeoutMs = params.timeoutMs ?? CODEX_VISIBLE_SWITCH_TIMEOUT_MS;
46
+ return await new Promise((resolve, reject) => {
47
+ const socket = net.connect({
48
+ host: CODEX_VISIBLE_CONTROL_HOST,
49
+ port: endpoint.codexControlPort,
50
+ });
51
+ let buffer = "";
52
+ let settled = false;
53
+ const finish = (error, threadId) => {
54
+ if (settled) {
55
+ return;
56
+ }
57
+ settled = true;
58
+ socket.destroy();
59
+ if (error) {
60
+ reject(error);
61
+ }
62
+ else {
63
+ resolve(threadId ?? params.threadId);
64
+ }
65
+ };
66
+ socket.setEncoding("utf8");
67
+ socket.setTimeout(timeoutMs);
68
+ socket.once("connect", () => {
69
+ const request = {
70
+ type: "switch_thread",
71
+ id: requestId,
72
+ token: endpoint.codexControlToken,
73
+ threadId: params.threadId,
74
+ };
75
+ socket.write(`${JSON.stringify(request)}\n`);
76
+ });
77
+ socket.on("data", (chunk) => {
78
+ buffer += chunk;
79
+ if (buffer.length > 65_536) {
80
+ finish(new Error("The visible Codex client returned an oversized control response."));
81
+ return;
82
+ }
83
+ const newlineIndex = buffer.indexOf("\n");
84
+ if (newlineIndex < 0) {
85
+ return;
86
+ }
87
+ const line = buffer.slice(0, newlineIndex).trim();
88
+ let parsed;
89
+ try {
90
+ parsed = JSON.parse(line);
91
+ }
92
+ catch {
93
+ finish(new Error("The visible Codex client returned an invalid control response."));
94
+ return;
95
+ }
96
+ if (!isRecord(parsed) || parsed.type !== "switch_thread_result" || parsed.id !== requestId) {
97
+ finish(new Error("The visible Codex client returned an unexpected control response."));
98
+ return;
99
+ }
100
+ if (parsed.ok !== true) {
101
+ finish(new Error(typeof parsed.error === "string"
102
+ ? parsed.error
103
+ : "The visible Codex client could not switch threads."));
104
+ return;
105
+ }
106
+ if (parsed.threadId !== params.threadId) {
107
+ finish(new Error(`The visible Codex client opened ${String(parsed.threadId)}, expected ${params.threadId}.`));
108
+ return;
109
+ }
110
+ finish(undefined, parsed.threadId);
111
+ });
112
+ socket.once("timeout", () => {
113
+ finish(new Error(`Timed out waiting for the visible Codex client to open ${params.threadId}.`));
114
+ });
115
+ socket.once("error", (error) => finish(error));
116
+ socket.once("close", () => {
117
+ if (!settled) {
118
+ finish(new Error("The visible Codex client closed the control connection."));
119
+ }
120
+ });
121
+ });
122
+ }
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import { ensureWorkspaceChannelDir, getWorkspaceAdapterEndpointFile, getWorkspaceChannelPaths, } from "../wechat/channel-config.js";
4
+ import { writeJsonFileAtomic } from "../utils/atomic-file.js";
4
5
  import { LOCAL_CLIENT_PROTOCOL_VERSION, } from "../runtime/runtime-types.js";
5
6
  function normalizeEndpoint(value) {
6
7
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -46,6 +47,9 @@ function normalizeEndpoint(value) {
46
47
  serverPort: typeof record.serverPort === "number" ? record.serverPort : undefined,
47
48
  serverUrl: typeof record.serverUrl === "string" ? record.serverUrl : undefined,
48
49
  remoteAuthTokenEnv: typeof record.remoteAuthTokenEnv === "string" ? record.remoteAuthTokenEnv : undefined,
50
+ codexControlPort: typeof record.codexControlPort === "number" ? record.codexControlPort : undefined,
51
+ codexControlToken: typeof record.codexControlToken === "string" ? record.codexControlToken : undefined,
52
+ codexVisibleThreadId: typeof record.codexVisibleThreadId === "string" ? record.codexVisibleThreadId : undefined,
49
53
  cwd: record.cwd,
50
54
  command: record.command,
51
55
  profile: typeof record.profile === "string" ? record.profile : undefined,
@@ -90,9 +94,13 @@ export function writeLocalCompanionEndpoint(endpoint, options = {}) {
90
94
  const payload = {
91
95
  ...serializeEndpoint(endpoint),
92
96
  };
93
- fs.writeFileSync(getWorkspaceAdapterEndpointFile(endpoint.cwd, endpoint.kind), JSON.stringify(payload, null, 2), "utf8");
97
+ // Atomic writes: the endpoint files are rewritten on every adapter event
98
+ // while companions poll them concurrently; a torn write reads as JSON.parse
99
+ // failure → "no endpoint" → healthy bridges get restarted or duplicate
100
+ // clients launched.
101
+ writeJsonFileAtomic(getWorkspaceAdapterEndpointFile(endpoint.cwd, endpoint.kind), payload);
94
102
  if (options.writeLegacy !== false) {
95
- fs.writeFileSync(endpointFile, JSON.stringify(payload, null, 2), "utf8");
103
+ writeJsonFileAtomic(endpointFile, payload);
96
104
  }
97
105
  }
98
106
  export function readLocalCompanionEndpoint(cwd, options = {}) {
@@ -159,6 +167,9 @@ export function clearLocalCompanionOccupancy(cwd, instanceId, options = {}) {
159
167
  companionStatus: undefined,
160
168
  companionLastStateAt: undefined,
161
169
  companionWorkerPid: undefined,
170
+ codexControlPort: undefined,
171
+ codexControlToken: undefined,
172
+ codexVisibleThreadId: undefined,
162
173
  });
163
174
  }
164
175
  catch {
@@ -204,6 +215,26 @@ export function updateLocalCompanionOccupancy(cwd, patch, instanceId, options =
204
215
  // Best effort cleanup.
205
216
  }
206
217
  }
218
+ export function updateCodexVisibleControl(cwd, patch, instanceId) {
219
+ try {
220
+ const endpoint = readLocalCompanionEndpoint(cwd, { adapter: "codex" });
221
+ if (!endpoint || endpoint.kind !== "codex") {
222
+ return;
223
+ }
224
+ if (instanceId && endpoint.instanceId !== instanceId) {
225
+ return;
226
+ }
227
+ writeLocalCompanionEndpoint({
228
+ ...endpoint,
229
+ codexControlPort: patch.codexControlPort,
230
+ codexControlToken: patch.codexControlToken,
231
+ codexVisibleThreadId: patch.codexVisibleThreadId,
232
+ });
233
+ }
234
+ catch {
235
+ // Best effort visible Codex control metadata update.
236
+ }
237
+ }
207
238
  export function sendLocalCompanionMessage(socket, message) {
208
239
  if (socket.destroyed || socket.writableEnded) {
209
240
  return false;