@unlinearity/cli-wechat-bridge 1.2.0 → 1.2.2

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 (50) hide show
  1. package/README.md +25 -10
  2. package/bin/wechat-check-update.mjs +0 -0
  3. package/bin/wechat-claude.mjs +0 -0
  4. package/bin/wechat-codex.mjs +0 -0
  5. package/bin/wechat-daemon.mjs +0 -0
  6. package/bin/wechat-opencode.mjs +0 -0
  7. package/bin/wechat-pi.mjs +0 -0
  8. package/bin/wechat-setup.mjs +0 -0
  9. package/bin/wecom-check-update.mjs +0 -0
  10. package/bin/wecom-claude.mjs +0 -0
  11. package/bin/wecom-codex.mjs +0 -0
  12. package/bin/wecom-daemon.mjs +0 -0
  13. package/bin/wecom-opencode.mjs +0 -0
  14. package/bin/wecom-pi.mjs +0 -0
  15. package/bin/wecom-setup.mjs +0 -0
  16. package/dist/bridge/adapter-control.js +19 -12
  17. package/dist/bridge/bridge-adapters.claude.js +219 -115
  18. package/dist/bridge/bridge-adapters.codex.js +295 -90
  19. package/dist/bridge/bridge-adapters.core.js +84 -7
  20. package/dist/bridge/bridge-adapters.opencode.js +344 -74
  21. package/dist/bridge/bridge-adapters.pi.js +23 -18
  22. package/dist/bridge/bridge-adapters.shared.js +9 -9
  23. package/dist/bridge/bridge-session-resume.js +13 -6
  24. package/dist/bridge/bridge-utils.js +94 -144
  25. package/dist/bridge/claude-hooks.js +60 -3
  26. package/dist/bridge/claude-native-controls.js +32 -13
  27. package/dist/bridge/codex-visible-thread-proxy.js +140 -0
  28. package/dist/bridge/language-control.js +33 -0
  29. package/dist/bridge/native-terminal-control.js +38 -10
  30. package/dist/bridge/opencode-v2-compat.js +458 -0
  31. package/dist/bridge/wechat-bridge.js +57 -42
  32. package/dist/channels/channel-driver-factory.js +44 -0
  33. package/dist/channels/local/local-channel-port.js +40 -0
  34. package/dist/channels/local/local-driver.js +217 -0
  35. package/dist/channels/wechat/wechat-driver.js +11 -2
  36. package/dist/channels/wecom/wecom-driver.js +8 -1
  37. package/dist/companion/local-companion.js +27 -7
  38. package/dist/companion/opencode-v2-tui-bridge-plugin.js +93 -0
  39. package/dist/companion/pi-tui-bridge-extension.js +10 -4
  40. package/dist/core/bridge-control.js +3 -0
  41. package/dist/core/bridge-defer.js +3 -2
  42. package/dist/core/bridge-event-forwarder.js +3 -0
  43. package/dist/core/channel-types.js +5 -1
  44. package/dist/daemon/wechat-daemon.js +202 -124
  45. package/dist/i18n/index.js +25 -11
  46. package/dist/i18n/messages-en.js +244 -1
  47. package/dist/i18n/messages-zh.js +244 -1
  48. package/dist/runtime/legacy-adapter-runtime.js +7 -4
  49. package/dist/wechat/channel-config.js +3 -2
  50. package/package.json +4 -2
@@ -4,6 +4,7 @@
4
4
  // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
5
  // must be released under AGPL-3.0-or-later with full source code; see
6
6
  // LICENSE.txt. Network services built on it must offer source to users.
7
+ import { t } from "../i18n/index.js";
7
8
  import { spawn as spawnChildProcess } from "node:child_process";
8
9
  import { randomBytes } from "node:crypto";
9
10
  import fs from "node:fs";
@@ -12,6 +13,7 @@ import path from "node:path";
12
13
  import { fileURLToPath } from "node:url";
13
14
  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
15
  import { killProcessTreeSync } from "./bridge-process-reaper.js";
16
+ import { createOpenCodeV2CompatClient } from "./opencode-v2-compat.js";
15
17
  import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
16
18
  import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, buildOneTimeCode, isWechatOutboundAttachmentMutationTool, isWechatOutboundAttachmentWriteCommand, normalizeOutput, nowIso, truncatePreview, OutputBatcher, } from "./bridge-utils.js";
17
19
  const OPENCODE_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.WECHAT_OPENCODE_DEBUG ?? "");
@@ -24,7 +26,11 @@ const TUI_SESSION_SELECT_SUPPRESSION_TTL_MS = 5_000;
24
26
  const OPENCODE_LOCAL_SESSION_CREATE_FOLLOW_TTL_MS = 5_000;
25
27
  const OPENCODE_TUI_SELECT_RETRY_DELAYS_MS = [250, 750, 1_500];
26
28
  const OPENCODE_MINIMUM_SUPPORTED_VERSION = "1.18.0";
27
- const OPENCODE_MAXIMUM_SUPPORTED_MAJOR = 2;
29
+ const OPENCODE_MAXIMUM_SUPPORTED_MAJOR = 3;
30
+ const OPENCODE_VERSION_PROBE_TIMEOUT_MS = 5_000;
31
+ export function parseOpenCodeCliVersion(output) {
32
+ return /\bv?(\d+\.\d+\.\d+)\b/.exec(output)?.[1] ?? null;
33
+ }
28
34
  const OPENCODE_TUI_ROUTE_READY_TIMEOUT_MS = 10_000;
29
35
  const OPENCODE_TUI_ROUTE_MAX_BUFFER_SIZE = 1024 * 1024;
30
36
  const MODULE_FILE = fileURLToPath(import.meta.url);
