agentp 1.14.0 → 2.0.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
@@ -3,31 +3,29 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const readline = require('readline');
6
+ const http = require('http');
7
+ const { URL } = require('url');
6
8
  const { spawnSync } = require('child_process');
7
9
  const os = require('os');
8
10
  const {
9
11
  SESSION,
10
- readState,
11
12
  statefileFor,
12
13
  tuiPaneId,
13
14
  windowByDir,
14
15
  windowNameByIndex,
15
16
  activeWindowIndex,
16
- paneCount,
17
- listServers,
17
+ listProjects,
18
+ activateProject,
19
+ relaunchTui,
20
+ closeProjectWindow,
21
+ createProjectWindow,
18
22
  ensureSession,
19
23
  pinWindowName,
20
24
  sleep,
21
- hashDir,
22
- logfileFor,
23
- URL_RE,
24
- resurrectServer,
25
- startServer,
26
- getActiveServer,
27
- activateServer,
28
25
  tuiAttachCommand,
29
26
  } = require('../lib/ocmux');
30
- const { listSessions, listModels, switchModel, resolveModelRef } = require('../lib/opencode');
27
+ const { listSessions, createSession, createSessionWithModel, updateSession, listModels, switchModel, resolveModelRef, getAuthHeaders, sortSessionsByRecency, deleteSession, listAgents, getActiveSessions } = require('../lib/opencode');
28
+ const projectState = require('../lib/project-state');
31
29
 
32
30
  // ---------------------------------------------------------------------------
33
31
  // Constants
@@ -37,6 +35,7 @@ const { version } = require('../package.json');
37
35
 
38
36
  const SCRIPT = path.basename(process.argv[1] || 'ocmux');
39
37
  const HOME = os.homedir();
38
+ const DEFAULT_SERVER = process.env.OCMUX_SERVER || 'http://localhost:4096';
40
39
 
41
40
  // ---------------------------------------------------------------------------
42
41
  // Helpers
@@ -94,17 +93,9 @@ function resolveGitDir(startDir, requireDir, flagLabel) {
94
93
  if (!result.dir) {
95
94
  const label = requireDir ? '.git directory' : '.git entry';
96
95
  console.error(`Error: no ${label} found in ${startDir} or any of its parents.`);
97
- console.error(`Run '${SCRIPT} serve' without ${flagLabel} to create a server in the current directory.`);
96
+ console.error(`Run '${SCRIPT} serve' without ${flagLabel} to create a project window in the current directory.`);
98
97
  process.exit(1);
99
98
  }
100
-
101
- if (result.stateDir && result.stateDir !== result.dir) {
102
- console.error(`Error: found an existing ocmux server in ${result.stateDir}.`);
103
- console.error(`Refusing to create a new server at ${result.dir} because it is above that session.`);
104
- console.error(`Move outside ${result.stateDir} or pass an explicit directory.`);
105
- process.exit(1);
106
- }
107
-
108
99
  return result.dir;
109
100
  }
110
101
 
@@ -137,614 +128,1502 @@ function resolveDir(arg) {
137
128
  process.exit(1);
138
129
  }
139
130
 
140
- function ensureTuiZoomed(windowIndex) {
141
- const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}|#{window_zoomed_flag}']);
142
- if (r.status !== 0) return;
143
- for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
144
- const [wi, zoomed] = line.split('|');
145
- if (parseInt(wi, 10) === windowIndex && zoomed === '1') return;
146
- }
147
- const paneId = tuiPaneId(windowIndex);
148
- if (paneId) tmux(['resize-pane', '-Z', '-t', paneId]);
131
+ // Health check: is a server answering on `server`? The server is user-managed
132
+ // (started with `opencode serve`); this only verifies something is listening.
133
+ function checkServer(server) {
134
+ return new Promise((resolve) => {
135
+ let parsed;
136
+ try {
137
+ parsed = new URL(`${server}/api/info`);
138
+ } catch {
139
+ return resolve(false);
140
+ }
141
+ const req = http.request({
142
+ hostname: parsed.hostname,
143
+ port: parsed.port,
144
+ path: parsed.pathname,
145
+ method: 'GET',
146
+ headers: getAuthHeaders(),
147
+ }, (res) => {
148
+ res.resume();
149
+ resolve(true); // any HTTP response means the server is up
150
+ });
151
+ req.setTimeout(3000, () => { req.destroy(); resolve(false); });
152
+ req.on('error', () => resolve(false));
153
+ req.end();
154
+ });
149
155
  }
150
156
 
157
+ function newestSessionId(sessions) {
158
+ if (!sessions || sessions.length === 0) return null;
159
+ // Implicit targets skip child/subagent sessions (parentID set) — OpenCode's
160
+ // TUI opens those on its "Subagents" tab instead of the prompt. Callers fall
161
+ // back to creating a session when this returns null.
162
+ const sorted = sortSessionsByRecency(sessions).filter(s => s && s.id && !s.parentID);
163
+ return sorted.length > 0 ? sorted[0].id : null;
164
+ }
151
165
 
166
+ // ---------------------------------------------------------------------------
167
+ // Shared list rendering (scrollable viewport + resize-aware)
168
+ // ---------------------------------------------------------------------------
152
169
 
