cli-wechat-bridge 1.1.4 → 1.1.6

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 (42) hide show
  1. package/README.md +153 -131
  2. package/bin/_run-entry.mjs +20 -17
  3. package/dist/bridge/bridge-adapters.claude.js +429 -11
  4. package/dist/bridge/bridge-adapters.codex.js +441 -22
  5. package/dist/bridge/bridge-adapters.core.js +2 -1
  6. package/dist/bridge/bridge-adapters.opencode.js +392 -53
  7. package/dist/bridge/bridge-adapters.pi.js +33 -7
  8. package/dist/bridge/bridge-adapters.shared.js +2 -1
  9. package/dist/bridge/bridge-controller.js +3 -0
  10. package/dist/bridge/bridge-final-reply.js +2 -65
  11. package/dist/bridge/bridge-session-resume.js +151 -0
  12. package/dist/bridge/bridge-state.js +1 -1
  13. package/dist/bridge/bridge-utils.js +30 -101
  14. package/dist/bridge/claude-hooks.js +1 -0
  15. package/dist/bridge/wechat-bridge.js +256 -211
  16. package/dist/bridge/wechat-forwarding.js +2 -58
  17. package/dist/bridge/wechat-outbound-queue.js +2 -0
  18. package/dist/channels/wechat/bridge-final-reply.js +65 -0
  19. package/dist/channels/wechat/channel-message.js +24 -0
  20. package/dist/channels/wechat/index.js +5 -0
  21. package/dist/channels/wechat/wechat-channel-port.js +67 -0
  22. package/dist/channels/wechat/wechat-forwarding.js +79 -0
  23. package/dist/channels/wechat/wechat-outbound-queue.js +72 -0
  24. package/dist/companion/codex-remote-client.js +291 -32
  25. package/dist/companion/codex-visible-client-link.js +122 -0
  26. package/dist/companion/local-companion-link.js +26 -0
  27. package/dist/companion/local-companion-start.js +0 -1
  28. package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
  29. package/dist/companion/pi-tui-bridge-extension.js +42 -3
  30. package/dist/core/bridge-control.js +32 -0
  31. package/dist/core/bridge-event-forwarder.js +50 -0
  32. package/dist/core/bridge-message-router.js +32 -0
  33. package/dist/core/channel-types.js +1 -0
  34. package/dist/core/text-utils.js +63 -0
  35. package/dist/daemon/wechat-daemon.js +242 -159
  36. package/dist/i18n/messages-en.js +2 -2
  37. package/dist/i18n/messages-zh.js +2 -2
  38. package/package.json +105 -109
  39. package/bin/wechat-claude-start.mjs +0 -10
  40. package/bin/wechat-codex-start.mjs +0 -10
  41. package/bin/wechat-opencode-start.mjs +0 -10
  42. package/bin/wechat-pi-start.mjs +0 -10
@@ -1,16 +1,73 @@
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";
10
+ import { requestCodexVisibleThreadSwitch } from "../companion/codex-visible-client-link.js";
9
11
  import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
10
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
+ }
11
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;
12
27
  const CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS = 150;
13
28
  const CODEX_NON_BLOCKING_USER_INPUT_TIMEOUT_MS = 120_000;
