cli-wechat-bridge 1.1.5 → 1.1.7

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 (52) hide show
  1. package/README.md +212 -232
  2. package/bin/_run-entry.mjs +20 -5
  3. package/bin/wecom-check-update.mjs +5 -0
  4. package/bin/wecom-claude.mjs +5 -0
  5. package/bin/wecom-codex.mjs +5 -0
  6. package/bin/wecom-daemon.mjs +5 -0
  7. package/bin/wecom-opencode.mjs +5 -0
  8. package/bin/wecom-pi.mjs +5 -0
  9. package/bin/wecom-setup.mjs +5 -0
  10. package/dist/bridge/bridge-adapters.claude.js +128 -4
  11. package/dist/bridge/bridge-adapters.codex.js +214 -23
  12. package/dist/bridge/bridge-adapters.core.js +2 -1
  13. package/dist/bridge/bridge-adapters.pi.js +1 -1
  14. package/dist/bridge/bridge-adapters.shared.js +2 -1
  15. package/dist/bridge/bridge-final-reply.js +2 -65
  16. package/dist/bridge/bridge-process-reaper.js +35 -2
  17. package/dist/bridge/bridge-state.js +11 -4
  18. package/dist/bridge/bridge-utils.js +4 -96
  19. package/dist/bridge/wechat-bridge.js +476 -206
  20. package/dist/bridge/wechat-forwarding.js +2 -58
  21. package/dist/bridge/wechat-outbound-queue.js +2 -0
  22. package/dist/channels/wechat/bridge-final-reply.js +65 -0
  23. package/dist/channels/wechat/channel-message.js +24 -0
  24. package/dist/channels/wechat/index.js +5 -0
  25. package/dist/channels/wechat/wechat-channel-port.js +67 -0
  26. package/dist/channels/wechat/wechat-forwarding.js +79 -0
  27. package/dist/channels/wechat/wechat-outbound-queue.js +79 -0
  28. package/dist/channels/wecom/bridge-final-reply.js +26 -0
  29. package/dist/channels/wecom/setup-cli.js +6 -0
  30. package/dist/channels/wecom/setup.js +176 -0
  31. package/dist/channels/wecom/wecom-channel-port.js +45 -0
  32. package/dist/channels/wecom/wecom-config.js +66 -0
  33. package/dist/channels/wecom/wecom-media.js +141 -0
  34. package/dist/channels/wecom/wecom-message.js +149 -0
  35. package/dist/channels/wecom/wecom-transport.js +308 -0
  36. package/dist/companion/codex-remote-client.js +2 -1
  37. package/dist/companion/local-companion-start.js +49 -9
  38. package/dist/companion/local-companion.js +2 -1
  39. package/dist/core/bridge-control.js +32 -0
  40. package/dist/core/bridge-event-forwarder.js +50 -0
  41. package/dist/core/bridge-message-router.js +32 -0
  42. package/dist/core/channel-types.js +3 -0
  43. package/dist/core/direct-run.js +17 -0
  44. package/dist/core/inbound-message-claims.js +61 -0
  45. package/dist/core/text-utils.js +63 -0
  46. package/dist/daemon/daemon-link.js +3 -0
  47. package/dist/daemon/wechat-daemon.js +466 -172
  48. package/dist/i18n/messages-en.js +4 -2
  49. package/dist/i18n/messages-zh.js +4 -2
  50. package/dist/utils/doctor.js +30 -10
  51. package/dist/wechat/setup.js +113 -13
  52. package/package.json +117 -107
@@ -1,17 +1,32 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
- import { spawn as spawnChild } from "node:child_process";
5
- import { detectCliApproval, normalizeOutput, nowIso, truncatePreview, } from "./bridge-utils.js";
4
+ import { spawn as spawnChild, spawnSync } from "node:child_process";
5
+ import { detectCliApproval, } from "./bridge-utils.js";
6
+ import { normalizeOutput, nowIso, summarizeOutput, truncatePreview, } from "../core/text-utils.js";
6
7
  import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
7
8
  import { killProcessTreeSync } from "./bridge-process-reaper.js";
8
9
  import * as shared from "./bridge-adapters.shared.js";
9
10
  import { requestCodexVisibleThreadSwitch } from "../companion/codex-visible-client-link.js";
10
11
  import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
11
12
  import { CODEX_REMOTE_AUTH_TOKEN_ENV, LOCAL_CLIENT_PROTOCOL_VERSION, } from "../runtime/runtime-types.js";
