agentp 1.13.0 → 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/README.md CHANGED
@@ -47,7 +47,7 @@ Notes:
47
47
 
48
48
  - `agentp` connects to the OpenCode event endpoint over HTTP.
49
49
  - In practice this means running `opencode serve` (or equivalent serve mode) so the port is open.
50
- - `opencode attach` is optional but useful to monitor the full conversation in another terminal/tmux pane.
50
+ - `opencode attach` (older OpenCode) / `opencode --server <url>` (OpenCode v2) is optional but useful to monitor the full conversation in another terminal/tmux pane. `ocmux` picks the right form automatically.
51
51
  - If the OpenCode server is password-protected (`OPENCODE_SERVER_PASSWORD`), both `agentp` and `ocmux` automatically send the required HTTP Basic Auth credentials.
52
52
 
53
53
  ## Usage
@@ -281,6 +281,7 @@ Subcommands:
281
281
  - `--print-logs` passes `--print-logs` to `opencode serve`, which prints server logs to stderr in the server tmux pane.
282
282
  - **`kill [dir]`** — Kill the server found upward from `dir`. Removes its tmux window and state file.
283
283
  - **`resurrect [--print-logs] [dir]`** — Recover a dead/crashed server: reads `.ocmux.json`, kills old tmux window, removes state file, then creates a fresh server + TUI in the same directory. Works even if no tmux window exists (stale state file).
284
+ - **`model [ref]`** — Switch the model of the newest session on the current project's server. With no `ref`, shows an interactive model picker (TTY required). With `ref`, switches directly and prints the server URL on stdout so `agentp $(ocmux model <ref>)` composes: the switch (a side effect) takes effect before agentp sends the next prompt. `ref` supports partial matches when unique (`ocmux model deepseek`, `opencode-go/deepseek`, `...#high`); multiple matches open the picker (or error when not a TTY).
284
285
  - **`switch`** — Interactive session picker: an interactive menu of all running servers (columns: dirname, status, url, full path). Arrow keys or `j`/`k` move the selection; `Enter`/`Space` switches to the selected server's tmux window (the menu stays open, so you can hop between servers); `q` or `Ctrl+C` exits. The currently active server (queried live from tmux on every redraw) is highlighted across the full line width. Prints the URL of the last selected server on exit. Requires a TTY.
285
286
  - **`list`** — List all running servers with their directories, URLs, and status.
286
287
 
@@ -325,7 +326,7 @@ The session API ensures the request is processed even when no TUI is attached, a
325
326
 
326
327
  Operational hint:
327
328
 
328
- - You can keep a separate `opencode attach` view open to see the full run context while `agentp` is used from shell scripts or editor buffers.
329
+ - You can keep a separate TUI view open to see the full run context while `agentp` is used from shell scripts or editor buffers: `opencode --server '<url>' --continue` on OpenCode v2, or `opencode attach --continue '<url>'` on older versions (`ocmux` uses the right one automatically).
329
330
 
330
331
  ## tgagentp
331
332
 
package/bin/ocmux CHANGED
@@ -25,7 +25,9 @@ const {
25
25
  startServer,
26
26
  getActiveServer,
27
27
  activateServer,
28
+ tuiAttachCommand,
28
29
  } = require('../lib/ocmux');
30
+ const { listSessions, listModels, switchModel, resolveModelRef } = require('../lib/opencode');
29
31
 
30
32
  // ---------------------------------------------------------------------------
31
33
  // Constants
