@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 { spawn as spawnChildProcess } from "node:child_process";
2
8
  import { randomBytes } from "node:crypto";
3
9
  import fs from "node:fs";
@@ -6,6 +12,7 @@ import path from "node:path";
6
12
  import { fileURLToPath } from "node:url";
7
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";
8
14
  import { killProcessTreeSync } from "./bridge-process-reaper.js";
15
+ import { createOpenCodeV2CompatClient } from "./opencode-v2-compat.js";
9
16
  import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
10
17
  import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, buildOneTimeCode, isWechatOutboundAttachmentMutationTool, isWechatOutboundAttachmentWriteCommand, normalizeOutput, nowIso, truncatePreview, OutputBatcher, } from "./bridge-utils.js";
11
18
  const OPENCODE_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.WECHAT_OPENCODE_DEBUG ?? "");
@@ -18,7 +25,11 @@ const TUI_SESSION_SELECT_SUPPRESSION_TTL_MS = 5_000;
18
25
  const OPENCODE_LOCAL_SESSION_CREATE_FOLLOW_TTL_MS = 5_000;
19
26
  const OPENCODE_TUI_SELECT_RETRY_DELAYS_MS = [250, 750, 1_500];
20
27
  const OPENCODE_MINIMUM_SUPPORTED_VERSION = "1.18.0";
21
- 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
+ }
22
33
  const OPENCODE_TUI_ROUTE_READY_TIMEOUT_MS = 10_000;
23
34
  const OPENCODE_TUI_ROUTE_MAX_BUFFER_SIZE = 1024 * 1024;
24
35
  const MODULE_FILE = fileURLToPath(import.meta.url);
