@unlinearity/cli-wechat-bridge 1.1.6 → 1.2.1

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 (88) hide show
  1. package/LICENSE.txt +661 -661
  2. package/README.md +468 -475
  3. package/bin/_run-entry.mjs +29 -6
  4. package/bin/wechat-check-update.mjs +6 -1
  5. package/bin/wechat-claude.mjs +6 -1
  6. package/bin/wechat-codex.mjs +6 -1
  7. package/bin/wechat-daemon.mjs +6 -1
  8. package/bin/wechat-opencode.mjs +6 -1
  9. package/bin/wechat-pi.mjs +6 -1
  10. package/bin/wechat-setup.mjs +6 -1
  11. package/bin/wecom-check-update.mjs +10 -0
  12. package/bin/wecom-claude.mjs +10 -0
  13. package/bin/wecom-codex.mjs +10 -0
  14. package/bin/wecom-daemon.mjs +10 -0
  15. package/bin/wecom-opencode.mjs +10 -0
  16. package/bin/wecom-pi.mjs +10 -0
  17. package/bin/wecom-setup.mjs +10 -0
  18. package/dist/bridge/adapter-control.js +58 -0
  19. package/dist/bridge/bridge-adapters.claude.js +161 -4
  20. package/dist/bridge/bridge-adapters.codex.js +206 -14
  21. package/dist/bridge/bridge-adapters.core.js +25 -1
  22. package/dist/bridge/bridge-adapters.opencode.js +298 -20
  23. package/dist/bridge/bridge-adapters.pi.js +45 -0
  24. package/dist/bridge/bridge-adapters.shared.js +6 -0
  25. package/dist/bridge/bridge-controller.js +6 -0
  26. package/dist/bridge/bridge-final-reply.js +6 -0
  27. package/dist/bridge/bridge-process-reaper.js +41 -2
  28. package/dist/bridge/bridge-state.js +16 -3
  29. package/dist/bridge/bridge-utils.js +6 -31
  30. package/dist/bridge/claude-hook.js +6 -0
  31. package/dist/bridge/claude-native-controls.js +145 -0
  32. package/dist/bridge/codex-visible-thread-proxy.js +140 -0
  33. package/dist/bridge/native-terminal-control.js +271 -0
  34. package/dist/bridge/opencode-v2-compat.js +450 -0
  35. package/dist/bridge/wechat-bridge.js +380 -171
  36. package/dist/bridge/wechat-forwarding.js +6 -0
  37. package/dist/bridge/wechat-outbound-queue.js +6 -0
  38. package/dist/channels/wechat/bridge-final-reply.js +13 -22
  39. package/dist/channels/wechat/index.js +6 -0
  40. package/dist/channels/wechat/wechat-driver.js +191 -0
  41. package/dist/channels/wechat/wechat-forwarding.js +18 -0
  42. package/dist/channels/wechat/wechat-outbound-queue.js +49 -3
  43. package/dist/channels/wecom/bridge-final-reply.js +32 -0
  44. package/dist/channels/wecom/setup-cli.js +12 -0
  45. package/dist/channels/wecom/setup.js +182 -0
  46. package/dist/channels/wecom/wecom-channel-port.js +45 -0
  47. package/dist/channels/wecom/wecom-config.js +72 -0
  48. package/dist/channels/wecom/wecom-driver.js +106 -0
  49. package/dist/channels/wecom/wecom-media.js +147 -0
  50. package/dist/channels/wecom/wecom-message.js +149 -0
  51. package/dist/channels/wecom/wecom-transport.js +314 -0
  52. package/dist/commands/check-update.js +6 -0
  53. package/dist/companion/codex-remote-client.js +30 -3
  54. package/dist/companion/codex-visible-client-link.js +107 -0
  55. package/dist/companion/local-companion-link.js +6 -0
  56. package/dist/companion/local-companion-start.js +62 -11
  57. package/dist/companion/local-companion.js +25 -1
  58. package/dist/companion/opencode-tui-bridge-plugin.js +6 -0
  59. package/dist/companion/opencode-v2-tui-bridge-plugin.js +93 -0
  60. package/dist/companion/pi-tui-bridge-extension.js +62 -3
  61. package/dist/core/bridge-control.js +10 -0
  62. package/dist/core/bridge-defer.js +25 -0
  63. package/dist/core/channel-driver.js +1 -0
  64. package/dist/core/channel-types.js +3 -1
  65. package/dist/core/conversation-routing.js +58 -0
  66. package/dist/core/direct-run.js +23 -0
  67. package/dist/core/inbound-message-claims.js +67 -0
  68. package/dist/core/outbound-target.js +32 -0
  69. package/dist/core/text-utils.js +6 -0
  70. package/dist/core/turn-coordinator.js +85 -0
  71. package/dist/daemon/daemon-link.js +9 -0
  72. package/dist/daemon/emoji-bindings.js +6 -0
  73. package/dist/daemon/wechat-daemon.js +675 -122
  74. package/dist/i18n/index.js +6 -0
  75. package/dist/i18n/messages-en.js +9 -1
  76. package/dist/i18n/messages-zh.js +9 -1
  77. package/dist/runtime/create-runtime-host.js +6 -0
  78. package/dist/runtime/legacy-adapter-runtime.js +15 -0
  79. package/dist/runtime/runtime-types.js +3 -0
  80. package/dist/utils/atomic-file.js +6 -0
  81. package/dist/utils/doctor.js +36 -10
  82. package/dist/utils/version-checker.js +17 -11
  83. package/dist/wechat/channel-config.js +6 -0
  84. package/dist/wechat/setup.js +119 -13
  85. package/dist/wechat/wechat-channel.js +6 -0
  86. package/dist/wechat/wechat-transport.js +169 -58
  87. package/package.json +16 -4
  88. package/scripts/ensure-node-pty-permissions.mjs +73 -73