153
- function outputUrl(url, dir, longMode) {
154
- if (longMode) {
155
- console.log(`${url} → ${dir}`);
156
- } else {
157
- console.log(url);
158
- }
170
+ // Visible window for a list rendered in `height` rows around `cursor`.
171
+ // Pure (unit-tested). Returns { start, count }.
172
+ function windowFor(len, cursor, height) {
173
+ if (len <= 0 || height <= 0) return { start: 0, count: 0 };
174
+ const count = Math.min(len, height);
175
+ if (len <= count) return { start: 0, count };
176
+ let start = cursor - Math.floor(count / 2);
177
+ start = Math.max(0, Math.min(start, len - count));
178
+ return { start, count };
159
179
  }
160
180
 
161
- function printHelp() {
162
- console.error(`Usage: ${SCRIPT} [-l] [<subcommand>] [<directory>]`);
163
- console.error('');
164
- console.error('Manage opencode server + TUI in tmux for project directories.');
165
- console.error('');
166
- console.error('Without arguments, searches upward from <directory> (default: $PWD)');
167
- console.error("for .ocmux.json and switches to that server's tmux window.");
168
- console.error('If <directory> is not a valid path, ocmux will match it against the');
169
- console.error("basenames of existing sessions' directories. If exactly one matches,");
170
- console.error('that session is used.');
171
- console.error('');
172
- console.error('Subcommands:');
173
- console.error(' serve Create a server in <directory> (default: $PWD) and attach a TUI.');
174
- console.error(' Aliased as "new" for backwards compatibility.');
175
- console.error(' Errors if one already exists there.');
176
- console.error(' Warns if a parent directory already has a server.');
177
- console.error(' --git Use the nearest parent with a .git entry (file or dir).');
178
- console.error(' --GIT Use the nearest parent with a .git directory only.');
179
- console.error(' Refuses to create above an existing .ocmux.json found while searching.');
180
- console.error(' --print-logs Pass --print-logs to opencode serve (prints server logs to stderr).');
181
- console.error(' kill Kill the server found upward from <directory>.');
182
- console.error(' Removes its tmux window and state file.');
183
- console.error(' resurrect Recover a dead server: kills the old window, removes the state file,');
184
- console.error(' then creates a fresh server + TUI in the same directory.');
185
- console.error(' Works even if no tmux window exists (stale state file).');
186
- console.error(' --print-logs Pass --print-logs to the new opencode serve.');
187
- console.error(' list List all running servers with their directories and URLs.');
188
- console.error(' switch Interactively select a server to switch to.');
189
- console.error(' j/k or arrows to move, Enter/Space selects (menu stays open),');
190
- console.error(' q or Ctrl+C quits. Prints the URL of the last selected server on exit.');
191
- console.error(' model Switch the model of the newest session on the current server.');
192
- console.error(' With no argument: interactive model picker (requires a TTY).');
193
- console.error(' With <ref>: switch directly and print the server URL on stdout,');
194
- console.error(' so `agentp $(ocmux model <ref>)` switches before the next prompt.');
195
- console.error(' Partial names work when unique (e.g. "ocmux model deepseek",');
196
- console.error(' "opencode-go/deepseek", "...#high"). Multiple matches open the picker.');
197
- console.error('');
198
- console.error(' -- Treat the next argument as a directory even if it matches');
199
- console.error(' a subcommand name (e.g. "ocmux -- kill").');
200
- console.error('');
201
- console.error('Options:');
202
- console.error(' -l Long output: append " → <dir>" after the URL (serve/default).');
203
- console.error(' --last Print the URL of the active tmux window (last used server).');
204
- console.error(' --print-logs Pass --print-logs to opencode serve (prints server logs to stderr).');
205
- console.error(' --version Show version');
206
- console.error(' -h Show this help');
207
- console.error('');
208
- console.error('See also: agentp -- pipe prompts into OpenCode from stdin');
209
- console.error(' tgagentp -- Telegram bridge for OpenCode (tgagentp --help)');
210
- process.exit(0);
181
+ // ── Small formatting / layout helpers ──────────────────────────────
182
+ function centerIn(s, w) {
183
+ const str = String(s);
184
+ if (str.length >= w) return str.slice(0, w);
185
+ const left = Math.floor((w - str.length) / 2);
186
+ return ' '.repeat(left) + str + ' '.repeat(w - str.length - left);
187
+ }
188
+ function padLeft(s, w) {
189
+ const str = String(s);
190
+ return ' '.repeat(Math.max(0, w - str.length)) + str;
211
191
  }
212
192
 
213
- // ---------------------------------------------------------------------------
214
- // Subcommand: default — switch to an existing server
215
- // ---------------------------------------------------------------------------
216
-
217
- function cmdSwitch(statefile, longMode) {
218
- const serverDir = path.dirname(statefile);
219
- const state = readState(statefile);
193
+ // ── '/' incremental search for interactive lists ──────────────────────────
194
+ // Filter `items` by `pattern`: case-insensitive, space-separated tokens that
195
+ // must ALL be substrings of `textOf(item)`. Empty pattern → list unchanged.
196
+ function filterByPattern(items, pattern, textOf) {
197
+ const p = String(pattern || '').trim().toLowerCase();
198
+ if (!p) return items;
199
+ const tokens = p.split(/\s+/);
200
+ return items.filter((it) => {
201
+ const hay = String(textOf(it) || '').toLowerCase();
202
+ return tokens.every((t) => hay.includes(t));
203
+ });
204
+ }
220
205
 
221
- if (!state || !state.url || state.window_index == null) {
222
- console.error(`Error: invalid state file ${statefile}`);
223
- process.exit(1);
224
- }
206
+ // State of a '/' search line: `active` a filter exists, `live` the user is
207
+ // still typing into it, `value` the pattern, `caret` the edit position.
208
+ function newSearchState() {
209
+ return { active: false, live: false, value: '', caret: 0 };
210
+ }
225
211
 
226
- let { url, window_index: winIdx } = state;
212
+ function clearSearchState(s) {
213
+ s.active = false;
214
+ s.live = false;
215
+ s.value = '';
216
+ s.caret = 0;
217
+ }
227
218
 
228
- const actualName = windowNameByIndex(winIdx);
229
- if (actualName !== serverDir) {
230
- const found = windowByDir(serverDir);
231
- if (found == null) {
232
- console.error(`Error: tmux window for ${serverDir} not found`);
233
- console.error("Run 'ocmux serve' to create a new server.");
234
- process.exit(1);
219
+ // Drive a '/' search line from a keypress. Returns:
220
+ // 'edit' — pattern/caret changed (caller re-filters and redraws)
221
+ // 'close' — search cancelled/cleared (caller re-filters and redraws)
222
+ // 'done' — search committed, pattern kept (caller redraws)
223
+ // 'pass' — not handled here (caller runs its normal key logic)
224
+ function handleSearch(str, key, s) {
225
+ if (str === '/' && !key.ctrl && !key.meta && !s.live) {
226
+ // '/' starts a new search or resumes editing the active filter.
227
+ if (!s.active) { s.value = ''; s.caret = 0; }
228
+ else s.caret = s.value.length;
229
+ s.active = true;
230
+ s.live = true;
231
+ return 'edit';
232
+ }
233
+ if (!s.active) return 'pass';
234
+ if (!s.live) {
235
+ // Committed filter: ESC drops it, Backspace resumes editing it.
236
+ if (key.name === 'escape') { clearSearchState(s); return 'close'; }
237
+ if (key.name === 'backspace') {
238
+ if (s.value === '') { clearSearchState(s); return 'close'; }
239
+ s.live = true;
240
+ s.caret = s.value.length;
241
+ s.value = s.value.slice(0, -1);
242
+ s.caret--;
243
+ return 'edit';
235
244
  }
236
- winIdx = found;
245
+ return 'pass';
237
246
  }
238
-
239
- pinWindowName(winIdx);
240
-
241
- const tuiPane = tuiPaneId(winIdx);
242
- if (!tuiPane) {
243
- console.error('TUI pane is dead, restarting...');
244
- const count = paneCount(winIdx);
245
- if (count >= 2) {
246
- const panesR = tmux(['list-panes', '-t', `${SESSION}:${winIdx}`, '-F', '#{pane_id}']);
247
- const panes = panesR.stdout.trim().split('\n').filter(Boolean);
248
- const lastPane = panes[panes.length - 1];
249
- const respawnR = tmux(['respawn-pane', '-k', '-t', lastPane, tuiAttachCommand(url)]);
250
- if (respawnR.status !== 0) {
251
- tmux(['send-keys', '-t', lastPane, tuiAttachCommand(url), 'Enter']);
252
- }
253
- } else {
254
- tmux(['split-window', '-v', '-t', `${SESSION}:${winIdx}`, '-c', serverDir,
255
- tuiAttachCommand(url)]);
247
+ // Live typing.
248
+ if (key.name === 'escape') { clearSearchState(s); return 'close'; }
249
+ if (key.name === 'return' || key.name === 'enter') {
250
+ if (!s.value.trim()) { clearSearchState(s); return 'close'; }
251
+ s.live = false;
252
+ return 'done';
253
+ }
254
+ if (key.name === 'backspace') {
255
+ // Backspace on an already-empty field exits the search (like ESC). Removing
256
+ // the last character keeps the field open.
257
+ if (s.value === '') { clearSearchState(s); return 'close'; }
258
+ if (s.caret > 0) {
259
+ s.value = s.value.slice(0, s.caret - 1) + s.value.slice(s.caret);
260
+ s.caret--;
256
261
  }
262
+ return 'edit';
257
263
  }
264
+ if (key.name === 'delete' || key.name === 'del') {
265
+ if (s.caret < s.value.length) s.value = s.value.slice(0, s.caret) + s.value.slice(s.caret + 1);
266
+ return 'edit';
267
+ }
268
+ if (key.name === 'left') { s.caret = Math.max(0, s.caret - 1); return 'done'; }
269
+ if (key.name === 'right') { s.caret = Math.min(s.value.length, s.caret + 1); return 'done'; }
270
+ if (key.name === 'home') { s.caret = 0; return 'done'; }
271
+ if (key.name === 'end') { s.caret = s.value.length; return 'done'; }
272
+ if (key.name === 'up' || key.name === 'down') return 'pass'; // navigate the filtered list
273
+ if (str && !key.ctrl && !key.meta && str >= ' ') {
274
+ s.value = s.value.slice(0, s.caret) + str + s.value.slice(s.caret);
275
+ s.caret += str.length;
276
+ return 'edit';
277
+ }
278
+ return 'pass';
279
+ }
258
280
 
259
- const activeWin = activeWindowIndex();
260
- if (activeWin !== winIdx) {
261
- tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
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).
283
+ // The caller adds the `Enter: confirm · Esc: cancel` hint on the right via
284
+ // renderList's `footerRight`.
285
+ const SEARCH_HINT = 'Enter: confirm · Esc: cancel';
286
+ function searchFooter(s, base) {
287
+ if (!s.active) return base;
288
+ const shown = s.live ? s.value.slice(0, s.caret) + '▏' + s.value.slice(s.caret) : s.value;
289
+ return s.live ? `Search: ${shown}` : `Search: ${shown} · /: edit`;
290
+ }
291
+
292
+ // Sentinel returned by switchMenu on Ctrl+C so the caller fully exits the CLI
293
+ // (instead of just returning to the session menu).
294
+ const SWITCH_QUIT = Symbol('ocmux-switch-quit');
295
+ // Last-view stamp: HH:MM within the last 24h, else DD/MM/YYYY; '--:--' if unknown.
296
+ function fmtClock(ts) {
297
+ if (!ts) return '--:--';
298
+ const d = new Date(ts);
299
+ if (Date.now() - ts < 24 * 3600 * 1000) {
300
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
262
301
  }
302
+ return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
303
+ }
304
+ function fmtDuration(ms) {
305
+ const s = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
306
+ const h = Math.floor(s / 3600);
307
+ const m = Math.floor((s % 3600) / 60);
308
+ const sec = s % 60;
309
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
310
+ }
311
+ function fmtTokens(n) {
312
+ if (n == null) return null;
313
+ const v = Number(n);
314
+ if (!Number.isFinite(v)) return null;
315
+ if (v >= 1e9) return (v / 1e9).toFixed(1) + 'B';
316
+ if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M';
317
+ if (v >= 1e3) return Math.round(v / 1e3) + 'K';
318
+ return String(v);
319
+ }
320
+ function fmtCost(c) {
321
+ if (c == null) return null;
322
+ const v = Number(c);
323
+ return Number.isFinite(v) ? '$' + v.toFixed(4) : null;
324
+ }
263
325
 
264
- ensureTuiZoomed(winIdx);
265
- outputUrl(url, serverDir, longMode);
326
+ // Lay cells out in a responsive grid (row-major), collapsing columns when narrow.
327
+ // A cell is a plain string or { label, value } (label rendered bold).
328
+ function cellPlain(c) {
329
+ return (c && typeof c === 'object') ? `${c.label}: ${c.value}` : String(c);
330
+ }
331
+ function cellRender(c, width, labelColor = '\x1b[1m') {
332
+ if (c && typeof c === 'object') {
333
+ const label = `${c.label}:`;
334
+ const avail = Math.max(0, width - label.length - 1);
335
+ let value = String(c.value == null ? '' : c.value);
336
+ if (value.length > avail) value = value.slice(0, avail);
337
+ const plainLen = label.length + 1 + value.length;
338
+ return `${labelColor}${label}\x1b[0m ${value}` + ' '.repeat(Math.max(0, width - plainLen));
339
+ }
340
+ return String(c) + ' '.repeat(Math.max(0, width - String(c).length));
341
+ }
342
+ function layoutCells(cells, cols, minCell = 26, labelColor = '\x1b[1m') {
343
+ if (!cells || cells.length === 0) return [];
344
+ const plains = cells.map(cellPlain);
345
+ let nCols = Math.max(1, Math.min(cells.length, Math.floor(Math.max(cols, 1) / minCell)));
346
+ const colWidths = (nc) => {
347
+ const w = new Array(nc).fill(0);
348
+ plains.forEach((p, i) => { w[i % nc] = Math.max(w[i % nc], p.length); });
349
+ return w;
350
+ };
351
+ let widths = colWidths(nCols);
352
+ while (nCols > 1 && widths.reduce((a, b) => a + b, 0) + 2 * (nCols - 1) > cols) {
353
+ nCols--;
354
+ widths = colWidths(nCols);
355
+ }
356
+ const nRows = Math.ceil(cells.length / nCols);
357
+ const lines = [];
358
+ for (let r = 0; r < nRows; r++) {
359
+ const parts = [];
360
+ for (let c = 0; c < nCols; c++) {
361
+ const idx = r * nCols + c;
362
+ if (idx >= cells.length) break;
363
+ parts.push(cellRender(cells[idx], widths[c], labelColor));
364
+ }
365
+ lines.push(parts.join(' ').trimEnd());
366
+ }
367
+ return lines;
266
368
  }
267
369
 
268
- // ---------------------------------------------------------------------------
269
- // Subcommand: switch — interactive session selection
270
- // ---------------------------------------------------------------------------
370
+ // Bold a label and append a (truncated) value, fitting `cols`.
371
+ function labeledLine(label, value, cols, labelColor = '\x1b[1m') {
372
+ const lbl = `${label}:`;
373
+ const avail = Math.max(0, cols - lbl.length - 1);
374
+ let v = String(value == null ? '' : value);
375
+ if (v.length > avail) v = v.slice(0, avail);
376
+ return `${labelColor}${lbl}\x1b[0m ${v}`;
377
+ }
271
378
 
272
- function cmdSwitchMenu(longMode) {
273
- const rows = listServers();
274
- if (rows.length === 0) {
275
- console.error('No opencode servers running.');
276
- process.exit(1);
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.
383
+ function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false) {
384
+ if (!s) return [];
385
+ const labelColor = highlight ? '\x1b[1;33m' : '\x1b[1m';
386
+ const title = (s.title && s.title.trim()) || 'Untitled';
387
+ const location = (s.location && s.location.directory) || 'Unknown';
388
+ const out = [
389
+ labeledLine('Title', title, cols, labelColor),
390
+ labeledLine('Location', location, cols, labelColor),
391
+ ];
392
+
393
+ const model = s.model ? `${s.model.providerID}/${s.model.id}` : null;
394
+ const modelLabel = model
395
+ ? model + (s.model.variant && s.model.variant !== 'default' ? '#' + s.model.variant : '')
396
+ : 'Unknown';
397
+ const busy = !!(runningIds && runningIds.has(s.id));
398
+ const since = busy
399
+ ? (s.time && (s.time.updated || s.time.created))
400
+ : (s.time && (s.time.idle || s.time.updated || s.time.created));
401
+ const status = `${busy ? 'BUSY' : 'IDLE'}${since ? ' ' + fmtDuration(Date.now() - since) : ' --:--:--'}`;
402
+
403
+ let tokensTxt = '--';
404
+ if (s.tokens) {
405
+ const t = s.tokens;
406
+ const total = (t.input || 0) + (t.output || 0) + (t.reasoning || 0);
407
+ tokensTxt = fmtTokens(total) || '--';
408
+ if (t.cache && t.cache.read) tokensTxt += ' (+' + fmtTokens(t.cache.read) + ' cache)';
277
409
  }
278
- if (!process.stdin.isTTY) {
279
- console.error('Error: "switch" is interactive and requires a TTY.');
280
- console.error('Use "ocmux list" to list servers, or run "ocmux <dir>" to switch directly.');
281
- process.exit(1);
410
+ const cost = fmtCost(s.cost) || '--';
411
+ const ctx = (model && modelContext && modelContext.get(model)) ? fmtTokens(modelContext.get(model)) : '--';
412
+
413
+ const cells = [
414
+ { label: 'Model', value: modelLabel },
415
+ { label: 'Agent', value: s.agent || 'Unknown' },
416
+ { label: 'Status', value: status },
417
+ { label: 'Tokens', value: tokensTxt },
418
+ { label: 'Cost', value: cost },
419
+ { label: 'Ctx', value: ctx },
420
+ { label: 'Outcome', value: s.outcome || 'Unknown' },
421
+ ];
422
+ return out.concat(layoutCells(cells, cols, 26, labelColor));
423
+ }
424
+
425
+ // Render a scrollable list into the alternate screen.
426
+ // heading -> centered + bold + inverted heading
427
+ // row(index, item) -> string | { text, reverse }
428
+ // extraLines -> prompt/confirm lines right after the list (normal colors)
429
+ // footer -> inverted, RIGHT-aligned key-hint bar (acts as a separator)
430
+ // (left-aligned when footerLeft is set — used by the search line)
431
+ // footerRight-> optional hint pinned to the right end of a left-aligned footer
432
+ // infoLines -> details below the footer (normal colors, bold labels)
433
+ // 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 }) {
435
+ const cols = colsArg || process.stdout.columns || 80;
436
+ const termRows = rowsArg || process.stdout.rows || 24;
437
+ const extras = (extraLines || []).filter((l) => l != null && l !== '');
438
+ const infos = (infoLines || []).filter((l) => l != null && l !== '');
439
+
440
+ // Chrome: heading + blank, extras, footer bar, infos.
441
+ const reserved = 2 + extras.length + 1 + infos.length;
442
+ const viewHeight = Math.max(1, termRows - reserved);
443
+ const cursorIdx = items.length ? Math.max(0, Math.min(cursor, items.length - 1)) : 0;
444
+ const win = windowFor(items.length, cursorIdx, viewHeight);
445
+
446
+ let titleLine = title;
447
+ if (items.length > win.count) {
448
+ titleLine += ` (${win.start + 1}-${win.start + win.count}/${items.length})`;
282
449
  }
283
450
 
284
- const cols = process.stderr.columns || 80;
285
- const nameW = Math.max(...rows.map(r => path.basename(r.dir).length), 4);
286
- const stsW = Math.max(...rows.map(r => r.status.length), 6);
287
- const urlW = Math.max(...rows.map(r => r.url.length), 3);
288
- const dirW = Math.max(...rows.map(r => r.dir.length), 9);
289
- const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
290
- const lineWidth = Math.max(cols, nameW + 2 + stsW + 2 + urlW + 2 + dirW);
291
-
292
- let cursor = 0;
293
- let activeRow = 0;
294
- let lastUrl = null;
295
- let lastDir = null;
296
- let exited = false;
297
-
298
- // Query tmux for the currently active window and map it to a row. Called at
299
- // init and on every draw, so the highlight always reflects tmux's real state.
300
- const refreshActive = () => {
301
- const idx = activeWindowIndex();
302
- if (idx == null) return;
303
- for (let i = 0; i < rows.length; i++) {
304
- if (rows[i].index === idx) { activeRow = i; return; }
451
+ let out = '\x1b[2J\x1b[H';
452
+ out += '\x1b[1;7m' + centerIn(titleLine, cols) + '\x1b[0m\n\n';
453
+
454
+ let rendered = 0;
455
+ if (items.length === 0) {
456
+ out += ' (none)\n';
457
+ rendered = 1;
458
+ } else {
459
+ for (let i = 0; i < win.count; i++) {
460
+ const idx = win.start + i;
461
+ const r = row(idx, items[idx]);
462
+ const text = r && typeof r === 'object' ? r.text : r;
463
+ 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';
466
+ rendered++;
305
467
  }
306
- };
468
+ }
469
+ // Pad the list area so the footer stays pinned to the bottom of the terminal
470
+ // (instead of floating right under the last row on a tall window).
471
+ for (let i = rendered; i < viewHeight; i++) out += '\n';
472
+ for (const l of extras) out += l + '\n';
473
+ let hint;
474
+ if (footerRight) {
475
+ // Left-aligned text plus a hint pinned to the right end of the bar.
476
+ const left = footer.length + footerRight.length + 1 <= cols
477
+ ? footer
478
+ : footer.slice(0, Math.max(0, cols - footerRight.length - 1));
479
+ const gap = Math.max(0, cols - left.length - footerRight.length);
480
+ hint = (left + ' '.repeat(gap) + footerRight).slice(0, cols);
481
+ } else if (footer.length > cols) {
482
+ hint = footer.slice(0, cols);
483
+ } else if (footerLeft) {
484
+ hint = footer + ' '.repeat(cols - footer.length);
485
+ } else {
486
+ hint = padLeft(footer, cols);
487
+ }
488
+ out += '\x1b[7m' + hint + '\x1b[0m\n';
489
+ for (const l of infos) out += l + '\n';
490
+ return out.replace(/\n$/, '');
491
+ }
307
492
 
308
- const cleanup = () => {
309
- process.stderr.write('\x1b[?25h');
310
- process.stderr.write('\x1b[?1049l');
311
- try { process.stdin.setRawMode(false); } catch {}
312
- };
493
+ // Render an interactive session picker (alt screen). Sorted most-recently-viewed
494
+ // first by the caller. Keys: Enter/Space switch (menu stays open), n create,
495
+ // r rename, d delete (confirm), a annotate, p switch project, h help,
496
+ // q/Ctrl+C quit. Input mode offers readline-style caret editing.
497
+ // Resolves on quit (null) or with the string 'switch-project' (caller then runs
498
+ // 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 } = {}) {
500
+ return new Promise((resolve) => {
501
+ if (!process.stdin.isTTY) return resolve(null);
313
502
 
314
- const finish = () => {
315
- if (exited) return;
316
- exited = true;
317
- cleanup();
318
- process.stdin.pause();
319
- if (lastUrl) outputUrl(lastUrl, lastDir, longMode);
320
- process.exit(0);
321
- };
503
+ let rows = [...sessions];
504
+ let current = currentSession;
505
+ let cursor = 0;
506
+ let mode = 'normal'; // 'normal' | 'help' | 'confirm' | 'input'
507
+ let confirmDel = null;
508
+ let input = null; // { label, value, kind, caret }
509
+ let list = null; // { title, items, format, onEnter }
510
+ let listCursor = 0;
511
+ let done = false;
512
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
513
+ let spinIdx = 0;
514
+ let spinTimer = null;
515
+ let broadcastSel = new Set(); // session ids selected in broadcast mode
516
+ let search = newSearchState(); // '/' filter for the session list
517
+ let listSearch = newSearchState(); // '/' filter for inline lists (models/agents)
518
+ let view = rows; // rows filtered by `search` (what is displayed)
519
+ let inspected = false; // broadcast inspection moved the TUI off `current`
520
+
521
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
522
+ const helpLines = [
523
+ 'Enter switch to the selected session (stays open)',
524
+ 'Space start BROADCAST: add the selected session to the current one',
525
+ ' (Space toggles each session; Enter keeps the selection)',
526
+ 'n create a new session (type a name, Enter; blank = auto)',
527
+ 'r rename the selected session (edit in place; ESC cancels)',
528
+ 'R (Shift+r) set a REMINDER for the selected session (sent in front of',
529
+ ' every agentp prompt; blank clears)',
530
+ 'd delete the selected session (then y/N to confirm)',
531
+ 'a switch the AGENT of the selected session',
532
+ 'm switch the MODEL of the selected session',
533
+ 'p open the project switcher',
534
+ '/ search/filter this list (Enter keeps the filter,',
535
+ ' ESC clears it, / resumes editing it)',
536
+ 'j / k, arrows move the cursor',
537
+ 'h toggle this help',
538
+ 'q / Ctrl+C quit',
539
+ '',
540
+ 'While typing (n/r/R):',
541
+ ' left/right, Home/End move the caret',
542
+ ' Backspace / Delete edit in place',
543
+ ' Enter confirm, ESC cancels',
544
+ ];
322
545
 
323
- const draw = () => {
324
- refreshActive();
325
- let out = '\x1b[2J\x1b[H';
326
- out += 'ocmux switch — j/k or arrows: move, Enter/Space: select (keep switching), q/Ctrl+C: quit\n\n';
327
- for (let i = 0; i < rows.length; i++) {
328
- const row = rows[i];
329
- const marker = i === cursor ? '▶ ' : ' ';
330
- const line = `${marker}${pad(path.basename(row.dir), nameW)} ${pad(row.status, stsW)} ${pad(row.url, urlW)} ${pad(row.dir, dirW)}`;
331
- if (i === activeRow) {
332
- out += '\x1b[7m' + pad(line, lineWidth) + '\x1b[0m\n';
546
+ const cleanup = () => {
547
+ process.stderr.write('\x1b[?25h');
548
+ process.stderr.write('\x1b[?1049l');
549
+ if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
550
+ process.stdout.removeListener('resize', draw);
551
+ try { process.stdin.setRawMode(false); } catch {}
552
+ process.stdin.pause();
553
+ };
554
+ const finish = (value) => {
555
+ if (done) return;
556
+ done = true;
557
+ cleanup();
558
+ process.stdin.removeAllListeners('keypress');
559
+ process.removeListener('SIGINT', onSig);
560
+ resolve(value);
561
+ };
562
+ const onSig = () => finish(null);
563
+ process.on('SIGINT', onSig);
564
+
565
+ const renderInputValue = (v) => {
566
+ const c = input.caret;
567
+ return v.slice(0, c) + '▏' + v.slice(c);
568
+ };
569
+
570
+ const draw = () => {
571
+ if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help') {
572
+ let out = '\x1b[2J\x1b[H';
573
+ if (mode === 'broadcast-help') {
574
+ out += 'ocmux broadcast mode — help\n\n';
575
+ out += ' Space select/deselect the cursor session\n';
576
+ out += ' Enter keep the broadcast selection; switch TUI to cursor\n';
577
+ out += ' m switch the model for ALL selected sessions\n';
578
+ out += ' d delete the cursor session (with confirmation)\n';
579
+ out += ' j / k, arrows move the cursor (selecting shows it in the TUI)\n';
580
+ out += ' / search/filter the session list\n';
581
+ out += ' ESC / q cancel broadcast mode (back to the session list)\n';
582
+ out += ' Ctrl+C quit ocmux\n';
583
+ } else if (mode === 'list-help') {
584
+ out += `ocmux ${list && list.title ? list.title : 'list'} — help\n\n`;
585
+ out += ' j / k, arrows move the cursor\n';
586
+ out += ' Enter / Space select the highlighted item\n';
587
+ out += ' / search/filter the list (Enter keeps, ESC clears)\n';
588
+ out += ' h toggle this help\n';
589
+ out += ' ESC / q / Backspace return to the previous menu\n';
590
+ out += ' Ctrl+C quit ocmux\n';
591
+ } else {
592
+ out += 'ocmux session picker — help\n\n';
593
+ for (const l of helpLines) out += ' ' + l + '\n';
594
+ }
595
+ out += '\nPress any key to return.\n';
596
+ process.stderr.write(out);
597
+ return;
598
+ }
599
+ if (mode === 'list' && list) {
600
+ const listExtras = (listSearch.active && list.view.length === 0) ? [`No matches for "${listSearch.value}"`] : [];
601
+ process.stderr.write(renderList({
602
+ title: `ocmux — ${list.title}`,
603
+ items: list.view,
604
+ cursor: listCursor,
605
+ row: (i, item) => list.format(item) || '',
606
+ footer: searchFooter(listSearch, 'Enter: select · /: search · h: help · ESC: back'),
607
+ footerLeft: listSearch.active,
608
+ footerRight: listSearch.active ? SEARCH_HINT : null,
609
+ extraLines: listExtras,
610
+ }));
611
+ return;
612
+ }
613
+ const cols = process.stdout.columns || 80;
614
+ const showTime = cols >= 48;
615
+ const TIME_W = 10; // dd/mm/yyyy worst case
616
+ const maxLen = Math.min(
617
+ Math.max(...rows.map(s => (s.title || s.id || '').length), 8) + 6,
618
+ Math.max(8, cols - (showTime ? TIME_W + 10 : 8)),
619
+ );
620
+ 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
+ }
627
+ if (search.active && view.length === 0) extras.push(`No sessions match "${search.value}"`);
628
+ const selected = view[cursor];
629
+ let infoLines = [];
630
+ let footer = 'Enter: switch · Space: broadcast · /: search · h: help · q: quit';
631
+ 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';
333
635
  } else {
334
- out += line + '\n';
636
+ const selIsCurrent = !!(selected && String(selected.id) === String(current));
637
+ infoLines = sessionInfoLines(selected, runningIds, modelContext, cols, selIsCurrent);
335
638
  }
336
- }
337
- process.stderr.write(out);
338
- };
339
-
340
- const activate = (i) => {
341
- const row = rows[i];
342
- try {
343
- activateServer(row.dir, row.index, row.url);
344
- lastUrl = row.url;
345
- lastDir = row.dir;
346
- } catch (err) {
347
- process.stderr.write(`\nError: ${err.message}\n`);
348
- }
349
- draw();
350
- };
639
+ footer = searchFooter(search, footer);
640
+ process.stderr.write(renderList({
641
+ title: 'ocmux — sessions',
642
+ items: view,
643
+ cursor,
644
+ row: (i, s) => {
645
+ const sel = inBc ? (broadcastSel.has(String(s.id)) ? '[x] ' : '[ ] ') : '';
646
+ const title = (s.title || s.id || '').slice(0, maxLen);
647
+ const timeStr = showTime ? padLeft(fmtClock(s.time && s.time.viewed), TIME_W) + ' ' : '';
648
+ const mark = String(s.id) === String(current) ? ' *' : '';
649
+ const hasNote = annotations && annotations[s.id] ? ' ◈' : '';
650
+ const spin = (runningIds && runningIds.has(s.id)) ? SPINNER[spinIdx % SPINNER.length] + ' ' : ' ';
651
+ 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;
654
+ },
655
+ footer,
656
+ footerLeft: search.active,
657
+ footerRight: search.active ? SEARCH_HINT : null,
658
+ infoLines,
659
+ extraLines: extras,
660
+ }));
661
+ };
351
662
 
352
- process.stdin.on('keypress', (str, key) => {
353
- if (key.ctrl && key.name === 'c') return finish();
354
- if (key.name === 'q') return finish();
355
- if (key.name === 'up' || str === 'k') {
356
- cursor = (cursor + rows.length - 1) % rows.length;
663
+ const startInput = (label, value, kind) => {
664
+ input = { label, value, kind, caret: value.length };
665
+ mode = 'input';
357
666
  draw();
358
- } else if (key.name === 'down' || str === 'j') {
359
- cursor = (cursor + 1) % rows.length;
360
- draw();
361
- } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
362
- activate(cursor);
363
- }
364
- });
667
+ };
668
+
669
+ const applyRenameWith = (name) => {
670
+ const row = view[cursor];
671
+ if (!row || !onRename || !name) return;
672
+ Promise.resolve(onRename(row, name)).then((updated) => {
673
+ row.title = (updated && updated.title) || name;
674
+ draw();
675
+ }).catch((err) => {
676
+ process.stderr.write(`\nError: ${err.message}\n`);
677
+ draw();
678
+ });
679
+ };
365
680
 
366
- process.on('SIGINT', finish);
367
- process.on('SIGTERM', finish);
681
+ const applyReminder = (text) => {
682
+ const row = view[cursor];
683
+ if (!row || !onReminder) return;
684
+ Promise.resolve(onReminder(row, text)).then((saved) => {
685
+ if (saved) annotations[row.id] = saved;
686
+ else delete annotations[row.id];
687
+ draw();
688
+ }).catch((err) => {
689
+ process.stderr.write(`\nError: ${err.message}\n`);
690
+ draw();
691
+ });
692
+ };
368
693
 
369
- readline.emitKeypressEvents(process.stdin);
370
- process.stdin.setRawMode(true);
371
- process.stdin.resume();
372
- process.stderr.write('\x1b[?1049h\x1b[?25l');
373
- refreshActive();
374
- cursor = activeRow;
694
+ const applyPick = (row) => {
695
+ Promise.resolve(onPick ? onPick(row) : null).then((created) => {
696
+ if (row.new && created) {
697
+ rows.unshift(created);
698
+ applySearch();
699
+ const ci = view.indexOf(created);
700
+ cursor = ci >= 0 ? ci : 0;
701
+ } else if (!row.new) {
702
+ current = row.id;
703
+ }
704
+ inspected = false;
705
+ confirmDel = null;
706
+ draw();
707
+ }).catch((err) => {
708
+ process.stderr.write(`\nError: ${err.message}\n`);
709
+ confirmDel = null;
710
+ draw();
711
+ });
712
+ };
713
+
714
+ const applyDelete = () => {
715
+ const s = confirmDel;
716
+ if (!s || !onDelete) return;
717
+ const wasBroadcast = mode === 'broadcast-confirm';
718
+ Promise.resolve(onDelete(s)).then(() => {
719
+ rows = rows.filter(r => r.id !== s.id);
720
+ applySearch();
721
+ if (cursor >= view.length && cursor > 0) cursor = view.length - 1;
722
+ confirmDel = null;
723
+ if (wasBroadcast) {
724
+ broadcastSel.delete(String(s.id));
725
+ if (broadcastSel.size < 2) {
726
+ // Broadcast ended: make the remaining selected session the real
727
+ // selection (switch the TUI to it and store it).
728
+ const remaining = [...broadcastSel][0];
729
+ persistBroadcast([]);
730
+ broadcastSel = new Set();
731
+ mode = 'normal';
732
+ const row = rows.find(r => String(r.id) === String(remaining));
733
+ if (row) applyPick(row);
734
+ else { draw(); }
735
+ } else {
736
+ persistBroadcast([...broadcastSel]);
737
+ mode = 'broadcast';
738
+ draw();
739
+ }
740
+ } else {
741
+ mode = 'normal';
742
+ draw();
743
+ }
744
+ }).catch((err) => {
745
+ process.stderr.write(`\nError: ${err.message}\n`);
746
+ confirmDel = null;
747
+ mode = wasBroadcast ? 'broadcast' : 'normal';
748
+ draw();
749
+ });
750
+ };
751
+
752
+ const persistBroadcast = (ids) => {
753
+ if (onBroadcastChange) onBroadcastChange(ids);
754
+ };
755
+ // Recompute the filtered session view after `rows` or the pattern change.
756
+ const applySearch = () => {
757
+ view = filterByPattern(rows, search.active ? search.value : '',
758
+ (r) => `${r.title || ''} ${r.id || ''}`);
759
+ if (cursor >= view.length) cursor = Math.max(0, view.length - 1);
760
+ };
761
+ // Recompute the filtered view of the currently open inline list.
762
+ const applyListSearch = () => {
763
+ if (!list) return;
764
+ list.view = filterByPattern(list.items, listSearch.active ? listSearch.value : '', list.format);
765
+ if (listCursor >= list.view.length) listCursor = Math.max(0, list.view.length - 1);
766
+ };
767
+ // After a broadcast attempt (cancel / drop below two): put the menu — and
768
+ // the TUI — back on the stored current session, which inspection may have
769
+ // relaunched away without updating `.ocmux.json`.
770
+ const restoreCurrent = () => {
771
+ const idx = view.findIndex((r) => String(r.id) === String(current));
772
+ if (idx >= 0) cursor = idx;
773
+ if (inspected && current != null && onInspectSession) onInspectSession(current);
774
+ inspected = false;
775
+ };
776
+ const startBroadcast = (idx) => {
777
+ broadcastSel = new Set([String(current), String(view[idx].id)]);
778
+ persistBroadcast([...broadcastSel]);
779
+ mode = 'broadcast';
780
+ if (onInspectSession) { onInspectSession(view[idx].id); inspected = true; }
375
781
  draw();
376
- }
782
+ };
783
+ const toggleBroadcast = (idx) => {
784
+ const id = String(view[idx].id);
785
+ const adding = !broadcastSel.has(id);
786
+ if (adding) broadcastSel.add(id);
787
+ else broadcastSel.delete(id);
788
+ if (adding && onInspectSession) { onInspectSession(id); inspected = true; }
789
+ if (broadcastSel.size < 2) {
790
+ // Deselecting down to one session ends broadcast mode by making that
791
+ // remaining session the actual selection.
792
+ const remaining = [...broadcastSel][0];
793
+ persistBroadcast([]);
794
+ broadcastSel = new Set();
795
+ mode = 'normal';
796
+ const row = rows.find(r => String(r.id) === String(remaining));
797
+ if (row) applyPick(row);
798
+ else { restoreCurrent(); draw(); }
799
+ return;
800
+ }
801
+ persistBroadcast([...broadcastSel]);
802
+ draw();
803
+ };
377
804
 
378
- // ---------------------------------------------------------------------------
379
- // Subcommand: model — switch the model of the server's most recent session
380
- // ---------------------------------------------------------------------------
805
+ const openList = (title, items, format, onEnter, returnMode = 'normal') => {
806
+ if (!items || items.length === 0) {
807
+ process.stderr.write(`\nNo ${title.toLowerCase()} available.\n`);
808
+ draw();
809
+ return;
810
+ }
811
+ list = { title, items, view: items, format, onEnter, returnMode };
812
+ listSearch = newSearchState();
813
+ listCursor = 0;
814
+ mode = 'list';
815
+ draw();
816
+ };
381
817
 
382
- // Newest session id for a server (same heuristic as agentp's default).
383
- function newestSessionId(sessions) {
384
- if (!sessions || sessions.length === 0) return null;
385
- const sorted = sessions
386
- .filter(s => s && s.id && s.time && (s.time.updated || s.time.created))
387
- .sort((a, b) => {
388
- const aTime = (a.time && a.time.updated) || (a.time && a.time.created) || 0;
389
- const bTime = (b.time && b.time.updated) || (b.time && b.time.created) || 0;
390
- return bTime - aTime;
818
+ const commandErr = (err) => {
819
+ process.stderr.write(`\nError: ${err.message}\n`);
820
+ draw();
821
+ };
822
+
823
+ process.stdin.on('keypress', (str, key) => {
824
+ // Ctrl+C always fully exits ocmux, from any menu.
825
+ if (key.ctrl && key.name === 'c') {
826
+ if (mode === 'broadcast' || mode === 'broadcast-confirm') {
827
+ persistBroadcast([]);
828
+ broadcastSel = new Set();
829
+ restoreCurrent();
830
+ }
831
+ return finish(null);
832
+ }
833
+ if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help') {
834
+ mode = mode === 'broadcast-help' ? 'broadcast' : (mode === 'list-help' ? 'list' : 'normal');
835
+ draw();
836
+ return;
837
+ }
838
+ if (mode === 'confirm' || mode === 'broadcast-confirm') {
839
+ if (key.name === 'y') return applyDelete();
840
+ if (key.name === 'n' || key.name === 'escape' || key.name === 'q' || key.name === 'return') {
841
+ confirmDel = null;
842
+ mode = mode === 'broadcast-confirm' ? 'broadcast' : 'normal';
843
+ draw();
844
+ }
845
+ return;
846
+ }
847
+ if (mode === 'input') {
848
+ if (key.name === 'escape') {
849
+ input = null;
850
+ mode = 'normal';
851
+ draw();
852
+ } else if (key.name === 'return' || key.name === 'enter') {
853
+ const v = input.value;
854
+ const kind = input.kind;
855
+ input = null;
856
+ mode = 'normal';
857
+ draw();
858
+ if (kind === 'create') {
859
+ applyPick({ new: true, name: v.trim() });
860
+ } else if (kind === 'reminder') {
861
+ applyReminder(v.trim());
862
+ } else if (v.trim()) {
863
+ applyRenameWith(v.trim());
864
+ }
865
+ } else if (key.name === 'backspace') {
866
+ if (input.caret > 0) {
867
+ input.value = input.value.slice(0, input.caret - 1) + input.value.slice(input.caret);
868
+ input.caret--;
869
+ }
870
+ draw();
871
+ } else if (key.name === 'delete' || key.name === 'del') {
872
+ if (input.caret < input.value.length) {
873
+ input.value = input.value.slice(0, input.caret) + input.value.slice(input.caret + 1);
874
+ }
875
+ draw();
876
+ } else if (key.name === 'left' || str === '\u001b[D') {
877
+ input.caret = Math.max(0, input.caret - 1);
878
+ draw();
879
+ } else if (key.name === 'right' || str === '\u001b[C') {
880
+ input.caret = Math.min(input.value.length, input.caret + 1);
881
+ draw();
882
+ } else if (key.name === 'home') {
883
+ input.caret = 0;
884
+ draw();
885
+ } else if (key.name === 'end') {
886
+ input.caret = input.value.length;
887
+ draw();
888
+ } else if (str && !key.ctrl && !key.meta && str >= ' ') {
889
+ input.value = input.value.slice(0, input.caret) + str + input.value.slice(input.caret);
890
+ input.caret++;
891
+ draw();
892
+ }
893
+ return;
894
+ }
895
+ if (mode === 'list') {
896
+ const lr = handleSearch(str, key, listSearch);
897
+ if (lr !== 'pass') { applyListSearch(); draw(); return; }
898
+ if (key.name === 'escape' || key.name === 'q' || key.name === 'backspace') {
899
+ const returnMode = list.returnMode;
900
+ list = null;
901
+ mode = returnMode;
902
+ draw();
903
+ } else if (key.name === 'h') {
904
+ mode = 'list-help';
905
+ draw();
906
+ } else if (key.name === 'up' || str === 'k') {
907
+ if (list.view.length) listCursor = (listCursor - 1 + list.view.length) % list.view.length;
908
+ draw();
909
+ } else if (key.name === 'down' || str === 'j') {
910
+ if (list.view.length) listCursor = (listCursor + 1) % list.view.length;
911
+ draw();
912
+ } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
913
+ const item = list.view[listCursor];
914
+ if (item === undefined) { draw(); return; }
915
+ const onEnter = list.onEnter;
916
+ const returnMode = list.returnMode;
917
+ list = null;
918
+ mode = returnMode;
919
+ draw();
920
+ Promise.resolve(onEnter(item)).then(() => draw()).catch(commandErr);
921
+ }
922
+ return;
923
+ }
924
+ const sr = handleSearch(str, key, search);
925
+ if (sr !== 'pass') { applySearch(); draw(); return; }
926
+ if (mode === 'broadcast') {
927
+ if (key.name === 'escape') {
928
+ persistBroadcast([]);
929
+ broadcastSel = new Set();
930
+ mode = 'normal';
931
+ restoreCurrent();
932
+ draw();
933
+ } else if (key.name === 'q') {
934
+ // 'q' only quits from the plain session list; in broadcast mode it
935
+ // cancels back to that list (Ctrl+C quits from anywhere).
936
+ persistBroadcast([]);
937
+ broadcastSel = new Set();
938
+ mode = 'normal';
939
+ restoreCurrent();
940
+ draw();
941
+ } else if (key.name === 'h') {
942
+ mode = 'broadcast-help';
943
+ draw();
944
+ } else if (key.name === 'space') {
945
+ if (cursor < view.length) toggleBroadcast(cursor);
946
+ } else if (key.name === 'd') {
947
+ if (cursor < view.length) {
948
+ confirmDel = view[cursor];
949
+ mode = 'broadcast-confirm';
950
+ draw();
951
+ }
952
+ } 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);
958
+ } else if (key.name === 'return' || key.name === 'enter') {
959
+ if (cursor < view.length) {
960
+ mode = 'normal';
961
+ broadcastSel = new Set(); // selection stays persisted for agentp
962
+ applyPick(view[cursor]);
963
+ }
964
+ } else if (view.length > 0 && (key.name === 'up' || str === 'k')) {
965
+ cursor = (cursor - 1 + view.length) % view.length;
966
+ draw();
967
+ } else if (view.length > 0 && (key.name === 'down' || str === 'j')) {
968
+ cursor = (cursor + 1) % view.length;
969
+ draw();
970
+ }
971
+ return;
972
+ }
973
+ if (key.name === 'q') return finish(null);
974
+ if (key.name === 'p') return finish('switch-project');
975
+ if (key.name === 'h') {
976
+ mode = 'help';
977
+ draw();
978
+ return;
979
+ }
980
+ if (key.name === 'n') {
981
+ startInput('New session name (blank for auto; ESC cancels):', '', 'create');
982
+ return;
983
+ }
984
+ // Shift+r → Reminder; lowercase r → Rename.
985
+ if (str === 'R') {
986
+ const row = view[cursor];
987
+ const note = (row && annotations && annotations[row.id]) || '';
988
+ startInput('Reminder for this session (blank clears; ESC cancels):', note, 'reminder');
989
+ return;
990
+ }
991
+ if (key.name === 'r' && cursor < view.length) {
992
+ startInput('Rename to (ESC cancels):', view[cursor].title || '', 'rename');
993
+ return;
994
+ }
995
+ if (key.name === 'a') {
996
+ const target = view[cursor];
997
+ if (target) {
998
+ listAgents(server).then((all) => {
999
+ const primary = all.filter(x => x.mode === 'primary' && !x.hidden);
1000
+ const agents = primary.length ? primary : all;
1001
+ openList('Agents', agents, (x) => `${x.name || x.id}${x.description ? ' — ' + x.description.slice(0, 40) : ''}`, (item) =>
1002
+ updateSession(server, target.id, null, item.id));
1003
+ }).catch(commandErr);
1004
+ }
1005
+ return;
1006
+ }
1007
+ if (key.name === 'm') {
1008
+ 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
+ }
1015
+ return;
1016
+ }
1017
+ if (key.name === 'd') {
1018
+ if (cursor < view.length) {
1019
+ confirmDel = view[cursor];
1020
+ mode = 'confirm';
1021
+ draw();
1022
+ }
1023
+ return;
1024
+ }
1025
+ if (view.length > 0 && (key.name === 'up' || str === 'k')) {
1026
+ cursor = (cursor - 1 + view.length) % view.length;
1027
+ draw();
1028
+ } else if (view.length > 0 && (key.name === 'down' || str === 'j')) {
1029
+ cursor = (cursor + 1) % view.length;
1030
+ draw();
1031
+ } else if (key.name === 'return' || key.name === 'enter') {
1032
+ if (cursor < view.length) applyPick(view[cursor]);
1033
+ } else if (key.name === 'space') {
1034
+ // Start broadcast only when adding a DIFFERENT session to the current one.
1035
+ if (cursor < view.length && String(view[cursor].id) !== String(current)) {
1036
+ startBroadcast(cursor);
1037
+ } else if (cursor < view.length) {
1038
+ draw(); // no-op on the already-selected session
1039
+ }
1040
+ }
391
1041
  });
392
- return sorted.length > 0 ? sorted[0].id : (sessions[0] && sessions[0].id) || null;
1042
+
1043
+ readline.emitKeypressEvents(process.stdin);
1044
+ process.stdin.setRawMode(true);
1045
+ process.stdin.resume();
1046
+ process.stdout.on('resize', draw);
1047
+ // Animate the spinner while any listed session is running.
1048
+ if (rows.some(s => runningIds && runningIds.has(s.id))) {
1049
+ spinTimer = setInterval(() => { spinIdx++; draw(); }, 120);
1050
+ }
1051
+ process.stderr.write('\x1b[?1049h\x1b[?25l');
1052
+ draw();
1053
+ });
393
1054
  }
394
1055
 
395
- // Interactive model picker (mirrors the ocmux switch menu; single selection).
396
- // Resolves with the chosen model or null on quit.
397
- function modelMenu(rows) {
1056
+ // Interactive project picker — purely visual/navigation, never writes state.
1057
+ function switchMenu(rows, longMode) {
398
1058
  return new Promise((resolve) => {
399
- if (!process.stdin.isTTY || rows.length === 0) return resolve(null);
400
-
401
- // Display name when it differs from the canonical label (e.g. "Kimi K2 (opencode/kimi-k2)").
402
- const display = (r) => (r.name && r.name !== r.label ? `${r.name} (${r.label})` : r.label);
403
- const maxLen = Math.max(
404
- ...rows.map(r => display(r).length),
405
- ...rows.map(r => (r.variants || []).join(',').length),
406
- 8,
407
- );
1059
+ if (!process.stdin.isTTY) {
1060
+ console.error('Error: "switch" is interactive and requires a TTY.');
1061
+ process.exit(1);
1062
+ }
1063
+ if (rows.length === 0) {
1064
+ console.log('No opencode projects running.');
1065
+ resolve(null);
1066
+ return;
1067
+ }
1068
+
1069
+ const nameW = Math.max(...rows.map(r => path.basename(r.dir).length), 4);
1070
+ const stsW = Math.max(...rows.map(r => r.status.length), 6);
1071
+ const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
408
1072
  const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
1073
+ const cols = process.stderr.columns || 80;
1074
+ const lineWidth = Math.max(cols, nameW + 2 + stsW + 2 + ssnW + 6);
1075
+
409
1076
  let cursor = 0;
1077
+ let activeIdx = null; // tmux window index of the focused project
1078
+ let lastDir = null;
410
1079
  let done = false;
1080
+ let help = false;
1081
+ const search = newSearchState();
1082
+ let view = rows;
1083
+
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);
1088
+ };
1089
+
1090
+ const refreshActive = () => {
1091
+ activeIdx = activeWindowIndex();
1092
+ };
411
1093
 
412
1094
  const cleanup = () => {
413
1095
  process.stderr.write('\x1b[?25h');
414
1096
  process.stderr.write('\x1b[?1049l');
1097
+ process.stdout.removeListener('resize', draw);
415
1098
  try { process.stdin.setRawMode(false); } catch {}
416
- process.stdin.pause();
417
1099
  };
1100
+
418
1101
  const finish = (value) => {
419
1102
  if (done) return;
420
1103
  done = true;
421
1104
  cleanup();
1105
+ process.stdin.pause();
422
1106
  process.stdin.removeAllListeners('keypress');
423
1107
  process.removeListener('SIGINT', onSig);
424
- resolve(value);
1108
+ resolve(value === undefined ? lastDir : value);
425
1109
  };
426
- const onSig = () => finish(null);
427
- process.on('SIGINT', onSig);
428
1110
 
429
1111
  const draw = () => {
430
- let out = '\x1b[2J\x1b[H';
431
- out += 'ocmux model — j/k or arrows: move, Enter: switch, q/Ctrl+C: quit\n\n';
432
- for (let i = 0; i < rows.length; i++) {
433
- const row = rows[i];
434
- const marker = i === cursor ? '▶ ' : ' ';
435
- const variants = (row.variants || []).join(',') || '—';
436
- out += `${marker}${pad(display(row), maxLen)} ${variants}\n`;
437
- }
438
- process.stderr.write(out);
1112
+ if (help) {
1113
+ let out = '\x1b[2J\x1b[H';
1114
+ 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';
1117
+ out += ' / search/filter the list (Enter keeps, ESC clears)\n';
1118
+ out += ' h toggle this help\n';
1119
+ out += ' q return to the session menu\n';
1120
+ out += ' Ctrl+C quit ocmux\n';
1121
+ out += '\nPress any key to return.\n';
1122
+ process.stderr.write(out);
1123
+ return;
1124
+ }
1125
+ refreshActive();
1126
+ const extras = (search.active && view.length === 0) ? [`No projects match "${search.value}"`] : [];
1127
+ process.stderr.write(renderList({
1128
+ title: 'ocmux — project switcher',
1129
+ items: view,
1130
+ 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;
1135
+ },
1136
+ footer: searchFooter(search, 'Enter: focus · /: search · h: help · q: back · Ctrl+C: quit'),
1137
+ footerLeft: search.active,
1138
+ footerRight: search.active ? SEARCH_HINT : null,
1139
+ extraLines: extras,
1140
+ }));
1141
+ };
1142
+
1143
+ const activate = (row) => {
1144
+ if (!row) return;
1145
+ 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;
1152
+ } catch (err) {
1153
+ process.stderr.write(`\nError: ${err.message}\n`);
1154
+ }
1155
+ draw();
439
1156
  };
440
1157
 
1158
+ const onSig = () => finish(SWITCH_QUIT);
1159
+ process.on('SIGINT', onSig);
1160
+
441
1161
  process.stdin.on('keypress', (str, key) => {
442
- if (key.ctrl && key.name === 'c') return finish(null);
443
- if (key.name === 'q') return finish(null);
1162
+ // Ctrl+C fully exits ocmux; 'q' only closes this menu (back to sessions).
1163
+ if (key.ctrl && key.name === 'c') return finish(SWITCH_QUIT);
1164
+ if (help) { help = false; draw(); return; }
1165
+ const sr = handleSearch(str, key, search);
1166
+ if (sr !== 'pass') { applySearch(); draw(); return; }
1167
+ if (key.name === 'h') { help = true; draw(); return; }
1168
+ if (key.name === 'q') return finish();
1169
+ if (view.length === 0) return;
444
1170
  if (key.name === 'up' || str === 'k') {
445
- cursor = (cursor + rows.length - 1) % rows.length;
1171
+ cursor = (cursor + view.length - 1) % view.length;
446
1172
  draw();
447
1173
  } else if (key.name === 'down' || str === 'j') {
448
- cursor = (cursor + 1) % rows.length;
1174
+ cursor = (cursor + 1) % view.length;
449
1175
  draw();
450
1176
  } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
451
- finish(rows[cursor]);
1177
+ activate(view[cursor]);
452
1178
  }
453
1179
  });
454
1180
 
455
1181
  readline.emitKeypressEvents(process.stdin);
456
1182
  process.stdin.setRawMode(true);
457
1183
  process.stdin.resume();
1184
+ process.stdout.on('resize', draw);
458
1185
  process.stderr.write('\x1b[?1049h\x1b[?25l');
459
1186
  draw();
460
1187
  });
