agentp 1.13.0 → 2.0.0

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