cli-wechat-bridge 1.1.1 → 1.1.3
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 +60 -43
- package/bin/_run-entry.mjs +24 -0
- package/bin/wechat-bridge-pi.mjs +5 -0
- package/bin/wechat-pi-start.mjs +5 -0
- package/bin/wechat-pi.mjs +5 -0
- package/dist/bridge/bridge-adapter-common.js +2 -0
- package/dist/bridge/bridge-adapters.claude.js +18 -0
- package/dist/bridge/bridge-adapters.codex.js +83 -2
- package/dist/bridge/bridge-adapters.core.js +36 -2
- package/dist/bridge/bridge-adapters.js +5 -0
- package/dist/bridge/bridge-adapters.opencode.js +278 -32
- package/dist/bridge/bridge-adapters.pi.js +697 -0
- package/dist/bridge/bridge-adapters.shared.js +44 -46
- package/dist/bridge/bridge-final-reply.js +8 -4
- package/dist/bridge/bridge-process-reaper.js +58 -3
- package/dist/bridge/bridge-state.js +15 -6
- package/dist/bridge/bridge-utils.js +129 -30
- package/dist/bridge/claude-hook.js +7 -0
- package/dist/bridge/claude-hooks.js +10 -2
- package/dist/bridge/wechat-bridge.js +20 -8
- package/dist/commands/check-update.js +15 -4
- package/dist/companion/local-companion-link.js +6 -1
- package/dist/companion/local-companion-start.js +29 -8
- package/dist/companion/local-companion.js +3 -3
- package/dist/companion/pi-tui-bridge-extension.js +256 -0
- package/dist/daemon/wechat-daemon.js +126 -25
- package/dist/i18n/messages-en.js +12 -2
- package/dist/i18n/messages-zh.js +12 -2
- package/dist/utils/doctor.js +85 -2
- package/dist/utils/version-checker.js +77 -44
- package/dist/wechat/channel-config.js +28 -0
- package/dist/wechat/setup.js +2 -0
- package/dist/wechat/wechat-transport.js +49 -13
- package/package.json +120 -100
- package/scripts/ensure-node-pty-permissions.mjs +73 -0
|
@@ -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;
|
|
@@ -39,13 +40,19 @@ export const OPENCODE_SERVER_READY_TIMEOUT_MS = 10_000;
|
|
|
39
40
|
export const OPENCODE_SSE_RECONNECT_DELAY_MS = 2_000;
|
|
40
41
|
export const OPENCODE_SESSION_IDLE_SETTLE_MS = 1_500;
|
|
41
42
|
export const OPENCODE_WECHAT_WORKING_NOTICE_DELAY_MS = 12_000;
|
|
43
|
+
// `opencode serve` binds its TCP port before the HTTP layer is ready. The
|
|
44
|
+
// health probe therefore retries within this total budget so the startup
|
|
45
|
+
// race self-heals instead of hanging until undici's ~300s headersTimeout.
|
|
46
|
+
export const OPENCODE_HTTP_READY_TIMEOUT_MS = 15_000;
|
|
47
|
+
export const OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS = 3_000;
|
|
48
|
+
export const OPENCODE_HTTP_READY_PROBE_INTERVAL_MS = 500;
|
|
42
49
|
export function buildCodexCliArgs(remoteUrl, options = {}) {
|
|
43
50
|
assertNoReservedExtraCliArgs(options.extraCliArgs ?? [], ["--remote", "--remote-auth-token-env"], "Codex remote connection");
|
|
44
51
|
const args = [];
|
|
45
52
|
if (options.resumeThreadId) {
|
|
46
53
|
args.push("resume", options.resumeThreadId);
|
|
47
54
|
}
|
|
48
|
-
args.push("--
|
|
55
|
+
args.push("--remote", remoteUrl);
|
|
49
56
|
if (options.inlineMode) {
|
|
50
57
|
args.push("--no-alt-screen");
|
|
51
58
|
}
|
|
@@ -250,7 +257,10 @@ function containsHighRiskPermissionTarget(value) {
|
|
|
250
257
|
collectPermissionPaths(value, paths);
|
|
251
258
|
return paths.some(isHighRiskPermissionPath);
|
|
252
259
|
}
|
|
253
|
-
export function getCodexApprovalAutoResponse(method, params) {
|
|
260
|
+
export function getCodexApprovalAutoResponse(method, params, env = process.env) {
|
|
261
|
+
if (isStrictApprovalModeEnabled(env)) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
254
264
|
if (method === "item/commandExecution/requestApproval") {
|
|
255
265
|
const command = typeof params.command === "string" ? params.command : "";
|
|
256
266
|
if (!commandApprovalAllowsAccept(params.availableDecisions)) {
|
|
@@ -687,50 +697,17 @@ export function resolveDefaultAdapterCommand(kind, options = {}) {
|
|
|
687
697
|
export function buildCliEnvironment(kind, options = {}) {
|
|
688
698
|
const sourceEnv = options.env ?? process.env;
|
|
689
699
|
const platform = options.platform ?? process.platform;
|
|
690
|
-
if (kind === "codex" || kind === "claude" || kind === "opencode") {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
}
|
|
700
|
+
if (kind === "codex" || kind === "claude" || kind === "opencode" || kind === "pi") {
|
|
701
|
+
// Pass the full environment through on every platform. A previous
|
|
702
|
+
// Windows-only allowlist silently dropped user-configured variables such
|
|
703
|
+
// as ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / OPENAI_API_KEY, which
|
|
704
|
+
// broke CLIs that authenticate via environment variables even though the
|
|
705
|
+
// same CLI worked when launched manually from the user's shell.
|
|
697
706
|
const env = {
|
|
707
|
+
...copyDefinedEnv(sourceEnv),
|
|
698
708
|
TERM: sourceEnv.TERM || "xterm-256color",
|
|
699
709
|
};
|
|
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) {
|
|
710
|
+
if (platform === "win32" && !env.HOME && env.USERPROFILE) {
|
|
734
711
|
env.HOME = env.USERPROFILE;
|
|
735
712
|
}
|
|
736
713
|
return applyLoopbackNoProxy(env);
|
|
@@ -740,6 +717,13 @@ export function buildCliEnvironment(kind, options = {}) {
|
|
|
740
717
|
TERM: sourceEnv.TERM || "xterm-256color",
|
|
741
718
|
};
|
|
742
719
|
}
|
|
720
|
+
// ConPTY shipped in Windows 10 build 18309; on older builds node-pty must be
|
|
721
|
+
// left to auto-select its winpty fallback instead of being forced onto ConPTY.
|
|
722
|
+
export const WINDOWS_CONPTY_MIN_BUILD = 18309;
|
|
723
|
+
export function windowsBuildSupportsConpty(osRelease) {
|
|
724
|
+
const build = Number(osRelease.split(".")[2]);
|
|
725
|
+
return Number.isFinite(build) && build >= WINDOWS_CONPTY_MIN_BUILD;
|
|
726
|
+
}
|
|
743
727
|
export function buildPtySpawnOptions(params) {
|
|
744
728
|
const options = {
|
|
745
729
|
name: "xterm-color",
|
|
@@ -748,7 +732,8 @@ export function buildPtySpawnOptions(params) {
|
|
|
748
732
|
cwd: params.cwd,
|
|
749
733
|
env: params.env,
|
|
750
734
|
};
|
|
751
|
-
if ((params.platform ?? process.platform) === "win32"
|
|
735
|
+
if ((params.platform ?? process.platform) === "win32" &&
|
|
736
|
+
windowsBuildSupportsConpty(params.osRelease ?? os.release())) {
|
|
752
737
|
options.useConpty = true;
|
|
753
738
|
}
|
|
754
739
|
return options;
|
|
@@ -1191,7 +1176,8 @@ export function resolveSpawnTarget(command, kind, options = {}) {
|
|
|
1191
1176
|
return { file: trimmed, args: [...forwardArgs] };
|
|
1192
1177
|
}
|
|
1193
1178
|
const resolved = resolveCommandPath(trimmed, platform, env) ?? trimmed;
|
|
1194
|
-
if (platform !== "win32" ||
|
|
1179
|
+
if (platform !== "win32" ||
|
|
1180
|
+
(kind !== "codex" && kind !== "claude" && kind !== "opencode" && kind !== "pi")) {
|
|
1195
1181
|
return { file: resolved, args: [...forwardArgs] };
|
|
1196
1182
|
}
|
|
1197
1183
|
const bundledExe = kind === "codex" || kind === "claude"
|
|
@@ -1246,6 +1232,18 @@ export function spawnFallbackProcess(file, args, options) {
|
|
|
1246
1232
|
listener({ exitCode });
|
|
1247
1233
|
}
|
|
1248
1234
|
});
|
|
1235
|
+
// spawn itself failed (e.g. ENOENT/EACCES) — surface as an exit so callers
|
|
1236
|
+
// stop waiting, instead of throwing an unhandled "error" event that would
|
|
1237
|
+
// crash the bridge on the PTY fallback path.
|
|
1238
|
+
child.on("error", () => {
|
|
1239
|
+
for (const listener of exitListeners) {
|
|
1240
|
+
listener({ exitCode: 1 });
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
// Swallow broken-pipe errors on stdin writes after the child has exited.
|
|
1244
|
+
child.stdin?.on("error", () => {
|
|
1245
|
+
/* best effort */
|
|
1246
|
+
});
|
|
1249
1247
|
return {
|
|
1250
1248
|
pid: child.pid,
|
|
1251
1249
|
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;
|
|
@@ -59,6 +59,7 @@ export function normalizeBridgeLockPayload(value) {
|
|
|
59
59
|
const adapter = record.adapter === "codex" ||
|
|
60
60
|
record.adapter === "claude" ||
|
|
61
61
|
record.adapter === "opencode" ||
|
|
62
|
+
record.adapter === "pi" ||
|
|
62
63
|
record.adapter === "shell"
|
|
63
64
|
? record.adapter
|
|
64
65
|
: null;
|
|
@@ -280,7 +281,7 @@ export class BridgeStateStore {
|
|
|
280
281
|
}
|
|
281
282
|
appendLog(message) {
|
|
282
283
|
ensureChannelDataDir();
|
|
283
|
-
|
|
284
|
+
appendBoundedLog(BRIDGE_LOG_FILE, `[${new Date().toISOString()}] ${message}\n`);
|
|
284
285
|
}
|
|
285
286
|
releaseLock() {
|
|
286
287
|
try {
|
|
@@ -293,9 +294,17 @@ export class BridgeStateStore {
|
|
|
293
294
|
// Best effort cleanup.
|
|
294
295
|
}
|
|
295
296
|
}
|
|
296
|
-
|
|
297
|
+
writeAtomicJsonFile(filePath, value) {
|
|
297
298
|
ensureChannelDataDir();
|
|
298
|
-
|
|
299
|
+
const data = JSON.stringify(value, null, 2);
|
|
300
|
+
// temp + rename so a crash mid-write cannot leave a truncated/half-written
|
|
301
|
+
// state or lock file that would break the next launch or look like a stale lock.
|
|
302
|
+
const tempPath = `${filePath}.${process.pid}.tmp`;
|
|
303
|
+
fs.writeFileSync(tempPath, data, "utf-8");
|
|
304
|
+
fs.renameSync(tempPath, filePath);
|
|
305
|
+
}
|
|
306
|
+
save() {
|
|
307
|
+
this.writeAtomicJsonFile(this.stateFilePath, this.state);
|
|
299
308
|
}
|
|
300
309
|
acquireLock() {
|
|
301
310
|
const existing = readBridgeLockFile();
|
|
@@ -317,7 +326,7 @@ export class BridgeStateStore {
|
|
|
317
326
|
throw buildLockConflictError(existing);
|
|
318
327
|
}
|
|
319
328
|
}
|
|
320
|
-
|
|
329
|
+
this.writeAtomicJsonFile(BRIDGE_LOCK_FILE, this.lockPayload);
|
|
321
330
|
}
|
|
322
331
|
verifyRuntimeOwnership() {
|
|
323
332
|
const workspaceState = this.readWorkspaceStateFile();
|
|
@@ -336,7 +345,7 @@ export class BridgeStateStore {
|
|
|
336
345
|
this.save();
|
|
337
346
|
}
|
|
338
347
|
if (ownership.rehydratedLock) {
|
|
339
|
-
|
|
348
|
+
this.writeAtomicJsonFile(BRIDGE_LOCK_FILE, this.lockPayload);
|
|
340
349
|
}
|
|
341
350
|
return ownership;
|
|
342
351
|
}
|
|
@@ -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 = /^(?:发|发呀|发呢|发吧|直接发|发给我|发我|发微信|发送微信)$/;
|
|
@@ -798,15 +802,25 @@ export function formatMirroredUserInputMessage(adapter, text) {
|
|
|
798
802
|
? "Local Claude input"
|
|
799
803
|
: adapter === "opencode"
|
|
800
804
|
? "Local OpenCode input"
|
|
801
|
-
:
|
|
805
|
+
: adapter === "pi"
|
|
806
|
+
? "Local Pi input"
|
|
807
|
+
: "Local input";
|
|
802
808
|
return `${label}:\n${truncatePreview(text, 500)}`;
|
|
803
809
|
}
|
|
804
810
|
export function formatFinalReplyMessage(adapter, text) {
|
|
805
|
-
if (adapter === "claude" || adapter === "codex" || adapter === "opencode") {
|
|
811
|
+
if (adapter === "claude" || adapter === "codex" || adapter === "opencode" || adapter === "pi") {
|
|
806
812
|
return text;
|
|
807
813
|
}
|
|
808
814
|
// After the early return above, only "shell" remains.
|
|
809
|
-
const label = adapter === "codex"
|
|
815
|
+
const label = adapter === "codex"
|
|
816
|
+
? "Codex"
|
|
817
|
+
: adapter === "claude"
|
|
818
|
+
? "Claude"
|
|
819
|
+
: adapter === "opencode"
|
|
820
|
+
? "OpenCode"
|
|
821
|
+
: adapter === "pi"
|
|
822
|
+
? "Pi"
|
|
823
|
+
: adapter;
|
|
810
824
|
return `${label} final reply:\n${text}`;
|
|
811
825
|
}
|
|
812
826
|
const OPENCODE_WORKING_NOTICE_RE = /^OpenCode is still working on:\s*$/i;
|
|
@@ -948,13 +962,21 @@ function extractInlineWechatAttachments(text) {
|
|
|
948
962
|
attachments,
|
|
949
963
|
};
|
|
950
964
|
}
|
|
965
|
+
// Platform-independent absolute-path check for WeChat attachment candidates.
|
|
966
|
+
// Recognizes Windows drive paths (C:\, C:/) and POSIX root paths (/, \), so an
|
|
967
|
+
// agent-emitted path resolves on any host OS. Node's path.isAbsolute is
|
|
968
|
+
// platform-specific: it rejects "C:\Users\..." on Linux/macOS, which dropped
|
|
969
|
+
// the attachment and failed CI on non-Windows runners.
|
|
970
|
+
function isWechatAttachmentAbsolutePath(candidate) {
|
|
971
|
+
return /^(?:[A-Za-z]:[\\/]|[\\/])/.test(candidate);
|
|
972
|
+
}
|
|
951
973
|
function resolveWechatAttachmentPath(candidatePath) {
|
|
952
974
|
const normalizedCandidate = normalizeWechatAttachmentCandidate(candidatePath);
|
|
953
975
|
if (!normalizedCandidate) {
|
|
954
976
|
return null;
|
|
955
977
|
}
|
|
956
|
-
if (
|
|
957
|
-
return
|
|
978
|
+
if (isWechatAttachmentAbsolutePath(normalizedCandidate)) {
|
|
979
|
+
return normalizedCandidate;
|
|
958
980
|
}
|
|
959
981
|
const homeRelativeMatch = /^(?:~[\\/])?(Desktop|Documents|Downloads|Pictures|Videos|Music)([\\/].+)?$/i.exec(normalizedCandidate);
|
|
960
982
|
if (!homeRelativeMatch) {
|
|
@@ -967,12 +989,15 @@ function resolveWechatAttachmentPath(candidatePath) {
|
|
|
967
989
|
}
|
|
968
990
|
return path.normalize(path.join(os.homedir(), ...relativeSegments));
|
|
969
991
|
}
|
|
992
|
+
// Trim and clean a WeChat attachment candidate path. Intentionally does NOT
|
|
993
|
+
// rewrite separators to path.sep: the path must keep its original separator
|
|
994
|
+
// style (Windows "\" or POSIX "/") so the bridge can resolve agent-emitted
|
|
995
|
+
// paths regardless of host OS (see isWechatAttachmentAbsolutePath).
|
|
970
996
|
function normalizeWechatAttachmentCandidate(candidatePath) {
|
|
971
997
|
return candidatePath
|
|
972
998
|
.trim()
|
|
973
999
|
.replace(/^`|`$/g, "")
|
|
974
|
-
.replace(/\.\s+([A-Za-z0-9]{2,8})(?=$|[?/\s])/g, ".$1")
|
|
975
|
-
.replace(/[\\/]+/g, path.sep);
|
|
1000
|
+
.replace(/\.\s+([A-Za-z0-9]{2,8})(?=$|[?/\s])/g, ".$1");
|
|
976
1001
|
}
|
|
977
1002
|
function inferInlineWechatAttachmentKind(filePath) {
|
|
978
1003
|
const extension = path.extname(filePath).toLowerCase();
|
|
@@ -1001,7 +1026,15 @@ function cleanupVisibleWechatReplyText(text) {
|
|
|
1001
1026
|
.trim();
|
|
1002
1027
|
}
|
|
1003
1028
|
export function formatTaskFailedMessage(adapter, text) {
|
|
1004
|
-
const label = adapter === "codex"
|
|
1029
|
+
const label = adapter === "codex"
|
|
1030
|
+
? "Codex"
|
|
1031
|
+
: adapter === "claude"
|
|
1032
|
+
? "Claude"
|
|
1033
|
+
: adapter === "opencode"
|
|
1034
|
+
? "OpenCode"
|
|
1035
|
+
: adapter === "pi"
|
|
1036
|
+
? "Pi"
|
|
1037
|
+
: adapter;
|
|
1005
1038
|
return `${label} task failed:\n${text}`;
|
|
1006
1039
|
}
|
|
1007
1040
|
export function formatApprovalMessage(pending, adapterState) {
|
|
@@ -1075,22 +1108,35 @@ function parseSingleUserInputAnswer(question, rawValue) {
|
|
|
1075
1108
|
answers: [trimmed],
|
|
1076
1109
|
};
|
|
1077
1110
|
}
|
|
1078
|
-
const
|
|
1111
|
+
const customAnswerMode = question.customAnswerMode ?? "note";
|
|
1112
|
+
const separatorIndex = customAnswerMode === "note" ? trimmed.indexOf("|") : -1;
|
|
1079
1113
|
const selection = separatorIndex >= 0 ? trimmed.slice(0, separatorIndex).trim() : trimmed;
|
|
1080
1114
|
let note = separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1).trim() : "";
|
|
1081
1115
|
const answers = [];
|
|
1082
1116
|
if (selection) {
|
|
1083
|
-
const
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1117
|
+
const selections = question.multiple
|
|
1118
|
+
? selection.split(",").map((value) => value.trim()).filter(Boolean)
|
|
1119
|
+
: [selection];
|
|
1120
|
+
for (const value of selections) {
|
|
1121
|
+
const selectedLabel = resolveUserInputOptionLabel(question, value);
|
|
1122
|
+
if (selectedLabel) {
|
|
1123
|
+
if (!answers.includes(selectedLabel)) {
|
|
1124
|
+
answers.push(selectedLabel);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
else if (question.isOther && customAnswerMode === "value") {
|
|
1128
|
+
if (!answers.includes(value)) {
|
|
1129
|
+
answers.push(value);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
else if (question.isOther) {
|
|
1133
|
+
note = note ? `${value}; ${note}` : value;
|
|
1134
|
+
}
|
|
1135
|
+
else {
|
|
1136
|
+
return {
|
|
1137
|
+
error: `Question "${question.header}" expects an option number or label.`,
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1094
1140
|
}
|
|
1095
1141
|
}
|
|
1096
1142
|
if (note) {
|
|
@@ -1195,8 +1241,13 @@ export function formatUserInputRequestMessage(pending, adapterState) {
|
|
|
1195
1241
|
lines.push(` ${optionIndex + 1}. ${option.label} - ${truncatePreview(option.description, 160)}`);
|
|
1196
1242
|
});
|
|
1197
1243
|
}
|
|
1244
|
+
if (question.multiple) {
|
|
1245
|
+
lines.push("multiple selections: allowed (separate values with commas)");
|
|
1246
|
+
}
|
|
1198
1247
|
if (question.isOther) {
|
|
1199
|
-
lines.push(
|
|
1248
|
+
lines.push(question.customAnswerMode === "value"
|
|
1249
|
+
? "custom answer: allowed"
|
|
1250
|
+
: "custom note: allowed");
|
|
1200
1251
|
}
|
|
1201
1252
|
});
|
|
1202
1253
|
lines.push("");
|
|
@@ -1206,8 +1257,13 @@ export function formatUserInputRequestMessage(pending, adapterState) {
|
|
|
1206
1257
|
return lines.join("\n");
|
|
1207
1258
|
}
|
|
1208
1259
|
if (question.options?.length) {
|
|
1209
|
-
lines.push(
|
|
1210
|
-
|
|
1260
|
+
lines.push(question.multiple
|
|
1261
|
+
? "Reply with /answer <option numbers or exact labels separated by commas>."
|
|
1262
|
+
: "Reply with /answer <option number or exact label>.");
|
|
1263
|
+
if (question.isOther && question.customAnswerMode === "value") {
|
|
1264
|
+
lines.push("You may enter a custom answer instead of an option.");
|
|
1265
|
+
}
|
|
1266
|
+
else if (question.isOther) {
|
|
1211
1267
|
lines.push('Add "| your note" to include extra context.');
|
|
1212
1268
|
}
|
|
1213
1269
|
}
|
|
@@ -1219,7 +1275,10 @@ export function formatUserInputRequestMessage(pending, adapterState) {
|
|
|
1219
1275
|
lines.push("Reply with /answer questionId=value; questionId2=value.");
|
|
1220
1276
|
lines.push("You can use question numbers instead of ids.");
|
|
1221
1277
|
lines.push("For option questions, value can be the option number or exact label.");
|
|
1222
|
-
lines.push(
|
|
1278
|
+
lines.push("For multi-select questions, separate option values with commas.");
|
|
1279
|
+
if (pending.questions.some((question) => question.customAnswerMode !== "value")) {
|
|
1280
|
+
lines.push('Add "| your note" after an option answer to include extra context.');
|
|
1281
|
+
}
|
|
1223
1282
|
}
|
|
1224
1283
|
lines.push("Use /stop to interrupt the active turn instead.");
|
|
1225
1284
|
return lines.join("\n");
|
|
@@ -1228,11 +1287,11 @@ export function formatPendingUserInputReminder(pending) {
|
|
|
1228
1287
|
if (pending.questions.length === 1) {
|
|
1229
1288
|
const question = pending.questions[0];
|
|
1230
1289
|
if (!question) {
|
|
1231
|
-
return "
|
|
1290
|
+
return "The active CLI is waiting for user input. Reply with /answer and your response, or use /stop to interrupt.";
|
|
1232
1291
|
}
|
|
1233
|
-
return `
|
|
1292
|
+
return `The active CLI is waiting for user input for ${question.header}. Reply with /answer and your response, or use /stop to interrupt.`;
|
|
1234
1293
|
}
|
|
1235
|
-
return `
|
|
1294
|
+
return `The active CLI is waiting for answers to ${pending.questions.length} questions. Reply with /answer questionId=value; questionId2=value, or use /stop to interrupt.`;
|
|
1236
1295
|
}
|
|
1237
1296
|
export class OutputBatcher {
|
|
1238
1297
|
onFlush;
|
|
@@ -1305,9 +1364,49 @@ export class OutputBatcher {
|
|
|
1305
1364
|
.catch(() => undefined);
|
|
1306
1365
|
}
|
|
1307
1366
|
}
|
|
1367
|
+
// When CLI_BRIDGE_STRICT_APPROVAL is enabled, no permission request is
|
|
1368
|
+
// auto-approved: everything is forwarded to WeChat for an explicit
|
|
1369
|
+
// /confirm or /deny decision.
|
|
1370
|
+
export function isStrictApprovalModeEnabled(env = process.env) {
|
|
1371
|
+
const value = env.CLI_BRIDGE_STRICT_APPROVAL?.trim().toLowerCase();
|
|
1372
|
+
return value === "1" || value === "true" || value === "yes" || value === "on";
|
|
1373
|
+
}
|
|
1374
|
+
// Outbound WeChat text size cap shared by the streaming OutputBatcher and the
|
|
1375
|
+
// final-reply forwarder. Long replies sent as a single sendmessage call can be
|
|
1376
|
+
// rejected by the server, which previously made long final replies vanish.
|
|
1377
|
+
export const WECHAT_TEXT_CHUNK_MAX_CHARS = 1_200;
|
|
1378
|
+
export function splitWechatTextIntoChunks(text, maxChars = WECHAT_TEXT_CHUNK_MAX_CHARS) {
|
|
1379
|
+
const normalized = text.trim();
|
|
1380
|
+
if (!normalized) {
|
|
1381
|
+
return [];
|
|
1382
|
+
}
|
|
1383
|
+
if (normalized.length <= maxChars) {
|
|
1384
|
+
return [normalized];
|
|
1385
|
+
}
|
|
1386
|
+
const chunks = [];
|
|
1387
|
+
let remaining = normalized;
|
|
1388
|
+
while (remaining.length > maxChars) {
|
|
1389
|
+
// Prefer breaking at a newline reasonably close to the cap so paragraphs
|
|
1390
|
+
// stay intact; fall back to a hard split.
|
|
1391
|
+
const window = remaining.slice(0, maxChars + 1);
|
|
1392
|
+
const newlineIndex = window.lastIndexOf("\n");
|
|
1393
|
+
const splitIndex = newlineIndex > maxChars / 2 ? newlineIndex : maxChars;
|
|
1394
|
+
const chunk = remaining.slice(0, splitIndex).trim();
|
|
1395
|
+
if (chunk) {
|
|
1396
|
+
chunks.push(chunk);
|
|
1397
|
+
}
|
|
1398
|
+
remaining = remaining.slice(splitIndex).trim();
|
|
1399
|
+
}
|
|
1400
|
+
if (remaining) {
|
|
1401
|
+
chunks.push(remaining);
|
|
1402
|
+
}
|
|
1403
|
+
return chunks;
|
|
1404
|
+
}
|
|
1308
1405
|
export function shouldDropStartupBacklogMessage(createdAtMs, bridgeStartedAtMs, graceMs = MESSAGE_START_GRACE_MS) {
|
|
1309
|
-
|
|
1310
|
-
|
|
1406
|
+
// A missing or unparsable timestamp must not drop the message: treat it as
|
|
1407
|
+
// fresh and let normal processing continue.
|
|
1408
|
+
if (!Number.isFinite(createdAtMs) || createdAtMs <= 0) {
|
|
1409
|
+
return false;
|
|
1311
1410
|
}
|
|
1312
1411
|
return createdAtMs < bridgeStartedAtMs - graceMs;
|
|
1313
1412
|
}
|
|
@@ -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;
|