agentp 2.1.0 → 2.1.1

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
@@ -352,11 +352,22 @@ the project found upward from `<directory>` (default: `$PWD`):
352
352
  and switches normally; `ESC`/`q` cancels back to the session list and returns
353
353
  the TUI to the stored session; deselecting down to a single session selects
354
354
  that remaining session. New selections open in the TUI for inspection.
355
- Broadcast mode also has `h` help, `d` delete, and `m` to change the model for
356
- every selected session.
355
+ Broadcast mode also has `h` help, `d` delete, `D` (Shift+d) to delete every
356
+ selected session at once (confirmation in the status bar), and `m` to change
357
+ the model for every selected session. The `Broadcast to sessions: …` line
358
+ caps itself to one row — up to 480 characters of names (or the terminal
359
+ width, whichever is smaller); past that the selection is truncated from the
360
+ beginning with a leading `...`, so the footer never wraps.
357
361
  - `n` create (name input) · `r` rename (edit in place) · `R` set a reminder ·
358
362
  `d` delete (confirm) · `a` switch agent · `m` switch model · `p` project
359
- switcher (inspect-only by default) · `h` help · `q` quit
363
+ switcher (inspect-only by default) · `h` help · `q` quit. The `n`/`r`/`R`
364
+ prompts and the `d` confirmation take over the **status bar**, with their
365
+ essential keys on the right (`Enter: create/rename/save · Esc: cancel`,
366
+ `y: delete · n/Esc: cancel`). While a question is active the bar flips from
367
+ brown to the same **light yellow as the list pointer**, so the change of
368
+ state is obvious at a glance. Deleting the current session adopts the session
369
+ under the cursor as the new current (recorded, refreshed in the TUI, and
370
+ highlighted)
360
371
  - `m` opens the model list **sorted by provider** with the cursor already on the
361
372
  session's **current model** — also in broadcast mode, where it starts from the
362
373
  cursor session's model, so a bulk change begins where you are
@@ -366,7 +377,14 @@ the project found upward from `<directory>` (default: `$PWD`):
366
377
  - switching updates `.ocmux.json` and refreshes the project's dedicated TUI, or
367
378
  the shared TUI when no live dedicated one exists; silent on success. The file
368
379
  updated is always the one of the project being listed — your own project
369
- unless ocmux was started with `--all-projects`
380
+ unless ocmux was started with `--all-projects`. Opening the picker in a
381
+ project also routes that project's TUI to its stored session (a TUI already
382
+ showing it is left untouched), so running `ocmux` from anywhere switches the
383
+ display even without picking a row
384
+ - when `.ocmux.json`'s server is unreachable but the default server
385
+ (`$OCMUX_SERVER` or `http://localhost:4096`) answers, `ocmux` offers (on a TTY)
386
+ to repoint the project to the default and rewrites `server` in `.ocmux.json`
387
+ if you confirm
370
388
  - new sessions inherit the model of the previously selected session (v2
371
389
  sessions created via the API have no model and won't run a prompt until set)
372
390
 
@@ -387,8 +405,10 @@ Subcommands:
387
405
  - **`tui [--shared] [--server <url>] [dir]`** — register the current tmux pane and run OpenCode
388
406
  in it. Without `--shared`, the pane follows only that project. With
389
407
  `--shared`, it becomes the one fallback TUI for all projects and can reconnect
390
- across server URLs as selections change. Re-registering a slot stops its old
391
- wrapper without destroying the old pane. Closing the TUI unregisters it.
408
+ across server URLs as selections change; `--shared` also starts without a
409
+ `.ocmux.json`, defaulting to the default server. Re-registering a slot stops
410
+ its old wrapper without destroying the old pane. Closing the TUI unregisters
411
+ it.
392
412
  `tui --list` lists live registrations; `tui [--shared] --status` inspects a
393
413
  dedicated/shared slot; `tui [--shared] --detach` unregisters that slot without
394
414
  closing the pane or its current TUI.
package/bin/ocmux CHANGED
@@ -113,6 +113,23 @@ function checkServer(server) {
113
113
  });
114
114
  }
115
115
 
