@unlinearity/cli-wechat-bridge 1.1.3 → 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 (46) hide show
  1. package/README.md +98 -135
  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 +7 -2
  10. package/bin/wechat-pi.mjs +1 -1
  11. package/dist/bridge/bridge-adapters.claude.js +21 -1
  12. package/dist/bridge/bridge-adapters.codex.js +27 -4
  13. package/dist/bridge/bridge-adapters.core.js +76 -4
  14. package/dist/bridge/bridge-adapters.js +0 -3
  15. package/dist/bridge/bridge-adapters.opencode.js +130 -21
  16. package/dist/bridge/bridge-adapters.pi.js +12 -2
  17. package/dist/bridge/bridge-adapters.shared.js +13 -100
  18. package/dist/bridge/bridge-final-reply.js +17 -2
  19. package/dist/bridge/bridge-process-reaper.js +14 -4
  20. package/dist/bridge/bridge-state.js +41 -6
  21. package/dist/bridge/bridge-utils.js +37 -224
  22. package/dist/bridge/wechat-bridge.js +75 -130
  23. package/dist/bridge/wechat-forwarding.js +58 -0
  24. package/dist/companion/codex-remote-client.js +20 -6
  25. package/dist/companion/local-companion-link.js +7 -2
  26. package/dist/companion/local-companion-start.js +45 -13
  27. package/dist/companion/local-companion.js +6 -3
  28. package/dist/daemon/daemon-link.js +25 -3
  29. package/dist/daemon/wechat-daemon.js +148 -54
  30. package/dist/i18n/index.js +0 -3
  31. package/dist/i18n/messages-en.js +2 -3
  32. package/dist/i18n/messages-zh.js +2 -3
  33. package/dist/utils/atomic-file.js +15 -0
  34. package/dist/utils/doctor.js +23 -11
  35. package/dist/wechat/channel-config.js +0 -2
  36. package/dist/wechat/setup.js +5 -11
  37. package/dist/wechat/wechat-transport.js +14 -8
  38. package/package.json +5 -8
  39. package/scripts/ensure-node-pty-permissions.mjs +73 -0
  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/dist/bridge/bridge-adapters.shell.js +0 -363
@@ -1,4 +1,5 @@
1
1
  import { spawn as spawnChildProcess } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { OPENCODE_SERVER_HOST, OPENCODE_SERVER_READY_TIMEOUT_MS, OPENCODE_SSE_RECONNECT_DELAY_MS, OPENCODE_SESSION_IDLE_SETTLE_MS, OPENCODE_WECHAT_WORKING_NOTICE_DELAY_MS, OPENCODE_HTTP_READY_TIMEOUT_MS, OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS, OPENCODE_HTTP_READY_PROBE_INTERVAL_MS, buildCliEnvironment, isRecord, describeUnknownError, resolveSpawnTarget, reserveLocalPort, waitForTcpPort, delay, } from "./bridge-adapters.shared.js";
3
4
  import { killProcessTreeSync } from "./bridge-process-reaper.js";
4
5
  import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, buildOneTimeCode, isWechatOutboundAttachmentMutationTool, isWechatOutboundAttachmentWriteCommand, normalizeOutput, nowIso, truncatePreview, OutputBatcher, } from "./bridge-utils.js";
@@ -6,6 +7,9 @@ const OPENCODE_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.WECHAT_OPEN
6
7
  const OPENCODE_DUPLICATE_EVENT_TTL_MS = 150;
7
8
  const OPENCODE_WECHAT_MIRROR_SUPPRESSION_TTL_MS = 30_000;
8
9
  const OPENCODE_RECENT_LOCAL_PROMPT_TTL_MS = 10_000;
10
+ // How long a selectSession echo suppression stays valid before it is treated
11
+ // as lost and cleared.
12
+ const TUI_SESSION_SELECT_SUPPRESSION_TTL_MS = 5_000;
9
13
  const OPENCODE_LOCAL_SESSION_CREATE_FOLLOW_TTL_MS = 5_000;
10
14
  const OPENCODE_TUI_SELECT_RETRY_DELAYS_MS = [250, 750, 1_500];
11
15
  const OPENCODE_MINIMUM_SUPPORTED_VERSION = "1.18.0";
