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 +3 -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 +34 -43
- 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 +12 -1
- package/dist/commands/check-update.js +15 -4
- package/dist/companion/local-companion-start.js +5 -0
- package/dist/daemon/wechat-daemon.js +10 -2
- package/dist/i18n/messages-en.js +10 -0
- package/dist/i18n/messages-zh.js +10 -0
- package/dist/utils/doctor.js +80 -1
- 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),同时把本地输出、审批请求与运行状态同步回微信。
|
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) {
|
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
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.
|
|
11
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 {
|
package/dist/i18n/messages-en.js
CHANGED
|
@@ -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",
|
package/dist/i18n/messages-zh.js
CHANGED
|
@@ -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": "数据目录",
|
package/dist/utils/doctor.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 (!
|
|
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
|
|
43
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
701
|
+
entries = fs.readdirSync(claimsDir);
|
|
683
702
|
}
|
|
684
703
|
catch {
|
|
685
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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": "cli-wechat-bridge",
|
|
3
|
-
"version": "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
|
],
|
|
@@ -48,6 +49,7 @@
|
|
|
48
49
|
"wechat-check-update": "bin/wechat-check-update.mjs"
|
|
49
50
|
},
|
|
50
51
|
"scripts": {
|
|
52
|
+
"postinstall": "node scripts/ensure-node-pty-permissions.mjs",
|
|
51
53
|
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
52
54
|
"build": "npm run clean && tsc -p tsconfig.build.json",
|
|
53
55
|
"prepack": "npm run build",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Ensure the bundled node-pty `spawn-helper` is executable.
|
|
4
|
+
//
|
|
5
|
+
// node-pty ships its prebuilt `spawn-helper` binaries with mode 0644 (no exec
|
|
6
|
+
// bit) in the published tarball. On macOS/Linux that makes `posix_spawnp`
|
|
7
|
+
// fail when node-pty tries to fork a PTY, so the bridge silently drops to the
|
|
8
|
+
// non-PTY fallback. The Claude adapter relies on PTY interactive mode, so the
|
|
9
|
+
// fallback surfaces to users as:
|
|
10
|
+
//
|
|
11
|
+
// Error: Input must be provided ... when using --print
|
|
12
|
+
//
|
|
13
|
+
// Restoring the exec bit after install fixes PTY spawning. This runs on
|
|
14
|
+
// postinstall and is idempotent; it never fails the install.
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { createRequire } from "node:module";
|
|
19
|
+
|
|
20
|
+
if (process.platform === "win32") {
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
|
|
26
|
+
let nodePtyDir;
|
|
27
|
+
try {
|
|
28
|
+
nodePtyDir = path.dirname(require.resolve("node-pty/package.json"));
|
|
29
|
+
} catch {
|
|
30
|
+
// node-pty is optional in unsupported environments; nothing to fix.
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const candidates = [
|
|
35
|
+
"prebuilds/darwin-arm64/spawn-helper",
|
|
36
|
+
"prebuilds/darwin-x64/spawn-helper",
|
|
37
|
+
"prebuilds/linux-x64/spawn-helper",
|
|
38
|
+
"prebuilds/linux-arm64/spawn-helper",
|
|
39
|
+
"build/Release/spawn-helper",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
let fixed = 0;
|
|
43
|
+
for (const relative of candidates) {
|
|
44
|
+
const helperPath = path.join(nodePtyDir, relative);
|
|
45
|
+
let stats;
|
|
46
|
+
try {
|
|
47
|
+
stats = fs.statSync(helperPath);
|
|
48
|
+
} catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!stats.isFile()) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if ((stats.mode & 0o111) !== 0) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
fs.chmodSync(helperPath, stats.mode | 0o755);
|
|
59
|
+
fixed += 1;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.warn(
|
|
62
|
+
`[cli-wechat-bridge] Could not make node-pty spawn-helper executable: ${helperPath}\n` +
|
|
63
|
+
` ${error instanceof Error ? error.message : String(error)}\n` +
|
|
64
|
+
" PTY mode may be unavailable; run --doctor for details.",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (fixed > 0) {
|
|
70
|
+
console.log(
|
|
71
|
+
`[cli-wechat-bridge] Restored executable bit on ${fixed} node-pty spawn-helper binary(ies).`,
|
|
72
|
+
);
|
|
73
|
+
}
|