agentp 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ocmux CHANGED
@@ -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,45 @@ function checkServer(server) {
154
113
  });
155
114
  }
156
115
 
116
+ function refreshTui(directory, server, session) {
117
+ const result = switchTui(directory, server, session, path.resolve(process.argv[1]));
118
+ if (!result.ok && result.reason === 'failed') {
119
+ console.error(`Warning: session selected, but the ${result.kind} TUI could not be refreshed: ${result.error}`);
120
+ }
121
+ return result;
122
+ }
123
+
124
+ async function configuredProjects(server) {
125
+ const [projects, sessions] = await Promise.all([
126
+ listOpenCodeProjects(server),
127
+ listSessions(server),
128
+ ]);
129
+ const rows = [];
130
+ const seen = new Set();
131
+ const directories = [
132
+ ...(projects || []).map(project => project.canonical || project.directory),
133
+ ...(sessions || []).map(session => session.location && session.location.directory),
134
+ ];
135
+ for (const dir of directories) {
136
+ if (!dir) continue;
137
+ let canonical;
138
+ try { canonical = fs.realpathSync(dir); } catch { canonical = path.resolve(dir); }
139
+ if (seen.has(canonical)) continue;
140
+ const sf = path.join(canonical, '.ocmux.json');
141
+ const state = projectState.readProjectState(sf);
142
+ if (!state) continue;
143
+ seen.add(canonical);
144
+ const target = tuiRegistry.resolve(canonical);
145
+ rows.push({
146
+ dir: canonical,
147
+ status: target ? target.kind : 'headless',
148
+ session: state.session,
149
+ server: state.server || server,
150
+ });
151
+ }
152
+ return rows.sort((a, b) => a.dir.localeCompare(b.dir));
153
+ }
154
+
157
155
  function newestSessionId(sessions) {
158
156
  if (!sessions || sessions.length === 0) return null;
159
157
  // Implicit targets skip child/subagent sessions (parentID set) — OpenCode's
@@ -190,6 +188,14 @@ function padLeft(s, w) {
190
188
  return ' '.repeat(Math.max(0, w - str.length)) + str;
191
189
  }
192
190
 
191
+ // ── Palette (classic 16-colour ANSI) ─────────────────────────────────────────
192
+ // Colour 3 renders as brown/dark-yellow; bold colour 3 as light yellow. The
193
+ // whole list UI (title/status bars, cursor pointer, info labels) sticks to these
194
+ // two shades so the chrome reads as one theme.
195
+ const BROWN = '\x1b[33m'; // text: brown/dark-yellow
196
+ const BAR_BG = '\x1b[30;43m'; // title/status bar: black on brown
197
+ const LIGHT_YELLOW = '\x1b[1;33m'; // text: light-yellow (bold brown)
198
+
193
199
  // ── '/' incremental search for interactive lists ──────────────────────────
194
200
  // Filter `items` by `pattern`: case-insensitive, space-separated tokens that
195
201
  // must ALL be substrings of `textOf(item)`. Empty pattern → list unchanged.
@@ -278,8 +284,8 @@ function handleSearch(str, key, s) {
278
284
  return 'pass';
279
285
  }
280
286
 
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).
287
+ // The status bar's search line: normal key hints, or the pattern while a '/'
288
+ // filter is active (live = input with caret; committed = pattern kept).
283
289
  // The caller adds the `Enter: confirm · Esc: cancel` hint on the right via
284
290
  // renderList's `footerRight`.
285
291
  const SEARCH_HINT = 'Enter: confirm · Esc: cancel';
@@ -289,6 +295,23 @@ function searchFooter(s, base) {
289
295
  return s.live ? `Search: ${shown}` : `Search: ${shown} · /: edit`;
290
296
  }
291
297
 
298
+ // ── Model picker ordering ─────────────────────────────────────────────────────
299
+ // Models sorted by provider then id (the provider already leads the label, so
300
+ // this is also alphabetical by display order).
301
+ function sortModels(models) {
302
+ const key = (m) => `${String(m.providerID || '').toLowerCase()}/${String(m.id || '').toLowerCase()}`;
303
+ return [...(models || [])].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
304
+ }
305
+
306
+ // Index of `model` (as stored on a session: { providerID, id }) inside
307
+ // `models`, or -1 when unknown/unmatched — so a picker can open on it.
308
+ function indexOfModel(models, model) {
309
+ if (!model || !models || !models.length) return -1;
310
+ const key = (m) => `${String(m.providerID || '').toLowerCase()}/${String(m.id || '').toLowerCase()}`;
311
+ const target = key(model);
312
+ return models.findIndex((m) => key(m) === target);
313
+ }
314
+
292
315
  // Sentinel returned by switchMenu on Ctrl+C so the caller fully exits the CLI
293
316
  // (instead of just returning to the session menu).
294
317
  const SWITCH_QUIT = Symbol('ocmux-switch-quit');
@@ -376,13 +399,14 @@ function labeledLine(label, value, cols, labelColor = '\x1b[1m') {
376
399
  return `${labelColor}${lbl}\x1b[0m ${v}`;
377
400
  }
378
401
 
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.
402
+ // Info lines about the selected session: title + location and a responsive
403
+ // grid. Fields are ALWAYS present (placeholders when unknown).
404
+ // Labels are brown (the same shade as the title/status bars); when `highlight`
405
+ // is true (the cursor is on the session currently selected in the TUI) they are
406
+ // light-yellow instead.
383
407
  function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false) {
384
408
  if (!s) return [];
385
- const labelColor = highlight ? '\x1b[1;33m' : '\x1b[1m';
409
+ const labelColor = highlight ? LIGHT_YELLOW : BROWN;
386
410
  const title = (s.title && s.title.trim()) || 'Untitled';
387
411
  const location = (s.location && s.location.directory) || 'Unknown';
388
412
  const out = [
@@ -423,13 +447,13 @@ function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false)
423
447
  }
424
448
 
425
449
  // Render a scrollable list into the alternate screen.
426
- // heading -> centered + bold + inverted heading
450
+ // heading -> centered title bar (brown background)
427
451
  // row(index, item) -> string | { text, reverse }
428
452
  // extraLines -> prompt/confirm lines right after the list (normal colors)
429
- // footer -> inverted, RIGHT-aligned key-hint bar (acts as a separator)
453
+ // footer -> status bar (brown background), key hints right-aligned
430
454
  // (left-aligned when footerLeft is set — used by the search line)
431
455
  // footerRight-> optional hint pinned to the right end of a left-aligned footer
432
- // infoLines -> details below the footer (normal colors, bold labels)
456
+ // infoLines -> details below the status bar (colours supplied by the caller)
433
457
  // A "(start-end/total)" range is appended to the heading when scrolling.
434
458
  function renderList({ title, items, cursor, row, footer, infoLines = [], extraLines = [], cols: colsArg, rows: rowsArg, footerLeft = false, footerRight = null }) {
435
459
  const cols = colsArg || process.stdout.columns || 80;
@@ -449,7 +473,7 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
449
473
  }
450
474
 
