cli-wechat-bridge 1.1.4 → 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.
@@ -1,7 +1,12 @@
1
1
  import { spawn as spawnChildProcess } from "node:child_process";
2
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";
3
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";
4
8
  import { killProcessTreeSync } from "./bridge-process-reaper.js";
9
+ import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
5
10
  import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, buildOneTimeCode, isWechatOutboundAttachmentMutationTool, isWechatOutboundAttachmentWriteCommand, normalizeOutput, nowIso, truncatePreview, OutputBatcher, } from "./bridge-utils.js";
6
11
  const OPENCODE_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.WECHAT_OPENCODE_DEBUG ?? "");
7
12
  const OPENCODE_DUPLICATE_EVENT_TTL_MS = 150;
@@ -14,6 +19,11 @@ const OPENCODE_LOCAL_SESSION_CREATE_FOLLOW_TTL_MS = 5_000;
14
19
  const OPENCODE_TUI_SELECT_RETRY_DELAYS_MS = [250, 750, 1_500];
15
20
  const OPENCODE_MINIMUM_SUPPORTED_VERSION = "1.18.0";
16
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";
17
27
  /* ------------------------------------------------------------------ */
18
28
  /* Adapter */
19
29
  /* ------------------------------------------------------------------ */
@@ -61,6 +71,16 @@ export class OpenCodeServerAdapter {
61
71
  recentWechatPromptMirrorSuppressions = [];
62
72
  recentSdkEventObservations = new Map();
63
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();
64
84
  lastMirroredLocalPrompt = null;
65
85
  pendingLocalSessionCreateFollowUntilMs = 0;
66
86
  pendingPermission = null;
@@ -99,7 +119,9 @@ export class OpenCodeServerAdapter {
99
119
  await this.initializeSessions();
100
120
  this.startSseListener();
101
121
  if (this.options.renderMode === "companion") {
122
+ await this.startTuiRouteBridge();
102
123
  await this.startNativeClient();
124
+ await this.waitForTuiRouteBridgeReady();
103
125
  await this.syncVisibleSessionToShared({ force: true, retry: true });
104
126
  }
105
127
  else {
@@ -169,48 +191,75 @@ export class OpenCodeServerAdapter {
169
191
  }
170
192
  async listResumeSessions(limit = 10) {
171
193
  if (!this.client) {
172
- return [];
194
+ throw new Error("OpenCode is not running yet.");
173
195
  }
174
- let sessions;
175
196
  try {
176
- sessions = this.unwrapOrThrow(await this.client.session.list()) ?? [];
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
+ });
177
226
  }
178
- catch {
179
- return [];
180
- }
181
- return sessions
182
- .filter((session) => this.isCurrentDirectorySession(session))
183
- .slice(0, limit)
184
- .map((session) => {
185
- const rawTime = typeof session.time?.updated === "number"
186
- ? session.time.updated
187
- : typeof session.time?.created === "number"
188
- ? session.time.created
189
- : 0;
190
- const updatedMs = rawTime > 0 && rawTime < 1_000_000_000_000 ? rawTime * 1_000 : rawTime;
191
- return {
192
- sessionId: session.id,
193
- title: session.title?.trim() || "untitled",
194
- lastUpdatedAt: new Date(updatedMs > 0 ? updatedMs : Date.now()).toISOString(),
195
- source: "opencode",
196
- };
197
- });
198
227
  }
199
228
  async resumeSession(sessionId) {
200
229
  if (!this.client) {
201
230
  throw new Error("OpenCode is not running yet.");
202
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
+ }
203
244
  const session = await this.getSessionForCurrentDirectory(sessionId, []);
204
245
  if (!session) {
205
246
  throw new Error(`No OpenCode session ${sessionId} was found for ${this.options.cwd}.`);
206
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);
207
257
  this.switchSharedSession(session, {
208
258
  source: "wechat",
209
259
  reason: "wechat_resume",
210
260
  notify: true,
211
261
  clearTrackedTurn: true,
212
- syncVisible: true,
213
- forceVisibleSync: true,
262
+ syncVisible: false,
214
263
  });
215
264
  }
216
265
  async createSession() {
@@ -392,6 +441,7 @@ export class OpenCodeServerAdapter {
392
441
  }
393
442
  }
394
443
  }
444
+ await this.stopTuiRouteBridge();
395
445
  if (this.serverProcess) {
396
446
  const proc = this.serverProcess;
397
447
  this.serverProcess = null;
@@ -413,6 +463,173 @@ export class OpenCodeServerAdapter {
413
463
  this.state.startedAt = undefined;
414
464
  }
415
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
+ }
416
633
  async startServerProcess() {
417
634
  const env = buildCliEnvironment(this.options.kind);
418
635
  this.serverPassword = randomBytes(24).toString("hex");
@@ -515,6 +732,9 @@ export class OpenCodeServerAdapter {
515
732
  });
