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