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/CONTRIBUTING.md +80 -83
- package/README.md +135 -49
- package/bin/agentp +337 -75
- package/bin/ocmux +1028 -587
- package/docs/specification.md +12 -454
- package/docs/specification_v2.md +611 -0
- package/lib/ocmux.js +123 -197
- package/lib/opencode.js +363 -657
- package/lib/project-state.js +205 -0
- package/package.json +1 -1
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
|
-
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
343
|
+
let out = '\x1b[2J\x1b[H';
|
|
344
|
+
out += '\x1b[1;7m' + centerIn(titleLine, cols) + '\x1b[0m\n\n';
|
|
227
345
|
|
|
228
|
-
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
}
|
|
362
|
-
activate(cursor);
|
|
363
|
-
}
|
|
364
|
-
});
|
|
498
|
+
};
|
|
365
499
|
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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
|
|
396
|
-
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
777
|
+
|
|
778
|
+
const finish = () => {
|
|
419
779
|
if (done) return;
|
|
420
780
|
done = true;
|
|
421
781
|
cleanup();
|
|
422
|
-
process.stdin.
|
|
423
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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(
|
|
443
|
-
if (key.name === 'q') return finish(
|
|
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
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
542
|
-
|
|
895
|
+
const applySession = (id) => {
|
|
896
|
+
projectState.writeProjectState(sf, { session: id });
|
|
897
|
+
relaunchTui(winIdx, dir, server, id);
|
|
898
|
+
};
|
|
543
899
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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:
|
|
979
|
+
// Subcommand: serve — create a project TUI window (server is user-managed)
|
|
565
980
|
// ---------------------------------------------------------------------------
|
|
566
981
|
|
|
567
|
-
function
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
|
579
|
-
console.error(
|
|
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
|
|
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
|
-
|
|
1008
|
+
doServeNew(targetDir, serverArg).catch((err) => {
|
|
1009
|
+
console.error(err ? err.message : String(err));
|
|
1010
|
+
process.exit(1);
|
|
1011
|
+
});
|
|
589
1012
|
});
|
|
590
|
-
|
|
591
|
-
doNew(targetDir, longMode, printLogs);
|
|
1013
|
+
return;
|
|
592
1014
|
}
|
|
1015
|
+
return doServeNew(targetDir, serverArg);
|
|
593
1016
|
}
|
|
594
1017
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
|
611
|
-
|
|
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
|
-
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
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:
|
|
1089
|
+
// Subcommand: list
|
|
665
1090
|
// ---------------------------------------------------------------------------
|
|
666
1091
|
|
|
667
|
-
function
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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(
|
|
1132
|
+
console.error(`Tmux window not found for ${serverDir} (state kept).`);
|
|
699
1133
|
}
|
|
700
|
-
|
|
701
|
-
|
|
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 —
|
|
1140
|
+
// Subcommand: resurrect — recreate a project window from its state
|
|
707
1141
|
// ---------------------------------------------------------------------------
|
|
708
1142
|
|
|
709
|
-
function cmdResurrect(targetDir
|
|
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
|
|
718
|
-
|
|
1157
|
+
const winIdx = windowByDir(dir);
|
|
1158
|
+
if (winIdx != null) {
|
|
1159
|
+
console.error(`Project window for ${dir} already exists.`);
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
719
1162
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
|
737
|
-
let
|
|
1248
|
+
let serverArg = null;
|
|
1249
|
+
let force = false;
|
|
738
1250
|
const positional = [];
|
|
739
1251
|
let dirIsForced = false;
|
|
740
1252
|
|
|
741
|
-
|
|
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 === '--
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
|
|
769
|
-
|
|
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
|
|
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'
|
|
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 === '
|
|
801
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
872
|
-
} else if (subcommand === '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
910
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|