agentp 1.14.0 → 2.0.1
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 +165 -51
- package/bin/agentp +525 -84
- package/bin/ocmux +1376 -580
- package/docs/specification.md +12 -454
- package/docs/specification_v2.md +652 -0
- package/lib/ocmux.js +123 -197
- package/lib/opencode.js +363 -657
- package/lib/project-state.js +213 -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,84 +20,27 @@ function getAuthHeaders() {
|
|
|
12
20
|
return { 'Authorization': `Basic ${encoded}` };
|
|
13
21
|
}
|
|
14
22
|
|
|
15
|
-
//
|
|
16
|
-
|
|
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;
|
|
23
|
+
// v2 is the only supported protocol now; kept for API compatibility.
|
|
24
|
+
function v2Mode() {
|
|
25
|
+
return true;
|
|
35
26
|
}
|
|
36
27
|
|
|
37
|
-
|
|
38
|
-
|
|
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;
|
|
28
|
+
function detectV2() {
|
|
29
|
+
return Promise.resolve(true);
|
|
45
30
|
}
|
|
46
31
|
|
|
47
|
-
|
|
48
|
-
function
|
|
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
|
-
}
|
|
32
|
+
function _setApiVersion() { /* v2 only — kept as a no-op for compatibility */ }
|
|
33
|
+
function _resetApiCache() { /* v2 only — kept as a no-op for compatibility */ }
|
|
80
34
|
|
|
81
|
-
// Base URL for API requests: `server/api`
|
|
82
|
-
function apiBase(server) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return isV2 ? `${key}/api` : key;
|
|
86
|
-
});
|
|
35
|
+
// Base URL for API requests (v2: `server/api`).
|
|
36
|
+
async function apiBase(server) {
|
|
37
|
+
const key = String(server).replace(/\/+$/, '');
|
|
38
|
+
return `${key}/api`;
|
|
87
39
|
}
|
|
88
40
|
|
|
89
|
-
// v2 wraps responses in `{ data: ... }`;
|
|
90
|
-
function parseBody(body
|
|
41
|
+
// v2 wraps responses in `{ data: ... }`; unwrap it when present.
|
|
42
|
+
function parseBody(body) {
|
|
91
43
|
const parsed = JSON.parse(body);
|
|
92
|
-
if (!isV2) return parsed;
|
|
93
44
|
return parsed && typeof parsed === 'object' && 'data' in parsed ? parsed.data : parsed;
|
|
94
45
|
}
|
|
95
46
|
|
|
@@ -135,155 +86,50 @@ function buildJsonRequest(url, method, body) {
|
|
|
135
86
|
return opts;
|
|
136
87
|
}
|
|
137
88
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
await makeRequest(buildJsonRequest(url, 'POST', '{}'), '{}');
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
async function appendPrompt(server, text) {
|
|
144
|
-
const url = `${server}/tui/append-prompt`;
|
|
145
|
-
const body = JSON.stringify({ text });
|
|
146
|
-
await makeRequest(buildJsonRequest(url, 'POST', body), body);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function submitPrompt(server) {
|
|
150
|
-
const url = `${server}/tui/submit-prompt`;
|
|
151
|
-
await makeRequest(buildJsonRequest(url, 'POST', '{}'), '{}');
|
|
152
|
-
}
|
|
153
|
-
|
|
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.
|
|
89
|
+
// Send a complete text string to the server's ACTIVE session (no TUI prompt
|
|
90
|
+
// endpoints exist in v2).
|
|
156
91
|
async function sendText(server, text) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// Listen for the final answer from the OpenCode event stream.
|
|
178
|
-
// onText(chunk) is called for each text part received (optional; defaults to no-op).
|
|
179
|
-
// cancelRef — if provided, { current: null } is populated with the request so it can be aborted.
|
|
180
|
-
// Returns a Promise that resolves with the full collected response string.
|
|
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);
|
|
105
|
+
}
|
|
106
|
+
|
|
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.
|
|
181
111
|
function listenForFinalAnswer(server, onText, cancelRef) {
|
|
182
|
-
|
|
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));
|
|
112
|
+
return listenV2(server, null, { onText }, cancelRef);
|
|
187
113
|
}
|
|
188
114
|
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
let buffer = '';
|
|
209
|
-
const userMessageIDs = new Set();
|
|
210
|
-
let collected = '';
|
|
211
|
-
|
|
212
|
-
res.on('data', (chunk) => {
|
|
213
|
-
buffer += chunk.toString();
|
|
214
|
-
|
|
215
|
-
// Split by double newline (SSE event separator)
|
|
216
|
-
while (buffer.includes('\n\n')) {
|
|
217
|
-
const eventEnd = buffer.indexOf('\n\n');
|
|
218
|
-
const eventData = buffer.slice(0, eventEnd);
|
|
219
|
-
buffer = buffer.slice(eventEnd + 2);
|
|
220
|
-
|
|
221
|
-
// Extract data lines
|
|
222
|
-
const lines = eventData.split('\n');
|
|
223
|
-
let jsonStr = '';
|
|
224
|
-
|
|
225
|
-
for (const line of lines) {
|
|
226
|
-
if (line.startsWith('data: ')) {
|
|
227
|
-
jsonStr += line.slice(6);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
if (!jsonStr.trim()) continue;
|
|
232
|
-
|
|
233
|
-
try {
|
|
234
|
-
const event = JSON.parse(jsonStr);
|
|
235
|
-
|
|
236
|
-
// Track user message IDs
|
|
237
|
-
if (event.type === 'message.updated' && event.properties?.info?.role === 'user') {
|
|
238
|
-
userMessageIDs.add(event.properties.info.id);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// Check for session.idle to detect completion
|
|
242
|
-
if (event.type === 'session.idle') {
|
|
243
|
-
cleanup();
|
|
244
|
-
req.destroy();
|
|
245
|
-
res.destroy();
|
|
246
|
-
resolve(collected);
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const part = event.properties?.part;
|
|
251
|
-
if (!part) continue;
|
|
252
|
-
|
|
253
|
-
// Filter out user input by checking messageID
|
|
254
|
-
if (part.messageID && userMessageIDs.has(part.messageID)) {
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
if (part.type === 'text' && part.text != null && part.text !== '') {
|
|
259
|
-
if (write) write(part.text);
|
|
260
|
-
collected += part.text;
|
|
261
|
-
}
|
|
262
|
-
} catch (e) {
|
|
263
|
-
// Malformed SSE event — skip and continue processing
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
res.on('end', () => {
|
|
269
|
-
cleanup();
|
|
270
|
-
resolve(collected);
|
|
271
|
-
});
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
const cleanup = () => {
|
|
275
|
-
if (cancelRef) cancelRef.current = null;
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
req.on('error', (err) => {
|
|
279
|
-
cleanup();
|
|
280
|
-
reject(err);
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
if (cancelRef) cancelRef.current = req;
|
|
284
|
-
req.end();
|
|
285
|
-
});
|
|
286
|
-
}
|
|
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; }
|
|
287
133
|
|
|
288
134
|
// OpenCode v2 SSE processor. Events arrive as `data: {id, type, data}` where
|
|
289
135
|
// `data` carries the payload keyed by sessionID. Text streams via
|
|
@@ -314,12 +160,19 @@ function listenV2(server, sessionId, callbacks, cancelRef, logFn, opts) {
|
|
|
314
160
|
let buffer = '';
|
|
315
161
|
let collected = '';
|
|
316
162
|
let resolved = false;
|
|
163
|
+
let completionTimer = null;
|
|
317
164
|
|
|
318
165
|
const cleanup = () => { if (cancelRef) cancelRef.current = null; };
|
|
166
|
+
|
|
167
|
+
const clearCompletionTimer = () => {
|
|
168
|
+
if (completionTimer) { clearTimeout(completionTimer); completionTimer = null; }
|
|
169
|
+
};
|
|
170
|
+
|
|
319
171
|
const finishResolve = (val) => {
|
|
320
172
|
if (resolved) return;
|
|
321
173
|
resolved = true;
|
|
322
174
|
clearTimeout(safetyTimer);
|
|
175
|
+
clearCompletionTimer();
|
|
323
176
|
cleanup();
|
|
324
177
|
req.destroy();
|
|
325
178
|
res.destroy();
|
|
@@ -329,6 +182,7 @@ function listenV2(server, sessionId, callbacks, cancelRef, logFn, opts) {
|
|
|
329
182
|
if (resolved) return;
|
|
330
183
|
resolved = true;
|
|
331
184
|
clearTimeout(safetyTimer);
|
|
185
|
+
clearCompletionTimer();
|
|
332
186
|
cleanup();
|
|
333
187
|
req.destroy();
|
|
334
188
|
res.destroy();
|
|
@@ -340,6 +194,30 @@ function listenV2(server, sessionId, callbacks, cancelRef, logFn, opts) {
|
|
|
340
194
|
finishResolve(collected);
|
|
341
195
|
}, 90000);
|
|
342
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
|
+
|
|
343
221
|
if (opts && opts.start) {
|
|
344
222
|
opts.start().catch(finishReject);
|
|
345
223
|
}
|
|
@@ -370,9 +248,30 @@ function listenV2(server, sessionId, callbacks, cancelRef, logFn, opts) {
|
|
|
370
248
|
}
|
|
371
249
|
if (!data || typeof data !== 'object') continue;
|
|
372
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();
|
|
263
|
+
|
|
373
264
|
if (sessionId && data.sessionID && String(data.sessionID) !== String(sessionId)) continue;
|
|
374
265
|
|
|
375
|
-
if (ev.type === 'session.text.
|
|
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;
|
|
273
|
+
}
|
|
274
|
+
} else if (ev.type === 'session.text.delta') {
|
|
376
275
|
if (data.delta != null && data.delta !== '') {
|
|
377
276
|
if (callbacks.onText) callbacks.onText(data.delta);
|
|
378
277
|
collected += data.delta;
|
|
@@ -381,6 +280,8 @@ function listenV2(server, sessionId, callbacks, cancelRef, logFn, opts) {
|
|
|
381
280
|
if (collected === '' && data.text != null) collected = data.text;
|
|
382
281
|
} else if (ev.type === 'session.reasoning.delta') {
|
|
383
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
|
|
384
285
|
} else if (ev.type === 'permission.asked') {
|
|
385
286
|
if (callbacks.onPermission) callbacks.onPermission(data);
|
|
386
287
|
} else if (ev.type === 'permission.replied') {
|
|
@@ -388,11 +289,9 @@ function listenV2(server, sessionId, callbacks, cancelRef, logFn, opts) {
|
|
|
388
289
|
} else if (ev.type === 'form.created' || ev.type === 'question.asked') {
|
|
389
290
|
if (callbacks.onQuestion) callbacks.onQuestion(data);
|
|
390
291
|
} else if (ev.type === 'session.execution.succeeded'
|
|
391
|
-
|| ev.type === 'session.execution.failed'
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
finishResolve(collected);
|
|
395
|
-
return;
|
|
292
|
+
|| ev.type === 'session.execution.failed') {
|
|
293
|
+
sseLog(` [SSE] ${ev.type} for session ${sessionId || 'any'} — awaiting quiescence\n`);
|
|
294
|
+
scheduleCompletion();
|
|
396
295
|
}
|
|
397
296
|
}
|
|
398
297
|
});
|
|
@@ -407,184 +306,137 @@ function listenV2(server, sessionId, callbacks, cancelRef, logFn, opts) {
|
|
|
407
306
|
});
|
|
408
307
|
}
|
|
409
308
|
|
|
410
|
-
// List
|
|
411
|
-
|
|
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).
|
|
412
324
|
async function listSessions(server, directory) {
|
|
413
|
-
const isV2 = await detectV2(server);
|
|
414
325
|
let url = `${await apiBase(server)}/session`;
|
|
415
326
|
if (directory) url += '?directory=' + encodeURIComponent(directory);
|
|
416
327
|
const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'));
|
|
417
328
|
if (status !== 200) throw new Error(`Failed to list sessions: ${status}`);
|
|
418
|
-
return parseBody(body
|
|
329
|
+
return parseBody(body);
|
|
419
330
|
}
|
|
420
331
|
|
|
421
|
-
// Send a message directly to a specific session
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
// 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.
|
|
425
335
|
async function sendToSession(server, sessionId, text, agent, cancelRef) {
|
|
426
336
|
const encoded = encodeURIComponent(sessionId);
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
});
|
|
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}`);
|
|
443
342
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
+
});
|
|
457
369
|
}
|
|
458
370
|
|
|
459
|
-
// Create a new session
|
|
460
|
-
|
|
461
|
-
|
|
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) {
|
|
462
374
|
const url = `${await apiBase(server)}/session`;
|
|
463
|
-
const
|
|
375
|
+
const payload = {};
|
|
376
|
+
if (title != null) payload.title = title;
|
|
377
|
+
if (location != null) payload.location = { directory: location };
|
|
378
|
+
const body = JSON.stringify(payload);
|
|
464
379
|
const { status, body: responseBody } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
|
|
465
380
|
if (status !== 200) throw new Error(`Failed to create session: ${status}`);
|
|
466
|
-
return parseBody(responseBody
|
|
381
|
+
return parseBody(responseBody);
|
|
467
382
|
}
|
|
468
383
|
|
|
469
|
-
// Update a session's properties (
|
|
470
|
-
//
|
|
384
|
+
// Update a session's properties (title via PATCH, agent/model via dedicated
|
|
385
|
+
// endpoints).
|
|
471
386
|
async function updateSession(server, sessionId, title, agent, model) {
|
|
472
387
|
const encoded = encodeURIComponent(sessionId);
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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);
|
|
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}`);
|
|
502
402
|
}
|
|
503
|
-
|
|
504
|
-
const bodyObj = {};
|
|
505
|
-
if (title != null) bodyObj.title = title;
|
|
506
|
-
if (agent != null) bodyObj.agent = agent;
|
|
507
403
|
if (model != null) {
|
|
508
|
-
|
|
509
|
-
? model
|
|
404
|
+
const mObj = typeof model === 'string'
|
|
405
|
+
? { id: model }
|
|
510
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}`);
|
|
511
410
|
}
|
|
512
|
-
|
|
513
|
-
const body =
|
|
514
|
-
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'));
|
|
515
413
|
if (status !== 200) throw new Error(`Failed to update session: ${status}`);
|
|
516
|
-
return
|
|
414
|
+
return parseBody(responseBody);
|
|
517
415
|
}
|
|
518
416
|
|
|
519
|
-
// Tell the TUI to navigate to a specific session
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
// itself in sync. Best effort: no-op.
|
|
525
|
-
return;
|
|
526
|
-
}
|
|
527
|
-
const encoded = encodeURIComponent(sessionId);
|
|
528
|
-
const attempts = [
|
|
529
|
-
{ url: `${server}/tui/select-session`, body: JSON.stringify({ sessionID: sessionId }) },
|
|
530
|
-
{ url: `${server}/session/${encoded}/select`, body: '{}' },
|
|
531
|
-
];
|
|
532
|
-
for (const { url, body } of attempts) {
|
|
533
|
-
try {
|
|
534
|
-
const { status } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
|
|
535
|
-
// /session/:id/select returns 200 with HTML even for bad IDs (TUI page),
|
|
536
|
-
// but doesn't actually navigate. Only consider it a success if the
|
|
537
|
-
// response looks like JSON (i.e. not HTML).
|
|
538
|
-
if (status === 200) return;
|
|
539
|
-
} catch {
|
|
540
|
-
// try next approach
|
|
541
|
-
}
|
|
542
|
-
}
|
|
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;
|
|
543
422
|
}
|
|
544
423
|
|
|
545
424
|
// Get a single session by ID, including its message history.
|
|
546
|
-
// Tries GET /session/:id first; falls back to filtering the sessions list.
|
|
547
425
|
async function getSession(server, sessionId) {
|
|
548
426
|
const encoded = encodeURIComponent(sessionId);
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
}
|
|
568
|
-
// Try several endpoint patterns that different opencode versions may expose
|
|
569
|
-
const urls = [
|
|
570
|
-
`${server}/session/${encoded}`,
|
|
571
|
-
`${server}/session/${encoded}/messages`,
|
|
572
|
-
`${server}/session/${encoded}/history`,
|
|
573
|
-
`${server}/session/${encoded}/conversation`,
|
|
574
|
-
`${server}/conversation/${encoded}`,
|
|
575
|
-
];
|
|
427
|
+
const base = await apiBase(server);
|
|
428
|
+
const urls = [`${base}/session/${encoded}`];
|
|
576
429
|
for (const url of urls) {
|
|
577
430
|
try {
|
|
578
431
|
const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'), null, null, 10000);
|
|
579
432
|
if (status === 200) {
|
|
580
433
|
try {
|
|
581
|
-
const parsed =
|
|
434
|
+
const parsed = parseBody(body);
|
|
582
435
|
if (parsed != null) return parsed;
|
|
583
436
|
} catch {}
|
|
584
437
|
}
|
|
585
438
|
} catch {}
|
|
586
439
|
}
|
|
587
|
-
// Fallback: get all sessions and find the one we need
|
|
588
440
|
const sessions = await listSessions(server);
|
|
589
441
|
const match = sessions.find(s => String(s.id) === String(sessionId));
|
|
590
442
|
if (match) return match;
|
|
@@ -593,32 +445,32 @@ async function getSession(server, sessionId) {
|
|
|
593
445
|
|
|
594
446
|
// List all agents from the OpenCode server.
|
|
595
447
|
async function listAgents(server) {
|
|
596
|
-
const isV2 = await detectV2(server);
|
|
597
448
|
const url = `${await apiBase(server)}/agent`;
|
|
598
449
|
const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'));
|
|
599
450
|
if (status !== 200) throw new Error(`Failed to list agents: ${status}`);
|
|
600
|
-
return parseBody(body
|
|
451
|
+
return parseBody(body);
|
|
601
452
|
}
|
|
602
453
|
|
|
603
|
-
// List all providers
|
|
454
|
+
// List all providers from the OpenCode server.
|
|
604
455
|
async function listProviders(server) {
|
|
605
|
-
const isV2 = await detectV2(server);
|
|
606
456
|
const url = `${await apiBase(server)}/provider`;
|
|
607
457
|
const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'));
|
|
608
458
|
if (status !== 200) throw new Error(`Failed to list providers: ${status}`);
|
|
609
|
-
return parseBody(body
|
|
459
|
+
return parseBody(body);
|
|
610
460
|
}
|
|
611
461
|
|
|
612
462
|
// List available models as { providerID, id, label: "providerID/id", name, variants: [] }.
|
|
613
463
|
// `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
464
|
async function listModels(server) {
|
|
616
|
-
const
|
|
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) || [];
|
|
617
469
|
const out = [];
|
|
618
470
|
const toVariants = (variants) => (Array.isArray(variants)
|
|
619
471
|
? variants.map(v => (typeof v === 'string' ? v : (v && v.id))).filter(Boolean)
|
|
620
472
|
: []);
|
|
621
|
-
const push = (providerID, id, name, variants) => {
|
|
473
|
+
const push = (providerID, id, name, variants, context) => {
|
|
622
474
|
if (!providerID || !id) return;
|
|
623
475
|
if (out.some(m => m.providerID === providerID && m.id === id)) return;
|
|
624
476
|
out.push({
|
|
@@ -627,22 +479,10 @@ async function listModels(server) {
|
|
|
627
479
|
label: `${providerID}/${id}`,
|
|
628
480
|
name: name && String(name) !== String(id) ? String(name) : null,
|
|
629
481
|
variants: toVariants(variants),
|
|
482
|
+
context: context || null,
|
|
630
483
|
});
|
|
631
484
|
};
|
|
632
|
-
|
|
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
|
-
}
|
|
485
|
+
for (const m of data) push(m.providerID, m.id, m.name || m.label, m.variants, m.limit && m.limit.context);
|
|
646
486
|
return out;
|
|
647
487
|
}
|
|
648
488
|
|
|
@@ -712,290 +552,81 @@ function resolveModelRef(models, ref) {
|
|
|
712
552
|
return { model: null, matches, error };
|
|
713
553
|
}
|
|
714
554
|
|
|
715
|
-
// Switch the session's model immediately
|
|
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).
|
|
555
|
+
// Switch the session's model immediately (POST /api/session/:id/model).
|
|
718
556
|
async function switchModel(server, sessionId, model) {
|
|
719
557
|
const encoded = encodeURIComponent(sessionId);
|
|
720
558
|
const mObj = model && typeof model === 'object' ? model : (parseModelRef(model) || {});
|
|
721
559
|
if (!mObj.id || !mObj.providerID) {
|
|
722
560
|
throw new Error(`Invalid model reference: ${model}`);
|
|
723
561
|
}
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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}`);
|
|
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}`);
|
|
735
568
|
}
|
|
736
569
|
|
|
737
|
-
// Send a message asynchronously (non-blocking).
|
|
738
|
-
//
|
|
739
|
-
async function sendToSessionAsync(server, sessionId, text, agent, model
|
|
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) {
|
|
740
573
|
const encoded = encodeURIComponent(sessionId);
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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;
|
|
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}`);
|
|
764
592
|
}
|
|
765
|
-
const url = `${server}/session/${encoded}/prompt_async`;
|
|
766
|
-
const bodyObj = { parts: [{ type: 'text', text }] };
|
|
767
|
-
if (agent) bodyObj.agent = agent;
|
|
768
|
-
if (model) bodyObj.model = typeof model === 'string' ? model : { providerID: model.providerID, modelID: model.modelID };
|
|
769
|
-
if (noReply) bodyObj.noReply = true;
|
|
770
|
-
const body = JSON.stringify(bodyObj);
|
|
771
|
-
const { status } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
|
|
772
|
-
if (status !== 204) throw new Error(`Failed to send async message: ${status}`);
|
|
773
593
|
}
|
|
774
594
|
|
|
775
|
-
// Respond to a permission request.
|
|
595
|
+
// Respond to a permission request (v2 decision enum: once | always | reject).
|
|
776
596
|
async function respondToPermission(server, sessionId, permissionId, response, remember) {
|
|
777
597
|
const encodedS = encodeURIComponent(sessionId);
|
|
778
598
|
const encodedP = encodeURIComponent(permissionId);
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
const bodyObj = { response };
|
|
792
|
-
if (remember) bodyObj.remember = true;
|
|
793
|
-
const body = JSON.stringify(bodyObj);
|
|
794
|
-
const { status } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
|
|
795
|
-
if (status !== 200) throw new Error(`Failed to respond to permission: ${status}`);
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// Listen for session events via SSE, collecting assistant text parts and detecting
|
|
799
|
-
// permission requests. Resolves with the full collected text when the session goes idle.
|
|
800
|
-
// callbacks: { onText(chunk), onPermission(permission), onThinking(chunk), onConnected(), onQuestion(question) }
|
|
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
|
+
}
|
|
607
|
+
|
|
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 }
|
|
801
611
|
// cancelRef — allows aborting the SSE stream via req.destroy()
|
|
802
|
-
// logFn — optional logger
|
|
612
|
+
// logFn — optional logger
|
|
803
613
|
function listenForSessionEvents(server, sessionId, callbacks, cancelRef, logFn) {
|
|
804
|
-
|
|
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));
|
|
614
|
+
return listenV2(server, sessionId, callbacks, cancelRef, logFn, null);
|
|
811
615
|
}
|
|
812
616
|
|
|
813
|
-
//
|
|
814
|
-
|
|
815
|
-
const sseLog = logFn || ((msg) => {
|
|
816
|
-
const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
817
|
-
process.stderr.write(`${ts} ${msg}`);
|
|
818
|
-
});
|
|
819
|
-
sseLog(` [SSE] connecting to ${server}/event for session ${sessionId}\n`);
|
|
820
|
-
return new Promise((resolve, reject) => {
|
|
821
|
-
const url = `${server}/event`;
|
|
822
|
-
const parsed = new URL(url);
|
|
823
|
-
|
|
824
|
-
const req = http.request({
|
|
825
|
-
hostname: parsed.hostname,
|
|
826
|
-
port: parsed.port,
|
|
827
|
-
path: parsed.pathname,
|
|
828
|
-
method: 'GET',
|
|
829
|
-
headers: { 'Accept': 'text/event-stream', ...getAuthHeaders() }
|
|
830
|
-
}, (res) => {
|
|
831
|
-
if (res.statusCode === 401) {
|
|
832
|
-
res.resume();
|
|
833
|
-
reject(new Error('OpenCode server authentication failed. Set OPENCODE_SERVER_PASSWORD to match the server password.'));
|
|
834
|
-
return;
|
|
835
|
-
}
|
|
836
|
-
sseLog(` [SSE] connected for session ${sessionId}\n`);
|
|
837
|
-
if (callbacks.onConnected) callbacks.onConnected();
|
|
838
|
-
let buffer = '';
|
|
839
|
-
const userMessageIDs = new Set();
|
|
840
|
-
let collected = '';
|
|
841
|
-
let resolved = false;
|
|
842
|
-
|
|
843
|
-
// Safety timeout: resolve with whatever we have after 90s
|
|
844
|
-
const safetyTimer = setTimeout(() => {
|
|
845
|
-
if (resolved) return;
|
|
846
|
-
resolved = true;
|
|
847
|
-
sseLog(` [SSE] safety timeout (90s) for session ${sessionId}, collected ${collected.length} chars\n`);
|
|
848
|
-
cleanup();
|
|
849
|
-
req.destroy();
|
|
850
|
-
res.destroy();
|
|
851
|
-
resolve(collected);
|
|
852
|
-
}, 90000);
|
|
853
|
-
|
|
854
|
-
res.on('data', (chunk) => {
|
|
855
|
-
buffer += chunk.toString();
|
|
856
|
-
while (buffer.includes('\n\n')) {
|
|
857
|
-
const eventEnd = buffer.indexOf('\n\n');
|
|
858
|
-
const eventData = buffer.slice(0, eventEnd);
|
|
859
|
-
buffer = buffer.slice(eventEnd + 2);
|
|
860
|
-
|
|
861
|
-
const lines = eventData.split('\n');
|
|
862
|
-
let jsonStr = '';
|
|
863
|
-
for (const line of lines) {
|
|
864
|
-
if (line.startsWith('data: ')) jsonStr += line.slice(6);
|
|
865
|
-
}
|
|
866
|
-
if (!jsonStr.trim()) continue;
|
|
867
|
-
|
|
868
|
-
try {
|
|
869
|
-
const event = JSON.parse(jsonStr);
|
|
870
|
-
const props = event.properties || {};
|
|
871
|
-
|
|
872
|
-
if (event.type === 'session.status') {
|
|
873
|
-
sseLog(` [SSE] session.status: type=${props.status?.type} status=${JSON.stringify(props.status)}\n`);
|
|
874
|
-
} else {
|
|
875
|
-
sseLog(` [SSE] event type=${event.type} sessionID=${props.sessionID} waitSession=${sessionId}\n`);
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
// Filter by sessionID when the event carries one (string-compare to handle type mismatches)
|
|
879
|
-
if (props.sessionID && String(props.sessionID) !== String(sessionId)) continue;
|
|
880
|
-
|
|
881
|
-
if (event.type === 'message.updated' && props.info?.role === 'user') {
|
|
882
|
-
userMessageIDs.add(props.info.id);
|
|
883
|
-
continue;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
if (event.type === 'permission.asked') {
|
|
887
|
-
const permStr = JSON.stringify(props);
|
|
888
|
-
sseLog(` [SSE] PERMISSION ASKED: ${permStr}\n`);
|
|
889
|
-
if (callbacks.onPermission) {
|
|
890
|
-
sseLog(` [SSE] calling onPermission callback\n`);
|
|
891
|
-
callbacks.onPermission(props);
|
|
892
|
-
} else {
|
|
893
|
-
sseLog(` [SSE] WARNING: no onPermission callback registered\n`);
|
|
894
|
-
}
|
|
895
|
-
continue;
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
if (event.type === 'permission.replied') {
|
|
899
|
-
sseLog(` [SSE] PERMISSION REPLIED: type=${props.type}\n`);
|
|
900
|
-
if (callbacks.onPermissionReplied) {
|
|
901
|
-
callbacks.onPermissionReplied(props);
|
|
902
|
-
}
|
|
903
|
-
continue;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
if (event.type === 'question.asked') {
|
|
907
|
-
const qStr = JSON.stringify(props);
|
|
908
|
-
sseLog(` [SSE] QUESTION ASKED: ${qStr}\n`);
|
|
909
|
-
if (callbacks.onQuestion) {
|
|
910
|
-
sseLog(` [SSE] calling onQuestion callback\n`);
|
|
911
|
-
callbacks.onQuestion(props);
|
|
912
|
-
} else {
|
|
913
|
-
sseLog(` [SSE] WARNING: no onQuestion callback registered\n`);
|
|
914
|
-
}
|
|
915
|
-
continue;
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
// Completion signals
|
|
919
|
-
if (event.type === 'session.idle') {
|
|
920
|
-
clearTimeout(safetyTimer);
|
|
921
|
-
if (resolved) return;
|
|
922
|
-
resolved = true;
|
|
923
|
-
cleanup();
|
|
924
|
-
req.destroy();
|
|
925
|
-
res.destroy();
|
|
926
|
-
resolve(collected);
|
|
927
|
-
return;
|
|
928
|
-
}
|
|
929
|
-
if (event.type === 'session.status' && props.status?.type === 'idle') {
|
|
930
|
-
clearTimeout(safetyTimer);
|
|
931
|
-
if (resolved) return;
|
|
932
|
-
resolved = true;
|
|
933
|
-
cleanup();
|
|
934
|
-
req.destroy();
|
|
935
|
-
res.destroy();
|
|
936
|
-
resolve(collected);
|
|
937
|
-
return;
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
const part = props.part;
|
|
941
|
-
if (!part) continue;
|
|
942
|
-
|
|
943
|
-
// Skip echoed user input
|
|
944
|
-
if (part.messageID && userMessageIDs.has(part.messageID)) continue;
|
|
945
|
-
|
|
946
|
-
if (part.type === 'text' && part.text != null && part.text !== '') {
|
|
947
|
-
if (callbacks.onText) callbacks.onText(part.text);
|
|
948
|
-
collected += part.text;
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
if ((part.type === 'reasoning' || part.type === 'thinking') && part.text != null && part.text !== '') {
|
|
952
|
-
if (callbacks.onThinking) callbacks.onThinking(part.text);
|
|
953
|
-
}
|
|
954
|
-
} catch (e) {
|
|
955
|
-
// Malformed SSE event — skip
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
});
|
|
959
|
-
|
|
960
|
-
res.on('end', () => {
|
|
961
|
-
clearTimeout(safetyTimer);
|
|
962
|
-
if (resolved) return;
|
|
963
|
-
resolved = true;
|
|
964
|
-
cleanup();
|
|
965
|
-
resolve(collected);
|
|
966
|
-
});
|
|
967
|
-
|
|
968
|
-
res.on('error', (err) => {
|
|
969
|
-
clearTimeout(safetyTimer);
|
|
970
|
-
if (resolved) return;
|
|
971
|
-
resolved = true;
|
|
972
|
-
cleanup();
|
|
973
|
-
sseLog(` [SSE] response stream error for session ${sessionId}: ${err.message}\n`);
|
|
974
|
-
resolve(collected); // resolve with what we have
|
|
975
|
-
});
|
|
976
|
-
});
|
|
977
|
-
|
|
978
|
-
const cleanup = () => {
|
|
979
|
-
if (cancelRef) cancelRef.current = null;
|
|
980
|
-
};
|
|
981
|
-
|
|
982
|
-
req.on('error', (err) => {
|
|
983
|
-
cleanup();
|
|
984
|
-
reject(err);
|
|
985
|
-
});
|
|
986
|
-
|
|
987
|
-
if (cancelRef) cancelRef.current = req;
|
|
988
|
-
req.end();
|
|
989
|
-
});
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
// Check if an OpenCode server is reachable by making a GET /session request.
|
|
993
|
-
// Returns a Promise that resolves to `true` (any response) or `false` (connection error / timeout).
|
|
617
|
+
// Check if an OpenCode server is reachable (any HTTP response = alive).
|
|
618
|
+
// Returns a Promise resolving to true/false.
|
|
994
619
|
function isServerAlive(url) {
|
|
995
|
-
return
|
|
620
|
+
return new Promise((resolve) => {
|
|
996
621
|
const base = String(url).replace(/\/+$/, '');
|
|
997
622
|
const mod = base.startsWith('https') ? https : http;
|
|
998
|
-
|
|
623
|
+
let parsed;
|
|
624
|
+
try {
|
|
625
|
+
parsed = new URL(`${base}/api/info`);
|
|
626
|
+
} catch {
|
|
627
|
+
resolve(false);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
999
630
|
const opts = {
|
|
1000
631
|
hostname: parsed.hostname,
|
|
1001
632
|
port: parsed.port,
|
|
@@ -1010,37 +641,106 @@ function isServerAlive(url) {
|
|
|
1010
641
|
req.on('error', () => resolve(false));
|
|
1011
642
|
req.setTimeout(5000, () => { req.destroy(); resolve(false); });
|
|
1012
643
|
req.end();
|
|
1013
|
-
})
|
|
644
|
+
}).catch(() => false);
|
|
1014
645
|
}
|
|
1015
646
|
|
|
1016
|
-
// Respond to a question (choice) asked by the AI.
|
|
647
|
+
// Respond to a question (choice) asked by the AI (v2 form reply).
|
|
1017
648
|
async function respondToQuestion(server, sessionId, questionId, answer) {
|
|
1018
649
|
const encodedS = encodeURIComponent(sessionId);
|
|
1019
650
|
const encodedQ = encodeURIComponent(questionId);
|
|
1020
|
-
|
|
651
|
+
const base = await apiBase(server);
|
|
652
|
+
const body = JSON.stringify({ answer });
|
|
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);
|
|
1021
682
|
const base = await apiBase(server);
|
|
1022
|
-
const body =
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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;
|
|
1026
693
|
}
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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;
|
|
1031
733
|
}
|
|
1032
734
|
|
|
1033
735
|
module.exports = {
|
|
1034
736
|
getAuthHeaders,
|
|
1035
737
|
makeRequest,
|
|
1036
738
|
buildJsonRequest,
|
|
1037
|
-
clearPrompt,
|
|
1038
|
-
appendPrompt,
|
|
1039
|
-
submitPrompt,
|
|
1040
739
|
sendText,
|
|
1041
740
|
listenForFinalAnswer,
|
|
1042
741
|
respondToQuestion,
|
|
1043
742
|
listSessions,
|
|
743
|
+
getActiveSessions,
|
|
1044
744
|
getSession,
|
|
1045
745
|
createSession,
|
|
1046
746
|
updateSession,
|
|
@@ -1060,6 +760,12 @@ module.exports = {
|
|
|
1060
760
|
apiBase,
|
|
1061
761
|
parseBody,
|
|
1062
762
|
listenV2,
|
|
763
|
+
sortSessionsByRecency,
|
|
764
|
+
deleteSession,
|
|
765
|
+
getDefaultModel,
|
|
766
|
+
createSessionWithModel,
|
|
767
|
+
interruptSession,
|
|
1063
768
|
_setApiVersion,
|
|
1064
769
|
_resetApiCache,
|
|
1065
|
-
|
|
770
|
+
_setCompletionGraceMs,
|
|
771
|
+
};
|