jukto-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -0
- package/dist/ai/codex.d.ts +149 -0
- package/dist/ai/codex.js +2122 -0
- package/dist/ai/index.d.ts +57 -0
- package/dist/ai/index.js +119 -0
- package/dist/ai/interface.d.ts +93 -0
- package/dist/ai/interface.js +3 -0
- package/dist/ai/opencode.d.ts +72 -0
- package/dist/ai/opencode.js +883 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3356 -0
- package/dist/transport/protocol.d.ts +77 -0
- package/dist/transport/protocol.js +79 -0
- package/dist/transport/v2.d.ts +47 -0
- package/dist/transport/v2.js +347 -0
- package/package.json +43 -0
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
// OpenCode AI provider — wraps @opencode-ai/sdk.
|
|
2
|
+
// All logic extracted verbatim from cli/src/index.ts AI handlers section.
|
|
3
|
+
import * as crypto from "crypto";
|
|
4
|
+
import { createOpencodeServer, createOpencodeClient } from "@opencode-ai/sdk";
|
|
5
|
+
const VERBOSE_AI_LOGS = process.env.JUKTO_DEBUG === "1" || process.env.JUKTO_DEBUG_AI === "1";
|
|
6
|
+
const SSE_BACKOFF_INITIAL_MS = 500;
|
|
7
|
+
const SSE_BACKOFF_CAP_MS = 30_000;
|
|
8
|
+
const SSE_MAX_RETRIES = 20;
|
|
9
|
+
function redactSensitive(input) {
|
|
10
|
+
const text = typeof input === "string" ? input : JSON.stringify(input);
|
|
11
|
+
return text
|
|
12
|
+
.replace(/([A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,})/g, "[redacted_jwt]")
|
|
13
|
+
.replace(/(password|token|authorization|resumeToken|x-manager-password)\s*[:=]\s*["']?[^"',\s}]+/gi, "$1=[redacted]")
|
|
14
|
+
.replace(/[A-Za-z0-9+/=_-]{40,}/g, "[redacted_secret]");
|
|
15
|
+
}
|
|
16
|
+
function requireData(response, label) {
|
|
17
|
+
if (!response.data) {
|
|
18
|
+
const errMsg = response.error
|
|
19
|
+
? (typeof response.error === "string" ? response.error : JSON.stringify(response.error))
|
|
20
|
+
: `${label} returned no data`;
|
|
21
|
+
console.error(`[ai] ${label} failed:`, redactSensitive(errMsg), "raw response:", redactSensitive(JSON.stringify(response).substring(0, 500)));
|
|
22
|
+
throw new Error(errMsg);
|
|
23
|
+
}
|
|
24
|
+
return response.data;
|
|
25
|
+
}
|
|
26
|
+
function asRecord(value) {
|
|
27
|
+
return value && typeof value === "object" ? value : {};
|
|
28
|
+
}
|
|
29
|
+
function readString(value) {
|
|
30
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
31
|
+
}
|
|
32
|
+
function normalizeToolOutput(output, metadata) {
|
|
33
|
+
const attachments = Array.isArray(metadata.attachments) ? metadata.attachments : [];
|
|
34
|
+
if (attachments.length === 0)
|
|
35
|
+
return output;
|
|
36
|
+
const attachmentLines = attachments
|
|
37
|
+
.map((entry) => {
|
|
38
|
+
const file = asRecord(entry);
|
|
39
|
+
const filename = readString(file.filename)
|
|
40
|
+
?? readString(file.path)
|
|
41
|
+
?? readString(file.url)
|
|
42
|
+
?? "attachment";
|
|
43
|
+
return `- ${filename}`;
|
|
44
|
+
})
|
|
45
|
+
.filter((line) => line.trim().length > 0);
|
|
46
|
+
if (attachmentLines.length === 0)
|
|
47
|
+
return output;
|
|
48
|
+
if (!output.trim()) {
|
|
49
|
+
return `Attachments:\n${attachmentLines.join("\n")}`;
|
|
50
|
+
}
|
|
51
|
+
return `${output}\n\nAttachments:\n${attachmentLines.join("\n")}`;
|
|
52
|
+
}
|
|
53
|
+
function buildPatchSummary(part) {
|
|
54
|
+
const hash = readString(part.hash);
|
|
55
|
+
const files = Array.isArray(part.files)
|
|
56
|
+
? part.files.map((value) => String(value)).filter((value) => value.trim().length > 0)
|
|
57
|
+
: [];
|
|
58
|
+
const lines = [];
|
|
59
|
+
if (hash)
|
|
60
|
+
lines.push(`Patch hash: ${hash}`);
|
|
61
|
+
if (files.length > 0) {
|
|
62
|
+
lines.push("Files:");
|
|
63
|
+
for (const file of files)
|
|
64
|
+
lines.push(`- ${file}`);
|
|
65
|
+
}
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
68
|
+
function normalizeOpenCodePart(part) {
|
|
69
|
+
const raw = asRecord(part);
|
|
70
|
+
const type = readString(raw.type);
|
|
71
|
+
if (!type)
|
|
72
|
+
return raw;
|
|
73
|
+
if (type === "tool") {
|
|
74
|
+
const tool = readString(raw.tool) ?? "tool";
|
|
75
|
+
const state = asRecord(raw.state);
|
|
76
|
+
const status = readString(state.status) ?? "running";
|
|
77
|
+
const metadata = asRecord(state.metadata ?? raw.metadata);
|
|
78
|
+
const normalized = {
|
|
79
|
+
...raw,
|
|
80
|
+
type: "tool",
|
|
81
|
+
toolName: tool,
|
|
82
|
+
name: tool,
|
|
83
|
+
state: status,
|
|
84
|
+
input: asRecord(state.input),
|
|
85
|
+
metadata,
|
|
86
|
+
};
|
|
87
|
+
const title = readString(state.title);
|
|
88
|
+
if (title)
|
|
89
|
+
normalized.title = title;
|
|
90
|
+
const rawText = readString(state.raw);
|
|
91
|
+
if (rawText)
|
|
92
|
+
normalized.raw = rawText;
|
|
93
|
+
const time = asRecord(state.time);
|
|
94
|
+
if (Object.keys(time).length > 0) {
|
|
95
|
+
normalized.time = time;
|
|
96
|
+
}
|
|
97
|
+
if (status === "completed") {
|
|
98
|
+
const output = typeof state.output === "string" ? state.output : "";
|
|
99
|
+
normalized.output = normalizeToolOutput(output, {
|
|
100
|
+
...metadata,
|
|
101
|
+
attachments: state.attachments,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
else if (status === "error") {
|
|
105
|
+
if (metadata.interrupted === true) {
|
|
106
|
+
normalized.state = "completed";
|
|
107
|
+
normalized.interrupted = true;
|
|
108
|
+
const interruptedOutput = readString(metadata.output);
|
|
109
|
+
if (interruptedOutput)
|
|
110
|
+
normalized.output = interruptedOutput;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
normalized.error = readString(state.error) ?? "Tool failed";
|
|
114
|
+
const errorMessage = readString(state.error);
|
|
115
|
+
if (errorMessage)
|
|
116
|
+
normalized.output = errorMessage;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const attachments = Array.isArray(state.attachments) ? state.attachments : [];
|
|
120
|
+
if (attachments.length > 0) {
|
|
121
|
+
normalized.attachments = attachments.map((entry) => normalizeOpenCodePart(entry));
|
|
122
|
+
}
|
|
123
|
+
return normalized;
|
|
124
|
+
}
|
|
125
|
+
if (type === "step-start") {
|
|
126
|
+
const snapshot = readString(raw.snapshot);
|
|
127
|
+
return {
|
|
128
|
+
...raw,
|
|
129
|
+
type: "step-start",
|
|
130
|
+
title: snapshot ? `Step started · ${snapshot}` : "Step started",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (type === "step-finish") {
|
|
134
|
+
const reason = readString(raw.reason);
|
|
135
|
+
return {
|
|
136
|
+
...raw,
|
|
137
|
+
type: "step-finish",
|
|
138
|
+
title: reason ? `Step finished · ${reason}` : "Step finished",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (type === "patch") {
|
|
142
|
+
return {
|
|
143
|
+
...raw,
|
|
144
|
+
type: "file-change",
|
|
145
|
+
title: "File changes",
|
|
146
|
+
output: buildPatchSummary(raw),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (type === "subtask") {
|
|
150
|
+
return {
|
|
151
|
+
...raw,
|
|
152
|
+
type: "tool",
|
|
153
|
+
toolName: "subtask",
|
|
154
|
+
name: "subtask",
|
|
155
|
+
state: "completed",
|
|
156
|
+
input: {
|
|
157
|
+
prompt: readString(raw.prompt) ?? "",
|
|
158
|
+
description: readString(raw.description) ?? "",
|
|
159
|
+
agent: readString(raw.agent) ?? "",
|
|
160
|
+
...(readString(raw.command) ? { command: readString(raw.command) } : {}),
|
|
161
|
+
},
|
|
162
|
+
output: readString(raw.description) ?? readString(raw.prompt) ?? "Subtask requested",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (type === "agent") {
|
|
166
|
+
const name = readString(raw.name) ?? "Agent";
|
|
167
|
+
return {
|
|
168
|
+
...raw,
|
|
169
|
+
type: "step-start",
|
|
170
|
+
title: `Agent · ${name}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (type === "retry") {
|
|
174
|
+
const attempt = raw.attempt;
|
|
175
|
+
const error = asRecord(raw.error);
|
|
176
|
+
const message = readString(error.message) ?? "Retry requested";
|
|
177
|
+
return {
|
|
178
|
+
...raw,
|
|
179
|
+
type: "tool",
|
|
180
|
+
toolName: "retry",
|
|
181
|
+
name: "retry",
|
|
182
|
+
state: "error",
|
|
183
|
+
input: {
|
|
184
|
+
attempt,
|
|
185
|
+
},
|
|
186
|
+
error: message,
|
|
187
|
+
output: message,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (type === "compaction") {
|
|
191
|
+
const auto = raw.auto === true;
|
|
192
|
+
const overflow = raw.overflow === true;
|
|
193
|
+
return {
|
|
194
|
+
...raw,
|
|
195
|
+
type: "step-start",
|
|
196
|
+
title: `Context compacted${auto ? " · auto" : ""}${overflow ? " · overflow" : ""}`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (type === "snapshot") {
|
|
200
|
+
return {
|
|
201
|
+
...raw,
|
|
202
|
+
type: "step-start",
|
|
203
|
+
title: "Workspace snapshot",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return raw;
|
|
207
|
+
}
|
|
208
|
+
function normalizeOpenCodeMessage(message) {
|
|
209
|
+
return {
|
|
210
|
+
id: message.info.id,
|
|
211
|
+
role: message.info.role,
|
|
212
|
+
parts: (message.parts || []).map((part) => normalizeOpenCodePart(part)),
|
|
213
|
+
time: message.info.time,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function normalizePermissionProperties(properties) {
|
|
217
|
+
const tool = asRecord(properties.tool);
|
|
218
|
+
const metadata = properties.metadata && typeof properties.metadata === "object"
|
|
219
|
+
? properties.metadata
|
|
220
|
+
: properties;
|
|
221
|
+
return {
|
|
222
|
+
id: readString(properties.id),
|
|
223
|
+
sessionID: readString(properties.sessionID) ?? readString(properties.sessionId),
|
|
224
|
+
messageID: readString(properties.messageID) ?? readString(tool.messageID),
|
|
225
|
+
callID: readString(properties.callID) ?? readString(tool.callID),
|
|
226
|
+
type: readString(properties.type) ?? readString(properties.permission) ?? "permission",
|
|
227
|
+
title: readString(properties.title)
|
|
228
|
+
?? readString(properties.permission)
|
|
229
|
+
?? "Permission requested",
|
|
230
|
+
metadata,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function normalizeOpenCodeEvent(event) {
|
|
234
|
+
const { type, properties } = event;
|
|
235
|
+
if (type === "message.part.updated") {
|
|
236
|
+
return {
|
|
237
|
+
type,
|
|
238
|
+
properties: {
|
|
239
|
+
...properties,
|
|
240
|
+
part: normalizeOpenCodePart(properties.part),
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
if (type === "permission.updated" || type === "permission.asked") {
|
|
245
|
+
return {
|
|
246
|
+
type: "permission.updated",
|
|
247
|
+
properties: normalizePermissionProperties(properties),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
if (type === "permission.replied") {
|
|
251
|
+
return {
|
|
252
|
+
type: "permission.replied",
|
|
253
|
+
properties: {
|
|
254
|
+
sessionID: readString(properties.sessionID) ?? readString(properties.sessionId),
|
|
255
|
+
permissionId: readString(properties.permissionID)
|
|
256
|
+
?? readString(properties.requestID)
|
|
257
|
+
?? readString(properties.permissionId)
|
|
258
|
+
?? readString(properties.id),
|
|
259
|
+
response: readString(properties.response) ?? readString(properties.reply),
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return event;
|
|
264
|
+
}
|
|
265
|
+
export class OpenCodeProvider {
|
|
266
|
+
client = null;
|
|
267
|
+
server = null;
|
|
268
|
+
authHeader = null;
|
|
269
|
+
lastActiveSessionId = null;
|
|
270
|
+
shuttingDown = false;
|
|
271
|
+
emitter = null;
|
|
272
|
+
knownPendingPermissionIds = new Set();
|
|
273
|
+
knownPendingQuestionIds = new Set();
|
|
274
|
+
debugLog(message, ...args) {
|
|
275
|
+
if (!VERBOSE_AI_LOGS)
|
|
276
|
+
return;
|
|
277
|
+
console.log(message, ...args);
|
|
278
|
+
}
|
|
279
|
+
debugWarn(message, ...args) {
|
|
280
|
+
if (!VERBOSE_AI_LOGS)
|
|
281
|
+
return;
|
|
282
|
+
console.warn(message, ...args);
|
|
283
|
+
}
|
|
284
|
+
debugError(message, ...args) {
|
|
285
|
+
if (!VERBOSE_AI_LOGS)
|
|
286
|
+
return;
|
|
287
|
+
console.error(message, ...args);
|
|
288
|
+
}
|
|
289
|
+
async init() {
|
|
290
|
+
const opencodeUsername = "jukto";
|
|
291
|
+
const opencodePassword = crypto.randomBytes(32).toString("base64url");
|
|
292
|
+
const authHeader = `Basic ${Buffer.from(`${opencodeUsername}:${opencodePassword}`).toString("base64")}`;
|
|
293
|
+
process.env.OPENCODE_SERVER_USERNAME = opencodeUsername;
|
|
294
|
+
process.env.OPENCODE_SERVER_PASSWORD = opencodePassword;
|
|
295
|
+
this.authHeader = authHeader;
|
|
296
|
+
if (VERBOSE_AI_LOGS)
|
|
297
|
+
console.log("Starting OpenCode...");
|
|
298
|
+
this.server = await createOpencodeServer({
|
|
299
|
+
hostname: "127.0.0.1",
|
|
300
|
+
port: 0,
|
|
301
|
+
timeout: 15000,
|
|
302
|
+
});
|
|
303
|
+
if (VERBOSE_AI_LOGS)
|
|
304
|
+
console.log(`OpenCode server listening on ${this.server.url}`);
|
|
305
|
+
this.client = createOpencodeClient({
|
|
306
|
+
baseUrl: this.server.url,
|
|
307
|
+
headers: { Authorization: authHeader },
|
|
308
|
+
});
|
|
309
|
+
if (VERBOSE_AI_LOGS)
|
|
310
|
+
console.log("OpenCode ready.\n");
|
|
311
|
+
}
|
|
312
|
+
async destroy() {
|
|
313
|
+
this.shuttingDown = true;
|
|
314
|
+
this.authHeader = null;
|
|
315
|
+
}
|
|
316
|
+
subscribe(emitter) {
|
|
317
|
+
this.emitter = emitter;
|
|
318
|
+
this.shuttingDown = false;
|
|
319
|
+
// Run the SSE loop in the background — it will call emitter for each event.
|
|
320
|
+
this.runSseLoop();
|
|
321
|
+
return () => {
|
|
322
|
+
this.emitter = null;
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
setActiveSession(sessionId) {
|
|
326
|
+
this.lastActiveSessionId = sessionId;
|
|
327
|
+
}
|
|
328
|
+
// -------------------------------------------------------------------------
|
|
329
|
+
// Session management
|
|
330
|
+
// -------------------------------------------------------------------------
|
|
331
|
+
async createSession(title) {
|
|
332
|
+
if (VERBOSE_AI_LOGS)
|
|
333
|
+
console.log("[ai] createSession called");
|
|
334
|
+
try {
|
|
335
|
+
const response = await this.client.session.create({ body: { title } });
|
|
336
|
+
if (VERBOSE_AI_LOGS) {
|
|
337
|
+
console.log("[ai] createSession response ok:", !!response.data, "error:", response.error ? redactSensitive(JSON.stringify(response.error).substring(0, 200)) : "none");
|
|
338
|
+
}
|
|
339
|
+
return { session: requireData(response, "session.create") };
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
console.error("[ai] createSession exception:", redactSensitive(err.message));
|
|
343
|
+
throw err;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async listSessions() {
|
|
347
|
+
if (VERBOSE_AI_LOGS)
|
|
348
|
+
console.log("[ai] listSessions called");
|
|
349
|
+
try {
|
|
350
|
+
const response = await this.client.session.list();
|
|
351
|
+
const data = requireData(response, "session.list");
|
|
352
|
+
if (VERBOSE_AI_LOGS) {
|
|
353
|
+
console.log("[ai] listSessions returned", Array.isArray(data) ? data.length : typeof data, "sessions");
|
|
354
|
+
}
|
|
355
|
+
return { sessions: data };
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
console.error("[ai] listSessions exception:", err.message);
|
|
359
|
+
throw err;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async getSession(id) {
|
|
363
|
+
const response = await this.client.session.get({ path: { id } });
|
|
364
|
+
return { session: requireData(response, "session.get") };
|
|
365
|
+
}
|
|
366
|
+
async deleteSession(id) {
|
|
367
|
+
const response = await this.client.session.delete({ path: { id } });
|
|
368
|
+
const raw = response;
|
|
369
|
+
if (raw.error) {
|
|
370
|
+
const errMsg = typeof raw.error === "string"
|
|
371
|
+
? raw.error
|
|
372
|
+
: JSON.stringify(raw.error);
|
|
373
|
+
throw new Error(errMsg);
|
|
374
|
+
}
|
|
375
|
+
// Treat any non-error delete response as success. Some SDK/runtime combos
|
|
376
|
+
// return inconsistent boolean payloads despite successful deletion.
|
|
377
|
+
return { deleted: true };
|
|
378
|
+
}
|
|
379
|
+
async renameSession(id, title) {
|
|
380
|
+
const response = await this.client.session.update({
|
|
381
|
+
path: { id },
|
|
382
|
+
body: { title },
|
|
383
|
+
});
|
|
384
|
+
return { session: requireData(response, "session.update") };
|
|
385
|
+
}
|
|
386
|
+
// -------------------------------------------------------------------------
|
|
387
|
+
// Messages
|
|
388
|
+
// -------------------------------------------------------------------------
|
|
389
|
+
async getMessages(sessionId) {
|
|
390
|
+
if (VERBOSE_AI_LOGS)
|
|
391
|
+
console.log("[ai] getMessages called");
|
|
392
|
+
try {
|
|
393
|
+
const response = await this.client.session.messages({ path: { id: sessionId } });
|
|
394
|
+
const raw = requireData(response, "session.messages");
|
|
395
|
+
const messages = raw.map((m) => normalizeOpenCodeMessage(m));
|
|
396
|
+
if (VERBOSE_AI_LOGS)
|
|
397
|
+
console.log("[ai] getMessages returned", messages.length, "messages");
|
|
398
|
+
return { messages };
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
console.error("[ai] getMessages exception:", err.message);
|
|
402
|
+
throw err;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
async statuses() {
|
|
406
|
+
return { statuses: await this.fetchSessionStatuses() };
|
|
407
|
+
}
|
|
408
|
+
// -------------------------------------------------------------------------
|
|
409
|
+
// Interaction
|
|
410
|
+
// -------------------------------------------------------------------------
|
|
411
|
+
async prompt(sessionId, text, model, agent, files = [], codexOptions) {
|
|
412
|
+
if (sessionId)
|
|
413
|
+
this.lastActiveSessionId = sessionId;
|
|
414
|
+
if (VERBOSE_AI_LOGS) {
|
|
415
|
+
console.log("[ai] prompt called", {
|
|
416
|
+
hasSessionId: Boolean(sessionId),
|
|
417
|
+
model: redactSensitive(JSON.stringify(model || {})),
|
|
418
|
+
hasAgent: Boolean(agent),
|
|
419
|
+
textLength: typeof text === "string" ? text.length : 0,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
// Fire-and-forget — results come back through the SSE event stream.
|
|
423
|
+
// Prefer the async prompt endpoint so long-running turns do not get tied
|
|
424
|
+
// to the request lifecycle the way the basic prompt route can be.
|
|
425
|
+
this.sendPromptAsync(sessionId, text, model, agent, files, codexOptions).catch((err) => {
|
|
426
|
+
console.error("[ai] prompt error:", err.message);
|
|
427
|
+
this.emitter?.({
|
|
428
|
+
type: "prompt_error",
|
|
429
|
+
properties: { sessionId, error: err.message },
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
return { ack: true };
|
|
433
|
+
}
|
|
434
|
+
async abort(sessionId) {
|
|
435
|
+
await this.client.session.abort({ path: { id: sessionId } });
|
|
436
|
+
return {};
|
|
437
|
+
}
|
|
438
|
+
// -------------------------------------------------------------------------
|
|
439
|
+
// Metadata
|
|
440
|
+
// -------------------------------------------------------------------------
|
|
441
|
+
async agents() {
|
|
442
|
+
if (VERBOSE_AI_LOGS)
|
|
443
|
+
console.log("[ai] getAgents called");
|
|
444
|
+
try {
|
|
445
|
+
const response = await this.client.app.agents();
|
|
446
|
+
const data = requireData(response, "app.agents");
|
|
447
|
+
if (VERBOSE_AI_LOGS) {
|
|
448
|
+
console.log("[ai] getAgents returned:", redactSensitive(JSON.stringify(data).substring(0, 300)));
|
|
449
|
+
}
|
|
450
|
+
return { agents: data };
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
console.error("[ai] getAgents exception:", err.message);
|
|
454
|
+
throw err;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async providers() {
|
|
458
|
+
if (VERBOSE_AI_LOGS)
|
|
459
|
+
console.log("[ai] getProviders called");
|
|
460
|
+
try {
|
|
461
|
+
const response = await this.client.config.providers();
|
|
462
|
+
const data = requireData(response, "config.providers");
|
|
463
|
+
if (VERBOSE_AI_LOGS) {
|
|
464
|
+
console.log("[ai] getProviders returned", data.providers?.length, "providers, defaults:", redactSensitive(JSON.stringify(data.default)));
|
|
465
|
+
}
|
|
466
|
+
return { providers: data.providers, default: data.default };
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
console.error("[ai] getProviders exception:", err.message);
|
|
470
|
+
throw err;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// -------------------------------------------------------------------------
|
|
474
|
+
// Auth
|
|
475
|
+
// -------------------------------------------------------------------------
|
|
476
|
+
async setAuth(providerId, key) {
|
|
477
|
+
await this.client.auth.set({
|
|
478
|
+
path: { id: providerId },
|
|
479
|
+
body: { type: "api", key },
|
|
480
|
+
});
|
|
481
|
+
return {};
|
|
482
|
+
}
|
|
483
|
+
// -------------------------------------------------------------------------
|
|
484
|
+
// Session operations
|
|
485
|
+
// -------------------------------------------------------------------------
|
|
486
|
+
async command(sessionId, command, args) {
|
|
487
|
+
const response = await this.client.session.command({
|
|
488
|
+
path: { id: sessionId },
|
|
489
|
+
body: { command, arguments: args },
|
|
490
|
+
});
|
|
491
|
+
return { result: response.data ?? null };
|
|
492
|
+
}
|
|
493
|
+
async revert(sessionId, messageId) {
|
|
494
|
+
await this.client.session.revert({
|
|
495
|
+
path: { id: sessionId },
|
|
496
|
+
body: { messageID: messageId },
|
|
497
|
+
});
|
|
498
|
+
return {};
|
|
499
|
+
}
|
|
500
|
+
async unrevert(sessionId) {
|
|
501
|
+
await this.client.session.unrevert({ path: { id: sessionId } });
|
|
502
|
+
return {};
|
|
503
|
+
}
|
|
504
|
+
async share(sessionId) {
|
|
505
|
+
const response = await this.client.session.share({ path: { id: sessionId } });
|
|
506
|
+
return { share: requireData(response, "session.share") };
|
|
507
|
+
}
|
|
508
|
+
async permissionReply(sessionId, permissionId, response) {
|
|
509
|
+
await this.client.postSessionIdPermissionsPermissionId({
|
|
510
|
+
path: { id: sessionId, permissionID: permissionId },
|
|
511
|
+
body: { response },
|
|
512
|
+
});
|
|
513
|
+
return {};
|
|
514
|
+
}
|
|
515
|
+
async questionReply(sessionId, questionId, answers) {
|
|
516
|
+
await this.fetchOpenCodeJson(`/question/${encodeURIComponent(questionId)}/reply`, {
|
|
517
|
+
method: "POST",
|
|
518
|
+
body: { answers },
|
|
519
|
+
});
|
|
520
|
+
this.knownPendingQuestionIds.delete(questionId);
|
|
521
|
+
this.emitter?.({ type: "question.replied", properties: { sessionID: sessionId, requestID: questionId, answers } });
|
|
522
|
+
return {};
|
|
523
|
+
}
|
|
524
|
+
async questionReject(sessionId, questionId) {
|
|
525
|
+
await this.fetchOpenCodeJson(`/question/${encodeURIComponent(questionId)}/reject`, {
|
|
526
|
+
method: "POST",
|
|
527
|
+
});
|
|
528
|
+
this.knownPendingQuestionIds.delete(questionId);
|
|
529
|
+
this.emitter?.({ type: "question.rejected", properties: { sessionID: sessionId, requestID: questionId } });
|
|
530
|
+
return {};
|
|
531
|
+
}
|
|
532
|
+
// -------------------------------------------------------------------------
|
|
533
|
+
// SSE event loop (private)
|
|
534
|
+
// -------------------------------------------------------------------------
|
|
535
|
+
async runSseLoop() {
|
|
536
|
+
let attempt = 0;
|
|
537
|
+
const backoffMs = (n) => {
|
|
538
|
+
const base = Math.min(SSE_BACKOFF_INITIAL_MS * 2 ** n, SSE_BACKOFF_CAP_MS);
|
|
539
|
+
const jitter = Math.random() * base * 0.3;
|
|
540
|
+
return Math.round(base + jitter);
|
|
541
|
+
};
|
|
542
|
+
while (!this.shuttingDown) {
|
|
543
|
+
try {
|
|
544
|
+
// On reconnect, verify the active session is still alive.
|
|
545
|
+
if (attempt > 0 && this.lastActiveSessionId) {
|
|
546
|
+
const checkResp = await this.client.session.get({
|
|
547
|
+
path: { id: this.lastActiveSessionId },
|
|
548
|
+
});
|
|
549
|
+
if (checkResp.error) {
|
|
550
|
+
this.debugWarn(`[sse] OpenCode session ${this.lastActiveSessionId} was garbage-collected. Notifying app.`);
|
|
551
|
+
const gcSessionId = this.lastActiveSessionId;
|
|
552
|
+
this.lastActiveSessionId = null;
|
|
553
|
+
this.emitter?.({ type: "session_gc", properties: { sessionId: gcSessionId } });
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
this.debugLog(`[sse] Active session ${this.lastActiveSessionId} still valid.`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (attempt > 0) {
|
|
560
|
+
await this.reconcileOpenCodeState();
|
|
561
|
+
}
|
|
562
|
+
const events = await this.client.event.subscribe();
|
|
563
|
+
if (attempt > 0) {
|
|
564
|
+
this.debugLog(`[sse] reconnected after ${attempt} attempt(s)`);
|
|
565
|
+
}
|
|
566
|
+
attempt = 0;
|
|
567
|
+
for await (const raw of events.stream) {
|
|
568
|
+
if (this.shuttingDown)
|
|
569
|
+
return;
|
|
570
|
+
// Handle two SSE payload shapes across SDK versions:
|
|
571
|
+
// { type, properties, ... }
|
|
572
|
+
// { payload: { type, properties, ... }, directory: "..." }
|
|
573
|
+
const parsed = raw;
|
|
574
|
+
const base = parsed?.payload && typeof parsed.payload === "object"
|
|
575
|
+
? parsed.payload
|
|
576
|
+
: parsed;
|
|
577
|
+
if (!base || typeof base.type !== "string") {
|
|
578
|
+
this.debugWarn("[sse] Dropped malformed event:", redactSensitive(JSON.stringify(parsed).substring(0, 200)));
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (base.type !== "server.heartbeat") {
|
|
582
|
+
this.debugLog("[sse]", base.type);
|
|
583
|
+
}
|
|
584
|
+
const normalizedEvent = normalizeOpenCodeEvent({
|
|
585
|
+
type: base.type,
|
|
586
|
+
properties: base.properties || {},
|
|
587
|
+
});
|
|
588
|
+
this.trackPermissionEvent(normalizedEvent.type, normalizedEvent.properties || {});
|
|
589
|
+
this.emitter?.(normalizedEvent);
|
|
590
|
+
}
|
|
591
|
+
this.debugLog("[sse] Event stream ended, reconnecting...");
|
|
592
|
+
attempt++;
|
|
593
|
+
}
|
|
594
|
+
catch (err) {
|
|
595
|
+
if (this.shuttingDown)
|
|
596
|
+
return;
|
|
597
|
+
attempt++;
|
|
598
|
+
const delay = backoffMs(attempt - 1);
|
|
599
|
+
this.debugError(`[sse] Stream error (attempt ${attempt}/${SSE_MAX_RETRIES}): ${err.message}. Retrying in ${delay}ms`);
|
|
600
|
+
if (attempt >= SSE_MAX_RETRIES) {
|
|
601
|
+
this.debugError("[sse] Max retries reached. Sending error event to app and giving up.");
|
|
602
|
+
this.emitter?.({
|
|
603
|
+
type: "sse_dead",
|
|
604
|
+
properties: { error: err.message, attempts: attempt },
|
|
605
|
+
});
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
async sendPromptAsync(sessionId, text, model, agent, files = [], promptOptions) {
|
|
613
|
+
const server = this.server;
|
|
614
|
+
const authHeader = this.authHeader;
|
|
615
|
+
if (!server || !authHeader) {
|
|
616
|
+
throw new Error("OpenCode server is not ready");
|
|
617
|
+
}
|
|
618
|
+
const url = new URL(`/session/${encodeURIComponent(sessionId)}/prompt_async`, server.url);
|
|
619
|
+
const response = await fetch(url, {
|
|
620
|
+
method: "POST",
|
|
621
|
+
headers: {
|
|
622
|
+
Authorization: authHeader,
|
|
623
|
+
"content-type": "application/json",
|
|
624
|
+
accept: "application/json",
|
|
625
|
+
},
|
|
626
|
+
body: JSON.stringify({
|
|
627
|
+
parts: [
|
|
628
|
+
...(text.trim().length > 0 ? [{ type: "text", text }] : []),
|
|
629
|
+
...files,
|
|
630
|
+
],
|
|
631
|
+
...(model ? { model } : {}),
|
|
632
|
+
...(agent ? { agent } : {}),
|
|
633
|
+
...(promptOptions?.reasoningEffort ? { variant: promptOptions.reasoningEffort } : {}),
|
|
634
|
+
}),
|
|
635
|
+
});
|
|
636
|
+
if (!response.ok) {
|
|
637
|
+
let detail = "";
|
|
638
|
+
try {
|
|
639
|
+
detail = await response.text();
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
// ignore detail read failures
|
|
643
|
+
}
|
|
644
|
+
const suffix = detail.trim().length > 0 ? `: ${detail.trim()}` : "";
|
|
645
|
+
throw new Error(`OpenCode prompt_async failed (${response.status})${suffix}`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
async reconcileOpenCodeState() {
|
|
649
|
+
await Promise.allSettled([
|
|
650
|
+
this.refreshSessionsMetadata(),
|
|
651
|
+
this.refreshPendingPermissions(),
|
|
652
|
+
this.refreshPendingQuestions(),
|
|
653
|
+
this.refreshSessionStatuses(),
|
|
654
|
+
]);
|
|
655
|
+
await this.refreshBusySessionMessages();
|
|
656
|
+
}
|
|
657
|
+
async refreshBusySessionMessages() {
|
|
658
|
+
const server = this.server;
|
|
659
|
+
const authHeader = this.authHeader;
|
|
660
|
+
if (!server || !authHeader)
|
|
661
|
+
return;
|
|
662
|
+
const statusUrl = new URL("/session/status", server.url);
|
|
663
|
+
const statusResp = await fetch(statusUrl, {
|
|
664
|
+
headers: { Authorization: authHeader, accept: "application/json" },
|
|
665
|
+
}).catch(() => null);
|
|
666
|
+
if (!statusResp?.ok)
|
|
667
|
+
return;
|
|
668
|
+
const payload = await statusResp.json().catch(() => null);
|
|
669
|
+
if (!payload || typeof payload !== "object")
|
|
670
|
+
return;
|
|
671
|
+
for (const [sessionId, status] of Object.entries(payload)) {
|
|
672
|
+
const statusObj = status;
|
|
673
|
+
const statusType = typeof statusObj?.type === "string" ? statusObj.type.toLowerCase() : "";
|
|
674
|
+
if (statusType !== "busy")
|
|
675
|
+
continue;
|
|
676
|
+
try {
|
|
677
|
+
const response = await this.client.session.messages({ path: { id: sessionId } });
|
|
678
|
+
const raw = Array.isArray(response.data) ? response.data : [];
|
|
679
|
+
for (const m of raw) {
|
|
680
|
+
const msgObj = this.asRecord(m);
|
|
681
|
+
const info = this.asRecord(msgObj.info);
|
|
682
|
+
const parts = Array.isArray(msgObj.parts) ? msgObj.parts : [];
|
|
683
|
+
const msgId = this.readString(info.id);
|
|
684
|
+
if (!msgId)
|
|
685
|
+
continue;
|
|
686
|
+
this.emitter?.({ type: "message.updated", properties: { info } });
|
|
687
|
+
for (const part of parts) {
|
|
688
|
+
const partObj = normalizeOpenCodePart(part);
|
|
689
|
+
this.emitter?.({
|
|
690
|
+
type: "message.part.updated",
|
|
691
|
+
properties: {
|
|
692
|
+
part: { ...partObj, sessionID: sessionId, messageID: msgId },
|
|
693
|
+
message: { sessionID: sessionId, id: msgId, role: info.role },
|
|
694
|
+
},
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
this.debugLog(`[sse] Re-synced messages for busy session ${sessionId} after reconnect`);
|
|
699
|
+
}
|
|
700
|
+
catch (err) {
|
|
701
|
+
this.debugWarn(`[sse] Failed to refresh messages for busy session ${sessionId}:`, err.message);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
async refreshSessionsMetadata() {
|
|
706
|
+
const response = await this.client.session.list();
|
|
707
|
+
const sessions = Array.isArray(response.data) ? response.data : [];
|
|
708
|
+
for (const session of sessions) {
|
|
709
|
+
const info = this.asRecord(session);
|
|
710
|
+
const id = this.readString(info.id);
|
|
711
|
+
if (!id)
|
|
712
|
+
continue;
|
|
713
|
+
this.emitter?.({
|
|
714
|
+
type: "session.updated",
|
|
715
|
+
properties: { info },
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
async refreshPendingPermissions() {
|
|
720
|
+
const permissionApi = this.client?.permission;
|
|
721
|
+
if (!permissionApi?.list) {
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const response = await permissionApi.list();
|
|
725
|
+
const data = Array.isArray(response.data) ? response.data : [];
|
|
726
|
+
const nextIds = new Set();
|
|
727
|
+
for (const entry of data) {
|
|
728
|
+
const permission = this.asRecord(entry);
|
|
729
|
+
const id = this.readString(permission.id);
|
|
730
|
+
if (!id)
|
|
731
|
+
continue;
|
|
732
|
+
nextIds.add(id);
|
|
733
|
+
if (this.knownPendingPermissionIds.has(id)) {
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
this.knownPendingPermissionIds.add(id);
|
|
737
|
+
this.emitter?.({
|
|
738
|
+
type: "permission.updated",
|
|
739
|
+
properties: normalizePermissionProperties(permission),
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
for (const id of Array.from(this.knownPendingPermissionIds)) {
|
|
743
|
+
if (nextIds.has(id))
|
|
744
|
+
continue;
|
|
745
|
+
this.knownPendingPermissionIds.delete(id);
|
|
746
|
+
this.emitter?.({ type: "permission.replied", properties: { permissionId: id } });
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
async refreshPendingQuestions() {
|
|
750
|
+
const data = await this.fetchOpenCodeJson("/question", {
|
|
751
|
+
method: "GET",
|
|
752
|
+
});
|
|
753
|
+
const questions = Array.isArray(data) ? data : [];
|
|
754
|
+
const nextIds = new Set();
|
|
755
|
+
for (const entry of questions) {
|
|
756
|
+
const question = this.asRecord(entry);
|
|
757
|
+
const id = this.readString(question.id);
|
|
758
|
+
const sessionID = this.readString(question.sessionID) ?? this.readString(question.sessionId);
|
|
759
|
+
if (!id || !sessionID)
|
|
760
|
+
continue;
|
|
761
|
+
nextIds.add(id);
|
|
762
|
+
if (this.knownPendingQuestionIds.has(id)) {
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
this.knownPendingQuestionIds.add(id);
|
|
766
|
+
this.emitter?.({
|
|
767
|
+
type: "question.asked",
|
|
768
|
+
properties: {
|
|
769
|
+
id,
|
|
770
|
+
sessionID,
|
|
771
|
+
questions: Array.isArray(question.questions) ? question.questions : [],
|
|
772
|
+
tool: typeof question.tool === "object" && question.tool !== null ? question.tool : undefined,
|
|
773
|
+
},
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
for (const id of Array.from(this.knownPendingQuestionIds)) {
|
|
777
|
+
if (nextIds.has(id))
|
|
778
|
+
continue;
|
|
779
|
+
this.knownPendingQuestionIds.delete(id);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
async fetchOpenCodeJson(pathname, options = {}) {
|
|
783
|
+
const server = this.server;
|
|
784
|
+
const authHeader = this.authHeader;
|
|
785
|
+
if (!server || !authHeader) {
|
|
786
|
+
throw new Error("OpenCode server is not ready");
|
|
787
|
+
}
|
|
788
|
+
const url = new URL(pathname, server.url);
|
|
789
|
+
const response = await fetch(url, {
|
|
790
|
+
method: options.method ?? "GET",
|
|
791
|
+
headers: {
|
|
792
|
+
Authorization: authHeader,
|
|
793
|
+
accept: "application/json",
|
|
794
|
+
...(options.body ? { "content-type": "application/json" } : {}),
|
|
795
|
+
},
|
|
796
|
+
...(options.body ? { body: JSON.stringify(options.body) } : {}),
|
|
797
|
+
});
|
|
798
|
+
if (!response.ok) {
|
|
799
|
+
let detail = "";
|
|
800
|
+
try {
|
|
801
|
+
detail = await response.text();
|
|
802
|
+
}
|
|
803
|
+
catch {
|
|
804
|
+
// ignore detail read failures
|
|
805
|
+
}
|
|
806
|
+
const suffix = detail.trim().length > 0 ? `: ${detail.trim()}` : "";
|
|
807
|
+
throw new Error(`OpenCode request failed (${response.status})${suffix}`);
|
|
808
|
+
}
|
|
809
|
+
return response.json().catch(() => null);
|
|
810
|
+
}
|
|
811
|
+
async refreshSessionStatuses() {
|
|
812
|
+
const payload = await this.fetchSessionStatuses();
|
|
813
|
+
for (const [sessionId, status] of Object.entries(payload)) {
|
|
814
|
+
this.emitter?.({
|
|
815
|
+
type: "session.status",
|
|
816
|
+
properties: {
|
|
817
|
+
sessionID: sessionId,
|
|
818
|
+
status: status,
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
async fetchSessionStatuses() {
|
|
824
|
+
const server = this.server;
|
|
825
|
+
const authHeader = this.authHeader;
|
|
826
|
+
if (!server || !authHeader) {
|
|
827
|
+
return {};
|
|
828
|
+
}
|
|
829
|
+
const url = new URL("/session/status", server.url);
|
|
830
|
+
const response = await fetch(url, {
|
|
831
|
+
headers: {
|
|
832
|
+
Authorization: authHeader,
|
|
833
|
+
accept: "application/json",
|
|
834
|
+
},
|
|
835
|
+
});
|
|
836
|
+
if (!response.ok) {
|
|
837
|
+
return {};
|
|
838
|
+
}
|
|
839
|
+
const payload = await response.json().catch(() => null);
|
|
840
|
+
if (!payload || typeof payload !== "object") {
|
|
841
|
+
return {};
|
|
842
|
+
}
|
|
843
|
+
return payload;
|
|
844
|
+
}
|
|
845
|
+
trackPermissionEvent(type, properties) {
|
|
846
|
+
if (type === "permission.updated") {
|
|
847
|
+
const id = readString(properties.id);
|
|
848
|
+
if (id) {
|
|
849
|
+
this.knownPendingPermissionIds.add(id);
|
|
850
|
+
}
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (type === "permission.replied") {
|
|
854
|
+
const id = readString(properties.permissionId)
|
|
855
|
+
?? readString(properties.requestID)
|
|
856
|
+
?? readString(properties.id);
|
|
857
|
+
if (id) {
|
|
858
|
+
this.knownPendingPermissionIds.delete(id);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
if (type === "question.asked") {
|
|
862
|
+
const id = readString(properties.id);
|
|
863
|
+
if (id) {
|
|
864
|
+
this.knownPendingQuestionIds.add(id);
|
|
865
|
+
}
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (type === "question.replied" || type === "question.rejected") {
|
|
869
|
+
const id = readString(properties.requestID)
|
|
870
|
+
?? readString(properties.questionId)
|
|
871
|
+
?? readString(properties.id);
|
|
872
|
+
if (id) {
|
|
873
|
+
this.knownPendingQuestionIds.delete(id);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
readString(value) {
|
|
878
|
+
return readString(value);
|
|
879
|
+
}
|
|
880
|
+
asRecord(value) {
|
|
881
|
+
return asRecord(value);
|
|
882
|
+
}
|
|
883
|
+
}
|