agentp 1.13.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ocmux CHANGED
@@ -3,29 +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,
25
+ tuiAttachCommand,
28
26
  } = require('../lib/ocmux');
27
+ const { listSessions, createSession, createSessionWithModel, updateSession, listModels, switchModel, resolveModelRef, getAuthHeaders, sortSessionsByRecency, deleteSession, listAgents, getActiveSessions } = require('../lib/opencode');
28
+ const projectState = require('../lib/project-state');
29
29
 
30
30
  // ---------------------------------------------------------------------------
31
31
  // Constants
@@ -35,6 +35,7 @@ const { version } = require('../package.json');
35
35
 
36
36
  const SCRIPT = path.basename(process.argv[1] || 'ocmux');
37
37
  const HOME = os.homedir();
38
+ const DEFAULT_SERVER = process.env.OCMUX_SERVER || 'http://localhost:4096';
38
39
 
39
40
  // ---------------------------------------------------------------------------
40
41
  // Helpers
@@ -92,17 +93,9 @@ function resolveGitDir(startDir, requireDir, flagLabel) {
92
93
  if (!result.dir) {
93
94
  const label = requireDir ? '.git directory' : '.git entry';
94
95
  console.error(`Error: no ${label} found in ${startDir} or any of its parents.`);
95
- 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.`);
96
97
  process.exit(1);
97
98
  }
98
-
99
- if (result.stateDir && result.stateDir !== result.dir) {
100
- console.error(`Error: found an existing ocmux server in ${result.stateDir}.`);
101
- console.error(`Refusing to create a new server at ${result.dir} because it is above that session.`);
102
- console.error(`Move outside ${result.stateDir} or pass an explicit directory.`);
103
- process.exit(1);
104
- }
105
-
106
99
  return result.dir;
107
100
  }
108
101
 
@@ -135,446 +128,1147 @@ function resolveDir(arg) {
135
128
  process.exit(1);
136
129
  }
137
130
 
138
- function ensureTuiZoomed(windowIndex) {
139
- const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}|#{window_zoomed_flag}']);
140
- if (r.status !== 0) return;
141
- for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
142
- const [wi, zoomed] = line.split('|');
143
- if (parseInt(wi, 10) === windowIndex && zoomed === '1') return;
144
- }
145
- const paneId = tuiPaneId(windowIndex);
146
- if (paneId) tmux(['resize-pane', '-Z', '-t', paneId]);
147
- }
148
-
149
-
150
-
151
- function outputUrl(url, dir, longMode) {
152
- if (longMode) {
153
- console.log(`${url} → ${dir}`);
154
- } else {
155
- console.log(url);
156
- }
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
+ });
157
155
  }
158
156
 
159
- function printHelp() {
160
- console.error(`Usage: ${SCRIPT} [-l] [<subcommand>] [<directory>]`);
161
- console.error('');
162
- console.error('Manage opencode server + TUI in tmux for project directories.');
163
- console.error('');
164
- console.error('Without arguments, searches upward from <directory> (default: $PWD)');
165
- console.error("for .ocmux.json and switches to that server's tmux window.");
166
- console.error('If <directory> is not a valid path, ocmux will match it against the');
167
- console.error("basenames of existing sessions' directories. If exactly one matches,");
168
- console.error('that session is used.');
169
- console.error('');
170
- console.error('Subcommands:');
171
- console.error(' serve Create a server in <directory> (default: $PWD) and attach a TUI.');
172
- console.error(' Aliased as "new" for backwards compatibility.');
173
- console.error(' Errors if one already exists there.');
174
- console.error(' Warns if a parent directory already has a server.');
175
- console.error(' --git Use the nearest parent with a .git entry (file or dir).');
176
- console.error(' --GIT Use the nearest parent with a .git directory only.');
177
- console.error(' Refuses to create above an existing .ocmux.json found while searching.');
178
- console.error(' --print-logs Pass --print-logs to opencode serve (prints server logs to stderr).');
179
- console.error(' kill Kill the server found upward from <directory>.');
180
- console.error(' Removes its tmux window and state file.');
181
- console.error(' resurrect Recover a dead server: kills the old window, removes the state file,');
182
- console.error(' then creates a fresh server + TUI in the same directory.');
183
- console.error(' Works even if no tmux window exists (stale state file).');
184
- console.error(' --print-logs Pass --print-logs to the new opencode serve.');
185
- console.error(' list List all running servers with their directories and URLs.');
186
- console.error(' switch Interactively select a server to switch to.');
187
- console.error(' j/k or arrows to move, Enter/Space selects (menu stays open),');
188
- console.error(' q or Ctrl+C quits. Prints the URL of the last selected server on exit.');
189
- console.error('');
190
- console.error(' -- Treat the next argument as a directory even if it matches');
191
- console.error(' a subcommand name (e.g. "ocmux -- kill").');
192
- console.error('');
193
- console.error('Options:');
194
- console.error(' -l Long output: append " → <dir>" after the URL (serve/default).');
195
- console.error(' --last Print the URL of the active tmux window (last used server).');
196
- console.error(' --print-logs Pass --print-logs to opencode serve (prints server logs to stderr).');
197
- console.error(' --version Show version');
198
- console.error(' -h Show this help');
199
- console.error('');
200
- console.error('See also: agentp -- pipe prompts into OpenCode from stdin');
201
- console.error(' tgagentp -- Telegram bridge for OpenCode (tgagentp --help)');
202
- process.exit(0);
157
+ function newestSessionId(sessions) {
158
+ if (!sessions || sessions.length === 0) return null;
159
+ const sorted = sortSessionsByRecency(sessions).filter(s => s && s.id);
160
+ return sorted.length > 0 ? sorted[0].id : null;
203
161
  }
204
162
 
205
163
  // ---------------------------------------------------------------------------
206
- // Subcommand: default — switch to an existing server
164
+ // Shared list rendering (scrollable viewport + resize-aware)
207
165
  // ---------------------------------------------------------------------------
208
166
 
209
- function cmdSwitch(statefile, longMode) {
210
- const serverDir = path.dirname(statefile);
211
- const state = readState(statefile);
167
+ // Visible window for a list rendered in `height` rows around `cursor`.
168
+ // Pure (unit-tested). Returns { start, count }.
169
+ function windowFor(len, cursor, height) {
170
+ if (len <= 0 || height <= 0) return { start: 0, count: 0 };
171
+ const count = Math.min(len, height);
172
+ if (len <= count) return { start: 0, count };
173
+ let start = cursor - Math.floor(count / 2);
174
+ start = Math.max(0, Math.min(start, len - count));
175
+ return { start, count };
176
+ }
212
177
 
213
- if (!state || !state.url || state.window_index == null) {
214
- console.error(`Error: invalid state file ${statefile}`);
215
- process.exit(1);
178
+ // ── Small formatting / layout helpers ──────────────────────────────
179
+ function centerIn(s, w) {
180
+ const str = String(s);
181
+ if (str.length >= w) return str.slice(0, w);
182
+ const left = Math.floor((w - str.length) / 2);
183
+ return ' '.repeat(left) + str + ' '.repeat(w - str.length - left);
184
+ }
185
+ function padLeft(s, w) {
186
+ const str = String(s);
187
+ return ' '.repeat(Math.max(0, w - str.length)) + str;
188
+ }
189
+ // Last-view stamp: HH:MM within the last 24h, else DD/MM/YYYY; '--:--' if unknown.
190
+ function fmtClock(ts) {
191
+ if (!ts) return '--:--';
192
+ const d = new Date(ts);
193
+ if (Date.now() - ts < 24 * 3600 * 1000) {
194
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
216
195
  }
196
+ return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
197
+ }
198
+ function fmtDuration(ms) {
199
+ const s = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
200
+ const h = Math.floor(s / 3600);
201
+ const m = Math.floor((s % 3600) / 60);
202
+ const sec = s % 60;
203
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
204
+ }
205
+ function fmtTokens(n) {
206
+ if (n == null) return null;
207
+ const v = Number(n);
208
+ if (!Number.isFinite(v)) return null;
209
+ if (v >= 1e9) return (v / 1e9).toFixed(1) + 'B';
210
+ if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M';
211
+ if (v >= 1e3) return Math.round(v / 1e3) + 'K';
212
+ return String(v);
213
+ }
214
+ function fmtCost(c) {
215
+ if (c == null) return null;
216
+ const v = Number(c);
217
+ return Number.isFinite(v) ? '$' + v.toFixed(4) : null;
218
+ }
217
219
 
218
- let { url, window_index: winIdx } = state;
219
-
220
- const actualName = windowNameByIndex(winIdx);
221
- if (actualName !== serverDir) {
222
- const found = windowByDir(serverDir);
223
- if (found == null) {
224
- console.error(`Error: tmux window for ${serverDir} not found`);
225
- console.error("Run 'ocmux serve' to create a new server.");
226
- process.exit(1);
227
- }
228
- winIdx = found;
229
- }
230
-
231
- pinWindowName(winIdx);
232
-
233
- const tuiPane = tuiPaneId(winIdx);
234
- if (!tuiPane) {
235
- console.error('TUI pane is dead, restarting...');
236
- const count = paneCount(winIdx);
237
- if (count >= 2) {
238
- const panesR = tmux(['list-panes', '-t', `${SESSION}:${winIdx}`, '-F', '#{pane_id}']);
239
- const panes = panesR.stdout.trim().split('\n').filter(Boolean);
240
- const lastPane = panes[panes.length - 1];
241
- const respawnR = tmux(['respawn-pane', '-k', '-t', lastPane, `opencode attach --continue '${url}'`]);
242
- if (respawnR.status !== 0) {
243
- tmux(['send-keys', '-t', lastPane, `opencode attach --continue '${url}'`, 'Enter']);
244
- }
245
- } else {
246
- tmux(['split-window', '-v', '-t', `${SESSION}:${winIdx}`, '-c', serverDir,
247
- `opencode attach --continue '${url}'`]);
248
- }
220
+ // Lay cells out in a responsive grid (row-major), collapsing columns when narrow.
221
+ // A cell is a plain string or { label, value } (label rendered bold).
222
+ function cellPlain(c) {
223
+ return (c && typeof c === 'object') ? `${c.label}: ${c.value}` : String(c);
224
+ }
225
+ function cellRender(c, width, labelColor = '\x1b[1m') {
226
+ if (c && typeof c === 'object') {
227
+ const label = `${c.label}:`;
228
+ const avail = Math.max(0, width - label.length - 1);
229
+ let value = String(c.value == null ? '' : c.value);
230
+ if (value.length > avail) value = value.slice(0, avail);
231
+ const plainLen = label.length + 1 + value.length;
232
+ return `${labelColor}${label}\x1b[0m ${value}` + ' '.repeat(Math.max(0, width - plainLen));
249
233
  }
250
-
251
- const activeWin = activeWindowIndex();
252
- if (activeWin !== winIdx) {
253
- tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
234
+ return String(c) + ' '.repeat(Math.max(0, width - String(c).length));
235
+ }
236
+ function layoutCells(cells, cols, minCell = 26, labelColor = '\x1b[1m') {
237
+ if (!cells || cells.length === 0) return [];
238
+ const plains = cells.map(cellPlain);
239
+ let nCols = Math.max(1, Math.min(cells.length, Math.floor(Math.max(cols, 1) / minCell)));
240
+ const colWidths = (nc) => {
241
+ const w = new Array(nc).fill(0);
242
+ plains.forEach((p, i) => { w[i % nc] = Math.max(w[i % nc], p.length); });
243
+ return w;
244
+ };
245
+ let widths = colWidths(nCols);
246
+ while (nCols > 1 && widths.reduce((a, b) => a + b, 0) + 2 * (nCols - 1) > cols) {
247
+ nCols--;
248
+ widths = colWidths(nCols);
254
249
  }
255
-
256
- ensureTuiZoomed(winIdx);
257
- outputUrl(url, serverDir, longMode);
250
+ const nRows = Math.ceil(cells.length / nCols);
251
+ const lines = [];
252
+ for (let r = 0; r < nRows; r++) {
253
+ const parts = [];
254
+ for (let c = 0; c < nCols; c++) {
255
+ const idx = r * nCols + c;
256
+ if (idx >= cells.length) break;
257
+ parts.push(cellRender(cells[idx], widths[c], labelColor));
258
+ }
259
+ lines.push(parts.join(' ').trimEnd());
260
+ }
261
+ return lines;
258
262
  }
259
263
 
260
- // ---------------------------------------------------------------------------
261
- // Subcommand: switch — interactive session selection
262
- // ---------------------------------------------------------------------------
264
+ // Bold a label and append a (truncated) value, fitting `cols`.
265
+ function labeledLine(label, value, cols, labelColor = '\x1b[1m') {
266
+ const lbl = `${label}:`;
267
+ const avail = Math.max(0, cols - lbl.length - 1);
268
+ let v = String(value == null ? '' : value);
269
+ if (v.length > avail) v = v.slice(0, avail);
270
+ return `${labelColor}${lbl}\x1b[0m ${v}`;
271
+ }
263
272
 
264
- function cmdSwitchMenu(longMode) {
265
- const rows = listServers();
266
- if (rows.length === 0) {
267
- console.error('No opencode servers running.');
268
- process.exit(1);
273
+ // Info lines about the selected session: title + location (bold labels) and a
274
+ // responsive grid. Fields are ALWAYS present (placeholders when unknown).
275
+ // When `highlight` is true (the cursor is on the current session), labels are
276
+ // yellow instead of white.
277
+ function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false) {
278
+ if (!s) return [];
279
+ const labelColor = highlight ? '\x1b[1;33m' : '\x1b[1m';
280
+ const title = (s.title && s.title.trim()) || 'Untitled';
281
+ const location = (s.location && s.location.directory) || 'Unknown';
282
+ const out = [
283
+ labeledLine('Title', title, cols, labelColor),
284
+ labeledLine('Location', location, cols, labelColor),
285
+ ];
286
+
287
+ const model = s.model ? `${s.model.providerID}/${s.model.id}` : null;
288
+ const modelLabel = model
289
+ ? model + (s.model.variant && s.model.variant !== 'default' ? '#' + s.model.variant : '')
290
+ : 'Unknown';
291
+ const busy = !!(runningIds && runningIds.has(s.id));
292
+ const since = busy
293
+ ? (s.time && (s.time.updated || s.time.created))
294
+ : (s.time && (s.time.idle || s.time.updated || s.time.created));
295
+ const status = `${busy ? 'BUSY' : 'IDLE'}${since ? ' ' + fmtDuration(Date.now() - since) : ' --:--:--'}`;
296
+
297
+ let tokensTxt = '--';
298
+ if (s.tokens) {
299
+ const t = s.tokens;
300
+ const total = (t.input || 0) + (t.output || 0) + (t.reasoning || 0);
301
+ tokensTxt = fmtTokens(total) || '--';
302
+ if (t.cache && t.cache.read) tokensTxt += ' (+' + fmtTokens(t.cache.read) + ' cache)';
269
303
  }
270
- if (!process.stdin.isTTY) {
271
- console.error('Error: "switch" is interactive and requires a TTY.');
272
- console.error('Use "ocmux list" to list servers, or run "ocmux <dir>" to switch directly.');
273
- process.exit(1);
304
+ const cost = fmtCost(s.cost) || '--';
305
+ const ctx = (model && modelContext && modelContext.get(model)) ? fmtTokens(modelContext.get(model)) : '--';
306
+
307
+ const cells = [
308
+ { label: 'Model', value: modelLabel },
309
+ { label: 'Agent', value: s.agent || 'Unknown' },
310
+ { label: 'Status', value: status },
311
+ { label: 'Tokens', value: tokensTxt },
312
+ { label: 'Cost', value: cost },
313
+ { label: 'Ctx', value: ctx },
314
+ { label: 'Outcome', value: s.outcome || 'Unknown' },
315
+ ];
316
+ return out.concat(layoutCells(cells, cols, 26, labelColor));
317
+ }
318
+
319
+ // Render a scrollable list into the alternate screen.
320
+ // heading -> centered + bold + inverted heading
321
+ // row(index, item) -> string | { text, reverse }
322
+ // extraLines -> prompt/confirm lines right after the list (normal colors)
323
+ // footer -> inverted, RIGHT-aligned key-hint bar (acts as a separator)
324
+ // infoLines -> details below the footer (normal colors, bold labels)
325
+ // A "(start-end/total)" range is appended to the heading when scrolling.
326
+ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLines = [], cols: colsArg, rows: rowsArg }) {
327
+ const cols = colsArg || process.stdout.columns || 80;
328
+ const termRows = rowsArg || process.stdout.rows || 24;
329
+ const extras = (extraLines || []).filter((l) => l != null && l !== '');
330
+ const infos = (infoLines || []).filter((l) => l != null && l !== '');
331
+
332
+ // Chrome: heading + blank, extras, footer bar, infos.
333
+ const reserved = 2 + extras.length + 1 + infos.length;
334
+ const viewHeight = Math.max(1, termRows - reserved);
335
+ const cursorIdx = items.length ? Math.max(0, Math.min(cursor, items.length - 1)) : 0;
336
+ const win = windowFor(items.length, cursorIdx, viewHeight);
337
+
338
+ let titleLine = title;
339
+ if (items.length > win.count) {
340
+ titleLine += ` (${win.start + 1}-${win.start + win.count}/${items.length})`;
274
341
  }
275
342
 
276
- const cols = process.stderr.columns || 80;
277
- const nameW = Math.max(...rows.map(r => path.basename(r.dir).length), 4);
278
- const stsW = Math.max(...rows.map(r => r.status.length), 6);
279
- const urlW = Math.max(...rows.map(r => r.url.length), 3);
280
- const dirW = Math.max(...rows.map(r => r.dir.length), 9);
281
- const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
282
- const lineWidth = Math.max(cols, nameW + 2 + stsW + 2 + urlW + 2 + dirW);
283
-
284
- let cursor = 0;
285
- let activeRow = 0;
286
- let lastUrl = null;
287
- let lastDir = null;
288
- let exited = false;
289
-
290
- // Query tmux for the currently active window and map it to a row. Called at
291
- // init and on every draw, so the highlight always reflects tmux's real state.
292
- const refreshActive = () => {
293
- const idx = activeWindowIndex();
294
- if (idx == null) return;
295
- for (let i = 0; i < rows.length; i++) {
296
- if (rows[i].index === idx) { activeRow = i; return; }
343
+ let out = '\x1b[2J\x1b[H';
344
+ out += '\x1b[1;7m' + centerIn(titleLine, cols) + '\x1b[0m\n\n';
345
+
346
+ let rendered = 0;
347
+ if (items.length === 0) {
348
+ out += ' (none)\n';
349
+ rendered = 1;
350
+ } else {
351
+ for (let i = 0; i < win.count; i++) {
352
+ const idx = win.start + i;
353
+ const r = row(idx, items[idx]);
354
+ const text = r && typeof r === 'object' ? r.text : r;
355
+ const reverse = r && typeof r === 'object' && r.reverse;
356
+ const line = `${idx === cursor ? '▶ ' : ' '}${text == null ? '' : text}`;
357
+ out += (reverse ? `\x1b[7m${line}\x1b[0m` : line) + '\n';
358
+ rendered++;
297
359
  }
298
- };
360
+ }
361
+ // Pad the list area so the footer stays pinned to the bottom of the terminal
362
+ // (instead of floating right under the last row on a tall window).
363
+ for (let i = rendered; i < viewHeight; i++) out += '\n';
364
+ for (const l of extras) out += l + '\n';
365
+ const hint = footer.length > cols ? footer.slice(0, cols) : padLeft(footer, cols);
366
+ out += '\x1b[7m' + hint + '\x1b[0m\n';
367
+ for (const l of infos) out += l + '\n';
368
+ return out.replace(/\n$/, '');
369
+ }
299
370
 
300
- const cleanup = () => {
301
- process.stderr.write('\x1b[?25h');
302
- process.stderr.write('\x1b[?1049l');
303
- try { process.stdin.setRawMode(false); } catch {}
304
- };
371
+ // Render an interactive session picker (alt screen). Sorted most-recently-viewed
372
+ // first by the caller. Keys: Enter/Space switch (menu stays open), n create,
373
+ // r rename, d delete (confirm), a annotate, p switch project, h help,
374
+ // q/Ctrl+C quit. Input mode offers readline-style caret editing.
375
+ // Resolves on quit (null) or with the string 'switch-project' (caller then runs
376
+ // the project switcher and re-invokes this picker for the new project).
377
+ function sessionMenu(sessions, currentSession, annotations, server, { onPick, onRename, onDelete, onReminder, runningIds, modelContext } = {}) {
378
+ return new Promise((resolve) => {
379
+ if (!process.stdin.isTTY) return resolve(null);
380
+
381
+ let rows = [...sessions];
382
+ let current = currentSession;
383
+ let cursor = 0;
384
+ let mode = 'normal'; // 'normal' | 'help' | 'confirm' | 'input'
385
+ let confirmDel = null;
386
+ let input = null; // { label, value, kind, caret }
387
+ let list = null; // { title, items, format, onEnter }
388
+ let listCursor = 0;
389
+ let done = false;
390
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
391
+ let spinIdx = 0;
392
+ let spinTimer = null;
393
+
394
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
395
+ const helpLines = [
396
+ 'Enter / Space switch to the selected session (stays open)',
397
+ 'n create a new session (type a name, Enter; blank = auto)',
398
+ 'r rename the selected session (edit in place; ESC cancels)',
399
+ 'R (Shift+r) set a REMINDER for the selected session (sent in front of',
400
+ ' every agentp prompt; blank clears)',
401
+ 'd delete the selected session (then y/N to confirm)',
402
+ 'a switch the AGENT of the selected session',
403
+ 'm switch the MODEL of the selected session',
404
+ 'p open the project switcher',
405
+ 'j / k, arrows move the cursor',
406
+ 'h toggle this help',
407
+ 'q / Ctrl+C quit',
408
+ '',
409
+ 'While typing (n/r/R):',
410
+ ' left/right, Home/End move the caret',
411
+ ' Backspace / Delete edit in place',
412
+ ' Enter confirm, ESC cancels',
413
+ ];
414
+
415
+ const cleanup = () => {
416
+ process.stderr.write('\x1b[?25h');
417
+ process.stderr.write('\x1b[?1049l');
418
+ if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
419
+ process.stdout.removeListener('resize', draw);
420
+ try { process.stdin.setRawMode(false); } catch {}
421
+ process.stdin.pause();
422
+ };
423
+ const finish = (value) => {
424
+ if (done) return;
425
+ done = true;
426
+ cleanup();
427
+ process.stdin.removeAllListeners('keypress');
428
+ process.removeListener('SIGINT', onSig);
429
+ resolve(value);
430
+ };
431
+ const onSig = () => finish(null);
432
+ process.on('SIGINT', onSig);
433
+
434
+ const renderInputValue = (v) => {
435
+ const c = input.caret;
436
+ return v.slice(0, c) + '▏' + v.slice(c);
437
+ };
438
+
439
+ const draw = () => {
440
+ if (mode === 'help') {
441
+ let out = '\x1b[2J\x1b[H';
442
+ out += 'ocmux session picker — help\n\n';
443
+ for (const l of helpLines) out += ' ' + l + '\n';
444
+ out += '\nPress any key to return.\n';
445
+ process.stderr.write(out);
446
+ return;
447
+ }
448
+ if (mode === 'list' && list) {
449
+ process.stderr.write(renderList({
450
+ title: `ocmux — ${list.title}`,
451
+ items: list.items,
452
+ cursor: listCursor,
453
+ row: (i, item) => list.format(item) || '',
454
+ footer: 'Enter: select · ESC: back',
455
+ }));
456
+ return;
457
+ }
458
+ const cols = process.stdout.columns || 80;
459
+ const showTime = cols >= 48;
460
+ const TIME_W = 10; // dd/mm/yyyy worst case
461
+ const maxLen = Math.min(
462
+ Math.max(...rows.map(s => (s.title || s.id || '').length), 8) + 6,
463
+ Math.max(8, cols - (showTime ? TIME_W + 10 : 8)),
464
+ );
465
+ const extras = [];
466
+ if (mode === 'confirm' && confirmDel) {
467
+ extras.push(`Delete session "${confirmDel.title || confirmDel.id || ''}"? (y/N) `);
468
+ } else if (mode === 'input' && input) {
469
+ extras.push(`${input.label} ${renderInputValue(input.value)}`);
470
+ }
471
+ const selected = rows[cursor];
472
+ const selIsCurrent = !!(selected && String(selected.id) === String(current));
473
+ const infoLines = sessionInfoLines(selected, runningIds, modelContext, cols, selIsCurrent);
474
+ process.stderr.write(renderList({
475
+ title: 'ocmux — sessions',
476
+ items: rows,
477
+ cursor,
478
+ row: (i, s) => {
479
+ const title = (s.title || s.id || '').slice(0, maxLen);
480
+ const timeStr = showTime ? padLeft(fmtClock(s.time && s.time.viewed), TIME_W) + ' ' : '';
481
+ const mark = String(s.id) === String(current) ? ' *' : '';
482
+ const hasNote = annotations && annotations[s.id] ? ' ◈' : '';
483
+ const spin = (runningIds && runningIds.has(s.id)) ? SPINNER[spinIdx % SPINNER.length] + ' ' : ' ';
484
+ const text = `${spin}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
485
+ // Yellow for the session currently selected in the TUI window.
486
+ return String(s.id) === String(current) ? `\x1b[33m${text}\x1b[0m` : text;
487
+ },
488
+ footer: 'Enter: switch · h: help · q: quit',
489
+ infoLines,
490
+ extraLines: extras,
491
+ }));
492
+ };
493
+
494
+ const startInput = (label, value, kind) => {
495
+ input = { label, value, kind, caret: value.length };
496
+ mode = 'input';
497
+ draw();
498
+ };
499
+
500
+ const applyRenameWith = (name) => {
501
+ const row = rows[cursor];
502
+ if (!row || !onRename || !name) return;
503
+ Promise.resolve(onRename(row, name)).then((updated) => {
504
+ row.title = (updated && updated.title) || name;
505
+ draw();
506
+ }).catch((err) => {
507
+ process.stderr.write(`\nError: ${err.message}\n`);
508
+ draw();
509
+ });
510
+ };
511
+
512
+ const applyReminder = (text) => {
513
+ const row = rows[cursor];
514
+ if (!row || !onReminder) return;
515
+ Promise.resolve(onReminder(row, text)).then((saved) => {
516
+ if (saved) annotations[row.id] = saved;
517
+ else delete annotations[row.id];
518
+ draw();
519
+ }).catch((err) => {
520
+ process.stderr.write(`\nError: ${err.message}\n`);
521
+ draw();
522
+ });
523
+ };
524
+
525
+ const applyPick = (row) => {
526
+ Promise.resolve(onPick ? onPick(row) : null).then((created) => {
527
+ if (row.new && created) {
528
+ rows.unshift(created);
529
+ current = created.id;
530
+ cursor = 0;
531
+ } else if (!row.new) {
532
+ current = row.id;
533
+ }
534
+ confirmDel = null;
535
+ draw();
536
+ }).catch((err) => {
537
+ process.stderr.write(`\nError: ${err.message}\n`);
538
+ confirmDel = null;
539
+ draw();
540
+ });
541
+ };
542
+
543
+ const applyDelete = () => {
544
+ const s = confirmDel;
545
+ if (!s || !onDelete) return;
546
+ Promise.resolve(onDelete(s)).then(() => {
547
+ rows = rows.filter(r => r.id !== s.id);
548
+ if (cursor >= rows.length && cursor > 0) cursor = rows.length - 1;
549
+ confirmDel = null;
550
+ mode = 'normal';
551
+ draw();
552
+ }).catch((err) => {
553
+ process.stderr.write(`\nError: ${err.message}\n`);
554
+ confirmDel = null;
555
+ mode = 'normal';
556
+ draw();
557
+ });
558
+ };
559
+
560
+ const openList = (title, items, format, onEnter) => {
561
+ if (!items || items.length === 0) {
562
+ process.stderr.write(`\nNo ${title.toLowerCase()} available.\n`);
563
+ draw();
564
+ return;
565
+ }
566
+ list = { title, items, format, onEnter };
567
+ listCursor = 0;
568
+ mode = 'list';
569
+ draw();
570
+ };
305
571
 
306
- const finish = () => {
307
- if (exited) return;
308
- exited = true;
309
- cleanup();
310
- process.stdin.pause();
311
- if (lastUrl) outputUrl(lastUrl, lastDir, longMode);
312
- process.exit(0);
313
- };
572
+ const commandErr = (err) => {
573
+ process.stderr.write(`\nError: ${err.message}\n`);
574
+ draw();
575
+ };
314
576
 
315
- const draw = () => {
316
- refreshActive();
317
- let out = '\x1b[2J\x1b[H';
318
- out += 'ocmux switch — j/k or arrows: move, Enter/Space: select (keep switching), q/Ctrl+C: quit\n\n';
319
- for (let i = 0; i < rows.length; i++) {
320
- const row = rows[i];
321
- const marker = i === cursor ? '▶ ' : ' ';
322
- const line = `${marker}${pad(path.basename(row.dir), nameW)} ${pad(row.status, stsW)} ${pad(row.url, urlW)} ${pad(row.dir, dirW)}`;
323
- if (i === activeRow) {
324
- out += '\x1b[7m' + pad(line, lineWidth) + '\x1b[0m\n';
325
- } else {
326
- out += line + '\n';
577
+ process.stdin.on('keypress', (str, key) => {
578
+ if (mode === 'help') {
579
+ mode = 'normal';
580
+ draw();
581
+ return;
327
582
  }
328
- }
329
- process.stderr.write(out);
330
- };
583
+ if (mode === 'confirm') {
584
+ if (key.name === 'y') return applyDelete();
585
+ if (key.name === 'n' || key.name === 'escape' || key.name === 'q' || key.name === 'return') {
586
+ confirmDel = null;
587
+ mode = 'normal';
588
+ draw();
589
+ }
590
+ return;
591
+ }
592
+ if (mode === 'input') {
593
+ if (key.name === 'escape') {
594
+ input = null;
595
+ mode = 'normal';
596
+ draw();
597
+ } else if (key.name === 'return' || key.name === 'enter') {
598
+ const v = input.value;
599
+ const kind = input.kind;
600
+ input = null;
601
+ mode = 'normal';
602
+ draw();
603
+ if (kind === 'create') {
604
+ applyPick({ new: true, name: v.trim() });
605
+ } else if (kind === 'reminder') {
606
+ applyReminder(v.trim());
607
+ } else if (v.trim()) {
608
+ applyRenameWith(v.trim());
609
+ }
610
+ } else if (key.name === 'backspace') {
611
+ if (input.caret > 0) {
612
+ input.value = input.value.slice(0, input.caret - 1) + input.value.slice(input.caret);
613
+ input.caret--;
614
+ }
615
+ draw();
616
+ } else if (key.name === 'delete' || key.name === 'del') {
617
+ if (input.caret < input.value.length) {
618
+ input.value = input.value.slice(0, input.caret) + input.value.slice(input.caret + 1);
619
+ }
620
+ draw();
621
+ } else if (key.name === 'left' || str === '\u001b[D') {
622
+ input.caret = Math.max(0, input.caret - 1);
623
+ draw();
624
+ } else if (key.name === 'right' || str === '\u001b[C') {
625
+ input.caret = Math.min(input.value.length, input.caret + 1);
626
+ draw();
627
+ } else if (key.name === 'home') {
628
+ input.caret = 0;
629
+ draw();
630
+ } else if (key.name === 'end') {
631
+ input.caret = input.value.length;
632
+ draw();
633
+ } else if (str && !key.ctrl && !key.meta && str >= ' ') {
634
+ input.value = input.value.slice(0, input.caret) + str + input.value.slice(input.caret);
635
+ input.caret++;
636
+ draw();
637
+ }
638
+ return;
639
+ }
640
+ if (mode === 'list') {
641
+ if (key.name === 'escape' || key.name === 'q' || key.name === 'backspace') {
642
+ list = null;
643
+ mode = 'normal';
644
+ draw();
645
+ } else if (key.name === 'up' || str === 'k') {
646
+ listCursor = (listCursor - 1 + list.items.length) % list.items.length;
647
+ draw();
648
+ } else if (key.name === 'down' || str === 'j') {
649
+ listCursor = (listCursor + 1) % list.items.length;
650
+ draw();
651
+ } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
652
+ const item = list.items[listCursor];
653
+ const onEnter = list.onEnter;
654
+ list = null;
655
+ mode = 'normal';
656
+ draw();
657
+ Promise.resolve(onEnter(item)).then(() => draw()).catch(commandErr);
658
+ }
659
+ return;
660
+ }
661
+ if (key.ctrl && key.name === 'c') return finish(null);
662
+ if (key.name === 'q') return finish(null);
663
+ if (key.name === 'p') return finish('switch-project');
664
+ if (key.name === 'h') {
665
+ mode = 'help';
666
+ draw();
667
+ return;
668
+ }
669
+ if (key.name === 'n') {
670
+ startInput('New session name (blank for auto; ESC cancels):', '', 'create');
671
+ return;
672
+ }
673
+ // Shift+r → Reminder; lowercase r → Rename.
674
+ if (str === 'R') {
675
+ const row = rows[cursor];
676
+ const note = (row && annotations && annotations[row.id]) || '';
677
+ startInput('Reminder for this session (blank clears; ESC cancels):', note, 'reminder');
678
+ return;
679
+ }
680
+ if (key.name === 'r' && cursor < rows.length) {
681
+ startInput('Rename to (ESC cancels):', rows[cursor].title || '', 'rename');
682
+ return;
683
+ }
684
+ if (key.name === 'a') {
685
+ const target = rows[cursor];
686
+ if (target) {
687
+ listAgents(server).then((all) => {
688
+ const primary = all.filter(x => x.mode === 'primary' && !x.hidden);
689
+ const agents = primary.length ? primary : all;
690
+ openList('Agents', agents, (x) => `${x.name || x.id}${x.description ? ' — ' + x.description.slice(0, 40) : ''}`, (item) =>
691
+ updateSession(server, target.id, null, item.id));
692
+ }).catch(commandErr);
693
+ }
694
+ return;
695
+ }
696
+ if (key.name === 'm') {
697
+ const target = rows[cursor];
698
+ if (target) {
699
+ listModels(server).then((models) => {
700
+ openList('Models', models, (x) => `${x.label}${x.variant ? `#${x.variant}` : ''}`, (item) =>
701
+ switchModel(server, target.id, item));
702
+ }).catch(commandErr);
703
+ }
704
+ return;
705
+ }
706
+ if (key.name === 'd') {
707
+ if (cursor < rows.length) {
708
+ confirmDel = rows[cursor];
709
+ mode = 'confirm';
710
+ draw();
711
+ }
712
+ return;
713
+ }
714
+ if (rows.length > 0 && (key.name === 'up' || str === 'k')) {
715
+ cursor = (cursor - 1 + rows.length) % rows.length;
716
+ draw();
717
+ } else if (rows.length > 0 && (key.name === 'down' || str === 'j')) {
718
+ cursor = (cursor + 1) % rows.length;
719
+ draw();
720
+ } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
721
+ if (cursor < rows.length) applyPick(rows[cursor]);
722
+ }
723
+ });
331
724
 
