cli-wechat-bridge 1.0.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/LICENSE.txt +21 -0
- package/README.md +637 -0
- package/bin/_run-entry.mjs +35 -0
- package/bin/wechat-bridge-claude.mjs +5 -0
- package/bin/wechat-bridge-codex.mjs +5 -0
- package/bin/wechat-bridge-opencode.mjs +5 -0
- package/bin/wechat-bridge-shell.mjs +5 -0
- package/bin/wechat-bridge.mjs +5 -0
- package/bin/wechat-check-update.mjs +5 -0
- package/bin/wechat-claude-start.mjs +5 -0
- package/bin/wechat-claude.mjs +5 -0
- package/bin/wechat-codex-start.mjs +5 -0
- package/bin/wechat-codex.mjs +5 -0
- package/bin/wechat-daemon.mjs +5 -0
- package/bin/wechat-opencode-start.mjs +5 -0
- package/bin/wechat-opencode.mjs +5 -0
- package/bin/wechat-setup.mjs +5 -0
- package/dist/bridge/bridge-adapter-common.js +95 -0
- package/dist/bridge/bridge-adapters.claude.js +829 -0
- package/dist/bridge/bridge-adapters.codex.js +2228 -0
- package/dist/bridge/bridge-adapters.core.js +717 -0
- package/dist/bridge/bridge-adapters.js +26 -0
- package/dist/bridge/bridge-adapters.opencode.js +2129 -0
- package/dist/bridge/bridge-adapters.shared.js +1005 -0
- package/dist/bridge/bridge-adapters.shell.js +363 -0
- package/dist/bridge/bridge-controller.js +48 -0
- package/dist/bridge/bridge-final-reply.js +46 -0
- package/dist/bridge/bridge-process-reaper.js +348 -0
- package/dist/bridge/bridge-state.js +362 -0
- package/dist/bridge/bridge-types.js +1 -0
- package/dist/bridge/bridge-utils.js +1240 -0
- package/dist/bridge/claude-hook.js +82 -0
- package/dist/bridge/claude-hooks.js +267 -0
- package/dist/bridge/wechat-bridge.js +1026 -0
- package/dist/commands/check-update.js +30 -0
- package/dist/companion/codex-panel-link.js +72 -0
- package/dist/companion/codex-panel.js +179 -0
- package/dist/companion/codex-remote-client.js +124 -0
- package/dist/companion/local-companion-link.js +240 -0
- package/dist/companion/local-companion-start.js +420 -0
- package/dist/companion/local-companion.js +424 -0
- package/dist/daemon/daemon-link.js +175 -0
- package/dist/daemon/wechat-daemon.js +1202 -0
- package/dist/media/media-types.js +1 -0
- package/dist/runtime/create-runtime-host.js +12 -0
- package/dist/runtime/legacy-adapter-runtime.js +46 -0
- package/dist/runtime/runtime-types.js +5 -0
- package/dist/utils/version-checker.js +161 -0
- package/dist/wechat/channel-config.js +196 -0
- package/dist/wechat/setup.js +283 -0
- package/dist/wechat/standalone-bot.js +355 -0
- package/dist/wechat/wechat-channel.js +492 -0
- package/dist/wechat/wechat-transport.js +1213 -0
- package/package.json +101 -0
|
@@ -0,0 +1,2129 @@
|
|
|
1
|
+
import { spawn as spawnChildProcess } from "node:child_process";
|
|
2
|
+
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, buildCliEnvironment, isRecord, describeUnknownError, resolveSpawnTarget, reserveLocalPort, waitForTcpPort, delay, } from "./bridge-adapters.shared.js";
|
|
3
|
+
import { killProcessTreeSync } from "./bridge-process-reaper.js";
|
|
4
|
+
import { buildOneTimeCode, normalizeOutput, nowIso, truncatePreview, OutputBatcher, } from "./bridge-utils.js";
|
|
5
|
+
const OPENCODE_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.WECHAT_OPENCODE_DEBUG ?? "");
|
|
6
|
+
const OPENCODE_DUPLICATE_EVENT_TTL_MS = 150;
|
|
7
|
+
const OPENCODE_WECHAT_MIRROR_SUPPRESSION_TTL_MS = 30_000;
|
|
8
|
+
const OPENCODE_RECENT_LOCAL_PROMPT_TTL_MS = 10_000;
|
|
9
|
+
const OPENCODE_LOCAL_SESSION_CREATE_FOLLOW_TTL_MS = 5_000;
|
|
10
|
+
/* ------------------------------------------------------------------ */
|
|
11
|
+
/* Adapter */
|
|
12
|
+
/* ------------------------------------------------------------------ */
|
|
13
|
+
export class OpenCodeServerAdapter {
|
|
14
|
+
options;
|
|
15
|
+
state;
|
|
16
|
+
eventSink = () => undefined;
|
|
17
|
+
serverProcess = null;
|
|
18
|
+
nativeProcess = null;
|
|
19
|
+
serverPort = 0;
|
|
20
|
+
client = null;
|
|
21
|
+
sseAbortController = null;
|
|
22
|
+
sseLoopPromise = null;
|
|
23
|
+
activeSessionId = null;
|
|
24
|
+
activeWorkspaceId = null;
|
|
25
|
+
outputBatcher;
|
|
26
|
+
shuttingDown = false;
|
|
27
|
+
hasAcceptedInput = false;
|
|
28
|
+
currentPreview = "(idle)";
|
|
29
|
+
workingNoticeDelayMs;
|
|
30
|
+
workingNoticeTimer = null;
|
|
31
|
+
workingNoticeSent = false;
|
|
32
|
+
lastBusyAtMs = 0;
|
|
33
|
+
pendingLocalPrompt = "";
|
|
34
|
+
localPromptNoticeSent = false;
|
|
35
|
+
loggedUnknownEventTypes = new Set();
|
|
36
|
+
emittedTextByPartId = new Map();
|
|
37
|
+
partTypeByPartId = new Map();
|
|
38
|
+
visibleReplyPartsByPartId = new Map();
|
|
39
|
+
visibleReplyMessageIds = new Set();
|
|
40
|
+
observedOpenCodeMessages = new Map();
|
|
41
|
+
observedUserTextByPartId = new Map();
|
|
42
|
+
observedUserMessagePartIds = new Map();
|
|
43
|
+
pendingWechatPromptMirrorSuppressions = [];
|
|
44
|
+
recentWechatPromptMirrorSuppressions = [];
|
|
45
|
+
recentSdkEventObservations = new Map();
|
|
46
|
+
suppressedTuiSessionSelectId = null;
|
|
47
|
+
lastMirroredLocalPrompt = null;
|
|
48
|
+
pendingLocalSessionCreateFollowUntilMs = 0;
|
|
49
|
+
pendingPermission = null;
|
|
50
|
+
constructor(options) {
|
|
51
|
+
this.options = options;
|
|
52
|
+
this.state = {
|
|
53
|
+
kind: options.kind,
|
|
54
|
+
status: "stopped",
|
|
55
|
+
cwd: options.cwd,
|
|
56
|
+
command: options.command,
|
|
57
|
+
profile: options.profile,
|
|
58
|
+
};
|
|
59
|
+
this.outputBatcher = new OutputBatcher((text) => this.flushOutputBatch(text));
|
|
60
|
+
this.workingNoticeDelayMs = OPENCODE_WECHAT_WORKING_NOTICE_DELAY_MS;
|
|
61
|
+
}
|
|
62
|
+
/* ---- BridgeAdapter interface ---- */
|
|
63
|
+
setEventSink(sink) {
|
|
64
|
+
this.eventSink = sink;
|
|
65
|
+
}
|
|
66
|
+
getState() {
|
|
67
|
+
return JSON.parse(JSON.stringify(this.state));
|
|
68
|
+
}
|
|
69
|
+
async start() {
|
|
70
|
+
if (this.serverProcess || this.nativeProcess) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.shuttingDown = false;
|
|
74
|
+
this.setStatus("starting", "Starting OpenCode companion...");
|
|
75
|
+
try {
|
|
76
|
+
this.serverPort = await reserveLocalPort();
|
|
77
|
+
const serverProcess = await this.startServerProcess();
|
|
78
|
+
await waitForTcpPort(OPENCODE_SERVER_HOST, this.serverPort, OPENCODE_SERVER_READY_TIMEOUT_MS);
|
|
79
|
+
await this.createSdkClient();
|
|
80
|
+
await this.checkHealth();
|
|
81
|
+
await this.initializeSessions();
|
|
82
|
+
this.startSseListener();
|
|
83
|
+
if (this.options.renderMode === "companion") {
|
|
84
|
+
await this.startNativeClient();
|
|
85
|
+
await this.syncVisibleSessionToShared({ force: true });
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
this.state.pid = serverProcess.pid;
|
|
89
|
+
this.state.startedAt = nowIso();
|
|
90
|
+
}
|
|
91
|
+
this.setStatus("idle", "OpenCode adapter is ready.");
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
this.state.status = "error";
|
|
95
|
+
this.emit({
|
|
96
|
+
type: "fatal_error",
|
|
97
|
+
message: `Failed to start OpenCode: ${describeUnknownError(err)}`,
|
|
98
|
+
timestamp: nowIso(),
|
|
99
|
+
});
|
|
100
|
+
await this.dispose();
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async sendInput(text) {
|
|
105
|
+
if (!this.client) {
|
|
106
|
+
throw new Error("OpenCode adapter is not running.");
|
|
107
|
+
}
|
|
108
|
+
if (this.state.status === "busy") {
|
|
109
|
+
throw new Error("OpenCode is still working. Wait for the current reply or use /stop.");
|
|
110
|
+
}
|
|
111
|
+
if (this.pendingPermission) {
|
|
112
|
+
throw new Error("An OpenCode approval request is pending. Reply with /confirm <code> or /deny.");
|
|
113
|
+
}
|
|
114
|
+
const normalized = normalizeOutput(text).trim();
|
|
115
|
+
if (!normalized) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
this.outputBatcher.clear();
|
|
119
|
+
this.clearStreamedPartState();
|
|
120
|
+
const session = await this.ensureSession();
|
|
121
|
+
this.switchSharedSession(session, {
|
|
122
|
+
source: "wechat",
|
|
123
|
+
reason: "wechat_resume",
|
|
124
|
+
syncVisible: true,
|
|
125
|
+
forceVisibleSync: true,
|
|
126
|
+
});
|
|
127
|
+
this.recordPendingWechatPromptMirrorSuppression(session.id, normalized);
|
|
128
|
+
this.beginTrackedTurn(normalized, "wechat");
|
|
129
|
+
try {
|
|
130
|
+
const result = await this.client.session.promptAsync({
|
|
131
|
+
sessionID: session.id,
|
|
132
|
+
directory: this.options.cwd,
|
|
133
|
+
workspace: session.workspaceID ?? this.activeWorkspaceId ?? undefined,
|
|
134
|
+
parts: [{ type: "text", text: normalized }],
|
|
135
|
+
});
|
|
136
|
+
if (result.error !== undefined) {
|
|
137
|
+
throw new Error(`SDK error: ${describeUnknownError(result.error)}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
this.settleTurnState();
|
|
142
|
+
this.clearObservedMessageTracking();
|
|
143
|
+
this.setStatus("idle");
|
|
144
|
+
throw new Error(`Failed to send prompt: ${describeUnknownError(err)}`, {
|
|
145
|
+
cause: err,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async listResumeSessions(limit = 10) {
|
|
150
|
+
void limit;
|
|
151
|
+
throw new Error('WeChat /resume is disabled in opencode mode. Use /session directly inside "wechat-opencode"; WeChat will follow the active local session.');
|
|
152
|
+
}
|
|
153
|
+
async resumeSession(sessionId) {
|
|
154
|
+
void sessionId;
|
|
155
|
+
throw new Error('WeChat /resume is disabled in opencode mode. Use /session directly inside "wechat-opencode"; WeChat will follow the active local session.');
|
|
156
|
+
}
|
|
157
|
+
async createSession() {
|
|
158
|
+
if (!this.client) {
|
|
159
|
+
throw new Error("OpenCode adapter is not running.");
|
|
160
|
+
}
|
|
161
|
+
if (this.state.status === "busy") {
|
|
162
|
+
throw new Error("OpenCode is still working. Wait for the current reply or use /stop.");
|
|
163
|
+
}
|
|
164
|
+
if (this.pendingPermission) {
|
|
165
|
+
throw new Error("An OpenCode approval request is pending. Reply with /confirm <code> or /deny.");
|
|
166
|
+
}
|
|
167
|
+
this.outputBatcher.clear();
|
|
168
|
+
this.clearStreamedPartState();
|
|
169
|
+
const session = this.unwrapOrThrow(await this.client.session.create({
|
|
170
|
+
directory: this.options.cwd,
|
|
171
|
+
workspace: this.activeWorkspaceId ?? undefined,
|
|
172
|
+
}));
|
|
173
|
+
this.switchSharedSession(session, {
|
|
174
|
+
source: "wechat",
|
|
175
|
+
reason: "wechat_resume",
|
|
176
|
+
notify: true,
|
|
177
|
+
clearTrackedTurn: true,
|
|
178
|
+
syncVisible: true,
|
|
179
|
+
forceVisibleSync: true,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
async interrupt() {
|
|
183
|
+
if (!this.client || !this.activeSessionId) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
if (this.state.status !== "busy" && this.state.status !== "awaiting_approval") {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
this.clearWechatWorkingNotice(true);
|
|
190
|
+
try {
|
|
191
|
+
await this.client.session.abort({
|
|
192
|
+
sessionID: this.activeSessionId,
|
|
193
|
+
directory: this.options.cwd,
|
|
194
|
+
workspace: this.activeWorkspaceId ?? undefined,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Best effort abort.
|
|
199
|
+
}
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
async reset() {
|
|
203
|
+
this.clearWechatWorkingNotice(true);
|
|
204
|
+
this.pendingLocalPrompt = "";
|
|
205
|
+
this.localPromptNoticeSent = false;
|
|
206
|
+
this.pendingLocalSessionCreateFollowUntilMs = 0;
|
|
207
|
+
this.clearObservedMessageTracking();
|
|
208
|
+
this.recentSdkEventObservations.clear();
|
|
209
|
+
this.clearPendingPermissionState();
|
|
210
|
+
this.activeSessionId = null;
|
|
211
|
+
this.state.sharedSessionId = undefined;
|
|
212
|
+
this.state.sharedThreadId = undefined;
|
|
213
|
+
this.state.activeRuntimeSessionId = undefined;
|
|
214
|
+
this.state.lastSessionSwitchAt = undefined;
|
|
215
|
+
this.state.lastSessionSwitchSource = undefined;
|
|
216
|
+
this.state.lastSessionSwitchReason = undefined;
|
|
217
|
+
this.hasAcceptedInput = false;
|
|
218
|
+
this.currentPreview = "(idle)";
|
|
219
|
+
this.outputBatcher.clear();
|
|
220
|
+
this.clearStreamedPartState();
|
|
221
|
+
await this.dispose();
|
|
222
|
+
await this.start();
|
|
223
|
+
}
|
|
224
|
+
async resolveApproval(action) {
|
|
225
|
+
if (!this.pendingPermission || !this.client) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
const { sessionId, permissionId } = this.pendingPermission;
|
|
229
|
+
const response = action === "confirm" ? "once" : "reject";
|
|
230
|
+
try {
|
|
231
|
+
const result = await this.client.permission.respond({
|
|
232
|
+
sessionID: sessionId,
|
|
233
|
+
permissionID: permissionId,
|
|
234
|
+
directory: this.options.cwd,
|
|
235
|
+
workspace: this.activeWorkspaceId ?? undefined,
|
|
236
|
+
response,
|
|
237
|
+
});
|
|
238
|
+
if (result.error !== undefined) {
|
|
239
|
+
throw new Error(`SDK error: ${describeUnknownError(result.error)}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
this.emit({
|
|
244
|
+
type: "stderr",
|
|
245
|
+
text: `Failed to resolve permission: ${describeUnknownError(err)}`,
|
|
246
|
+
timestamp: nowIso(),
|
|
247
|
+
});
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
this.clearWechatWorkingNotice();
|
|
251
|
+
this.clearPendingPermissionState();
|
|
252
|
+
this.setStatus("busy");
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
async submitUserInput(_answers) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
async dispose() {
|
|
259
|
+
this.shuttingDown = true;
|
|
260
|
+
this.clearWechatWorkingNotice(true);
|
|
261
|
+
this.pendingLocalPrompt = "";
|
|
262
|
+
this.localPromptNoticeSent = false;
|
|
263
|
+
this.pendingLocalSessionCreateFollowUntilMs = 0;
|
|
264
|
+
this.clearObservedMessageTracking();
|
|
265
|
+
this.recentSdkEventObservations.clear();
|
|
266
|
+
this.outputBatcher.clear();
|
|
267
|
+
this.clearStreamedPartState();
|
|
268
|
+
this.clearPendingPermissionState();
|
|
269
|
+
// Stop SSE listener
|
|
270
|
+
if (this.sseAbortController) {
|
|
271
|
+
this.sseAbortController.abort();
|
|
272
|
+
this.sseAbortController = null;
|
|
273
|
+
}
|
|
274
|
+
if (this.sseLoopPromise) {
|
|
275
|
+
try {
|
|
276
|
+
await Promise.race([this.sseLoopPromise, delay(3_000)]);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// Ignore SSE loop errors during shutdown.
|
|
280
|
+
}
|
|
281
|
+
this.sseLoopPromise = null;
|
|
282
|
+
}
|
|
283
|
+
if (this.nativeProcess) {
|
|
284
|
+
const proc = this.nativeProcess;
|
|
285
|
+
this.nativeProcess = null;
|
|
286
|
+
try {
|
|
287
|
+
killProcessTreeSync(proc.pid);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// Best effort.
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (this.serverProcess) {
|
|
294
|
+
const proc = this.serverProcess;
|
|
295
|
+
this.serverProcess = null;
|
|
296
|
+
try {
|
|
297
|
+
killProcessTreeSync(proc.pid);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// Best effort.
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
this.client = null;
|
|
304
|
+
this.activeSessionId = null;
|
|
305
|
+
this.activeWorkspaceId = null;
|
|
306
|
+
this.suppressedTuiSessionSelectId = null;
|
|
307
|
+
this.state.status = "stopped";
|
|
308
|
+
this.state.pid = undefined;
|
|
309
|
+
this.state.startedAt = undefined;
|
|
310
|
+
}
|
|
311
|
+
/* ---- Server management ---- */
|
|
312
|
+
async startServerProcess() {
|
|
313
|
+
const env = buildCliEnvironment(this.options.kind);
|
|
314
|
+
const serverArgs = [
|
|
315
|
+
"serve",
|
|
316
|
+
"--port",
|
|
317
|
+
String(this.serverPort),
|
|
318
|
+
"--hostname",
|
|
319
|
+
OPENCODE_SERVER_HOST,
|
|
320
|
+
];
|
|
321
|
+
const target = resolveSpawnTarget(this.options.command, this.options.kind, { env });
|
|
322
|
+
this.serverProcess = spawnChildProcess(target.file, [...target.args, ...serverArgs], {
|
|
323
|
+
cwd: this.options.cwd,
|
|
324
|
+
env,
|
|
325
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
326
|
+
windowsHide: true,
|
|
327
|
+
});
|
|
328
|
+
const server = this.serverProcess;
|
|
329
|
+
server.stdout?.on("data", (chunk) => {
|
|
330
|
+
const text = chunk.toString("utf8").trim();
|
|
331
|
+
if (text) {
|
|
332
|
+
this.logDebug(`[opencode-serve:out] ${text}`);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
server.stderr?.on("data", (chunk) => {
|
|
336
|
+
const text = chunk.toString("utf8").trim();
|
|
337
|
+
if (text) {
|
|
338
|
+
this.logDebug(`[opencode-serve:err] ${text}`);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
server.once("exit", (code) => {
|
|
342
|
+
if (this.shuttingDown) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
this.emit({
|
|
346
|
+
type: "fatal_error",
|
|
347
|
+
message: `OpenCode server exited unexpectedly (code ${code ?? "unknown"}).`,
|
|
348
|
+
timestamp: nowIso(),
|
|
349
|
+
});
|
|
350
|
+
this.setStatus("stopped");
|
|
351
|
+
});
|
|
352
|
+
server.once("error", (err) => {
|
|
353
|
+
if (this.shuttingDown) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
this.emit({
|
|
357
|
+
type: "fatal_error",
|
|
358
|
+
message: `OpenCode server error: ${err.message}`,
|
|
359
|
+
timestamp: nowIso(),
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
return server;
|
|
363
|
+
}
|
|
364
|
+
async startNativeClient() {
|
|
365
|
+
if (this.nativeProcess) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const env = buildCliEnvironment(this.options.kind);
|
|
369
|
+
const attachArgs = await this.buildNativeAttachArgs();
|
|
370
|
+
const target = resolveSpawnTarget(this.options.command, this.options.kind, { env });
|
|
371
|
+
const startedAt = nowIso();
|
|
372
|
+
const child = spawnChildProcess(target.file, [...target.args, ...attachArgs], {
|
|
373
|
+
cwd: this.options.cwd,
|
|
374
|
+
env,
|
|
375
|
+
stdio: "inherit",
|
|
376
|
+
windowsHide: false,
|
|
377
|
+
});
|
|
378
|
+
this.nativeProcess = child;
|
|
379
|
+
this.state.pid = child.pid ?? process.pid;
|
|
380
|
+
this.state.startedAt = startedAt;
|
|
381
|
+
child.once("error", (err) => {
|
|
382
|
+
if (this.shuttingDown) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
this.emit({
|
|
386
|
+
type: "fatal_error",
|
|
387
|
+
message: `Failed to start OpenCode companion: ${describeUnknownError(err)}`,
|
|
388
|
+
timestamp: nowIso(),
|
|
389
|
+
});
|
|
390
|
+
this.setStatus("error", "OpenCode companion failed to start.");
|
|
391
|
+
});
|
|
392
|
+
child.once("exit", (code, signal) => {
|
|
393
|
+
if (this.nativeProcess === child) {
|
|
394
|
+
this.nativeProcess = null;
|
|
395
|
+
}
|
|
396
|
+
if (this.shuttingDown) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
this.state.pid = undefined;
|
|
400
|
+
this.setStatus("stopped", "OpenCode companion exited.");
|
|
401
|
+
const detail = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`;
|
|
402
|
+
this.emit({
|
|
403
|
+
type: "shutdown_requested",
|
|
404
|
+
reason: "companion_closed",
|
|
405
|
+
message: `OpenCode companion exited (${detail}).`,
|
|
406
|
+
exitCode: typeof code === "number" ? code : 0,
|
|
407
|
+
timestamp: nowIso(),
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
async buildNativeAttachArgs() {
|
|
412
|
+
const args = ["attach", this.getServerUrl()];
|
|
413
|
+
const sessionId = this.activeSessionId;
|
|
414
|
+
if (sessionId && (await this.hasSession(sessionId))) {
|
|
415
|
+
args.push("--session", sessionId);
|
|
416
|
+
}
|
|
417
|
+
args.push(...(this.options.extraCliArgs ?? []));
|
|
418
|
+
return args;
|
|
419
|
+
}
|
|
420
|
+
async createSdkClient() {
|
|
421
|
+
try {
|
|
422
|
+
const { createOpencodeClient } = await import("@opencode-ai/sdk/v2");
|
|
423
|
+
this.client = createOpencodeClient({
|
|
424
|
+
baseUrl: `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`,
|
|
425
|
+
directory: this.options.cwd,
|
|
426
|
+
experimental_workspaceID: this.activeWorkspaceId ?? undefined,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
throw new Error(`Failed to load @opencode-ai/sdk. Make sure it is installed: ${describeUnknownError(err)}`, { cause: err });
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
async checkHealth() {
|
|
434
|
+
const baseUrl = `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`;
|
|
435
|
+
const response = await fetch(`${baseUrl}/session/status`);
|
|
436
|
+
if (!response.ok) {
|
|
437
|
+
throw new Error(`OpenCode health check failed (HTTP ${response.status}).`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
async initializeSessions() {
|
|
441
|
+
if (!this.client) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
let listedSessions = [];
|
|
445
|
+
try {
|
|
446
|
+
const result = await this.client.session.list();
|
|
447
|
+
if (result.data && result.data.length > 0) {
|
|
448
|
+
listedSessions = result.data.filter((session) => this.isCurrentDirectorySession(session));
|
|
449
|
+
const latest = listedSessions[0];
|
|
450
|
+
if (latest) {
|
|
451
|
+
this.switchSharedSession(latest, {
|
|
452
|
+
source: "restore",
|
|
453
|
+
reason: "startup_restore",
|
|
454
|
+
syncVisible: false,
|
|
455
|
+
});
|
|
456
|
+
this.state.lastSessionSwitchAt = undefined;
|
|
457
|
+
this.state.lastSessionSwitchSource = undefined;
|
|
458
|
+
this.state.lastSessionSwitchReason = undefined;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
// Session listing is optional at startup.
|
|
464
|
+
}
|
|
465
|
+
if (this.options.initialSharedSessionId) {
|
|
466
|
+
const restoredSessionId = this.options.initialSharedSessionId;
|
|
467
|
+
const restoredSession = await this.getSessionForCurrentDirectory(restoredSessionId, listedSessions);
|
|
468
|
+
if (!restoredSession) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
this.switchSharedSession(restoredSession, {
|
|
472
|
+
source: "restore",
|
|
473
|
+
reason: "startup_restore",
|
|
474
|
+
syncVisible: false,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
async hasSession(sessionId, listedSessions = []) {
|
|
479
|
+
return Boolean(await this.getSessionForCurrentDirectory(sessionId, listedSessions));
|
|
480
|
+
}
|
|
481
|
+
async getSessionForCurrentDirectory(sessionId, listedSessions = []) {
|
|
482
|
+
if (!this.client) {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
const listedSession = listedSessions.find((session) => session.id === sessionId);
|
|
486
|
+
if (listedSession && this.isCurrentDirectorySession(listedSession)) {
|
|
487
|
+
return listedSession;
|
|
488
|
+
}
|
|
489
|
+
const queryVariants = this.activeWorkspaceId
|
|
490
|
+
? [
|
|
491
|
+
{
|
|
492
|
+
sessionID: sessionId,
|
|
493
|
+
directory: this.options.cwd,
|
|
494
|
+
workspace: this.activeWorkspaceId,
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
sessionID: sessionId,
|
|
498
|
+
directory: this.options.cwd,
|
|
499
|
+
},
|
|
500
|
+
]
|
|
501
|
+
: [
|
|
502
|
+
{
|
|
503
|
+
sessionID: sessionId,
|
|
504
|
+
directory: this.options.cwd,
|
|
505
|
+
},
|
|
506
|
+
];
|
|
507
|
+
for (const query of queryVariants) {
|
|
508
|
+
try {
|
|
509
|
+
const result = await this.client.session.get(query);
|
|
510
|
+
if (result.error !== undefined || !result.data) {
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (!this.isCurrentDirectorySession(result.data)) {
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
return result.data;
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
// Try the next query variant.
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
/* ---- SSE event handling ---- */
|
|
525
|
+
startSseListener() {
|
|
526
|
+
if (!this.client || this.sseLoopPromise) {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
this.sseAbortController = new AbortController();
|
|
530
|
+
this.sseLoopPromise = Promise.all([
|
|
531
|
+
this.runSseLoop("event"),
|
|
532
|
+
this.runSseLoop("global-event"),
|
|
533
|
+
this.runSseLoop("global-sync"),
|
|
534
|
+
]).then(() => undefined);
|
|
535
|
+
}
|
|
536
|
+
async runSseLoop(streamName) {
|
|
537
|
+
while (!this.shuttingDown) {
|
|
538
|
+
try {
|
|
539
|
+
const subscription = await this.subscribeToSseStream(streamName);
|
|
540
|
+
const stream = subscription.stream;
|
|
541
|
+
for await (const rawEvent of stream) {
|
|
542
|
+
if (this.shuttingDown) {
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
const event = this.normalizeSdkEvent(rawEvent);
|
|
546
|
+
if (!event || !this.shouldHandleSseEvent(event, streamName)) {
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (this.shouldSkipDuplicateSdkEvent(event, streamName)) {
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
this.handleSseEvent(event);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
if (this.shuttingDown) {
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
this.logDebug(`[opencode-adapter:${streamName}] Stream error: ${describeUnknownError(err)}`);
|
|
560
|
+
}
|
|
561
|
+
if (this.shuttingDown) {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
await delay(OPENCODE_SSE_RECONNECT_DELAY_MS);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
subscribeToSseStream(streamName) {
|
|
568
|
+
const signal = this.sseAbortController?.signal;
|
|
569
|
+
if (streamName === "global-sync") {
|
|
570
|
+
return this.client.global.syncEvent.subscribe({ signal });
|
|
571
|
+
}
|
|
572
|
+
if (streamName === "global-event") {
|
|
573
|
+
return this.client.global.event({ signal });
|
|
574
|
+
}
|
|
575
|
+
return this.client.event.subscribe({
|
|
576
|
+
directory: this.options.cwd,
|
|
577
|
+
workspace: this.activeWorkspaceId ?? undefined,
|
|
578
|
+
}, { signal });
|
|
579
|
+
}
|
|
580
|
+
normalizeSdkEvent(rawEvent) {
|
|
581
|
+
if (!isRecord(rawEvent)) {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
if (typeof rawEvent.type === "string") {
|
|
585
|
+
return rawEvent;
|
|
586
|
+
}
|
|
587
|
+
const payload = rawEvent.payload;
|
|
588
|
+
if (isRecord(payload) && typeof payload.type === "string") {
|
|
589
|
+
const normalized = { ...payload };
|
|
590
|
+
if (typeof rawEvent.directory === "string") {
|
|
591
|
+
normalized.directory = rawEvent.directory;
|
|
592
|
+
}
|
|
593
|
+
return normalized;
|
|
594
|
+
}
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
shouldHandleSseEvent(event, streamName) {
|
|
598
|
+
if (streamName === "event") {
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
const type = this.normalizeEventType(event.type);
|
|
602
|
+
if (streamName === "global-event") {
|
|
603
|
+
if (type !== "tui.prompt.append" &&
|
|
604
|
+
type !== "tui.command.execute" &&
|
|
605
|
+
type !== "tui.session.select" &&
|
|
606
|
+
type !== "command.executed" &&
|
|
607
|
+
type !== "session.created" &&
|
|
608
|
+
type !== "session.updated" &&
|
|
609
|
+
type !== "session.deleted") {
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
if (this.isImplicitLocalCompanionUiEvent(type)) {
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
return (this.matchesCurrentDirectoryEvent(event) ||
|
|
616
|
+
this.shouldAcceptUnscopedLocalSessionCreatedEvent(event));
|
|
617
|
+
}
|
|
618
|
+
if (type === "session.created" ||
|
|
619
|
+
type === "session.updated" ||
|
|
620
|
+
type === "session.deleted") {
|
|
621
|
+
return (this.matchesCurrentDirectoryEvent(event) ||
|
|
622
|
+
(type === "session.created" &&
|
|
623
|
+
this.shouldAcceptUnscopedLocalSessionCreatedEvent(event)));
|
|
624
|
+
}
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
isImplicitLocalCompanionUiEvent(type) {
|
|
628
|
+
if (this.options.renderMode !== "companion") {
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
return (type === "tui.prompt.append" ||
|
|
632
|
+
type === "tui.command.execute" ||
|
|
633
|
+
type === "tui.session.select");
|
|
634
|
+
}
|
|
635
|
+
handleSseEvent(event) {
|
|
636
|
+
const type = this.normalizeEventType(event.type);
|
|
637
|
+
const payload = this.extractEventPayload(event);
|
|
638
|
+
if (type === "message.updated") {
|
|
639
|
+
this.handleMessageUpdated(payload);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
switch (type) {
|
|
643
|
+
case "server.connected":
|
|
644
|
+
case "server.heartbeat":
|
|
645
|
+
return;
|
|
646
|
+
case "session.idle": {
|
|
647
|
+
this.handleSessionIdle(isRecord(payload) ? payload : undefined);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
case "session.status": {
|
|
651
|
+
this.handleSessionStatus(isRecord(payload) ? payload : undefined);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
case "session.error": {
|
|
655
|
+
this.handleSessionError(isRecord(payload) ? payload : undefined);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
case "permission.updated":
|
|
659
|
+
case "permission.asked": {
|
|
660
|
+
this.handlePermissionRequest(payload);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
case "session.created": {
|
|
664
|
+
this.handleSessionCreated(payload);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
case "session.updated": {
|
|
668
|
+
this.handleSessionUpdated(payload);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
case "message.updated": {
|
|
672
|
+
// Full message update — not used for incremental text extraction.
|
|
673
|
+
// Text output comes from message.part.updated events.
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
case "message.part.updated": {
|
|
677
|
+
this.handleMessagePartUpdated(payload);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
case "message.part.delta": {
|
|
681
|
+
this.handleMessagePartDelta(payload);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
case "message.part.removed": {
|
|
685
|
+
this.handleMessagePartRemoved(payload);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
case "tui.prompt.append": {
|
|
689
|
+
this.handleTuiPromptAppend(payload);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
case "tui.command.execute": {
|
|
693
|
+
this.handleTuiCommandExecute(payload);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
case "tui.session.select": {
|
|
697
|
+
this.handleTuiSessionSelect(payload);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
case "command.executed": {
|
|
701
|
+
this.handleCommandExecuted(payload);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
case "session.diff":
|
|
705
|
+
case "session.diff.delta":
|
|
706
|
+
case "session.deleted":
|
|
707
|
+
case "message.removed":
|
|
708
|
+
case "permission.replied":
|
|
709
|
+
case "tui.toast.show":
|
|
710
|
+
return;
|
|
711
|
+
default:
|
|
712
|
+
this.logUnknownEvent(type);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
handleSessionIdle(properties) {
|
|
717
|
+
if (!isRecord(properties)) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const sessionId = this.extractSessionId(properties) ?? this.activeSessionId;
|
|
721
|
+
if (!this.syncTrackedSessionFromEvent(sessionId, { allowLocalTurnFollow: false })) {
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (this.state.status !== "busy" && this.state.status !== "awaiting_approval") {
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
// Wait a short settle time before emitting task_complete,
|
|
728
|
+
// in case more events follow the idle signal.
|
|
729
|
+
setTimeout(() => {
|
|
730
|
+
if (this.state.status !== "busy" && this.state.status !== "awaiting_approval") {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
this.clearWechatWorkingNotice(true);
|
|
734
|
+
this.pendingLocalPrompt = "";
|
|
735
|
+
this.clearPendingPermissionState();
|
|
736
|
+
this.state.activeTurnOrigin = undefined;
|
|
737
|
+
this.hasAcceptedInput = false;
|
|
738
|
+
const completedPreview = this.currentPreview;
|
|
739
|
+
const turnStartedAtMs = this.lastBusyAtMs;
|
|
740
|
+
void this.outputBatcher.flushNow()
|
|
741
|
+
.catch(() => undefined)
|
|
742
|
+
.then(async () => {
|
|
743
|
+
const finalReplyText = await this.resolveFinalReplyText(sessionId, turnStartedAtMs);
|
|
744
|
+
this.setStatus("idle");
|
|
745
|
+
if (finalReplyText) {
|
|
746
|
+
this.emit({
|
|
747
|
+
type: "final_reply",
|
|
748
|
+
text: finalReplyText,
|
|
749
|
+
timestamp: nowIso(),
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
this.emit({
|
|
753
|
+
type: "task_complete",
|
|
754
|
+
summary: completedPreview,
|
|
755
|
+
timestamp: nowIso(),
|
|
756
|
+
});
|
|
757
|
+
this.currentPreview = "(idle)";
|
|
758
|
+
this.outputBatcher.clear();
|
|
759
|
+
this.clearStreamedPartState();
|
|
760
|
+
});
|
|
761
|
+
}, OPENCODE_SESSION_IDLE_SETTLE_MS).unref?.();
|
|
762
|
+
}
|
|
763
|
+
handleSessionStatus(properties) {
|
|
764
|
+
if (!isRecord(properties)) {
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const sessionId = this.extractSessionId(properties);
|
|
768
|
+
if (sessionId && !this.syncTrackedSessionFromEvent(sessionId)) {
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
// properties: { sessionID: string, status: { type: "busy" | "idle" | ... } }
|
|
772
|
+
const status = properties.status;
|
|
773
|
+
if (!isRecord(status)) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const statusType = typeof status.type === "string" ? status.type : undefined;
|
|
777
|
+
if (!statusType) {
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
if (statusType === "busy" || statusType === "running") {
|
|
781
|
+
if (this.state.status === "idle") {
|
|
782
|
+
this.outputBatcher.clear();
|
|
783
|
+
this.clearStreamedPartState();
|
|
784
|
+
this.lastBusyAtMs = Date.now();
|
|
785
|
+
this.setStatus("busy", this.state.activeTurnOrigin === "local"
|
|
786
|
+
? "OpenCode is busy with a local terminal turn."
|
|
787
|
+
: undefined);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
handlePermissionRequest(properties) {
|
|
792
|
+
if (!isRecord(properties) || !this.client) {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const sessionId = this.extractSessionId(properties);
|
|
796
|
+
if (sessionId && !this.syncTrackedSessionFromEvent(sessionId)) {
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
const pendingPermission = this.buildPendingPermission(properties);
|
|
800
|
+
if (!pendingPermission) {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
this.clearWechatWorkingNotice();
|
|
804
|
+
const approval = this.toPendingApproval(pendingPermission);
|
|
805
|
+
this.pendingPermission = pendingPermission;
|
|
806
|
+
this.state.pendingApproval = approval;
|
|
807
|
+
this.state.pendingApprovalOrigin = this.state.activeTurnOrigin;
|
|
808
|
+
this.setStatus("awaiting_approval", "OpenCode approval is required.");
|
|
809
|
+
this.emit({
|
|
810
|
+
type: "approval_required",
|
|
811
|
+
request: approval,
|
|
812
|
+
timestamp: nowIso(),
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
clearPendingPermissionState() {
|
|
816
|
+
this.pendingPermission = null;
|
|
817
|
+
this.state.pendingApproval = null;
|
|
818
|
+
this.state.pendingApprovalOrigin = undefined;
|
|
819
|
+
}
|
|
820
|
+
toPendingApproval(pendingPermission) {
|
|
821
|
+
return {
|
|
822
|
+
...pendingPermission.request,
|
|
823
|
+
code: pendingPermission.code,
|
|
824
|
+
createdAt: pendingPermission.createdAt,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
buildPendingPermission(properties) {
|
|
828
|
+
const sessionId = typeof properties.sessionID === "string"
|
|
829
|
+
? properties.sessionID
|
|
830
|
+
: this.activeSessionId;
|
|
831
|
+
const permissionId = typeof properties.id === "string"
|
|
832
|
+
? properties.id
|
|
833
|
+
: undefined;
|
|
834
|
+
if (!sessionId || !permissionId) {
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
const toolName = typeof properties.type === "string"
|
|
838
|
+
? properties.type
|
|
839
|
+
: typeof properties.permission === "string"
|
|
840
|
+
? properties.permission
|
|
841
|
+
: undefined;
|
|
842
|
+
const title = typeof properties.title === "string"
|
|
843
|
+
? properties.title
|
|
844
|
+
: typeof properties.permission === "string"
|
|
845
|
+
? `Permission request: ${properties.permission}`
|
|
846
|
+
: undefined;
|
|
847
|
+
const metadata = isRecord(properties.metadata) ? properties.metadata : {};
|
|
848
|
+
const command = typeof metadata.command === "string"
|
|
849
|
+
? metadata.command
|
|
850
|
+
: typeof metadata.detail === "string"
|
|
851
|
+
? metadata.detail
|
|
852
|
+
: Array.isArray(properties.patterns)
|
|
853
|
+
? properties.patterns.filter((value) => typeof value === "string").join(", ")
|
|
854
|
+
: undefined;
|
|
855
|
+
return {
|
|
856
|
+
sessionId,
|
|
857
|
+
permissionId,
|
|
858
|
+
code: buildOneTimeCode(),
|
|
859
|
+
createdAt: nowIso(),
|
|
860
|
+
request: {
|
|
861
|
+
source: "cli",
|
|
862
|
+
summary: title ?? `OpenCode needs approval${toolName ? ` for tool: ${toolName}` : ""}.`,
|
|
863
|
+
commandPreview: truncatePreview(command ?? title ?? "Permission request", 180),
|
|
864
|
+
toolName,
|
|
865
|
+
detailPreview: typeof metadata.detail === "string" ? metadata.detail : undefined,
|
|
866
|
+
detailLabel: typeof metadata.label === "string" ? metadata.label : undefined,
|
|
867
|
+
confirmInput: undefined,
|
|
868
|
+
denyInput: undefined,
|
|
869
|
+
},
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
handleSessionCreated(properties) {
|
|
873
|
+
if (!isRecord(properties)) {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const session = this.extractSessionReference(properties);
|
|
877
|
+
if (this.syncTrackedSessionFromEvent(session)) {
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (!this.shouldFollowCreatedLocalSession(session)) {
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
this.pendingLocalSessionCreateFollowUntilMs = 0;
|
|
884
|
+
this.logDebug(`[opencode-adapter:local-follow] session.created ${session.id}`);
|
|
885
|
+
this.switchSharedSession(session, {
|
|
886
|
+
source: "local",
|
|
887
|
+
reason: "local_follow",
|
|
888
|
+
notify: true,
|
|
889
|
+
clearTrackedTurn: true,
|
|
890
|
+
syncVisible: true,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
handleSessionUpdated(properties) {
|
|
894
|
+
if (!isRecord(properties)) {
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const session = this.extractSessionReference(properties);
|
|
898
|
+
this.syncTrackedSessionFromEvent(session, { allowLocalTurnFollow: false });
|
|
899
|
+
}
|
|
900
|
+
handleMessageUpdated(properties) {
|
|
901
|
+
if (!isRecord(properties)) {
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
const sessionId = this.extractSessionId(properties);
|
|
905
|
+
if (sessionId && !this.syncTrackedSessionFromEvent(sessionId, { allowLocalTurnFollow: false })) {
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const info = isRecord(properties.info) ? properties.info : undefined;
|
|
909
|
+
const messageId = typeof info?.id === "string" ? info.id : undefined;
|
|
910
|
+
const role = info?.role === "user" || info?.role === "assistant" ? info.role : undefined;
|
|
911
|
+
if (!messageId) {
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
const observed = this.getOrCreateObservedOpenCodeMessage(messageId, sessionId ?? undefined);
|
|
915
|
+
observed.updatedAtMs = Date.now();
|
|
916
|
+
observed.sessionId = sessionId ?? observed.sessionId;
|
|
917
|
+
observed.role = role;
|
|
918
|
+
if (role === "assistant") {
|
|
919
|
+
this.cleanupObservedOpenCodeMessage(messageId);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
this.tryEmitObservedLocalUserMessage(messageId);
|
|
923
|
+
}
|
|
924
|
+
handleMessagePartUpdated(properties) {
|
|
925
|
+
if (!isRecord(properties)) {
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const part = isRecord(properties.part) ? properties.part : undefined;
|
|
929
|
+
const partId = this.extractPartId(properties, part);
|
|
930
|
+
if (partId && typeof part?.type === "string") {
|
|
931
|
+
this.partTypeByPartId.set(partId, part.type);
|
|
932
|
+
}
|
|
933
|
+
if (this.isVisibleTextPart(part)) {
|
|
934
|
+
this.trackObservedOpenCodeMessagePart({
|
|
935
|
+
messageId: part.messageID,
|
|
936
|
+
sessionId: part.sessionID,
|
|
937
|
+
partId: partId ?? part.id,
|
|
938
|
+
snapshotText: typeof part.text === "string" ? part.text : undefined,
|
|
939
|
+
deltaText: typeof properties.delta === "string" ? properties.delta : undefined,
|
|
940
|
+
});
|
|
941
|
+
if (this.observedOpenCodeMessages.get(part.messageID)?.role === "user") {
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
if (this.state.status !== "busy") {
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (!this.isVisibleTextPart(part)) {
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
if (!this.syncTrackedSessionFromEvent(part.sessionID)) {
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
if (!partId) {
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
const partText = typeof part.text === "string"
|
|
958
|
+
? part.text
|
|
959
|
+
: undefined;
|
|
960
|
+
const delta = typeof properties.delta === "string"
|
|
961
|
+
? properties.delta
|
|
962
|
+
: undefined;
|
|
963
|
+
const text = partText
|
|
964
|
+
? this.consumeVisiblePartSnapshot(partId, partText)
|
|
965
|
+
: delta
|
|
966
|
+
? this.consumeVisiblePartDelta(partId, delta)
|
|
967
|
+
: "";
|
|
968
|
+
const observedText = typeof part.messageID === "string"
|
|
969
|
+
? this.observedOpenCodeMessages.get(part.messageID)?.text
|
|
970
|
+
: undefined;
|
|
971
|
+
const observedRole = typeof part.messageID === "string"
|
|
972
|
+
? this.observedOpenCodeMessages.get(part.messageID)?.role
|
|
973
|
+
: undefined;
|
|
974
|
+
if (observedRole !== "assistant" &&
|
|
975
|
+
this.matchesRecentLocalPromptMirror(observedText || partText || delta || text, {
|
|
976
|
+
allowPrefix: true,
|
|
977
|
+
})) {
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (this.matchesRecentWechatPromptMirror(part.sessionID, observedText || partText || delta || text, {
|
|
981
|
+
allowPrefix: true,
|
|
982
|
+
})) {
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (partText) {
|
|
986
|
+
this.recordVisibleReplyPartSnapshot(partId, part.sessionID, part.messageID, partText);
|
|
987
|
+
}
|
|
988
|
+
else if (text) {
|
|
989
|
+
this.recordVisibleReplyPartDelta(partId, part.sessionID, part.messageID, text);
|
|
990
|
+
}
|
|
991
|
+
this.pushVisibleOutput(text);
|
|
992
|
+
}
|
|
993
|
+
handleMessagePartDelta(properties) {
|
|
994
|
+
if (!isRecord(properties)) {
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (this.state.status !== "busy") {
|
|
998
|
+
const sessionIdForTrackedMessage = typeof properties.sessionID === "string" ? properties.sessionID : undefined;
|
|
999
|
+
const messageIdForTrackedMessage = typeof properties.messageID === "string" ? properties.messageID : undefined;
|
|
1000
|
+
const partIdForTrackedMessage = typeof properties.partID === "string" ? properties.partID : undefined;
|
|
1001
|
+
const deltaForTrackedMessage = typeof properties.delta === "string" ? properties.delta : undefined;
|
|
1002
|
+
if (messageIdForTrackedMessage && partIdForTrackedMessage && deltaForTrackedMessage) {
|
|
1003
|
+
this.trackObservedOpenCodeMessagePart({
|
|
1004
|
+
messageId: messageIdForTrackedMessage,
|
|
1005
|
+
sessionId: sessionIdForTrackedMessage,
|
|
1006
|
+
partId: partIdForTrackedMessage,
|
|
1007
|
+
deltaText: deltaForTrackedMessage,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
const sessionId = typeof properties.sessionID === "string"
|
|
1013
|
+
? properties.sessionID
|
|
1014
|
+
: undefined;
|
|
1015
|
+
const partId = this.extractPartId(properties);
|
|
1016
|
+
const knownPartType = partId ? this.partTypeByPartId.get(partId) : undefined;
|
|
1017
|
+
if (knownPartType && knownPartType !== "text") {
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (properties.field !== "text") {
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
const delta = typeof properties.delta === "string"
|
|
1024
|
+
? properties.delta
|
|
1025
|
+
: undefined;
|
|
1026
|
+
if (typeof properties.messageID === "string" && partId && delta) {
|
|
1027
|
+
this.trackObservedOpenCodeMessagePart({
|
|
1028
|
+
messageId: properties.messageID,
|
|
1029
|
+
sessionId,
|
|
1030
|
+
partId,
|
|
1031
|
+
deltaText: delta,
|
|
1032
|
+
});
|
|
1033
|
+
if (this.observedOpenCodeMessages.get(properties.messageID)?.role === "user") {
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (!delta || !partId || !this.syncTrackedSessionFromEvent(sessionId)) {
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
const text = this.consumeVisiblePartDelta(partId, delta);
|
|
1041
|
+
const observedText = typeof properties.messageID === "string"
|
|
1042
|
+
? this.observedOpenCodeMessages.get(properties.messageID)?.text
|
|
1043
|
+
: undefined;
|
|
1044
|
+
const observedRole = typeof properties.messageID === "string"
|
|
1045
|
+
? this.observedOpenCodeMessages.get(properties.messageID)?.role
|
|
1046
|
+
: undefined;
|
|
1047
|
+
if (observedRole !== "assistant" &&
|
|
1048
|
+
this.matchesRecentLocalPromptMirror(observedText || delta || text, {
|
|
1049
|
+
allowPrefix: true,
|
|
1050
|
+
})) {
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
if (this.matchesRecentWechatPromptMirror(sessionId, observedText || delta || text, {
|
|
1054
|
+
allowPrefix: true,
|
|
1055
|
+
})) {
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
this.recordVisibleReplyPartDelta(partId, sessionId, properties.messageID, text);
|
|
1059
|
+
this.pushVisibleOutput(text);
|
|
1060
|
+
}
|
|
1061
|
+
handleMessagePartRemoved(properties) {
|
|
1062
|
+
if (!isRecord(properties)) {
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const partId = this.extractPartId(properties);
|
|
1066
|
+
if (!partId) {
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
this.emittedTextByPartId.delete(partId);
|
|
1070
|
+
this.partTypeByPartId.delete(partId);
|
|
1071
|
+
this.visibleReplyPartsByPartId.delete(partId);
|
|
1072
|
+
}
|
|
1073
|
+
recordVisibleReplyPartSnapshot(partId, sessionId, messageId, text) {
|
|
1074
|
+
const normalizedText = normalizeOutput(text);
|
|
1075
|
+
if (!normalizedText) {
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
const resolvedSessionId = sessionId ?? this.activeSessionId ?? undefined;
|
|
1079
|
+
if (!resolvedSessionId) {
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
const resolvedMessageId = typeof messageId === "string" ? messageId : undefined;
|
|
1083
|
+
this.visibleReplyPartsByPartId.set(partId, {
|
|
1084
|
+
sessionId: resolvedSessionId,
|
|
1085
|
+
messageId: resolvedMessageId,
|
|
1086
|
+
text: normalizedText,
|
|
1087
|
+
});
|
|
1088
|
+
if (resolvedMessageId) {
|
|
1089
|
+
this.visibleReplyMessageIds.add(resolvedMessageId);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
recordVisibleReplyPartDelta(partId, sessionId, messageId, delta) {
|
|
1093
|
+
const normalizedDelta = normalizeOutput(delta);
|
|
1094
|
+
if (!normalizedDelta) {
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const previous = this.visibleReplyPartsByPartId.get(partId);
|
|
1098
|
+
this.recordVisibleReplyPartSnapshot(partId, sessionId ?? previous?.sessionId, messageId ?? previous?.messageId, `${previous?.text ?? ""}${normalizedDelta}`);
|
|
1099
|
+
}
|
|
1100
|
+
async resolveFinalReplyText(sessionId, turnStartedAtMs) {
|
|
1101
|
+
const sessionReply = sessionId
|
|
1102
|
+
? await this.fetchLatestAssistantVisibleReply(sessionId, turnStartedAtMs)
|
|
1103
|
+
: "";
|
|
1104
|
+
if (sessionReply.trim()) {
|
|
1105
|
+
return sessionReply.trim();
|
|
1106
|
+
}
|
|
1107
|
+
const streamedReply = this.getBufferedVisibleReplyText(sessionId ?? undefined);
|
|
1108
|
+
if (streamedReply.trim()) {
|
|
1109
|
+
return streamedReply.trim();
|
|
1110
|
+
}
|
|
1111
|
+
const summary = this.outputBatcher.getRecentSummary(500);
|
|
1112
|
+
return summary && summary !== "(no output)" ? summary : "";
|
|
1113
|
+
}
|
|
1114
|
+
getBufferedVisibleReplyText(sessionId) {
|
|
1115
|
+
const chunks = [];
|
|
1116
|
+
for (const part of this.visibleReplyPartsByPartId.values()) {
|
|
1117
|
+
if (sessionId && part.sessionId !== sessionId) {
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
if (part.text) {
|
|
1121
|
+
chunks.push(part.text);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return normalizeOutput(chunks.join(""));
|
|
1125
|
+
}
|
|
1126
|
+
async fetchLatestAssistantVisibleReply(sessionId, turnStartedAtMs) {
|
|
1127
|
+
const sessionClient = this.client?.session;
|
|
1128
|
+
if (!sessionClient?.messages) {
|
|
1129
|
+
return "";
|
|
1130
|
+
}
|
|
1131
|
+
const queryVariants = this.activeWorkspaceId
|
|
1132
|
+
? [
|
|
1133
|
+
{
|
|
1134
|
+
sessionID: sessionId,
|
|
1135
|
+
directory: this.options.cwd,
|
|
1136
|
+
workspace: this.activeWorkspaceId,
|
|
1137
|
+
limit: 20,
|
|
1138
|
+
},
|
|
1139
|
+
{
|
|
1140
|
+
sessionID: sessionId,
|
|
1141
|
+
directory: this.options.cwd,
|
|
1142
|
+
limit: 20,
|
|
1143
|
+
},
|
|
1144
|
+
]
|
|
1145
|
+
: [
|
|
1146
|
+
{
|
|
1147
|
+
sessionID: sessionId,
|
|
1148
|
+
directory: this.options.cwd,
|
|
1149
|
+
limit: 20,
|
|
1150
|
+
},
|
|
1151
|
+
];
|
|
1152
|
+
for (const query of queryVariants) {
|
|
1153
|
+
try {
|
|
1154
|
+
const result = await sessionClient.messages(query);
|
|
1155
|
+
if (result.error !== undefined || !Array.isArray(result.data)) {
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
const text = this.extractLatestAssistantVisibleReply(result.data, turnStartedAtMs, this.visibleReplyMessageIds);
|
|
1159
|
+
if (text) {
|
|
1160
|
+
return text;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
catch {
|
|
1164
|
+
// Fall back to the streamed visible text buffer.
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return "";
|
|
1168
|
+
}
|
|
1169
|
+
extractLatestAssistantVisibleReply(messages, turnStartedAtMs, expectedMessageIds) {
|
|
1170
|
+
let best = null;
|
|
1171
|
+
const minTimeMs = turnStartedAtMs > 0 ? turnStartedAtMs - 5_000 : 0;
|
|
1172
|
+
for (const [index, message] of messages.entries()) {
|
|
1173
|
+
if (!isRecord(message) || !isRecord(message.info)) {
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
if (message.info.role !== "assistant") {
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
const messageId = typeof message.info.id === "string" ? message.info.id : "";
|
|
1180
|
+
if (expectedMessageIds.size > 0 && !expectedMessageIds.has(messageId)) {
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
const timeMs = this.extractMessageTimeMs(message.info);
|
|
1184
|
+
if (expectedMessageIds.size === 0 && (!timeMs || timeMs < minTimeMs)) {
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
const text = this.extractVisibleTextFromParts(message.parts);
|
|
1188
|
+
if (!text.trim()) {
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
const candidate = { text, timeMs: timeMs ?? 0, index };
|
|
1192
|
+
if (!best ||
|
|
1193
|
+
candidate.timeMs > best.timeMs ||
|
|
1194
|
+
(candidate.timeMs === best.timeMs && candidate.index > best.index)) {
|
|
1195
|
+
best = candidate;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
return best?.text ?? "";
|
|
1199
|
+
}
|
|
1200
|
+
extractVisibleTextFromParts(parts) {
|
|
1201
|
+
if (!Array.isArray(parts)) {
|
|
1202
|
+
return "";
|
|
1203
|
+
}
|
|
1204
|
+
return normalizeOutput(parts
|
|
1205
|
+
.filter((part) => this.isVisibleTextPart(isRecord(part) ? part : undefined))
|
|
1206
|
+
.map((part) => part.text ?? "")
|
|
1207
|
+
.join(""));
|
|
1208
|
+
}
|
|
1209
|
+
extractMessageTimeMs(info) {
|
|
1210
|
+
const time = isRecord(info.time) ? info.time : undefined;
|
|
1211
|
+
const rawTime = typeof time?.completed === "number"
|
|
1212
|
+
? time.completed
|
|
1213
|
+
: typeof time?.created === "number"
|
|
1214
|
+
? time.created
|
|
1215
|
+
: undefined;
|
|
1216
|
+
if (typeof rawTime !== "number" || !Number.isFinite(rawTime) || rawTime <= 0) {
|
|
1217
|
+
return null;
|
|
1218
|
+
}
|
|
1219
|
+
return rawTime < 1_000_000_000_000 ? rawTime * 1_000 : rawTime;
|
|
1220
|
+
}
|
|
1221
|
+
handleTuiPromptAppend(properties) {
|
|
1222
|
+
if (!isRecord(properties)) {
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
const text = typeof properties.text === "string" ? properties.text : undefined;
|
|
1226
|
+
if (!text) {
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
this.pendingLocalPrompt += text;
|
|
1230
|
+
this.maybeNotifyLocalPromptDraftStarted();
|
|
1231
|
+
}
|
|
1232
|
+
handleTuiCommandExecute(properties) {
|
|
1233
|
+
if (!isRecord(properties)) {
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
const command = typeof properties.command === "string" ? properties.command : undefined;
|
|
1237
|
+
if (!command) {
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
switch (command) {
|
|
1241
|
+
case "prompt.clear":
|
|
1242
|
+
this.pendingLocalPrompt = "";
|
|
1243
|
+
this.localPromptNoticeSent = false;
|
|
1244
|
+
return;
|
|
1245
|
+
case "prompt.submit":
|
|
1246
|
+
this.handleLocalPromptSubmit();
|
|
1247
|
+
return;
|
|
1248
|
+
default:
|
|
1249
|
+
if (this.isLocalSessionNavigationCommand(command)) {
|
|
1250
|
+
this.markPendingLocalSessionCreateFollow();
|
|
1251
|
+
}
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
handleTuiSessionSelect(properties) {
|
|
1256
|
+
if (!isRecord(properties)) {
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
const sessionId = this.extractSessionId(properties) ?? undefined;
|
|
1260
|
+
if (!sessionId) {
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
if (sessionId === this.suppressedTuiSessionSelectId) {
|
|
1264
|
+
this.suppressedTuiSessionSelectId = null;
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
this.pendingLocalPrompt = "";
|
|
1268
|
+
this.localPromptNoticeSent = false;
|
|
1269
|
+
this.pendingLocalSessionCreateFollowUntilMs = 0;
|
|
1270
|
+
this.logDebug(`[opencode-adapter:local-follow] tui.session.select ${sessionId}`);
|
|
1271
|
+
this.switchSharedSession({
|
|
1272
|
+
id: sessionId,
|
|
1273
|
+
workspaceID: this.extractWorkspaceId(properties) ?? undefined,
|
|
1274
|
+
}, {
|
|
1275
|
+
source: "local",
|
|
1276
|
+
reason: "local_follow",
|
|
1277
|
+
notify: true,
|
|
1278
|
+
clearTrackedTurn: true,
|
|
1279
|
+
syncVisible: true,
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
handleCommandExecuted(properties) {
|
|
1283
|
+
if (!isRecord(properties)) {
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
const name = typeof properties.name === "string" ? properties.name : undefined;
|
|
1287
|
+
if (!this.isLocalSessionNavigationCommand(name)) {
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
const sessionId = this.extractSessionId(properties);
|
|
1291
|
+
if (sessionId) {
|
|
1292
|
+
this.logDebug(`[opencode-adapter:local-follow] command.executed ${name} -> ${sessionId}`);
|
|
1293
|
+
this.switchSharedSession({
|
|
1294
|
+
id: sessionId,
|
|
1295
|
+
workspaceID: this.extractWorkspaceId(properties) ?? undefined,
|
|
1296
|
+
}, {
|
|
1297
|
+
source: "local",
|
|
1298
|
+
reason: "local_follow",
|
|
1299
|
+
notify: true,
|
|
1300
|
+
clearTrackedTurn: true,
|
|
1301
|
+
syncVisible: true,
|
|
1302
|
+
});
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
this.markPendingLocalSessionCreateFollow();
|
|
1306
|
+
}
|
|
1307
|
+
handleSessionError(properties) {
|
|
1308
|
+
if (!isRecord(properties)) {
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
const error = isRecord(properties.error) ? properties.error : undefined;
|
|
1312
|
+
const errorName = typeof error?.name === "string" ? error.name : undefined;
|
|
1313
|
+
const message = this.describeSessionError(error);
|
|
1314
|
+
if (!message) {
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
const sessionId = this.extractSessionId(properties);
|
|
1318
|
+
if (sessionId && !this.syncTrackedSessionFromEvent(sessionId, { allowLocalTurnFollow: false })) {
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
if (!this.hasTrackedTurnState()) {
|
|
1322
|
+
this.emit({
|
|
1323
|
+
type: "stderr",
|
|
1324
|
+
text: `OpenCode session error: ${message}`,
|
|
1325
|
+
timestamp: nowIso(),
|
|
1326
|
+
});
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
if (errorName === "MessageAbortedError") {
|
|
1330
|
+
this.settleTurnState();
|
|
1331
|
+
this.setStatus("idle");
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
this.failTrackedTurn(message);
|
|
1335
|
+
}
|
|
1336
|
+
handleLocalPromptSubmit() {
|
|
1337
|
+
const prompt = normalizeOutput(this.pendingLocalPrompt).trim();
|
|
1338
|
+
this.pendingLocalPrompt = "";
|
|
1339
|
+
this.localPromptNoticeSent = false;
|
|
1340
|
+
if (!prompt) {
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
this.outputBatcher.clear();
|
|
1344
|
+
this.clearStreamedPartState();
|
|
1345
|
+
this.beginTrackedTurn(prompt, "local", {
|
|
1346
|
+
busyMessage: "OpenCode is busy with a local terminal turn.",
|
|
1347
|
+
emitMirroredUserInput: true,
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
trackObservedOpenCodeMessagePart(params) {
|
|
1351
|
+
const observed = this.getOrCreateObservedOpenCodeMessage(params.messageId, params.sessionId);
|
|
1352
|
+
observed.updatedAtMs = Date.now();
|
|
1353
|
+
observed.sessionId = params.sessionId ?? observed.sessionId;
|
|
1354
|
+
let chunk = "";
|
|
1355
|
+
if (typeof params.snapshotText === "string") {
|
|
1356
|
+
chunk = this.consumeObservedUserPartSnapshot(params.partId, params.snapshotText);
|
|
1357
|
+
}
|
|
1358
|
+
else if (typeof params.deltaText === "string") {
|
|
1359
|
+
chunk = this.consumeObservedUserPartDelta(params.partId, params.deltaText);
|
|
1360
|
+
}
|
|
1361
|
+
if (!chunk) {
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
let partIds = this.observedUserMessagePartIds.get(params.messageId);
|
|
1365
|
+
if (!partIds) {
|
|
1366
|
+
partIds = new Set();
|
|
1367
|
+
this.observedUserMessagePartIds.set(params.messageId, partIds);
|
|
1368
|
+
}
|
|
1369
|
+
partIds.add(params.partId);
|
|
1370
|
+
observed.text = normalizeOutput(`${observed.text}${chunk}`);
|
|
1371
|
+
this.tryEmitObservedLocalUserMessage(params.messageId);
|
|
1372
|
+
}
|
|
1373
|
+
getOrCreateObservedOpenCodeMessage(messageId, sessionId) {
|
|
1374
|
+
const existing = this.observedOpenCodeMessages.get(messageId);
|
|
1375
|
+
if (existing) {
|
|
1376
|
+
if (sessionId) {
|
|
1377
|
+
existing.sessionId = sessionId;
|
|
1378
|
+
}
|
|
1379
|
+
return existing;
|
|
1380
|
+
}
|
|
1381
|
+
const created = {
|
|
1382
|
+
sessionId: sessionId ?? this.activeSessionId ?? "",
|
|
1383
|
+
text: "",
|
|
1384
|
+
emitted: false,
|
|
1385
|
+
updatedAtMs: Date.now(),
|
|
1386
|
+
};
|
|
1387
|
+
this.observedOpenCodeMessages.set(messageId, created);
|
|
1388
|
+
return created;
|
|
1389
|
+
}
|
|
1390
|
+
tryEmitObservedLocalUserMessage(messageId) {
|
|
1391
|
+
const observed = this.observedOpenCodeMessages.get(messageId);
|
|
1392
|
+
if (!observed || observed.role !== "user" || observed.emitted) {
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
const text = normalizeOutput(observed.text).trim();
|
|
1396
|
+
if (!text) {
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
observed.emitted = true;
|
|
1400
|
+
if (this.shouldSuppressWechatMirroredPrompt(observed.sessionId, text) ||
|
|
1401
|
+
this.matchesRecentWechatPromptMirror(observed.sessionId, text)) {
|
|
1402
|
+
this.cleanupObservedOpenCodeMessage(messageId);
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
if (this.wasRecentlyMirroredLocalPrompt(text)) {
|
|
1406
|
+
this.cleanupObservedOpenCodeMessage(messageId);
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
this.outputBatcher.clear();
|
|
1410
|
+
this.clearStreamedPartState();
|
|
1411
|
+
this.beginTrackedTurn(text, "local", {
|
|
1412
|
+
busyMessage: "OpenCode is busy with a local terminal turn.",
|
|
1413
|
+
emitMirroredUserInput: true,
|
|
1414
|
+
});
|
|
1415
|
+
this.cleanupObservedOpenCodeMessage(messageId);
|
|
1416
|
+
}
|
|
1417
|
+
recordPendingWechatPromptMirrorSuppression(sessionId, text) {
|
|
1418
|
+
const normalizedText = normalizeOutput(text).trim();
|
|
1419
|
+
if (!sessionId || !normalizedText) {
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
this.pruneWechatPromptMirrorSuppressions();
|
|
1423
|
+
const suppression = {
|
|
1424
|
+
sessionId,
|
|
1425
|
+
text: normalizedText,
|
|
1426
|
+
createdAtMs: Date.now(),
|
|
1427
|
+
};
|
|
1428
|
+
this.pendingWechatPromptMirrorSuppressions.push(suppression);
|
|
1429
|
+
this.recentWechatPromptMirrorSuppressions.push(suppression);
|
|
1430
|
+
}
|
|
1431
|
+
shouldSuppressWechatMirroredPrompt(sessionId, text) {
|
|
1432
|
+
const normalizedText = normalizeOutput(text).trim();
|
|
1433
|
+
if (!sessionId || !normalizedText) {
|
|
1434
|
+
return false;
|
|
1435
|
+
}
|
|
1436
|
+
this.pruneWechatPromptMirrorSuppressions();
|
|
1437
|
+
for (let index = this.pendingWechatPromptMirrorSuppressions.length - 1; index >= 0; index -= 1) {
|
|
1438
|
+
const pending = this.pendingWechatPromptMirrorSuppressions[index];
|
|
1439
|
+
if (pending.sessionId === sessionId && pending.text === normalizedText) {
|
|
1440
|
+
this.pendingWechatPromptMirrorSuppressions.splice(index, 1);
|
|
1441
|
+
return true;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
return false;
|
|
1445
|
+
}
|
|
1446
|
+
matchesRecentWechatPromptMirror(sessionId, text, options = {}) {
|
|
1447
|
+
const normalizedText = normalizeOutput(text ?? "").trim();
|
|
1448
|
+
if (!sessionId || !normalizedText) {
|
|
1449
|
+
return false;
|
|
1450
|
+
}
|
|
1451
|
+
this.pruneWechatPromptMirrorSuppressions();
|
|
1452
|
+
return this.recentWechatPromptMirrorSuppressions.some((pending) => {
|
|
1453
|
+
if (pending.sessionId !== sessionId) {
|
|
1454
|
+
return false;
|
|
1455
|
+
}
|
|
1456
|
+
if (pending.text === normalizedText) {
|
|
1457
|
+
return true;
|
|
1458
|
+
}
|
|
1459
|
+
return options.allowPrefix === true && pending.text.startsWith(normalizedText);
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
pruneWechatPromptMirrorSuppressions() {
|
|
1463
|
+
const cutoff = Date.now() - OPENCODE_WECHAT_MIRROR_SUPPRESSION_TTL_MS;
|
|
1464
|
+
for (let index = this.pendingWechatPromptMirrorSuppressions.length - 1; index >= 0; index -= 1) {
|
|
1465
|
+
if (this.pendingWechatPromptMirrorSuppressions[index].createdAtMs < cutoff) {
|
|
1466
|
+
this.pendingWechatPromptMirrorSuppressions.splice(index, 1);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
for (let index = this.recentWechatPromptMirrorSuppressions.length - 1; index >= 0; index -= 1) {
|
|
1470
|
+
if (this.recentWechatPromptMirrorSuppressions[index].createdAtMs < cutoff) {
|
|
1471
|
+
this.recentWechatPromptMirrorSuppressions.splice(index, 1);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
wasRecentlyMirroredLocalPrompt(text) {
|
|
1476
|
+
return this.matchesRecentLocalPromptMirror(text);
|
|
1477
|
+
}
|
|
1478
|
+
matchesRecentLocalPromptMirror(text, options = {}) {
|
|
1479
|
+
const normalizedText = normalizeOutput(text ?? "").trim();
|
|
1480
|
+
if (!normalizedText) {
|
|
1481
|
+
return false;
|
|
1482
|
+
}
|
|
1483
|
+
if (this.state.activeTurnOrigin === "local" &&
|
|
1484
|
+
this.hasAcceptedInput &&
|
|
1485
|
+
this.lastMirroredLocalPrompt &&
|
|
1486
|
+
this.lastMirroredLocalPrompt.createdAtMs >= Date.now() - OPENCODE_RECENT_LOCAL_PROMPT_TTL_MS &&
|
|
1487
|
+
(this.lastMirroredLocalPrompt.text === normalizedText ||
|
|
1488
|
+
(options.allowPrefix === true &&
|
|
1489
|
+
this.lastMirroredLocalPrompt.text.startsWith(normalizedText)))) {
|
|
1490
|
+
return true;
|
|
1491
|
+
}
|
|
1492
|
+
return false;
|
|
1493
|
+
}
|
|
1494
|
+
/* ---- Session helpers ---- */
|
|
1495
|
+
unwrapOrThrow(result) {
|
|
1496
|
+
if (result.error !== undefined) {
|
|
1497
|
+
throw new Error(`SDK error: ${describeUnknownError(result.error)}`);
|
|
1498
|
+
}
|
|
1499
|
+
return result.data;
|
|
1500
|
+
}
|
|
1501
|
+
async ensureSession() {
|
|
1502
|
+
if (this.activeSessionId && this.client) {
|
|
1503
|
+
const session = await this.getSessionForCurrentDirectory(this.activeSessionId);
|
|
1504
|
+
if (session) {
|
|
1505
|
+
return session;
|
|
1506
|
+
}
|
|
1507
|
+
this.activeSessionId = null;
|
|
1508
|
+
}
|
|
1509
|
+
if (!this.client) {
|
|
1510
|
+
throw new Error("OpenCode SDK client is not initialized.");
|
|
1511
|
+
}
|
|
1512
|
+
const session = this.unwrapOrThrow(await this.client.session.create({
|
|
1513
|
+
directory: this.options.cwd,
|
|
1514
|
+
workspace: this.activeWorkspaceId ?? undefined,
|
|
1515
|
+
}));
|
|
1516
|
+
return session;
|
|
1517
|
+
}
|
|
1518
|
+
beginTrackedTurn(text, origin, options = {}) {
|
|
1519
|
+
this.hasAcceptedInput = true;
|
|
1520
|
+
this.currentPreview = truncatePreview(text);
|
|
1521
|
+
this.state.lastInputAt = nowIso();
|
|
1522
|
+
this.state.activeTurnOrigin = origin;
|
|
1523
|
+
this.lastBusyAtMs = Date.now();
|
|
1524
|
+
this.clearWechatWorkingNotice(true);
|
|
1525
|
+
this.setStatus("busy", options.busyMessage);
|
|
1526
|
+
if (options.emitMirroredUserInput && origin === "local") {
|
|
1527
|
+
this.lastMirroredLocalPrompt = {
|
|
1528
|
+
text: normalizeOutput(text).trim(),
|
|
1529
|
+
createdAtMs: Date.now(),
|
|
1530
|
+
};
|
|
1531
|
+
this.emit({
|
|
1532
|
+
type: "mirrored_user_input",
|
|
1533
|
+
text,
|
|
1534
|
+
origin: "local",
|
|
1535
|
+
timestamp: nowIso(),
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
if (origin === "wechat") {
|
|
1539
|
+
this.armWechatWorkingNotice();
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
hasTrackedTurnState() {
|
|
1543
|
+
return (this.state.status === "busy" ||
|
|
1544
|
+
this.state.status === "awaiting_approval" ||
|
|
1545
|
+
this.hasAcceptedInput ||
|
|
1546
|
+
this.pendingPermission !== null ||
|
|
1547
|
+
this.state.activeTurnOrigin !== undefined ||
|
|
1548
|
+
this.currentPreview !== "(idle)");
|
|
1549
|
+
}
|
|
1550
|
+
settleTurnState() {
|
|
1551
|
+
this.clearWechatWorkingNotice(true);
|
|
1552
|
+
this.pendingLocalPrompt = "";
|
|
1553
|
+
this.localPromptNoticeSent = false;
|
|
1554
|
+
this.clearPendingPermissionState();
|
|
1555
|
+
this.state.activeTurnOrigin = undefined;
|
|
1556
|
+
this.hasAcceptedInput = false;
|
|
1557
|
+
this.currentPreview = "(idle)";
|
|
1558
|
+
this.outputBatcher.clear();
|
|
1559
|
+
this.clearStreamedPartState();
|
|
1560
|
+
}
|
|
1561
|
+
clearObservedMessageTracking() {
|
|
1562
|
+
this.observedOpenCodeMessages.clear();
|
|
1563
|
+
this.observedUserTextByPartId.clear();
|
|
1564
|
+
this.observedUserMessagePartIds.clear();
|
|
1565
|
+
this.pendingWechatPromptMirrorSuppressions.length = 0;
|
|
1566
|
+
this.recentWechatPromptMirrorSuppressions.length = 0;
|
|
1567
|
+
this.lastMirroredLocalPrompt = null;
|
|
1568
|
+
}
|
|
1569
|
+
clearTrackedTurnForLocalSessionSwitch() {
|
|
1570
|
+
if (!this.hasTrackedTurnState()) {
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
this.settleTurnState();
|
|
1574
|
+
if (this.state.status !== "idle") {
|
|
1575
|
+
this.setStatus("idle");
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
failTrackedTurn(message) {
|
|
1579
|
+
if (!this.hasTrackedTurnState()) {
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
this.settleTurnState();
|
|
1583
|
+
this.setStatus("idle");
|
|
1584
|
+
this.emit({
|
|
1585
|
+
type: "task_failed",
|
|
1586
|
+
message,
|
|
1587
|
+
timestamp: nowIso(),
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
describeSessionError(error) {
|
|
1591
|
+
if (!error) {
|
|
1592
|
+
return "OpenCode reported an unknown session error.";
|
|
1593
|
+
}
|
|
1594
|
+
const name = typeof error.name === "string" ? error.name : "UnknownError";
|
|
1595
|
+
const data = isRecord(error.data) ? error.data : undefined;
|
|
1596
|
+
const message = typeof data?.message === "string" ? data.message.trim() : "";
|
|
1597
|
+
const providerId = typeof data?.providerID === "string" ? data.providerID : undefined;
|
|
1598
|
+
if (name === "ProviderAuthError") {
|
|
1599
|
+
return providerId
|
|
1600
|
+
? `Authentication is required for provider "${providerId}".${message ? ` ${message}` : ""}`.trim()
|
|
1601
|
+
: message || "Authentication is required for the configured provider.";
|
|
1602
|
+
}
|
|
1603
|
+
return message || name;
|
|
1604
|
+
}
|
|
1605
|
+
/* ---- Working notice ---- */
|
|
1606
|
+
armWechatWorkingNotice() {
|
|
1607
|
+
this.clearWechatWorkingNotice();
|
|
1608
|
+
if (this.workingNoticeSent ||
|
|
1609
|
+
!this.hasAcceptedInput ||
|
|
1610
|
+
this.state.status !== "busy" ||
|
|
1611
|
+
this.pendingPermission ||
|
|
1612
|
+
this.state.activeTurnOrigin !== "wechat") {
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
this.workingNoticeTimer = setTimeout(() => {
|
|
1616
|
+
this.workingNoticeTimer = null;
|
|
1617
|
+
if (this.workingNoticeSent ||
|
|
1618
|
+
!this.hasAcceptedInput ||
|
|
1619
|
+
this.state.status !== "busy" ||
|
|
1620
|
+
this.pendingPermission ||
|
|
1621
|
+
this.state.activeTurnOrigin !== "wechat") {
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
this.workingNoticeSent = true;
|
|
1625
|
+
this.emit({
|
|
1626
|
+
type: "notice",
|
|
1627
|
+
text: `OpenCode is still working on:\n${this.currentPreview}`,
|
|
1628
|
+
level: "info",
|
|
1629
|
+
timestamp: nowIso(),
|
|
1630
|
+
});
|
|
1631
|
+
}, this.workingNoticeDelayMs);
|
|
1632
|
+
this.workingNoticeTimer.unref?.();
|
|
1633
|
+
}
|
|
1634
|
+
clearWechatWorkingNotice(resetSent = false) {
|
|
1635
|
+
if (this.workingNoticeTimer) {
|
|
1636
|
+
clearTimeout(this.workingNoticeTimer);
|
|
1637
|
+
this.workingNoticeTimer = null;
|
|
1638
|
+
}
|
|
1639
|
+
if (resetSent) {
|
|
1640
|
+
this.workingNoticeSent = false;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
/* ---- Output batching ---- */
|
|
1644
|
+
flushOutputBatch(text) {
|
|
1645
|
+
this.emit({
|
|
1646
|
+
type: "stdout",
|
|
1647
|
+
text,
|
|
1648
|
+
timestamp: nowIso(),
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
/* ---- Core helpers ---- */
|
|
1652
|
+
emit(event) {
|
|
1653
|
+
this.eventSink(event);
|
|
1654
|
+
}
|
|
1655
|
+
assignActiveSession(session) {
|
|
1656
|
+
const sessionId = typeof session === "string" ? session : session?.id;
|
|
1657
|
+
if (!sessionId) {
|
|
1658
|
+
return false;
|
|
1659
|
+
}
|
|
1660
|
+
const changed = sessionId !== this.activeSessionId;
|
|
1661
|
+
this.activeSessionId = sessionId;
|
|
1662
|
+
if (typeof session !== "string" && session?.workspaceID) {
|
|
1663
|
+
this.activeWorkspaceId = session.workspaceID;
|
|
1664
|
+
}
|
|
1665
|
+
this.state.sharedSessionId = sessionId;
|
|
1666
|
+
this.state.sharedThreadId = sessionId;
|
|
1667
|
+
this.state.activeRuntimeSessionId = sessionId;
|
|
1668
|
+
return changed;
|
|
1669
|
+
}
|
|
1670
|
+
syncTrackedSessionFromEvent(session, options = {}) {
|
|
1671
|
+
const sessionId = typeof session === "string" ? session : session?.id;
|
|
1672
|
+
if (!sessionId) {
|
|
1673
|
+
return false;
|
|
1674
|
+
}
|
|
1675
|
+
if (sessionId === this.activeSessionId) {
|
|
1676
|
+
this.assignActiveSession(session);
|
|
1677
|
+
return true;
|
|
1678
|
+
}
|
|
1679
|
+
if (options.allowLocalTurnFollow !== false && this.shouldFollowLocalTurnSession(sessionId)) {
|
|
1680
|
+
this.switchSharedSession(session ?? sessionId, {
|
|
1681
|
+
source: "local",
|
|
1682
|
+
reason: "local_turn",
|
|
1683
|
+
notify: true,
|
|
1684
|
+
syncVisible: true,
|
|
1685
|
+
});
|
|
1686
|
+
return true;
|
|
1687
|
+
}
|
|
1688
|
+
return false;
|
|
1689
|
+
}
|
|
1690
|
+
extractSessionReference(properties) {
|
|
1691
|
+
if (typeof properties.sessionID === "string" || typeof properties.sessionId === "string") {
|
|
1692
|
+
const id = typeof properties.sessionID === "string"
|
|
1693
|
+
? properties.sessionID
|
|
1694
|
+
: String(properties.sessionId);
|
|
1695
|
+
return {
|
|
1696
|
+
id,
|
|
1697
|
+
workspaceID: this.extractWorkspaceId(properties) ?? undefined,
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
const session = isRecord(properties.session) ? properties.session : undefined;
|
|
1701
|
+
if (typeof session?.id === "string") {
|
|
1702
|
+
return {
|
|
1703
|
+
id: session.id,
|
|
1704
|
+
workspaceID: this.extractWorkspaceId(session) ?? this.extractWorkspaceId(properties) ?? undefined,
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
const info = isRecord(properties.info) ? properties.info : undefined;
|
|
1708
|
+
if (typeof info?.id === "string") {
|
|
1709
|
+
return {
|
|
1710
|
+
id: info.id,
|
|
1711
|
+
workspaceID: this.extractWorkspaceId(info) ?? this.extractWorkspaceId(properties) ?? undefined,
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
return null;
|
|
1715
|
+
}
|
|
1716
|
+
extractSessionId(properties) {
|
|
1717
|
+
return this.extractSessionReference(properties)?.id ?? null;
|
|
1718
|
+
}
|
|
1719
|
+
extractWorkspaceId(properties) {
|
|
1720
|
+
if (typeof properties.workspaceID === "string") {
|
|
1721
|
+
return properties.workspaceID;
|
|
1722
|
+
}
|
|
1723
|
+
const session = isRecord(properties.session) ? properties.session : undefined;
|
|
1724
|
+
if (typeof session?.workspaceID === "string") {
|
|
1725
|
+
return session.workspaceID;
|
|
1726
|
+
}
|
|
1727
|
+
const info = isRecord(properties.info) ? properties.info : undefined;
|
|
1728
|
+
if (typeof info?.workspaceID === "string") {
|
|
1729
|
+
return info.workspaceID;
|
|
1730
|
+
}
|
|
1731
|
+
return null;
|
|
1732
|
+
}
|
|
1733
|
+
shouldFollowLocalTurnSession(sessionId) {
|
|
1734
|
+
return (sessionId !== this.activeSessionId &&
|
|
1735
|
+
this.state.activeTurnOrigin === "local" &&
|
|
1736
|
+
this.hasTrackedTurnState());
|
|
1737
|
+
}
|
|
1738
|
+
markPendingLocalSessionCreateFollow() {
|
|
1739
|
+
this.pendingLocalSessionCreateFollowUntilMs =
|
|
1740
|
+
Date.now() + OPENCODE_LOCAL_SESSION_CREATE_FOLLOW_TTL_MS;
|
|
1741
|
+
}
|
|
1742
|
+
hasPendingLocalSessionCreateFollow() {
|
|
1743
|
+
return Date.now() <= this.pendingLocalSessionCreateFollowUntilMs;
|
|
1744
|
+
}
|
|
1745
|
+
shouldAcceptUnscopedLocalSessionCreatedEvent(event) {
|
|
1746
|
+
if (this.normalizeEventType(event.type) !== "session.created") {
|
|
1747
|
+
return false;
|
|
1748
|
+
}
|
|
1749
|
+
if (this.options.renderMode !== "companion") {
|
|
1750
|
+
return false;
|
|
1751
|
+
}
|
|
1752
|
+
const payload = this.extractEventPayload(event);
|
|
1753
|
+
if (!isRecord(payload)) {
|
|
1754
|
+
return false;
|
|
1755
|
+
}
|
|
1756
|
+
const session = this.extractSessionReference(payload);
|
|
1757
|
+
if (!session?.id || session.id === this.activeSessionId) {
|
|
1758
|
+
return false;
|
|
1759
|
+
}
|
|
1760
|
+
if (typeof event.directory === "string" ||
|
|
1761
|
+
this.extractSessionDirectory(payload) ||
|
|
1762
|
+
this.extractWorkspaceId(payload)) {
|
|
1763
|
+
return false;
|
|
1764
|
+
}
|
|
1765
|
+
return this.hasPendingLocalSessionCreateFollow() || Boolean(this.activeSessionId);
|
|
1766
|
+
}
|
|
1767
|
+
shouldFollowCreatedLocalSession(session) {
|
|
1768
|
+
if (!session?.id || session.id === this.activeSessionId) {
|
|
1769
|
+
return false;
|
|
1770
|
+
}
|
|
1771
|
+
if (this.hasPendingLocalSessionCreateFollow()) {
|
|
1772
|
+
this.pendingLocalSessionCreateFollowUntilMs = 0;
|
|
1773
|
+
return true;
|
|
1774
|
+
}
|
|
1775
|
+
this.pendingLocalSessionCreateFollowUntilMs = 0;
|
|
1776
|
+
return Boolean(this.activeSessionId);
|
|
1777
|
+
}
|
|
1778
|
+
switchSharedSession(session, options) {
|
|
1779
|
+
const sessionId = typeof session === "string" ? session : session.id;
|
|
1780
|
+
if (!sessionId) {
|
|
1781
|
+
return false;
|
|
1782
|
+
}
|
|
1783
|
+
const changed = this.assignActiveSession(session);
|
|
1784
|
+
if (changed && options.clearTrackedTurn) {
|
|
1785
|
+
this.clearTrackedTurnForLocalSessionSwitch();
|
|
1786
|
+
}
|
|
1787
|
+
if (changed) {
|
|
1788
|
+
this.recordSessionSwitch(sessionId, options.source, options.reason, options.notify);
|
|
1789
|
+
}
|
|
1790
|
+
if (options.syncVisible !== false && (changed || options.forceVisibleSync)) {
|
|
1791
|
+
void this.syncVisibleSessionSelection(typeof session === "string" ? { id: session } : session);
|
|
1792
|
+
}
|
|
1793
|
+
return changed;
|
|
1794
|
+
}
|
|
1795
|
+
async syncVisibleSessionToShared(options = {}) {
|
|
1796
|
+
if (!this.activeSessionId) {
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
await this.syncVisibleSessionSelection({
|
|
1800
|
+
id: this.activeSessionId,
|
|
1801
|
+
workspaceID: this.activeWorkspaceId ?? undefined,
|
|
1802
|
+
}, { force: options.force });
|
|
1803
|
+
}
|
|
1804
|
+
async syncVisibleSessionSelection(session, options = {}) {
|
|
1805
|
+
if (!this.client?.tui ||
|
|
1806
|
+
this.options.renderMode !== "companion" ||
|
|
1807
|
+
(!options.force && session.id === this.suppressedTuiSessionSelectId)) {
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
this.suppressedTuiSessionSelectId = session.id;
|
|
1811
|
+
this.logDebug(`[opencode-adapter:tui] selectSession session=${session.id} workspace=${session.workspaceID ?? this.activeWorkspaceId ?? "(none)"}`);
|
|
1812
|
+
try {
|
|
1813
|
+
const result = await this.client.tui.selectSession({
|
|
1814
|
+
directory: this.options.cwd,
|
|
1815
|
+
workspace: session.workspaceID ?? this.activeWorkspaceId ?? undefined,
|
|
1816
|
+
sessionID: session.id,
|
|
1817
|
+
});
|
|
1818
|
+
if (result.error !== undefined) {
|
|
1819
|
+
throw result.error;
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
catch (err) {
|
|
1823
|
+
if (this.suppressedTuiSessionSelectId === session.id) {
|
|
1824
|
+
this.suppressedTuiSessionSelectId = null;
|
|
1825
|
+
}
|
|
1826
|
+
this.logDebug(`[opencode-adapter:tui] selectSession failed for ${session.id}: ${describeUnknownError(err)}`);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
isLocalSessionNavigationCommand(command) {
|
|
1830
|
+
if (!command) {
|
|
1831
|
+
return false;
|
|
1832
|
+
}
|
|
1833
|
+
const normalized = command.trim().toLowerCase();
|
|
1834
|
+
return (normalized === "session" ||
|
|
1835
|
+
normalized === "new" ||
|
|
1836
|
+
normalized === "/session" ||
|
|
1837
|
+
normalized === "/new" ||
|
|
1838
|
+
normalized.startsWith("session.") ||
|
|
1839
|
+
normalized.startsWith("/session "));
|
|
1840
|
+
}
|
|
1841
|
+
normalizeEventType(type) {
|
|
1842
|
+
return type.endsWith(".1") ? type.slice(0, -2) : type;
|
|
1843
|
+
}
|
|
1844
|
+
extractEventPayload(event) {
|
|
1845
|
+
const syncEvent = event;
|
|
1846
|
+
return syncEvent.properties ?? syncEvent.data;
|
|
1847
|
+
}
|
|
1848
|
+
matchesCurrentDirectoryEvent(event) {
|
|
1849
|
+
const payload = this.extractEventPayload(event);
|
|
1850
|
+
const eventRecord = event;
|
|
1851
|
+
const wrappedDirectory = typeof eventRecord.directory === "string"
|
|
1852
|
+
? eventRecord.directory
|
|
1853
|
+
: undefined;
|
|
1854
|
+
if (wrappedDirectory) {
|
|
1855
|
+
const matchesWrappedDirectory = this.normalizeDirectory(wrappedDirectory) === this.normalizeDirectory(this.options.cwd);
|
|
1856
|
+
if (!matchesWrappedDirectory) {
|
|
1857
|
+
return false;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
if (!isRecord(payload)) {
|
|
1861
|
+
return Boolean(wrappedDirectory);
|
|
1862
|
+
}
|
|
1863
|
+
const directory = this.extractSessionDirectory(payload);
|
|
1864
|
+
if (directory) {
|
|
1865
|
+
const matchesDirectory = this.normalizeDirectory(directory) === this.normalizeDirectory(this.options.cwd);
|
|
1866
|
+
if (!matchesDirectory) {
|
|
1867
|
+
return false;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
const workspaceId = this.extractWorkspaceId(payload);
|
|
1871
|
+
if (workspaceId && this.activeWorkspaceId && workspaceId !== this.activeWorkspaceId) {
|
|
1872
|
+
this.logDebug(`[opencode-adapter:sse] Ignored workspace mismatch session=${this.extractSessionId(payload) ?? "(unknown)"} eventWorkspace=${workspaceId} activeWorkspace=${this.activeWorkspaceId}`);
|
|
1873
|
+
return false;
|
|
1874
|
+
}
|
|
1875
|
+
if (wrappedDirectory || directory || workspaceId) {
|
|
1876
|
+
return true;
|
|
1877
|
+
}
|
|
1878
|
+
const sessionId = this.extractSessionId(payload);
|
|
1879
|
+
return Boolean(sessionId &&
|
|
1880
|
+
(sessionId === this.activeSessionId || sessionId === this.state.sharedSessionId));
|
|
1881
|
+
}
|
|
1882
|
+
extractSessionDirectory(properties) {
|
|
1883
|
+
if (typeof properties.directory === "string") {
|
|
1884
|
+
return properties.directory;
|
|
1885
|
+
}
|
|
1886
|
+
const session = isRecord(properties.session) ? properties.session : undefined;
|
|
1887
|
+
if (typeof session?.directory === "string") {
|
|
1888
|
+
return session.directory;
|
|
1889
|
+
}
|
|
1890
|
+
const info = isRecord(properties.info) ? properties.info : undefined;
|
|
1891
|
+
if (typeof info?.directory === "string") {
|
|
1892
|
+
return info.directory;
|
|
1893
|
+
}
|
|
1894
|
+
return null;
|
|
1895
|
+
}
|
|
1896
|
+
isCurrentDirectorySession(session) {
|
|
1897
|
+
if (this.normalizeDirectory(session.directory) !== this.normalizeDirectory(this.options.cwd)) {
|
|
1898
|
+
return false;
|
|
1899
|
+
}
|
|
1900
|
+
if (session.workspaceID && this.activeWorkspaceId && session.workspaceID !== this.activeWorkspaceId) {
|
|
1901
|
+
return false;
|
|
1902
|
+
}
|
|
1903
|
+
return true;
|
|
1904
|
+
}
|
|
1905
|
+
normalizeDirectory(directory) {
|
|
1906
|
+
return directory.replace(/[\\/]+/g, "\\").replace(/\\$/, "").toLowerCase();
|
|
1907
|
+
}
|
|
1908
|
+
recordSessionSwitch(sessionId, source, reason, notify = false) {
|
|
1909
|
+
const timestamp = nowIso();
|
|
1910
|
+
this.state.lastSessionSwitchAt = timestamp;
|
|
1911
|
+
this.state.lastSessionSwitchSource = source;
|
|
1912
|
+
this.state.lastSessionSwitchReason = reason;
|
|
1913
|
+
if (!notify) {
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
this.emit({
|
|
1917
|
+
type: "session_switched",
|
|
1918
|
+
sessionId,
|
|
1919
|
+
source,
|
|
1920
|
+
reason,
|
|
1921
|
+
timestamp,
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
getServerUrl() {
|
|
1925
|
+
return `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`;
|
|
1926
|
+
}
|
|
1927
|
+
isVisibleTextPart(part) {
|
|
1928
|
+
return !!part && part.type === "text" && part.ignored !== true;
|
|
1929
|
+
}
|
|
1930
|
+
extractPartId(properties, part) {
|
|
1931
|
+
if (typeof properties.partID === "string") {
|
|
1932
|
+
return properties.partID;
|
|
1933
|
+
}
|
|
1934
|
+
if (typeof part?.id === "string") {
|
|
1935
|
+
return part.id;
|
|
1936
|
+
}
|
|
1937
|
+
return null;
|
|
1938
|
+
}
|
|
1939
|
+
consumeVisiblePartSnapshot(partId, text) {
|
|
1940
|
+
const nextText = normalizeOutput(text);
|
|
1941
|
+
if (!nextText) {
|
|
1942
|
+
return "";
|
|
1943
|
+
}
|
|
1944
|
+
const previousText = this.emittedTextByPartId.get(partId) ?? "";
|
|
1945
|
+
if (nextText === previousText) {
|
|
1946
|
+
return "";
|
|
1947
|
+
}
|
|
1948
|
+
this.emittedTextByPartId.set(partId, nextText);
|
|
1949
|
+
if (!previousText) {
|
|
1950
|
+
return nextText;
|
|
1951
|
+
}
|
|
1952
|
+
if (nextText.startsWith(previousText)) {
|
|
1953
|
+
return nextText.slice(previousText.length);
|
|
1954
|
+
}
|
|
1955
|
+
const sharedPrefixLength = this.getSharedPrefixLength(previousText, nextText);
|
|
1956
|
+
return nextText.slice(sharedPrefixLength);
|
|
1957
|
+
}
|
|
1958
|
+
consumeVisiblePartDelta(partId, delta) {
|
|
1959
|
+
const nextChunk = normalizeOutput(delta);
|
|
1960
|
+
if (!nextChunk) {
|
|
1961
|
+
return "";
|
|
1962
|
+
}
|
|
1963
|
+
const previousText = this.emittedTextByPartId.get(partId) ?? "";
|
|
1964
|
+
if (nextChunk === previousText || previousText.endsWith(nextChunk)) {
|
|
1965
|
+
return "";
|
|
1966
|
+
}
|
|
1967
|
+
if (previousText && nextChunk.startsWith(previousText)) {
|
|
1968
|
+
this.emittedTextByPartId.set(partId, nextChunk);
|
|
1969
|
+
return nextChunk.slice(previousText.length);
|
|
1970
|
+
}
|
|
1971
|
+
this.emittedTextByPartId.set(partId, `${previousText}${nextChunk}`);
|
|
1972
|
+
return nextChunk;
|
|
1973
|
+
}
|
|
1974
|
+
consumeObservedUserPartSnapshot(partId, text) {
|
|
1975
|
+
const nextText = normalizeOutput(text);
|
|
1976
|
+
if (!nextText) {
|
|
1977
|
+
return "";
|
|
1978
|
+
}
|
|
1979
|
+
const previousText = this.observedUserTextByPartId.get(partId) ?? "";
|
|
1980
|
+
if (nextText === previousText) {
|
|
1981
|
+
return "";
|
|
1982
|
+
}
|
|
1983
|
+
this.observedUserTextByPartId.set(partId, nextText);
|
|
1984
|
+
if (!previousText) {
|
|
1985
|
+
return nextText;
|
|
1986
|
+
}
|
|
1987
|
+
if (nextText.startsWith(previousText)) {
|
|
1988
|
+
return nextText.slice(previousText.length);
|
|
1989
|
+
}
|
|
1990
|
+
const sharedPrefixLength = this.getSharedPrefixLength(previousText, nextText);
|
|
1991
|
+
return nextText.slice(sharedPrefixLength);
|
|
1992
|
+
}
|
|
1993
|
+
consumeObservedUserPartDelta(partId, delta) {
|
|
1994
|
+
const nextChunk = normalizeOutput(delta);
|
|
1995
|
+
if (!nextChunk) {
|
|
1996
|
+
return "";
|
|
1997
|
+
}
|
|
1998
|
+
const previousText = this.observedUserTextByPartId.get(partId) ?? "";
|
|
1999
|
+
if (nextChunk === previousText || previousText.endsWith(nextChunk)) {
|
|
2000
|
+
return "";
|
|
2001
|
+
}
|
|
2002
|
+
if (previousText && nextChunk.startsWith(previousText)) {
|
|
2003
|
+
this.observedUserTextByPartId.set(partId, nextChunk);
|
|
2004
|
+
return nextChunk.slice(previousText.length);
|
|
2005
|
+
}
|
|
2006
|
+
this.observedUserTextByPartId.set(partId, `${previousText}${nextChunk}`);
|
|
2007
|
+
return nextChunk;
|
|
2008
|
+
}
|
|
2009
|
+
pushVisibleOutput(text) {
|
|
2010
|
+
if (!text) {
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
this.state.lastOutputAt = nowIso();
|
|
2014
|
+
this.outputBatcher.push(text);
|
|
2015
|
+
}
|
|
2016
|
+
clearStreamedPartState() {
|
|
2017
|
+
this.emittedTextByPartId.clear();
|
|
2018
|
+
this.partTypeByPartId.clear();
|
|
2019
|
+
this.visibleReplyPartsByPartId.clear();
|
|
2020
|
+
this.visibleReplyMessageIds.clear();
|
|
2021
|
+
}
|
|
2022
|
+
cleanupObservedOpenCodeMessage(messageId) {
|
|
2023
|
+
this.observedOpenCodeMessages.delete(messageId);
|
|
2024
|
+
const partIds = this.observedUserMessagePartIds.get(messageId);
|
|
2025
|
+
if (partIds) {
|
|
2026
|
+
for (const partId of partIds) {
|
|
2027
|
+
this.observedUserTextByPartId.delete(partId);
|
|
2028
|
+
}
|
|
2029
|
+
this.observedUserMessagePartIds.delete(messageId);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
getSharedPrefixLength(left, right) {
|
|
2033
|
+
const limit = Math.min(left.length, right.length);
|
|
2034
|
+
let index = 0;
|
|
2035
|
+
while (index < limit && left[index] === right[index]) {
|
|
2036
|
+
index += 1;
|
|
2037
|
+
}
|
|
2038
|
+
return index;
|
|
2039
|
+
}
|
|
2040
|
+
logDebug(message) {
|
|
2041
|
+
if (!OPENCODE_DEBUG_ENABLED) {
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
process.stderr.write(`${message}\n`);
|
|
2045
|
+
}
|
|
2046
|
+
logUnknownEvent(type) {
|
|
2047
|
+
if (!OPENCODE_DEBUG_ENABLED || this.loggedUnknownEventTypes.has(type)) {
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
this.loggedUnknownEventTypes.add(type);
|
|
2051
|
+
this.logDebug(`[opencode-adapter:sse] Unknown event: ${type}`);
|
|
2052
|
+
}
|
|
2053
|
+
shouldSkipDuplicateSdkEvent(event, streamName) {
|
|
2054
|
+
const key = this.getDuplicateSdkEventKey(event);
|
|
2055
|
+
if (!key) {
|
|
2056
|
+
return false;
|
|
2057
|
+
}
|
|
2058
|
+
const now = Date.now();
|
|
2059
|
+
const cutoff = now - OPENCODE_DUPLICATE_EVENT_TTL_MS;
|
|
2060
|
+
for (const [candidateKey, observed] of this.recentSdkEventObservations.entries()) {
|
|
2061
|
+
if (observed.observedAtMs < cutoff) {
|
|
2062
|
+
this.recentSdkEventObservations.delete(candidateKey);
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
const previous = this.recentSdkEventObservations.get(key);
|
|
2066
|
+
this.recentSdkEventObservations.set(key, { streamName, observedAtMs: now });
|
|
2067
|
+
return Boolean(previous && previous.streamName !== streamName && previous.observedAtMs >= cutoff);
|
|
2068
|
+
}
|
|
2069
|
+
getDuplicateSdkEventKey(event) {
|
|
2070
|
+
const type = this.normalizeEventType(event.type);
|
|
2071
|
+
const payload = this.extractEventPayload(event);
|
|
2072
|
+
if (!isRecord(payload)) {
|
|
2073
|
+
return null;
|
|
2074
|
+
}
|
|
2075
|
+
switch (type) {
|
|
2076
|
+
case "tui.prompt.append": {
|
|
2077
|
+
const text = typeof payload.text === "string" ? payload.text : undefined;
|
|
2078
|
+
return text ? `${type}:${text}` : null;
|
|
2079
|
+
}
|
|
2080
|
+
case "tui.command.execute": {
|
|
2081
|
+
const command = typeof payload.command === "string" ? payload.command : undefined;
|
|
2082
|
+
return command ? `${type}:${command}` : null;
|
|
2083
|
+
}
|
|
2084
|
+
case "tui.session.select": {
|
|
2085
|
+
const sessionId = typeof payload.sessionID === "string" ? payload.sessionID : undefined;
|
|
2086
|
+
return sessionId ? `${type}:${sessionId}` : null;
|
|
2087
|
+
}
|
|
2088
|
+
case "command.executed": {
|
|
2089
|
+
const name = typeof payload.name === "string" ? payload.name : undefined;
|
|
2090
|
+
const sessionId = this.extractSessionId(payload) ?? "";
|
|
2091
|
+
const args = typeof payload.arguments === "string" ? payload.arguments : "";
|
|
2092
|
+
return name ? `${type}:${name}:${sessionId}:${args}` : null;
|
|
2093
|
+
}
|
|
2094
|
+
case "session.created":
|
|
2095
|
+
case "session.updated":
|
|
2096
|
+
case "session.deleted": {
|
|
2097
|
+
const sessionId = this.extractSessionId(payload);
|
|
2098
|
+
return sessionId ? `${type}:${sessionId}` : null;
|
|
2099
|
+
}
|
|
2100
|
+
default:
|
|
2101
|
+
return null;
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
maybeNotifyLocalPromptDraftStarted() {
|
|
2105
|
+
if (this.localPromptNoticeSent) {
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
const preview = truncatePreview(normalizeOutput(this.pendingLocalPrompt).trim(), 180);
|
|
2109
|
+
if (!preview) {
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
this.localPromptNoticeSent = true;
|
|
2113
|
+
this.emit({
|
|
2114
|
+
type: "notice",
|
|
2115
|
+
text: `OpenCode local draft:\n${preview}`,
|
|
2116
|
+
level: "info",
|
|
2117
|
+
timestamp: nowIso(),
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
setStatus(status, message) {
|
|
2121
|
+
this.state.status = status;
|
|
2122
|
+
this.emit({
|
|
2123
|
+
type: "status",
|
|
2124
|
+
status,
|
|
2125
|
+
message,
|
|
2126
|
+
timestamp: nowIso(),
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
}
|