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.
- package/README.md +14 -7
- package/bin/_run-entry.mjs +0 -12
- package/dist/bridge/bridge-adapters.claude.js +427 -10
- package/dist/bridge/bridge-adapters.codex.js +235 -7
- package/dist/bridge/bridge-adapters.opencode.js +392 -53
- package/dist/bridge/bridge-adapters.pi.js +32 -6
- package/dist/bridge/bridge-controller.js +3 -0
- package/dist/bridge/bridge-session-resume.js +151 -0
- package/dist/bridge/bridge-utils.js +26 -5
- package/dist/bridge/claude-hooks.js +1 -0
- package/dist/bridge/wechat-bridge.js +37 -29
- package/dist/companion/codex-remote-client.js +291 -32
- package/dist/companion/codex-visible-client-link.js +122 -0
- package/dist/companion/local-companion-link.js +26 -0
- package/dist/companion/local-companion-start.js +0 -1
- package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
- package/dist/companion/pi-tui-bridge-extension.js +42 -3
- package/dist/daemon/wechat-daemon.js +35 -17
- package/package.json +1 -5
- package/bin/wechat-claude-start.mjs +0 -10
- package/bin/wechat-codex-start.mjs +0 -10
- package/bin/wechat-opencode-start.mjs +0 -10
- package/bin/wechat-pi-start.mjs +0 -10
|
@@ -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
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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", {
|
|
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
|
-
|
|
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 (
|
|
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,
|