@@ -83,6 +89,8 @@ export class OpenCodeServerAdapter {
83
89
  tuiRoutePort = 0;
84
90
  tuiRouteConfigDir = null;
85
91
  tuiRouteConfigPath = null;
92
+ tuiRoutePluginPath = null;
93
+ openCodeMajor = 1;
86
94
  visibleTuiSessionId = null;
87
95
  tuiRouteStateReceived = false;
88
96
  expectedVisibleTuiSessionId = null;
@@ -90,8 +98,14 @@ export class OpenCodeServerAdapter {
90
98
  lastMirroredLocalPrompt = null;
91
99
  pendingLocalSessionCreateFollowUntilMs = 0;
92
100
  pendingPermission = null;
101
+ pendingPermissions = new Map();
102
+ approvalOwnershipGeneration = 0;
103
+ settledPermissionIds = new Set();
104
+ resolvingPermissions = new Set();
105
+ childSessionParents = new Map();
93
106
  pendingQuestion = null;
94
107
  previousAgentBySession = new Map();
108
+ childSessionIds = new Set();
95
109
  async listModels() {
96
110
  this.assertRemoteControlReady();
97
111
  const session = await this.ensureControlSession();
@@ -109,7 +123,7 @@ export class OpenCodeServerAdapter {
109
123
  const models = await this.listControlModels();
110
124
  const target = models.find((model) => model.id === modelId);
111
125
  if (!target) {
112
- throw new Error("This OpenCode model is no longer available. Send /model again.");
126
+ throw new Error(t("adapter.opencode.modelUnavailable"));
113
127
  }
114
128
  const currentVariant = session.model?.variant;
115
129
  const variant = currentVariant && currentVariant !== "default" && target.variants.includes(currentVariant)
@@ -129,7 +143,7 @@ export class OpenCodeServerAdapter {
129
143
  updated.model?.providerID !== target.providerID ||
130
144
  updated.model.id !== target.modelID ||
131
145
  (updated.model.variant ?? "default") !== variant) {
132
- throw new Error("OpenCode did not confirm the selected model.");
146
+ throw new Error(t("adapter.opencode.modelUnconfirmed"));
133
147
  }
134
148
  return { id: target.id, displayName: target.displayName, isCurrent: true };
135
149
  }
@@ -139,11 +153,11 @@ export class OpenCodeServerAdapter {
139
153
  const agents = await this.listPrimaryAgents();
140
154
  const planAgent = agents.find((agent) => agent.name === "plan");
141
155
  if (!planAgent) {
142
- throw new Error("OpenCode primary agent 'plan' is not available.");
156
+ throw new Error(t("adapter.opencode.planUnavailable"));
143
157
  }
144
158
  const currentAgent = session.agent ?? agents[0]?.name;
145
159
  if (!currentAgent) {
146
- throw new Error("OpenCode did not return any primary agents.");
160
+ throw new Error(t("adapter.opencode.agentsMissing"));
147
161
  }
148
162
  if (enabled && currentAgent === "plan") {
149
163
  return true;
@@ -160,7 +174,7 @@ export class OpenCodeServerAdapter {
160
174
  : agents.find((agent) => agent.name === "build")?.name ??
161
175
  agents.find((agent) => agent.name !== "plan")?.name;
162
176
  if (!target) {
163
- throw new Error("OpenCode did not return an agent that can replace plan mode.");
177
+ throw new Error(t("adapter.opencode.planReplacementMissing"));
164
178
  }
165
179
  const result = await this.client.v2.session.switchAgent({
166
180
  sessionID: session.id,
@@ -169,7 +183,7 @@ export class OpenCodeServerAdapter {
169
183
  this.unwrapOrThrow(result);
170
184
  const updated = await this.getSessionForCurrentDirectory(session.id);
171
185
  if (!updated || updated.agent !== target) {
172
- throw new Error("OpenCode did not confirm the selected agent.");
186
+ throw new Error(t("adapter.opencode.agentUnconfirmed"));
173
187
  }
174
188
  if (enabled) {
175
189
  this.previousAgentBySession.set(session.id, currentAgent);
@@ -181,16 +195,16 @@ export class OpenCodeServerAdapter {
181
195
  }
182
196
  assertRemoteControlReady() {
183
197
  if (!this.client) {
184
- throw new Error("OpenCode is not running.");
198
+ throw new Error(t("adapter.opencode.notRunning"));
185
199
  }
186
200
  if (this.state.status === "busy") {
187
- throw new Error("OpenCode is still working. Wait for the current reply or use /stop.");
201
+ throw new Error(t("adapter.opencode.busy"));
188
202
  }
189
203
  if (this.pendingPermission) {
190
- throw new Error("An OpenCode approval request is pending. Reply with /confirm or /deny.");
204
+ throw new Error(t("adapter.opencode.approvalPending"));
191
205
  }
192
206
  if (this.pendingQuestion) {
193
- throw new Error("OpenCode is waiting for user input. Reply with /answer or use /stop.");
207
+ throw new Error(t("adapter.opencode.inputPending"));
194
208
  }
195
209
  }
196
210
  async ensureControlSession() {
@@ -207,7 +221,7 @@ export class OpenCodeServerAdapter {
207
221
  }
208
222
  async listPrimaryAgents() {
209
223
  if (!this.client)
210
- throw new Error("OpenCode is not running.");
224
+ throw new Error(t("adapter.opencode.notRunning"));
211
225
  const agents = this.unwrapOrThrow(await this.client.app.agents({
212
226
  directory: this.options.cwd,
213
227
  workspace: this.activeWorkspaceId ?? undefined,
@@ -216,7 +230,7 @@ export class OpenCodeServerAdapter {
216
230
  }
217
231
  async listControlModels() {
218
232
  if (!this.client)
219
- throw new Error("OpenCode is not running.");
233
+ throw new Error(t("adapter.opencode.notRunning"));
220
234
  const providers = this.unwrapOrThrow(await this.client.provider.list({ directory: this.options.cwd, workspace: this.activeWorkspaceId ?? undefined }, { signal: AbortSignal.timeout(10_000) }));
221
235
  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]) => ({
222
236
  id: `${provider.id}/${id}`, displayName: `${model.name || id} (${provider.name})`,
@@ -229,6 +243,8 @@ export class OpenCodeServerAdapter {
229
243
  this.state = {
230
244
  kind: options.kind,
231
245
  status: "stopped",
246
+ supportsApprovalRequestIds: true,
247
+ pendingApprovals: [],
232
248
  cwd: options.cwd,
233
249
  command: options.command,
234
250
  profile: options.profile,
@@ -250,6 +266,7 @@ export class OpenCodeServerAdapter {
250
266
  this.shuttingDown = false;
251
267
  this.setStatus("starting", "Starting OpenCode companion...");
252
268
  try {
269
+ await this.probeOpenCodeVersion();
253
270
  this.serverPort = await reserveLocalPort();
254
271
  const serverProcess = await this.startServerProcess();
255
272
  await waitForTcpPort(OPENCODE_SERVER_HOST, this.serverPort, OPENCODE_SERVER_READY_TIMEOUT_MS);
@@ -282,16 +299,16 @@ export class OpenCodeServerAdapter {
282
299
  }
283
300
  async sendInput(text) {
284
301
  if (!this.client) {
285
- throw new Error("OpenCode adapter is not running.");
302
+ throw new Error(t("adapter.opencode.adapterNotRunning"));
286
303
  }
287
304
  if (this.state.status === "busy") {
288
- throw new Error("OpenCode is still working. Wait for the current reply or use /stop.");
305
+ throw new Error(t("adapter.opencode.busy"));
289
306
  }
290
307
  if (this.pendingPermission) {
291
- throw new Error("An OpenCode approval request is pending. Reply with /confirm or /deny.");
308
+ throw new Error(t("adapter.opencode.approvalPending"));
292
309
  }
293
310
  if (this.pendingQuestion) {
294
- throw new Error("OpenCode is waiting for user input. Reply with /answer or use /stop.");
311
+ throw new Error(t("adapter.opencode.inputPending"));
295
312
  }
296
313
  const normalized = normalizeOutput(text).trim();
297
314
  if (!normalized) {
@@ -341,7 +358,7 @@ export class OpenCodeServerAdapter {
341
358
  }
342
359
  async listResumeSessions(limit = 10) {
343
360
  if (!this.client) {
344
- throw new Error("OpenCode is not running yet.");
361
+ throw new Error(t("adapter.opencode.notReady"));
345
362
  }
346
363
  try {
347
364
  const sessions = this.unwrapOrThrow(await this.client.session.list({
@@ -377,16 +394,16 @@ export class OpenCodeServerAdapter {
377
394
  }
378
395
  async resumeSession(sessionId) {
379
396
  if (!this.client) {
380
- throw new Error("OpenCode is not running yet.");
397
+ throw new Error(t("adapter.opencode.notReady"));
381
398
  }
382
399
  if (this.pendingPermission) {
383
- throw new Error("An OpenCode approval request is pending. Reply with /confirm or /deny.");
400
+ throw new Error(t("adapter.opencode.approvalPending"));
384
401
  }
385
402
  if (this.pendingQuestion) {
386
- throw new Error("OpenCode is waiting for user input. Reply with /answer or use /stop.");
403
+ throw new Error(t("adapter.opencode.inputPending"));
387
404
  }
388
405
  if (this.state.status === "busy") {
389
- throw new Error("OpenCode is still working. Wait for the current reply or use /stop.");
406
+ throw new Error(t("adapter.opencode.busy"));
390
407
  }
391
408
  if (this.state.status !== "idle") {
392
409
  throw new Error(`OpenCode cannot switch sessions while its status is ${this.state.status}.`);
@@ -414,16 +431,16 @@ export class OpenCodeServerAdapter {
414
431
  }
415
432
  async createSession() {
416
433
  if (!this.client) {
417
- throw new Error("OpenCode adapter is not running.");
434
+ throw new Error(t("adapter.opencode.adapterNotRunning"));
418
435
  }
419
436
  if (this.state.status === "busy") {
420
- throw new Error("OpenCode is still working. Wait for the current reply or use /stop.");
437
+ throw new Error(t("adapter.opencode.busy"));
421
438
  }
422
439
  if (this.pendingPermission) {
423
- throw new Error("An OpenCode approval request is pending. Reply with /confirm or /deny.");
440
+ throw new Error(t("adapter.opencode.approvalPending"));
424
441
  }
425
442
  if (this.pendingQuestion) {
426
- throw new Error("OpenCode is waiting for user input. Reply with /answer or use /stop.");
443
+ throw new Error(t("adapter.opencode.inputPending"));
427
444
  }
428
445
  this.outputBatcher.clear();
429
446
  this.clearStreamedPartState();
@@ -490,22 +507,30 @@ export class OpenCodeServerAdapter {
490
507
  await this.dispose();
491
508
  await this.start();
492
509
  }
493
- async resolveApproval(action) {
494
- if (!this.pendingPermission || !this.client) {
510
+ async resolveApproval(action, requestId) {
511
+ const pending = requestId === undefined
512
+ ? this.pendingPermission
513
+ : this.pendingPermissions.get(requestId);
514
+ if (!pending || !this.client || this.resolvingPermissions.has(pending.permissionId)) {
495
515
  return false;
496
516
  }
497
- const { permissionId } = this.pendingPermission;
498
- const response = action === "confirm" ? "once" : "reject";
517
+ this.resolvingPermissions.add(pending.permissionId);
499
518
  try {
500
519
  const result = await this.client.permission.reply({
501
- requestID: permissionId,
520
+ requestID: pending.permissionId,
521
+ sessionID: pending.sessionId,
502
522
  directory: this.options.cwd,
503
523
  workspace: this.activeWorkspaceId ?? undefined,
504
- reply: response,
524
+ reply: action === "confirm" ? "once" : "reject",
505
525
  });
506
526
  if (result.error !== undefined) {
507
527
  throw new Error(`SDK error: ${describeUnknownError(result.error)}`);
508
528
  }
529
+ this.rememberSettledPermission(pending.permissionId);
530
+ this.pendingPermissions.delete(pending.permissionId);
531
+ this.clearWechatWorkingNotice();
532
+ this.syncPendingPermissions();
533
+ return true;
509
534
  }
510
535
  catch (err) {
511
536
  this.emit({
@@ -515,14 +540,17 @@ export class OpenCodeServerAdapter {
515
540
  });
516
541
  return false;
517
542
  }
518
- this.clearWechatWorkingNotice();
519
- this.clearPendingPermissionState();
520
- this.setStatus("busy");
521
- return true;
543
+ finally {
544
+ this.resolvingPermissions.delete(pending.permissionId);
545
+ }
522
546
  }
523
547
  async resolveAllApprovals(action) {
524
- const ok = await this.resolveApproval(action);
525
- return ok ? 1 : 0;
548
+ let count = 0;
549
+ for (const id of Array.from(this.pendingPermissions.keys())) {
550
+ if (await this.resolveApproval(action, id))
551
+ count++;
552
+ }
553
+ return count;
526
554
  }
527
555
  async submitUserInput(answers) {
528
556
  if (!this.pendingQuestion || !this.client) {
@@ -550,11 +578,14 @@ export class OpenCodeServerAdapter {
550
578
  return false;
551
579
  }
552
580
  this.clearPendingQuestionState();
553
- this.setStatus("busy", "OpenCode user input submitted.");
581
+ this.setStatus(this.pendingPermissions.size ? "awaiting_approval" : "busy", "OpenCode user input submitted.");
554
582
  return true;
555
583
  }
556
584
  async dispose() {
557
585
  this.shuttingDown = true;
586
+ this.childSessionIds.clear();
587
+ this.childSessionParents.clear();
588
+ this.settledPermissionIds.clear();
558
589
  this.clearWechatWorkingNotice(true);
559
590
  this.pendingLocalPrompt = "";
560
591
  this.localPromptNoticeSent = false;
@@ -632,13 +663,28 @@ export class OpenCodeServerAdapter {
632
663
  resolve(address.port);
633
664
  });
634
665
  });
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}`);
666
+ const pluginName = this.openCodeMajor >= 2
667
+ ? "opencode-v2-tui-bridge-plugin"
668
+ : "opencode-tui-bridge-plugin";
669
+ const pluginPath = path.join(MODULE_DIR, "..", "companion", `${pluginName}${RUNTIME_ENTRY_EXTENSION}`);
639
670
  if (!fs.existsSync(pluginPath)) {
640
671
  throw new Error(`OpenCode TUI bridge plugin was not found: ${pluginPath}`);
641
672
  }
673
+ const workspaceDir = ensureWorkspaceChannelDir(this.options.cwd).workspaceDir;
674
+ const configDir = fs.mkdtempSync(path.join(workspaceDir, "opencode-tui-route-"));
675
+ this.tuiRouteConfigDir = configDir;
676
+ if (this.openCodeMajor >= 2) {
677
+ fs.copyFileSync(pluginPath, path.join(configDir, `tui${RUNTIME_ENTRY_EXTENSION}`));
678
+ fs.writeFileSync(path.join(configDir, "package.json"), `${JSON.stringify({
679
+ name: "cli-wechat-bridge-opencode-tui-route",
680
+ private: true,
681
+ type: "module",
682
+ }, null, 2)}\n`, "utf8");
683
+ this.tuiRoutePluginPath = configDir;
684
+ return;
685
+ }
686
+ this.tuiRoutePluginPath = pluginPath;
687
+ const configPath = path.join(configDir, "tui.json");
642
688
  fs.writeFileSync(configPath, `${JSON.stringify({
643
689
  plugin: [
644
690
  [
@@ -650,7 +696,6 @@ export class OpenCodeServerAdapter {
650
696
  ],
651
697
  ],
652
698
  }, null, 2)}\n`, "utf8");
653
- this.tuiRouteConfigDir = configDir;
654
699
  this.tuiRouteConfigPath = configPath;
655
700
  }
656
701
  acceptTuiRouteSocket(socket) {
@@ -765,6 +810,7 @@ export class OpenCodeServerAdapter {
765
810
  const configDir = this.tuiRouteConfigDir;
766
811
  this.tuiRouteConfigDir = null;
767
812
  this.tuiRouteConfigPath = null;
813
+ this.tuiRoutePluginPath = null;
768
814
  this.tuiRoutePort = 0;
769
815
  this.tuiRouteToken = "";
770
816
  this.tuiRouteStateReceived = false;
@@ -781,6 +827,50 @@ export class OpenCodeServerAdapter {
781
827
  }
782
828
  }
783
829
  }
830
+ async probeOpenCodeVersion() {
831
+ const env = buildCliEnvironment(this.options.kind);
832
+ const target = resolveSpawnTarget(this.options.command, this.options.kind, { env });
833
+ const version = await new Promise((resolve, reject) => {
834
+ const child = spawnChildProcess(target.file, [...target.args, "--version"], {
835
+ cwd: this.options.cwd,
836
+ env,
837
+ stdio: ["ignore", "pipe", "pipe"],
838
+ windowsHide: true,
839
+ });
840
+ let output = "";
841
+ let settled = false;
842
+ const finish = (error) => {
843
+ if (settled)
844
+ return;
845
+ settled = true;
846
+ clearTimeout(timer);
847
+ if (error)
848
+ reject(error);
849
+ else
850
+ resolve(output);
851
+ };
852
+ const timer = setTimeout(() => {
853
+ if (child.pid != null) {
854
+ killProcessTreeSync(child.pid);
855
+ }
856
+ finish(new Error("Timed out checking the OpenCode CLI version."));
857
+ }, OPENCODE_VERSION_PROBE_TIMEOUT_MS);
858
+ child.stdout?.on("data", (chunk) => { output += chunk.toString("utf8"); });
859
+ child.stderr?.on("data", (chunk) => { output += chunk.toString("utf8"); });
860
+ child.once("error", (error) => finish(error));
861
+ child.once("exit", (code) => {
862
+ if (code === 0)
863
+ finish();
864
+ else
865
+ finish(new Error(`OpenCode --version exited with code ${code ?? "unknown"}.`));
866
+ });
867
+ });
868
+ const parsed = parseOpenCodeCliVersion(version);
869
+ if (!parsed || !this.isSupportedOpenCodeVersion(parsed)) {
870
+ throw new Error(`Unsupported OpenCode version ${parsed ?? (version.trim() || "unknown")}. Install OpenCode >=${OPENCODE_MINIMUM_SUPPORTED_VERSION} <${OPENCODE_MAXIMUM_SUPPORTED_MAJOR}.0.0.`);
871
+ }
872
+ this.openCodeMajor = Number(parsed.split(".")[0]);
873
+ }
784
874
  async startServerProcess() {
785
875
  const env = buildCliEnvironment(this.options.kind);
786
876
  this.serverPassword = randomBytes(24).toString("hex");
@@ -886,8 +976,9 @@ export class OpenCodeServerAdapter {
886
976
  if ((this.options.extraCliArgs ?? []).includes("--pure")) {
887
977
  throw new Error("OpenCode --pure cannot be used with the WeChat bridge because local session following requires the managed TUI plugin.");
888
978
  }
889
- const args = ["attach", this.getServerUrl()];
890
- args.push("--dir", this.options.cwd);
979
+ const args = this.openCodeMajor >= 2
980
+ ? ["--server", this.getServerUrl()]
981
+ : ["attach", this.getServerUrl(), "--dir", this.options.cwd];
891
982
  const sessionId = this.activeSessionId;
892
983
  if (sessionId && (await this.hasSession(sessionId))) {
893
984
  args.push("--session", sessionId);
@@ -897,7 +988,37 @@ export class OpenCodeServerAdapter {
897
988
  }
898
989
  buildNativeClientEnv() {
899
990
  const env = buildCliEnvironment(this.options.kind);
900
- if (this.tuiRouteConfigDir && this.tuiRouteConfigPath) {
991
+ if (this.openCodeMajor >= 2 && this.tuiRoutePluginPath) {
992
+ let existingConfig = {};
993
+ if (env.OPENCODE_CLI_CONFIG_CONTENT) {
994
+ try {
995
+ const parsed = JSON.parse(env.OPENCODE_CLI_CONFIG_CONTENT);
996
+ if (isRecord(parsed))
997
+ existingConfig = parsed;
998
+ }
999
+ catch (error) {
1000
+ throw new Error(`OpenCode cannot merge the managed TUI plugin into invalid OPENCODE_CLI_CONFIG_CONTENT: ${describeUnknownError(error)}`, { cause: error });
1001
+ }
1002
+ }
1003
+ const existingPlugins = Array.isArray(existingConfig.plugins)
1004
+ ? existingConfig.plugins
1005
+ : [];
1006
+ env.OPENCODE_CLI_CONFIG_CONTENT = JSON.stringify({
1007
+ ...existingConfig,
1008
+ plugins: [
1009
+ ...existingPlugins,
1010
+ {
1011
+ package: this.tuiRoutePluginPath,
1012
+ options: {
1013
+ port: this.tuiRoutePort,
1014
+ token: this.tuiRouteToken,
1015
+ },
1016
+ },
1017
+ ],
1018
+ });
1019
+ env.OPENCODE_PASSWORD = this.serverPassword ?? "";
1020
+ }
1021
+ else if (this.tuiRouteConfigDir && this.tuiRouteConfigPath) {
901
1022
  if (env.OPENCODE_CONFIG_DIR && env.OPENCODE_TUI_CONFIG) {
902
1023
  throw new Error("OpenCode cannot inject the managed TUI session plugin while both OPENCODE_CONFIG_DIR and OPENCODE_TUI_CONFIG are set.");
903
1024
  }
@@ -911,7 +1032,7 @@ export class OpenCodeServerAdapter {
911
1032
  if (this.serverPassword) {
912
1033
  env.OPENCODE_SERVER_PASSWORD = this.serverPassword;
913
1034
  }
914
- if (this.activeSessionId) {
1035
+ if (this.activeSessionId && this.openCodeMajor < 2) {
915
1036
  env.OPENCODE_ROUTE = JSON.stringify({
916
1037
  type: "session",
917
1038
  sessionID: this.activeSessionId,
@@ -928,6 +1049,14 @@ export class OpenCodeServerAdapter {
928
1049
  }
929
1050
  async createSdkClient() {
930
1051
  try {
1052
+ if (this.openCodeMajor >= 2) {
1053
+ this.client = await createOpenCodeV2CompatClient({
1054
+ baseUrl: `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`,
1055
+ directory: this.options.cwd,
1056
+ headers: this.buildServerAuthHeaders(),
1057
+ });
1058
+ return;
1059
+ }
931
1060
  const { createOpencodeClient } = await import("@opencode-ai/sdk/v2");
932
1061
  this.client = createOpencodeClient({
933
1062
  baseUrl: `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`,
@@ -937,7 +1066,8 @@ export class OpenCodeServerAdapter {
937
1066
  });
938
1067
  }
939
1068
  catch (err) {
940
- throw new Error(`Failed to load @opencode-ai/sdk. Make sure it is installed: ${describeUnknownError(err)}`, { cause: err });
1069
+ const packageName = this.openCodeMajor >= 2 ? "@opencode/client" : "@opencode-ai/sdk";
1070
+ throw new Error(`Failed to load ${packageName}. Make sure it is installed: ${describeUnknownError(err)}`, { cause: err });
941
1071
  }
942
1072
  }
943
1073
  async checkHealth() {
@@ -952,17 +1082,24 @@ export class OpenCodeServerAdapter {
952
1082
  let lastError;
953
1083
  while (Date.now() < deadline) {
954
1084
  try {
955
- const response = await fetch(`${baseUrl}/global/health`, {
1085
+ const healthPath = this.openCodeMajor >= 2 ? "/api/info" : "/global/health";
1086
+ const response = await fetch(`${baseUrl}${healthPath}`, {
956
1087
  headers: this.buildServerAuthHeaders(),
957
1088
  signal: AbortSignal.timeout(OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS),
958
1089
  });
959
1090
  if (response.ok) {
960
1091
  const health = await response.json();
961
- if (!isRecord(health) || health.healthy !== true || typeof health.version !== "string") {
1092
+ const version = isRecord(health) && typeof health.version === "string"
1093
+ ? health.version
1094
+ : undefined;
1095
+ const healthy = this.openCodeMajor >= 2
1096
+ ? Boolean(version)
1097
+ : isRecord(health) && health.healthy === true;
1098
+ if (!healthy || !version) {
962
1099
  lastError = new Error("OpenCode health check returned an invalid response.");
963
1100
  }
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.`);
1101
+ else if (!this.isSupportedOpenCodeVersion(version)) {
1102
+ throw new Error(`Unsupported OpenCode version ${version}. Install OpenCode >=${OPENCODE_MINIMUM_SUPPORTED_VERSION} <${OPENCODE_MAXIMUM_SUPPORTED_MAJOR}.0.0.`);
966
1103
  }
967
1104
  else {
968
1105
  return;
@@ -1099,10 +1236,9 @@ export class OpenCodeServerAdapter {
1099
1236
  return;
1100
1237
  }
1101
1238
  this.sseAbortController = new AbortController();
1102
- const sseLoops = [
1103
- this.runSseLoop("event"),
1104
- this.runSseLoop("global-event"),
1105
- ];
1239
+ const sseLoops = this.openCodeMajor >= 2
1240
+ ? [this.runSseLoop("event")]
1241
+ : [this.runSseLoop("event"), this.runSseLoop("global-event")];
1106
1242
  this.sseLoopPromise = Promise.all(sseLoops).then(() => undefined);
1107
1243
  }
1108
1244
  async runSseLoop(streamName) {
@@ -1242,6 +1378,10 @@ export class OpenCodeServerAdapter {
1242
1378
  }
1243
1379
  // Note: only permission.asked exists in the OpenCode wire protocol;
1244
1380
  // permission.updated was never emitted by the server.
1381
+ case "permission.replied": {
1382
+ this.handlePermissionSettled(payload);
1383
+ return;
1384
+ }
1245
1385
  case "permission.asked": {
1246
1386
  this.handlePermissionRequest(payload);
1247
1387
  return;
@@ -1303,7 +1443,6 @@ export class OpenCodeServerAdapter {
1303
1443
  case "session.diff":
1304
1444
  case "session.diff.delta":
1305
1445
  case "message.removed":
1306
- case "permission.replied":
1307
1446
  case "tui.toast.show":
1308
1447
  return;
1309
1448
  default:
@@ -1410,7 +1549,17 @@ export class OpenCodeServerAdapter {
1410
1549
  return;
1411
1550
  }
1412
1551
  const sessionId = this.extractSessionId(properties);
1413
- if (sessionId && !this.syncTrackedSessionFromEvent(sessionId)) {
1552
+ if (!sessionId)
1553
+ return;
1554
+ if (!this.isApprovalSessionOwned(sessionId)) {
1555
+ const ownerSessionId = this.activeSessionId;
1556
+ const generation = this.approvalOwnershipGeneration;
1557
+ void this.discoverApprovalSessionOwnership(sessionId).then((owned) => {
1558
+ if (owned && ownerSessionId === this.activeSessionId && generation === this.approvalOwnershipGeneration)
1559
+ this.handlePermissionRequest(properties);
1560
+ }).catch((error) => {
1561
+ this.logDebug(`[opencode-adapter:approval-owner] ${describeUnknownError(error)}`);
1562
+ });
1414
1563
  return;
1415
1564
  }
1416
1565
  const pendingPermission = this.buildPendingPermission(properties);
@@ -1423,21 +1572,86 @@ export class OpenCodeServerAdapter {
1423
1572
  this.rejectPermission(pendingPermission, denyMessage);
1424
1573
  return;
1425
1574
  }
1426
- const approval = this.toPendingApproval(pendingPermission);
1427
- this.pendingPermission = pendingPermission;
1428
- this.state.pendingApproval = approval;
1429
- this.state.pendingApprovalOrigin = this.state.activeTurnOrigin;
1430
- this.setStatus("awaiting_approval", "OpenCode approval is required.");
1431
- this.emit({
1432
- type: "approval_required",
1433
- request: approval,
1434
- timestamp: nowIso(),
1435
- });
1575
+ if (this.pendingPermissions.has(pendingPermission.permissionId) || this.settledPermissionIds.has(pendingPermission.permissionId))
1576
+ return;
1577
+ this.pendingPermissions.set(pendingPermission.permissionId, pendingPermission);
1578
+ this.syncPendingPermissions();
1579
+ }
1580
+ isApprovalSessionOwned(sessionId) {
1581
+ const seen = new Set();
1582
+ let current = sessionId;
1583
+ while (current && !seen.has(current)) {
1584
+ if (current === this.activeSessionId)
1585
+ return true;
1586
+ seen.add(current);
1587
+ current = this.childSessionParents.get(current);
1588
+ }
1589
+ return false;
1590
+ }
1591
+ async discoverApprovalSessionOwnership(sessionId, seen = new Set()) {
1592
+ if (this.isApprovalSessionOwned(sessionId))
1593
+ return true;
1594
+ if (seen.has(sessionId) || seen.size >= 32 || !this.client?.session?.get)
1595
+ return false;
1596
+ seen.add(sessionId);
1597
+ const result = await this.client.session.get({ sessionID: sessionId, directory: this.options.cwd });
1598
+ const session = result.data;
1599
+ if (!session || !this.isCurrentDirectorySession(session) || !session.parentID)
1600
+ return false;
1601
+ this.childSessionIds.add(sessionId);
1602
+ if (!await this.discoverApprovalSessionOwnership(session.parentID, seen))
1603
+ return false;
1604
+ this.childSessionParents.set(sessionId, session.parentID);
1605
+ return this.isApprovalSessionOwned(sessionId);
1606
+ }
1607
+ rememberSettledPermission(id) {
1608
+ this.settledPermissionIds.add(id);
1609
+ if (this.settledPermissionIds.size > 512) {
1610
+ this.settledPermissionIds.delete(this.settledPermissionIds.values().next().value);
1611
+ }
1612
+ }
1613
+ handlePermissionSettled(properties) {
1614
+ if (!isRecord(properties))
1615
+ return;
1616
+ const id = typeof properties.requestID === "string" ? properties.requestID : properties.id;
1617
+ if (typeof id !== "string")
1618
+ return;
1619
+ const pending = this.pendingPermissions.get(id);
1620
+ if (!pending) {
1621
+ this.rememberSettledPermission(id);
1622
+ return;
1623
+ }
1624
+ if (typeof properties.sessionID === "string" && properties.sessionID !== pending.sessionId)
1625
+ return;
1626
+ this.rememberSettledPermission(id);
1627
+ this.pendingPermissions.delete(id);
1628
+ this.syncPendingPermissions();
1629
+ }
1630
+ syncPendingPermissions() {
1631
+ const previousId = this.state.pendingApproval?.requestId;
1632
+ this.pendingPermission = this.pendingPermissions.values().next().value ?? null;
1633
+ const requests = Array.from(this.pendingPermissions.values(), (pending) => this.toPendingApproval(pending));
1634
+ this.state.pendingApprovals = requests;
1635
+ this.state.pendingApproval = requests[0] ?? null;
1636
+ this.state.pendingApprovalOrigin = this.pendingPermission?.origin;
1637
+ this.setStatus(requests.length ? "awaiting_approval" : this.state.pendingUserInput ? "awaiting_input" : "busy");
1638
+ this.emit({ type: "approval_state_changed", requests, timestamp: nowIso() });
1639
+ if (requests[0] && requests[0].requestId !== previousId) {
1640
+ this.emit({ type: "approval_required", request: requests[0], timestamp: nowIso() });
1641
+ }
1436
1642
  }
1437
1643
  clearPendingPermissionState() {
1644
+ this.approvalOwnershipGeneration++;
1645
+ const hadPending = this.pendingPermissions.size > 0;
1646
+ for (const id of this.pendingPermissions.keys())
1647
+ this.rememberSettledPermission(id);
1648
+ this.pendingPermissions.clear();
1438
1649
  this.pendingPermission = null;
1439
1650
  this.state.pendingApproval = null;
1651
+ this.state.pendingApprovals = [];
1440
1652
  this.state.pendingApprovalOrigin = undefined;
1653
+ if (hadPending)
1654
+ this.emit({ type: "approval_state_changed", requests: [], timestamp: nowIso() });
1441
1655
  }
1442
1656
  handleQuestionAsked(properties) {
1443
1657
  if (!isRecord(properties) || !this.client) {
@@ -1486,7 +1700,7 @@ export class OpenCodeServerAdapter {
1486
1700
  return;
1487
1701
  }
1488
1702
  const request = {
1489
- summary: `OpenCode requested answers to ${questions.length} question${questions.length === 1 ? "" : "s"}.`,
1703
+ summary: t("input.request.opencode", { count: questions.length, questions: t(questions.length === 1 ? "input.request.singular" : "input.request.plural") }),
1490
1704
  questions,
1491
1705
  };
1492
1706
  this.pendingQuestion = {
@@ -1497,7 +1711,7 @@ export class OpenCodeServerAdapter {
1497
1711
  this.state.pendingUserInput = request;
1498
1712
  this.state.pendingUserInputOrigin = this.state.activeTurnOrigin;
1499
1713
  this.clearWechatWorkingNotice();
1500
- this.setStatus("awaiting_input", "OpenCode is waiting for user input.");
1714
+ this.setStatus(this.pendingPermissions.size ? "awaiting_approval" : "awaiting_input", "OpenCode is waiting for user input.");
1501
1715
  this.emit({
1502
1716
  type: "user_input_required",
1503
1717
  request,
@@ -1580,6 +1794,7 @@ export class OpenCodeServerAdapter {
1580
1794
  try {
1581
1795
  const result = await client.permission.reply({
1582
1796
  requestID: pendingPermission.permissionId,
1797
+ sessionID: pendingPermission.sessionId,
1583
1798
  directory: this.options.cwd,
1584
1799
  workspace: this.activeWorkspaceId ?? undefined,
1585
1800
  reply: "reject",
@@ -1623,12 +1838,14 @@ export class OpenCodeServerAdapter {
1623
1838
  const metadata = isRecord(properties.metadata) ? properties.metadata : {};
1624
1839
  const command = this.extractPermissionCommand(properties, metadata);
1625
1840
  return {
1841
+ origin: this.state.activeTurnOrigin,
1626
1842
  sessionId,
1627
1843
  permissionId,
1628
1844
  code: buildOneTimeCode(),
1629
1845
  createdAt: nowIso(),
1630
1846
  request: {
1631
1847
  source: "cli",
1848
+ requestId: permissionId,
1632
1849
  summary: title ?? `OpenCode needs approval${toolName ? ` for tool: ${toolName}` : ""}.`,
1633
1850
  commandPreview: truncatePreview(command ?? title ?? "Permission request", 180),
1634
1851
  toolName,
@@ -1664,6 +1881,17 @@ export class OpenCodeServerAdapter {
1664
1881
  return;
1665
1882
  }
1666
1883
  const session = this.extractSessionReference(properties);
1884
+ if (session?.parentID) {
1885
+ // Subagent creation is not a visible TUI selection. Remember the child
1886
+ // so its later status/output events cannot implicitly follow it either.
1887
+ this.childSessionIds.add(session.id);
1888
+ const directory = this.extractSessionDirectory(properties);
1889
+ if ((!directory || this.normalizeDirectory(directory) === this.normalizeDirectory(this.options.cwd))
1890
+ && (!session.workspaceID || !this.activeWorkspaceId || session.workspaceID === this.activeWorkspaceId)) {
1891
+ this.childSessionParents.set(session.id, session.parentID);
1892
+ }
1893
+ return;
1894
+ }
1667
1895
  if (this.syncTrackedSessionFromEvent(session)) {
1668
1896
  return;
1669
1897
  }
@@ -1689,6 +1917,31 @@ export class OpenCodeServerAdapter {
1689
1917
  if (!sessionId) {
1690
1918
  return;
1691
1919
  }
1920
+ const removedSessions = new Set([sessionId]);
1921
+ let expanded = true;
1922
+ while (expanded) {
1923
+ expanded = false;
1924
+ for (const [child, parent] of this.childSessionParents) {
1925
+ if (removedSessions.has(parent) && !removedSessions.has(child)) {
1926
+ removedSessions.add(child);
1927
+ expanded = true;
1928
+ }
1929
+ }
1930
+ }
1931
+ for (const id of removedSessions) {
1932
+ this.childSessionIds.delete(id);
1933
+ this.childSessionParents.delete(id);
1934
+ }
1935
+ let removedApproval = false;
1936
+ for (const [id, pending] of this.pendingPermissions) {
1937
+ if (removedSessions.has(pending.sessionId)) {
1938
+ this.rememberSettledPermission(id);
1939
+ this.pendingPermissions.delete(id);
1940
+ removedApproval = true;
1941
+ }
1942
+ }
1943
+ if (removedApproval)
1944
+ this.syncPendingPermissions();
1692
1945
  this.previousAgentBySession.delete(sessionId);
1693
1946
  if (sessionId !== this.activeSessionId) {
1694
1947
  return;
@@ -2479,6 +2732,9 @@ export class OpenCodeServerAdapter {
2479
2732
  this.assignActiveSession(session);
2480
2733
  return true;
2481
2734
  }
2735
+ if (this.childSessionIds.has(sessionId)) {
2736
+ return false;
2737
+ }
2482
2738
  if (options.allowLocalTurnFollow !== false && this.shouldFollowLocalTurnSession(sessionId)) {
2483
2739
  this.switchSharedSession(session ?? sessionId, {
2484
2740
  source: "local",
@@ -2491,6 +2747,9 @@ export class OpenCodeServerAdapter {
2491
2747
  return false;
2492
2748
  }
2493
2749
  extractSessionReference(properties) {
2750
+ const session = isRecord(properties.session) ? properties.session : undefined;
2751
+ const info = isRecord(properties.info) ? properties.info : undefined;
2752
+ const parentID = [properties.parentID, session?.parentID, info?.parentID].find((value) => typeof value === "string" && value.length > 0);
2494
2753
  if (typeof properties.sessionID === "string" || typeof properties.sessionId === "string") {
2495
2754
  const id = typeof properties.sessionID === "string"
2496
2755
  ? properties.sessionID
@@ -2498,20 +2757,21 @@ export class OpenCodeServerAdapter {
2498
2757
  return {
2499
2758
  id,
2500
2759
  workspaceID: this.extractWorkspaceId(properties) ?? undefined,
2760
+ parentID,
2501
2761
  };
2502
2762
  }
2503
- const session = isRecord(properties.session) ? properties.session : undefined;
2504
2763
  if (typeof session?.id === "string") {
2505
2764
  return {
2506
2765
  id: session.id,
2507
2766
  workspaceID: this.extractWorkspaceId(session) ?? this.extractWorkspaceId(properties) ?? undefined,
2767
+ parentID,
2508
2768
  };
2509
2769
  }
2510
- const info = isRecord(properties.info) ? properties.info : undefined;
2511
2770
  if (typeof info?.id === "string") {
2512
2771
  return {
2513
2772
  id: info.id,
2514
2773
  workspaceID: this.extractWorkspaceId(info) ?? this.extractWorkspaceId(properties) ?? undefined,
2774
+ parentID,
2515
2775
  };
2516
2776
  }
2517
2777
  return null;
@@ -2557,7 +2817,7 @@ export class OpenCodeServerAdapter {
2557
2817
  return false;
2558
2818
  }
2559
2819
  const session = this.extractSessionReference(payload);
2560
- if (!session?.id || session.id === this.activeSessionId) {
2820
+ if (!session?.id || session.parentID || session.id === this.activeSessionId) {
2561
2821
  return false;
2562
2822
  }
2563
2823
  if (typeof event.directory === "string" ||
@@ -2745,6 +3005,16 @@ export class OpenCodeServerAdapter {
2745
3005
  }
2746
3006
  }
2747
3007
  async sendVisibleSessionSelection(session) {
3008
+ if (this.openCodeMajor >= 2) {
3009
+ if (!this.tuiRouteSocket || this.tuiRouteSocket.destroyed) {
3010
+ throw new Error("The OpenCode 2 TUI route bridge is not connected.");
3011
+ }
3012
+ this.tuiRouteSocket.write(`${JSON.stringify({
3013
+ type: "select_session",
3014
+ sessionId: session.id,
3015
+ })}\n`);
3016
+ return;
3017
+ }
2748
3018
  if (!this.client?.tui) {
2749
3019
  return;
2750
3020
  }