@unlinearity/cli-wechat-bridge 1.0.0 → 1.0.1

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 (80) hide show
  1. package/bin/_run-entry.mjs +2 -8
  2. package/bin/wechat-bridge-claude.mjs +2 -2
  3. package/bin/wechat-bridge-codex.mjs +2 -2
  4. package/bin/wechat-bridge-opencode.mjs +2 -2
  5. package/bin/wechat-bridge-shell.mjs +2 -2
  6. package/bin/wechat-bridge.mjs +2 -2
  7. package/bin/wechat-check-update.mjs +2 -2
  8. package/bin/wechat-claude-start.mjs +2 -2
  9. package/bin/wechat-claude.mjs +2 -2
  10. package/bin/wechat-codex-start.mjs +2 -2
  11. package/bin/wechat-codex.mjs +2 -2
  12. package/bin/wechat-opencode-start.mjs +2 -2
  13. package/bin/wechat-opencode.mjs +2 -2
  14. package/dist/bridge/bridge-adapters.claude.js +829 -0
  15. package/dist/bridge/bridge-adapters.codex.js +2206 -0
  16. package/dist/bridge/bridge-adapters.core.js +668 -0
  17. package/dist/bridge/bridge-adapters.js +26 -0
  18. package/dist/bridge/bridge-adapters.opencode.js +1777 -0
  19. package/dist/bridge/bridge-adapters.shared.js +1098 -0
  20. package/dist/bridge/bridge-adapters.shell.js +363 -0
  21. package/dist/bridge/bridge-controller.js +44 -0
  22. package/dist/bridge/bridge-final-reply.js +31 -0
  23. package/dist/bridge/bridge-process-reaper.js +348 -0
  24. package/dist/bridge/bridge-state.js +356 -0
  25. package/dist/bridge/bridge-types.js +1 -0
  26. package/dist/bridge/bridge-utils.js +1150 -0
  27. package/dist/bridge/claude-hook.js +82 -0
  28. package/dist/bridge/claude-hooks.js +267 -0
  29. package/dist/bridge/wechat-bridge.js +904 -0
  30. package/dist/commands/check-update.js +30 -0
  31. package/dist/companion/codex-panel-link.js +72 -0
  32. package/dist/companion/codex-panel.js +179 -0
  33. package/dist/companion/codex-remote-client.js +117 -0
  34. package/dist/companion/local-companion-link.js +210 -0
  35. package/dist/companion/local-companion-start.js +364 -0
  36. package/dist/companion/local-companion.js +411 -0
  37. package/dist/media/media-types.js +1 -0
  38. package/dist/runtime/create-runtime-host.js +12 -0
  39. package/dist/runtime/legacy-adapter-runtime.js +40 -0
  40. package/dist/runtime/runtime-types.js +5 -0
  41. package/dist/utils/version-checker.js +161 -0
  42. package/dist/wechat/channel-config.js +87 -0
  43. package/dist/wechat/setup.js +184 -0
  44. package/dist/wechat/standalone-bot.js +356 -0
  45. package/dist/wechat/wechat-channel.js +481 -0
  46. package/dist/wechat/wechat-transport.js +870 -0
  47. package/package.json +17 -14
  48. package/src/bridge/bridge-adapters.claude.ts +0 -1047
  49. package/src/bridge/bridge-adapters.codex.ts +0 -2733
  50. package/src/bridge/bridge-adapters.core.ts +0 -877
  51. package/src/bridge/bridge-adapters.opencode.ts +0 -2390
  52. package/src/bridge/bridge-adapters.shared.ts +0 -1647
  53. package/src/bridge/bridge-adapters.shell.ts +0 -456
  54. package/src/bridge/bridge-adapters.ts +0 -30
  55. package/src/bridge/bridge-controller.ts +0 -58
  56. package/src/bridge/bridge-final-reply.ts +0 -56
  57. package/src/bridge/bridge-process-reaper.ts +0 -417
  58. package/src/bridge/bridge-state.ts +0 -520
  59. package/src/bridge/bridge-types.ts +0 -218
  60. package/src/bridge/bridge-utils.ts +0 -1524
  61. package/src/bridge/claude-hook.ts +0 -92
  62. package/src/bridge/claude-hooks.ts +0 -376
  63. package/src/bridge/wechat-bridge.ts +0 -1305
  64. package/src/commands/check-update.ts +0 -46
  65. package/src/companion/codex-panel-link.ts +0 -151
  66. package/src/companion/codex-panel.ts +0 -225
  67. package/src/companion/codex-remote-client.ts +0 -178
  68. package/src/companion/local-companion-link.ts +0 -338
  69. package/src/companion/local-companion-start.ts +0 -520
  70. package/src/companion/local-companion.ts +0 -532
  71. package/src/media/media-types.ts +0 -11
  72. package/src/runtime/create-runtime-host.ts +0 -16
  73. package/src/runtime/legacy-adapter-runtime.ts +0 -55
  74. package/src/runtime/runtime-types.ts +0 -51
  75. package/src/utils/version-checker.ts +0 -197
  76. package/src/wechat/channel-config.ts +0 -141
  77. package/src/wechat/setup.ts +0 -232
  78. package/src/wechat/standalone-bot.ts +0 -483
  79. package/src/wechat/wechat-channel.ts +0 -608
  80. package/src/wechat/wechat-transport.ts +0 -1290
