@unlinearity/cli-wechat-bridge 1.1.2 → 1.1.4

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 (47) hide show
  1. package/README.md +129 -150
  2. package/bin/_run-entry.mjs +12 -0
  3. package/bin/wechat-claude-start.mjs +7 -2
  4. package/bin/wechat-claude.mjs +1 -1
  5. package/bin/wechat-codex-start.mjs +7 -2
  6. package/bin/wechat-codex.mjs +1 -1
  7. package/bin/wechat-opencode-start.mjs +7 -2
  8. package/bin/wechat-opencode.mjs +1 -1
  9. package/bin/wechat-pi-start.mjs +10 -0
  10. package/bin/wechat-pi.mjs +5 -0
  11. package/dist/bridge/bridge-adapter-common.js +2 -0
  12. package/dist/bridge/bridge-adapters.claude.js +21 -1
  13. package/dist/bridge/bridge-adapters.codex.js +85 -6
  14. package/dist/bridge/bridge-adapters.core.js +112 -6
  15. package/dist/bridge/bridge-adapters.js +5 -3
  16. package/dist/bridge/bridge-adapters.opencode.js +390 -52
  17. package/dist/bridge/bridge-adapters.pi.js +707 -0
  18. package/dist/bridge/bridge-adapters.shared.js +22 -102
  19. package/dist/bridge/bridge-final-reply.js +17 -2
  20. package/dist/bridge/bridge-process-reaper.js +14 -4
  21. package/dist/bridge/bridge-state.js +42 -6
  22. package/dist/bridge/bridge-utils.js +94 -237
  23. package/dist/bridge/wechat-bridge.js +77 -131
  24. package/dist/bridge/wechat-forwarding.js +58 -0
  25. package/dist/companion/codex-remote-client.js +20 -6
  26. package/dist/companion/local-companion-link.js +13 -3
  27. package/dist/companion/local-companion-start.js +64 -16
  28. package/dist/companion/local-companion.js +8 -5
  29. package/dist/companion/pi-tui-bridge-extension.js +256 -0
  30. package/dist/daemon/daemon-link.js +25 -3
  31. package/dist/daemon/wechat-daemon.js +264 -77
  32. package/dist/i18n/index.js +0 -3
  33. package/dist/i18n/messages-en.js +4 -5
  34. package/dist/i18n/messages-zh.js +4 -5
  35. package/dist/utils/atomic-file.js +15 -0
  36. package/dist/utils/doctor.js +27 -11
  37. package/dist/wechat/channel-config.js +0 -2
  38. package/dist/wechat/setup.js +5 -9
  39. package/dist/wechat/wechat-transport.js +14 -8
  40. package/package.json +21 -9
  41. package/scripts/ensure-node-pty-permissions.mjs +73 -0
  42. package/bin/wechat-bridge-claude.mjs +0 -5
  43. package/bin/wechat-bridge-codex.mjs +0 -5
  44. package/bin/wechat-bridge-opencode.mjs +0 -5
  45. package/bin/wechat-bridge-shell.mjs +0 -5
  46. package/bin/wechat-bridge.mjs +0 -5
  47. package/dist/bridge/bridge-adapters.shell.js +0 -363
@@ -32,21 +32,25 @@ export const LOCAL_COMPANION_RECONNECT_GRACE_MS = 15_000;
32
32
  export const CLAUDE_HOOK_LISTEN_HOST = "127.0.0.1";
33
33
  export const CLAUDE_HELP_PROBE_TIMEOUT_MS = 5_000;
34
34
  export const CLAUDE_WECHAT_WORKING_NOTICE_DELAY_MS = 12_000;
35
- export const DEFAULT_UNIX_SHELL_CANDIDATES = ["pwsh", "bash", "zsh", "sh"];
36
- export const POSIX_SHELL_NAMES = new Set(["bash", "zsh", "sh", "dash", "ksh"]);
37
35
  export const CLAUDE_FLAG_SUPPORT_CACHE = new Map();
38
36
  export const OPENCODE_SERVER_HOST = "127.0.0.1";
39
37
  export const OPENCODE_SERVER_READY_TIMEOUT_MS = 10_000;
40
38
  export const OPENCODE_SSE_RECONNECT_DELAY_MS = 2_000;
