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
@@ -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";
@@ -36,7 +36,7 @@ const DAEMON_TAKEOVER_FORCE_STOP_TIMEOUT_MS = 3_000;
36
36
  const DAEMON_TAKEOVER_STOP_POLL_MS = 250;
37
37
  const VISIBLE_CLIENT_CONNECT_TIMEOUT_MS = 15_000;
38
38
  const VISIBLE_CLIENT_CONNECT_POLL_MS = 250;
39
- const DAEMON_ADAPTERS = ["codex", "claude", "opencode"];
39
+ const DAEMON_ADAPTERS = ["codex", "claude", "opencode", "pi"];
40
40
  function log(message) {
41
41
  process.stderr.write(`[wechat-daemon] ${message}\n`);
42
42
  }
@@ -45,7 +45,7 @@ function logError(message) {
45
45
  }
46
46
  function appendDaemonLog(message) {
47
47
  ensureChannelDataDir();
48
- fs.appendFileSync(BRIDGE_LOG_FILE, `[${new Date().toISOString()}] daemon: ${message}\n`, "utf8");
48
+ appendBoundedLog(BRIDGE_LOG_FILE, `[${new Date().toISOString()}] daemon: ${message}\n`);
49
49
  }
50
50
  function computePollRetryDelayMs(consecutiveFailures) {
51
51
  const normalizedFailures = Math.max(1, consecutiveFailures);
@@ -56,7 +56,7 @@ function computeWechatSendRetryDelayMs(attempt) {
56
56
  return WECHAT_SEND_RETRY_BASE_MS * attempt;
57
57
  }
58
58
  function isDaemonAdapterKind(value) {
59
- return value === "codex" || value === "claude" || value === "opencode";
59
+ return value === "codex" || value === "claude" || value === "opencode" || value === "pi";
60
60
  }
61
61
  function isSameWorkspaceCwd(left, right) {
62
62
  const normalizedLeft = path.resolve(left);
@@ -84,10 +84,10 @@ export function parseDaemonCliArgs(argv) {
84
84
  const next = argv[i + 1];
85
85
  if (arg === "--help" || arg === "-h") {
86
86
  process.stdout.write([
87
- "Usage: wechat-daemon [--cwd <path>] [--adapter <codex|claude|opencode>] [--profile <name-or-path>] [--no-open]",
87
+ "Usage: wechat-daemon [--cwd <path>] [--adapter <codex|claude|opencode|pi>] [--profile <name-or-path>] [--no-open]",
88
88
  "",
89
- "Keeps one WeChat connection alive and switches between Codex, Claude Code, and OpenCode from WeChat.",
90
- "Send /codex, /claude, or /opencode in WeChat to switch the active terminal.",
89
+ "Keeps one WeChat connection alive and switches between Codex, Claude Code, OpenCode, and Pi from WeChat.",
90
+ "Send /codex, /claude, /opencode, or /pi in WeChat to switch the active terminal.",
91
91
  "",
92
92
  ].join("\n"));
93
93
  process.exit(0);
@@ -133,12 +133,26 @@ export function parseDaemonSwitchCommand(text) {
133
133
  return "claude";
134
134
  case "/opencode":
135
135
  return "opencode";
136
+ case "/pi":
137
+ return "pi";
136
138
  default:
137
139
  return null;
138
140
  }
139
141
  }
142
+ export function parseDaemonSwitchDirective(text) {
143
+ const match = text
144
+ .trim()
145
+ .match(/^\/(codex|claude|opencode|pi)(?:\s+([\s\S]+))?$/i);
146
+ if (!match) {
147
+ return null;
148
+ }
149
+ return {
150
+ adapter: match[1].toLowerCase(),
151
+ remainder: match[2]?.trim() ?? "",
152
+ };
153
+ }
140
154
  export function defaultDaemonSessionStartMode(adapter) {
141
- return adapter === "claude" || adapter === "opencode" ? "new" : "restore";
155
+ return adapter === "codex" ? "restore" : "new";
142
156
  }
143
157
  export function resolveDaemonSessionStartMode(params) {
144
158
  if (params.reuseExistingVisible && params.visibleConnected) {
@@ -340,6 +354,12 @@ function isVisibleClientAlive(cwd, adapter) {
340
354
  clearLocalCompanionOccupancy(cwd, endpoint.instanceId, { adapter });
341
355
  return false;
342
356
  }
357
+ export function shouldRestartDeadCodexVisibleRuntime(params) {
358
+ return (params.adapter === "codex" &&
359
+ !params.slotCreated &&
360
+ params.hadVisibleClient &&
361
+ !params.visibleConnected);
362
+ }
343
363
  function cleanupVisibleClientLauncher(launch) {
344
364
  if (!launch.pid || !isPidAlive(launch.pid)) {
345
365
  return false;
@@ -370,6 +390,24 @@ export async function waitForVisibleClientConnection(params, deps = {}) {
370
390
  await sleepFn(Math.min(pollMs, remainingMs));
371
391
  }
372
392
  }
393
+ export async function waitForCodexVisibleThread(params, deps = {}) {
394
+ const timeoutMs = params.timeoutMs ?? VISIBLE_CLIENT_CONNECT_TIMEOUT_MS;
395
+ const pollMs = params.pollMs ?? VISIBLE_CLIENT_CONNECT_POLL_MS;
396
+ const sleepFn = deps.sleep ?? sleep;
397
+ const now = deps.now ?? (() => Date.now());
398
+ const deadline = now() + timeoutMs;
399
+ while (true) {
400
+ const threadId = params.getThreadId();
401
+ if (threadId) {
402
+ return threadId;
403
+ }
404
+ const remainingMs = deadline - now();
405
+ if (remainingMs <= 0) {
406
+ return null;
407
+ }
408
+ await sleepFn(Math.min(pollMs, remainingMs));
409
+ }
410
+ }
373
411
  function formatInboundMessagePreview(message) {
374
412
  if (message.text.trim()) {
375
413
  return message.text;
@@ -384,7 +422,7 @@ function formatInboundMessagePreview(message) {
384
422
  function formatNoActiveAdapterMessage() {
385
423
  return [
386
424
  "No active terminal is selected.",
387
- "Send /codex, /claude, or /opencode to choose one.",
425
+ "Send /codex, /claude, /opencode, or /pi to choose one.",
388
426
  ].join("\n");
389
427
  }
390
428
  export function formatDaemonSwitchResultDetail(result) {
@@ -392,6 +430,9 @@ export function formatDaemonSwitchResultDetail(result) {
392
430
  const previous = result.previousActiveAdapter
393
431
  ? ` Active terminal remains ${result.previousActiveAdapter}.`
394
432
  : " No terminal is active yet.";
433
+ if (result.visibleConnected && result.visibleReady === false) {
434
+ return `The visible Codex CLI connected, but its active thread is not ready yet.${previous} Check ${BRIDGE_LOG_FILE}.`;
435
+ }
395
436
  if (result.openedVisible) {
396
437
  return result.created
397
438
  ? `Started the bridge slot and tried to open the visible CLI, but it has not connected yet.${previous} Check ${BRIDGE_LOG_FILE}.`
@@ -444,6 +485,7 @@ class WechatDaemon {
444
485
  slots = new Map();
445
486
  startedAt = new Date().toISOString();
446
487
  bridgeStartedAtMs = Date.now();
488
+ backlogNoticeSent = false;
447
489
  activeAdapter = null;
448
490
  takenOverAdapter;
449
491
  textSendChain = Promise.resolve();
@@ -542,7 +584,7 @@ class WechatDaemon {
542
584
  let consecutivePollFailures = 0;
543
585
  log("WeChat daemon is ready.");
544
586
  log(`Working directory: ${this.cwd}`);
545
- log("Switch from WeChat with /codex, /claude, or /opencode.");
587
+ log("Switch from WeChat with /codex, /claude, /opencode, or /pi.");
546
588
  appendDaemonLog(`started: cwd=${this.cwd}`);
547
589
  const activeSlot = this.getActiveSlot();
548
590
  const welcomeText = t("daemon.welcome", {
@@ -582,6 +624,13 @@ class WechatDaemon {
582
624
  }
583
625
  if (pollResult.ignoredBacklogCount > 0) {
584
626
  appendDaemonLog(`ignored_startup_backlog: count=${pollResult.ignoredBacklogCount}`);
627
+ if (!this.backlogNoticeSent) {
628
+ this.backlogNoticeSent = true;
629
+ await this.queueWechatMessage(this.authorizedUserId, t("bridge.backlogIgnored", {
630
+ count: pollResult.ignoredBacklogCount,
631
+ graceSeconds: Math.round(MESSAGE_START_GRACE_MS / 1000),
632
+ }), "notice");
633
+ }
585
634
  }
586
635
  for (const message of pollResult.messages) {
587
636
  try {
@@ -689,7 +738,24 @@ class WechatDaemon {
689
738
  created = true;
690
739
  }
691
740
  let openedVisible = false;
741
+ const visibleEndpointBeforeProbe = readLocalCompanionEndpoint(this.cwd, {
742
+ adapter,
743
+ });
744
+ const hadVisibleClient = Boolean(visibleEndpointBeforeProbe?.companionPid ||
745
+ visibleEndpointBeforeProbe?.companionConnectedAt ||
746
+ visibleEndpointBeforeProbe?.sharedThreadId ||
747
+ visibleEndpointBeforeProbe?.sharedSessionId);
692
748
  let visibleConnected = isVisibleClientAlive(this.cwd, adapter);
749
+ if (shouldRestartDeadCodexVisibleRuntime({
750
+ adapter,
751
+ slotCreated: created,
752
+ hadVisibleClient,
753
+ visibleConnected,
754
+ })) {
755
+ await this.startFreshSlotSession(slot);
756
+ appendDaemonLog(`dead_visible_codex_runtime_restarted: cwd=${this.cwd}`);
757
+ }
758
+ const sharedSessionBeforeVisible = getSharedSessionIdFromAdapterState(slot.runtime.getState());
693
759
  const sessionStartMode = resolveDaemonSessionStartMode({
694
760
  adapter,
695
761
  explicitSessionStartMode: options.sessionStartMode,
@@ -701,7 +767,7 @@ class WechatDaemon {
701
767
  if (!created &&
702
768
  options.reuseExistingVisible === false &&
703
769
  sessionStartMode === "new" &&
704
- (adapter === "claude" || adapter === "opencode") &&
770
+ (adapter === "claude" || adapter === "opencode" || adapter === "pi") &&
705
771
  visibleConnected) {
706
772
  await this.startFreshSlotSession(slot);
707
773
  appendDaemonLog(`fresh_session_started: adapter=${adapter} source=start_command`);
@@ -732,16 +798,36 @@ class WechatDaemon {
732
798
  appendDaemonLog(`visible_client_connect_timeout: adapter=${adapter} cwd=${this.cwd} timeout_ms=${VISIBLE_CLIENT_CONNECT_TIMEOUT_MS} cleaned_launcher=${cleanedLauncher}`);
733
799
  }
734
800
  }
735
- const activated = options.openVisible === false || visibleConnected;
801
+ let visibleReady = visibleConnected;
802
+ if (adapter === "codex" && visibleConnected && !sharedSessionBeforeVisible) {
803
+ const visibleThreadId = await waitForCodexVisibleThread({
804
+ getThreadId: () => {
805
+ const state = slot.runtime.getState();
806
+ if (state.lastThreadSwitchSource !== "local") {
807
+ return undefined;
808
+ }
809
+ return state.sharedThreadId ?? state.sharedSessionId;
810
+ },
811
+ });
812
+ visibleReady = Boolean(visibleThreadId);
813
+ if (visibleThreadId) {
814
+ appendDaemonLog(`visible_codex_thread_ready: thread=${visibleThreadId} cwd=${this.cwd}`);
815
+ }
816
+ else {
817
+ appendDaemonLog(`visible_codex_thread_timeout: cwd=${this.cwd} timeout_ms=${VISIBLE_CLIENT_CONNECT_TIMEOUT_MS}`);
818
+ }
819
+ }
820
+ const activated = options.openVisible === false || (visibleConnected && visibleReady);
736
821
  if (activated) {
737
822
  this.activeAdapter = adapter;
738
823
  }
739
- appendDaemonLog(`switch_adapter: adapter=${adapter} created=${created} opened_visible=${openedVisible} visible_connected=${visibleConnected} activated=${activated} previous_active=${previousActiveAdapter ?? "(none)"} session_start_mode=${sessionStartMode}`);
824
+ appendDaemonLog(`switch_adapter: adapter=${adapter} created=${created} opened_visible=${openedVisible} visible_connected=${visibleConnected} visible_ready=${visibleReady} activated=${activated} previous_active=${previousActiveAdapter ?? "(none)"} session_start_mode=${sessionStartMode}`);
740
825
  return {
741
826
  activeAdapter: adapter,
742
827
  created,
743
828
  openedVisible,
744
829
  visibleConnected,
830
+ visibleReady,
745
831
  activated,
746
832
  previousActiveAdapter,
747
833
  };
@@ -784,12 +870,12 @@ class WechatDaemon {
784
870
  slot.pendingConfirmations = [];
785
871
  slot.pendingUserInput = null;
786
872
  slot.activeTask = null;
787
- if (slot.adapter === "claude") {
873
+ if (slot.adapter === "codex" || slot.adapter === "claude") {
788
874
  await slot.runtime.reset();
789
875
  }
790
- else if (slot.adapter === "opencode") {
876
+ else if (slot.adapter === "opencode" || slot.adapter === "pi") {
791
877
  if (!slot.runtime.createSession) {
792
- throw new Error("/new is not available in opencode mode.");
878
+ throw new Error(`/new is not available in ${slot.adapter} mode.`);
793
879
  }
794
880
  await slot.runtime.createSession();
795
881
  }
@@ -967,18 +1053,24 @@ class WechatDaemon {
967
1053
  message = { ...message, text: rewritten };
968
1054
  }
969
1055
  }
970
- const switchAdapter = parseDaemonSwitchCommand(message.text);
971
- if (switchAdapter) {
972
- const result = await this.ensureSlot(switchAdapter, {
1056
+ const switchDirective = parseDaemonSwitchDirective(message.text);
1057
+ if (switchDirective) {
1058
+ const result = await this.ensureSlot(switchDirective.adapter, {
973
1059
  openVisible: true,
974
1060
  reuseExistingVisible: true,
975
1061
  });
976
1062
  const detail = formatDaemonSwitchResultDetail(result);
977
- const heading = result.activated
978
- ? `Active terminal: ${switchAdapter}.`
979
- : `Could not activate terminal: ${switchAdapter}.`;
980
- await this.queueWechatMessage(message.senderId, `${heading}\n${detail}`);
981
- return;
1063
+ if (!result.activated) {
1064
+ await this.queueWechatMessage(message.senderId, `Could not activate terminal: ${switchDirective.adapter}.\n${detail}`);
1065
+ return;
1066
+ }
1067
+ if (switchDirective.remainder) {
1068
+ message = { ...message, text: switchDirective.remainder };
1069
+ }
1070
+ else {
1071
+ await this.queueWechatMessage(message.senderId, `Active terminal: ${switchDirective.adapter}.\n${detail}`);
1072
+ return;
1073
+ }
982
1074
  }
983
1075
  if (message.text.trim().toLowerCase() === "/daemon-stop") {
984
1076
  await this.queueWechatMessage(message.senderId, "Stopping wechat-daemon...");
@@ -996,7 +1088,7 @@ class WechatDaemon {
996
1088
  await this.queueWechatMessage(message.senderId, formatBindCommandUsage());
997
1089
  return;
998
1090
  }
999
- const slot = this.getActiveSlot();
1091
+ let slot = this.getActiveSlot();
1000
1092
  if (!slot) {
1001
1093
  await this.queueWechatMessage(message.senderId, formatNoActiveAdapterMessage());
1002
1094
  return;
@@ -1018,6 +1110,15 @@ class WechatDaemon {
1018
1110
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, formatPendingUserInputReminder(slot.pendingUserInput)));
1019
1111
  return;
1020
1112
  }
1113
+ const visibleResult = await this.ensureSlot(slot.adapter, {
1114
+ openVisible: true,
1115
+ reuseExistingVisible: true,
1116
+ });
1117
+ if (!visibleResult.activated) {
1118
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, formatDaemonSwitchResultDetail(visibleResult)));
1119
+ return;
1120
+ }
1121
+ slot = this.getActiveSlot() ?? slot;
1021
1122
  const adapterState = slot.runtime.getState();
1022
1123
  if (adapterState.status === "busy" || adapterState.status === "awaiting_approval") {
1023
1124
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`));
@@ -9,9 +9,10 @@ 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
- "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
- "daemon.noActiveAdapter": "No active terminal is selected. Send /codex, /claude, or /opencode to start one.",
14
+ "daemon.welcome": "WeChat Daemon ready.\nCWD: {cwd}\nActive: {adapter}\n\nCommands: /claude, /codex, /opencode, /pi, /stop, /confirm, /deny, /status\n{bindings}\n\nManage: /bind [emoji] cmd, /unbind [emoji], /bindings",
15
+ "daemon.noActiveAdapter": "No active terminal is selected. Send /codex, /claude, /opencode, or /pi to start one.",
15
16
  "daemon.switchResult.new": "Started a new visible CLI.",
16
17
  "daemon.switchResult.reused": "Reused the existing visible CLI.",
17
18
  // === Approval ===
@@ -55,6 +56,15 @@ export const messages = {
55
56
  "doctor.label.node": "Node.js",
56
57
  "doctor.label.platform": "Platform",
57
58
  "doctor.label.winBuild": "Windows build",
59
+ "doctor.label.codePage": "Console code page",
60
+ "doctor.codePage.nonAsciiWarning": "{codePage} (not UTF-8 and the data directory path contains non-ASCII characters; hook scripts from older versions break in this environment — upgrade to the latest release)",
61
+ "doctor.label.connectivity": "WeChat connectivity",
62
+ "doctor.connectivity.ok": "reachable ({baseUrl})",
63
+ "doctor.connectivity.unreachable": "cannot reach {baseUrl} (check network and firewall)",
64
+ "doctor.connectivity.proxyHint": "cannot reach {baseUrl}. Proxy {proxy} detected, but Node fetch ignores proxy environment variables by default; if your network requires the proxy, set NODE_USE_ENV_PROXY=1 and retry.",
65
+ "doctor.label.clock": "System clock",
66
+ "doctor.clock.ok": "~{skewSeconds}s skew vs server time",
67
+ "doctor.clock.skewWarning": "~{skewSeconds}s skew vs server time (large skew makes the bridge treat fresh messages as pre-start backlog — sync the system clock)",
58
68
  "doctor.label.nodePty": "node-pty",
59
69
  "doctor.label.shell": "shell",
60
70
  "doctor.label.dataDir": "Data dir",
@@ -9,9 +9,10 @@ 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
- "daemon.welcome": "微信 Daemon 就绪。\n工作目录: {cwd}\n当前: {adapter}\n\n命令: /claude, /codex, /opencode, /stop, /confirm, /deny, /status\n{bindings}\n\n管理: /bind [表情] 命令, /unbind [表情], /bindings",
14
- "daemon.noActiveAdapter": "未选择活跃终端。发送 /codex、/claude 或 /opencode 启动一个。",
14
+ "daemon.welcome": "微信 Daemon 就绪。\n工作目录: {cwd}\n当前: {adapter}\n\n命令: /claude, /codex, /opencode, /pi, /stop, /confirm, /deny, /status\n{bindings}\n\n管理: /bind [表情] 命令, /unbind [表情], /bindings",
15
+ "daemon.noActiveAdapter": "未选择活跃终端。发送 /codex、/claude、/opencode 或 /pi 启动一个。",
15
16
  "daemon.switchResult.new": "已启动新的可见 CLI。",
16
17
  "daemon.switchResult.reused": "已复用现有的可见 CLI。",
17
18
  // === 审批 ===
@@ -55,6 +56,15 @@ export const messages = {
55
56
  "doctor.label.node": "Node.js",
56
57
  "doctor.label.platform": "平台",
57
58
  "doctor.label.winBuild": "Windows build",
59
+ "doctor.label.codePage": "控制台代码页",
60
+ "doctor.codePage.nonAsciiWarning": "{codePage}(非 UTF-8,且数据目录路径含非 ASCII 字符;旧版本的 hook 脚本在此环境下会乱码失效,请升级到最新版本)",
61
+ "doctor.label.connectivity": "微信服务可达性",
62
+ "doctor.connectivity.ok": "可达 ({baseUrl})",
63
+ "doctor.connectivity.unreachable": "无法访问 {baseUrl}(检查网络与防火墙)",
64
+ "doctor.connectivity.proxyHint": "无法访问 {baseUrl}。检测到代理 {proxy},但 Node fetch 默认不使用代理环境变量;若必须经代理出网,请设置 NODE_USE_ENV_PROXY=1 后重试。",
65
+ "doctor.label.clock": "系统时钟",
66
+ "doctor.clock.ok": "与服务器时间偏差约 {skewSeconds}s",
67
+ "doctor.clock.skewWarning": "与服务器时间偏差约 {skewSeconds}s(偏差过大会导致桥接启动后的消息被当作积压忽略,请校准系统时间)",
58
68
  "doctor.label.nodePty": "node-pty",
59
69
  "doctor.label.shell": "shell",
60
70
  "doctor.label.dataDir": "数据目录",
@@ -7,12 +7,14 @@ import { normalizeBridgeLockPayload, shouldAutoReclaimBridgeLock, } from "../bri
7
7
  import { readLocalCompanionEndpoint as readWorkspaceEndpoint, } from "../companion/local-companion-link.js";
8
8
  import { isDaemonEndpointAlive, } from "../daemon/daemon-link.js";
9
9
  import { t } from "../i18n/index.js";
10
- import { resolveChannelDataDir } from "../wechat/channel-config.js";
10
+ import { DEFAULT_BASE_URL, resolveChannelDataDir } from "../wechat/channel-config.js";
11
11
  const STATE_OK = "[ok]";
12
12
  const STATE_WARN = "[warn]";
13
13
  const STATE_FAIL = "[fail]";
14
14
  const RUNTIME_ENDPOINT_TIMEOUT_MS = 500;
15
15
  const DAEMON_ENDPOINT_TIMEOUT_MS = 500;
16
+ const SERVER_DATE_TIMEOUT_MS = 3_000;
17
+ const CLOCK_SKEW_WARN_MS = 30_000;
16
18
  function msg(key, params) {
17
19
  return t(`doctor.${key}`, params);
18
20
  }
@@ -58,6 +60,53 @@ function getPlatformPtyFix(platform) {
58
60
  return "npm rebuild node-pty";
59
61
  }
60
62
  }
63
+ function defaultGetWindowsCodePage() {
64
+ try {
65
+ const output = execFileSync("cmd.exe", ["/d", "/c", "chcp"], {
66
+ stdio: ["ignore", "pipe", "ignore"],
67
+ encoding: "utf8",
68
+ windowsHide: true,
69
+ });
70
+ const match = /(\d+)\s*\.?\s*$/.exec(output.trim());
71
+ return match ? Number(match[1]) : null;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ async function defaultFetchServerDate() {
78
+ const controller = new AbortController();
79
+ const timer = setTimeout(() => controller.abort(), SERVER_DATE_TIMEOUT_MS);
80
+ try {
81
+ const res = await fetch(DEFAULT_BASE_URL, {
82
+ method: "GET",
83
+ signal: controller.signal,
84
+ });
85
+ const dateHeader = res.headers.get("date");
86
+ if (!dateHeader) {
87
+ return null;
88
+ }
89
+ const parsed = new Date(dateHeader);
90
+ return Number.isFinite(parsed.getTime()) ? parsed : null;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ finally {
96
+ clearTimeout(timer);
97
+ }
98
+ }
99
+ function containsNonAscii(value) {
100
+ return Array.from(value).some((character) => character.charCodeAt(0) > 0x7f);
101
+ }
102
+ function pickProxyEnvValue(env) {
103
+ return (env.HTTPS_PROXY ??
104
+ env.https_proxy ??
105
+ env.HTTP_PROXY ??
106
+ env.http_proxy ??
107
+ env.ALL_PROXY ??
108
+ env.all_proxy)?.trim() || undefined;
109
+ }
61
110
  function defaultIsProcessAlive(pid) {
62
111
  if (!Number.isInteger(pid) || pid <= 0) {
63
112
  return false;
@@ -100,6 +149,10 @@ function resolveDeps(deps = {}) {
100
149
  arch: deps.arch ?? process.arch,
101
150
  nodeVersion: deps.nodeVersion ?? process.version,
102
151
  osRelease: deps.osRelease ?? os.release,
152
+ env: deps.env ?? process.env,
153
+ now: deps.now ?? Date.now,
154
+ getWindowsCodePage: deps.getWindowsCodePage ?? defaultGetWindowsCodePage,
155
+ fetchServerDate: deps.fetchServerDate ?? defaultFetchServerDate,
103
156
  findExecutable: deps.findExecutable ?? findExecutable,
104
157
  loadNodePty: deps.loadNodePty ??
105
158
  (async () => {
@@ -121,6 +174,7 @@ function isBridgeAdapterKind(value) {
121
174
  return (value === "codex" ||
122
175
  value === "claude" ||
123
176
  value === "opencode" ||
177
+ value === "pi" ||
124
178
  value === "shell");
125
179
  }
126
180
  export function parseDoctorCliArgs(argv, mode = "generic", fallbackCwd = process.cwd()) {
@@ -349,7 +403,7 @@ function chooseEndpointAdapters(options, lock) {
349
403
  return [lock.adapter];
350
404
  }
351
405
  return options.mode === "daemon"
352
- ? ["codex", "claude", "opencode"]
406
+ ? ["codex", "claude", "opencode", "pi"]
353
407
  : ["codex"];
354
408
  }
355
409
  function chooseCliChecks(options) {
@@ -361,6 +415,8 @@ function chooseCliChecks(options) {
361
415
  return [{ name: "claude", label: "Claude CLI", optional: false }];
362
416
  case "opencode":
363
417
  return [{ name: "opencode", label: "OpenCode", optional: false }];
418
+ case "pi":
419
+ return [{ name: "pi", label: "Pi", optional: false }];
364
420
  case "shell":
365
421
  return [];
366
422
  default:
@@ -371,6 +427,7 @@ function chooseCliChecks(options) {
371
427
  { name: "codex", label: "Codex CLI", optional: true },
372
428
  { name: "claude", label: "Claude CLI", optional: true },
373
429
  { name: "opencode", label: "OpenCode", optional: true },
430
+ { name: "pi", label: "Pi", optional: true },
374
431
  ];
375
432
  }
376
433
  function appendCliLines(lines, options, deps) {
@@ -503,6 +560,32 @@ export async function buildDoctorReport(options = {}, rawDeps = {}) {
503
560
  const build = parseInt(release.split(".").pop() ?? "0", 10);
504
561
  const ok = build >= 18309;
505
562
  lines.push(row(ok ? STATE_OK : STATE_FAIL, msg("label.winBuild"), ok ? String(build) : `${build} ${msg("winBuildConpty")}`));
563
+ const codePage = deps.getWindowsCodePage();
564
+ if (codePage !== null) {
565
+ const dataDirHasNonAscii = containsNonAscii(deps.resolveDataDir());
566
+ const risky = codePage !== 65001 && dataDirHasNonAscii;
567
+ lines.push(row(risky ? STATE_WARN : STATE_OK, msg("label.codePage"), risky
568
+ ? msg("codePage.nonAsciiWarning", { codePage })
569
+ : String(codePage)));
570
+ }
571
+ }
572
+ const proxyUrl = pickProxyEnvValue(deps.env);
573
+ const serverDate = await deps.fetchServerDate();
574
+ if (serverDate) {
575
+ lines.push(row(STATE_OK, msg("label.connectivity"), msg("connectivity.ok", {
576
+ baseUrl: DEFAULT_BASE_URL,
577
+ })));
578
+ const skewMs = deps.now() - serverDate.getTime();
579
+ const skewSeconds = Math.round(Math.abs(skewMs) / 1000);
580
+ const clockOk = Math.abs(skewMs) <= CLOCK_SKEW_WARN_MS;
581
+ lines.push(row(clockOk ? STATE_OK : STATE_WARN, msg("label.clock"), clockOk
582
+ ? msg("clock.ok", { skewSeconds })
583
+ : msg("clock.skewWarning", { skewSeconds })));
584
+ }
585
+ else {
586
+ lines.push(row(STATE_WARN, msg("label.connectivity"), proxyUrl && deps.env.NODE_USE_ENV_PROXY !== "1"
587
+ ? msg("connectivity.proxyHint", { baseUrl: DEFAULT_BASE_URL, proxy: proxyUrl })
588
+ : msg("connectivity.unreachable", { baseUrl: DEFAULT_BASE_URL })));
506
589
  }
507
590
  try {
508
591
  await deps.loadNodePty();
@@ -1,48 +1,70 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { execSync } from "node:child_process";
4
3
  import { CHANNEL_DATA_DIR, ensureChannelDataDir } from "../wechat/channel-config.js";
5
4
  const UPDATE_CHECK_FILE = path.join(CHANNEL_DATA_DIR, "update-check.json");
6
5
  const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24小时
7
- /**
8
- * 使用 git 命令获取远程仓库最新版本号
9
- */
10
- export async function fetchLatestVersion() {
6
+ // npm registry 是权威更新源——它是用户实际安装的渠道;GitHub tags 作为回退,
7
+ // 以便在 registry 暂时不可达或包尚未发布时仍能给出最新版本。
8
+ const NPM_PACKAGE_NAME = "cli-wechat-bridge";
9
+ const DEFAULT_NPM_REGISTRY_URL = `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`;
10
+ const DEFAULT_GITHUB_TAGS_URL = "https://api.github.com/repos/UNLINEARITY/CLI-WeChat-Bridge/tags?per_page=20";
11
+ const FETCH_TIMEOUT_MS = 10_000;
12
+ function resolveFetchDeps(options) {
13
+ return {
14
+ fetch: options.fetchImpl ?? globalThis.fetch,
15
+ npmRegistryUrl: options.npmRegistryUrl ?? DEFAULT_NPM_REGISTRY_URL,
16
+ githubTagsUrl: options.githubTagsUrl ?? DEFAULT_GITHUB_TAGS_URL,
17
+ timeoutMs: options.timeoutMs ?? FETCH_TIMEOUT_MS,
18
+ };
19
+ }
20
+ async function fetchJsonWithTimeout(url, deps) {
21
+ const controller = new AbortController();
22
+ const timer = setTimeout(() => controller.abort(), deps.timeoutMs);
11
23
  try {
12
- // 尝试使用 git ls-remote 获取所有 tags
13
- const tagsOutput = execSync('git ls-remote --tags origin', {
14
- cwd: path.resolve(import.meta.dirname, "..", ".."),
15
- encoding: 'utf-8',
16
- stdio: ['ignore', 'pipe', 'ignore']
24
+ const res = await deps.fetch(url, {
25
+ method: "GET",
26
+ signal: controller.signal,
27
+ headers: { Accept: "application/json" },
17
28
  });
18
- if (!tagsOutput) {
19
- return null;
20
- }
21
- // 解析 tags,找到版本号格式(如 0.1.0, 0.2.0 等)
22
- const versionTags = tagsOutput
23
- .split('\n')
24
- .filter(line => {
25
- // 匹配 refs/tags/0.5.0 格式(不是 v0.5.0)
26
- return line.includes('refs/tags/') &&
27
- !line.includes('^{}') &&
28
- /^\w+\s+refs\/tags\/\d+\.\d+\.\d+$/.test(line);
29
- })
30
- .map(line => {
31
- const match = line.match(/refs\/tags\/(\d+\.\d+\.\d+)$/);
32
- return match ? match[1] : null;
33
- })
34
- .filter((v) => v !== null);
35
- if (versionTags.length === 0) {
29
+ if (!res.ok) {
36
30
  return null;
37
31
  }
38
- // 按版本号排序,返回最新的
39
- versionTags.sort((a, b) => compareVersions(b, a));
40
- return versionTags[0] ?? null;
32
+ return await res.json();
41
33
  }
42
- catch (error) {
43
- // 如果 git 命令失败,静默返回 null
34
+ catch {
35
+ // 网络/超时/解析失败一律视为该源不可用,交由调用方回退。
36
+ return null;
37
+ }
38
+ finally {
39
+ clearTimeout(timer);
40
+ }
41
+ }
42
+ async function fetchVersionFromNpm(deps) {
43
+ const data = await fetchJsonWithTimeout(deps.npmRegistryUrl, deps);
44
+ if (!data || typeof data !== "object") {
44
45
  return null;
45
46
  }
47
+ const version = data.version;
48
+ return typeof version === "string" ? parseVersion(version) : null;
49
+ }
50
+ async function fetchVersionFromGithub(deps) {
51
+ const data = await fetchJsonWithTimeout(deps.githubTagsUrl, deps);
52
+ if (!Array.isArray(data)) {
53
+ return null;
54
+ }
55
+ const versions = data
56
+ .map((tag) => parseVersion(tag.name))
57
+ .filter((v) => v !== null)
58
+ .sort((a, b) => compareVersions(b, a));
59
+ return versions[0] ?? null;
60
+ }
61
+ /**
62
+ * 从 npm registry 获取最新版本号(回退到 GitHub tags)。
63
+ * 通过 HTTPS 请求而非本地 git 命令,因此对全局安装的命令(wechat-check-update)同样可用。
64
+ */
65
+ export async function fetchLatestVersion(options = {}) {
66
+ const deps = resolveFetchDeps(options);
67
+ return (await fetchVersionFromNpm(deps)) ?? (await fetchVersionFromGithub(deps));
46
68
  }
47
69
  /**
48
70
  * 从本地 package.json 读取当前版本
@@ -85,6 +107,17 @@ function writeUpdateCache(cache) {
85
107
  // 静默失败,不影响正常使用
86
108
  }
87
109
  }
110
+ const VERSION_PATTERN = /(\d+\.\d+\.\d+)/;
111
+ /**
112
+ * 从任意字符串中提取版本号,兼容带 "v" 前缀的 tag(如 "v1.1.1")。
113
+ */
114
+ export function parseVersion(raw) {
115
+ if (typeof raw !== "string") {
116
+ return null;
117
+ }
118
+ const match = VERSION_PATTERN.exec(raw.trim());
119
+ return match ? (match[1] ?? null) : null;
120
+ }
88
121
  /**
89
122
  * 比较两个版本号
90
123
  * @returns 返回值 > 0 表示 version1 更新,< 0 表示 version2 更新,= 0 表示相等
@@ -146,16 +179,16 @@ export async function checkForUpdate(forceCheck = false) {
146
179
  */
147
180
  export function formatUpdateMessage(versionInfo) {
148
181
  const { current, latest } = versionInfo;
149
- return `
150
- [Update Available] Version ${latest} is available (current: ${current})
151
-
152
- Update instructions:
153
- cd CLI-WeChat-Bridge
154
- git pull
155
- bun install
156
- npm install -g .
157
-
158
- For more information:
159
- https://github.com/UNLINEARITY/CLI-WeChat-Bridge/releases
182
+ return `
183
+ [Update Available] Version ${latest} is available (current: ${current})
184
+
185
+ Update instructions:
186
+ cd CLI-WeChat-Bridge
187
+ git pull
188
+ bun install
189
+ npm install -g .
190
+
191
+ For more information:
192
+ https://github.com/UNLINEARITY/CLI-WeChat-Bridge/releases
160
193
  `;
161
194
  }