461
1188
  }
462
1189
 
463
- async function cmdModel(modelRef, longMode) {
464
- const sf = findParentStatefile(process.cwd());
465
- if (!sf) {
466
- console.error(`Error: no opencode server found in ${process.cwd()} or any of its parents.`);
467
- console.error("Run 'ocmux serve' to create a new server.");
468
- process.exit(1);
469
- }
470
- const state = readState(sf);
471
- if (!state || !state.url) {
472
- console.error(`Error: invalid state file ${sf}`);
473
- process.exit(1);
474
- }
475
-
476
- // Fail fast before any HTTP: interactive picker requires a TTY.
477
- if (!modelRef && !process.stdin.isTTY) {
478
- console.error('Error: "model" with no argument is interactive and requires a TTY.');
479
- console.error('Use "ocmux model <ref>" (partial matches allowed when unique) to switch directly.');
480
- process.exit(1);
481
- }
1190
+ // ---------------------------------------------------------------------------
1191
+ // Subcommand: default — interactive session switcher for the current project
1192
+ // ---------------------------------------------------------------------------
482
1193
 
483
- const sessions = await listSessions(state.url).catch((err) => {
484
- console.error(`Error connecting to ${state.url}: ${err.message}`);
485
- process.exit(1);
486
- });
487
- const sessionId = newestSessionId(sessions);
488
- if (!sessionId) {
489
- console.error(`Error: no sessions found on ${state.url} (${path.dirname(sf)}).`);
490
- console.error('Open a session in the TUI first, or send a prompt with agentp.');
491
- process.exit(1);
492
- }
1194
+ async function cmdSessionSwitch(targetDir, directRef) {
1195
+ let target = targetDir;
1196
+ for (;;) {
1197
+ const sf = findParentStatefile(target);
1198
+ if (!sf) {
1199
+ 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.`);
1201
+ process.exit(1);
1202
+ }
1203
+ const state = projectState.readProjectState(sf);
1204
+ const server = state.server;
1205
+ if (!server) {
1206
+ console.error(`Error: ${sf} does not record a server.`);
1207
+ console.error(`Run 'ocmux serve --server <url>' to set it up, or add "server" to the file.`);
1208
+ process.exit(1);
1209
+ }
1210
+ const dir = state.directory;
493
1211
 
494
- const models = await listModels(state.url).catch((err) => {
495
- console.error(`Error listing models: ${err.message}`);
496
- process.exit(1);
497
- });
498
- if (models.length === 0) {
499
- console.error('Error: no models available from this server.');
500
- process.exit(1);
501
- }
1212
+ const alive = await checkServer(server);
1213
+ if (!alive) {
1214
+ console.error(`Error: OpenCode server not reachable at ${server}.`);
1215
+ console.error(`Start it with 'opencode serve' (or your usual launcher) first.`);
1216
+ process.exit(1);
1217
+ }
502
1218
 
503
- let pick = null;
504
- if (modelRef) {
505
- const { model, matches, error } = resolveModelRef(models, modelRef);
506
- if (model) {
507
- pick = model;
508
- } else if (matches.length > 1 && process.stdin.isTTY) {
509
- console.error(`Multiple models match "${modelRef}", select one:`);
510
- pick = await modelMenu(matches);
511
- } else {
512
- console.error(`Error: ${error}`);
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.`);
513
1223
  process.exit(1);
514
1224
  }
515
- } else if (process.stdin.isTTY) {
516
- pick = await modelMenu(models);
517
- } else {
518
- // Unreachable (guarded above) — kept for safety.
519
- console.error('Error: "model" with no argument is interactive and requires a TTY.');
520
- console.error('Use "ocmux model <ref>" (partial matches allowed when unique) to switch directly.');
521
- process.exit(1);
522
- }
523
1225
 
524
- if (!pick) {
525
- console.error('Cancelled.');
526
- return;
527
- }
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);
528
1229
 