@@ -0,0 +1,904 @@
1
+ #!/usr/bin/env bun
2
+ import path from "node:path";
3
+ import { resolveDefaultAdapterCommand, } from "./bridge-adapters.js";
4
+ import { delay } from "./bridge-adapters.shared.js";
5
+ import { BridgeController } from "./bridge-controller.js";
6
+ import { forwardWechatFinalReply } from "./bridge-final-reply.js";
7
+ import { migrateLegacyChannelFiles } from "../wechat/channel-config.js";
8
+ import { BridgeStateStore } from "./bridge-state.js";
9
+ import { reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses } from "./bridge-process-reaper.js";
10
+ import { createRuntimeHost } from "../runtime/create-runtime-host.js";
11
+ import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatDuration, formatMirroredUserInputMessage, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
12
+ import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, } from "../wechat/wechat-transport.js";
13
+ import { checkForUpdate, formatUpdateMessage, } from "../utils/version-checker.js";
14
+ const POLL_RETRY_BASE_MS = 1_000;
15
+ const POLL_RETRY_MAX_MS = 30_000;
16
+ const PARENT_PROCESS_POLL_MS = 5_000;
17
+ function log(message) {
18
+ process.stderr.write(`[wechat-bridge] ${message}\n`);
19
+ }
20
+ function logError(message) {
21
+ process.stderr.write(`[wechat-bridge] ERROR: ${message}\n`);
22
+ }
23
+ function computePollRetryDelayMs(consecutiveFailures) {
24
+ const normalizedFailures = Math.max(1, consecutiveFailures);
25
+ const exponent = Math.min(normalizedFailures - 1, 5);
26
+ return Math.min(POLL_RETRY_MAX_MS, POLL_RETRY_BASE_MS * 2 ** exponent);
27
+ }
28
+ function isPidAlive(pid) {
29
+ if (!Number.isInteger(pid) || pid <= 0) {
30
+ return false;
31
+ }
32
+ try {
33
+ process.kill(pid, 0);
34
+ return true;
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ export function formatUserFacingBridgeFatalError(message) {
41
+ return `Bridge error: ${message.replace(/\s+Recent app-server log:.*$/s, "").trim()}`;
42
+ }
43
+ export function shouldForwardBridgeEventToWechat(adapter, eventType, options = {}) {
44
+ if (adapter !== "opencode") {
45
+ return true;
46
+ }
47
+ switch (eventType) {
48
+ case "stdout":
49
+ case "stderr":
50
+ case "thread_switched":
51
+ return false;
52
+ case "notice":
53
+ return /^OpenCode local draft:\s*/i.test(options.text ?? "");
54
+ case "mirrored_user_input":
55
+ return true;
56
+ default:
57
+ return true;
58
+ }
59
+ }
60
+ export function formatUserFacingInboundError(params) {
61
+ const { adapter, cwd, errorText, isUserFacingShellRejection } = params;
62
+ if (isUserFacingShellRejection) {
63
+ return errorText;
64
+ }
65
+ if (adapter === "opencode" &&
66
+ /opencode companion is not connected/i.test(errorText)) {
67
+ return cwd
68
+ ? `OpenCode companion is not connected for bridge workspace:\n${cwd}\nRun "wechat-opencode" in that directory to reconnect the current local terminal, or run "wechat-bridge-opencode" and then "wechat-opencode" in your target project to replace this bridge.`
69
+ : 'OpenCode companion is not connected. Start "wechat-opencode" in this directory to reconnect it, then retry.';
70
+ }
71
+ return `Bridge error: ${errorText}`;
72
+ }
73
+ export function formatWechatSendFailureLogEntry(params) {
74
+ return `wechat_send_failed: context=${params.context} recipient=${params.recipientId} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
75
+ }
76
+ export function shouldWatchParentProcess(options) {
77
+ return (options.startupParentPid > 1 &&
78
+ (options.attachedToTerminal || options.lifecycle === "companion_bound"));
79
+ }
80
+ function toPendingApproval(request) {
81
+ if (typeof request.code === "string") {
82
+ return request;
83
+ }
84
+ return {
85
+ ...request,
86
+ code: buildOneTimeCode(),
87
+ createdAt: nowIso(),
88
+ };
89
+ }
90
+ export function shouldDeferCodexInboundMessage(params) {
91
+ return (params.adapter === "codex" &&
92
+ !params.hasPendingConfirmation &&
93
+ !params.hasSystemCommand &&
94
+ params.activeTurnOrigin === "local" &&
95
+ (params.status === "busy" || params.status === "awaiting_approval"));
96
+ }
97
+ export function canDrainDeferredCodexInboundQueue(params) {
98
+ return (params.adapter === "codex" &&
99
+ params.deferredCount > 0 &&
100
+ !params.hasPendingConfirmation &&
101
+ !params.hasPendingApproval &&
102
+ !params.hasActiveTask &&
103
+ !params.activeTurnId &&
104
+ params.status !== "busy" &&
105
+ params.status !== "awaiting_approval");
106
+ }
107
+ export function formatDeferredCodexInboundQueueMessage(queuePosition) {
108
+ return `Queued for delivery after the current local Codex turn finishes. Queue position: ${queuePosition}.`;
109
+ }
110
+ export function isRetryableDeferredCodexDrainError(errorText) {
111
+ return /still working|approval request is pending|waiting for local terminal input/i.test(errorText);
112
+ }
113
+ export function parseCliArgs(argv) {
114
+ let adapter = null;
115
+ let commandOverride;
116
+ let cwd = process.cwd();
117
+ let profile;
118
+ let lifecycle = "persistent";
119
+ for (let i = 0; i < argv.length; i += 1) {
120
+ const arg = argv[i];
121
+ const next = argv[i + 1];
122
+ switch (arg) {
123
+ case "--adapter":
124
+ if (!next || !["codex", "claude", "opencode", "shell"].includes(next)) {
125
+ throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
126
+ }
127
+ adapter = next;
128
+ i += 1;
129
+ break;
130
+ case "--cmd":
131
+ if (!next) {
132
+ throw new Error("--cmd requires a value");
133
+ }
134
+ commandOverride = next;
135
+ i += 1;
136
+ break;
137
+ case "--cwd":
138
+ if (!next) {
139
+ throw new Error("--cwd requires a value");
140
+ }
141
+ cwd = path.resolve(next);
142
+ i += 1;
143
+ break;
144
+ case "--profile":
145
+ if (!next) {
146
+ throw new Error("--profile requires a value");
147
+ }
148
+ profile = next;
149
+ i += 1;
150
+ break;
151
+ case "--lifecycle":
152
+ if (!next || !["persistent", "companion_bound"].includes(next)) {
153
+ throw new Error(`Invalid lifecycle: ${next ?? "(missing)"}`);
154
+ }
155
+ lifecycle = next;
156
+ i += 1;
157
+ break;
158
+ case "--shutdown-on-parent-exit":
159
+ lifecycle = "companion_bound";
160
+ break;
161
+ case "--help":
162
+ case "-h":
163
+ printUsageAndExit();
164
+ break;
165
+ default:
166
+ throw new Error(`Unknown argument: ${arg}`);
167
+ }
168
+ }
169
+ if (!adapter) {
170
+ throw new Error("Missing required --adapter <codex|claude|opencode|shell>");
171
+ }
172
+ const defaultCommand = resolveDefaultAdapterCommand(adapter);
173
+ return {
174
+ adapter,
175
+ command: commandOverride ?? defaultCommand,
176
+ cwd,
177
+ profile,
178
+ lifecycle,
179
+ };
180
+ }
181
+ function printUsageAndExit() {
182
+ process.stdout.write([
183
+ "Usage: wechat-bridge --adapter <codex|claude|opencode|shell> [--cmd <executable>] [--cwd <path>] [--profile <name-or-path>] [--lifecycle <persistent|companion_bound>]",
184
+ "",
185
+ "Examples:",
186
+ " wechat-bridge-codex",
187
+ " wechat-bridge-claude --cwd ~/work/my-project",
188
+ " wechat-bridge-opencode --cwd ~/work/my-project",
189
+ " wechat-bridge-shell --cmd pwsh # headless shell executor for non-interactive commands/scripts",
190
+ " wechat-bridge-shell --cmd bash # headless shell executor for non-interactive commands/scripts",
191
+ " wechat-bridge-codex --lifecycle companion_bound",
192
+ " bun run bridge:codex # repo-local development entrypoint",
193
+ " bun run bridge:opencode # repo-local development entrypoint",
194
+ "",
195
+ ].join("\n"));
196
+ process.exit(0);
197
+ }
198
+ async function main() {
199
+ migrateLegacyChannelFiles(log);
200
+ // 非阻塞地检查更新(不影响启动速度)
201
+ setTimeout(async () => {
202
+ try {
203
+ const versionInfo = await checkForUpdate();
204
+ if (versionInfo?.hasUpdate) {
205
+ log(formatUpdateMessage(versionInfo));
206
+ }
207
+ }
208
+ catch (error) {
209
+ // 静默失败,不影响正常使用
210
+ }
211
+ }, 3000); // 延迟3秒,确保不影响启动
212
+ const options = parseCliArgs(process.argv.slice(2));
213
+ const transport = new WeChatTransport({ log, logError });
214
+ const credentials = transport.getCredentials();
215
+ if (!credentials) {
216
+ throw new Error('No saved WeChat credentials found. Run "bun run setup" first.');
217
+ }
218
+ if (!credentials.userId) {
219
+ throw new Error('Saved WeChat credentials are missing userId. Run "bun run setup" again.');
220
+ }
221
+ const stateStore = new BridgeStateStore({
222
+ ...options,
223
+ authorizedUserId: credentials.userId,
224
+ });
225
+ const reapedPeerPids = await reapPeerBridgeProcesses({
226
+ logger: (message) => stateStore.appendLog(message),
227
+ });
228
+ if (reapedPeerPids.length > 0) {
229
+ log(`Reaped ${reapedPeerPids.length} stale bridge process(es): ${reapedPeerPids.join(", ")}`);
230
+ }
231
+ if (options.adapter === "opencode") {
232
+ const reapedOpencodePids = await reapOrphanedOpencodeProcesses({
233
+ logger: (message) => stateStore.appendLog(message),
234
+ });
235
+ if (reapedOpencodePids.length > 0) {
236
+ log(`Reaped ${reapedOpencodePids.length} orphaned opencode process(es): ${reapedOpencodePids.join(", ")}`);
237
+ }
238
+ }
239
+ let lockRehydratedLogged = false;
240
+ const ensureRuntimeOwnership = () => {
241
+ const ownership = stateStore.verifyRuntimeOwnership();
242
+ if (!ownership.ok) {
243
+ if (ownership.reason === "superseded") {
244
+ requestShutdown(`Bridge instance ${stateStore.getState().instanceId} was superseded by ${ownership.activeInstanceId}. Stopping duplicate bridge.`);
245
+ return false;
246
+ }
247
+ requestShutdown(`Bridge instance ${stateStore.getState().instanceId} lost the global lock to pid=${ownership.activePid} (${ownership.activeInstanceId}). Stopping duplicate bridge.`);
248
+ return false;
249
+ }
250
+ if (ownership.rehydratedLock && !lockRehydratedLogged) {
251
+ lockRehydratedLogged = true;
252
+ stateStore.appendLog(`lock_rehydrated: pid=${process.pid} instanceId=${stateStore.getState().instanceId} adapter=${options.adapter} cwd=${options.cwd}`);
253
+ }
254
+ return true;
255
+ };
256
+ // Clear any stale endpoint left by a previous bridge for this workspace.
257
+ // This prevents `wechat-*` companions from reconnecting to a dead bridge
258
+ // while the new runtime is still starting up.
259
+ const adapter = createRuntimeHost({
260
+ kind: options.adapter,
261
+ command: options.command,
262
+ cwd: options.cwd,
263
+ profile: options.profile,
264
+ lifecycle: options.lifecycle,
265
+ initialSharedSessionId: stateStore.getState().sharedSessionId ?? stateStore.getState().sharedThreadId,
266
+ initialResumeConversationId: stateStore.getState().resumeConversationId,
267
+ initialTranscriptPath: stateStore.getState().transcriptPath,
268
+ });
269
+ const controller = new BridgeController(adapter, options.cwd);
270
+ controller.clearLocalClientEndpoint();
271
+ stateStore.appendLog(`Cleared stale companion endpoint for ${options.cwd} before adapter start.`);
272
+ let textSendChain = Promise.resolve();
273
+ let attachmentSendChain = Promise.resolve();
274
+ let activeTask = null;
275
+ const deferredInboundMessages = [];
276
+ let drainingDeferredInboundMessages = false;
277
+ let lastOutputAt = 0;
278
+ let lastHeartbeatAt = 0;
279
+ let consecutivePollFailures = 0;
280
+ const queueWechatTextAction = (action) => {
281
+ const run = textSendChain.then(action);
282
+ textSendChain = run.then(() => undefined, () => undefined);
283
+ return run;
284
+ };
285
+ const queueWechatAttachmentAction = (action) => {
286
+ const run = attachmentSendChain.then(action);
287
+ attachmentSendChain = run.then(() => undefined, () => undefined);
288
+ return run;
289
+ };
290
+ const queueWechatMessage = (senderId, text, context = "message") => {
291
+ return queueWechatTextAction(() => transport.sendText(senderId, text)).catch((err) => {
292
+ logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(err)}`);
293
+ stateStore.appendLog(formatWechatSendFailureLogEntry({
294
+ context,
295
+ recipientId: senderId,
296
+ error: err,
297
+ }));
298
+ });
299
+ };
300
+ const outputBatcher = new OutputBatcher(async (text) => {
301
+ await queueWechatMessage(stateStore.getState().authorizedUserId, text);
302
+ });
303
+ const maybeDrainDeferredInboundMessages = async () => {
304
+ if (drainingDeferredInboundMessages || !ensureRuntimeOwnership()) {
305
+ return;
306
+ }
307
+ const adapterState = adapter.getState();
308
+ if (!canDrainDeferredCodexInboundQueue({
309
+ adapter: options.adapter,
310
+ deferredCount: deferredInboundMessages.length,
311
+ status: adapterState.status,
312
+ activeTurnId: adapterState.activeTurnId,
313
+ hasPendingConfirmation: Boolean(stateStore.getState().pendingConfirmation),
314
+ hasPendingApproval: Boolean(adapterState.pendingApproval),
315
+ hasActiveTask: Boolean(activeTask),
316
+ })) {
317
+ return;
318
+ }
319
+ const nextDeferred = deferredInboundMessages.shift();
320
+ if (!nextDeferred) {
321
+ return;
322
+ }
323
+ drainingDeferredInboundMessages = true;
324
+ try {
325
+ stateStore.appendLog(`draining_deferred_inbound_input: remaining=${deferredInboundMessages.length} text=${truncatePreview(nextDeferred.message.text)}`);
326
+ const nextTask = await dispatchInboundWechatText({
327
+ message: nextDeferred.message,
328
+ options,
329
+ stateStore,
330
+ adapter,
331
+ });
332
+ activeTask = nextTask;
333
+ lastHeartbeatAt = 0;
334
+ }
335
+ catch (err) {
336
+ const errorText = err instanceof Error ? err.message : String(err);
337
+ if (isRetryableDeferredCodexDrainError(errorText)) {
338
+ deferredInboundMessages.unshift(nextDeferred);
339
+ stateStore.appendLog(`deferred_inbound_blocked: ${truncatePreview(errorText, 400)}`);
340
+ return;
341
+ }
342
+ logError(errorText);
343
+ stateStore.appendLog(`deferred_inbound_error: ${errorText}`);
344
+ await queueWechatMessage(nextDeferred.message.senderId, formatUserFacingInboundError({
345
+ adapter: options.adapter,
346
+ cwd: options.cwd,
347
+ errorText,
348
+ isUserFacingShellRejection: false,
349
+ }), "inbound_error");
350
+ }
351
+ finally {
352
+ drainingDeferredInboundMessages = false;
353
+ }
354
+ };
355
+ const startupParentPid = process.ppid;
356
+ const attachedToTerminal = Boolean(process.stdin.isTTY || process.stdout.isTTY || process.stderr.isTTY);
357
+ let shutdownPromise = null;
358
+ let requestedExitCode = 0;
359
+ let stdinDetached = false;
360
+ const parentWatchTimer = shouldWatchParentProcess({
361
+ startupParentPid,
362
+ attachedToTerminal,
363
+ lifecycle: options.lifecycle,
364
+ })
365
+ ? setInterval(() => {
366
+ if (shutdownPromise || isPidAlive(startupParentPid)) {
367
+ return;
368
+ }
369
+ log(`Parent process ${startupParentPid} exited. Stopping bridge.`);
370
+ void shutdown(0);
371
+ }, PARENT_PROCESS_POLL_MS)
372
+ : null;
373
+ parentWatchTimer?.unref();
374
+ const cleanup = async () => {
375
+ if (parentWatchTimer) {
376
+ clearInterval(parentWatchTimer);
377
+ }
378
+ try {
379
+ await outputBatcher.flushNow();
380
+ }
381
+ catch {
382
+ // Best effort flush.
383
+ }
384
+ try {
385
+ await textSendChain;
386
+ await attachmentSendChain;
387
+ }
388
+ catch {
389
+ // Best effort flush.
390
+ }
391
+ try {
392
+ await adapter.dispose();
393
+ }
394
+ catch {
395
+ // Best effort shutdown.
396
+ }
397
+ controller.clearLocalClientEndpoint();
398
+ stateStore.releaseLock();
399
+ };
400
+ const shutdown = async (exitCode = 0) => {
401
+ requestedExitCode = exitCode;
402
+ if (!shutdownPromise) {
403
+ shutdownPromise = cleanup().catch((error) => {
404
+ logError(`Shutdown cleanup failed: ${describeWechatTransportError(error)}`);
405
+ });
406
+ }
407
+ await shutdownPromise;
408
+ };
409
+ const requestShutdown = (message, exitCode = 0) => {
410
+ if (shutdownPromise) {
411
+ return;
412
+ }
413
+ log(message);
414
+ void shutdown(exitCode).finally(() => process.exit(requestedExitCode));
415
+ };
416
+ process.once("SIGINT", () => {
417
+ requestShutdown("Received SIGINT. Stopping bridge.");
418
+ });
419
+ process.once("SIGTERM", () => {
420
+ requestShutdown("Received SIGTERM. Stopping bridge.");
421
+ });
422
+ process.once("SIGHUP", () => {
423
+ requestShutdown("Terminal session closed. Stopping bridge.");
424
+ });
425
+ if (process.platform === "win32") {
426
+ process.once("SIGBREAK", () => {
427
+ requestShutdown("Received SIGBREAK. Stopping bridge.");
428
+ });
429
+ }
430
+ if (attachedToTerminal) {
431
+ process.stdin.on("close", () => {
432
+ if (stdinDetached) {
433
+ return;
434
+ }
435
+ stdinDetached = true;
436
+ requestShutdown("Standard input closed. Stopping bridge.");
437
+ });
438
+ process.stdin.on("end", () => {
439
+ if (stdinDetached) {
440
+ return;
441
+ }
442
+ stdinDetached = true;
443
+ requestShutdown("Standard input ended. Stopping bridge.");
444
+ });
445
+ }
446
+ process.on("exit", () => {
447
+ if (parentWatchTimer) {
448
+ clearInterval(parentWatchTimer);
449
+ }
450
+ stateStore.releaseLock();
451
+ });
452
+ try {
453
+ wireAdapterEvents({
454
+ adapter,
455
+ options,
456
+ transport,
457
+ stateStore,
458
+ outputBatcher,
459
+ queueWechatAttachmentAction,
460
+ queueWechatMessage,
461
+ maybeDrainDeferredInboundMessages,
462
+ getActiveTask: () => activeTask,
463
+ clearActiveTask: () => {
464
+ activeTask = null;
465
+ lastHeartbeatAt = 0;
466
+ },
467
+ updateLastOutputAt: () => {
468
+ lastOutputAt = Date.now();
469
+ },
470
+ syncSharedSessionState: () => {
471
+ syncSharedSessionState(stateStore, adapter);
472
+ },
473
+ syncLocalClientEndpoint: () => {
474
+ controller.syncLocalClientEndpoint();
475
+ },
476
+ requestShutdown,
477
+ });
478
+ await adapter.start();
479
+ if (!ensureRuntimeOwnership()) {
480
+ return;
481
+ }
482
+ syncSharedSessionState(stateStore, adapter);
483
+ controller.syncLocalClientEndpoint();
484
+ stateStore.appendLog(`Bridge started with adapter=${options.adapter} command=${options.command} cwd=${options.cwd}`);
485
+ log(`WeChat bridge is ready for adapter "${options.adapter}".`);
486
+ log(`Working directory: ${options.cwd}`);
487
+ if (options.profile) {
488
+ log(`Profile: ${options.profile}`);
489
+ }
490
+ log(`Authorized WeChat user: ${credentials.userId}`);
491
+ if (options.adapter === "codex") {
492
+ log('Start the visible Codex panel in a second terminal with: wechat-codex');
493
+ }
494
+ else if (options.adapter === "opencode") {
495
+ log('Start the visible OpenCode companion in a second terminal with: wechat-opencode');
496
+ }
497
+ else if (options.adapter === "claude") {
498
+ log('Start the visible Claude companion in a second terminal with: wechat-claude');
499
+ }
500
+ else if (options.adapter === "shell") {
501
+ log("Shell mode runs as a headless remote executor for non-interactive commands and scripts.");
502
+ }
503
+ while (true) {
504
+ if (!ensureRuntimeOwnership()) {
505
+ break;
506
+ }
507
+ let pollResult;
508
+ try {
509
+ pollResult = await transport.pollMessages({
510
+ timeoutMs: DEFAULT_LONG_POLL_TIMEOUT_MS,
511
+ minCreatedAtMs: stateStore.getState().bridgeStartedAtMs - MESSAGE_START_GRACE_MS,
512
+ });
513
+ }
514
+ catch (err) {
515
+ const classification = classifyWechatTransportError(err);
516
+ if (!classification.retryable) {
517
+ throw err;
518
+ }
519
+ consecutivePollFailures += 1;
520
+ const delayMs = computePollRetryDelayMs(consecutivePollFailures);
521
+ const errorText = describeWechatTransportError(err);
522
+ const statusDetails = typeof classification.statusCode === "number"
523
+ ? ` status=${classification.statusCode}`
524
+ : "";
525
+ logError(`WeChat long poll failed (${classification.kind}${statusDetails}, attempt ${consecutivePollFailures}). Retrying in ${formatDuration(delayMs)}. ${errorText}`);
526
+ stateStore.appendLog(`poll_retry: kind=${classification.kind}${statusDetails} attempt=${consecutivePollFailures} delay_ms=${delayMs} error=${truncatePreview(errorText, 400)}`);
527
+ await delay(delayMs);
528
+ continue;
529
+ }
530
+ if (!ensureRuntimeOwnership()) {
531
+ break;
532
+ }
533
+ if (consecutivePollFailures > 0) {
534
+ const recoveredFailures = consecutivePollFailures;
535
+ consecutivePollFailures = 0;
536
+ log(`WeChat long poll recovered after ${recoveredFailures} transient error(s).`);
537
+ stateStore.appendLog(`poll_recovered: failures=${recoveredFailures}`);
538
+ }
539
+ if (pollResult.ignoredBacklogCount > 0) {
540
+ stateStore.incrementIgnoredBacklog(pollResult.ignoredBacklogCount);
541
+ stateStore.appendLog(`ignored_startup_backlog: count=${pollResult.ignoredBacklogCount}`);
542
+ }
543
+ for (const message of pollResult.messages) {
544
+ if (!ensureRuntimeOwnership()) {
545
+ break;
546
+ }
547
+ stateStore.touchActivity(message.createdAt);
548
+ let nextTask = null;
549
+ try {
550
+ nextTask = await handleInboundMessage({
551
+ message,
552
+ options,
553
+ stateStore,
554
+ adapter,
555
+ queueWechatMessage,
556
+ outputBatcher,
557
+ deferInboundMessage: async (nextMessage) => {
558
+ deferredInboundMessages.push({
559
+ message: nextMessage,
560
+ });
561
+ stateStore.appendLog(`deferred_inbound_input: position=${deferredInboundMessages.length} text=${truncatePreview(nextMessage.text)}`);
562
+ await queueWechatMessage(nextMessage.senderId, formatDeferredCodexInboundQueueMessage(deferredInboundMessages.length));
563
+ },
564
+ });
565
+ }
566
+ catch (err) {
567
+ const errorText = err instanceof Error ? err.message : String(err);
568
+ const isUserFacingShellRejection = err instanceof Error && err.name === "ShellCommandRejectedError";
569
+ logError(errorText);
570
+ stateStore.appendLog(`${isUserFacingShellRejection ? "inbound_rejected" : "inbound_error"}: ${errorText}`);
571
+ await queueWechatMessage(message.senderId, formatUserFacingInboundError({
572
+ adapter: options.adapter,
573
+ cwd: options.cwd,
574
+ errorText,
575
+ isUserFacingShellRejection,
576
+ }), "inbound_error");
577
+ }
578
+ if (nextTask) {
579
+ activeTask = nextTask;
580
+ lastHeartbeatAt = 0;
581
+ }
582
+ syncSharedSessionState(stateStore, adapter);
583
+ await maybeDrainDeferredInboundMessages();
584
+ }
585
+ const adapterState = adapter.getState();
586
+ const lastSignalAt = Math.max(lastHeartbeatAt, lastOutputAt || activeTask?.startedAt || 0);
587
+ if (activeTask &&
588
+ options.adapter === "shell" &&
589
+ adapterState.status === "busy" &&
590
+ Date.now() - lastSignalAt >= 30_000) {
591
+ lastHeartbeatAt = Date.now();
592
+ await queueWechatMessage(stateStore.getState().authorizedUserId, `${options.adapter} is still running. Waiting for more output...`);
593
+ }
594
+ }
595
+ }
596
+ finally {
597
+ await shutdown(requestedExitCode);
598
+ }
599
+ }
600
+ function syncSharedSessionState(stateStore, adapter) {
601
+ const persistedState = stateStore.getState();
602
+ const persistedSessionId = persistedState.sharedSessionId ?? persistedState.sharedThreadId;
603
+ const adapterState = adapter.getState();
604
+ const adapterSessionId = adapterState.sharedSessionId ?? adapterState.sharedThreadId;
605
+ if (adapterSessionId && adapterSessionId !== persistedSessionId) {
606
+ stateStore.setSharedSessionId(adapterSessionId);
607
+ }
608
+ else if (!adapterSessionId && persistedSessionId) {
609
+ stateStore.clearSharedSessionId();
610
+ }
611
+ if (persistedState.adapter !== "claude") {
612
+ return;
613
+ }
614
+ if (adapterState.resumeConversationId !== persistedState.resumeConversationId ||
615
+ adapterState.transcriptPath !== persistedState.transcriptPath) {
616
+ if (adapterState.resumeConversationId || adapterState.transcriptPath) {
617
+ stateStore.setClaudeResumeState(adapterState.resumeConversationId, adapterState.transcriptPath);
618
+ }
619
+ else {
620
+ stateStore.clearClaudeResumeState();
621
+ }
622
+ }
623
+ }
624
+ function wireAdapterEvents(params) {
625
+ const { adapter, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, maybeDrainDeferredInboundMessages, getActiveTask, clearActiveTask, updateLastOutputAt, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
626
+ adapter.setEventSink((event) => {
627
+ syncSharedSessionState();
628
+ syncLocalClientEndpoint();
629
+ const adapterState = adapter.getState();
630
+ const bridgeState = stateStore.getState();
631
+ if (bridgeState.pendingConfirmation && !adapterState.pendingApproval) {
632
+ stateStore.clearPendingConfirmation();
633
+ }
634
+ const authorizedUserId = stateStore.getState().authorizedUserId;
635
+ switch (event.type) {
636
+ case "stdout":
637
+ case "stderr":
638
+ updateLastOutputAt();
639
+ if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
640
+ outputBatcher.push(event.text);
641
+ }
642
+ break;
643
+ case "final_reply":
644
+ void outputBatcher.flushNow().then(async () => {
645
+ await forwardWechatFinalReply({
646
+ adapter: options.adapter,
647
+ rawText: event.text,
648
+ sender: {
649
+ sendText: (text) => queueWechatMessage(authorizedUserId, text),
650
+ sendImage: (imagePath) => queueWechatAttachmentAction(() => transport.sendImage(imagePath, { recipientId: authorizedUserId })),
651
+ sendFile: (filePath) => queueWechatAttachmentAction(() => transport.sendFile(filePath, { recipientId: authorizedUserId })),
652
+ sendVoice: (voicePath) => queueWechatAttachmentAction(() => transport.sendVoice(voicePath, authorizedUserId)),
653
+ sendVideo: (videoPath) => queueWechatAttachmentAction(() => transport.sendVideo(videoPath, { recipientId: authorizedUserId })),
654
+ },
655
+ });
656
+ });
657
+ break;
658
+ case "status":
659
+ if (event.message) {
660
+ log(`${event.status}: ${event.message}`);
661
+ stateStore.appendLog(`${event.status}: ${event.message}`);
662
+ }
663
+ void maybeDrainDeferredInboundMessages();
664
+ break;
665
+ case "notice":
666
+ updateLastOutputAt();
667
+ stateStore.appendLog(`${event.level}_notice: ${truncatePreview(event.text)}`);
668
+ if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
669
+ void outputBatcher.flushNow().then(async () => {
670
+ await queueWechatMessage(authorizedUserId, event.text, "notice");
671
+ });
672
+ }
673
+ break;
674
+ case "approval_required":
675
+ void outputBatcher.flushNow().then(async () => {
676
+ const pending = toPendingApproval(event.request);
677
+ stateStore.setPendingConfirmation(pending);
678
+ stateStore.appendLog(`Approval requested (${pending.source}): ${pending.commandPreview}`);
679
+ await queueWechatMessage(authorizedUserId, formatApprovalMessage(pending, adapterState), "approval_required");
680
+ });
681
+ break;
682
+ case "mirrored_user_input":
683
+ stateStore.appendLog(`mirrored_local_input: ${truncatePreview(event.text)}`);
684
+ if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
685
+ void outputBatcher.flushNow().then(async () => {
686
+ await queueWechatMessage(authorizedUserId, formatMirroredUserInputMessage(options.adapter, event.text), "mirrored_user_input");
687
+ });
688
+ }
689
+ break;
690
+ case "session_switched":
691
+ stateStore.appendLog(`session_switched: ${event.sessionId} source=${event.source} reason=${event.reason}`);
692
+ if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
693
+ void outputBatcher.flushNow().then(async () => {
694
+ await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
695
+ adapter: options.adapter,
696
+ sessionId: event.sessionId,
697
+ source: event.source,
698
+ reason: event.reason,
699
+ }), "session_switched");
700
+ });
701
+ }
702
+ break;
703
+ case "thread_switched":
704
+ stateStore.appendLog(`thread_switched: ${event.threadId} source=${event.source} reason=${event.reason}`);
705
+ if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
706
+ void outputBatcher.flushNow().then(async () => {
707
+ await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
708
+ adapter: options.adapter,
709
+ sessionId: event.threadId,
710
+ source: event.source,
711
+ reason: event.reason,
712
+ }), "thread_switched");
713
+ });
714
+ }
715
+ void maybeDrainDeferredInboundMessages();
716
+ break;
717
+ case "task_complete":
718
+ void outputBatcher.flushNow().then(async () => {
719
+ stateStore.clearPendingConfirmation();
720
+ if (options.adapter === "shell") {
721
+ const summary = buildCompletionSummary({
722
+ adapter: options.adapter,
723
+ activeTask: getActiveTask(),
724
+ exitCode: event.exitCode,
725
+ recentOutput: outputBatcher.getRecentSummary(),
726
+ });
727
+ await queueWechatMessage(authorizedUserId, summary);
728
+ }
729
+ clearActiveTask();
730
+ await maybeDrainDeferredInboundMessages();
731
+ });
732
+ break;
733
+ case "task_failed":
734
+ void outputBatcher.flushNow().then(async () => {
735
+ stateStore.clearPendingConfirmation();
736
+ clearActiveTask();
737
+ await queueWechatMessage(authorizedUserId, formatTaskFailedMessage(options.adapter, event.message), "task_failed");
738
+ await maybeDrainDeferredInboundMessages();
739
+ });
740
+ break;
741
+ case "fatal_error":
742
+ logError(event.message);
743
+ stateStore.appendLog(`fatal_error: ${event.message}`);
744
+ stateStore.clearPendingConfirmation();
745
+ clearActiveTask();
746
+ void outputBatcher.flushNow().then(async () => {
747
+ await queueWechatMessage(authorizedUserId, formatUserFacingBridgeFatalError(event.message), "fatal_error");
748
+ await maybeDrainDeferredInboundMessages();
749
+ });
750
+ break;
751
+ case "shutdown_requested":
752
+ stateStore.appendLog(`shutdown_requested: ${event.reason}`);
753
+ requestShutdown(event.message, event.exitCode ?? 0);
754
+ break;
755
+ }
756
+ });
757
+ }
758
+ function buildCompletionSummary(params) {
759
+ const lines = [`${params.adapter} task complete.`];
760
+ if (params.activeTask) {
761
+ lines.push(`duration: ${formatDuration(Date.now() - params.activeTask.startedAt)}`);
762
+ lines.push(`input: ${params.activeTask.inputPreview}`);
763
+ }
764
+ if (typeof params.exitCode === "number") {
765
+ lines.push(`exit_code: ${params.exitCode}`);
766
+ }
767
+ lines.push(`recent_output:\n${params.recentOutput}`);
768
+ return lines.join("\n");
769
+ }
770
+ async function handleInboundMessage(params) {
771
+ const { message, options, stateStore, adapter, queueWechatMessage, outputBatcher, deferInboundMessage, } = params;
772
+ const state = stateStore.getState();
773
+ const systemCommand = parseWechatControlCommand(message.text, {
774
+ adapter: options.adapter,
775
+ hasPendingConfirmation: Boolean(state.pendingConfirmation),
776
+ });
777
+ if (message.senderId !== state.authorizedUserId) {
778
+ await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
779
+ return null;
780
+ }
781
+ switch (systemCommand?.type) {
782
+ case "status":
783
+ await queueWechatMessage(message.senderId, formatStatusReport(stateStore.getState(), adapter.getState()));
784
+ return null;
785
+ case "resume": {
786
+ if (options.adapter === "codex") {
787
+ await queueWechatMessage(message.senderId, 'WeChat /resume is disabled in codex mode. Use /resume directly inside "wechat-codex"; WeChat will follow the active local thread.');
788
+ return null;
789
+ }
790
+ if (options.adapter === "claude") {
791
+ await queueWechatMessage(message.senderId, 'WeChat /resume is disabled in claude mode. Use /resume directly inside "wechat-claude"; WeChat will follow the active local session.');
792
+ return null;
793
+ }
794
+ if (options.adapter === "opencode") {
795
+ await queueWechatMessage(message.senderId, 'WeChat /resume is disabled in opencode mode. Use /resume directly inside "wechat-opencode"; WeChat will follow the active local session.');
796
+ return null;
797
+ }
798
+ await queueWechatMessage(message.senderId, `/resume is not available in ${options.adapter} mode.`);
799
+ return null;
800
+ }
801
+ case "stop": {
802
+ const interrupted = await adapter.interrupt();
803
+ await queueWechatMessage(message.senderId, interrupted
804
+ ? "Interrupt signal sent to the active worker."
805
+ : "No running worker was available to interrupt.");
806
+ return null;
807
+ }
808
+ case "reset":
809
+ await outputBatcher.flushNow();
810
+ outputBatcher.clear();
811
+ stateStore.clearPendingConfirmation();
812
+ stateStore.clearSharedSessionId();
813
+ await adapter.reset();
814
+ stateStore.appendLog("Worker reset by owner.");
815
+ await queueWechatMessage(message.senderId, "Worker session has been reset.");
816
+ return null;
817
+ case "confirm": {
818
+ const pending = state.pendingConfirmation;
819
+ if (!pending) {
820
+ await queueWechatMessage(message.senderId, "No pending approval request.");
821
+ return null;
822
+ }
823
+ if (options.adapter !== "claude" && pending.code !== systemCommand.code) {
824
+ await queueWechatMessage(message.senderId, "Confirmation code did not match.");
825
+ return null;
826
+ }
827
+ const confirmed = await adapter.resolveApproval("confirm");
828
+ if (!confirmed) {
829
+ await queueWechatMessage(message.senderId, "The worker could not apply this approval request.");
830
+ return null;
831
+ }
832
+ stateStore.clearPendingConfirmation();
833
+ stateStore.appendLog(`Approval confirmed: ${pending.commandPreview}`);
834
+ await queueWechatMessage(message.senderId, "Approval confirmed. Continuing...");
835
+ return {
836
+ startedAt: Date.now(),
837
+ inputPreview: pending.commandPreview,
838
+ };
839
+ }
840
+ case "deny": {
841
+ const pending = state.pendingConfirmation;
842
+ if (!pending) {
843
+ await queueWechatMessage(message.senderId, "No pending approval request.");
844
+ return null;
845
+ }
846
+ const denied = await adapter.resolveApproval("deny");
847
+ if (!denied) {
848
+ await queueWechatMessage(message.senderId, "The worker could not deny this approval request cleanly.");
849
+ return null;
850
+ }
851
+ stateStore.clearPendingConfirmation();
852
+ stateStore.appendLog(`Approval denied: ${pending.commandPreview}`);
853
+ await queueWechatMessage(message.senderId, "Approval denied.");
854
+ return null;
855
+ }
856
+ }
857
+ if (state.pendingConfirmation) {
858
+ await queueWechatMessage(message.senderId, formatPendingApprovalReminder(state.pendingConfirmation, adapter.getState()));
859
+ return null;
860
+ }
861
+ const adapterState = adapter.getState();
862
+ if (shouldDeferCodexInboundMessage({
863
+ adapter: options.adapter,
864
+ status: adapterState.status,
865
+ activeTurnOrigin: adapterState.activeTurnOrigin,
866
+ hasPendingConfirmation: Boolean(state.pendingConfirmation),
867
+ hasSystemCommand: Boolean(systemCommand),
868
+ })) {
869
+ await deferInboundMessage(message);
870
+ return null;
871
+ }
872
+ if (adapterState.status === "busy") {
873
+ if ((options.adapter === "codex" || options.adapter === "opencode") &&
874
+ adapterState.activeTurnOrigin === "local") {
875
+ await queueWechatMessage(message.senderId, `${options.adapter === "opencode" ? "OpenCode" : "codex"} is currently busy with a local terminal turn. Wait for it to finish or use /stop.`);
876
+ return null;
877
+ }
878
+ await queueWechatMessage(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
879
+ return null;
880
+ }
881
+ return dispatchInboundWechatText({
882
+ message,
883
+ options,
884
+ stateStore,
885
+ adapter,
886
+ });
887
+ }
888
+ async function dispatchInboundWechatText(params) {
889
+ const { message, options, stateStore, adapter } = params;
890
+ const activeTask = {
891
+ startedAt: Date.now(),
892
+ inputPreview: truncatePreview(message.text, 180),
893
+ };
894
+ stateStore.appendLog(`Forwarded input to ${options.adapter}: ${truncatePreview(message.text)}`);
895
+ await adapter.sendInput(buildWechatInboundPrompt(message.text));
896
+ return activeTask;
897
+ }
898
+ const isDirectRun = Boolean(import.meta.main);
899
+ if (isDirectRun) {
900
+ main().catch((err) => {
901
+ logError(describeWechatTransportError(err));
902
+ process.exit(1);
903
+ });
904
+ }