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.
- package/README.md +110 -140
- package/bin/wechat-claude.mjs +1 -1
- package/bin/wechat-codex.mjs +1 -1
- package/bin/wechat-opencode.mjs +1 -1
- package/bin/wechat-pi.mjs +1 -1
- package/dist/bridge/bridge-adapters.claude.js +448 -11
- package/dist/bridge/bridge-adapters.codex.js +262 -11
- package/dist/bridge/bridge-adapters.core.js +76 -4
- package/dist/bridge/bridge-adapters.js +0 -3
- package/dist/bridge/bridge-adapters.opencode.js +497 -49
- package/dist/bridge/bridge-adapters.pi.js +44 -8
- package/dist/bridge/bridge-adapters.shared.js +13 -100
- package/dist/bridge/bridge-controller.js +3 -0
- package/dist/bridge/bridge-final-reply.js +17 -2
- package/dist/bridge/bridge-process-reaper.js +14 -4
- package/dist/bridge/bridge-session-resume.js +151 -0
- package/dist/bridge/bridge-state.js +41 -6
- package/dist/bridge/bridge-utils.js +63 -229
- package/dist/bridge/claude-hooks.js +1 -0
- package/dist/bridge/wechat-bridge.js +93 -140
- package/dist/bridge/wechat-forwarding.js +58 -0
- package/dist/companion/codex-remote-client.js +309 -36
- package/dist/companion/codex-visible-client-link.js +122 -0
- package/dist/companion/local-companion-link.js +33 -2
- package/dist/companion/local-companion-start.js +44 -13
- package/dist/companion/local-companion.js +6 -3
- package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
- package/dist/companion/pi-tui-bridge-extension.js +42 -3
- package/dist/daemon/daemon-link.js +25 -3
- package/dist/daemon/wechat-daemon.js +168 -56
- package/dist/i18n/index.js +0 -3
- package/dist/i18n/messages-en.js +2 -3
- package/dist/i18n/messages-zh.js +2 -3
- package/dist/utils/atomic-file.js +15 -0
- package/dist/utils/doctor.js +23 -11
- package/dist/wechat/channel-config.js +0 -2
- package/dist/wechat/setup.js +5 -11
- package/dist/wechat/wechat-transport.js +14 -8
- package/package.json +2 -13
- package/bin/wechat-bridge-claude.mjs +0 -5
- package/bin/wechat-bridge-codex.mjs +0 -5
- package/bin/wechat-bridge-opencode.mjs +0 -5
- package/bin/wechat-bridge-pi.mjs +0 -5
- package/bin/wechat-bridge-shell.mjs +0 -5
- package/bin/wechat-bridge.mjs +0 -5
- package/bin/wechat-claude-start.mjs +0 -5
- package/bin/wechat-codex-start.mjs +0 -5
- package/bin/wechat-opencode-start.mjs +0 -5
- package/bin/wechat-pi-start.mjs +0 -5
- 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 {
|
|
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
|
-
"
|
|
29
|
+
"Internal Codex client usage: npm run codex:panel -- [--cwd <path>] [--session-start-mode <restore|new>] [...codex args]",
|
|
23
30
|
"",
|
|
24
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|