agentp 1.14.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/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
- // OpenCode v2 detection
17
- //
18
- // OpenCode v2 moved the HTTP API under the `/api` prefix (besides now serving
19
- // the web UI as HTML at the root), wraps responses in a `{ data }` envelope,
20
- // and changed the SSE event schema from `{type, properties}` to
21
- // `{id, type, data}`. Older versions keep the bare paths and old schemas.
22
- // We probe once per server URL and cache the result.
23
- // ---------------------------------------------------------------------------
24
-
25
- let apiVersionOverride = null; // 'v2' | 'legacy' | null (auto-detect)
26
- const v2Servers = new Set();
27
- const legacyServers = new Set();
28
-
29
- // Test hooks: force a protocol version / clear the detection cache.
30
- function _setApiVersion(v) { apiVersionOverride = v; }
31
- function _resetApiCache() {
32
- v2Servers.clear();
33
- legacyServers.clear();
34
- apiVersionOverride = null;
23
+ // v2 is the only supported protocol now; kept for API compatibility.
24
+ function v2Mode() {
25
+ return true;
35
26
  }
36
27
 
37
- // Synchronous fast-path for forced/cached mode; null means auto-detect needed.
38
- function v2Mode(server) {
39
- if (apiVersionOverride === 'v2') return true;
40
- if (apiVersionOverride === 'legacy') return false;
41
- const key = String(server).replace(/\/+$/, '');
42
- if (v2Servers.has(key)) return true;
43
- if (legacyServers.has(key)) return false;
44
- return null;
28
+ function detectV2() {
29
+ return Promise.resolve(true);
45
30
  }
46
31
 
47
- // Returns true when `server` speaks the OpenCode v2 HTTP API.
48
- function detectV2(server) {
49
- const forced = v2Mode(server);
50
- if (forced !== null) return Promise.resolve(forced);
51
- const key = String(server).replace(/\/+$/, '');
52
- return makeRequest(buildJsonRequest(`${key}/api/info`, 'GET'), null, null, 5000)
53
- .then(({ status, body }) => {
54
- let isV2 = status === 401; // a v2 server requires auth; legacy never 401s on /api/info
55
- if (status === 200) {
56
- const t = String(body).trim();
57
- if (t.startsWith('{')) {
58
- try {
59
- const info = JSON.parse(t);
60
- isV2 = !!(info && typeof info.version === 'string');
61
- } catch {
62
- isV2 = false;
63
- }
64
- }
65
- }
66
- (isV2 ? v2Servers : legacyServers).add(key);
67
- return isV2;
68
- })
69
- .catch((err) => {
70
- // makeRequest rejects on 401; a v2 server requires auth (legacy never
71
- // 401s on /api/info), so treat an auth failure as v2.
72
- if (err && /authentication failed/i.test(err.message)) {
73
- v2Servers.add(key);
74
- return true;
75
- }
76
- legacyServers.add(key);
77
- return false;
78
- });
79
- }
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` on v2, `server` on legacy.
82
- function apiBase(server) {
83
- return detectV2(server).then((isV2) => {
84
- const key = String(server).replace(/\/+$/, '');
85
- return isV2 ? `${key}/api` : key;
86
- });
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: ... }`; legacy returns raw JSON.
90
- function parseBody(body, isV2) {
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
- async function clearPrompt(server) {
139
- const url = `${server}/tui/clear-prompt`;
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
- if (await detectV2(server)) {
158
- const base = await apiBase(server);
159
- const { status, body } = await makeRequest(buildJsonRequest(`${base}/session/active`, 'GET'));
160
- if (status !== 200) return;
161
- let active = null;
162
- try {
163
- const data = parseBody(body, true); // data: { <sessionID>: {...} }
164
- const ids = data && typeof data === 'object' ? Object.keys(data) : [];
165
- if (ids.length > 0) active = ids[0];
166
- } catch {}
167
- if (!active) return;
168
- const pbody = JSON.stringify({ text, delivery: 'queue' });
169
- await makeRequest(buildJsonRequest(`${base}/session/${encodeURIComponent(active)}/prompt`, 'POST', pbody), pbody);
170
- return;
171
- }
172
- await clearPrompt(server);
173
- await appendPrompt(server, text);
174
- await submitPrompt(server);
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
- const mode = v2Mode(server);
183
- if (mode === true) return listenV2(server, null, { onText }, cancelRef);
184
- if (mode === false) return listenLegacyFinalAnswer(server, onText, cancelRef);
185
- return detectV2(server).then((isV2) =>
186
- isV2 ? listenV2(server, null, { onText }, cancelRef) : listenLegacyFinalAnswer(server, onText, cancelRef));
112
+ return listenV2(server, null, { onText }, cancelRef);
187
113
  }
188
114
 
189
- // Legacy (pre-v2) SSE final-answer listener: events are `{type, properties}`.
190
- function listenLegacyFinalAnswer(server, onText, cancelRef) {
191
- return new Promise((resolve, reject) => {
192
- const url = `${server}/event`;
193
- const parsed = new URL(url);
194
- const write = typeof onText === 'function' ? onText : null;
195
-
196
- const req = http.request({
197
- hostname: parsed.hostname,
198
- port: parsed.port,
199
- path: parsed.pathname,
200
- method: 'GET',
201
- headers: { 'Accept': 'text/event-stream', ...getAuthHeaders() }
202
- }, (res) => {
203
- if (res.statusCode === 401) {
204
- res.resume();
205
- reject(new Error('OpenCode server authentication failed. Set OPENCODE_SERVER_PASSWORD to match the server password.'));
206
- return;
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.delta') {
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
- || ev.type === 'session.execution.interrupted') {
393
- sseLog(` [SSE] ${ev.type} for session ${sessionId || 'any'}\n`);
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 all sessions from the OpenCode server.
411
- // Optionally filter by project directory.
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, isV2);
329
+ return parseBody(body);
419
330
  }
420
331
 
421
- // Send a message directly to a specific session via the synchronous session API.
422
- // Returns the collected text from all text parts in the response.
423
- // Optionally specify an agent to handle the message.
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
- if (await detectV2(server)) {
428
- const base = await apiBase(server);
429
- if (agent != null) {
430
- const body = JSON.stringify({ agent });
431
- const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/agent`, 'POST', body), body);
432
- if (status !== 200 && status !== 204) throw new Error(`Failed to set agent: ${status}`);
433
- }
434
- // Attach to the event stream FIRST (so no events are missed), then send the
435
- // prompt, and resolve with the collected text when execution completes.
436
- return listenV2(server, sessionId, {}, cancelRef, null, {
437
- start: async () => {
438
- const body = JSON.stringify({ text, delivery: 'steer' });
439
- const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/prompt`, 'POST', body), body, cancelRef);
440
- if (status !== 200) throw new Error(`Failed to send to session: ${status}`);
441
- },
442
- });
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
- const url = `${server}/session/${encoded}/message`;
446
- const bodyObj = { parts: [{ type: 'text', text }] };
447
- if (agent) bodyObj.agent = agent;
448
- const body = JSON.stringify(bodyObj);
449
- const { status, body: responseBody } = await makeRequest(buildJsonRequest(url, 'POST', body), body, cancelRef);
450
- if (status !== 200) throw new Error(`Failed to send to session: ${status}`);
451
- const result = JSON.parse(responseBody);
452
- const texts = (result.parts || [])
453
- .filter(p => p.type === 'text')
454
- .map(p => p.text)
455
- .join('');
456
- return texts;
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 on the OpenCode server. Optionally set a title.
460
- async function createSession(server, title) {
461
- const isV2 = await detectV2(server);
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 body = JSON.stringify(title != null ? { title } : {});
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, isV2);
381
+ return parseBody(responseBody);
467
382
  }
468
383
 
469
- // Update a session's properties (e.g. title, agent, model).
470
- // model can be a string "providerID/modelID" or an object { providerID, modelID }.
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
- if (await detectV2(server)) {
474
- // v2 PATCH accepts only title/metadata/permissions; agent and model have
475
- // dedicated endpoints.
476
- const base = await apiBase(server);
477
- let session = null;
478
- if (title != null) {
479
- const body = JSON.stringify({ title });
480
- const { status, body: responseBody } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}`, 'PATCH', body), body);
481
- if (status !== 200) throw new Error(`Failed to update session: ${status}`);
482
- session = parseBody(responseBody, true);
483
- }
484
- if (agent != null) {
485
- const body = JSON.stringify({ agent });
486
- const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/agent`, 'POST', body), body);
487
- if (status !== 200 && status !== 204) throw new Error(`Failed to set agent: ${status}`);
488
- }
489
- if (model != null) {
490
- const mObj = typeof model === 'string'
491
- ? { id: model }
492
- : { providerID: model.providerID, id: model.modelID || model.id };
493
- const body = JSON.stringify({ model: mObj });
494
- const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/model`, 'POST', body), body);
495
- if (status !== 200 && status !== 204) throw new Error(`Failed to set model: ${status}`);
496
- }
497
- if (session) return session;
498
- // Nothing to patch: return the current session info.
499
- const { status, body: responseBody } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}`, 'GET'));
500
- if (status !== 200) throw new Error(`Failed to update session: ${status}`);
501
- return parseBody(responseBody, true);
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
- bodyObj.model = typeof model === 'string'
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
- const url = `${server}/session/${encoded}`;
513
- const body = JSON.stringify(bodyObj);
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 JSON.parse(responseBody);
414
+ return parseBody(responseBody);
517
415
  }