@@ -186,6 +188,12 @@ function printHelp() {
186
188
  console.error(' switch Interactively select a server to switch to.');
187
189
  console.error(' j/k or arrows to move, Enter/Space selects (menu stays open),');
188
190
  console.error(' q or Ctrl+C quits. Prints the URL of the last selected server on exit.');
191
+ console.error(' model Switch the model of the newest session on the current server.');
192
+ console.error(' With no argument: interactive model picker (requires a TTY).');
193
+ console.error(' With <ref>: switch directly and print the server URL on stdout,');
194
+ console.error(' so `agentp $(ocmux model <ref>)` switches before the next prompt.');
195
+ console.error(' Partial names work when unique (e.g. "ocmux model deepseek",');
196
+ console.error(' "opencode-go/deepseek", "...#high"). Multiple matches open the picker.');
189
197
  console.error('');
190
198
  console.error(' -- Treat the next argument as a directory even if it matches');
191
199
  console.error(' a subcommand name (e.g. "ocmux -- kill").');
@@ -238,13 +246,13 @@ function cmdSwitch(statefile, longMode) {
238
246
  const panesR = tmux(['list-panes', '-t', `${SESSION}:${winIdx}`, '-F', '#{pane_id}']);
239
247
  const panes = panesR.stdout.trim().split('\n').filter(Boolean);
240
248
  const lastPane = panes[panes.length - 1];
241
- const respawnR = tmux(['respawn-pane', '-k', '-t', lastPane, `opencode attach --continue '${url}'`]);
249
+ const respawnR = tmux(['respawn-pane', '-k', '-t', lastPane, tuiAttachCommand(url)]);
242
250
  if (respawnR.status !== 0) {
243
- tmux(['send-keys', '-t', lastPane, `opencode attach --continue '${url}'`, 'Enter']);
251
+ tmux(['send-keys', '-t', lastPane, tuiAttachCommand(url), 'Enter']);
244
252
  }
245
253
  } else {
246
254
  tmux(['split-window', '-v', '-t', `${SESSION}:${winIdx}`, '-c', serverDir,
247
- `opencode attach --continue '${url}'`]);
255
+ tuiAttachCommand(url)]);
248
256
  }
249
257
  }
250
258
 
@@ -367,6 +375,168 @@ function cmdSwitchMenu(longMode) {
367
375
  draw();
368
376
  }
369
377
 
