agentp 1.12.1 → 1.14.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 +90 -10
- package/bin/agentp +372 -135
- package/bin/ocmux +334 -7
- package/docs/specification.md +26 -4
- package/lib/ocmux.js +30 -5
- package/lib/opencode.js +518 -17
- package/package.json +1 -1
package/lib/opencode.js
CHANGED
|
@@ -12,6 +12,87 @@ function getAuthHeaders() {
|
|
|
12
12
|
return { 'Authorization': `Basic ${encoded}` };
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// OpenCode v2 detection
|
|
17
|
+
//
|
|
18
|
+
// OpenCode v2 moved the HTTP API under the `/api` prefix (besides now serving
|
|
19
|
+
// the web UI as HTML at the root), wraps responses in a `{ data }` envelope,
|
|
20
|
+
// and changed the SSE event schema from `{type, properties}` to
|
|
21
|
+
// `{id, type, data}`. Older versions keep the bare paths and old schemas.
|
|
22
|
+
// We probe once per server URL and cache the result.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
let apiVersionOverride = null; // 'v2' | 'legacy' | null (auto-detect)
|
|
26
|
+
const v2Servers = new Set();
|
|
27
|
+
const legacyServers = new Set();
|
|
28
|
+
|
|
29
|
+
// Test hooks: force a protocol version / clear the detection cache.
|
|
30
|
+
function _setApiVersion(v) { apiVersionOverride = v; }
|
|
31
|
+
function _resetApiCache() {
|
|
32
|
+
v2Servers.clear();
|
|
33
|
+
legacyServers.clear();
|
|
34
|
+
apiVersionOverride = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Synchronous fast-path for forced/cached mode; null means auto-detect needed.
|
|
38
|
+
function v2Mode(server) {
|
|
39
|
+
if (apiVersionOverride === 'v2') return true;
|
|
40
|
+
if (apiVersionOverride === 'legacy') return false;
|
|
41
|
+
const key = String(server).replace(/\/+$/, '');
|
|
42
|
+
if (v2Servers.has(key)) return true;
|
|
43
|
+
if (legacyServers.has(key)) return false;
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Returns true when `server` speaks the OpenCode v2 HTTP API.
|
|
48
|
+
function detectV2(server) {
|
|
49
|
+
const forced = v2Mode(server);
|
|
50
|
+
if (forced !== null) return Promise.resolve(forced);
|
|
51
|
+
const key = String(server).replace(/\/+$/, '');
|
|
52
|
+
return makeRequest(buildJsonRequest(`${key}/api/info`, 'GET'), null, null, 5000)
|
|
53
|
+
.then(({ status, body }) => {
|
|
54
|
+
let isV2 = status === 401; // a v2 server requires auth; legacy never 401s on /api/info
|
|
55
|
+
if (status === 200) {
|
|
56
|
+
const t = String(body).trim();
|
|
57
|
+
if (t.startsWith('{')) {
|
|
58
|
+
try {
|
|
59
|
+
const info = JSON.parse(t);
|
|
60
|
+
isV2 = !!(info && typeof info.version === 'string');
|
|
61
|
+
} catch {
|
|
62
|
+
isV2 = false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
(isV2 ? v2Servers : legacyServers).add(key);
|
|
67
|
+
return isV2;
|
|
68
|
+
})
|
|
69
|
+
.catch((err) => {
|
|
70
|
+
// makeRequest rejects on 401; a v2 server requires auth (legacy never
|
|
71
|
+
// 401s on /api/info), so treat an auth failure as v2.
|
|
72
|
+
if (err && /authentication failed/i.test(err.message)) {
|
|
73
|
+
v2Servers.add(key);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
legacyServers.add(key);
|
|
77
|
+
return false;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Base URL for API requests: `server/api` on v2, `server` on legacy.
|
|
82
|
+
function apiBase(server) {
|
|
83
|
+
return detectV2(server).then((isV2) => {
|
|
84
|
+
const key = String(server).replace(/\/+$/, '');
|
|
85
|
+
return isV2 ? `${key}/api` : key;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// v2 wraps responses in `{ data: ... }`; legacy returns raw JSON.
|
|
90
|
+
function parseBody(body, isV2) {
|
|
91
|
+
const parsed = JSON.parse(body);
|
|
92
|
+
if (!isV2) return parsed;
|
|
93
|
+
return parsed && typeof parsed === 'object' && 'data' in parsed ? parsed.data : parsed;
|
|
94
|
+
}
|
|
95
|
+
|
|
15
96
|
function makeRequest(options, data, cancelRef, timeoutMs) {
|
|
16
97
|
return new Promise((resolve, reject) => {
|
|
17
98
|
const req = http.request(options, (res) => {
|
|
@@ -71,7 +152,23 @@ async function submitPrompt(server) {
|
|
|
71
152
|
}
|
|
72
153
|
|
|
73
154
|
// Send a complete text string as a prompt (clear + append + submit).
|
|
155
|
+
// v2 has no TUI prompt endpoints; deliver to the active session instead.
|
|
74
156
|
async function sendText(server, text) {
|
|
157
|
+
if (await detectV2(server)) {
|
|
158
|
+
const base = await apiBase(server);
|
|
159
|
+
const { status, body } = await makeRequest(buildJsonRequest(`${base}/session/active`, 'GET'));
|
|
160
|
+
if (status !== 200) return;
|
|
161
|
+
let active = null;
|
|
162
|
+
try {
|
|
163
|
+
const data = parseBody(body, true); // data: { <sessionID>: {...} }
|
|
164
|
+
const ids = data && typeof data === 'object' ? Object.keys(data) : [];
|
|
165
|
+
if (ids.length > 0) active = ids[0];
|
|
166
|
+
} catch {}
|
|
167
|
+
if (!active) return;
|
|
168
|
+
const pbody = JSON.stringify({ text, delivery: 'queue' });
|
|
169
|
+
await makeRequest(buildJsonRequest(`${base}/session/${encodeURIComponent(active)}/prompt`, 'POST', pbody), pbody);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
75
172
|
await clearPrompt(server);
|
|
76
173
|
await appendPrompt(server, text);
|
|
77
174
|
await submitPrompt(server);
|
|
@@ -82,6 +179,15 @@ async function sendText(server, text) {
|
|
|
82
179
|
// cancelRef — if provided, { current: null } is populated with the request so it can be aborted.
|
|
83
180
|
// Returns a Promise that resolves with the full collected response string.
|
|
84
181
|
function listenForFinalAnswer(server, onText, cancelRef) {
|
|
182
|
+
const mode = v2Mode(server);
|
|
183
|
+
if (mode === true) return listenV2(server, null, { onText }, cancelRef);
|
|
184
|
+
if (mode === false) return listenLegacyFinalAnswer(server, onText, cancelRef);
|
|
185
|
+
return detectV2(server).then((isV2) =>
|
|
186
|
+
isV2 ? listenV2(server, null, { onText }, cancelRef) : listenLegacyFinalAnswer(server, onText, cancelRef));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Legacy (pre-v2) SSE final-answer listener: events are `{type, properties}`.
|
|
190
|
+
function listenLegacyFinalAnswer(server, onText, cancelRef) {
|
|
85
191
|
return new Promise((resolve, reject) => {
|
|
86
192
|
const url = `${server}/event`;
|
|
87
193
|
const parsed = new URL(url);
|
|
@@ -179,14 +285,137 @@ function listenForFinalAnswer(server, onText, cancelRef) {
|
|
|
179
285
|
});
|
|
180
286
|
}
|
|
181
287
|
|
|
288
|
+
// OpenCode v2 SSE processor. Events arrive as `data: {id, type, data}` where
|
|
289
|
+
// `data` carries the payload keyed by sessionID. Text streams via
|
|
290
|
+
// `session.text.delta`, reasoning via `session.reasoning.delta`, completion is
|
|
291
|
+
// signaled by `session.execution.succeeded|failed|interrupted`.
|
|
292
|
+
// callbacks: { onText, onThinking, onPermission, onPermissionReplied, onQuestion, onConnected }
|
|
293
|
+
// opts.start — optional async fn called once the stream is connected (used to
|
|
294
|
+
// send the prompt AFTER attaching the listener, so no events are missed).
|
|
295
|
+
// Resolves with the collected text on completion, stream end, or 90s safety timeout.
|
|
296
|
+
function listenV2(server, sessionId, callbacks, cancelRef, logFn, opts) {
|
|
297
|
+
const sseLog = logFn || (() => {});
|
|
298
|
+
return new Promise((resolve, reject) => {
|
|
299
|
+
const base = String(server).replace(/\/+$/, '');
|
|
300
|
+
const parsed = new URL(`${base}/api/event`);
|
|
301
|
+
const req = http.request({
|
|
302
|
+
hostname: parsed.hostname,
|
|
303
|
+
port: parsed.port,
|
|
304
|
+
path: parsed.pathname,
|
|
305
|
+
method: 'GET',
|
|
306
|
+
headers: { 'Accept': 'text/event-stream', ...getAuthHeaders() }
|
|
307
|
+
}, (res) => {
|
|
308
|
+
if (res.statusCode === 401) {
|
|
309
|
+
res.resume();
|
|
310
|
+
reject(new Error('OpenCode server authentication failed. Set OPENCODE_SERVER_PASSWORD to match the server password.'));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (callbacks.onConnected) callbacks.onConnected();
|
|
314
|
+
let buffer = '';
|
|
315
|
+
let collected = '';
|
|
316
|
+
let resolved = false;
|
|
317
|
+
|
|
318
|
+
const cleanup = () => { if (cancelRef) cancelRef.current = null; };
|
|
319
|
+
const finishResolve = (val) => {
|
|
320
|
+
if (resolved) return;
|
|
321
|
+
resolved = true;
|
|
322
|
+
clearTimeout(safetyTimer);
|
|
323
|
+
cleanup();
|
|
324
|
+
req.destroy();
|
|
325
|
+
res.destroy();
|
|
326
|
+
resolve(val);
|
|
327
|
+
};
|
|
328
|
+
const finishReject = (err) => {
|
|
329
|
+
if (resolved) return;
|
|
330
|
+
resolved = true;
|
|
331
|
+
clearTimeout(safetyTimer);
|
|
332
|
+
cleanup();
|
|
333
|
+
req.destroy();
|
|
334
|
+
res.destroy();
|
|
335
|
+
reject(err);
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const safetyTimer = setTimeout(() => {
|
|
339
|
+
sseLog(` [SSE] safety timeout (90s), collected ${collected.length} chars\n`);
|
|
340
|
+
finishResolve(collected);
|
|
341
|
+
}, 90000);
|
|
342
|
+
|
|
343
|
+
if (opts && opts.start) {
|
|
344
|
+
opts.start().catch(finishReject);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
res.on('data', (chunk) => {
|
|
348
|
+
buffer += chunk.toString();
|
|
349
|
+
while (buffer.includes('\n\n')) {
|
|
350
|
+
const eventEnd = buffer.indexOf('\n\n');
|
|
351
|
+
const eventData = buffer.slice(0, eventEnd);
|
|
352
|
+
buffer = buffer.slice(eventEnd + 2);
|
|
353
|
+
|
|
354
|
+
const lines = eventData.split('\n');
|
|
355
|
+
let jsonStr = '';
|
|
356
|
+
for (const line of lines) {
|
|
357
|
+
if (line.startsWith('data: ')) jsonStr += line.slice(6);
|
|
358
|
+
}
|
|
359
|
+
if (!jsonStr.trim()) continue;
|
|
360
|
+
|
|
361
|
+
let ev;
|
|
362
|
+
try {
|
|
363
|
+
ev = JSON.parse(jsonStr);
|
|
364
|
+
} catch {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
let data = ev && ev.data;
|
|
368
|
+
if (typeof data === 'string') {
|
|
369
|
+
try { data = JSON.parse(data); } catch { data = null; }
|
|
370
|
+
}
|
|
371
|
+
if (!data || typeof data !== 'object') continue;
|
|
372
|
+
|
|
373
|
+
if (sessionId && data.sessionID && String(data.sessionID) !== String(sessionId)) continue;
|
|
374
|
+
|
|
375
|
+
if (ev.type === 'session.text.delta') {
|
|
376
|
+
if (data.delta != null && data.delta !== '') {
|
|
377
|
+
if (callbacks.onText) callbacks.onText(data.delta);
|
|
378
|
+
collected += data.delta;
|
|
379
|
+
}
|
|
380
|
+
} else if (ev.type === 'session.text.ended') {
|
|
381
|
+
if (collected === '' && data.text != null) collected = data.text;
|
|
382
|
+
} else if (ev.type === 'session.reasoning.delta') {
|
|
383
|
+
if (data.delta != null && data.delta !== '' && callbacks.onThinking) callbacks.onThinking(data.delta);
|
|
384
|
+
} else if (ev.type === 'permission.asked') {
|
|
385
|
+
if (callbacks.onPermission) callbacks.onPermission(data);
|
|
386
|
+
} else if (ev.type === 'permission.replied') {
|
|
387
|
+
if (callbacks.onPermissionReplied) callbacks.onPermissionReplied(data);
|
|
388
|
+
} else if (ev.type === 'form.created' || ev.type === 'question.asked') {
|
|
389
|
+
if (callbacks.onQuestion) callbacks.onQuestion(data);
|
|
390
|
+
} else if (ev.type === 'session.execution.succeeded'
|
|
391
|
+
|| ev.type === 'session.execution.failed'
|
|
392
|
+
|| ev.type === 'session.execution.interrupted') {
|
|
393
|
+
sseLog(` [SSE] ${ev.type} for session ${sessionId || 'any'}\n`);
|
|
394
|
+
finishResolve(collected);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
res.on('end', () => finishResolve(collected));
|
|
401
|
+
res.on('error', () => finishResolve(collected));
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
req.on('error', reject);
|
|
405
|
+
if (cancelRef) cancelRef.current = req;
|
|
406
|
+
req.end();
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
182
410
|
// List all sessions from the OpenCode server.
|
|
183
411
|
// Optionally filter by project directory.
|
|
184
412
|
async function listSessions(server, directory) {
|
|
185
|
-
|
|
413
|
+
const isV2 = await detectV2(server);
|
|
414
|
+
let url = `${await apiBase(server)}/session`;
|
|
186
415
|
if (directory) url += '?directory=' + encodeURIComponent(directory);
|
|
187
416
|
const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'));
|
|
188
417
|
if (status !== 200) throw new Error(`Failed to list sessions: ${status}`);
|
|
189
|
-
return
|
|
418
|
+
return parseBody(body, isV2);
|
|
190
419
|
}
|
|
191
420
|
|
|
192
421
|
// Send a message directly to a specific session via the synchronous session API.
|
|
@@ -194,7 +423,26 @@ async function listSessions(server, directory) {
|
|
|
194
423
|
// Optionally specify an agent to handle the message.
|
|
195
424
|
// Optional cancelRef allows aborting the HTTP request (for /cancel support).
|
|
196
425
|
async function sendToSession(server, sessionId, text, agent, cancelRef) {
|
|
197
|
-
const
|
|
426
|
+
const encoded = encodeURIComponent(sessionId);
|
|
427
|
+
if (await detectV2(server)) {
|
|
428
|
+
const base = await apiBase(server);
|
|
429
|
+
if (agent != null) {
|
|
430
|
+
const body = JSON.stringify({ agent });
|
|
431
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/agent`, 'POST', body), body);
|
|
432
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to set agent: ${status}`);
|
|
433
|
+
}
|
|
434
|
+
// Attach to the event stream FIRST (so no events are missed), then send the
|
|
435
|
+
// prompt, and resolve with the collected text when execution completes.
|
|
436
|
+
return listenV2(server, sessionId, {}, cancelRef, null, {
|
|
437
|
+
start: async () => {
|
|
438
|
+
const body = JSON.stringify({ text, delivery: 'steer' });
|
|
439
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/prompt`, 'POST', body), body, cancelRef);
|
|
440
|
+
if (status !== 200) throw new Error(`Failed to send to session: ${status}`);
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const url = `${server}/session/${encoded}/message`;
|
|
198
446
|
const bodyObj = { parts: [{ type: 'text', text }] };
|
|
199
447
|
if (agent) bodyObj.agent = agent;
|
|
200
448
|
const body = JSON.stringify(bodyObj);
|
|
@@ -210,16 +458,49 @@ async function sendToSession(server, sessionId, text, agent, cancelRef) {
|
|
|
210
458
|
|
|
211
459
|
// Create a new session on the OpenCode server. Optionally set a title.
|
|
212
460
|
async function createSession(server, title) {
|
|
213
|
-
const
|
|
461
|
+
const isV2 = await detectV2(server);
|
|
462
|
+
const url = `${await apiBase(server)}/session`;
|
|
214
463
|
const body = JSON.stringify(title != null ? { title } : {});
|
|
215
464
|
const { status, body: responseBody } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
|
|
216
465
|
if (status !== 200) throw new Error(`Failed to create session: ${status}`);
|
|
217
|
-
return
|
|
466
|
+
return parseBody(responseBody, isV2);
|
|
218
467
|
}
|
|
219
468
|
|
|
220
469
|
// Update a session's properties (e.g. title, agent, model).
|
|
221
470
|
// model can be a string "providerID/modelID" or an object { providerID, modelID }.
|
|
222
471
|
async function updateSession(server, sessionId, title, agent, model) {
|
|
472
|
+
const encoded = encodeURIComponent(sessionId);
|
|
473
|
+
if (await detectV2(server)) {
|
|
474
|
+
// v2 PATCH accepts only title/metadata/permissions; agent and model have
|
|
475
|
+
// dedicated endpoints.
|
|
476
|
+
const base = await apiBase(server);
|
|
477
|
+
let session = null;
|
|
478
|
+
if (title != null) {
|
|
479
|
+
const body = JSON.stringify({ title });
|
|
480
|
+
const { status, body: responseBody } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}`, 'PATCH', body), body);
|
|
481
|
+
if (status !== 200) throw new Error(`Failed to update session: ${status}`);
|
|
482
|
+
session = parseBody(responseBody, true);
|
|
483
|
+
}
|
|
484
|
+
if (agent != null) {
|
|
485
|
+
const body = JSON.stringify({ agent });
|
|
486
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/agent`, 'POST', body), body);
|
|
487
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to set agent: ${status}`);
|
|
488
|
+
}
|
|
489
|
+
if (model != null) {
|
|
490
|
+
const mObj = typeof model === 'string'
|
|
491
|
+
? { id: model }
|
|
492
|
+
: { providerID: model.providerID, id: model.modelID || model.id };
|
|
493
|
+
const body = JSON.stringify({ model: mObj });
|
|
494
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/model`, 'POST', body), body);
|
|
495
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to set model: ${status}`);
|
|
496
|
+
}
|
|
497
|
+
if (session) return session;
|
|
498
|
+
// Nothing to patch: return the current session info.
|
|
499
|
+
const { status, body: responseBody } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}`, 'GET'));
|
|
500
|
+
if (status !== 200) throw new Error(`Failed to update session: ${status}`);
|
|
501
|
+
return parseBody(responseBody, true);
|
|
502
|
+
}
|
|
503
|
+
|
|
223
504
|
const bodyObj = {};
|
|
224
505
|
if (title != null) bodyObj.title = title;
|
|
225
506
|
if (agent != null) bodyObj.agent = agent;
|
|
@@ -228,7 +509,7 @@ async function updateSession(server, sessionId, title, agent, model) {
|
|
|
228
509
|
? model
|
|
229
510
|
: { providerID: model.providerID, id: model.modelID || model.id };
|
|
230
511
|
}
|
|
231
|
-
const url = `${server}/session/${
|
|
512
|
+
const url = `${server}/session/${encoded}`;
|
|
232
513
|
const body = JSON.stringify(bodyObj);
|
|
233
514
|
const { status, body: responseBody } = await makeRequest(buildJsonRequest(url, 'PATCH', body), body);
|
|
234
515
|
if (status !== 200) throw new Error(`Failed to update session: ${status}`);
|
|
@@ -238,6 +519,11 @@ async function updateSession(server, sessionId, title, agent, model) {
|
|
|
238
519
|
// Tell the TUI to navigate to a specific session (if a TUI is attached).
|
|
239
520
|
// Tries multiple approaches since the endpoint varies by opencode version.
|
|
240
521
|
async function selectSession(server, sessionId) {
|
|
522
|
+
if (await detectV2(server)) {
|
|
523
|
+
// v2 has no HTTP endpoint to steer the TUI to a session; the TUI keeps
|
|
524
|
+
// itself in sync. Best effort: no-op.
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
241
527
|
const encoded = encodeURIComponent(sessionId);
|
|
242
528
|
const attempts = [
|
|
243
529
|
{ url: `${server}/tui/select-session`, body: JSON.stringify({ sessionID: sessionId }) },
|
|
@@ -260,6 +546,25 @@ async function selectSession(server, sessionId) {
|
|
|
260
546
|
// Tries GET /session/:id first; falls back to filtering the sessions list.
|
|
261
547
|
async function getSession(server, sessionId) {
|
|
262
548
|
const encoded = encodeURIComponent(sessionId);
|
|
549
|
+
if (await detectV2(server)) {
|
|
550
|
+
const base = await apiBase(server);
|
|
551
|
+
const urls = [`${base}/session/${encoded}`];
|
|
552
|
+
for (const url of urls) {
|
|
553
|
+
try {
|
|
554
|
+
const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'), null, null, 10000);
|
|
555
|
+
if (status === 200) {
|
|
556
|
+
try {
|
|
557
|
+
const parsed = parseBody(body, true);
|
|
558
|
+
if (parsed != null) return parsed;
|
|
559
|
+
} catch {}
|
|
560
|
+
}
|
|
561
|
+
} catch {}
|
|
562
|
+
}
|
|
563
|
+
const sessions = await listSessions(server);
|
|
564
|
+
const match = sessions.find(s => String(s.id) === String(sessionId));
|
|
565
|
+
if (match) return match;
|
|
566
|
+
throw new Error(`Session ${sessionId} not found`);
|
|
567
|
+
}
|
|
263
568
|
// Try several endpoint patterns that different opencode versions may expose
|
|
264
569
|
const urls = [
|
|
265
570
|
`${server}/session/${encoded}`,
|
|
@@ -288,24 +593,176 @@ async function getSession(server, sessionId) {
|
|
|
288
593
|
|
|
289
594
|
// List all agents from the OpenCode server.
|
|
290
595
|
async function listAgents(server) {
|
|
291
|
-
const
|
|
596
|
+
const isV2 = await detectV2(server);
|
|
597
|
+
const url = `${await apiBase(server)}/agent`;
|
|
292
598
|
const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'));
|
|
293
599
|
if (status !== 200) throw new Error(`Failed to list agents: ${status}`);
|
|
294
|
-
return
|
|
600
|
+
return parseBody(body, isV2);
|
|
295
601
|
}
|
|
296
602
|
|
|
297
603
|
// List all providers/models from the OpenCode server.
|
|
298
604
|
async function listProviders(server) {
|
|
299
|
-
const
|
|
605
|
+
const isV2 = await detectV2(server);
|
|
606
|
+
const url = `${await apiBase(server)}/provider`;
|
|
300
607
|
const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'));
|
|
301
608
|
if (status !== 200) throw new Error(`Failed to list providers: ${status}`);
|
|
302
|
-
return
|
|
609
|
+
return parseBody(body, isV2);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// List available models as { providerID, id, label: "providerID/id", name, variants: [] }.
|
|
613
|
+
// `name` is the friendly display name (e.g. "Kimi K2") when the server provides it.
|
|
614
|
+
// v2: GET /api/model. Legacy: derive from the provider list.
|
|
615
|
+
async function listModels(server) {
|
|
616
|
+
const isV2 = await detectV2(server);
|
|
617
|
+
const out = [];
|
|
618
|
+
const toVariants = (variants) => (Array.isArray(variants)
|
|
619
|
+
? variants.map(v => (typeof v === 'string' ? v : (v && v.id))).filter(Boolean)
|
|
620
|
+
: []);
|
|
621
|
+
const push = (providerID, id, name, variants) => {
|
|
622
|
+
if (!providerID || !id) return;
|
|
623
|
+
if (out.some(m => m.providerID === providerID && m.id === id)) return;
|
|
624
|
+
out.push({
|
|
625
|
+
providerID,
|
|
626
|
+
id,
|
|
627
|
+
label: `${providerID}/${id}`,
|
|
628
|
+
name: name && String(name) !== String(id) ? String(name) : null,
|
|
629
|
+
variants: toVariants(variants),
|
|
630
|
+
});
|
|
631
|
+
};
|
|
632
|
+
if (isV2) {
|
|
633
|
+
const url = `${await apiBase(server)}/model`;
|
|
634
|
+
const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'));
|
|
635
|
+
if (status !== 200) throw new Error(`Failed to list models: ${status}`);
|
|
636
|
+
const data = parseBody(body, true) || [];
|
|
637
|
+
for (const m of data) push(m.providerID, m.id, m.name || m.label, m.variants);
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
640
|
+
const providers = await listProviders(server);
|
|
641
|
+
for (const p of providers) {
|
|
642
|
+
const pid = p.provider || p.id || p.name;
|
|
643
|
+
const models = p.models || [];
|
|
644
|
+
for (const m of models) push(pid, m.id || m.model || m.name, m.name, m.variants);
|
|
645
|
+
}
|
|
646
|
+
return out;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Parse a model reference: "providerID/modelID", "modelID", optionally with a
|
|
650
|
+
// "#variant" suffix. Returns { providerID, id, variant } (providerID may be null).
|
|
651
|
+
function parseModelRef(ref) {
|
|
652
|
+
const s = String(ref || '').trim();
|
|
653
|
+
if (!s) return null;
|
|
654
|
+
let variant = null;
|
|
655
|
+
let base = s;
|
|
656
|
+
const hashIdx = s.indexOf('#');
|
|
657
|
+
if (hashIdx !== -1) {
|
|
658
|
+
variant = s.slice(hashIdx + 1) || null;
|
|
659
|
+
base = s.slice(0, hashIdx);
|
|
660
|
+
}
|
|
661
|
+
const slashIdx = base.indexOf('/');
|
|
662
|
+
if (slashIdx === -1) return { providerID: null, id: base, variant };
|
|
663
|
+
return { providerID: base.slice(0, slashIdx) || null, id: base.slice(slashIdx + 1) || null, variant };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Resolve a (possibly partial) model reference against available models.
|
|
667
|
+
// Returns { model, matches, error }; `model` is set only when exactly one
|
|
668
|
+
// candidate matches. Matching is case-insensitive and, besides the canonical
|
|
669
|
+
// "providerID/modelID" label and bare id, also matches the friendly display
|
|
670
|
+
// name (e.g. "Kimi K2"); separators/quotes are ignored so "deep seek" matches
|
|
671
|
+
// "DeepSeek V4" and "deepseek-v4-flash".
|
|
672
|
+
function resolveModelRef(models, ref) {
|
|
673
|
+
const q = String(ref || '').trim();
|
|
674
|
+
if (!q) return { model: null, matches: models, error: null };
|
|
675
|
+
const parsed = parseModelRef(q);
|
|
676
|
+
const variant = parsed ? parsed.variant : null;
|
|
677
|
+
const baseQ = parsed
|
|
678
|
+
? (parsed.providerID ? `${parsed.providerID}/${parsed.id}` : parsed.id)
|
|
679
|
+
: q;
|
|
680
|
+
const baseQLower = baseQ.toLowerCase();
|
|
681
|
+
const norm = (s) => String(s || '').toLowerCase().replace(/[\s\-_.'’]/g, '');
|
|
682
|
+
|
|
683
|
+
// 1) Exact canonical label ("providerID/modelID").
|
|
684
|
+
const byLabel = models.filter(m => m.label.toLowerCase() === baseQLower);
|
|
685
|
+
if (byLabel.length === 1) return { model: { ...byLabel[0], variant }, matches: byLabel, error: null };
|
|
686
|
+
if (byLabel.length > 1) {
|
|
687
|
+
return { model: null, matches: byLabel, error: `multiple models match "${q}" (${byLabel.length}): ${byLabel.map(m => m.label).join(', ')}` };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// 2) Exact bare id (only when the reference has no provider prefix).
|
|
691
|
+
if (!parsed || !parsed.providerID) {
|
|
692
|
+
const byId = models.filter(m => m.id.toLowerCase() === baseQLower);
|
|
693
|
+
if (byId.length === 1) return { model: { ...byId[0], variant }, matches: byId, error: null };
|
|
694
|
+
if (byId.length > 1) {
|
|
695
|
+
return { model: null, matches: byId, error: `multiple models match "${q}" (${byId.length}): ${byId.map(m => m.label).join(', ')}` };
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// 3) Substring match on label, bare id, or display name (raw and normalized).
|
|
700
|
+
const qLower = q.toLowerCase();
|
|
701
|
+
const qNorm = norm(q);
|
|
702
|
+
const matches = models.filter(m =>
|
|
703
|
+
m.label.toLowerCase().includes(qLower)
|
|
704
|
+
|| m.id.toLowerCase().includes(qLower)
|
|
705
|
+
|| (m.name != null && m.name.toLowerCase().includes(qLower))
|
|
706
|
+
|| (qNorm !== '' && (norm(m.label).includes(qNorm) || norm(m.id).includes(qNorm) || (m.name != null && norm(m.name).includes(qNorm))))
|
|
707
|
+
);
|
|
708
|
+
if (matches.length === 1) return { model: { ...matches[0], variant }, matches, error: null };
|
|
709
|
+
const error = matches.length === 0
|
|
710
|
+
? `no model matches "${q}"`
|
|
711
|
+
: `multiple models match "${q}" (${matches.length}): ${matches.map(m => m.label).join(', ')}`;
|
|
712
|
+
return { model: null, matches, error };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Switch the session's model immediately. On v2 POSTs /api/session/:id/model;
|
|
716
|
+
// on legacy PATCHes the session's model. `model` may be a string reference or a
|
|
717
|
+
// { providerID, id, variant? } object (as produced by resolveModelRef).
|
|
718
|
+
async function switchModel(server, sessionId, model) {
|
|
719
|
+
const encoded = encodeURIComponent(sessionId);
|
|
720
|
+
const mObj = model && typeof model === 'object' ? model : (parseModelRef(model) || {});
|
|
721
|
+
if (!mObj.id || !mObj.providerID) {
|
|
722
|
+
throw new Error(`Invalid model reference: ${model}`);
|
|
723
|
+
}
|
|
724
|
+
const isV2 = await detectV2(server);
|
|
725
|
+
if (isV2) {
|
|
726
|
+
const base = await apiBase(server);
|
|
727
|
+
const bodyObj = { model: { providerID: mObj.providerID, id: mObj.id } };
|
|
728
|
+
if (mObj.variant) bodyObj.model.variant = mObj.variant;
|
|
729
|
+
const body = JSON.stringify(bodyObj);
|
|
730
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/model`, 'POST', body), body);
|
|
731
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to switch model: ${status}`);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
await updateSession(server, sessionId, null, null, `${mObj.providerID}/${mObj.id}`);
|
|
303
735
|
}
|
|
304
736
|
|
|
305
737
|
// Send a message asynchronously (non-blocking). Returns 204 No Content.
|
|
306
738
|
// Optionally specify a model and/or noReply (injects context without AI response).
|
|
307
739
|
async function sendToSessionAsync(server, sessionId, text, agent, model, noReply) {
|
|
308
|
-
const
|
|
740
|
+
const encoded = encodeURIComponent(sessionId);
|
|
741
|
+
if (await detectV2(server)) {
|
|
742
|
+
const base = await apiBase(server);
|
|
743
|
+
if (agent != null) {
|
|
744
|
+
const body = JSON.stringify({ agent });
|
|
745
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/agent`, 'POST', body), body);
|
|
746
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to set agent: ${status}`);
|
|
747
|
+
}
|
|
748
|
+
if (model != null) {
|
|
749
|
+
const mObj = typeof model === 'string'
|
|
750
|
+
? { id: model }
|
|
751
|
+
: { providerID: model.providerID, id: model.modelID || model.id };
|
|
752
|
+
const body = JSON.stringify({ model: mObj });
|
|
753
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/model`, 'POST', body), body);
|
|
754
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to set model: ${status}`);
|
|
755
|
+
}
|
|
756
|
+
// Non-blocking enqueue. (legacy noReply has no v2 equivalent; the prompt is
|
|
757
|
+
// queued and executed when the session processes its inbox.)
|
|
758
|
+
const body = JSON.stringify({ text, delivery: 'queue' });
|
|
759
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/prompt`, 'POST', body), body);
|
|
760
|
+
if (status !== 200 && status !== 201 && status !== 202 && status !== 204) {
|
|
761
|
+
throw new Error(`Failed to send async message: ${status}`);
|
|
762
|
+
}
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const url = `${server}/session/${encoded}/prompt_async`;
|
|
309
766
|
const bodyObj = { parts: [{ type: 'text', text }] };
|
|
310
767
|
if (agent) bodyObj.agent = agent;
|
|
311
768
|
if (model) bodyObj.model = typeof model === 'string' ? model : { providerID: model.providerID, modelID: model.modelID };
|
|
@@ -317,7 +774,20 @@ async function sendToSessionAsync(server, sessionId, text, agent, model, noReply
|
|
|
317
774
|
|
|
318
775
|
// Respond to a permission request.
|
|
319
776
|
async function respondToPermission(server, sessionId, permissionId, response, remember) {
|
|
320
|
-
const
|
|
777
|
+
const encodedS = encodeURIComponent(sessionId);
|
|
778
|
+
const encodedP = encodeURIComponent(permissionId);
|
|
779
|
+
if (await detectV2(server)) {
|
|
780
|
+
const base = await apiBase(server);
|
|
781
|
+
// v2 decision enum: once | always | reject
|
|
782
|
+
let decision = response === 'allow' ? 'once' : response;
|
|
783
|
+
if (remember && decision !== 'reject') decision = 'always';
|
|
784
|
+
if (!['once', 'always', 'reject'].includes(decision)) decision = 'once';
|
|
785
|
+
const body = JSON.stringify({ decision });
|
|
786
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encodedS}/permission/${encodedP}/reply`, 'POST', body), body);
|
|
787
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to respond to permission: ${status}`);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const url = `${server}/session/${encodedS}/permissions/${encodedP}`;
|
|
321
791
|
const bodyObj = { response };
|
|
322
792
|
if (remember) bodyObj.remember = true;
|
|
323
793
|
const body = JSON.stringify(bodyObj);
|
|
@@ -331,6 +801,17 @@ async function respondToPermission(server, sessionId, permissionId, response, re
|
|
|
331
801
|
// cancelRef — allows aborting the SSE stream via req.destroy()
|
|
332
802
|
// logFn — optional logger; if provided, used instead of process.stderr.write
|
|
333
803
|
function listenForSessionEvents(server, sessionId, callbacks, cancelRef, logFn) {
|
|
804
|
+
const mode = v2Mode(server);
|
|
805
|
+
if (mode === true) return listenV2(server, sessionId, callbacks, cancelRef, logFn, null);
|
|
806
|
+
if (mode === false) return listenLegacySessionEvents(server, sessionId, callbacks, cancelRef, logFn);
|
|
807
|
+
return detectV2(server).then((isV2) =>
|
|
808
|
+
isV2
|
|
809
|
+
? listenV2(server, sessionId, callbacks, cancelRef, logFn, null)
|
|
810
|
+
: listenLegacySessionEvents(server, sessionId, callbacks, cancelRef, logFn));
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Legacy (pre-v2) SSE session-event listener: events are `{type, properties}`.
|
|
814
|
+
function listenLegacySessionEvents(server, sessionId, callbacks, cancelRef, logFn) {
|
|
334
815
|
const sseLog = logFn || ((msg) => {
|
|
335
816
|
const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
336
817
|
process.stderr.write(`${ts} ${msg}`);
|
|
@@ -511,9 +992,10 @@ function listenForSessionEvents(server, sessionId, callbacks, cancelRef, logFn)
|
|
|
511
992
|
// Check if an OpenCode server is reachable by making a GET /session request.
|
|
512
993
|
// Returns a Promise that resolves to `true` (any response) or `false` (connection error / timeout).
|
|
513
994
|
function isServerAlive(url) {
|
|
514
|
-
return new Promise((resolve) => {
|
|
515
|
-
const
|
|
516
|
-
const
|
|
995
|
+
return detectV2(url).then((isV2) => new Promise((resolve) => {
|
|
996
|
+
const base = String(url).replace(/\/+$/, '');
|
|
997
|
+
const mod = base.startsWith('https') ? https : http;
|
|
998
|
+
const parsed = new URL(`${base}${isV2 ? '/api' : ''}/session`);
|
|
517
999
|
const opts = {
|
|
518
1000
|
hostname: parsed.hostname,
|
|
519
1001
|
port: parsed.port,
|
|
@@ -528,12 +1010,21 @@ function isServerAlive(url) {
|
|
|
528
1010
|
req.on('error', () => resolve(false));
|
|
529
1011
|
req.setTimeout(5000, () => { req.destroy(); resolve(false); });
|
|
530
1012
|
req.end();
|
|
531
|
-
});
|
|
1013
|
+
})).catch(() => false);
|
|
532
1014
|
}
|
|
533
1015
|
|
|
534
1016
|
// Respond to a question (choice) asked by the AI.
|
|
535
1017
|
async function respondToQuestion(server, sessionId, questionId, answer) {
|
|
536
|
-
const
|
|
1018
|
+
const encodedS = encodeURIComponent(sessionId);
|
|
1019
|
+
const encodedQ = encodeURIComponent(questionId);
|
|
1020
|
+
if (await detectV2(server)) {
|
|
1021
|
+
const base = await apiBase(server);
|
|
1022
|
+
const body = JSON.stringify({ answer });
|
|
1023
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encodedS}/form/${encodedQ}/reply`, 'POST', body), body);
|
|
1024
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to respond to question: ${status}`);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
const url = `${server}/session/${encodedS}/questions/${encodedQ}`;
|
|
537
1028
|
const body = JSON.stringify({ answer });
|
|
538
1029
|
const { status } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
|
|
539
1030
|
if (status !== 200) throw new Error(`Failed to respond to question: ${status}`);
|
|
@@ -560,5 +1051,15 @@ module.exports = {
|
|
|
560
1051
|
listenForSessionEvents,
|
|
561
1052
|
listAgents,
|
|
562
1053
|
listProviders,
|
|
1054
|
+
listModels,
|
|
1055
|
+
parseModelRef,
|
|
1056
|
+
resolveModelRef,
|
|
1057
|
+
switchModel,
|
|
563
1058
|
isServerAlive,
|
|
1059
|
+
detectV2,
|
|
1060
|
+
apiBase,
|
|
1061
|
+
parseBody,
|
|
1062
|
+
listenV2,
|
|
1063
|
+
_setApiVersion,
|
|
1064
|
+
_resetApiCache,
|
|
564
1065
|
};
|