cli-wechat-bridge 1.1.3 → 1.1.5

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 +110 -140
  2. package/bin/wechat-claude.mjs +1 -1
  3. package/bin/wechat-codex.mjs +1 -1
  4. package/bin/wechat-opencode.mjs +1 -1
  5. package/bin/wechat-pi.mjs +1 -1
  6. package/dist/bridge/bridge-adapters.claude.js +448 -11
  7. package/dist/bridge/bridge-adapters.codex.js +262 -11
  8. package/dist/bridge/bridge-adapters.core.js +76 -4
  9. package/dist/bridge/bridge-adapters.js +0 -3
  10. package/dist/bridge/bridge-adapters.opencode.js +497 -49
  11. package/dist/bridge/bridge-adapters.pi.js +44 -8
  12. package/dist/bridge/bridge-adapters.shared.js +13 -100
  13. package/dist/bridge/bridge-controller.js +3 -0
  14. package/dist/bridge/bridge-final-reply.js +17 -2
  15. package/dist/bridge/bridge-process-reaper.js +14 -4
  16. package/dist/bridge/bridge-session-resume.js +151 -0
  17. package/dist/bridge/bridge-state.js +41 -6
  18. package/dist/bridge/bridge-utils.js +63 -229
  19. package/dist/bridge/claude-hooks.js +1 -0
  20. package/dist/bridge/wechat-bridge.js +93 -140
  21. package/dist/bridge/wechat-forwarding.js +58 -0
  22. package/dist/companion/codex-remote-client.js +309 -36
  23. package/dist/companion/codex-visible-client-link.js +122 -0
  24. package/dist/companion/local-companion-link.js +33 -2
  25. package/dist/companion/local-companion-start.js +44 -13
  26. package/dist/companion/local-companion.js +6 -3
  27. package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
  28. package/dist/companion/pi-tui-bridge-extension.js +42 -3
  29. package/dist/daemon/daemon-link.js +25 -3
  30. package/dist/daemon/wechat-daemon.js +168 -56
  31. package/dist/i18n/index.js +0 -3
  32. package/dist/i18n/messages-en.js +2 -3
  33. package/dist/i18n/messages-zh.js +2 -3
  34. package/dist/utils/atomic-file.js +15 -0
  35. package/dist/utils/doctor.js +23 -11
  36. package/dist/wechat/channel-config.js +0 -2
  37. package/dist/wechat/setup.js +5 -11
  38. package/dist/wechat/wechat-transport.js +14 -8
  39. package/package.json +2 -13
  40. package/bin/wechat-bridge-claude.mjs +0 -5
  41. package/bin/wechat-bridge-codex.mjs +0 -5
  42. package/bin/wechat-bridge-opencode.mjs +0 -5
  43. package/bin/wechat-bridge-pi.mjs +0 -5
  44. package/bin/wechat-bridge-shell.mjs +0 -5
  45. package/bin/wechat-bridge.mjs +0 -5
  46. package/bin/wechat-claude-start.mjs +0 -5
  47. package/bin/wechat-codex-start.mjs +0 -5
  48. package/bin/wechat-opencode-start.mjs +0 -5
  49. package/bin/wechat-pi-start.mjs +0 -5
  50. package/dist/bridge/bridge-adapters.shell.js +0 -363
@@ -1,15 +1,29 @@
1
1
  import { spawn as spawnChildProcess } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import net from "node:net";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
2
7
  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";
3
8
  import { killProcessTreeSync } from "./bridge-process-reaper.js";
9
+ import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
4
10
  import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, buildOneTimeCode, isWechatOutboundAttachmentMutationTool, isWechatOutboundAttachmentWriteCommand, normalizeOutput, nowIso, truncatePreview, OutputBatcher, } from "./bridge-utils.js";
5
11
  const OPENCODE_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.WECHAT_OPENCODE_DEBUG ?? "");
6
12
  const OPENCODE_DUPLICATE_EVENT_TTL_MS = 150;
7
13
  const OPENCODE_WECHAT_MIRROR_SUPPRESSION_TTL_MS = 30_000;
8
14
  const OPENCODE_RECENT_LOCAL_PROMPT_TTL_MS = 10_000;
15
+ // How long a selectSession echo suppression stays valid before it is treated
16
+ // as lost and cleared.
17
+ const TUI_SESSION_SELECT_SUPPRESSION_TTL_MS = 5_000;
9
18
  const OPENCODE_LOCAL_SESSION_CREATE_FOLLOW_TTL_MS = 5_000;
10
19
  const OPENCODE_TUI_SELECT_RETRY_DELAYS_MS = [250, 750, 1_500];
11
20
  const OPENCODE_MINIMUM_SUPPORTED_VERSION = "1.18.0";
12
21
  const OPENCODE_MAXIMUM_SUPPORTED_MAJOR = 2;
22
+ const OPENCODE_TUI_ROUTE_READY_TIMEOUT_MS = 10_000;
23
+ const OPENCODE_TUI_ROUTE_MAX_BUFFER_SIZE = 1024 * 1024;
24
+ const MODULE_FILE = fileURLToPath(import.meta.url);
25
+ const MODULE_DIR = path.dirname(MODULE_FILE);
26
+ const RUNTIME_ENTRY_EXTENSION = path.extname(MODULE_FILE) === ".ts" ? ".ts" : ".js";
13
27
  /* ------------------------------------------------------------------ */