@@ -77,6 +88,8 @@ export class OpenCodeServerAdapter {
77
88
  tuiRoutePort = 0;
78
89
  tuiRouteConfigDir = null;
79
90
  tuiRouteConfigPath = null;
91
+ tuiRoutePluginPath = null;
92
+ openCodeMajor = 1;
80
93
  visibleTuiSessionId = null;
81
94
  tuiRouteStateReceived = false;
82
95
  expectedVisibleTuiSessionId = null;
@@ -85,6 +98,139 @@ export class OpenCodeServerAdapter {
85
98
  pendingLocalSessionCreateFollowUntilMs = 0;
86
99
  pendingPermission = null;
87
100
  pendingQuestion = null;
101
+ previousAgentBySession = new Map();
102
+ async listModels() {
103
+ this.assertRemoteControlReady();
104
+ const session = await this.ensureControlSession();
105
+ const models = await this.listControlModels();
106
+ return models.map((model) => ({
107
+ id: model.id,
108
+ displayName: model.displayName,
109
+ isCurrent: session.model?.providerID === model.providerID &&
110
+ session.model.id === model.modelID,
111
+ }));
112
+ }
113
+ async selectModel(modelId) {
114
+ this.assertRemoteControlReady();
115
+ const session = await this.ensureControlSession();
116
+ const models = await this.listControlModels();
117
+ const target = models.find((model) => model.id === modelId);
118
+ if (!target) {
119
+ throw new Error("This OpenCode model is no longer available. Send /model again.");
120
+ }
121
+ const currentVariant = session.model?.variant;
122
+ const variant = currentVariant && currentVariant !== "default" && target.variants.includes(currentVariant)
123
+ ? currentVariant
124
+ : "default";
125
+ const result = await this.client.v2.session.switchModel({
126
+ sessionID: session.id,
127
+ model: {
128
+ providerID: target.providerID,
129
+ id: target.modelID,
130
+ variant,
131
+ },
132
+ });
133
+ this.unwrapOrThrow(result);
134
+ const updated = await this.getSessionForCurrentDirectory(session.id);
135
+ if (!updated ||
136
+ updated.model?.providerID !== target.providerID ||
137
+ updated.model.id !== target.modelID ||
138
+ (updated.model.variant ?? "default") !== variant) {
139
+ throw new Error("OpenCode did not confirm the selected model.");
140
+ }
141
+ return { id: target.id, displayName: target.displayName, isCurrent: true };
142
+ }
143
+ async setPlanMode(enabled) {
144
+ this.assertRemoteControlReady();
145
+ const session = await this.ensureControlSession();
146
+ const agents = await this.listPrimaryAgents();
147
+ const planAgent = agents.find((agent) => agent.name === "plan");
148
+ if (!planAgent) {
149
+ throw new Error("OpenCode primary agent 'plan' is not available.");
150
+ }
151
+ const currentAgent = session.agent ?? agents[0]?.name;
152
+ if (!currentAgent) {
153
+ throw new Error("OpenCode did not return any primary agents.");
154
+ }
155
+ if (enabled && currentAgent === "plan") {
156
+ return true;
157
+ }
158
+ if (!enabled && currentAgent !== "plan") {
159
+ this.previousAgentBySession.delete(session.id);
160
+ return false;
161
+ }
162
+ const previousAgent = this.previousAgentBySession.get(session.id);
163
+ const target = enabled
164
+ ? "plan"
165
+ : agents.some((agent) => agent.name === previousAgent)
166
+ ? previousAgent
167
+ : agents.find((agent) => agent.name === "build")?.name ??
168
+ agents.find((agent) => agent.name !== "plan")?.name;
169
+ if (!target) {
170
+ throw new Error("OpenCode did not return an agent that can replace plan mode.");
171
+ }
172
+ const result = await this.client.v2.session.switchAgent({
173
+ sessionID: session.id,
174
+ agent: target,
175
+ });
176
+ this.unwrapOrThrow(result);
177
+ const updated = await this.getSessionForCurrentDirectory(session.id);
178
+ if (!updated || updated.agent !== target) {
179
+ throw new Error("OpenCode did not confirm the selected agent.");
180
+ }
181
+ if (enabled) {
182
+ this.previousAgentBySession.set(session.id, currentAgent);
183
+ }
184
+ else {
185
+ this.previousAgentBySession.delete(session.id);
186
+ }
187
+ return enabled;
188
+ }
189
+ assertRemoteControlReady() {
190
+ if (!this.client) {
191
+ throw new Error("OpenCode is not running.");
192
+ }
193
+ if (this.state.status === "busy") {
194
+ throw new Error("OpenCode is still working. Wait for the current reply or use /stop.");
195
+ }
196
+ if (this.pendingPermission) {
197
+ throw new Error("An OpenCode approval request is pending. Reply with /confirm or /deny.");
198
+ }
199
+ if (this.pendingQuestion) {
200
+ throw new Error("OpenCode is waiting for user input. Reply with /answer or use /stop.");
201
+ }
202
+ }
203
+ async ensureControlSession() {
204
+ const session = await this.ensureSession();
205
+ if (session.id !== this.activeSessionId) {
206
+ this.switchSharedSession(session, {
207
+ source: "wechat",
208
+ reason: "wechat_resume",
209
+ syncVisible: true,
210
+ forceVisibleSync: true,
211
+ });
212
+ }
213
+ return session;
214
+ }
215
+ async listPrimaryAgents() {
216
+ if (!this.client)
217
+ throw new Error("OpenCode is not running.");
218
+ const agents = this.unwrapOrThrow(await this.client.app.agents({
219
+ directory: this.options.cwd,
220
+ workspace: this.activeWorkspaceId ?? undefined,
221
+ }, { signal: AbortSignal.timeout(10_000) }));
222
+ return agents.filter((agent) => agent.mode !== "subagent" && !agent.hidden);
223
+ }
224
+ async listControlModels() {
225
+ if (!this.client)
226
+ throw new Error("OpenCode is not running.");
227
+ const providers = this.unwrapOrThrow(await this.client.provider.list({ directory: this.options.cwd, workspace: this.activeWorkspaceId ?? undefined }, { signal: AbortSignal.timeout(10_000) }));
228
+ return providers.all.filter((provider) => providers.connected.includes(provider.id)).flatMap((provider) => Object.entries(provider.models).filter(([id, model]) => model.status !== "deprecated" && !(provider.id === "opencode" && id.includes("-nano"))).map(([id, model]) => ({
229
+ id: `${provider.id}/${id}`, displayName: `${model.name || id} (${provider.name})`,
230
+ providerID: provider.id, modelID: id,
231
+ variants: Object.keys(model.variants ?? {}),
232
+ })));
233
+ }
88
234
  constructor(options) {
89
235
  this.options = options;
90
236
  this.state = {
@@ -111,6 +257,7 @@ export class OpenCodeServerAdapter {
111
257
  this.shuttingDown = false;
112
258
  this.setStatus("starting", "Starting OpenCode companion...");
113
259
  try {
260
+ await this.probeOpenCodeVersion();
114
261
  this.serverPort = await reserveLocalPort();
115
262
  const serverProcess = await this.startServerProcess();
116
263
  await waitForTcpPort(OPENCODE_SERVER_HOST, this.serverPort, OPENCODE_SERVER_READY_TIMEOUT_MS);
@@ -170,10 +317,21 @@ export class OpenCodeServerAdapter {
170
317
  this.recordPendingWechatPromptMirrorSuppression(session.id, normalized);
171
318
  this.beginTrackedTurn(normalized, "wechat");
172
319
  try {
320
+ const selection = await this.getSessionForCurrentDirectory(session.id) ?? session;
173
321
  const result = await this.client.session.promptAsync({
174
322
  sessionID: session.id,
175
323
  directory: this.options.cwd,
176
324
  workspace: session.workspaceID ?? this.activeWorkspaceId ?? undefined,
325
+ ...(selection.agent ? { agent: selection.agent } : {}),
326
+ ...(selection.model
327
+ ? {
328
+ model: {
329
+ providerID: selection.model.providerID,
330
+ modelID: selection.model.id,
331
+ },
332
+ variant: selection.model.variant ?? "default",
333
+ }
334
+ : {}),
177
335
  parts: [{ type: "text", text: normalized }],
178
336
  });
179
337
  if (result.error !== undefined) {
@@ -411,6 +569,7 @@ export class OpenCodeServerAdapter {
411
569
  this.pendingLocalSessionCreateFollowUntilMs = 0;
412
570
  this.clearObservedMessageTracking();
413
571
  this.recentSdkEventObservations.clear();
572
+ this.previousAgentBySession.clear();
414
573
  this.outputBatcher.clear();
415
574
  this.clearStreamedPartState();
416
575
  this.clearPendingPermissionState();
@@ -481,13 +640,28 @@ export class OpenCodeServerAdapter {
481
640
  resolve(address.port);
482
641
  });
483
642
  });
484
- const workspaceDir = ensureWorkspaceChannelDir(this.options.cwd).workspaceDir;
485
- const configDir = fs.mkdtempSync(path.join(workspaceDir, "opencode-tui-route-"));
486
- const configPath = path.join(configDir, "tui.json");
487
- 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}`);
488
647
  if (!fs.existsSync(pluginPath)) {
489
648
  throw new Error(`OpenCode TUI bridge plugin was not found: ${pluginPath}`);
490
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");
491
665
  fs.writeFileSync(configPath, `${JSON.stringify({
492
666
  plugin: [
493
667
  [
@@ -499,7 +673,6 @@ export class OpenCodeServerAdapter {
499
673
  ],
500
674
  ],
501
675
  }, null, 2)}\n`, "utf8");
502
- this.tuiRouteConfigDir = configDir;
503
676
  this.tuiRouteConfigPath = configPath;
504
677
  }
505
678
  acceptTuiRouteSocket(socket) {
@@ -614,6 +787,7 @@ export class OpenCodeServerAdapter {
614
787
  const configDir = this.tuiRouteConfigDir;
615
788
  this.tuiRouteConfigDir = null;
616
789
  this.tuiRouteConfigPath = null;
790
+ this.tuiRoutePluginPath = null;
617
791
  this.tuiRoutePort = 0;
618
792
  this.tuiRouteToken = "";
619
793
  this.tuiRouteStateReceived = false;
@@ -630,6 +804,50 @@ export class OpenCodeServerAdapter {
630
804
  }
631
805
  }
632
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
+ }
633
851
  async startServerProcess() {
634
852
  const env = buildCliEnvironment(this.options.kind);
635
853
  this.serverPassword = randomBytes(24).toString("hex");
@@ -735,8 +953,9 @@ export class OpenCodeServerAdapter {
735
953
  if ((this.options.extraCliArgs ?? []).includes("--pure")) {
736
954
  throw new Error("OpenCode --pure cannot be used with the WeChat bridge because local session following requires the managed TUI plugin.");
737
955
  }
738
- const args = ["attach", this.getServerUrl()];
739
- args.push("--dir", this.options.cwd);
956
+ const args = this.openCodeMajor >= 2
957
+ ? ["--server", this.getServerUrl()]
958
+ : ["attach", this.getServerUrl(), "--dir", this.options.cwd];
740
959
  const sessionId = this.activeSessionId;
741
960
  if (sessionId && (await this.hasSession(sessionId))) {
742
961
  args.push("--session", sessionId);
@@ -746,7 +965,37 @@ export class OpenCodeServerAdapter {
746
965
  }
747
966
  buildNativeClientEnv() {
748
967
  const env = buildCliEnvironment(this.options.kind);
749
- 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) {
750
999
  if (env.OPENCODE_CONFIG_DIR && env.OPENCODE_TUI_CONFIG) {
751
1000
  throw new Error("OpenCode cannot inject the managed TUI session plugin while both OPENCODE_CONFIG_DIR and OPENCODE_TUI_CONFIG are set.");
752
1001
  }
@@ -760,7 +1009,7 @@ export class OpenCodeServerAdapter {
760
1009
  if (this.serverPassword) {
761
1010
  env.OPENCODE_SERVER_PASSWORD = this.serverPassword;
762
1011
  }
763
- if (this.activeSessionId) {
1012
+ if (this.activeSessionId && this.openCodeMajor < 2) {
764
1013
  env.OPENCODE_ROUTE = JSON.stringify({
765
1014
  type: "session",
766
1015
  sessionID: this.activeSessionId,
@@ -777,6 +1026,14 @@ export class OpenCodeServerAdapter {
777
1026
  }
778
1027
  async createSdkClient() {
779
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
+ }
780
1037
  const { createOpencodeClient } = await import("@opencode-ai/sdk/v2");
781
1038
  this.client = createOpencodeClient({
782
1039
  baseUrl: `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`,
@@ -786,7 +1043,8 @@ export class OpenCodeServerAdapter {
786
1043
  });
787
1044
  }
788
1045
  catch (err) {
789
- 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 });
790
1048
  }
791
1049
  }
792
1050
  async checkHealth() {
@@ -801,17 +1059,24 @@ export class OpenCodeServerAdapter {
801
1059
  let lastError;
802
1060
  while (Date.now() < deadline) {
803
1061
  try {
804
- const response = await fetch(`${baseUrl}/global/health`, {
1062
+ const healthPath = this.openCodeMajor >= 2 ? "/api/info" : "/global/health";
1063
+ const response = await fetch(`${baseUrl}${healthPath}`, {
805
1064
  headers: this.buildServerAuthHeaders(),
806
1065
  signal: AbortSignal.timeout(OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS),
807
1066
  });
808
1067
  if (response.ok) {
809
1068
  const health = await response.json();
810
- 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) {
811
1076
  lastError = new Error("OpenCode health check returned an invalid response.");
812
1077
  }
813
- else if (!this.isSupportedOpenCodeVersion(health.version)) {
814
- 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.`);
815
1080
  }
816
1081
  else {
817
1082
  return;
@@ -948,10 +1213,9 @@ export class OpenCodeServerAdapter {
948
1213
  return;
949
1214
  }
950
1215
  this.sseAbortController = new AbortController();
951
- const sseLoops = [
952
- this.runSseLoop("event"),
953
- this.runSseLoop("global-event"),
954
- ];
1216
+ const sseLoops = this.openCodeMajor >= 2
1217
+ ? [this.runSseLoop("event")]
1218
+ : [this.runSseLoop("event"), this.runSseLoop("global-event")];
955
1219
  this.sseLoopPromise = Promise.all(sseLoops).then(() => undefined);
956
1220
  }
957
1221
  async runSseLoop(streamName) {
@@ -1535,7 +1799,11 @@ export class OpenCodeServerAdapter {
1535
1799
  return;
1536
1800
  }
1537
1801
  const sessionId = this.extractSessionId(properties);
1538
- if (!sessionId || sessionId !== this.activeSessionId) {
1802
+ if (!sessionId) {
1803
+ return;
1804
+ }
1805
+ this.previousAgentBySession.delete(sessionId);
1806
+ if (sessionId !== this.activeSessionId) {
1539
1807
  return;
1540
1808
  }
1541
1809
  this.clearTrackedTurnForLocalSessionSwitch();
@@ -2590,6 +2858,16 @@ export class OpenCodeServerAdapter {
2590
2858
  }
2591
2859
  }
2592
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
+ }
2593
2871
  if (!this.client?.tui) {
2594
2872
  return;
2595
2873
  }
@@ -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 crypto from "node:crypto";
2
8
  import fs from "node:fs";
3
9
  import net from "node:net";
@@ -356,6 +362,41 @@ export class PiTuiAdapter {
356
362
  this.pendingSessionSwitch = null;
357
363
  }
358
364
  }
365
+ async listModels() {
366
+ if (this.state.status === "busy") {
367
+ throw new Error("Pi is still working. Wait for the current turn before listing models.");
368
+ }
369
+ const response = await this.sendCommand("list_models");
370
+ if (!isRecord(response.data) || !Array.isArray(response.data.models)) {
371
+ throw new Error("Pi TUI returned an invalid model list.");
372
+ }
373
+ return response.data.models
374
+ .filter((model) => isRecord(model))
375
+ .filter((model) => typeof model.id === "string" && typeof model.displayName === "string")
376
+ .map((model) => ({
377
+ id: model.id,
378
+ displayName: model.displayName,
379
+ isCurrent: model.isCurrent === true,
380
+ }));
381
+ }
382
+ async selectModel(modelId) {
383
+ if (this.state.status === "busy") {
384
+ throw new Error("Pi is still working. Wait for the current turn before switching models.");
385
+ }
386
+ const response = await this.sendCommand("select_model", { modelId });
387
+ if (!isRecord(response.data) || !isRecord(response.data.model)) {
388
+ throw new Error("Pi TUI returned an invalid selected model.");
389
+ }
390
+ const model = response.data.model;
391
+ if (typeof model.id !== "string" || typeof model.displayName !== "string") {
392
+ throw new Error("Pi TUI returned an invalid selected model.");
393
+ }
394
+ return {
395
+ id: model.id,
396
+ displayName: model.displayName,
397
+ isCurrent: true,
398
+ };
399
+ }
359
400
  async interrupt() {
360
401
  if (!this.socket || this.state.status !== "busy") {
361
402
  return false;
@@ -569,6 +610,10 @@ export class PiTuiAdapter {
569
610
  else if (this.currentAssistantText) {
570
611
  this.emit({ type: "final_reply", text: this.currentAssistantText, timestamp: nowIso() });
571
612
  }
613
+ // Mirror the other adapters: a settled turn always reports completion so
614
+ // bridge/daemon ownership state is released even when the turn produced
615
+ // no visible reply.
616
+ this.emit({ type: "task_complete", timestamp: nowIso() });
572
617
  this.state.lastOutputAt = nowIso();
573
618
  this.clearActiveTurn();
574
619
  this.setStatus("idle");
@@ -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 os from "node:os";
3
9
  import path from "node:path";
@@ -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 { clearLocalCompanionEndpoint, readLocalCompanionEndpoint, writeLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
2
8
  import { hasLocalClientEndpointProvider } from "../runtime/runtime-types.js";
3
9
  export class BridgeController {
@@ -1,2 +1,8 @@
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
  /** @deprecated Import from ../channels/wechat/bridge-final-reply.ts instead. */
2
8
  export * from "../channels/wechat/bridge-final-reply.js";
@@ -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 { spawnSync } from "node:child_process";
2
8
  import path from "node:path";
3
9
  const PEER_BRIDGE_EXIT_TIMEOUT_MS = 4_000;
@@ -113,7 +119,7 @@ function normalizeBridgeProcessRecord(value) {
113
119
  }
114
120
  return record;
115
121
  }
116
- export function parseWindowsBridgeProcessProbeOutput(stdout, currentPid = process.pid) {
122
+ function parseWindowsProcessProbeRecords(stdout) {
117
123
  const trimmed = stdout.trim();
118
124
  if (!trimmed) {
119
125
  return [];
@@ -128,7 +134,13 @@ export function parseWindowsBridgeProcessProbeOutput(stdout, currentPid = proces
128
134
  const values = Array.isArray(parsed) ? parsed : [parsed];
129
135
  return values
130
136
  .map(normalizeBridgeProcessRecord)
131
- .filter((record) => Boolean(record))
137
+ .filter((record) => Boolean(record));
138
+ }
139
+ export function parseWindowsProcessRecordProbeOutput(stdout, currentPid = process.pid) {
140
+ return (parseWindowsProcessProbeRecords(stdout).find((record) => record.pid !== currentPid) ?? null);
141
+ }
142
+ export function parseWindowsBridgeProcessProbeOutput(stdout, currentPid = process.pid) {
143
+ return parseWindowsProcessProbeRecords(stdout)
132
144
  .filter((record) => record.pid !== currentPid && isWechatBridgeCommandLine(record.commandLine));
133
145
  }
134
146
  export function parsePosixBridgeProcessProbeOutput(stdout, currentPid = process.pid) {
@@ -195,10 +207,37 @@ export function listPeerBridgeProcesses(currentPid = process.pid) {
195
207
  ? listWindowsBridgeProcesses(currentPid)
196
208
  : listPosixBridgeProcesses(currentPid);
197
209
  }
210
+ function getWindowsProcessRecordByPid(pid, currentPid = process.pid) {
211
+ const probe = spawnSync("powershell.exe", [
212
+ "-NoLogo",
213
+ "-NoProfile",
214
+ "-NonInteractive",
215
+ "-ExecutionPolicy",
216
+ "Bypass",
217
+ "-Command",
218
+ [
219
+ "$ErrorActionPreference='Stop';",
220
+ `Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}'`,
221
+ "| Select-Object ProcessId,ParentProcessId,Name,CommandLine",
222
+ "| ConvertTo-Json -Compress",
223
+ ].join(" "),
224
+ ], {
225
+ encoding: "utf8",
226
+ windowsHide: true,
227
+ timeout: 8_000,
228
+ });
229
+ if (probe.status !== 0 || typeof probe.stdout !== "string") {
230
+ return null;
231
+ }
232
+ return parseWindowsProcessRecordProbeOutput(probe.stdout, currentPid);
233
+ }
198
234
  export function getProcessRecordByPid(pid, currentPid = process.pid) {
199
235
  if (!Number.isInteger(pid) || pid <= 0 || pid === currentPid) {
200
236
  return null;
201
237
  }
238
+ if (process.platform === "win32") {
239
+ return getWindowsProcessRecordByPid(pid, currentPid);
240
+ }
202
241
  return listAllProcessesRaw(currentPid).find((record) => record.pid === pid) ?? null;
203
242
  }
204
243
  export function listWechatDaemonProcesses(params = {}) {