529
- await switchModel(state.url, sessionId, pick).catch((err) => {
530
- console.error(`Error switching model: ${err.message}`);
531
- process.exit(1);
532
- });
533
- const label = pick.label + (pick.variant ? `#${pick.variant}` : '');
534
- console.error(`Switched session ${sessionId} to ${label}.`);
535
- // Print the URL on stdout so `agentp $(ocmux model <ref>)` composes: the
536
- // switch (side effect) takes effect before agentp proceeds with the prompt.
537
- outputUrl(state.url, path.dirname(sf), longMode);
538
- }
1230
+ const sessions = await listSessions(server, dir).catch((err) => {
1231
+ console.error(`Error connecting to ${server}: ${err.message}`);
1232
+ process.exit(1);
1233
+ });
1234
+ const sorted = sortSessionsByRecency(sessions);
1235
+ // Only "main" sessions are listed in the picker: exclude child/subagent
1236
+ // sessions (parentID set) — they are work products of a main session, not
1237
+ // targets. Explicit --session can still reach one by id/title.
1238
+ const mainSessions = sorted.filter(s => !s.parentID);
1239
+ const annotations = projectState.readAnnotations(sf);
1240
+ const runningIds = await getActiveSessions(server);
1241
+ let modelContext = new Map();
1242
+ try {
1243
+ const models = await listModels(server);
1244
+ modelContext = new Map(models.filter(m => m.context).map(m => [`${m.providerID}/${m.id}`, m.context]));
1245
+ } catch { /* context limits unavailable */ }
539
1246
 
