cli-wechat-bridge 1.1.3 → 1.1.5

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 (50) hide show
  1. package/README.md +110 -140
  2. package/bin/wechat-claude.mjs +1 -1
  3. package/bin/wechat-codex.mjs +1 -1
  4. package/bin/wechat-opencode.mjs +1 -1
  5. package/bin/wechat-pi.mjs +1 -1
  6. package/dist/bridge/bridge-adapters.claude.js +448 -11
  7. package/dist/bridge/bridge-adapters.codex.js +262 -11
  8. package/dist/bridge/bridge-adapters.core.js +76 -4
  9. package/dist/bridge/bridge-adapters.js +0 -3
  10. package/dist/bridge/bridge-adapters.opencode.js +497 -49
  11. package/dist/bridge/bridge-adapters.pi.js +44 -8
  12. package/dist/bridge/bridge-adapters.shared.js +13 -100
  13. package/dist/bridge/bridge-controller.js +3 -0
  14. package/dist/bridge/bridge-final-reply.js +17 -2
  15. package/dist/bridge/bridge-process-reaper.js +14 -4
  16. package/dist/bridge/bridge-session-resume.js +151 -0
  17. package/dist/bridge/bridge-state.js +41 -6
  18. package/dist/bridge/bridge-utils.js +63 -229
  19. package/dist/bridge/claude-hooks.js +1 -0
  20. package/dist/bridge/wechat-bridge.js +93 -140
  21. package/dist/bridge/wechat-forwarding.js +58 -0
  22. package/dist/companion/codex-remote-client.js +309 -36
  23. package/dist/companion/codex-visible-client-link.js +122 -0
  24. package/dist/companion/local-companion-link.js +33 -2
  25. package/dist/companion/local-companion-start.js +44 -13
  26. package/dist/companion/local-companion.js +6 -3
  27. package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
  28. package/dist/companion/pi-tui-bridge-extension.js +42 -3
  29. package/dist/daemon/daemon-link.js +25 -3
  30. package/dist/daemon/wechat-daemon.js +168 -56
  31. package/dist/i18n/index.js +0 -3
  32. package/dist/i18n/messages-en.js +2 -3
  33. package/dist/i18n/messages-zh.js +2 -3
  34. package/dist/utils/atomic-file.js +15 -0
  35. package/dist/utils/doctor.js +23 -11
  36. package/dist/wechat/channel-config.js +0 -2
  37. package/dist/wechat/setup.js +5 -11
  38. package/dist/wechat/wechat-transport.js +14 -8
  39. package/package.json +2 -13
  40. package/bin/wechat-bridge-claude.mjs +0 -5
  41. package/bin/wechat-bridge-codex.mjs +0 -5
  42. package/bin/wechat-bridge-opencode.mjs +0 -5
  43. package/bin/wechat-bridge-pi.mjs +0 -5
  44. package/bin/wechat-bridge-shell.mjs +0 -5
  45. package/bin/wechat-bridge.mjs +0 -5
  46. package/bin/wechat-claude-start.mjs +0 -5
  47. package/bin/wechat-codex-start.mjs +0 -5
  48. package/bin/wechat-opencode-start.mjs +0 -5
  49. package/bin/wechat-pi-start.mjs +0 -5
  50. package/dist/bridge/bridge-adapters.shell.js +0 -363