332
- const activate = (i) => {
333
- const row = rows[i];
334
- try {
335
- activateServer(row.dir, row.index, row.url);
336
- lastUrl = row.url;
337
- lastDir = row.dir;
338
- } catch (err) {
339
- process.stderr.write(`\nError: ${err.message}\n`);
725
+ readline.emitKeypressEvents(process.stdin);
726
+ process.stdin.setRawMode(true);
727
+ process.stdin.resume();
728
+ process.stdout.on('resize', draw);
729
+ // Animate the spinner while any listed session is running.
730
+ if (rows.some(s => runningIds && runningIds.has(s.id))) {
731
+ spinTimer = setInterval(() => { spinIdx++; draw(); }, 120);
340
732
  }
733
+ process.stderr.write('\x1b[?1049h\x1b[?25l');
341
734
  draw();
342
- };
735
+ });
736
+ }
343
737
 
344
- process.stdin.on('keypress', (str, key) => {
345
- if (key.ctrl && key.name === 'c') return finish();
346
- if (key.name === 'q') return finish();
347
- if (key.name === 'up' || str === 'k') {
348
- cursor = (cursor + rows.length - 1) % rows.length;
349
- draw();
350
- } else if (key.name === 'down' || str === 'j') {
351
- cursor = (cursor + 1) % rows.length;
352
- draw();
353
- } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
354
- activate(cursor);
738
+ // Interactive project picker — purely visual/navigation, never writes state.
739
+ function switchMenu(rows, longMode) {
740
+ return new Promise((resolve) => {
741
+ if (!process.stdin.isTTY) {
742
+ console.error('Error: "switch" is interactive and requires a TTY.');
743
+ process.exit(1);
744
+ }
745
+ if (rows.length === 0) {
746
+ console.log('No opencode projects running.');
747
+ resolve(null);
748
+ return;
355
749
  }
356
- });
357
750
 
358
- process.on('SIGINT', finish);
359
- process.on('SIGTERM', finish);
751
+ const nameW = Math.max(...rows.map(r => path.basename(r.dir).length), 4);
752
+ const stsW = Math.max(...rows.map(r => r.status.length), 6);
753
+ const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
754
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
755
+ const cols = process.stderr.columns || 80;
756
+ const lineWidth = Math.max(cols, nameW + 2 + stsW + 2 + ssnW + 6);
757
+
758
+ let cursor = 0;
759
+ let activeRow = 0;
760
+ let lastDir = null;
761
+ let done = false;
762
+
763
+ const refreshActive = () => {
764
+ const idx = activeWindowIndex();
765
+ if (idx == null) return;
766
+ for (let i = 0; i < rows.length; i++) {
767
+ if (rows[i].index === idx) { activeRow = i; return; }
768
+ }
769
+ };
770
+
771
+ const cleanup = () => {
772
+ process.stderr.write('\x1b[?25h');
773
+ process.stderr.write('\x1b[?1049l');
774
+ process.stdout.removeListener('resize', draw);
775
+ try { process.stdin.setRawMode(false); } catch {}
776
+ };
777
+
778
+ const finish = () => {
779
+ if (done) return;
780
+ done = true;
781
+ cleanup();
782
+ process.stdin.pause();
783
+ resolve(lastDir);
784
+ };
785
+
786
+ const draw = () => {
787
+ refreshActive();
788
+ process.stderr.write(renderList({
789
+ title: 'ocmux — project switcher',
790
+ items: rows,
791
+ cursor,
792
+ row: (i, row) => {
793
+ const session = row.session ? row.session.slice(0, 14) : '(none)';
794
+ const text = `${pad(path.basename(row.dir), nameW)} ${pad(row.status, stsW)} ${pad(session, ssnW)} ${row.dir}`;
795
+ return i === activeRow ? { text, reverse: true } : text;
796
+ },
797
+ footer: 'Enter: focus · q: quit',
798
+ }));
799
+ };
800
+
801
+ const activate = (i) => {
802
+ const row = rows[i];
803
+ try {
804
+ if (row.server) {
805
+ activateProject(row.dir, row.index, row.server, row.session);
806
+ } else {
807
+ tmux(['select-window', '-t', `${SESSION}:${row.index}`]);
808
+ }
809
+ lastDir = row.dir;
810
+ } catch (err) {
811
+ process.stderr.write(`\nError: ${err.message}\n`);
812
+ }
813
+ draw();
814
+ };
815
+
816
+ const onSig = () => finish();
817
+ process.on('SIGINT', onSig);
818
+
819
+ process.stdin.on('keypress', (str, key) => {
820
+ if (key.ctrl && key.name === 'c') return finish();
821
+ if (key.name === 'q') return finish();
822
+ if (key.name === 'up' || str === 'k') {
823
+ cursor = (cursor + rows.length - 1) % rows.length;
824
+ draw();
825
+ } else if (key.name === 'down' || str === 'j') {
826
+ cursor = (cursor + 1) % rows.length;
827
+ draw();
828
+ } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
829
+ activate(cursor);
830
+ }
831
+ });
360
832
 
361
- readline.emitKeypressEvents(process.stdin);
362
- process.stdin.setRawMode(true);
363
- process.stdin.resume();
364
- process.stderr.write('\x1b[?1049h\x1b[?25l');
365
- refreshActive();
366
- cursor = activeRow;
367
- draw();
833
+ readline.emitKeypressEvents(process.stdin);
834
+ process.stdin.setRawMode(true);
835
+ process.stdin.resume();
836
+ process.stdout.on('resize', draw);
837
+ process.stderr.write('\x1b[?1049h\x1b[?25l');
838
+ draw();
839
+ });
368
840
  }