41
39
  export const OPENCODE_SESSION_IDLE_SETTLE_MS = 1_500;
42
40
  export const OPENCODE_WECHAT_WORKING_NOTICE_DELAY_MS = 12_000;
41
+ // `opencode serve` binds its TCP port before the HTTP layer is ready. The
42
+ // health probe therefore retries within this total budget so the startup
43
+ // race self-heals instead of hanging until undici's ~300s headersTimeout.
44
+ export const OPENCODE_HTTP_READY_TIMEOUT_MS = 15_000;
45
+ export const OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS = 3_000;
46
+ export const OPENCODE_HTTP_READY_PROBE_INTERVAL_MS = 500;
43
47
  export function buildCodexCliArgs(remoteUrl, options = {}) {
44
48
  assertNoReservedExtraCliArgs(options.extraCliArgs ?? [], ["--remote", "--remote-auth-token-env"], "Codex remote connection");
45
49
  const args = [];
46
50
  if (options.resumeThreadId) {
47
51
  args.push("resume", options.resumeThreadId);
48
52
  }
49
- args.push("--enable", "tui_app_server", "--remote", remoteUrl);
53
+ args.push("--remote", remoteUrl);
50
54
  if (options.inlineMode) {
51
55
  args.push("--no-alt-screen");
52
56
  }
@@ -672,44 +676,25 @@ function applyLoopbackNoProxy(env) {
672
676
  env.no_proxy = mergeNoProxyValue(env.no_proxy);
673
677
  return env;
674
678
  }
675
- export function resolveDefaultAdapterCommand(kind, options = {}) {
676
- const platform = options.platform ?? process.platform;
677
- if (kind !== "shell") {
678
- return kind;
679
- }
680
- if (platform === "win32") {
681
- return "powershell.exe";
682
- }
683
- const env = options.env ?? process.env;
684
- for (const candidate of DEFAULT_UNIX_SHELL_CANDIDATES) {
685
- if (resolveCommandPath(candidate, platform, env)) {
686
- return candidate;
687
- }
688
- }
689
- throw new Error(`No default shell executable was found on ${platform}. Tried: ${DEFAULT_UNIX_SHELL_CANDIDATES.join(", ")}. Use --cmd <executable>.`);
679
+ export function resolveDefaultAdapterCommand(kind, _options = {}) {
680
+ return kind;
690
681
  }
691
- export function buildCliEnvironment(kind, options = {}) {
682
+ export function buildCliEnvironment(_kind, options = {}) {
692
683
  const sourceEnv = options.env ?? process.env;
693
684
  const platform = options.platform ?? process.platform;
694
- if (kind === "codex" || kind === "claude" || kind === "opencode") {
695
- // Pass the full environment through on every platform. A previous
696
- // Windows-only allowlist silently dropped user-configured variables such
697
- // as ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / OPENAI_API_KEY, which
698
- // broke CLIs that authenticate via environment variables even though the
699
- // same CLI worked when launched manually from the user's shell.
700
- const env = {
701
- ...copyDefinedEnv(sourceEnv),
702
- TERM: sourceEnv.TERM || "xterm-256color",
703
- };
704
- if (platform === "win32" && !env.HOME && env.USERPROFILE) {
705
- env.HOME = env.USERPROFILE;
706
- }
707
- return applyLoopbackNoProxy(env);
708
- }
709
- return {
685
+ // Pass the full environment through on every platform. A previous
686
+ // Windows-only allowlist silently dropped user-configured variables such
687
+ // as ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / OPENAI_API_KEY, which
688
+ // broke CLIs that authenticate via environment variables even though the
689
+ // same CLI worked when launched manually from the user's shell.
690
+ const env = {
710
691
  ...copyDefinedEnv(sourceEnv),
711
692
  TERM: sourceEnv.TERM || "xterm-256color",
712
693
  };
694
+ if (platform === "win32" && !env.HOME && env.USERPROFILE) {
695
+ env.HOME = env.USERPROFILE;
696
+ }
697
+ return applyLoopbackNoProxy(env);
713
698
  }
714
699
  // ConPTY shipped in Windows 10 build 18309; on older builds node-pty must be
715
700
  // left to auto-select its winpty fallback instead of being forced onto ConPTY.
@@ -732,72 +717,6 @@ export function buildPtySpawnOptions(params) {
732
717
  }
733
718
  return options;
734
719
  }
735
- export function normalizeShellCommandName(command) {
736
- return path.parse(path.basename(command)).name.toLowerCase();
737
- }
738
- export function resolveShellRuntime(command, options = {}) {
739
- const platform = options.platform ?? process.platform;
740
- const name = normalizeShellCommandName(command);
741
- if (name === "powershell" || name === "pwsh") {
742
- return {
743
- family: "powershell",
744
- launchArgs: platform === "win32"
745
- ? ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-NoExit"]
746
- : ["-NoLogo", "-NoProfile", "-NoExit"],
747
- };
748
- }
749
- if (POSIX_SHELL_NAMES.has(name)) {
750
- return {
751
- family: "posix",
752
- launchArgs: ["-i"],
753
- };
754
- }
755
- throw new Error(`Unsupported shell executable for shell adapter: ${command}. Supported shells: powershell, pwsh, bash, zsh, sh, dash, ksh.`);
756
- }
757
- export function escapePowerShellString(text) {
758
- return text.replace(/`/g, "``").replace(/"/g, '`"');
759
- }
760
- export function escapePosixShellString(text) {
761
- return `'${text.replace(/'/g, `'"'"'`)}'`;
762
- }
763
- export function buildShellProfileCommand(profilePath, family) {
764
- const resolved = path.resolve(profilePath);
765
- if (family === "powershell") {
766
- return `. "${escapePowerShellString(resolved)}"`;
767
- }
768
- return `. ${escapePosixShellString(resolved)}`;
769
- }
770
- export function buildShellInputPayload(text, family, completionMarker = "__WECHAT_BRIDGE_DONE__") {
771
- if (family === "powershell") {
772
- const encodedCommand = Buffer.from(text, "utf8").toString("base64");
773
- const script = [
774
- "$__wechatBridgePreviousErrorActionPreference = $ErrorActionPreference",
775
- "$ErrorActionPreference = 'Continue'",
776
- "$global:LASTEXITCODE = 0",
777
- "try {",
778
- ` $decoded = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${escapePowerShellString(encodedCommand)}"))`,
779
- " $scriptBlock = [scriptblock]::Create($decoded)",
780
- " & $scriptBlock",
781
- "} catch {",
782
- " Write-Error $_",
783
- " $global:LASTEXITCODE = 1",
784
- "} finally {",
785
- " if (-not ($global:LASTEXITCODE -is [int])) { $global:LASTEXITCODE = 0 }",
786
- ` Write-Output "${escapePowerShellString(completionMarker)}:$global:LASTEXITCODE"`,
787
- " $ErrorActionPreference = $__wechatBridgePreviousErrorActionPreference",
788
- "}",
789
- "",
790
- ];
791
- return `${script.join("\r")}\r`;
792
- }
793
- const script = [
794
- text,
795
- "__wechat_bridge_status=$?",
796
- `printf '%s:%s\\n' ${escapePosixShellString(completionMarker)} "$__wechat_bridge_status"`,
797
- "",
798
- ];
799
- return `${script.join("\r")}\r`;
800
- }
801
720
  export async function reserveLocalPort() {
802
721
  return await new Promise((resolve, reject) => {
803
722
  const server = net.createServer();
@@ -1170,7 +1089,8 @@ export function resolveSpawnTarget(command, kind, options = {}) {
1170
1089
  return { file: trimmed, args: [...forwardArgs] };
1171
1090
  }
1172
1091
  const resolved = resolveCommandPath(trimmed, platform, env) ?? trimmed;
1173
- if (platform !== "win32" || (kind !== "codex" && kind !== "claude" && kind !== "opencode")) {
1092
+ if (platform !== "win32" ||
1093
+ (kind !== "codex" && kind !== "claude" && kind !== "opencode" && kind !== "pi")) {
1174
1094
  return { file: resolved, args: [...forwardArgs] };
1175
1095
  }
1176
1096
  const bundledExe = kind === "codex" || kind === "claude"
@@ -8,9 +8,24 @@ export async function forwardWechatFinalReply(params) {
8
8
  if (visibleText) {
9
9
  // Send long replies in bounded chunks: a single oversized sendmessage call
10
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);
11
+ const chunks = splitWechatTextIntoChunks(visibleText);
12
+ for (let index = 0; index < chunks.length; index += 1) {
13
+ const sent = await sender.sendText(chunks[index]);
13
14
  if (sent === false) {
15
+ // The send channel is failing (e.g. an expired context token).
16
+ // Report what was dropped instead of ending mid-reply silently;
17
+ // this notice itself is best effort and may also fail.
18
+ const remainingChunks = chunks.length - index - 1;
19
+ if (remainingChunks > 0 || parsed.attachments.length > 0) {
20
+ const parts = [];
21
+ if (remainingChunks > 0) {
22
+ parts.push(`${remainingChunks} reply chunk(s)`);
23
+ }
24
+ if (parsed.attachments.length > 0) {
25
+ parts.push(`${parsed.attachments.length} attachment(s)`);
26
+ }
27
+ await sender.sendText(`[bridge] Reply delivery was interrupted; ${parts.join(" and ")} could not be sent. Send a new message to retry.`);
28
+ }
14
29
  return;
15
30
  }
16
31
  }
@@ -56,9 +56,14 @@ export function isWechatDaemonCommandLineForCwd(commandLine, cwd) {
56
56
  * opencode serve --port 12345 --hostname 127.0.0.1
57
57
  * opencode.exe serve --port 12345
58
58
  * Also matches wrapper invocations (opencode.cmd / opencode.bat) on Windows.
59
+ *
60
+ * `serve` must be the first subcommand token after the executable so that
61
+ * unrelated command lines merely containing both words (e.g.
62
+ * `node opencode-lint/serve.js` or `mytool --theme opencode --mode serve`)
63
+ * are not reaped as orphans.
59
64
  */
60
65
  export function isOpencodeServeCommandLine(commandLine) {
61
- return /\bopencode(?:\.exe|\.cmd|\.bat)?\b.*\bserve\b/i.test(commandLine);
66
+ return /\bopencode(?:\.exe|\.cmd|\.bat)?["']?\s+serve\b/i.test(commandLine);
62
67
  }
63
68
  /**
64
69
  * Detects `opencode attach` command lines spawned by the local OpenCode companion.
@@ -67,7 +72,7 @@ export function isOpencodeServeCommandLine(commandLine) {
67
72
  * opencode.exe attach http://127.0.0.1:12345 --session ses_123
68
73
  */
69
74
  export function isOpencodeAttachCommandLine(commandLine) {
70
- return /\bopencode(?:\.exe|\.cmd|\.bat)?\b.*\battach\b/i.test(commandLine);
75
+ return /\bopencode(?:\.exe|\.cmd|\.bat)?["']?\s+attach\b/i.test(commandLine);
71
76
  }
72
77
  function normalizeBridgeProcessRecord(value) {
73
78
  if (!isRecord(value)) {
@@ -158,7 +163,8 @@ function listWindowsBridgeProcesses(currentPid = process.pid) {
158
163
  "Bypass",
159
164
  "-Command",
160
165
  [
161
- "$ErrorActionPreference='Stop'",
166
+ // Trailing semicolon required — see the note in listAllProcessesRaw.
167
+ "$ErrorActionPreference='Stop';",
162
168
  "Get-CimInstance Win32_Process",
163
169
  "| Where-Object { $_.CommandLine }",
164
170
  "| Select-Object ProcessId,ParentProcessId,Name,CommandLine",
@@ -236,7 +242,11 @@ function listAllProcessesRaw(currentPid = process.pid) {
236
242
  "Bypass",
237
243
  "-Command",
238
244
  [
239
- "$ErrorActionPreference='Stop'",
245
+ // Statement separator required: without the trailing semicolon
246
+ // PowerShell parses the preference assignment and the first command
247
+ // as one statement and fails with a ParserError, silently
248
+ // returning an empty process list on every probe.
249
+ "$ErrorActionPreference='Stop';",
240
250
  "Get-CimInstance Win32_Process",
241
251
  "| Where-Object { $_.CommandLine }",
242
252
  "| Select-Object ProcessId,ParentProcessId,Name,CommandLine",
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
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
+ import { writeJsonFileAtomic } from "../utils/atomic-file.js";
5
+ import { getProcessRecordByPid, isWechatBridgeCommandLine, } from "./bridge-process-reaper.js";
4
6
  const ORPHAN_LOCK_RECLAIM_TIMEOUT_MS = 2_000;
5
7
  const ORPHAN_LOCK_RECLAIM_POLL_MS = 100;
6
8
  export function resolveRestorableSharedSessionId(persisted, options) {
@@ -59,6 +61,7 @@ export function normalizeBridgeLockPayload(value) {
59
61
  const adapter = record.adapter === "codex" ||
60
62
  record.adapter === "claude" ||
61
63
  record.adapter === "opencode" ||
64
+ record.adapter === "pi" ||
62
65
  record.adapter === "shell"
63
66
  ? record.adapter
64
67
  : null;
@@ -136,7 +139,37 @@ export function evaluateBridgeRuntimeOwnership(params) {
136
139
  function buildLockConflictError(lock) {
137
140
  return new Error(`Another bridge instance is already running (pid=${lock.pid}, instanceId=${lock.instanceId}, adapter=${lock.adapter}, cwd=${lock.cwd}, startedAt=${lock.startedAt}, lifecycle=${lock.lifecycle}). Stop it before starting a new bridge.`);
138
141
  }
142
+ /**
143
+ * Check whether the process currently holding a stale lock is still a
144
+ * wechat-bridge process. OS pid reuse can hand the recorded pid to an
145
+ * unrelated process; terminating that pid would kill an innocent process.
146
+ *
147
+ * Returns "bridge" when the command line matches a bridge, "foreign" when the
148
+ * pid is alive but belongs to something else, and "unknown" when the command
149
+ * line could not be resolved (probe failure, restricted permissions).
150
+ */
151
+ export function classifyLockHolderProcess(pid) {
152
+ if (!isPidAlive(pid)) {
153
+ return "unknown";
154
+ }
155
+ const record = getProcessRecordByPid(pid);
156
+ if (!record) {
157
+ return "unknown";
158
+ }
159
+ return isWechatBridgeCommandLine(record.commandLine) ? "bridge" : "foreign";
160
+ }
139
161
  function tryTerminateOrphanedBridge(lock) {
162
+ const holderKind = classifyLockHolderProcess(lock.pid);
163
+ if (holderKind === "foreign") {
164
+ // The lock-holding bridge is gone and its pid was reused by an unrelated
165
+ // process. Reclaim the lock without killing that process.
166
+ return true;
167
+ }
168
+ if (holderKind === "unknown") {
169
+ // Could not confirm the pid still belongs to a bridge. Do not risk
170
+ // killing an innocent process; keep the conflict error instead.
171
+ return false;
172
+ }
140
173
  try {
141
174
  process.kill(lock.pid);
142
175
  }
@@ -294,13 +327,9 @@ export class BridgeStateStore {
294
327
  }
295
328
  }
296
329
  writeAtomicJsonFile(filePath, value) {
297
- ensureChannelDataDir();
298
- const data = JSON.stringify(value, null, 2);
299
330
  // temp + rename so a crash mid-write cannot leave a truncated/half-written
300
331
  // state or lock file that would break the next launch or look like a stale lock.
301
- const tempPath = `${filePath}.${process.pid}.tmp`;
302
- fs.writeFileSync(tempPath, data, "utf-8");
303
- fs.renameSync(tempPath, filePath);
332
+ writeJsonFileAtomic(filePath, value);
304
333
  }
305
334
  save() {
306
335
  this.writeAtomicJsonFile(this.stateFilePath, this.state);
@@ -310,7 +339,14 @@ export class BridgeStateStore {
310
339
  if (existing &&
311
340
  existing.pid !== process.pid &&
312
341
  isPidAlive(existing.pid)) {
313
- if (shouldAutoReclaimBridgeLock(existing)) {
342
+ const holderKind = classifyLockHolderProcess(existing.pid);
343
+ if (holderKind === "foreign") {
344
+ // The recorded bridge exited and the OS reused its pid for an
345
+ // unrelated process. The lock is stale: reclaim it instead of
346
+ // dead-locking new bridges on a phantom holder.
347
+ this.appendLog(`stale_lock_pid_reuse: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
348
+ }
349
+ else if (shouldAutoReclaimBridgeLock(existing)) {
314
350
  this.appendLog(`lock_reclaim_attempt: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
315
351
  if (tryTerminateOrphanedBridge(existing)) {
316
352
  this.appendLog(`lock_reclaimed: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);