agentp 1.12.1 → 1.14.0

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