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.
Files changed (35) hide show
  1. package/README.md +60 -43
  2. package/bin/_run-entry.mjs +24 -0
  3. package/bin/wechat-bridge-pi.mjs +5 -0
  4. package/bin/wechat-pi-start.mjs +5 -0
  5. package/bin/wechat-pi.mjs +5 -0
  6. package/dist/bridge/bridge-adapter-common.js +2 -0
  7. package/dist/bridge/bridge-adapters.claude.js +18 -0
  8. package/dist/bridge/bridge-adapters.codex.js +83 -2
  9. package/dist/bridge/bridge-adapters.core.js +36 -2
  10. package/dist/bridge/bridge-adapters.js +5 -0
  11. package/dist/bridge/bridge-adapters.opencode.js +278 -32
  12. package/dist/bridge/bridge-adapters.pi.js +697 -0
  13. package/dist/bridge/bridge-adapters.shared.js +44 -46
  14. package/dist/bridge/bridge-final-reply.js +8 -4
  15. package/dist/bridge/bridge-process-reaper.js +58 -3
  16. package/dist/bridge/bridge-state.js +15 -6
  17. package/dist/bridge/bridge-utils.js +129 -30
  18. package/dist/bridge/claude-hook.js +7 -0
  19. package/dist/bridge/claude-hooks.js +10 -2
  20. package/dist/bridge/wechat-bridge.js +20 -8
  21. package/dist/commands/check-update.js +15 -4
  22. package/dist/companion/local-companion-link.js +6 -1
  23. package/dist/companion/local-companion-start.js +29 -8
  24. package/dist/companion/local-companion.js +3 -3
  25. package/dist/companion/pi-tui-bridge-extension.js +256 -0
  26. package/dist/daemon/wechat-daemon.js +126 -25
  27. package/dist/i18n/messages-en.js +12 -2
  28. package/dist/i18n/messages-zh.js +12 -2
  29. package/dist/utils/doctor.js +85 -2
  30. package/dist/utils/version-checker.js +77 -44
  31. package/dist/wechat/channel-config.js +28 -0
  32. package/dist/wechat/setup.js +2 -0
  33. package/dist/wechat/wechat-transport.js +49 -13
  34. package/package.json +120 -100
  35. 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("--enable", "tui_app_server", "--remote", remoteUrl);
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
- if (platform !== "win32") {
692
- return applyLoopbackNoProxy({
693
- ...copyDefinedEnv(sourceEnv),
694
- TERM: sourceEnv.TERM || "xterm-256color",
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
- const keys = [
701
- "PATH",
702
- "PATHEXT",
703
- "ComSpec",
704
- "COMSPEC",
705
- "SystemRoot",
706
- "SYSTEMROOT",
707
- "USERPROFILE",
708
- "HOME",
709
- "APPDATA",
710
- "LOCALAPPDATA",
711
- "TEMP",
712
- "TMP",
713
- "OS",
714
- "ProgramFiles",
715
- "ProgramFiles(x86)",
716
- "CommonProgramFiles",
717
- "CommonProgramFiles(x86)",
718
- "HTTP_PROXY",
719
- "HTTPS_PROXY",
720
- "ALL_PROXY",
721
- "http_proxy",
722
- "https_proxy",
723
- "all_proxy",
724
- "NO_PROXY",
725
- "no_proxy",
726
- ];
727
- for (const key of keys) {
728
- const value = sourceEnv[key];
729
- if (value) {
730
- env[key] = value;
731
- }
732
- }
733
- if (!env.HOME && env.USERPROFILE) {
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" || (kind !== "codex" && kind !== "claude" && kind !== "opencode")) {
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
- const sent = await sender.sendText(visibleText);
10
- if (sent === false) {
11
- return;
9
+ // Send long replies in bounded chunks: a single oversized sendmessage call
10
+ // can be rejected by the WeChat API, silently losing the whole reply.
11
+ for (const chunk of splitWechatTextIntoChunks(visibleText)) {
12
+ const sent = await sender.sendText(chunk);
13
+ if (sent === false) {
14
+ return;
15
+ }
12
16
  }
13
17
  }
14
18
  else if (adapter === "opencode" && parsed.visibleText.trim()) {
@@ -303,10 +303,52 @@ async function waitForProcessExit(pid, timeoutMs) {
303
303
  }
304
304
  return !isPidAlive(pid);
305
305
  }
306
+ /**
307
+ * Collect all descendant PIDs of `rootPid` on POSIX by walking `pgrep -P`.
308
+ * Used so killProcessTreeSync can reach grandchildren even when the child was
309
+ * spawned without `detached` (and therefore shares our process group).
310
+ */
311
+ function collectPosixDescendantsSync(rootPid) {
312
+ const descendants = [];
313
+ const stack = [rootPid];
314
+ while (stack.length > 0) {
315
+ const parent = stack.pop();
316
+ if (parent === undefined) {
317
+ break;
318
+ }
319
+ try {
320
+ const result = spawnSync("pgrep", ["-P", String(parent)], {
321
+ encoding: "utf8",
322
+ windowsHide: true,
323
+ });
324
+ if (result.error || result.status !== 0) {
325
+ continue;
326
+ }
327
+ const stdout = result.stdout ?? "";
328
+ for (const line of stdout.split(/\r?\n/)) {
329
+ const childPid = Number.parseInt(line.trim(), 10);
330
+ if (Number.isInteger(childPid) &&
331
+ childPid > 0 &&
332
+ childPid !== rootPid &&
333
+ !descendants.includes(childPid)) {
334
+ descendants.push(childPid);
335
+ stack.push(childPid);
336
+ }
337
+ }
338
+ }
339
+ catch {
340
+ continue;
341
+ }
342
+ }
343
+ return descendants;
344
+ }
306
345
  /**
307
346
  * Synchronously kill a process and its entire descendant tree.
308
347
  * On Windows uses `taskkill /T /F /PID` to recursively kill all descendants.
309
- * On other platforms uses `process.kill(pid)` directly.
348
+ * On POSIX, first tries the process group (kill -pgid) for group leaders, then
349
+ * walks the descendant tree via pgrep — required because codex/opencode children
350
+ * are spawned without `detached`, so they share our process group and a bare
351
+ * kill(pid) would orphan them on Linux/macOS.
310
352
  */
311
353
  export function killProcessTreeSync(pid) {
312
354
  if (!Number.isInteger(pid) || pid <= 0) {
@@ -327,15 +369,28 @@ export function killProcessTreeSync(pid) {
327
369
  }
328
370
  }
329
371
  else {
372
+ // Best effort: signal the whole group if the child happens to be a leader.
330
373
  try {
331
374
  process.kill(-pid);
332
375
  }
333
- catch {
376
+ catch { /* not a group leader or already gone */ }
377
+ // Walk and kill descendants (leaves first), then the root, so grandchildren
378
+ // are not re-parented to init and left running.
379
+ const descendants = collectPosixDescendantsSync(pid);
380
+ for (let i = descendants.length - 1; i >= 0; i--) {
381
+ const descendantPid = descendants[i];
382
+ if (descendantPid === undefined) {
383
+ continue;
384
+ }
334
385
  try {
335
- process.kill(pid);
386
+ process.kill(descendantPid);
336
387
  }
337
388
  catch { /* best effort */ }
338
389
  }
390
+ try {
391
+ process.kill(pid);
392
+ }
393
+ catch { /* best effort */ }
339
394
  }
340
395
  }
341
396
  export async function reapPeerBridgeProcesses(params = {}) {
@@ -1,5 +1,5 @@
1
1
  import fs from "node:fs";
2
- import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, ensureWorkspaceChannelDir, ensureChannelDataDir, } from "../wechat/channel-config.js";
2
+ import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureWorkspaceChannelDir, ensureChannelDataDir, } from "../wechat/channel-config.js";
3
3
  import { buildInstanceId } from "./bridge-utils.js";
4
4
  const ORPHAN_LOCK_RECLAIM_TIMEOUT_MS = 2_000;
5
5
  const ORPHAN_LOCK_RECLAIM_POLL_MS = 100;
@@ -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
- fs.appendFileSync(BRIDGE_LOG_FILE, `[${new Date().toISOString()}] ${message}\n`, "utf-8");
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
- save() {
297
+ writeAtomicJsonFile(filePath, value) {
297
298
  ensureChannelDataDir();
298
- fs.writeFileSync(this.stateFilePath, JSON.stringify(this.state, null, 2), "utf-8");
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
- fs.writeFileSync(BRIDGE_LOCK_FILE, JSON.stringify(this.lockPayload, null, 2), "utf-8");
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
- fs.writeFileSync(BRIDGE_LOCK_FILE, JSON.stringify(this.lockPayload, null, 2), "utf-8");
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
- export const MESSAGE_START_GRACE_MS = 5_000;
7
+ // Messages older than bridge start minus this grace window are treated as
8
+ // pre-start backlog and skipped. The window must absorb realistic clock skew
9
+ // between the local machine and WeChat server timestamps: with a small value,
10
+ // a PC clock running slightly ahead silently dropped fresh messages.
11
+ export const MESSAGE_START_GRACE_MS = 30_000;
8
12
  const WECHAT_ATTACHMENT_SEND_INTENT_RE = /\b(send|upload|attach|forward|share)\b/i;
9
13
  const WECHAT_ATTACHMENT_SEND_INTENT_ZH_RE = /发送|发给我|发我|给我发|发过来|发一下|发来|发到|发微信|上传|转发|分享|传给我|传我|传到/;
10
14
  const WECHAT_ATTACHMENT_SHORT_SEND_ZH_RE = /^(?:发|发呀|发呢|发吧|直接发|发给我|发我|发微信|发送微信)$/;
@@ -798,15 +802,25 @@ export function formatMirroredUserInputMessage(adapter, text) {
798
802
  ? "Local Claude input"
799
803
  : adapter === "opencode"
800
804
  ? "Local OpenCode input"
801
- : "Local input";
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" ? "Codex" : adapter === "claude" ? "Claude" : adapter === "opencode" ? "OpenCode" : adapter;
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 (path.isAbsolute(normalizedCandidate)) {
957
- return path.normalize(normalizedCandidate);
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" ? "Codex" : adapter === "claude" ? "Claude" : adapter === "opencode" ? "OpenCode" : adapter;
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 separatorIndex = trimmed.indexOf("|");
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 selectedLabel = resolveUserInputOptionLabel(question, selection);
1084
- if (selectedLabel) {
1085
- answers.push(selectedLabel);
1086
- }
1087
- else if (question.isOther) {
1088
- note = note ? `${selection}; ${note}` : selection;
1089
- }
1090
- else {
1091
- return {
1092
- error: `Question "${question.header}" expects an option number or label.`,
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("custom note: allowed");
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("Reply with /answer <option number or exact label>.");
1210
- if (question.isOther) {
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('Add "| your note" after an option answer to include extra context.');
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 "Codex is waiting for user input. Reply with /answer and your response, or use /stop to interrupt.";
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 `Codex is waiting for user input for ${question.header}. Reply with /answer and your response, or use /stop to interrupt.`;
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 `Codex is waiting for answers to ${pending.questions.length} questions. Reply with /answer questionId=value; questionId2=value, or use /stop to interrupt.`;
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
- if (!Number.isFinite(createdAtMs)) {
1310
- return true;
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;