cli-wechat-bridge 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE.txt +21 -0
  2. package/README.md +637 -0
  3. package/bin/_run-entry.mjs +35 -0
  4. package/bin/wechat-bridge-claude.mjs +5 -0
  5. package/bin/wechat-bridge-codex.mjs +5 -0
  6. package/bin/wechat-bridge-opencode.mjs +5 -0
  7. package/bin/wechat-bridge-shell.mjs +5 -0
  8. package/bin/wechat-bridge.mjs +5 -0
  9. package/bin/wechat-check-update.mjs +5 -0
  10. package/bin/wechat-claude-start.mjs +5 -0
  11. package/bin/wechat-claude.mjs +5 -0
  12. package/bin/wechat-codex-start.mjs +5 -0
  13. package/bin/wechat-codex.mjs +5 -0
  14. package/bin/wechat-daemon.mjs +5 -0
  15. package/bin/wechat-opencode-start.mjs +5 -0
  16. package/bin/wechat-opencode.mjs +5 -0
  17. package/bin/wechat-setup.mjs +5 -0
  18. package/dist/bridge/bridge-adapter-common.js +95 -0
  19. package/dist/bridge/bridge-adapters.claude.js +829 -0
  20. package/dist/bridge/bridge-adapters.codex.js +2228 -0
  21. package/dist/bridge/bridge-adapters.core.js +717 -0
  22. package/dist/bridge/bridge-adapters.js +26 -0
  23. package/dist/bridge/bridge-adapters.opencode.js +2129 -0
  24. package/dist/bridge/bridge-adapters.shared.js +1005 -0
  25. package/dist/bridge/bridge-adapters.shell.js +363 -0
  26. package/dist/bridge/bridge-controller.js +48 -0
  27. package/dist/bridge/bridge-final-reply.js +46 -0
  28. package/dist/bridge/bridge-process-reaper.js +348 -0
  29. package/dist/bridge/bridge-state.js +362 -0
  30. package/dist/bridge/bridge-types.js +1 -0
  31. package/dist/bridge/bridge-utils.js +1240 -0
  32. package/dist/bridge/claude-hook.js +82 -0
  33. package/dist/bridge/claude-hooks.js +267 -0
  34. package/dist/bridge/wechat-bridge.js +1026 -0
  35. package/dist/commands/check-update.js +30 -0
  36. package/dist/companion/codex-panel-link.js +72 -0
  37. package/dist/companion/codex-panel.js +179 -0
  38. package/dist/companion/codex-remote-client.js +124 -0
  39. package/dist/companion/local-companion-link.js +240 -0
  40. package/dist/companion/local-companion-start.js +420 -0
  41. package/dist/companion/local-companion.js +424 -0
  42. package/dist/daemon/daemon-link.js +175 -0
  43. package/dist/daemon/wechat-daemon.js +1202 -0
  44. package/dist/media/media-types.js +1 -0
  45. package/dist/runtime/create-runtime-host.js +12 -0
  46. package/dist/runtime/legacy-adapter-runtime.js +46 -0
  47. package/dist/runtime/runtime-types.js +5 -0
  48. package/dist/utils/version-checker.js +161 -0
  49. package/dist/wechat/channel-config.js +196 -0
  50. package/dist/wechat/setup.js +283 -0
  51. package/dist/wechat/standalone-bot.js +355 -0
  52. package/dist/wechat/wechat-channel.js +492 -0
  53. package/dist/wechat/wechat-transport.js +1213 -0
  54. package/package.json +101 -0
