cli-wechat-bridge 1.1.4 → 1.1.6
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 +153 -131
- package/bin/_run-entry.mjs +20 -17
- package/dist/bridge/bridge-adapters.claude.js +429 -11
- package/dist/bridge/bridge-adapters.codex.js +441 -22
- package/dist/bridge/bridge-adapters.core.js +2 -1
- package/dist/bridge/bridge-adapters.opencode.js +392 -53
- package/dist/bridge/bridge-adapters.pi.js +33 -7
- package/dist/bridge/bridge-adapters.shared.js +2 -1
- package/dist/bridge/bridge-controller.js +3 -0
- package/dist/bridge/bridge-final-reply.js +2 -65
- package/dist/bridge/bridge-session-resume.js +151 -0
- package/dist/bridge/bridge-state.js +1 -1
- package/dist/bridge/bridge-utils.js +30 -101
- package/dist/bridge/claude-hooks.js +1 -0
- package/dist/bridge/wechat-bridge.js +256 -211
- package/dist/bridge/wechat-forwarding.js +2 -58
- package/dist/bridge/wechat-outbound-queue.js +2 -0
- package/dist/channels/wechat/bridge-final-reply.js +65 -0
- package/dist/channels/wechat/channel-message.js +24 -0
- package/dist/channels/wechat/index.js +5 -0
- package/dist/channels/wechat/wechat-channel-port.js +67 -0
- package/dist/channels/wechat/wechat-forwarding.js +79 -0
- package/dist/channels/wechat/wechat-outbound-queue.js +72 -0
- package/dist/companion/codex-remote-client.js +291 -32
- package/dist/companion/codex-visible-client-link.js +122 -0
- package/dist/companion/local-companion-link.js +26 -0
- package/dist/companion/local-companion-start.js +0 -1
- package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
- package/dist/companion/pi-tui-bridge-extension.js +42 -3
- package/dist/core/bridge-control.js +32 -0
- package/dist/core/bridge-event-forwarder.js +50 -0
- package/dist/core/bridge-message-router.js +32 -0
- package/dist/core/channel-types.js +1 -0
- package/dist/core/text-utils.js +63 -0
- package/dist/daemon/wechat-daemon.js +242 -159
- package/dist/i18n/messages-en.js +2 -2
- package/dist/i18n/messages-zh.js +2 -2
- package/package.json +105 -109
- package/bin/wechat-claude-start.mjs +0 -10
- package/bin/wechat-codex-start.mjs +0 -10
- package/bin/wechat-opencode-start.mjs +0 -10
- package/bin/wechat-pi-start.mjs +0 -10
|
@@ -1,10 +1,16 @@
|
|
|
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
|
}
|
|
@@ -67,7 +73,9 @@ export function buildRemoteCodexClientArgs(endpoint, options = {}) {
|
|
|
67
73
|
profile: endpoint.profile,
|
|
68
74
|
// "new" skips resume so the visible client opens a fresh Codex session
|
|
69
75
|
// instead of rejoining the shared thread.
|
|
70
|
-
resumeThreadId: options.sessionStartMode === "new"
|
|
76
|
+
resumeThreadId: options.sessionStartMode === "new"
|
|
77
|
+
? undefined
|
|
78
|
+
: options.resumeThreadId ?? endpoint.sharedThreadId,
|
|
71
79
|
});
|
|
72
80
|
const tokenEnvName = endpoint.remoteAuthTokenEnv ?? CODEX_REMOTE_AUTH_TOKEN_ENV;
|
|
73
81
|
return [...args, "--remote-auth-token-env", tokenEnvName, ...extraCliArgs];
|
|
@@ -78,43 +86,294 @@ export function buildRemoteCodexClientEnv(endpoint, env = process.env) {
|
|
|
78
86
|
nextEnv[tokenEnvName] = endpoint.token;
|
|
79
87
|
return nextEnv;
|
|
80
88
|
}
|
|
81
|
-
export
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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),
|
|
92
197
|
stdio: "inherit",
|
|
93
198
|
windowsHide: false,
|
|
94
199
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
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 }));
|
|
106
212
|
});
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
});
|
|
111
|
-
if (signal) {
|
|
112
|
-
process.kill(process.pid, signal);
|
|
213
|
+
this.childExitPromises.set(child, exitPromise);
|
|
214
|
+
void exitPromise.then((result) => {
|
|
215
|
+
if (this.child !== child || this.switching) {
|
|
113
216
|
return;
|
|
114
217
|
}
|
|
115
|
-
|
|
218
|
+
this.child = null;
|
|
219
|
+
if (result.error) {
|
|
220
|
+
this.finishRun(result.error);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
this.finishRun(undefined, result.code ?? (result.signal ? 1 : 0));
|
|
116
224
|
});
|
|
117
|
-
|
|
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();
|
|
118
377
|
}
|
|
119
378
|
export async function runCodexRemoteClient(options) {
|
|
120
379
|
const endpoint = readCodexRuntimeEndpoint(options.cwd);
|
|
@@ -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
|
+
}
|
|
@@ -47,6 +47,9 @@ function normalizeEndpoint(value) {
|
|
|
47
47
|
serverPort: typeof record.serverPort === "number" ? record.serverPort : undefined,
|
|
48
48
|
serverUrl: typeof record.serverUrl === "string" ? record.serverUrl : undefined,
|
|
49
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,
|
|
50
53
|
cwd: record.cwd,
|
|
51
54
|
command: record.command,
|
|
52
55
|
profile: typeof record.profile === "string" ? record.profile : undefined,
|
|
@@ -164,6 +167,9 @@ export function clearLocalCompanionOccupancy(cwd, instanceId, options = {}) {
|
|
|
164
167
|
companionStatus: undefined,
|
|
165
168
|
companionLastStateAt: undefined,
|
|
166
169
|
companionWorkerPid: undefined,
|
|
170
|
+
codexControlPort: undefined,
|
|
171
|
+
codexControlToken: undefined,
|
|
172
|
+
codexVisibleThreadId: undefined,
|
|
167
173
|
});
|
|
168
174
|
}
|
|
169
175
|
catch {
|
|
@@ -209,6 +215,26 @@ export function updateLocalCompanionOccupancy(cwd, patch, instanceId, options =
|
|
|
209
215
|
// Best effort cleanup.
|
|
210
216
|
}
|
|
211
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
|
+
}
|
|
212
238
|
export function sendLocalCompanionMessage(socket, message) {
|
|
213
239
|
if (socket.destroyed || socket.writableEnded) {
|
|
214
240
|
return false;
|
|
@@ -129,7 +129,6 @@ export function parseCliArgs(argv) {
|
|
|
129
129
|
"Use --doctor to check the selected adapter and workspace without starting it.",
|
|
130
130
|
"Without a daemon, the transient runtime is companion-bound and stops when the visible CLI closes.",
|
|
131
131
|
"Unknown arguments are forwarded to the visible CLI client.",
|
|
132
|
-
`Deprecated alias: ${commandName}-start.`,
|
|
133
132
|
"",
|
|
134
133
|
].join("\n"));
|
|
135
134
|
process.exit(0);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
const ROUTE_POLL_INTERVAL_MS = 100;
|
|
3
|
+
const MAX_BUFFER_SIZE = 1024 * 1024;
|
|
4
|
+
function extractSessionId(route) {
|
|
5
|
+
if (route.name === "session" && typeof route.params?.sessionID === "string") {
|
|
6
|
+
return route.params.sessionID;
|
|
7
|
+
}
|
|
8
|
+
if (route.type === "session" && typeof route.sessionID === "string") {
|
|
9
|
+
return route.sessionID;
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
export default {
|
|
14
|
+
id: "cli-wechat-bridge.session-route",
|
|
15
|
+
tui: async (api, options) => {
|
|
16
|
+
const port = Number(options?.port);
|
|
17
|
+
const token = typeof options?.token === "string" ? options.token : "";
|
|
18
|
+
if (!Number.isInteger(port) || port <= 0 || !token) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const socket = net.connect({ host: "127.0.0.1", port });
|
|
22
|
+
let connected = false;
|
|
23
|
+
let lastSessionId;
|
|
24
|
+
let queuedFrame = null;
|
|
25
|
+
const writeFrame = (frame) => {
|
|
26
|
+
if (socket.destroyed) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (!connected) {
|
|
30
|
+
queuedFrame = frame;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
socket.write(`${JSON.stringify(frame)}\n`);
|
|
34
|
+
};
|
|
35
|
+
const publishRoute = () => {
|
|
36
|
+
const sessionId = extractSessionId(api.route.current);
|
|
37
|
+
if (sessionId === lastSessionId) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
lastSessionId = sessionId;
|
|
41
|
+
writeFrame({
|
|
42
|
+
type: "route_state",
|
|
43
|
+
sessionId,
|
|
44
|
+
observedAt: new Date().toISOString(),
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
socket.setEncoding("utf8");
|
|
48
|
+
socket.on("connect", () => {
|
|
49
|
+
socket.setNoDelay(true);
|
|
50
|
+
socket.write(`${JSON.stringify({ type: "hello", token })}\n`);
|
|
51
|
+
connected = true;
|
|
52
|
+
publishRoute();
|
|
53
|
+
if (queuedFrame) {
|
|
54
|
+
socket.write(`${JSON.stringify(queuedFrame)}\n`);
|
|
55
|
+
queuedFrame = null;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
socket.on("data", (chunk) => {
|
|
59
|
+
if (chunk.length > MAX_BUFFER_SIZE) {
|
|
60
|
+
socket.destroy();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
socket.on("error", () => {
|
|
64
|
+
// Keep the native TUI usable if the bridge-side observer disappears.
|
|
65
|
+
});
|
|
66
|
+
const timer = setInterval(publishRoute, ROUTE_POLL_INTERVAL_MS);
|
|
67
|
+
timer.unref?.();
|
|
68
|
+
api.lifecycle.onDispose(() => {
|
|
69
|
+
clearInterval(timer);
|
|
70
|
+
socket.destroy();
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import net from "node:net";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
const BRIDGE_HOST = "127.0.0.1";
|
|
3
5
|
const INTERNAL_NEW_COMMAND = "__cli_bridge_new";
|
|
4
6
|
const INTERNAL_SWITCH_COMMAND = "__cli_bridge_switch";
|
|
@@ -110,8 +112,36 @@ export default function piTuiBridgeExtension(pi) {
|
|
|
110
112
|
return;
|
|
111
113
|
}
|
|
112
114
|
try {
|
|
115
|
+
if (!context.isIdle()) {
|
|
116
|
+
throw new Error("Pi TUI is already processing a turn.");
|
|
117
|
+
}
|
|
118
|
+
let request;
|
|
119
|
+
try {
|
|
120
|
+
request = JSON.parse(parsed.value);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
throw new Error("The Pi session switch request is malformed.");
|
|
124
|
+
}
|
|
125
|
+
if (typeof request.sessionPath !== "string" ||
|
|
126
|
+
typeof request.sessionId !== "string" ||
|
|
127
|
+
typeof request.cwd !== "string" ||
|
|
128
|
+
!fs.existsSync(request.sessionPath)) {
|
|
129
|
+
throw new Error("The requested Pi session is no longer available.");
|
|
130
|
+
}
|
|
131
|
+
const firstLine = fs
|
|
132
|
+
.readFileSync(request.sessionPath, "utf8")
|
|
133
|
+
.split(/\r?\n/)
|
|
134
|
+
.find((line) => line.trim());
|
|
135
|
+
const header = firstLine ? JSON.parse(firstLine) : null;
|
|
136
|
+
if (!isRecord(header) ||
|
|
137
|
+
header.type !== "session" ||
|
|
138
|
+
header.id !== request.sessionId ||
|
|
139
|
+
typeof header.cwd !== "string" ||
|
|
140
|
+
path.resolve(header.cwd) !== path.resolve(request.cwd)) {
|
|
141
|
+
throw new Error("The requested Pi session no longer matches this workspace.");
|
|
142
|
+
}
|
|
113
143
|
let sessionState = {};
|
|
114
|
-
const result = await context.switchSession(
|
|
144
|
+
const result = await context.switchSession(request.sessionPath, {
|
|
115
145
|
withSession: async (nextContext) => {
|
|
116
146
|
writeSessionState(nextContext, "resume");
|
|
117
147
|
sessionState = {
|
|
@@ -157,8 +187,17 @@ export default function piTuiBridgeExtension(pi) {
|
|
|
157
187
|
}
|
|
158
188
|
else if (command.type === "switch_session" &&
|
|
159
189
|
typeof command.id === "string" &&
|
|
160
|
-
typeof command.sessionPath === "string"
|
|
161
|
-
|
|
190
|
+
typeof command.sessionPath === "string" &&
|
|
191
|
+
typeof command.sessionId === "string" &&
|
|
192
|
+
typeof command.cwd === "string") {
|
|
193
|
+
if (!latestContext?.isIdle()) {
|
|
194
|
+
throw new Error("Pi TUI is already processing a turn.");
|
|
195
|
+
}
|
|
196
|
+
pi.sendUserMessage(`/${INTERNAL_SWITCH_COMMAND} ${command.id} ${JSON.stringify({
|
|
197
|
+
sessionPath: command.sessionPath,
|
|
198
|
+
sessionId: command.sessionId,
|
|
199
|
+
cwd: command.cwd,
|
|
200
|
+
})}`, { expandPromptTemplates: true });
|
|
162
201
|
}
|
|
163
202
|
}
|
|
164
203
|
catch (error) {
|