378
+ // ---------------------------------------------------------------------------
379
+ // Subcommand: model — switch the model of the server's most recent session
380
+ // ---------------------------------------------------------------------------
381
+
382
+ // Newest session id for a server (same heuristic as agentp's default).
383
+ function newestSessionId(sessions) {
384
+ if (!sessions || sessions.length === 0) return null;
385
+ const sorted = sessions
386
+ .filter(s => s && s.id && s.time && (s.time.updated || s.time.created))
387
+ .sort((a, b) => {
388
+ const aTime = (a.time && a.time.updated) || (a.time && a.time.created) || 0;
389
+ const bTime = (b.time && b.time.updated) || (b.time && b.time.created) || 0;
390
+ return bTime - aTime;
391
+ });
392
+ return sorted.length > 0 ? sorted[0].id : (sessions[0] && sessions[0].id) || null;
393
+ }
394
+
395
+ // Interactive model picker (mirrors the ocmux switch menu; single selection).
396
+ // Resolves with the chosen model or null on quit.
397
+ function modelMenu(rows) {
398
+ return new Promise((resolve) => {
399
+ if (!process.stdin.isTTY || rows.length === 0) return resolve(null);
400
+
401
+ // Display name when it differs from the canonical label (e.g. "Kimi K2 (opencode/kimi-k2)").
402
+ const display = (r) => (r.name && r.name !== r.label ? `${r.name} (${r.label})` : r.label);
403
+ const maxLen = Math.max(
404
+ ...rows.map(r => display(r).length),
405
+ ...rows.map(r => (r.variants || []).join(',').length),
406
+ 8,
407
+ );
408
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
409
+ let cursor = 0;
410
+ let done = false;
411
+
412
+ const cleanup = () => {
413
+ process.stderr.write('\x1b[?25h');
414
+ process.stderr.write('\x1b[?1049l');
415
+ try { process.stdin.setRawMode(false); } catch {}
416
+ process.stdin.pause();
417
+ };
418
+ const finish = (value) => {
419
+ if (done) return;
420
+ done = true;
421
+ cleanup();
422
+ process.stdin.removeAllListeners('keypress');
423
+ process.removeListener('SIGINT', onSig);
424
+ resolve(value);
425
+ };
426
+ const onSig = () => finish(null);
427
+ process.on('SIGINT', onSig);
428
+
429
+ const draw = () => {
430
+ let out = '\x1b[2J\x1b[H';
431
+ out += 'ocmux model — j/k or arrows: move, Enter: switch, q/Ctrl+C: quit\n\n';
432
+ for (let i = 0; i < rows.length; i++) {
433
+ const row = rows[i];
434
+ const marker = i === cursor ? '▶ ' : ' ';
435
+ const variants = (row.variants || []).join(',') || '—';
436
+ out += `${marker}${pad(display(row), maxLen)} ${variants}\n`;
437
+ }
438
+ process.stderr.write(out);
439
+ };
440
+
441
+ process.stdin.on('keypress', (str, key) => {
442
+ if (key.ctrl && key.name === 'c') return finish(null);
443
+ if (key.name === 'q') return finish(null);
444
+ if (key.name === 'up' || str === 'k') {
445
+ cursor = (cursor + rows.length - 1) % rows.length;
446
+ draw();
447
+ } else if (key.name === 'down' || str === 'j') {
448
+ cursor = (cursor + 1) % rows.length;
449
+ draw();
450
+ } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
451
+ finish(rows[cursor]);
452
+ }
453
+ });
454
+
455
+ readline.emitKeypressEvents(process.stdin);
456
+ process.stdin.setRawMode(true);
457
+ process.stdin.resume();
458
+ process.stderr.write('\x1b[?1049h\x1b[?25l');
459
+ draw();
460
+ });
461
+ }
462
+
463
+ async function cmdModel(modelRef, longMode) {
464
+ const sf = findParentStatefile(process.cwd());
465
+ if (!sf) {
466
+ console.error(`Error: no opencode server found in ${process.cwd()} or any of its parents.`);
467
+ console.error("Run 'ocmux serve' to create a new server.");
468
+ process.exit(1);
469
+ }
470
+ const state = readState(sf);
471
+ if (!state || !state.url) {
472
+ console.error(`Error: invalid state file ${sf}`);
473
+ process.exit(1);
474
+ }
475
+
476
+ // Fail fast before any HTTP: interactive picker requires a TTY.
477
+ if (!modelRef && !process.stdin.isTTY) {
478
+ console.error('Error: "model" with no argument is interactive and requires a TTY.');
479
+ console.error('Use "ocmux model <ref>" (partial matches allowed when unique) to switch directly.');
480
+ process.exit(1);
481
+ }
482
+
483
+ const sessions = await listSessions(state.url).catch((err) => {
484
+ console.error(`Error connecting to ${state.url}: ${err.message}`);
485
+ process.exit(1);
486
+ });
487
+ const sessionId = newestSessionId(sessions);
488
+ if (!sessionId) {
489
+ console.error(`Error: no sessions found on ${state.url} (${path.dirname(sf)}).`);
490
+ console.error('Open a session in the TUI first, or send a prompt with agentp.');
491
+ process.exit(1);
492
+ }
493
+
494
+ const models = await listModels(state.url).catch((err) => {
495
+ console.error(`Error listing models: ${err.message}`);
496
+ process.exit(1);
497
+ });
498
+ if (models.length === 0) {
499
+ console.error('Error: no models available from this server.');
500
+ process.exit(1);
501
+ }
502
+
503
+ let pick = null;
504
+ if (modelRef) {
505
+ const { model, matches, error } = resolveModelRef(models, modelRef);
506
+ if (model) {
507
+ pick = model;
508
+ } else if (matches.length > 1 && process.stdin.isTTY) {
509
+ console.error(`Multiple models match "${modelRef}", select one:`);
510
+ pick = await modelMenu(matches);
511
+ } else {
512
+ console.error(`Error: ${error}`);
513
+ process.exit(1);
514
+ }
515
+ } else if (process.stdin.isTTY) {
516
+ pick = await modelMenu(models);
517
+ } else {
518
+ // Unreachable (guarded above) — kept for safety.
519
+ console.error('Error: "model" with no argument is interactive and requires a TTY.');
520
+ console.error('Use "ocmux model <ref>" (partial matches allowed when unique) to switch directly.');
521
+ process.exit(1);
522
+ }
523
+
524
+ if (!pick) {
525
+ console.error('Cancelled.');
526
+ return;
527
+ }
528
+
529
+ await switchModel(state.url, sessionId, pick).catch((err) => {
530
+ console.error(`Error switching model: ${err.message}`);
531
+ process.exit(1);
532
+ });
533
+ const label = pick.label + (pick.variant ? `#${pick.variant}` : '');
534
+ console.error(`Switched session ${sessionId} to ${label}.`);
535
+ // Print the URL on stdout so `agentp $(ocmux model <ref>)` composes: the
536
+ // switch (side effect) takes effect before agentp proceeds with the prompt.
537
+ outputUrl(state.url, path.dirname(sf), longMode);
538
+ }
539
+
370
540
  // ---------------------------------------------------------------------------