@@ -1,3 +1,9 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
1
7
  import fs from "node:fs";
2
8
  import path from "node:path";
3
9
  import crypto from "node:crypto";
@@ -6,9 +12,11 @@ import { detectCliApproval, } from "./bridge-utils.js";
6
12
  import { normalizeOutput, nowIso, summarizeOutput, truncatePreview, } from "../core/text-utils.js";
7
13
  import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
8
14
  import { killProcessTreeSync } from "./bridge-process-reaper.js";
15
+ import { CodexVisibleThreadProxy } from "./codex-visible-thread-proxy.js";
9
16
  import * as shared from "./bridge-adapters.shared.js";
10
- import { requestCodexVisibleThreadSwitch } from "../companion/codex-visible-client-link.js";
11
- import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
17
+ import { requestCodexVisibleClientShutdown, requestCodexVisibleThreadSwitch, } from "../companion/codex-visible-client-link.js";
18
+ import { readLocalCompanionEndpoint } from "../companion/local-companion-link.js";
19
+ import { appendBoundedLog as appendBridgeLog, BRIDGE_LOG_FILE, ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
12
20
  import { CODEX_REMOTE_AUTH_TOKEN_ENV, LOCAL_CLIENT_PROTOCOL_VERSION, } from "../runtime/runtime-types.js";
13
21
  export function parseCodexCliVersion(output) {
14
22
  const match = output.match(/\b(\d+\.\d+\.\d+)\b/);
@@ -21,7 +29,7 @@ export function isCodexVersionInCompatibilityRange(version) {
21
29
  }
22
30
  const major = parts[0] ?? -1;
23
31
  const minor = parts[1] ?? -1;
24
- return major === 0 && minor >= 149 && minor <= 151;
32
+ return major === 0 && minor >= 149 && minor <= 157;
25
33
  }
26
34
  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;
27
35
  const CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS = 150;
@@ -73,11 +81,14 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
73
81
  appServer = null;
74
82
  nativeProcess = null;
75
83
  appServerPort = null;
84
+ visibleThreadProxy = null;
85
+ visibleOutOfWorkspaceThreadId = null;
76
86
  appServerShuttingDown = false;
77
87
  appServerLog = "";
78
88
  appServerAuthToken = null;
79
89
  appServerAuthTokenFilePath = null;
80
90
  rpcSocket = null;
91
+ lastRpcCloseDetail = "";
81
92
  rpcShuttingDown = false;
82
93
  rpcReconnectPromise = null;
83
94
  cleanPanelExitInProgress = false;
@@ -86,6 +97,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
86
97
  subscribedThreadIds = new Set();
87
98
  pendingThreadSubscriptions = new Map();
88
99
  sharedThreadId = null;
100
+ previousCollaborationMode = null;
89
101
  announcedThreadId = null;
90
102
  localThreadFollowBlockedUntilMs = 0;
91
103
  pendingThreadStatusChecks = new Map();
@@ -157,8 +169,18 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
157
169
  this.setStatus("starting", `Codex protocol compatibility: ${compatibility}.`);
158
170
  await this.startAppServer();
159
171
  await this.connectRpcClient();
160
- await this.restoreInitialSharedThreadIfNeeded();
161
172
  try {
173
+ if (this.isHeadlessRuntimeMode() && this.appServerPort && this.appServerAuthToken) {
174
+ const trace = (message) => appendBridgeLog(BRIDGE_LOG_FILE, `[${nowIso()}] codex_visible_proxy: ${message}\n`);
175
+ this.visibleThreadProxy = await CodexVisibleThreadProxy.start({
176
+ upstreamUrl: `ws://${CODEX_APP_SERVER_HOST}:${this.appServerPort}`,
177
+ token: this.appServerAuthToken,
178
+ onTrace: trace,
179
+ onThreadOpened: (threadId, cwd) => this.handleVisibleCodexThreadOpened(threadId, cwd, trace),
180
+ onVisibleTurn: (threadId) => this.handleVisibleCodexTurn(threadId, trace),
181
+ });
182
+ }
183
+ await this.restoreInitialSharedThreadIfNeeded();
162
184
  if (this.isNativePanelMode()) {
163
185
  await this.startNativeClient();
164
186
  }
@@ -182,6 +204,13 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
182
204
  throw err;
183
205
  }
184
206
  }