540
- // ---------------------------------------------------------------------------
541
- // Subcommand: list
542
- // ---------------------------------------------------------------------------
1247
+ const applySession = (id) => {
1248
+ projectState.writeProjectState(sf, { session: id });
1249
+ relaunchTui(winIdx, dir, server, id);
1250
+ };
543
1251
 
544
- function cmdList() {
545
- const rows = listServers();
546
- if (rows.length === 0) {
547
- console.log('No opencode servers running.');
548
- return;
549
- }
1252
+ if (directRef) {
1253
+ const pick = sorted.find(s => String(s.id) === directRef)
1254
+ || sorted.find(s => (s.title || '').toLowerCase() === directRef.toLowerCase())
1255
+ || null;
1256
+ if (!pick) {
1257
+ console.error(`Error: no session matching "${directRef}" in ${dir}.`);
1258
+ process.exit(1);
1259
+ }
1260
+ applySession(pick.id);
1261
+ return;
1262
+ }
550
1263
 
551
- const urlW = Math.max(...rows.map(r => r.url.length), 3);
552
- const dirW = Math.max(...rows.map(r => r.dir.length), 9);
553
- const stsW = Math.max(...rows.map(r => r.status.length), 6);
1264
+ if (!process.stdin.isTTY) {
1265
+ // Silent path (non-TTY): just make sure the window is focused.
1266
+ activateProject(dir, winIdx, server, state.session);
1267
+ return;
1268
+ }
554
1269
 
555
- const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
1270
+ // Interactive: Enter switches but stays open; n creates (inheriting the
1271
+ // model of the previously selected session); r renames; R sets a reminder;
1272
+ // d deletes; a switches agent; m switches model; p switches project; q quits.
1273
+ let lastRef = (state.session && mainSessions.find(s => String(s.id) === String(state.session))) || mainSessions[0] || null;
1274
+ const result = await new Promise((resolve) => {
1275
+ sessionMenu(mainSessions, state.session, annotations, server, {
1276
+ runningIds,
1277
+ modelContext,
1278
+ onBroadcastChange: (ids) => projectState.writeProjectState(sf, { broadcast: ids }),
1279
+ onInspectSession: (id) => relaunchTui(winIdx, dir, server, id),
1280
+ onPick: async (row) => {
1281
+ if (row.new) {
1282
+ const created = await createSessionWithModel(server, row.name || undefined, dir, [lastRef, ...sorted]).catch((err) => {
1283
+ console.error(`Error creating session: ${err.message}`);
1284
+ return null;
1285
+ });
1286
+ if (created) applySession(created.id);
1287
+ return created || null;
1288
+ }
1289
+ lastRef = row;
1290
+ applySession(row.id);
1291
+ },
1292
+ onRename: async (row, name) => {
1293
+ const updated = await updateSession(server, row.id, name).catch((err) => {
1294
+ console.error(`Error renaming session: ${err.message}`);
1295
+ throw err;
1296
+ });
1297
+ return updated;
1298
+ },
1299
+ onDelete: async (row) => {
1300
+ await deleteSession(server, row.id).catch((err) => {
1301
+ console.error(`Error deleting session: ${err.message}`);
1302
+ throw err;
1303
+ });
1304
+ if (String(row.id) === String(state.session)) {
1305
+ projectState.writeProjectState(sf, { session: null });
1306
+ }
1307
+ },
1308
+ onReminder: async (row, text) => {
1309
+ try {
1310
+ return projectState.writeAnnotation(sf, row.id, text);
1311
+ } catch (err) {
1312
+ console.error(`Error saving reminder: ${err.message}`);
1313
+ throw err;
1314
+ }
1315
+ },
1316
+ }).then(resolve);
1317
+ });
556
1318
 
557
- console.log(`${pad('URL', urlW)} ${pad('Directory', dirW)} ${pad('Status', stsW)}`);
558
- for (const row of rows) {
559
- console.log(`${pad(row.url, urlW)} ${pad(row.dir, dirW)} ${pad(row.status, stsW)}`);
1319
+ 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());
1324
+ if (picked === SWITCH_QUIT) break;
1325
+ if (!picked) continue;
1326
+ target = picked;
1327
+ continue;
1328
+ }
1329
+ break;
560
1330
  }
