agentp 2.0.1 → 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/bin/ocmux CHANGED
@@ -5,26 +5,11 @@ const path = require('path');
5
5
  const readline = require('readline');
6
6
  const http = require('http');
7
7
  const { URL } = require('url');
8
- const { spawnSync } = require('child_process');
8
+ const child_process = require('child_process');
9
9
  const os = require('os');
10
- const {
11
- SESSION,
12
- statefileFor,
13
- tuiPaneId,
14
- windowByDir,
15
- windowNameByIndex,
16
- activeWindowIndex,
17
- listProjects,
18
- activateProject,
19
- relaunchTui,
20
- closeProjectWindow,
21
- createProjectWindow,
22
- ensureSession,
23
- pinWindowName,
24
- sleep,
25
- tuiAttachCommand,
26
- } = require('../lib/ocmux');
27
- const { listSessions, createSession, createSessionWithModel, updateSession, listModels, switchModel, resolveModelRef, getAuthHeaders, sortSessionsByRecency, deleteSession, listAgents, getActiveSessions } = require('../lib/opencode');
10
+ const { tuiArgs, switchTui } = require('../lib/ocmux');
11
+ const tuiRegistry = require('../lib/tui-registry');
12
+ const { listSessions, listProjects: listOpenCodeProjects, createSession, createSessionWithModel, updateSession, listModels, switchModel, resolveModelRef, getAuthHeaders, sortSessionsByRecency, deleteSession, listAgents, getActiveSessions, getSession } = require('../lib/opencode');
28
13
  const projectState = require('../lib/project-state');
29
14
 
30
15
  // ---------------------------------------------------------------------------
@@ -41,15 +26,6 @@ const DEFAULT_SERVER = process.env.OCMUX_SERVER || 'http://localhost:4096';
41
26
  // Helpers
42
27
  // ---------------------------------------------------------------------------
43
28
 
44
- function tmux(args) {
45
- const result = spawnSync('tmux', args, { encoding: 'utf8', maxBuffer: 1024 * 1024 });
46
- if (result.error && result.error.code === 'ENOENT') {
47
- console.error('Error: tmux is not installed or not in PATH');
48
- process.exit(1);
49
- }
50
- return result;
51
- }
52
-
53
29
  function findParentStatefile(startDir) {
54
30
  let dir = path.resolve(startDir);
55
31
  while (true) {
@@ -93,18 +69,12 @@ function resolveGitDir(startDir, requireDir, flagLabel) {
93
69
  if (!result.dir) {
94
70
  const label = requireDir ? '.git directory' : '.git entry';
95
71
  console.error(`Error: no ${label} found in ${startDir} or any of its parents.`);
96
- console.error(`Run '${SCRIPT} serve' without ${flagLabel} to create a project window in the current directory.`);
72
+ console.error(`Run '${SCRIPT} serve' without ${flagLabel} to initialize the current directory.`);
97
73
  process.exit(1);
98
74
  }
99
75
  return result.dir;
100
76
  }
101
77
 
102
- function findWindowsByBasename(name) {
103
- const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_name}']);
104
- if (r.status !== 0) return [];
105
- return r.stdout.trim().split('\n').filter(Boolean).filter(w => path.basename(w) === name);
106
- }
107
-
108
78
  function resolveDir(arg) {
109
79
  const resolved = path.resolve(arg);
110
80
  try {
@@ -112,17 +82,6 @@ function resolveDir(arg) {
112
82
  } catch {
113
83
  // not a valid path or inaccessible
114
84
  }
115
-
116
- if (!arg.includes('/')) {
117
- const matches = findWindowsByBasename(arg);
118
- if (matches.length === 1) return matches[0];
119
- if (matches.length > 1) {
120
- console.error(`Error: more than one match for '${arg}':`);
121
- for (const m of matches) console.error(` ${m}`);
122
- process.exit(1);
123
- }
124
- }
125
-
126
85
  console.error(`Error: invalid directory '${arg}'`);
127
86
  console.error(`Run '${SCRIPT} -h' for help.`);
128
87
  process.exit(1);
@@ -154,6 +113,62 @@ function checkServer(server) {
154
113
  });
155
114
  }
156
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
+
133
+ function refreshTui(directory, server, session) {
134
+ const result = switchTui(directory, server, session, path.resolve(process.argv[1]));
135
+ if (!result.ok && result.reason === 'failed') {
136
+ console.error(`Warning: session selected, but the ${result.kind} TUI could not be refreshed: ${result.error}`);
137
+ }
138
+ return result;
139
+ }
140
+
141
+ async function configuredProjects(server) {
142
+ const [projects, sessions] = await Promise.all([
143
+ listOpenCodeProjects(server),
144
+ listSessions(server),
145
+ ]);
146
+ const rows = [];
147
+ const seen = new Set();
148
+ const directories = [
149
+ ...(projects || []).map(project => project.canonical || project.directory),
150
+ ...(sessions || []).map(session => session.location && session.location.directory),
151
+ ];
152
+ for (const dir of directories) {
153
+ if (!dir) continue;
154
+ let canonical;
155
+ try { canonical = fs.realpathSync(dir); } catch { canonical = path.resolve(dir); }
156
+ if (seen.has(canonical)) continue;
157
+ const sf = path.join(canonical, '.ocmux.json');
158
+ const state = projectState.readProjectState(sf);
159
+ if (!state) continue;
160
+ seen.add(canonical);
161
+ const target = tuiRegistry.resolve(canonical);
162
+ rows.push({
163
+ dir: canonical,
164
+ status: target ? target.kind : 'headless',
165
+ session: state.session,
166
+ server: state.server || server,
167
+ });
168
+ }
169
+ return rows.sort((a, b) => a.dir.localeCompare(b.dir));
170
+ }
171
+
157
172
  function newestSessionId(sessions) {
158
173
  if (!sessions || sessions.length === 0) return null;
159
174
  // Implicit targets skip child/subagent sessions (parentID set) — OpenCode's
@@ -190,6 +205,15 @@ function padLeft(s, w) {
190
205
  return ' '.repeat(Math.max(0, w - str.length)) + str;
191
206
  }
192
207
 
208
+ // ── Palette (classic 16-colour ANSI) ─────────────────────────────────────────
209
+ // Colour 3 renders as brown/dark-yellow; bold colour 3 as light yellow. The
210
+ // whole list UI (title/status bars, cursor pointer, info labels) sticks to these
211
+ // two shades so the chrome reads as one theme.
212
+ const BROWN = '\x1b[33m'; // text: brown/dark-yellow
213
+ const BAR_BG = '\x1b[30;43m'; // title/status bar: black on brown
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
216
+
193
217
  // ── '/' incremental search for interactive lists ──────────────────────────
194
218
  // Filter `items` by `pattern`: case-insensitive, space-separated tokens that
195
219
  // must ALL be substrings of `textOf(item)`. Empty pattern → list unchanged.
@@ -278,8 +302,8 @@ function handleSearch(str, key, s) {
278
302
  return 'pass';
279
303
  }
280
304
 
281
- // The inverted bottom line: normal key hints, or the search line while a
282
- // '/' filter is active (live = input with caret; committed = pattern kept).
305
+ // The status bar's search line: normal key hints, or the pattern while a '/'
306
+ // filter is active (live = input with caret; committed = pattern kept).
283
307
  // The caller adds the `Enter: confirm · Esc: cancel` hint on the right via
284
308
  // renderList's `footerRight`.
285
309
  const SEARCH_HINT = 'Enter: confirm · Esc: cancel';
@@ -289,6 +313,23 @@ function searchFooter(s, base) {
289
313
  return s.live ? `Search: ${shown}` : `Search: ${shown} · /: edit`;
290
314
  }
291
315
 
316
+ // ── Model picker ordering ─────────────────────────────────────────────────────
317
+ // Models sorted by provider then id (the provider already leads the label, so
318
+ // this is also alphabetical by display order).
319
+ function sortModels(models) {
320
+ const key = (m) => `${String(m.providerID || '').toLowerCase()}/${String(m.id || '').toLowerCase()}`;
321
+ return [...(models || [])].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
322
+ }
323
+
324
+ // Index of `model` (as stored on a session: { providerID, id }) inside
325
+ // `models`, or -1 when unknown/unmatched — so a picker can open on it.
326
+ function indexOfModel(models, model) {
327
+ if (!model || !models || !models.length) return -1;
328
+ const key = (m) => `${String(m.providerID || '').toLowerCase()}/${String(m.id || '').toLowerCase()}`;
329
+ const target = key(model);
330
+ return models.findIndex((m) => key(m) === target);
331
+ }
332
+
292
333
  // Sentinel returned by switchMenu on Ctrl+C so the caller fully exits the CLI
293
334
  // (instead of just returning to the session menu).
294
335
  const SWITCH_QUIT = Symbol('ocmux-switch-quit');
@@ -376,13 +417,14 @@ function labeledLine(label, value, cols, labelColor = '\x1b[1m') {
376
417
  return `${labelColor}${lbl}\x1b[0m ${v}`;
377
418
  }
378
419
 
379
- // Info lines about the selected session: title + location (bold labels) and a
380
- // responsive grid. Fields are ALWAYS present (placeholders when unknown).
381
- // When `highlight` is true (the cursor is on the current session), labels are
382
- // yellow instead of white.
420
+ // Info lines about the selected session: title + location and a responsive
421
+ // grid. Fields are ALWAYS present (placeholders when unknown).
422
+ // Labels are brown (the same shade as the title/status bars); when `highlight`
423
+ // is true (the cursor is on the session currently selected in the TUI) they are
424
+ // light-yellow instead.
383
425
  function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false) {
384
426
  if (!s) return [];
385
- const labelColor = highlight ? '\x1b[1;33m' : '\x1b[1m';
427
+ const labelColor = highlight ? LIGHT_YELLOW : BROWN;
386
428
  const title = (s.title && s.title.trim()) || 'Untitled';
387
429
  const location = (s.location && s.location.directory) || 'Unknown';
388
430
  const out = [
@@ -423,15 +465,17 @@ function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false)
423
465
  }
424
466
 
425
467
  // Render a scrollable list into the alternate screen.
426
- // heading -> centered + bold + inverted heading
468
+ // heading -> centered title bar (brown background)
427
469
  // row(index, item) -> string | { text, reverse }
428
470
  // extraLines -> prompt/confirm lines right after the list (normal colors)
429
- // footer -> inverted, RIGHT-aligned key-hint bar (acts as a separator)
471
+ // footer -> status bar (brown background), key hints right-aligned
430
472
  // (left-aligned when footerLeft is set — used by the search line)
431
473
  // footerRight-> optional hint pinned to the right end of a left-aligned footer
432
- // infoLines -> details below the footer (normal colors, bold labels)
474
+ // footerBg -> background for the footer bar (default: BAR_BG; question bars
475
+ // pass QUESTION_BG so an active prompt stands out)
476
+ // infoLines -> details below the status bar (colours supplied by the caller)
433
477
  // A "(start-end/total)" range is appended to the heading when scrolling.
434
- 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 }) {
435
479
  const cols = colsArg || process.stdout.columns || 80;
436
480
  const termRows = rowsArg || process.stdout.rows || 24;
437
481
  const extras = (extraLines || []).filter((l) => l != null && l !== '');
@@ -449,7 +493,7 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
449
493
  }