451
475
  let out = '\x1b[2J\x1b[H';
452
- out += '\x1b[1;7m' + centerIn(titleLine, cols) + '\x1b[0m\n\n';
476
+ out += BAR_BG + centerIn(titleLine, cols) + '\x1b[0m\n\n';
453
477
 
454
478
  let rendered = 0;
455
479
  if (items.length === 0) {
@@ -461,8 +485,11 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
461
485
  const r = row(idx, items[idx]);
462
486
  const text = r && typeof r === 'object' ? r.text : r;
463
487
  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';
488
+ // The pointer sits outside the row's own styling so a light-yellow arrow
489
+ // stays visible even on a reverse-video row.
490
+ const ptr = idx === cursor ? `${LIGHT_YELLOW}▶\x1b[0m ` : ' ';
491
+ const body = text == null ? '' : text;
492
+ out += ptr + (reverse ? `\x1b[7m${body}\x1b[0m` : body) + '\n';
466
493
  rendered++;
467
494
  }
468
495
  }
@@ -485,7 +512,7 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
485
512
  } else {
486
513
  hint = padLeft(footer, cols);
487
514
  }
488
- out += '\x1b[7m' + hint + '\x1b[0m\n';
515
+ out += BAR_BG + hint + '\x1b[0m\n';
489
516
  for (const l of infos) out += l + '\n';
490
517
  return out.replace(/\n$/, '');
491
518
  }
@@ -496,7 +523,9 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
496
523
  // q/Ctrl+C quit. Input mode offers readline-style caret editing.
497
524
  // Resolves on quit (null) or with the string 'switch-project' (caller then runs
498
525
  // 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 } = {}) {