371
541
  // Subcommand: list
372
542
  // ---------------------------------------------------------------------------
@@ -478,7 +648,7 @@ function doNew(targetDir, longMode, printLogs) {
478
648
  const tuiPane = splitResult.stdout.trim();
479
649
  sleep(0.2);
480
650
  tmux(['send-keys', '-t', tuiPane,
481
- `opencode attach --continue '${url}'`, 'Enter']);
651
+ tuiAttachCommand(url), 'Enter']);
482
652
  sleep(0.5);
483
653
  if (tuiPane) tmux(['resize-pane', '-Z', '-t', tuiPane]);
484
654
 
@@ -611,6 +781,7 @@ function main() {
611
781
 
612
782
  let subcommand = null;
613
783
  let targetDir = null;
784
+ let modelRef = null;
614
785
 
615
786
  for (const arg of positional) {
616
787
  if (dirIsForced) {
@@ -620,12 +791,15 @@ function main() {
620
791
  process.exit(1);
621
792
  }
622
793
  targetDir = resolveDir(arg);
623
- } else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list' || arg === 'resurrect' || arg === 'switch') {
794
+ } else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list' || arg === 'resurrect' || arg === 'switch' || arg === 'model') {
624
795
  if (subcommand) {
625
796
  console.error(`Error: multiple subcommands specified: '${subcommand}' and '${arg}'`);
626
797
  process.exit(1);
627
798
  }
628
799
  subcommand = arg;
800
+ } else if (subcommand === 'model' && modelRef === null) {
801
+ // model reference (supports partial matches) — not a directory
802
+ modelRef = arg;
629
803
  } else {
630
804
  if (targetDir) {
631
805
  console.error(`Error: unexpected argument '${arg}'`);
@@ -695,6 +869,26 @@ function main() {
695
869
  }
696
870
  ensureSession();
697
871
  cmdSwitchMenu(longMode);
872
+ } else if (subcommand === 'model') {
873
+ if (gitMode) {
874
+ console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
875
+ process.exit(1);
876
+ }
877
+ if (printLogs) {
878
+ console.error("Error: '--print-logs' is only valid with 'serve'.");
879
+ process.exit(1);
880
+ }
881
+ if (targetDir !== process.cwd()) {
882
+ console.error("Error: 'model' does not accept a directory path.");
883
+ process.exit(1);
884
+ }
885
+ // cmdModel is async; swallow the (test-mocked) process.exit throw.
886
+ cmdModel(modelRef, longMode).catch((err) => {
887
+ if (!err || !err.message || !err.message.startsWith('EXIT:')) {
888
+ console.error(err ? err.message : String(err));
889
+ process.exit(1);
890
+ }
891
+ });
698
892
  } else if (subcommand === 'kill') {
699
893
  if (gitMode) {
700
894
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
@@ -75,10 +75,13 @@ Manages per-project OpenCode servers in a persistent `Opencode` tmux session.
75
75
  | `new [dir]` | Alias for `serve` (deprecated) |
76
76
  | `kill [dir]` | Kill server, remove tmux window + `.ocmux.json` |
77
77
  | `resurrect [dir]` | Recover dead server: kill old window, remove state file, create fresh server + TUI |
78
+ | `model [ref]` | Switch the model of the newest session (interactive picker without `ref`; prints the server URL on success so `agentp $(ocmux model <ref>)` composes) |
78
79
  | `switch` | Interactive session picker (TTY required) |
79
80
  | `list [-l]` | List all running servers |
80
81
  | _(no arg)_ | Switch to existing server (searches upward for `.ocmux.json`) |
81
82
 
83
+ **`model` behavior:** Targets the newest session on the server found upward from `$PWD`. With no argument it opens an interactive model picker (all models, arrow/`j`/`k`, Enter to switch, `q`/`Ctrl+C` to quit; requires a TTY). With a reference it switches directly; references may be full (`providerID/modelID`), partial (matched uniquely against label or bare id), and carry a variant suffix (`#variant`). On success it writes the server URL to stdout (plus a confirmation to stderr), so `agentp $(ocmux model <ref>)` switches the model before the next prompt is sent. Multiple matches open the picker on a TTY, otherwise error listing the candidates. Rejects `--git`, `--GIT`, `--print-logs`, and directory arguments.
84
+
82
85
  **`switch` behavior:** Renders an interactive menu of all running servers on an alt screen with columns `dirname | status | url | full path`. `j`/`k` or arrow keys move the cursor; `Enter`/`Space` activates the selected server (switches its tmux window) and keeps the menu open; `q`/`Ctrl+C` exits and prints the URL of the last selected server (if any). The row matching the tmux-active window (queried live from tmux via `activeWindowIndex()` on every redraw) is highlighted across the full line width, so the highlight tracks both local activations and external tmux window switches. Errors (exit 1) when no TTY or no servers. Rejects `--git`, `--GIT`, `--print-logs`, and directory arguments.
83
86
 
84
87
  **Default no-server behavior:** When no `.ocmux.json` is found for the current/target directory or any parent, default mode prints the primary `Error: no opencode server found...` line to stdout and the `Run 'ocmux serve'...` hint to stderr. This makes command substitution (`agentp $(ocmux)`) fail safely instead of passing no argument and falling back to `agentp`'s default server.
@@ -88,7 +91,7 @@ Manages per-project OpenCode servers in a persistent `Opencode` tmux session.
88
91
  **Window layout:**
89
92
 
90
93
  - Pane 0: server (`opencode serve --port 0 2>&1 | tee <logfile>`)
91
- - Pane 1+: TUI (`opencode attach --continue '<url>'`)
94
+ - Pane 1+: TUI (`opencode --server '<url>' --continue` on OpenCode v2; `opencode attach --continue '<url>'` on older versions — chosen automatically via `opencode --version`)
92
95
  - Log: `/tmp/opencode-serve-<hashDir(dir)>.log`
93
96
  - State: `<dir>/.ocmux.json` (contains `url`, `logfile`, `window_index`)
94
97
 
@@ -235,6 +238,8 @@ Server log file. Continuously written by `tee` in the server pane. Polled by `do
235
238
  opencode server listening on http://localhost:40999
236
239
  ```
237
240
 
241
+ OpenCode v2 prints `server listening on http://...` (without the `opencode ` prefix); both formats are matched for URL extraction.
242
+
238
243
  ---
239
244
 
240
245
  ## Inter-Process Communication
@@ -281,10 +286,10 @@ All servers live in a single tmux session named `Opencode`. Each project gets on
281
286
  Session: Opencode
282
287
  ├── Window 3: /home/user/project-a
283
288
  │ ├── Pane 0: opencode serve --port 0 ... (server)
284
- │ └── Pane 1: opencode attach --continue ... (TUI, zoomed)
289
+ │ └── Pane 1: TUI (opencode --server <url> --continue / attach, zoomed)
285
290
  ├── Window 4: /home/user/project-b
286
291
  │ ├── Pane 0: opencode serve --port 0 ...
287
- │ └── Pane 1: opencode attach --continue ...
292
+ │ └── Pane 1: TUI (opencode --server <url> --continue / attach)
288
293
  ```
289
294
 
290
295
  Window names are the full project directory path. Pane 0 is always the server; pane 1+ is the TUI. The TUI pane is zoomed on switch/create. Dead TUI panes are auto-restarted on switch.
package/lib/ocmux.js CHANGED
@@ -6,7 +6,30 @@ const crypto = require('crypto');
6
6
  const child_process = require('child_process');
7
7
 
8
8
  const SESSION = 'Opencode';
9
- const URL_RE = /opencode server listening on (http:\/\/[^\s]*)/;
9
+ // OpenCode v2 prints `server listening on http://...`; older versions printed
10
+ // `opencode server listening on http://...`. Accept both.
11
+ const URL_RE = /(?:opencode )?server listening on (http:\/\/[^\s]*)/;
12
+
13
+ // Detect the OpenCode major version: v2 runs the TUI with `opencode --server`,
14
+ // older versions used the `attach` subcommand. Returns null when undetectable.
15
+ function detectOcMajor() {
16
+ const r = child_process.spawnSync('opencode', ['--version'], { encoding: 'utf8' });
17
+ const out = (r.stdout || '') + (r.stderr || '');
18
+ const m = out.match(/v?(\d+)\.(\d+)\./);
19
+ return m ? parseInt(m[1], 10) : null;
20
+ }
21
+
22
+ // Command to attach the TUI to a running server. OpenCode v2 dropped the
23
+ // `attach` subcommand in favor of `opencode --server <url>`; older versions
24
+ // still use `opencode attach --continue <url>`. Falls back to the legacy form
25
+ // when the version cannot be detected (keeps old installations working).
26
+ function tuiAttachCommand(url) {
27
+ const major = detectOcMajor();
28
+ if (major !== null && major >= 2) {
29
+ return `opencode --server '${url}' --continue`;
30
+ }
31
+ return `opencode attach --continue '${url}'`;
32
+ }
10
33
 
11
34
  function _tmux(args) {
12
35
  const result = child_process.spawnSync('tmux', args, { encoding: 'utf8', maxBuffer: 1024 * 1024 });
@@ -182,14 +205,14 @@ function activateServer(dir, index, url) {
182
205
  if (panesR.status === 0) {
183
206
  const panes = panesR.stdout.trim().split('\n').filter(Boolean);
184
207
  const lastPane = panes[panes.length - 1];
185
- const respawnR = _tmux(['respawn-pane', '-k', '-t', lastPane, `opencode attach --continue '${url}'`]);
208
+ const respawnR = _tmux(['respawn-pane', '-k', '-t', lastPane, tuiAttachCommand(url)]);
186
209
  if (respawnR.status !== 0) {
187
- _tmux(['send-keys', '-t', lastPane, `opencode attach --continue '${url}'`, 'Enter']);
210
+ _tmux(['send-keys', '-t', lastPane, tuiAttachCommand(url), 'Enter']);
188
211
  }
189
212
  }
190
213
  } else {
191
214
  _tmux(['split-window', '-v', '-t', `${SESSION}:${winIdx}`, '-c', dir,
192
- `opencode attach --continue '${url}'`]);
215
+ tuiAttachCommand(url)]);
193
216
  }
194
217
  }
195
218
 
@@ -312,7 +335,7 @@ function startServer(dir, printLogs) {
312
335
  const splitResult = _tmux(['split-window', '-v', '-P', '-F', '#{pane_id}', '-t', `${SESSION}:${winIdx}`, '-c', dir]);
313
336
  const tuiPane = splitResult.stdout.trim();
314
337
  sleep(0.2);
315
- _tmux(['send-keys', '-t', tuiPane, `opencode attach --continue '${url}'`, 'Enter']);
338
+ _tmux(['send-keys', '-t', tuiPane, tuiAttachCommand(url), 'Enter']);
316
339
  sleep(0.5);
317
340
  if (tuiPane) _tmux(['resize-pane', '-Z', '-t', tuiPane]);
318
341
 
@@ -346,4 +369,6 @@ module.exports = {
346
369
  startServer,
347
370
  serverStatus,
348
371
  listDeadServers,
372
+ detectOcMajor,
373
+ tuiAttachCommand,
349
374
  };
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.13.0",
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",