cli-wechat-bridge 1.1.6 → 1.1.8
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/LICENSE.txt +661 -661
- package/README.md +454 -475
- package/bin/_run-entry.mjs +23 -5
- package/bin/wecom-check-update.mjs +5 -0
- package/bin/wecom-claude.mjs +5 -0
- package/bin/wecom-codex.mjs +5 -0
- package/bin/wecom-daemon.mjs +5 -0
- package/bin/wecom-opencode.mjs +5 -0
- package/bin/wecom-pi.mjs +5 -0
- package/bin/wecom-setup.mjs +5 -0
- package/dist/bridge/adapter-control.js +58 -0
- package/dist/bridge/bridge-adapters.claude.js +155 -4
- package/dist/bridge/bridge-adapters.codex.js +112 -7
- package/dist/bridge/bridge-adapters.core.js +19 -1
- package/dist/bridge/bridge-adapters.opencode.js +150 -1
- package/dist/bridge/bridge-adapters.pi.js +39 -0
- package/dist/bridge/bridge-process-reaper.js +35 -2
- package/dist/bridge/bridge-state.js +10 -3
- package/dist/bridge/claude-native-controls.js +133 -0
- package/dist/bridge/native-terminal-control.js +253 -0
- package/dist/bridge/wechat-bridge.js +389 -87
- package/dist/channels/wechat/wechat-outbound-queue.js +10 -3
- package/dist/channels/wecom/bridge-final-reply.js +26 -0
- package/dist/channels/wecom/setup-cli.js +6 -0
- package/dist/channels/wecom/setup.js +176 -0
- package/dist/channels/wecom/wecom-channel-port.js +45 -0
- package/dist/channels/wecom/wecom-config.js +66 -0
- package/dist/channels/wecom/wecom-media.js +141 -0
- package/dist/channels/wecom/wecom-message.js +149 -0
- package/dist/channels/wecom/wecom-transport.js +308 -0
- package/dist/companion/codex-remote-client.js +24 -3
- package/dist/companion/codex-visible-client-link.js +101 -0
- package/dist/companion/local-companion-start.js +56 -11
- package/dist/companion/local-companion.js +19 -1
- package/dist/companion/pi-tui-bridge-extension.js +56 -3
- package/dist/core/bridge-control.js +8 -0
- package/dist/core/channel-types.js +3 -1
- package/dist/core/conversation-routing.js +48 -0
- package/dist/core/direct-run.js +17 -0
- package/dist/core/inbound-message-claims.js +61 -0
- package/dist/daemon/daemon-link.js +3 -0
- package/dist/daemon/wechat-daemon.js +335 -60
- package/dist/i18n/messages-en.js +3 -1
- package/dist/i18n/messages-zh.js +3 -1
- package/dist/runtime/legacy-adapter-runtime.js +15 -0
- package/dist/runtime/runtime-types.js +3 -0
- package/dist/utils/doctor.js +30 -10
- package/dist/utils/version-checker.js +11 -11
- package/dist/wechat/setup.js +113 -13
- package/dist/wechat/wechat-transport.js +96 -58
- package/package.json +116 -104
- package/scripts/ensure-node-pty-permissions.mjs +73 -73
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { generateReqId, WSAuthFailureError, WSClient, WSReconnectExhaustedError, } from "@wecom/aibot-node-sdk";
|
|
4
|
+
import { tryClaimChannelInboundMessage } from "../../core/inbound-message-claims.js";
|
|
5
|
+
import { downloadWecomInboundAttachments, } from "./wecom-media.js";
|
|
6
|
+
import { parseWecomInboundFrame, toWecomChannelInboundMessage, } from "./wecom-message.js";
|
|
7
|
+
import { WECOM_INBOUND_MESSAGE_CLAIMS_DIR, } from "./wecom-config.js";
|
|
8
|
+
const WECOM_TEXT_MAX_BYTES = 20_480;
|
|
9
|
+
const WECOM_TEXT_CHUNK_BYTES = 18_000;
|
|
10
|
+
const WECOM_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
|
11
|
+
const WECOM_VIDEO_MAX_BYTES = 10 * 1024 * 1024;
|
|
12
|
+
const WECOM_VOICE_MAX_BYTES = 2 * 1024 * 1024;
|
|
13
|
+
const WECOM_FILE_MAX_BYTES = 20 * 1024 * 1024;
|
|
14
|
+
function createDefaultClient(account, logger) {
|
|
15
|
+
return new WSClient({
|
|
16
|
+
botId: account.botId,
|
|
17
|
+
secret: account.secret,
|
|
18
|
+
maxReconnectAttempts: 10,
|
|
19
|
+
maxAuthFailureAttempts: 5,
|
|
20
|
+
heartbeatInterval: 30_000,
|
|
21
|
+
maxReplyQueueSize: 500,
|
|
22
|
+
logger: {
|
|
23
|
+
debug: (message, ...args) => logger.log?.(`[wecom-sdk] ${message} ${args.join(" ")}`.trim()),
|
|
24
|
+
info: (message, ...args) => logger.log?.(`[wecom-sdk] ${message} ${args.join(" ")}`.trim()),
|
|
25
|
+
warn: (message, ...args) => logger.log?.(`[wecom-sdk] WARN: ${message} ${args.join(" ")}`.trim()),
|
|
26
|
+
error: (message, ...args) => logger.error?.(`[wecom-sdk] ${message} ${args.join(" ")}`.trim()),
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function splitUtf8Text(text, maxBytes = WECOM_TEXT_CHUNK_BYTES) {
|
|
31
|
+
if (Buffer.byteLength(text, "utf-8") <= maxBytes) {
|
|
32
|
+
return [text];
|
|
33
|
+
}
|
|
34
|
+
const chunks = [];
|
|
35
|
+
let current = "";
|
|
36
|
+
let bytes = 0;
|
|
37
|
+
for (const character of text) {
|
|
38
|
+
const nextBytes = Buffer.byteLength(character, "utf-8");
|
|
39
|
+
if (current && bytes + nextBytes > maxBytes) {
|
|
40
|
+
chunks.push(current);
|
|
41
|
+
current = "";
|
|
42
|
+
bytes = 0;
|
|
43
|
+
}
|
|
44
|
+
current += character;
|
|
45
|
+
bytes += nextBytes;
|
|
46
|
+
}
|
|
47
|
+
if (current) {
|
|
48
|
+
chunks.push(current);
|
|
49
|
+
}
|
|
50
|
+
return chunks;
|
|
51
|
+
}
|
|
52
|
+
function getChatType(target) {
|
|
53
|
+
return target.metadata?.chatType === "group" ? "group" : "direct";
|
|
54
|
+
}
|
|
55
|
+
function getReplyFrame(target) {
|
|
56
|
+
return target.opaqueRef
|
|
57
|
+
? { headers: { req_id: target.opaqueRef } }
|
|
58
|
+
: null;
|
|
59
|
+
}
|
|
60
|
+
function inferMimeType(filePath) {
|
|
61
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
62
|
+
const known = {
|
|
63
|
+
".jpg": "image/jpeg",
|
|
64
|
+
".jpeg": "image/jpeg",
|
|
65
|
+
".png": "image/png",
|
|
66
|
+
".gif": "image/gif",
|
|
67
|
+
".amr": "audio/amr",
|
|
68
|
+
".mp4": "video/mp4",
|
|
69
|
+
".pdf": "application/pdf",
|
|
70
|
+
".txt": "text/plain",
|
|
71
|
+
};
|
|
72
|
+
return known[ext] || "application/octet-stream";
|
|
73
|
+
}
|
|
74
|
+
function resolveOutboundMedia(attachment) {
|
|
75
|
+
const fileName = attachment.fileName || path.basename(attachment.path);
|
|
76
|
+
const size = fs.statSync(attachment.path).size;
|
|
77
|
+
const mimeType = attachment.mimeType || inferMimeType(attachment.path);
|
|
78
|
+
if (attachment.kind === "image" &&
|
|
79
|
+
size <= WECOM_IMAGE_MAX_BYTES &&
|
|
80
|
+
["image/jpeg", "image/png", "image/gif"].includes(mimeType)) {
|
|
81
|
+
return { type: "image", maxBytes: WECOM_IMAGE_MAX_BYTES, fileName };
|
|
82
|
+
}
|
|
83
|
+
if (attachment.kind === "voice" &&
|
|
84
|
+
size <= WECOM_VOICE_MAX_BYTES &&
|
|
85
|
+
path.extname(fileName).toLowerCase() === ".amr") {
|
|
86
|
+
return { type: "voice", maxBytes: WECOM_VOICE_MAX_BYTES, fileName };
|
|
87
|
+
}
|
|
88
|
+
if (attachment.kind === "video" &&
|
|
89
|
+
size <= WECOM_VIDEO_MAX_BYTES &&
|
|
90
|
+
path.extname(fileName).toLowerCase() === ".mp4") {
|
|
91
|
+
return { type: "video", maxBytes: WECOM_VIDEO_MAX_BYTES, fileName };
|
|
92
|
+
}
|
|
93
|
+
return { type: "file", maxBytes: WECOM_FILE_MAX_BYTES, fileName };
|
|
94
|
+
}
|
|
95
|
+
export class WecomTransport {
|
|
96
|
+
account;
|
|
97
|
+
client;
|
|
98
|
+
logger;
|
|
99
|
+
claimsDir;
|
|
100
|
+
attachmentsDir;
|
|
101
|
+
fetchImpl;
|
|
102
|
+
streamStates = new Map();
|
|
103
|
+
inboundChains = new Map();
|
|
104
|
+
outputChain = Promise.resolve();
|
|
105
|
+
authenticated = false;
|
|
106
|
+
started = false;
|
|
107
|
+
onMessage = null;
|
|
108
|
+
onUnauthorized = null;
|
|
109
|
+
onFatal = null;
|
|
110
|
+
onConnected = null;
|
|
111
|
+
constructor(options) {
|
|
112
|
+
this.account = options.account;
|
|
113
|
+
this.logger = options.logger ?? {};
|
|
114
|
+
this.claimsDir = options.claimsDir ?? WECOM_INBOUND_MESSAGE_CLAIMS_DIR;
|
|
115
|
+
this.attachmentsDir = options.attachmentsDir;
|
|
116
|
+
this.fetchImpl = options.fetchImpl;
|
|
117
|
+
this.client = (options.clientFactory ?? createDefaultClient)(this.account, this.logger);
|
|
118
|
+
this.attachClientEvents();
|
|
119
|
+
}
|
|
120
|
+
attachClientEvents() {
|
|
121
|
+
this.client.on("authenticated", () => {
|
|
122
|
+
this.authenticated = true;
|
|
123
|
+
this.logger.log?.("wecom_authenticated");
|
|
124
|
+
void this.onConnected?.();
|
|
125
|
+
});
|
|
126
|
+
this.client.on("disconnected", (reason) => {
|
|
127
|
+
this.authenticated = false;
|
|
128
|
+
this.logger.log?.(`wecom_disconnected: reason=${reason}`);
|
|
129
|
+
});
|
|
130
|
+
this.client.on("reconnecting", (attempt) => {
|
|
131
|
+
this.logger.log?.(`wecom_reconnecting: attempt=${attempt}`);
|
|
132
|
+
});
|
|
133
|
+
this.client.on("event.disconnected_event", () => {
|
|
134
|
+
const error = new Error("The WeCom bot was connected by another process. This connection was stopped to avoid a reconnect loop.");
|
|
135
|
+
this.authenticated = false;
|
|
136
|
+
this.client.disconnect();
|
|
137
|
+
void this.onFatal?.(error);
|
|
138
|
+
});
|
|
139
|
+
this.client.on("error", (error) => {
|
|
140
|
+
this.logger.error?.(`wecom_transport_error: ${error.message}`);
|
|
141
|
+
if (error instanceof WSAuthFailureError ||
|
|
142
|
+
error instanceof WSReconnectExhaustedError) {
|
|
143
|
+
void this.onFatal?.(error);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
this.client.on("message", (frame) => {
|
|
147
|
+
void this.handleInboundFrame(frame).catch((error) => {
|
|
148
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
149
|
+
this.logger.error?.(`wecom_inbound_failed: ${message}`);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
setHandlers(handlers) {
|
|
154
|
+
this.onMessage = handlers.onMessage;
|
|
155
|
+
this.onUnauthorized = handlers.onUnauthorized ?? null;
|
|
156
|
+
this.onFatal = handlers.onFatal ?? null;
|
|
157
|
+
this.onConnected = handlers.onConnected ?? null;
|
|
158
|
+
}
|
|
159
|
+
start() {
|
|
160
|
+
if (this.started) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.started = true;
|
|
164
|
+
this.client.connect();
|
|
165
|
+
}
|
|
166
|
+
stop() {
|
|
167
|
+
if (!this.started) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
this.started = false;
|
|
171
|
+
this.authenticated = false;
|
|
172
|
+
this.streamStates.clear();
|
|
173
|
+
this.client.disconnect();
|
|
174
|
+
}
|
|
175
|
+
get isConnected() {
|
|
176
|
+
return this.authenticated && this.client.isConnected;
|
|
177
|
+
}
|
|
178
|
+
async waitUntilConnected(timeoutMs = 15_000) {
|
|
179
|
+
const deadline = Date.now() + timeoutMs;
|
|
180
|
+
while (Date.now() < deadline) {
|
|
181
|
+
if (this.isConnected) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
185
|
+
}
|
|
186
|
+
throw new Error("Timed out waiting for WeCom WebSocket authentication.");
|
|
187
|
+
}
|
|
188
|
+
async handleInboundFrame(frame) {
|
|
189
|
+
const parsed = parseWecomInboundFrame(frame);
|
|
190
|
+
if (!parsed) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (parsed.senderId !== this.account.operatorUserId) {
|
|
194
|
+
await this.onUnauthorized?.(parsed.senderId, parsed.chatType);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (!tryClaimChannelInboundMessage(`${this.account.botId}|${parsed.id}`, {
|
|
198
|
+
claimsDir: this.claimsDir,
|
|
199
|
+
})) {
|
|
200
|
+
this.logger.log?.(`wecom_duplicate_skipped: msgid=${parsed.id}`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const queueKey = `${this.account.botId}:${parsed.conversationId}`;
|
|
204
|
+
const previous = this.inboundChains.get(queueKey) ?? Promise.resolve();
|
|
205
|
+
const deliver = async () => {
|
|
206
|
+
const downloaded = await downloadWecomInboundAttachments(parsed, {
|
|
207
|
+
attachmentsDir: this.attachmentsDir,
|
|
208
|
+
fetchImpl: this.fetchImpl,
|
|
209
|
+
});
|
|
210
|
+
const text = [parsed.text, ...downloaded.failureNotes]
|
|
211
|
+
.filter(Boolean)
|
|
212
|
+
.join("\n")
|
|
213
|
+
.trim();
|
|
214
|
+
await this.onMessage?.(toWecomChannelInboundMessage({ ...parsed, text }, downloaded.attachments, this.account.botId));
|
|
215
|
+
};
|
|
216
|
+
const next = previous.then(deliver, deliver);
|
|
217
|
+
this.inboundChains.set(queueKey, next);
|
|
218
|
+
try {
|
|
219
|
+
await next;
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
if (this.inboundChains.get(queueKey) === next) {
|
|
223
|
+
this.inboundChains.delete(queueKey);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
queueOutput(action) {
|
|
228
|
+
const run = this.outputChain.then(action);
|
|
229
|
+
this.outputChain = run.then(() => undefined, () => undefined);
|
|
230
|
+
return run;
|
|
231
|
+
}
|
|
232
|
+
async sendActiveText(target, text) {
|
|
233
|
+
for (const chunk of splitUtf8Text(text)) {
|
|
234
|
+
if (Buffer.byteLength(chunk, "utf-8") > WECOM_TEXT_MAX_BYTES) {
|
|
235
|
+
throw new Error("WeCom text chunk exceeds the 20480-byte platform limit.");
|
|
236
|
+
}
|
|
237
|
+
await this.client.sendMessage(target.recipientId, {
|
|
238
|
+
msgtype: "markdown",
|
|
239
|
+
markdown: { content: chunk },
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async sendText(target, text, kind) {
|
|
244
|
+
const normalized = text.trim();
|
|
245
|
+
if (!normalized) {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
return this.queueOutput(async () => {
|
|
249
|
+
if (!this.isConnected) {
|
|
250
|
+
throw new Error("WeCom WebSocket is not connected.");
|
|
251
|
+
}
|
|
252
|
+
const chunks = splitUtf8Text(normalized);
|
|
253
|
+
const replyFrame = getReplyFrame(target);
|
|
254
|
+
const canUseStream = Boolean(replyFrame) && (kind === "thinking" || kind === "final_reply");
|
|
255
|
+
if (!canUseStream) {
|
|
256
|
+
await this.sendActiveText(target, normalized);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
const reqId = target.opaqueRef;
|
|
260
|
+
const state = this.streamStates.get(reqId) ?? {
|
|
261
|
+
streamId: generateReqId("stream"),
|
|
262
|
+
finished: false,
|
|
263
|
+
};
|
|
264
|
+
this.streamStates.set(reqId, state);
|
|
265
|
+
try {
|
|
266
|
+
if (kind === "thinking") {
|
|
267
|
+
const result = await this.client.replyStreamNonBlocking(replyFrame, state.streamId, chunks[0], false);
|
|
268
|
+
return result !== "skipped";
|
|
269
|
+
}
|
|
270
|
+
await this.client.replyStream(replyFrame, state.streamId, chunks[0], true);
|
|
271
|
+
state.finished = true;
|
|
272
|
+
this.streamStates.delete(reqId);
|
|
273
|
+
for (const chunk of chunks.slice(1)) {
|
|
274
|
+
await this.sendActiveText(target, chunk);
|
|
275
|
+
}
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
if (kind !== "final_reply") {
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
this.streamStates.delete(reqId);
|
|
283
|
+
this.logger.log?.(`wecom_stream_fallback: reqid=${reqId} chat_type=${getChatType(target)}`);
|
|
284
|
+
await this.sendActiveText(target, normalized);
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
async sendAttachment(target, attachment) {
|
|
290
|
+
await this.queueOutput(async () => {
|
|
291
|
+
if (!this.isConnected) {
|
|
292
|
+
throw new Error("WeCom WebSocket is not connected.");
|
|
293
|
+
}
|
|
294
|
+
const media = resolveOutboundMedia(attachment);
|
|
295
|
+
const buffer = fs.readFileSync(attachment.path);
|
|
296
|
+
if (buffer.length > media.maxBytes) {
|
|
297
|
+
throw new Error(`${media.type} attachment exceeds the ${Math.round(media.maxBytes / 1024 / 1024)} MB WeCom limit`);
|
|
298
|
+
}
|
|
299
|
+
const uploaded = await this.client.uploadMedia(buffer, {
|
|
300
|
+
type: media.type,
|
|
301
|
+
filename: media.fileName,
|
|
302
|
+
});
|
|
303
|
+
await this.client.sendMediaMessage(target.recipientId, media.type, uploaded.media_id, media.type === "video"
|
|
304
|
+
? { title: media.fileName, description: "" }
|
|
305
|
+
: undefined);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -5,10 +5,11 @@ import net from "node:net";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { assertNoReservedExtraCliArgs, buildCliEnvironment, buildCodexCliArgs, resolveSpawnTarget, } from "../bridge/bridge-adapters.shared.js";
|
|
7
7
|
import { killProcessTreeSync } from "../bridge/bridge-process-reaper.js";
|
|
8
|
-
import { CODEX_VISIBLE_CONTROL_HOST,
|
|
8
|
+
import { CODEX_VISIBLE_CONTROL_HOST, parseCodexVisibleControlRequest, sendCodexVisibleShutdownResponse, sendCodexVisibleSwitchResponse, } from "./codex-visible-client-link.js";
|
|
9
9
|
import { clearLocalCompanionOccupancy, readLocalCompanionEndpoint, updateCodexVisibleControl, updateLocalCompanionOccupancy, } from "./local-companion-link.js";
|
|
10
10
|
import { migrateLegacyChannelFiles } from "../wechat/channel-config.js";
|
|
11
11
|
import { CODEX_REMOTE_AUTH_TOKEN_ENV } from "../runtime/runtime-types.js";
|
|
12
|
+
import { isDirectModuleRun } from "../core/direct-run.js";
|
|
12
13
|
const CODEX_VISIBLE_SWITCH_SETTLE_MS = 500;
|
|
13
14
|
const CODEX_VISIBLE_CHILD_STOP_TIMEOUT_MS = 2_000;
|
|
14
15
|
function log(message) {
|
|
@@ -301,11 +302,20 @@ export class CodexVisibleClientSupervisor {
|
|
|
301
302
|
socket.destroy();
|
|
302
303
|
return;
|
|
303
304
|
}
|
|
304
|
-
const request =
|
|
305
|
+
const request = parseCodexVisibleControlRequest(parsed);
|
|
305
306
|
if (!request || request.token !== this.controlToken) {
|
|
306
307
|
socket.destroy();
|
|
307
308
|
return;
|
|
308
309
|
}
|
|
310
|
+
if (request.type === "shutdown") {
|
|
311
|
+
sendCodexVisibleShutdownResponse(socket, {
|
|
312
|
+
type: "shutdown_result",
|
|
313
|
+
id: request.id,
|
|
314
|
+
ok: true,
|
|
315
|
+
});
|
|
316
|
+
void this.shutdownFromControl();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
309
319
|
void this.switchThread(request.threadId).then(() => {
|
|
310
320
|
sendCodexVisibleSwitchResponse(socket, {
|
|
311
321
|
type: "switch_thread_result",
|
|
@@ -339,6 +349,17 @@ export class CodexVisibleClientSupervisor {
|
|
|
339
349
|
this.controlServer = server;
|
|
340
350
|
this.controlPort = address.port;
|
|
341
351
|
}
|
|
352
|
+
async shutdownFromControl() {
|
|
353
|
+
await this.switchChain.catch(() => undefined);
|
|
354
|
+
if (this.runSettled) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const child = this.child;
|
|
358
|
+
if (child) {
|
|
359
|
+
await this.stopChild(child);
|
|
360
|
+
}
|
|
361
|
+
this.finishRun(undefined, 0);
|
|
362
|
+
}
|
|
342
363
|
async closeControlServer() {
|
|
343
364
|
const server = this.controlServer;
|
|
344
365
|
this.controlServer = null;
|
|
@@ -388,7 +409,7 @@ async function main() {
|
|
|
388
409
|
const exitCode = await runCodexRemoteClient(options);
|
|
389
410
|
process.exit(exitCode);
|
|
390
411
|
}
|
|
391
|
-
const isDirectRun =
|
|
412
|
+
const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
|
|
392
413
|
if (isDirectRun) {
|
|
393
414
|
main().catch((error) => {
|
|
394
415
|
log(error instanceof Error ? error.message : String(error));
|
|
@@ -23,12 +23,37 @@ export function parseCodexVisibleSwitchRequest(value) {
|
|
|
23
23
|
threadId: value.threadId,
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
+
export function parseCodexVisibleControlRequest(value) {
|
|
27
|
+
const switchRequest = parseCodexVisibleSwitchRequest(value);
|
|
28
|
+
if (switchRequest) {
|
|
29
|
+
return switchRequest;
|
|
30
|
+
}
|
|
31
|
+
if (!isRecord(value)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (value.type !== "shutdown" ||
|
|
35
|
+
typeof value.id !== "string" ||
|
|
36
|
+
typeof value.token !== "string") {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
type: "shutdown",
|
|
41
|
+
id: value.id,
|
|
42
|
+
token: value.token,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
26
45
|
export function sendCodexVisibleSwitchResponse(socket, response) {
|
|
27
46
|
if (socket.destroyed || socket.writableEnded) {
|
|
28
47
|
return;
|
|
29
48
|
}
|
|
30
49
|
socket.end(`${JSON.stringify(response)}\n`);
|
|
31
50
|
}
|
|
51
|
+
export function sendCodexVisibleShutdownResponse(socket, response) {
|
|
52
|
+
if (socket.destroyed || socket.writableEnded) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
socket.end(`${JSON.stringify(response)}\n`);
|
|
56
|
+
}
|
|
32
57
|
function resolveCodexControlEndpoint(cwd, instanceId) {
|
|
33
58
|
const endpoint = readLocalCompanionEndpoint(cwd, { adapter: "codex" });
|
|
34
59
|
if (!endpoint || endpoint.kind !== "codex" || endpoint.instanceId !== instanceId) {
|
|
@@ -120,3 +145,79 @@ export async function requestCodexVisibleThreadSwitch(params) {
|
|
|
120
145
|
});
|
|
121
146
|
});
|
|
122
147
|
}
|
|
148
|
+
export async function requestCodexVisibleClientShutdown(params) {
|
|
149
|
+
const endpoint = resolveCodexControlEndpoint(params.cwd, params.instanceId);
|
|
150
|
+
const requestId = crypto.randomUUID();
|
|
151
|
+
const timeoutMs = params.timeoutMs ?? 5_000;
|
|
152
|
+
await new Promise((resolve, reject) => {
|
|
153
|
+
const socket = net.connect({
|
|
154
|
+
host: CODEX_VISIBLE_CONTROL_HOST,
|
|
155
|
+
port: endpoint.codexControlPort,
|
|
156
|
+
});
|
|
157
|
+
let buffer = "";
|
|
158
|
+
let settled = false;
|
|
159
|
+
const finish = (error) => {
|
|
160
|
+
if (settled) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
settled = true;
|
|
164
|
+
socket.destroy();
|
|
165
|
+
if (error) {
|
|
166
|
+
reject(error);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
resolve();
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
socket.setEncoding("utf8");
|
|
173
|
+
socket.setTimeout(timeoutMs);
|
|
174
|
+
socket.once("connect", () => {
|
|
175
|
+
const request = {
|
|
176
|
+
type: "shutdown",
|
|
177
|
+
id: requestId,
|
|
178
|
+
token: endpoint.codexControlToken,
|
|
179
|
+
};
|
|
180
|
+
socket.write(`${JSON.stringify(request)}\n`);
|
|
181
|
+
});
|
|
182
|
+
socket.on("data", (chunk) => {
|
|
183
|
+
buffer += chunk;
|
|
184
|
+
if (buffer.length > 65_536) {
|
|
185
|
+
finish(new Error("The visible Codex client returned an oversized shutdown response."));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const newlineIndex = buffer.indexOf("\n");
|
|
189
|
+
if (newlineIndex < 0) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
193
|
+
let parsed;
|
|
194
|
+
try {
|
|
195
|
+
parsed = JSON.parse(line);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
finish(new Error("The visible Codex client returned an invalid shutdown response."));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (!isRecord(parsed) || parsed.type !== "shutdown_result" || parsed.id !== requestId) {
|
|
202
|
+
finish(new Error("The visible Codex client returned an unexpected shutdown response."));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (parsed.ok !== true) {
|
|
206
|
+
finish(new Error(typeof parsed.error === "string"
|
|
207
|
+
? parsed.error
|
|
208
|
+
: "The visible Codex client could not shut down."));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
finish();
|
|
212
|
+
});
|
|
213
|
+
socket.once("timeout", () => {
|
|
214
|
+
finish(new Error("Timed out waiting for the visible Codex client to shut down."));
|
|
215
|
+
});
|
|
216
|
+
socket.once("error", (error) => finish(error));
|
|
217
|
+
socket.once("close", () => {
|
|
218
|
+
if (!settled) {
|
|
219
|
+
finish(new Error("The visible Codex client closed the shutdown connection."));
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
@@ -5,6 +5,8 @@ import { spawn } from "node:child_process";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { BRIDGE_LOG_FILE } from "../wechat/channel-config.js";
|
|
7
7
|
import { ensureWechatCredentials } from "../wechat/setup.js";
|
|
8
|
+
import { ensureWecomAccount } from "../channels/wecom/setup.js";
|
|
9
|
+
import { isDirectModuleRun } from "../core/direct-run.js";
|
|
8
10
|
import { readBridgeLockFile, shouldAutoReclaimBridgeLock, } from "../bridge/bridge-state.js";
|
|
9
11
|
import { clearLocalCompanionOccupancy, clearLocalCompanionEndpoint, readLocalCompanionEndpoint, } from "./local-companion-link.js";
|
|
10
12
|
import { runCodexRemoteClient } from "./codex-remote-client.js";
|
|
@@ -15,8 +17,8 @@ const MODULE_DIR = path.dirname(MODULE_FILE);
|
|
|
15
17
|
const RUNTIME_ENTRY_EXTENSION = path.extname(MODULE_FILE) === ".ts" ? ".ts" : ".js";
|
|
16
18
|
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
|
17
19
|
const DEFAULT_ADAPTER = "codex";
|
|
18
|
-
function log(adapter, message) {
|
|
19
|
-
process.stderr.write(`[
|
|
20
|
+
function log(adapter, message, channelId = "wechat") {
|
|
21
|
+
process.stderr.write(`[${channelId}-${adapter}] ${message}\n`);
|
|
20
22
|
}
|
|
21
23
|
export function normalizeComparablePath(cwd) {
|
|
22
24
|
const normalized = path.resolve(cwd);
|
|
@@ -37,8 +39,13 @@ export function formatSwitchFailureMessage(cwd) {
|
|
|
37
39
|
export function formatRestartUnhealthyMessage(cwd) {
|
|
38
40
|
return `Detected unhealthy companion state for ${cwd}. Restarting bridge...`;
|
|
39
41
|
}
|
|
40
|
-
export function defaultSessionStartMode(
|
|
41
|
-
|
|
42
|
+
export function defaultSessionStartMode(_adapter) {
|
|
43
|
+
// Codex 0.155+ persists app-server threads lazily, so launching the visible
|
|
44
|
+
// client with a freshly prepared (not yet persisted) thread id crashes its
|
|
45
|
+
// bootstrap with "no rollout found". Start every adapter fresh by default;
|
|
46
|
+
// explicit --session-start-mode restore remains available for deliberate
|
|
47
|
+
// restores of already persisted threads.
|
|
48
|
+
return "new";
|
|
42
49
|
}
|
|
43
50
|
export function decideLaunchAction(input) {
|
|
44
51
|
if (input.lockShouldAutoReclaim) {
|
|
@@ -48,6 +55,8 @@ export function decideLaunchAction(input) {
|
|
|
48
55
|
};
|
|
49
56
|
}
|
|
50
57
|
const sameWorkspace = input.runningLock.adapter === input.requestedAdapter &&
|
|
58
|
+
(input.runningLock.channelId ?? "wechat") ===
|
|
59
|
+
(input.requestedChannelId ?? "wechat") &&
|
|
51
60
|
isSameWorkspaceCwd(input.runningLock.cwd, input.requestedCwd);
|
|
52
61
|
if (!sameWorkspace) {
|
|
53
62
|
return {
|
|
@@ -99,6 +108,7 @@ export function parseCliArgs(argv) {
|
|
|
99
108
|
let profile;
|
|
100
109
|
let timeoutMs = DEFAULT_WAIT_TIMEOUT_MS;
|
|
101
110
|
let sessionStartMode;
|
|
111
|
+
let channelId = "wechat";
|
|
102
112
|
const cliArgs = [];
|
|
103
113
|
for (let i = 0; i < argv.length; i += 1) {
|
|
104
114
|
const arg = argv[i];
|
|
@@ -108,7 +118,7 @@ export function parseCliArgs(argv) {
|
|
|
108
118
|
const next = argv[i + 1];
|
|
109
119
|
if (arg === "--help" || arg === "-h") {
|
|
110
120
|
const helpAdapter = adapter;
|
|
111
|
-
const commandName =
|
|
121
|
+
const commandName = `${channelId}-${helpAdapter}`;
|
|
112
122
|
const adapterLabel = {
|
|
113
123
|
codex: "Codex",
|
|
114
124
|
claude: "Claude Code",
|
|
@@ -118,7 +128,7 @@ export function parseCliArgs(argv) {
|
|
|
118
128
|
process.stdout.write([
|
|
119
129
|
`Usage: ${commandName} [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [--session-start-mode <restore|new>] [...${helpAdapter} args]`,
|
|
120
130
|
"",
|
|
121
|
-
|
|
131
|
+
`Ensures the ${channelId === "wecom" ? "WeCom" : "WeChat"} runtime for the current directory, then opens or reuses the visible CLI.`,
|
|
122
132
|
"If a same-workspace daemon is running, the command delegates to that daemon.",
|
|
123
133
|
helpAdapter === "codex"
|
|
124
134
|
? "Codex restores the current session by default."
|
|
@@ -136,6 +146,14 @@ export function parseCliArgs(argv) {
|
|
|
136
146
|
if (arg === "--doctor") {
|
|
137
147
|
continue;
|
|
138
148
|
}
|
|
149
|
+
if (arg === "--channel") {
|
|
150
|
+
if (!next || (next !== "wechat" && next !== "wecom")) {
|
|
151
|
+
throw new Error(`Invalid channel: ${next ?? "(missing)"}`);
|
|
152
|
+
}
|
|
153
|
+
channelId = next;
|
|
154
|
+
i += 1;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
139
157
|
if (arg === "--adapter") {
|
|
140
158
|
if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
|
|
141
159
|
throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
|
|
@@ -189,6 +207,7 @@ export function parseCliArgs(argv) {
|
|
|
189
207
|
timeoutMs,
|
|
190
208
|
sessionStartMode: sessionStartMode ?? defaultSessionStartMode(adapter),
|
|
191
209
|
cliArgs,
|
|
210
|
+
channelId,
|
|
192
211
|
};
|
|
193
212
|
}
|
|
194
213
|
function isPidAlive(pid) {
|
|
@@ -295,6 +314,9 @@ export function buildBackgroundBridgeArgs(entryPath, options) {
|
|
|
295
314
|
args.push("--experimental-strip-types");
|
|
296
315
|
}
|
|
297
316
|
args.push(entryPath, "--adapter", options.adapter, "--cwd", options.cwd, "--lifecycle", lifecycle);
|
|
317
|
+
if (options.channelId === "wecom") {
|
|
318
|
+
args.push("--channel", "wecom");
|
|
319
|
+
}
|
|
298
320
|
if (options.sessionStartMode !== "restore") {
|
|
299
321
|
args.push("--session-start-mode", options.sessionStartMode);
|
|
300
322
|
}
|
|
@@ -361,6 +383,7 @@ async function ensureBridgeReady(options) {
|
|
|
361
383
|
endpointIsReachable: Boolean(endpointResult.endpoint),
|
|
362
384
|
companionIsAlive: isCompanionAlive(endpointResult.endpoint),
|
|
363
385
|
sessionStartMode: options.sessionStartMode,
|
|
386
|
+
requestedChannelId: options.channelId,
|
|
364
387
|
});
|
|
365
388
|
log(options.adapter, decision.message);
|
|
366
389
|
if (decision.kind === "already_active") {
|
|
@@ -408,8 +431,13 @@ export async function tryDelegateToDaemon(options, deps = {}) {
|
|
|
408
431
|
clearEndpoint(endpoint.pid);
|
|
409
432
|
return false;
|
|
410
433
|
}
|
|
434
|
+
const requestedChannel = options.channelId ?? "wechat";
|
|
435
|
+
const endpointChannel = endpoint.channelId ?? "wechat";
|
|
436
|
+
if (requestedChannel !== endpointChannel) {
|
|
437
|
+
throw new Error(`${endpointChannel}-daemon is running for ${endpoint.cwd}. Stop it before starting the ${requestedChannel} channel.`);
|
|
438
|
+
}
|
|
411
439
|
if (!isSameWorkspaceCwd(endpoint.cwd, options.cwd)) {
|
|
412
|
-
throw new Error(
|
|
440
|
+
throw new Error(`${requestedChannel}-daemon is running for ${endpoint.cwd}. This launcher requested ${options.cwd}; v1 daemon switching is limited to its startup cwd.`);
|
|
413
441
|
}
|
|
414
442
|
const response = await sendRequest(endpoint, {
|
|
415
443
|
command: "ensure_slot",
|
|
@@ -429,7 +457,7 @@ export async function tryDelegateToDaemon(options, deps = {}) {
|
|
|
429
457
|
log(options.adapter, `Daemon delegation failed (${response.error ?? "unknown error"}). Falling back to a local bridge.`);
|
|
430
458
|
return false;
|
|
431
459
|
}
|
|
432
|
-
log(options.adapter, `Delegated to running
|
|
460
|
+
log(options.adapter, `Delegated to running ${requestedChannel}-daemon for ${options.cwd}.`, requestedChannel);
|
|
433
461
|
return true;
|
|
434
462
|
}
|
|
435
463
|
export async function runVisibleClient(options, runners = {}) {
|
|
@@ -456,6 +484,15 @@ export async function ensureCompanionStartWechatCredentials(adapter, ensureCrede
|
|
|
456
484
|
log: (message) => log(adapter, message),
|
|
457
485
|
});
|
|
458
486
|
}
|
|
487
|
+
export async function ensureCompanionStartChannelCredentials(options) {
|
|
488
|
+
if (options.channelId === "wecom") {
|
|
489
|
+
await ensureWecomAccount({
|
|
490
|
+
log: (message) => log(options.adapter, message, "wecom"),
|
|
491
|
+
});
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
await ensureCompanionStartWechatCredentials(options.adapter);
|
|
495
|
+
}
|
|
459
496
|
export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
|
|
460
497
|
if (argv.includes("--doctor")) {
|
|
461
498
|
const { runDoctorCheck } = await import("../utils/doctor.js");
|
|
@@ -463,7 +500,7 @@ export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
|
|
|
463
500
|
return 0;
|
|
464
501
|
}
|
|
465
502
|
const options = parseCliArgs(argv);
|
|
466
|
-
await
|
|
503
|
+
await ensureCompanionStartChannelCredentials(options);
|
|
467
504
|
if (await tryDelegateToDaemon(options)) {
|
|
468
505
|
return 0;
|
|
469
506
|
}
|
|
@@ -487,11 +524,19 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
487
524
|
return DEFAULT_ADAPTER;
|
|
488
525
|
}
|
|
489
526
|
})();
|
|
490
|
-
|
|
527
|
+
const channelId = (() => {
|
|
528
|
+
try {
|
|
529
|
+
return parseCliArgs(argv).channelId ?? "wechat";
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
return "wechat";
|
|
533
|
+
}
|
|
534
|
+
})();
|
|
535
|
+
log(adapter, error instanceof Error ? error.message : String(error), channelId);
|
|
491
536
|
process.exit(1);
|
|
492
537
|
}
|
|
493
538
|
}
|
|
494
|
-
const isDirectRun =
|
|
539
|
+
const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
|
|
495
540
|
if (isDirectRun) {
|
|
496
541
|
void main();
|
|
497
542
|
}
|