29
+ const CODEX_RESUME_REPLAY_SETTLE_MS = 5_000;
30
+ function getCodexThreadStatusType(value) {
31
+ if (typeof value === "string") {
32
+ return value;
33
+ }
34
+ return isRecord(value) && typeof value.type === "string" ? value.type : null;
35
+ }
36
+ function getCodexThreadTitle(thread) {
37
+ const name = typeof thread.name === "string" ? normalizeOutput(thread.name).trim() : "";
38
+ const preview = typeof thread.preview === "string" ? normalizeOutput(thread.preview).trim() : "";
39
+ const threadId = typeof thread.id === "string" ? thread.id : "";
40
+ return truncatePreview(name || preview || `Codex thread ${threadId.slice(0, 8)}`, 120);
41
+ }
42
+ export function buildCodexResumeCandidatesFromThreadList(response, cwd, limit) {
43
+ if (!isRecord(response) || !Array.isArray(response.data)) {
44
+ throw new Error("Codex returned an invalid thread/list response.");
45
+ }
46
+ const currentCwd = normalizeComparablePath(cwd);
47
+ return response.data
48
+ .filter((thread) => isRecord(thread))
49
+ .filter((thread) => typeof thread.id === "string")
50
+ .filter((thread) => {
51
+ const threadCwd = typeof thread.cwd === "string" ? thread.cwd : "";
52
+ return threadCwd && normalizeComparablePath(threadCwd) === currentCwd;
53
+ })
54
+ .filter((thread) => typeof thread.parentThreadId !== "string")
55
+ .map((thread) => {
56
+ const updatedAt = typeof thread.updatedAt === "number" && Number.isFinite(thread.updatedAt)
57
+ ? thread.updatedAt * 1_000
58
+ : 0;
59
+ const threadId = thread.id;
60
+ return {
61
+ sessionId: threadId,
62
+ threadId,
63
+ title: getCodexThreadTitle(thread),
64
+ lastUpdatedAt: new Date(updatedAt).toISOString(),
65
+ source: "codex",
66
+ };
67
+ })
68
+ .sort((left, right) => Date.parse(right.lastUpdatedAt) - Date.parse(left.lastUpdatedAt))
69
+ .slice(0, Math.max(1, limit));
70
+ }
14
71
  export class CodexPtyAdapter extends AbstractPtyAdapter {
15
72
  runtimeKind = "codex_runtime_host";
16
73
  appServer = null;
@@ -27,8 +84,12 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
27
84
  rpcRequestCounter = 0;
28
85
  pendingRpcRequests = new Map();
29
86
  subscribedThreadIds = new Set();
87
+ pendingThreadSubscriptions = new Map();
30
88
  sharedThreadId = null;
31
89
  announcedThreadId = null;
90
+ localThreadFollowBlockedUntilMs = 0;
91
+ pendingThreadStatusChecks = new Map();
92
+ threadStatusCheckEpoch = 0;
32
93
  pendingThreadAnnouncement = null;
33
94
  activeTurn = null;
34
95
  bridgeOwnedTurnIds = new Set();
@@ -37,6 +98,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
37
98
  pendingTurnThreadId = null;
38
99
  interruptPendingTurnStart = false;
39
100
  pendingThreadFollowId = null;
101
+ pendingVisibleResume = null;
102
+ bridgeResumeReplayThreadId = null;
103
+ bridgeResumeReplayUntilMs = 0;
40
104
  pendingApprovalRequests = [];
41
105
  pendingUserInputRequest = null;
42
106
  pendingUserInputTimer = null;
@@ -65,6 +129,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
65
129
  finalReplyCompletionTimer = null;
66
130
  finalReplyCompletionTurnId = null;
67
131
  resumeThreadId;
132
+ codexCliVersion = null;
68
133
  localClientInstanceId = `${process.pid}-${Date.now().toString(36)}`;
69
134
  constructor(options) {
70
135
  super(options);
@@ -83,6 +148,13 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
83
148
  if (this.isHeadlessRuntimeMode()) {
84
149
  this.setStatus("starting", `Starting ${this.options.kind} runtime host...`);
85
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}.`);
86
158
  await this.startAppServer();
87
159
  await this.connectRpcClient();
88
160
  await this.restoreInitialSharedThreadIfNeeded();
@@ -165,13 +237,16 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
165
237
  this.writeToPty("\r");
166
238
  }
167
239
  async listResumeSessions(limit = 10) {
240
+ if (this.usesRpcTurnTransport()) {
241
+ return await this.listAppServerResumeSessions(limit);
242
+ }
168
243
  return listCodexResumeSessions(this.options.cwd, limit);
169
244
  }
170
245
  async resumeSession(threadId) {
171
- if (this.isNativePanelMode()) {
172
- throw new Error('WeChat /resume is disabled in codex mode. Use /resume directly inside "wechat-codex"; WeChat will follow the active local thread.');
246
+ if (!this.isHeadlessRuntimeMode()) {
247
+ throw new Error('WeChat /resume requires the managed "wechat-codex" visible client.');
173
248
  }
174
- await this.resumeSharedThread(threadId);
249
+ await this.resumeVisibleSharedThread(threadId);
175
250
  }
176
251
  async interrupt() {
177
252
  if (this.usesRpcTurnTransport()) {
@@ -292,6 +367,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
292
367
  profile: this.options.profile,
293
368
  sharedSessionId: this.state.sharedSessionId,
294
369
  sharedThreadId: this.state.sharedThreadId,
370
+ codexVisibleThreadId: this.state.sharedThreadId,
295
371
  resumeConversationId: this.state.resumeConversationId,
296
372
  transcriptPath: this.state.transcriptPath,
297
373
  startedAt: this.state.startedAt ?? nowIso(),
@@ -359,6 +435,24 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
359
435
  }
360
436
  return this.isNativePanelMode() ? Boolean(this.nativeProcess) : Boolean(this.pty);
361
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
+ }
362
456
  shouldPollSessionLog() {
363
457
  return (this.isCodexClientRunning() ||
364
458
  this.pendingTurnStart ||
@@ -610,7 +704,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
610
704
  if (this.sessionFilePath && candidate.modifiedAtMs <= currentSessionModifiedAtMs) {
611
705
  return;
612
706
  }
613
- if (!this.activeTurn || this.activeTurn.threadId === candidate.threadId) {
707
+ if (!this.activeTurn ||
708
+ this.activeTurn.threadId === candidate.threadId ||
709
+ this.activeTurn.origin === "wechat") {
614
710
  this.trackLocalSharedThread(candidate.threadId, {
615
711
  reason: "local_session_fallback",
616
712
  signal: "session_fallback",
@@ -1286,13 +1382,18 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1286
1382
  if (this.subscribedThreadIds.has(threadId)) {
1287
1383
  return true;
1288
1384
  }
1289
- try {
1290
- await this.ensureSharedThreadSubscribed(threadId);
1291
- return true;
1292
- }
1293
- catch {
1294
- return false;
1385
+ const pending = this.pendingThreadSubscriptions.get(threadId);
1386
+ if (pending) {
1387
+ return await pending;
1295
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;
1296
1397
  }
1297
1398
  async ensureSharedThreadSubscribed(threadId) {
1298
1399
  if (this.subscribedThreadIds.has(threadId)) {
@@ -1310,10 +1411,177 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1310
1411
  if (!resumedThreadId) {
1311
1412
  throw new Error("Codex did not return a thread id while subscribing the bridge client.");
1312
1413
  }
1414
+ if (resumedThreadId !== threadId) {
1415
+ throw new Error(`Codex opened ${resumedThreadId}, expected ${threadId}.`);
1416
+ }
1417
+ this.validateCodexThreadWorkspaceIfPresent(response, threadId);
1313
1418
  this.rememberBridgeOwnedThreadSignal(resumedThreadId);
1314
1419
  this.subscribedThreadIds.add(resumedThreadId);
1315
- if (resumedThreadId !== this.sharedThreadId) {
1316
- this.updateSharedThread(resumedThreadId);
1420
+ }
1421
+ async listAppServerResumeSessions(limit) {
1422
+ const boundedLimit = Math.max(1, Math.min(100, limit));
1423
+ const baseParams = {
1424
+ limit: boundedLimit,
1425
+ sortKey: "updated_at",
1426
+ sortDirection: "desc",
1427
+ sourceKinds: ["cli", "vscode"],
1428
+ archived: false,
1429
+ cwd: this.options.cwd,
1430
+ };
1431
+ try {
1432
+ const fastResponse = await this.sendRpcRequest("thread/list", {
1433
+ ...baseParams,
1434
+ useStateDbOnly: true,
1435
+ });
1436
+ const fastCandidates = buildCodexResumeCandidatesFromThreadList(fastResponse, this.options.cwd, boundedLimit);
1437
+ if (fastCandidates.length > 0) {
1438
+ return fastCandidates;
1439
+ }
1440
+ }
1441
+ catch {
1442
+ // Older supported app-server builds may not understand useStateDbOnly.
1443
+ // The scan-and-repair request below is also the authoritative fallback.
1444
+ }
1445
+ const response = await this.sendRpcRequest("thread/list", baseParams);
1446
+ return buildCodexResumeCandidatesFromThreadList(response, this.options.cwd, boundedLimit);
1447
+ }
1448
+ validateCodexResumeThread(response, targetThreadId) {
1449
+ if (!isRecord(response) || !isRecord(response.thread)) {
1450
+ throw new Error("Codex returned an invalid thread response.");
1451
+ }
1452
+ const thread = response.thread;
1453
+ if (thread.id !== targetThreadId) {
1454
+ throw new Error(`Codex opened ${String(thread.id)}, expected ${targetThreadId}.`);
1455
+ }
1456
+ if (typeof thread.cwd !== "string" ||
1457
+ normalizeComparablePath(thread.cwd) !== normalizeComparablePath(this.options.cwd)) {
1458
+ throw new Error(`Codex thread ${targetThreadId} does not belong to ${this.options.cwd}.`);
1459
+ }
1460
+ if (typeof thread.parentThreadId === "string") {
1461
+ throw new Error(`Codex thread ${targetThreadId} is a subagent thread and cannot be resumed.`);
1462
+ }
1463
+ if (thread.ephemeral === true) {
1464
+ throw new Error(`Codex thread ${targetThreadId} is ephemeral and cannot be resumed.`);
1465
+ }
1466
+ if (getCodexThreadStatusType(thread.status) === "active") {
1467
+ throw new Error(`Codex thread ${targetThreadId} is still active. Stop its running task before resuming it from WeChat.`);
1468
+ }
1469
+ return thread;
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
+ }
1490
+ async resumeVisibleSharedThread(threadId) {
1491
+ const targetThreadId = threadId.trim();
1492
+ if (!targetThreadId) {
1493
+ throw new Error("A thread id is required to resume a Codex thread.");
1494
+ }
1495
+ if (this.pendingVisibleResume) {
1496
+ throw new Error("Codex is already switching visible threads.");
1497
+ }
1498
+ if (this.pendingApproval || this.pendingApprovalRequests.length > 0) {
1499
+ throw new Error("A Codex approval request is pending. Reply with /confirm or /deny.");
1500
+ }
1501
+ if (this.pendingUserInputRequest || this.state.pendingUserInput) {
1502
+ throw new Error("Codex is waiting for user input. Reply with /answer or use /stop.");
1503
+ }
1504
+ if (this.pendingTurnStart ||
1505
+ this.activeTurn ||
1506
+ this.state.status === "busy" ||
1507
+ this.state.status === "awaiting_approval" ||
1508
+ this.state.status === "awaiting_input") {
1509
+ throw new Error("codex is still working. Wait for the current reply or use /stop.");
1510
+ }
1511
+ if (this.state.status !== "idle") {
1512
+ throw new Error(`Codex cannot switch threads while its status is ${this.state.status}.`);
1513
+ }
1514
+ const previousThreadId = this.sharedThreadId;
1515
+ let targetSubscribed = false;
1516
+ this.pendingVisibleResume = { targetThreadId };
1517
+ this.setStatus("starting", `Codex resume validating thread ${targetThreadId.slice(0, 12)}...`);
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)}.`);
1525
+ this.rememberBridgeOwnedThreadSignal(targetThreadId);
1526
+ this.setStatus("starting", `Codex resume requesting thread ${targetThreadId.slice(0, 12)}.`);
1527
+ const resumeResponse = await this.sendRpcRequest("thread/resume", {
1528
+ threadId: targetThreadId,
1529
+ approvalPolicy: "on-request",
1530
+ approvalsReviewer: "user",
1531
+ sandbox: "workspace-write",
1532
+ excludeTurns: true,
1533
+ });
1534
+ targetSubscribed = true;
1535
+ this.validateCodexResumeThread(resumeResponse, targetThreadId);
1536
+ this.subscribedThreadIds.add(targetThreadId);
1537
+ this.setStatus("starting", `Codex resume switching visible thread ${targetThreadId.slice(0, 12)}.`);
1538
+ const visibleThreadId = await this.switchVisibleCodexThread(targetThreadId);
1539
+ if (visibleThreadId !== targetThreadId) {
1540
+ throw new Error(`The visible Codex client opened ${visibleThreadId}, expected ${targetThreadId}.`);
1541
+ }
1542
+ this.sessionFilePath = null;
1543
+ this.sessionReadOffset = 0;
1544
+ this.sessionPartialLine = "";
1545
+ this.sessionFinalText = null;
1546
+ this.pendingThreadFollowId = null;
1547
+ this.updateSharedThread(targetThreadId, {
1548
+ source: "wechat",
1549
+ reason: "wechat_resume",
1550
+ notify: true,
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)}.`);
1555
+ this.setStatus("idle");
1556
+ if (previousThreadId && previousThreadId !== targetThreadId) {
1557
+ await this.unsubscribeCodexThreadBestEffort(previousThreadId);
1558
+ }
1559
+ }
1560
+ catch (error) {
1561
+ if (targetSubscribed && targetThreadId !== previousThreadId) {
1562
+ await this.unsubscribeCodexThreadBestEffort(targetThreadId);
1563
+ }
1564
+ this.setStatus("idle", `Codex resume failed for ${targetThreadId.slice(0, 12)}: ${describeUnknownError(error)}`);
1565
+ throw error;
1566
+ }
1567
+ finally {
1568
+ this.pendingVisibleResume = null;
1569
+ }
1570
+ }
1571
+ async switchVisibleCodexThread(threadId) {
1572
+ return await requestCodexVisibleThreadSwitch({
1573
+ cwd: this.options.cwd,
1574
+ instanceId: this.localClientInstanceId,
1575
+ threadId,
1576
+ });
1577
+ }
1578
+ async unsubscribeCodexThreadBestEffort(threadId) {
1579
+ try {
1580
+ await this.sendRpcRequest("thread/unsubscribe", { threadId });
1581
+ this.subscribedThreadIds.delete(threadId);
1582
+ }
1583
+ catch {
1584
+ // A failed unsubscribe must not undo an otherwise successful visible switch.
1317
1585
  }
1318
1586
  }
1319
1587
  async resumeSharedThread(threadId, options = {}) {
@@ -1343,6 +1611,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1343
1611
  if (!resumedThreadId) {
1344
1612
  throw new Error("Codex did not return a thread id while resuming the saved thread.");
1345
1613
  }
1614
+ this.validateCodexThreadWorkspaceIfPresent(response, resumedThreadId);
1346
1615
  this.rememberBridgeOwnedThreadSignal(resumedThreadId);
1347
1616
  this.subscribedThreadIds.add(resumedThreadId);
1348
1617
  this.sessionFilePath = null;
@@ -1490,6 +1759,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1490
1759
  this.completedTurnOrder = [];
1491
1760
  }
1492
1761
  this.pendingInjectedInputs = [];
1762
+ this.threadStatusCheckEpoch += 1;
1763
+ this.pendingThreadStatusChecks.clear();
1764
+ this.clearBridgeResumeReplay();
1493
1765
  this.recentBridgeThreadSignalAtById.clear();
1494
1766
  this.sessionFinalText = null;
1495
1767
  this.nextSessionFallbackScanAtMs = 0;
@@ -1556,6 +1828,10 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1556
1828
  });
1557
1829
  }
1558
1830
  }
1831
+ blockLateLocalThreadFollow() {
1832
+ this.localThreadFollowBlockedUntilMs =
1833
+ Date.now() + CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS;
1834
+ }
1559
1835
  clearPendingThreadAnnouncement() {
1560
1836
  if (!this.pendingThreadAnnouncement) {
1561
1837
  return;
@@ -1614,12 +1890,29 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1614
1890
  pending.timer.unref?.();
1615
1891
  }
1616
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
+ }
1902
+ if (this.pendingVisibleResume) {
1903
+ return;
1904
+ }
1905
+ this.interruptWechatTurnForLocalThreadSwitch(threadId);
1617
1906
  if (!this.isNativePanelMode()) {
1907
+ const threadChanged = this.sharedThreadId !== threadId;
1618
1908
  this.updateSharedThread(threadId, {
1619
1909
  source: "local",
1620
1910
  reason: options.reason,
1621
1911
  notify: true,
1622
1912
  });
1913
+ if (threadChanged) {
1914
+ void this.tryEnsureSharedThreadSubscribed(threadId);
1915
+ }
1623
1916
  return;
1624
1917
  }
1625
1918
  this.updateSharedThread(threadId, {
@@ -1655,6 +1948,34 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1655
1948
  }
1656
1949
  this.schedulePendingThreadAnnouncement();
1657
1950
  }
1951
+ interruptWechatTurnForLocalThreadSwitch(nextThreadId) {
1952
+ const activeTurn = this.activeTurn;
1953
+ if (!activeTurn ||
1954
+ activeTurn.origin !== "wechat" ||
1955
+ activeTurn.threadId === nextThreadId) {
1956
+ return;
1957
+ }
1958
+ const interruptedTurnId = activeTurn.turnId;
1959
+ void this.sendRpcRequest("turn/interrupt", {
1960
+ threadId: activeTurn.threadId,
1961
+ turnId: interruptedTurnId,
1962
+ }).catch(() => undefined);
1963
+ this.clearInterruptTimer();
1964
+ this.clearFinalReplyCompletionTimerForTurn(interruptedTurnId);
1965
+ this.clearPendingApprovalState();
1966
+ this.clearPendingUserInputState();
1967
+ this.pendingThreadFollowId = null;
1968
+ this.cleanupTurnArtifacts(interruptedTurnId);
1969
+ this.rememberCompletedTurn(interruptedTurnId);
1970
+ this.bridgeOwnedTurnIds.add(interruptedTurnId);
1971
+ this.setActiveTurn(null);
1972
+ this.setStatus("idle");
1973
+ this.emit({
1974
+ type: "task_failed",
1975
+ message: "The active WeChat task was interrupted because the local Codex terminal switched threads.",
1976
+ timestamp: nowIso(),
1977
+ });
1978
+ }
1658
1979
  rememberBridgeOwnedThreadSignal(threadId) {
1659
1980
  const cutoff = Date.now() - CODEX_THREAD_SIGNAL_TTL_MS;
1660
1981
  for (const [candidateThreadId, recordedAtMs] of this.recentBridgeThreadSignalAtById.entries()) {
@@ -1675,6 +1996,20 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1675
1996
  }
1676
1997
  return true;
1677
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
+ }
1678
2013
  clearPendingApprovalState() {
1679
2014
  this.pendingApprovalRequests = [];
1680
2015
  this.pendingApproval = null;
@@ -2171,7 +2506,71 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2171
2506
  if (!threadId) {
2172
2507
  return;
2173
2508
  }
2174
- if (!this.activeTurn || this.activeTurn.threadId === threadId) {
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) {
2571
+ if (!this.activeTurn ||
2572
+ this.activeTurn.threadId === threadId ||
2573
+ this.activeTurn.origin === "wechat") {
2175
2574
  this.trackLocalSharedThread(threadId, {
2176
2575
  reason: "local_follow",
2177
2576
  signal: "status_changed",
@@ -2186,16 +2585,24 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2186
2585
  if (!threadId) {
2187
2586
  return;
2188
2587
  }
2588
+ if (this.isBridgeResumeReplay(threadId)) {
2589
+ return;
2590
+ }
2189
2591
  if (this.isRecentlyBridgeOwnedThread(threadId)) {
2190
2592
  return;
2191
2593
  }
2192
2594
  const thread = isRecord(params.thread) ? params.thread : null;
2193
- if (thread && typeof thread.cwd === "string") {
2194
- 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) {
2195
2600
  return;
2196
2601
  }
2197
2602
  }
2198
- if (!this.activeTurn || this.activeTurn.threadId === threadId) {
2603
+ if (!this.activeTurn ||
2604
+ this.activeTurn.threadId === threadId ||
2605
+ this.activeTurn.origin === "wechat") {
2199
2606
  this.trackLocalSharedThread(threadId, {
2200
2607
  reason: "local_follow",
2201
2608
  signal: "thread_started",
@@ -2209,9 +2616,14 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2209
2616
  if (this.activeTurn?.turnId === trackedTurn.turnId) {
2210
2617
  return;
2211
2618
  }
2619
+ if (trackedTurn.origin === "local") {
2620
+ this.clearBridgeResumeReplay();
2621
+ }
2212
2622
  if (trackedTurn.origin === "local" &&
2213
2623
  trackedTurn.threadId !== this.sharedThreadId) {
2214
- if (!this.activeTurn || this.activeTurn.threadId === trackedTurn.threadId) {
2624
+ if (!this.activeTurn ||
2625
+ this.activeTurn.threadId === trackedTurn.threadId ||
2626
+ this.activeTurn.origin === "wechat") {
2215
2627
  this.trackLocalSharedThread(trackedTurn.threadId, {
2216
2628
  reason: "local_turn",
2217
2629
  signal: "turn_started",
@@ -2285,6 +2697,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2285
2697
  if (this.activeTurn?.turnId === trackedTurn.turnId) {
2286
2698
  this.setActiveTurn(null);
2287
2699
  }
2700
+ if (completedTrackedTurn.origin === "wechat") {
2701
+ this.blockLateLocalThreadFollow();
2702
+ }
2288
2703
  this.cleanupTurnArtifacts(trackedTurn.turnId);
2289
2704
  if (this.state.status !== "stopped" &&
2290
2705
  (!this.activeTurn || this.activeTurn.turnId === trackedTurn.turnId)) {
@@ -2424,6 +2839,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2424
2839
  if (!activeTurn || !finalText) {
2425
2840
  return;
2426
2841
  }
2842
+ if (activeTurn.origin === "wechat") {
2843
+ this.blockLateLocalThreadFollow();
2844
+ }
2427
2845
  this.clearPendingApprovalState();
2428
2846
  this.clearPendingUserInputState();
2429
2847
  this.setActiveTurn(null);
@@ -2484,11 +2902,12 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2484
2902
  this.appServerAuthToken = null;
2485
2903
  }
2486
2904
  describeAppServerLog() {
2487
- const summary = normalizeOutput(this.appServerLog).trim();
2488
- if (!summary) {
2905
+ const normalized = normalizeOutput(this.appServerLog).trim();
2906
+ if (!normalized) {
2489
2907
  return "";
2490
2908
  }
2491
- return ` Recent app-server log: ${truncatePreview(summary, 220)}`;
2909
+ const summary = summarizeOutput(normalized, 500);
2910
+ return ` Recent app-server log: ${summary}`;
2492
2911
  }
2493
2912
  terminateCodexClient() {
2494
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;