cli-wechat-bridge 1.1.5 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +212 -232
  2. package/bin/_run-entry.mjs +20 -5
  3. package/bin/wecom-check-update.mjs +5 -0
  4. package/bin/wecom-claude.mjs +5 -0
  5. package/bin/wecom-codex.mjs +5 -0
  6. package/bin/wecom-daemon.mjs +5 -0
  7. package/bin/wecom-opencode.mjs +5 -0
  8. package/bin/wecom-pi.mjs +5 -0
  9. package/bin/wecom-setup.mjs +5 -0
  10. package/dist/bridge/bridge-adapters.claude.js +128 -4
  11. package/dist/bridge/bridge-adapters.codex.js +214 -23
  12. package/dist/bridge/bridge-adapters.core.js +2 -1
  13. package/dist/bridge/bridge-adapters.pi.js +1 -1
  14. package/dist/bridge/bridge-adapters.shared.js +2 -1
  15. package/dist/bridge/bridge-final-reply.js +2 -65
  16. package/dist/bridge/bridge-process-reaper.js +35 -2
  17. package/dist/bridge/bridge-state.js +11 -4
  18. package/dist/bridge/bridge-utils.js +4 -96
  19. package/dist/bridge/wechat-bridge.js +476 -206
  20. package/dist/bridge/wechat-forwarding.js +2 -58
  21. package/dist/bridge/wechat-outbound-queue.js +2 -0
  22. package/dist/channels/wechat/bridge-final-reply.js +65 -0
  23. package/dist/channels/wechat/channel-message.js +24 -0
  24. package/dist/channels/wechat/index.js +5 -0
  25. package/dist/channels/wechat/wechat-channel-port.js +67 -0
  26. package/dist/channels/wechat/wechat-forwarding.js +79 -0
  27. package/dist/channels/wechat/wechat-outbound-queue.js +79 -0
  28. package/dist/channels/wecom/bridge-final-reply.js +26 -0
  29. package/dist/channels/wecom/setup-cli.js +6 -0
  30. package/dist/channels/wecom/setup.js +176 -0
  31. package/dist/channels/wecom/wecom-channel-port.js +45 -0
  32. package/dist/channels/wecom/wecom-config.js +66 -0
  33. package/dist/channels/wecom/wecom-media.js +141 -0
  34. package/dist/channels/wecom/wecom-message.js +149 -0
  35. package/dist/channels/wecom/wecom-transport.js +308 -0
  36. package/dist/companion/codex-remote-client.js +2 -1
  37. package/dist/companion/local-companion-start.js +49 -9
  38. package/dist/companion/local-companion.js +2 -1
  39. package/dist/core/bridge-control.js +32 -0
  40. package/dist/core/bridge-event-forwarder.js +50 -0
  41. package/dist/core/bridge-message-router.js +32 -0
  42. package/dist/core/channel-types.js +3 -0
  43. package/dist/core/direct-run.js +17 -0
  44. package/dist/core/inbound-message-claims.js +61 -0
  45. package/dist/core/text-utils.js +63 -0
  46. package/dist/daemon/daemon-link.js +3 -0
  47. package/dist/daemon/wechat-daemon.js +466 -172
  48. package/dist/i18n/messages-en.js +4 -2
  49. package/dist/i18n/messages-zh.js +4 -2
  50. package/dist/utils/doctor.js +30 -10
  51. package/dist/wechat/setup.js +113 -13
  52. package/package.json +117 -107
@@ -5,6 +5,8 @@ import { spawn } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { BRIDGE_LOG_FILE } from "../wechat/channel-config.js";
7
7
  import { ensureWechatCredentials } from "../wechat/setup.js";
8
+ import { ensureWecomAccount } from "../channels/wecom/setup.js";
9
+ import { isDirectModuleRun } from "../core/direct-run.js";
8
10
  import { readBridgeLockFile, shouldAutoReclaimBridgeLock, } from "../bridge/bridge-state.js";