561
1331
  }
562
1332
 
563
1333
  // ---------------------------------------------------------------------------
564
- // Subcommand: new
1334
+ // Subcommand: serve — create a project TUI window (server is user-managed)
565
1335
  // ---------------------------------------------------------------------------
566
1336
 
567
- function cmdNew(targetDir, longMode, printLogs) {
1337
+ async function cmdServeNew(targetDir, serverArg, force) {
1338
+ try { targetDir = fs.realpathSync(targetDir); } catch {}
1339
+
568
1340
  const sf = path.join(targetDir, '.ocmux.json');
569
1341
  if (fs.existsSync(sf)) {
570
- console.error(`Error: a server already exists in ${targetDir}.`);
571
- console.error("Run 'ocmux' without arguments to switch to it, or 'ocmux kill' to stop it first.");
572
- process.exit(1);
1342
+ if (!force) {
1343
+ console.error(`Error: a project window already exists in ${targetDir}.`);
1344
+ console.error(`Use 'ocmux' to switch sessions there, or 'ocmux serve --server <url> --force' to repoint it.`);
1345
+ process.exit(1);
1346
+ }
1347
+ return doForceServe(targetDir, sf, serverArg || DEFAULT_SERVER);
573
1348
  }
574
1349
 
575
1350
  const parent = findParentStatefile(path.dirname(targetDir));
576
1351
  if (parent) {
577
1352
  const parentDir = path.dirname(parent);
578
- console.error(`Warning: there is already an opencode server in ${parentDir}.`);
579
- console.error("Running 'ocmux' without arguments would switch to that server.");
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.`);
580
1355
 
581
1356
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
582
- rl.question('Are you sure you want to create a new server here? [y/N] ', (answer) => {
1357
+ rl.question('Are you sure you want to create a new project window here? [y/N] ', (answer) => {
583
1358
  rl.close();
584
1359
  if (!/^[yY]([eE][sS])?$/.test(answer.trim())) {
585
1360
  console.error('Cancelled.');
586
1361
  process.exit(0);
587
1362
  }
588
- doNew(targetDir, longMode, printLogs);
1363
+ doServeNew(targetDir, serverArg).catch((err) => {
1364
+ console.error(err ? err.message : String(err));
1365
+ process.exit(1);
1366
+ });
589
1367
  });
590
- } else {
591
- doNew(targetDir, longMode, printLogs);
1368
+ return;
592
1369
  }
1370
+ return doServeNew(targetDir, serverArg);
593
1371
  }
594
1372
 
595
- function doNew(targetDir, longMode, printLogs) {
596
- // Normalize to real path so tmux window names are consistent regardless of symlinks
597
- try { targetDir = fs.realpathSync(targetDir); } catch {}
598
- ensureSession();
599
- const lf = logfileFor(targetDir);
600
-
601
- console.error(`Starting opencode server for ${targetDir}...`);
602
-
603
- // Create window with interactive shell so .bashrc/.zshrc is sourced
604
- const rc = tmux(['new-window', '-d', '-t', SESSION, '-n', targetDir, '-c', targetDir]);
605
- if (rc.status !== 0) {
606
- console.error(`Error: failed to create tmux window: ${rc.stderr}`);
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.
1375
+ async function doForceServe(targetDir, sf, server) {
1376
+ const alive = await checkServer(server);
1377
+ if (!alive) {
1378
+ console.error(`Error: OpenCode server not reachable at ${server}.`);
1379
+ console.error(`Start it with 'opencode serve' (or pass --server <url>).`);
607
1380
  process.exit(1);
608
1381
  }
609
-
610
- const winIdx = windowByDir(targetDir);
611
- if (winIdx == null) {
612
- console.error('Error: window was not created properly');
1382
+ const state = projectState.readProjectState(sf);
1383
+ const sessions = await listSessions(server, targetDir).catch((err) => {
1384
+ console.error(`Error connecting to ${server}: ${err.message}`);
613
1385
  process.exit(1);
1386
+ });
1387
+ let session = (state && state.session && sessions.some(s => String(s.id) === String(state.session)))
1388
+ ? state.session
1389
+ : newestSessionId(sessions);
1390
+ if (!session) {
1391
+ const created = await createSessionWithModel(server, undefined, targetDir, sessions);
1392
+ session = created.id;
614
1393
  }
1394
+ 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
+ }
1403
+ }
615
1404
 
616
- pinWindowName(winIdx);
617
-
618
- // Truncate log file so stale URL from previous run can't be matched by polling
619
- try { fs.truncateSync(lf); } catch { /* may not exist yet */ }
620
-
621
- // Send the command to the interactive shell (like typing it manually)
622
- const serveFlags = printLogs ? '--print-logs' : '';
623
- tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`,
624
- `opencode serve ${serveFlags} 2>&1 | tee '${lf}'`, 'Enter']);
1405
+ async function doServeNew(targetDir, serverArg) {
1406
+ const server = serverArg || DEFAULT_SERVER;
625
1407
 
626
- // Wait up to 10s for the URL in the logfile.
627
- // Don't break on first match — keep polling so url ends up holding the
628
- // final URL in case the server prints multiple URLs during startup.
629
- let url = null;
630
- for (let i = 0; i < 50; i++) {
631
- sleep(0.2);
632
- const log = (() => { try { return fs.readFileSync(lf, 'utf8'); } catch { return ''; } })();
633
- const m = log.match(URL_RE);
634
- if (m) url = m[1];
1408
+ const alive = await checkServer(server);
1409
+ if (!alive) {
1410
+ console.error(`Error: OpenCode server not reachable at ${server}.`);
1411
+ console.error(`Start it with 'opencode serve' (or --server <url> to point elsewhere).`);
1412
+ process.exit(1);
635
1413
  }
636
1414
 
637
- if (!url) {
638
- console.error('Error: opencode server did not start within 10 seconds');
639
- const wi = windowByDir(targetDir);
640
- if (wi != null) tmux(['kill-window', '-t', `${SESSION}:${wi}`]);
1415
+ // Pick the newest session already in this directory, or create one.
1416
+ let sessions = [];
1417
+ try {
1418
+ sessions = await listSessions(server, targetDir);
1419
+ } catch (err) {
1420
+ console.error(`Error connecting to ${server}: ${err.message}`);
641
1421
  process.exit(1);
642
1422
  }
643
-
644
- const state = { url, logfile: lf, window_index: winIdx };
645
- fs.writeFileSync(path.join(targetDir, '.ocmux.json'), JSON.stringify(state) + '\n');
646
-
647
- const splitResult = tmux(['split-window', '-v', '-P', '-F', '#{pane_id}', '-t', `${SESSION}:${winIdx}`, '-c', targetDir]);
648
- const tuiPane = splitResult.stdout.trim();
649
- sleep(0.2);
650
- tmux(['send-keys', '-t', tuiPane,
651
- tuiAttachCommand(url), 'Enter']);
652
- sleep(0.5);
653
- if (tuiPane) tmux(['resize-pane', '-Z', '-t', tuiPane]);
654
-
655
- const activeWin = activeWindowIndex();
656
- if (activeWin !== winIdx) {
657
- tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
1423
+ let session = newestSessionId(sessions);
1424
+ if (!session) {
1425
+ try {
1426
+ const created = await createSessionWithModel(server, undefined, targetDir, sessions);
1427
+ session = created.id;
1428
+ } catch (err) {
1429
+ console.error(`Error creating session: ${err.message}`);
1430
+ process.exit(1);
1431
+ }
658
1432
  }
659
1433
 
660
- outputUrl(url, targetDir, longMode);
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
+ }
661
1441
  }
662
1442
 
663
1443
  // ---------------------------------------------------------------------------
664
- // Subcommand: kill
1444
+ // Subcommand: list
665
1445
  // ---------------------------------------------------------------------------
666
1446
 
667
- function cmdKill(statefile) {
668
- const serverDir = path.dirname(statefile);
669
- const state = readState(statefile);
670
-
671
- if (!state || !state.url || state.window_index == null) {
672
- console.error(`Error: invalid state file ${statefile}`);
673
- process.exit(1);
1447
+ function cmdList(longMode) {
1448
+ const rows = listProjects();
1449
+ if (rows.length === 0) {
1450
+ console.log('No opencode projects running.');
1451
+ return;
674
1452
  }
675
1453
 
676
- let { window_index: winIdx } = state;
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));
677
1458
 
678
- console.error(`Stopping server and removing window for ${serverDir}...`);
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}`);
1463
+ }
1464
+ }
679
1465
 
680
- const sessionR = tmux(['has-session', '-t', SESSION]);
681
- if (sessionR.status === 0) {
682
- const actualName = windowNameByIndex(winIdx);
683
- if (actualName !== serverDir) {
684
- const found = windowByDir(serverDir);
685
- if (found != null) winIdx = found;
686
- }
1466
+ // ---------------------------------------------------------------------------
1467
+ // Subcommand: kill — close the project window but keep .ocmux.json
1468
+ // ---------------------------------------------------------------------------
687
1469
 
688
- if (winIdx != null) {
689
- tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
690
- tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`, 'C-c']);
691
- sleep(0.3);
692
- tmux(['kill-window', '-t', `${SESSION}:${winIdx}`]);
693
- console.error('Window removed.');
694
- } else {
695
- console.error(`Tmux window not found for ${serverDir} (cleaning up state file).`);
1470
+ function cmdKill(targetDir) {
1471
+ const sf = findParentStatefile(targetDir);
1472
+ if (!sf) {
1473
+ console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
1474
+ process.exit(1);
1475
+ }
1476
+ const serverDir = path.dirname(sf);
1477
+ const winIdx = windowByDir(serverDir);
1478
+ if (winIdx != null) {
1479
+ console.error(`Closing project window for ${serverDir}...`);
1480
+ try {
1481
+ closeProjectWindow(winIdx);
1482
+ } catch (err) {
1483
+ console.error(`Error: ${err.message}`);
1484
+ process.exit(1);
696
1485
  }
697
1486
  } else {
698
- console.error("No tmux session '" + SESSION + "' found (cleaning up state file).");
1487
+ console.error(`Tmux window not found for ${serverDir} (state kept).`);
699
1488
  }
700
-
701
- fs.unlinkSync(statefile);
702
- console.error('Done.');
1489
+ // Session memory intentionally kept.
1490
+ projectState.writeProjectState(sf, { status: 'stopped' });
1491
+ console.error('Done (state file kept).');
703
1492
  }
704
1493
 
705
1494
  // ---------------------------------------------------------------------------
706
- // Subcommand: resurrect — kill dead server and create a fresh one
1495
+ // Subcommand: resurrect — recreate a project window from its state
707
1496
  // ---------------------------------------------------------------------------
708
1497
 
709
- function cmdResurrect(targetDir, longMode, printLogs) {
1498
+ async function cmdResurrect(targetDir) {
710
1499
  const sf = findParentStatefile(targetDir);
711
1500
  if (!sf) {
712
1501
  console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
713
- console.error("Run 'ocmux serve' to create a new server.");
714
1502
  process.exit(1);
715
1503
  }
1504
+ const state = projectState.readProjectState(sf);
1505
+ const server = state.server;
1506
+ if (!server) {
1507
+ console.error(`Error: ${sf} does not record a server.`);
1508
+ process.exit(1);
1509
+ }
1510
+ const dir = state.directory;
716
1511
 
717
- const serverDir = path.dirname(sf);
718
- console.error(`Resurrecting server for ${serverDir}...`);
1512
+ const winIdx = windowByDir(dir);
1513
+ if (winIdx != null) {
1514
+ console.error(`Project window for ${dir} already exists.`);
1515
+ return;
1516
+ }
719
1517
 
720
- try {
721
- const { url } = resurrectServer(serverDir, printLogs);
722
- outputUrl(url, serverDir, longMode);
723
- } catch (err) {
724
- console.error(`Error: ${err.message}`);
1518
+ const alive = await checkServer(server);
1519
+ if (!alive) {
1520
+ console.error(`Error: OpenCode server not reachable at ${server}.`);
725
1521
  process.exit(1);
726
1522
  }
1523
+ console.error(`Recreating project window for ${dir}...`);
1524
+ createProjectWindow(dir, server, state.session);
1525
+ }
1526
+
1527
+ // ---------------------------------------------------------------------------
1528
+ // Subcommand: migrate — rewrite legacy .ocmux.json files to v2
1529
+ // ---------------------------------------------------------------------------
1530
+
1531
+ function cmdMigrate() {
1532
+ const rows = listProjects();
1533
+ let migrated = 0;
1534
+ for (const row of rows) {
1535
+ const sf = statefileFor(row.dir);
1536
+ const state = projectState.readProjectState(sf);
1537
+ if (!state) continue;
1538
+ if (state.version >= 2) continue;
1539
+ projectState.writeProjectState(sf, {
1540
+ directory: row.dir,
1541
+ server: state.server,
1542
+ session: state.session,
1543
+ });
1544
+ migrated++;
1545
+ console.error(`Migrated ${sf}`);
1546
+ }
1547
+ if (migrated === 0) console.error('No legacy state files found.');
727
1548
  }
728
1549
 
729
1550
  // ---------------------------------------------------------------------------
730
- // Argument parsing
1551
+ // ---------------------------------------------------------------------------
1552
+ // Help
1553
+ // ---------------------------------------------------------------------------
1554
+
1555
+ function printHelp() {
1556
+ console.error(`Usage: ${SCRIPT} [-l] [<subcommand>] [<directory>]`);
1557
+ 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`.');
1560
+ console.error('');
1561
+ console.error('Without arguments, searches upward from <directory> (default: $PWD)');
1562
+ console.error('for .ocmux.json and opens an interactive SESSION picker for that');
1563
+ console.error('project (requires a TTY). In the picker:');
1564
+ console.error(' Enter switch · n new · r rename · R remind · d delete · a agents · m model');
1565
+ 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.)');
1569
+ console.error('');
1570
+ console.error('Subcommands:');
1571
+ console.error(' serve Create a TUI window for <directory> (default: $PWD).');
1572
+ console.error(' --server <url> Server to record (default: $OCMUX_SERVER or http://localhost:4096).');
1573
+ console.error(' --force Repoint an existing project to --server (overwrites .ocmux.json).');
1574
+ console.error(' --git / --GIT Use the nearest parent with a .git entry / directory.');
1575
+ console.error(' Aliased as "new" for backwards compatibility.');
1576
+ console.error(' session <id|title>');
1577
+ 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.');
1581
+ console.error(' migrate Rewrite legacy .ocmux.json files to the v2 schema.');
1582
+ console.error('');
1583
+ console.error('Reminders: the R key in the session picker attaches a note to a');
1584
+ console.error('session; agentp prepends it to every prompt sent to that session.');
1585
+ console.error('');
1586
+ console.error('Options:');
1587
+ console.error(' -l Long output (list: also show server URL).');
1588
+ console.error(' --version Show version');
1589
+ console.error(' -h Show this help');
1590
+ console.error('');
1591
+ console.error('See also: agentp -- pipe prompts into OpenCode from stdin');
1592
+ console.error(' tgagentp -- Telegram bridge for OpenCode (tgagentp --help)');
1593
+ process.exit(0);
1594
+ }
1595
+
1596
+ // ---------------------------------------------------------------------------
1597
+ // Argument parsing + dispatch
731
1598
  // ---------------------------------------------------------------------------
732
1599
 
733
1600
  function main() {
734
1601
  let longMode = false;
735
1602
  let gitMode = null;
736
- let printLogs = false;
737
- let lastMode = false;
1603
+ let serverArg = null;
1604
+ let force = false;
738
1605
  const positional = [];
739
1606
  let dirIsForced = false;
740
1607
 
741
- for (const arg of process.argv.slice(2)) {
1608
+ const parsedArgs = process.argv.slice(2);
1609
+ for (let i = 0; i < parsedArgs.length; i++) {
1610
+ const arg = parsedArgs[i];
742
1611
  if (arg === '-l') {
743
1612
  longMode = true;
744
- } else if (arg === '--last') {
745
- lastMode = true;
746
- } else if (arg === '--print-logs') {
747
- printLogs = true;
1613
+ } else if (arg === '--server') {
1614
+ const val = parsedArgs[i + 1];
1615
+ if (!val || val.startsWith('-')) {
1616
+ console.error("Error: '--server' requires a URL argument.");
1617
+ process.exit(1);
1618
+ }
1619
+ serverArg = val;
1620
+ i++;
1621
+ } else if (arg === 'switch' || arg === 'model') {
1622
+ console.error(`Error: the '${arg}' subcommand was removed.`);
1623
+ console.error(`Press '${arg === 'switch' ? 'p' : 'm'}' inside the session picker (ocmux) for that action.`);
1624
+ process.exit(1);
1625
+ } else if (arg === '--force') {
1626
+ force = true;
748
1627
  } else if (arg === '--git') {
749
1628
  if (gitMode && gitMode !== 'nearest') {
750
1629
  console.error("Error: cannot combine '--git' with '--GIT'.");
@@ -765,23 +1644,19 @@ function main() {
765
1644
  } else if (arg === '--') {
766
1645
  dirIsForced = true;
767
1646
  } else if (arg.startsWith('-')) {
768
- if (arg === '--new') {
769
- console.error("Error: '--new' is no longer a flag. Use 'ocmux serve' instead.");
770
- } else if (arg === '--kill') {
771
- console.error("Error: '--kill' is no longer a flag. Use 'ocmux kill' instead.");
772
- } else {
773
- console.error(`Error: unknown option '${arg}'`);
774
- console.error(`Run '${SCRIPT} -h' for help.`);
775
- }
1647
+ console.error(`Error: unknown option '${arg}'`);
1648
+ console.error(`Run '${SCRIPT} -h' for help.`);
776
1649
  process.exit(1);
777
1650
  } else {
778
1651
  positional.push(arg);
779
1652
  }
780
1653
  }
781
1654
 
1655
+ // Handle --server <url> (inline-consumed in the loop above; nothing here).
1656
+
782
1657
  let subcommand = null;
783
1658
  let targetDir = null;
784
- let modelRef = null;
1659
+ let directRef = null;
785
1660
 
786
1661
  for (const arg of positional) {
787
1662
  if (dirIsForced) {
@@ -791,15 +1666,16 @@ function main() {
791
1666
  process.exit(1);
792
1667
  }
793
1668
  targetDir = resolveDir(arg);
794
- } else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list' || arg === 'resurrect' || arg === 'switch' || arg === 'model') {
1669
+ } else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list'
1670
+ || arg === 'resurrect' || arg === 'session'
1671
+ || arg === 'migrate') {
795
1672
  if (subcommand) {
796
1673
  console.error(`Error: multiple subcommands specified: '${subcommand}' and '${arg}'`);
797
1674
  process.exit(1);
798
1675
  }
799
1676
  subcommand = arg;
800
- } else if (subcommand === 'model' && modelRef === null) {
801
- // model reference (supports partial matches) — not a directory
802
- modelRef = arg;
1677
+ } else if (subcommand === 'session' && directRef === null) {
1678
+ directRef = arg;
803
1679
  } else {
804
1680
  if (targetDir) {
805
1681
  console.error(`Error: unexpected argument '${arg}'`);
@@ -811,148 +1687,68 @@ function main() {
811
1687
 
812
1688
  if (!targetDir) targetDir = process.cwd();
813
1689
 
814
- // Dispatch
815
- if (lastMode) {
816
- if (gitMode) {
817
- console.error("Error: '--last' cannot be combined with '--git' or '--GIT'.");
818
- process.exit(1);
819
- }
820
- if (printLogs) {
821
- console.error("Error: '--last' cannot be combined with '--print-logs'.");
822
- process.exit(1);
823
- }
824
- if (subcommand) {
825
- console.error(`Error: '--last' cannot be combined with subcommand '${subcommand}'.`);
826
- process.exit(1);
827
- }
828
- if (targetDir !== process.cwd()) {
829
- console.error("Error: '--last' does not accept a directory path.");
830
- process.exit(1);
831
- }
832
- ensureSession();
833
- const url = getActiveServer();
834
- if (!url) {
835
- console.error('Error: no active opencode server found in tmux.');
836
- console.error("Run 'ocmux serve' to create a server, or switch to an existing one.");
837
- process.exit(1);
838
- }
839
- outputUrl(url, null, longMode);
840
- return;
841
- }
1690
+ // Dispatch. Each branch may throw synchronously (EXIT) for validation errors
1691
+ // or return a promise for async commands; main() returns the settled promise
1692
+ // so tests can await it.
1693
+ let p = Promise.resolve();
842
1694
 
843
1695
  if (subcommand === 'list') {
844
1696
  if (gitMode) {
845
1697
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
846
1698
  process.exit(1);
847
1699
  }
848
- if (printLogs) {
849
- console.error("Error: '--print-logs' is only valid with 'serve'.");
850
- process.exit(1);
851
- }
852
1700
  if (targetDir !== process.cwd()) {
853
1701
  console.error("Error: 'list' does not accept a directory path.");
854
1702
  process.exit(1);
855
1703
  }
856
- cmdList();
857
- } else if (subcommand === 'switch') {
858
- if (gitMode) {
859
- console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
860
- process.exit(1);
861
- }
862
- if (printLogs) {
863
- console.error("Error: '--print-logs' is only valid with 'serve'.");
864
- process.exit(1);
865
- }
866
- if (targetDir !== process.cwd()) {
867
- console.error("Error: 'switch' does not accept a directory path.");
868
- process.exit(1);
869
- }
870
1704
  ensureSession();
871
- cmdSwitchMenu(longMode);
872
- } else if (subcommand === 'model') {
1705
+ cmdList(longMode);
1706
+ } else if (subcommand === 'session') {
873
1707
  if (gitMode) {
874
1708
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
875
1709
  process.exit(1);
876
1710
  }
877
- if (printLogs) {
878
- console.error("Error: '--print-logs' is only valid with 'serve'.");
879
- process.exit(1);
880
- }
881
- if (targetDir !== process.cwd()) {
882
- console.error("Error: 'model' does not accept a directory path.");
883
- process.exit(1);
884
- }
885
- // cmdModel is async; swallow the (test-mocked) process.exit throw.
886
- cmdModel(modelRef, longMode).catch((err) => {
887
- if (!err || !err.message || !err.message.startsWith('EXIT:')) {
888
- console.error(err ? err.message : String(err));
889
- process.exit(1);
890
- }
891
- });
1711
+ p = cmdSessionSwitch(targetDir, directRef);
892
1712
  } else if (subcommand === 'kill') {
893
1713
  if (gitMode) {
894
1714
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
895
1715
  process.exit(1);
896
1716
  }
897
- if (printLogs) {
898
- console.error("Error: '--print-logs' is only valid with 'serve'.");
899
- process.exit(1);
900
- }
901
- const sf = findParentStatefile(targetDir);
902
- if (!sf) {
903
- console.error(`Error: no opencode server found in ${targetDir} or any of its parents.`);
904
- console.error("Run 'ocmux serve' to create a new server.");
905
- process.exit(1);
906
- }
907
- cmdKill(sf);
1717
+ cmdKill(targetDir);
908
1718
  } else if (subcommand === 'resurrect') {
909
- if (gitMode) {
910
- console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
1719
+ p = cmdResurrect(targetDir);
1720
+ } else if (subcommand === 'migrate') {
1721
+ if (targetDir !== process.cwd()) {
1722
+ console.error("Error: 'migrate' does not accept a directory path.");
911
1723
  process.exit(1);
912
1724
  }
913
- cmdResurrect(targetDir, longMode, printLogs);
1725
+ cmdMigrate();
914
1726
  } else if (subcommand === 'serve' || subcommand === 'new') {
915
1727
  if (gitMode) {
916
1728
  const flagLabel = gitMode === 'root' ? '--GIT' : '--git';
917
1729
  targetDir = resolveGitDir(targetDir, gitMode === 'root', flagLabel);
918
1730
  }
919
- cmdNew(targetDir, longMode, printLogs);
1731
+ p = cmdServeNew(targetDir, serverArg, force);
920
1732
  } else {
1733
+ // Default: session switcher for the current project.
921
1734
  if (gitMode) {
922
1735
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
923
1736
  process.exit(1);
924
1737
  }
925
- if (printLogs) {
926
- console.error("Error: '--print-logs' is only valid with 'serve'.");
927
- process.exit(1);
928
- }
929
- // Default mode — find an existing server and switch to it
930
- let sf = findParentStatefile(targetDir);
931
- if (!sf) {
932
- console.log(`Error: no opencode server found in ${targetDir} or any of its parents.`);
933
- console.error("Run 'ocmux serve' to create a new server.");
934
- process.exit(1);
935
- }
936
-
937
- // Skip stale state files whose tmux window no longer exists,
938
- // and search from the parent directory instead.
939
- const state = readState(sf);
940
- if (state && state.window_index != null) {
941
- const serverDir = path.dirname(sf);
942
- const actualName = windowNameByIndex(state.window_index);
943
- if (actualName !== serverDir && windowByDir(serverDir) == null) {
944
- const parentSf = findParentStatefile(path.dirname(serverDir));
945
- if (parentSf) sf = parentSf;
946
- }
947
- }
948
-
949
- ensureSession();
950
- cmdSwitch(sf, longMode);
1738
+ p = cmdSessionSwitch(targetDir, null);
951
1739
  }
1740
+
1741
+ return p.catch((err) => {
1742
+ // Already exiting (a command called process.exit — the test stub throws
1743
+ // EXIT:code). Swallow so the chain settles cleanly.
1744
+ if (err && err.message && err.message.startsWith('EXIT:')) return;
1745
+ console.error(err ? err.message : String(err));
1746
+ process.exit(1);
1747
+ });
952
1748
  }
953
1749
 
954
1750
  if (require.main === module) {
955
1751
  main();
956
1752
  }
957
1753
 
958
- module.exports = { main };
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 };