agentp 1.14.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,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,310 +128,697 @@ 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
+ const sorted = sortSessionsByRecency(sessions).filter(s => s && s.id);
160
+ return sorted.length > 0 ? sorted[0].id : null;
161
+ }
151
162
 
163
+ // ---------------------------------------------------------------------------
164
+ // Shared list rendering (scrollable viewport + resize-aware)
165
+ // ---------------------------------------------------------------------------
152
166
 
153
- function outputUrl(url, dir, longMode) {
154
- if (longMode) {
155
- console.log(`${url} → ${dir}`);
156
- } else {
157
- console.log(url);
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
+ }
177
+
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')}`;
158
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;
159
218
  }
160
219
 
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);
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));
233
+ }
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);
249
+ }
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;
211
262
  }
212
263
 
213
- // ---------------------------------------------------------------------------
214
- // Subcommand: default — switch to an existing server
215
- // ---------------------------------------------------------------------------
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
+ }
216
272
 
217
- function cmdSwitch(statefile, longMode) {
218
- const serverDir = path.dirname(statefile);
219
- const state = readState(statefile);
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)';
303
+ }
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
+ }
220
318
 
221
- if (!state || !state.url || state.window_index == null) {
222
- console.error(`Error: invalid state file ${statefile}`);
223
- process.exit(1);
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})`;
224
341
  }
225
342
 
226
- let { url, window_index: winIdx } = state;
343
+ let out = '\x1b[2J\x1b[H';
344
+ out += '\x1b[1;7m' + centerIn(titleLine, cols) + '\x1b[0m\n\n';
227
345
 
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);
235
- }
236
- winIdx = found;
237
- }
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)]);
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++;
256
359
  }
257
360
  }
258
-
259
- const activeWin = activeWindowIndex();
260
- if (activeWin !== winIdx) {
261
- tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
262
- }
263
-
264
- ensureTuiZoomed(winIdx);
265
- outputUrl(url, serverDir, longMode);
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$/, '');
266
369
  }
267
370
 
268
- // ---------------------------------------------------------------------------
269
- // Subcommand: switch — interactive session selection
270
- // ---------------------------------------------------------------------------
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);
271
380
 
272
- function cmdSwitchMenu(longMode) {
273
- const rows = listServers();
274
- if (rows.length === 0) {
275
- console.error('No opencode servers running.');
276
- process.exit(1);
277
- }
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);
282
- }
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;
283
393
 
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; }
305
- }
306
- };
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
+ ];
307
414
 
308
- const cleanup = () => {
309
- process.stderr.write('\x1b[?25h');
310
- process.stderr.write('\x1b[?1049l');
311
- try { process.stdin.setRawMode(false); } catch {}
312
- };
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);
313
433
 
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
- };
434
+ const renderInputValue = (v) => {
435
+ const c = input.caret;
436
+ return v.slice(0, c) + '▏' + v.slice(c);
437
+ };
322
438
 
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';
333
- } else {
334
- out += line + '\n';
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;
335
447
  }
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
- };
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
+ };
351
493
 
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;
357
- draw();
358
- } else if (key.name === 'down' || str === 'j') {
359
- cursor = (cursor + 1) % rows.length;
494
+ const startInput = (label, value, kind) => {
495
+ input = { label, value, kind, caret: value.length };
496
+ mode = 'input';
360
497
  draw();
361
- } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
362
- activate(cursor);
363
- }
364
- });
498
+ };
365
499
 
366
- process.on('SIGINT', finish);
367
- process.on('SIGTERM', finish);
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
+ };
368
511
 
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;
375
- draw();
376
- }
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
+ };
377
524
 
378
- // ---------------------------------------------------------------------------
379
- // Subcommand: model — switch the model of the server's most recent session
380
- // ---------------------------------------------------------------------------
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
+ };
381
542
 
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;
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
+ };
571
+
572
+ const commandErr = (err) => {
573
+ process.stderr.write(`\nError: ${err.message}\n`);
574
+ draw();
575
+ };
576
+
577
+ process.stdin.on('keypress', (str, key) => {
578
+ if (mode === 'help') {
579
+ mode = 'normal';
580
+ draw();
581
+ return;
582
+ }
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
+ }
391
723
  });
392
- return sorted.length > 0 ? sorted[0].id : (sessions[0] && sessions[0].id) || null;
724
+
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);
732
+ }
733
+ process.stderr.write('\x1b[?1049h\x1b[?25l');
734
+ draw();
735
+ });
393
736
  }
394
737
 
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) {
738
+ // Interactive project picker — purely visual/navigation, never writes state.
739
+ function switchMenu(rows, longMode) {
398
740
  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
- );
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;
749
+ }
750
+
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);
408
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
+
409
758
  let cursor = 0;
759
+ let activeRow = 0;
760
+ let lastDir = null;
410
761
  let done = false;
411
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
+
412
771
  const cleanup = () => {
413
772
  process.stderr.write('\x1b[?25h');
414
773
  process.stderr.write('\x1b[?1049l');
774
+ process.stdout.removeListener('resize', draw);
415
775
  try { process.stdin.setRawMode(false); } catch {}
416
- process.stdin.pause();
417
776
  };
418
- const finish = (value) => {
777
+
778
+ const finish = () => {
419
779
  if (done) return;
420
780
  done = true;
421
781
  cleanup();
422
- process.stdin.removeAllListeners('keypress');
423
- process.removeListener('SIGINT', onSig);
424
- resolve(value);
782
+ process.stdin.pause();
783
+ resolve(lastDir);
425
784
  };
426
- const onSig = () => finish(null);
427
- process.on('SIGINT', onSig);
428
785
 
429
786
  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`;
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`);
437
812
  }
438
- process.stderr.write(out);
813
+ draw();
439
814
  };
440
815
 
816
+ const onSig = () => finish();
817
+ process.on('SIGINT', onSig);
818
+
441
819
  process.stdin.on('keypress', (str, key) => {
442
- if (key.ctrl && key.name === 'c') return finish(null);
443
- if (key.name === 'q') return finish(null);
820
+ if (key.ctrl && key.name === 'c') return finish();
821
+ if (key.name === 'q') return finish();
444
822
  if (key.name === 'up' || str === 'k') {
445
823
  cursor = (cursor + rows.length - 1) % rows.length;
446
824
  draw();
@@ -448,303 +826,449 @@ function modelMenu(rows) {
448
826
  cursor = (cursor + 1) % rows.length;
449
827
  draw();
450
828
  } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
451
- finish(rows[cursor]);
829
+ activate(cursor);
452
830
  }
453
831
  });
454
832
 
455
833
  readline.emitKeypressEvents(process.stdin);
456
834
  process.stdin.setRawMode(true);
457
835
  process.stdin.resume();
836
+ process.stdout.on('resize', draw);
458
837
  process.stderr.write('\x1b[?1049h\x1b[?25l');
459
838
  draw();
460
839
  });
461
840
  }
462
841
 
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
- }
842
+ // ---------------------------------------------------------------------------
843
+ // Subcommand: default — interactive session switcher for the current project
844
+ // ---------------------------------------------------------------------------
482
845
 
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
- }
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;
493
863
 
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
- }
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
+ }
502
870
 
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}`);
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.`);
513
875
  process.exit(1);
514
876
  }
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
877
 
524
- if (!pick) {
525
- console.error('Cancelled.');
526
- return;
527
- }
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);
528
881
 
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
- }
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 */ }
539
894
 
540
- // ---------------------------------------------------------------------------
541
- // Subcommand: list
542
- // ---------------------------------------------------------------------------
895
+ const applySession = (id) => {
896
+ projectState.writeProjectState(sf, { session: id });
897
+ relaunchTui(winIdx, dir, server, id);
898
+ };
543
899
 
544
- function cmdList() {
545
- const rows = listServers();
546
- if (rows.length === 0) {
547
- console.log('No opencode servers running.');
548
- return;
549
- }
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
+ }
550
911
 
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);
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
+ }
554
917
 
555
- const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
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
+ });
556
964
 
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)}`);
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;
560
975
  }