516
733
  }
517
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
+ }
518
738
  const args = ["attach", this.getServerUrl()];
519
739
  args.push("--dir", this.options.cwd);
520
740
  const sessionId = this.activeSessionId;
@@ -526,6 +746,17 @@ export class OpenCodeServerAdapter {
526
746
  }
527
747
  buildNativeClientEnv() {
528
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
+ }
529
760
  if (this.serverPassword) {
530
761
  env.OPENCODE_SERVER_PASSWORD = this.serverPassword;
531
762
  }
@@ -881,6 +1112,10 @@ export class OpenCodeServerAdapter {
881
1112
  this.handleSessionUpdated(payload);
882
1113
  return;
883
1114
  }
1115
+ case "session.deleted": {
1116
+ this.handleSessionDeleted(payload);
1117
+ return;
1118
+ }
884
1119
  case "message.updated": {
885
1120
  // Full message update — not used for incremental text extraction.
886
1121
  // Text output comes from message.part.updated events.
@@ -916,7 +1151,6 @@ export class OpenCodeServerAdapter {
916
1151
  }
917
1152
  case "session.diff":
918
1153
  case "session.diff.delta":
919
- case "session.deleted":
920
1154
  case "message.removed":
921
1155
  case "permission.replied":
922
1156
  case "tui.toast.show":
@@ -1287,13 +1521,7 @@ export class OpenCodeServerAdapter {
1287
1521
  }
1288
1522
  this.pendingLocalSessionCreateFollowUntilMs = 0;
1289
1523
  this.logDebug(`[opencode-adapter:local-follow] session.created ${session.id}`);
1290
- this.switchSharedSession(session, {
1291
- source: "local",
1292
- reason: "local_follow",
1293
- notify: true,
1294
- clearTrackedTurn: true,
1295
- syncVisible: true,
1296
- });
1524
+ this.queueLocalVisibleSessionFollow(session.id, session.workspaceID);
1297
1525
  }