@@ -20,6 +24,11 @@ export class OpenCodeServerAdapter {
20
24
  serverProcess = null;
21
25
  nativeProcess = null;
22
26
  serverPort = 0;
27
+ // Random per-instance password handed to `opencode serve` via
28
+ // OPENCODE_SERVER_PASSWORD. Without it the server accepts unauthenticated
29
+ // connections from any local process; the attach client inherits the env
30
+ // automatically, and the SDK/health calls send the matching Basic header.
31
+ serverPassword = null;
23
32
  client = null;
24
33
  sseAbortController = null;
25
34
  sseLoopPromise = null;
@@ -51,7 +60,7 @@ export class OpenCodeServerAdapter {
51
60
  pendingWechatPromptMirrorSuppressions = [];
52
61
  recentWechatPromptMirrorSuppressions = [];
53
62
  recentSdkEventObservations = new Map();
54
- suppressedTuiSessionSelectId = null;
63
+ suppressedTuiSessionSelect = null;
55
64
  lastMirroredLocalPrompt = null;
56
65
  pendingLocalSessionCreateFollowUntilMs = 0;
57
66
  pendingPermission = null;
@@ -159,12 +168,50 @@ export class OpenCodeServerAdapter {
159
168
  }
160
169
  }
161
170
  async listResumeSessions(limit = 10) {
162
- void limit;
163
- throw new Error('WeChat /resume is disabled in opencode mode. Use /session directly inside "wechat-opencode"; WeChat will follow the active local session.');
171
+ if (!this.client) {
172
+ return [];
173
+ }
174
+ let sessions;
175
+ try {
176
+ sessions = this.unwrapOrThrow(await this.client.session.list()) ?? [];
177
+ }
178
+ catch {
179
+ return [];
180
+ }
181
+ return sessions
182
+ .filter((session) => this.isCurrentDirectorySession(session))
183
+ .slice(0, limit)
184
+ .map((session) => {
185
+ const rawTime = typeof session.time?.updated === "number"
186
+ ? session.time.updated
187
+ : typeof session.time?.created === "number"
188
+ ? session.time.created
189
+ : 0;
190
+ const updatedMs = rawTime > 0 && rawTime < 1_000_000_000_000 ? rawTime * 1_000 : rawTime;
191
+ return {
192
+ sessionId: session.id,
193
+ title: session.title?.trim() || "untitled",
194
+ lastUpdatedAt: new Date(updatedMs > 0 ? updatedMs : Date.now()).toISOString(),
195
+ source: "opencode",
196
+ };
197
+ });
164
198
  }
165
199
  async resumeSession(sessionId) {
166
- void sessionId;
167
- throw new Error('WeChat /resume is disabled in opencode mode. Use /session directly inside "wechat-opencode"; WeChat will follow the active local session.');
200
+ if (!this.client) {
201
+ throw new Error("OpenCode is not running yet.");
202
+ }
203
+ const session = await this.getSessionForCurrentDirectory(sessionId, []);
204
+ if (!session) {
205
+ throw new Error(`No OpenCode session ${sessionId} was found for ${this.options.cwd}.`);
206
+ }
207
+ this.switchSharedSession(session, {
208
+ source: "wechat",
209
+ reason: "wechat_resume",
210
+ notify: true,
211
+ clearTrackedTurn: true,
212
+ syncVisible: true,
213
+ forceVisibleSync: true,
214
+ });
168
215
  }
169
216
  async createSession() {
170
217
  if (!this.client) {
@@ -360,7 +407,7 @@ export class OpenCodeServerAdapter {
360
407
  this.client = null;
361
408
  this.activeSessionId = null;
362
409
  this.activeWorkspaceId = null;
363
- this.suppressedTuiSessionSelectId = null;
410
+ this.suppressedTuiSessionSelect = null;
364
411
  this.state.status = "stopped";
365
412
  this.state.pid = undefined;
366
413
  this.state.startedAt = undefined;
@@ -368,6 +415,8 @@ export class OpenCodeServerAdapter {
368
415
  /* ---- Server management ---- */
369
416
  async startServerProcess() {
370
417
  const env = buildCliEnvironment(this.options.kind);
418
+ this.serverPassword = randomBytes(24).toString("hex");
419
+ env.OPENCODE_SERVER_PASSWORD = this.serverPassword;
371
420
  const serverArgs = [
372
421
  "serve",
373
422
  "--port",
@@ -477,6 +526,9 @@ export class OpenCodeServerAdapter {
477
526
  }
478
527
  buildNativeClientEnv() {
479
528
  const env = buildCliEnvironment(this.options.kind);
529
+ if (this.serverPassword) {
530
+ env.OPENCODE_SERVER_PASSWORD = this.serverPassword;
531
+ }
480
532
  if (this.activeSessionId) {
481
533
  env.OPENCODE_ROUTE = JSON.stringify({
482
534
  type: "session",
@@ -485,6 +537,13 @@ export class OpenCodeServerAdapter {
485
537
  }
486
538
  return env;
487
539
  }
540
+ buildServerAuthHeaders() {
541
+ if (!this.serverPassword) {
542
+ return {};
543
+ }
544
+ const credentials = Buffer.from(`opencode:${this.serverPassword}`, "utf8").toString("base64");
545
+ return { Authorization: `Basic ${credentials}` };
546
+ }
488
547
  async createSdkClient() {
489
548
  try {
490
549
  const { createOpencodeClient } = await import("@opencode-ai/sdk/v2");
@@ -492,6 +551,7 @@ export class OpenCodeServerAdapter {
492
551
  baseUrl: `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`,
493
552
  directory: this.options.cwd,
494
553
  experimental_workspaceID: this.activeWorkspaceId ?? undefined,
554
+ headers: this.buildServerAuthHeaders(),
495
555
  });
496
556
  }
497
557
  catch (err) {
@@ -511,6 +571,7 @@ export class OpenCodeServerAdapter {
511
571
  while (Date.now() < deadline) {
512
572
  try {
513
573
  const response = await fetch(`${baseUrl}/global/health`, {
574
+ headers: this.buildServerAuthHeaders(),
514
575
  signal: AbortSignal.timeout(OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS),
515
576
  });
516
577
  if (response.ok) {
@@ -547,7 +608,9 @@ export class OpenCodeServerAdapter {
547
608
  }
548
609
  const major = Number(match[1]);
549
610
  const minor = Number(match[2]);
550
- return major === 1 && minor >= 18;
611
+ return (major >= 1 &&
612
+ major < OPENCODE_MAXIMUM_SUPPORTED_MAJOR &&
613
+ (major > 1 || minor >= 18));
551
614
  }
552
615
  async initializeSessions() {
553
616
  if (!this.client) {
@@ -717,6 +780,9 @@ export class OpenCodeServerAdapter {
717
780
  return this.normalizeGlobalSyncPayload(payload, rawEvent);
718
781
  }
719
782
  const normalized = { ...payload };
783
+ if (typeof payload.id === "string") {
784
+ normalized.id = payload.id;
785
+ }
720
786
  if (typeof rawEvent.directory === "string") {
721
787
  normalized.directory = rawEvent.directory;
722
788
  }
@@ -792,7 +858,8 @@ export class OpenCodeServerAdapter {
792
858
  this.handleSessionError(isRecord(payload) ? payload : undefined);
793
859
  return;
794
860
  }
795
- case "permission.updated":
861
+ // Note: only permission.asked exists in the OpenCode wire protocol;
862
+ // permission.updated was never emitted by the server.
796
863
  case "permission.asked": {
797
864
  this.handlePermissionRequest(payload);
798
865
  return;
@@ -930,7 +997,10 @@ export class OpenCodeServerAdapter {
930
997
  if (sessionId && !this.syncTrackedSessionFromEvent(sessionId)) {
931
998
  return;
932
999
  }
933
- // properties: { sessionID: string, status: { type: "busy" | "idle" | ... } }
1000
+ // properties: { sessionID, status: { type: "busy" | "idle" | "retry" | ... } }
1001
+ // "retry" means the model call is being retried (attempt, message): the
1002
+ // session is still working, so it must keep the busy state instead of
1003
+ // letting WeChat messages race a retried turn.
934
1004
  const status = properties.status;
935
1005
  if (!isRecord(status)) {
936
1006
  return;
@@ -939,7 +1009,7 @@ export class OpenCodeServerAdapter {
939
1009
  if (!statusType) {
940
1010
  return;
941
1011
  }
942
- if (statusType === "busy" || statusType === "running") {
1012
+ if (statusType === "busy" || statusType === "running" || statusType === "retry") {
943
1013
  if (this.state.status === "idle") {
944
1014
  this.outputBatcher.clear();
945
1015
  this.clearStreamedPartState();
@@ -1595,8 +1665,11 @@ export class OpenCodeServerAdapter {
1595
1665
  if (!sessionId) {
1596
1666
  return;
1597
1667
  }
1598
- if (sessionId === this.suppressedTuiSessionSelectId) {
1599
- this.suppressedTuiSessionSelectId = null;
1668
+ if (this.suppressedTuiSessionSelect &&
1669
+ sessionId === this.suppressedTuiSessionSelect.sessionId) {
1670
+ // Echo of our own selectSession — swallow it once. The TTL guard in
1671
+ // isSuppressedTuiSessionSelectExpired covers the lost-echo case.
1672
+ this.suppressedTuiSessionSelect = null;
1600
1673
  return;
1601
1674
  }
1602
1675
  this.pendingLocalPrompt = "";
@@ -2142,13 +2215,27 @@ export class OpenCodeServerAdapter {
2142
2215
  workspaceID: this.activeWorkspaceId ?? undefined,
2143
2216
  }, { force: options.force, retry: options.retry });
2144
2217
  }
2218
+ isTuiSessionSelectSuppressed(sessionId) {
2219
+ const suppression = this.suppressedTuiSessionSelect;
2220
+ if (!suppression || suppression.sessionId !== sessionId) {
2221
+ return false;
2222
+ }
2223
+ // Bound the suppression window: if the echo never arrives (TUI not ready,
2224
+ // dropped event), a stale flag must not swallow a later genuine local
2225
+ // switch to the same session.
2226
+ if (Date.now() - suppression.setAtMs > TUI_SESSION_SELECT_SUPPRESSION_TTL_MS) {
2227
+ this.suppressedTuiSessionSelect = null;
2228
+ return false;
2229
+ }
2230
+ return true;
2231
+ }
2145
2232
  async syncVisibleSessionSelection(session, options = {}) {
2146
2233
  if (!this.client?.tui ||
2147
2234
  this.options.renderMode !== "companion" ||
2148
- (!options.force && session.id === this.suppressedTuiSessionSelectId)) {
2235
+ (!options.force && this.isTuiSessionSelectSuppressed(session.id))) {
2149
2236
  return;
2150
2237
  }
2151
- this.suppressedTuiSessionSelectId = session.id;
2238
+ this.suppressedTuiSessionSelect = { sessionId: session.id, setAtMs: Date.now() };
2152
2239
  this.logDebug(`[opencode-adapter:tui] selectSession session=${session.id} workspace=${session.workspaceID ?? this.activeWorkspaceId ?? "(none)"}`);
2153
2240
  try {
2154
2241
  await this.sendVisibleSessionSelection(session);
@@ -2157,8 +2244,8 @@ export class OpenCodeServerAdapter {
2157
2244
  }
2158
2245
  }
2159
2246
  catch (err) {
2160
- if (this.suppressedTuiSessionSelectId === session.id) {
2161
- this.suppressedTuiSessionSelectId = null;
2247
+ if (this.suppressedTuiSessionSelect?.sessionId === session.id) {
2248
+ this.suppressedTuiSessionSelect = null;
2162
2249
  }
2163
2250
  this.logDebug(`[opencode-adapter:tui] selectSession failed for ${session.id}: ${describeUnknownError(err)}`);
2164
2251
  }
@@ -2266,8 +2353,10 @@ export class OpenCodeServerAdapter {
2266
2353
  }
2267
2354
  return true;
2268
2355
  }
2356
+ // Fold both path separators to "/" so directory comparisons behave
2357
+ // identically on Windows (backslash-native) and POSIX hosts.
2269
2358
  normalizeDirectory(directory) {
2270
- return directory.replace(/[\\/]+/g, "\\").replace(/\\$/, "").toLowerCase();
2359
+ return directory.replace(/[\\/]+/g, "/").replace(/\/+$/, "").toLowerCase();
2271
2360
  }
2272
2361
  recordSessionSwitch(sessionId, source, reason, notify = false) {
2273
2362
  const timestamp = nowIso();
@@ -2325,13 +2414,20 @@ export class OpenCodeServerAdapter {
2325
2414
  return "";
2326
2415
  }
2327
2416
  const previousText = this.emittedTextByPartId.get(partId) ?? "";
2328
- if (nextChunk === previousText || previousText.endsWith(nextChunk)) {
2417
+ if (nextChunk === previousText) {
2418
+ // Full replay of everything emitted for this part (an SSE replay after
2419
+ // a reconnect): nothing new.
2329
2420
  return "";
2330
2421
  }
2331
2422
  if (previousText && nextChunk.startsWith(previousText)) {
2423
+ // The "delta" actually carries the accumulated part text: keep the tail.
2332
2424
  this.emittedTextByPartId.set(partId, nextChunk);
2333
2425
  return nextChunk.slice(previousText.length);
2334
2426
  }
2427
+ // Plain incremental delta: append. Duplicated fragments that legitimately
2428
+ // occur in model output (repeated words, table separators, markdown
2429
+ // rules) must still be forwarded, so there is deliberately no
2430
+ // suffix-based dedup here — it silently truncated real content.
2335
2431
  this.emittedTextByPartId.set(partId, `${previousText}${nextChunk}`);
2336
2432
  return nextChunk;
2337
2433
  }
@@ -2419,18 +2515,31 @@ export class OpenCodeServerAdapter {
2419
2515
  if (!key) {
2420
2516
  return false;
2421
2517
  }
2518
+ // Frame-id keys identify one exact server event, so any repeat — even on
2519
+ // the same stream (SSE replay) — is a duplicate; the wide TTL absorbs
2520
+ // reconnect replays. Synthetic keys still rely on the narrow cross-stream
2521
+ // window only, because they cannot distinguish a replay from a legitimate
2522
+ // repeated payload.
2523
+ const idKeyTtlMs = 30_000;
2524
+ const isIdKey = key.startsWith("evt:");
2525
+ const ttlMs = isIdKey ? idKeyTtlMs : OPENCODE_DUPLICATE_EVENT_TTL_MS;
2422
2526
  const now = Date.now();
2423
- const cutoff = now - OPENCODE_DUPLICATE_EVENT_TTL_MS;
2424
2527
  for (const [candidateKey, observed] of this.recentSdkEventObservations.entries()) {
2425
- if (observed.observedAtMs < cutoff) {
2528
+ if (observed.observedAtMs < now - idKeyTtlMs) {
2426
2529
  this.recentSdkEventObservations.delete(candidateKey);
2427
2530
  }
2428
2531
  }
2429
2532
  const previous = this.recentSdkEventObservations.get(key);
2430
2533
  this.recentSdkEventObservations.set(key, { streamName, observedAtMs: now });
2431
- return Boolean(previous && previous.streamName !== streamName && previous.observedAtMs >= cutoff);
2534
+ return Boolean(previous &&
2535
+ previous.observedAtMs >= now - ttlMs &&
2536
+ (isIdKey || previous.streamName !== streamName));
2432
2537
  }
2433
2538
  getDuplicateSdkEventKey(event) {
2539
+ // Exact frame ids (when the stream provides them) dedupe precisely.
2540
+ if (typeof event.id === "string" && event.id) {
2541
+ return `evt:${event.id}`;
2542
+ }
2434
2543
  const type = this.normalizeEventType(event.type);
2435
2544
  const payload = this.extractEventPayload(event);
2436
2545
  if (!isRecord(payload)) {
@@ -636,6 +636,12 @@ export class PiTuiAdapter {
636
636
  this.reconnectTimer = setTimeout(() => {
637
637
  this.reconnectTimer = null;
638
638
  if (!this.socket && !this.disposing) {
639
+ // Reset the turn state alongside the fatal error: leaving status at
640
+ // "busy" would keep rejecting every message with "still working"
641
+ // while /stop stays unavailable (no socket) and /reset is the only
642
+ // way out.
643
+ this.clearActiveTurn();
644
+ this.setStatus("idle", "The Pi TUI extension disconnected; the bridge is waiting for a reconnect.");
639
645
  this.emit({
640
646
  type: "fatal_error",
641
647
  message: "The native Pi TUI bridge extension disconnected and did not reconnect.",
@@ -680,9 +686,13 @@ export class PiTuiAdapter {
680
686
  this.extensionReady = false;
681
687
  this.clearActiveTurn();
682
688
  if (!this.disposing) {
689
+ // Report unexpected exits as fatal errors, matching the other adapters:
690
+ // a bare "stopped" status is only logged by the bridge, so WeChat users
691
+ // would get no signal that the TUI is gone until their next message
692
+ // fails with a cryptic not-connected error.
683
693
  this.emit({
684
- type: "status",
685
- status: "stopped",
694
+ type: "fatal_error",
695
+ message: `Pi TUI exited unexpectedly (code=${code ?? "none"}, signal=${signal ?? "none"}). Restart the bridge or use /reset to recover.`,
686
696
  timestamp: nowIso(),
687
697
  });
688
698
  }
@@ -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();
@@ -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) {
@@ -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}`);