561
976
  }
562
977
 
563
978
  // ---------------------------------------------------------------------------
564
- // Subcommand: new
979
+ // Subcommand: serve — create a project TUI window (server is user-managed)
565
980
  // ---------------------------------------------------------------------------
566
981
 
567
- function cmdNew(targetDir, longMode, printLogs) {
982
+ async function cmdServeNew(targetDir, serverArg, force) {
983
+ try { targetDir = fs.realpathSync(targetDir); } catch {}
984
+
568
985
  const sf = path.join(targetDir, '.ocmux.json');
569
986
  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);
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);
573
993
  }
574
994
 
575
995
  const parent = findParentStatefile(path.dirname(targetDir));
576
996
  if (parent) {
577
997
  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.");
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.`);
580
1000
 
581
1001
  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) => {
1002
+ rl.question('Are you sure you want to create a new project window here? [y/N] ', (answer) => {
583
1003
  rl.close();
584
1004
  if (!/^[yY]([eE][sS])?$/.test(answer.trim())) {
585
1005
  console.error('Cancelled.');
586
1006
  process.exit(0);
587
1007
  }
588
- doNew(targetDir, longMode, printLogs);
1008
+ doServeNew(targetDir, serverArg).catch((err) => {
1009
+ console.error(err ? err.message : String(err));
1010
+ process.exit(1);
1011
+ });
589
1012
  });
590
- } else {
591
- doNew(targetDir, longMode, printLogs);
1013
+ return;
592
1014
  }
1015
+ return doServeNew(targetDir, serverArg);
593
1016
  }
594
1017
 
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}`);
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>).`);
607
1025
  process.exit(1);
608
1026
  }
609
-
610
- const winIdx = windowByDir(targetDir);
611
- if (winIdx == null) {
612
- 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}`);
613
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;
614
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
+ }
615
1049
 
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']);
1050
+ async function doServeNew(targetDir, serverArg) {
1051
+ const server = serverArg || DEFAULT_SERVER;
625
1052
 
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];
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);
635
1058
  }