526
+ // `dir` names the project this list belongs to — it heads the title bar so a
527
+ // multi-project run always says which project's sessions are on screen.
528
+ function sessionMenu(sessions, currentSession, annotations, server, { onPick, onRename, onDelete, onReminder, runningIds, modelContext, onBroadcastChange, onInspectSession, dir, notices = [] } = {}) {
500
529
  return new Promise((resolve) => {
501
530
  if (!process.stdin.isTTY) return resolve(null);
502
531
 
@@ -618,7 +647,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
618
647
  Math.max(8, cols - (showTime ? TIME_W + 10 : 8)),
619
648
  );
620
649
  const inBc = mode === 'broadcast' || mode === 'broadcast-confirm';
621
- const extras = [];
650
+ const extras = [...notices];
622
651
  if ((mode === 'confirm' || mode === 'broadcast-confirm') && confirmDel) {
623
652
  extras.push(`Delete session "${confirmDel.title || confirmDel.id || ''}"? (y/N) `);
624
653
  } else if (mode === 'input' && input) {
@@ -638,7 +667,9 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
638
667
  }
639
668
  footer = searchFooter(search, footer);
640
669
  process.stderr.write(renderList({
641
- title: 'ocmux — sessions',
670
+ // The project name heads the title so you always know which project's
671
+ // sessions are listed (matters when --all-projects moves you around).
672
+ title: dir ? `ocmux — ${path.basename(dir)} sessions` : 'ocmux — sessions',
642
673
  items: view,
643
674
  cursor,
644
675
  row: (i, s) => {
@@ -649,8 +680,8 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
649
680
  const hasNote = annotations && annotations[s.id] ? ' ◈' : '';
650
681
  const spin = (runningIds && runningIds.has(s.id)) ? SPINNER[spinIdx % SPINNER.length] + ' ' : ' ';
651
682
  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;
683
+ // Brown for the session currently selected in the TUI window.
684
+ return String(s.id) === String(current) ? `${BROWN}${text}\x1b[0m` : text;
654
685
  },
655
686
  footer,
656
687
  footerLeft: search.active,
@@ -698,6 +729,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
698
729
  applySearch();
699
730
  const ci = view.indexOf(created);
700
731
  cursor = ci >= 0 ? ci : 0;
732
+ current = created.id;
701
733
  } else if (!row.new) {
702
734
  current = row.id;
703
735
  }
@@ -802,7 +834,9 @@ const toggleBroadcast = (idx) => {
802
834
  draw();
803
835
  };
804
836
 
805
- const openList = (title, items, format, onEnter, returnMode = 'normal') => {
837
+ // `startCursor` lets a picker open with the cursor already on the relevant
838
+ // entry (the model pickers open on the current model).
839
+ const openList = (title, items, format, onEnter, returnMode = 'normal', startCursor = 0) => {
806
840
  if (!items || items.length === 0) {
807
841
  process.stderr.write(`\nNo ${title.toLowerCase()} available.\n`);
808
842
  draw();
@@ -810,7 +844,7 @@ const toggleBroadcast = (idx) => {
810
844
  }
811
845
  list = { title, items, view: items, format, onEnter, returnMode };
812
846
  listSearch = newSearchState();
813
- listCursor = 0;
847
+ listCursor = Math.max(0, Math.min(startCursor, items.length - 1));
814
848
  mode = 'list';
815
849
  draw();
816
850
  };
@@ -820,6 +854,16 @@ const toggleBroadcast = (idx) => {
820
854
  draw();
821
855
  };
822
856
 
857
+ // Open the model picker with the list sorted by provider and the cursor
858
+ // already on `session`'s current model (so `m` starts where the change is).
859
+ const openModelPicker = (title, session, returnMode, onPick) => {
860
+ listModels(server).then((models) => {
861
+ const sorted = sortModels(models);
862
+ openList(title, sorted, (x) => `${x.label}${x.variant ? `#${x.variant}` : ''}`,
863
+ onPick, returnMode, indexOfModel(sorted, session && session.model));
864
+ }).catch(commandErr);
865
+ };
866
+
823
867
  process.stdin.on('keypress', (str, key) => {
824
868
  // Ctrl+C always fully exits ocmux, from any menu.
825
869
  if (key.ctrl && key.name === 'c') {
@@ -950,11 +994,9 @@ const toggleBroadcast = (idx) => {
950
994
  draw();
951
995
  }
952
996
  } 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);
997
+ openModelPicker('Broadcast model (all selected sessions)', view[cursor], 'broadcast', async (item) => {
998
+ await Promise.all([...broadcastSel].map(id => switchModel(server, id, item)));
999
+ });
958
1000
  } else if (key.name === 'return' || key.name === 'enter') {
959
1001
  if (cursor < view.length) {
960
1002
  mode = 'normal';
@@ -1006,12 +1048,7 @@ const toggleBroadcast = (idx) => {
1006
1048
  }
1007
1049
  if (key.name === 'm') {
1008
1050
  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
- }
1051
+ if (target) openModelPicker('Models', target, 'normal', (item) => switchModel(server, target.id, item));
1015
1052
  return;
1016
1053
  }
1017
1054
  if (key.name === 'd') {
@@ -1053,8 +1090,19 @@ const toggleBroadcast = (idx) => {
1053
1090
  });
1054
1091
  }
1055
1092
 
1056
- // Interactive project picker — purely visual/navigation, never writes state.
1057
- function switchMenu(rows, longMode) {
1093
+ // Interactive project picker — never writes state (it only moves what a
1094
+ // registered dedicated/shared TUI displays).
1095
+ //
1096
+ // Default (safe): an INSPECTOR. Selecting a project/session row still focuses
1097
+ // the routed TUI and shows the session there, but the promise always resolves to
1098
+ // null, so the caller re-opens its own project's list. Nothing about the
1099
+ // caller's project changes.
1100
+ //
1101
+ // `{ allProjects: true }` (CLI `--all-projects`): a real SWITCHER. Selecting a
1102
+ // row also records the target directory, so the caller moves its picker there
1103
+ // and can then write that project's `.ocmux.json` — the file `agentp` reads in
1104
+ // that directory.
1105
+ function switchMenu(rows, { allProjects = false, onOpen } = {}) {
1058
1106
  return new Promise((resolve) => {
1059
1107
  if (!process.stdin.isTTY) {
1060
1108
  console.error('Error: "switch" is interactive and requires a TTY.');
@@ -1074,22 +1122,53 @@ function switchMenu(rows, longMode) {
1074
1122
  const lineWidth = Math.max(cols, nameW + 2 + stsW + 2 + ssnW + 6);
1075
1123
 
1076
1124
  let cursor = 0;
1077
- let activeIdx = null; // tmux window index of the focused project
1078
- let lastDir = null;
1125
+ let lastDir = null; // target dir to move to (only with allProjects)
1079
1126
  let done = false;
1080
1127
  let help = false;
1081
1128
  const search = newSearchState();
1082
- let view = rows;
1129
+ let view = []; // flattened nodes: { kind:'project'|'session', ... }
1130
+ // Fold state per project directory: { open, sessions, loading, error }.
1131
+ const folds = new Map();
1132
+
1133
+ const foldOf = (p) => folds.get(p.dir) || { open: false, sessions: null, loading: false, error: null };
1134
+ const projectText = (p) => `${path.basename(p.dir)} ${p.dir} ${p.session || ''}`;
1135
+ const sessionText = (s) => `${s.title || ''} ${s.id || ''}`;
1136
+ const matches = (pattern, text) => filterByPattern([1], pattern, () => text).length > 0;
1137
+
1138
+ // Flatten projects (plus their unfolded sessions) honouring the '/' pattern.
1139
+ // A project header survives when it matches itself OR when one of its
1140
+ // unfolded sessions does, so a match deeper down is still navigable.
1141
+ const buildView = () => {
1142
+ const pattern = search.active ? search.value : '';
1143
+ const out = [];
1144
+ for (const p of rows) {
1145
+ const f = foldOf(p);
1146
+ const sessions = f.open && f.sessions ? f.sessions : [];
1147
+ const shown = sessions.filter((s) => matches(pattern, sessionText(s)));
1148
+ const header = { kind: 'project', project: p };
1149
+ if (sessions.length === 0) {
1150
+ if (matches(pattern, projectText(p))) out.push(header);
1151
+ } else if (matches(pattern, projectText(p)) || shown.length) {
1152
+ out.push(header);
1153
+ for (const s of shown) out.push({ kind: 'session', project: p, session: s });
1154
+ }
1155
+ }
1156
+ return out;
1157
+ };
1083
1158
 
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);
1159
+ const sameNode = (a, b) => {
1160
+ if (!a || !b || a.kind !== b.kind || a.project.dir !== b.project.dir) return false;
1161
+ return a.kind !== 'session' || a.session.id === b.session.id;
1088
1162
  };
1089
1163
 
1090
- const refreshActive = () => {
1091
- activeIdx = activeWindowIndex();
1164
+ const applySearch = () => {
1165
+ const prev = view[cursor];
1166
+ view = buildView();
1167
+ const idx = prev ? view.findIndex((n) => sameNode(n, prev)) : -1;
1168
+ if (idx >= 0) cursor = idx;
1169
+ else if (cursor >= view.length) cursor = Math.max(0, view.length - 1);
1092
1170
  };
1171
+ applySearch();
1093
1172
 
1094
1173
  const cleanup = () => {
1095
1174
  process.stderr.write('\x1b[?25h');
@@ -1108,53 +1187,130 @@ function switchMenu(rows, longMode) {
1108
1187
  resolve(value === undefined ? lastDir : value);
1109
1188
  };
1110
1189
 
1190
+ // Mode-dependent wording: the switcher inspects by default and really
1191
+ // switches only under --all-projects.
1192
+ const switcherTitle = allProjects ? 'ocmux — project switcher (all projects)' : 'ocmux — project switcher';
1193
+ const enterHint = allProjects ? 'Enter: switch' : 'Enter: view';
1194
+ const modeNote = allProjects
1195
+ ? ['--all-projects: selecting moves the picker to that project, so a',
1196
+ 'session picked there updates ITS .ocmux.json — the file agentp',
1197
+ 'reads when it runs in that directory.']
1198
+ : ['Selecting only moves the TUI view — .ocmux.json is never',
1199
+ 'written, so agentp keeps prompting your own project’s session.',
1200
+ 'You always come back to your own project’s list.'];
1201
+
1111
1202
  const draw = () => {
1203
+ if (done) return;
1112
1204
  if (help) {
1113
1205
  let out = '\x1b[2J\x1b[H';
1114
1206
  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';
1207
+ out += ' j / k, arrows move the cursor (projects and sessions)\n';
1208
+ out += ` Enter ${allProjects ? 'switch the picker to the selected project' : 'focus the selected project (menu stays open)'}\n`;
1209
+ out += ' (on a session, show it in that project’s TUI)\n';
1210
+ out += ' Space fold/unfold a project’s sessions;\n';
1211
+ out += ' on a session, show it in the TUI as well\n';
1117
1212
  out += ' / search/filter the list (Enter keeps, ESC clears)\n';
1118
1213
  out += ' h toggle this help\n';
1119
1214
  out += ' q return to the session menu\n';
1120
1215
  out += ' Ctrl+C quit ocmux\n';
1216
+ out += '\n';
1217
+ for (const l of modeNote) out += `${l}\n`;
1121
1218
  out += '\nPress any key to return.\n';
1122
1219
  process.stderr.write(out);
1123
1220
  return;
1124
1221
  }
1125
- refreshActive();
1126
- const extras = (search.active && view.length === 0) ? [`No projects match "${search.value}"`] : [];
1222
+ const extras = [];
1223
+ if (search.active && view.length === 0) extras.push(`No projects match "${search.value}"`);
1224
+ for (const n of view) {
1225
+ if (n.kind !== 'project') continue;
1226
+ const f = folds.get(n.project.dir);
1227
+ if (!f || !f.open) continue;
1228
+ const name = path.basename(n.project.dir);
1229
+ if (f.loading) extras.push(` … loading sessions of ${name} …`);
1230
+ else if (f.error) extras.push(` ! ${name}: ${f.error}`);
1231
+ }
1127
1232
  process.stderr.write(renderList({
1128
- title: 'ocmux — project switcher',
1233
+ title: switcherTitle,
1129
1234
  items: view,
1130
1235
  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;
1236
+ row: (i, node) => {
1237
+ if (node.kind === 'session') {
1238
+ const s = node.session;
1239
+ const id = String(s.id || '');
1240
+ const mark = String(s.id) === String(node.project.session) ? ' *' : '';
1241
+ const title = (s.title || id).slice(0, Math.max(8, nameW + 6));
1242
+ return ` ${title}${mark} ${id}`;
1243
+ }
1244
+ const p = node.project;
1245
+ const fold = foldOf(p).open ? '▾' : '▸';
1246
+ const session = p.session ? p.session.slice(0, 14) : '(none)';
1247
+ const text = `${fold} ${pad(path.basename(p.dir), nameW)} ${pad(p.status, stsW)} ${pad(session, ssnW)} ${p.dir}`;
1248
+ return text;
1135
1249
  },
1136
- footer: searchFooter(search, 'Enter: focus · /: search · h: help · q: back · Ctrl+C: quit'),
1250
+ footer: searchFooter(search, `${enterHint} · Space: fold · /: search · h: help · q: back · Ctrl+C: quit`),
1137
1251
  footerLeft: search.active,
1138
1252
  footerRight: search.active ? SEARCH_HINT : null,
1139
1253
  extraLines: extras,
1140
1254
  }));
1141
1255
  };
1142
1256
 
1143
- const activate = (row) => {
1144
- if (!row) return;
1257
+ // Enter on a project row: show its current session in its routed TUI. With
1258
+ // allProjects off this stays a pure inspection and
1259
+ // `lastDir` is left null so the caller returns to its own project.
1260
+ const activate = (p) => {
1261
+ if (!p) return;
1145
1262
  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;
1263
+ if (p.server && p.session && onOpen) onOpen(p, p.session);
1264
+ if (allProjects) lastDir = p.dir;
1152
1265
  } catch (err) {
1153
1266
  process.stderr.write(`\nError: ${err.message}\n`);
1154
1267
  }
1155
1268
  draw();
1156
1269
  };
1157
1270
 
1271
+ // Enter/Space on a session row: move the TUI VIEW onto it. The switcher
1272
+ // itself never writes `.ocmux.json`; with allProjects on the caller only
1273
+ // moves its picker to that project — the write happens later, when a
1274
+ // session is picked there (and that file is what agentp reads there).
1275
+ const openSession = (node) => {
1276
+ const p = node.project;
1277
+ try {
1278
+ if (p.server && onOpen) onOpen(p, node.session.id);
1279
+ if (allProjects) lastDir = p.dir;
1280
+ } catch (err) {
1281
+ process.stderr.write(`\nError: ${err.message}\n`);
1282
+ }
1283
+ draw();
1284
+ };
1285
+
1286
+ // Space on a project row: fetch its sessions once, then flip the fold.
1287
+ const toggleFold = (p) => {
1288
+ const f = folds.get(p.dir) || { open: false, sessions: null, loading: false, error: null };
1289
+ folds.set(p.dir, f);
1290
+ if (!f.open && !f.sessions && !f.error && !f.loading) {
1291
+ if (!p.server) {
1292
+ f.error = 'no server recorded';
1293
+ } else {
1294
+ f.loading = true;
1295
+ f.open = true;
1296
+ applySearch();
1297
+ draw();
1298
+ listSessions(p.server, p.dir)
1299
+ .then((list) => {
1300
+ // Main sessions only: child/subagent sessions are work products,
1301
+ // not navigation targets (same rule as the session picker).
1302
+ f.sessions = sortSessionsByRecency((list || []).filter((s) => !s.parentID));
1303
+ })
1304
+ .catch((err) => { f.error = err.message; })
1305
+ .then(() => { f.loading = false; applySearch(); draw(); });
1306
+ return;
1307
+ }
1308
+ }
1309
+ f.open = !f.open;
1310
+ applySearch();
1311
+ draw();
1312
+ };
1313
+
1158
1314
  const onSig = () => finish(SWITCH_QUIT);
1159
1315
  process.on('SIGINT', onSig);
1160
1316
 
@@ -1167,14 +1323,19 @@ function switchMenu(rows, longMode) {
1167
1323
  if (key.name === 'h') { help = true; draw(); return; }
1168
1324
  if (key.name === 'q') return finish();
1169
1325
  if (view.length === 0) return;
1326
+ const node = view[cursor];
1170
1327
  if (key.name === 'up' || str === 'k') {
1171
1328
  cursor = (cursor + view.length - 1) % view.length;
1172
1329
  draw();
1173
1330
  } else if (key.name === 'down' || str === 'j') {
1174
1331
  cursor = (cursor + 1) % view.length;
1175
1332
  draw();
1176
- } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
1177
- activate(view[cursor]);
1333
+ } else if (key.name === 'return' || key.name === 'enter') {
1334
+ if (node.kind === 'project') activate(node.project);
1335
+ else openSession(node);
1336
+ } else if (key.name === 'space') {
1337
+ if (node.kind === 'project') toggleFold(node.project);
1338
+ else openSession(node);
1178
1339
  }
1179
1340
  });
1180
1341
 
@@ -1191,13 +1352,18 @@ function switchMenu(rows, longMode) {
1191
1352
  // Subcommand: default — interactive session switcher for the current project
1192
1353
  // ---------------------------------------------------------------------------
1193
1354
 
1194
- async function cmdSessionSwitch(targetDir, directRef) {
1355
+ // `allProjects` (CLI `--all-projects`) unlocks cross-project switching: the
1356
+ // project switcher may then move this picker onto another project, whose
1357
+ // `.ocmux.json` gets written when a session is picked there. Without it the
1358
+ // switcher is inspection-only and this command always stays on `targetDir`'s
1359
+ // project.
1360
+ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
1195
1361
  let target = targetDir;
1196
1362
  for (;;) {
1197
1363
  const sf = findParentStatefile(target);
1198
1364
  if (!sf) {
1199
1365
  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.`);
1366
+ console.error(`Run 'ocmux serve' to initialize the project.`);
1201
1367
  process.exit(1);
1202
1368
  }
1203
1369
  const state = projectState.readProjectState(sf);
@@ -1216,22 +1382,24 @@ async function cmdSessionSwitch(targetDir, directRef) {
1216
1382
  process.exit(1);
1217
1383
  }
1218
1384
 
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
1385
  const sessions = await listSessions(server, dir).catch((err) => {
1231
1386
  console.error(`Error connecting to ${server}: ${err.message}`);
1232
1387
  process.exit(1);
1233
1388
  });
1234
1389
  const sorted = sortSessionsByRecency(sessions);
1390
+ const notices = [];
1391
+ if (state.session && !sorted.some(s => String(s.id) === String(state.session))) {
1392
+ try {
1393
+ const moved = await getSession(server, state.session);
1394
+ const movedDir = moved && moved.location && moved.location.directory;
1395
+ if (movedDir && path.resolve(movedDir) !== path.resolve(dir)) {
1396
+ notices.push(`⚠ Current session moved to ${movedDir}`);
1397
+ notices.push(` Run: ocmux serve ${JSON.stringify(movedDir)} --server ${JSON.stringify(server)}`);
1398
+ }
1399
+ } catch {
1400
+ // A deleted/stale stored session is handled by the normal picker.
1401
+ }
1402
+ }
1235
1403
  // Only "main" sessions are listed in the picker: exclude child/subagent
1236
1404
  // sessions (parentID set) — they are work products of a main session, not
1237
1405
  // targets. Explicit --session can still reach one by id/title.
@@ -1246,7 +1414,7 @@ async function cmdSessionSwitch(targetDir, directRef) {
1246
1414
 
1247
1415
  const applySession = (id) => {
1248
1416
  projectState.writeProjectState(sf, { session: id });
1249
- relaunchTui(winIdx, dir, server, id);
1417
+ refreshTui(dir, server, id);
1250
1418
  };
1251
1419
 
1252
1420
  if (directRef) {
@@ -1262,8 +1430,7 @@ async function cmdSessionSwitch(targetDir, directRef) {
1262
1430
  }
1263
1431
 
1264
1432
  if (!process.stdin.isTTY) {
1265
- // Silent path (non-TTY): just make sure the window is focused.
1266
- activateProject(dir, winIdx, server, state.session);
1433
+ // Silent path (non-TTY): project state already identifies the target.
1267
1434
  return;
1268
1435
  }
1269
1436
 
@@ -1273,10 +1440,12 @@ async function cmdSessionSwitch(targetDir, directRef) {
1273
1440
  let lastRef = (state.session && mainSessions.find(s => String(s.id) === String(state.session))) || mainSessions[0] || null;
1274
1441
  const result = await new Promise((resolve) => {
1275
1442
  sessionMenu(mainSessions, state.session, annotations, server, {
1443
+ dir,
1276
1444
  runningIds,
1277
1445
  modelContext,
1446
+ notices,
1278
1447
  onBroadcastChange: (ids) => projectState.writeProjectState(sf, { broadcast: ids }),
1279
- onInspectSession: (id) => relaunchTui(winIdx, dir, server, id),
1448
+ onInspectSession: (id) => refreshTui(dir, server, id),
1280
1449
  onPick: async (row) => {
1281
1450
  if (row.new) {
1282
1451
  const created = await createSessionWithModel(server, row.name || undefined, dir, [lastRef, ...sorted]).catch((err) => {
@@ -1317,10 +1486,18 @@ async function cmdSessionSwitch(targetDir, directRef) {
1317
1486
  });
1318
1487
 
1319
1488
  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());
1489
+ // 'p' pressed: open the project switcher. Without --all-projects it only
1490
+ // inspects (picked stays null => re-run this picker for the same
1491
+ // project); with it, picking a project re-runs the picker there.
1492
+ // Ctrl+C fully exits.
1493
+ const rows = await configuredProjects(server).catch((err) => {
1494
+ console.error(`Error listing projects: ${err.message}`);
1495
+ return [];
1496
+ });
1497
+ const picked = await switchMenu(rows, {
1498
+ allProjects,
1499
+ onOpen: (project, id) => refreshTui(project.dir, project.server, id),
1500
+ });
1324
1501
  if (picked === SWITCH_QUIT) break;
1325
1502
  if (!picked) continue;
1326
1503
  target = picked;
@@ -1331,7 +1508,7 @@ async function cmdSessionSwitch(targetDir, directRef) {
1331
1508
  }
1332
1509
 
1333
1510
  // ---------------------------------------------------------------------------
1334
- // Subcommand: serve — create a project TUI window (server is user-managed)
1511
+ // Subcommand: serve — initialize project/session state (server is user-managed)
1335
1512
  // ---------------------------------------------------------------------------
1336
1513
 
1337
1514
  async function cmdServeNew(targetDir, serverArg, force) {
@@ -1340,7 +1517,7 @@ async function cmdServeNew(targetDir, serverArg, force) {
1340
1517
  const sf = path.join(targetDir, '.ocmux.json');
1341
1518
  if (fs.existsSync(sf)) {
1342
1519
  if (!force) {
1343
- console.error(`Error: a project window already exists in ${targetDir}.`);
1520
+ console.error(`Error: project state already exists in ${targetDir}.`);
1344
1521
  console.error(`Use 'ocmux' to switch sessions there, or 'ocmux serve --server <url> --force' to repoint it.`);
1345
1522
  process.exit(1);
1346
1523
  }
@@ -1350,11 +1527,10 @@ async function cmdServeNew(targetDir, serverArg, force) {
1350
1527
  const parent = findParentStatefile(path.dirname(targetDir));
1351
1528
  if (parent) {
1352
1529
  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.`);
1530
+ console.error(`Warning: there is already an ocmux project in ${parentDir}.`);
1355
1531
 
1356
1532
  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) => {
1533
+ rl.question('Are you sure you want to initialize a nested project here? [y/N] ', (answer) => {
1358
1534
  rl.close();
1359
1535
  if (!/^[yY]([eE][sS])?$/.test(answer.trim())) {
1360
1536
  console.error('Cancelled.');
@@ -1370,8 +1546,8 @@ async function cmdServeNew(targetDir, serverArg, force) {
1370
1546
  return doServeNew(targetDir, serverArg);
1371
1547
  }
1372
1548
 
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.
1549
+ // Repoint an existing project to a (possibly new) server. A registered TUI is
1550
+ // refreshed through the normal dedicated → shared routing rule.
1375
1551
  async function doForceServe(targetDir, sf, server) {
1376
1552
  const alive = await checkServer(server);
1377
1553
  if (!alive) {
@@ -1392,14 +1568,8 @@ async function doForceServe(targetDir, sf, server) {
1392
1568
  session = created.id;
1393
1569
  }
1394
1570
  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
- }
1571
+ refreshTui(targetDir, server, session);
1572
+ console.error(`Repointed ${targetDir} to ${server} (session ${session}).`);
1403
1573
  }
1404
1574
 
1405
1575
  async function doServeNew(targetDir, serverArg) {
@@ -1431,108 +1601,181 @@ async function doServeNew(targetDir, serverArg) {
1431
1601
  }
1432
1602
  }
1433
1603
 
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
- }
1604
+ projectState.writeProjectState(path.join(targetDir, '.ocmux.json'), {
1605
+ directory: targetDir,
1606
+ server,
1607
+ session,
1608
+ status: 'active',
1609
+ });
1610
+ refreshTui(targetDir, server, session);
1611
+ console.error(`Initialized ${targetDir} → ${server} (session ${session}).`);
1441
1612
  }
1442
1613
 
1443
1614
  // ---------------------------------------------------------------------------
1444
- // Subcommand: list
1615
+ // Subcommand: tui — register this tmux pane and host an OpenCode TUI
1445
1616
  // ---------------------------------------------------------------------------
1446
1617
 
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}`);
1618
+ async function cmdTui(targetDir, serverArg, shared, sessionArg) {
1619
+ const tmuxTarget = tuiRegistry.parseTmuxEnvironment();
1620
+ if (!tmuxTarget) {
1621
+ console.error("Error: 'ocmux tui' must run inside a tmux pane.");
1622
+ process.exit(1);
1463
1623
  }
1464
- }
1465
-
1466
- // ---------------------------------------------------------------------------
1467
- // Subcommand: kill — close the project window but keep .ocmux.json
1468
- // ---------------------------------------------------------------------------
1469
-
1470
- function cmdKill(targetDir) {
1471
1624
  const sf = findParentStatefile(targetDir);
1472
1625
  if (!sf) {
1473
1626
  console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
1627
+ console.error("Run 'ocmux serve' first.");
1628
+ process.exit(1);
1629
+ }
1630
+ const state = projectState.readProjectState(sf);
1631
+ const directory = state.directory;
1632
+ const server = serverArg || state.server || DEFAULT_SERVER;
1633
+ if (!await checkServer(server)) {
1634
+ console.error(`Error: OpenCode server not reachable at ${server}.`);
1474
1635
  process.exit(1);
1475
1636
  }
1476
- const serverDir = path.dirname(sf);
1477
- const winIdx = windowByDir(serverDir);
1478
- if (winIdx != null) {
1479
- console.error(`Closing project window for ${serverDir}...`);
1637
+
1638
+ let session = sessionArg || state.session;
1639
+ if (session) {
1480
1640
  try {
1481
- closeProjectWindow(winIdx);
1482
- } catch (err) {
1483
- console.error(`Error: ${err.message}`);
1484
- process.exit(1);
1641
+ const found = await getSession(server, session);
1642
+ if (found.location && found.location.directory) targetDir = found.location.directory;
1643
+ } catch {
1644
+ session = null;
1485
1645
  }
1486
- } else {
1487
- console.error(`Tmux window not found for ${serverDir} (state kept).`);
1488
1646
  }
1489
- // Session memory intentionally kept.
1490
- projectState.writeProjectState(sf, { status: 'stopped' });
1491
- console.error('Done (state file kept).');
1492
- }
1647
+ if (!session) {
1648
+ const sessions = await listSessions(server, directory);
1649
+ session = newestSessionId(sessions);
1650
+ if (!session) session = (await createSessionWithModel(server, undefined, directory, sessions)).id;
1651
+ projectState.writeProjectState(sf, { session, server });
1652
+ }
1493
1653
 
1494
- // ---------------------------------------------------------------------------
1495
- // Subcommand: resurrect — recreate a project window from its state
1496
- // ---------------------------------------------------------------------------
1654
+ const registration = tuiRegistry.register({
1655
+ ...tmuxTarget,
1656
+ shared,
1657
+ directory: shared ? targetDir : directory,
1658
+ server,
1659
+ session,
1660
+ pid: process.pid,
1661
+ });
1497
1662
 
1498
- async function cmdResurrect(targetDir) {
1663
+ const instance = registration.instance;
1664
+ const launchDir = targetDir || directory;
1665
+ const child = child_process.spawn('opencode', tuiArgs(server, session, launchDir), {
1666
+ cwd: launchDir,
1667
+ stdio: 'inherit',
1668
+ });
1669
+ tuiRegistry.updateInstance(instance.id, instance.token, { childPid: child.pid || null });
1670
+
1671
+ return new Promise((resolve, reject) => {
1672
+ let settled = false;
1673
+ const forward = (signal) => {
1674
+ try { child.kill(signal); } catch {}
1675
+ };
1676
+ const onHup = () => forward('SIGHUP');
1677
+ const onTerm = () => forward('SIGTERM');
1678
+ const onInt = () => forward('SIGINT');
1679
+ process.on('SIGHUP', onHup);
1680
+ process.on('SIGTERM', onTerm);
1681
+ process.on('SIGINT', onInt);
1682
+ const finish = (err) => {
1683
+ if (settled) return;
1684
+ settled = true;
1685
+ process.removeListener('SIGHUP', onHup);
1686
+ process.removeListener('SIGTERM', onTerm);
1687
+ process.removeListener('SIGINT', onInt);
1688
+ tuiRegistry.unregister(instance.id, instance.token);
1689
+ if (err) reject(err);
1690
+ else resolve();
1691
+ };
1692
+ child.once('error', finish);
1693
+ child.once('exit', () => finish());
1694
+ });
1695
+ }
1696
+
1697
+ function tuiProjectDirectory(targetDir) {
1499
1698
  const sf = findParentStatefile(targetDir);
1500
- if (!sf) {
1699
+ if (!sf) return null;
1700
+ const state = projectState.readProjectState(sf);
1701
+ return state && state.directory;
1702
+ }
1703
+
1704
+ function formatTuiRegistration(instance) {
1705
+ return `${instance.shared ? 'shared' : 'project'} ${instance.pane} ${instance.directory || '(none)'} ${instance.server || '(none)'} ${instance.session || '(none)'}`;
1706
+ }
1707
+
1708
+ function cmdTuiList() {
1709
+ const instances = tuiRegistry.list();
1710
+ if (instances.length === 0) {
1711
+ console.log('No registered TUIs.');
1712
+ return;
1713
+ }
1714
+ console.log('Kind Pane Directory Server Session');
1715
+ for (const instance of instances) console.log(formatTuiRegistration(instance));
1716
+ }
1717
+
1718
+ function cmdTuiStatus(targetDir, shared) {
1719
+ const directory = shared ? null : tuiProjectDirectory(targetDir);
1720
+ if (!shared && !directory) {
1501
1721
  console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
1502
1722
  process.exit(1);
1503
1723
  }
1504
- const state = projectState.readProjectState(sf);
1505
- const server = state.server;
1506
- if (!server) {
1507
- console.error(`Error: ${sf} does not record a server.`);
1724
+ const instance = shared ? tuiRegistry.shared() : tuiRegistry.dedicated(directory);
1725
+ if (!instance) {
1726
+ console.log(shared ? 'Shared TUI: not registered' : `Project TUI: not registered (${directory})`);
1727
+ return;
1728
+ }
1729
+ console.log(formatTuiRegistration(instance));
1730
+ }
1731
+
1732
+ function cmdTuiDetach(targetDir, shared) {
1733
+ const directory = shared ? null : tuiProjectDirectory(targetDir);
1734
+ if (!shared && !directory) {
1735
+ console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
1508
1736
  process.exit(1);
1509
1737
  }
1510
- const dir = state.directory;
1738
+ const instance = shared ? tuiRegistry.shared() : tuiRegistry.dedicated(directory);
1739
+ if (!instance) {
1740
+ console.log(shared ? 'Shared TUI was not registered.' : `Project TUI was not registered (${directory}).`);
1741
+ return;
1742
+ }
1743
+ tuiRegistry.unregister(instance.id, instance.token);
1744
+ console.log(`Detached ${shared ? 'shared' : 'project'} TUI from ${instance.pane}.`);
1745
+ }
1746
+
1747
+ // ---------------------------------------------------------------------------
1748
+ // Subcommand: list
1749
+ // ---------------------------------------------------------------------------
1511
1750
 
1512
- const winIdx = windowByDir(dir);
1513
- if (winIdx != null) {
1514
- console.error(`Project window for ${dir} already exists.`);
1751
+ async function cmdList(longMode, server) {
1752
+ const rows = await configuredProjects(server);
1753
+ if (rows.length === 0) {
1754
+ console.log('No configured ocmux projects found.');
1515
1755
  return;
1516
1756
  }
1517
1757
 
1518
- const alive = await checkServer(server);
1519
- if (!alive) {
1520
- console.error(`Error: OpenCode server not reachable at ${server}.`);
1521
- process.exit(1);
1758
+ const dirW = Math.max(...rows.map(r => r.dir.length), 9);
1759
+ const stsW = Math.max(...rows.map(r => r.status.length), 6);
1760
+ const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
1761
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
1762
+
1763
+ console.log(`${pad('Directory', dirW)} ${pad('Status', stsW)} ${pad('Session', ssnW)}`);
1764
+ for (const row of rows) {
1765
+ console.log(`${pad(row.dir, dirW)} ${pad(row.status, stsW)} ${pad(row.session || '(none)', ssnW)}`);
1766
+ if (longMode && row.server) console.log(` server: ${row.server}`);
1522
1767
  }
1523
- console.error(`Recreating project window for ${dir}...`);
1524
- createProjectWindow(dir, server, state.session);
1525
1768
  }
1526
1769
 
1527
1770
  // ---------------------------------------------------------------------------
1528
1771
  // Subcommand: migrate — rewrite legacy .ocmux.json files to v2
1529
1772
  // ---------------------------------------------------------------------------
1530
1773
 
1531
- function cmdMigrate() {
1532
- const rows = listProjects();
1774
+ async function cmdMigrate(server) {
1775
+ const rows = await configuredProjects(server);
1533
1776
  let migrated = 0;
1534
1777
  for (const row of rows) {
1535
- const sf = statefileFor(row.dir);
1778
+ const sf = projectState.statefileFor(row.dir);
1536
1779
  const state = projectState.readProjectState(sf);
1537
1780
  if (!state) continue;
1538
1781
  if (state.version >= 2) continue;
@@ -1553,31 +1796,39 @@ function cmdMigrate() {
1553
1796
  // ---------------------------------------------------------------------------
1554
1797
 
1555
1798
  function printHelp() {
1556
- console.error(`Usage: ${SCRIPT} [-l] [<subcommand>] [<directory>]`);
1799
+ console.error(`Usage: ${SCRIPT} [-l] [--all-projects] [<subcommand>] [<directory>]`);
1557
1800
  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`.');
1801
+ console.error('Manage project/session routing and optional registered OpenCode TUIs.');
1802
+ console.error('The OpenCode server is user-managed: start it with `opencode serve`.');
1560
1803
  console.error('');
1561
1804
  console.error('Without arguments, searches upward from <directory> (default: $PWD)');
1562
1805
  console.error('for .ocmux.json and opens an interactive SESSION picker for that');
1563
- console.error('project (requires a TTY). In the picker:');
1806
+ console.error('project (requires a TTY). The title bar names the project whose');
1807
+ console.error('sessions are listed. In the picker:');
1564
1808
  console.error(' Enter switch · n new · r rename · R remind · d delete · a agents · m model');
1565
1809
  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.)');
1810
+ 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');
1812
+ console.error('default it only inspects other projects and never modifies any');
1813
+ console.error('.ocmux.json — see --all-projects.)');
1569
1814
  console.error('');
1570
1815
  console.error('Subcommands:');
1571
- console.error(' serve Create a TUI window for <directory> (default: $PWD).');
1816
+ console.error(' serve Initialize project state for <directory> (default: $PWD).');
1572
1817
  console.error(' --server <url> Server to record (default: $OCMUX_SERVER or http://localhost:4096).');
1573
1818
  console.error(' --force Repoint an existing project to --server (overwrites .ocmux.json).');
1574
1819
  console.error(' --git / --GIT Use the nearest parent with a .git entry / directory.');
1575
1820
  console.error(' Aliased as "new" for backwards compatibility.');
1821
+ console.error(' tui [--shared] [--server <url>] [directory]');
1822
+ console.error(' Register the current tmux pane and run its OpenCode TUI.');
1823
+ console.error(' Without --shared it is dedicated to the current project;');
1824
+ console.error(' --shared makes it the single fallback for every project.');
1825
+ console.error(' --list lists all live registrations; --status inspects one;');
1826
+ console.error(' --detach unregisters one without closing its pane or TUI.');
1827
+ console.error(' With --status/--detach, --shared targets the shared slot;');
1828
+ console.error(' otherwise the slot dedicated to directory is targeted.');
1576
1829
  console.error(' session <id|title>');
1577
1830
  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.');
1831
+ console.error(' list [-l] List configured projects and TUI routing status.');
1581
1832
  console.error(' migrate Rewrite legacy .ocmux.json files to the v2 schema.');
1582
1833
  console.error('');
1583
1834
  console.error('Reminders: the R key in the session picker attaches a note to a');
@@ -1585,6 +1836,12 @@ function printHelp() {
1585
1836
  console.error('');
1586
1837
  console.error('Options:');
1587
1838
  console.error(' -l Long output (list: also show server URL).');
1839
+ console.error(' --all-projects');
1840
+ console.error(' Let the picker’s project switcher (p) MOVE to another');
1841
+ console.error(' project, so picking a session there updates that');
1842
+ console.error(' project’s .ocmux.json (what agentp reads in it).');
1843
+ console.error(' Default: the switcher only inspects other projects');
1844
+ console.error(' and you always return to your own project’s list.');
1588
1845
  console.error(' --version Show version');
1589
1846
  console.error(' -h Show this help');
1590
1847
  console.error('');
@@ -1602,8 +1859,13 @@ function main() {
1602
1859
  let gitMode = null;
1603
1860
  let serverArg = null;
1604
1861
  let force = false;
1862
+ let allProjects = false;
1863
+ let sharedTui = false;
1864
+ let tuiSession = null;
1865
+ let tuiList = false;
1866
+ let tuiStatus = false;
1867
+ let tuiDetach = false;
1605
1868
  const positional = [];
1606
- let dirIsForced = false;
1607
1869
 
1608
1870
  const parsedArgs = process.argv.slice(2);
1609
1871
  for (let i = 0; i < parsedArgs.length; i++) {
@@ -1624,6 +1886,24 @@ function main() {
1624
1886
  process.exit(1);
1625
1887
  } else if (arg === '--force') {
1626
1888
  force = true;
1889
+ } else if (arg === '--shared') {
1890
+ sharedTui = true;
1891
+ } else if (arg === '--session-id') {
1892
+ const val = parsedArgs[i + 1];
1893
+ if (!val || val.startsWith('-')) {
1894
+ console.error("Error: '--session-id' requires a session ID.");
1895
+ process.exit(1);
1896
+ }
1897
+ tuiSession = val;
1898
+ i++;
1899
+ } else if (arg === '--list') {
1900
+ tuiList = true;
1901
+ } else if (arg === '--status') {
1902
+ tuiStatus = true;
1903
+ } else if (arg === '--detach') {
1904
+ tuiDetach = true;
1905
+ } else if (arg === '--all-projects') {
1906
+ allProjects = true;
1627
1907
  } else if (arg === '--git') {
1628
1908
  if (gitMode && gitMode !== 'nearest') {
1629
1909
  console.error("Error: cannot combine '--git' with '--GIT'.");
@@ -1642,13 +1922,19 @@ function main() {
1642
1922
  } else if (arg === '-h' || arg === '--help') {
1643
1923
  printHelp();
1644
1924
  } else if (arg === '--') {
1645
- dirIsForced = true;
1925
+ const directory = parsedArgs[i + 1];
1926
+ if (!directory) {
1927
+ console.error("Error: '--' requires a directory argument.");
1928
+ process.exit(1);
1929
+ }
1930
+ positional.push({ value: directory, forcedDirectory: true });
1931
+ i++;
1646
1932
  } else if (arg.startsWith('-')) {
1647
1933
  console.error(`Error: unknown option '${arg}'`);
1648
1934
  console.error(`Run '${SCRIPT} -h' for help.`);
1649
1935
  process.exit(1);
1650
1936
  } else {
1651
- positional.push(arg);
1937
+ positional.push({ value: arg, forcedDirectory: false });
1652
1938
  }
1653
1939
  }
1654
1940
 
@@ -1658,16 +1944,16 @@ function main() {
1658
1944
  let targetDir = null;
1659
1945
  let directRef = null;
1660
1946
 
1661
- for (const arg of positional) {
1662
- if (dirIsForced) {
1663
- dirIsForced = false;
1947
+ for (const positionalArg of positional) {
1948
+ const arg = positionalArg.value;
1949
+ if (positionalArg.forcedDirectory) {
1664
1950
  if (targetDir) {
1665
1951
  console.error(`Error: unexpected argument '${arg}'`);
1666
1952
  process.exit(1);
1667
1953
  }
1668
1954
  targetDir = resolveDir(arg);
1669
- } else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list'
1670
- || arg === 'resurrect' || arg === 'session'
1955
+ } else if (arg === 'serve' || arg === 'new' || arg === 'list' || arg === 'tui'
1956
+ || arg === 'session'
1671
1957
  || arg === 'migrate') {
1672
1958
  if (subcommand) {
1673
1959
  console.error(`Error: multiple subcommands specified: '${subcommand}' and '${arg}'`);
@@ -1687,6 +1973,23 @@ function main() {
1687
1973
 
1688
1974
  if (!targetDir) targetDir = process.cwd();
1689
1975
 
1976
+ if (sharedTui && subcommand !== 'tui') {
1977
+ console.error("Error: '--shared' is only valid with 'ocmux tui'.");
1978
+ process.exit(1);
1979
+ }
1980
+ if (tuiSession && subcommand !== 'tui') {
1981
+ console.error("Error: '--session-id' is only valid with 'ocmux tui'.");
1982
+ process.exit(1);
1983
+ }
1984
+ if ((tuiList || tuiStatus || tuiDetach) && subcommand !== 'tui') {
1985
+ console.error("Error: '--list', '--status', and '--detach' are only valid with 'ocmux tui'.");
1986
+ process.exit(1);
1987
+ }
1988
+ if ([tuiList, tuiStatus, tuiDetach].filter(Boolean).length > 1) {
1989
+ console.error("Error: combine at most one of '--list', '--status', and '--detach'.");
1990
+ process.exit(1);
1991
+ }
1992
+
1690
1993
  // Dispatch. Each branch may throw synchronously (EXIT) for validation errors
1691
1994
  // or return a promise for async commands; main() returns the settled promise
1692
1995
  // so tests can await it.
@@ -1701,28 +2004,45 @@ function main() {
1701
2004
  console.error("Error: 'list' does not accept a directory path.");
1702
2005
  process.exit(1);
1703
2006
  }
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'.");
2007
+ p = cmdList(longMode, serverArg || DEFAULT_SERVER);
2008
+ } else if (subcommand === 'tui') {
2009
+ if (gitMode || force || allProjects || longMode) {
2010
+ console.error("Error: 'tui' does not accept --git, --GIT, --force, --all-projects, or -l.");
1709
2011
  process.exit(1);
1710
2012
  }
1711
- p = cmdSessionSwitch(targetDir, directRef);
1712
- } else if (subcommand === 'kill') {
2013
+ if (tuiList) {
2014
+ if (sharedTui || serverArg || tuiSession || targetDir !== process.cwd()) {
2015
+ console.error("Error: 'ocmux tui --list' does not accept --shared, --server, --session-id, or a directory.");
2016
+ process.exit(1);
2017
+ }
2018
+ cmdTuiList();
2019
+ } else if (tuiStatus) {
2020
+ if (serverArg || tuiSession) {
2021
+ console.error("Error: 'ocmux tui --status' does not accept --server or --session-id.");
2022
+ process.exit(1);
2023
+ }
2024
+ cmdTuiStatus(targetDir, sharedTui);
2025
+ } else if (tuiDetach) {
2026
+ if (serverArg || tuiSession) {
2027
+ console.error("Error: 'ocmux tui --detach' does not accept --server or --session-id.");
2028
+ process.exit(1);
2029
+ }
2030
+ cmdTuiDetach(targetDir, sharedTui);
2031
+ } else {
2032
+ p = cmdTui(targetDir, serverArg, sharedTui, tuiSession);
2033
+ }
2034
+ } else if (subcommand === 'session') {
1713
2035
  if (gitMode) {
1714
2036
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
1715
2037
  process.exit(1);
1716
2038
  }
1717
- cmdKill(targetDir);
1718
- } else if (subcommand === 'resurrect') {
1719
- p = cmdResurrect(targetDir);
2039
+ p = cmdSessionSwitch(targetDir, directRef, allProjects);
1720
2040
  } else if (subcommand === 'migrate') {
1721
2041
  if (targetDir !== process.cwd()) {
1722
2042
  console.error("Error: 'migrate' does not accept a directory path.");
1723
2043
  process.exit(1);
1724
2044
  }
1725
- cmdMigrate();
2045
+ p = cmdMigrate(serverArg || DEFAULT_SERVER);
1726
2046
  } else if (subcommand === 'serve' || subcommand === 'new') {
1727
2047
  if (gitMode) {
1728
2048
  const flagLabel = gitMode === 'root' ? '--GIT' : '--git';
@@ -1735,7 +2055,7 @@ function main() {
1735
2055
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
1736
2056
  process.exit(1);
1737
2057
  }
1738
- p = cmdSessionSwitch(targetDir, null);
2058
+ p = cmdSessionSwitch(targetDir, null, allProjects);
1739
2059
  }
1740
2060
 
1741
2061
  return p.catch((err) => {
@@ -1751,4 +2071,4 @@ if (require.main === module) {
1751
2071
  main();
1752
2072
  }
1753
2073
 
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 };
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 };