450
494
 
451
495
  let out = '\x1b[2J\x1b[H';
452
- out += '\x1b[1;7m' + centerIn(titleLine, cols) + '\x1b[0m\n\n';
496
+ out += BAR_BG + centerIn(titleLine, cols) + '\x1b[0m\n\n';
453
497
 
454
498
  let rendered = 0;
455
499
  if (items.length === 0) {
@@ -461,8 +505,11 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
461
505
  const r = row(idx, items[idx]);
462
506
  const text = r && typeof r === 'object' ? r.text : r;
463
507
  const reverse = r && typeof r === 'object' && r.reverse;
464
- const line = `${idx === cursor ? '▶ ' : ' '}${text == null ? '' : text}`;
465
- out += (reverse ? `\x1b[7m${line}\x1b[0m` : line) + '\n';
508
+ // The pointer sits outside the row's own styling so a light-yellow arrow
509
+ // stays visible even on a reverse-video row.
510
+ const ptr = idx === cursor ? `${LIGHT_YELLOW}▶\x1b[0m ` : ' ';
511
+ const body = text == null ? '' : text;
512
+ out += ptr + (reverse ? `\x1b[7m${body}\x1b[0m` : body) + '\n';
466
513
  rendered++;
467
514
  }
468
515
  }
@@ -485,7 +532,7 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
485
532
  } else {
486
533
  hint = padLeft(footer, cols);
487
534
  }
488
- out += '\x1b[7m' + hint + '\x1b[0m\n';
535
+ out += footerBg + hint + '\x1b[0m\n';
489
536
  for (const l of infos) out += l + '\n';
490
537
  return out.replace(/\n$/, '');
491
538
  }
@@ -496,7 +543,9 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
496
543
  // q/Ctrl+C quit. Input mode offers readline-style caret editing.
497
544
  // Resolves on quit (null) or with the string 'switch-project' (caller then runs
498
545
  // the project switcher and re-invokes this picker for the new project).