636
1059
 
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}`]);
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}`);
641
1066
  process.exit(1);
642
1067
  }
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}`]);
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
+ }
658
1077
  }
659
1078
 
660
- 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
+ }
661
1086
  }
662
1087
 
663
1088
  // ---------------------------------------------------------------------------
664
- // Subcommand: kill
1089
+ // Subcommand: list
665
1090
  // ---------------------------------------------------------------------------
666
1091
 
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);
1092
+ function cmdList(longMode) {
1093
+ const rows = listProjects();
1094
+ if (rows.length === 0) {
1095
+ console.log('No opencode projects running.');
1096
+ return;
674
1097
  }
675
1098
 
676
- 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));
677
1103
 
678
- 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
+ }
679
1110
 
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
- }
1111
+ // ---------------------------------------------------------------------------
1112
+ // Subcommand: kill — close the project window but keep .ocmux.json
1113
+ // ---------------------------------------------------------------------------
687
1114
 
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).`);
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);
696
1130
  }
697
1131
  } else {
698
- console.error("No tmux session '" + SESSION + "' found (cleaning up state file).");
1132
+ console.error(`Tmux window not found for ${serverDir} (state kept).`);
699
1133
  }
700
-
701
- fs.unlinkSync(statefile);
702
- console.error('Done.');
1134
+ // Session memory intentionally kept.
1135
+ projectState.writeProjectState(sf, { status: 'stopped' });
1136
+ console.error('Done (state file kept).');
703
1137
  }
704
1138
 
705
1139
  // ---------------------------------------------------------------------------
706
- // Subcommand: resurrect — kill dead server and create a fresh one
1140
+ // Subcommand: resurrect — recreate a project window from its state
707
1141
  // ---------------------------------------------------------------------------
708
1142
 
709
- function cmdResurrect(targetDir, longMode, printLogs) {
1143
+ async function cmdResurrect(targetDir) {
710
1144
  const sf = findParentStatefile(targetDir);
711
1145
  if (!sf) {
712
1146
  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
1147
  process.exit(1);
715
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;
716
1156
 
717
- const serverDir = path.dirname(sf);
718
- 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
+ }
719
1162
 
720
- try {
721
- const { url } = resurrectServer(serverDir, printLogs);
722
- outputUrl(url, serverDir, longMode);
723
- } catch (err) {
724
- console.error(`Error: ${err.message}`);
1163
+ const alive = await checkServer(server);
1164
+ if (!alive) {
1165
+ console.error(`Error: OpenCode server not reachable at ${server}.`);
725
1166
  process.exit(1);
726
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.');
1193
+ }
1194
+
1195
+ // ---------------------------------------------------------------------------
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);
727
1239
  }
728
1240
 
729
1241
  // ---------------------------------------------------------------------------
730
- // Argument parsing
1242
+ // Argument parsing + dispatch
731
1243
  // ---------------------------------------------------------------------------
732
1244
 
733
1245
  function main() {
734
1246
  let longMode = false;
735
1247
  let gitMode = null;
736
- let printLogs = false;
737
- let lastMode = false;
1248
+ let serverArg = null;
1249
+ let force = false;
738
1250
  const positional = [];
739
1251
  let dirIsForced = false;
740
1252
 
741
- 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];
742
1256
  if (arg === '-l') {
743
1257
  longMode = true;
744
- } else if (arg === '--last') {
745
- lastMode = true;
746
- } else if (arg === '--print-logs') {
747
- 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;
748
1272
  } else if (arg === '--git') {
749
1273
  if (gitMode && gitMode !== 'nearest') {
750
1274
  console.error("Error: cannot combine '--git' with '--GIT'.");
@@ -765,23 +1289,19 @@ function main() {
765
1289
  } else if (arg === '--') {
766
1290
  dirIsForced = true;
767
1291
  } 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
- }
1292
+ console.error(`Error: unknown option '${arg}'`);
1293
+ console.error(`Run '${SCRIPT} -h' for help.`);
776
1294
  process.exit(1);
777
1295
  } else {
778
1296
  positional.push(arg);
779
1297
  }
780
1298
  }