116
+ // Ask a yes/no question on stderr. Only meaningful on a TTY; resolves false on
117
+ // EOF/close so a non-answer never silently changes state.
118
+ function askYesNo(question) {
119
+ return new Promise((resolve) => {
120
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
121
+ let done = false;
122
+ const settle = (value) => {
123
+ if (done) return;
124
+ done = true;
125
+ try { rl.close(); } catch {}
126
+ resolve(value);
127
+ };
128
+ rl.question(question, (answer) => settle(/^y(es)?$/i.test((answer || '').trim())));
129
+ rl.on('close', () => settle(false));
130
+ });
131
+ }
132
+
116
133
  function refreshTui(directory, server, session) {
117
134
  const result = switchTui(directory, server, session, path.resolve(process.argv[1]));
118
135
  if (!result.ok && result.reason === 'failed') {
@@ -195,6 +212,7 @@ function padLeft(s, w) {
195
212
  const BROWN = '\x1b[33m'; // text: brown/dark-yellow
196
213
  const BAR_BG = '\x1b[30;43m'; // title/status bar: black on brown
197
214
  const LIGHT_YELLOW = '\x1b[1;33m'; // text: light-yellow (bold brown)
215
+ const QUESTION_BG = '\x1b[30;103m'; // question/input bar: black on light-yellow
198
216
 
199
217
  // ── '/' incremental search for interactive lists ──────────────────────────
200
218
  // Filter `items` by `pattern`: case-insensitive, space-separated tokens that
@@ -453,9 +471,11 @@ function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false)
453
471
  // footer -> status bar (brown background), key hints right-aligned
454
472
  // (left-aligned when footerLeft is set — used by the search line)
455
473
  // footerRight-> optional hint pinned to the right end of a left-aligned footer
474
+ // footerBg -> background for the footer bar (default: BAR_BG; question bars
475
+ // pass QUESTION_BG so an active prompt stands out)
456
476
  // infoLines -> details below the status bar (colours supplied by the caller)
457
477
  // A "(start-end/total)" range is appended to the heading when scrolling.
458
- function renderList({ title, items, cursor, row, footer, infoLines = [], extraLines = [], cols: colsArg, rows: rowsArg, footerLeft = false, footerRight = null }) {
478
+ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLines = [], cols: colsArg, rows: rowsArg, footerLeft = false, footerRight = null, footerBg = BAR_BG }) {
459
479
  const cols = colsArg || process.stdout.columns || 80;
460
480
  const termRows = rowsArg || process.stdout.rows || 24;
461
481
  const extras = (extraLines || []).filter((l) => l != null && l !== '');
@@ -512,7 +532,7 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
512
532
  } else {
513
533
  hint = padLeft(footer, cols);
514
534
  }
515
- out += BAR_BG + hint + '\x1b[0m\n';
535
+ out += footerBg + hint + '\x1b[0m\n';
516
536
  for (const l of infos) out += l + '\n';
517
537
  return out.replace(/\n$/, '');
518
538
  }
@@ -534,6 +554,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
534
554
  let cursor = 0;
535
555
  let mode = 'normal'; // 'normal' | 'help' | 'confirm' | 'input'
536
556
  let confirmDel = null;
557
+ let confirmAll = false; // 'D' deletes every broadcast-selected session
537
558
  let input = null; // { label, value, kind, caret }
538
559
  let list = null; // { title, items, format, onEnter }
539
560
  let listCursor = 0;
@@ -557,6 +578,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
557
578
  'R (Shift+r) set a REMINDER for the selected session (sent in front of',
558
579
  ' every agentp prompt; blank clears)',
559
580
  'd delete the selected session (then y/N to confirm)',
581
+ 'D (Shift+d) in BROADCAST: delete ALL selected sessions (then y/N)',
560
582
  'a switch the AGENT of the selected session',
561
583
  'm switch the MODEL of the selected session',
562
584
  'p open the project switcher',
@@ -605,6 +627,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
605
627
  out += ' Enter keep the broadcast selection; switch TUI to cursor\n';
606
628
  out += ' m switch the model for ALL selected sessions\n';
607
629
  out += ' d delete the cursor session (with confirmation)\n';
630
+ out += ' D (Shift+d) delete ALL selected sessions (with confirmation)\n';
608
631
  out += ' j / k, arrows move the cursor (selecting shows it in the TUI)\n';
609
632
  out += ' / search/filter the session list\n';