13
+ export function parseCodexCliVersion(output) {
14
+ const match = output.match(/\b(\d+\.\d+\.\d+)\b/);
15
+ return match?.[1] ?? null;
16
+ }
17
+ export function isCodexVersionInCompatibilityRange(version) {
18
+ const parts = version.split(".").map((part) => Number(part));
19
+ if (parts.length !== 3 || parts.some((part) => !Number.isInteger(part))) {
20
+ return false;
21
+ }
22
+ const major = parts[0] ?? -1;
23
+ const minor = parts[1] ?? -1;
24
+ return major === 0 && minor >= 149 && minor <= 151;
25
+ }
12
26
  const { CODEX_APP_SERVER_HOST, CODEX_APP_SERVER_READY_TIMEOUT_MS, CODEX_FINAL_REPLY_SETTLE_DELAY_MS, CODEX_RECENT_SESSION_KEY_LIMIT, CODEX_RPC_CONNECT_RETRY_MS, CODEX_RPC_RECONNECT_TIMEOUT_MS, CODEX_SESSION_FALLBACK_SCAN_INTERVAL_MS, CODEX_SESSION_LOCAL_MIRROR_FALLBACK_WINDOW_MS, CODEX_SESSION_POLL_INTERVAL_MS, CODEX_STARTUP_WARMUP_MS, CODEX_THREAD_SIGNAL_TTL_MS, INTERRUPT_SETTLE_DELAY_MS, appendBoundedLog, buildCodexApprovalRequest, buildCodexCliArgs, buildCodexDynamicToolCallFailureResponse, buildCodexMcpServerElicitationDeclineResponse, buildCodexPermissionsRequestApprovalResponse, buildCodexUserInputRequest, coerceWebSocketMessageData, delay, describeUnknownError, extractCodexFinalTextFromItem, extractCodexThreadFollowIdFromStatusChanged, extractCodexThreadStartedThreadId, extractCodexUserMessageText, findCodexSessionFile, findRecentCodexSessionFileForCwd, getCodexRpcRequestId, getCodexApprovalAutoResponse, getCodexWechatOutboundAttachmentDenyMessage, getNotificationThreadId, getNotificationTurnId, isRecord, isRecentIsoTimestamp, listCodexResumeSessions, normalizeComparablePath, normalizeCodexRpcError, reserveLocalPort, resolveSpawnTarget, shouldAutoCompleteCodexWechatTurnAfterFinalReply, shouldIgnoreCodexSessionReplayEntry, shouldRecoverCodexStaleBusyState, waitForTcpPort, } = shared;
13
27
  const CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS = 150;
14
28
  const CODEX_NON_BLOCKING_USER_INPUT_TIMEOUT_MS = 120_000;