9
11
  import { clearLocalCompanionOccupancy, clearLocalCompanionEndpoint, readLocalCompanionEndpoint, } from "./local-companion-link.js";
10
12
  import { runCodexRemoteClient } from "./codex-remote-client.js";
@@ -15,8 +17,8 @@ const MODULE_DIR = path.dirname(MODULE_FILE);
15
17
  const RUNTIME_ENTRY_EXTENSION = path.extname(MODULE_FILE) === ".ts" ? ".ts" : ".js";
16
18
  const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
17
19
  const DEFAULT_ADAPTER = "codex";
18
- function log(adapter, message) {
19
- process.stderr.write(`[wechat-${adapter}] ${message}\n`);
20
+ function log(adapter, message, channelId = "wechat") {
21
+ process.stderr.write(`[${channelId}-${adapter}] ${message}\n`);
20
22
  }
21
23
  export function normalizeComparablePath(cwd) {
22
24
  const normalized = path.resolve(cwd);
@@ -48,6 +50,8 @@ export function decideLaunchAction(input) {
48
50
  };
49
51
  }
50
52
  const sameWorkspace = input.runningLock.adapter === input.requestedAdapter &&
53
+ (input.runningLock.channelId ?? "wechat") ===
54
+ (input.requestedChannelId ?? "wechat") &&
51
55
  isSameWorkspaceCwd(input.runningLock.cwd, input.requestedCwd);
52
56
  if (!sameWorkspace) {
53
57
  return {
@@ -99,6 +103,7 @@ export function parseCliArgs(argv) {
99
103
  let profile;
100
104
  let timeoutMs = DEFAULT_WAIT_TIMEOUT_MS;
101
105
  let sessionStartMode;
106
+ let channelId = "wechat";
102
107
  const cliArgs = [];
103
108
  for (let i = 0; i < argv.length; i += 1) {
104
109
  const arg = argv[i];
@@ -108,7 +113,7 @@ export function parseCliArgs(argv) {
108
113
  const next = argv[i + 1];
109
114
  if (arg === "--help" || arg === "-h") {
110
115
  const helpAdapter = adapter;
111
- const commandName = `wechat-${helpAdapter}`;
116
+ const commandName = `${channelId}-${helpAdapter}`;
112
117
  const adapterLabel = {
113
118
  codex: "Codex",
114
119
  claude: "Claude Code",
@@ -118,7 +123,7 @@ export function parseCliArgs(argv) {
118
123
  process.stdout.write([
119
124
  `Usage: ${commandName} [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [--session-start-mode <restore|new>] [...${helpAdapter} args]`,
120
125
  "",
121
- "Ensures the WeChat runtime for the current directory, then opens or reuses the visible CLI.",
126
+ `Ensures the ${channelId === "wecom" ? "WeCom" : "WeChat"} runtime for the current directory, then opens or reuses the visible CLI.`,
122
127
  "If a same-workspace daemon is running, the command delegates to that daemon.",
123
128
  helpAdapter === "codex"
124
129
  ? "Codex restores the current session by default."
@@ -136,6 +141,14 @@ export function parseCliArgs(argv) {
136
141
  if (arg === "--doctor") {
137
142
  continue;
138
143
  }
144
+ if (arg === "--channel") {
145
+ if (!next || (next !== "wechat" && next !== "wecom")) {
146
+ throw new Error(`Invalid channel: ${next ?? "(missing)"}`);
147
+ }
148
+ channelId = next;
149
+ i += 1;
150
+ continue;
151
+ }
139
152
  if (arg === "--adapter") {
140
153
  if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
141
154
  throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
@@ -189,6 +202,7 @@ export function parseCliArgs(argv) {
189
202
  timeoutMs,
190
203
  sessionStartMode: sessionStartMode ?? defaultSessionStartMode(adapter),
191
204
  cliArgs,
205
+ channelId,
192
206
  };
193
207
  }
194
208
  function isPidAlive(pid) {
@@ -295,6 +309,9 @@ export function buildBackgroundBridgeArgs(entryPath, options) {
295
309
  args.push("--experimental-strip-types");
296
310
  }
297
311
  args.push(entryPath, "--adapter", options.adapter, "--cwd", options.cwd, "--lifecycle", lifecycle);
312
+ if (options.channelId === "wecom") {
313
+ args.push("--channel", "wecom");
314
+ }
298
315
  if (options.sessionStartMode !== "restore") {
299
316
  args.push("--session-start-mode", options.sessionStartMode);
300
317
  }
@@ -361,6 +378,7 @@ async function ensureBridgeReady(options) {
361
378
  endpointIsReachable: Boolean(endpointResult.endpoint),
362
379
  companionIsAlive: isCompanionAlive(endpointResult.endpoint),
363
380
  sessionStartMode: options.sessionStartMode,
381
+ requestedChannelId: options.channelId,
364
382
  });
365
383
  log(options.adapter, decision.message);
366
384
  if (decision.kind === "already_active") {
@@ -408,8 +426,13 @@ export async function tryDelegateToDaemon(options, deps = {}) {
408
426
  clearEndpoint(endpoint.pid);
409
427
  return false;
410
428
  }
429
+ const requestedChannel = options.channelId ?? "wechat";
430
+ const endpointChannel = endpoint.channelId ?? "wechat";
431
+ if (requestedChannel !== endpointChannel) {
432
+ throw new Error(`${endpointChannel}-daemon is running for ${endpoint.cwd}. Stop it before starting the ${requestedChannel} channel.`);
433
+ }
411
434
  if (!isSameWorkspaceCwd(endpoint.cwd, options.cwd)) {
412
- throw new Error(`wechat-daemon is running for ${endpoint.cwd}. This launcher requested ${options.cwd}; v1 daemon switching is limited to its startup cwd.`);
435
+ throw new Error(`${requestedChannel}-daemon is running for ${endpoint.cwd}. This launcher requested ${options.cwd}; v1 daemon switching is limited to its startup cwd.`);
413
436
  }
414
437
  const response = await sendRequest(endpoint, {
415
438
  command: "ensure_slot",
@@ -429,7 +452,7 @@ export async function tryDelegateToDaemon(options, deps = {}) {
429
452
  log(options.adapter, `Daemon delegation failed (${response.error ?? "unknown error"}). Falling back to a local bridge.`);
430
453
  return false;
431
454
  }
432
- log(options.adapter, `Delegated to running wechat-daemon for ${options.cwd}.`);
455
+ log(options.adapter, `Delegated to running ${requestedChannel}-daemon for ${options.cwd}.`, requestedChannel);
433
456
  return true;
434
457
  }
435
458
  export async function runVisibleClient(options, runners = {}) {
@@ -456,6 +479,15 @@ export async function ensureCompanionStartWechatCredentials(adapter, ensureCrede
456
479
  log: (message) => log(adapter, message),
457
480
  });
458
481
  }
482
+ export async function ensureCompanionStartChannelCredentials(options) {
483
+ if (options.channelId === "wecom") {
484
+ await ensureWecomAccount({
485
+ log: (message) => log(options.adapter, message, "wecom"),
486
+ });
487
+ return;
488
+ }
489
+ await ensureCompanionStartWechatCredentials(options.adapter);
490
+ }
459
491
  export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
460
492
  if (argv.includes("--doctor")) {
461
493
  const { runDoctorCheck } = await import("../utils/doctor.js");
@@ -463,7 +495,7 @@ export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
463
495
  return 0;
464
496
  }
465
497
  const options = parseCliArgs(argv);
466
- await ensureCompanionStartWechatCredentials(options.adapter);
498
+ await ensureCompanionStartChannelCredentials(options);
467
499
  if (await tryDelegateToDaemon(options)) {
468
500
  return 0;
469
501
  }
@@ -487,11 +519,19 @@ export async function main(argv = process.argv.slice(2)) {
487
519
  return DEFAULT_ADAPTER;
488
520
  }
489
521
  })();
490
- log(adapter, error instanceof Error ? error.message : String(error));
522
+ const channelId = (() => {
523
+ try {
524
+ return parseCliArgs(argv).channelId ?? "wechat";
525
+ }
526
+ catch {
527
+ return "wechat";
528
+ }
529
+ })();
530
+ log(adapter, error instanceof Error ? error.message : String(error), channelId);
491
531
  process.exit(1);
492
532
  }
493
533
  }
494
- const isDirectRun = Boolean(import.meta.main);
534
+ const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
495
535
  if (isDirectRun) {
496
536
  void main();
497
537
  }
@@ -6,6 +6,7 @@ import { LOCAL_COMPANION_RECONNECT_GRACE_MS, } from "../bridge/bridge-adapters.s
6
6
  import { runCodexRemoteClientFromEndpoint } from "./codex-remote-client.js";
7
7
  import { attachLocalCompanionMessageListener, readLocalCompanionEndpoint, sendLocalCompanionMessage, } from "./local-companion-link.js";
8
8
  import { migrateLegacyChannelFiles } from "../wechat/channel-config.js";
9
+ import { isDirectModuleRun } from "../core/direct-run.js";
9
10
  export const LOCAL_COMPANION_RECONNECT_RETRY_MS = 250;
10
11
  function log(adapter, message) {
11
12
  process.stderr.write(`[${adapter}-companion] ${message}\n`);
@@ -439,7 +440,7 @@ export async function main(argv = process.argv.slice(2)) {
439
440
  process.exit(1);
440
441
  }
441
442
  }
442
- const isDirectRun = Boolean(import.meta.main);
443
+ const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
443
444
  if (isDirectRun) {
444
445
  void main();
445
446
  }
@@ -0,0 +1,32 @@
1
+ /** Parse the canonical control command grammar shared by all channels. */
2
+ export function parseBridgeControlCommand(text) {
3
+ const [rawCommand, ...rest] = text.trim().split(/\s+/);
4
+ if (!rawCommand?.startsWith("/")) {
5
+ return null;
6
+ }
7
+ const command = rawCommand.toLowerCase();
8
+ const argument = rest.join(" ").trim();
9
+ switch (command) {
10
+ case "/status":
11
+ return { type: "status" };
12
+ case "/resume":
13
+ return argument ? { type: "resume", target: argument } : { type: "resume" };
14
+ case "/new":
15
+ case "/new-session":
16
+ return { type: "new_session" };
17
+ case "/stop":
18
+ return { type: "stop" };
19
+ case "/reset":
20
+ return { type: "reset" };
21
+ case "/confirm":
22
+ case "/yes":
23
+ return { type: "confirm" };
24
+ case "/deny":
25
+ case "/no":
26
+ return { type: "deny" };
27
+ case "/answer":
28
+ return argument ? { type: "answer", raw: argument } : null;
29
+ default:
30
+ return null;
31
+ }
32
+ }
@@ -0,0 +1,50 @@
1
+ /** Dispatch a BridgeEvent without importing any channel or transport code. */
2
+ export async function forwardBridgeEvent(event, handlers) {
3
+ switch (event.type) {
4
+ case "stdout":
5
+ await handlers.stdout?.(event);
6
+ return;
7
+ case "stderr":
8
+ await handlers.stderr?.(event);
9
+ return;
10
+ case "final_reply":
11
+ await handlers.finalReply?.(event);
12
+ return;
13
+ case "status":
14
+ await handlers.status?.(event);
15
+ return;
16
+ case "notice":
17
+ await handlers.notice?.(event);
18
+ return;
19
+ case "thinking":
20
+ await handlers.thinking?.(event);
21
+ return;
22
+ case "approval_required":
23
+ await handlers.approvalRequired?.(event);
24
+ return;
25
+ case "user_input_required":
26
+ await handlers.userInputRequired?.(event);
27
+ return;
28
+ case "mirrored_user_input":
29
+ await handlers.mirroredUserInput?.(event);
30
+ return;
31
+ case "session_switched":
32
+ await handlers.sessionSwitched?.(event);
33
+ return;
34
+ case "thread_switched":
35
+ await handlers.threadSwitched?.(event);
36
+ return;
37
+ case "task_complete":
38
+ await handlers.taskComplete?.(event);
39
+ return;
40
+ case "task_failed":
41
+ await handlers.taskFailed?.(event);
42
+ return;
43
+ case "fatal_error":
44
+ await handlers.fatalError?.(event);
45
+ return;
46
+ case "shutdown_requested":
47
+ await handlers.shutdownRequested?.(event);
48
+ return;
49
+ }
50
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Common, channel-neutral ordering for an inbound bridge message.
3
+ * Channel-specific parsing and daemon slot selection stay outside this
4
+ * function and are supplied through callbacks.
5
+ */
6
+ export async function routeBridgeMessage(context) {
7
+ if (!context.authorized) {
8
+ await context.onUnauthorized();
9
+ return { kind: "handled" };
10
+ }
11
+ if (context.command && await context.handleCommand(context.command)) {
12
+ return { kind: "handled" };
13
+ }
14
+ if (context.hasPendingApproval) {
15
+ await context.remindPendingApproval();
16
+ return { kind: "handled" };
17
+ }
18
+ if (context.hasPendingUserInput || context.adapterState.status === "awaiting_input") {
19
+ await context.remindPendingUserInput();
20
+ return { kind: "handled" };
21
+ }
22
+ if (context.shouldDefer) {
23
+ await context.defer();
24
+ return { kind: "deferred" };
25
+ }
26
+ if (context.adapterState.status === "busy" ||
27
+ context.adapterState.status === "awaiting_approval") {
28
+ await context.remindBusy();
29
+ return { kind: "handled" };
30
+ }
31
+ return { kind: "dispatched", result: await context.dispatch() };
32
+ }
@@ -0,0 +1,3 @@
1
+ export function normalizeBridgeChannelId(value) {
2
+ return value === "wecom" ? "wecom" : "wechat";
3
+ }
@@ -0,0 +1,17 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ function normalizeComparablePath(filePath) {
4
+ const normalized = path.resolve(filePath);
5
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
6
+ }
7
+ export function isDirectModuleRun(importMetaUrl, argv = process.argv, importMetaMain) {
8
+ if (typeof importMetaMain === "boolean") {
9
+ return importMetaMain;
10
+ }
11
+ const entryPath = argv[1];
12
+ if (!entryPath) {
13
+ return false;
14
+ }
15
+ return normalizeComparablePath(fileURLToPath(importMetaUrl)) ===
16
+ normalizeComparablePath(entryPath);
17
+ }
@@ -0,0 +1,61 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ export const DEFAULT_INBOUND_MESSAGE_CLAIM_TTL_MS = 10 * 60 * 1000;
5
+ export function buildInboundMessageClaimPath(messageKey, claimsDir) {
6
+ const fileName = `${crypto.createHash("sha1").update(messageKey).digest("hex")}.json`;
7
+ return path.join(claimsDir, fileName);
8
+ }
9
+ export function tryClaimChannelInboundMessage(messageKey, options) {
10
+ if (!messageKey) {
11
+ return false;
12
+ }
13
+ const nowMs = options.nowMs ?? Date.now();
14
+ const ttlMs = options.ttlMs ?? DEFAULT_INBOUND_MESSAGE_CLAIM_TTL_MS;
15
+ const claimPath = buildInboundMessageClaimPath(messageKey, options.claimsDir);
16
+ const attemptClaim = () => {
17
+ fs.mkdirSync(options.claimsDir, { recursive: true });
18
+ const handle = fs.openSync(claimPath, "wx");
19
+ try {
20
+ fs.writeFileSync(handle, JSON.stringify({
21
+ key: messageKey,
22
+ claimedAt: new Date(nowMs).toISOString(),
23
+ pid: process.pid,
24
+ }, null, 2), "utf-8");
25
+ }
26
+ finally {
27
+ fs.closeSync(handle);
28
+ }
29
+ return true;
30
+ };
31
+ try {
32
+ return attemptClaim();
33
+ }
34
+ catch (error) {
35
+ const code = typeof error === "object" &&
36
+ error !== null &&
37
+ "code" in error &&
38
+ typeof error.code === "string"
39
+ ? error.code
40
+ : "";
41
+ if (code !== "EEXIST") {
42
+ return false;
43
+ }
44
+ }
45
+ try {
46
+ const stat = fs.statSync(claimPath);
47
+ if (Number.isFinite(stat.mtimeMs) && nowMs - stat.mtimeMs > ttlMs) {
48
+ fs.rmSync(claimPath, { force: true });
49
+ return attemptClaim();
50
+ }
51
+ }
52
+ catch {
53
+ try {
54
+ return attemptClaim();
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ return false;
61
+ }
@@ -0,0 +1,63 @@
1
+ const ANSI_ESCAPE_RE =
2
+ // eslint-disable-next-line no-control-regex
3
+ /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
4
+ export function nowIso() {
5
+ return new Date().toISOString();
6
+ }
7
+ export function stripAnsi(text) {
8
+ return text.replace(ANSI_ESCAPE_RE, "");
9
+ }
10
+ export function normalizeOutput(text) {
11
+ return stripAnsi(text)
12
+ // eslint-disable-next-line no-control-regex
13
+ .replace(/\u0000/g, "")
14
+ .replace(/\r\n/g, "\n")
15
+ .replace(/\r/g, "\n");
16
+ }
17
+ export function truncatePreview(text, maxLength = 140) {
18
+ const normalized = normalizeOutput(text).trim().replace(/\s+/g, " ");
19
+ if (!normalized) {
20
+ return "(empty)";
21
+ }
22
+ if (normalized.length <= maxLength) {
23
+ return normalized;
24
+ }
25
+ return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
26
+ }
27
+ export function buildOneTimeCode(length = 6) {
28
+ const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
29
+ let code = "";
30
+ while (code.length < length) {
31
+ code += alphabet[Math.floor(Math.random() * alphabet.length)];
32
+ }
33
+ return code;
34
+ }
35
+ export function buildInstanceId() {
36
+ return `bridge-${Date.now().toString(36)}-${buildOneTimeCode(6).toLowerCase()}`;
37
+ }
38
+ export function formatDuration(durationMs) {
39
+ if (!Number.isFinite(durationMs) || durationMs < 0) {
40
+ return "0s";
41
+ }
42
+ const totalSeconds = Math.floor(durationMs / 1000);
43
+ const minutes = Math.floor(totalSeconds / 60);
44
+ const seconds = totalSeconds % 60;
45
+ if (!minutes) {
46
+ return `${seconds}s`;
47
+ }
48
+ return `${minutes}m ${seconds}s`;
49
+ }
50
+ export function summarizeOutput(text, maxLength = 280) {
51
+ const normalized = normalizeOutput(text)
52
+ .split("\n")
53
+ .map((line) => line.trim())
54
+ .filter(Boolean);
55
+ if (!normalized.length) {
56
+ return "(no output)";
57
+ }
58
+ const summary = normalized.slice(-6).join("\n");
59
+ if (summary.length <= maxLength) {
60
+ return summary;
61
+ }
62
+ return summary.slice(summary.length - maxLength);
63
+ }
@@ -23,6 +23,9 @@ function normalizeDaemonEndpoint(value) {
23
23
  token: record.token,
24
24
  cwd: record.cwd,
25
25
  startedAt: record.startedAt,
26
+ ...(record.channelId === "wechat" || record.channelId === "wecom"
27
+ ? { channelId: record.channelId }
28
+ : {}),
26
29
  };
27
30
  }
28
31
  export function buildDaemonToken() {