610
633
  out += ' ESC / q cancel broadcast mode (back to the session list)\n';
@@ -648,24 +671,55 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
648
671
  );
649
672
  const inBc = mode === 'broadcast' || mode === 'broadcast-confirm';
650
673
  const extras = [...notices];
651
- if ((mode === 'confirm' || mode === 'broadcast-confirm') && confirmDel) {
652
- extras.push(`Delete session "${confirmDel.title || confirmDel.id || ''}"? (y/N) `);
653
- } else if (mode === 'input' && input) {
654
- extras.push(`${input.label} ${renderInputValue(input.value)}`);
655
- }
656
674
  if (search.active && view.length === 0) extras.push(`No sessions match "${search.value}"`);
657
675
  const selected = view[cursor];
658
676
  let infoLines = [];
659
677
  let footer = 'Enter: switch · Space: broadcast · /: search · h: help · q: quit';
678
+ let footerLeft = search.active;
679
+ let footerRight = search.active ? SEARCH_HINT : null;
660
680
  if (inBc) {
661
- const names = rows.filter(r => broadcastSel.has(String(r.id))).map(r => r.title || r.id).join(', ');
662
- infoLines = [`Broadcast to sessions: ${names}`];
663
- footer = 'Enter: switch · Space: toggle · d: delete · m: model · /: search · h: help · q: cancel · Ctrl+C: quit';
681
+ // List every selected session, capped so the info line never wraps. On
682
+ // overflow the head is trimmed and marked with '...' — the picker lists
683
+ // most-recently-viewed first, so the trimmed head is the most recently
684
+ // viewed part of the selection.
685
+ let names = rows.filter(r => broadcastSel.has(String(r.id))).map(r => r.title || r.id).join(', ');
686
+ const bcHead = 'Broadcast to sessions: ';
687
+ const budget = Math.max(8, Math.min(480, cols - bcHead.length));
688
+ if (names.length > budget) {
689
+ let keep = names.slice(-(budget - 4)); // 3 for '...', 1 for the space
690
+ const at = keep.indexOf(', ');
691
+ if (at > 0) keep = keep.slice(at + 2);
692
+ names = '... ' + keep;
693
+ }
694
+ infoLines = [bcHead + names];
695
+ footer = 'Enter: switch · Space: toggle · d: delete · D: delete all · m: model · /: search · h: help · q: cancel · Ctrl+C: quit';
664
696
  } else {
665
697
  const selIsCurrent = !!(selected && String(selected.id) === String(current));
666
698
  infoLines = sessionInfoLines(selected, runningIds, modelContext, cols, selIsCurrent);
667
699
  }
668
- footer = searchFooter(search, footer);
700
+ // 'd'/n/r/R prompts take over the status bar (previously an easy-to-miss
701
+ // line at the bottom of the list) with their essential keys on the right.
702
+ // A prompt also flips the bar to light yellow, matching the list pointer,
703
+ // so the change of state is obvious at a glance.
704
+ let questionBar = false;
705
+ if ((mode === 'confirm' || mode === 'broadcast-confirm') && confirmDel) {
706
+ questionBar = true;
707
+ if (confirmAll) {
708
+ const n = broadcastSel.size;
709
+ footer = `Delete all ${n} selected session${n === 1 ? '' : 's'}?`;
710
+ } else {
711
+ footer = `Delete session "${confirmDel.title || confirmDel.id || ''}"?`;
712
+ }
713
+ footerLeft = true;
714
+ footerRight = 'y: delete · n/Esc: cancel';
715
+ } else if (mode === 'input' && input) {
716
+ questionBar = true;
717
+ footer = `${input.prompt} ${renderInputValue(input.value)}`;
718
+ footerLeft = true;
719
+ footerRight = input.hint;
720
+ } else {
721
+ footer = searchFooter(search, footer);
722
+ }
669
723
  process.stderr.write(renderList({
670
724
  // The project name heads the title so you always know which project's
671
725
  // sessions are listed (matters when --all-projects moves you around).
@@ -684,15 +738,16 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
684
738
  return String(s.id) === String(current) ? `${BROWN}${text}\x1b[0m` : text;
685
739
  },
686
740
  footer,
687
- footerLeft: search.active,
688
- footerRight: search.active ? SEARCH_HINT : null,
741
+ footerLeft,
742
+ footerRight,
743
+ footerBg: questionBar ? QUESTION_BG : BAR_BG,
689
744
  infoLines,
690
745
  extraLines: extras,
691
746
  }));