@@ -319,9 +319,17 @@ export class PiTuiAdapter {
319
319
  if (!sessionPath) {
320
320
  throw new Error(`Pi session not found for this workspace: ${sessionId}`);
321
321
  }
322
+ const verifiedSession = summarizePiSessionFile(sessionPath, this.options.cwd);
323
+ if (!verifiedSession || verifiedSession.sessionId !== sessionId) {
324
+ throw new Error(`Pi session changed or became unavailable before it could be resumed: ${sessionId}`);
325
+ }
322
326
  this.pendingSessionSwitch = { source: "wechat", reason: "wechat_resume" };
323
327
  try {
324
- const response = await this.sendCommand("switch_session", { sessionPath });
328
+ const response = await this.sendCommand("switch_session", {
329
+ sessionPath,
330
+ sessionId,
331
+ cwd: this.options.cwd,
332
+ });
325
333
  this.applySessionState(response.data, "wechat", "wechat_resume", true);
326
334
  if (isRecord(response.data) && response.data.cancelled === true) {
327
335
  throw new Error("Pi session switch was cancelled by an extension.");
@@ -482,6 +490,14 @@ export class PiTuiAdapter {
482
490
  switch (frame.type) {
483
491
  case "session_state": {
484
492
  const pending = this.pendingSessionSwitch;
493
+ const nextSessionId = typeof frame.sessionId === "string" ? frame.sessionId : undefined;
494
+ const isLocalSessionSwitch = !pending &&
495
+ Boolean(nextSessionId) &&
496
+ Boolean(this.state.sharedSessionId) &&
497
+ nextSessionId !== this.state.sharedSessionId;
498
+ if (isLocalSessionSwitch) {
499
+ this.prepareForLocalSessionSwitch();
500
+ }
485
501
  this.applySessionState(frame, pending?.source ?? "local", pending?.reason ?? "local_follow", Boolean(this.state.sharedSessionId));
486
502
  this.setStatus(this.state.status);
487
503
  break;
@@ -520,6 +536,19 @@ export class PiTuiAdapter {
520
536
  timestamp: nowIso(),
521
537
  });
522
538
  }
539
+ prepareForLocalSessionSwitch() {
540
+ if (this.state.activeTurnOrigin === "wechat") {
541
+ this.emit({
542
+ type: "task_failed",
543
+ message: "The active WeChat task was interrupted because the local Pi terminal switched sessions.",
544
+ timestamp: nowIso(),
545
+ });
546
+ }
547
+ this.currentAssistantText = "";
548
+ this.currentTurnError = "";
549
+ this.clearActiveTurn();
550
+ this.setStatus("idle");
551
+ }
523
552
  beginTurn(origin) {
524
553
  this.currentAssistantText = "";
525
554
  this.currentTurnError = "";
@@ -533,14 +562,11 @@ export class PiTuiAdapter {
533
562
  return;
534
563
  }
535
564
  this.finalizingTurn = true;
536
- const origin = this.state.activeTurnOrigin;
537
565
  try {
538
566
  if (this.currentTurnError) {
539
- if (origin === "wechat") {
540
- this.emit({ type: "task_failed", message: this.currentTurnError, timestamp: nowIso() });
541
- }
567
+ this.emit({ type: "task_failed", message: this.currentTurnError, timestamp: nowIso() });
542
568
  }
543
- else if (origin === "wechat" && this.currentAssistantText) {
569
+ else if (this.currentAssistantText) {
544
570
  this.emit({ type: "final_reply", text: this.currentAssistantText, timestamp: nowIso() });
545
571
  }
546
572
  this.state.lastOutputAt = nowIso();
@@ -636,6 +662,12 @@ export class PiTuiAdapter {
636
662
  this.reconnectTimer = setTimeout(() => {
637
663
  this.reconnectTimer = null;
638
664
  if (!this.socket && !this.disposing) {
665
+ // Reset the turn state alongside the fatal error: leaving status at
666
+ // "busy" would keep rejecting every message with "still working"
667
+ // while /stop stays unavailable (no socket) and /reset is the only
668
+ // way out.
669
+ this.clearActiveTurn();
670
+ this.setStatus("idle", "The Pi TUI extension disconnected; the bridge is waiting for a reconnect.");
639
671
  this.emit({
640
672
  type: "fatal_error",
641
673
  message: "The native Pi TUI bridge extension disconnected and did not reconnect.",
@@ -680,9 +712,13 @@ export class PiTuiAdapter {
680
712
  this.extensionReady = false;
681
713
  this.clearActiveTurn();
682
714
  if (!this.disposing) {
715
+ // Report unexpected exits as fatal errors, matching the other adapters:
716
+ // a bare "stopped" status is only logged by the bridge, so WeChat users
717
+ // would get no signal that the TUI is gone until their next message
718
+ // fails with a cryptic not-connected error.
683
719
  this.emit({
684
- type: "status",
685
- status: "stopped",
720
+ type: "fatal_error",
721
+ message: `Pi TUI exited unexpectedly (code=${code ?? "none"}, signal=${signal ?? "none"}). Restart the bridge or use /reset to recover.`,
686
722
  timestamp: nowIso(),
687
723
  });
688
724
  }
@@ -32,8 +32,6 @@ 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;
@@ -678,44 +676,25 @@ function applyLoopbackNoProxy(env) {
678
676
  env.no_proxy = mergeNoProxyValue(env.no_proxy);
679
677
  return env;
680
678
  }
681
- export function resolveDefaultAdapterCommand(kind, options = {}) {
682
- const platform = options.platform ?? process.platform;
683
- if (kind !== "shell") {
684
- return kind;
685
- }
686
- if (platform === "win32") {
687
- return "powershell.exe";
688
- }
689
- const env = options.env ?? process.env;
690
- for (const candidate of DEFAULT_UNIX_SHELL_CANDIDATES) {
691
- if (resolveCommandPath(candidate, platform, env)) {
692
- return candidate;
693
- }
694
- }
695
- 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;
696
681
  }
697
- export function buildCliEnvironment(kind, options = {}) {
682
+ export function buildCliEnvironment(_kind, options = {}) {
698
683
  const sourceEnv = options.env ?? process.env;
699
684
  const platform = options.platform ?? process.platform;
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.
706
- const env = {
707
- ...copyDefinedEnv(sourceEnv),
708
- TERM: sourceEnv.TERM || "xterm-256color",
709
- };
710
- if (platform === "win32" && !env.HOME && env.USERPROFILE) {
711
- env.HOME = env.USERPROFILE;
712
- }
713
- return applyLoopbackNoProxy(env);
714
- }
715
- 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 = {
716
691
  ...copyDefinedEnv(sourceEnv),
717
692
  TERM: sourceEnv.TERM || "xterm-256color",
718
693
  };
694
+ if (platform === "win32" && !env.HOME && env.USERPROFILE) {
695
+ env.HOME = env.USERPROFILE;
696
+ }
697
+ return applyLoopbackNoProxy(env);
719
698
  }
720
699
  // ConPTY shipped in Windows 10 build 18309; on older builds node-pty must be
721
700
  // left to auto-select its winpty fallback instead of being forced onto ConPTY.
@@ -738,72 +717,6 @@ export function buildPtySpawnOptions(params) {
738
717
  }
739
718
  return options;
740
719
  }
741
- export function normalizeShellCommandName(command) {
742
- return path.parse(path.basename(command)).name.toLowerCase();
743
- }
744
- export function resolveShellRuntime(command, options = {}) {
745
- const platform = options.platform ?? process.platform;
746
- const name = normalizeShellCommandName(command);
747
- if (name === "powershell" || name === "pwsh") {
748
- return {
749
- family: "powershell",
750
- launchArgs: platform === "win32"
751
- ? ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-NoExit"]
752
- : ["-NoLogo", "-NoProfile", "-NoExit"],
753
- };
754
- }
755
- if (POSIX_SHELL_NAMES.has(name)) {
756
- return {
757
- family: "posix",
758
- launchArgs: ["-i"],
759
- };
760
- }
761
- throw new Error(`Unsupported shell executable for shell adapter: ${command}. Supported shells: powershell, pwsh, bash, zsh, sh, dash, ksh.`);
762
- }
763
- export function escapePowerShellString(text) {
764
- return text.replace(/`/g, "``").replace(/"/g, '`"');
765
- }
766
- export function escapePosixShellString(text) {
767
- return `'${text.replace(/'/g, `'"'"'`)}'`;
768
- }
769
- export function buildShellProfileCommand(profilePath, family) {
770
- const resolved = path.resolve(profilePath);
771
- if (family === "powershell") {
772
- return `. "${escapePowerShellString(resolved)}"`;
773
- }
774
- return `. ${escapePosixShellString(resolved)}`;
775
- }
776
- export function buildShellInputPayload(text, family, completionMarker = "__WECHAT_BRIDGE_DONE__") {
777
- if (family === "powershell") {
778
- const encodedCommand = Buffer.from(text, "utf8").toString("base64");
779
- const script = [
780
- "$__wechatBridgePreviousErrorActionPreference = $ErrorActionPreference",
781
- "$ErrorActionPreference = 'Continue'",
782
- "$global:LASTEXITCODE = 0",
783
- "try {",
784
- ` $decoded = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${escapePowerShellString(encodedCommand)}"))`,
785
- " $scriptBlock = [scriptblock]::Create($decoded)",
786
- " & $scriptBlock",
787
- "} catch {",
788
- " Write-Error $_",
789
- " $global:LASTEXITCODE = 1",
790
- "} finally {",
791
- " if (-not ($global:LASTEXITCODE -is [int])) { $global:LASTEXITCODE = 0 }",
792
- ` Write-Output "${escapePowerShellString(completionMarker)}:$global:LASTEXITCODE"`,
793
- " $ErrorActionPreference = $__wechatBridgePreviousErrorActionPreference",
794
- "}",
795
- "",
796
- ];
797
- return `${script.join("\r")}\r`;
798
- }
799
- const script = [
800
- text,
801
- "__wechat_bridge_status=$?",
802
- `printf '%s:%s\\n' ${escapePosixShellString(completionMarker)} "$__wechat_bridge_status"`,
803
- "",
804
- ];
805
- return `${script.join("\r")}\r`;
806
- }
807
720
  export async function reserveLocalPort() {
808
721
  return await new Promise((resolve, reject) => {
809
722
  const server = net.createServer();
@@ -29,6 +29,9 @@ export class BridgeController {
29
29
  companionStatus: adapterState.status,
30
30
  companionLastStateAt: new Date().toISOString(),
31
31
  companionWorkerPid: adapterState.pid,
32
+ codexControlPort: endpoint.codexControlPort ?? existing.codexControlPort,
33
+ codexControlToken: endpoint.codexControlToken ?? existing.codexControlToken,
34
+ codexVisibleThreadId: endpoint.codexVisibleThreadId ?? existing.codexVisibleThreadId,
32
35
  }
33
36
  : {
34
37
  ...endpoint,
@@ -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",
@@ -0,0 +1,151 @@
1
+ import { formatResumeSessionList, formatSessionSwitchMessage, } from "./bridge-utils.js";
2
+ export const RESUME_SESSION_LIST_LIMIT = 8;
3
+ export const RESUME_SESSION_SNAPSHOT_TTL_MS = 5 * 60 * 1_000;
4
+ const RESUME_SESSION_PREFIX_SCAN_LIMIT = 100;
5
+ function describeError(error) {
6
+ return error instanceof Error ? error.message : String(error);
7
+ }
8
+ function adapterDisplayName(adapter) {
9
+ switch (adapter) {
10
+ case "codex":
11
+ return "Codex";
12
+ case "claude":
13
+ return "Claude";
14
+ case "opencode":
15
+ return "OpenCode";
16
+ case "pi":
17
+ return "Pi";
18
+ }
19
+ }
20
+ function adapterSessionNoun(adapter) {
21
+ return adapter === "codex" ? "thread" : "session";
22
+ }
23
+ export function isWechatResumeEnabled(adapter) {
24
+ return (adapter === "codex" ||
25
+ adapter === "claude" ||
26
+ adapter === "opencode" ||
27
+ adapter === "pi");
28
+ }
29
+ export function shouldForwardSessionSwitchEvent(reason) {
30
+ return reason !== "wechat_resume";
31
+ }
32
+ export class ResumeSessionCoordinator {
33
+ adapter;
34
+ runtime;
35
+ now;
36
+ snapshot = null;
37
+ constructor(options) {
38
+ this.adapter = options.adapter;
39
+ this.runtime = options.runtime;
40
+ this.now = options.now ?? Date.now;
41
+ }
42
+ clear() {
43
+ this.snapshot = null;
44
+ }
45
+ async execute(target) {
46
+ const normalizedTarget = target?.trim() ?? "";
47
+ if (!normalizedTarget) {
48
+ const candidates = await this.listCandidates(RESUME_SESSION_LIST_LIMIT);
49
+ this.snapshot = {
50
+ createdAtMs: this.now(),
51
+ candidates,
52
+ };
53
+ return {
54
+ kind: "list",
55
+ message: formatResumeSessionList({
56
+ adapter: this.adapter,
57
+ candidates,
58
+ currentSessionId: this.runtime.getState().sharedSessionId ??
59
+ this.runtime.getState().sharedThreadId,
60
+ }),
61
+ };
62
+ }
63
+ const candidate = await this.resolveCandidate(normalizedTarget);
64
+ const currentSessionId = this.runtime.getState().sharedSessionId ??
65
+ this.runtime.getState().sharedThreadId;
66
+ if (candidate.sessionId === currentSessionId) {
67
+ return {
68
+ kind: "already_active",
69
+ sessionId: candidate.sessionId,
70
+ message: `${adapterDisplayName(this.adapter)} ${adapterSessionNoun(this.adapter)} ${candidate.sessionId.slice(0, 12)} is already active.`,
71
+ };
72
+ }
73
+ await this.runtime.resumeSession(candidate.sessionId);
74
+ return {
75
+ kind: "resumed",
76
+ sessionId: candidate.sessionId,
77
+ message: formatSessionSwitchMessage({
78
+ adapter: this.adapter,
79
+ sessionId: candidate.sessionId,
80
+ source: "wechat",
81
+ reason: "wechat_resume",
82
+ }),
83
+ };
84
+ }
85
+ async resolveCandidate(target) {
86
+ if (/^\d+$/.test(target)) {
87
+ const snapshot = this.getCurrentSnapshot();
88
+ if (!snapshot) {
89
+ throw new Error("The recent session list has expired. Send /resume again before choosing a number.");
90
+ }
91
+ const index = Number(target) - 1;
92
+ const candidate = snapshot.candidates[index];
93
+ if (!candidate) {
94
+ throw new Error(`Resume selection ${target} is outside the displayed range. Send /resume again to refresh the list.`);
95
+ }
96
+ return candidate;
97
+ }
98
+ const snapshot = this.getCurrentSnapshot();
99
+ const snapshotMatch = this.findIdMatch(snapshot?.candidates ?? [], target);
100
+ if (snapshotMatch) {
101
+ return snapshotMatch;
102
+ }
103
+ const candidates = await this.listCandidates(RESUME_SESSION_PREFIX_SCAN_LIMIT);
104
+ const candidate = this.findIdMatch(candidates, target);
105
+ if (candidate) {
106
+ return candidate;
107
+ }
108
+ // A complete ID may be older than the recent candidate window. The
109
+ // adapter remains responsible for validating its existence and cwd.
110
+ return {
111
+ sessionId: target,
112
+ title: target,
113
+ lastUpdatedAt: new Date(0).toISOString(),
114
+ source: this.adapter,
115
+ };
116
+ }
117
+ findIdMatch(candidates, target) {
118
+ const normalizedTarget = target.toLowerCase();
119
+ const exact = candidates.find((candidate) => candidate.sessionId.toLowerCase() === normalizedTarget);
120
+ if (exact) {
121
+ return exact;
122
+ }
123
+ const prefixMatches = candidates.filter((candidate) => candidate.sessionId.toLowerCase().startsWith(normalizedTarget));
124
+ if (prefixMatches.length > 1) {
125
+ throw new Error(`Session ID prefix ${target} is ambiguous. Send /resume again and use a longer ID prefix.`);
126
+ }
127
+ return prefixMatches[0] ?? null;
128
+ }
129
+ getCurrentSnapshot() {
130
+ if (!this.snapshot) {
131
+ return null;
132
+ }
133
+ if (this.now() - this.snapshot.createdAtMs > RESUME_SESSION_SNAPSHOT_TTL_MS) {
134
+ this.snapshot = null;
135
+ return null;
136
+ }
137
+ return this.snapshot;
138
+ }
139
+ async listCandidates(limit) {
140
+ try {
141
+ return await this.runtime.listResumeSessions(limit);
142
+ }
143
+ catch (error) {
144
+ const message = describeError(error);
145
+ if (/^Failed to list\s/i.test(message)) {
146
+ throw error;
147
+ }
148
+ throw new Error(`Failed to list ${adapterDisplayName(this.adapter)} ${adapterSessionNoun(this.adapter)}s: ${message}`, { cause: error });
149
+ }
150
+ }
151
+ }
@@ -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) {
@@ -137,7 +139,37 @@ export function evaluateBridgeRuntimeOwnership(params) {
137
139
  function buildLockConflictError(lock) {
138
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.`);
139
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
+ }
140
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
+ }
141
173
  try {
142
174
  process.kill(lock.pid);
143
175
  }
@@ -295,13 +327,9 @@ export class BridgeStateStore {
295
327
  }
296
328
  }
297
329
  writeAtomicJsonFile(filePath, value) {
298
- ensureChannelDataDir();
299
- const data = JSON.stringify(value, null, 2);
300
330
  // temp + rename so a crash mid-write cannot leave a truncated/half-written
301
331
  // 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);
332
+ writeJsonFileAtomic(filePath, value);
305
333
  }
306
334
  save() {
307
335
  this.writeAtomicJsonFile(this.stateFilePath, this.state);
@@ -311,7 +339,14 @@ export class BridgeStateStore {
311
339
  if (existing &&
312
340
  existing.pid !== process.pid &&
313
341
  isPidAlive(existing.pid)) {
314
- 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)) {
315
350
  this.appendLog(`lock_reclaim_attempt: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
316
351
  if (tryTerminateOrphanedBridge(existing)) {
317
352
  this.appendLog(`lock_reclaimed: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);