29
+ const CODEX_RESUME_REPLAY_SETTLE_MS = 5_000;
15
30
  function getCodexThreadStatusType(value) {
16
31
  if (typeof value === "string") {
17
32
  return value;
@@ -69,8 +84,12 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
69
84
  rpcRequestCounter = 0;
70
85
  pendingRpcRequests = new Map();
71
86
  subscribedThreadIds = new Set();
87
+ pendingThreadSubscriptions = new Map();
72
88
  sharedThreadId = null;
73
89
  announcedThreadId = null;
90
+ localThreadFollowBlockedUntilMs = 0;
91
+ pendingThreadStatusChecks = new Map();
92
+ threadStatusCheckEpoch = 0;
74
93
  pendingThreadAnnouncement = null;
75
94
  activeTurn = null;
76
95
  bridgeOwnedTurnIds = new Set();
@@ -80,6 +99,8 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
80
99
  interruptPendingTurnStart = false;
81
100
  pendingThreadFollowId = null;
82
101
  pendingVisibleResume = null;
102
+ bridgeResumeReplayThreadId = null;
103
+ bridgeResumeReplayUntilMs = 0;
83
104
  pendingApprovalRequests = [];
84
105
  pendingUserInputRequest = null;
85
106
  pendingUserInputTimer = null;
@@ -108,6 +129,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
108
129
  finalReplyCompletionTimer = null;
109
130
  finalReplyCompletionTurnId = null;
110
131
  resumeThreadId;
132
+ codexCliVersion = null;
111
133
  localClientInstanceId = `${process.pid}-${Date.now().toString(36)}`;
112
134
  constructor(options) {
113
135
  super(options);
@@ -126,6 +148,13 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
126
148
  if (this.isHeadlessRuntimeMode()) {
127
149
  this.setStatus("starting", `Starting ${this.options.kind} runtime host...`);
128
150
  }
151
+ this.codexCliVersion = this.detectCodexCliVersion();
152
+ const compatibility = this.codexCliVersion
153
+ ? isCodexVersionInCompatibilityRange(this.codexCliVersion)
154
+ ? `compatible=${this.codexCliVersion}`
155
+ : `outside-supported-range=${this.codexCliVersion}`
156
+ : "unknown-version";
157
+ this.setStatus("starting", `Codex protocol compatibility: ${compatibility}.`);
129
158
  await this.startAppServer();
130
159
  await this.connectRpcClient();
131
160
  await this.restoreInitialSharedThreadIfNeeded();
@@ -406,6 +435,24 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
406
435
  }
407
436
  return this.isNativePanelMode() ? Boolean(this.nativeProcess) : Boolean(this.pty);
408
437
  }
438
+ detectCodexCliVersion() {
439
+ try {
440
+ const target = resolveSpawnTarget(this.options.command, "codex", {
441
+ forwardArgs: ["--version"],
442
+ });
443
+ const result = spawnSync(target.file, target.args, {
444
+ cwd: this.options.cwd,
445
+ env: this.buildEnv(),
446
+ encoding: "utf8",
447
+ timeout: 5_000,
448
+ windowsHide: true,
449
+ });
450
+ return parseCodexCliVersion(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
451
+ }
452
+ catch {
453
+ return null;
454
+ }
455
+ }
409
456
  shouldPollSessionLog() {
410
457
  return (this.isCodexClientRunning() ||
411
458
  this.pendingTurnStart ||
@@ -1335,13 +1382,18 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1335
1382
  if (this.subscribedThreadIds.has(threadId)) {
1336
1383
  return true;
1337
1384
  }
1338
- try {
1339
- await this.ensureSharedThreadSubscribed(threadId);
1340
- return true;
1341
- }
1342
- catch {
1343
- return false;
1385
+ const pending = this.pendingThreadSubscriptions.get(threadId);
1386
+ if (pending) {
1387
+ return await pending;
1344
1388
  }
1389
+ const attempt = this.ensureSharedThreadSubscribed(threadId)
1390
+ .then(() => true)
1391
+ .catch(() => false)
1392
+ .finally(() => {
1393
+ this.pendingThreadSubscriptions.delete(threadId);
1394
+ });
1395
+ this.pendingThreadSubscriptions.set(threadId, attempt);
1396
+ return await attempt;
1345
1397
  }
1346
1398
  async ensureSharedThreadSubscribed(threadId) {
1347
1399
  if (this.subscribedThreadIds.has(threadId)) {
@@ -1359,11 +1411,12 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1359
1411
  if (!resumedThreadId) {
1360
1412
  throw new Error("Codex did not return a thread id while subscribing the bridge client.");
1361
1413
  }
1414
+ if (resumedThreadId !== threadId) {
1415
+ throw new Error(`Codex opened ${resumedThreadId}, expected ${threadId}.`);
1416
+ }
1417
+ this.validateCodexThreadWorkspaceIfPresent(response, threadId);
1362
1418
  this.rememberBridgeOwnedThreadSignal(resumedThreadId);
1363
1419
  this.subscribedThreadIds.add(resumedThreadId);
1364
- if (resumedThreadId !== this.sharedThreadId) {
1365
- this.updateSharedThread(resumedThreadId);
1366
- }
1367
1420
  }
1368
1421
  async listAppServerResumeSessions(limit) {
1369
1422
  const boundedLimit = Math.max(1, Math.min(100, limit));
@@ -1415,6 +1468,25 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1415
1468
  }
1416
1469
  return thread;
1417
1470
  }
1471
+ validateCodexThreadWorkspaceIfPresent(response, targetThreadId) {
1472
+ if (!isRecord(response) || !isRecord(response.thread)) {
1473
+ return;
1474
+ }
1475
+ const thread = response.thread;
1476
+ if (typeof thread.id === "string" && thread.id !== targetThreadId) {
1477
+ throw new Error(`Codex opened ${thread.id}, expected ${targetThreadId}.`);
1478
+ }
1479
+ if (typeof thread.cwd === "string" &&
1480
+ normalizeComparablePath(thread.cwd) !== normalizeComparablePath(this.options.cwd)) {
1481
+ throw new Error(`Codex thread ${targetThreadId} does not belong to ${this.options.cwd}.`);
1482
+ }
1483
+ if (typeof thread.parentThreadId === "string") {
1484
+ throw new Error(`Codex thread ${targetThreadId} is a subagent thread.`);
1485
+ }
1486
+ if (thread.ephemeral === true) {
1487
+ throw new Error(`Codex thread ${targetThreadId} is ephemeral.`);
1488
+ }
1489
+ }
1418
1490
  async resumeVisibleSharedThread(threadId) {
1419
1491
  const targetThreadId = threadId.trim();
1420
1492
  if (!targetThreadId) {
@@ -1439,17 +1511,19 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1439
1511
  if (this.state.status !== "idle") {
1440
1512
  throw new Error(`Codex cannot switch threads while its status is ${this.state.status}.`);
1441
1513
  }
1442
- const readResponse = await this.sendRpcRequest("thread/read", {
1443
- threadId: targetThreadId,
1444
- includeTurns: false,
1445
- });
1446
- this.validateCodexResumeThread(readResponse, targetThreadId);
1447
1514
  const previousThreadId = this.sharedThreadId;
1448
1515
  let targetSubscribed = false;
1449
1516
  this.pendingVisibleResume = { targetThreadId };
1450
- this.setStatus("starting", `Switching Codex to thread ${targetThreadId.slice(0, 12)}...`);
1517
+ this.setStatus("starting", `Codex resume validating thread ${targetThreadId.slice(0, 12)}...`);
1451
1518
  try {
1519
+ const readResponse = await this.sendRpcRequest("thread/read", {
1520
+ threadId: targetThreadId,
1521
+ includeTurns: false,
1522
+ });
1523
+ this.validateCodexResumeThread(readResponse, targetThreadId);
1524
+ this.setStatus("starting", `Codex resume validated thread ${targetThreadId.slice(0, 12)}.`);
1452
1525
  this.rememberBridgeOwnedThreadSignal(targetThreadId);
1526
+ this.setStatus("starting", `Codex resume requesting thread ${targetThreadId.slice(0, 12)}.`);
1453
1527
  const resumeResponse = await this.sendRpcRequest("thread/resume", {
1454
1528
  threadId: targetThreadId,
1455
1529
  approvalPolicy: "on-request",
@@ -1460,6 +1534,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1460
1534
  targetSubscribed = true;
1461
1535
  this.validateCodexResumeThread(resumeResponse, targetThreadId);
1462
1536
  this.subscribedThreadIds.add(targetThreadId);
1537
+ this.setStatus("starting", `Codex resume switching visible thread ${targetThreadId.slice(0, 12)}.`);
1463
1538
  const visibleThreadId = await this.switchVisibleCodexThread(targetThreadId);
1464
1539
  if (visibleThreadId !== targetThreadId) {
1465
1540
  throw new Error(`The visible Codex client opened ${visibleThreadId}, expected ${targetThreadId}.`);
@@ -1474,6 +1549,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1474
1549
  reason: "wechat_resume",
1475
1550
  notify: true,
1476
1551
  });
1552
+ this.bridgeResumeReplayThreadId = targetThreadId;
1553
+ this.bridgeResumeReplayUntilMs = Date.now() + CODEX_RESUME_REPLAY_SETTLE_MS;
1554
+ this.setStatus("starting", `Codex resume committed thread ${targetThreadId.slice(0, 12)}.`);
1477
1555
  this.setStatus("idle");
1478
1556
  if (previousThreadId && previousThreadId !== targetThreadId) {
1479
1557
  await this.unsubscribeCodexThreadBestEffort(previousThreadId);
@@ -1483,7 +1561,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1483
1561
  if (targetSubscribed && targetThreadId !== previousThreadId) {
1484
1562
  await this.unsubscribeCodexThreadBestEffort(targetThreadId);
1485
1563
  }
1486
- this.setStatus("idle");
1564
+ this.setStatus("idle", `Codex resume failed for ${targetThreadId.slice(0, 12)}: ${describeUnknownError(error)}`);
1487
1565
  throw error;
1488
1566
  }
1489
1567
  finally {
@@ -1533,6 +1611,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1533
1611
  if (!resumedThreadId) {
1534
1612
  throw new Error("Codex did not return a thread id while resuming the saved thread.");
1535
1613
  }
1614
+ this.validateCodexThreadWorkspaceIfPresent(response, resumedThreadId);
1536
1615
  this.rememberBridgeOwnedThreadSignal(resumedThreadId);
1537
1616
  this.subscribedThreadIds.add(resumedThreadId);
1538
1617
  this.sessionFilePath = null;
@@ -1680,6 +1759,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1680
1759
  this.completedTurnOrder = [];
1681
1760
  }
1682
1761
  this.pendingInjectedInputs = [];
1762
+ this.threadStatusCheckEpoch += 1;
1763
+ this.pendingThreadStatusChecks.clear();
1764
+ this.clearBridgeResumeReplay();
1683
1765
  this.recentBridgeThreadSignalAtById.clear();
1684
1766
  this.sessionFinalText = null;
1685
1767
  this.nextSessionFallbackScanAtMs = 0;
@@ -1746,6 +1828,10 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1746
1828
  });
1747
1829
  }
1748
1830
  }
1831
+ blockLateLocalThreadFollow() {
1832
+ this.localThreadFollowBlockedUntilMs =
1833
+ Date.now() + CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS;
1834
+ }
1749
1835
  clearPendingThreadAnnouncement() {
1750
1836
  if (!this.pendingThreadAnnouncement) {
1751
1837
  return;
@@ -1804,16 +1890,29 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1804
1890
  pending.timer.unref?.();
1805
1891
  }
1806
1892
  trackLocalSharedThread(threadId, options) {
1893
+ const weakSignal = options.signal === "status_changed" ||
1894
+ options.signal === "thread_started" ||
1895
+ options.signal === "session_fallback";
1896
+ if (weakSignal && Date.now() < this.localThreadFollowBlockedUntilMs) {
1897
+ return;
1898
+ }
1899
+ if (options.signal === "user_message" || options.signal === "turn_started") {
1900
+ this.localThreadFollowBlockedUntilMs = 0;
1901
+ }
1807
1902
  if (this.pendingVisibleResume) {
1808
1903
  return;
1809
1904
  }
1810
1905
  this.interruptWechatTurnForLocalThreadSwitch(threadId);
1811
1906
  if (!this.isNativePanelMode()) {
1907
+ const threadChanged = this.sharedThreadId !== threadId;
1812
1908
  this.updateSharedThread(threadId, {
1813
1909
  source: "local",
1814
1910
  reason: options.reason,
1815
1911
  notify: true,
1816
1912
  });
1913
+ if (threadChanged) {
1914
+ void this.tryEnsureSharedThreadSubscribed(threadId);
1915
+ }
1817
1916
  return;
1818
1917
  }
1819
1918
  this.updateSharedThread(threadId, {
@@ -1897,6 +1996,20 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1897
1996
  }
1898
1997
  return true;
1899
1998
  }
1999
+ isBridgeResumeReplay(threadId) {
2000
+ if (this.bridgeResumeReplayThreadId !== threadId) {
2001
+ return false;
2002
+ }
2003
+ if (Date.now() >= this.bridgeResumeReplayUntilMs) {
2004
+ this.clearBridgeResumeReplay();
2005
+ return false;
2006
+ }
2007
+ return true;
2008
+ }
2009
+ clearBridgeResumeReplay() {
2010
+ this.bridgeResumeReplayThreadId = null;
2011
+ this.bridgeResumeReplayUntilMs = 0;
2012
+ }
1900
2013
  clearPendingApprovalState() {
1901
2014
  this.pendingApprovalRequests = [];
1902
2015
  this.pendingApproval = null;
@@ -2393,6 +2506,68 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2393
2506
  if (!threadId) {
2394
2507
  return;
2395
2508
  }
2509
+ if (this.pendingVisibleResume) {
2510
+ return;
2511
+ }
2512
+ if (this.isBridgeResumeReplay(threadId)) {
2513
+ return;
2514
+ }
2515
+ const notificationCwd = typeof params.cwd === "string"
2516
+ ? params.cwd
2517
+ : isRecord(params.thread) && typeof params.thread.cwd === "string"
2518
+ ? params.thread.cwd
2519
+ : null;
2520
+ if (notificationCwd &&
2521
+ normalizeComparablePath(notificationCwd) !== normalizeComparablePath(this.options.cwd)) {
2522
+ return;
2523
+ }
2524
+ if (!this.isTrustedThreadStatus(threadId, Boolean(notificationCwd))) {
2525
+ this.queueThreadStatusCwdCheck(threadId);
2526
+ return;
2527
+ }
2528
+ this.applyThreadStatusChanged(threadId);
2529
+ }
2530
+ isTrustedThreadStatus(threadId, hasCwd) {
2531
+ if (hasCwd || this.isNativePanelMode()) {
2532
+ return true;
2533
+ }
2534
+ return (this.subscribedThreadIds.has(threadId) ||
2535
+ this.activeTurn?.threadId === threadId ||
2536
+ this.pendingTurnThreadId === threadId ||
2537
+ this.pendingVisibleResume?.targetThreadId === threadId);
2538
+ }
2539
+ queueThreadStatusCwdCheck(threadId) {
2540
+ if (this.pendingThreadStatusChecks.has(threadId)) {
2541
+ return;
2542
+ }
2543
+ const epoch = this.threadStatusCheckEpoch;
2544
+ const check = this.sendRpcRequest("thread/read", {
2545
+ threadId,
2546
+ includeTurns: false,
2547
+ })
2548
+ .then((response) => {
2549
+ if (epoch !== this.threadStatusCheckEpoch) {
2550
+ return;
2551
+ }
2552
+ if (!isRecord(response) || !isRecord(response.thread)) {
2553
+ return;
2554
+ }
2555
+ const thread = response.thread;
2556
+ if (thread.id === threadId &&
2557
+ typeof thread.cwd === "string" &&
2558
+ normalizeComparablePath(thread.cwd) === normalizeComparablePath(this.options.cwd) &&
2559
+ typeof thread.parentThreadId !== "string" &&
2560
+ thread.ephemeral !== true) {
2561
+ this.applyThreadStatusChanged(threadId);
2562
+ }
2563
+ })
2564
+ .catch(() => undefined)
2565
+ .finally(() => {
2566
+ this.pendingThreadStatusChecks.delete(threadId);
2567
+ });
2568
+ this.pendingThreadStatusChecks.set(threadId, check);
2569
+ }
2570
+ applyThreadStatusChanged(threadId) {
2396
2571
  if (!this.activeTurn ||
2397
2572
  this.activeTurn.threadId === threadId ||
2398
2573
  this.activeTurn.origin === "wechat") {
@@ -2410,12 +2585,18 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2410
2585
  if (!threadId) {
2411
2586
  return;
2412
2587
  }
2588
+ if (this.isBridgeResumeReplay(threadId)) {
2589
+ return;
2590
+ }
2413
2591
  if (this.isRecentlyBridgeOwnedThread(threadId)) {
2414
2592
  return;
2415
2593
  }
2416
2594
  const thread = isRecord(params.thread) ? params.thread : null;
2417
- if (thread && typeof thread.cwd === "string") {
2418
- if (normalizeComparablePath(thread.cwd) !== normalizeComparablePath(this.options.cwd)) {
2595
+ if (thread) {
2596
+ if ((typeof thread.cwd === "string" &&
2597
+ normalizeComparablePath(thread.cwd) !== normalizeComparablePath(this.options.cwd)) ||
2598
+ typeof thread.parentThreadId === "string" ||
2599
+ thread.ephemeral === true) {
2419
2600
  return;
2420
2601
  }
2421
2602
  }
@@ -2435,6 +2616,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2435
2616
  if (this.activeTurn?.turnId === trackedTurn.turnId) {
2436
2617
  return;
2437
2618
  }
2619
+ if (trackedTurn.origin === "local") {
2620
+ this.clearBridgeResumeReplay();
2621
+ }
2438
2622
  if (trackedTurn.origin === "local" &&
2439
2623
  trackedTurn.threadId !== this.sharedThreadId) {
2440
2624
  if (!this.activeTurn ||
@@ -2513,6 +2697,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2513
2697
  if (this.activeTurn?.turnId === trackedTurn.turnId) {
2514
2698
  this.setActiveTurn(null);
2515
2699
  }
2700
+ if (completedTrackedTurn.origin === "wechat") {
2701
+ this.blockLateLocalThreadFollow();
2702
+ }
2516
2703
  this.cleanupTurnArtifacts(trackedTurn.turnId);
2517
2704
  if (this.state.status !== "stopped" &&
2518
2705
  (!this.activeTurn || this.activeTurn.turnId === trackedTurn.turnId)) {
@@ -2652,6 +2839,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2652
2839
  if (!activeTurn || !finalText) {
2653
2840
  return;
2654
2841
  }
2842
+ if (activeTurn.origin === "wechat") {
2843
+ this.blockLateLocalThreadFollow();
2844
+ }
2655
2845
  this.clearPendingApprovalState();
2656
2846
  this.clearPendingUserInputState();
2657
2847
  this.setActiveTurn(null);
@@ -2712,11 +2902,12 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2712
2902
  this.appServerAuthToken = null;
2713
2903
  }
2714
2904
  describeAppServerLog() {
2715
- const summary = normalizeOutput(this.appServerLog).trim();
2716
- if (!summary) {
2905
+ const normalized = normalizeOutput(this.appServerLog).trim();
2906
+ if (!normalized) {
2717
2907
  return "";
2718
2908
  }
2719
- return ` Recent app-server log: ${truncatePreview(summary, 220)}`;
2909
+ const summary = summarizeOutput(normalized, 500);
2910
+ return ` Recent app-server log: ${summary}`;
2720
2911
  }
2721
2912
  terminateCodexClient() {
2722
2913
  this.shuttingDown = true;
@@ -3,7 +3,8 @@ import path from "node:path";
3
3
  import { spawn as spawnPty } from "node-pty";
4
4
  import { t } from "../i18n/index.js";
5
5
  import { attachLocalCompanionMessageListener, buildLocalCompanionToken, clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, sendLocalCompanionMessage, updateLocalCompanionOccupancy, writeLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
6
- import { detectCliApproval, normalizeOutput, nowIso, truncatePreview, } from "./bridge-utils.js";
6
+ import { detectCliApproval, } from "./bridge-utils.js";
7
+ import { normalizeOutput, nowIso, truncatePreview } from "../core/text-utils.js";
7
8
  import * as shared from "./bridge-adapters.shared.js";
8
9
  import { LOCAL_CLIENT_PROTOCOL_VERSION } from "../runtime/runtime-types.js";
9
10
  const { CODEX_APP_SERVER_HOST, INTERRUPT_SETTLE_DELAY_MS, buildCliEnvironment, buildPtySpawnOptions, buildSpawnDiagnostic, getLocalCompanionCommandName, getSharedSessionIdFromAdapterState, LOCAL_COMPANION_RECONNECT_GRACE_MS, resolveSpawnTarget, spawnFallbackProcess, } = shared;
@@ -7,7 +7,7 @@ import { spawn } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { assertNoReservedExtraCliArgs, buildCliEnvironment, describeUnknownError, isRecord, resolveSpawnTarget, } from "./bridge-adapters.shared.js";
9
9
  import { killProcessTreeSync } from "./bridge-process-reaper.js";
10
- import { normalizeOutput, nowIso } from "./bridge-utils.js";
10
+ import { normalizeOutput, nowIso } from "../core/text-utils.js";
11
11
  const PI_TUI_CONNECT_WARNING_MS = 30_000;
12
12
  const PI_TUI_REQUEST_TIMEOUT_MS = 30_000;
13
13
  const PI_TUI_RECONNECT_TIMEOUT_MS = 10_000;
@@ -6,7 +6,8 @@ import { fileURLToPath } from "node:url";
6
6
  import { spawn, spawnSync } from "node:child_process";
7
7
  import { t } from "../i18n/index.js";
8
8
  import { spawn as spawnPty } from "node-pty";
9
- import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, containsWechatOutboundAttachmentPath, containsWechatOutboundAttachmentPathDeep, isWechatOutboundAttachmentWriteCommand, isHighRiskShellCommand, isStrictApprovalModeEnabled, normalizeOutput, truncatePreview, } from "./bridge-utils.js";
9
+ import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, containsWechatOutboundAttachmentPath, containsWechatOutboundAttachmentPathDeep, isWechatOutboundAttachmentWriteCommand, isHighRiskShellCommand, isStrictApprovalModeEnabled, } from "./bridge-utils.js";
10
+ import { normalizeOutput, truncatePreview } from "../core/text-utils.js";
10
11
  import { getNotificationThreadId, isRecord, } from "./bridge-adapter-common.js";
11
12
  export { coerceWebSocketMessageData, describeUnknownError, getCodexRpcRequestId, getLocalCompanionCommandName, getNotificationThreadId, getNotificationTurnId, getSharedSessionIdFromAdapterState, isRecentIsoTimestamp, isRecord, normalizeCodexRpcError, quotePosixCommandArg, quoteWindowsCommandArg, } from "./bridge-adapter-common.js";
12
13
  export const DEFAULT_COLS = 120;
@@ -1,65 +1,2 @@
1
- import { formatFinalReplyMessage, parseWechatFinalReply, sanitizeWechatFinalReplyText, splitWechatTextIntoChunks, } from "./bridge-utils.js";
2
- export const OPENCODE_EMPTY_VISIBLE_REPLY_MESSAGE = "OpenCode 没有产生可发送到微信的可见回复。请查看本地终端输出,或重试这条消息。";
3
- export async function forwardWechatFinalReply(params) {
4
- const { adapter, rawText, sender, onEmptyVisibleReply } = params;
5
- const parsed = parseWechatFinalReply(rawText);
6
- const sanitizedText = sanitizeWechatFinalReplyText(adapter, parsed.visibleText);
7
- const visibleText = formatFinalReplyMessage(adapter, sanitizedText).trim();
8
- if (visibleText) {
9
- // Send long replies in bounded chunks: a single oversized sendmessage call
10
- // can be rejected by the WeChat API, silently losing the whole reply.
11
- const chunks = splitWechatTextIntoChunks(visibleText);
12
- for (let index = 0; index < chunks.length; index += 1) {
13
- const sent = await sender.sendText(chunks[index]);
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
- }
29
- return;
30
- }
31
- }
32
- }
33
- else if (adapter === "opencode" && parsed.visibleText.trim()) {
34
- onEmptyVisibleReply?.({
35
- adapter,
36
- rawVisibleText: parsed.visibleText,
37
- });
38
- const sent = await sender.sendText(OPENCODE_EMPTY_VISIBLE_REPLY_MESSAGE);
39
- if (sent === false) {
40
- return;
41
- }
42
- }
43
- for (const attachment of parsed.attachments) {
44
- try {
45
- switch (attachment.kind) {
46
- case "image":
47
- await sender.sendImage(attachment.path);
48
- break;
49
- case "file":
50
- await sender.sendFile(attachment.path);
51
- break;
52
- case "voice":
53
- await sender.sendVoice(attachment.path);
54
- break;
55
- case "video":
56
- await sender.sendVideo(attachment.path);
57
- break;
58
- }
59
- }
60
- catch (error) {
61
- const errorText = error instanceof Error ? error.message : String(error ?? "unknown error");
62
- await sender.sendText(`Failed to send ${attachment.kind} attachment: ${attachment.path}\n${errorText}`);
63
- }
64
- }
65
- }
1
+ /** @deprecated Import from ../channels/wechat/bridge-final-reply.ts instead. */
2
+ export * from "../channels/wechat/bridge-final-reply.js";
@@ -113,7 +113,7 @@ function normalizeBridgeProcessRecord(value) {
113
113
  }
114
114
  return record;
115
115
  }
116
- export function parseWindowsBridgeProcessProbeOutput(stdout, currentPid = process.pid) {
116
+ function parseWindowsProcessProbeRecords(stdout) {
117
117
  const trimmed = stdout.trim();
118
118
  if (!trimmed) {
119
119
  return [];
@@ -128,7 +128,13 @@ export function parseWindowsBridgeProcessProbeOutput(stdout, currentPid = proces
128
128
  const values = Array.isArray(parsed) ? parsed : [parsed];
129
129
  return values
130
130
  .map(normalizeBridgeProcessRecord)
131
- .filter((record) => Boolean(record))
131
+ .filter((record) => Boolean(record));
132
+ }
133
+ export function parseWindowsProcessRecordProbeOutput(stdout, currentPid = process.pid) {
134
+ return (parseWindowsProcessProbeRecords(stdout).find((record) => record.pid !== currentPid) ?? null);
135
+ }
136
+ export function parseWindowsBridgeProcessProbeOutput(stdout, currentPid = process.pid) {
137
+ return parseWindowsProcessProbeRecords(stdout)
132
138
  .filter((record) => record.pid !== currentPid && isWechatBridgeCommandLine(record.commandLine));
133
139
  }
134
140
  export function parsePosixBridgeProcessProbeOutput(stdout, currentPid = process.pid) {
@@ -195,10 +201,37 @@ export function listPeerBridgeProcesses(currentPid = process.pid) {
195
201
  ? listWindowsBridgeProcesses(currentPid)
196
202
  : listPosixBridgeProcesses(currentPid);
197
203
  }
204
+ function getWindowsProcessRecordByPid(pid, currentPid = process.pid) {
205
+ const probe = spawnSync("powershell.exe", [
206
+ "-NoLogo",
207
+ "-NoProfile",
208
+ "-NonInteractive",
209
+ "-ExecutionPolicy",
210
+ "Bypass",
211
+ "-Command",
212
+ [
213
+ "$ErrorActionPreference='Stop';",
214
+ `Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}'`,
215
+ "| Select-Object ProcessId,ParentProcessId,Name,CommandLine",
216
+ "| ConvertTo-Json -Compress",
217
+ ].join(" "),
218
+ ], {
219
+ encoding: "utf8",
220
+ windowsHide: true,
221
+ timeout: 8_000,
222
+ });
223
+ if (probe.status !== 0 || typeof probe.stdout !== "string") {
224
+ return null;
225
+ }
226
+ return parseWindowsProcessRecordProbeOutput(probe.stdout, currentPid);
227
+ }
198
228
  export function getProcessRecordByPid(pid, currentPid = process.pid) {
199
229
  if (!Number.isInteger(pid) || pid <= 0 || pid === currentPid) {
200
230
  return null;
201
231
  }
232
+ if (process.platform === "win32") {
233
+ return getWindowsProcessRecordByPid(pid, currentPid);
234
+ }
202
235
  return listAllProcessesRaw(currentPid).find((record) => record.pid === pid) ?? null;
203
236
  }
204
237
  export function listWechatDaemonProcesses(params = {}) {
@@ -1,6 +1,6 @@
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
- import { buildInstanceId } from "./bridge-utils.js";
3
+ import { buildInstanceId } from "../core/text-utils.js";
4
4
  import { writeJsonFileAtomic } from "../utils/atomic-file.js";
5
5
  import { getProcessRecordByPid, isWechatBridgeCommandLine, } from "./bridge-process-reaper.js";
6
6
  const ORPHAN_LOCK_RECLAIM_TIMEOUT_MS = 2_000;
@@ -79,6 +79,9 @@ export function normalizeBridgeLockPayload(value) {
79
79
  startedAt: record.startedAt,
80
80
  lifecycle: record.lifecycle === "companion_bound" ? "companion_bound" : "persistent",
81
81
  legacyLifecycleFallback: hasExplicitLifecycle ? undefined : true,
82
+ ...(record.channelId === "wechat" || record.channelId === "wecom"
83
+ ? { channelId: record.channelId }
84
+ : {}),
82
85
  };
83
86
  }
84
87
  export function readBridgeLockFile() {
@@ -148,11 +151,13 @@ function buildLockConflictError(lock) {
148
151
  * pid is alive but belongs to something else, and "unknown" when the command
149
152
  * line could not be resolved (probe failure, restricted permissions).
150
153
  */
151
- export function classifyLockHolderProcess(pid) {
152
- if (!isPidAlive(pid)) {
154
+ export function classifyLockHolderProcess(pid, dependencies = {}) {
155
+ const isProcessAlive = dependencies.isPidAlive ?? isPidAlive;
156
+ const getProcessRecord = dependencies.getProcessRecordByPid ?? getProcessRecordByPid;
157
+ if (!isProcessAlive(pid)) {
153
158
  return "unknown";
154
159
  }
155
- const record = getProcessRecordByPid(pid);
160
+ const record = getProcessRecord(pid);
156
161
  if (!record) {
157
162
  return "unknown";
158
163
  }
@@ -200,6 +205,7 @@ export class BridgeStateStore {
200
205
  cwd: options.cwd,
201
206
  startedAt: new Date(this.bridgeStartedAtMs).toISOString(),
202
207
  lifecycle: options.lifecycle,
208
+ channelId: options.channelId,
203
209
  };
204
210
  this.acquireLock();
205
211
  const shouldRestoreSessionState = options.sessionStartMode !== "new";
@@ -231,6 +237,7 @@ export class BridgeStateStore {
231
237
  resumeConversationId: persistedResumeConversationId,
232
238
  transcriptPath: persistedTranscriptPath,
233
239
  lastActivityAt: persisted?.lastActivityAt,
240
+ channelId: options.channelId,
234
241
  pendingConfirmation: null,
235
242
  pendingUserInput: null,
236
243
  };