692
747
  };
693
748
 
694
- const startInput = (label, value, kind) => {
695
- input = { label, value, kind, caret: value.length };
749
+ const startInput = (prompt, hint, value, kind) => {
750
+ input = { prompt, hint, value, kind, caret: value.length };
696
751
  mode = 'input';
697
752
  draw();
698
753
  };
@@ -735,10 +790,12 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
735
790
  }
736
791
  inspected = false;
737
792
  confirmDel = null;
793
+ confirmAll = false;
738
794
  draw();
739
795
  }).catch((err) => {
740
796
  process.stderr.write(`\nError: ${err.message}\n`);
741
797
  confirmDel = null;
798
+ confirmAll = false;
742
799
  draw();
743
800
  });
744
801
  };
@@ -747,11 +804,31 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
747
804
  const s = confirmDel;
748
805
  if (!s || !onDelete) return;
749
806
  const wasBroadcast = mode === 'broadcast-confirm';
750
- Promise.resolve(onDelete(s)).then(() => {
751
- rows = rows.filter(r => r.id !== s.id);
807
+ const deletingAll = confirmAll;
808
+ // 'D' removes every session in the broadcast selection; 'd' removes just
809
+ // the cursor row.
810
+ const targets = deletingAll
811
+ ? rows.filter(r => broadcastSel.has(String(r.id)))
812
+ : [s];
813
+ confirmDel = null;
814
+ confirmAll = false;
815
+ Promise.resolve(Promise.all(targets.map(t => onDelete(t)))).then(() => {
816
+ const removed = new Set(targets.map(t => String(t.id)));
817
+ rows = rows.filter(r => !removed.has(String(r.id)));
752
818
  applySearch();
753
819
  if (cursor >= view.length && cursor > 0) cursor = view.length - 1;
754
- confirmDel = null;
820
+ if (deletingAll) {
821
+ // Deleting the whole broadcast selection ends broadcast mode; adopt
822
+ // the row now under the cursor as the new current (state + TUI +
823
+ // highlight) so the list and the TUI agree on the new selection.
824
+ persistBroadcast([]);
825
+ broadcastSel = new Set();
826
+ mode = 'normal';
827
+ const next = view[cursor];
828
+ if (next) applyPick(next);
829
+ else { current = null; draw(); }
830
+ return;
831
+ }
755
832
  if (wasBroadcast) {
756
833
  broadcastSel.delete(String(s.id));
757
834
  if (broadcastSel.size < 2) {
@@ -767,15 +844,32 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
767
844
  } else {
768
845
  persistBroadcast([...broadcastSel]);
769
846
  mode = 'broadcast';
847
+ // Keep the anchor session pointing at something that still exists
848
+ // (the cursor row when it is one of the remaining selections).
849
+ if (String(s.id) === String(current)) {
850
+ const next = view[cursor];
851
+ current = (next && broadcastSel.has(String(next.id))) ? next.id : ([...broadcastSel][0] || null);
852
+ }
770
853
  draw();
771
854
  }
772
855
  } else {
773
856
  mode = 'normal';
774
- draw();
857
+ if (String(s.id) === String(current)) {
858
+ // The highlighted session was deleted: adopt the row now under the
859
+ // cursor as the new current. applyPick records it in .ocmux.json
860
+ // and refreshes the TUI, so the list highlight and the display
861
+ // agree on the new selection.
862
+ const next = view[cursor];
863
+ if (next) applyPick(next);
864
+ else { current = null; draw(); }
865
+ } else {
866
+ draw();
867
+ }
775
868
  }
776
869
  }).catch((err) => {
777
870
  process.stderr.write(`\nError: ${err.message}\n`);
778
871
  confirmDel = null;
872
+ confirmAll = false;
779
873
  mode = wasBroadcast ? 'broadcast' : 'normal';
780
874
  draw();
781
875
  });
@@ -883,6 +977,7 @@ const toggleBroadcast = (idx) => {
883
977
  if (key.name === 'y') return applyDelete();
884
978
  if (key.name === 'n' || key.name === 'escape' || key.name === 'q' || key.name === 'return') {
885
979
  confirmDel = null;
980
+ confirmAll = false;
886
981
  mode = mode === 'broadcast-confirm' ? 'broadcast' : 'normal';
887
982
  draw();
888
983
  }
@@ -987,6 +1082,15 @@ const toggleBroadcast = (idx) => {
987
1082
  draw();
988
1083
  } else if (key.name === 'space') {
989
1084
  if (cursor < view.length) toggleBroadcast(cursor);
1085
+ } else if (str === 'D') {
1086
+ // Shift+d: delete ALL broadcast-selected sessions at once (with
1087
+ // confirmation), handy to drop a whole selection you no longer want.
1088
+ if (broadcastSel.size > 0) {
1089
+ confirmAll = true;
1090
+ confirmDel = { all: true };
1091
+ mode = 'broadcast-confirm';
1092
+ draw();
1093
+ }
990
1094
  } else if (key.name === 'd') {
991
1095
  if (cursor < view.length) {
992
1096
  confirmDel = view[cursor];
@@ -1020,18 +1124,18 @@ const toggleBroadcast = (idx) => {
1020
1124
  return;
1021
1125
  }
1022
1126
  if (key.name === 'n') {
1023
- startInput('New session name (blank for auto; ESC cancels):', '', 'create');
1127
+ startInput('New session name (blank = auto):', 'Enter: create · Esc: cancel', '', 'create');
1024
1128
  return;
1025
1129
  }
1026
1130
  // Shift+r → Reminder; lowercase r → Rename.
1027
1131
  if (str === 'R') {
1028
1132
  const row = view[cursor];
1029
1133
  const note = (row && annotations && annotations[row.id]) || '';
1030
- startInput('Reminder for this session (blank clears; ESC cancels):', note, 'reminder');
1134
+ startInput('Reminder (blank clears):', 'Enter: save · Esc: cancel', note, 'reminder');
1031
1135
  return;
1032
1136
  }
1033
1137
  if (key.name === 'r' && cursor < view.length) {
1034
- startInput('Rename to (ESC cancels):', view[cursor].title || '', 'rename');
1138
+ startInput('Rename to:', 'Enter: rename · Esc: cancel', view[cursor].title || '', 'rename');
1035
1139
  return;
1036
1140
  }
1037
1141
  if (key.name === 'a') {
@@ -1367,7 +1471,7 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
1367
1471
  process.exit(1);
1368
1472
  }
1369
1473
  const state = projectState.readProjectState(sf);
1370
- const server = state.server;
1474
+ let server = state.server;
1371
1475
  if (!server) {
1372
1476
  console.error(`Error: ${sf} does not record a server.`);
1373
1477
  console.error(`Run 'ocmux serve --server <url>' to set it up, or add "server" to the file.`);
@@ -1375,7 +1479,19 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
1375
1479
  }
1376
1480
  const dir = state.directory;
1377
1481
 
1378
- const alive = await checkServer(server);
1482
+ // If the recorded server is down, offer to repoint the project to the
1483
+ // default server — but only after confirming the default actually answers.
1484
+ let alive = await checkServer(server);
1485
+ if (!alive && process.stdin.isTTY && server !== DEFAULT_SERVER
1486
+ && await checkServer(DEFAULT_SERVER)) {
1487
+ const useDefault = await askYesNo(
1488
+ `OpenCode server not reachable at ${server}. Use the default ${DEFAULT_SERVER} instead? [y/N] `);
1489
+ if (useDefault) {
1490
+ projectState.writeProjectState(sf, { server: DEFAULT_SERVER });
1491
+ server = DEFAULT_SERVER;
1492
+ alive = true;
1493
+ }
1494
+ }
1379
1495
  if (!alive) {
1380
1496
  console.error(`Error: OpenCode server not reachable at ${server}.`);
1381
1497
  console.error(`Start it with 'opencode serve' (or your usual launcher) first.`);
@@ -1438,6 +1554,12 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
1438
1554
  // model of the previously selected session); r renames; R sets a reminder;
1439
1555
  // d deletes; a switches agent; m switches model; p switches project; q quits.
1440
1556
  let lastRef = (state.session && mainSessions.find(s => String(s.id) === String(state.session))) || mainSessions[0] || null;
1557
+ // Opening the picker in a project routes its applicable TUI to the stored
1558
+ // session, so merely running `ocmux` here switches the pane. (Respawn is
1559
+ // skipped when the TUI is already showing that project/session.)
1560
+ if (state.session && mainSessions.some(s => String(s.id) === String(state.session))) {
1561
+ refreshTui(dir, server, state.session);
1562
+ }
1441
1563
  const result = await new Promise((resolve) => {
1442
1564
  sessionMenu(mainSessions, state.session, annotations, server, {
1443
1565
  dir,
@@ -1622,20 +1744,22 @@ async function cmdTui(targetDir, serverArg, shared, sessionArg) {
1622
1744
  process.exit(1);
1623
1745
  }
1624
1746
  const sf = findParentStatefile(targetDir);
1625
- if (!sf) {
1747
+ const state = sf ? projectState.readProjectState(sf) : null;
1748
+ if (!state && !shared) {
1626
1749
  console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
1627
1750
  console.error("Run 'ocmux serve' first.");
1628
1751
  process.exit(1);
1629
1752
  }
1630
- const state = projectState.readProjectState(sf);
1631
- const directory = state.directory;
1632
- const server = serverArg || state.server || DEFAULT_SERVER;
1753
+ // The shared TUI is a cross-project fallback, so it can run without a project
1754
+ // state file (defaulting to the configured/default server).
1755
+ const directory = state ? state.directory : targetDir;
1756
+ const server = serverArg || (state && state.server) || DEFAULT_SERVER;
1633
1757
  if (!await checkServer(server)) {
1634
1758
  console.error(`Error: OpenCode server not reachable at ${server}.`);
1635
1759
  process.exit(1);
1636
1760
  }
1637
1761
 
1638
- let session = sessionArg || state.session;
1762
+ let session = sessionArg || (state && state.session) || null;
1639
1763
  if (session) {
1640
1764
  try {
1641
1765
  const found = await getSession(server, session);
@@ -1648,7 +1772,7 @@ async function cmdTui(targetDir, serverArg, shared, sessionArg) {
1648
1772
  const sessions = await listSessions(server, directory);
1649
1773
  session = newestSessionId(sessions);
1650
1774
  if (!session) session = (await createSessionWithModel(server, undefined, directory, sessions)).id;
1651
- projectState.writeProjectState(sf, { session, server });
1775
+ if (sf) projectState.writeProjectState(sf, { session, server });
1652
1776
  }
1653
1777
 
1654
1778
  const registration = tuiRegistry.register({
@@ -1807,10 +1931,14 @@ function printHelp() {
1807
1931
  console.error('sessions are listed. In the picker:');
1808
1932
  console.error(' Enter switch · n new · r rename · R remind · d delete · a agents · m model');
1809
1933
  console.error(' p projects · h help · q quit');
1934
+ console.error(' In BROADCAST: Space toggles a session · d deletes cursor · D deletes all');
1810
1935
  console.error('Selecting updates .ocmux.json and refreshes a dedicated TUI, falling');
1811
- console.error('back to the shared TUI. Silent on success. (p opens projects; by');
1936
+ console.error('back to the shared TUI. Opening the picker also routes that project\'s');
1937
+ console.error('TUI to its stored session. Silent on success. (p opens projects; by');
1812
1938
  console.error('default it only inspects other projects and never modifies any');
1813
1939
  console.error('.ocmux.json — see --all-projects.)');
1940
+ console.error('If the recorded server is down but the default server answers, ocmux');
1941
+ console.error('offers to repoint the project to the default.');
1814
1942
  console.error('');
1815
1943
  console.error('Subcommands:');
1816
1944
  console.error(' serve Initialize project state for <directory> (default: $PWD).');
@@ -1821,7 +1949,8 @@ function printHelp() {
1821
1949
  console.error(' tui [--shared] [--server <url>] [directory]');
1822
1950
  console.error(' Register the current tmux pane and run its OpenCode TUI.');
1823
1951
  console.error(' Without --shared it is dedicated to the current project;');
1824
- console.error(' --shared makes it the single fallback for every project.');
1952
+ console.error(' --shared makes it the single fallback for every project and');
1953
+ console.error(' runs even without .ocmux.json (using the default server).');
1825
1954
  console.error(' --list lists all live registrations; --status inspects one;');
1826
1955
  console.error(' --detach unregisters one without closing its pane or TUI.');
1827
1956
  console.error(' With --status/--detach, --shared targets the shared slot;');
@@ -2071,4 +2200,4 @@ if (require.main === module) {
2071
2200
  main();
2072
2201
  }
2073
2202
 
2074
- module.exports = { main, checkServer, newestSessionId, configuredProjects, refreshTui, cmdTui, cmdTuiList, cmdTuiStatus, cmdTuiDetach, cmdList, cmdMigrate, windowFor, renderList, fmtClock, fmtDuration, fmtTokens, fmtCost, layoutCells, sessionInfoLines, centerIn, filterByPattern, newSearchState, handleSearch, searchFooter, sessionMenu, switchMenu, SWITCH_QUIT, SEARCH_HINT, sortModels, indexOfModel, BROWN, BAR_BG, LIGHT_YELLOW };
2203
+ module.exports = { main, checkServer, askYesNo, newestSessionId, configuredProjects, refreshTui, cmdTui, cmdTuiList, cmdTuiStatus, cmdTuiDetach, cmdList, cmdMigrate, windowFor, renderList, fmtClock, fmtDuration, fmtTokens, fmtCost, layoutCells, sessionInfoLines, centerIn, filterByPattern, newSearchState, handleSearch, searchFooter, sessionMenu, switchMenu, SWITCH_QUIT, SEARCH_HINT, sortModels, indexOfModel, BROWN, BAR_BG, LIGHT_YELLOW, QUESTION_BG };
@@ -59,6 +59,8 @@ Each instance records:
59
59
  - tmux socket and pane ID;
60
60
  - dedicated/shared mode;
61
61
  - last directory, server, and session;
62
+ - the directory and session currently displayed (so routing can skip a
63
+ respawn that would change nothing);
62
64
  - foreground wrapper PID and current child PID (diagnostic only);
63
65
  - registration/update timestamps.
64
66
 
@@ -95,7 +97,9 @@ a different server, the same pane reconnects to that server.
95
97
  `ocmux tui [--shared] [directory]`:
96
98
 
97
99
  1. Requires `$TMUX` and `$TMUX_PANE`.
98
- 2. Resolves `.ocmux.json`, server, directory, and session.
100
+ 2. Resolves `.ocmux.json`, server, directory, and session. A shared TUI may run
101
+ without a state file, in which case it uses the current directory and the
102
+ default server (`$OCMUX_SERVER` or `http://localhost:4096`).
99
103
  3. Health-checks the server and validates the session, creating one with a
100
104
  model when necessary.
101
105
  4. Marks and registers the current pane.
@@ -127,6 +131,10 @@ The new `ocmux tui` process re-registers the pane and hosts the new OpenCode
127
131
  child. Server-side session execution continues while the old client disconnects.
128
132
  Client-local state—draft prompt, scroll position, dialogs, and tabs—is lost.
129
133
 
134
+ Routing is idempotent: the registry records the directory and session the pane
135
+ currently displays, so a switch to the same project/session is skipped without
136
+ respawning.
137
+
130
138
  Native TUI navigation is temporary inspection and is not observable through
131
139
  the OpenCode HTTP API. The next ocmux selection restores the canonical target.
132
140
 
@@ -142,7 +150,8 @@ or creating one with a model. It creates no tmux session, window, or pane.
142
150
 
143
151
  Registers and hosts a dedicated or shared TUI as described above. `--shared`
144
152
  is valid only for this command. `--server` overrides the project's recorded
145
- server for the launched wrapper. Management modes are:
153
+ server for the launched wrapper; when `--shared` runs without a state file it
154
+ falls back to the default server. Management modes are:
146
155
 
147
156
  - `ocmux tui --list` — prune stale entries and print every live registration;
148
157
  - `ocmux tui [dir] --status` — inspect the dedicated slot for that project;
@@ -156,9 +165,25 @@ destroy the pane. Management modes do not require running inside tmux.
156
165
  ### `ocmux`
157
166
 
158
167
  Opens the current project's session picker. Picking writes that project's
159
- session and refreshes its routed TUI. Session creation, rename/delete,
168
+ session and refreshes its routed TUI. Merely opening the picker also routes the
169
+ applicable TUI to the project's stored session (skipped when it already shows
170
+ it), so starting `ocmux` in a project switches the display even without picking
171
+ a row. When the recorded server is unreachable but the default server answers,
172
+ `ocmux` offers on a TTY to repoint the project to the default and rewrites
173
+ `server`. Session creation, rename/delete,
160
174
  annotations, agent/model choice, broadcast selection, search, and project
161
- inspection operate through OpenCode's API.
175
+ inspection operate through OpenCode's API. Deleting the session currently
176
+ selected adopts the session under the cursor as the new current (state write,
177
+ TUI refresh, and list highlight); deleting any other session leaves the current
178
+ selection unchanged. In broadcast mode `d` deletes just the cursor session
179
+ while `D` (Shift+d) deletes every selected session at once — after confirming,
180
+ broadcast mode ends and the row under the cursor becomes the new current.
181
+ Interactive prompts (create/rename/reminder/delete/delete-all) take over the
182
+ status bar and flip its background from brown to light yellow — the same color
183
+ as the session-list pointer — so an active question is immediately visible.
184
+ The broadcast info line (`Broadcast to sessions: …`) never wraps: it holds up
185
+ to 480 characters of names (or the terminal width, whichever is smaller), and
186
+ an oversized selection is truncated from the beginning with a leading `...`.
162
187
 
163
188
  ### `ocmux session <id|title> [dir]`
164
189
 
package/lib/ocmux.js CHANGED
@@ -18,7 +18,15 @@ function tuiArgs(server, session, directory) {
18
18
  function switchTui(directory, server, session, executable) {
19
19
  const target = registry.resolve(directory);
20
20
  if (!target) return { ok: false, reason: 'none' };
21
- const result = registry.respawn(target.instance, {
21
+ // Skip the respawn when the routed pane already shows this project/session,
22
+ // so merely opening ocmux does not restart a TUI that is already correct.
23
+ const instance = target.instance;
24
+ if (instance.activeDirectory
25
+ && path.resolve(instance.activeDirectory) === path.resolve(directory)
26
+ && String(instance.activeSession || '') === String(session || '')) {
27
+ return { ok: true, kind: target.kind, instance, unchanged: true };
28
+ }
29
+ const result = registry.respawn(instance, {
22
30
  executable: executable || path.resolve(__dirname, '..', 'bin', 'ocmux'),
23
31
  directory,
24
32
  server,
@@ -28,7 +36,7 @@ function switchTui(directory, server, session, executable) {
28
36
  registry.pruneDead();
29
37
  return { ok: false, reason: 'failed', kind: target.kind, error: result.error };
30
38
  }
31
- return { ok: true, kind: target.kind, instance: target.instance };
39
+ return { ok: true, kind: target.kind, instance };
32
40
  }
33
41
 
34
42
  module.exports = {
@@ -171,6 +171,10 @@ function register({ socket, pane, shared, directory, server, session, pid }) {
171
171
  directory: directory ? path.resolve(directory) : null,
172
172
  server: server || null,
173
173
  session: session || null,
174
+ // What the pane is currently displaying. `respawn()` refreshes these so a
175
+ // caller (ocmux) can skip restarting a TUI that already shows the target.
176
+ activeDirectory: directory ? path.resolve(directory) : null,
177
+ activeSession: session || null,
174
178
  pid: Number(pid) || process.pid,
175
179
  registeredAt: now,
176
180
  updatedAt: now,
@@ -276,6 +280,10 @@ function respawn(instance, { executable, directory, server, session }) {
276
280
  if (result.status !== 0) {
277
281
  return { ok: false, error: (result.stderr || '').trim() || `failed to respawn ${instance.pane}` };
278
282
  }
283
+ updateInstance(instance.id, instance.token, {
284
+ activeDirectory: directory ? path.resolve(directory) : null,
285
+ activeSession: session || null,
286
+ });
279
287
  return { ok: true };
280
288
  }
281
289
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "Pipe prompts into a running OpenCode TUI session and stream the final answer",
5
5
  "bin": {
6
6
  "agentp": "bin/agentp",