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.
- package/README.md +110 -140
- package/bin/wechat-claude.mjs +1 -1
- package/bin/wechat-codex.mjs +1 -1
- package/bin/wechat-opencode.mjs +1 -1
- package/bin/wechat-pi.mjs +1 -1
- package/dist/bridge/bridge-adapters.claude.js +448 -11
- package/dist/bridge/bridge-adapters.codex.js +262 -11
- package/dist/bridge/bridge-adapters.core.js +76 -4
- package/dist/bridge/bridge-adapters.js +0 -3
- package/dist/bridge/bridge-adapters.opencode.js +497 -49
- package/dist/bridge/bridge-adapters.pi.js +44 -8
- package/dist/bridge/bridge-adapters.shared.js +13 -100
- package/dist/bridge/bridge-controller.js +3 -0
- package/dist/bridge/bridge-final-reply.js +17 -2
- package/dist/bridge/bridge-process-reaper.js +14 -4
- package/dist/bridge/bridge-session-resume.js +151 -0
- package/dist/bridge/bridge-state.js +41 -6
- package/dist/bridge/bridge-utils.js +63 -229
- package/dist/bridge/claude-hooks.js +1 -0
- package/dist/bridge/wechat-bridge.js +93 -140
- package/dist/bridge/wechat-forwarding.js +58 -0
- package/dist/companion/codex-remote-client.js +309 -36
- package/dist/companion/codex-visible-client-link.js +122 -0
- package/dist/companion/local-companion-link.js +33 -2
- package/dist/companion/local-companion-start.js +44 -13
- package/dist/companion/local-companion.js +6 -3
- package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
- package/dist/companion/pi-tui-bridge-extension.js +42 -3
- package/dist/daemon/daemon-link.js +25 -3
- package/dist/daemon/wechat-daemon.js +168 -56
- package/dist/i18n/index.js +0 -3
- package/dist/i18n/messages-en.js +2 -3
- package/dist/i18n/messages-zh.js +2 -3
- package/dist/utils/atomic-file.js +15 -0
- package/dist/utils/doctor.js +23 -11
- package/dist/wechat/channel-config.js +0 -2
- package/dist/wechat/setup.js +5 -11
- package/dist/wechat/wechat-transport.js +14 -8
- package/package.json +2 -13
- package/bin/wechat-bridge-claude.mjs +0 -5
- package/bin/wechat-bridge-codex.mjs +0 -5
- package/bin/wechat-bridge-opencode.mjs +0 -5
- package/bin/wechat-bridge-pi.mjs +0 -5
- package/bin/wechat-bridge-shell.mjs +0 -5
- package/bin/wechat-bridge.mjs +0 -5
- package/bin/wechat-claude-start.mjs +0 -5
- package/bin/wechat-codex-start.mjs +0 -5
- package/bin/wechat-opencode-start.mjs +0 -5
- package/bin/wechat-pi-start.mjs +0 -5
- 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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 (
|
|
1599
|
-
this.
|
|
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.
|
|
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.
|
|
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
|
|
2574
|
+
(!options.force && this.isTuiSessionSelectSuppressed(session.id))) {
|
|
2149
2575
|
return;
|
|
2150
2576
|
}
|
|
2151
|
-
this.
|
|
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.
|
|
2161
|
-
this.
|
|
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, "
|
|
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
|
|
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 <
|
|
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 &&
|
|
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)) {
|