cli-wechat-bridge 1.2.0 → 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.
package/README.md CHANGED
@@ -53,10 +53,11 @@
53
53
 
54
54
  - [Node.js](https://nodejs.org/en/download) `>= 22.13.0`(建议直接安装官网 LTS 版本)
55
55
  - 已安装以下任意一种本地 CLI,并尽量保持最新版本:
56
- - [Codex](https://github.com/openai/codex)
56
+ - [Codex](https://github.com/openai/codex)(已验证 `0.149.x`–`0.157.x`)
57
57
  - [Claude Code](https://code.claude.com/docs/en/overview)
58
- - [OpenCode](https://github.com/anomalyco/opencode) `>= 1.18.0 < 2.0.0`
59
- - [Pi](https://github.com/earendil-works/pi)(已验证 `0.84.2`;需要本机可执行 `pi` 命令)
58
+ - [OpenCode](https://github.com/anomalyco/opencode)(同时支持 `1.18.x` 与 `2.0.x`)
59
+ - [Pi](https://github.com/earendil-works/pi)(已验证 `0.85.1`;需要本机可执行 `pi` 命令)
60
+ - Bridge 仅调用安装在 `PATH` 中、可直接执行的独立 CLI;不会发现或调用 ChatGPT.app 等桌面应用内部捆绑的私有可执行文件。
60
61
  - 已准备一个远程通道:个人微信使用 `wechat-setup`,企业微信使用 `wecom-setup`
61
62
 
62
63
  ### 2. 安装与更新
File without changes
File without changes
File without changes
File without changes
File without changes
package/bin/wechat-pi.mjs CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/bin/wecom-pi.mjs CHANGED
File without changes
File without changes
@@ -12,10 +12,11 @@ import { detectCliApproval, } from "./bridge-utils.js";
12
12
  import { normalizeOutput, nowIso, summarizeOutput, truncatePreview, } from "../core/text-utils.js";
13
13
  import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
14
14
  import { killProcessTreeSync } from "./bridge-process-reaper.js";
15
+ import { CodexVisibleThreadProxy } from "./codex-visible-thread-proxy.js";
15
16
  import * as shared from "./bridge-adapters.shared.js";
16
17
  import { requestCodexVisibleClientShutdown, requestCodexVisibleThreadSwitch, } from "../companion/codex-visible-client-link.js";
17
18
  import { readLocalCompanionEndpoint } from "../companion/local-companion-link.js";
18
- import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
19
+ import { appendBoundedLog as appendBridgeLog, BRIDGE_LOG_FILE, ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
19
20
  import { CODEX_REMOTE_AUTH_TOKEN_ENV, LOCAL_CLIENT_PROTOCOL_VERSION, } from "../runtime/runtime-types.js";
20
21
  export function parseCodexCliVersion(output) {
21
22
  const match = output.match(/\b(\d+\.\d+\.\d+)\b/);
@@ -28,7 +29,7 @@ export function isCodexVersionInCompatibilityRange(version) {
28
29
  }
29
30
  const major = parts[0] ?? -1;
30
31
  const minor = parts[1] ?? -1;
31
- return major === 0 && minor >= 149 && minor <= 155;
32
+ return major === 0 && minor >= 149 && minor <= 157;
32
33
  }
33
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;
34
35
  const CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS = 150;
@@ -80,6 +81,8 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
80
81
  appServer = null;
81
82
  nativeProcess = null;
82
83
  appServerPort = null;
84
+ visibleThreadProxy = null;
85
+ visibleOutOfWorkspaceThreadId = null;
83
86
  appServerShuttingDown = false;
84
87
  appServerLog = "";
85
88
  appServerAuthToken = null;
@@ -166,8 +169,18 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
166
169
  this.setStatus("starting", `Codex protocol compatibility: ${compatibility}.`);
167
170
  await this.startAppServer();
168
171
  await this.connectRpcClient();
169
- await this.restoreInitialSharedThreadIfNeeded();
170
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();
171
184
  if (this.isNativePanelMode()) {
172
185
  await this.startNativeClient();
173
186
  }
@@ -286,6 +299,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
286
299
  return models;
287
300
  }
288
301
  async selectModel(modelId) {
302
+ this.assertVisibleCodexWorkspace();
289
303
  const models = await this.listModels();
290
304
  const selected = models.find((model) => model.id === modelId);
291
305
  if (!selected)
@@ -294,6 +308,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
294
308
  return selected;
295
309
  }
296
310
  async setPlanMode(enabled) {
311
+ this.assertVisibleCodexWorkspace();
297
312
  if (!this.usesRpcTurnTransport() || !this.sharedThreadId) {
298
313
  throw new Error("Codex plan mode requires an active app-server thread.");
299
314
  }
@@ -464,7 +479,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
464
479
  renderMode: "headless",
465
480
  bridgeOwnerPid: process.pid,
466
481
  serverPort: this.appServerPort,
467
- serverUrl: `ws://${CODEX_APP_SERVER_HOST}:${this.appServerPort}`,
482
+ serverUrl: this.visibleThreadProxy?.url ?? `ws://${CODEX_APP_SERVER_HOST}:${this.appServerPort}`,
468
483
  remoteAuthTokenEnv: CODEX_REMOTE_AUTH_TOKEN_ENV,
469
484
  cwd: this.options.cwd,
470
485
  command: this.options.command,
@@ -1020,6 +1035,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1020
1035
  }
1021
1036
  }
1022
1037
  async sendPanelTurn(text) {
1038
+ this.assertVisibleCodexWorkspace();
1023
1039
  if (this.isNativePanelMode() && !this.nativeProcess) {
1024
1040
  throw new Error("codex panel is not running.");
1025
1041
  }
@@ -1168,6 +1184,8 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1168
1184
  });
1169
1185
  this.appServer = null;
1170
1186
  this.appServerPort = null;
1187
+ void this.visibleThreadProxy?.close();
1188
+ this.visibleThreadProxy = null;
1171
1189
  this.appServerShuttingDown = false;
1172
1190
  this.deleteAppServerAuthTokenFile();
1173
1191
  this.appServerAuthToken = null;
@@ -1190,6 +1208,8 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1190
1208
  });
1191
1209
  this.appServer = null;
1192
1210
  this.appServerPort = null;
1211
+ void this.visibleThreadProxy?.close();
1212
+ this.visibleThreadProxy = null;
1193
1213
  this.appServerShuttingDown = false;
1194
1214
  this.deleteAppServerAuthTokenFile();
1195
1215
  this.appServerAuthToken = null;
@@ -1660,6 +1680,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1660
1680
  reason: "wechat_resume",
1661
1681
  notify: true,
1662
1682
  });
1683
+ this.clearVisibleCodexWorkspaceBlock();
1663
1684
  this.bridgeResumeReplayThreadId = targetThreadId;
1664
1685
  this.bridgeResumeReplayUntilMs = Date.now() + CODEX_RESUME_REPLAY_SETTLE_MS;
1665
1686
  this.setStatus("starting", `Codex resume committed thread ${targetThreadId.slice(0, 12)}.`);
@@ -1881,6 +1902,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1881
1902
  if (!options.preserveThread) {
1882
1903
  this.clearPendingThreadAnnouncement();
1883
1904
  this.announcedThreadId = null;
1905
+ this.visibleOutOfWorkspaceThreadId = null;
1884
1906
  }
1885
1907
  if (!options.preserveThread) {
1886
1908
  this.updateSharedThread(null);
@@ -2000,6 +2022,62 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2000
2022
  }, CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS);
2001
2023
  pending.timer.unref?.();
2002
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
+ }
2003
2081
  trackLocalSharedThread(threadId, options) {
2004
2082
  const weakSignal = options.signal === "status_changed" ||
2005
2083
  options.signal === "thread_started" ||
@@ -2007,7 +2085,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2007
2085
  if (weakSignal && Date.now() < this.localThreadFollowBlockedUntilMs) {
2008
2086
  return;
2009
2087
  }
2010
- if (options.signal === "user_message" || options.signal === "turn_started") {
2088
+ if (options.signal === "user_message" || options.signal === "turn_started" || options.signal === "visible_request") {
2011
2089
  this.localThreadFollowBlockedUntilMs = 0;
2012
2090
  }
2013
2091
  if (this.pendingVisibleResume) {
@@ -2343,7 +2421,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2343
2421
  origin: "local",
2344
2422
  };
2345
2423
  }
2346
- if (method === "turn/started" && !this.activeTurn) {
2424
+ if (method === "turn/started" && !this.activeTurn && !this.visibleThreadProxy) {
2347
2425
  return {
2348
2426
  threadId,
2349
2427
  turnId,
@@ -2617,7 +2695,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2617
2695
  if (!threadId) {
2618
2696
  return;
2619
2697
  }
2620
- if (this.pendingVisibleResume) {
2698
+ if (this.pendingVisibleResume || this.visibleThreadProxy) {
2621
2699
  return;
2622
2700
  }
2623
2701
  if (this.isBridgeResumeReplay(threadId)) {
@@ -2696,7 +2774,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2696
2774
  if (!threadId) {
2697
2775
  return;
2698
2776
  }
2699
- if (this.isBridgeResumeReplay(threadId)) {
2777
+ if (this.visibleThreadProxy || this.isBridgeResumeReplay(threadId)) {
2700
2778
  return;
2701
2779
  }
2702
2780
  if (this.isRecentlyBridgeOwnedThread(threadId)) {
@@ -2974,6 +3052,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2974
3052
  this.rememberCompletedTurn(turnId);
2975
3053
  }
2976
3054
  async stopAppServer() {
3055
+ const visibleThreadProxy = this.visibleThreadProxy;
3056
+ this.visibleThreadProxy = null;
3057
+ await visibleThreadProxy?.close();
2977
3058
  if (!this.appServer) {
2978
3059
  this.appServerPort = null;
2979
3060
  this.appServerShuttingDown = false;
@@ -12,6 +12,7 @@ import path from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { OPENCODE_SERVER_HOST, OPENCODE_SERVER_READY_TIMEOUT_MS, OPENCODE_SSE_RECONNECT_DELAY_MS, OPENCODE_SESSION_IDLE_SETTLE_MS, OPENCODE_WECHAT_WORKING_NOTICE_DELAY_MS, OPENCODE_HTTP_READY_TIMEOUT_MS, OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS, OPENCODE_HTTP_READY_PROBE_INTERVAL_MS, buildCliEnvironment, isRecord, describeUnknownError, resolveSpawnTarget, reserveLocalPort, waitForTcpPort, delay, } from "./bridge-adapters.shared.js";
14
14
  import { killProcessTreeSync } from "./bridge-process-reaper.js";
15
+ import { createOpenCodeV2CompatClient } from "./opencode-v2-compat.js";
15
16
  import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
16
17
  import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, buildOneTimeCode, isWechatOutboundAttachmentMutationTool, isWechatOutboundAttachmentWriteCommand, normalizeOutput, nowIso, truncatePreview, OutputBatcher, } from "./bridge-utils.js";
17
18
  const OPENCODE_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.WECHAT_OPENCODE_DEBUG ?? "");
@@ -24,7 +25,11 @@ const TUI_SESSION_SELECT_SUPPRESSION_TTL_MS = 5_000;
24
25
  const OPENCODE_LOCAL_SESSION_CREATE_FOLLOW_TTL_MS = 5_000;
25
26
  const OPENCODE_TUI_SELECT_RETRY_DELAYS_MS = [250, 750, 1_500];
26
27
  const OPENCODE_MINIMUM_SUPPORTED_VERSION = "1.18.0";
27
- const OPENCODE_MAXIMUM_SUPPORTED_MAJOR = 2;
28
+ const OPENCODE_MAXIMUM_SUPPORTED_MAJOR = 3;
29
+ const OPENCODE_VERSION_PROBE_TIMEOUT_MS = 5_000;
30
+ export function parseOpenCodeCliVersion(output) {
31
+ return /\bv?(\d+\.\d+\.\d+)\b/.exec(output)?.[1] ?? null;
32
+ }
28
33
  const OPENCODE_TUI_ROUTE_READY_TIMEOUT_MS = 10_000;
29
34
  const OPENCODE_TUI_ROUTE_MAX_BUFFER_SIZE = 1024 * 1024;
30
35
  const MODULE_FILE = fileURLToPath(import.meta.url);
@@ -83,6 +88,8 @@ export class OpenCodeServerAdapter {
83
88
  tuiRoutePort = 0;
84
89
  tuiRouteConfigDir = null;
85
90
  tuiRouteConfigPath = null;
91
+ tuiRoutePluginPath = null;
92
+ openCodeMajor = 1;
86
93
  visibleTuiSessionId = null;
87
94
  tuiRouteStateReceived = false;
88
95
  expectedVisibleTuiSessionId = null;
@@ -250,6 +257,7 @@ export class OpenCodeServerAdapter {
250
257
  this.shuttingDown = false;
251
258
  this.setStatus("starting", "Starting OpenCode companion...");
252
259
  try {
260
+ await this.probeOpenCodeVersion();
253
261
  this.serverPort = await reserveLocalPort();
254
262
  const serverProcess = await this.startServerProcess();
255
263
  await waitForTcpPort(OPENCODE_SERVER_HOST, this.serverPort, OPENCODE_SERVER_READY_TIMEOUT_MS);
@@ -632,13 +640,28 @@ export class OpenCodeServerAdapter {
632
640
  resolve(address.port);
633
641
  });
634
642
  });
635
- const workspaceDir = ensureWorkspaceChannelDir(this.options.cwd).workspaceDir;
636
- const configDir = fs.mkdtempSync(path.join(workspaceDir, "opencode-tui-route-"));
637
- const configPath = path.join(configDir, "tui.json");
638
- const pluginPath = path.join(MODULE_DIR, "..", "companion", `opencode-tui-bridge-plugin${RUNTIME_ENTRY_EXTENSION}`);
643
+ const pluginName = this.openCodeMajor >= 2
644
+ ? "opencode-v2-tui-bridge-plugin"
645
+ : "opencode-tui-bridge-plugin";
646
+ const pluginPath = path.join(MODULE_DIR, "..", "companion", `${pluginName}${RUNTIME_ENTRY_EXTENSION}`);
639
647
  if (!fs.existsSync(pluginPath)) {
640
648
  throw new Error(`OpenCode TUI bridge plugin was not found: ${pluginPath}`);
641
649
  }
650
+ const workspaceDir = ensureWorkspaceChannelDir(this.options.cwd).workspaceDir;
651
+ const configDir = fs.mkdtempSync(path.join(workspaceDir, "opencode-tui-route-"));
652
+ this.tuiRouteConfigDir = configDir;
653
+ if (this.openCodeMajor >= 2) {
654
+ fs.copyFileSync(pluginPath, path.join(configDir, `tui${RUNTIME_ENTRY_EXTENSION}`));
655
+ fs.writeFileSync(path.join(configDir, "package.json"), `${JSON.stringify({
656
+ name: "cli-wechat-bridge-opencode-tui-route",
657
+ private: true,
658
+ type: "module",
659
+ }, null, 2)}\n`, "utf8");
660
+ this.tuiRoutePluginPath = configDir;
661
+ return;
662
+ }
663
+ this.tuiRoutePluginPath = pluginPath;
664
+ const configPath = path.join(configDir, "tui.json");
642
665
  fs.writeFileSync(configPath, `${JSON.stringify({
643
666
  plugin: [
644
667
  [
@@ -650,7 +673,6 @@ export class OpenCodeServerAdapter {
650
673
  ],
651
674
  ],
652
675
  }, null, 2)}\n`, "utf8");
653
- this.tuiRouteConfigDir = configDir;
654
676
  this.tuiRouteConfigPath = configPath;
655
677
  }
656
678
  acceptTuiRouteSocket(socket) {
@@ -765,6 +787,7 @@ export class OpenCodeServerAdapter {
765
787
  const configDir = this.tuiRouteConfigDir;
766
788
  this.tuiRouteConfigDir = null;
767
789
  this.tuiRouteConfigPath = null;
790
+ this.tuiRoutePluginPath = null;
768
791
  this.tuiRoutePort = 0;
769
792
  this.tuiRouteToken = "";
770
793
  this.tuiRouteStateReceived = false;
@@ -781,6 +804,50 @@ export class OpenCodeServerAdapter {
781
804
  }
782
805
  }
783
806
  }
807
+ async probeOpenCodeVersion() {
808
+ const env = buildCliEnvironment(this.options.kind);
809
+ const target = resolveSpawnTarget(this.options.command, this.options.kind, { env });
810
+ const version = await new Promise((resolve, reject) => {
811
+ const child = spawnChildProcess(target.file, [...target.args, "--version"], {
812
+ cwd: this.options.cwd,
813
+ env,
814
+ stdio: ["ignore", "pipe", "pipe"],
815
+ windowsHide: true,
816
+ });
817
+ let output = "";
818
+ let settled = false;
819
+ const finish = (error) => {
820
+ if (settled)
821
+ return;
822
+ settled = true;
823
+ clearTimeout(timer);
824
+ if (error)
825
+ reject(error);
826
+ else
827
+ resolve(output);
828
+ };
829
+ const timer = setTimeout(() => {
830
+ if (child.pid != null) {
831
+ killProcessTreeSync(child.pid);
832
+ }
833
+ finish(new Error("Timed out checking the OpenCode CLI version."));
834
+ }, OPENCODE_VERSION_PROBE_TIMEOUT_MS);
835
+ child.stdout?.on("data", (chunk) => { output += chunk.toString("utf8"); });
836
+ child.stderr?.on("data", (chunk) => { output += chunk.toString("utf8"); });
837
+ child.once("error", (error) => finish(error));
838
+ child.once("exit", (code) => {
839
+ if (code === 0)
840
+ finish();
841
+ else
842
+ finish(new Error(`OpenCode --version exited with code ${code ?? "unknown"}.`));
843
+ });
844
+ });
845
+ const parsed = parseOpenCodeCliVersion(version);
846
+ if (!parsed || !this.isSupportedOpenCodeVersion(parsed)) {
847
+ throw new Error(`Unsupported OpenCode version ${parsed ?? (version.trim() || "unknown")}. Install OpenCode >=${OPENCODE_MINIMUM_SUPPORTED_VERSION} <${OPENCODE_MAXIMUM_SUPPORTED_MAJOR}.0.0.`);
848
+ }
849
+ this.openCodeMajor = Number(parsed.split(".")[0]);
850
+ }
784
851
  async startServerProcess() {
785
852
  const env = buildCliEnvironment(this.options.kind);
786
853
  this.serverPassword = randomBytes(24).toString("hex");
@@ -886,8 +953,9 @@ export class OpenCodeServerAdapter {
886
953
  if ((this.options.extraCliArgs ?? []).includes("--pure")) {
887
954
  throw new Error("OpenCode --pure cannot be used with the WeChat bridge because local session following requires the managed TUI plugin.");
888
955
  }
889
- const args = ["attach", this.getServerUrl()];
890
- args.push("--dir", this.options.cwd);
956
+ const args = this.openCodeMajor >= 2
957
+ ? ["--server", this.getServerUrl()]
958
+ : ["attach", this.getServerUrl(), "--dir", this.options.cwd];
891
959
  const sessionId = this.activeSessionId;
892
960
  if (sessionId && (await this.hasSession(sessionId))) {
893
961
  args.push("--session", sessionId);
@@ -897,7 +965,37 @@ export class OpenCodeServerAdapter {
897
965
  }
898
966
  buildNativeClientEnv() {
899
967
  const env = buildCliEnvironment(this.options.kind);
900
- if (this.tuiRouteConfigDir && this.tuiRouteConfigPath) {
968
+ if (this.openCodeMajor >= 2 && this.tuiRoutePluginPath) {
969
+ let existingConfig = {};
970
+ if (env.OPENCODE_CLI_CONFIG_CONTENT) {
971
+ try {
972
+ const parsed = JSON.parse(env.OPENCODE_CLI_CONFIG_CONTENT);
973
+ if (isRecord(parsed))
974
+ existingConfig = parsed;
975
+ }
976
+ catch (error) {
977
+ throw new Error(`OpenCode cannot merge the managed TUI plugin into invalid OPENCODE_CLI_CONFIG_CONTENT: ${describeUnknownError(error)}`, { cause: error });
978
+ }
979
+ }
980
+ const existingPlugins = Array.isArray(existingConfig.plugins)
981
+ ? existingConfig.plugins
982
+ : [];
983
+ env.OPENCODE_CLI_CONFIG_CONTENT = JSON.stringify({
984
+ ...existingConfig,
985
+ plugins: [
986
+ ...existingPlugins,
987
+ {
988
+ package: this.tuiRoutePluginPath,
989
+ options: {
990
+ port: this.tuiRoutePort,
991
+ token: this.tuiRouteToken,
992
+ },
993
+ },
994
+ ],
995
+ });
996
+ env.OPENCODE_PASSWORD = this.serverPassword ?? "";
997
+ }
998
+ else if (this.tuiRouteConfigDir && this.tuiRouteConfigPath) {
901
999
  if (env.OPENCODE_CONFIG_DIR && env.OPENCODE_TUI_CONFIG) {
902
1000
  throw new Error("OpenCode cannot inject the managed TUI session plugin while both OPENCODE_CONFIG_DIR and OPENCODE_TUI_CONFIG are set.");
903
1001
  }
@@ -911,7 +1009,7 @@ export class OpenCodeServerAdapter {
911
1009
  if (this.serverPassword) {
912
1010
  env.OPENCODE_SERVER_PASSWORD = this.serverPassword;
913
1011
  }
914
- if (this.activeSessionId) {
1012
+ if (this.activeSessionId && this.openCodeMajor < 2) {
915
1013
  env.OPENCODE_ROUTE = JSON.stringify({
916
1014
  type: "session",
917
1015
  sessionID: this.activeSessionId,
@@ -928,6 +1026,14 @@ export class OpenCodeServerAdapter {
928
1026
  }
929
1027
  async createSdkClient() {
930
1028
  try {
1029
+ if (this.openCodeMajor >= 2) {
1030
+ this.client = await createOpenCodeV2CompatClient({
1031
+ baseUrl: `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`,
1032
+ directory: this.options.cwd,
1033
+ headers: this.buildServerAuthHeaders(),
1034
+ });
1035
+ return;
1036
+ }
931
1037
  const { createOpencodeClient } = await import("@opencode-ai/sdk/v2");
932
1038
  this.client = createOpencodeClient({
933
1039
  baseUrl: `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`,
@@ -937,7 +1043,8 @@ export class OpenCodeServerAdapter {
937
1043
  });
938
1044
  }
939
1045
  catch (err) {
940
- throw new Error(`Failed to load @opencode-ai/sdk. Make sure it is installed: ${describeUnknownError(err)}`, { cause: err });
1046
+ const packageName = this.openCodeMajor >= 2 ? "@opencode/client" : "@opencode-ai/sdk";
1047
+ throw new Error(`Failed to load ${packageName}. Make sure it is installed: ${describeUnknownError(err)}`, { cause: err });
941
1048
  }
942
1049
  }
943
1050
  async checkHealth() {
@@ -952,17 +1059,24 @@ export class OpenCodeServerAdapter {
952
1059
  let lastError;
953
1060
  while (Date.now() < deadline) {
954
1061
  try {
955
- const response = await fetch(`${baseUrl}/global/health`, {
1062
+ const healthPath = this.openCodeMajor >= 2 ? "/api/info" : "/global/health";
1063
+ const response = await fetch(`${baseUrl}${healthPath}`, {
956
1064
  headers: this.buildServerAuthHeaders(),
957
1065
  signal: AbortSignal.timeout(OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS),
958
1066
  });
959
1067
  if (response.ok) {
960
1068
  const health = await response.json();
961
- if (!isRecord(health) || health.healthy !== true || typeof health.version !== "string") {
1069
+ const version = isRecord(health) && typeof health.version === "string"
1070
+ ? health.version
1071
+ : undefined;
1072
+ const healthy = this.openCodeMajor >= 2
1073
+ ? Boolean(version)
1074
+ : isRecord(health) && health.healthy === true;
1075
+ if (!healthy || !version) {
962
1076
  lastError = new Error("OpenCode health check returned an invalid response.");
963
1077
  }
964
- else if (!this.isSupportedOpenCodeVersion(health.version)) {
965
- throw new Error(`Unsupported OpenCode version ${health.version}. Install OpenCode >=${OPENCODE_MINIMUM_SUPPORTED_VERSION} <${OPENCODE_MAXIMUM_SUPPORTED_MAJOR}.0.0.`);
1078
+ else if (!this.isSupportedOpenCodeVersion(version)) {
1079
+ throw new Error(`Unsupported OpenCode version ${version}. Install OpenCode >=${OPENCODE_MINIMUM_SUPPORTED_VERSION} <${OPENCODE_MAXIMUM_SUPPORTED_MAJOR}.0.0.`);
966
1080
  }
967
1081
  else {
968
1082
  return;
@@ -1099,10 +1213,9 @@ export class OpenCodeServerAdapter {
1099
1213
  return;
1100
1214
  }
1101
1215
  this.sseAbortController = new AbortController();
1102
- const sseLoops = [
1103
- this.runSseLoop("event"),
1104
- this.runSseLoop("global-event"),
1105
- ];
1216
+ const sseLoops = this.openCodeMajor >= 2
1217
+ ? [this.runSseLoop("event")]
1218
+ : [this.runSseLoop("event"), this.runSseLoop("global-event")];
1106
1219
  this.sseLoopPromise = Promise.all(sseLoops).then(() => undefined);
1107
1220
  }
1108
1221
  async runSseLoop(streamName) {
@@ -2745,6 +2858,16 @@ export class OpenCodeServerAdapter {
2745
2858
  }
2746
2859
  }
2747
2860
  async sendVisibleSessionSelection(session) {
2861
+ if (this.openCodeMajor >= 2) {
2862
+ if (!this.tuiRouteSocket || this.tuiRouteSocket.destroyed) {
2863
+ throw new Error("The OpenCode 2 TUI route bridge is not connected.");
2864
+ }
2865
+ this.tuiRouteSocket.write(`${JSON.stringify({
2866
+ type: "select_session",
2867
+ sessionId: session.id,
2868
+ })}\n`);
2869
+ return;
2870
+ }
2748
2871
  if (!this.client?.tui) {
2749
2872
  return;
2750
2873
  }
@@ -1,4 +1,4 @@
1
- import { NativeTerminalControl, parseClaudeModelPicker, readClaudePermissionMode } from "./native-terminal-control.js";
1
+ import { NativeTerminalControl, parseClaudeModelPicker, parseClaudeModelSwitchConfirmation, readClaudePermissionMode } from "./native-terminal-control.js";
2
2
  export class ClaudeNativeControls {
3
3
  previousMode = null;
4
4
  terminal;
@@ -83,7 +83,19 @@ export class ClaudeNativeControls {
83
83
  throw new Error("This Claude version does not expose session-only model selection. Update Claude Code and try again.");
84
84
  }
85
85
  await this.terminal.submitMenu(undefined, "s");
86
- await this.terminal.waitFor(() => readClaudePermissionMode(this.terminal.text) !== null);
86
+ await this.terminal.waitFor(() => parseClaudeModelSwitchConfirmation(this.terminal.text) !== null || readClaudePermissionMode(this.terminal.text) !== null);
87
+ const confirmation = parseClaudeModelSwitchConfirmation(this.terminal.text);
88
+ if (confirmation) {
89
+ const targetLabel = target.label.toLowerCase();
90
+ const confirmedModel = confirmation.model.toLowerCase();
91
+ const matchesTarget = targetLabel.includes(confirmedModel)
92
+ || (targetLabel.startsWith("default (recommended)") && confirmedModel.endsWith(" (default)"));
93
+ if (!confirmation.yesFocused || !matchesTarget) {
94
+ throw new Error("Claude displayed an unexpected model switch confirmation. Check the visible terminal before trying again.");
95
+ }
96
+ await this.terminal.key("\r");
97
+ await this.terminal.waitFor(() => readClaudePermissionMode(this.terminal.text) !== null);
98
+ }
87
99
  await this.terminal.openMenu("/model", "Select model");
88
100
  const applied = parseClaudeModelPicker(this.terminal.text).find((row) => row.focused);
89
101
  if (!applied || applied.label !== target.label || applied.index !== target.index)