cli-wechat-bridge 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,11 +7,12 @@
7
7
  <p align="center">
8
8
  <a href="https://github.com/UNLINEARITY/CLI-WeChat-Bridge"><img alt="GitHub stars" src="https://img.shields.io/github/stars/UNLINEARITY/CLI-WeChat-Bridge?label=Stars&amp;style=for-the-badge&amp;logo=github&amp;color=0891b2&amp;labelColor=1c1917"></a>
9
9
  <a href="https://www.npmjs.com/package/cli-wechat-bridge"><img alt="npm version" src="https://img.shields.io/npm/v/cli-wechat-bridge?label=npm&amp;style=for-the-badge&amp;logo=npm&amp;color=cb3837&amp;labelColor=1c1917"></a>
10
- <a href="https://www.npmjs.com/package/cli-wechat-bridge"><img alt="npm downloads" src="https://img.shields.io/npm/dm/cli-wechat-bridge?label=Downloads&amp;style=for-the-badge&amp;logo=npm&amp;color=16a34a&amp;labelColor=1c1917"></a>
11
- <a href="https://www.npmjs.com/package/@unlinearity/cli-wechat-bridge"><img alt="scoped npm downloads" src="https://img.shields.io/npm/dm/@unlinearity/cli-wechat-bridge?label=Scoped%20downloads&amp;style=for-the-badge&amp;logo=npm&amp;color=15803d&amp;labelColor=1c1917"></a>
10
+ <a href="https://www.npmjs.com/package/cli-wechat-bridge"><img alt="npm downloads" src="https://img.shields.io/npm/dt/cli-wechat-bridge?label=Downloads&amp;style=for-the-badge&amp;logo=npm&amp;color=16a34a&amp;labelColor=1c1917"></a>
11
+ <a href="https://www.npmjs.com/package/@unlinearity/cli-wechat-bridge"><img alt="scoped npm downloads" src="https://img.shields.io/npm/dt/@unlinearity/cli-wechat-bridge?label=Scoped%20downloads&amp;style=for-the-badge&amp;logo=npm&amp;color=15803d&amp;labelColor=1c1917"></a>
12
12
  <a href="https://www.npmjs.com/package/cli-wechat-bridge">
13
13
  <img alt="License" src="https://img.shields.io/badge/License-AGPL--3.0-7c3aed?style=for-the-badge&labelColor=1c1917">
14
14
  </a>
15
+ <a href="https://github.com/UNLINEARITY/CLI-WeChat-Bridge/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/UNLINEARITY/CLI-WeChat-Bridge/ci.yml?branch=main&label=CI&style=for-the-badge&logo=githubactions&logoColor=white&labelColor=1c1917"></a>
15
16
  </p>
16
17
 