207
+ async prepareVisibleClientSession() {
208
+ if (this.isNativePanelMode() || this.sharedThreadId) {
209
+ return Boolean(this.sharedThreadId);
210
+ }
211
+ const threadId = await this.ensureThreadStarted();
212
+ return Boolean(threadId);
213
+ }
185
214
  buildSpawnArgs() {
186
215
  if (!this.appServerPort) {
187
216
  throw new Error("Codex app-server is not ready.");
@@ -248,6 +277,76 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
248
277
  }
249
278
  await this.resumeVisibleSharedThread(threadId);
250
279
  }
280
+ async listModels() {
281
+ if (!this.usesRpcTurnTransport() || !this.sharedThreadId) {
282
+ throw new Error("Codex model selection requires an active app-server thread.");
283
+ }
284
+ const response = await this.sendRpcRequest("model/list", { includeHidden: false });
285
+ const data = isRecord(response) && Array.isArray(response.data) ? response.data : [];
286
+ const current = await this.readCodexThreadSettings();
287
+ const models = data.flatMap((item) => {
288
+ if (!isRecord(item))
289
+ return [];
290
+ const id = typeof item.id === "string" ? item.id : typeof item.model === "string" ? item.model : "";
291
+ if (!id)
292
+ return [];
293
+ const displayName = typeof item.displayName === "string" ? item.displayName : id;
294
+ const efforts = Array.isArray(item.supportedReasoningEfforts)
295
+ ? item.supportedReasoningEfforts.filter((value) => typeof value === "string")
296
+ : undefined;
297
+ return [{ id, displayName, isCurrent: id === current.model, supportedReasoningEfforts: efforts }];
298
+ });
299
+ return models;
300
+ }
301
+ async selectModel(modelId) {
302
+ this.assertVisibleCodexWorkspace();
303
+ const models = await this.listModels();
304
+ const selected = models.find((model) => model.id === modelId);
305
+ if (!selected)
306
+ throw new Error(`Codex model ${modelId} is not available.`);
307
+ await this.sendRpcRequest("thread/settings/update", { threadId: this.sharedThreadId, model: selected.id });
308
+ return selected;
309
+ }
310
+ async setPlanMode(enabled) {
311
+ this.assertVisibleCodexWorkspace();
312
+ if (!this.usesRpcTurnTransport() || !this.sharedThreadId) {
313
+ throw new Error("Codex plan mode requires an active app-server thread.");
314
+ }
315
+ const settings = await this.readCodexThreadSettings();
316
+ if (enabled) {
317
+ const response = await this.sendRpcRequest("collaborationMode/list", {});
318
+ const modes = isRecord(response) && Array.isArray(response.data) ? response.data : [];
319
+ const plan = modes.find((item) => isRecord(item) && (item.mode === "plan" || item.name?.toString().toLowerCase() === "plan"));
320
+ if (!isRecord(plan))
321
+ throw new Error("Codex plan mode is not available in this app-server.");
322
+ this.previousCollaborationMode = isRecord(settings.collaborationMode) ? settings.collaborationMode : null;
323
+ const mode = typeof plan.mode === "string" ? plan.mode : "plan";
324
+ const model = typeof plan.model === "string" ? plan.model : settings.model;
325
+ const effort = plan.reasoning_effort ?? plan.reasoningEffort ?? null;
326
+ await this.sendRpcRequest("thread/settings/update", {
327
+ threadId: this.sharedThreadId,
328
+ collaborationMode: { mode, settings: { model, reasoning_effort: effort, developer_instructions: null } },
329
+ });
330
+ return true;
331
+ }
332
+ const previous = this.previousCollaborationMode ?? {
333
+ mode: "default",
334
+ settings: { model: settings.model, reasoning_effort: settings.effort ?? null, developer_instructions: null },
335
+ };
336
+ await this.sendRpcRequest("thread/settings/update", {
337
+ threadId: this.sharedThreadId,
338
+ collaborationMode: previous,
339
+ });
340
+ this.previousCollaborationMode = null;
341
+ return false;
342
+ }
343
+ async readCodexThreadSettings() {
344
+ const response = await this.sendRpcRequest("thread/read", { threadId: this.sharedThreadId, includeTurns: false });
345
+ const thread = isRecord(response) && isRecord(response.thread) ? response.thread : null;
346
+ if (!thread || typeof thread.model !== "string")
347
+ throw new Error("Codex did not return the current thread model.");
348
+ return { model: thread.model, effort: thread.reasoningEffort, collaborationMode: thread.collaborationMode };
349
+ }
251
350
  async interrupt() {
252
351
  if (this.usesRpcTurnTransport()) {
253
352
  return await this.interruptPanelTurn();
@@ -315,6 +414,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
315
414
  return true;
316
415
  }
317
416
  async dispose() {
417
+ await this.shutdownVisibleClient();
318
418
  this.resetTurnTracking({ preserveThread: false });
319
419
  if (this.isEmbeddedCliMode()) {
320
420
  this.detachLocalInputForwarding();
@@ -346,6 +446,25 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
346
446
  }
347
447
  await this.stopAppServer();
348
448
  }
449
+ async shutdownVisibleClient() {
450
+ const endpoint = readLocalCompanionEndpoint(this.options.cwd, { adapter: "codex" });
451
+ if (!endpoint?.companionPid ||
452
+ endpoint.instanceId !== this.localClientInstanceId ||
453
+ !endpoint.codexControlPort ||
454
+ !endpoint.codexControlToken) {
455
+ return;
456
+ }
457
+ try {
458
+ await requestCodexVisibleClientShutdown({
459
+ cwd: this.options.cwd,
460
+ instanceId: endpoint.instanceId,
461
+ });
462
+ }
463
+ catch {
464
+ // The visible client may already be exiting. Its endpoint is cleared
465
+ // by the supervisor when the child process finishes.
466
+ }
467
+ }
349
468
  getLocalClientEndpoint() {
350
469
  if (!this.isHeadlessRuntimeMode() || !this.appServerPort || !this.appServerAuthToken) {
351
470
  return null;
@@ -360,7 +479,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
360
479
  renderMode: "headless",
361
480
  bridgeOwnerPid: process.pid,
362
481
  serverPort: this.appServerPort,
363
- serverUrl: `ws://${CODEX_APP_SERVER_HOST}:${this.appServerPort}`,
482
+ serverUrl: this.visibleThreadProxy?.url ?? `ws://${CODEX_APP_SERVER_HOST}:${this.appServerPort}`,
364
483
  remoteAuthTokenEnv: CODEX_REMOTE_AUTH_TOKEN_ENV,
365
484
  cwd: this.options.cwd,
366
485
  command: this.options.command,
@@ -916,6 +1035,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
916
1035
  }
917
1036
  }
918
1037
  async sendPanelTurn(text) {
1038
+ this.assertVisibleCodexWorkspace();
919
1039
  if (this.isNativePanelMode() && !this.nativeProcess) {
920
1040
  throw new Error("codex panel is not running.");
921
1041
  }
@@ -1064,6 +1184,8 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1064
1184
  });