1298
1526
  handleSessionUpdated(properties) {
1299
1527
  if (!isRecord(properties)) {
@@ -1302,6 +1530,32 @@ export class OpenCodeServerAdapter {
1302
1530
  const session = this.extractSessionReference(properties);
1303
1531
  this.syncTrackedSessionFromEvent(session, { allowLocalTurnFollow: false });
1304
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
+ }
1305
1559
  handleMessageUpdated(properties) {
1306
1560
  if (!isRecord(properties)) {
1307
1561
  return;
@@ -1676,16 +1930,7 @@ export class OpenCodeServerAdapter {
1676
1930
  this.localPromptNoticeSent = false;
1677
1931
  this.pendingLocalSessionCreateFollowUntilMs = 0;
1678
1932
  this.logDebug(`[opencode-adapter:local-follow] tui.session.select ${sessionId}`);
1679
- this.switchSharedSession({
1680
- id: sessionId,
1681
- workspaceID: this.extractWorkspaceId(properties) ?? undefined,
1682
- }, {
1683
- source: "local",
1684
- reason: "local_follow",
1685
- notify: true,
1686
- clearTrackedTurn: true,
1687
- syncVisible: true,
1688
- });
1933
+ this.queueLocalVisibleSessionFollow(sessionId, this.extractWorkspaceId(properties) ?? undefined);
1689
1934
  }
1690
1935
  handleCommandExecuted(properties) {
1691
1936
  if (!isRecord(properties)) {
@@ -1698,16 +1943,7 @@ export class OpenCodeServerAdapter {
1698
1943
  const sessionId = this.extractSessionId(properties);
1699
1944
  if (sessionId) {
1700
1945
  this.logDebug(`[opencode-adapter:local-follow] command.executed ${name} -> ${sessionId}`);
1701
- this.switchSharedSession({
1702
- id: sessionId,
1703
- workspaceID: this.extractWorkspaceId(properties) ?? undefined,
1704
- }, {
1705
- source: "local",
1706
- reason: "local_follow",
1707
- notify: true,
1708
- clearTrackedTurn: true,
1709
- syncVisible: true,
1710
- });
1946
+ this.queueLocalVisibleSessionFollow(sessionId, this.extractWorkspaceId(properties) ?? undefined);
1711
1947
  return;
1712
1948
  }
1713
1949
  this.markPendingLocalSessionCreateFollow();
@@ -2215,6 +2451,109 @@ export class OpenCodeServerAdapter {
2215
2451
  workspaceID: this.activeWorkspaceId ?? undefined,
2216
2452
  }, { force: options.force, retry: options.retry });
2217
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
+ }
2218
2557
  isTuiSessionSelectSuppressed(sessionId) {
2219
2558
  const suppression = this.suppressedTuiSessionSelect;
2220
2559
  if (!suppression || suppression.sessionId !== sessionId) {
@@ -319,9 +319,17 @@ export class PiTuiAdapter {
319
319
  if (!sessionPath) {
320
320
  throw new Error(`Pi session not found for this workspace: ${sessionId}`);
321
321
  }
322
+ const verifiedSession = summarizePiSessionFile(sessionPath, this.options.cwd);
323
+ if (!verifiedSession || verifiedSession.sessionId !== sessionId) {
324
+ throw new Error(`Pi session changed or became unavailable before it could be resumed: ${sessionId}`);
325
+ }
322
326
  this.pendingSessionSwitch = { source: "wechat", reason: "wechat_resume" };
323
327
  try {
324
- const response = await this.sendCommand("switch_session", { sessionPath });
328
+ const response = await this.sendCommand("switch_session", {
329
+ sessionPath,
330
+ sessionId,
331
+ cwd: this.options.cwd,
332
+ });
325
333
  this.applySessionState(response.data, "wechat", "wechat_resume", true);
326
334
  if (isRecord(response.data) && response.data.cancelled === true) {
327
335
  throw new Error("Pi session switch was cancelled by an extension.");
@@ -482,6 +490,14 @@ export class PiTuiAdapter {
482
490
  switch (frame.type) {
483
491
  case "session_state": {
484
492
  const pending = this.pendingSessionSwitch;
493
+ const nextSessionId = typeof frame.sessionId === "string" ? frame.sessionId : undefined;
494
+ const isLocalSessionSwitch = !pending &&
495
+ Boolean(nextSessionId) &&
496
+ Boolean(this.state.sharedSessionId) &&
497
+ nextSessionId !== this.state.sharedSessionId;
498
+ if (isLocalSessionSwitch) {
499
+ this.prepareForLocalSessionSwitch();
500
+ }
485
501
  this.applySessionState(frame, pending?.source ?? "local", pending?.reason ?? "local_follow", Boolean(this.state.sharedSessionId));
486
502
  this.setStatus(this.state.status);
487
503
  break;
@@ -520,6 +536,19 @@ export class PiTuiAdapter {
520
536
  timestamp: nowIso(),
521
537
  });
522
538
  }
539
+ prepareForLocalSessionSwitch() {
540
+ if (this.state.activeTurnOrigin === "wechat") {
541
+ this.emit({
542
+ type: "task_failed",
543
+ message: "The active WeChat task was interrupted because the local Pi terminal switched sessions.",
544
+ timestamp: nowIso(),
545
+ });
546
+ }
547
+ this.currentAssistantText = "";
548
+ this.currentTurnError = "";
549
+ this.clearActiveTurn();
550
+ this.setStatus("idle");
551
+ }
523
552
  beginTurn(origin) {
524
553
  this.currentAssistantText = "";
525
554
  this.currentTurnError = "";
@@ -533,14 +562,11 @@ export class PiTuiAdapter {
533
562
  return;
534
563
  }
535
564
  this.finalizingTurn = true;
536
- const origin = this.state.activeTurnOrigin;
537
565
  try {
538
566
  if (this.currentTurnError) {
539
- if (origin === "wechat") {
540
- this.emit({ type: "task_failed", message: this.currentTurnError, timestamp: nowIso() });
541
- }
567
+ this.emit({ type: "task_failed", message: this.currentTurnError, timestamp: nowIso() });
542
568
  }
543
- else if (origin === "wechat" && this.currentAssistantText) {
569
+ else if (this.currentAssistantText) {
544
570
  this.emit({ type: "final_reply", text: this.currentAssistantText, timestamp: nowIso() });
545
571
  }
546
572
  this.state.lastOutputAt = nowIso();
@@ -29,6 +29,9 @@ export class BridgeController {
29
29
  companionStatus: adapterState.status,
30
30
  companionLastStateAt: new Date().toISOString(),
31
31
  companionWorkerPid: adapterState.pid,
32
+ codexControlPort: endpoint.codexControlPort ?? existing.codexControlPort,
33
+ codexControlToken: endpoint.codexControlToken ?? existing.codexControlToken,
34
+ codexVisibleThreadId: endpoint.codexVisibleThreadId ?? existing.codexVisibleThreadId,
32
35
  }
33
36
  : {
34
37
  ...endpoint,