agentp 1.12.1 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ocmux CHANGED
@@ -24,7 +24,10 @@ const {
24
24
  resurrectServer,
25
25
  startServer,
26
26
  getActiveServer,
27
+ activateServer,
28
+ tuiAttachCommand,
27
29
  } = require('../lib/ocmux');
30
+ const { listSessions, listModels, switchModel, resolveModelRef } = require('../lib/opencode');
28
31
 
29
32
  // ---------------------------------------------------------------------------
30
33
  // Constants
@@ -182,6 +185,15 @@ function printHelp() {
182
185
  console.error(' Works even if no tmux window exists (stale state file).');
183
186
  console.error(' --print-logs Pass --print-logs to the new opencode serve.');
184
187
  console.error(' list List all running servers with their directories and URLs.');
188
+ console.error(' switch Interactively select a server to switch to.');
189
+ console.error(' j/k or arrows to move, Enter/Space selects (menu stays open),');
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.');
185
197
  console.error('');
186
198
  console.error(' -- Treat the next argument as a directory even if it matches');
187
199
  console.error(' a subcommand name (e.g. "ocmux -- kill").');
@@ -234,13 +246,13 @@ function cmdSwitch(statefile, longMode) {
234
246
  const panesR = tmux(['list-panes', '-t', `${SESSION}:${winIdx}`, '-F', '#{pane_id}']);
235
247
  const panes = panesR.stdout.trim().split('\n').filter(Boolean);
236
248
  const lastPane = panes[panes.length - 1];
237
- const respawnR = tmux(['respawn-pane', '-k', '-t', lastPane, `opencode attach --continue '${url}'`]);
249
+ const respawnR = tmux(['respawn-pane', '-k', '-t', lastPane, tuiAttachCommand(url)]);
238
250
  if (respawnR.status !== 0) {
239
- tmux(['send-keys', '-t', lastPane, `opencode attach --continue '${url}'`, 'Enter']);
251
+ tmux(['send-keys', '-t', lastPane, tuiAttachCommand(url), 'Enter']);
240
252
  }
241
253
  } else {
242
254
  tmux(['split-window', '-v', '-t', `${SESSION}:${winIdx}`, '-c', serverDir,
243
- `opencode attach --continue '${url}'`]);
255
+ tuiAttachCommand(url)]);
244
256
  }
245
257
  }
246
258
 
@@ -253,6 +265,278 @@ function cmdSwitch(statefile, longMode) {
253
265
  outputUrl(url, serverDir, longMode);
254
266
  }
255
267
 
268
+ // ---------------------------------------------------------------------------
269
+ // Subcommand: switch — interactive session selection
270
+ // ---------------------------------------------------------------------------
271
+
272
+ function cmdSwitchMenu(longMode) {
273
+ const rows = listServers();
274
+ if (rows.length === 0) {
275
+ console.error('No opencode servers running.');
276
+ process.exit(1);
277
+ }
278
+ if (!process.stdin.isTTY) {
279
+ console.error('Error: "switch" is interactive and requires a TTY.');
280
+ console.error('Use "ocmux list" to list servers, or run "ocmux <dir>" to switch directly.');
281
+ process.exit(1);
282
+ }
283
+
284
+ const cols = process.stderr.columns || 80;
285
+ const nameW = Math.max(...rows.map(r => path.basename(r.dir).length), 4);
286
+ const stsW = Math.max(...rows.map(r => r.status.length), 6);
287
+ const urlW = Math.max(...rows.map(r => r.url.length), 3);
288
+ const dirW = Math.max(...rows.map(r => r.dir.length), 9);
289
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
290
+ const lineWidth = Math.max(cols, nameW + 2 + stsW + 2 + urlW + 2 + dirW);
291
+
292
+ let cursor = 0;
293
+ let activeRow = 0;
294
+ let lastUrl = null;
295
+ let lastDir = null;
296
+ let exited = false;
297
+
298
+ // Query tmux for the currently active window and map it to a row. Called at
299
+ // init and on every draw, so the highlight always reflects tmux's real state.
300
+ const refreshActive = () => {
301
+ const idx = activeWindowIndex();
302
+ if (idx == null) return;
303
+ for (let i = 0; i < rows.length; i++) {
304
+ if (rows[i].index === idx) { activeRow = i; return; }
305
+ }
306
+ };
307
+
308
+ const cleanup = () => {
309
+ process.stderr.write('\x1b[?25h');
310
+ process.stderr.write('\x1b[?1049l');
311
+ try { process.stdin.setRawMode(false); } catch {}
312
+ };
313
+
314
+ const finish = () => {
315
+ if (exited) return;
316
+ exited = true;
317
+ cleanup();
318
+ process.stdin.pause();
319
+ if (lastUrl) outputUrl(lastUrl, lastDir, longMode);
320
+ process.exit(0);
321
+ };
322
+
323
+ const draw = () => {
324
+ refreshActive();
325
+ let out = '\x1b[2J\x1b[H';
326
+ out += 'ocmux switch — j/k or arrows: move, Enter/Space: select (keep switching), q/Ctrl+C: quit\n\n';
327
+ for (let i = 0; i < rows.length; i++) {
328
+ const row = rows[i];
329
+ const marker = i === cursor ? '▶ ' : ' ';
330
+ const line = `${marker}${pad(path.basename(row.dir), nameW)} ${pad(row.status, stsW)} ${pad(row.url, urlW)} ${pad(row.dir, dirW)}`;
331
+ if (i === activeRow) {
332
+ out += '\x1b[7m' + pad(line, lineWidth) + '\x1b[0m\n';
333
+ } else {
334
+ out += line + '\n';
335
+ }
336
+ }
337
+ process.stderr.write(out);
338
+ };
339
+
340
+ const activate = (i) => {
341
+ const row = rows[i];
342
+ try {
343
+ activateServer(row.dir, row.index, row.url);
344
+ lastUrl = row.url;
345
+ lastDir = row.dir;
346
+ } catch (err) {
347
+ process.stderr.write(`\nError: ${err.message}\n`);
348
+ }
349
+ draw();
350
+ };
351
+
352
+ process.stdin.on('keypress', (str, key) => {
353
+ if (key.ctrl && key.name === 'c') return finish();
354
+ if (key.name === 'q') return finish();
355
+ if (key.name === 'up' || str === 'k') {
356
+ cursor = (cursor + rows.length - 1) % rows.length;
357
+ draw();
358
+ } else if (key.name === 'down' || str === 'j') {
359
+ cursor = (cursor + 1) % rows.length;
360
+ draw();
361
+ } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
362
+ activate(cursor);
363
+ }
364
+ });
365
+
366
+ process.on('SIGINT', finish);
367
+ process.on('SIGTERM', finish);
368
+
369
+ readline.emitKeypressEvents(process.stdin);
370
+ process.stdin.setRawMode(true);
371
+ process.stdin.resume();
372
+ process.stderr.write('\x1b[?1049h\x1b[?25l');
373
+ refreshActive();
374
+ cursor = activeRow;
375
+ draw();
376
+ }
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
+
256
540
  // ---------------------------------------------------------------------------