1065
1185
  this.appServer = null;
1066
1186
  this.appServerPort = null;
1187
+ void this.visibleThreadProxy?.close();
1188
+ this.visibleThreadProxy = null;
1067
1189
  this.appServerShuttingDown = false;
1068
1190
  this.deleteAppServerAuthTokenFile();
1069
1191
  this.appServerAuthToken = null;
@@ -1086,6 +1208,8 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1086
1208
  });
1087
1209
  this.appServer = null;
1088
1210
  this.appServerPort = null;
1211
+ void this.visibleThreadProxy?.close();
1212
+ this.visibleThreadProxy = null;
1089
1213
  this.appServerShuttingDown = false;
1090
1214
  this.deleteAppServerAuthTokenFile();
1091
1215
  this.appServerAuthToken = null;
@@ -1192,8 +1316,8 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1192
1316
  socket.addEventListener("message", (event) => {
1193
1317
  this.handleRpcMessageData(event.data);
1194
1318
  });
1195
- socket.addEventListener("close", () => {
1196
- this.handleRpcSocketClosed();
1319
+ socket.addEventListener("close", (event) => {
1320
+ this.handleRpcSocketClosed(event.code, event.reason);
1197
1321
  });
1198
1322
  }
1199
1323
  async disconnectRpcClient() {
@@ -1231,13 +1355,14 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1231
1355
  });