369
841
 
370
842
  // ---------------------------------------------------------------------------
371
- // Subcommand: list
843
+ // Subcommand: default — interactive session switcher for the current project
372
844
  // ---------------------------------------------------------------------------
373
845
 
374
- function cmdList() {
375
- const rows = listServers();
376
- if (rows.length === 0) {
377
- console.log('No opencode servers running.');
378
- return;
379
- }
846
+ async function cmdSessionSwitch(targetDir, directRef) {
847
+ let target = targetDir;
848
+ for (;;) {
849
+ const sf = findParentStatefile(target);
850
+ if (!sf) {
851
+ console.error(`Error: no .ocmux.json found in ${target} or any of its parents.`);
852
+ console.error(`Run 'ocmux serve' to create a project window.`);
853
+ process.exit(1);
854
+ }
855
+ const state = projectState.readProjectState(sf);
856
+ const server = state.server;
857
+ if (!server) {
858
+ console.error(`Error: ${sf} does not record a server.`);
859
+ console.error(`Run 'ocmux serve --server <url>' to set it up, or add "server" to the file.`);
860
+ process.exit(1);
861
+ }
862
+ const dir = state.directory;
380
863
 
381
- const urlW = Math.max(...rows.map(r => r.url.length), 3);
382
- const dirW = Math.max(...rows.map(r => r.dir.length), 9);
383
- const stsW = Math.max(...rows.map(r => r.status.length), 6);
864
+ const alive = await checkServer(server);
865
+ if (!alive) {
866
+ console.error(`Error: OpenCode server not reachable at ${server}.`);
867
+ console.error(`Start it with 'opencode serve' (or your usual launcher) first.`);
868
+ process.exit(1);
869
+ }
384
870
 
385
- const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
871
+ const winIdx = windowByDir(dir);
872
+ if (winIdx == null) {
873
+ console.error(`Error: no tmux window for ${dir}.`);
874
+ console.error(`Run 'ocmux serve' to create it.`);
875
+ process.exit(1);
876
+ }
386
877
 
387
- console.log(`${pad('URL', urlW)} ${pad('Directory', dirW)} ${pad('Status', stsW)}`);
388
- for (const row of rows) {
389
- console.log(`${pad(row.url, urlW)} ${pad(row.dir, dirW)} ${pad(row.status, stsW)}`);
878
+ // Focus this project's window up-front so the picker (and any session
879
+ // switch) never leaves another project's window selected.
880
+ activateProject(dir, winIdx, server, state.session);
881
+
882
+ const sessions = await listSessions(server, dir).catch((err) => {
883
+ console.error(`Error connecting to ${server}: ${err.message}`);
884
+ process.exit(1);
885
+ });
886
+ const sorted = sortSessionsByRecency(sessions);
887
+ const annotations = projectState.readAnnotations(sf);
888
+ const runningIds = await getActiveSessions(server);
889
+ let modelContext = new Map();
890
+ try {
891
+ const models = await listModels(server);
892
+ modelContext = new Map(models.filter(m => m.context).map(m => [`${m.providerID}/${m.id}`, m.context]));
893
+ } catch { /* context limits unavailable */ }
894
+
895
+ const applySession = (id) => {
896
+ projectState.writeProjectState(sf, { session: id });
897
+ relaunchTui(winIdx, dir, server, id);
898
+ };
899
+
900
+ if (directRef) {
901
+ const pick = sorted.find(s => String(s.id) === directRef)
902
+ || sorted.find(s => (s.title || '').toLowerCase() === directRef.toLowerCase())
903
+ || null;
904
+ if (!pick) {
905
+ console.error(`Error: no session matching "${directRef}" in ${dir}.`);
906
+ process.exit(1);
907
+ }
908
+ applySession(pick.id);
909
+ return;
910
+ }
911
+
912
+ if (!process.stdin.isTTY) {
913
+ // Silent path (non-TTY): just make sure the window is focused.
914
+ activateProject(dir, winIdx, server, state.session);
915
+ return;
916
+ }
917
+
918
+ // Interactive: Enter switches but stays open; n creates (inheriting the
919
+ // model of the previously selected session); r renames; R sets a reminder;
920
+ // d deletes; a switches agent; m switches model; p switches project; q quits.
921
+ let lastRef = (state.session && sorted.find(s => String(s.id) === String(state.session))) || sorted[0] || null;
922
+ const result = await new Promise((resolve) => {
923
+ sessionMenu(sorted, state.session, annotations, server, {
924
+ runningIds,
925
+ modelContext,
926
+ onPick: async (row) => {
927
+ if (row.new) {
928
+ const created = await createSessionWithModel(server, row.name || undefined, dir, [lastRef, ...sorted]).catch((err) => {
929
+ console.error(`Error creating session: ${err.message}`);
930
+ return null;
931
+ });
932
+ if (created) applySession(created.id);
933
+ return created || null;
934
+ }
935
+ lastRef = row;
936
+ applySession(row.id);
937
+ },
938
+ onRename: async (row, name) => {
939
+ const updated = await updateSession(server, row.id, name).catch((err) => {
940
+ console.error(`Error renaming session: ${err.message}`);
941
+ throw err;
942
+ });
943
+ return updated;
944
+ },
945
+ onDelete: async (row) => {
946
+ await deleteSession(server, row.id).catch((err) => {
947
+ console.error(`Error deleting session: ${err.message}`);
948
+ throw err;
949
+ });
950
+ if (String(row.id) === String(state.session)) {
951
+ projectState.writeProjectState(sf, { session: null });
952
+ }
953
+ },
954
+ onReminder: async (row, text) => {
955
+ try {
956
+ return projectState.writeAnnotation(sf, row.id, text);
957
+ } catch (err) {
958
+ console.error(`Error saving reminder: ${err.message}`);
959
+ throw err;
960
+ }
961
+ },
962
+ }).then(resolve);
963
+ });
964
+
965
+ if (result === 'switch-project') {
966
+ // 'p' pressed: open the project switcher, then re-run this picker for
967
+ // the project that was just activated (if any).
968
+ ensureSession();
969
+ const lastDir = await switchMenu(listProjects());
970
+ if (!lastDir) break;
971
+ target = lastDir;
972
+ continue;
973
+ }
974
+ break;
390
975
  }
391
976
  }
392
977
 
393
978
  // ---------------------------------------------------------------------------
394
- // Subcommand: new
979
+ // Subcommand: serve — create a project TUI window (server is user-managed)
395
980
  // ---------------------------------------------------------------------------
396
981
 
397
- function cmdNew(targetDir, longMode, printLogs) {
982
+ async function cmdServeNew(targetDir, serverArg, force) {
983
+ try { targetDir = fs.realpathSync(targetDir); } catch {}
984
+
398
985
  const sf = path.join(targetDir, '.ocmux.json');
399
986
  if (fs.existsSync(sf)) {
400
- console.error(`Error: a server already exists in ${targetDir}.`);
401
- console.error("Run 'ocmux' without arguments to switch to it, or 'ocmux kill' to stop it first.");
402
- process.exit(1);
987
+ if (!force) {
988
+ console.error(`Error: a project window already exists in ${targetDir}.`);
989
+ console.error(`Use 'ocmux' to switch sessions there, or 'ocmux serve --server <url> --force' to repoint it.`);
990
+ process.exit(1);
991
+ }
992
+ return doForceServe(targetDir, sf, serverArg || DEFAULT_SERVER);
403
993
  }
404
994
 
405
995
  const parent = findParentStatefile(path.dirname(targetDir));
406
996
  if (parent) {
407
997
  const parentDir = path.dirname(parent);
408
- console.error(`Warning: there is already an opencode server in ${parentDir}.`);
409
- console.error("Running 'ocmux' without arguments would switch to that server.");
998
+ console.error(`Warning: there is already an opencode project in ${parentDir}.`);
999
+ console.error(`Run 'ocmux' and press 'p' to move between project windows.`);
410
1000
 
411
1001
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
412
- rl.question('Are you sure you want to create a new server here? [y/N] ', (answer) => {
1002
+ rl.question('Are you sure you want to create a new project window here? [y/N] ', (answer) => {
413
1003
  rl.close();
414
1004
  if (!/^[yY]([eE][sS])?$/.test(answer.trim())) {
415
1005
  console.error('Cancelled.');
416
1006
  process.exit(0);
417
1007
  }
418
- doNew(targetDir, longMode, printLogs);
1008
+ doServeNew(targetDir, serverArg).catch((err) => {
1009
+ console.error(err ? err.message : String(err));
1010
+ process.exit(1);
1011
+ });
419
1012
  });
420
- } else {
421
- doNew(targetDir, longMode, printLogs);
1013
+ return;
422
1014
  }
1015
+ return doServeNew(targetDir, serverArg);
423
1016
  }
424
1017
 
425
- function doNew(targetDir, longMode, printLogs) {
426
- // Normalize to real path so tmux window names are consistent regardless of symlinks
427
- try { targetDir = fs.realpathSync(targetDir); } catch {}
428
- ensureSession();
429
- const lf = logfileFor(targetDir);
430
-
431
- console.error(`Starting opencode server for ${targetDir}...`);
432
-
433
- // Create window with interactive shell so .bashrc/.zshrc is sourced
434
- const rc = tmux(['new-window', '-d', '-t', SESSION, '-n', targetDir, '-c', targetDir]);
435
- if (rc.status !== 0) {
436
- console.error(`Error: failed to create tmux window: ${rc.stderr}`);
1018
+ // Repoint an existing project to a (possibly new) server: update .ocmux.json's
1019
+ // `server`, keep the session if it still exists, and (re)launch the TUI on it.
1020
+ async function doForceServe(targetDir, sf, server) {
1021
+ const alive = await checkServer(server);
1022
+ if (!alive) {
1023
+ console.error(`Error: OpenCode server not reachable at ${server}.`);
1024
+ console.error(`Start it with 'opencode serve' (or pass --server <url>).`);
437
1025
  process.exit(1);
438
1026
  }
439
-
440
- const winIdx = windowByDir(targetDir);
441
- if (winIdx == null) {
442
- console.error('Error: window was not created properly');
1027
+ const state = projectState.readProjectState(sf);
1028
+ const sessions = await listSessions(server, targetDir).catch((err) => {
1029
+ console.error(`Error connecting to ${server}: ${err.message}`);
443
1030
  process.exit(1);
1031
+ });
1032
+ let session = (state && state.session && sessions.some(s => String(s.id) === String(state.session)))
1033
+ ? state.session
1034
+ : newestSessionId(sessions);
1035
+ if (!session) {
1036
+ const created = await createSessionWithModel(server, undefined, targetDir, sessions);
1037
+ session = created.id;
444
1038
  }
1039
+ projectState.writeProjectState(sf, { server, session });
1040
+ const winIdx = windowByDir(targetDir);
1041
+ if (winIdx != null) {
1042
+ relaunchTui(winIdx, targetDir, server, session);
1043
+ console.error(`Repointed ${targetDir} to ${server} (session ${session}).`);
1044
+ } else {
1045
+ createProjectWindow(targetDir, server, session);
1046
+ console.error(`Recreated project window for ${targetDir} → ${server} (session ${session}).`);
1047
+ }
1048
+ }
445
1049
 
446
- pinWindowName(winIdx);
447
-
448
- // Truncate log file so stale URL from previous run can't be matched by polling
449
- try { fs.truncateSync(lf); } catch { /* may not exist yet */ }
450
-
451
- // Send the command to the interactive shell (like typing it manually)
452
- const serveFlags = printLogs ? '--print-logs' : '';
453
- tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`,
454
- `opencode serve ${serveFlags} 2>&1 | tee '${lf}'`, 'Enter']);
1050
+ async function doServeNew(targetDir, serverArg) {
1051
+ const server = serverArg || DEFAULT_SERVER;
455
1052
 
456
- // Wait up to 10s for the URL in the logfile.
457
- // Don't break on first match — keep polling so url ends up holding the
458
- // final URL in case the server prints multiple URLs during startup.
459
- let url = null;
460
- for (let i = 0; i < 50; i++) {
461
- sleep(0.2);
462
- const log = (() => { try { return fs.readFileSync(lf, 'utf8'); } catch { return ''; } })();
463
- const m = log.match(URL_RE);
464
- if (m) url = m[1];
1053
+ const alive = await checkServer(server);
1054
+ if (!alive) {
1055
+ console.error(`Error: OpenCode server not reachable at ${server}.`);
1056
+ console.error(`Start it with 'opencode serve' (or --server <url> to point elsewhere).`);
1057
+ process.exit(1);
465
1058
  }
466
1059
 
467
- if (!url) {
468
- console.error('Error: opencode server did not start within 10 seconds');
469
- const wi = windowByDir(targetDir);
470
- if (wi != null) tmux(['kill-window', '-t', `${SESSION}:${wi}`]);
1060
+ // Pick the newest session already in this directory, or create one.
1061
+ let sessions = [];
1062
+ try {
1063
+ sessions = await listSessions(server, targetDir);
1064
+ } catch (err) {
1065
+ console.error(`Error connecting to ${server}: ${err.message}`);
471
1066
  process.exit(1);
472
1067
  }
473
-
474
- const state = { url, logfile: lf, window_index: winIdx };
475
- fs.writeFileSync(path.join(targetDir, '.ocmux.json'), JSON.stringify(state) + '\n');
476
-
477
- const splitResult = tmux(['split-window', '-v', '-P', '-F', '#{pane_id}', '-t', `${SESSION}:${winIdx}`, '-c', targetDir]);
478
- const tuiPane = splitResult.stdout.trim();
479
- sleep(0.2);
480
- tmux(['send-keys', '-t', tuiPane,
481
- `opencode attach --continue '${url}'`, 'Enter']);
482
- sleep(0.5);
483
- if (tuiPane) tmux(['resize-pane', '-Z', '-t', tuiPane]);
484
-
485
- const activeWin = activeWindowIndex();
486
- if (activeWin !== winIdx) {
487
- tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
1068
+ let session = newestSessionId(sessions);
1069
+ if (!session) {
1070
+ try {
1071
+ const created = await createSessionWithModel(server, undefined, targetDir, sessions);
1072
+ session = created.id;
1073
+ } catch (err) {
1074
+ console.error(`Error creating session: ${err.message}`);
1075
+ process.exit(1);
1076
+ }
488
1077
  }
489
1078
 
490
- outputUrl(url, targetDir, longMode);
1079
+ console.error(`Creating project window for ${targetDir} (session ${session})...`);
1080
+ try {
1081
+ createProjectWindow(targetDir, server, session);
1082
+ } catch (err) {
1083
+ console.error(`Error: ${err.message}`);
1084
+ process.exit(1);
1085
+ }
491
1086
  }
492
1087
 
493
1088
  // ---------------------------------------------------------------------------
494
- // Subcommand: kill
1089
+ // Subcommand: list
495
1090
  // ---------------------------------------------------------------------------
496
1091
 
497
- function cmdKill(statefile) {
498
- const serverDir = path.dirname(statefile);
499
- const state = readState(statefile);
500
-
501
- if (!state || !state.url || state.window_index == null) {
502
- console.error(`Error: invalid state file ${statefile}`);
503
- process.exit(1);
1092
+ function cmdList(longMode) {
1093
+ const rows = listProjects();
1094
+ if (rows.length === 0) {
1095
+ console.log('No opencode projects running.');
1096
+ return;
504
1097
  }
505
1098
 
506
- let { window_index: winIdx } = state;
1099
+ const dirW = Math.max(...rows.map(r => r.dir.length), 9);
1100
+ const stsW = Math.max(...rows.map(r => r.status.length), 6);
1101
+ const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
1102
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
507
1103
 
508
- console.error(`Stopping server and removing window for ${serverDir}...`);
1104
+ console.log(`${pad('Directory', dirW)} ${pad('Status', stsW)} ${pad('Session', ssnW)}`);
1105
+ for (const row of rows) {
1106
+ console.log(`${pad(row.dir, dirW)} ${pad(row.status, stsW)} ${pad(row.session || '(none)', ssnW)}`);
1107
+ if (longMode && row.server) console.log(` server: ${row.server}`);
1108
+ }
1109
+ }
509
1110
 
510
- const sessionR = tmux(['has-session', '-t', SESSION]);
511
- if (sessionR.status === 0) {
512
- const actualName = windowNameByIndex(winIdx);
513
- if (actualName !== serverDir) {
514
- const found = windowByDir(serverDir);
515
- if (found != null) winIdx = found;
516
- }
1111
+ // ---------------------------------------------------------------------------
1112
+ // Subcommand: kill — close the project window but keep .ocmux.json
1113
+ // ---------------------------------------------------------------------------
517
1114
 
518
- if (winIdx != null) {
519
- tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
520
- tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`, 'C-c']);
521
- sleep(0.3);
522
- tmux(['kill-window', '-t', `${SESSION}:${winIdx}`]);
523
- console.error('Window removed.');
524
- } else {
525
- console.error(`Tmux window not found for ${serverDir} (cleaning up state file).`);
1115
+ function cmdKill(targetDir) {
1116
+ const sf = findParentStatefile(targetDir);
1117
+ if (!sf) {
1118
+ console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
1119
+ process.exit(1);
1120
+ }
1121
+ const serverDir = path.dirname(sf);
1122
+ const winIdx = windowByDir(serverDir);
1123
+ if (winIdx != null) {
1124
+ console.error(`Closing project window for ${serverDir}...`);
1125
+ try {
1126
+ closeProjectWindow(winIdx);
1127
+ } catch (err) {
1128
+ console.error(`Error: ${err.message}`);
1129
+ process.exit(1);
526
1130
  }
527
1131
  } else {
528
- console.error("No tmux session '" + SESSION + "' found (cleaning up state file).");
1132
+ console.error(`Tmux window not found for ${serverDir} (state kept).`);
529
1133
  }
530
-
531
- fs.unlinkSync(statefile);
532
- console.error('Done.');
1134
+ // Session memory intentionally kept.
1135
+ projectState.writeProjectState(sf, { status: 'stopped' });
1136
+ console.error('Done (state file kept).');
533
1137
  }
534
1138
 
535
1139
  // ---------------------------------------------------------------------------
536
- // Subcommand: resurrect — kill dead server and create a fresh one
1140
+ // Subcommand: resurrect — recreate a project window from its state
537
1141
  // ---------------------------------------------------------------------------
538
1142
 
539
- function cmdResurrect(targetDir, longMode, printLogs) {
1143
+ async function cmdResurrect(targetDir) {
540
1144
  const sf = findParentStatefile(targetDir);
541
1145
  if (!sf) {
542
1146
  console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
543
- console.error("Run 'ocmux serve' to create a new server.");
544
1147
  process.exit(1);
545
1148
  }
1149
+ const state = projectState.readProjectState(sf);
1150
+ const server = state.server;
1151
+ if (!server) {
1152
+ console.error(`Error: ${sf} does not record a server.`);
1153
+ process.exit(1);
1154
+ }
1155
+ const dir = state.directory;
546
1156
 
547
- const serverDir = path.dirname(sf);
548
- console.error(`Resurrecting server for ${serverDir}...`);
1157
+ const winIdx = windowByDir(dir);
1158
+ if (winIdx != null) {
1159
+ console.error(`Project window for ${dir} already exists.`);
1160
+ return;
1161
+ }
549
1162
 
550
- try {
551
- const { url } = resurrectServer(serverDir, printLogs);
552
- outputUrl(url, serverDir, longMode);
553
- } catch (err) {
554
- console.error(`Error: ${err.message}`);
1163
+ const alive = await checkServer(server);
1164
+ if (!alive) {
1165
+ console.error(`Error: OpenCode server not reachable at ${server}.`);
555
1166
  process.exit(1);
556
1167
  }
1168
+ console.error(`Recreating project window for ${dir}...`);
1169
+ createProjectWindow(dir, server, state.session);
1170
+ }
1171
+
1172
+ // ---------------------------------------------------------------------------
1173
+ // Subcommand: migrate — rewrite legacy .ocmux.json files to v2
1174
+ // ---------------------------------------------------------------------------
1175
+
1176
+ function cmdMigrate() {
1177
+ const rows = listProjects();
1178
+ let migrated = 0;
1179
+ for (const row of rows) {
1180
+ const sf = statefileFor(row.dir);
1181
+ const state = projectState.readProjectState(sf);
1182
+ if (!state) continue;
1183
+ if (state.version >= 2) continue;
1184
+ projectState.writeProjectState(sf, {
1185
+ directory: row.dir,
1186
+ server: state.server,
1187
+ session: state.session,
1188
+ });
1189
+ migrated++;
1190
+ console.error(`Migrated ${sf}`);
1191
+ }
1192
+ if (migrated === 0) console.error('No legacy state files found.');
557
1193
  }
558
1194
 
559
1195
  // ---------------------------------------------------------------------------
560
- // Argument parsing
1196
+ // ---------------------------------------------------------------------------
1197
+ // Help
1198
+ // ---------------------------------------------------------------------------
1199
+
1200
+ function printHelp() {
1201
+ console.error(`Usage: ${SCRIPT} [-l] [<subcommand>] [<directory>]`);
1202
+ console.error('');
1203
+ console.error('Manage project OpenCode TUI windows in tmux. The OpenCode server');
1204
+ console.error('itself is user-managed: start it once with `opencode serve`.');
1205
+ console.error('');
1206
+ console.error('Without arguments, searches upward from <directory> (default: $PWD)');
1207
+ console.error('for .ocmux.json and opens an interactive SESSION picker for that');
1208
+ console.error('project (requires a TTY). In the picker:');
1209
+ console.error(' Enter switch · n new · r rename · R remind · d delete · a agents · m model');
1210
+ console.error(' p projects · h help · q quit');
1211
+ console.error('Selecting updates .ocmux.json and relaunches the project TUI on the');
1212
+ console.error('chosen session. Silent on success. (p opens the read-only project');
1213
+ console.error('switcher; it never modifies .ocmux.json.)');
1214
+ console.error('');
1215
+ console.error('Subcommands:');
1216
+ console.error(' serve Create a TUI window for <directory> (default: $PWD).');
1217
+ console.error(' --server <url> Server to record (default: $OCMUX_SERVER or http://localhost:4096).');
1218
+ console.error(' --force Repoint an existing project to --server (overwrites .ocmux.json).');
1219
+ console.error(' --git / --GIT Use the nearest parent with a .git entry / directory.');
1220
+ console.error(' Aliased as "new" for backwards compatibility.');
1221
+ console.error(' session <id|title>');
1222
+ console.error(' Non-interactive session switch for the current project.');
1223
+ console.error(' list [-l] List project windows (with -l, their server URL).');
1224
+ console.error(' kill Close the current project window. KEEPS .ocmux.json (session memory).');
1225
+ console.error(' resurrect Recreate the project window from its .ocmux.json.');
1226
+ console.error(' migrate Rewrite legacy .ocmux.json files to the v2 schema.');
1227
+ console.error('');
1228
+ console.error('Reminders: the R key in the session picker attaches a note to a');
1229
+ console.error('session; agentp prepends it to every prompt sent to that session.');
1230
+ console.error('');
1231
+ console.error('Options:');
1232
+ console.error(' -l Long output (list: also show server URL).');
1233
+ console.error(' --version Show version');
1234
+ console.error(' -h Show this help');
1235
+ console.error('');
1236
+ console.error('See also: agentp -- pipe prompts into OpenCode from stdin');
1237
+ console.error(' tgagentp -- Telegram bridge for OpenCode (tgagentp --help)');
1238
+ process.exit(0);
1239
+ }
1240
+
1241
+ // ---------------------------------------------------------------------------
1242
+ // Argument parsing + dispatch
561
1243
  // ---------------------------------------------------------------------------
562
1244
 
563
1245
  function main() {
564
1246
  let longMode = false;
565
1247
  let gitMode = null;
566
- let printLogs = false;
567
- let lastMode = false;
1248
+ let serverArg = null;
1249
+ let force = false;
568
1250
  const positional = [];
569
1251
  let dirIsForced = false;
570
1252
 
571
- for (const arg of process.argv.slice(2)) {
1253
+ const parsedArgs = process.argv.slice(2);
1254
+ for (let i = 0; i < parsedArgs.length; i++) {
1255
+ const arg = parsedArgs[i];
572
1256
  if (arg === '-l') {
573
1257
  longMode = true;
574
- } else if (arg === '--last') {
575
- lastMode = true;
576
- } else if (arg === '--print-logs') {
577
- printLogs = true;
1258
+ } else if (arg === '--server') {
1259
+ const val = parsedArgs[i + 1];
1260
+ if (!val || val.startsWith('-')) {
1261
+ console.error("Error: '--server' requires a URL argument.");
1262
+ process.exit(1);
1263
+ }
1264
+ serverArg = val;
1265
+ i++;
1266
+ } else if (arg === 'switch' || arg === 'model') {
1267
+ console.error(`Error: the '${arg}' subcommand was removed.`);
1268
+ console.error(`Press '${arg === 'switch' ? 'p' : 'm'}' inside the session picker (ocmux) for that action.`);
1269
+ process.exit(1);
1270
+ } else if (arg === '--force') {
1271
+ force = true;
578
1272
  } else if (arg === '--git') {
579
1273
  if (gitMode && gitMode !== 'nearest') {
580
1274
  console.error("Error: cannot combine '--git' with '--GIT'.");
@@ -595,22 +1289,19 @@ function main() {
595
1289
  } else if (arg === '--') {
596
1290
  dirIsForced = true;
597
1291
  } else if (arg.startsWith('-')) {
598
- if (arg === '--new') {
599
- console.error("Error: '--new' is no longer a flag. Use 'ocmux serve' instead.");
600
- } else if (arg === '--kill') {
601
- console.error("Error: '--kill' is no longer a flag. Use 'ocmux kill' instead.");
602
- } else {
603
- console.error(`Error: unknown option '${arg}'`);
604
- console.error(`Run '${SCRIPT} -h' for help.`);
605
- }
1292
+ console.error(`Error: unknown option '${arg}'`);
1293
+ console.error(`Run '${SCRIPT} -h' for help.`);
606
1294
  process.exit(1);
607
1295
  } else {
608
1296
  positional.push(arg);
609
1297
  }
610
1298
  }
611
1299
 
1300
+ // Handle --server <url> (inline-consumed in the loop above; nothing here).
1301
+
612
1302
  let subcommand = null;
613
1303
  let targetDir = null;
1304
+ let directRef = null;
614
1305
 
615
1306
  for (const arg of positional) {
616
1307
  if (dirIsForced) {
@@ -620,12 +1311,16 @@ function main() {
620
1311
  process.exit(1);
621
1312
  }
622
1313
  targetDir = resolveDir(arg);
623
- } else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list' || arg === 'resurrect' || arg === 'switch') {
1314
+ } else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list'
1315
+ || arg === 'resurrect' || arg === 'session'
1316
+ || arg === 'migrate') {
624
1317
  if (subcommand) {
625
1318
  console.error(`Error: multiple subcommands specified: '${subcommand}' and '${arg}'`);
626
1319
  process.exit(1);
627
1320
  }
628
1321
  subcommand = arg;
1322
+ } else if (subcommand === 'session' && directRef === null) {
1323
+ directRef = arg;
629
1324
  } else {
630
1325
  if (targetDir) {
631
1326
  console.error(`Error: unexpected argument '${arg}'`);
@@ -637,128 +1332,68 @@ function main() {
637
1332
 
638
1333
  if (!targetDir) targetDir = process.cwd();
639
1334
 
640
- // Dispatch
641
- if (lastMode) {
642
- if (gitMode) {
643
- console.error("Error: '--last' cannot be combined with '--git' or '--GIT'.");
644
- process.exit(1);
645
- }
646
- if (printLogs) {
647
- console.error("Error: '--last' cannot be combined with '--print-logs'.");
648
- process.exit(1);
649
- }
650
- if (subcommand) {
651
- console.error(`Error: '--last' cannot be combined with subcommand '${subcommand}'.`);
652
- process.exit(1);
653
- }
654
- if (targetDir !== process.cwd()) {
655
- console.error("Error: '--last' does not accept a directory path.");
656
- process.exit(1);
657
- }
658
- ensureSession();
659
- const url = getActiveServer();
660
- if (!url) {
661
- console.error('Error: no active opencode server found in tmux.');
662
- console.error("Run 'ocmux serve' to create a server, or switch to an existing one.");
663
- process.exit(1);
664
- }
665
- outputUrl(url, null, longMode);
666
- return;
667
- }
1335
+ // Dispatch. Each branch may throw synchronously (EXIT) for validation errors
1336
+ // or return a promise for async commands; main() returns the settled promise
1337
+ // so tests can await it.
1338
+ let p = Promise.resolve();
668
1339
 
669
1340
  if (subcommand === 'list') {
670
1341
  if (gitMode) {
671
1342
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
672
1343
  process.exit(1);
673
1344
  }
674
- if (printLogs) {
675
- console.error("Error: '--print-logs' is only valid with 'serve'.");
676
- process.exit(1);
677
- }
678
1345
  if (targetDir !== process.cwd()) {
679
1346
  console.error("Error: 'list' does not accept a directory path.");
680
1347
  process.exit(1);
681
1348
  }
682
- cmdList();
683
- } else if (subcommand === 'switch') {
1349
+ ensureSession();
1350
+ cmdList(longMode);
1351
+ } else if (subcommand === 'session') {
684
1352
  if (gitMode) {
685
1353
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
686
1354
  process.exit(1);
687
1355
  }
688
- if (printLogs) {
689
- console.error("Error: '--print-logs' is only valid with 'serve'.");
690
- process.exit(1);
691
- }
692
- if (targetDir !== process.cwd()) {
693
- console.error("Error: 'switch' does not accept a directory path.");
694
- process.exit(1);
695
- }
696
- ensureSession();
697
- cmdSwitchMenu(longMode);
1356
+ p = cmdSessionSwitch(targetDir, directRef);
698
1357
  } else if (subcommand === 'kill') {
699
1358
  if (gitMode) {
700
1359
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
701
1360
  process.exit(1);
702
1361
  }
703
- if (printLogs) {
704
- console.error("Error: '--print-logs' is only valid with 'serve'.");
705
- process.exit(1);
706
- }
707
- const sf = findParentStatefile(targetDir);
708
- if (!sf) {
709
- console.error(`Error: no opencode server found in ${targetDir} or any of its parents.`);
710
- console.error("Run 'ocmux serve' to create a new server.");
711
- process.exit(1);
712
- }
713
- cmdKill(sf);
1362
+ cmdKill(targetDir);
714
1363
  } else if (subcommand === 'resurrect') {
715
- if (gitMode) {
716
- console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
1364
+ p = cmdResurrect(targetDir);
1365
+ } else if (subcommand === 'migrate') {
1366
+ if (targetDir !== process.cwd()) {
1367
+ console.error("Error: 'migrate' does not accept a directory path.");
717
1368
  process.exit(1);
718
1369
  }
719
- cmdResurrect(targetDir, longMode, printLogs);
1370
+ cmdMigrate();
720
1371
  } else if (subcommand === 'serve' || subcommand === 'new') {
721
1372
  if (gitMode) {
722
1373
  const flagLabel = gitMode === 'root' ? '--GIT' : '--git';
723
1374
  targetDir = resolveGitDir(targetDir, gitMode === 'root', flagLabel);
724
1375
  }
725
- cmdNew(targetDir, longMode, printLogs);
1376
+ p = cmdServeNew(targetDir, serverArg, force);
726
1377
  } else {
1378
+ // Default: session switcher for the current project.
727
1379
  if (gitMode) {
728
1380
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
729
1381
  process.exit(1);
730
1382
  }
731
- if (printLogs) {
732
- console.error("Error: '--print-logs' is only valid with 'serve'.");
733
- process.exit(1);
734
- }
735
- // Default mode — find an existing server and switch to it
736
- let sf = findParentStatefile(targetDir);
737
- if (!sf) {
738
- console.log(`Error: no opencode server found in ${targetDir} or any of its parents.`);
739
- console.error("Run 'ocmux serve' to create a new server.");
740
- process.exit(1);
741
- }
742
-
743
- // Skip stale state files whose tmux window no longer exists,
744
- // and search from the parent directory instead.
745
- const state = readState(sf);
746
- if (state && state.window_index != null) {
747
- const serverDir = path.dirname(sf);
748
- const actualName = windowNameByIndex(state.window_index);
749
- if (actualName !== serverDir && windowByDir(serverDir) == null) {
750
- const parentSf = findParentStatefile(path.dirname(serverDir));
751
- if (parentSf) sf = parentSf;
752
- }
753
- }
754
-
755
- ensureSession();
756
- cmdSwitch(sf, longMode);
1383
+ p = cmdSessionSwitch(targetDir, null);
757
1384
  }
1385
+
1386
+ return p.catch((err) => {
1387
+ // Already exiting (a command called process.exit — the test stub throws
1388
+ // EXIT:code). Swallow so the chain settles cleanly.
1389
+ if (err && err.message && err.message.startsWith('EXIT:')) return;
1390
+ console.error(err ? err.message : String(err));
1391
+ process.exit(1);
1392
+ });
758
1393
  }
759
1394
 
760
1395
  if (require.main === module) {
761
1396
  main();
762
1397
  }
763
1398
 
764
- module.exports = { main };
1399
+ module.exports = { main, checkServer, newestSessionId, cmdKill, cmdList, cmdMigrate, windowFor, renderList, fmtClock, fmtDuration, fmtTokens, fmtCost, layoutCells, sessionInfoLines, centerIn };