cli-wechat-bridge 1.1.6 → 1.1.7
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 +163 -198
- 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/bridge-adapters.claude.js +126 -3
- package/dist/bridge/bridge-process-reaper.js +35 -2
- package/dist/bridge/bridge-state.js +10 -3
- package/dist/bridge/wechat-bridge.js +277 -44
- 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 +2 -1
- package/dist/companion/local-companion-start.js +49 -9
- package/dist/companion/local-companion.js +2 -1
- package/dist/core/channel-types.js +3 -1
- 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 +265 -36
- package/dist/i18n/messages-en.js +2 -0
- package/dist/i18n/messages-zh.js +2 -0
- package/dist/utils/doctor.js +30 -10
- package/dist/wechat/setup.js +113 -13
- package/package.json +20 -10
|
@@ -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
|
+
}
|
|
@@ -9,6 +9,7 @@ import { CODEX_VISIBLE_CONTROL_HOST, parseCodexVisibleSwitchRequest, sendCodexVi
|
|
|
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) {
|
|
@@ -388,7 +389,7 @@ async function main() {
|
|
|
388
389
|
const exitCode = await runCodexRemoteClient(options);
|
|
389
390
|
process.exit(exitCode);
|
|
390
391
|
}
|
|
391
|
-
const isDirectRun =
|
|
392
|
+
const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
|
|
392
393
|
if (isDirectRun) {
|
|
393
394
|
main().catch((error) => {
|
|
394
395
|
log(error instanceof Error ? error.message : String(error));
|
|
@@ -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);
|
|
@@ -48,6 +50,8 @@ export function decideLaunchAction(input) {
|
|
|
48
50
|
};
|
|
49
51
|
}
|
|
50
52
|
const sameWorkspace = input.runningLock.adapter === input.requestedAdapter &&
|
|
53
|
+
(input.runningLock.channelId ?? "wechat") ===
|
|
54
|
+
(input.requestedChannelId ?? "wechat") &&
|
|
51
55
|
isSameWorkspaceCwd(input.runningLock.cwd, input.requestedCwd);
|
|
52
56
|
if (!sameWorkspace) {
|
|
53
57
|
return {
|
|
@@ -99,6 +103,7 @@ export function parseCliArgs(argv) {
|
|
|
99
103
|
let profile;
|
|
100
104
|
let timeoutMs = DEFAULT_WAIT_TIMEOUT_MS;
|
|
101
105
|
let sessionStartMode;
|
|
106
|
+
let channelId = "wechat";
|
|
102
107
|
const cliArgs = [];
|
|
103
108
|
for (let i = 0; i < argv.length; i += 1) {
|
|
104
109
|
const arg = argv[i];
|
|
@@ -108,7 +113,7 @@ export function parseCliArgs(argv) {
|
|
|
108
113
|
const next = argv[i + 1];
|
|
109
114
|
if (arg === "--help" || arg === "-h") {
|
|
110
115
|
const helpAdapter = adapter;
|
|
111
|
-
const commandName =
|
|
116
|
+
const commandName = `${channelId}-${helpAdapter}`;
|
|
112
117
|
const adapterLabel = {
|
|
113
118
|
codex: "Codex",
|
|
114
119
|
claude: "Claude Code",
|
|
@@ -118,7 +123,7 @@ export function parseCliArgs(argv) {
|
|
|
118
123
|
process.stdout.write([
|
|
119
124
|
`Usage: ${commandName} [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [--session-start-mode <restore|new>] [...${helpAdapter} args]`,
|
|
120
125
|
"",
|
|
121
|
-
|
|
126
|
+
`Ensures the ${channelId === "wecom" ? "WeCom" : "WeChat"} runtime for the current directory, then opens or reuses the visible CLI.`,
|
|
122
127
|
"If a same-workspace daemon is running, the command delegates to that daemon.",
|
|
123
128
|
helpAdapter === "codex"
|
|
124
129
|
? "Codex restores the current session by default."
|
|
@@ -136,6 +141,14 @@ export function parseCliArgs(argv) {
|
|
|
136
141
|
if (arg === "--doctor") {
|
|
137
142
|
continue;
|
|
138
143
|
}
|
|
144
|
+
if (arg === "--channel") {
|
|
145
|
+
if (!next || (next !== "wechat" && next !== "wecom")) {
|
|
146
|
+
throw new Error(`Invalid channel: ${next ?? "(missing)"}`);
|
|
147
|
+
}
|
|
148
|
+
channelId = next;
|
|
149
|
+
i += 1;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
139
152
|
if (arg === "--adapter") {
|
|
140
153
|
if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
|
|
141
154
|
throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
|
|
@@ -189,6 +202,7 @@ export function parseCliArgs(argv) {
|
|
|
189
202
|
timeoutMs,
|
|
190
203
|
sessionStartMode: sessionStartMode ?? defaultSessionStartMode(adapter),
|
|
191
204
|
cliArgs,
|
|
205
|
+
channelId,
|
|
192
206
|
};
|
|
193
207
|
}
|
|
194
208
|
function isPidAlive(pid) {
|
|
@@ -295,6 +309,9 @@ export function buildBackgroundBridgeArgs(entryPath, options) {
|
|
|
295
309
|
args.push("--experimental-strip-types");
|
|
296
310
|
}
|
|
297
311
|
args.push(entryPath, "--adapter", options.adapter, "--cwd", options.cwd, "--lifecycle", lifecycle);
|
|
312
|
+
if (options.channelId === "wecom") {
|
|
313
|
+
args.push("--channel", "wecom");
|
|
314
|
+
}
|
|
298
315
|
if (options.sessionStartMode !== "restore") {
|
|
299
316
|
args.push("--session-start-mode", options.sessionStartMode);
|
|
300
317
|
}
|
|
@@ -361,6 +378,7 @@ async function ensureBridgeReady(options) {
|
|
|
361
378
|
endpointIsReachable: Boolean(endpointResult.endpoint),
|
|
362
379
|
companionIsAlive: isCompanionAlive(endpointResult.endpoint),
|
|
363
380
|
sessionStartMode: options.sessionStartMode,
|
|
381
|
+
requestedChannelId: options.channelId,
|
|
364
382
|
});
|
|
365
383
|
log(options.adapter, decision.message);
|
|
366
384
|
if (decision.kind === "already_active") {
|
|
@@ -408,8 +426,13 @@ export async function tryDelegateToDaemon(options, deps = {}) {
|
|
|
408
426
|
clearEndpoint(endpoint.pid);
|
|
409
427
|
return false;
|
|
410
428
|
}
|
|
429
|
+
const requestedChannel = options.channelId ?? "wechat";
|
|
430
|
+
const endpointChannel = endpoint.channelId ?? "wechat";
|
|
431
|
+
if (requestedChannel !== endpointChannel) {
|
|
432
|
+
throw new Error(`${endpointChannel}-daemon is running for ${endpoint.cwd}. Stop it before starting the ${requestedChannel} channel.`);
|
|
433
|
+
}
|
|
411
434
|
if (!isSameWorkspaceCwd(endpoint.cwd, options.cwd)) {
|
|
412
|
-
throw new Error(
|
|
435
|
+
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
436
|
}
|
|
414
437
|
const response = await sendRequest(endpoint, {
|
|
415
438
|
command: "ensure_slot",
|
|
@@ -429,7 +452,7 @@ export async function tryDelegateToDaemon(options, deps = {}) {
|
|
|
429
452
|
log(options.adapter, `Daemon delegation failed (${response.error ?? "unknown error"}). Falling back to a local bridge.`);
|
|
430
453
|
return false;
|
|
431
454
|
}
|
|
432
|
-
log(options.adapter, `Delegated to running
|
|
455
|
+
log(options.adapter, `Delegated to running ${requestedChannel}-daemon for ${options.cwd}.`, requestedChannel);
|
|
433
456
|
return true;
|
|
434
457
|
}
|
|
435
458
|
export async function runVisibleClient(options, runners = {}) {
|
|
@@ -456,6 +479,15 @@ export async function ensureCompanionStartWechatCredentials(adapter, ensureCrede
|
|
|
456
479
|
log: (message) => log(adapter, message),
|
|
457
480
|
});
|
|
458
481
|
}
|
|
482
|
+
export async function ensureCompanionStartChannelCredentials(options) {
|
|
483
|
+
if (options.channelId === "wecom") {
|
|
484
|
+
await ensureWecomAccount({
|
|
485
|
+
log: (message) => log(options.adapter, message, "wecom"),
|
|
486
|
+
});
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
await ensureCompanionStartWechatCredentials(options.adapter);
|
|
490
|
+
}
|
|
459
491
|
export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
|
|
460
492
|
if (argv.includes("--doctor")) {
|
|
461
493
|
const { runDoctorCheck } = await import("../utils/doctor.js");
|
|
@@ -463,7 +495,7 @@ export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
|
|
|
463
495
|
return 0;
|
|
464
496
|
}
|
|
465
497
|
const options = parseCliArgs(argv);
|
|
466
|
-
await
|
|
498
|
+
await ensureCompanionStartChannelCredentials(options);
|
|
467
499
|
if (await tryDelegateToDaemon(options)) {
|
|
468
500
|
return 0;
|
|
469
501
|
}
|
|
@@ -487,11 +519,19 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
487
519
|
return DEFAULT_ADAPTER;
|
|
488
520
|
}
|
|
489
521
|
})();
|
|
490
|
-
|
|
522
|
+
const channelId = (() => {
|
|
523
|
+
try {
|
|
524
|
+
return parseCliArgs(argv).channelId ?? "wechat";
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return "wechat";
|
|
528
|
+
}
|
|
529
|
+
})();
|
|
530
|
+
log(adapter, error instanceof Error ? error.message : String(error), channelId);
|
|
491
531
|
process.exit(1);
|
|
492
532
|
}
|
|
493
533
|
}
|
|
494
|
-
const isDirectRun =
|
|
534
|
+
const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
|
|
495
535
|
if (isDirectRun) {
|
|
496
536
|
void main();
|
|
497
537
|
}
|
|
@@ -6,6 +6,7 @@ import { LOCAL_COMPANION_RECONNECT_GRACE_MS, } from "../bridge/bridge-adapters.s
|
|
|
6
6
|
import { runCodexRemoteClientFromEndpoint } from "./codex-remote-client.js";
|
|
7
7
|
import { attachLocalCompanionMessageListener, readLocalCompanionEndpoint, sendLocalCompanionMessage, } from "./local-companion-link.js";
|
|
8
8
|
import { migrateLegacyChannelFiles } from "../wechat/channel-config.js";
|
|
9
|
+
import { isDirectModuleRun } from "../core/direct-run.js";
|
|
9
10
|
export const LOCAL_COMPANION_RECONNECT_RETRY_MS = 250;
|
|
10
11
|
function log(adapter, message) {
|
|
11
12
|
process.stderr.write(`[${adapter}-companion] ${message}\n`);
|
|
@@ -439,7 +440,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
439
440
|
process.exit(1);
|
|
440
441
|
}
|
|
441
442
|
}
|
|
442
|
-
const isDirectRun =
|
|
443
|
+
const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
|
|
443
444
|
if (isDirectRun) {
|
|
444
445
|
void main();
|
|
445
446
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
function normalizeComparablePath(filePath) {
|
|
4
|
+
const normalized = path.resolve(filePath);
|
|
5
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
6
|
+
}
|
|
7
|
+
export function isDirectModuleRun(importMetaUrl, argv = process.argv, importMetaMain) {
|
|
8
|
+
if (typeof importMetaMain === "boolean") {
|
|
9
|
+
return importMetaMain;
|
|
10
|
+
}
|
|
11
|
+
const entryPath = argv[1];
|
|
12
|
+
if (!entryPath) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return normalizeComparablePath(fileURLToPath(importMetaUrl)) ===
|
|
16
|
+
normalizeComparablePath(entryPath);
|
|
17
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const DEFAULT_INBOUND_MESSAGE_CLAIM_TTL_MS = 10 * 60 * 1000;
|
|
5
|
+
export function buildInboundMessageClaimPath(messageKey, claimsDir) {
|
|
6
|
+
const fileName = `${crypto.createHash("sha1").update(messageKey).digest("hex")}.json`;
|
|
7
|
+
return path.join(claimsDir, fileName);
|
|
8
|
+
}
|
|
9
|
+
export function tryClaimChannelInboundMessage(messageKey, options) {
|
|
10
|
+
if (!messageKey) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
14
|
+
const ttlMs = options.ttlMs ?? DEFAULT_INBOUND_MESSAGE_CLAIM_TTL_MS;
|
|
15
|
+
const claimPath = buildInboundMessageClaimPath(messageKey, options.claimsDir);
|
|
16
|
+
const attemptClaim = () => {
|
|
17
|
+
fs.mkdirSync(options.claimsDir, { recursive: true });
|
|
18
|
+
const handle = fs.openSync(claimPath, "wx");
|
|
19
|
+
try {
|
|
20
|
+
fs.writeFileSync(handle, JSON.stringify({
|
|
21
|
+
key: messageKey,
|
|
22
|
+
claimedAt: new Date(nowMs).toISOString(),
|
|
23
|
+
pid: process.pid,
|
|
24
|
+
}, null, 2), "utf-8");
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
fs.closeSync(handle);
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
};
|
|
31
|
+
try {
|
|
32
|
+
return attemptClaim();
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
const code = typeof error === "object" &&
|
|
36
|
+
error !== null &&
|
|
37
|
+
"code" in error &&
|
|
38
|
+
typeof error.code === "string"
|
|
39
|
+
? error.code
|
|
40
|
+
: "";
|
|
41
|
+
if (code !== "EEXIST") {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const stat = fs.statSync(claimPath);
|
|
47
|
+
if (Number.isFinite(stat.mtimeMs) && nowMs - stat.mtimeMs > ttlMs) {
|
|
48
|
+
fs.rmSync(claimPath, { force: true });
|
|
49
|
+
return attemptClaim();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
try {
|
|
54
|
+
return attemptClaim();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
@@ -23,6 +23,9 @@ function normalizeDaemonEndpoint(value) {
|
|
|
23
23
|
token: record.token,
|
|
24
24
|
cwd: record.cwd,
|
|
25
25
|
startedAt: record.startedAt,
|
|
26
|
+
...(record.channelId === "wechat" || record.channelId === "wecom"
|
|
27
|
+
? { channelId: record.channelId }
|
|
28
|
+
: {}),
|
|
26
29
|
};
|
|
27
30
|
}
|
|
28
31
|
export function buildDaemonToken() {
|