257
541
  // Subcommand: list
258
542
  // ---------------------------------------------------------------------------
@@ -364,7 +648,7 @@ function doNew(targetDir, longMode, printLogs) {
364
648
  const tuiPane = splitResult.stdout.trim();
365
649
  sleep(0.2);
366
650
  tmux(['send-keys', '-t', tuiPane,
367
- `opencode attach --continue '${url}'`, 'Enter']);
651
+ tuiAttachCommand(url), 'Enter']);
368
652
  sleep(0.5);
369
653
  if (tuiPane) tmux(['resize-pane', '-Z', '-t', tuiPane]);
370
654
 
@@ -497,6 +781,7 @@ function main() {
497
781
 
498
782
  let subcommand = null;
499
783
  let targetDir = null;
784
+ let modelRef = null;
500
785
 
501
786
  for (const arg of positional) {
502
787
  if (dirIsForced) {
@@ -506,12 +791,15 @@ function main() {
506
791
  process.exit(1);
507
792
  }
508
793
  targetDir = resolveDir(arg);
509
- } else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list' || arg === 'resurrect') {
794
+ } else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list' || arg === 'resurrect' || arg === 'switch' || arg === 'model') {
510
795
  if (subcommand) {
511
796
  console.error(`Error: multiple subcommands specified: '${subcommand}' and '${arg}'`);
512
797
  process.exit(1);
513
798
  }
514
799
  subcommand = arg;
800
+ } else if (subcommand === 'model' && modelRef === null) {
801
+ // model reference (supports partial matches) — not a directory
802
+ modelRef = arg;
515
803
  } else {
516
804
  if (targetDir) {
517
805
  console.error(`Error: unexpected argument '${arg}'`);
@@ -566,6 +854,41 @@ function main() {
566
854
  process.exit(1);
567
855
  }
568
856
  cmdList();
857
+ } else if (subcommand === 'switch') {
858
+ if (gitMode) {
859
+ console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
860
+ process.exit(1);
861
+ }
862
+ if (printLogs) {
863
+ console.error("Error: '--print-logs' is only valid with 'serve'.");
864
+ process.exit(1);
865
+ }
866
+ if (targetDir !== process.cwd()) {
867
+ console.error("Error: 'switch' does not accept a directory path.");
868
+ process.exit(1);
869
+ }
870
+ ensureSession();
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
+ });
569
892
  } else if (subcommand === 'kill') {
570
893
  if (gitMode) {
571
894
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
@@ -606,7 +929,7 @@ function main() {
606
929
  // Default mode — find an existing server and switch to it
607
930
  let sf = findParentStatefile(targetDir);
608
931
  if (!sf) {
609
- console.error(`Error: no opencode server found in ${targetDir} or any of its parents.`);
932
+ console.log(`Error: no opencode server found in ${targetDir} or any of its parents.`);
610
933
  console.error("Run 'ocmux serve' to create a new server.");
611
934
  process.exit(1);
612
935
  }
@@ -628,4 +951,8 @@ function main() {
628
951
  }
629
952
  }
630
953
 
631
- main();
954
+ if (require.main === module) {
955
+ main();
956
+ }
957
+
958
+ module.exports = { main };
@@ -31,13 +31,25 @@ Reads stdin, sends to the most recent or named OpenCode session, streams answer
31
31
  **Options:**
32
32
 
33
33
  | Flag | Effect |
34
- |---|---|
34
+ |---|---|---|
35
35
  | `--qa` | Print prompt/answer with rulers; auto-detect tgagentp |
36
+ | `--defer [N]` | Deferred execution — submit a prompt and get a ticket immediately, or wait up to N seconds (default `0`) for the answer; piping the ticket back retrieves the result later |
37
+ | `--onlineTicket` | Print deferred tickets on a single line (default: pretty-printed JSON) |
36
38
  | `--tg` | Forward answer via tgagentp gateway (error if unavailable) |
37
39
  | `--no-tg` | Explicitly disable Telegram forwarding |
38
40
  | `--flush` | Flush recorded buffer without prepending |
39
41
  | `--getLast N` | Retrieve last N assistant answers from session history |
40
42
 
43
+ **Deferred tickets (`--defer`):**
44
+
45
+ Ticket format on stdout: `agentp_ticket` followed by a JSON object `{ ctime, path, server, sessionId, elapsed, defer }`, printed as pretty-printed (multi-line) JSON by default or on a single line with `--onlineTicket`. Both formats are accepted when piping a ticket back.
46
+
47
+ - First print omits `elapsed`; `defer` is included only when it was `> 0`.
48
+ - Re-prints (answer not ready yet) include `elapsed` computed from `ctime` (0 when `ctime` is missing/unparseable).
49
+ - A ticket piped back to `agentp --defer` ignores the CLI `--defer` value and uses the ticket's own `defer` (default 0): ready → print answer + delete temp file; not ready → re-print the ticket with updated `elapsed`; file missing → error, exit 1.
50
+ - If a ticket is followed by additional text and the answer is not ready, `agentp --defer` sends that text to the ticket's `server`/`sessionId` with `sendToSessionAsync()` (`POST /session/:id/prompt_async`), stores it in a ticket sidecar file, and re-prints the ticket without echoing the extra text. When the final answer is retrieved with `--qa`, stored follow-ups are injected into the prompt block under `📝` separators; without `--qa`, they are omitted from output. If the answer is ready, appended text is not sent and is printed after the returned answer. Older tickets without `server`/`sessionId` can retrieve results but cannot queue follow-up text.
51
+ - New run with `--defer N` (N > 0): wait up to N seconds polling the temp file; answer arrives in time → print + delete; else print the ticket and keep the background child alive.
52
+
41
53
  **Protocol:**
42
54
 
43
55
  1. Reads all stdin → string
@@ -63,15 +75,23 @@ Manages per-project OpenCode servers in a persistent `Opencode` tmux session.
63
75
  | `new [dir]` | Alias for `serve` (deprecated) |
64
76
  | `kill [dir]` | Kill server, remove tmux window + `.ocmux.json` |
65
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) |
79
+ | `switch` | Interactive session picker (TTY required) |
66
80
  | `list [-l]` | List all running servers |
67
81
  | _(no arg)_ | Switch to existing server (searches upward for `.ocmux.json`) |
68
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
+
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.
86
+
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
+
69
89
  **Flags:** `--git`, `--GIT`, `--print-logs`, `-l`, `--version`
70
90
 
71
91
  **Window layout:**
72
92
 
73
93
  - Pane 0: server (`opencode serve --port 0 2>&1 | tee <logfile>`)
74
- - 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`)
75
95
  - Log: `/tmp/opencode-serve-<hashDir(dir)>.log`
76
96
  - State: `<dir>/.ocmux.json` (contains `url`, `logfile`, `window_index`)
77
97
 
@@ -218,6 +238,8 @@ Server log file. Continuously written by `tee` in the server pane. Polled by `do
218
238
  opencode server listening on http://localhost:40999
219
239
  ```
220
240
 
241
+ OpenCode v2 prints `server listening on http://...` (without the `opencode ` prefix); both formats are matched for URL extraction.
242
+
221
243
  ---
222
244
 
223
245
  ## Inter-Process Communication
@@ -264,10 +286,10 @@ All servers live in a single tmux session named `Opencode`. Each project gets on
264
286
  Session: Opencode
265
287
  ├── Window 3: /home/user/project-a
266
288
  │ ├── Pane 0: opencode serve --port 0 ... (server)
267
- │ └── Pane 1: opencode attach --continue ... (TUI, zoomed)
289
+ │ └── Pane 1: TUI (opencode --server <url> --continue / attach, zoomed)
268
290
  ├── Window 4: /home/user/project-b
269
291
  │ ├── Pane 0: opencode serve --port 0 ...
270
- │ └── Pane 1: opencode attach --continue ...
292
+ │ └── Pane 1: TUI (opencode --server <url> --continue / attach)
271
293
  ```
272
294
 
273
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
  };