@love-moon/ai-sdk 0.2.18 → 0.2.20
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/dist/client.d.ts +1 -1
- package/dist/providers/opencode-sdk-session.d.ts +179 -0
- package/dist/providers/opencode-sdk-session.js +1200 -0
- package/dist/session-factory.d.ts +6 -4
- package/dist/session-factory.js +20 -4
- package/dist/shared.js +58 -1
- package/dist/transports/opencode-server-transport.d.ts +62 -0
- package/dist/transports/opencode-server-transport.js +442 -0
- package/dist/tui-session.d.ts +1 -0
- package/dist/tui-session.js +13 -0
- package/package.json +3 -2
- package/dist/resume.d.ts +0 -26
- package/dist/resume.js +0 -380
|
@@ -0,0 +1,1200 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { OpencodeServerTransport } from "../transports/opencode-server-transport.js";
|
|
3
|
+
import { emitLog, getBoundedEnvInt, loadEnvConfig, normalizeLogger, proxyToEnv, sanitizeForLog, } from "../shared.js";
|
|
4
|
+
const DEFAULT_TURN_DEADLINE_MS = 12 * 60 * 1000;
|
|
5
|
+
const MIN_TURN_DEADLINE_MS = 30 * 1000;
|
|
6
|
+
const MAX_TURN_DEADLINE_MS = 30 * 60 * 1000;
|
|
7
|
+
const OPENCODE_PROVIDER_VARIANT = "opencode-sdk";
|
|
8
|
+
function waitForever() {
|
|
9
|
+
return new Promise(() => { });
|
|
10
|
+
}
|
|
11
|
+
function createTurnError(message, extras = {}) {
|
|
12
|
+
const error = new Error(message);
|
|
13
|
+
for (const [key, value] of Object.entries(extras)) {
|
|
14
|
+
error[key] = value;
|
|
15
|
+
}
|
|
16
|
+
return error;
|
|
17
|
+
}
|
|
18
|
+
function normalizeOpencodeBackend(backend) {
|
|
19
|
+
const normalized = String(backend || "").trim().toLowerCase();
|
|
20
|
+
if (normalized === "open-code" || normalized === "open_code") {
|
|
21
|
+
return "opencode";
|
|
22
|
+
}
|
|
23
|
+
return normalized || "opencode";
|
|
24
|
+
}
|
|
25
|
+
function normalizeText(value) {
|
|
26
|
+
return typeof value === "string" ? value : "";
|
|
27
|
+
}
|
|
28
|
+
function sanitizeSummary(value, maxLen = 180) {
|
|
29
|
+
return sanitizeForLog(value, maxLen);
|
|
30
|
+
}
|
|
31
|
+
function toolPhaseForName(toolName) {
|
|
32
|
+
const normalized = String(toolName || "").trim().toLowerCase();
|
|
33
|
+
if (!normalized) {
|
|
34
|
+
return "tool_call";
|
|
35
|
+
}
|
|
36
|
+
if (normalized.includes("bash") || normalized.includes("shell") || normalized.includes("command")) {
|
|
37
|
+
return "command_execution";
|
|
38
|
+
}
|
|
39
|
+
if (normalized.includes("edit") ||
|
|
40
|
+
normalized.includes("write") ||
|
|
41
|
+
normalized.includes("patch") ||
|
|
42
|
+
normalized.includes("replace")) {
|
|
43
|
+
return "file_update";
|
|
44
|
+
}
|
|
45
|
+
if (normalized.includes("read") ||
|
|
46
|
+
normalized.includes("grep") ||
|
|
47
|
+
normalized.includes("glob") ||
|
|
48
|
+
normalized.includes("ls")) {
|
|
49
|
+
return "workspace_inspection";
|
|
50
|
+
}
|
|
51
|
+
if (normalized.includes("web") || normalized.includes("fetch") || normalized.includes("search")) {
|
|
52
|
+
return "web_lookup";
|
|
53
|
+
}
|
|
54
|
+
if (normalized.includes("task") || normalized.includes("agent")) {
|
|
55
|
+
return "task_progress";
|
|
56
|
+
}
|
|
57
|
+
return "tool_call";
|
|
58
|
+
}
|
|
59
|
+
function statusLineForPhase(phase, toolName = "", detail = "") {
|
|
60
|
+
switch (phase) {
|
|
61
|
+
case "context_compaction":
|
|
62
|
+
return "opencode compacting context";
|
|
63
|
+
case "reasoning":
|
|
64
|
+
return "opencode reasoning";
|
|
65
|
+
case "planning":
|
|
66
|
+
return "opencode updating plan";
|
|
67
|
+
case "command_execution":
|
|
68
|
+
return toolName ? `opencode running ${toolName}` : "opencode running command";
|
|
69
|
+
case "file_update":
|
|
70
|
+
return toolName ? `opencode editing with ${toolName}` : "opencode editing files";
|
|
71
|
+
case "workspace_inspection":
|
|
72
|
+
return toolName ? `opencode reading with ${toolName}` : "opencode reading workspace";
|
|
73
|
+
case "web_lookup":
|
|
74
|
+
return toolName ? `opencode browsing with ${toolName}` : "opencode browsing";
|
|
75
|
+
case "task_progress":
|
|
76
|
+
return detail || (toolName ? `opencode running ${toolName}` : "opencode running task");
|
|
77
|
+
case "message_aggregation":
|
|
78
|
+
return "opencode composing reply";
|
|
79
|
+
case "tool_call":
|
|
80
|
+
return detail || (toolName ? `opencode calling ${toolName}` : "opencode calling tool");
|
|
81
|
+
default:
|
|
82
|
+
return "opencode is working";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function isTruthyPlainObject(value) {
|
|
86
|
+
return value && typeof value === "object" && !Array.isArray(value);
|
|
87
|
+
}
|
|
88
|
+
function buildOpencodeConfig(options = {}) {
|
|
89
|
+
const userConfig = isTruthyPlainObject(options.opencodeConfig) ? { ...options.opencodeConfig } : {};
|
|
90
|
+
if (userConfig.permission === undefined) {
|
|
91
|
+
userConfig.permission = "allow";
|
|
92
|
+
}
|
|
93
|
+
if (userConfig.share === undefined) {
|
|
94
|
+
userConfig.share = "disabled";
|
|
95
|
+
}
|
|
96
|
+
if (typeof options.model === "string" && options.model.trim() && userConfig.model === undefined) {
|
|
97
|
+
userConfig.model = options.model.trim();
|
|
98
|
+
}
|
|
99
|
+
if (typeof options.agent === "string" && options.agent.trim() && userConfig.default_agent === undefined) {
|
|
100
|
+
userConfig.default_agent = options.agent.trim();
|
|
101
|
+
}
|
|
102
|
+
return userConfig;
|
|
103
|
+
}
|
|
104
|
+
function buildEmptyTurnResult() {
|
|
105
|
+
return {
|
|
106
|
+
text: "",
|
|
107
|
+
usage: null,
|
|
108
|
+
items: [],
|
|
109
|
+
events: [],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function isAbortError(error) {
|
|
113
|
+
return error?.name === "AbortError" || error?.code === "ABORT_ERR";
|
|
114
|
+
}
|
|
115
|
+
function extractErrorMessage(error) {
|
|
116
|
+
if (!error) {
|
|
117
|
+
return "Opencode turn failed";
|
|
118
|
+
}
|
|
119
|
+
if (typeof error.message === "string" && error.message.trim()) {
|
|
120
|
+
return error.message.trim();
|
|
121
|
+
}
|
|
122
|
+
if (typeof error === "string" && error.trim()) {
|
|
123
|
+
return error.trim();
|
|
124
|
+
}
|
|
125
|
+
return "Opencode turn failed";
|
|
126
|
+
}
|
|
127
|
+
export class OpencodeSdkSession extends EventEmitter {
|
|
128
|
+
constructor(backend, options = {}) {
|
|
129
|
+
super();
|
|
130
|
+
this.backend = normalizeOpencodeBackend(backend);
|
|
131
|
+
this.options = options;
|
|
132
|
+
this.logger = normalizeLogger(options.logger);
|
|
133
|
+
this.cwd =
|
|
134
|
+
typeof options.cwd === "string" && options.cwd.trim()
|
|
135
|
+
? options.cwd.trim()
|
|
136
|
+
: process.cwd();
|
|
137
|
+
this.resumeSessionId = typeof options.resumeSessionId === "string" ? options.resumeSessionId.trim() : "";
|
|
138
|
+
this.sessionId = this.resumeSessionId || "";
|
|
139
|
+
this.sessionInfo = this.sessionId
|
|
140
|
+
? {
|
|
141
|
+
backend: this.backend,
|
|
142
|
+
sessionId: this.sessionId,
|
|
143
|
+
}
|
|
144
|
+
: null;
|
|
145
|
+
this.history = Array.isArray(options.initialHistory) ? [...options.initialHistory] : [];
|
|
146
|
+
this.pendingHistorySeed = this.history.length > 0;
|
|
147
|
+
this.closeRequested = false;
|
|
148
|
+
this.closed = false;
|
|
149
|
+
this.closeWaiters = new Set();
|
|
150
|
+
this.sessionMessageHandler = null;
|
|
151
|
+
this.workingStatusHandler = null;
|
|
152
|
+
this.activeReplyTarget = "";
|
|
153
|
+
this.lastReplyTarget = "";
|
|
154
|
+
this.currentTurn = null;
|
|
155
|
+
this.lastUsage = null;
|
|
156
|
+
this.lastAssistantInfo = null;
|
|
157
|
+
this.turnDeadlineMs = getBoundedEnvInt("CONDUCTOR_TURN_DEADLINE_MS", DEFAULT_TURN_DEADLINE_MS, MIN_TURN_DEADLINE_MS, MAX_TURN_DEADLINE_MS);
|
|
158
|
+
this.client = null;
|
|
159
|
+
this.sdkModulePromise = null;
|
|
160
|
+
this.bootPromise = null;
|
|
161
|
+
this.booted = false;
|
|
162
|
+
this.eventStreamAbortController = null;
|
|
163
|
+
this.eventStreamPromise = null;
|
|
164
|
+
const envConfig = loadEnvConfig(options.configFile);
|
|
165
|
+
const proxyEnv = proxyToEnv(envConfig);
|
|
166
|
+
const extraEnv = envConfig && typeof envConfig === "object" ? { ...envConfig, ...proxyEnv } : proxyEnv;
|
|
167
|
+
this.env = {
|
|
168
|
+
...extraEnv,
|
|
169
|
+
...(options.env && typeof options.env === "object" ? options.env : {}),
|
|
170
|
+
};
|
|
171
|
+
this.transport = options.transport || new OpencodeServerTransport({
|
|
172
|
+
cwd: this.cwd,
|
|
173
|
+
env: this.env,
|
|
174
|
+
logger: {
|
|
175
|
+
log: (message) => {
|
|
176
|
+
this.writeLog(message);
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
config: buildOpencodeConfig(options),
|
|
180
|
+
commandLine: options.commandLine,
|
|
181
|
+
hostname: options.serverHostname,
|
|
182
|
+
port: options.serverPort,
|
|
183
|
+
timeout: options.serverTimeoutMs,
|
|
184
|
+
});
|
|
185
|
+
this.transport.on("process_exit", (payload) => {
|
|
186
|
+
this.handleTransportExit(payload);
|
|
187
|
+
});
|
|
188
|
+
this.transport.on("process_error", (payload) => {
|
|
189
|
+
const error = createTurnError(payload?.message || "Opencode server transport error", payload || {});
|
|
190
|
+
this.handleTransportFailure(error);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
writeLog(message) {
|
|
194
|
+
emitLog(this.logger, message);
|
|
195
|
+
}
|
|
196
|
+
trace(message) {
|
|
197
|
+
this.writeLog(`[${this.backend}] [opencode-sdk] ${message}`);
|
|
198
|
+
}
|
|
199
|
+
get threadId() {
|
|
200
|
+
return this.sessionId;
|
|
201
|
+
}
|
|
202
|
+
get threadOptions() {
|
|
203
|
+
const model = typeof this.options.model === "string" && this.options.model.trim()
|
|
204
|
+
? this.options.model.trim()
|
|
205
|
+
: this.backend;
|
|
206
|
+
return { model };
|
|
207
|
+
}
|
|
208
|
+
getSnapshot() {
|
|
209
|
+
return {
|
|
210
|
+
backend: this.backend,
|
|
211
|
+
provider: OPENCODE_PROVIDER_VARIANT,
|
|
212
|
+
cwd: this.cwd,
|
|
213
|
+
sessionId: this.sessionId || undefined,
|
|
214
|
+
sessionInfo: this.getSessionInfo(),
|
|
215
|
+
useSessionFileReplyStream: this.usesSessionFileReplyStream(),
|
|
216
|
+
resumeReady: Boolean(this.sessionId),
|
|
217
|
+
manualResume: null,
|
|
218
|
+
pid: this.transport.pid || undefined,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
getSessionInfo() {
|
|
222
|
+
return this.sessionInfo ? { ...this.sessionInfo } : null;
|
|
223
|
+
}
|
|
224
|
+
async ensureSessionInfo() {
|
|
225
|
+
await this.boot();
|
|
226
|
+
return this.getSessionInfo();
|
|
227
|
+
}
|
|
228
|
+
async getSessionUsageSummary() {
|
|
229
|
+
return {
|
|
230
|
+
sessionId: this.sessionId || undefined,
|
|
231
|
+
sessionFilePath: undefined,
|
|
232
|
+
totalCostUsd: Number.isFinite(Number(this.lastAssistantInfo?.cost))
|
|
233
|
+
? Number(this.lastAssistantInfo.cost)
|
|
234
|
+
: undefined,
|
|
235
|
+
usage: this.lastUsage ? { ...this.lastUsage } : null,
|
|
236
|
+
rateLimits: null,
|
|
237
|
+
manualResume: null,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
usesSessionFileReplyStream() {
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
setSessionMessageHandler(handler) {
|
|
244
|
+
this.sessionMessageHandler = typeof handler === "function" ? handler : null;
|
|
245
|
+
}
|
|
246
|
+
setWorkingStatusHandler(handler) {
|
|
247
|
+
this.workingStatusHandler = typeof handler === "function" ? handler : null;
|
|
248
|
+
}
|
|
249
|
+
setSessionReplyTarget(replyTo) {
|
|
250
|
+
const normalizedReplyTo = typeof replyTo === "string" ? replyTo.trim() : "";
|
|
251
|
+
this.activeReplyTarget = normalizedReplyTo;
|
|
252
|
+
if (normalizedReplyTo) {
|
|
253
|
+
this.lastReplyTarget = normalizedReplyTo;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
getCurrentReplyTarget() {
|
|
257
|
+
return this.activeReplyTarget || this.lastReplyTarget || undefined;
|
|
258
|
+
}
|
|
259
|
+
async emitWorkingStatus(payload, onProgress = null) {
|
|
260
|
+
const normalized = {
|
|
261
|
+
source: OPENCODE_PROVIDER_VARIANT,
|
|
262
|
+
reply_in_progress: Boolean(payload?.reply_in_progress),
|
|
263
|
+
replyTo: payload?.replyTo || this.getCurrentReplyTarget(),
|
|
264
|
+
state: payload?.state,
|
|
265
|
+
phase: payload?.phase,
|
|
266
|
+
status_line: payload?.status_line,
|
|
267
|
+
status_done_line: payload?.status_done_line,
|
|
268
|
+
reply_preview: payload?.reply_preview,
|
|
269
|
+
thread_id: this.sessionId || undefined,
|
|
270
|
+
session_id: this.sessionId || undefined,
|
|
271
|
+
session_file_path: undefined,
|
|
272
|
+
};
|
|
273
|
+
if (typeof onProgress === "function") {
|
|
274
|
+
onProgress(normalized);
|
|
275
|
+
}
|
|
276
|
+
if (typeof this.workingStatusHandler === "function") {
|
|
277
|
+
await this.workingStatusHandler(normalized);
|
|
278
|
+
}
|
|
279
|
+
this.emit("working_status", normalized);
|
|
280
|
+
}
|
|
281
|
+
async emitAssistantMessage(text) {
|
|
282
|
+
const payload = {
|
|
283
|
+
text,
|
|
284
|
+
preserveWhitespace: true,
|
|
285
|
+
source: OPENCODE_PROVIDER_VARIANT,
|
|
286
|
+
replyTo: this.getCurrentReplyTarget(),
|
|
287
|
+
sessionId: this.sessionId || undefined,
|
|
288
|
+
sessionFilePath: undefined,
|
|
289
|
+
timestamp: new Date().toISOString(),
|
|
290
|
+
};
|
|
291
|
+
if (typeof this.sessionMessageHandler === "function") {
|
|
292
|
+
await this.sessionMessageHandler(payload);
|
|
293
|
+
}
|
|
294
|
+
this.emit("assistant_message", payload);
|
|
295
|
+
}
|
|
296
|
+
async emitTerminalWorkingStatus(currentTurn, payload, onProgress = null) {
|
|
297
|
+
if (!currentTurn || currentTurn.terminalWorkingStatusEmitted) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
currentTurn.terminalWorkingStatusEmitted = true;
|
|
301
|
+
await this.emitWorkingStatus({
|
|
302
|
+
...payload,
|
|
303
|
+
reply_in_progress: false,
|
|
304
|
+
}, onProgress);
|
|
305
|
+
}
|
|
306
|
+
createSessionClosedError() {
|
|
307
|
+
const error = new Error("Opencode session closed");
|
|
308
|
+
error.reason = "session_closed";
|
|
309
|
+
return error;
|
|
310
|
+
}
|
|
311
|
+
createTurnTimeoutError(timeoutMs) {
|
|
312
|
+
const seconds = Math.max(1, Math.round(timeoutMs / 1000));
|
|
313
|
+
const error = new Error(`Turn exceeded hard deadline (${seconds}s)`);
|
|
314
|
+
error.reason = "turn_timeout";
|
|
315
|
+
error.timeoutMs = timeoutMs;
|
|
316
|
+
return error;
|
|
317
|
+
}
|
|
318
|
+
createCloseGuard(onClose) {
|
|
319
|
+
if (this.closeRequested) {
|
|
320
|
+
return {
|
|
321
|
+
promise: Promise.reject(this.createSessionClosedError()),
|
|
322
|
+
cleanup: () => { },
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
let waiter = null;
|
|
326
|
+
const promise = new Promise((_, reject) => {
|
|
327
|
+
waiter = () => {
|
|
328
|
+
try {
|
|
329
|
+
onClose?.();
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
// best effort
|
|
333
|
+
}
|
|
334
|
+
reject(this.createSessionClosedError());
|
|
335
|
+
};
|
|
336
|
+
this.closeWaiters.add(waiter);
|
|
337
|
+
});
|
|
338
|
+
return {
|
|
339
|
+
promise,
|
|
340
|
+
cleanup: () => {
|
|
341
|
+
if (waiter) {
|
|
342
|
+
this.closeWaiters.delete(waiter);
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
createTurnTimeoutGuard(onTimeout) {
|
|
348
|
+
if (!Number.isFinite(this.turnDeadlineMs) || this.turnDeadlineMs <= 0) {
|
|
349
|
+
return {
|
|
350
|
+
promise: waitForever(),
|
|
351
|
+
cleanup: () => { },
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
let timer = null;
|
|
355
|
+
const promise = new Promise((_, reject) => {
|
|
356
|
+
timer = setTimeout(() => {
|
|
357
|
+
try {
|
|
358
|
+
onTimeout?.();
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// best effort
|
|
362
|
+
}
|
|
363
|
+
reject(this.createTurnTimeoutError(this.turnDeadlineMs));
|
|
364
|
+
}, this.turnDeadlineMs);
|
|
365
|
+
if (typeof timer.unref === "function") {
|
|
366
|
+
timer.unref();
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
return {
|
|
370
|
+
promise,
|
|
371
|
+
cleanup: () => {
|
|
372
|
+
if (timer) {
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
flushCloseWaiters() {
|
|
379
|
+
if (this.closeWaiters.size === 0) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
for (const waiter of this.closeWaiters) {
|
|
383
|
+
try {
|
|
384
|
+
waiter();
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
// best effort
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
this.closeWaiters.clear();
|
|
391
|
+
}
|
|
392
|
+
buildPrompt(promptText, { useInitialImages = false } = {}) {
|
|
393
|
+
let effectivePrompt = String(promptText || "").trim();
|
|
394
|
+
if (!effectivePrompt) {
|
|
395
|
+
return "";
|
|
396
|
+
}
|
|
397
|
+
if (this.pendingHistorySeed) {
|
|
398
|
+
const historyText = this.history
|
|
399
|
+
.map((item) => {
|
|
400
|
+
const role = String(item?.role || "").toLowerCase() === "assistant" ? "Assistant" : "User";
|
|
401
|
+
return `${role}: ${String(item?.content || "").trim()}`;
|
|
402
|
+
})
|
|
403
|
+
.filter(Boolean)
|
|
404
|
+
.join("\n\n");
|
|
405
|
+
if (historyText) {
|
|
406
|
+
effectivePrompt = [
|
|
407
|
+
"Continue the existing conversation with this history.",
|
|
408
|
+
"",
|
|
409
|
+
historyText,
|
|
410
|
+
"",
|
|
411
|
+
`User: ${effectivePrompt}`,
|
|
412
|
+
].join("\n");
|
|
413
|
+
}
|
|
414
|
+
this.pendingHistorySeed = false;
|
|
415
|
+
}
|
|
416
|
+
const images = Array.isArray(this.options.initialImages) ? this.options.initialImages : [];
|
|
417
|
+
if (useInitialImages && images.length > 0) {
|
|
418
|
+
const imageContext = images.map((item, idx) => `${idx + 1}. ${item}`).join("\n");
|
|
419
|
+
effectivePrompt = `${effectivePrompt}\n\nAttached image files:\n${imageContext}`;
|
|
420
|
+
}
|
|
421
|
+
return effectivePrompt;
|
|
422
|
+
}
|
|
423
|
+
async getSdkModule() {
|
|
424
|
+
if (this.sdkModulePromise) {
|
|
425
|
+
return this.sdkModulePromise;
|
|
426
|
+
}
|
|
427
|
+
if (this.options.sdkModule && typeof this.options.sdkModule === "object") {
|
|
428
|
+
this.sdkModulePromise = Promise.resolve(this.options.sdkModule);
|
|
429
|
+
return this.sdkModulePromise;
|
|
430
|
+
}
|
|
431
|
+
this.sdkModulePromise = import("@opencode-ai/sdk/v2/client");
|
|
432
|
+
return this.sdkModulePromise;
|
|
433
|
+
}
|
|
434
|
+
async boot() {
|
|
435
|
+
if (this.booted) {
|
|
436
|
+
await this.startEventStream();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (this.bootPromise) {
|
|
440
|
+
return this.bootPromise;
|
|
441
|
+
}
|
|
442
|
+
this.bootPromise = this.bootInternal();
|
|
443
|
+
try {
|
|
444
|
+
await this.bootPromise;
|
|
445
|
+
this.booted = true;
|
|
446
|
+
}
|
|
447
|
+
finally {
|
|
448
|
+
this.bootPromise = null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
async bootInternal() {
|
|
452
|
+
const sdkModule = await this.getSdkModule();
|
|
453
|
+
if (!sdkModule || typeof sdkModule.createOpencodeClient !== "function") {
|
|
454
|
+
throw new Error("Opencode SDK client is unavailable");
|
|
455
|
+
}
|
|
456
|
+
const { url } = await this.transport.boot();
|
|
457
|
+
this.client = sdkModule.createOpencodeClient({
|
|
458
|
+
baseUrl: url,
|
|
459
|
+
directory: this.cwd,
|
|
460
|
+
});
|
|
461
|
+
await this.startEventStream();
|
|
462
|
+
if (this.resumeSessionId) {
|
|
463
|
+
const session = await this.requestOrThrow(this.client.session.get({ sessionID: this.resumeSessionId }, { throwOnError: true, responseStyle: "data" }));
|
|
464
|
+
this.applySessionInfo(session);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const session = await this.requestOrThrow(this.client.session.create({}, { throwOnError: true, responseStyle: "data" }));
|
|
468
|
+
this.applySessionInfo(session);
|
|
469
|
+
}
|
|
470
|
+
async startEventStream() {
|
|
471
|
+
if (this.eventStreamPromise) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (!this.client?.event || typeof this.client.event.subscribe !== "function") {
|
|
475
|
+
throw new Error("Opencode event subscription is unavailable");
|
|
476
|
+
}
|
|
477
|
+
this.eventStreamAbortController = new AbortController();
|
|
478
|
+
const controller = this.eventStreamAbortController;
|
|
479
|
+
let streamResult;
|
|
480
|
+
try {
|
|
481
|
+
streamResult = await this.client.event.subscribe({}, {
|
|
482
|
+
signal: controller.signal,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
if (this.eventStreamAbortController === controller) {
|
|
487
|
+
this.eventStreamAbortController = null;
|
|
488
|
+
}
|
|
489
|
+
throw error;
|
|
490
|
+
}
|
|
491
|
+
let streamPromise = null;
|
|
492
|
+
streamPromise = (async () => {
|
|
493
|
+
try {
|
|
494
|
+
for await (const event of streamResult.stream) {
|
|
495
|
+
await this.handleOpencodeEvent(event);
|
|
496
|
+
}
|
|
497
|
+
if (!this.closeRequested && !controller.signal.aborted) {
|
|
498
|
+
throw createTurnError("Opencode event stream ended unexpectedly", {
|
|
499
|
+
reason: "event_stream_ended",
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
if (controller.signal.aborted && (isAbortError(error) || this.closeRequested)) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
this.handleTransportFailure(error);
|
|
508
|
+
}
|
|
509
|
+
finally {
|
|
510
|
+
if (this.eventStreamPromise === streamPromise) {
|
|
511
|
+
this.eventStreamPromise = null;
|
|
512
|
+
}
|
|
513
|
+
if (this.eventStreamAbortController === controller) {
|
|
514
|
+
this.eventStreamAbortController = null;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
})();
|
|
518
|
+
this.eventStreamPromise = streamPromise;
|
|
519
|
+
streamPromise.catch(() => {
|
|
520
|
+
// handled via handleTransportFailure
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
async requestOrThrow(promise) {
|
|
524
|
+
try {
|
|
525
|
+
return await promise;
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
this.maybeEmitAuthRequired(error);
|
|
529
|
+
throw error;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
applySessionInfo(session) {
|
|
533
|
+
const normalizedSessionId = typeof session?.id === "string" ? session.id.trim() : "";
|
|
534
|
+
if (!normalizedSessionId) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const changed = this.sessionId !== normalizedSessionId;
|
|
538
|
+
this.sessionId = normalizedSessionId;
|
|
539
|
+
this.sessionInfo = {
|
|
540
|
+
backend: this.backend,
|
|
541
|
+
sessionId: normalizedSessionId,
|
|
542
|
+
};
|
|
543
|
+
if (changed) {
|
|
544
|
+
this.trace(`session ready id=${normalizedSessionId}`);
|
|
545
|
+
this.emit("session", this.getSessionInfo());
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
createAssistantMessageState(currentTurn, messageId) {
|
|
549
|
+
const normalizedMessageId = typeof messageId === "string" ? messageId.trim() : "";
|
|
550
|
+
if (!normalizedMessageId) {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
if (!currentTurn.assistantMessages.has(normalizedMessageId)) {
|
|
554
|
+
currentTurn.assistantMessages.set(normalizedMessageId, {
|
|
555
|
+
id: normalizedMessageId,
|
|
556
|
+
partOrder: [],
|
|
557
|
+
parts: new Map(),
|
|
558
|
+
emitted: false,
|
|
559
|
+
info: null,
|
|
560
|
+
});
|
|
561
|
+
currentTurn.assistantMessageOrder.push(normalizedMessageId);
|
|
562
|
+
}
|
|
563
|
+
return currentTurn.assistantMessages.get(normalizedMessageId);
|
|
564
|
+
}
|
|
565
|
+
bufferPendingMessageEvent(currentTurn, messageId, payload) {
|
|
566
|
+
const normalizedMessageId = typeof messageId === "string" ? messageId.trim() : "";
|
|
567
|
+
if (!normalizedMessageId) {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const existing = currentTurn.pendingMessageEvents.get(normalizedMessageId) || [];
|
|
571
|
+
existing.push(payload);
|
|
572
|
+
currentTurn.pendingMessageEvents.set(normalizedMessageId, existing);
|
|
573
|
+
}
|
|
574
|
+
clearPendingMessageEvents(currentTurn, messageId) {
|
|
575
|
+
const normalizedMessageId = typeof messageId === "string" ? messageId.trim() : "";
|
|
576
|
+
if (!normalizedMessageId) {
|
|
577
|
+
return [];
|
|
578
|
+
}
|
|
579
|
+
const events = currentTurn.pendingMessageEvents.get(normalizedMessageId) || [];
|
|
580
|
+
currentTurn.pendingMessageEvents.delete(normalizedMessageId);
|
|
581
|
+
return events;
|
|
582
|
+
}
|
|
583
|
+
setPartState(messageState, part) {
|
|
584
|
+
if (!messageState || !part?.id) {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
const partId = String(part.id).trim();
|
|
588
|
+
if (!partId) {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
if (!messageState.partOrder.includes(partId)) {
|
|
592
|
+
messageState.partOrder.push(partId);
|
|
593
|
+
}
|
|
594
|
+
const existing = messageState.parts.get(partId) || {
|
|
595
|
+
id: partId,
|
|
596
|
+
type: part.type,
|
|
597
|
+
value: "",
|
|
598
|
+
tool: "",
|
|
599
|
+
toolState: null,
|
|
600
|
+
};
|
|
601
|
+
if (part.type === "text" || part.type === "reasoning") {
|
|
602
|
+
existing.value = normalizeText(part.text);
|
|
603
|
+
}
|
|
604
|
+
if (part.type === "tool") {
|
|
605
|
+
existing.tool = normalizeText(part.tool);
|
|
606
|
+
existing.toolState = part.state || null;
|
|
607
|
+
}
|
|
608
|
+
existing.type = part.type;
|
|
609
|
+
messageState.parts.set(partId, existing);
|
|
610
|
+
return existing;
|
|
611
|
+
}
|
|
612
|
+
applyPartDelta(messageState, partId, delta, field) {
|
|
613
|
+
if (!messageState) {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
const normalizedPartId = typeof partId === "string" ? partId.trim() : "";
|
|
617
|
+
if (!normalizedPartId) {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
const existing = messageState.parts.get(normalizedPartId);
|
|
621
|
+
if (!existing) {
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
if (field === "text" || field === "delta" || existing.type === "text" || existing.type === "reasoning") {
|
|
625
|
+
existing.value = `${normalizeText(existing.value)}${normalizeText(delta)}`;
|
|
626
|
+
messageState.parts.set(normalizedPartId, existing);
|
|
627
|
+
return existing;
|
|
628
|
+
}
|
|
629
|
+
return existing;
|
|
630
|
+
}
|
|
631
|
+
async processAssistantPartUpdated(currentTurn, part, onProgress) {
|
|
632
|
+
const messageState = this.createAssistantMessageState(currentTurn, part.messageID);
|
|
633
|
+
if (!messageState) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (currentTurn.activeAssistantMessageId &&
|
|
637
|
+
currentTurn.activeAssistantMessageId !== messageState.id) {
|
|
638
|
+
await this.finalizeAssistantMessage(currentTurn, currentTurn.activeAssistantMessageId);
|
|
639
|
+
}
|
|
640
|
+
currentTurn.activeAssistantMessageId = messageState.id;
|
|
641
|
+
const partState = this.setPartState(messageState, part);
|
|
642
|
+
if (part.type === "text") {
|
|
643
|
+
await this.emitWorkingStatus({
|
|
644
|
+
phase: "message_aggregation",
|
|
645
|
+
reply_in_progress: true,
|
|
646
|
+
status_line: statusLineForPhase("message_aggregation"),
|
|
647
|
+
reply_preview: sanitizeSummary(this.collectAssistantText(messageState), 120),
|
|
648
|
+
}, onProgress);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (part.type === "reasoning") {
|
|
652
|
+
await this.emitWorkingStatus({
|
|
653
|
+
phase: "reasoning",
|
|
654
|
+
reply_in_progress: true,
|
|
655
|
+
status_line: statusLineForPhase("reasoning"),
|
|
656
|
+
}, onProgress);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (part.type === "tool") {
|
|
660
|
+
const phase = toolPhaseForName(part.tool);
|
|
661
|
+
const toolStatus = part.state?.status === "completed"
|
|
662
|
+
? part.state?.title || part.state?.output || ""
|
|
663
|
+
: part.state?.title || "";
|
|
664
|
+
await this.emitWorkingStatus({
|
|
665
|
+
phase,
|
|
666
|
+
reply_in_progress: true,
|
|
667
|
+
status_line: sanitizeSummary(toolStatus) || statusLineForPhase(phase, part.tool),
|
|
668
|
+
}, onProgress);
|
|
669
|
+
if (part.state?.status === "completed" && currentTurn.activeAssistantMessageId !== messageState.id) {
|
|
670
|
+
await this.finalizeAssistantMessage(currentTurn, currentTurn.activeAssistantMessageId);
|
|
671
|
+
}
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (part.type === "compaction") {
|
|
675
|
+
await this.emitWorkingStatus({
|
|
676
|
+
phase: "context_compaction",
|
|
677
|
+
reply_in_progress: true,
|
|
678
|
+
status_line: statusLineForPhase("context_compaction"),
|
|
679
|
+
}, onProgress);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (part.type === "step-start") {
|
|
683
|
+
await this.emitWorkingStatus({
|
|
684
|
+
phase: "task_progress",
|
|
685
|
+
reply_in_progress: true,
|
|
686
|
+
status_line: statusLineForPhase("task_progress"),
|
|
687
|
+
}, onProgress);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (part.type === "step-finish") {
|
|
691
|
+
currentTurn.lastAssistantInfo = currentTurn.lastAssistantInfo || {};
|
|
692
|
+
if (partState && part.tokens) {
|
|
693
|
+
currentTurn.lastAssistantInfo = {
|
|
694
|
+
...currentTurn.lastAssistantInfo,
|
|
695
|
+
tokens: part.tokens,
|
|
696
|
+
cost: part.cost,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
async processAssistantPartDelta(currentTurn, properties, onProgress) {
|
|
702
|
+
const messageState = this.createAssistantMessageState(currentTurn, properties.messageID);
|
|
703
|
+
const partState = this.applyPartDelta(messageState, properties.partID, properties.delta, properties.field);
|
|
704
|
+
if (partState?.type === "text") {
|
|
705
|
+
await this.emitWorkingStatus({
|
|
706
|
+
phase: "message_aggregation",
|
|
707
|
+
reply_in_progress: true,
|
|
708
|
+
status_line: statusLineForPhase("message_aggregation"),
|
|
709
|
+
reply_preview: sanitizeSummary(this.collectAssistantText(messageState), 120),
|
|
710
|
+
}, onProgress);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (partState?.type === "reasoning") {
|
|
714
|
+
await this.emitWorkingStatus({
|
|
715
|
+
phase: "reasoning",
|
|
716
|
+
reply_in_progress: true,
|
|
717
|
+
status_line: statusLineForPhase("reasoning"),
|
|
718
|
+
}, onProgress);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
collectAssistantText(messageState) {
|
|
722
|
+
if (!messageState) {
|
|
723
|
+
return "";
|
|
724
|
+
}
|
|
725
|
+
return messageState.partOrder
|
|
726
|
+
.map((partId) => messageState.parts.get(partId))
|
|
727
|
+
.filter((part) => part?.type === "text")
|
|
728
|
+
.map((part) => normalizeText(part.value))
|
|
729
|
+
.join("");
|
|
730
|
+
}
|
|
731
|
+
async finalizeAssistantMessage(currentTurn, messageId = "") {
|
|
732
|
+
if (!currentTurn) {
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
const normalizedMessageId = typeof messageId === "string" ? messageId.trim() : "";
|
|
736
|
+
const targetId = normalizedMessageId || currentTurn.activeAssistantMessageId || "";
|
|
737
|
+
if (!targetId) {
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
const messageState = currentTurn.assistantMessages.get(targetId);
|
|
741
|
+
if (!messageState || messageState.emitted) {
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
const text = this.collectAssistantText(messageState);
|
|
745
|
+
messageState.emitted = true;
|
|
746
|
+
currentTurn.assistantMessages.set(targetId, messageState);
|
|
747
|
+
if (currentTurn.activeAssistantMessageId === targetId) {
|
|
748
|
+
currentTurn.activeAssistantMessageId = "";
|
|
749
|
+
}
|
|
750
|
+
if (!text) {
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
currentTurn.fullText += text;
|
|
754
|
+
await this.emitAssistantMessage(text);
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
757
|
+
buildUsageFromAssistantInfo(info) {
|
|
758
|
+
if (!info?.tokens || typeof info.tokens !== "object") {
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
const tokens = info.tokens;
|
|
762
|
+
return {
|
|
763
|
+
total: Number.isFinite(Number(tokens.total)) ? Number(tokens.total) : undefined,
|
|
764
|
+
input: Number.isFinite(Number(tokens.input)) ? Number(tokens.input) : undefined,
|
|
765
|
+
output: Number.isFinite(Number(tokens.output)) ? Number(tokens.output) : undefined,
|
|
766
|
+
reasoning: Number.isFinite(Number(tokens.reasoning)) ? Number(tokens.reasoning) : undefined,
|
|
767
|
+
cache: {
|
|
768
|
+
read: Number.isFinite(Number(tokens.cache?.read)) ? Number(tokens.cache.read) : undefined,
|
|
769
|
+
write: Number.isFinite(Number(tokens.cache?.write)) ? Number(tokens.cache.write) : undefined,
|
|
770
|
+
},
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
maybeEmitAuthRequired(error) {
|
|
774
|
+
const providerId = error?.data?.providerID;
|
|
775
|
+
const message = extractErrorMessage(error);
|
|
776
|
+
const normalized = `${String(providerId || "")} ${message}`.toLowerCase();
|
|
777
|
+
if (!normalized.includes("auth") && !normalized.includes("login") && !normalized.includes("providerautherror")) {
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
this.emit("auth_required", {
|
|
781
|
+
reason: "login_required",
|
|
782
|
+
message,
|
|
783
|
+
providerId: providerId || undefined,
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
async handlePermissionRequest(event, currentTurn) {
|
|
787
|
+
const permission = normalizeText(event?.properties?.permission);
|
|
788
|
+
const patterns = Array.isArray(event?.properties?.patterns)
|
|
789
|
+
? event.properties.patterns.filter((item) => typeof item === "string" && item.trim())
|
|
790
|
+
: [];
|
|
791
|
+
const details = [permission, ...patterns].filter(Boolean).join(" ");
|
|
792
|
+
await this.emitWorkingStatus({
|
|
793
|
+
phase: "tool_call",
|
|
794
|
+
reply_in_progress: true,
|
|
795
|
+
status_line: sanitizeSummary(details) || statusLineForPhase("tool_call"),
|
|
796
|
+
}, currentTurn?.onProgress);
|
|
797
|
+
const error = createTurnError(details ? `Opencode requested permission: ${details}` : "Opencode requested permission approval", {
|
|
798
|
+
reason: "permission_required",
|
|
799
|
+
permission,
|
|
800
|
+
patterns,
|
|
801
|
+
});
|
|
802
|
+
await this.interruptCurrentTurn();
|
|
803
|
+
this.handleTransportFailure(error);
|
|
804
|
+
}
|
|
805
|
+
async handleQuestionRequest(event, currentTurn) {
|
|
806
|
+
const questions = Array.isArray(event?.properties?.questions) ? event.properties.questions : [];
|
|
807
|
+
const summary = questions
|
|
808
|
+
.map((item) => normalizeText(item?.header || item?.question))
|
|
809
|
+
.filter(Boolean)
|
|
810
|
+
.join(" / ");
|
|
811
|
+
await this.emitWorkingStatus({
|
|
812
|
+
phase: "tool_call",
|
|
813
|
+
reply_in_progress: true,
|
|
814
|
+
status_line: sanitizeSummary(summary) || statusLineForPhase("tool_call"),
|
|
815
|
+
}, currentTurn?.onProgress);
|
|
816
|
+
const error = createTurnError(summary ? `Opencode requested user input: ${summary}` : "Opencode requested user input", {
|
|
817
|
+
reason: "question_required",
|
|
818
|
+
});
|
|
819
|
+
await this.interruptCurrentTurn();
|
|
820
|
+
this.handleTransportFailure(error);
|
|
821
|
+
}
|
|
822
|
+
async handleOpencodeEvent(event) {
|
|
823
|
+
if (!event || typeof event !== "object") {
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const currentTurn = this.currentTurn;
|
|
827
|
+
const type = normalizeText(event.type);
|
|
828
|
+
const properties = isTruthyPlainObject(event.properties) ? event.properties : {};
|
|
829
|
+
switch (type) {
|
|
830
|
+
case "session.created":
|
|
831
|
+
case "session.updated":
|
|
832
|
+
case "session.deleted":
|
|
833
|
+
this.applySessionInfo(properties.info);
|
|
834
|
+
return;
|
|
835
|
+
case "session.compacted":
|
|
836
|
+
if (!currentTurn || properties.sessionID !== this.sessionId) {
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
await this.emitWorkingStatus({
|
|
840
|
+
phase: "context_compaction",
|
|
841
|
+
reply_in_progress: true,
|
|
842
|
+
status_line: statusLineForPhase("context_compaction"),
|
|
843
|
+
}, currentTurn.onProgress);
|
|
844
|
+
return;
|
|
845
|
+
case "session.status":
|
|
846
|
+
if (!currentTurn || properties.sessionID !== this.sessionId) {
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (properties.status?.type === "retry") {
|
|
850
|
+
await this.emitWorkingStatus({
|
|
851
|
+
phase: "task_progress",
|
|
852
|
+
reply_in_progress: true,
|
|
853
|
+
status_line: sanitizeSummary(properties.status.message) || statusLineForPhase("task_progress"),
|
|
854
|
+
}, currentTurn.onProgress);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if (properties.status?.type === "busy") {
|
|
858
|
+
await this.emitWorkingStatus({
|
|
859
|
+
phase: "turn_started",
|
|
860
|
+
reply_in_progress: true,
|
|
861
|
+
status_line: statusLineForPhase("turn_started"),
|
|
862
|
+
}, currentTurn.onProgress);
|
|
863
|
+
}
|
|
864
|
+
return;
|
|
865
|
+
case "session.idle":
|
|
866
|
+
if (!currentTurn || properties.sessionID !== this.sessionId) {
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
await this.finalizeAssistantMessage(currentTurn, currentTurn.activeAssistantMessageId);
|
|
870
|
+
if (currentTurn.settled) {
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
currentTurn.settled = true;
|
|
874
|
+
this.activeReplyTarget = "";
|
|
875
|
+
this.lastUsage = this.buildUsageFromAssistantInfo(currentTurn.lastAssistantInfo);
|
|
876
|
+
this.lastAssistantInfo = currentTurn.lastAssistantInfo || null;
|
|
877
|
+
await this.emitTerminalWorkingStatus(currentTurn, {
|
|
878
|
+
phase: "turn_completed",
|
|
879
|
+
status_done_line: "opencode finished",
|
|
880
|
+
}, currentTurn.onProgress);
|
|
881
|
+
currentTurn.resolve({
|
|
882
|
+
usage: this.lastUsage ? { ...this.lastUsage } : null,
|
|
883
|
+
assistantInfo: currentTurn.lastAssistantInfo || null,
|
|
884
|
+
});
|
|
885
|
+
return;
|
|
886
|
+
case "session.error": {
|
|
887
|
+
const sessionMatches = !properties.sessionID || properties.sessionID === this.sessionId;
|
|
888
|
+
if (!sessionMatches) {
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
const error = properties.error || {};
|
|
892
|
+
this.maybeEmitAuthRequired(error);
|
|
893
|
+
const errorMessage = error?.data?.message || error?.message || "Opencode turn failed";
|
|
894
|
+
this.handleTransportFailure(createTurnError(String(errorMessage), {
|
|
895
|
+
reason: "turn_failed",
|
|
896
|
+
error,
|
|
897
|
+
}));
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
case "permission.asked":
|
|
901
|
+
if (!currentTurn || properties.sessionID !== this.sessionId) {
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
await this.handlePermissionRequest(event, currentTurn);
|
|
905
|
+
return;
|
|
906
|
+
case "question.asked":
|
|
907
|
+
if (!currentTurn || properties.sessionID !== this.sessionId) {
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
await this.handleQuestionRequest(event, currentTurn);
|
|
911
|
+
return;
|
|
912
|
+
case "todo.updated":
|
|
913
|
+
if (!currentTurn || properties.sessionID !== this.sessionId) {
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
await this.emitWorkingStatus({
|
|
917
|
+
phase: "planning",
|
|
918
|
+
reply_in_progress: true,
|
|
919
|
+
status_line: statusLineForPhase("planning"),
|
|
920
|
+
}, currentTurn.onProgress);
|
|
921
|
+
return;
|
|
922
|
+
case "message.updated": {
|
|
923
|
+
if (!currentTurn || properties.info?.sessionID !== this.sessionId) {
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
const info = properties.info;
|
|
927
|
+
currentTurn.items.push(event);
|
|
928
|
+
const messageId = typeof info.id === "string" ? info.id.trim() : "";
|
|
929
|
+
if (messageId) {
|
|
930
|
+
currentTurn.messageRoles.set(messageId, info.role);
|
|
931
|
+
}
|
|
932
|
+
if (info.role !== "assistant") {
|
|
933
|
+
this.clearPendingMessageEvents(currentTurn, messageId);
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
const messageState = this.createAssistantMessageState(currentTurn, messageId);
|
|
937
|
+
if (!messageState) {
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (currentTurn.activeAssistantMessageId &&
|
|
941
|
+
currentTurn.activeAssistantMessageId !== messageState.id) {
|
|
942
|
+
await this.finalizeAssistantMessage(currentTurn, currentTurn.activeAssistantMessageId);
|
|
943
|
+
}
|
|
944
|
+
currentTurn.activeAssistantMessageId = messageState.id;
|
|
945
|
+
messageState.info = info;
|
|
946
|
+
currentTurn.lastAssistantInfo = info;
|
|
947
|
+
currentTurn.assistantMessages.set(messageState.id, messageState);
|
|
948
|
+
for (const pendingEvent of this.clearPendingMessageEvents(currentTurn, messageState.id)) {
|
|
949
|
+
if (pendingEvent?.type === "part.updated") {
|
|
950
|
+
await this.processAssistantPartUpdated(currentTurn, pendingEvent.part, currentTurn.onProgress);
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
if (pendingEvent?.type === "part.delta") {
|
|
954
|
+
await this.processAssistantPartDelta(currentTurn, pendingEvent.properties, currentTurn.onProgress);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
await this.emitWorkingStatus({
|
|
958
|
+
phase: "message_aggregation",
|
|
959
|
+
reply_in_progress: true,
|
|
960
|
+
status_line: statusLineForPhase("message_aggregation"),
|
|
961
|
+
}, currentTurn.onProgress);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
case "message.part.updated": {
|
|
965
|
+
if (!currentTurn || properties.part?.sessionID !== this.sessionId) {
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
currentTurn.items.push(event);
|
|
969
|
+
const part = properties.part;
|
|
970
|
+
const messageRole = currentTurn.messageRoles.get(part.messageID);
|
|
971
|
+
if (messageRole && messageRole !== "assistant") {
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (!messageRole) {
|
|
975
|
+
this.bufferPendingMessageEvent(currentTurn, part.messageID, {
|
|
976
|
+
type: "part.updated",
|
|
977
|
+
part,
|
|
978
|
+
});
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
await this.processAssistantPartUpdated(currentTurn, part, currentTurn.onProgress);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
case "message.part.delta": {
|
|
985
|
+
if (!currentTurn || properties.sessionID !== this.sessionId) {
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
currentTurn.items.push(event);
|
|
989
|
+
const messageRole = currentTurn.messageRoles.get(properties.messageID);
|
|
990
|
+
if (messageRole && messageRole !== "assistant") {
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
if (!messageRole) {
|
|
994
|
+
this.bufferPendingMessageEvent(currentTurn, properties.messageID, {
|
|
995
|
+
type: "part.delta",
|
|
996
|
+
properties: { ...properties },
|
|
997
|
+
});
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
await this.processAssistantPartDelta(currentTurn, properties, currentTurn.onProgress);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
default:
|
|
1004
|
+
if (currentTurn) {
|
|
1005
|
+
currentTurn.items.push(event);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
handleTransportFailure(error) {
|
|
1010
|
+
const currentTurn = this.currentTurn;
|
|
1011
|
+
if (!currentTurn || currentTurn.settled) {
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
currentTurn.settled = true;
|
|
1015
|
+
currentTurn.reject(error);
|
|
1016
|
+
}
|
|
1017
|
+
handleTransportExit(payload) {
|
|
1018
|
+
const exitError = createTurnError("Opencode server exited", {
|
|
1019
|
+
reason: this.closeRequested ? "session_closed" : "transport_exited",
|
|
1020
|
+
code: payload?.code,
|
|
1021
|
+
signal: payload?.signal,
|
|
1022
|
+
stderr: payload?.stderr,
|
|
1023
|
+
});
|
|
1024
|
+
this.closed = true;
|
|
1025
|
+
this.closeRequested = true;
|
|
1026
|
+
this.flushCloseWaiters();
|
|
1027
|
+
this.handleTransportFailure(exitError);
|
|
1028
|
+
this.emit("process.exited", {
|
|
1029
|
+
pid: this.transport.pid || null,
|
|
1030
|
+
code: payload?.code,
|
|
1031
|
+
signal: payload?.signal,
|
|
1032
|
+
stderr: payload?.stderr,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
async interruptCurrentTurn() {
|
|
1036
|
+
const currentTurn = this.currentTurn;
|
|
1037
|
+
if (!currentTurn || !this.client?.session || !this.sessionId) {
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
try {
|
|
1041
|
+
currentTurn.abortController?.abort?.();
|
|
1042
|
+
}
|
|
1043
|
+
catch {
|
|
1044
|
+
// best effort
|
|
1045
|
+
}
|
|
1046
|
+
try {
|
|
1047
|
+
await this.client.session.abort({ sessionID: this.sessionId }, { throwOnError: true, responseStyle: "data" });
|
|
1048
|
+
}
|
|
1049
|
+
catch {
|
|
1050
|
+
// best effort
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
async runTurn(promptText, { useInitialImages = false, onProgress = null } = {}) {
|
|
1054
|
+
if (this.closeRequested) {
|
|
1055
|
+
throw this.createSessionClosedError();
|
|
1056
|
+
}
|
|
1057
|
+
const effectivePrompt = this.buildPrompt(promptText, { useInitialImages });
|
|
1058
|
+
if (!effectivePrompt) {
|
|
1059
|
+
return buildEmptyTurnResult();
|
|
1060
|
+
}
|
|
1061
|
+
await this.boot();
|
|
1062
|
+
if (this.currentTurn) {
|
|
1063
|
+
throw createTurnError("Opencode turn already running", {
|
|
1064
|
+
reason: "turn_already_running",
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
if (!this.client?.session || typeof this.client.session.promptAsync !== "function") {
|
|
1068
|
+
throw new Error("Opencode session client is unavailable");
|
|
1069
|
+
}
|
|
1070
|
+
this.history.push({ role: "user", content: promptText });
|
|
1071
|
+
const abortController = new AbortController();
|
|
1072
|
+
const currentTurn = {
|
|
1073
|
+
abortController,
|
|
1074
|
+
assistantMessages: new Map(),
|
|
1075
|
+
assistantMessageOrder: [],
|
|
1076
|
+
activeAssistantMessageId: "",
|
|
1077
|
+
messageRoles: new Map(),
|
|
1078
|
+
pendingMessageEvents: new Map(),
|
|
1079
|
+
fullText: "",
|
|
1080
|
+
items: [],
|
|
1081
|
+
lastAssistantInfo: null,
|
|
1082
|
+
onProgress,
|
|
1083
|
+
resolve: null,
|
|
1084
|
+
reject: null,
|
|
1085
|
+
settled: false,
|
|
1086
|
+
terminalWorkingStatusEmitted: false,
|
|
1087
|
+
};
|
|
1088
|
+
const completionPromise = new Promise((resolve, reject) => {
|
|
1089
|
+
currentTurn.resolve = resolve;
|
|
1090
|
+
currentTurn.reject = reject;
|
|
1091
|
+
});
|
|
1092
|
+
this.currentTurn = currentTurn;
|
|
1093
|
+
const closeGuard = this.createCloseGuard(() => {
|
|
1094
|
+
abortController.abort();
|
|
1095
|
+
void this.interruptCurrentTurn();
|
|
1096
|
+
});
|
|
1097
|
+
const turnTimeoutGuard = this.createTurnTimeoutGuard(() => {
|
|
1098
|
+
abortController.abort();
|
|
1099
|
+
void this.interruptCurrentTurn();
|
|
1100
|
+
});
|
|
1101
|
+
try {
|
|
1102
|
+
await this.emitWorkingStatus({
|
|
1103
|
+
phase: "turn_started",
|
|
1104
|
+
reply_in_progress: true,
|
|
1105
|
+
status_line: "opencode is working",
|
|
1106
|
+
}, onProgress);
|
|
1107
|
+
const completion = await Promise.race([
|
|
1108
|
+
(async () => {
|
|
1109
|
+
await this.requestOrThrow(this.client.session.promptAsync({
|
|
1110
|
+
sessionID: this.sessionId,
|
|
1111
|
+
agent: typeof this.options.agent === "string" && this.options.agent.trim()
|
|
1112
|
+
? this.options.agent.trim()
|
|
1113
|
+
: undefined,
|
|
1114
|
+
system: typeof this.options.systemPrompt === "string" && this.options.systemPrompt.trim()
|
|
1115
|
+
? this.options.systemPrompt.trim()
|
|
1116
|
+
: typeof this.options.system === "string" && this.options.system.trim()
|
|
1117
|
+
? this.options.system.trim()
|
|
1118
|
+
: undefined,
|
|
1119
|
+
tools: isTruthyPlainObject(this.options.tools) ? { ...this.options.tools } : undefined,
|
|
1120
|
+
variant: typeof this.options.variant === "string" && this.options.variant.trim()
|
|
1121
|
+
? this.options.variant.trim()
|
|
1122
|
+
: undefined,
|
|
1123
|
+
parts: [
|
|
1124
|
+
{
|
|
1125
|
+
type: "text",
|
|
1126
|
+
text: effectivePrompt,
|
|
1127
|
+
},
|
|
1128
|
+
],
|
|
1129
|
+
}, {
|
|
1130
|
+
throwOnError: true,
|
|
1131
|
+
responseStyle: "data",
|
|
1132
|
+
signal: abortController.signal,
|
|
1133
|
+
}));
|
|
1134
|
+
return await completionPromise;
|
|
1135
|
+
})(),
|
|
1136
|
+
closeGuard.promise,
|
|
1137
|
+
turnTimeoutGuard.promise,
|
|
1138
|
+
]);
|
|
1139
|
+
await this.finalizeAssistantMessage(currentTurn, currentTurn.activeAssistantMessageId);
|
|
1140
|
+
if (currentTurn.fullText) {
|
|
1141
|
+
this.history.push({ role: "assistant", content: currentTurn.fullText });
|
|
1142
|
+
}
|
|
1143
|
+
this.activeReplyTarget = "";
|
|
1144
|
+
return {
|
|
1145
|
+
text: currentTurn.fullText,
|
|
1146
|
+
usage: completion?.usage || null,
|
|
1147
|
+
items: currentTurn.items,
|
|
1148
|
+
events: currentTurn.items,
|
|
1149
|
+
provider: this.backend,
|
|
1150
|
+
metadata: {
|
|
1151
|
+
source: OPENCODE_PROVIDER_VARIANT,
|
|
1152
|
+
sessionId: this.sessionId || undefined,
|
|
1153
|
+
totalCostUsd: Number.isFinite(Number(completion?.assistantInfo?.cost))
|
|
1154
|
+
? Number(completion.assistantInfo.cost)
|
|
1155
|
+
: undefined,
|
|
1156
|
+
},
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
catch (error) {
|
|
1160
|
+
if (error?.reason === "turn_timeout") {
|
|
1161
|
+
await this.interruptCurrentTurn();
|
|
1162
|
+
}
|
|
1163
|
+
if (!this.closeRequested && error?.reason !== "session_closed") {
|
|
1164
|
+
await this.emitTerminalWorkingStatus(currentTurn, {
|
|
1165
|
+
phase: "turn_failed",
|
|
1166
|
+
status_done_line: extractErrorMessage(error),
|
|
1167
|
+
}, onProgress);
|
|
1168
|
+
}
|
|
1169
|
+
if (this.closeRequested && error?.reason !== "session_closed") {
|
|
1170
|
+
throw this.createSessionClosedError();
|
|
1171
|
+
}
|
|
1172
|
+
this.maybeEmitAuthRequired(error);
|
|
1173
|
+
throw error;
|
|
1174
|
+
}
|
|
1175
|
+
finally {
|
|
1176
|
+
this.activeReplyTarget = "";
|
|
1177
|
+
if (this.currentTurn === currentTurn) {
|
|
1178
|
+
this.currentTurn = null;
|
|
1179
|
+
}
|
|
1180
|
+
closeGuard.cleanup();
|
|
1181
|
+
turnTimeoutGuard.cleanup();
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
async close() {
|
|
1185
|
+
if (this.closed) {
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
this.closeRequested = true;
|
|
1189
|
+
this.flushCloseWaiters();
|
|
1190
|
+
await this.interruptCurrentTurn();
|
|
1191
|
+
try {
|
|
1192
|
+
this.eventStreamAbortController?.abort?.();
|
|
1193
|
+
}
|
|
1194
|
+
catch {
|
|
1195
|
+
// best effort
|
|
1196
|
+
}
|
|
1197
|
+
await this.transport.close();
|
|
1198
|
+
this.closed = true;
|
|
1199
|
+
}
|
|
1200
|
+
}
|