1232
1356
  this.rpcShuttingDown = false;
1233
1357
  }
1234
- handleRpcSocketClosed() {
1358
+ handleRpcSocketClosed(code, reason) {
1235
1359
  const expectedShutdown = shouldSuppressCodexTransportFatalError({
1236
1360
  transportShuttingDown: this.rpcShuttingDown,
1237
1361
  shuttingDown: this.shuttingDown,
1238
1362
  cleanPanelExitInProgress: this.cleanPanelExitInProgress,
1239
1363
  });
1240
1364
  this.rpcSocket = null;
1365
+ this.lastRpcCloseDetail = `close_code=${code ?? "unknown"}${reason ? ` reason=${truncatePreview(reason, 240)}` : ""}`;
1241
1366
  this.subscribedThreadIds.clear();
1242
1367
  this.rejectPendingRpcRequests("Codex websocket connection closed.");
1243
1368
  this.rpcShuttingDown = false;
@@ -1269,7 +1394,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1269
1394
  const details = this.describeAppServerLog();
1270
1395
  this.emit({
1271
1396
  type: "fatal_error",
1272
- message: `codex app-server websocket closed unexpectedly.${details}`,
1397
+ message: `codex app-server websocket closed unexpectedly (${this.lastRpcCloseDetail || "no close details"}).${details}`,
1273
1398
  timestamp: nowIso(),
1274
1399
  });
1275
1400
  this.terminateCodexClient();
@@ -1307,7 +1432,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1307
1432
  }
1308
1433
  this.emit({
1309
1434
  type: "fatal_error",
1310
- message: `codex app-server websocket closed unexpectedly and could not reconnect: ${lastError}.${details}`,
1435
+ message: `codex app-server websocket closed unexpectedly and could not reconnect (${this.lastRpcCloseDetail || "no close details"}): ${lastError}.${details}`,
1311
1436
  timestamp: nowIso(),
1312
1437
  });
1313
1438
  this.terminateCodexClient();
@@ -1460,6 +1585,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1460
1585
  if (typeof thread.parentThreadId === "string") {
1461
1586
  throw new Error(`Codex thread ${targetThreadId} is a subagent thread and cannot be resumed.`);
1462
1587
  }
1588
+ if (thread.canAcceptDirectInput === false) {
1589
+ throw new Error(`Codex thread ${targetThreadId} is owned by a parent agent and cannot accept direct input.`);
1590
+ }
1463
1591
  if (thread.ephemeral === true) {
1464
1592
  throw new Error(`Codex thread ${targetThreadId} is ephemeral and cannot be resumed.`);
1465
1593
  }
@@ -1483,6 +1611,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1483
1611
  if (typeof thread.parentThreadId === "string") {
1484
1612
  throw new Error(`Codex thread ${targetThreadId} is a subagent thread.`);
1485
1613
  }
1614
+ if (thread.canAcceptDirectInput === false) {
1615
+ throw new Error(`Codex thread ${targetThreadId} is owned by a parent agent.`);
1616
+ }
1486
1617
  if (thread.ephemeral === true) {
1487
1618
  throw new Error(`Codex thread ${targetThreadId} is ephemeral.`);
1488
1619
  }
@@ -1549,6 +1680,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1549
1680
  reason: "wechat_resume",
1550
1681
  notify: true,
1551
1682
  });