781
1299
 
1300
+ // Handle --server <url> (inline-consumed in the loop above; nothing here).
1301
+
782
1302
  let subcommand = null;
783
1303
  let targetDir = null;
784
- let modelRef = null;
1304
+ let directRef = null;
785
1305
 
786
1306
  for (const arg of positional) {
787
1307
  if (dirIsForced) {
@@ -791,15 +1311,16 @@ function main() {
791
1311
  process.exit(1);
792
1312
  }
793
1313
  targetDir = resolveDir(arg);
794
- } else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list' || arg === 'resurrect' || arg === 'switch' || arg === 'model') {
1314
+ } else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list'
1315
+ || arg === 'resurrect' || arg === 'session'
1316
+ || arg === 'migrate') {
795
1317
  if (subcommand) {
796
1318
  console.error(`Error: multiple subcommands specified: '${subcommand}' and '${arg}'`);
797
1319
  process.exit(1);
798
1320
  }
799
1321
  subcommand = arg;
800
- } else if (subcommand === 'model' && modelRef === null) {
801
- // model reference (supports partial matches) — not a directory
802
- modelRef = arg;
1322
+ } else if (subcommand === 'session' && directRef === null) {
1323
+ directRef = arg;
803
1324
  } else {
804
1325
  if (targetDir) {
805
1326
  console.error(`Error: unexpected argument '${arg}'`);
@@ -811,148 +1332,68 @@ function main() {
811
1332
 
812
1333
  if (!targetDir) targetDir = process.cwd();
813
1334
 
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
- }
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();
842
1339
 
843
1340
  if (subcommand === 'list') {
844
1341
  if (gitMode) {
845
1342
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
846
1343
  process.exit(1);
847
1344
  }
848
- if (printLogs) {
849
- console.error("Error: '--print-logs' is only valid with 'serve'.");
850
- process.exit(1);
851
- }
852
1345
  if (targetDir !== process.cwd()) {
853
1346
  console.error("Error: 'list' does not accept a directory path.");
854
1347
  process.exit(1);
855
1348
  }
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
1349
  ensureSession();
871
- cmdSwitchMenu(longMode);
872
- } else if (subcommand === 'model') {
1350
+ cmdList(longMode);
1351
+ } else if (subcommand === 'session') {
873
1352
  if (gitMode) {
874
1353
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
875
1354
  process.exit(1);
876
1355
  }
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
- });
1356
+ p = cmdSessionSwitch(targetDir, directRef);
892
1357
  } else if (subcommand === 'kill') {
893
1358
  if (gitMode) {
894
1359
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
895
1360
  process.exit(1);
896
1361
  }
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);
1362
+ cmdKill(targetDir);
908
1363
  } else if (subcommand === 'resurrect') {
909
- if (gitMode) {
910
- 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.");
911
1368
  process.exit(1);
912
1369
  }
913
- cmdResurrect(targetDir, longMode, printLogs);
1370
+ cmdMigrate();
914
1371
  } else if (subcommand === 'serve' || subcommand === 'new') {
915
1372
  if (gitMode) {
916
1373
  const flagLabel = gitMode === 'root' ? '--GIT' : '--git';
917
1374
  targetDir = resolveGitDir(targetDir, gitMode === 'root', flagLabel);
918
1375
  }
919
- cmdNew(targetDir, longMode, printLogs);
1376
+ p = cmdServeNew(targetDir, serverArg, force);
920
1377
  } else {
1378
+ // Default: session switcher for the current project.
921
1379
  if (gitMode) {
922
1380
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
923
1381
  process.exit(1);
924
1382
  }
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);
1383
+ p = cmdSessionSwitch(targetDir, null);
951
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
+ });
952
1393
  }
953
1394
 
954
1395
  if (require.main === module) {
955
1396
  main();
956
1397
  }
957
1398
 
958
- module.exports = { main };
1399
+ module.exports = { main, checkServer, newestSessionId, cmdKill, cmdList, cmdMigrate, windowFor, renderList, fmtClock, fmtDuration, fmtTokens, fmtCost, layoutCells, sessionInfoLines, centerIn };