@@ -0,0 +1,1202 @@
1
+ #!/usr/bin/env bun
2
+ import fs from "node:fs";
3
+ import net from "node:net";
4
+ import path from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { resolveDefaultAdapterCommand, } from "../bridge/bridge-adapters.js";
8
+ import { delay, quoteWindowsCommandArg, } from "../bridge/bridge-adapters.shared.js";
9
+ import { BridgeController } from "../bridge/bridge-controller.js";
10
+ import { forwardWechatFinalReply } from "../bridge/bridge-final-reply.js";
11
+ import { readBridgeLockFile, } from "../bridge/bridge-state.js";
12
+ import { killProcessTreeSync, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "../bridge/bridge-process-reaper.js";
13
+ import { buildOneTimeCode, buildWechatInboundPrompt, formatApprovalMessage, formatDuration, formatMirroredUserInputMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatSessionSwitchMessage, formatTaskFailedMessage, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "../bridge/bridge-utils.js";
14
+ import { formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "../bridge/wechat-bridge.js";
15
+ import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, ensureChannelDataDir, migrateLegacyChannelFiles, } from "../wechat/channel-config.js";
16
+ import { ensureWechatCredentials } from "../wechat/setup.js";
17
+ import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, describeWechatTransportError, isWechatContextTokenStaleError, WeChatTransport, } from "../wechat/wechat-transport.js";
18
+ import { createRuntimeHost, } from "../runtime/create-runtime-host.js";
19
+ import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
20
+ import { attachDaemonRequestListener, buildDaemonToken, clearDaemonEndpoint, DAEMON_PROTOCOL_VERSION, isDaemonEndpointAlive, isPidAlive, readDaemonEndpoint, sendDaemonResponse, writeDaemonEndpoint, } from "./daemon-link.js";
21
+ const MODULE_FILE = fileURLToPath(import.meta.url);
22
+ const MODULE_DIR = path.dirname(MODULE_FILE);
23
+ const RUNTIME_ENTRY_EXTENSION = path.extname(MODULE_FILE) === ".ts" ? ".ts" : ".js";
24
+ const DAEMON_HOST = "127.0.0.1";
25
+ const POLL_RETRY_BASE_MS = 1_000;
26
+ const POLL_RETRY_MAX_MS = 30_000;
27
+ const WECHAT_SEND_MAX_ATTEMPTS = 3;
28
+ const WECHAT_SEND_RETRY_BASE_MS = 750;
29
+ const SINGLE_BRIDGE_STOP_TIMEOUT_MS = 10_000;
30
+ const SINGLE_BRIDGE_FORCE_STOP_TIMEOUT_MS = 3_000;
31
+ const SINGLE_BRIDGE_STOP_POLL_MS = 250;
32
+ const VISIBLE_CLIENT_CONNECT_TIMEOUT_MS = 15_000;
33
+ const VISIBLE_CLIENT_CONNECT_POLL_MS = 250;
34
+ const DAEMON_ADAPTERS = ["codex", "claude", "opencode"];
35
+ function log(message) {
36
+ process.stderr.write(`[wechat-daemon] ${message}\n`);
37
+ }
38
+ function logError(message) {
39
+ process.stderr.write(`[wechat-daemon] ERROR: ${message}\n`);
40
+ }
41
+ function appendDaemonLog(message) {
42
+ ensureChannelDataDir();
43
+ fs.appendFileSync(BRIDGE_LOG_FILE, `[${new Date().toISOString()}] daemon: ${message}\n`, "utf8");
44
+ }
45
+ function computePollRetryDelayMs(consecutiveFailures) {
46
+ const normalizedFailures = Math.max(1, consecutiveFailures);
47
+ const exponent = Math.min(normalizedFailures - 1, 5);
48
+ return Math.min(POLL_RETRY_MAX_MS, POLL_RETRY_BASE_MS * 2 ** exponent);
49
+ }
50
+ function computeWechatSendRetryDelayMs(attempt) {
51
+ return WECHAT_SEND_RETRY_BASE_MS * attempt;
52
+ }
53
+ function isDaemonAdapterKind(value) {
54
+ return value === "codex" || value === "claude" || value === "opencode";
55
+ }
56
+ function isSameWorkspaceCwd(left, right) {
57
+ const normalizedLeft = path.resolve(left);
58
+ const normalizedRight = path.resolve(right);
59
+ return process.platform === "win32"
60
+ ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
61
+ : normalizedLeft === normalizedRight;
62
+ }
63
+ function sleep(ms) {
64
+ if (ms <= 0) {
65
+ return Promise.resolve();
66
+ }
67
+ return new Promise((resolve) => setTimeout(resolve, ms));
68
+ }
69
+ export function parseDaemonCliArgs(argv) {
70
+ let cwd = process.cwd();
71
+ let profile;
72
+ let initialAdapter;
73
+ let openVisible = true;
74
+ for (let i = 0; i < argv.length; i += 1) {
75
+ const arg = argv[i];
76
+ if (!arg) {
77
+ continue;
78
+ }
79
+ const next = argv[i + 1];
80
+ if (arg === "--help" || arg === "-h") {
81
+ process.stdout.write([
82
+ "Usage: wechat-daemon [--cwd <path>] [--adapter <codex|claude|opencode>] [--profile <name-or-path>] [--no-open]",
83
+ "",
84
+ "Keeps one WeChat connection alive and switches between Codex, Claude Code, and OpenCode from WeChat.",
85
+ "Send /codex, /claude, or /opencode in WeChat to switch the active terminal.",
86
+ "",
87
+ ].join("\n"));
88
+ process.exit(0);
89
+ }
90
+ if (arg === "--cwd") {
91
+ if (!next) {
92
+ throw new Error("--cwd requires a value");
93
+ }
94
+ cwd = path.resolve(next);
95
+ i += 1;
96
+ continue;
97
+ }
98
+ if (arg === "--adapter") {
99
+ if (!isDaemonAdapterKind(next)) {
100
+ throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
101
+ }
102
+ initialAdapter = next;
103
+ i += 1;
104
+ continue;
105
+ }
106
+ if (arg === "--profile") {
107
+ if (!next) {
108
+ throw new Error("--profile requires a value");
109
+ }
110
+ profile = next;
111
+ i += 1;
112
+ continue;
113
+ }
114
+ if (arg === "--no-open") {
115
+ openVisible = false;
116
+ continue;
117
+ }
118
+ throw new Error(`Unknown argument: ${arg}`);
119
+ }
120
+ return { cwd, profile, initialAdapter, openVisible };
121
+ }
122
+ export function parseDaemonSwitchCommand(text) {
123
+ const normalized = text.trim().toLowerCase();
124
+ switch (normalized) {
125
+ case "/codex":
126
+ return "codex";
127
+ case "/claude":
128
+ return "claude";
129
+ case "/opencode":
130
+ return "opencode";
131
+ default:
132
+ return null;
133
+ }
134
+ }
135
+ function toPendingApproval(request) {
136
+ const rawRequest = request.request;
137
+ if (typeof rawRequest.code === "string") {
138
+ return rawRequest;
139
+ }
140
+ return {
141
+ ...rawRequest,
142
+ code: buildOneTimeCode(),
143
+ createdAt: nowIso(),
144
+ };
145
+ }
146
+ function toPendingUserInput(request) {
147
+ if (typeof request.createdAt === "string") {
148
+ return request;
149
+ }
150
+ return {
151
+ ...request,
152
+ createdAt: nowIso(),
153
+ };
154
+ }
155
+ function prefixDaemonAdapterMessage(adapter, text) {
156
+ const trimmed = text.trim();
157
+ return trimmed ? `[${adapter}]\n${trimmed}` : `[${adapter}]`;
158
+ }
159
+ export function buildVisibleClientLaunchArgs(params) {
160
+ const entryPath = params.adapter === "codex"
161
+ ? path.resolve(MODULE_DIR, "..", "companion", `codex-remote-client${RUNTIME_ENTRY_EXTENSION}`)
162
+ : path.resolve(MODULE_DIR, "..", "companion", `local-companion${RUNTIME_ENTRY_EXTENSION}`);
163
+ const args = ["--no-warnings"];
164
+ if (path.extname(entryPath) === ".ts") {
165
+ args.push("--experimental-strip-types");
166
+ }
167
+ args.push(entryPath);
168
+ if (params.adapter !== "codex") {
169
+ args.push("--adapter", params.adapter);
170
+ }
171
+ args.push("--cwd", params.cwd, ...(params.cliArgs ?? []));
172
+ return args;
173
+ }
174
+ export function buildWindowsVisibleClientLaunchCommand(params) {
175
+ return [
176
+ "start",
177
+ quoteWindowsCommandArg(`wechat-${params.adapter}`),
178
+ "/D",
179
+ quoteWindowsCommandArg(params.cwd),
180
+ quoteWindowsCommandArg(process.execPath),
181
+ ...params.args.map((arg) => quoteWindowsCommandArg(arg)),
182
+ ].join(" ");
183
+ }
184
+ function formatLaunchPreview(launch) {
185
+ return [launch.command, ...launch.args].join(" ");
186
+ }
187
+ function openVisibleClient(params) {
188
+ const args = buildVisibleClientLaunchArgs(params);
189
+ if (process.platform === "win32") {
190
+ const command = process.env.ComSpec || "cmd.exe";
191
+ const launchArgs = [
192
+ "/d",
193
+ "/c",
194
+ buildWindowsVisibleClientLaunchCommand({
195
+ adapter: params.adapter,
196
+ cwd: params.cwd,
197
+ args,
198
+ }),
199
+ ];
200
+ const child = spawn(command, launchArgs, {
201
+ cwd: params.cwd,
202
+ env: process.env,
203
+ detached: true,
204
+ stdio: "ignore",
205
+ windowsVerbatimArguments: true,
206
+ windowsHide: false,
207
+ });
208
+ child.once("error", (error) => {
209
+ params.onError?.(error instanceof Error ? error : new Error(String(error)));
210
+ });
211
+ child.unref();
212
+ return {
213
+ command,
214
+ args: launchArgs,
215
+ pid: child.pid,
216
+ };
217
+ }
218
+ const child = spawn(process.execPath, args, {
219
+ cwd: params.cwd,
220
+ env: process.env,
221
+ detached: true,
222
+ stdio: "ignore",
223
+ windowsHide: false,
224
+ });
225
+ child.once("error", (error) => {
226
+ params.onError?.(error instanceof Error ? error : new Error(String(error)));
227
+ });
228
+ child.unref();
229
+ return {
230
+ command: process.execPath,
231
+ args,
232
+ pid: child.pid,
233
+ };
234
+ }
235
+ function isVisibleClientAlive(cwd, adapter) {
236
+ const endpoint = readLocalCompanionEndpoint(cwd, { adapter });
237
+ if (!endpoint?.companionPid) {
238
+ return false;
239
+ }
240
+ if (isPidAlive(endpoint.companionPid)) {
241
+ return true;
242
+ }
243
+ clearLocalCompanionOccupancy(cwd, endpoint.instanceId, { adapter });
244
+ return false;
245
+ }
246
+ function cleanupVisibleClientLauncher(launch) {
247
+ if (!launch.pid || !isPidAlive(launch.pid)) {
248
+ return false;
249
+ }
250
+ try {
251
+ killProcessTreeSync(launch.pid);
252
+ return true;
253
+ }
254
+ catch {
255
+ return false;
256
+ }
257
+ }
258
+ export async function waitForVisibleClientConnection(params, deps = {}) {
259
+ const timeoutMs = params.timeoutMs ?? VISIBLE_CLIENT_CONNECT_TIMEOUT_MS;
260
+ const pollMs = params.pollMs ?? VISIBLE_CLIENT_CONNECT_POLL_MS;
261
+ const isAlive = deps.isAlive ?? isVisibleClientAlive;
262
+ const sleepFn = deps.sleep ?? sleep;
263
+ const now = deps.now ?? (() => Date.now());
264
+ const deadline = now() + timeoutMs;
265
+ while (true) {
266
+ if (isAlive(params.cwd, params.adapter)) {
267
+ return true;
268
+ }
269
+ const remainingMs = deadline - now();
270
+ if (remainingMs <= 0) {
271
+ return false;
272
+ }
273
+ await sleepFn(Math.min(pollMs, remainingMs));
274
+ }
275
+ }
276
+ function formatInboundMessagePreview(message) {
277
+ if (message.text.trim()) {
278
+ return message.text;
279
+ }
280
+ if (message.attachments.length > 0) {
281
+ return message.attachments
282
+ .map((attachment) => `${attachment.kind}: ${attachment.path}`)
283
+ .join("\n");
284
+ }
285
+ return "(empty)";
286
+ }
287
+ function formatNoActiveAdapterMessage() {
288
+ return [
289
+ "No active terminal is selected.",
290
+ "Send /codex, /claude, or /opencode to choose one.",
291
+ ].join("\n");
292
+ }
293
+ export function formatDaemonSwitchResultDetail(result) {
294
+ if (result.openedVisible && result.visibleConnected) {
295
+ return result.created
296
+ ? "Started a new visible CLI."
297
+ : "Opened a visible CLI for the existing slot.";
298
+ }
299
+ if (result.openedVisible) {
300
+ return result.created
301
+ ? `Started the bridge slot and tried to open the visible CLI, but it has not connected yet. Check ${BRIDGE_LOG_FILE}.`
302
+ : `Tried to open a visible CLI for the existing slot, but it has not connected yet. Check ${BRIDGE_LOG_FILE}.`;
303
+ }
304
+ if (result.visibleConnected) {
305
+ return "Reused the existing visible CLI.";
306
+ }
307
+ return result.created ? "Started the bridge slot." : "Reused the bridge slot.";
308
+ }
309
+ export function formatDaemonStatus(status) {
310
+ const lines = [
311
+ "wechat-daemon status",
312
+ `cwd: ${status.cwd}`,
313
+ `active: ${status.activeAdapter ?? "(none)"}`,
314
+ `started_at: ${status.startedAt}`,
315
+ ];
316
+ for (const adapter of DAEMON_ADAPTERS) {
317
+ const slot = status.slots.find((entry) => entry.adapter === adapter);
318
+ if (!slot) {
319
+ lines.push(`${adapter}: not started`);
320
+ continue;
321
+ }
322
+ const flags = [
323
+ slot.pendingApproval ? "pending_approval" : "",
324
+ slot.pendingUserInput ? "pending_input" : "",
325
+ slot.companionPid ? `companion_pid=${slot.companionPid}` : "",
326
+ ].filter(Boolean);
327
+ lines.push(`${adapter}: ${slot.status}${flags.length ? ` (${flags.join(", ")})` : ""}`);
328
+ }
329
+ return lines.join("\n");
330
+ }
331
+ class WechatDaemon {
332
+ cwd;
333
+ profile;
334
+ authorizedUserId;
335
+ transport;
336
+ slots = new Map();
337
+ startedAt = new Date().toISOString();
338
+ bridgeStartedAtMs = Date.now();
339
+ activeAdapter = null;
340
+ textSendChain = Promise.resolve();
341
+ attachmentSendChain = Promise.resolve();
342
+ pendingWechatForwardTasks = new Set();
343
+ shutdownPromise = null;
344
+ ipcServer = null;
345
+ endpointToken = "";
346
+ constructor(params) {
347
+ this.cwd = params.cwd;
348
+ this.profile = params.profile;
349
+ this.authorizedUserId = params.authorizedUserId;
350
+ this.transport = params.transport;
351
+ }
352
+ async startIpcServer() {
353
+ this.endpointToken = buildDaemonToken();
354
+ await new Promise((resolve, reject) => {
355
+ const server = net.createServer((socket) => {
356
+ socket.setNoDelay(true);
357
+ let detach = null;
358
+ detach = attachDaemonRequestListener(socket, (frame) => {
359
+ if (frame.token !== this.endpointToken) {
360
+ sendDaemonResponse(socket, frame.id, {
361
+ ok: false,
362
+ error: "Invalid daemon IPC token.",
363
+ });
364
+ return;
365
+ }
366
+ void this.handleDaemonRequest(frame.payload).then((result) => {
367
+ sendDaemonResponse(socket, frame.id, { ok: true, result });
368
+ }, (error) => {
369
+ sendDaemonResponse(socket, frame.id, {
370
+ ok: false,
371
+ error: error instanceof Error ? error.message : String(error),
372
+ });
373
+ });
374
+ });
375
+ socket.once("close", () => {
376
+ detach?.();
377
+ detach = null;
378
+ });
379
+ socket.once("error", () => {
380
+ socket.destroy();
381
+ });
382
+ });
383
+ this.ipcServer = server;
384
+ server.once("error", reject);
385
+ server.listen(0, DAEMON_HOST, () => {
386
+ const address = server.address();
387
+ if (!address || typeof address === "string") {
388
+ reject(new Error("Failed to allocate daemon IPC port."));
389
+ return;
390
+ }
391
+ writeDaemonEndpoint({
392
+ protocolVersion: DAEMON_PROTOCOL_VERSION,
393
+ pid: process.pid,
394
+ port: address.port,
395
+ token: this.endpointToken,
396
+ cwd: this.cwd,
397
+ startedAt: this.startedAt,
398
+ });
399
+ resolve();
400
+ });
401
+ });
402
+ }
403
+ getStatus() {
404
+ return {
405
+ cwd: this.cwd,
406
+ activeAdapter: this.activeAdapter ?? undefined,
407
+ startedAt: this.startedAt,
408
+ slots: Array.from(this.slots.values()).map((slot) => {
409
+ const endpoint = readLocalCompanionEndpoint(this.cwd, {
410
+ adapter: slot.adapter,
411
+ });
412
+ return {
413
+ adapter: slot.adapter,
414
+ status: slot.runtime.getState().status,
415
+ cwd: this.cwd,
416
+ companionPid: endpoint?.companionPid,
417
+ pendingApproval: Boolean(slot.pendingConfirmation),
418
+ pendingUserInput: Boolean(slot.pendingUserInput),
419
+ };
420
+ }),
421
+ };
422
+ }
423
+ async runInitialAdapter(options) {
424
+ if (!options.initialAdapter) {
425
+ return;
426
+ }
427
+ await this.ensureSlot(options.initialAdapter, {
428
+ profile: options.profile,
429
+ openVisible: options.openVisible,
430
+ });
431
+ }
432
+ async runPollLoop() {
433
+ let consecutivePollFailures = 0;
434
+ log("WeChat daemon is ready.");
435
+ log(`Working directory: ${this.cwd}`);
436
+ log("Switch from WeChat with /codex, /claude, or /opencode.");
437
+ appendDaemonLog(`started: cwd=${this.cwd}`);
438
+ while (!this.shutdownPromise) {
439
+ let pollResult;
440
+ try {
441
+ pollResult = await this.transport.pollMessages({
442
+ timeoutMs: DEFAULT_LONG_POLL_TIMEOUT_MS,
443
+ minCreatedAtMs: this.bridgeStartedAtMs - MESSAGE_START_GRACE_MS,
444
+ });
445
+ }
446
+ catch (error) {
447
+ const classification = classifyWechatTransportError(error);
448
+ if (!classification.retryable) {
449
+ throw error;
450
+ }
451
+ consecutivePollFailures += 1;
452
+ const delayMs = computePollRetryDelayMs(consecutivePollFailures);
453
+ const errorText = describeWechatTransportError(error);
454
+ const statusDetails = typeof classification.statusCode === "number"
455
+ ? ` status=${classification.statusCode}`
456
+ : "";
457
+ logError(`WeChat long poll failed (${classification.kind}${statusDetails}, attempt ${consecutivePollFailures}). Retrying in ${formatDuration(delayMs)}. ${errorText}`);
458
+ appendDaemonLog(`poll_retry: kind=${classification.kind}${statusDetails} attempt=${consecutivePollFailures} delay_ms=${delayMs} error=${truncatePreview(errorText, 400)}`);
459
+ await delay(delayMs);
460
+ continue;
461
+ }
462
+ if (consecutivePollFailures > 0) {
463
+ log(`WeChat long poll recovered after ${consecutivePollFailures} transient error(s).`);
464
+ appendDaemonLog(`poll_recovered: failures=${consecutivePollFailures}`);
465
+ consecutivePollFailures = 0;
466
+ }
467
+ if (pollResult.ignoredBacklogCount > 0) {
468
+ appendDaemonLog(`ignored_startup_backlog: count=${pollResult.ignoredBacklogCount}`);
469
+ }
470
+ for (const message of pollResult.messages) {
471
+ try {
472
+ await this.handleInboundMessage(message);
473
+ }
474
+ catch (error) {
475
+ const errorText = error instanceof Error ? error.message : String(error);
476
+ const isUserFacingShellRejection = error instanceof Error && error.name === "ShellCommandRejectedError";
477
+ logError(errorText);
478
+ appendDaemonLog(`${isUserFacingShellRejection ? "inbound_rejected" : "inbound_error"}: ${errorText}`);
479
+ await this.queueWechatMessage(message.senderId, formatUserFacingInboundError({
480
+ adapter: this.activeAdapter ?? "codex",
481
+ cwd: this.cwd,
482
+ errorText,
483
+ isUserFacingShellRejection,
484
+ }), "inbound_error");
485
+ }
486
+ }
487
+ }
488
+ }
489
+ async shutdown() {
490
+ if (!this.shutdownPromise) {
491
+ this.shutdownPromise = this.cleanup();
492
+ }
493
+ await this.shutdownPromise;
494
+ }
495
+ async cleanup() {
496
+ appendDaemonLog("shutdown_started");
497
+ for (const slot of this.slots.values()) {
498
+ try {
499
+ await slot.outputBatcher.flushNow();
500
+ }
501
+ catch {
502
+ // Best effort flush.
503
+ }
504
+ }
505
+ await this.waitForPendingWechatForwardTasks();
506
+ await this.textSendChain.catch(() => undefined);
507
+ await this.attachmentSendChain.catch(() => undefined);
508
+ for (const slot of this.slots.values()) {
509
+ try {
510
+ await slot.runtime.dispose();
511
+ }
512
+ catch {
513
+ // Best effort shutdown.
514
+ }
515
+ slot.controller.clearLocalClientEndpoint();
516
+ }
517
+ this.slots.clear();
518
+ if (this.ipcServer) {
519
+ const server = this.ipcServer;
520
+ this.ipcServer = null;
521
+ await new Promise((resolve) => {
522
+ server.close(() => resolve());
523
+ });
524
+ }
525
+ clearDaemonEndpoint();
526
+ appendDaemonLog("shutdown_complete");
527
+ }
528
+ async handleDaemonRequest(request) {
529
+ switch (request.command) {
530
+ case "status":
531
+ return this.getStatus();
532
+ case "shutdown":
533
+ setTimeout(() => {
534
+ void this.shutdown().finally(() => process.exit(0));
535
+ }, 0);
536
+ return { shuttingDown: true };
537
+ case "ensure_slot":
538
+ if (!isSameWorkspaceCwd(request.cwd, this.cwd)) {
539
+ throw new Error(`wechat-daemon is bound to ${this.cwd}; requested cwd was ${request.cwd}.`);
540
+ }
541
+ return await this.ensureSlot(request.adapter, {
542
+ profile: request.profile,
543
+ cliArgs: request.cliArgs,
544
+ openVisible: request.openVisible ?? true,
545
+ });
546
+ case "switch_adapter":
547
+ return await this.ensureSlot(request.adapter, {
548
+ profile: request.profile,
549
+ cliArgs: request.cliArgs,
550
+ openVisible: request.openVisible ?? true,
551
+ });
552
+ }
553
+ }
554
+ async ensureSlot(adapter, options = {}) {
555
+ let slot = this.slots.get(adapter);
556
+ let created = false;
557
+ if (!slot) {
558
+ slot = await this.createSlot(adapter, {
559
+ profile: options.profile ?? this.profile,
560
+ });
561
+ this.slots.set(adapter, slot);
562
+ created = true;
563
+ }
564
+ this.activeAdapter = adapter;
565
+ let openedVisible = false;
566
+ let visibleConnected = isVisibleClientAlive(this.cwd, adapter);
567
+ if (options.openVisible !== false && !visibleConnected) {
568
+ const launch = openVisibleClient({
569
+ adapter,
570
+ cwd: this.cwd,
571
+ cliArgs: options.cliArgs,
572
+ onError: (error) => {
573
+ appendDaemonLog(`visible_client_open_error: adapter=${adapter} error=${truncatePreview(error.message, 400)}`);
574
+ },
575
+ });
576
+ openedVisible = true;
577
+ appendDaemonLog(`visible_client_open_attempt: adapter=${adapter} cwd=${this.cwd} pid=${launch.pid ?? "unknown"} command=${truncatePreview(formatLaunchPreview(launch), 400)}`);
578
+ visibleConnected = await waitForVisibleClientConnection({
579
+ cwd: this.cwd,
580
+ adapter,
581
+ });
582
+ if (visibleConnected) {
583
+ appendDaemonLog(`visible_client_connected: adapter=${adapter} cwd=${this.cwd}`);
584
+ }
585
+ else {
586
+ log(`${adapter} visible CLI did not connect within ${formatDuration(VISIBLE_CLIENT_CONNECT_TIMEOUT_MS)}. Check ${BRIDGE_LOG_FILE}.`);
587
+ const cleanedLauncher = cleanupVisibleClientLauncher(launch);
588
+ appendDaemonLog(`visible_client_connect_timeout: adapter=${adapter} cwd=${this.cwd} timeout_ms=${VISIBLE_CLIENT_CONNECT_TIMEOUT_MS} cleaned_launcher=${cleanedLauncher}`);
589
+ }
590
+ }
591
+ appendDaemonLog(`switch_adapter: adapter=${adapter} created=${created} opened_visible=${openedVisible} visible_connected=${visibleConnected}`);
592
+ return {
593
+ activeAdapter: adapter,
594
+ created,
595
+ openedVisible,
596
+ visibleConnected,
597
+ };
598
+ }
599
+ async createSlot(adapter, options) {
600
+ clearLocalCompanionEndpoint(this.cwd, undefined, { adapter });
601
+ const runtime = createRuntimeHost({
602
+ kind: adapter,
603
+ command: resolveDefaultAdapterCommand(adapter),
604
+ cwd: this.cwd,
605
+ profile: options.profile,
606
+ lifecycle: "persistent",
607
+ companionLaunchMode: "daemon_auto",
608
+ });
609
+ const controller = new BridgeController(runtime, this.cwd);
610
+ const slot = {
611
+ adapter,
612
+ runtime,
613
+ controller,
614
+ outputBatcher: new OutputBatcher(async (text) => {
615
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(adapter, text));
616
+ }),
617
+ pendingConfirmation: null,
618
+ pendingUserInput: null,
619
+ activeTask: null,
620
+ lastOutputAt: 0,
621
+ };
622
+ runtime.setEventSink((event) => {
623
+ this.handleSlotEvent(slot, event);
624
+ });
625
+ await runtime.start();
626
+ controller.syncLocalClientEndpoint();
627
+ appendDaemonLog(`slot_started: adapter=${adapter} command=${resolveDefaultAdapterCommand(adapter)} cwd=${this.cwd}`);
628
+ return slot;
629
+ }
630
+ handleSlotEvent(slot, event) {
631
+ slot.controller.syncLocalClientEndpoint();
632
+ const adapterState = slot.runtime.getState();
633
+ if (slot.pendingConfirmation && !adapterState.pendingApproval) {
634
+ slot.pendingConfirmation = null;
635
+ }
636
+ if (slot.pendingUserInput && !adapterState.pendingUserInput) {
637
+ slot.pendingUserInput = null;
638
+ }
639
+ switch (event.type) {
640
+ case "stdout":
641
+ case "stderr":
642
+ slot.lastOutputAt = Date.now();
643
+ if (shouldForwardBridgeEventToWechat(slot.adapter, event.type)) {
644
+ slot.outputBatcher.push(event.text);
645
+ }
646
+ break;
647
+ case "final_reply":
648
+ appendDaemonLog(`final_reply: adapter=${slot.adapter} text=${truncatePreview(event.text)}`);
649
+ this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
650
+ await forwardWechatFinalReply({
651
+ adapter: slot.adapter,
652
+ rawText: event.text,
653
+ onEmptyVisibleReply: ({ rawVisibleText }) => {
654
+ appendDaemonLog(`empty_visible_final_reply: adapter=${slot.adapter} raw=${truncatePreview(rawVisibleText)}`);
655
+ },
656
+ sender: {
657
+ sendText: async (text) => {
658
+ const sent = await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, text), "final_reply");
659
+ if (sent) {
660
+ appendDaemonLog(`final_reply_sent: adapter=${slot.adapter} chars=${Array.from(text).length}`);
661
+ }
662
+ return sent;
663
+ },
664
+ sendImage: (imagePath) => this.queueWechatAttachmentAction(() => this.transport.sendImage(imagePath, {
665
+ recipientId: this.authorizedUserId,
666
+ })),
667
+ sendFile: (filePath) => this.queueWechatAttachmentAction(() => this.transport.sendFile(filePath, {
668
+ recipientId: this.authorizedUserId,
669
+ })),
670
+ sendVoice: (voicePath) => this.queueWechatAttachmentAction(() => this.transport.sendVoice(voicePath, this.authorizedUserId)),
671
+ sendVideo: (videoPath) => this.queueWechatAttachmentAction(() => this.transport.sendVideo(videoPath, {
672
+ recipientId: this.authorizedUserId,
673
+ })),
674
+ },
675
+ });
676
+ }));
677
+ break;
678
+ case "status":
679
+ if (event.message) {
680
+ log(`${slot.adapter} ${event.status}: ${event.message}`);
681
+ appendDaemonLog(`${slot.adapter}_${event.status}: ${event.message}`);
682
+ }
683
+ break;
684
+ case "notice":
685
+ slot.lastOutputAt = Date.now();
686
+ appendDaemonLog(`${slot.adapter}_${event.level}_notice: ${truncatePreview(event.text)}`);
687
+ if (shouldForwardBridgeEventToWechat(slot.adapter, event.type, { text: event.text })) {
688
+ this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
689
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, event.text), "notice");
690
+ }));
691
+ }
692
+ break;
693
+ case "approval_required":
694
+ this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
695
+ const pending = toPendingApproval(event);
696
+ slot.pendingConfirmation = pending;
697
+ appendDaemonLog(`approval_required: adapter=${slot.adapter} source=${pending.source} command=${truncatePreview(pending.commandPreview)}`);
698
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatApprovalMessage(pending, adapterState)), "approval_required");
699
+ }));
700
+ break;
701
+ case "user_input_required":
702
+ this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
703
+ const pending = toPendingUserInput(event.request);
704
+ slot.pendingUserInput = pending;
705
+ appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
706
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserInputRequestMessage(pending, adapterState)), "approval_required");
707
+ }));
708
+ break;
709
+ case "mirrored_user_input":
710
+ appendDaemonLog(`mirrored_local_input: adapter=${slot.adapter} text=${truncatePreview(event.text)}`);
711
+ if (shouldForwardBridgeEventToWechat(slot.adapter, event.type, { text: event.text })) {
712
+ this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
713
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatMirroredUserInputMessage(slot.adapter, event.text)), "mirrored_user_input");
714
+ }));
715
+ }
716
+ break;
717
+ case "session_switched":
718
+ appendDaemonLog(`session_switched: adapter=${slot.adapter} session=${event.sessionId} source=${event.source} reason=${event.reason}`);
719
+ if (shouldForwardBridgeEventToWechat(slot.adapter, event.type)) {
720
+ this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
721
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({
722
+ adapter: slot.adapter,
723
+ sessionId: event.sessionId,
724
+ source: event.source,
725
+ reason: event.reason,
726
+ })), "session_switched");
727
+ }));
728
+ }
729
+ break;
730
+ case "thread_switched":
731
+ appendDaemonLog(`thread_switched: adapter=${slot.adapter} thread=${event.threadId} source=${event.source} reason=${event.reason}`);
732
+ if (shouldForwardBridgeEventToWechat(slot.adapter, event.type)) {
733
+ this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
734
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({
735
+ adapter: slot.adapter,
736
+ sessionId: event.threadId,
737
+ source: event.source,
738
+ reason: event.reason,
739
+ })), "thread_switched");
740
+ }));
741
+ }
742
+ break;
743
+ case "task_complete":
744
+ this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(() => {
745
+ slot.pendingConfirmation = null;
746
+ slot.pendingUserInput = null;
747
+ slot.activeTask = null;
748
+ }));
749
+ break;
750
+ case "task_failed":
751
+ this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
752
+ slot.pendingConfirmation = null;
753
+ slot.pendingUserInput = null;
754
+ slot.activeTask = null;
755
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatTaskFailedMessage(slot.adapter, event.message)), "task_failed");
756
+ }));
757
+ break;
758
+ case "fatal_error":
759
+ logError(`${slot.adapter}: ${event.message}`);
760
+ appendDaemonLog(`fatal_error: adapter=${slot.adapter} message=${event.message}`);
761
+ slot.pendingConfirmation = null;
762
+ slot.pendingUserInput = null;
763
+ slot.activeTask = null;
764
+ this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
765
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(event.message)), "fatal_error");
766
+ }));
767
+ break;
768
+ case "shutdown_requested":
769
+ appendDaemonLog(`slot_shutdown_requested: adapter=${slot.adapter} reason=${event.reason}`);
770
+ break;
771
+ }
772
+ }
773
+ async handleInboundMessage(message) {
774
+ if (message.senderId !== this.authorizedUserId) {
775
+ await this.queueWechatMessage(message.senderId, "Unauthorized. This daemon only accepts messages from the configured WeChat owner.");
776
+ return;
777
+ }
778
+ const switchAdapter = parseDaemonSwitchCommand(message.text);
779
+ if (switchAdapter) {
780
+ const result = await this.ensureSlot(switchAdapter, {
781
+ openVisible: true,
782
+ });
783
+ const detail = formatDaemonSwitchResultDetail(result);
784
+ await this.queueWechatMessage(message.senderId, `Active terminal: ${switchAdapter}.\n${detail}`);
785
+ return;
786
+ }
787
+ if (message.text.trim().toLowerCase() === "/daemon-stop") {
788
+ await this.queueWechatMessage(message.senderId, "Stopping wechat-daemon...");
789
+ setTimeout(() => {
790
+ void this.shutdown().finally(() => process.exit(0));
791
+ }, 0);
792
+ return;
793
+ }
794
+ const slot = this.getActiveSlot();
795
+ if (!slot) {
796
+ await this.queueWechatMessage(message.senderId, formatNoActiveAdapterMessage());
797
+ return;
798
+ }
799
+ const command = parseWechatControlCommand(message.text, {
800
+ adapter: slot.adapter,
801
+ hasPendingConfirmation: Boolean(slot.pendingConfirmation),
802
+ hasPendingUserInput: Boolean(slot.pendingUserInput),
803
+ });
804
+ if (command) {
805
+ await this.handleSystemCommand(message, slot, command);
806
+ return;
807
+ }
808
+ if (slot.pendingConfirmation) {
809
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, formatPendingApprovalReminder(slot.pendingConfirmation, slot.runtime.getState())));
810
+ return;
811
+ }
812
+ if (slot.pendingUserInput) {
813
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, formatPendingUserInputReminder(slot.pendingUserInput)));
814
+ return;
815
+ }
816
+ const adapterState = slot.runtime.getState();
817
+ if (adapterState.status === "busy" || adapterState.status === "awaiting_approval") {
818
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`));
819
+ return;
820
+ }
821
+ await this.dispatchInboundWechatText(message, slot);
822
+ }
823
+ async handleSystemCommand(message, activeSlot, command) {
824
+ switch (command.type) {
825
+ case "status":
826
+ await this.queueWechatMessage(message.senderId, formatDaemonStatus(this.getStatus()));
827
+ return;
828
+ case "resume":
829
+ await this.queueWechatMessage(message.senderId, `WeChat /resume is disabled in daemon mode. Use /resume directly inside the visible ${activeSlot.adapter} terminal; WeChat will follow that local session.`);
830
+ return;
831
+ case "new_session":
832
+ if (!activeSlot.runtime.createSession) {
833
+ await this.queueWechatMessage(message.senderId, `/new is not available in ${activeSlot.adapter} mode.`);
834
+ return;
835
+ }
836
+ await activeSlot.outputBatcher.flushNow();
837
+ activeSlot.outputBatcher.clear();
838
+ activeSlot.pendingConfirmation = null;
839
+ activeSlot.pendingUserInput = null;
840
+ await activeSlot.runtime.createSession();
841
+ appendDaemonLog(`new_session: adapter=${activeSlot.adapter}`);
842
+ return;
843
+ case "stop": {
844
+ const interrupted = await activeSlot.runtime.interrupt();
845
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, interrupted
846
+ ? "Interrupt signal sent to the active worker."
847
+ : "No running worker was available to interrupt."));
848
+ return;
849
+ }
850
+ case "reset":
851
+ await activeSlot.outputBatcher.flushNow();
852
+ activeSlot.outputBatcher.clear();
853
+ activeSlot.pendingConfirmation = null;
854
+ activeSlot.pendingUserInput = null;
855
+ await activeSlot.runtime.reset();
856
+ appendDaemonLog(`reset: adapter=${activeSlot.adapter}`);
857
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "Worker session has been reset."));
858
+ return;
859
+ case "confirm":
860
+ await this.confirmPendingApproval(message, activeSlot, command.code);
861
+ return;
862
+ case "deny":
863
+ await this.denyPendingApproval(message, activeSlot);
864
+ return;
865
+ case "answer":
866
+ await this.answerPendingUserInput(message, activeSlot, command.raw);
867
+ return;
868
+ }
869
+ }
870
+ async confirmPendingApproval(message, activeSlot, code) {
871
+ const slot = this.resolvePendingApprovalSlot(activeSlot, code);
872
+ if (!slot) {
873
+ await this.queueWechatMessage(message.senderId, "No matching pending approval request.");
874
+ return;
875
+ }
876
+ const pending = slot.pendingConfirmation;
877
+ if (!pending) {
878
+ await this.queueWechatMessage(message.senderId, "No pending approval request.");
879
+ return;
880
+ }
881
+ if (pending.code !== code && this.countPendingApprovals() > 1) {
882
+ await this.queueWechatMessage(message.senderId, "Confirmation code did not match.");
883
+ return;
884
+ }
885
+ const confirmed = await slot.runtime.resolveApproval("confirm");
886
+ if (!confirmed) {
887
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "The worker could not apply this approval request."));
888
+ return;
889
+ }
890
+ slot.pendingConfirmation = null;
891
+ slot.activeTask = {
892
+ startedAt: Date.now(),
893
+ inputPreview: pending.commandPreview,
894
+ };
895
+ appendDaemonLog(`approval_confirmed: adapter=${slot.adapter} command=${truncatePreview(pending.commandPreview)}`);
896
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "Approval confirmed. Continuing..."));
897
+ }
898
+ async denyPendingApproval(message, activeSlot) {
899
+ const slot = activeSlot.pendingConfirmation || this.countPendingApprovals() !== 1
900
+ ? activeSlot
901
+ : Array.from(this.slots.values()).find((entry) => entry.pendingConfirmation) ?? activeSlot;
902
+ const pending = slot.pendingConfirmation;
903
+ if (!pending) {
904
+ await this.queueWechatMessage(message.senderId, "No pending approval request.");
905
+ return;
906
+ }
907
+ const denied = await slot.runtime.resolveApproval("deny");
908
+ if (!denied) {
909
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "The worker could not deny this approval request cleanly."));
910
+ return;
911
+ }
912
+ slot.pendingConfirmation = null;
913
+ appendDaemonLog(`approval_denied: adapter=${slot.adapter} command=${truncatePreview(pending.commandPreview)}`);
914
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "Approval denied."));
915
+ }
916
+ async answerPendingUserInput(message, activeSlot, raw) {
917
+ const pending = activeSlot.pendingUserInput;
918
+ if (!pending) {
919
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "No pending user input request."));
920
+ return;
921
+ }
922
+ const parsed = parsePendingUserInputAnswerCommand(raw, pending);
923
+ if ("error" in parsed) {
924
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, parsed.error));
925
+ return;
926
+ }
927
+ const submitted = await activeSlot.runtime.submitUserInput(parsed.answers);
928
+ if (!submitted) {
929
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "The worker could not apply this answer."));
930
+ return;
931
+ }
932
+ activeSlot.pendingUserInput = null;
933
+ activeSlot.activeTask = {
934
+ startedAt: Date.now(),
935
+ inputPreview: parsed.preview,
936
+ };
937
+ appendDaemonLog(`user_input_answered: adapter=${activeSlot.adapter} preview=${parsed.preview}`);
938
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "Answer submitted. Continuing..."));
939
+ }
940
+ resolvePendingApprovalSlot(activeSlot, code) {
941
+ if (code) {
942
+ return (Array.from(this.slots.values()).find((slot) => slot.pendingConfirmation?.code === code) ?? null);
943
+ }
944
+ if (activeSlot.pendingConfirmation && this.countPendingApprovals() <= 1) {
945
+ return activeSlot;
946
+ }
947
+ return null;
948
+ }
949
+ countPendingApprovals() {
950
+ return Array.from(this.slots.values()).filter((slot) => slot.pendingConfirmation)
951
+ .length;
952
+ }
953
+ getActiveSlot() {
954
+ if (!this.activeAdapter) {
955
+ return null;
956
+ }
957
+ return this.slots.get(this.activeAdapter) ?? null;
958
+ }
959
+ async dispatchInboundWechatText(message, slot) {
960
+ const preview = formatInboundMessagePreview(message);
961
+ slot.activeTask = {
962
+ startedAt: Date.now(),
963
+ inputPreview: truncatePreview(preview, 180),
964
+ };
965
+ appendDaemonLog(`forwarded_input: adapter=${slot.adapter} text=${truncatePreview(preview)}`);
966
+ await slot.runtime.sendInput(buildWechatInboundPrompt(message.text, message.attachments));
967
+ }
968
+ queueWechatTextAction(action) {
969
+ const run = this.textSendChain.then(action);
970
+ this.textSendChain = run.then(() => undefined, () => undefined);
971
+ return run;
972
+ }
973
+ queueWechatAttachmentAction(action) {
974
+ const run = this.attachmentSendChain.then(action);
975
+ this.attachmentSendChain = run.then(() => undefined, () => undefined);
976
+ return run;
977
+ }
978
+ queueWechatMessage(senderId, text, context = "message") {
979
+ return this.queueWechatTextAction(async () => {
980
+ for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
981
+ try {
982
+ await this.transport.sendText(senderId, text);
983
+ return true;
984
+ }
985
+ catch (error) {
986
+ if (isWechatContextTokenStaleError(error)) {
987
+ this.transport.clearCachedContextToken(senderId);
988
+ appendDaemonLog(formatWechatContextTokenStaleLogEntry({
989
+ context,
990
+ recipientId: senderId,
991
+ error,
992
+ }));
993
+ return false;
994
+ }
995
+ if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(error)) {
996
+ const delayMs = computeWechatSendRetryDelayMs(attempt);
997
+ appendDaemonLog(`wechat_send_retry: context=${context} recipient=${senderId} attempt=${attempt} delay_ms=${delayMs} error=${truncatePreview(describeWechatTransportError(error), 400)}`);
998
+ await delay(delayMs);
999
+ continue;
1000
+ }
1001
+ logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(error)}`);
1002
+ appendDaemonLog(formatWechatSendFailureLogEntry({
1003
+ context,
1004
+ recipientId: senderId,
1005
+ error,
1006
+ }));
1007
+ return false;
1008
+ }
1009
+ }
1010
+ return false;
1011
+ });
1012
+ }
1013
+ trackWechatForwardTask(task) {
1014
+ const tracked = task
1015
+ .catch((error) => {
1016
+ logError(`WeChat forward task failed: ${describeWechatTransportError(error)}`);
1017
+ appendDaemonLog(`wechat_forward_failed: error=${truncatePreview(describeWechatTransportError(error), 400)}`);
1018
+ })
1019
+ .finally(() => {
1020
+ this.pendingWechatForwardTasks.delete(tracked);
1021
+ });
1022
+ this.pendingWechatForwardTasks.add(tracked);
1023
+ }
1024
+ async waitForPendingWechatForwardTasks() {
1025
+ while (this.pendingWechatForwardTasks.size > 0) {
1026
+ await Promise.allSettled([...this.pendingWechatForwardTasks]);
1027
+ }
1028
+ }
1029
+ }
1030
+ async function assertNoLiveDaemon() {
1031
+ const endpoint = readDaemonEndpoint();
1032
+ if (!endpoint) {
1033
+ return;
1034
+ }
1035
+ if (await isDaemonEndpointAlive(endpoint, { timeoutMs: 500 })) {
1036
+ throw new Error(`wechat-daemon is already running (pid=${endpoint.pid}, cwd=${endpoint.cwd}).`);
1037
+ }
1038
+ clearDaemonEndpoint(endpoint.pid);
1039
+ }
1040
+ async function waitForProcessExit(params) {
1041
+ const deadline = Date.now() + params.timeoutMs;
1042
+ while (Date.now() < deadline) {
1043
+ if (!params.isAlive(params.pid)) {
1044
+ return true;
1045
+ }
1046
+ await params.sleep(Math.min(params.pollMs, deadline - Date.now()));
1047
+ }
1048
+ return !params.isAlive(params.pid);
1049
+ }
1050
+ function clearSingleBridgeLock(lock) {
1051
+ try {
1052
+ const current = readBridgeLockFile();
1053
+ if (!current ||
1054
+ current.pid === lock.pid ||
1055
+ current.instanceId === lock.instanceId) {
1056
+ fs.rmSync(BRIDGE_LOCK_FILE, { force: true });
1057
+ }
1058
+ }
1059
+ catch {
1060
+ // Best effort cleanup.
1061
+ }
1062
+ }
1063
+ function clearSingleBridgeEndpoint(lock) {
1064
+ clearLocalCompanionEndpoint(lock.cwd, undefined, { adapter: lock.adapter });
1065
+ }
1066
+ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
1067
+ const readLock = deps.readLock ?? readBridgeLockFile;
1068
+ const isAlive = deps.isAlive ?? isPidAlive;
1069
+ const killProcess = deps.killProcess ?? ((pid, signal) => {
1070
+ if (signal === "SIGKILL") {
1071
+ killProcessTreeSync(pid);
1072
+ return;
1073
+ }
1074
+ process.kill(pid, signal);
1075
+ });
1076
+ const clearLock = deps.clearLock ?? clearSingleBridgeLock;
1077
+ const clearEndpoint = deps.clearEndpoint ?? clearSingleBridgeEndpoint;
1078
+ const sleepFn = deps.sleep ?? sleep;
1079
+ const cleanupLog = deps.log ?? log;
1080
+ const stopTimeoutMs = deps.stopTimeoutMs ?? SINGLE_BRIDGE_STOP_TIMEOUT_MS;
1081
+ const forceStopTimeoutMs = deps.forceStopTimeoutMs ?? SINGLE_BRIDGE_FORCE_STOP_TIMEOUT_MS;
1082
+ const pollMs = deps.pollMs ?? SINGLE_BRIDGE_STOP_POLL_MS;
1083
+ const lock = readLock();
1084
+ if (!lock) {
1085
+ return { action: "none" };
1086
+ }
1087
+ const clearBridgeArtifacts = () => {
1088
+ clearEndpoint(lock);
1089
+ clearLock(lock);
1090
+ };
1091
+ if (!isAlive(lock.pid)) {
1092
+ cleanupLog(`Found stale single bridge lock for ${lock.cwd} (pid=${lock.pid} dead). Cleaning it before daemon startup.`);
1093
+ appendDaemonLog(`single_bridge_stale_cleanup: pid=${lock.pid} adapter=${lock.adapter} cwd=${lock.cwd}`);
1094
+ clearBridgeArtifacts();
1095
+ return { action: "cleared_stale_lock", lock };
1096
+ }
1097
+ cleanupLog(`Found existing single bridge for ${lock.cwd} (pid=${lock.pid}, adapter=${lock.adapter}). Stopping it before daemon startup...`);
1098
+ appendDaemonLog(`single_bridge_takeover_attempt: pid=${lock.pid} adapter=${lock.adapter} cwd=${lock.cwd}`);
1099
+ try {
1100
+ killProcess(lock.pid, "SIGTERM");
1101
+ }
1102
+ catch (error) {
1103
+ if (isAlive(lock.pid)) {
1104
+ appendDaemonLog(`single_bridge_sigterm_failed: pid=${lock.pid} error=${truncatePreview(error instanceof Error ? error.message : String(error), 400)}`);
1105
+ }
1106
+ }
1107
+ let forced = false;
1108
+ let stopped = await waitForProcessExit({
1109
+ pid: lock.pid,
1110
+ timeoutMs: stopTimeoutMs,
1111
+ pollMs,
1112
+ isAlive,
1113
+ sleep: sleepFn,
1114
+ });
1115
+ if (!stopped) {
1116
+ forced = true;
1117
+ cleanupLog(`Single bridge pid=${lock.pid} did not stop in ${formatDuration(stopTimeoutMs)}. Forcing cleanup...`);
1118
+ appendDaemonLog(`single_bridge_force_stop_attempt: pid=${lock.pid} adapter=${lock.adapter} cwd=${lock.cwd}`);
1119
+ try {
1120
+ killProcess(lock.pid, "SIGKILL");
1121
+ }
1122
+ catch (error) {
1123
+ if (isAlive(lock.pid)) {
1124
+ appendDaemonLog(`single_bridge_sigkill_failed: pid=${lock.pid} error=${truncatePreview(error instanceof Error ? error.message : String(error), 400)}`);
1125
+ }
1126
+ }
1127
+ stopped = await waitForProcessExit({
1128
+ pid: lock.pid,
1129
+ timeoutMs: forceStopTimeoutMs,
1130
+ pollMs,
1131
+ isAlive,
1132
+ sleep: sleepFn,
1133
+ });
1134
+ }
1135
+ if (!stopped && isAlive(lock.pid)) {
1136
+ throw new Error(`Could not stop existing single bridge automatically (pid=${lock.pid}, adapter=${lock.adapter}, cwd=${lock.cwd}).`);
1137
+ }
1138
+ clearBridgeArtifacts();
1139
+ cleanupLog(`Cleaned previous single bridge for ${lock.cwd}; daemon startup can continue.`);
1140
+ appendDaemonLog(`single_bridge_takeover_complete: pid=${lock.pid} adapter=${lock.adapter} cwd=${lock.cwd} forced=${forced}`);
1141
+ return { action: "stopped", lock, forced };
1142
+ }
1143
+ export async function runDaemon(options) {
1144
+ migrateLegacyChannelFiles((message) => log(message));
1145
+ await assertNoLiveDaemon();
1146
+ await cleanupSingleBridgeBeforeDaemon();
1147
+ const reapedPeerPids = await reapPeerBridgeProcesses({
1148
+ logger: (message) => appendDaemonLog(message),
1149
+ });
1150
+ if (reapedPeerPids.length > 0) {
1151
+ log(`Cleaned ${reapedPeerPids.length} peer bridge process(es): ${reapedPeerPids.join(", ")}`);
1152
+ }
1153
+ const reapedOpencodePids = await reapOrphanedOpencodeProcesses({
1154
+ logger: (message) => appendDaemonLog(message),
1155
+ });
1156
+ if (reapedOpencodePids.length > 0) {
1157
+ log(`Cleaned ${reapedOpencodePids.length} orphaned OpenCode process(es): ${reapedOpencodePids.join(", ")}`);
1158
+ }
1159
+ const credentials = await ensureWechatCredentials({
1160
+ requireUserId: true,
1161
+ validateExisting: true,
1162
+ log,
1163
+ });
1164
+ if (!credentials.userId) {
1165
+ throw new Error("Saved WeChat credentials are missing userId.");
1166
+ }
1167
+ const daemon = new WechatDaemon({
1168
+ cwd: options.cwd,
1169
+ profile: options.profile,
1170
+ authorizedUserId: credentials.userId,
1171
+ transport: new WeChatTransport({ log, logError }),
1172
+ });
1173
+ await daemon.startIpcServer();
1174
+ await daemon.runInitialAdapter(options);
1175
+ const requestShutdown = (signal) => {
1176
+ log(`Received ${signal}. Stopping daemon.`);
1177
+ void daemon.shutdown().finally(() => process.exit(0));
1178
+ };
1179
+ process.once("SIGINT", () => requestShutdown("SIGINT"));
1180
+ process.once("SIGTERM", () => requestShutdown("SIGTERM"));
1181
+ process.once("SIGHUP", () => requestShutdown("SIGHUP"));
1182
+ if (process.platform === "win32") {
1183
+ process.once("SIGBREAK", () => requestShutdown("SIGBREAK"));
1184
+ }
1185
+ process.on("exit", () => {
1186
+ clearDaemonEndpoint();
1187
+ });
1188
+ await daemon.runPollLoop();
1189
+ }
1190
+ export async function main(argv = process.argv.slice(2)) {
1191
+ try {
1192
+ await runDaemon(parseDaemonCliArgs(argv));
1193
+ }
1194
+ catch (error) {
1195
+ logError(error instanceof Error ? error.message : String(error));
1196
+ process.exit(1);
1197
+ }
1198
+ }
1199
+ const isDirectRun = Boolean(import.meta.main);
1200
+ if (isDirectRun) {
1201
+ void main();
1202
+ }