1683
+ this.clearVisibleCodexWorkspaceBlock();
1552
1684
  this.bridgeResumeReplayThreadId = targetThreadId;
1553
1685
  this.bridgeResumeReplayUntilMs = Date.now() + CODEX_RESUME_REPLAY_SETTLE_MS;
1554
1686
  this.setStatus("starting", `Codex resume committed thread ${targetThreadId.slice(0, 12)}.`);
@@ -1770,6 +1902,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1770
1902
  if (!options.preserveThread) {
1771
1903
  this.clearPendingThreadAnnouncement();
1772
1904
  this.announcedThreadId = null;
1905
+ this.visibleOutOfWorkspaceThreadId = null;
1773
1906
  }
1774
1907
  if (!options.preserveThread) {
1775
1908
  this.updateSharedThread(null);
@@ -1889,6 +2022,62 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1889
2022
  }, CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS);
1890
2023
  pending.timer.unref?.();
1891
2024
  }
2025
+ handleVisibleCodexThreadOpened(threadId, cwd, trace) {
2026
+ if (this.pendingVisibleResume || this.shuttingDown) {
2027
+ trace?.(`ignored thread=${threadId.slice(0, 12)} reason=bridge_switch`);
2028
+ return;
2029
+ }
2030
+ if (cwd && normalizeComparablePath(cwd) !== normalizeComparablePath(this.options.cwd)) {
2031
+ trace?.(`paused thread=${threadId.slice(0, 12)} reason=workspace`);
2032
+ this.interruptWechatTurnForLocalThreadSwitch(threadId);
2033
+ this.clearPendingThreadAnnouncement();
2034
+ this.pendingThreadFollowId = null;
2035
+ if (this.visibleOutOfWorkspaceThreadId !== threadId) {
2036
+ this.visibleOutOfWorkspaceThreadId = threadId;
2037
+ this.emit({
2038
+ type: "notice",
2039
+ level: "warning",
2040
+ text: `The visible Codex terminal switched to ${cwd}, outside the bridge workspace ${this.options.cwd}. WeChat input is paused instead of being sent to the previous session. Resume a session from the bridge workspace, or restart the bridge in ${cwd}.`,
2041
+ timestamp: nowIso(),
2042
+ });
2043
+ }
2044
+ return;
2045
+ }
2046
+ this.clearVisibleCodexWorkspaceBlock();
2047
+ trace?.(`follow thread=${threadId.slice(0, 12)} source=visible_open`);
2048
+ this.trackLocalSharedThread(threadId, { reason: "local_follow", signal: "visible_request" });
2049
+ }
2050
+ handleVisibleCodexTurn(threadId, trace) {
2051
+ if (this.pendingVisibleResume || this.shuttingDown)
2052
+ return;
2053
+ if (threadId === this.sharedThreadId) {
2054
+ this.clearVisibleCodexWorkspaceBlock();
2055
+ return;
2056
+ }
2057
+ if (!findCodexSessionFile(this.options.cwd, Date.now(), { threadId })) {
2058
+ trace?.(`ignored thread=${threadId.slice(0, 12)} reason=workspace_or_unpersisted`);
2059
+ return;
2060
+ }
2061
+ this.clearVisibleCodexWorkspaceBlock();
2062
+ trace?.(`follow thread=${threadId.slice(0, 12)} source=visible_turn`);
2063
+ this.trackLocalSharedThread(threadId, { reason: "local_follow", signal: "visible_request" });
2064
+ }
2065
+ clearVisibleCodexWorkspaceBlock() {
2066
+ if (!this.visibleOutOfWorkspaceThreadId)
2067
+ return;
2068
+ this.visibleOutOfWorkspaceThreadId = null;
2069
+ this.emit({
2070
+ type: "notice",
2071
+ level: "info",
2072
+ text: `The visible Codex terminal is back in the bridge workspace ${this.options.cwd}. WeChat input has resumed.`,
2073
+ timestamp: nowIso(),
2074
+ });
2075
+ }
2076
+ assertVisibleCodexWorkspace() {
2077
+ if (this.visibleOutOfWorkspaceThreadId) {
2078
+ throw new Error(`The visible Codex terminal is outside the bridge workspace ${this.options.cwd}. WeChat input is paused; resume a session from this workspace or restart the bridge in the terminal's workspace.`);
2079
+ }
2080
+ }
1892
2081
  trackLocalSharedThread(threadId, options) {
1893
2082
  const weakSignal = options.signal === "status_changed" ||
1894
2083
  options.signal === "thread_started" ||
@@ -1896,7 +2085,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1896
2085
  if (weakSignal && Date.now() < this.localThreadFollowBlockedUntilMs) {
1897
2086
  return;
1898
2087
  }
1899
- if (options.signal === "user_message" || options.signal === "turn_started") {
2088
+ if (options.signal === "user_message" || options.signal === "turn_started" || options.signal === "visible_request") {
1900
2089
  this.localThreadFollowBlockedUntilMs = 0;
1901
2090
  }
1902
2091
  if (this.pendingVisibleResume) {
@@ -2232,7 +2421,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2232
2421
  origin: "local",
2233
2422
  };
2234
2423
  }
2235
- if (method === "turn/started" && !this.activeTurn) {
2424
+ if (method === "turn/started" && !this.activeTurn && !this.visibleThreadProxy) {
2236
2425
  return {
2237
2426
  threadId,
2238
2427
  turnId,
@@ -2506,7 +2695,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2506
2695
  if (!threadId) {
2507
2696
  return;
2508
2697
  }
2509
- if (this.pendingVisibleResume) {
2698
+ if (this.pendingVisibleResume || this.visibleThreadProxy) {
2510
2699
  return;
2511
2700
  }
2512
2701
  if (this.isBridgeResumeReplay(threadId)) {
@@ -2585,7 +2774,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2585
2774
  if (!threadId) {
2586
2775
  return;
2587
2776
  }
2588
- if (this.isBridgeResumeReplay(threadId)) {
2777
+ if (this.visibleThreadProxy || this.isBridgeResumeReplay(threadId)) {
2589
2778
  return;
2590
2779
  }
2591
2780
  if (this.isRecentlyBridgeOwnedThread(threadId)) {
@@ -2863,6 +3052,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2863
3052
  this.rememberCompletedTurn(turnId);
2864
3053
  }
2865
3054
  async stopAppServer() {
3055
+ const visibleThreadProxy = this.visibleThreadProxy;
3056
+ this.visibleThreadProxy = null;
3057
+ await visibleThreadProxy?.close();
2866
3058
  if (!this.appServer) {
2867
3059
  this.appServerPort = null;
2868
3060
  this.appServerShuttingDown = false;
@@ -1,3 +1,9 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
1
7
  import net from "node:net";
2
8
  import path from "node:path";
3
9
  import { spawn as spawnPty } from "node-pty";
@@ -15,7 +21,7 @@ const { CODEX_APP_SERVER_HOST, INTERRUPT_SETTLE_DELAY_MS, buildCliEnvironment, b
15
21
  * the script path contains spaces: node-pty quotes the already-quoted path,
16
22
  * making cmd.exe treat the literal quotes as part of the filename.
17
23
  */
18
- function unwrapCmdExeForPty(rawTarget) {
24
+ export function unwrapCmdExeForPty(rawTarget) {
19
25
  if (process.platform !== "win32") {
20
26
  return rawTarget;
21
27
  }
@@ -166,6 +172,15 @@ export class LocalCompanionProxyAdapter {
166
172
  command: "create_session",
167
173
  });
168
174
  }
175
+ async listModels() {
176
+ return await this.sendRequest({ command: "list_models" });
177
+ }
178
+ async selectModel(modelId) {
179
+ return await this.sendRequest({ command: "select_model", modelId });
180
+ }
181
+ async setPlanMode(enabled) {
182
+ return await this.sendRequest({ command: "set_plan_mode", enabled });
183
+ }
169
184
  async interrupt() {
170
185
  const result = await this.sendRequest({
171
186
  command: "interrupt",
@@ -612,6 +627,15 @@ export class AbstractPtyAdapter {
612
627
  setEventSink(sink) {
613
628
  this.eventSink = sink;
614
629
  }
630
+ async listModels() {
631
+ throw new Error(`/${this.options.kind} model selection is not available.`);
632
+ }
633
+ async selectModel(_modelId) {
634
+ throw new Error(`/${this.options.kind} model selection is not available.`);
635
+ }
636
+ async setPlanMode(_enabled) {
637
+ throw new Error(`/${this.options.kind} plan mode is not available.`);
638
+ }
615
639
  async start() {
616
640
  if (this.pty) {
617
641
  return;