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 +33 -2
- package/bin/_run-entry.mjs +24 -0
- package/dist/bridge/bridge-adapters.claude.js +18 -0
- package/dist/bridge/bridge-adapters.codex.js +25 -0
- package/dist/bridge/bridge-adapters.opencode.js +17 -0
- package/dist/bridge/bridge-adapters.shared.js +38 -44
- package/dist/bridge/bridge-final-reply.js +8 -4
- package/dist/bridge/bridge-process-reaper.js +58 -3
- package/dist/bridge/bridge-state.js +14 -6
- package/dist/bridge/bridge-utils.js +62 -7
- package/dist/bridge/claude-hook.js +7 -0
- package/dist/bridge/claude-hooks.js +10 -2
- package/dist/bridge/wechat-bridge.js +17 -1
- package/dist/commands/check-update.js +15 -4
- package/dist/companion/local-companion-start.js +5 -0
- package/dist/daemon/wechat-daemon.js +15 -2
- package/dist/i18n/messages-en.js +89 -2
- package/dist/i18n/messages-zh.js +89 -2
- package/dist/utils/doctor.js +619 -0
- package/dist/utils/version-checker.js +77 -44
- package/dist/wechat/channel-config.js +28 -0
- package/dist/wechat/wechat-transport.js +49 -13
- package/package.json +3 -1
- package/scripts/ensure-node-pty-permissions.mjs +73 -0
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&style=for-the-badge&logo=github&color=0891b2&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&style=for-the-badge&logo=npm&color=cb3837&labelColor=1c1917"></a>
|
|
10
|
-
<a href="https://www.npmjs.com/package/cli-wechat-bridge"><img alt="npm downloads" src="https://img.shields.io/npm/
|
|
11
|
-
<a href="https://www.npmjs.com/package/@unlinearity/cli-wechat-bridge"><img alt="scoped npm downloads" src="https://img.shields.io/npm/
|
|
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&style=for-the-badge&logo=npm&color=16a34a&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&style=for-the-badge&logo=npm&color=15803d&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
|
全局安装后运行:
|
package/bin/_run-entry.mjs
CHANGED
|
@@ -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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
296
|
+
writeAtomicJsonFile(filePath, value) {
|
|
297
297
|
ensureChannelDataDir();
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
957
|
-
return
|
|
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
|
-
|
|
1310
|
-
|
|
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
|
}
|