499
- function sessionMenu(sessions, currentSession, annotations, server, { onPick, onRename, onDelete, onReminder, runningIds, modelContext, onBroadcastChange, onInspectSession } = {}) {
546
+ // `dir` names the project this list belongs to — it heads the title bar so a
547
+ // multi-project run always says which project's sessions are on screen.
548
+ function sessionMenu(sessions, currentSession, annotations, server, { onPick, onRename, onDelete, onReminder, runningIds, modelContext, onBroadcastChange, onInspectSession, dir, notices = [] } = {}) {
500
549
  return new Promise((resolve) => {
501
550
  if (!process.stdin.isTTY) return resolve(null);
502
551
 
@@ -505,6 +554,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
505
554
  let cursor = 0;
506
555
  let mode = 'normal'; // 'normal' | 'help' | 'confirm' | 'input'
507
556
  let confirmDel = null;
557
+ let confirmAll = false; // 'D' deletes every broadcast-selected session
508
558
  let input = null; // { label, value, kind, caret }
509
559
  let list = null; // { title, items, format, onEnter }
510
560
  let listCursor = 0;
@@ -528,6 +578,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
528
578
  'R (Shift+r) set a REMINDER for the selected session (sent in front of',
529
579
  ' every agentp prompt; blank clears)',
530
580
  'd delete the selected session (then y/N to confirm)',
581
+ 'D (Shift+d) in BROADCAST: delete ALL selected sessions (then y/N)',
531
582
  'a switch the AGENT of the selected session',
532
583
  'm switch the MODEL of the selected session',
533
584
  'p open the project switcher',
@@ -576,6 +627,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
576
627
  out += ' Enter keep the broadcast selection; switch TUI to cursor\n';
577
628
  out += ' m switch the model for ALL selected sessions\n';
578
629
  out += ' d delete the cursor session (with confirmation)\n';
630
+ out += ' D (Shift+d) delete ALL selected sessions (with confirmation)\n';
579
631
  out += ' j / k, arrows move the cursor (selecting shows it in the TUI)\n';
580
632
  out += ' / search/filter the session list\n';
581
633
  out += ' ESC / q cancel broadcast mode (back to the session list)\n';
@@ -618,27 +670,60 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
618
670
  Math.max(8, cols - (showTime ? TIME_W + 10 : 8)),
619
671
  );
620
672
  const inBc = mode === 'broadcast' || mode === 'broadcast-confirm';
621
- const extras = [];
622
- if ((mode === 'confirm' || mode === 'broadcast-confirm') && confirmDel) {
623
- extras.push(`Delete session "${confirmDel.title || confirmDel.id || ''}"? (y/N) `);
624
- } else if (mode === 'input' && input) {
625
- extras.push(`${input.label} ${renderInputValue(input.value)}`);
626
- }
673
+ const extras = [...notices];
627
674
  if (search.active && view.length === 0) extras.push(`No sessions match "${search.value}"`);
628
675
  const selected = view[cursor];
629
676
  let infoLines = [];
630
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;
631
680
  if (inBc) {
632
- const names = rows.filter(r => broadcastSel.has(String(r.id))).map(r => r.title || r.id).join(', ');
633
- infoLines = [`Broadcast to sessions: ${names}`];
634
- 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';
635
696
  } else {
636
697
  const selIsCurrent = !!(selected && String(selected.id) === String(current));
637
698
  infoLines = sessionInfoLines(selected, runningIds, modelContext, cols, selIsCurrent);
638
699
  }
639
- 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
+ }
640
723
  process.stderr.write(renderList({
641
- title: 'ocmux — sessions',
724
+ // The project name heads the title so you always know which project's
725
+ // sessions are listed (matters when --all-projects moves you around).
726
+ title: dir ? `ocmux — ${path.basename(dir)} sessions` : 'ocmux — sessions',
642
727
  items: view,
643
728
  cursor,
644
729
  row: (i, s) => {
@@ -649,19 +734,20 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
649
734
  const hasNote = annotations && annotations[s.id] ? ' ◈' : '';
650
735
  const spin = (runningIds && runningIds.has(s.id)) ? SPINNER[spinIdx % SPINNER.length] + ' ' : ' ';
651
736
  const text = `${sel}${spin}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
652
- // Yellow for the session currently selected in the TUI window.
653
- return String(s.id) === String(current) ? `\x1b[33m${text}\x1b[0m` : text;
737
+ // Brown for the session currently selected in the TUI window.
738
+ return String(s.id) === String(current) ? `${BROWN}${text}\x1b[0m` : text;
654
739
  },
655
740
  footer,
656
- footerLeft: search.active,
657
- footerRight: search.active ? SEARCH_HINT : null,
741
+ footerLeft,
742
+ footerRight,
743
+ footerBg: questionBar ? QUESTION_BG : BAR_BG,
658
744
  infoLines,
659
745
  extraLines: extras,
660
746
  }));
661
747
  };
662
748
 
663
- const startInput = (label, value, kind) => {
664
- input = { label, value, kind, caret: value.length };
749
+ const startInput = (prompt, hint, value, kind) => {
750
+ input = { prompt, hint, value, kind, caret: value.length };
665
751
  mode = 'input';
666
752
  draw();
667
753
  };
@@ -698,15 +784,18 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
698
784
  applySearch();
699
785
  const ci = view.indexOf(created);
700
786
  cursor = ci >= 0 ? ci : 0;
787
+ current = created.id;
701
788
  } else if (!row.new) {
702
789
  current = row.id;
703
790
  }
704
791
  inspected = false;
705
792
  confirmDel = null;
793
+ confirmAll = false;
706
794
  draw();
707
795
  }).catch((err) => {
708
796
  process.stderr.write(`\nError: ${err.message}\n`);
709
797
  confirmDel = null;
798
+ confirmAll = false;
710
799
  draw();
711
800
  });
712
801
  };
@@ -715,11 +804,31 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
715
804
  const s = confirmDel;
716
805
  if (!s || !onDelete) return;
717
806
  const wasBroadcast = mode === 'broadcast-confirm';
718
- Promise.resolve(onDelete(s)).then(() => {
719
- 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)));
720
818
  applySearch();
721
819
  if (cursor >= view.length && cursor > 0) cursor = view.length - 1;
722
- 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
+ }
723
832
  if (wasBroadcast) {
724
833
  broadcastSel.delete(String(s.id));
725
834
  if (broadcastSel.size < 2) {
@@ -735,15 +844,32 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
735
844
  } else {
736
845
  persistBroadcast([...broadcastSel]);
737
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
+ }
738
853
  draw();
739
854
  }
740
855
  } else {
741
856
  mode = 'normal';
742
- 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
+ }
743
868
  }
744
869
  }).catch((err) => {
745
870
  process.stderr.write(`\nError: ${err.message}\n`);
746
871
  confirmDel = null;
872
+ confirmAll = false;
747
873
  mode = wasBroadcast ? 'broadcast' : 'normal';
748
874
  draw();
749
875
  });
@@ -802,7 +928,9 @@ const toggleBroadcast = (idx) => {
802
928
  draw();
803
929
  };
804
930
 
805
- const openList = (title, items, format, onEnter, returnMode = 'normal') => {
931
+ // `startCursor` lets a picker open with the cursor already on the relevant
932
+ // entry (the model pickers open on the current model).
933
+ const openList = (title, items, format, onEnter, returnMode = 'normal', startCursor = 0) => {
806
934
  if (!items || items.length === 0) {
807
935
  process.stderr.write(`\nNo ${title.toLowerCase()} available.\n`);
808
936
  draw();
@@ -810,7 +938,7 @@ const toggleBroadcast = (idx) => {
810
938
  }
811
939
  list = { title, items, view: items, format, onEnter, returnMode };
812
940
  listSearch = newSearchState();
813
- listCursor = 0;
941
+ listCursor = Math.max(0, Math.min(startCursor, items.length - 1));
814
942
  mode = 'list';
815
943
  draw();
816
944
  };
@@ -820,6 +948,16 @@ const toggleBroadcast = (idx) => {
820
948
  draw();
821
949
  };
822
950
 
951
+ // Open the model picker with the list sorted by provider and the cursor
952
+ // already on `session`'s current model (so `m` starts where the change is).
953
+ const openModelPicker = (title, session, returnMode, onPick) => {
954
+ listModels(server).then((models) => {
955
+ const sorted = sortModels(models);
956
+ openList(title, sorted, (x) => `${x.label}${x.variant ? `#${x.variant}` : ''}`,
957
+ onPick, returnMode, indexOfModel(sorted, session && session.model));
958
+ }).catch(commandErr);
959
+ };
960
+
823
961
  process.stdin.on('keypress', (str, key) => {
824
962
  // Ctrl+C always fully exits ocmux, from any menu.
825
963
  if (key.ctrl && key.name === 'c') {
@@ -839,6 +977,7 @@ const toggleBroadcast = (idx) => {
839
977
  if (key.name === 'y') return applyDelete();
840
978
  if (key.name === 'n' || key.name === 'escape' || key.name === 'q' || key.name === 'return') {
841
979
  confirmDel = null;
980
+ confirmAll = false;
842
981
  mode = mode === 'broadcast-confirm' ? 'broadcast' : 'normal';
843
982
  draw();
844
983
  }
@@ -943,6 +1082,15 @@ const toggleBroadcast = (idx) => {
943
1082
  draw();
944
1083
  } else if (key.name === 'space') {
945
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
+ }
946
1094
  } else if (key.name === 'd') {
947
1095
  if (cursor < view.length) {
948
1096
  confirmDel = view[cursor];
@@ -950,11 +1098,9 @@ const toggleBroadcast = (idx) => {
950
1098
  draw();
951
1099
  }
952
1100
  } else if (key.name === 'm') {
953
- listModels(server).then((models) => {
954
- openList('Broadcast model (all selected sessions)', models, (x) => `${x.label}${x.variant ? `#${x.variant}` : ''}`, async (item) => {
955
- await Promise.all([...broadcastSel].map(id => switchModel(server, id, item)));
956
- }, 'broadcast');
957
- }).catch(commandErr);
1101
+ openModelPicker('Broadcast model (all selected sessions)', view[cursor], 'broadcast', async (item) => {
1102
+ await Promise.all([...broadcastSel].map(id => switchModel(server, id, item)));
1103
+ });
958
1104
  } else if (key.name === 'return' || key.name === 'enter') {
959
1105
  if (cursor < view.length) {
960
1106
  mode = 'normal';
@@ -978,18 +1124,18 @@ const toggleBroadcast = (idx) => {
978
1124
  return;
979
1125
  }
980
1126
  if (key.name === 'n') {
981
- startInput('New session name (blank for auto; ESC cancels):', '', 'create');
1127
+ startInput('New session name (blank = auto):', 'Enter: create · Esc: cancel', '', 'create');
982
1128
  return;
983
1129
  }
984
1130
  // Shift+r → Reminder; lowercase r → Rename.
985
1131
  if (str === 'R') {
986
1132
  const row = view[cursor];
987
1133
  const note = (row && annotations && annotations[row.id]) || '';
988
- startInput('Reminder for this session (blank clears; ESC cancels):', note, 'reminder');
1134
+ startInput('Reminder (blank clears):', 'Enter: save · Esc: cancel', note, 'reminder');
989
1135
  return;
990
1136
  }
991
1137
  if (key.name === 'r' && cursor < view.length) {
992
- startInput('Rename to (ESC cancels):', view[cursor].title || '', 'rename');
1138
+ startInput('Rename to:', 'Enter: rename · Esc: cancel', view[cursor].title || '', 'rename');
993
1139
  return;
994
1140
  }
995
1141
  if (key.name === 'a') {
@@ -1006,12 +1152,7 @@ const toggleBroadcast = (idx) => {
1006
1152
  }
1007
1153
  if (key.name === 'm') {
1008
1154
  const target = view[cursor];
1009
- if (target) {
1010
- listModels(server).then((models) => {
1011
- openList('Models', models, (x) => `${x.label}${x.variant ? `#${x.variant}` : ''}`, (item) =>
1012
- switchModel(server, target.id, item));
1013
- }).catch(commandErr);
1014
- }
1155
+ if (target) openModelPicker('Models', target, 'normal', (item) => switchModel(server, target.id, item));
1015
1156
  return;
1016
1157
  }
1017
1158
  if (key.name === 'd') {
@@ -1053,8 +1194,19 @@ const toggleBroadcast = (idx) => {
1053
1194
  });
1054
1195
  }
1055
1196
 
1056
- // Interactive project picker — purely visual/navigation, never writes state.
1057
- function switchMenu(rows, longMode) {
1197
+ // Interactive project picker — never writes state (it only moves what a
1198
+ // registered dedicated/shared TUI displays).
1199
+ //
1200
+ // Default (safe): an INSPECTOR. Selecting a project/session row still focuses
1201
+ // the routed TUI and shows the session there, but the promise always resolves to
1202
+ // null, so the caller re-opens its own project's list. Nothing about the
1203
+ // caller's project changes.
1204
+ //
1205
+ // `{ allProjects: true }` (CLI `--all-projects`): a real SWITCHER. Selecting a
1206
+ // row also records the target directory, so the caller moves its picker there
1207
+ // and can then write that project's `.ocmux.json` — the file `agentp` reads in
1208
+ // that directory.
1209
+ function switchMenu(rows, { allProjects = false, onOpen } = {}) {
1058
1210
  return new Promise((resolve) => {
1059
1211
  if (!process.stdin.isTTY) {
1060
1212
  console.error('Error: "switch" is interactive and requires a TTY.');
@@ -1074,22 +1226,53 @@ function switchMenu(rows, longMode) {
1074
1226
  const lineWidth = Math.max(cols, nameW + 2 + stsW + 2 + ssnW + 6);
1075
1227
 
1076
1228
  let cursor = 0;
1077
- let activeIdx = null; // tmux window index of the focused project
1078
- let lastDir = null;
1229
+ let lastDir = null; // target dir to move to (only with allProjects)
1079
1230
  let done = false;
1080
1231
  let help = false;
1081
1232
  const search = newSearchState();
1082
- let view = rows;
1233
+ let view = []; // flattened nodes: { kind:'project'|'session', ... }
1234
+ // Fold state per project directory: { open, sessions, loading, error }.
1235
+ const folds = new Map();
1236
+
1237
+ const foldOf = (p) => folds.get(p.dir) || { open: false, sessions: null, loading: false, error: null };
1238
+ const projectText = (p) => `${path.basename(p.dir)} ${p.dir} ${p.session || ''}`;
1239
+ const sessionText = (s) => `${s.title || ''} ${s.id || ''}`;
1240
+ const matches = (pattern, text) => filterByPattern([1], pattern, () => text).length > 0;
1241
+
1242
+ // Flatten projects (plus their unfolded sessions) honouring the '/' pattern.
1243
+ // A project header survives when it matches itself OR when one of its
1244
+ // unfolded sessions does, so a match deeper down is still navigable.
1245
+ const buildView = () => {
1246
+ const pattern = search.active ? search.value : '';
1247
+ const out = [];
1248
+ for (const p of rows) {
1249
+ const f = foldOf(p);
1250
+ const sessions = f.open && f.sessions ? f.sessions : [];
1251
+ const shown = sessions.filter((s) => matches(pattern, sessionText(s)));
1252
+ const header = { kind: 'project', project: p };
1253
+ if (sessions.length === 0) {
1254
+ if (matches(pattern, projectText(p))) out.push(header);
1255
+ } else if (matches(pattern, projectText(p)) || shown.length) {
1256
+ out.push(header);
1257
+ for (const s of shown) out.push({ kind: 'session', project: p, session: s });
1258
+ }
1259
+ }
1260
+ return out;
1261
+ };
1083
1262
 
1084
- const applySearch = () => {
1085
- view = filterByPattern(rows, search.active ? search.value : '',
1086
- (r) => `${path.basename(r.dir)} ${r.dir} ${r.session || ''}`);
1087
- if (cursor >= view.length) cursor = Math.max(0, view.length - 1);
1263
+ const sameNode = (a, b) => {
1264
+ if (!a || !b || a.kind !== b.kind || a.project.dir !== b.project.dir) return false;
1265
+ return a.kind !== 'session' || a.session.id === b.session.id;
1088
1266
  };
1089
1267
 
1090
- const refreshActive = () => {
1091
- activeIdx = activeWindowIndex();
1268
+ const applySearch = () => {
1269
+ const prev = view[cursor];
1270
+ view = buildView();
1271
+ const idx = prev ? view.findIndex((n) => sameNode(n, prev)) : -1;
1272
+ if (idx >= 0) cursor = idx;
1273
+ else if (cursor >= view.length) cursor = Math.max(0, view.length - 1);
1092
1274
  };
1275
+ applySearch();
1093
1276
 
1094
1277
  const cleanup = () => {
1095
1278
  process.stderr.write('\x1b[?25h');
@@ -1108,53 +1291,130 @@ function switchMenu(rows, longMode) {
1108
1291
  resolve(value === undefined ? lastDir : value);
1109
1292
  };
1110
1293
 
1294
+ // Mode-dependent wording: the switcher inspects by default and really
1295
+ // switches only under --all-projects.
1296
+ const switcherTitle = allProjects ? 'ocmux — project switcher (all projects)' : 'ocmux — project switcher';
1297
+ const enterHint = allProjects ? 'Enter: switch' : 'Enter: view';
1298
+ const modeNote = allProjects
1299
+ ? ['--all-projects: selecting moves the picker to that project, so a',
1300
+ 'session picked there updates ITS .ocmux.json — the file agentp',
1301
+ 'reads when it runs in that directory.']
1302
+ : ['Selecting only moves the TUI view — .ocmux.json is never',
1303
+ 'written, so agentp keeps prompting your own project’s session.',
1304
+ 'You always come back to your own project’s list.'];
1305
+
1111
1306
  const draw = () => {
1307
+ if (done) return;
1112
1308
  if (help) {
1113
1309
  let out = '\x1b[2J\x1b[H';
1114
1310
  out += 'ocmux project switcher — help\n\n';
1115
- out += ' j / k, arrows move the cursor\n';
1116
- out += ' Enter / Space focus the selected project (menu stays open)\n';
1311
+ out += ' j / k, arrows move the cursor (projects and sessions)\n';
1312
+ out += ` Enter ${allProjects ? 'switch the picker to the selected project' : 'focus the selected project (menu stays open)'}\n`;
1313
+ out += ' (on a session, show it in that project’s TUI)\n';
1314
+ out += ' Space fold/unfold a project’s sessions;\n';
1315
+ out += ' on a session, show it in the TUI as well\n';
1117
1316
  out += ' / search/filter the list (Enter keeps, ESC clears)\n';
1118
1317
  out += ' h toggle this help\n';
1119
1318
  out += ' q return to the session menu\n';
1120
1319
  out += ' Ctrl+C quit ocmux\n';
1320
+ out += '\n';
1321
+ for (const l of modeNote) out += `${l}\n`;
1121
1322
  out += '\nPress any key to return.\n';
1122
1323
  process.stderr.write(out);
1123
1324
  return;
1124
1325
  }
1125
- refreshActive();
1126
- const extras = (search.active && view.length === 0) ? [`No projects match "${search.value}"`] : [];
1326
+ const extras = [];
1327
+ if (search.active && view.length === 0) extras.push(`No projects match "${search.value}"`);
1328
+ for (const n of view) {
1329
+ if (n.kind !== 'project') continue;
1330
+ const f = folds.get(n.project.dir);
1331
+ if (!f || !f.open) continue;
1332
+ const name = path.basename(n.project.dir);
1333
+ if (f.loading) extras.push(` … loading sessions of ${name} …`);
1334
+ else if (f.error) extras.push(` ! ${name}: ${f.error}`);
1335
+ }
1127
1336
  process.stderr.write(renderList({
1128
- title: 'ocmux — project switcher',
1337
+ title: switcherTitle,
1129
1338
  items: view,
1130
1339
  cursor,
1131
- row: (i, row) => {
1132
- const session = row.session ? row.session.slice(0, 14) : '(none)';
1133
- const text = `${pad(path.basename(row.dir), nameW)} ${pad(row.status, stsW)} ${pad(session, ssnW)} ${row.dir}`;
1134
- return row.index === activeIdx ? { text, reverse: true } : text;
1340
+ row: (i, node) => {
1341
+ if (node.kind === 'session') {
1342
+ const s = node.session;
1343
+ const id = String(s.id || '');
1344
+ const mark = String(s.id) === String(node.project.session) ? ' *' : '';
1345
+ const title = (s.title || id).slice(0, Math.max(8, nameW + 6));
1346
+ return ` ${title}${mark} ${id}`;
1347
+ }
1348
+ const p = node.project;
1349
+ const fold = foldOf(p).open ? '▾' : '▸';
1350
+ const session = p.session ? p.session.slice(0, 14) : '(none)';
1351
+ const text = `${fold} ${pad(path.basename(p.dir), nameW)} ${pad(p.status, stsW)} ${pad(session, ssnW)} ${p.dir}`;
1352
+ return text;
1135
1353
  },
1136
- footer: searchFooter(search, 'Enter: focus · /: search · h: help · q: back · Ctrl+C: quit'),
1354
+ footer: searchFooter(search, `${enterHint} · Space: fold · /: search · h: help · q: back · Ctrl+C: quit`),
1137
1355
  footerLeft: search.active,
1138
1356
  footerRight: search.active ? SEARCH_HINT : null,
1139
1357
  extraLines: extras,
1140
1358
  }));
1141
1359
  };
1142
1360
 
1143
- const activate = (row) => {
1144
- if (!row) return;
1361
+ // Enter on a project row: show its current session in its routed TUI. With
1362
+ // allProjects off this stays a pure inspection and
1363
+ // `lastDir` is left null so the caller returns to its own project.
1364
+ const activate = (p) => {
1365
+ if (!p) return;
1145
1366
  try {
1146
- if (row.server) {
1147
- activateProject(row.dir, row.index, row.server, row.session);
1148
- } else {
1149
- tmux(['select-window', '-t', `${SESSION}:${row.index}`]);
1150
- }
1151
- lastDir = row.dir;
1367
+ if (p.server && p.session && onOpen) onOpen(p, p.session);
1368
+ if (allProjects) lastDir = p.dir;
1152
1369
  } catch (err) {
1153
1370
  process.stderr.write(`\nError: ${err.message}\n`);
1154
1371
  }
1155
1372
  draw();
1156
1373
  };
1157
1374
 
1375
+ // Enter/Space on a session row: move the TUI VIEW onto it. The switcher
1376
+ // itself never writes `.ocmux.json`; with allProjects on the caller only
1377
+ // moves its picker to that project — the write happens later, when a
1378
+ // session is picked there (and that file is what agentp reads there).
1379
+ const openSession = (node) => {
1380
+ const p = node.project;
1381
+ try {
1382
+ if (p.server && onOpen) onOpen(p, node.session.id);
1383
+ if (allProjects) lastDir = p.dir;
1384
+ } catch (err) {
1385
+ process.stderr.write(`\nError: ${err.message}\n`);
1386
+ }
1387
+ draw();
1388
+ };
1389
+
1390
+ // Space on a project row: fetch its sessions once, then flip the fold.
1391
+ const toggleFold = (p) => {
1392
+ const f = folds.get(p.dir) || { open: false, sessions: null, loading: false, error: null };
1393
+ folds.set(p.dir, f);
1394
+ if (!f.open && !f.sessions && !f.error && !f.loading) {
1395
+ if (!p.server) {
1396
+ f.error = 'no server recorded';
1397
+ } else {
1398
+ f.loading = true;
1399
+ f.open = true;
1400
+ applySearch();
1401
+ draw();
1402
+ listSessions(p.server, p.dir)
1403
+ .then((list) => {
1404
+ // Main sessions only: child/subagent sessions are work products,
1405
+ // not navigation targets (same rule as the session picker).
1406
+ f.sessions = sortSessionsByRecency((list || []).filter((s) => !s.parentID));
1407
+ })
1408
+ .catch((err) => { f.error = err.message; })
1409
+ .then(() => { f.loading = false; applySearch(); draw(); });
1410
+ return;
1411
+ }
1412
+ }
1413
+ f.open = !f.open;
1414
+ applySearch();
1415
+ draw();
1416
+ };
1417
+
1158
1418
  const onSig = () => finish(SWITCH_QUIT);
1159
1419
  process.on('SIGINT', onSig);
1160
1420
 
@@ -1167,14 +1427,19 @@ function switchMenu(rows, longMode) {
1167
1427
  if (key.name === 'h') { help = true; draw(); return; }
1168
1428
  if (key.name === 'q') return finish();
1169
1429
  if (view.length === 0) return;
1430
+ const node = view[cursor];
1170
1431
  if (key.name === 'up' || str === 'k') {
1171
1432
  cursor = (cursor + view.length - 1) % view.length;
1172
1433
  draw();
1173
1434
  } else if (key.name === 'down' || str === 'j') {
1174
1435
  cursor = (cursor + 1) % view.length;
1175
1436
  draw();
1176
- } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
1177
- activate(view[cursor]);
1437
+ } else if (key.name === 'return' || key.name === 'enter') {
1438
+ if (node.kind === 'project') activate(node.project);
1439
+ else openSession(node);
1440
+ } else if (key.name === 'space') {
1441
+ if (node.kind === 'project') toggleFold(node.project);
1442
+ else openSession(node);
1178
1443
  }
1179
1444
  });
1180
1445
 
@@ -1191,17 +1456,22 @@ function switchMenu(rows, longMode) {
1191
1456
  // Subcommand: default — interactive session switcher for the current project
1192
1457
  // ---------------------------------------------------------------------------
1193
1458
 
1194
- async function cmdSessionSwitch(targetDir, directRef) {
1459
+ // `allProjects` (CLI `--all-projects`) unlocks cross-project switching: the
1460
+ // project switcher may then move this picker onto another project, whose
1461
+ // `.ocmux.json` gets written when a session is picked there. Without it the
1462
+ // switcher is inspection-only and this command always stays on `targetDir`'s
1463
+ // project.
1464
+ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
1195
1465
  let target = targetDir;
1196
1466
  for (;;) {
1197
1467
  const sf = findParentStatefile(target);
1198
1468
  if (!sf) {
1199
1469
  console.error(`Error: no .ocmux.json found in ${target} or any of its parents.`);
1200
- console.error(`Run 'ocmux serve' to create a project window.`);
1470
+ console.error(`Run 'ocmux serve' to initialize the project.`);
1201
1471
  process.exit(1);
1202
1472
  }
1203
1473
  const state = projectState.readProjectState(sf);
1204
- const server = state.server;
1474
+ let server = state.server;
1205
1475
  if (!server) {
1206
1476
  console.error(`Error: ${sf} does not record a server.`);
1207
1477
  console.error(`Run 'ocmux serve --server <url>' to set it up, or add "server" to the file.`);
@@ -1209,29 +1479,43 @@ async function cmdSessionSwitch(targetDir, directRef) {
1209
1479
  }
1210
1480
  const dir = state.directory;
1211
1481
 
1212
- 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
+ }
1213
1495
  if (!alive) {
1214
1496
  console.error(`Error: OpenCode server not reachable at ${server}.`);
1215
1497
  console.error(`Start it with 'opencode serve' (or your usual launcher) first.`);
1216
1498
  process.exit(1);
1217
1499
  }
1218
1500
 
1219
- const winIdx = windowByDir(dir);
1220
- if (winIdx == null) {
1221
- console.error(`Error: no tmux window for ${dir}.`);
1222
- console.error(`Run 'ocmux serve' to create it.`);
1223
- process.exit(1);
1224
- }
1225
-
1226
- // Focus this project's window up-front so the picker (and any session
1227
- // switch) never leaves another project's window selected.
1228
- activateProject(dir, winIdx, server, state.session);
1229
-
1230
1501
  const sessions = await listSessions(server, dir).catch((err) => {
1231
1502
  console.error(`Error connecting to ${server}: ${err.message}`);
1232
1503
  process.exit(1);
1233
1504
  });
1234
1505
  const sorted = sortSessionsByRecency(sessions);
1506
+ const notices = [];
1507
+ if (state.session && !sorted.some(s => String(s.id) === String(state.session))) {
1508
+ try {
1509
+ const moved = await getSession(server, state.session);
1510
+ const movedDir = moved && moved.location && moved.location.directory;
1511
+ if (movedDir && path.resolve(movedDir) !== path.resolve(dir)) {
1512
+ notices.push(`⚠ Current session moved to ${movedDir}`);
1513
+ notices.push(` Run: ocmux serve ${JSON.stringify(movedDir)} --server ${JSON.stringify(server)}`);
1514
+ }
1515
+ } catch {
1516
+ // A deleted/stale stored session is handled by the normal picker.
1517
+ }
1518
+ }
1235
1519
  // Only "main" sessions are listed in the picker: exclude child/subagent
1236
1520
  // sessions (parentID set) — they are work products of a main session, not
1237
1521
  // targets. Explicit --session can still reach one by id/title.
@@ -1246,7 +1530,7 @@ async function cmdSessionSwitch(targetDir, directRef) {
1246
1530
 
1247
1531
  const applySession = (id) => {
1248
1532
  projectState.writeProjectState(sf, { session: id });
1249
- relaunchTui(winIdx, dir, server, id);
1533
+ refreshTui(dir, server, id);
1250
1534
  };
1251
1535
 
1252
1536
  if (directRef) {
@@ -1262,8 +1546,7 @@ async function cmdSessionSwitch(targetDir, directRef) {
1262
1546
  }
1263
1547
 
1264
1548
  if (!process.stdin.isTTY) {
1265
- // Silent path (non-TTY): just make sure the window is focused.
1266
- activateProject(dir, winIdx, server, state.session);
1549
+ // Silent path (non-TTY): project state already identifies the target.
1267
1550
  return;
1268
1551
  }
1269
1552
 
@@ -1271,12 +1554,20 @@ async function cmdSessionSwitch(targetDir, directRef) {
1271
1554
  // model of the previously selected session); r renames; R sets a reminder;
1272
1555
  // d deletes; a switches agent; m switches model; p switches project; q quits.
1273
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
+ }
1274
1563
  const result = await new Promise((resolve) => {
1275
1564
  sessionMenu(mainSessions, state.session, annotations, server, {
1565
+ dir,
1276
1566
  runningIds,
1277
1567
  modelContext,
1568
+ notices,
1278
1569
  onBroadcastChange: (ids) => projectState.writeProjectState(sf, { broadcast: ids }),
1279
- onInspectSession: (id) => relaunchTui(winIdx, dir, server, id),
1570
+ onInspectSession: (id) => refreshTui(dir, server, id),
1280
1571
  onPick: async (row) => {
1281
1572
  if (row.new) {
1282
1573
  const created = await createSessionWithModel(server, row.name || undefined, dir, [lastRef, ...sorted]).catch((err) => {
@@ -1317,10 +1608,18 @@ async function cmdSessionSwitch(targetDir, directRef) {
1317
1608
  });
1318
1609
 
1319
1610
  if (result === 'switch-project') {
1320
- // 'p' pressed: open the project switcher. Picking a project re-runs this
1321
- // picker for it; 'q' returns here unchanged; Ctrl+C fully exits.
1322
- ensureSession();
1323
- const picked = await switchMenu(listProjects());
1611
+ // 'p' pressed: open the project switcher. Without --all-projects it only
1612
+ // inspects (picked stays null => re-run this picker for the same
1613
+ // project); with it, picking a project re-runs the picker there.
1614
+ // Ctrl+C fully exits.
1615
+ const rows = await configuredProjects(server).catch((err) => {
1616
+ console.error(`Error listing projects: ${err.message}`);
1617
+ return [];
1618
+ });
1619
+ const picked = await switchMenu(rows, {
1620
+ allProjects,
1621
+ onOpen: (project, id) => refreshTui(project.dir, project.server, id),
1622
+ });
1324
1623
  if (picked === SWITCH_QUIT) break;
1325
1624
  if (!picked) continue;
1326
1625
  target = picked;
@@ -1331,7 +1630,7 @@ async function cmdSessionSwitch(targetDir, directRef) {
1331
1630
  }
1332
1631
 
1333
1632
  // ---------------------------------------------------------------------------
1334
- // Subcommand: serve — create a project TUI window (server is user-managed)
1633
+ // Subcommand: serve — initialize project/session state (server is user-managed)
1335
1634
  // ---------------------------------------------------------------------------
1336
1635
 
1337
1636
  async function cmdServeNew(targetDir, serverArg, force) {
@@ -1340,7 +1639,7 @@ async function cmdServeNew(targetDir, serverArg, force) {
1340
1639
  const sf = path.join(targetDir, '.ocmux.json');
1341
1640
  if (fs.existsSync(sf)) {
1342
1641
  if (!force) {
1343
- console.error(`Error: a project window already exists in ${targetDir}.`);
1642
+ console.error(`Error: project state already exists in ${targetDir}.`);
1344
1643
  console.error(`Use 'ocmux' to switch sessions there, or 'ocmux serve --server <url> --force' to repoint it.`);
1345
1644
  process.exit(1);
1346
1645
  }
@@ -1350,11 +1649,10 @@ async function cmdServeNew(targetDir, serverArg, force) {
1350
1649
  const parent = findParentStatefile(path.dirname(targetDir));
1351
1650
  if (parent) {
1352
1651
  const parentDir = path.dirname(parent);
1353
- console.error(`Warning: there is already an opencode project in ${parentDir}.`);
1354
- console.error(`Run 'ocmux' and press 'p' to move between project windows.`);
1652
+ console.error(`Warning: there is already an ocmux project in ${parentDir}.`);
1355
1653
 
1356
1654
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
1357
- rl.question('Are you sure you want to create a new project window here? [y/N] ', (answer) => {
1655
+ rl.question('Are you sure you want to initialize a nested project here? [y/N] ', (answer) => {
1358
1656
  rl.close();
1359
1657
  if (!/^[yY]([eE][sS])?$/.test(answer.trim())) {
1360
1658
  console.error('Cancelled.');
@@ -1370,8 +1668,8 @@ async function cmdServeNew(targetDir, serverArg, force) {
1370
1668
  return doServeNew(targetDir, serverArg);
1371
1669
  }
1372
1670
 
1373
- // Repoint an existing project to a (possibly new) server: update .ocmux.json's
1374
- // `server`, keep the session if it still exists, and (re)launch the TUI on it.
1671
+ // Repoint an existing project to a (possibly new) server. A registered TUI is
1672
+ // refreshed through the normal dedicated → shared routing rule.
1375
1673
  async function doForceServe(targetDir, sf, server) {
1376
1674
  const alive = await checkServer(server);
1377
1675
  if (!alive) {
@@ -1392,14 +1690,8 @@ async function doForceServe(targetDir, sf, server) {
1392
1690
  session = created.id;
1393
1691
  }
1394
1692
  projectState.writeProjectState(sf, { server, session });
1395
- const winIdx = windowByDir(targetDir);
1396
- if (winIdx != null) {
1397
- relaunchTui(winIdx, targetDir, server, session);
1398
- console.error(`Repointed ${targetDir} to ${server} (session ${session}).`);
1399
- } else {
1400
- createProjectWindow(targetDir, server, session);
1401
- console.error(`Recreated project window for ${targetDir} → ${server} (session ${session}).`);
1402
- }
1693
+ refreshTui(targetDir, server, session);
1694
+ console.error(`Repointed ${targetDir} to ${server} (session ${session}).`);
1403
1695
  }
1404
1696
 
1405
1697
  async function doServeNew(targetDir, serverArg) {
@@ -1431,108 +1723,183 @@ async function doServeNew(targetDir, serverArg) {
1431
1723
  }
1432
1724
  }
1433
1725
 
1434
- console.error(`Creating project window for ${targetDir} (session ${session})...`);
1435
- try {
1436
- createProjectWindow(targetDir, server, session);
1437
- } catch (err) {
1438
- console.error(`Error: ${err.message}`);
1439
- process.exit(1);
1440
- }
1726
+ projectState.writeProjectState(path.join(targetDir, '.ocmux.json'), {
1727
+ directory: targetDir,
1728
+ server,
1729
+ session,
1730
+ status: 'active',
1731
+ });
1732
+ refreshTui(targetDir, server, session);
1733
+ console.error(`Initialized ${targetDir} → ${server} (session ${session}).`);
1441
1734
  }
1442
1735
 
1443
1736
  // ---------------------------------------------------------------------------
1444
- // Subcommand: list
1737
+ // Subcommand: tui — register this tmux pane and host an OpenCode TUI
1445
1738
  // ---------------------------------------------------------------------------
1446
1739
 
1447
- function cmdList(longMode) {
1448
- const rows = listProjects();
1449
- if (rows.length === 0) {
1450
- console.log('No opencode projects running.');
1451
- return;
1452
- }
1453
-
1454
- const dirW = Math.max(...rows.map(r => r.dir.length), 9);
1455
- const stsW = Math.max(...rows.map(r => r.status.length), 6);
1456
- const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
1457
- const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
1458
-
1459
- console.log(`${pad('Directory', dirW)} ${pad('Status', stsW)} ${pad('Session', ssnW)}`);
1460
- for (const row of rows) {
1461
- console.log(`${pad(row.dir, dirW)} ${pad(row.status, stsW)} ${pad(row.session || '(none)', ssnW)}`);
1462
- if (longMode && row.server) console.log(` server: ${row.server}`);
1740
+ async function cmdTui(targetDir, serverArg, shared, sessionArg) {
1741
+ const tmuxTarget = tuiRegistry.parseTmuxEnvironment();
1742
+ if (!tmuxTarget) {
1743
+ console.error("Error: 'ocmux tui' must run inside a tmux pane.");
1744
+ process.exit(1);
1463
1745
  }
1464
- }
1465
-
1466
- // ---------------------------------------------------------------------------
1467
- // Subcommand: kill — close the project window but keep .ocmux.json
1468
- // ---------------------------------------------------------------------------
1469
-
1470
- function cmdKill(targetDir) {
1471
1746
  const sf = findParentStatefile(targetDir);
1472
- if (!sf) {
1747
+ const state = sf ? projectState.readProjectState(sf) : null;
1748
+ if (!state && !shared) {
1473
1749
  console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
1750
+ console.error("Run 'ocmux serve' first.");
1474
1751
  process.exit(1);
1475
1752
  }
1476
- const serverDir = path.dirname(sf);
1477
- const winIdx = windowByDir(serverDir);
1478
- if (winIdx != null) {
1479
- console.error(`Closing project window for ${serverDir}...`);
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;
1757
+ if (!await checkServer(server)) {
1758
+ console.error(`Error: OpenCode server not reachable at ${server}.`);
1759
+ process.exit(1);
1760
+ }
1761
+
1762
+ let session = sessionArg || (state && state.session) || null;
1763
+ if (session) {
1480
1764
  try {
1481
- closeProjectWindow(winIdx);
1482
- } catch (err) {
1483
- console.error(`Error: ${err.message}`);
1484
- process.exit(1);
1765
+ const found = await getSession(server, session);
1766
+ if (found.location && found.location.directory) targetDir = found.location.directory;
1767
+ } catch {
1768
+ session = null;
1485
1769
  }
1486
- } else {
1487
- console.error(`Tmux window not found for ${serverDir} (state kept).`);
1488
1770
  }
1489
- // Session memory intentionally kept.
1490
- projectState.writeProjectState(sf, { status: 'stopped' });
1491
- console.error('Done (state file kept).');
1492
- }
1771
+ if (!session) {
1772
+ const sessions = await listSessions(server, directory);
1773
+ session = newestSessionId(sessions);
1774
+ if (!session) session = (await createSessionWithModel(server, undefined, directory, sessions)).id;
1775
+ if (sf) projectState.writeProjectState(sf, { session, server });
1776
+ }
1493
1777
 
1494
- // ---------------------------------------------------------------------------
1495
- // Subcommand: resurrect — recreate a project window from its state
1496
- // ---------------------------------------------------------------------------
1778
+ const registration = tuiRegistry.register({
1779
+ ...tmuxTarget,
1780
+ shared,
1781
+ directory: shared ? targetDir : directory,
1782
+ server,
1783
+ session,
1784
+ pid: process.pid,
1785
+ });
1497
1786
 
1498
- async function cmdResurrect(targetDir) {
1787
+ const instance = registration.instance;
1788
+ const launchDir = targetDir || directory;
1789
+ const child = child_process.spawn('opencode', tuiArgs(server, session, launchDir), {
1790
+ cwd: launchDir,
1791
+ stdio: 'inherit',
1792
+ });
1793
+ tuiRegistry.updateInstance(instance.id, instance.token, { childPid: child.pid || null });
1794
+
1795
+ return new Promise((resolve, reject) => {
1796
+ let settled = false;
1797
+ const forward = (signal) => {
1798
+ try { child.kill(signal); } catch {}
1799
+ };
1800
+ const onHup = () => forward('SIGHUP');
1801
+ const onTerm = () => forward('SIGTERM');
1802
+ const onInt = () => forward('SIGINT');
1803
+ process.on('SIGHUP', onHup);
1804
+ process.on('SIGTERM', onTerm);
1805
+ process.on('SIGINT', onInt);
1806
+ const finish = (err) => {
1807
+ if (settled) return;
1808
+ settled = true;
1809
+ process.removeListener('SIGHUP', onHup);
1810
+ process.removeListener('SIGTERM', onTerm);
1811
+ process.removeListener('SIGINT', onInt);
1812
+ tuiRegistry.unregister(instance.id, instance.token);
1813
+ if (err) reject(err);
1814
+ else resolve();
1815
+ };
1816
+ child.once('error', finish);
1817
+ child.once('exit', () => finish());
1818
+ });
1819
+ }
1820
+
1821
+ function tuiProjectDirectory(targetDir) {
1499
1822
  const sf = findParentStatefile(targetDir);
1500
- if (!sf) {
1823
+ if (!sf) return null;
1824
+ const state = projectState.readProjectState(sf);
1825
+ return state && state.directory;
1826
+ }
1827
+
1828
+ function formatTuiRegistration(instance) {
1829
+ return `${instance.shared ? 'shared' : 'project'} ${instance.pane} ${instance.directory || '(none)'} ${instance.server || '(none)'} ${instance.session || '(none)'}`;
1830
+ }
1831
+
1832
+ function cmdTuiList() {
1833
+ const instances = tuiRegistry.list();
1834
+ if (instances.length === 0) {
1835
+ console.log('No registered TUIs.');
1836
+ return;
1837
+ }
1838
+ console.log('Kind Pane Directory Server Session');
1839
+ for (const instance of instances) console.log(formatTuiRegistration(instance));
1840
+ }
1841
+
1842
+ function cmdTuiStatus(targetDir, shared) {
1843
+ const directory = shared ? null : tuiProjectDirectory(targetDir);
1844
+ if (!shared && !directory) {
1501
1845
  console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
1502
1846
  process.exit(1);
1503
1847
  }
1504
- const state = projectState.readProjectState(sf);
1505
- const server = state.server;
1506
- if (!server) {
1507
- console.error(`Error: ${sf} does not record a server.`);
1848
+ const instance = shared ? tuiRegistry.shared() : tuiRegistry.dedicated(directory);
1849
+ if (!instance) {
1850
+ console.log(shared ? 'Shared TUI: not registered' : `Project TUI: not registered (${directory})`);
1851
+ return;
1852
+ }
1853
+ console.log(formatTuiRegistration(instance));
1854
+ }
1855
+
1856
+ function cmdTuiDetach(targetDir, shared) {
1857
+ const directory = shared ? null : tuiProjectDirectory(targetDir);
1858
+ if (!shared && !directory) {
1859
+ console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
1508
1860
  process.exit(1);
1509
1861
  }
1510
- const dir = state.directory;
1862
+ const instance = shared ? tuiRegistry.shared() : tuiRegistry.dedicated(directory);
1863
+ if (!instance) {
1864
+ console.log(shared ? 'Shared TUI was not registered.' : `Project TUI was not registered (${directory}).`);
1865
+ return;
1866
+ }
1867
+ tuiRegistry.unregister(instance.id, instance.token);
1868
+ console.log(`Detached ${shared ? 'shared' : 'project'} TUI from ${instance.pane}.`);
1869
+ }
1511
1870
 
1512
- const winIdx = windowByDir(dir);
1513
- if (winIdx != null) {
1514
- console.error(`Project window for ${dir} already exists.`);
1871
+ // ---------------------------------------------------------------------------
1872
+ // Subcommand: list
1873
+ // ---------------------------------------------------------------------------
1874
+
1875
+ async function cmdList(longMode, server) {
1876
+ const rows = await configuredProjects(server);
1877
+ if (rows.length === 0) {
1878
+ console.log('No configured ocmux projects found.');
1515
1879
  return;
1516
1880
  }
1517
1881
 
1518
- const alive = await checkServer(server);
1519
- if (!alive) {
1520
- console.error(`Error: OpenCode server not reachable at ${server}.`);
1521
- process.exit(1);
1882
+ const dirW = Math.max(...rows.map(r => r.dir.length), 9);
1883
+ const stsW = Math.max(...rows.map(r => r.status.length), 6);
1884
+ const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
1885
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
1886
+
1887
+ console.log(`${pad('Directory', dirW)} ${pad('Status', stsW)} ${pad('Session', ssnW)}`);
1888
+ for (const row of rows) {
1889
+ console.log(`${pad(row.dir, dirW)} ${pad(row.status, stsW)} ${pad(row.session || '(none)', ssnW)}`);
1890
+ if (longMode && row.server) console.log(` server: ${row.server}`);
1522
1891
  }
1523
- console.error(`Recreating project window for ${dir}...`);
1524
- createProjectWindow(dir, server, state.session);
1525
1892
  }
1526
1893
 
1527
1894
  // ---------------------------------------------------------------------------
1528
1895
  // Subcommand: migrate — rewrite legacy .ocmux.json files to v2
1529
1896
  // ---------------------------------------------------------------------------
1530
1897
 
1531
- function cmdMigrate() {
1532
- const rows = listProjects();
1898
+ async function cmdMigrate(server) {
1899
+ const rows = await configuredProjects(server);
1533
1900
  let migrated = 0;
1534
1901
  for (const row of rows) {
1535
- const sf = statefileFor(row.dir);
1902
+ const sf = projectState.statefileFor(row.dir);
1536
1903
  const state = projectState.readProjectState(sf);
1537
1904
  if (!state) continue;
1538
1905
  if (state.version >= 2) continue;
@@ -1553,31 +1920,44 @@ function cmdMigrate() {
1553
1920
  // ---------------------------------------------------------------------------
1554
1921
 
1555
1922
  function printHelp() {
1556
- console.error(`Usage: ${SCRIPT} [-l] [<subcommand>] [<directory>]`);
1923
+ console.error(`Usage: ${SCRIPT} [-l] [--all-projects] [<subcommand>] [<directory>]`);
1557
1924
  console.error('');
1558
- console.error('Manage project OpenCode TUI windows in tmux. The OpenCode server');
1559
- console.error('itself is user-managed: start it once with `opencode serve`.');
1925
+ console.error('Manage project/session routing and optional registered OpenCode TUIs.');
1926
+ console.error('The OpenCode server is user-managed: start it with `opencode serve`.');
1560
1927
  console.error('');
1561
1928
  console.error('Without arguments, searches upward from <directory> (default: $PWD)');
1562
1929
  console.error('for .ocmux.json and opens an interactive SESSION picker for that');
1563
- console.error('project (requires a TTY). In the picker:');
1930
+ console.error('project (requires a TTY). The title bar names the project whose');
1931
+ console.error('sessions are listed. In the picker:');
1564
1932
  console.error(' Enter switch · n new · r rename · R remind · d delete · a agents · m model');
1565
1933
  console.error(' p projects · h help · q quit');
1566
- console.error('Selecting updates .ocmux.json and relaunches the project TUI on the');
1567
- console.error('chosen session. Silent on success. (p opens the read-only project');
1568
- console.error('switcher; it never modifies .ocmux.json.)');
1934
+ console.error(' In BROADCAST: Space toggles a session · d deletes cursor · D deletes all');
1935
+ console.error('Selecting updates .ocmux.json and refreshes a dedicated TUI, falling');
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');
1938
+ console.error('default it only inspects other projects and never modifies any');
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.');
1569
1942
  console.error('');
1570
1943
  console.error('Subcommands:');
1571
- console.error(' serve Create a TUI window for <directory> (default: $PWD).');
1944
+ console.error(' serve Initialize project state for <directory> (default: $PWD).');
1572
1945
  console.error(' --server <url> Server to record (default: $OCMUX_SERVER or http://localhost:4096).');
1573
1946
  console.error(' --force Repoint an existing project to --server (overwrites .ocmux.json).');
1574
1947
  console.error(' --git / --GIT Use the nearest parent with a .git entry / directory.');
1575
1948
  console.error(' Aliased as "new" for backwards compatibility.');
1949
+ console.error(' tui [--shared] [--server <url>] [directory]');
1950
+ console.error(' Register the current tmux pane and run its OpenCode TUI.');
1951
+ console.error(' Without --shared it is dedicated to the current 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).');
1954
+ console.error(' --list lists all live registrations; --status inspects one;');
1955
+ console.error(' --detach unregisters one without closing its pane or TUI.');
1956
+ console.error(' With --status/--detach, --shared targets the shared slot;');
1957
+ console.error(' otherwise the slot dedicated to directory is targeted.');
1576
1958
  console.error(' session <id|title>');
1577
1959
  console.error(' Non-interactive session switch for the current project.');
1578
- console.error(' list [-l] List project windows (with -l, their server URL).');
1579
- console.error(' kill Close the current project window. KEEPS .ocmux.json (session memory).');
1580
- console.error(' resurrect Recreate the project window from its .ocmux.json.');
1960
+ console.error(' list [-l] List configured projects and TUI routing status.');
1581
1961
  console.error(' migrate Rewrite legacy .ocmux.json files to the v2 schema.');
1582
1962
  console.error('');
1583
1963
  console.error('Reminders: the R key in the session picker attaches a note to a');
@@ -1585,6 +1965,12 @@ function printHelp() {
1585
1965
  console.error('');
1586
1966
  console.error('Options:');
1587
1967
  console.error(' -l Long output (list: also show server URL).');
1968
+ console.error(' --all-projects');
1969
+ console.error(' Let the picker’s project switcher (p) MOVE to another');
1970
+ console.error(' project, so picking a session there updates that');
1971
+ console.error(' project’s .ocmux.json (what agentp reads in it).');
1972
+ console.error(' Default: the switcher only inspects other projects');
1973
+ console.error(' and you always return to your own project’s list.');
1588
1974
  console.error(' --version Show version');
1589
1975
  console.error(' -h Show this help');
1590
1976
  console.error('');
@@ -1602,8 +1988,13 @@ function main() {
1602
1988
  let gitMode = null;
1603
1989
  let serverArg = null;
1604
1990
  let force = false;
1991
+ let allProjects = false;
1992
+ let sharedTui = false;
1993
+ let tuiSession = null;
1994
+ let tuiList = false;
1995
+ let tuiStatus = false;
1996
+ let tuiDetach = false;
1605
1997
  const positional = [];
1606
- let dirIsForced = false;
1607
1998
 
1608
1999
  const parsedArgs = process.argv.slice(2);
1609
2000
  for (let i = 0; i < parsedArgs.length; i++) {
@@ -1624,6 +2015,24 @@ function main() {
1624
2015
  process.exit(1);
1625
2016
  } else if (arg === '--force') {
1626
2017
  force = true;
2018
+ } else if (arg === '--shared') {
2019
+ sharedTui = true;
2020
+ } else if (arg === '--session-id') {
2021
+ const val = parsedArgs[i + 1];
2022
+ if (!val || val.startsWith('-')) {
2023
+ console.error("Error: '--session-id' requires a session ID.");
2024
+ process.exit(1);
2025
+ }
2026
+ tuiSession = val;
2027
+ i++;
2028
+ } else if (arg === '--list') {
2029
+ tuiList = true;
2030
+ } else if (arg === '--status') {
2031
+ tuiStatus = true;
2032
+ } else if (arg === '--detach') {
2033
+ tuiDetach = true;
2034
+ } else if (arg === '--all-projects') {
2035
+ allProjects = true;
1627
2036
  } else if (arg === '--git') {
1628
2037
  if (gitMode && gitMode !== 'nearest') {
1629
2038
  console.error("Error: cannot combine '--git' with '--GIT'.");
@@ -1642,13 +2051,19 @@ function main() {
1642
2051
  } else if (arg === '-h' || arg === '--help') {
1643
2052
  printHelp();
1644
2053
  } else if (arg === '--') {
1645
- dirIsForced = true;
2054
+ const directory = parsedArgs[i + 1];
2055
+ if (!directory) {
2056
+ console.error("Error: '--' requires a directory argument.");
2057
+ process.exit(1);
2058
+ }
2059
+ positional.push({ value: directory, forcedDirectory: true });
2060
+ i++;
1646
2061
  } else if (arg.startsWith('-')) {
1647
2062
  console.error(`Error: unknown option '${arg}'`);
1648
2063
  console.error(`Run '${SCRIPT} -h' for help.`);
1649
2064
  process.exit(1);
1650
2065
  } else {
1651
- positional.push(arg);
2066
+ positional.push({ value: arg, forcedDirectory: false });
1652
2067
  }
1653
2068
  }
1654
2069
 
@@ -1658,16 +2073,16 @@ function main() {
1658
2073
  let targetDir = null;
1659
2074
  let directRef = null;
1660
2075
 
1661
- for (const arg of positional) {
1662
- if (dirIsForced) {
1663
- dirIsForced = false;
2076
+ for (const positionalArg of positional) {
2077
+ const arg = positionalArg.value;
2078
+ if (positionalArg.forcedDirectory) {
1664
2079
  if (targetDir) {
1665
2080
  console.error(`Error: unexpected argument '${arg}'`);
1666
2081
  process.exit(1);
1667
2082
  }
1668
2083
  targetDir = resolveDir(arg);
1669
- } else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list'
1670
- || arg === 'resurrect' || arg === 'session'
2084
+ } else if (arg === 'serve' || arg === 'new' || arg === 'list' || arg === 'tui'
2085
+ || arg === 'session'
1671
2086
  || arg === 'migrate') {
1672
2087
  if (subcommand) {
1673
2088
  console.error(`Error: multiple subcommands specified: '${subcommand}' and '${arg}'`);
@@ -1687,6 +2102,23 @@ function main() {
1687
2102
 
1688
2103
  if (!targetDir) targetDir = process.cwd();
1689
2104
 
2105
+ if (sharedTui && subcommand !== 'tui') {
2106
+ console.error("Error: '--shared' is only valid with 'ocmux tui'.");
2107
+ process.exit(1);
2108
+ }
2109
+ if (tuiSession && subcommand !== 'tui') {
2110
+ console.error("Error: '--session-id' is only valid with 'ocmux tui'.");
2111
+ process.exit(1);
2112
+ }
2113
+ if ((tuiList || tuiStatus || tuiDetach) && subcommand !== 'tui') {
2114
+ console.error("Error: '--list', '--status', and '--detach' are only valid with 'ocmux tui'.");
2115
+ process.exit(1);
2116
+ }
2117
+ if ([tuiList, tuiStatus, tuiDetach].filter(Boolean).length > 1) {
2118
+ console.error("Error: combine at most one of '--list', '--status', and '--detach'.");
2119
+ process.exit(1);
2120
+ }
2121
+
1690
2122
  // Dispatch. Each branch may throw synchronously (EXIT) for validation errors
1691
2123
  // or return a promise for async commands; main() returns the settled promise
1692
2124
  // so tests can await it.
@@ -1701,28 +2133,45 @@ function main() {
1701
2133
  console.error("Error: 'list' does not accept a directory path.");
1702
2134
  process.exit(1);
1703
2135
  }
1704
- ensureSession();
1705
- cmdList(longMode);
1706
- } else if (subcommand === 'session') {
1707
- if (gitMode) {
1708
- console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
2136
+ p = cmdList(longMode, serverArg || DEFAULT_SERVER);
2137
+ } else if (subcommand === 'tui') {
2138
+ if (gitMode || force || allProjects || longMode) {
2139
+ console.error("Error: 'tui' does not accept --git, --GIT, --force, --all-projects, or -l.");
1709
2140
  process.exit(1);
1710
2141
  }
1711
- p = cmdSessionSwitch(targetDir, directRef);
1712
- } else if (subcommand === 'kill') {
2142
+ if (tuiList) {
2143
+ if (sharedTui || serverArg || tuiSession || targetDir !== process.cwd()) {
2144
+ console.error("Error: 'ocmux tui --list' does not accept --shared, --server, --session-id, or a directory.");
2145
+ process.exit(1);
2146
+ }
2147
+ cmdTuiList();
2148
+ } else if (tuiStatus) {
2149
+ if (serverArg || tuiSession) {
2150
+ console.error("Error: 'ocmux tui --status' does not accept --server or --session-id.");
2151
+ process.exit(1);
2152
+ }
2153
+ cmdTuiStatus(targetDir, sharedTui);
2154
+ } else if (tuiDetach) {
2155
+ if (serverArg || tuiSession) {
2156
+ console.error("Error: 'ocmux tui --detach' does not accept --server or --session-id.");
2157
+ process.exit(1);
2158
+ }
2159
+ cmdTuiDetach(targetDir, sharedTui);
2160
+ } else {
2161
+ p = cmdTui(targetDir, serverArg, sharedTui, tuiSession);
2162
+ }
2163
+ } else if (subcommand === 'session') {
1713
2164
  if (gitMode) {
1714
2165
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
1715
2166
  process.exit(1);
1716
2167
  }
1717
- cmdKill(targetDir);
1718
- } else if (subcommand === 'resurrect') {
1719
- p = cmdResurrect(targetDir);
2168
+ p = cmdSessionSwitch(targetDir, directRef, allProjects);
1720
2169
  } else if (subcommand === 'migrate') {
1721
2170
  if (targetDir !== process.cwd()) {
1722
2171
  console.error("Error: 'migrate' does not accept a directory path.");
1723
2172
  process.exit(1);
1724
2173
  }
1725
- cmdMigrate();
2174
+ p = cmdMigrate(serverArg || DEFAULT_SERVER);
1726
2175
  } else if (subcommand === 'serve' || subcommand === 'new') {
1727
2176
  if (gitMode) {
1728
2177
  const flagLabel = gitMode === 'root' ? '--GIT' : '--git';
@@ -1735,7 +2184,7 @@ function main() {
1735
2184
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
1736
2185
  process.exit(1);
1737
2186
  }
1738
- p = cmdSessionSwitch(targetDir, null);
2187
+ p = cmdSessionSwitch(targetDir, null, allProjects);
1739
2188
  }
1740
2189
 
1741
2190
  return p.catch((err) => {
@@ -1751,4 +2200,4 @@ if (require.main === module) {
1751
2200
  main();
1752
2201
  }
1753
2202
 
1754
- module.exports = { main, checkServer, newestSessionId, cmdKill, cmdList, cmdMigrate, windowFor, renderList, fmtClock, fmtDuration, fmtTokens, fmtCost, layoutCells, sessionInfoLines, centerIn, filterByPattern, newSearchState, handleSearch, searchFooter, sessionMenu, switchMenu, SWITCH_QUIT, SEARCH_HINT };
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 };