14
28
  /* Adapter */
15
29
  /* ------------------------------------------------------------------ */
@@ -20,6 +34,11 @@ export class OpenCodeServerAdapter {
20
34
  serverProcess = null;
21
35
  nativeProcess = null;
22
36
  serverPort = 0;
37
+ // Random per-instance password handed to `opencode serve` via
38
+ // OPENCODE_SERVER_PASSWORD. Without it the server accepts unauthenticated
39
+ // connections from any local process; the attach client inherits the env
40
+ // automatically, and the SDK/health calls send the matching Basic header.
41
+ serverPassword = null;
23
42
  client = null;
24
43
  sseAbortController = null;
25
44
  sseLoopPromise = null;
@@ -51,7 +70,17 @@ export class OpenCodeServerAdapter {
51
70
  pendingWechatPromptMirrorSuppressions = [];
52
71
  recentWechatPromptMirrorSuppressions = [];
53
72
  recentSdkEventObservations = new Map();
54
- suppressedTuiSessionSelectId = null;
73
+ suppressedTuiSessionSelect = null;
74
+ tuiRouteServer = null;
75
+ tuiRouteSocket = null;
76
+ tuiRouteToken = "";
77
+ tuiRoutePort = 0;
78
+ tuiRouteConfigDir = null;
79
+ tuiRouteConfigPath = null;
80
+ visibleTuiSessionId = null;
81
+ tuiRouteStateReceived = false;
82
+ expectedVisibleTuiSessionId = null;
83
+ localSessionFollowChain = Promise.resolve();
55
84
  lastMirroredLocalPrompt = null;
56
85
  pendingLocalSessionCreateFollowUntilMs = 0;
57
86
  pendingPermission = null;
@@ -90,7 +119,9 @@ export class OpenCodeServerAdapter {
90
119
  await this.initializeSessions();
91
120
  this.startSseListener();
92
121
  if (this.options.renderMode === "companion") {
122
+ await this.startTuiRouteBridge();
93
123
  await this.startNativeClient();
124
+ await this.waitForTuiRouteBridgeReady();
94
125
  await this.syncVisibleSessionToShared({ force: true, retry: true });
95
126
  }
96
127
  else {
@@ -159,12 +190,77 @@ export class OpenCodeServerAdapter {
159
190
  }
160
191
  }
161
192
  async listResumeSessions(limit = 10) {
162
- void limit;
163
- throw new Error('WeChat /resume is disabled in opencode mode. Use /session directly inside "wechat-opencode"; WeChat will follow the active local session.');
193
+ if (!this.client) {
194
+ throw new Error("OpenCode is not running yet.");
195
+ }
196
+ try {
197
+ const sessions = this.unwrapOrThrow(await this.client.session.list({
198
+ directory: this.options.cwd,
199
+ workspace: this.activeWorkspaceId ?? undefined,
200
+ roots: true,
201
+ limit: Math.max(1, limit),
202
+ })) ?? [];
203
+ return sessions
204
+ .filter((session) => !session.parentID && this.isCurrentDirectorySession(session))
205
+ .sort((left, right) => right.time.updated - left.time.updated)
206
+ .slice(0, limit)
207
+ .map((session) => {
208
+ const rawTime = typeof session.time?.updated === "number"
209
+ ? session.time.updated
210
+ : typeof session.time?.created === "number"
211
+ ? session.time.created
212
+ : 0;
213
+ const updatedMs = rawTime > 0 && rawTime < 1_000_000_000_000 ? rawTime * 1_000 : rawTime;
214
+ return {
215
+ sessionId: session.id,
216
+ title: session.title?.trim() || "untitled",
217
+ lastUpdatedAt: new Date(updatedMs > 0 ? updatedMs : Date.now()).toISOString(),
218
+ source: "opencode",
219
+ };
220
+ });
221
+ }
222
+ catch (error) {
223
+ throw new Error(`Failed to list OpenCode sessions: ${describeUnknownError(error)}`, {
224
+ cause: error,
225
+ });
226
+ }
164
227
  }
165
228
  async resumeSession(sessionId) {
166
- void sessionId;
167
- throw new Error('WeChat /resume is disabled in opencode mode. Use /session directly inside "wechat-opencode"; WeChat will follow the active local session.');
229
+ if (!this.client) {
230
+ throw new Error("OpenCode is not running yet.");
231
+ }
232
+ if (this.pendingPermission) {
233
+ throw new Error("An OpenCode approval request is pending. Reply with /confirm or /deny.");
234
+ }
235
+ if (this.pendingQuestion) {
236
+ throw new Error("OpenCode is waiting for user input. Reply with /answer or use /stop.");
237
+ }
238
+ if (this.state.status === "busy") {
239
+ throw new Error("OpenCode is still working. Wait for the current reply or use /stop.");
240
+ }
241
+ if (this.state.status !== "idle") {
242
+ throw new Error(`OpenCode cannot switch sessions while its status is ${this.state.status}.`);
243
+ }
244
+ const session = await this.getSessionForCurrentDirectory(sessionId, []);
245
+ if (!session) {
246
+ throw new Error(`No OpenCode session ${sessionId} was found for ${this.options.cwd}.`);
247
+ }
248
+ const sessionStatuses = this.unwrapOrThrow(await this.client.session.status({
249
+ workspace: session.workspaceID ?? this.activeWorkspaceId ?? undefined,
250
+ }));
251
+ const targetStatus = sessionStatuses[session.id];
252
+ if (targetStatus && targetStatus.type !== "idle") {
253
+ const statusLabel = targetStatus.type === "retry" ? "retrying" : "busy";
254
+ throw new Error(`OpenCode session ${session.id} is still ${statusLabel}. Wait for it to become idle before resuming it.`);
255
+ }
256
+ await this.selectVisibleSessionForResume(session);
257
+ this.switchSharedSession(session, {
258
+ source: "wechat",
259
+ reason: "wechat_resume",
260
+ notify: true,
261
+ clearTrackedTurn: true,
262
+ syncVisible: false,
263
+ });
168
264
  }
169
265
  async createSession() {
170
266
  if (!this.client) {
@@ -345,6 +441,7 @@ export class OpenCodeServerAdapter {
345
441
  }
346
442
  }
347
443
  }
444
+ await this.stopTuiRouteBridge();
348
445
  if (this.serverProcess) {
349
446
  const proc = this.serverProcess;
350
447
  this.serverProcess = null;
@@ -360,14 +457,183 @@ export class OpenCodeServerAdapter {
360
457
  this.client = null;
361
458
  this.activeSessionId = null;
362
459
  this.activeWorkspaceId = null;
363
- this.suppressedTuiSessionSelectId = null;
460
+ this.suppressedTuiSessionSelect = null;
364
461
  this.state.status = "stopped";
365
462
  this.state.pid = undefined;
366
463
  this.state.startedAt = undefined;
367
464
  }
368
465
  /* ---- Server management ---- */
466
+ async startTuiRouteBridge() {
467
+ this.tuiRouteToken = randomBytes(24).toString("hex");
468
+ this.tuiRouteStateReceived = false;
469
+ this.visibleTuiSessionId = null;
470
+ const server = net.createServer((socket) => this.acceptTuiRouteSocket(socket));
471
+ this.tuiRouteServer = server;
472
+ this.tuiRoutePort = await new Promise((resolve, reject) => {
473
+ server.once("error", reject);
474
+ server.listen(0, OPENCODE_SERVER_HOST, () => {
475
+ server.removeListener("error", reject);
476
+ const address = server.address();
477
+ if (!address || typeof address === "string") {
478
+ reject(new Error("OpenCode TUI route bridge did not expose a local port."));
479
+ return;
480
+ }
481
+ resolve(address.port);
482
+ });
483
+ });
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}`);
488
+ if (!fs.existsSync(pluginPath)) {
489
+ throw new Error(`OpenCode TUI bridge plugin was not found: ${pluginPath}`);
490
+ }
491
+ fs.writeFileSync(configPath, `${JSON.stringify({
492
+ plugin: [
493
+ [
494
+ pluginPath,
495
+ {
496
+ port: this.tuiRoutePort,
497
+ token: this.tuiRouteToken,
498
+ },
499
+ ],
500
+ ],
501
+ }, null, 2)}\n`, "utf8");
502
+ this.tuiRouteConfigDir = configDir;
503
+ this.tuiRouteConfigPath = configPath;
504
+ }
505
+ acceptTuiRouteSocket(socket) {
506
+ socket.setEncoding("utf8");
507
+ socket.setNoDelay(true);
508
+ let buffer = "";
509
+ let authenticated = false;
510
+ socket.on("data", (chunk) => {
511
+ buffer += chunk;
512
+ if (buffer.length > OPENCODE_TUI_ROUTE_MAX_BUFFER_SIZE) {
513
+ socket.destroy();
514
+ return;
515
+ }
516
+ while (true) {
517
+ const newlineIndex = buffer.indexOf("\n");
518
+ if (newlineIndex < 0) {
519
+ return;
520
+ }
521
+ const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
522
+ buffer = buffer.slice(newlineIndex + 1);
523
+ if (!line.trim()) {
524
+ continue;
525
+ }
526
+ try {
527
+ const frame = JSON.parse(line);
528
+ if (!isRecord(frame)) {
529
+ continue;
530
+ }
531
+ if (!authenticated) {
532
+ if (frame.type !== "hello" || frame.token !== this.tuiRouteToken) {
533
+ socket.destroy();
534
+ return;
535
+ }
536
+ authenticated = true;
537
+ const previousSocket = this.tuiRouteSocket;
538
+ this.tuiRouteSocket = socket;
539
+ if (previousSocket && previousSocket !== socket) {
540
+ previousSocket.destroy();
541
+ }
542
+ continue;
543
+ }
544
+ this.handleTuiRouteFrame(frame);
545
+ }
546
+ catch {
547
+ // Ignore malformed plugin frames and keep the visible TUI alive.
548
+ }
549
+ }
550
+ });
551
+ socket.once("close", () => {
552
+ if (this.tuiRouteSocket === socket) {
553
+ this.tuiRouteSocket = null;
554
+ }
555
+ });
556
+ socket.once("error", () => {
557
+ // Close handling owns route observer cleanup.
558
+ });
559
+ }
560
+ handleTuiRouteFrame(frame) {
561
+ if (frame.type !== "route_state") {
562
+ return;
563
+ }
564
+ const sessionId = typeof frame.sessionId === "string" ? frame.sessionId : null;
565
+ this.tuiRouteStateReceived = true;
566
+ this.visibleTuiSessionId = sessionId;
567
+ if (!sessionId ||
568
+ sessionId === this.activeSessionId ||
569
+ sessionId === this.expectedVisibleTuiSessionId) {
570
+ return;
571
+ }
572
+ this.queueLocalVisibleSessionFollow(sessionId);
573
+ }
574
+ async waitForTuiRouteBridgeReady() {
575
+ const deadline = Date.now() + OPENCODE_TUI_ROUTE_READY_TIMEOUT_MS;
576
+ while (Date.now() < deadline) {
577
+ if (this.tuiRouteSocket && this.tuiRouteStateReceived) {
578
+ return;
579
+ }
580
+ await delay(100);
581
+ }
582
+ throw new Error("The OpenCode TUI route bridge did not become ready. OpenCode 1.18 or newer with TUI plugin support is required.");
583
+ }
584
+ async waitForVisibleTuiSession(sessionId) {
585
+ const deadline = Date.now() + OPENCODE_TUI_ROUTE_READY_TIMEOUT_MS;
586
+ while (Date.now() < deadline) {
587
+ if (!this.tuiRouteSocket) {
588
+ throw new Error("The visible OpenCode TUI route bridge disconnected.");
589
+ }
590
+ if (this.visibleTuiSessionId === sessionId) {
591
+ return;
592
+ }
593
+ await delay(100);
594
+ }
595
+ throw new Error(`The visible OpenCode TUI did not confirm session ${sessionId} before the timeout.`);
596
+ }
597
+ async stopTuiRouteBridge() {
598
+ this.tuiRouteSocket?.destroy();
599
+ this.tuiRouteSocket = null;
600
+ if (this.tuiRouteServer) {
601
+ const server = this.tuiRouteServer;
602
+ this.tuiRouteServer = null;
603
+ if (server.listening) {
604
+ await new Promise((resolve) => {
605
+ try {
606
+ server.close(() => resolve());
607
+ }
608
+ catch {
609
+ resolve();
610
+ }
611
+ });
612
+ }
613
+ }
614
+ const configDir = this.tuiRouteConfigDir;
615
+ this.tuiRouteConfigDir = null;
616
+ this.tuiRouteConfigPath = null;
617
+ this.tuiRoutePort = 0;
618
+ this.tuiRouteToken = "";
619
+ this.tuiRouteStateReceived = false;
620
+ this.visibleTuiSessionId = null;
621
+ this.expectedVisibleTuiSessionId = null;
622
+ if (configDir) {
623
+ const workspaceDir = ensureWorkspaceChannelDir(this.options.cwd).workspaceDir;
624
+ const resolvedConfigDir = path.resolve(configDir);
625
+ const resolvedWorkspaceDir = path.resolve(workspaceDir);
626
+ const isManagedConfigDir = resolvedConfigDir.startsWith(`${resolvedWorkspaceDir}${path.sep}`) &&
627
+ path.basename(resolvedConfigDir).startsWith("opencode-tui-route-");
628
+ if (isManagedConfigDir) {
629
+ fs.rmSync(resolvedConfigDir, { recursive: true, force: true });
630
+ }
631
+ }
632
+ }
369
633
  async startServerProcess() {
370
634
  const env = buildCliEnvironment(this.options.kind);
635
+ this.serverPassword = randomBytes(24).toString("hex");
636
+ env.OPENCODE_SERVER_PASSWORD = this.serverPassword;
371
637
  const serverArgs = [
372
638
  "serve",
373
639
  "--port",
@@ -466,6 +732,9 @@ export class OpenCodeServerAdapter {
466
732
  });
467
733
  }
468
734
  async buildNativeAttachArgs() {
735
+ if ((this.options.extraCliArgs ?? []).includes("--pure")) {
736
+ throw new Error("OpenCode --pure cannot be used with the WeChat bridge because local session following requires the managed TUI plugin.");
737
+ }
469
738
  const args = ["attach", this.getServerUrl()];
470
739
  args.push("--dir", this.options.cwd);
471
740
  const sessionId = this.activeSessionId;
@@ -477,6 +746,20 @@ export class OpenCodeServerAdapter {
477
746
  }
478
747
  buildNativeClientEnv() {
479
748
  const env = buildCliEnvironment(this.options.kind);
749
+ if (this.tuiRouteConfigDir && this.tuiRouteConfigPath) {
750
+ if (env.OPENCODE_CONFIG_DIR && env.OPENCODE_TUI_CONFIG) {
751
+ throw new Error("OpenCode cannot inject the managed TUI session plugin while both OPENCODE_CONFIG_DIR and OPENCODE_TUI_CONFIG are set.");
752
+ }
753
+ if (env.OPENCODE_CONFIG_DIR) {
754
+ env.OPENCODE_TUI_CONFIG = this.tuiRouteConfigPath;
755
+ }
756
+ else {
757
+ env.OPENCODE_CONFIG_DIR = this.tuiRouteConfigDir;
758
+ }
759
+ }
760
+ if (this.serverPassword) {
761
+ env.OPENCODE_SERVER_PASSWORD = this.serverPassword;
762
+ }
480
763
  if (this.activeSessionId) {
481
764
  env.OPENCODE_ROUTE = JSON.stringify({
482
765
  type: "session",
@@ -485,6 +768,13 @@ export class OpenCodeServerAdapter {
485
768
  }
486
769
  return env;
487
770
  }
771
+ buildServerAuthHeaders() {
772
+ if (!this.serverPassword) {
773
+ return {};
774
+ }
775
+ const credentials = Buffer.from(`opencode:${this.serverPassword}`, "utf8").toString("base64");
776
+ return { Authorization: `Basic ${credentials}` };
777
+ }
488
778
  async createSdkClient() {
489
779
  try {
490
780
  const { createOpencodeClient } = await import("@opencode-ai/sdk/v2");
@@ -492,6 +782,7 @@ export class OpenCodeServerAdapter {
492
782
  baseUrl: `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`,
493
783
  directory: this.options.cwd,
494
784
  experimental_workspaceID: this.activeWorkspaceId ?? undefined,
785
+ headers: this.buildServerAuthHeaders(),
495
786
  });
496
787
  }
497
788
  catch (err) {
@@ -511,6 +802,7 @@ export class OpenCodeServerAdapter {
511
802
  while (Date.now() < deadline) {
512
803
  try {
513
804
  const response = await fetch(`${baseUrl}/global/health`, {
805
+ headers: this.buildServerAuthHeaders(),
514
806
  signal: AbortSignal.timeout(OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS),
515
807
  });
516
808
  if (response.ok) {
@@ -547,7 +839,9 @@ export class OpenCodeServerAdapter {
547
839
  }
548
840
  const major = Number(match[1]);
549
841
  const minor = Number(match[2]);
550
- return major === 1 && minor >= 18;
842
+ return (major >= 1 &&
843
+ major < OPENCODE_MAXIMUM_SUPPORTED_MAJOR &&
844
+ (major > 1 || minor >= 18));
551
845
  }
552
846
  async initializeSessions() {
553
847
  if (!this.client) {
@@ -717,6 +1011,9 @@ export class OpenCodeServerAdapter {
717
1011
  return this.normalizeGlobalSyncPayload(payload, rawEvent);
718
1012
  }
719
1013
  const normalized = { ...payload };
1014
+ if (typeof payload.id === "string") {
1015
+ normalized.id = payload.id;
1016
+ }
720
1017
  if (typeof rawEvent.directory === "string") {
721
1018
  normalized.directory = rawEvent.directory;
722
1019
  }
@@ -792,7 +1089,8 @@ export class OpenCodeServerAdapter {
792
1089
  this.handleSessionError(isRecord(payload) ? payload : undefined);
793
1090
  return;
794
1091
  }
795
- case "permission.updated":
1092
+ // Note: only permission.asked exists in the OpenCode wire protocol;
1093
+ // permission.updated was never emitted by the server.
796
1094
  case "permission.asked": {
797
1095
  this.handlePermissionRequest(payload);
798
1096
  return;
@@ -814,6 +1112,10 @@ export class OpenCodeServerAdapter {
814
1112
  this.handleSessionUpdated(payload);
815
1113
  return;
816
1114
  }
1115
+ case "session.deleted": {
1116
+ this.handleSessionDeleted(payload);
1117
+ return;
1118
+ }
817
1119
  case "message.updated": {
818
1120
  // Full message update — not used for incremental text extraction.
819
1121
  // Text output comes from message.part.updated events.
@@ -849,7 +1151,6 @@ export class OpenCodeServerAdapter {
849
1151
  }
850
1152
  case "session.diff":
851
1153
  case "session.diff.delta":
852
- case "session.deleted":
853
1154
  case "message.removed":
854
1155
  case "permission.replied":
855
1156
  case "tui.toast.show":
@@ -930,7 +1231,10 @@ export class OpenCodeServerAdapter {
930
1231
  if (sessionId && !this.syncTrackedSessionFromEvent(sessionId)) {
931
1232
  return;
932
1233
  }
933
- // properties: { sessionID: string, status: { type: "busy" | "idle" | ... } }
1234
+ // properties: { sessionID, status: { type: "busy" | "idle" | "retry" | ... } }
1235
+ // "retry" means the model call is being retried (attempt, message): the
1236
+ // session is still working, so it must keep the busy state instead of
1237
+ // letting WeChat messages race a retried turn.
934
1238
  const status = properties.status;
935
1239
  if (!isRecord(status)) {
936
1240
  return;
@@ -939,7 +1243,7 @@ export class OpenCodeServerAdapter {
939
1243
  if (!statusType) {
940
1244
  return;
941
1245
  }
942
- if (statusType === "busy" || statusType === "running") {
1246
+ if (statusType === "busy" || statusType === "running" || statusType === "retry") {
943
1247
  if (this.state.status === "idle") {
944
1248
  this.outputBatcher.clear();
945
1249
  this.clearStreamedPartState();
@@ -1217,13 +1521,7 @@ export class OpenCodeServerAdapter {
1217
1521
  }
1218
1522
  this.pendingLocalSessionCreateFollowUntilMs = 0;
1219
1523
  this.logDebug(`[opencode-adapter:local-follow] session.created ${session.id}`);
1220
- this.switchSharedSession(session, {
1221
- source: "local",
1222
- reason: "local_follow",
1223
- notify: true,
1224
- clearTrackedTurn: true,
1225
- syncVisible: true,
1226
- });
1524
+ this.queueLocalVisibleSessionFollow(session.id, session.workspaceID);
1227
1525
  }
1228
1526
  handleSessionUpdated(properties) {
1229
1527
  if (!isRecord(properties)) {
@@ -1232,6 +1530,32 @@ export class OpenCodeServerAdapter {
1232
1530
  const session = this.extractSessionReference(properties);
1233
1531
  this.syncTrackedSessionFromEvent(session, { allowLocalTurnFollow: false });
1234
1532
  }
1533
+ handleSessionDeleted(properties) {
1534
+ if (!isRecord(properties)) {
1535
+ return;
1536
+ }
1537
+ const sessionId = this.extractSessionId(properties);
1538
+ if (!sessionId || sessionId !== this.activeSessionId) {
1539
+ return;
1540
+ }
1541
+ this.clearTrackedTurnForLocalSessionSwitch();
1542
+ this.activeSessionId = null;
1543
+ this.state.sharedSessionId = undefined;
1544
+ this.state.sharedThreadId = undefined;
1545
+ this.state.activeRuntimeSessionId = undefined;
1546
+ this.suppressedTuiSessionSelect = null;
1547
+ if (this.state.status !== "idle") {
1548
+ this.setStatus("idle");
1549
+ }
1550
+ else {
1551
+ this.emit({
1552
+ type: "status",
1553
+ status: "idle",
1554
+ message: "The active OpenCode session was deleted locally.",
1555
+ timestamp: nowIso(),
1556
+ });
1557
+ }
1558
+ }
1235
1559
  handleMessageUpdated(properties) {
1236
1560
  if (!isRecord(properties)) {
1237
1561
  return;
@@ -1595,24 +1919,18 @@ export class OpenCodeServerAdapter {
1595
1919
  if (!sessionId) {
1596
1920
  return;
1597
1921
  }
1598
- if (sessionId === this.suppressedTuiSessionSelectId) {
1599
- this.suppressedTuiSessionSelectId = null;
1922
+ if (this.suppressedTuiSessionSelect &&
1923
+ sessionId === this.suppressedTuiSessionSelect.sessionId) {
1924
+ // Echo of our own selectSession — swallow it once. The TTL guard in
1925
+ // isSuppressedTuiSessionSelectExpired covers the lost-echo case.
1926
+ this.suppressedTuiSessionSelect = null;
1600
1927
  return;
1601
1928
  }
1602
1929
  this.pendingLocalPrompt = "";
1603
1930
  this.localPromptNoticeSent = false;
1604
1931
  this.pendingLocalSessionCreateFollowUntilMs = 0;
1605
1932
  this.logDebug(`[opencode-adapter:local-follow] tui.session.select ${sessionId}`);
1606
- this.switchSharedSession({
1607
- id: sessionId,
1608
- workspaceID: this.extractWorkspaceId(properties) ?? undefined,
1609
- }, {
1610
- source: "local",
1611
- reason: "local_follow",
1612
- notify: true,
1613
- clearTrackedTurn: true,
1614
- syncVisible: true,
1615
- });
1933
+ this.queueLocalVisibleSessionFollow(sessionId, this.extractWorkspaceId(properties) ?? undefined);
1616
1934
  }
1617
1935
  handleCommandExecuted(properties) {
1618
1936
  if (!isRecord(properties)) {
@@ -1625,16 +1943,7 @@ export class OpenCodeServerAdapter {
1625
1943
  const sessionId = this.extractSessionId(properties);
1626
1944
  if (sessionId) {
1627
1945
  this.logDebug(`[opencode-adapter:local-follow] command.executed ${name} -> ${sessionId}`);
1628
- this.switchSharedSession({
1629
- id: sessionId,
1630
- workspaceID: this.extractWorkspaceId(properties) ?? undefined,
1631
- }, {
1632
- source: "local",
1633
- reason: "local_follow",
1634
- notify: true,
1635
- clearTrackedTurn: true,
1636
- syncVisible: true,
1637
- });
1946
+ this.queueLocalVisibleSessionFollow(sessionId, this.extractWorkspaceId(properties) ?? undefined);
1638
1947
  return;
1639
1948
  }
1640
1949
  this.markPendingLocalSessionCreateFollow();
@@ -2142,13 +2451,130 @@ export class OpenCodeServerAdapter {
2142
2451
  workspaceID: this.activeWorkspaceId ?? undefined,
2143
2452
  }, { force: options.force, retry: options.retry });
2144
2453
  }
2454
+ queueLocalVisibleSessionFollow(sessionId, workspaceID) {
2455
+ if (!this.client?.session?.get) {
2456
+ this.switchSharedSession({ id: sessionId, workspaceID }, {
2457
+ source: "local",
2458
+ reason: "local_follow",
2459
+ notify: true,
2460
+ clearTrackedTurn: true,
2461
+ syncVisible: false,
2462
+ });
2463
+ return;
2464
+ }
2465
+ const run = this.localSessionFollowChain.then(async () => {
2466
+ await this.followLocalVisibleSession(sessionId, workspaceID);
2467
+ });
2468
+ this.localSessionFollowChain = run.catch((error) => {
2469
+ this.logDebug(`[opencode-adapter:local-follow] failed for ${sessionId}: ${describeUnknownError(error)}`);
2470
+ });
2471
+ }
2472
+ async followLocalVisibleSession(sessionId, workspaceID) {
2473
+ if (this.shuttingDown || !this.client || sessionId === this.activeSessionId) {
2474
+ return;
2475
+ }
2476
+ const session = await this.getSessionForCurrentDirectory(sessionId, []);
2477
+ if (!session) {
2478
+ throw new Error(`The locally selected OpenCode session ${sessionId} does not belong to ${this.options.cwd}.`);
2479
+ }
2480
+ if (workspaceID && !session.workspaceID) {
2481
+ session.workspaceID = workspaceID;
2482
+ }
2483
+ const previousSessionId = this.activeSessionId;
2484
+ const interruptedWechatTurn = Boolean(previousSessionId) &&
2485
+ this.state.activeTurnOrigin === "wechat" &&
2486
+ this.hasTrackedTurnState();
2487
+ if (interruptedWechatTurn && previousSessionId) {
2488
+ try {
2489
+ const result = await this.client.session.abort({
2490
+ sessionID: previousSessionId,
2491
+ directory: this.options.cwd,
2492
+ workspace: this.activeWorkspaceId ?? undefined,
2493
+ });
2494
+ if (result.error !== undefined) {
2495
+ throw result.error;
2496
+ }
2497
+ }
2498
+ catch (error) {
2499
+ this.logDebug(`[opencode-adapter:local-follow] failed to abort ${previousSessionId}: ${describeUnknownError(error)}`);
2500
+ }
2501
+ this.failTrackedTurn("The active WeChat task was interrupted because the local OpenCode terminal switched sessions.");
2502
+ }
2503
+ else {
2504
+ this.clearTrackedTurnForLocalSessionSwitch();
2505
+ }
2506
+ this.switchSharedSession(session, {
2507
+ source: "local",
2508
+ reason: "local_follow",
2509
+ notify: true,
2510
+ clearTrackedTurn: false,
2511
+ syncVisible: false,
2512
+ });
2513
+ }
2514
+ async selectVisibleSessionForResume(session) {
2515
+ if (!this.client?.tui || this.options.renderMode !== "companion") {
2516
+ throw new Error("The visible OpenCode companion is not connected.");
2517
+ }
2518
+ this.suppressedTuiSessionSelect = {
2519
+ sessionId: session.id,
2520
+ setAtMs: Date.now(),
2521
+ };
2522
+ this.expectedVisibleTuiSessionId = session.id;
2523
+ let lastError;
2524
+ let confirmed = false;
2525
+ try {
2526
+ let selected = false;
2527
+ for (const delayMs of [0, ...OPENCODE_TUI_SELECT_RETRY_DELAYS_MS]) {
2528
+ if (delayMs > 0) {
2529
+ await delay(delayMs);
2530
+ }
2531
+ try {
2532
+ await this.sendVisibleSessionSelection(session);
2533
+ selected = true;
2534
+ break;
2535
+ }
2536
+ catch (error) {
2537
+ lastError = error;
2538
+ }
2539
+ }
2540
+ if (selected) {
2541
+ await this.waitForVisibleTuiSession(session.id);
2542
+ confirmed = true;
2543
+ return;
2544
+ }
2545
+ }
2546
+ finally {
2547
+ if (this.expectedVisibleTuiSessionId === session.id) {
2548
+ this.expectedVisibleTuiSessionId = null;
2549
+ }
2550
+ if (!confirmed &&
2551
+ this.suppressedTuiSessionSelect?.sessionId === session.id) {
2552
+ this.suppressedTuiSessionSelect = null;
2553
+ }
2554
+ }
2555
+ throw new Error(`Failed to switch the visible OpenCode session: ${describeUnknownError(lastError)}`, { cause: lastError });
2556
+ }
2557
+ isTuiSessionSelectSuppressed(sessionId) {
2558
+ const suppression = this.suppressedTuiSessionSelect;
2559
+ if (!suppression || suppression.sessionId !== sessionId) {
2560
+ return false;
2561
+ }
2562
+ // Bound the suppression window: if the echo never arrives (TUI not ready,
2563
+ // dropped event), a stale flag must not swallow a later genuine local
2564
+ // switch to the same session.
2565
+ if (Date.now() - suppression.setAtMs > TUI_SESSION_SELECT_SUPPRESSION_TTL_MS) {
2566
+ this.suppressedTuiSessionSelect = null;
2567
+ return false;
2568
+ }
2569
+ return true;
2570
+ }
2145
2571
  async syncVisibleSessionSelection(session, options = {}) {
2146
2572
  if (!this.client?.tui ||
2147
2573
  this.options.renderMode !== "companion" ||
2148
- (!options.force && session.id === this.suppressedTuiSessionSelectId)) {
2574
+ (!options.force && this.isTuiSessionSelectSuppressed(session.id))) {
2149
2575
  return;
2150
2576
  }
2151
- this.suppressedTuiSessionSelectId = session.id;
2577
+ this.suppressedTuiSessionSelect = { sessionId: session.id, setAtMs: Date.now() };
2152
2578
  this.logDebug(`[opencode-adapter:tui] selectSession session=${session.id} workspace=${session.workspaceID ?? this.activeWorkspaceId ?? "(none)"}`);
2153
2579
  try {
2154
2580
  await this.sendVisibleSessionSelection(session);
@@ -2157,8 +2583,8 @@ export class OpenCodeServerAdapter {
2157
2583
  }
2158
2584
  }
2159
2585
  catch (err) {
2160
- if (this.suppressedTuiSessionSelectId === session.id) {
2161
- this.suppressedTuiSessionSelectId = null;
2586
+ if (this.suppressedTuiSessionSelect?.sessionId === session.id) {
2587
+ this.suppressedTuiSessionSelect = null;
2162
2588
  }
2163
2589
  this.logDebug(`[opencode-adapter:tui] selectSession failed for ${session.id}: ${describeUnknownError(err)}`);
2164
2590
  }
@@ -2266,8 +2692,10 @@ export class OpenCodeServerAdapter {
2266
2692
  }
2267
2693
  return true;
2268
2694
  }
2695
+ // Fold both path separators to "/" so directory comparisons behave
2696
+ // identically on Windows (backslash-native) and POSIX hosts.
2269
2697
  normalizeDirectory(directory) {
2270
- return directory.replace(/[\\/]+/g, "\\").replace(/\\$/, "").toLowerCase();
2698
+ return directory.replace(/[\\/]+/g, "/").replace(/\/+$/, "").toLowerCase();
2271
2699
  }
2272
2700
  recordSessionSwitch(sessionId, source, reason, notify = false) {
2273
2701
  const timestamp = nowIso();
@@ -2325,13 +2753,20 @@ export class OpenCodeServerAdapter {
2325
2753
  return "";
2326
2754
  }
2327
2755
  const previousText = this.emittedTextByPartId.get(partId) ?? "";
2328
- if (nextChunk === previousText || previousText.endsWith(nextChunk)) {
2756
+ if (nextChunk === previousText) {
2757
+ // Full replay of everything emitted for this part (an SSE replay after
2758
+ // a reconnect): nothing new.
2329
2759
  return "";
2330
2760
  }
2331
2761
  if (previousText && nextChunk.startsWith(previousText)) {
2762
+ // The "delta" actually carries the accumulated part text: keep the tail.
2332
2763
  this.emittedTextByPartId.set(partId, nextChunk);
2333
2764
  return nextChunk.slice(previousText.length);
2334
2765
  }
2766
+ // Plain incremental delta: append. Duplicated fragments that legitimately
2767
+ // occur in model output (repeated words, table separators, markdown
2768
+ // rules) must still be forwarded, so there is deliberately no
2769
+ // suffix-based dedup here — it silently truncated real content.
2335
2770
  this.emittedTextByPartId.set(partId, `${previousText}${nextChunk}`);
2336
2771
  return nextChunk;
2337
2772
  }
@@ -2419,18 +2854,31 @@ export class OpenCodeServerAdapter {
2419
2854
  if (!key) {
2420
2855
  return false;
2421
2856
  }
2857
+ // Frame-id keys identify one exact server event, so any repeat — even on
2858
+ // the same stream (SSE replay) — is a duplicate; the wide TTL absorbs
2859
+ // reconnect replays. Synthetic keys still rely on the narrow cross-stream
2860
+ // window only, because they cannot distinguish a replay from a legitimate
2861
+ // repeated payload.
2862
+ const idKeyTtlMs = 30_000;
2863
+ const isIdKey = key.startsWith("evt:");
2864
+ const ttlMs = isIdKey ? idKeyTtlMs : OPENCODE_DUPLICATE_EVENT_TTL_MS;
2422
2865
  const now = Date.now();
2423
- const cutoff = now - OPENCODE_DUPLICATE_EVENT_TTL_MS;
2424
2866
  for (const [candidateKey, observed] of this.recentSdkEventObservations.entries()) {
2425
- if (observed.observedAtMs < cutoff) {
2867
+ if (observed.observedAtMs < now - idKeyTtlMs) {
2426
2868
  this.recentSdkEventObservations.delete(candidateKey);
2427
2869
  }
2428
2870
  }
2429
2871
  const previous = this.recentSdkEventObservations.get(key);
2430
2872
  this.recentSdkEventObservations.set(key, { streamName, observedAtMs: now });
2431
- return Boolean(previous && previous.streamName !== streamName && previous.observedAtMs >= cutoff);
2873
+ return Boolean(previous &&
2874
+ previous.observedAtMs >= now - ttlMs &&
2875
+ (isIdKey || previous.streamName !== streamName));
2432
2876
  }
2433
2877
  getDuplicateSdkEventKey(event) {
2878
+ // Exact frame ids (when the stream provides them) dedupe precisely.
2879
+ if (typeof event.id === "string" && event.id) {
2880
+ return `evt:${event.id}`;
2881
+ }
2434
2882
  const type = this.normalizeEventType(event.type);
2435
2883
  const payload = this.extractEventPayload(event);
2436
2884
  if (!isRecord(payload)) {