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 +3 -2
- package/bin/ocmux +199 -5
- package/docs/specification.md +8 -3
- package/lib/ocmux.js +30 -5
- package/lib/opencode.js +518 -17
- package/package.json +1 -1
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
|
|
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,
|
|
249
|
+
const respawnR = tmux(['respawn-pane', '-k', '-t', lastPane, tuiAttachCommand(url)]);
|
|
242
250
|
if (respawnR.status !== 0) {
|
|
243
|
-
tmux(['send-keys', '-t', lastPane,
|
|
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
|
-
|
|
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
|
-
|
|
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'.");
|
package/docs/specification.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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,
|
|
208
|
+
const respawnR = _tmux(['respawn-pane', '-k', '-t', lastPane, tuiAttachCommand(url)]);
|
|
186
209
|
if (respawnR.status !== 0) {
|
|
187
|
-
_tmux(['send-keys', '-t', lastPane,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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/${
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
516
|
-
const
|
|
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
|
|
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
|
};
|