518
416
 
519
- // Tell the TUI to navigate to a specific session (if a TUI is attached).
520
- // Tries multiple approaches since the endpoint varies by opencode version.
521
- async function selectSession(server, sessionId) {
522
- if (await detectV2(server)) {
523
- // v2 has no HTTP endpoint to steer the TUI to a session; the TUI keeps
524
- // itself in sync. Best effort: no-op.
525
- return;
526
- }
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
- if (await detectV2(server)) {
550
- const base = await apiBase(server);
551
- const urls = [`${base}/session/${encoded}`];
552
- for (const url of urls) {
553
- try {
554
- const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'), null, null, 10000);
555
- if (status === 200) {
556
- try {
557
- const parsed = parseBody(body, true);
558
- if (parsed != null) return parsed;
559
- } catch {}
560
- }
561
- } catch {}
562
- }
563
- const sessions = await listSessions(server);
564
- const match = sessions.find(s => String(s.id) === String(sessionId));
565
- if (match) return match;
566
- throw new Error(`Session ${sessionId} not found`);
567
- }
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 = JSON.parse(body);
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, isV2);
451
+ return parseBody(body);
601
452
  }
602
453
 
603
- // List all providers/models from the OpenCode server.
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, isV2);
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 isV2 = await detectV2(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) || [];
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
- if (isV2) {
633
- const url = `${await apiBase(server)}/model`;
634
- const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'));
635
- if (status !== 200) throw new Error(`Failed to list models: ${status}`);
636
- const data = parseBody(body, true) || [];
637
- for (const m of data) push(m.providerID, m.id, m.name || m.label, m.variants);
638
- return out;
639
- }
640
- const providers = await listProviders(server);
641
- for (const p of providers) {
642
- const pid = p.provider || p.id || p.name;
643
- const models = p.models || [];
644
- for (const m of models) push(pid, m.id || m.model || m.name, m.name, m.variants);
645
- }
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. On v2 POSTs /api/session/:id/model;
716
- // on legacy PATCHes the session's model. `model` may be a string reference or a
717
- // { providerID, id, variant? } object (as produced by resolveModelRef).
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 isV2 = await detectV2(server);
725
- if (isV2) {
726
- const base = await apiBase(server);
727
- const bodyObj = { model: { providerID: mObj.providerID, id: mObj.id } };
728
- if (mObj.variant) bodyObj.model.variant = mObj.variant;
729
- const body = JSON.stringify(bodyObj);
730
- const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/model`, 'POST', body), body);
731
- if (status !== 200 && status !== 204) throw new Error(`Failed to switch model: ${status}`);
732
- return;
733
- }
734
- await updateSession(server, sessionId, null, null, `${mObj.providerID}/${mObj.id}`);
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). Returns 204 No Content.
738
- // Optionally specify a model and/or noReply (injects context without AI response).
739
- async function sendToSessionAsync(server, sessionId, text, agent, model, noReply) {
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
- if (await detectV2(server)) {
742
- const base = await apiBase(server);
743
- if (agent != null) {
744
- const body = JSON.stringify({ agent });
745
- const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/agent`, 'POST', body), body);
746
- if (status !== 200 && status !== 204) throw new Error(`Failed to set agent: ${status}`);
747
- }
748
- if (model != null) {
749
- const mObj = typeof model === 'string'
750
- ? { id: model }
751
- : { providerID: model.providerID, id: model.modelID || model.id };
752
- const body = JSON.stringify({ model: mObj });
753
- const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/model`, 'POST', body), body);
754
- if (status !== 200 && status !== 204) throw new Error(`Failed to set model: ${status}`);
755
- }
756
- // Non-blocking enqueue. (legacy noReply has no v2 equivalent; the prompt is
757
- // queued and executed when the session processes its inbox.)
758
- const body = JSON.stringify({ text, delivery: 'queue' });
759
- const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/prompt`, 'POST', body), body);
760
- if (status !== 200 && status !== 201 && status !== 202 && status !== 204) {
761
- throw new Error(`Failed to send async message: ${status}`);
762
- }
763
- return;
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
- if (await detectV2(server)) {
780
- const base = await apiBase(server);
781
- // v2 decision enum: once | always | reject
782
- let decision = response === 'allow' ? 'once' : response;
783
- if (remember && decision !== 'reject') decision = 'always';
784
- if (!['once', 'always', 'reject'].includes(decision)) decision = 'once';
785
- const body = JSON.stringify({ decision });
786
- const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encodedS}/permission/${encodedP}/reply`, 'POST', body), body);
787
- if (status !== 200 && status !== 204) throw new Error(`Failed to respond to permission: ${status}`);
788
- return;
789
- }
790
- const url = `${server}/session/${encodedS}/permissions/${encodedP}`;
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; if provided, used instead of process.stderr.write
612
+ // logFn — optional logger
803
613
  function listenForSessionEvents(server, sessionId, callbacks, cancelRef, logFn) {
804
- const mode = v2Mode(server);
805
- if (mode === true) return listenV2(server, sessionId, callbacks, cancelRef, logFn, null);
806
- if (mode === false) return listenLegacySessionEvents(server, sessionId, callbacks, cancelRef, logFn);
807
- return detectV2(server).then((isV2) =>
808
- isV2
809
- ? listenV2(server, sessionId, callbacks, cancelRef, logFn, null)
810
- : listenLegacySessionEvents(server, sessionId, callbacks, cancelRef, logFn));
614
+ return listenV2(server, sessionId, callbacks, cancelRef, logFn, null);
811
615
  }
812
616
 
813
- // Legacy (pre-v2) SSE session-event listener: events are `{type, properties}`.
814
- function listenLegacySessionEvents(server, sessionId, callbacks, cancelRef, logFn) {
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 detectV2(url).then((isV2) => new Promise((resolve) => {
620
+ return new Promise((resolve) => {
996
621
  const base = String(url).replace(/\/+$/, '');
997
622
  const mod = base.startsWith('https') ? https : http;
998
- const parsed = new URL(`${base}${isV2 ? '/api' : ''}/session`);
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
- })).catch(() => false);
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
- if (await detectV2(server)) {
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 = JSON.stringify({ answer });
1023
- const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encodedS}/form/${encodedQ}/reply`, 'POST', body), body);
1024
- if (status !== 200 && status !== 204) throw new Error(`Failed to respond to question: ${status}`);
1025
- return;
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
- const url = `${server}/session/${encodedS}/questions/${encodedQ}`;
1028
- const body = JSON.stringify({ answer });
1029
- const { status } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
1030
- if (status !== 200) throw new Error(`Failed to respond to question: ${status}`);
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
+ };