@unlinearity/cli-wechat-bridge 1.1.1 → 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),同时把本地输出、审批请求与运行状态同步回微信。
@@ -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) {
@@ -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
  }
@@ -1,4 +1,4 @@
1
- import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, containsWechatOutboundAttachmentPath, isHighRiskShellCommand, isWechatOutboundAttachmentWriteCommand, normalizeOutput, truncatePreview, } from "./bridge-utils.js";
1
+ import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, containsWechatOutboundAttachmentPath, isHighRiskShellCommand, isStrictApprovalModeEnabled, isWechatOutboundAttachmentWriteCommand, normalizeOutput, truncatePreview, } from "./bridge-utils.js";
2
2
  export const CLAUDE_WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE = WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE;
3
3
  const FILE_MUTATION_TOOL_NAMES = new Set([
4
4
  "Write",
@@ -88,6 +88,11 @@ export function buildClaudeHookScript(params) {
88
88
  return [
89
89
  "@echo off",
90
90
  "setlocal",
91
+ // The script is written as UTF-8, but cmd.exe decodes batch lines with
92
+ // the active OEM code page (GBK on zh-CN systems). Paths below may
93
+ // contain non-ASCII characters (e.g. a Chinese Windows user name), so
94
+ // switch to UTF-8 before any line that embeds a path.
95
+ "chcp 65001>nul",
91
96
  `set "CLAUDE_WECHAT_HOOK_PORT=${params.hookPort}"`,
92
97
  `set "CLAUDE_WECHAT_HOOK_TOKEN=${params.hookToken}"`,
93
98
  `${command} ${stderrRedirect}`,
@@ -205,7 +210,10 @@ export function getClaudeWechatOutboundAttachmentDenyMessage(payload) {
205
210
  }
206
211
  return null;
207
212
  }
208
- export function getClaudePermissionAutoResponse(payload) {
213
+ export function getClaudePermissionAutoResponse(payload, env = process.env) {
214
+ if (isStrictApprovalModeEnabled(env)) {
215
+ return null;
216
+ }
209
217
  const toolName = typeof payload.tool_name === "string" ? payload.tool_name.trim() : "";
210
218
  if (!toolName) {
211
219
  return null;
@@ -266,7 +266,9 @@ async function main() {
266
266
  }
267
267
  const transport = new WeChatTransport({ log, logError });
268
268
  // 非阻塞地检查更新(不影响启动速度,也避免首次登录时打断二维码输出)
269
- setTimeout(async () => {
269
+ // unref:不能让这个延迟检查把 event loop 挂活(如 --doctor 或快速退出场景),
270
+ // 否则会与强制退出 teardown 竞态。
271
+ const updateCheckTimer = setTimeout(async () => {
270
272
  try {
271
273
  const versionInfo = await checkForUpdate();
272
274
  if (versionInfo?.hasUpdate) {
@@ -277,6 +279,7 @@ async function main() {
277
279
  // 静默失败,不影响正常使用
278
280
  }
279
281
  }, 3000); // 延迟3秒,确保不影响启动
282
+ updateCheckTimer.unref?.();
280
283
  const stateStore = new BridgeStateStore({
281
284
  ...options,
282
285
  authorizedUserId: credentials.userId,
@@ -338,6 +341,7 @@ async function main() {
338
341
  let lastOutputAt = 0;
339
342
  let lastHeartbeatAt = 0;
340
343
  let consecutivePollFailures = 0;
344
+ let backlogNoticeSent = false;
341
345
  const queueWechatTextAction = (action) => {
342
346
  const run = textSendChain.then(action);
343
347
  textSendChain = run.then(() => undefined, () => undefined);
@@ -661,6 +665,13 @@ async function main() {
661
665
  if (pollResult.ignoredBacklogCount > 0) {
662
666
  stateStore.incrementIgnoredBacklog(pollResult.ignoredBacklogCount);
663
667
  stateStore.appendLog(`ignored_startup_backlog: count=${pollResult.ignoredBacklogCount}`);
668
+ if (!backlogNoticeSent) {
669
+ backlogNoticeSent = true;
670
+ await queueWechatMessage(stateStore.getState().authorizedUserId, t("bridge.backlogIgnored", {
671
+ count: pollResult.ignoredBacklogCount,
672
+ graceSeconds: Math.round(MESSAGE_START_GRACE_MS / 1000),
673
+ }), "notice");
674
+ }
664
675
  }
665
676
  for (const message of pollResult.messages) {
666
677
  if (!ensureRuntimeOwnership()) {
@@ -7,12 +7,23 @@ async function main() {
7
7
  console.log(`Checking for updates...`);
8
8
  const versionInfo = await checkForUpdate(true); // 强制检查
9
9
  if (!versionInfo) {
10
- console.log(`ERROR: Unable to check for updates, please check your network connection`);
11
- process.exit(1);
10
+ console.error(`ERROR: Unable to check for updates.`);
11
+ console.error(`Could not read the latest version from the npm registry or GitHub.`);
12
+ console.error(`Possible causes:`);
13
+ console.error(` - No network connection`);
14
+ console.error(` - A proxy is required but not configured (set HTTP_PROXY/HTTPS_PROXY and NODE_USE_ENV_PROXY=1)`);
15
+ console.error(` - The npm registry or GitHub API are temporarily unavailable`);
16
+ console.error(`\nCheck the latest release manually:`);
17
+ console.error(` https://github.com/UNLINEARITY/CLI-WeChat-Bridge/releases`);
18
+ // 不使用 process.exit():强制退出会打断 fetch 底层 handle 的关闭流程,
19
+ // 在 Windows + Node 24 上触发 libuv 的 UV_HANDLE_CLOSING 断言崩溃。
20
+ // 设置 exitCode 后让事件循环自然退出。
21
+ process.exitCode = 1;
22
+ return;
12
23
  }
13
24
  if (!versionInfo.hasUpdate) {
14
25
  console.log(`OK: Already up to date (v${versionInfo.latest})`);
15
- process.exit(0);
26
+ return;
16
27
  }
17
28
  console.log(`[New Version Available] v${versionInfo.latest}`);
18
29
  console.log(`Current version: v${versionInfo.current}\n`);
@@ -26,5 +37,5 @@ async function main() {
26
37
  }
27
38
  main().catch((error) => {
28
39
  console.error(`Error checking for updates: ${error.message}`);
29
- process.exit(1);
40
+ process.exitCode = 1;
30
41
  });
@@ -283,6 +283,11 @@ function startBridgeInBackground(options) {
283
283
  stdio: "ignore",
284
284
  windowsHide: true,
285
285
  });
286
+ // A detached background spawn must still handle its own 'error' event
287
+ // (e.g. ENOENT), otherwise it surfaces as an unhandled exception.
288
+ child.on("error", () => {
289
+ /* best effort: background bridge failed to detach */
290
+ });
286
291
  child.unref();
287
292
  }
288
293
  async function waitForEndpoint(cwd, adapter, timeoutMs) {
@@ -13,7 +13,7 @@ import { readBridgeLockFile, } from "../bridge/bridge-state.js";
13
13
  import { getProcessRecordByPid, isWechatDaemonCommandLine, killProcessTreeSync, listWechatDaemonProcesses, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "../bridge/bridge-process-reaper.js";
14
14
  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";
15
15
  import { formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "../bridge/wechat-bridge.js";
16
- import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, ensureChannelDataDir, migrateLegacyChannelFiles, } from "../wechat/channel-config.js";
16
+ import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureChannelDataDir, migrateLegacyChannelFiles, } from "../wechat/channel-config.js";
17
17
  import { ensureWechatCredentials } from "../wechat/setup.js";
18
18
  import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix, listBindings, loadEmojiBindings, parseEmojiBindingsCommand, removeBinding, resolveEmojiCommand, setBinding, } from "./emoji-bindings.js";
19
19
  import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, describeWechatTransportError, isWechatContextTokenStaleError, WeChatTransport, } from "../wechat/wechat-transport.js";
@@ -45,7 +45,7 @@ function logError(message) {
45
45
  }
46
46
  function appendDaemonLog(message) {
47
47
  ensureChannelDataDir();
48
- fs.appendFileSync(BRIDGE_LOG_FILE, `[${new Date().toISOString()}] daemon: ${message}\n`, "utf8");
48
+ appendBoundedLog(BRIDGE_LOG_FILE, `[${new Date().toISOString()}] daemon: ${message}\n`);
49
49
  }
50
50
  function computePollRetryDelayMs(consecutiveFailures) {
51
51
  const normalizedFailures = Math.max(1, consecutiveFailures);
@@ -444,6 +444,7 @@ class WechatDaemon {
444
444
  slots = new Map();
445
445
  startedAt = new Date().toISOString();
446
446
  bridgeStartedAtMs = Date.now();
447
+ backlogNoticeSent = false;
447
448
  activeAdapter = null;
448
449
  takenOverAdapter;
449
450
  textSendChain = Promise.resolve();
@@ -582,6 +583,13 @@ class WechatDaemon {
582
583
  }
583
584
  if (pollResult.ignoredBacklogCount > 0) {
584
585
  appendDaemonLog(`ignored_startup_backlog: count=${pollResult.ignoredBacklogCount}`);
586
+ if (!this.backlogNoticeSent) {
587
+ this.backlogNoticeSent = true;
588
+ await this.queueWechatMessage(this.authorizedUserId, t("bridge.backlogIgnored", {
589
+ count: pollResult.ignoredBacklogCount,
590
+ graceSeconds: Math.round(MESSAGE_START_GRACE_MS / 1000),
591
+ }), "notice");
592
+ }
585
593
  }
586
594
  for (const message of pollResult.messages) {
587
595
  try {
@@ -9,6 +9,7 @@ export const messages = {
9
9
  "bridge.status.busy": "{adapter} is still working on the previous request. Wait for it to finish or send /interrupt.",
10
10
  "bridge.newSession.done": "New session started.",
11
11
  "bridge.fatalError": "Bridge error: {message}",
12
+ "bridge.backlogIgnored": "Ignored {count} backlog message(s) sent before the bridge started (they will not be processed).\nIf you actually sent them after the bridge started, your system clock is likely skewed by more than {graceSeconds}s — sync the clock and restart the bridge.",
12
13
  // === Daemon welcome & control ===
13
14
  "daemon.welcome": "WeChat Daemon ready.\nCWD: {cwd}\nActive: {adapter}\n\nCommands: /claude, /codex, /opencode, /stop, /confirm, /deny, /status\n{bindings}\n\nManage: /bind [emoji] cmd, /unbind [emoji], /bindings",
14
15
  "daemon.noActiveAdapter": "No active terminal is selected. Send /codex, /claude, or /opencode to start one.",
@@ -55,6 +56,15 @@ export const messages = {
55
56
  "doctor.label.node": "Node.js",
56
57
  "doctor.label.platform": "Platform",
57
58
  "doctor.label.winBuild": "Windows build",
59
+ "doctor.label.codePage": "Console code page",
60
+ "doctor.codePage.nonAsciiWarning": "{codePage} (not UTF-8 and the data directory path contains non-ASCII characters; hook scripts from older versions break in this environment — upgrade to the latest release)",
61
+ "doctor.label.connectivity": "WeChat connectivity",
62
+ "doctor.connectivity.ok": "reachable ({baseUrl})",
63
+ "doctor.connectivity.unreachable": "cannot reach {baseUrl} (check network and firewall)",
64
+ "doctor.connectivity.proxyHint": "cannot reach {baseUrl}. Proxy {proxy} detected, but Node fetch ignores proxy environment variables by default; if your network requires the proxy, set NODE_USE_ENV_PROXY=1 and retry.",
65
+ "doctor.label.clock": "System clock",
66
+ "doctor.clock.ok": "~{skewSeconds}s skew vs server time",
67
+ "doctor.clock.skewWarning": "~{skewSeconds}s skew vs server time (large skew makes the bridge treat fresh messages as pre-start backlog — sync the system clock)",
58
68
  "doctor.label.nodePty": "node-pty",
59
69
  "doctor.label.shell": "shell",
60
70
  "doctor.label.dataDir": "Data dir",
@@ -9,6 +9,7 @@ export const messages = {
9
9
  "bridge.status.busy": "{adapter} 仍在处理上一个请求。请等待完成或发送 /interrupt。",
10
10
  "bridge.newSession.done": "已开始新会话。",
11
11
  "bridge.fatalError": "桥接错误: {message}",
12
+ "bridge.backlogIgnored": "已忽略 {count} 条桥接启动前的积压消息(不会自动处理)。\n如果你是在桥接启动后才发送的消息却看到此提示,说明本机系统时钟可能不准(偏差超过 {graceSeconds} 秒),请校准时间后重启桥接。",
12
13
  // === Daemon 欢迎 & 控制 ===
13
14
  "daemon.welcome": "微信 Daemon 就绪。\n工作目录: {cwd}\n当前: {adapter}\n\n命令: /claude, /codex, /opencode, /stop, /confirm, /deny, /status\n{bindings}\n\n管理: /bind [表情] 命令, /unbind [表情], /bindings",
14
15
  "daemon.noActiveAdapter": "未选择活跃终端。发送 /codex、/claude 或 /opencode 启动一个。",
@@ -55,6 +56,15 @@ export const messages = {
55
56
  "doctor.label.node": "Node.js",
56
57
  "doctor.label.platform": "平台",
57
58
  "doctor.label.winBuild": "Windows build",
59
+ "doctor.label.codePage": "控制台代码页",
60
+ "doctor.codePage.nonAsciiWarning": "{codePage}(非 UTF-8,且数据目录路径含非 ASCII 字符;旧版本的 hook 脚本在此环境下会乱码失效,请升级到最新版本)",
61
+ "doctor.label.connectivity": "微信服务可达性",
62
+ "doctor.connectivity.ok": "可达 ({baseUrl})",
63
+ "doctor.connectivity.unreachable": "无法访问 {baseUrl}(检查网络与防火墙)",
64
+ "doctor.connectivity.proxyHint": "无法访问 {baseUrl}。检测到代理 {proxy},但 Node fetch 默认不使用代理环境变量;若必须经代理出网,请设置 NODE_USE_ENV_PROXY=1 后重试。",
65
+ "doctor.label.clock": "系统时钟",
66
+ "doctor.clock.ok": "与服务器时间偏差约 {skewSeconds}s",
67
+ "doctor.clock.skewWarning": "与服务器时间偏差约 {skewSeconds}s(偏差过大会导致桥接启动后的消息被当作积压忽略,请校准系统时间)",
58
68
  "doctor.label.nodePty": "node-pty",
59
69
  "doctor.label.shell": "shell",
60
70
  "doctor.label.dataDir": "数据目录",
@@ -7,12 +7,14 @@ import { normalizeBridgeLockPayload, shouldAutoReclaimBridgeLock, } from "../bri
7
7
  import { readLocalCompanionEndpoint as readWorkspaceEndpoint, } from "../companion/local-companion-link.js";
8
8
  import { isDaemonEndpointAlive, } from "../daemon/daemon-link.js";
9
9
  import { t } from "../i18n/index.js";
10
- import { resolveChannelDataDir } from "../wechat/channel-config.js";
10
+ import { DEFAULT_BASE_URL, resolveChannelDataDir } from "../wechat/channel-config.js";
11
11
  const STATE_OK = "[ok]";
12
12
  const STATE_WARN = "[warn]";
13
13
  const STATE_FAIL = "[fail]";
14
14
  const RUNTIME_ENDPOINT_TIMEOUT_MS = 500;
15
15
  const DAEMON_ENDPOINT_TIMEOUT_MS = 500;
16
+ const SERVER_DATE_TIMEOUT_MS = 3_000;
17
+ const CLOCK_SKEW_WARN_MS = 30_000;
16
18
  function msg(key, params) {
17
19
  return t(`doctor.${key}`, params);
18
20
  }
@@ -58,6 +60,53 @@ function getPlatformPtyFix(platform) {
58
60
  return "npm rebuild node-pty";
59
61
  }
60
62
  }
63
+ function defaultGetWindowsCodePage() {
64
+ try {
65
+ const output = execFileSync("cmd.exe", ["/d", "/c", "chcp"], {
66
+ stdio: ["ignore", "pipe", "ignore"],
67
+ encoding: "utf8",
68
+ windowsHide: true,
69
+ });
70
+ const match = /(\d+)\s*\.?\s*$/.exec(output.trim());
71
+ return match ? Number(match[1]) : null;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ async function defaultFetchServerDate() {
78
+ const controller = new AbortController();
79
+ const timer = setTimeout(() => controller.abort(), SERVER_DATE_TIMEOUT_MS);
80
+ try {
81
+ const res = await fetch(DEFAULT_BASE_URL, {
82
+ method: "GET",
83
+ signal: controller.signal,
84
+ });
85
+ const dateHeader = res.headers.get("date");
86
+ if (!dateHeader) {
87
+ return null;
88
+ }
89
+ const parsed = new Date(dateHeader);
90
+ return Number.isFinite(parsed.getTime()) ? parsed : null;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ finally {
96
+ clearTimeout(timer);
97
+ }
98
+ }
99
+ function containsNonAscii(value) {
100
+ return Array.from(value).some((character) => character.charCodeAt(0) > 0x7f);
101
+ }
102
+ function pickProxyEnvValue(env) {
103
+ return (env.HTTPS_PROXY ??
104
+ env.https_proxy ??
105
+ env.HTTP_PROXY ??
106
+ env.http_proxy ??
107
+ env.ALL_PROXY ??
108
+ env.all_proxy)?.trim() || undefined;
109
+ }
61
110
  function defaultIsProcessAlive(pid) {
62
111
  if (!Number.isInteger(pid) || pid <= 0) {
63
112
  return false;
@@ -100,6 +149,10 @@ function resolveDeps(deps = {}) {
100
149
  arch: deps.arch ?? process.arch,
101
150
  nodeVersion: deps.nodeVersion ?? process.version,
102
151
  osRelease: deps.osRelease ?? os.release,
152
+ env: deps.env ?? process.env,
153
+ now: deps.now ?? Date.now,
154
+ getWindowsCodePage: deps.getWindowsCodePage ?? defaultGetWindowsCodePage,
155
+ fetchServerDate: deps.fetchServerDate ?? defaultFetchServerDate,
103
156
  findExecutable: deps.findExecutable ?? findExecutable,
104
157
  loadNodePty: deps.loadNodePty ??
105
158
  (async () => {
@@ -503,6 +556,32 @@ export async function buildDoctorReport(options = {}, rawDeps = {}) {
503
556
  const build = parseInt(release.split(".").pop() ?? "0", 10);
504
557
  const ok = build >= 18309;
505
558
  lines.push(row(ok ? STATE_OK : STATE_FAIL, msg("label.winBuild"), ok ? String(build) : `${build} ${msg("winBuildConpty")}`));
559
+ const codePage = deps.getWindowsCodePage();
560
+ if (codePage !== null) {
561
+ const dataDirHasNonAscii = containsNonAscii(deps.resolveDataDir());
562
+ const risky = codePage !== 65001 && dataDirHasNonAscii;
563
+ lines.push(row(risky ? STATE_WARN : STATE_OK, msg("label.codePage"), risky
564
+ ? msg("codePage.nonAsciiWarning", { codePage })
565
+ : String(codePage)));
566
+ }
567
+ }
568
+ const proxyUrl = pickProxyEnvValue(deps.env);
569
+ const serverDate = await deps.fetchServerDate();
570
+ if (serverDate) {
571
+ lines.push(row(STATE_OK, msg("label.connectivity"), msg("connectivity.ok", {
572
+ baseUrl: DEFAULT_BASE_URL,
573
+ })));
574
+ const skewMs = deps.now() - serverDate.getTime();
575
+ const skewSeconds = Math.round(Math.abs(skewMs) / 1000);
576
+ const clockOk = Math.abs(skewMs) <= CLOCK_SKEW_WARN_MS;
577
+ lines.push(row(clockOk ? STATE_OK : STATE_WARN, msg("label.clock"), clockOk
578
+ ? msg("clock.ok", { skewSeconds })
579
+ : msg("clock.skewWarning", { skewSeconds })));
580
+ }
581
+ else {
582
+ lines.push(row(STATE_WARN, msg("label.connectivity"), proxyUrl && deps.env.NODE_USE_ENV_PROXY !== "1"
583
+ ? msg("connectivity.proxyHint", { baseUrl: DEFAULT_BASE_URL, proxy: proxyUrl })
584
+ : msg("connectivity.unreachable", { baseUrl: DEFAULT_BASE_URL })));
506
585
  }
507
586
  try {
508
587
  await deps.loadNodePty();
@@ -1,48 +1,70 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { execSync } from "node:child_process";
4
3
  import { CHANNEL_DATA_DIR, ensureChannelDataDir } from "../wechat/channel-config.js";
5
4
  const UPDATE_CHECK_FILE = path.join(CHANNEL_DATA_DIR, "update-check.json");
6
5
  const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24小时
7
- /**
8
- * 使用 git 命令获取远程仓库最新版本号
9
- */
10
- export async function fetchLatestVersion() {
6
+ // npm registry 是权威更新源——它是用户实际安装的渠道;GitHub tags 作为回退,
7
+ // 以便在 registry 暂时不可达或包尚未发布时仍能给出最新版本。
8
+ const NPM_PACKAGE_NAME = "cli-wechat-bridge";
9
+ const DEFAULT_NPM_REGISTRY_URL = `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`;
10
+ const DEFAULT_GITHUB_TAGS_URL = "https://api.github.com/repos/UNLINEARITY/CLI-WeChat-Bridge/tags?per_page=20";
11
+ const FETCH_TIMEOUT_MS = 10_000;
12
+ function resolveFetchDeps(options) {
13
+ return {
14
+ fetch: options.fetchImpl ?? globalThis.fetch,
15
+ npmRegistryUrl: options.npmRegistryUrl ?? DEFAULT_NPM_REGISTRY_URL,
16
+ githubTagsUrl: options.githubTagsUrl ?? DEFAULT_GITHUB_TAGS_URL,
17
+ timeoutMs: options.timeoutMs ?? FETCH_TIMEOUT_MS,
18
+ };
19
+ }
20
+ async function fetchJsonWithTimeout(url, deps) {
21
+ const controller = new AbortController();
22
+ const timer = setTimeout(() => controller.abort(), deps.timeoutMs);
11
23
  try {
12
- // 尝试使用 git ls-remote 获取所有 tags
13
- const tagsOutput = execSync('git ls-remote --tags origin', {
14
- cwd: path.resolve(import.meta.dirname, "..", ".."),
15
- encoding: 'utf-8',
16
- stdio: ['ignore', 'pipe', 'ignore']
24
+ const res = await deps.fetch(url, {
25
+ method: "GET",
26
+ signal: controller.signal,
27
+ headers: { Accept: "application/json" },
17
28
  });
18
- if (!tagsOutput) {
19
- return null;
20
- }
21
- // 解析 tags,找到版本号格式(如 0.1.0, 0.2.0 等)
22
- const versionTags = tagsOutput
23
- .split('\n')
24
- .filter(line => {
25
- // 匹配 refs/tags/0.5.0 格式(不是 v0.5.0)
26
- return line.includes('refs/tags/') &&
27
- !line.includes('^{}') &&
28
- /^\w+\s+refs\/tags\/\d+\.\d+\.\d+$/.test(line);
29
- })
30
- .map(line => {
31
- const match = line.match(/refs\/tags\/(\d+\.\d+\.\d+)$/);
32
- return match ? match[1] : null;
33
- })
34
- .filter((v) => v !== null);
35
- if (versionTags.length === 0) {
29
+ if (!res.ok) {
36
30
  return null;
37
31
  }
38
- // 按版本号排序,返回最新的
39
- versionTags.sort((a, b) => compareVersions(b, a));
40
- return versionTags[0] ?? null;
32
+ return await res.json();
41
33
  }
42
- catch (error) {
43
- // 如果 git 命令失败,静默返回 null
34
+ catch {
35
+ // 网络/超时/解析失败一律视为该源不可用,交由调用方回退。
36
+ return null;
37
+ }
38
+ finally {
39
+ clearTimeout(timer);
40
+ }
41
+ }
42
+ async function fetchVersionFromNpm(deps) {
43
+ const data = await fetchJsonWithTimeout(deps.npmRegistryUrl, deps);
44
+ if (!data || typeof data !== "object") {
44
45
  return null;
45
46
  }
47
+ const version = data.version;
48
+ return typeof version === "string" ? parseVersion(version) : null;
49
+ }
50
+ async function fetchVersionFromGithub(deps) {
51
+ const data = await fetchJsonWithTimeout(deps.githubTagsUrl, deps);
52
+ if (!Array.isArray(data)) {
53
+ return null;
54
+ }
55
+ const versions = data
56
+ .map((tag) => parseVersion(tag.name))
57
+ .filter((v) => v !== null)
58
+ .sort((a, b) => compareVersions(b, a));
59
+ return versions[0] ?? null;
60
+ }
61
+ /**
62
+ * 从 npm registry 获取最新版本号(回退到 GitHub tags)。
63
+ * 通过 HTTPS 请求而非本地 git 命令,因此对全局安装的命令(wechat-check-update)同样可用。
64
+ */
65
+ export async function fetchLatestVersion(options = {}) {
66
+ const deps = resolveFetchDeps(options);
67
+ return (await fetchVersionFromNpm(deps)) ?? (await fetchVersionFromGithub(deps));
46
68
  }
47
69
  /**
48
70
  * 从本地 package.json 读取当前版本
@@ -85,6 +107,17 @@ function writeUpdateCache(cache) {
85
107
  // 静默失败,不影响正常使用
86
108
  }
87
109
  }
110
+ const VERSION_PATTERN = /(\d+\.\d+\.\d+)/;
111
+ /**
112
+ * 从任意字符串中提取版本号,兼容带 "v" 前缀的 tag(如 "v1.1.1")。
113
+ */
114
+ export function parseVersion(raw) {
115
+ if (typeof raw !== "string") {
116
+ return null;
117
+ }
118
+ const match = VERSION_PATTERN.exec(raw.trim());
119
+ return match ? (match[1] ?? null) : null;
120
+ }
88
121
  /**
89
122
  * 比较两个版本号
90
123
  * @returns 返回值 > 0 表示 version1 更新,< 0 表示 version2 更新,= 0 表示相等
@@ -146,16 +179,16 @@ export async function checkForUpdate(forceCheck = false) {
146
179
  */
147
180
  export function formatUpdateMessage(versionInfo) {
148
181
  const { current, latest } = versionInfo;
149
- return `
150
- [Update Available] Version ${latest} is available (current: ${current})
151
-
152
- Update instructions:
153
- cd CLI-WeChat-Bridge
154
- git pull
155
- bun install
156
- npm install -g .
157
-
158
- For more information:
159
- https://github.com/UNLINEARITY/CLI-WeChat-Bridge/releases
182
+ return `
183
+ [Update Available] Version ${latest} is available (current: ${current})
184
+
185
+ Update instructions:
186
+ cd CLI-WeChat-Bridge
187
+ git pull
188
+ bun install
189
+ npm install -g .
190
+
191
+ For more information:
192
+ https://github.com/UNLINEARITY/CLI-WeChat-Bridge/releases
160
193
  `;
161
194
  }
@@ -18,6 +18,34 @@ export const SYNC_BUF_FILE = path.join(CHANNEL_DATA_DIR, "sync_buf.txt");
18
18
  export const CONTEXT_CACHE_FILE = path.join(CHANNEL_DATA_DIR, "context_tokens.json");
19
19
  export const BRIDGE_STATE_FILE = path.join(CHANNEL_DATA_DIR, "bridge-state.json");
20
20
  export const BRIDGE_LOG_FILE = path.join(CHANNEL_DATA_DIR, "bridge.log");
21
+ // Hard cap for bridge.log. Long-running daemons otherwise grow it without bound.
22
+ export const BRIDGE_LOG_MAX_BYTES = 5 * 1024 * 1024; // 5 MiB
23
+ /**
24
+ * Append a line to a log file, trimming it down to the tail half whenever it
25
+ * exceeds BRIDGE_LOG_MAX_BYTES. Trimming is best-effort under concurrent
26
+ * writers (a few lines may be lost during a trim) but keeps the file bounded.
27
+ */
28
+ export function appendBoundedLog(filePath, line) {
29
+ try {
30
+ const stat = fs.statSync(filePath);
31
+ if (stat.size > BRIDGE_LOG_MAX_BYTES) {
32
+ const keepSize = Math.floor(BRIDGE_LOG_MAX_BYTES / 2);
33
+ const fd = fs.openSync(filePath, "r");
34
+ try {
35
+ const tail = Buffer.alloc(keepSize);
36
+ const bytesRead = fs.readSync(fd, tail, 0, keepSize, stat.size - keepSize);
37
+ fs.writeFileSync(filePath, tail.subarray(0, bytesRead));
38
+ }
39
+ finally {
40
+ fs.closeSync(fd);
41
+ }
42
+ }
43
+ }
44
+ catch {
45
+ // File missing or unreadable: fall through to a plain append.
46
+ }
47
+ fs.appendFileSync(filePath, line);
48
+ }
21
49
  export const BRIDGE_LOCK_FILE = path.join(CHANNEL_DATA_DIR, "bridge.lock.json");
22
50
  export const DAEMON_ENDPOINT_FILE = path.join(CHANNEL_DATA_DIR, "daemon-endpoint.json");
23
51
  export const CODEX_PANEL_ENDPOINT_FILE = path.join(CHANNEL_DATA_DIR, "codex-panel-endpoint.json");
@@ -306,7 +306,12 @@ export function classifyWechatTransportError(error) {
306
306
  }
307
307
  function writeJsonFile(filePath, value) {
308
308
  ensureChannelDataDir();
309
- fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf-8");
309
+ const data = JSON.stringify(value, null, 2);
310
+ // Atomic write via temp file + rename, so a crash mid-write cannot leave a
311
+ // truncated/half-written JSON that would break readers on the next launch.
312
+ const tempPath = `${filePath}.${process.pid}.tmp`;
313
+ fs.writeFileSync(tempPath, data, "utf-8");
314
+ fs.renameSync(tempPath, filePath);
310
315
  }
311
316
  function randomWechatUin() {
312
317
  const uint32 = crypto.randomBytes(4).readUInt32BE(0);
@@ -445,7 +450,15 @@ async function getUploadUrl(account, params) {
445
450
  token: account.token,
446
451
  timeoutMs: SEND_TIMEOUT_MS,
447
452
  });
448
- return JSON.parse(raw);
453
+ try {
454
+ return JSON.parse(raw);
455
+ }
456
+ catch (error) {
457
+ // A 200-OK but non-JSON body (e.g. an HTML error page, empty body) would
458
+ // otherwise throw an unclassified SyntaxError that bypasses transport error
459
+ // classification/retry. Surface it as a meaningful error instead.
460
+ throw new Error(`getUploadUrl returned a non-JSON response (${raw.length} bytes): ${String(error)}`, { cause: error });
461
+ }
449
462
  }
450
463
  function buildCdnUploadUrl(cdnBaseUrl, uploadParam, filekey) {
451
464
  return `${cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`;
@@ -678,11 +691,25 @@ export function buildInboundMessageClaimPath(messageKey, claimsDir = INBOUND_MES
678
691
  return path.join(claimsDir, fileName);
679
692
  }
680
693
  export function clearInboundMessageClaims(claimsDir = INBOUND_MESSAGE_CLAIMS_DIR) {
694
+ // Delete claim files individually from a directory snapshot rather than
695
+ // `rmSync(claimsDir, { recursive })`. A recursive rmdir can remove a file a
696
+ // concurrent process just created via tryClaimInboundMessage, silently
697
+ // invalidating its claim and letting the same message be double-processed.
698
+ // Per-file removal only deletes files present at snapshot time.
699
+ let entries;
681
700
  try {
682
- fs.rmSync(claimsDir, { recursive: true, force: true });
701
+ entries = fs.readdirSync(claimsDir);
683
702
  }
684
703
  catch {
685
- // Best effort cleanup.
704
+ return; // directory does not exist; nothing to clear
705
+ }
706
+ for (const entry of entries) {
707
+ try {
708
+ fs.rmSync(path.join(claimsDir, entry), { force: true });
709
+ }
710
+ catch {
711
+ // best effort
712
+ }
686
713
  }
687
714
  }
688
715
  export function tryClaimInboundMessage(messageKey, options = {}) {
@@ -719,7 +746,11 @@ export function tryClaimInboundMessage(messageKey, options = {}) {
719
746
  ? error.code
720
747
  : "";
721
748
  if (code !== "EEXIST") {
722
- return true;
749
+ // Real persistence failure (permissions, disk full, missing dir, ...).
750
+ // Returning true here would claim success WITHOUT writing the claim file,
751
+ // letting concurrent processes all think they own this message and
752
+ // double-process it. Fail closed so the message is retried instead.
753
+ return false;
723
754
  }
724
755
  }
725
756
  try {
@@ -849,23 +880,28 @@ export class WeChatTransport {
849
880
  if (!extracted.text && extracted.attachments.length === 0) {
850
881
  continue;
851
882
  }
852
- const messageKey = buildMessageKey(rawMessage);
853
- if (!this.rememberMessage(messageKey)) {
854
- continue;
855
- }
856
- if (!tryClaimInboundMessage(buildScopedMessageClaimKey(account.accountId, messageKey))) {
857
- continue;
858
- }
859
883
  const senderId = rawMessage.from_user_id ?? "unknown";
860
884
  if (rawMessage.context_token) {
861
885
  this.cacheContextToken(senderId, rawMessage.context_token);
862
886
  }
887
+ // Filter pre-start backlog BEFORE claiming/remembering the message so a
888
+ // skipped message is not permanently locked away from other processes.
889
+ // A missing create_time_ms is treated as fresh rather than dropped.
863
890
  const createdAtMs = rawMessage.create_time_ms ?? 0;
864
891
  if (typeof options.minCreatedAtMs === "number" &&
865
- (!Number.isFinite(createdAtMs) || createdAtMs < options.minCreatedAtMs)) {
892
+ Number.isFinite(createdAtMs) &&
893
+ createdAtMs > 0 &&
894
+ createdAtMs < options.minCreatedAtMs) {
866
895
  ignoredBacklogCount += 1;
867
896
  continue;
868
897
  }
898
+ const messageKey = buildMessageKey(rawMessage);
899
+ if (!this.rememberMessage(messageKey)) {
900
+ continue;
901
+ }
902
+ if (!tryClaimInboundMessage(buildScopedMessageClaimKey(account.accountId, messageKey))) {
903
+ continue;
904
+ }
869
905
  const { attachments, failureLines } = await this.downloadInboundAttachments(extracted.attachments, rawMessage);
870
906
  const text = appendInboundAttachmentFailureText(extracted.text, failureLines);
871
907
  messages.push({
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@unlinearity/cli-wechat-bridge",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "CLI WeChat Bridge for Codex, Claude Code, OpenCode, and PowerShell over WeChat",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "bin/",
8
8
  "dist/",
9
+ "scripts/ensure-node-pty-permissions.mjs",
9
10
  "README.md",
10
11
  "LICENSE.txt"
11
12
  ],