17
18
  **命令行工具的微信桥接**:本项目将微信消息桥接到本地运行的 [`Codex`](https://github.com/openai/codex)、[`Claude Code`](https://code.claude.com/docs/en/overview) 和 [`OpenCode`](https://github.com/anomalyco/opencode),同时把本地输出、审批请求与运行状态同步回微信。
@@ -26,6 +27,7 @@
26
27
  - [运行配置](docs/configuration.md):数据目录、上传大小限制、调试开关等环境变量。
27
28
  - [开发说明](docs/development.md):源码运行、测试、构建、打包和全局 smoke 验证。
28
29
  - [发布说明](docs/releases/README.md):各版本变更与升级说明。
30
+ - [通信架构](docs/architecture.md):三种 CLI 适配器的通信机制、PTY 依赖分析和技术决策。
29
31
 
30
32
  ## 这个项目解决什么问题?
31
33
 
@@ -63,6 +65,35 @@ npm install -g cli-wechat-bridge@latest
63
65
 
64
66
  兼容性说明:旧包名 `@unlinearity/cli-wechat-bridge` 会继续同步发布,已经安装旧包名的用户可以正常升级;新用户优先使用更短的 `cli-wechat-bridge`。
65
67
 
68
+ <details>
69
+ <summary><b>安装遇到问题?(node-pty 原生模块)</b></summary>
70
+
71
+ 本项目使用 `node-pty` 为 CLI 适配器提供完整终端模拟。**Claude Code 适配器当前通过 PTY 交互模式工作**,node-pty 不可用时会回退到兼容模式,但 Claude Code 在此模式下可能无法正常桥接;Codex 适配器主要通过 WebSocket RPC 通信,通常不受影响;OpenCode 适配器完全不依赖 node-pty。
72
+
73
+ **Linux 用户**(最常见):需要原生模块编译工具:
74
+
75
+ ```bash
76
+ # Debian / Ubuntu
77
+ sudo apt install build-essential python3
78
+ # RHEL / Fedora
79
+ sudo dnf groupinstall "Development Tools" && sudo dnf install python3
80
+ # Alpine
81
+ apk add build-base python3
82
+ ```
83
+
84
+ 安装编译工具后重新安装:`npm install -g cli-wechat-bridge@latest`
85
+
86
+ **macOS 用户**:如遇编译问题,安装 Xcode 命令行工具:`xcode-select --install`
87
+
88
+ **Windows 用户**:
89
+ - 需要 Windows 10 1809(build 18309)或更高版本
90
+ - 如果 node-pty 加载失败,运行 `npm rebuild node-pty` 或重新安装
91
+ - 确保已安装 [Visual C++ Redistributable](https://aka.ms/vs/17/release/vc_redist.x64.exe)
92
+
93
+ 运行 `wechat-daemon --doctor` 可快速检查环境状态。详见 [问题排查](docs/troubleshooting.md#pty-不可用--回退模式)。
94
+
95
+ </details>
96
+
66
97
  ### 3. 完成微信登录
67
98
 
68
99
  全局安装后运行:
@@ -6,8 +6,32 @@ import { fileURLToPath } from "node:url";
6
6
 
7
7
  const BIN_DIR = path.dirname(fileURLToPath(import.meta.url));
8
8
  const PROJECT_DIR = path.resolve(BIN_DIR, "..");
9
+ const MIN_NODE_MAJOR = 24;
10
+
11
+ function ensureSupportedNodeVersion() {
12
+ if (process.env.CLI_BRIDGE_SKIP_NODE_CHECK === "1") {
13
+ return;
14
+ }
15
+
16
+ const major = Number(process.versions.node.split(".")[0]);
17
+ if (Number.isFinite(major) && major >= MIN_NODE_MAJOR) {
18
+ return;
19
+ }
20
+
21
+ process.stderr.write(
22
+ [
23
+ `[cli-wechat-bridge] Node.js >= ${MIN_NODE_MAJOR} is required, but you are running ${process.version}.`,
24
+ `[cli-wechat-bridge] 需要 Node.js >= ${MIN_NODE_MAJOR},当前版本为 ${process.version}。`,
25
+ "Install the latest LTS from https://nodejs.org/ (or via nvm), then retry.",
26
+ "Set CLI_BRIDGE_SKIP_NODE_CHECK=1 to bypass this check at your own risk.",
27
+ "",
28
+ ].join("\n"),
29
+ );
30
+ process.exit(1);
31
+ }
9
32
 
10
33
  export function runJsEntry(relativeEntryPath, extraArgs = []) {
34
+ ensureSupportedNodeVersion();
11
35
  const entryPath = path.join(PROJECT_DIR, relativeEntryPath);
12
36
  const child = spawn(
13
37
  process.execPath,
@@ -393,6 +393,14 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
393
393
  socket.setEncoding("utf8");
394
394
  socket.on("data", (chunk) => {
395
395
  buffer += chunk;
396
+ // Cap the buffer: a single hook frame is tiny, so a frame larger
397
+ // than 1 MiB means a misbehaving client (e.g. one that never sends a
398
+ // newline). Without this the buffer could grow unbounded. Sibling
399
+ // IPC paths already cap their buffers; this one did not.
400
+ if (buffer.length > 1024 * 1024) {
401
+ socket.destroy();
402
+ return;
403
+ }
396
404
  while (true) {
397
405
  const newlineIndex = buffer.indexOf("\n");
398
406
  if (newlineIndex < 0) {
@@ -903,6 +911,16 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
903
911
  try {
904
912
  await super.reset();
905
913
  }
914
+ catch (error) {
915
+ // recoverFromInvalidResume is invoked fire-and-forget (void) and there is
916
+ // no global unhandled-rejection handler, so a throwing reset() must be
917
+ // caught here to avoid crashing the bridge.
918
+ this.emit({
919
+ type: "fatal_error",
920
+ message: `Failed to restart Claude after invalid resume: ${String(error)}`,
921
+ timestamp: nowIso(),
922
+ });
923
+ }
906
924
  finally {
907
925
  this.recoveringInvalidResume = false;
908
926
  }
@@ -937,6 +937,31 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
937
937
  child.stderr.on("data", (chunk) => {
938
938
  this.appServerLog = appendBoundedLog(this.appServerLog, chunk);
939
939
  });
940
+ child.once("error", (error) => {
941
+ // spawn itself failed (ENOENT/EACCES/EMFILE). Without this listener the
942
+ // 'error' event is unhandled and crashes the bridge. Mirror the exit
943
+ // handler's cleanup so the failure surfaces as a fatal_error instead.
944
+ const expectedShutdown = shouldSuppressCodexTransportFatalError({
945
+ transportShuttingDown: this.appServerShuttingDown,
946
+ shuttingDown: this.shuttingDown,
947
+ cleanPanelExitInProgress: this.cleanPanelExitInProgress,
948
+ });
949
+ this.appServer = null;
950
+ this.appServerPort = null;
951
+ this.appServerShuttingDown = false;
952
+ this.deleteAppServerAuthTokenFile();
953
+ this.appServerAuthToken = null;
954
+ if (expectedShutdown) {
955
+ return;
956
+ }
957
+ const details = this.describeAppServerLog();
958
+ this.emit({
959
+ type: "fatal_error",
960
+ message: `codex app-server failed to start: ${String(error)}${details}`,
961
+ timestamp: nowIso(),
962
+ });
963
+ this.terminateCodexClient();
964
+ });
940
965
  child.on("exit", (code, signal) => {
941
966
  const expectedShutdown = shouldSuppressCodexTransportFatalError({
942
967
  transportShuttingDown: this.appServerShuttingDown,
@@ -31,6 +31,11 @@ export class OpenCodeServerAdapter {
31
31
  workingNoticeTimer = null;
32
32
  workingNoticeSent = false;
33
33
  lastBusyAtMs = 0;
34
+ // Monotonic token bumped on every beginTrackedTurn. Lets a deferred turn
35
+ // completion (.then in handleSessionIdle) detect that a newer turn started
36
+ // while it was awaiting, so the stale completion becomes a no-op instead of
37
+ // clobbering the in-flight turn (stale final_reply / status flip / batcher clear).
38
+ currentTurnToken = 0;
34
39
  pendingLocalPrompt = "";
35
40
  localPromptNoticeSent = false;
36
41
  loggedUnknownEventTypes = new Set();
@@ -773,10 +778,21 @@ export class OpenCodeServerAdapter {
773
778
  this.hasAcceptedInput = false;
774
779
  const completedPreview = this.currentPreview;
775
780
  const turnStartedAtMs = this.lastBusyAtMs;
781
+ const completingTurnToken = this.currentTurnToken;
776
782
  void this.outputBatcher.flushNow()
777
783
  .catch(() => undefined)
778
784
  .then(async () => {
785
+ // A newer turn (local or wechat) may have started while we awaited
786
+ // the flush. If so, this completion is stale: dropping it avoids
787
+ // flipping the active turn back to idle, emitting the old final_reply,
788
+ // and clearing the new turn's buffered output.
789
+ if (this.currentTurnToken !== completingTurnToken) {
790
+ return;
791
+ }
779
792
  const finalReplyText = await this.resolveFinalReplyText(sessionId, turnStartedAtMs);
793
+ if (this.currentTurnToken !== completingTurnToken) {
794
+ return;
795
+ }
780
796
  this.setStatus("idle");
781
797
  if (finalReplyText) {
782
798
  this.emit({
@@ -1618,6 +1634,7 @@ export class OpenCodeServerAdapter {
1618
1634
  return session;
1619
1635
  }
1620
1636
  beginTrackedTurn(text, origin, options = {}) {
1637
+ this.currentTurnToken += 1;
1621
1638
  this.hasAcceptedInput = true;
1622
1639
  this.currentPreview = truncatePreview(text);
1623
1640
  this.state.lastInputAt = nowIso();
@@ -1,11 +1,12 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
  import net from "node:net";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { spawn, spawnSync } from "node:child_process";
6
7
  import { t } from "../i18n/index.js";
7
8
  import { spawn as spawnPty } from "node-pty";
8
- import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, containsWechatOutboundAttachmentPath, containsWechatOutboundAttachmentPathDeep, isWechatOutboundAttachmentWriteCommand, isHighRiskShellCommand, normalizeOutput, truncatePreview, } from "./bridge-utils.js";
9
+ import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, containsWechatOutboundAttachmentPath, containsWechatOutboundAttachmentPathDeep, isWechatOutboundAttachmentWriteCommand, isHighRiskShellCommand, isStrictApprovalModeEnabled, normalizeOutput, truncatePreview, } from "./bridge-utils.js";
9
10
  import { getNotificationThreadId, isRecord, } from "./bridge-adapter-common.js";
10
11
  export { coerceWebSocketMessageData, describeUnknownError, getCodexRpcRequestId, getLocalCompanionCommandName, getNotificationThreadId, getNotificationTurnId, getSharedSessionIdFromAdapterState, isRecentIsoTimestamp, isRecord, normalizeCodexRpcError, quotePosixCommandArg, quoteWindowsCommandArg, } from "./bridge-adapter-common.js";
11
12
  export const DEFAULT_COLS = 120;
@@ -250,7 +251,10 @@ function containsHighRiskPermissionTarget(value) {
250
251
  collectPermissionPaths(value, paths);
251
252
  return paths.some(isHighRiskPermissionPath);
252
253
  }
253
- export function getCodexApprovalAutoResponse(method, params) {
254
+ export function getCodexApprovalAutoResponse(method, params, env = process.env) {
255
+ if (isStrictApprovalModeEnabled(env)) {
256
+ return null;
257
+ }
254
258
  if (method === "item/commandExecution/requestApproval") {
255
259
  const command = typeof params.command === "string" ? params.command : "";
256
260
  if (!commandApprovalAllowsAccept(params.availableDecisions)) {
@@ -688,49 +692,16 @@ export function buildCliEnvironment(kind, options = {}) {
688
692
  const sourceEnv = options.env ?? process.env;
689
693
  const platform = options.platform ?? process.platform;
690
694
  if (kind === "codex" || kind === "claude" || kind === "opencode") {
691
- if (platform !== "win32") {
692
- return applyLoopbackNoProxy({
693
- ...copyDefinedEnv(sourceEnv),
694
- TERM: sourceEnv.TERM || "xterm-256color",
695
- });
696
- }
695
+ // Pass the full environment through on every platform. A previous
696
+ // Windows-only allowlist silently dropped user-configured variables such
697
+ // as ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / OPENAI_API_KEY, which
698
+ // broke CLIs that authenticate via environment variables even though the
699
+ // same CLI worked when launched manually from the user's shell.
697
700
  const env = {
701
+ ...copyDefinedEnv(sourceEnv),
698
702
  TERM: sourceEnv.TERM || "xterm-256color",
699
703
  };
700
- const keys = [
701
- "PATH",
702
- "PATHEXT",
703
- "ComSpec",
704
- "COMSPEC",
705
- "SystemRoot",
706
- "SYSTEMROOT",
707
- "USERPROFILE",
708
- "HOME",
709
- "APPDATA",
710
- "LOCALAPPDATA",
711
- "TEMP",
712
- "TMP",
713
- "OS",
714
- "ProgramFiles",
715
- "ProgramFiles(x86)",
716
- "CommonProgramFiles",
717
- "CommonProgramFiles(x86)",
718
- "HTTP_PROXY",
719
- "HTTPS_PROXY",
720
- "ALL_PROXY",
721
- "http_proxy",
722
- "https_proxy",
723
- "all_proxy",
724
- "NO_PROXY",
725
- "no_proxy",
726
- ];
727
- for (const key of keys) {
728
- const value = sourceEnv[key];
729
- if (value) {
730
- env[key] = value;
731
- }
732
- }
733
- if (!env.HOME && env.USERPROFILE) {
704
+ if (platform === "win32" && !env.HOME && env.USERPROFILE) {
734
705
  env.HOME = env.USERPROFILE;
735
706
  }
736
707
  return applyLoopbackNoProxy(env);
@@ -740,6 +711,13 @@ export function buildCliEnvironment(kind, options = {}) {
740
711
  TERM: sourceEnv.TERM || "xterm-256color",
741
712
  };
742
713
  }
714
+ // ConPTY shipped in Windows 10 build 18309; on older builds node-pty must be
715
+ // left to auto-select its winpty fallback instead of being forced onto ConPTY.
716
+ export const WINDOWS_CONPTY_MIN_BUILD = 18309;
717
+ export function windowsBuildSupportsConpty(osRelease) {
718
+ const build = Number(osRelease.split(".")[2]);
719
+ return Number.isFinite(build) && build >= WINDOWS_CONPTY_MIN_BUILD;
720
+ }
743
721
  export function buildPtySpawnOptions(params) {
744
722
  const options = {
745
723
  name: "xterm-color",
@@ -748,7 +726,8 @@ export function buildPtySpawnOptions(params) {
748
726
  cwd: params.cwd,
749
727
  env: params.env,
750
728
  };
751
- if ((params.platform ?? process.platform) === "win32") {
729
+ if ((params.platform ?? process.platform) === "win32" &&
730
+ windowsBuildSupportsConpty(params.osRelease ?? os.release())) {
752
731
  options.useConpty = true;
753
732
  }
754
733
  return options;
@@ -1246,6 +1225,18 @@ export function spawnFallbackProcess(file, args, options) {
1246
1225
  listener({ exitCode });
1247
1226
  }
1248
1227
  });
1228
+ // spawn itself failed (e.g. ENOENT/EACCES) — surface as an exit so callers
1229
+ // stop waiting, instead of throwing an unhandled "error" event that would
1230
+ // crash the bridge on the PTY fallback path.
1231
+ child.on("error", () => {
1232
+ for (const listener of exitListeners) {
1233
+ listener({ exitCode: 1 });
1234
+ }
1235
+ });
1236
+ // Swallow broken-pipe errors on stdin writes after the child has exited.
1237
+ child.stdin?.on("error", () => {
1238
+ /* best effort */
1239
+ });
1249
1240
  return {
1250
1241
  pid: child.pid,
1251
1242
  write(data) {
@@ -1301,6 +1292,9 @@ export function buildSpawnDiagnostic(error, spawnTarget, platform = process.plat
1301
1292
  if (platform === "darwin") {
1302
1293
  lines.push(t("spawn.diagnostic.xcode"));
1303
1294
  }
1295
+ if (platform === "linux") {
1296
+ lines.push(t("spawn.diagnostic.linuxBuildTools"));
1297
+ }
1304
1298
  }
1305
1299
  else if (errorMessage.includes("ENOENT") || errorMessage.includes("spawn")) {
1306
1300
  lines.push(t("spawn.diagnostic.notFound", { target }));
@@ -1310,7 +1304,7 @@ export function buildSpawnDiagnostic(error, spawnTarget, platform = process.plat
1310
1304
  }
1311
1305
  lines.push(t("spawn.diagnostic.nodeVersion"));
1312
1306
  if (platform === "win32") {
1313
- lines.push(t("spawn.diagnostic.winAdmin"));
1307
+ lines.push(t("spawn.diagnostic.winFull"));
1314
1308
  }
1315
1309
  return lines.join("\n");
1316
1310
  }
@@ -1,4 +1,4 @@
1
- import { formatFinalReplyMessage, parseWechatFinalReply, sanitizeWechatFinalReplyText, } from "./bridge-utils.js";
1
+ import { formatFinalReplyMessage, parseWechatFinalReply, sanitizeWechatFinalReplyText, splitWechatTextIntoChunks, } from "./bridge-utils.js";
2
2
  export const OPENCODE_EMPTY_VISIBLE_REPLY_MESSAGE = "OpenCode 没有产生可发送到微信的可见回复。请查看本地终端输出,或重试这条消息。";
3
3
  export async function forwardWechatFinalReply(params) {
4
4
  const { adapter, rawText, sender, onEmptyVisibleReply } = params;
@@ -6,9 +6,13 @@ export async function forwardWechatFinalReply(params) {
6
6
  const sanitizedText = sanitizeWechatFinalReplyText(adapter, parsed.visibleText);
7
7
  const visibleText = formatFinalReplyMessage(adapter, sanitizedText).trim();
8
8
  if (visibleText) {
9
- const sent = await sender.sendText(visibleText);
10
- if (sent === false) {
11
- return;
9
+ // Send long replies in bounded chunks: a single oversized sendmessage call
10
+ // can be rejected by the WeChat API, silently losing the whole reply.
11
+ for (const chunk of splitWechatTextIntoChunks(visibleText)) {
12
+ const sent = await sender.sendText(chunk);
13
+ if (sent === false) {
14
+ return;
15
+ }
12
16
  }
13
17
  }
14
18
  else if (adapter === "opencode" && parsed.visibleText.trim()) {
@@ -303,10 +303,52 @@ async function waitForProcessExit(pid, timeoutMs) {
303
303
  }
304
304
  return !isPidAlive(pid);
305
305
  }
306
+ /**
307
+ * Collect all descendant PIDs of `rootPid` on POSIX by walking `pgrep -P`.
308
+ * Used so killProcessTreeSync can reach grandchildren even when the child was
309
+ * spawned without `detached` (and therefore shares our process group).
310
+ */
311
+ function collectPosixDescendantsSync(rootPid) {
312
+ const descendants = [];
313
+ const stack = [rootPid];
314
+ while (stack.length > 0) {
315
+ const parent = stack.pop();
316
+ if (parent === undefined) {
317
+ break;
318
+ }
319
+ try {
320
+ const result = spawnSync("pgrep", ["-P", String(parent)], {
321
+ encoding: "utf8",
322
+ windowsHide: true,
323
+ });
324
+ if (result.error || result.status !== 0) {
325
+ continue;
326
+ }
327
+ const stdout = result.stdout ?? "";
328
+ for (const line of stdout.split(/\r?\n/)) {
329
+ const childPid = Number.parseInt(line.trim(), 10);
330
+ if (Number.isInteger(childPid) &&
331
+ childPid > 0 &&
332
+ childPid !== rootPid &&
333
+ !descendants.includes(childPid)) {
334
+ descendants.push(childPid);
335
+ stack.push(childPid);
336
+ }
337
+ }
338
+ }
339
+ catch {
340
+ continue;
341
+ }
342
+ }
343
+ return descendants;
344
+ }
306
345
  /**
307
346
  * Synchronously kill a process and its entire descendant tree.
308
347
  * On Windows uses `taskkill /T /F /PID` to recursively kill all descendants.
309
- * On other platforms uses `process.kill(pid)` directly.
348
+ * On POSIX, first tries the process group (kill -pgid) for group leaders, then
349
+ * walks the descendant tree via pgrep — required because codex/opencode children
350
+ * are spawned without `detached`, so they share our process group and a bare
351
+ * kill(pid) would orphan them on Linux/macOS.
310
352
  */
311
353
  export function killProcessTreeSync(pid) {
312
354
  if (!Number.isInteger(pid) || pid <= 0) {
@@ -327,15 +369,28 @@ export function killProcessTreeSync(pid) {
327
369
  }
328
370
  }
329
371
  else {
372
+ // Best effort: signal the whole group if the child happens to be a leader.
330
373
  try {
331
374
  process.kill(-pid);
332
375
  }
333
- catch {
376
+ catch { /* not a group leader or already gone */ }
377
+ // Walk and kill descendants (leaves first), then the root, so grandchildren
378
+ // are not re-parented to init and left running.
379
+ const descendants = collectPosixDescendantsSync(pid);
380
+ for (let i = descendants.length - 1; i >= 0; i--) {
381
+ const descendantPid = descendants[i];
382
+ if (descendantPid === undefined) {
383
+ continue;
384
+ }
334
385
  try {
335
- process.kill(pid);
386
+ process.kill(descendantPid);
336
387
  }
337
388
  catch { /* best effort */ }
338
389
  }
390
+ try {
391
+ process.kill(pid);
392
+ }
393
+ catch { /* best effort */ }
339
394
  }
340
395
  }
341
396
  export async function reapPeerBridgeProcesses(params = {}) {
@@ -1,5 +1,5 @@
1
1
  import fs from "node:fs";
2
- import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, ensureWorkspaceChannelDir, ensureChannelDataDir, } from "../wechat/channel-config.js";
2
+ import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureWorkspaceChannelDir, ensureChannelDataDir, } from "../wechat/channel-config.js";
3
3
  import { buildInstanceId } from "./bridge-utils.js";
4
4
  const ORPHAN_LOCK_RECLAIM_TIMEOUT_MS = 2_000;
5
5
  const ORPHAN_LOCK_RECLAIM_POLL_MS = 100;
@@ -280,7 +280,7 @@ export class BridgeStateStore {
280
280
  }
281
281
  appendLog(message) {
282
282
  ensureChannelDataDir();
283
- fs.appendFileSync(BRIDGE_LOG_FILE, `[${new Date().toISOString()}] ${message}\n`, "utf-8");
283
+ appendBoundedLog(BRIDGE_LOG_FILE, `[${new Date().toISOString()}] ${message}\n`);
284
284
  }
285
285
  releaseLock() {
286
286
  try {
@@ -293,9 +293,17 @@ export class BridgeStateStore {
293
293
  // Best effort cleanup.
294
294
  }
295
295
  }
296
- save() {
296
+ writeAtomicJsonFile(filePath, value) {
297
297
  ensureChannelDataDir();
298
- fs.writeFileSync(this.stateFilePath, JSON.stringify(this.state, null, 2), "utf-8");
298
+ const data = JSON.stringify(value, null, 2);
299
+ // temp + rename so a crash mid-write cannot leave a truncated/half-written
300
+ // state or lock file that would break the next launch or look like a stale lock.
301
+ const tempPath = `${filePath}.${process.pid}.tmp`;
302
+ fs.writeFileSync(tempPath, data, "utf-8");
303
+ fs.renameSync(tempPath, filePath);
304
+ }
305
+ save() {
306
+ this.writeAtomicJsonFile(this.stateFilePath, this.state);
299
307
  }
300
308
  acquireLock() {
301
309
  const existing = readBridgeLockFile();
@@ -317,7 +325,7 @@ export class BridgeStateStore {
317
325
  throw buildLockConflictError(existing);
318
326
  }
319
327
  }
320
- fs.writeFileSync(BRIDGE_LOCK_FILE, JSON.stringify(this.lockPayload, null, 2), "utf-8");
328
+ this.writeAtomicJsonFile(BRIDGE_LOCK_FILE, this.lockPayload);
321
329
  }
322
330
  verifyRuntimeOwnership() {
323
331
  const workspaceState = this.readWorkspaceStateFile();
@@ -336,7 +344,7 @@ export class BridgeStateStore {
336
344
  this.save();
337
345
  }
338
346
  if (ownership.rehydratedLock) {
339
- fs.writeFileSync(BRIDGE_LOCK_FILE, JSON.stringify(this.lockPayload, null, 2), "utf-8");
347
+ this.writeAtomicJsonFile(BRIDGE_LOCK_FILE, this.lockPayload);
340
348
  }
341
349
  return ownership;
342
350
  }
@@ -4,7 +4,11 @@ import path from "node:path";
4
4
  const ANSI_ESCAPE_RE =
5
5
  // eslint-disable-next-line no-control-regex
6
6
  /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
7
- export const MESSAGE_START_GRACE_MS = 5_000;
7
+ // Messages older than bridge start minus this grace window are treated as
8
+ // pre-start backlog and skipped. The window must absorb realistic clock skew
9
+ // between the local machine and WeChat server timestamps: with a small value,
10
+ // a PC clock running slightly ahead silently dropped fresh messages.
11
+ export const MESSAGE_START_GRACE_MS = 30_000;
8
12
  const WECHAT_ATTACHMENT_SEND_INTENT_RE = /\b(send|upload|attach|forward|share)\b/i;
9
13
  const WECHAT_ATTACHMENT_SEND_INTENT_ZH_RE = /发送|发给我|发我|给我发|发过来|发一下|发来|发到|发微信|上传|转发|分享|传给我|传我|传到/;
10
14
  const WECHAT_ATTACHMENT_SHORT_SEND_ZH_RE = /^(?:发|发呀|发呢|发吧|直接发|发给我|发我|发微信|发送微信)$/;
@@ -948,13 +952,21 @@ function extractInlineWechatAttachments(text) {
948
952
  attachments,
949
953
  };
950
954
  }
955
+ // Platform-independent absolute-path check for WeChat attachment candidates.
956
+ // Recognizes Windows drive paths (C:\, C:/) and POSIX root paths (/, \), so an
957
+ // agent-emitted path resolves on any host OS. Node's path.isAbsolute is
958
+ // platform-specific: it rejects "C:\Users\..." on Linux/macOS, which dropped
959
+ // the attachment and failed CI on non-Windows runners.
960
+ function isWechatAttachmentAbsolutePath(candidate) {
961
+ return /^(?:[A-Za-z]:[\\/]|[\\/])/.test(candidate);
962
+ }
951
963
  function resolveWechatAttachmentPath(candidatePath) {
952
964
  const normalizedCandidate = normalizeWechatAttachmentCandidate(candidatePath);
953
965
  if (!normalizedCandidate) {
954
966
  return null;
955
967
  }
956
- if (path.isAbsolute(normalizedCandidate)) {
957
- return path.normalize(normalizedCandidate);
968
+ if (isWechatAttachmentAbsolutePath(normalizedCandidate)) {
969
+ return normalizedCandidate;
958
970
  }
959
971
  const homeRelativeMatch = /^(?:~[\\/])?(Desktop|Documents|Downloads|Pictures|Videos|Music)([\\/].+)?$/i.exec(normalizedCandidate);
960
972
  if (!homeRelativeMatch) {
@@ -967,12 +979,15 @@ function resolveWechatAttachmentPath(candidatePath) {
967
979
  }
968
980
  return path.normalize(path.join(os.homedir(), ...relativeSegments));
969
981
  }
982
+ // Trim and clean a WeChat attachment candidate path. Intentionally does NOT
983
+ // rewrite separators to path.sep: the path must keep its original separator
984
+ // style (Windows "\" or POSIX "/") so the bridge can resolve agent-emitted
985
+ // paths regardless of host OS (see isWechatAttachmentAbsolutePath).
970
986
  function normalizeWechatAttachmentCandidate(candidatePath) {
971
987
  return candidatePath
972
988
  .trim()
973
989
  .replace(/^`|`$/g, "")
974
- .replace(/\.\s+([A-Za-z0-9]{2,8})(?=$|[?/\s])/g, ".$1")
975
- .replace(/[\\/]+/g, path.sep);
990
+ .replace(/\.\s+([A-Za-z0-9]{2,8})(?=$|[?/\s])/g, ".$1");
976
991
  }
977
992
  function inferInlineWechatAttachmentKind(filePath) {
978
993
  const extension = path.extname(filePath).toLowerCase();
@@ -1305,9 +1320,49 @@ export class OutputBatcher {
1305
1320
  .catch(() => undefined);
1306
1321
  }
1307
1322
  }
1323
+ // When CLI_BRIDGE_STRICT_APPROVAL is enabled, no permission request is
1324
+ // auto-approved: everything is forwarded to WeChat for an explicit
1325
+ // /confirm or /deny decision.
1326
+ export function isStrictApprovalModeEnabled(env = process.env) {
1327
+ const value = env.CLI_BRIDGE_STRICT_APPROVAL?.trim().toLowerCase();
1328
+ return value === "1" || value === "true" || value === "yes" || value === "on";
1329
+ }
1330
+ // Outbound WeChat text size cap shared by the streaming OutputBatcher and the
1331
+ // final-reply forwarder. Long replies sent as a single sendmessage call can be
1332
+ // rejected by the server, which previously made long final replies vanish.
1333
+ export const WECHAT_TEXT_CHUNK_MAX_CHARS = 1_200;
1334
+ export function splitWechatTextIntoChunks(text, maxChars = WECHAT_TEXT_CHUNK_MAX_CHARS) {
1335
+ const normalized = text.trim();
1336
+ if (!normalized) {
1337
+ return [];
1338
+ }
1339
+ if (normalized.length <= maxChars) {
1340
+ return [normalized];
1341
+ }
1342
+ const chunks = [];
1343
+ let remaining = normalized;
1344
+ while (remaining.length > maxChars) {
1345
+ // Prefer breaking at a newline reasonably close to the cap so paragraphs
1346
+ // stay intact; fall back to a hard split.
1347
+ const window = remaining.slice(0, maxChars + 1);
1348
+ const newlineIndex = window.lastIndexOf("\n");
1349
+ const splitIndex = newlineIndex > maxChars / 2 ? newlineIndex : maxChars;
1350
+ const chunk = remaining.slice(0, splitIndex).trim();
1351
+ if (chunk) {
1352
+ chunks.push(chunk);
1353
+ }
1354
+ remaining = remaining.slice(splitIndex).trim();
1355
+ }
1356
+ if (remaining) {
1357
+ chunks.push(remaining);
1358
+ }
1359
+ return chunks;
1360
+ }
1308
1361
  export function shouldDropStartupBacklogMessage(createdAtMs, bridgeStartedAtMs, graceMs = MESSAGE_START_GRACE_MS) {
1309
- if (!Number.isFinite(createdAtMs)) {
1310
- return true;
1362
+ // A missing or unparsable timestamp must not drop the message: treat it as
1363
+ // fresh and let normal processing continue.
1364
+ if (!Number.isFinite(createdAtMs) || createdAtMs <= 0) {
1365
+ return false;
1311
1366
  }
1312
1367
  return createdAtMs < bridgeStartedAtMs - graceMs;
1313
1368
  }
@@ -30,6 +30,7 @@ async function main() {
30
30
  const socket = net.connect({ host: "127.0.0.1", port });
31
31
  let buffer = "";
32
32
  const finish = () => {
33
+ clearTimeout(connectTimer);
33
34
  try {
34
35
  socket.destroy();
35
36
  }
@@ -38,7 +39,13 @@ async function main() {
38
39
  }
39
40
  resolve();
40
41
  };
42
+ // Bail out if the bridge hook server never accepts the connection, so a
43
+ // wedged bridge cannot leave Claude CLI blocked on this hook forever.
44
+ // Once connected, we wait indefinitely: approval responses legitimately
45
+ // take as long as the user needs to reply on WeChat.
46
+ const connectTimer = setTimeout(finish, 10_000);
41
47
  socket.once("connect", () => {
48
+ clearTimeout(connectTimer);
42
49
  try {
43
50
  socket.write(`${JSON.stringify({ token, requestId, payload })}\n`);
44
51
  }