agentp 1.13.0 → 2.0.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/CONTRIBUTING.md +80 -83
- package/README.md +135 -48
- package/bin/agentp +337 -75
- package/bin/ocmux +1075 -440
- package/docs/specification.md +12 -449
- package/docs/specification_v2.md +611 -0
- package/lib/ocmux.js +124 -173
- package/lib/opencode.js +563 -356
- package/lib/project-state.js +205 -0
- package/package.json +1 -1
package/lib/opencode.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// OpenCode v2 HTTP/SSE session API client.
|
|
4
|
+
//
|
|
5
|
+
// v2-only: endpoints live under `/api`, responses are wrapped in a `{data}`
|
|
6
|
+
// envelope, and the SSE event schema is `{id, type, data}` (text streams via
|
|
7
|
+
// `session.text.delta`, completion via `session.execution.succeeded|failed`,
|
|
8
|
+
// interruption via `session.execution.interrupted`). Legacy (v1) support has
|
|
9
|
+
// been removed.
|
|
10
|
+
|
|
3
11
|
const http = require('http');
|
|
4
12
|
const https = require('https');
|
|
5
13
|
const { URL } = require('url');
|
|
@@ -12,6 +20,30 @@ function getAuthHeaders() {
|
|
|
12
20
|
return { 'Authorization': `Basic ${encoded}` };
|
|
13
21
|
}
|
|
14
22
|
|
|
23
|
+
// v2 is the only supported protocol now; kept for API compatibility.
|
|
24
|
+
function v2Mode() {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function detectV2() {
|
|
29
|
+
return Promise.resolve(true);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function _setApiVersion() { /* v2 only — kept as a no-op for compatibility */ }
|
|
33
|
+
function _resetApiCache() { /* v2 only — kept as a no-op for compatibility */ }
|
|
34
|
+
|
|
35
|
+
// Base URL for API requests (v2: `server/api`).
|
|
36
|
+
async function apiBase(server) {
|
|
37
|
+
const key = String(server).replace(/\/+$/, '');
|
|
38
|
+
return `${key}/api`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// v2 wraps responses in `{ data: ... }`; unwrap it when present.
|
|
42
|
+
function parseBody(body) {
|
|
43
|
+
const parsed = JSON.parse(body);
|
|
44
|
+
return parsed && typeof parsed === 'object' && 'data' in parsed ? parsed.data : parsed;
|
|
45
|
+
}
|
|
46
|
+
|
|
15
47
|
function makeRequest(options, data, cancelRef, timeoutMs) {
|
|
16
48
|
return new Promise((resolve, reject) => {
|
|
17
49
|
const req = http.request(options, (res) => {
|
|
@@ -54,39 +86,64 @@ function buildJsonRequest(url, method, body) {
|
|
|
54
86
|
return opts;
|
|
55
87
|
}
|
|
56
88
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
await makeRequest(buildJsonRequest(url, 'POST', '{}'), '{}');
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async function appendPrompt(server, text) {
|
|
63
|
-
const url = `${server}/tui/append-prompt`;
|
|
64
|
-
const body = JSON.stringify({ text });
|
|
65
|
-
await makeRequest(buildJsonRequest(url, 'POST', body), body);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function submitPrompt(server) {
|
|
69
|
-
const url = `${server}/tui/submit-prompt`;
|
|
70
|
-
await makeRequest(buildJsonRequest(url, 'POST', '{}'), '{}');
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Send a complete text string as a prompt (clear + append + submit).
|
|
89
|
+
// Send a complete text string to the server's ACTIVE session (no TUI prompt
|
|
90
|
+
// endpoints exist in v2).
|
|
74
91
|
async function sendText(server, text) {
|
|
75
|
-
await
|
|
76
|
-
await
|
|
77
|
-
|
|
92
|
+
const base = await apiBase(server);
|
|
93
|
+
const { status, body } = await makeRequest(buildJsonRequest(`${base}/session/active`, 'GET'));
|
|
94
|
+
if (status !== 200) return;
|
|
95
|
+
let active = null;
|
|
96
|
+
try {
|
|
97
|
+
// GET /session/active → { data: { <sessionID>: {...running} } }
|
|
98
|
+
const data = parseBody(body);
|
|
99
|
+
const ids = data && typeof data === 'object' ? Object.keys(data) : [];
|
|
100
|
+
if (ids.length > 0) active = ids[0];
|
|
101
|
+
} catch {}
|
|
102
|
+
if (!active) return;
|
|
103
|
+
const pbody = JSON.stringify({ text, delivery: 'queue' });
|
|
104
|
+
await makeRequest(buildJsonRequest(`${base}/session/${encodeURIComponent(active)}/prompt`, 'POST', pbody), pbody);
|
|
78
105
|
}
|
|
79
106
|
|
|
80
|
-
// Listen for the
|
|
81
|
-
// onText(chunk) is called for each text part received
|
|
82
|
-
// cancelRef — if provided, { current: null } is populated with the request so
|
|
83
|
-
// Returns a Promise
|
|
107
|
+
// Listen for the assistant's answer from the v2 SSE stream.
|
|
108
|
+
// onText(chunk) is called for each text part received.
|
|
109
|
+
// cancelRef — if provided, { current: null } is populated with the request so
|
|
110
|
+
// it can be aborted. Returns a Promise resolving with the full collected text.
|
|
84
111
|
function listenForFinalAnswer(server, onText, cancelRef) {
|
|
85
|
-
return
|
|
86
|
-
|
|
87
|
-
const parsed = new URL(url);
|
|
88
|
-
const write = typeof onText === 'function' ? onText : null;
|
|
112
|
+
return listenV2(server, null, { onText }, cancelRef);
|
|
113
|
+
}
|
|
89
114
|
|
|
115
|
+
// Quiescence window after a terminal `session.execution.*` event. OpenCode v2
|
|
116
|
+
// can signal completion before the agent is truly done (a retry may schedule a
|
|
117
|
+
// new execution, or a sub-agent may resume later). We resolve only after this
|
|
118
|
+
// window elapses with no further activity — and ANY event on the stream
|
|
119
|
+
// (including child-session/sub-agent events) resets it. `interrupted` resolves
|
|
120
|
+
// immediately.
|
|
121
|
+
//
|
|
122
|
+
// The window must comfortably exceed the longest SILENT gap of a turn (e.g. a
|
|
123
|
+
// long `bash`/tool call, or a sub-agent that emits nothing for a while);
|
|
124
|
+
// otherwise the answer gets truncated. Tunable via AGENTP_COMPLETION_GRACE_MS.
|
|
125
|
+
const DEFAULT_COMPLETION_GRACE_MS = 15000;
|
|
126
|
+
let completionGraceMs = (() => {
|
|
127
|
+
const env = parseInt(process.env.AGENTP_COMPLETION_GRACE_MS, 10);
|
|
128
|
+
return Number.isFinite(env) && env > 0 ? env : DEFAULT_COMPLETION_GRACE_MS;
|
|
129
|
+
})();
|
|
130
|
+
|
|
131
|
+
// Test hook: override the quiescence window (e.g. to speed up tests).
|
|
132
|
+
function _setCompletionGraceMs(ms) { completionGraceMs = ms; }
|
|
133
|
+
|
|
134
|
+
// OpenCode v2 SSE processor. Events arrive as `data: {id, type, data}` where
|
|
135
|
+
// `data` carries the payload keyed by sessionID. Text streams via
|
|
136
|
+
// `session.text.delta`, reasoning via `session.reasoning.delta`, completion is
|
|
137
|
+
// signaled by `session.execution.succeeded|failed|interrupted`.
|
|
138
|
+
// callbacks: { onText, onThinking, onPermission, onPermissionReplied, onQuestion, onConnected }
|
|
139
|
+
// opts.start — optional async fn called once the stream is connected (used to
|
|
140
|
+
// send the prompt AFTER attaching the listener, so no events are missed).
|
|
141
|
+
// Resolves with the collected text on completion, stream end, or 90s safety timeout.
|
|
142
|
+
function listenV2(server, sessionId, callbacks, cancelRef, logFn, opts) {
|
|
143
|
+
const sseLog = logFn || (() => {});
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const base = String(server).replace(/\/+$/, '');
|
|
146
|
+
const parsed = new URL(`${base}/api/event`);
|
|
90
147
|
const req = http.request({
|
|
91
148
|
hostname: parsed.hostname,
|
|
92
149
|
port: parsed.port,
|
|
@@ -99,187 +156,287 @@ function listenForFinalAnswer(server, onText, cancelRef) {
|
|
|
99
156
|
reject(new Error('OpenCode server authentication failed. Set OPENCODE_SERVER_PASSWORD to match the server password.'));
|
|
100
157
|
return;
|
|
101
158
|
}
|
|
159
|
+
if (callbacks.onConnected) callbacks.onConnected();
|
|
102
160
|
let buffer = '';
|
|
103
|
-
const userMessageIDs = new Set();
|
|
104
161
|
let collected = '';
|
|
162
|
+
let resolved = false;
|
|
163
|
+
let completionTimer = null;
|
|
164
|
+
|
|
165
|
+
const cleanup = () => { if (cancelRef) cancelRef.current = null; };
|
|
166
|
+
|
|
167
|
+
const clearCompletionTimer = () => {
|
|
168
|
+
if (completionTimer) { clearTimeout(completionTimer); completionTimer = null; }
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const finishResolve = (val) => {
|
|
172
|
+
if (resolved) return;
|
|
173
|
+
resolved = true;
|
|
174
|
+
clearTimeout(safetyTimer);
|
|
175
|
+
clearCompletionTimer();
|
|
176
|
+
cleanup();
|
|
177
|
+
req.destroy();
|
|
178
|
+
res.destroy();
|
|
179
|
+
resolve(val);
|
|
180
|
+
};
|
|
181
|
+
const finishReject = (err) => {
|
|
182
|
+
if (resolved) return;
|
|
183
|
+
resolved = true;
|
|
184
|
+
clearTimeout(safetyTimer);
|
|
185
|
+
clearCompletionTimer();
|
|
186
|
+
cleanup();
|
|
187
|
+
req.destroy();
|
|
188
|
+
res.destroy();
|
|
189
|
+
reject(err);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const safetyTimer = setTimeout(() => {
|
|
193
|
+
sseLog(` [SSE] safety timeout (90s), collected ${collected.length} chars\n`);
|
|
194
|
+
finishResolve(collected);
|
|
195
|
+
}, 90000);
|
|
196
|
+
|
|
197
|
+
const scheduleCompletion = () => {
|
|
198
|
+
if (resolved || completionTimer) return;
|
|
199
|
+
completionTimer = setTimeout(async () => {
|
|
200
|
+
completionTimer = null;
|
|
201
|
+
// Optional second opinion: if the caller can verify the session is
|
|
202
|
+
// truly idle (its idle marker newer than the send time), keep waiting
|
|
203
|
+
// — otherwise a terminal signal from an OLDER queued execution would
|
|
204
|
+
// resolve with an empty/partial answer.
|
|
205
|
+
if (opts && typeof opts.verify === 'function' && !(await opts.verify())) {
|
|
206
|
+
sseLog(' [SSE] session not yet idle after quiescence — waiting more\n');
|
|
207
|
+
scheduleCompletion();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
sseLog(` [SSE] terminal signal confirmed after quiescence (${collected.length} chars)\n`);
|
|
211
|
+
finishResolve(collected);
|
|
212
|
+
}, completionGraceMs);
|
|
213
|
+
};
|
|
214
|
+
const cancelCompletion = () => {
|
|
215
|
+
if (completionTimer) {
|
|
216
|
+
clearCompletionTimer();
|
|
217
|
+
sseLog(` [SSE] activity after terminal signal — continuing\n`);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
if (opts && opts.start) {
|
|
222
|
+
opts.start().catch(finishReject);
|
|
223
|
+
}
|
|
105
224
|
|
|
106
225
|
res.on('data', (chunk) => {
|
|
107
226
|
buffer += chunk.toString();
|
|
108
|
-
|
|
109
|
-
// Split by double newline (SSE event separator)
|
|
110
227
|
while (buffer.includes('\n\n')) {
|
|
111
228
|
const eventEnd = buffer.indexOf('\n\n');
|
|
112
229
|
const eventData = buffer.slice(0, eventEnd);
|
|
113
230
|
buffer = buffer.slice(eventEnd + 2);
|
|
114
231
|
|
|
115
|
-
// Extract data lines
|
|
116
232
|
const lines = eventData.split('\n');
|
|
117
233
|
let jsonStr = '';
|
|
118
|
-
|
|
119
234
|
for (const line of lines) {
|
|
120
|
-
if (line.startsWith('data: '))
|
|
121
|
-
jsonStr += line.slice(6);
|
|
122
|
-
}
|
|
235
|
+
if (line.startsWith('data: ')) jsonStr += line.slice(6);
|
|
123
236
|
}
|
|
124
|
-
|
|
125
237
|
if (!jsonStr.trim()) continue;
|
|
126
238
|
|
|
239
|
+
let ev;
|
|
127
240
|
try {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
241
|
+
ev = JSON.parse(jsonStr);
|
|
242
|
+
} catch {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
let data = ev && ev.data;
|
|
246
|
+
if (typeof data === 'string') {
|
|
247
|
+
try { data = JSON.parse(data); } catch { data = null; }
|
|
248
|
+
}
|
|
249
|
+
if (!data || typeof data !== 'object') continue;
|
|
250
|
+
|
|
251
|
+
// Interrupted → resolve immediately with what we have. Every other
|
|
252
|
+
// event counts as activity — including sub-agent events from child
|
|
253
|
+
// sessions flowing on the same stream — and defers completion: we
|
|
254
|
+
// only finish after a silent grace window following the last
|
|
255
|
+
// terminal signal (a long-running tool or sub-agent would otherwise
|
|
256
|
+
// truncate the answer).
|
|
257
|
+
if (ev.type === 'session.execution.interrupted') {
|
|
258
|
+
sseLog(` [SSE] session.execution.interrupted for session ${sessionId || 'any'}\n`);
|
|
259
|
+
finishResolve(collected);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
cancelCompletion();
|
|
143
263
|
|
|
144
|
-
|
|
145
|
-
if (!part) continue;
|
|
264
|
+
if (sessionId && data.sessionID && String(data.sessionID) !== String(sessionId)) continue;
|
|
146
265
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
266
|
+
if (ev.type === 'session.text.started') {
|
|
267
|
+
// v2 emits one text segment per step; separate them so the answer
|
|
268
|
+
// doesn't run adjacent segments together (as the TUI shows them).
|
|
269
|
+
if (collected !== '' && !collected.endsWith('\n\n')) {
|
|
270
|
+
const sep = '\n\n';
|
|
271
|
+
if (callbacks.onText) callbacks.onText(sep);
|
|
272
|
+
collected += sep;
|
|
150
273
|
}
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
if (
|
|
154
|
-
collected +=
|
|
274
|
+
} else if (ev.type === 'session.text.delta') {
|
|
275
|
+
if (data.delta != null && data.delta !== '') {
|
|
276
|
+
if (callbacks.onText) callbacks.onText(data.delta);
|
|
277
|
+
collected += data.delta;
|
|
155
278
|
}
|
|
156
|
-
}
|
|
157
|
-
|
|
279
|
+
} else if (ev.type === 'session.text.ended') {
|
|
280
|
+
if (collected === '' && data.text != null) collected = data.text;
|
|
281
|
+
} else if (ev.type === 'session.reasoning.delta') {
|
|
282
|
+
if (data.delta != null && data.delta !== '' && callbacks.onThinking) callbacks.onThinking(data.delta);
|
|
283
|
+
} else if (ev.type === 'session.execution.started' || ev.type === 'session.retry.scheduled') {
|
|
284
|
+
// activity → completion already cancelled above
|
|
285
|
+
} else if (ev.type === 'permission.asked') {
|
|
286
|
+
if (callbacks.onPermission) callbacks.onPermission(data);
|
|
287
|
+
} else if (ev.type === 'permission.replied') {
|
|
288
|
+
if (callbacks.onPermissionReplied) callbacks.onPermissionReplied(data);
|
|
289
|
+
} else if (ev.type === 'form.created' || ev.type === 'question.asked') {
|
|
290
|
+
if (callbacks.onQuestion) callbacks.onQuestion(data);
|
|
291
|
+
} else if (ev.type === 'session.execution.succeeded'
|
|
292
|
+
|| ev.type === 'session.execution.failed') {
|
|
293
|
+
sseLog(` [SSE] ${ev.type} for session ${sessionId || 'any'} — awaiting quiescence\n`);
|
|
294
|
+
scheduleCompletion();
|
|
158
295
|
}
|
|
159
296
|
}
|
|
160
297
|
});
|
|
161
298
|
|
|
162
|
-
res.on('end', () =>
|
|
163
|
-
|
|
164
|
-
resolve(collected);
|
|
165
|
-
});
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
const cleanup = () => {
|
|
169
|
-
if (cancelRef) cancelRef.current = null;
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
req.on('error', (err) => {
|
|
173
|
-
cleanup();
|
|
174
|
-
reject(err);
|
|
299
|
+
res.on('end', () => finishResolve(collected));
|
|
300
|
+
res.on('error', () => finishResolve(collected));
|
|
175
301
|
});
|
|
176
302
|
|
|
303
|
+
req.on('error', reject);
|
|
177
304
|
if (cancelRef) cancelRef.current = req;
|
|
178
305
|
req.end();
|
|
179
306
|
});
|
|
180
307
|
}
|
|
181
308
|
|
|
182
|
-
// List
|
|
183
|
-
|
|
309
|
+
// List sessions with a running (foreground) drain, as a Set of session IDs.
|
|
310
|
+
async function getActiveSessions(server) {
|
|
311
|
+
try {
|
|
312
|
+
const base = await apiBase(server);
|
|
313
|
+
const { status, body } = await makeRequest(buildJsonRequest(`${base}/session/active`, 'GET'));
|
|
314
|
+
if (status !== 200) return new Set();
|
|
315
|
+
const data = parseBody(body);
|
|
316
|
+
return new Set(data && typeof data === 'object' ? Object.keys(data) : []);
|
|
317
|
+
} catch {
|
|
318
|
+
return new Set();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// List all sessions from the OpenCode server, optionally filtered by project
|
|
323
|
+
// directory (exact match on the session's location.directory).
|
|
184
324
|
async function listSessions(server, directory) {
|
|
185
|
-
let url = `${server}/session`;
|
|
325
|
+
let url = `${await apiBase(server)}/session`;
|
|
186
326
|
if (directory) url += '?directory=' + encodeURIComponent(directory);
|
|
187
327
|
const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'));
|
|
188
328
|
if (status !== 200) throw new Error(`Failed to list sessions: ${status}`);
|
|
189
|
-
return
|
|
329
|
+
return parseBody(body);
|
|
190
330
|
}
|
|
191
331
|
|
|
192
|
-
// Send a message directly to a specific session
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
// Optional cancelRef allows aborting the HTTP request (for /cancel support).
|
|
332
|
+
// Send a message directly to a specific session. Attaches to the event stream
|
|
333
|
+
// FIRST (so no events are missed), then posts a steered prompt, and resolves
|
|
334
|
+
// with the collected text when execution completes.
|
|
196
335
|
async function sendToSession(server, sessionId, text, agent, cancelRef) {
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
if (agent
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
336
|
+
const encoded = encodeURIComponent(sessionId);
|
|
337
|
+
const base = await apiBase(server);
|
|
338
|
+
if (agent != null) {
|
|
339
|
+
const body = JSON.stringify({ agent });
|
|
340
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/agent`, 'POST', body), body);
|
|
341
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to set agent: ${status}`);
|
|
342
|
+
}
|
|
343
|
+
let sendAt = Date.now();
|
|
344
|
+
let verifyTries = 0;
|
|
345
|
+
return listenV2(server, sessionId, {}, cancelRef, null, {
|
|
346
|
+
start: async () => {
|
|
347
|
+
const body = JSON.stringify({ text, delivery: 'steer' });
|
|
348
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/prompt`, 'POST', body), body, cancelRef);
|
|
349
|
+
if (status !== 200) throw new Error(`Failed to send to session: ${status}`);
|
|
350
|
+
sendAt = Date.now();
|
|
351
|
+
},
|
|
352
|
+
// After a quiescence candidate, only treat the turn as done when the
|
|
353
|
+
// session's idle marker is newer than our send — otherwise the terminal
|
|
354
|
+
// signal probably belonged to an older queued execution (which would
|
|
355
|
+
// produce an empty/partial answer). Give up after a few tryouts so a
|
|
356
|
+
// stalled session can't hang the caller forever.
|
|
357
|
+
verify: async () => {
|
|
358
|
+
verifyTries++;
|
|
359
|
+
if (verifyTries > 5) return true;
|
|
360
|
+
try {
|
|
361
|
+
const s = await getSession(server, sessionId);
|
|
362
|
+
const idle = s && s.time && s.time.idle;
|
|
363
|
+
return !!(idle && idle >= sendAt - 1000);
|
|
364
|
+
} catch {
|
|
365
|
+
return true; // cannot verify → trust the quiescence window
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Create a new session. Optionally set a title and pin it to a project
|
|
372
|
+
// location (directory → Location.PublicRef `{ directory }`).
|
|
373
|
+
async function createSession(server, title, location) {
|
|
374
|
+
const url = `${await apiBase(server)}/session`;
|
|
375
|
+
const payload = {};
|
|
376
|
+
if (title != null) payload.title = title;
|
|
377
|
+
if (location != null) payload.location = { directory: location };
|
|
378
|
+
const body = JSON.stringify(payload);
|
|
215
379
|
const { status, body: responseBody } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
|
|
216
380
|
if (status !== 200) throw new Error(`Failed to create session: ${status}`);
|
|
217
|
-
return
|
|
381
|
+
return parseBody(responseBody);
|
|
218
382
|
}
|
|
219
383
|
|
|
220
|
-
// Update a session's properties (
|
|
221
|
-
//
|
|
384
|
+
// Update a session's properties (title via PATCH, agent/model via dedicated
|
|
385
|
+
// endpoints).
|
|
222
386
|
async function updateSession(server, sessionId, title, agent, model) {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
387
|
+
const encoded = encodeURIComponent(sessionId);
|
|
388
|
+
const base = await apiBase(server);
|
|
389
|
+
let session = null;
|
|
390
|
+
if (title != null) {
|
|
391
|
+
const body = JSON.stringify({ title });
|
|
392
|
+
const { status, body: responseBody } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}`, 'PATCH', body), body);
|
|
393
|
+
// v2 returns 204 No Content on success; the updated session comes from the
|
|
394
|
+
// GET below.
|
|
395
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to update session: ${status}`);
|
|
396
|
+
if (status === 200) session = parseBody(responseBody);
|
|
397
|
+
}
|
|
398
|
+
if (agent != null) {
|
|
399
|
+
const body = JSON.stringify({ agent });
|
|
400
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/agent`, 'POST', body), body);
|
|
401
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to set agent: ${status}`);
|
|
402
|
+
}
|
|
226
403
|
if (model != null) {
|
|
227
|
-
|
|
228
|
-
? model
|
|
404
|
+
const mObj = typeof model === 'string'
|
|
405
|
+
? { id: model }
|
|
229
406
|
: { providerID: model.providerID, id: model.modelID || model.id };
|
|
407
|
+
const body = JSON.stringify({ model: mObj });
|
|
408
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/model`, 'POST', body), body);
|
|
409
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to set model: ${status}`);
|
|
230
410
|
}
|
|
231
|
-
|
|
232
|
-
const body =
|
|
233
|
-
const { status, body: responseBody } = await makeRequest(buildJsonRequest(url, 'PATCH', body), body);
|
|
411
|
+
if (session) return session;
|
|
412
|
+
const { status, body: responseBody } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}`, 'GET'));
|
|
234
413
|
if (status !== 200) throw new Error(`Failed to update session: ${status}`);
|
|
235
|
-
return
|
|
414
|
+
return parseBody(responseBody);
|
|
236
415
|
}
|
|
237
416
|
|
|
238
|
-
// Tell the TUI to navigate to a specific session
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
{ url: `${server}/tui/select-session`, body: JSON.stringify({ sessionID: sessionId }) },
|
|
244
|
-
{ url: `${server}/session/${encoded}/select`, body: '{}' },
|
|
245
|
-
];
|
|
246
|
-
for (const { url, body } of attempts) {
|
|
247
|
-
try {
|
|
248
|
-
const { status } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
|
|
249
|
-
// /session/:id/select returns 200 with HTML even for bad IDs (TUI page),
|
|
250
|
-
// but doesn't actually navigate. Only consider it a success if the
|
|
251
|
-
// response looks like JSON (i.e. not HTML).
|
|
252
|
-
if (status === 200) return;
|
|
253
|
-
} catch {
|
|
254
|
-
// try next approach
|
|
255
|
-
}
|
|
256
|
-
}
|
|
417
|
+
// Tell the TUI to navigate to a specific session. v2 has no HTTP endpoint to
|
|
418
|
+
// steer the TUI; the TUI keeps itself in sync, and ocmux relaunches the TUI on
|
|
419
|
+
// the chosen session instead.
|
|
420
|
+
async function selectSession() {
|
|
421
|
+
return;
|
|
257
422
|
}
|
|
258
423
|
|
|
259
424
|
// Get a single session by ID, including its message history.
|
|
260
|
-
// Tries GET /session/:id first; falls back to filtering the sessions list.
|
|
261
425
|
async function getSession(server, sessionId) {
|
|
262
426
|
const encoded = encodeURIComponent(sessionId);
|
|
263
|
-
|
|
264
|
-
const urls = [
|
|
265
|
-
`${server}/session/${encoded}`,
|
|
266
|
-
`${server}/session/${encoded}/messages`,
|
|
267
|
-
`${server}/session/${encoded}/history`,
|
|
268
|
-
`${server}/session/${encoded}/conversation`,
|
|
269
|
-
`${server}/conversation/${encoded}`,
|
|
270
|
-
];
|
|
427
|
+
const base = await apiBase(server);
|
|
428
|
+
const urls = [`${base}/session/${encoded}`];
|
|
271
429
|
for (const url of urls) {
|
|
272
430
|
try {
|
|
273
431
|
const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'), null, null, 10000);
|
|
274
432
|
if (status === 200) {
|
|
275
433
|
try {
|
|
276
|
-
const parsed =
|
|
434
|
+
const parsed = parseBody(body);
|
|
277
435
|
if (parsed != null) return parsed;
|
|
278
436
|
} catch {}
|
|
279
437
|
}
|
|
280
438
|
} catch {}
|
|
281
439
|
}
|
|
282
|
-
// Fallback: get all sessions and find the one we need
|
|
283
440
|
const sessions = await listSessions(server);
|
|
284
441
|
const match = sessions.find(s => String(s.id) === String(sessionId));
|
|
285
442
|
if (match) return match;
|
|
@@ -288,232 +445,188 @@ async function getSession(server, sessionId) {
|
|
|
288
445
|
|
|
289
446
|
// List all agents from the OpenCode server.
|
|
290
447
|
async function listAgents(server) {
|
|
291
|
-
const url = `${server}/agent`;
|
|
448
|
+
const url = `${await apiBase(server)}/agent`;
|
|
292
449
|
const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'));
|
|
293
450
|
if (status !== 200) throw new Error(`Failed to list agents: ${status}`);
|
|
294
|
-
return
|
|
451
|
+
return parseBody(body);
|
|
295
452
|
}
|
|
296
453
|
|
|
297
|
-
// List all providers
|
|
454
|
+
// List all providers from the OpenCode server.
|
|
298
455
|
async function listProviders(server) {
|
|
299
|
-
const url = `${server}/provider`;
|
|
456
|
+
const url = `${await apiBase(server)}/provider`;
|
|
300
457
|
const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'));
|
|
301
458
|
if (status !== 200) throw new Error(`Failed to list providers: ${status}`);
|
|
302
|
-
return
|
|
459
|
+
return parseBody(body);
|
|
303
460
|
}
|
|
304
461
|
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
async function
|
|
308
|
-
const url = `${server
|
|
309
|
-
const
|
|
310
|
-
if (
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
462
|
+
// List available models as { providerID, id, label: "providerID/id", name, variants: [] }.
|
|
463
|
+
// `name` is the friendly display name (e.g. "Kimi K2") when the server provides it.
|
|
464
|
+
async function listModels(server) {
|
|
465
|
+
const url = `${await apiBase(server)}/model`;
|
|
466
|
+
const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'));
|
|
467
|
+
if (status !== 200) throw new Error(`Failed to list models: ${status}`);
|
|
468
|
+
const data = parseBody(body) || [];
|
|
469
|
+
const out = [];
|
|
470
|
+
const toVariants = (variants) => (Array.isArray(variants)
|
|
471
|
+
? variants.map(v => (typeof v === 'string' ? v : (v && v.id))).filter(Boolean)
|
|
472
|
+
: []);
|
|
473
|
+
const push = (providerID, id, name, variants, context) => {
|
|
474
|
+
if (!providerID || !id) return;
|
|
475
|
+
if (out.some(m => m.providerID === providerID && m.id === id)) return;
|
|
476
|
+
out.push({
|
|
477
|
+
providerID,
|
|
478
|
+
id,
|
|
479
|
+
label: `${providerID}/${id}`,
|
|
480
|
+
name: name && String(name) !== String(id) ? String(name) : null,
|
|
481
|
+
variants: toVariants(variants),
|
|
482
|
+
context: context || null,
|
|
483
|
+
});
|
|
484
|
+
};
|
|
485
|
+
for (const m of data) push(m.providerID, m.id, m.name || m.label, m.variants, m.limit && m.limit.context);
|
|
486
|
+
return out;
|
|
316
487
|
}
|
|
317
488
|
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
489
|
+
// Parse a model reference: "providerID/modelID", "modelID", optionally with a
|
|
490
|
+
// "#variant" suffix. Returns { providerID, id, variant } (providerID may be null).
|
|
491
|
+
function parseModelRef(ref) {
|
|
492
|
+
const s = String(ref || '').trim();
|
|
493
|
+
if (!s) return null;
|
|
494
|
+
let variant = null;
|
|
495
|
+
let base = s;
|
|
496
|
+
const hashIdx = s.indexOf('#');
|
|
497
|
+
if (hashIdx !== -1) {
|
|
498
|
+
variant = s.slice(hashIdx + 1) || null;
|
|
499
|
+
base = s.slice(0, hashIdx);
|
|
500
|
+
}
|
|
501
|
+
const slashIdx = base.indexOf('/');
|
|
502
|
+
if (slashIdx === -1) return { providerID: null, id: base, variant };
|
|
503
|
+
return { providerID: base.slice(0, slashIdx) || null, id: base.slice(slashIdx + 1) || null, variant };
|
|
326
504
|
}
|
|
327
505
|
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
res.resume();
|
|
352
|
-
reject(new Error('OpenCode server authentication failed. Set OPENCODE_SERVER_PASSWORD to match the server password.'));
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
sseLog(` [SSE] connected for session ${sessionId}\n`);
|
|
356
|
-
if (callbacks.onConnected) callbacks.onConnected();
|
|
357
|
-
let buffer = '';
|
|
358
|
-
const userMessageIDs = new Set();
|
|
359
|
-
let collected = '';
|
|
360
|
-
let resolved = false;
|
|
361
|
-
|
|
362
|
-
// Safety timeout: resolve with whatever we have after 90s
|
|
363
|
-
const safetyTimer = setTimeout(() => {
|
|
364
|
-
if (resolved) return;
|
|
365
|
-
resolved = true;
|
|
366
|
-
sseLog(` [SSE] safety timeout (90s) for session ${sessionId}, collected ${collected.length} chars\n`);
|
|
367
|
-
cleanup();
|
|
368
|
-
req.destroy();
|
|
369
|
-
res.destroy();
|
|
370
|
-
resolve(collected);
|
|
371
|
-
}, 90000);
|
|
372
|
-
|
|
373
|
-
res.on('data', (chunk) => {
|
|
374
|
-
buffer += chunk.toString();
|
|
375
|
-
while (buffer.includes('\n\n')) {
|
|
376
|
-
const eventEnd = buffer.indexOf('\n\n');
|
|
377
|
-
const eventData = buffer.slice(0, eventEnd);
|
|
378
|
-
buffer = buffer.slice(eventEnd + 2);
|
|
379
|
-
|
|
380
|
-
const lines = eventData.split('\n');
|
|
381
|
-
let jsonStr = '';
|
|
382
|
-
for (const line of lines) {
|
|
383
|
-
if (line.startsWith('data: ')) jsonStr += line.slice(6);
|
|
384
|
-
}
|
|
385
|
-
if (!jsonStr.trim()) continue;
|
|
386
|
-
|
|
387
|
-
try {
|
|
388
|
-
const event = JSON.parse(jsonStr);
|
|
389
|
-
const props = event.properties || {};
|
|
390
|
-
|
|
391
|
-
if (event.type === 'session.status') {
|
|
392
|
-
sseLog(` [SSE] session.status: type=${props.status?.type} status=${JSON.stringify(props.status)}\n`);
|
|
393
|
-
} else {
|
|
394
|
-
sseLog(` [SSE] event type=${event.type} sessionID=${props.sessionID} waitSession=${sessionId}\n`);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Filter by sessionID when the event carries one (string-compare to handle type mismatches)
|
|
398
|
-
if (props.sessionID && String(props.sessionID) !== String(sessionId)) continue;
|
|
399
|
-
|
|
400
|
-
if (event.type === 'message.updated' && props.info?.role === 'user') {
|
|
401
|
-
userMessageIDs.add(props.info.id);
|
|
402
|
-
continue;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
if (event.type === 'permission.asked') {
|
|
406
|
-
const permStr = JSON.stringify(props);
|
|
407
|
-
sseLog(` [SSE] PERMISSION ASKED: ${permStr}\n`);
|
|
408
|
-
if (callbacks.onPermission) {
|
|
409
|
-
sseLog(` [SSE] calling onPermission callback\n`);
|
|
410
|
-
callbacks.onPermission(props);
|
|
411
|
-
} else {
|
|
412
|
-
sseLog(` [SSE] WARNING: no onPermission callback registered\n`);
|
|
413
|
-
}
|
|
414
|
-
continue;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
if (event.type === 'permission.replied') {
|
|
418
|
-
sseLog(` [SSE] PERMISSION REPLIED: type=${props.type}\n`);
|
|
419
|
-
if (callbacks.onPermissionReplied) {
|
|
420
|
-
callbacks.onPermissionReplied(props);
|
|
421
|
-
}
|
|
422
|
-
continue;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
if (event.type === 'question.asked') {
|
|
426
|
-
const qStr = JSON.stringify(props);
|
|
427
|
-
sseLog(` [SSE] QUESTION ASKED: ${qStr}\n`);
|
|
428
|
-
if (callbacks.onQuestion) {
|
|
429
|
-
sseLog(` [SSE] calling onQuestion callback\n`);
|
|
430
|
-
callbacks.onQuestion(props);
|
|
431
|
-
} else {
|
|
432
|
-
sseLog(` [SSE] WARNING: no onQuestion callback registered\n`);
|
|
433
|
-
}
|
|
434
|
-
continue;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// Completion signals
|
|
438
|
-
if (event.type === 'session.idle') {
|
|
439
|
-
clearTimeout(safetyTimer);
|
|
440
|
-
if (resolved) return;
|
|
441
|
-
resolved = true;
|
|
442
|
-
cleanup();
|
|
443
|
-
req.destroy();
|
|
444
|
-
res.destroy();
|
|
445
|
-
resolve(collected);
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
if (event.type === 'session.status' && props.status?.type === 'idle') {
|
|
449
|
-
clearTimeout(safetyTimer);
|
|
450
|
-
if (resolved) return;
|
|
451
|
-
resolved = true;
|
|
452
|
-
cleanup();
|
|
453
|
-
req.destroy();
|
|
454
|
-
res.destroy();
|
|
455
|
-
resolve(collected);
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
const part = props.part;
|
|
460
|
-
if (!part) continue;
|
|
461
|
-
|
|
462
|
-
// Skip echoed user input
|
|
463
|
-
if (part.messageID && userMessageIDs.has(part.messageID)) continue;
|
|
464
|
-
|
|
465
|
-
if (part.type === 'text' && part.text != null && part.text !== '') {
|
|
466
|
-
if (callbacks.onText) callbacks.onText(part.text);
|
|
467
|
-
collected += part.text;
|
|
468
|
-
}
|
|
506
|
+
// Resolve a (possibly partial) model reference against available models.
|
|
507
|
+
// Returns { model, matches, error }; `model` is set only when exactly one
|
|
508
|
+
// candidate matches. Matching is case-insensitive and, besides the canonical
|
|
509
|
+
// "providerID/modelID" label and bare id, also matches the friendly display
|
|
510
|
+
// name (e.g. "Kimi K2"); separators/quotes are ignored so "deep seek" matches
|
|
511
|
+
// "DeepSeek V4" and "deepseek-v4-flash".
|
|
512
|
+
function resolveModelRef(models, ref) {
|
|
513
|
+
const q = String(ref || '').trim();
|
|
514
|
+
if (!q) return { model: null, matches: models, error: null };
|
|
515
|
+
const parsed = parseModelRef(q);
|
|
516
|
+
const variant = parsed ? parsed.variant : null;
|
|
517
|
+
const baseQ = parsed
|
|
518
|
+
? (parsed.providerID ? `${parsed.providerID}/${parsed.id}` : parsed.id)
|
|
519
|
+
: q;
|
|
520
|
+
const baseQLower = baseQ.toLowerCase();
|
|
521
|
+
const norm = (s) => String(s || '').toLowerCase().replace(/[\s\-_.'’]/g, '');
|
|
522
|
+
|
|
523
|
+
// 1) Exact canonical label ("providerID/modelID").
|
|
524
|
+
const byLabel = models.filter(m => m.label.toLowerCase() === baseQLower);
|
|
525
|
+
if (byLabel.length === 1) return { model: { ...byLabel[0], variant }, matches: byLabel, error: null };
|
|
526
|
+
if (byLabel.length > 1) {
|
|
527
|
+
return { model: null, matches: byLabel, error: `multiple models match "${q}" (${byLabel.length}): ${byLabel.map(m => m.label).join(', ')}` };
|
|
528
|
+
}
|
|
469
529
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
530
|
+
// 2) Exact bare id (only when the reference has no provider prefix).
|
|
531
|
+
if (!parsed || !parsed.providerID) {
|
|
532
|
+
const byId = models.filter(m => m.id.toLowerCase() === baseQLower);
|
|
533
|
+
if (byId.length === 1) return { model: { ...byId[0], variant }, matches: byId, error: null };
|
|
534
|
+
if (byId.length > 1) {
|
|
535
|
+
return { model: null, matches: byId, error: `multiple models match "${q}" (${byId.length}): ${byId.map(m => m.label).join(', ')}` };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
478
538
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
539
|
+
// 3) Substring match on label, bare id, or display name (raw and normalized).
|
|
540
|
+
const qLower = q.toLowerCase();
|
|
541
|
+
const qNorm = norm(q);
|
|
542
|
+
const matches = models.filter(m =>
|
|
543
|
+
m.label.toLowerCase().includes(qLower)
|
|
544
|
+
|| m.id.toLowerCase().includes(qLower)
|
|
545
|
+
|| (m.name != null && m.name.toLowerCase().includes(qLower))
|
|
546
|
+
|| (qNorm !== '' && (norm(m.label).includes(qNorm) || norm(m.id).includes(qNorm) || (m.name != null && norm(m.name).includes(qNorm))))
|
|
547
|
+
);
|
|
548
|
+
if (matches.length === 1) return { model: { ...matches[0], variant }, matches, error: null };
|
|
549
|
+
const error = matches.length === 0
|
|
550
|
+
? `no model matches "${q}"`
|
|
551
|
+
: `multiple models match "${q}" (${matches.length}): ${matches.map(m => m.label).join(', ')}`;
|
|
552
|
+
return { model: null, matches, error };
|
|
553
|
+
}
|
|
486
554
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
555
|
+
// Switch the session's model immediately (POST /api/session/:id/model).
|
|
556
|
+
async function switchModel(server, sessionId, model) {
|
|
557
|
+
const encoded = encodeURIComponent(sessionId);
|
|
558
|
+
const mObj = model && typeof model === 'object' ? model : (parseModelRef(model) || {});
|
|
559
|
+
if (!mObj.id || !mObj.providerID) {
|
|
560
|
+
throw new Error(`Invalid model reference: ${model}`);
|
|
561
|
+
}
|
|
562
|
+
const base = await apiBase(server);
|
|
563
|
+
const bodyObj = { model: { providerID: mObj.providerID, id: mObj.id } };
|
|
564
|
+
if (mObj.variant) bodyObj.model.variant = mObj.variant;
|
|
565
|
+
const body = JSON.stringify(bodyObj);
|
|
566
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/model`, 'POST', body), body);
|
|
567
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to switch model: ${status}`);
|
|
568
|
+
}
|
|
496
569
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
570
|
+
// Send a message asynchronously (non-blocking enqueue). Optionally set an
|
|
571
|
+
// agent/model first. v2's noReply has no equivalent; the prompt is queued.
|
|
572
|
+
async function sendToSessionAsync(server, sessionId, text, agent, model) {
|
|
573
|
+
const encoded = encodeURIComponent(sessionId);
|
|
574
|
+
const base = await apiBase(server);
|
|
575
|
+
if (agent != null) {
|
|
576
|
+
const body = JSON.stringify({ agent });
|
|
577
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/agent`, 'POST', body), body);
|
|
578
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to set agent: ${status}`);
|
|
579
|
+
}
|
|
580
|
+
if (model != null) {
|
|
581
|
+
const mObj = typeof model === 'string'
|
|
582
|
+
? { id: model }
|
|
583
|
+
: { providerID: model.providerID, id: model.modelID || model.id };
|
|
584
|
+
const body = JSON.stringify({ model: mObj });
|
|
585
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/model`, 'POST', body), body);
|
|
586
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to set model: ${status}`);
|
|
587
|
+
}
|
|
588
|
+
const body = JSON.stringify({ text, delivery: 'queue' });
|
|
589
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/prompt`, 'POST', body), body);
|
|
590
|
+
if (status !== 200 && status !== 201 && status !== 202 && status !== 204) {
|
|
591
|
+
throw new Error(`Failed to send async message: ${status}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
500
594
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
595
|
+
// Respond to a permission request (v2 decision enum: once | always | reject).
|
|
596
|
+
async function respondToPermission(server, sessionId, permissionId, response, remember) {
|
|
597
|
+
const encodedS = encodeURIComponent(sessionId);
|
|
598
|
+
const encodedP = encodeURIComponent(permissionId);
|
|
599
|
+
const base = await apiBase(server);
|
|
600
|
+
let decision = response === 'allow' ? 'once' : response;
|
|
601
|
+
if (remember && decision !== 'reject') decision = 'always';
|
|
602
|
+
if (!['once', 'always', 'reject'].includes(decision)) decision = 'once';
|
|
603
|
+
const body = JSON.stringify({ decision });
|
|
604
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encodedS}/permission/${encodedP}/reply`, 'POST', body), body);
|
|
605
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to respond to permission: ${status}`);
|
|
606
|
+
}
|
|
505
607
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
608
|
+
// Listen for session events via the v2 SSE stream, collecting assistant text
|
|
609
|
+
// parts and detecting permission requests. Resolves with the collected text.
|
|
610
|
+
// callbacks: { onText, onPermission, onThinking, onConnected, onQuestion }
|
|
611
|
+
// cancelRef — allows aborting the SSE stream via req.destroy()
|
|
612
|
+
// logFn — optional logger
|
|
613
|
+
function listenForSessionEvents(server, sessionId, callbacks, cancelRef, logFn) {
|
|
614
|
+
return listenV2(server, sessionId, callbacks, cancelRef, logFn, null);
|
|
509
615
|
}
|
|
510
616
|
|
|
511
|
-
// Check if an OpenCode server is reachable
|
|
512
|
-
// Returns a Promise
|
|
617
|
+
// Check if an OpenCode server is reachable (any HTTP response = alive).
|
|
618
|
+
// Returns a Promise resolving to true/false.
|
|
513
619
|
function isServerAlive(url) {
|
|
514
620
|
return new Promise((resolve) => {
|
|
515
|
-
const
|
|
516
|
-
const
|
|
621
|
+
const base = String(url).replace(/\/+$/, '');
|
|
622
|
+
const mod = base.startsWith('https') ? https : http;
|
|
623
|
+
let parsed;
|
|
624
|
+
try {
|
|
625
|
+
parsed = new URL(`${base}/api/info`);
|
|
626
|
+
} catch {
|
|
627
|
+
resolve(false);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
517
630
|
const opts = {
|
|
518
631
|
hostname: parsed.hostname,
|
|
519
632
|
port: parsed.port,
|
|
@@ -528,28 +641,106 @@ function isServerAlive(url) {
|
|
|
528
641
|
req.on('error', () => resolve(false));
|
|
529
642
|
req.setTimeout(5000, () => { req.destroy(); resolve(false); });
|
|
530
643
|
req.end();
|
|
531
|
-
});
|
|
644
|
+
}).catch(() => false);
|
|
532
645
|
}
|
|
533
646
|
|
|
534
|
-
// Respond to a question (choice) asked by the AI.
|
|
647
|
+
// Respond to a question (choice) asked by the AI (v2 form reply).
|
|
535
648
|
async function respondToQuestion(server, sessionId, questionId, answer) {
|
|
536
|
-
const
|
|
649
|
+
const encodedS = encodeURIComponent(sessionId);
|
|
650
|
+
const encodedQ = encodeURIComponent(questionId);
|
|
651
|
+
const base = await apiBase(server);
|
|
537
652
|
const body = JSON.stringify({ answer });
|
|
538
|
-
const { status } = await makeRequest(buildJsonRequest(
|
|
539
|
-
if (status !== 200) throw new Error(`Failed to respond to question: ${status}`);
|
|
653
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encodedS}/form/${encodedQ}/reply`, 'POST', body), body);
|
|
654
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to respond to question: ${status}`);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Sort sessions most-recently-viewed first (`time.viewed`), tie-broken by
|
|
658
|
+
// `time.updated`, then `time.created`. Used for target selection so agentp and
|
|
659
|
+
// ocmux line up with what the TUI last showed.
|
|
660
|
+
function sortSessionsByRecency(sessions) {
|
|
661
|
+
const score = (s) => {
|
|
662
|
+
const t = (s && s.time) || {};
|
|
663
|
+
return t.viewed || t.updated || t.created || 0;
|
|
664
|
+
};
|
|
665
|
+
return [...(sessions || [])].sort((a, b) => score(b) - score(a));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Delete a session (DELETE /api/session/:id).
|
|
669
|
+
async function deleteSession(server, sessionId) {
|
|
670
|
+
const encoded = encodeURIComponent(sessionId);
|
|
671
|
+
const base = await apiBase(server);
|
|
672
|
+
const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}`, 'DELETE'));
|
|
673
|
+
if (status !== 200 && status !== 204) throw new Error(`Failed to delete session: ${status}`);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Interrupt a session's active execution (like pressing ESC in the TUI).
|
|
677
|
+
// Returns true when an execution was actually interrupted, false otherwise
|
|
678
|
+
// (idle / already finished). Best-effort: never throws.
|
|
679
|
+
async function interruptSession(server, sessionId) {
|
|
680
|
+
try {
|
|
681
|
+
const encoded = encodeURIComponent(sessionId);
|
|
682
|
+
const base = await apiBase(server);
|
|
683
|
+
const { status, body } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/interrupt`, 'POST'));
|
|
684
|
+
if (status !== 200) return false;
|
|
685
|
+
try {
|
|
686
|
+
const d = parseBody(body);
|
|
687
|
+
return !!(d && d.interrupted);
|
|
688
|
+
} catch {
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
} catch {
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// The server's default model as { providerID, id, variant? }, or null.
|
|
697
|
+
async function getDefaultModel(server) {
|
|
698
|
+
try {
|
|
699
|
+
const base = await apiBase(server);
|
|
700
|
+
const { status, body } = await makeRequest(buildJsonRequest(`${base}/model/default`, 'GET'), null, null, 5000);
|
|
701
|
+
if (status !== 200) return null;
|
|
702
|
+
const d = parseBody(body);
|
|
703
|
+
if (!d) return null;
|
|
704
|
+
const providerID = d.providerID;
|
|
705
|
+
const id = d.id || d.modelID;
|
|
706
|
+
if (!providerID || !id) return null;
|
|
707
|
+
return { providerID, id, variant: d.variant };
|
|
708
|
+
} catch {
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Create a session and ensure it has a model. v2 sessions created via the API
|
|
714
|
+
// start with NO model and will not execute prompts until one is set (the prompt
|
|
715
|
+
// just sits in the inbox → an empty answer). Prefer the model of a reference
|
|
716
|
+
// session (e.g. the newest one in the project); fall back to the server default.
|
|
717
|
+
async function createSessionWithModel(server, title, location, referenceSessions) {
|
|
718
|
+
const created = await createSession(server, title, location);
|
|
719
|
+
let model = null;
|
|
720
|
+
const ref = (referenceSessions || []).find(s => s && s.model && s.model.providerID && (s.model.id || s.model.modelID));
|
|
721
|
+
if (ref) {
|
|
722
|
+
model = {
|
|
723
|
+
providerID: ref.model.providerID,
|
|
724
|
+
id: ref.model.id || ref.model.modelID,
|
|
725
|
+
variant: ref.model.variant,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
if (!model) model = await getDefaultModel(server);
|
|
729
|
+
if (model && model.providerID && model.id) {
|
|
730
|
+
await switchModel(server, created.id, model).catch(() => {});
|
|
731
|
+
}
|
|
732
|
+
return created;
|
|
540
733
|
}
|
|
541
734
|
|
|
542
735
|
module.exports = {
|
|
543
736
|
getAuthHeaders,
|
|
544
737
|
makeRequest,
|
|
545
738
|
buildJsonRequest,
|
|
546
|
-
clearPrompt,
|
|
547
|
-
appendPrompt,
|
|
548
|
-
submitPrompt,
|
|
549
739
|
sendText,
|
|
550
740
|
listenForFinalAnswer,
|
|
551
741
|
respondToQuestion,
|
|
552
742
|
listSessions,
|
|
743
|
+
getActiveSessions,
|
|
553
744
|
getSession,
|
|
554
745
|
createSession,
|
|
555
746
|
updateSession,
|
|
@@ -560,5 +751,21 @@ module.exports = {
|
|
|
560
751
|
listenForSessionEvents,
|
|
561
752
|
listAgents,
|
|
562
753
|
listProviders,
|
|
754
|
+
listModels,
|
|
755
|
+
parseModelRef,
|
|
756
|
+
resolveModelRef,
|
|
757
|
+
switchModel,
|
|
563
758
|
isServerAlive,
|
|
564
|
-
|
|
759
|
+
detectV2,
|
|
760
|
+
apiBase,
|
|
761
|
+
parseBody,
|
|
762
|
+
listenV2,
|
|
763
|
+
sortSessionsByRecency,
|
|
764
|
+
deleteSession,
|
|
765
|
+
getDefaultModel,
|
|
766
|
+
createSessionWithModel,
|
|
767
|
+
interruptSession,
|
|
768
|
+
_setApiVersion,
|
|
769
|
+
_resetApiCache,
|
|
770
|
+
_setCompletionGraceMs,
|
|
771
|
+
};
|