agentp 2.0.1 → 2.1.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 +7 -5
- package/README.md +97 -23
- package/bin/agentp +3 -6
- package/bin/ocmux +564 -244
- package/bin/tgagentp +121 -130
- package/docs/specification.md +2 -1
- package/docs/specification_v2.md +188 -624
- package/lib/ocmux.js +26 -289
- package/lib/opencode.js +186 -43
- package/lib/tui-cmd.js +12 -5
- package/lib/tui-registry.js +313 -0
- package/package.json +1 -1
package/bin/ocmux
CHANGED
|
@@ -5,26 +5,11 @@ const path = require('path');
|
|
|
5
5
|
const readline = require('readline');
|
|
6
6
|
const http = require('http');
|
|
7
7
|
const { URL } = require('url');
|
|
8
|
-
const
|
|
8
|
+
const child_process = require('child_process');
|
|
9
9
|
const os = require('os');
|
|
10
|
-
const {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
tuiPaneId,
|
|
14
|
-
windowByDir,
|
|
15
|
-
windowNameByIndex,
|
|
16
|
-
activeWindowIndex,
|
|
17
|
-
listProjects,
|
|
18
|
-
activateProject,
|
|
19
|
-
relaunchTui,
|
|
20
|
-
closeProjectWindow,
|
|
21
|
-
createProjectWindow,
|
|
22
|
-
ensureSession,
|
|
23
|
-
pinWindowName,
|
|
24
|
-
sleep,
|
|
25
|
-
tuiAttachCommand,
|
|
26
|
-
} = require('../lib/ocmux');
|
|
27
|
-
const { listSessions, createSession, createSessionWithModel, updateSession, listModels, switchModel, resolveModelRef, getAuthHeaders, sortSessionsByRecency, deleteSession, listAgents, getActiveSessions } = require('../lib/opencode');
|
|
10
|
+
const { tuiArgs, switchTui } = require('../lib/ocmux');
|
|
11
|
+
const tuiRegistry = require('../lib/tui-registry');
|
|
12
|
+
const { listSessions, listProjects: listOpenCodeProjects, createSession, createSessionWithModel, updateSession, listModels, switchModel, resolveModelRef, getAuthHeaders, sortSessionsByRecency, deleteSession, listAgents, getActiveSessions, getSession } = require('../lib/opencode');
|
|
28
13
|
const projectState = require('../lib/project-state');
|
|
29
14
|
|
|
30
15
|
// ---------------------------------------------------------------------------
|
|
@@ -41,15 +26,6 @@ const DEFAULT_SERVER = process.env.OCMUX_SERVER || 'http://localhost:4096';
|
|
|
41
26
|
// Helpers
|
|
42
27
|
// ---------------------------------------------------------------------------
|
|
43
28
|
|
|
44
|
-
function tmux(args) {
|
|
45
|
-
const result = spawnSync('tmux', args, { encoding: 'utf8', maxBuffer: 1024 * 1024 });
|
|
46
|
-
if (result.error && result.error.code === 'ENOENT') {
|
|
47
|
-
console.error('Error: tmux is not installed or not in PATH');
|
|
48
|
-
process.exit(1);
|
|
49
|
-
}
|
|
50
|
-
return result;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
29
|
function findParentStatefile(startDir) {
|
|
54
30
|
let dir = path.resolve(startDir);
|
|
55
31
|
while (true) {
|
|
@@ -93,18 +69,12 @@ function resolveGitDir(startDir, requireDir, flagLabel) {
|
|
|
93
69
|
if (!result.dir) {
|
|
94
70
|
const label = requireDir ? '.git directory' : '.git entry';
|
|
95
71
|
console.error(`Error: no ${label} found in ${startDir} or any of its parents.`);
|
|
96
|
-
console.error(`Run '${SCRIPT} serve' without ${flagLabel} to
|
|
72
|
+
console.error(`Run '${SCRIPT} serve' without ${flagLabel} to initialize the current directory.`);
|
|
97
73
|
process.exit(1);
|
|
98
74
|
}
|
|
99
75
|
return result.dir;
|
|
100
76
|
}
|
|
101
77
|
|
|
102
|
-
function findWindowsByBasename(name) {
|
|
103
|
-
const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_name}']);
|
|
104
|
-
if (r.status !== 0) return [];
|
|
105
|
-
return r.stdout.trim().split('\n').filter(Boolean).filter(w => path.basename(w) === name);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
78
|
function resolveDir(arg) {
|
|
109
79
|
const resolved = path.resolve(arg);
|
|
110
80
|
try {
|
|
@@ -112,17 +82,6 @@ function resolveDir(arg) {
|
|
|
112
82
|
} catch {
|
|
113
83
|
// not a valid path or inaccessible
|
|
114
84
|
}
|
|
115
|
-
|
|
116
|
-
if (!arg.includes('/')) {
|
|
117
|
-
const matches = findWindowsByBasename(arg);
|
|
118
|
-
if (matches.length === 1) return matches[0];
|
|
119
|
-
if (matches.length > 1) {
|
|
120
|
-
console.error(`Error: more than one match for '${arg}':`);
|
|
121
|
-
for (const m of matches) console.error(` ${m}`);
|
|
122
|
-
process.exit(1);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
85
|
console.error(`Error: invalid directory '${arg}'`);
|
|
127
86
|
console.error(`Run '${SCRIPT} -h' for help.`);
|
|
128
87
|
process.exit(1);
|
|
@@ -154,6 +113,45 @@ function checkServer(server) {
|
|
|
154
113
|
});
|
|
155
114
|
}
|
|
156
115
|
|
|
116
|
+
function refreshTui(directory, server, session) {
|
|
117
|
+
const result = switchTui(directory, server, session, path.resolve(process.argv[1]));
|
|
118
|
+
if (!result.ok && result.reason === 'failed') {
|
|
119
|
+
console.error(`Warning: session selected, but the ${result.kind} TUI could not be refreshed: ${result.error}`);
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function configuredProjects(server) {
|
|
125
|
+
const [projects, sessions] = await Promise.all([
|
|
126
|
+
listOpenCodeProjects(server),
|
|
127
|
+
listSessions(server),
|
|
128
|
+
]);
|
|
129
|
+
const rows = [];
|
|
130
|
+
const seen = new Set();
|
|
131
|
+
const directories = [
|
|
132
|
+
...(projects || []).map(project => project.canonical || project.directory),
|
|
133
|
+
...(sessions || []).map(session => session.location && session.location.directory),
|
|
134
|
+
];
|
|
135
|
+
for (const dir of directories) {
|
|
136
|
+
if (!dir) continue;
|
|
137
|
+
let canonical;
|
|
138
|
+
try { canonical = fs.realpathSync(dir); } catch { canonical = path.resolve(dir); }
|
|
139
|
+
if (seen.has(canonical)) continue;
|
|
140
|
+
const sf = path.join(canonical, '.ocmux.json');
|
|
141
|
+
const state = projectState.readProjectState(sf);
|
|
142
|
+
if (!state) continue;
|
|
143
|
+
seen.add(canonical);
|
|
144
|
+
const target = tuiRegistry.resolve(canonical);
|
|
145
|
+
rows.push({
|
|
146
|
+
dir: canonical,
|
|
147
|
+
status: target ? target.kind : 'headless',
|
|
148
|
+
session: state.session,
|
|
149
|
+
server: state.server || server,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return rows.sort((a, b) => a.dir.localeCompare(b.dir));
|
|
153
|
+
}
|
|
154
|
+
|
|
157
155
|
function newestSessionId(sessions) {
|
|
158
156
|
if (!sessions || sessions.length === 0) return null;
|
|
159
157
|
// Implicit targets skip child/subagent sessions (parentID set) — OpenCode's
|
|
@@ -190,6 +188,14 @@ function padLeft(s, w) {
|
|
|
190
188
|
return ' '.repeat(Math.max(0, w - str.length)) + str;
|
|
191
189
|
}
|
|
192
190
|
|
|
191
|
+
// ── Palette (classic 16-colour ANSI) ─────────────────────────────────────────
|
|
192
|
+
// Colour 3 renders as brown/dark-yellow; bold colour 3 as light yellow. The
|
|
193
|
+
// whole list UI (title/status bars, cursor pointer, info labels) sticks to these
|
|
194
|
+
// two shades so the chrome reads as one theme.
|
|
195
|
+
const BROWN = '\x1b[33m'; // text: brown/dark-yellow
|
|
196
|
+
const BAR_BG = '\x1b[30;43m'; // title/status bar: black on brown
|
|
197
|
+
const LIGHT_YELLOW = '\x1b[1;33m'; // text: light-yellow (bold brown)
|
|
198
|
+
|
|
193
199
|
// ── '/' incremental search for interactive lists ──────────────────────────
|
|
194
200
|
// Filter `items` by `pattern`: case-insensitive, space-separated tokens that
|
|
195
201
|
// must ALL be substrings of `textOf(item)`. Empty pattern → list unchanged.
|
|
@@ -278,8 +284,8 @@ function handleSearch(str, key, s) {
|
|
|
278
284
|
return 'pass';
|
|
279
285
|
}
|
|
280
286
|
|
|
281
|
-
// The
|
|
282
|
-
//
|
|
287
|
+
// The status bar's search line: normal key hints, or the pattern while a '/'
|
|
288
|
+
// filter is active (live = input with caret; committed = pattern kept).
|
|
283
289
|
// The caller adds the `Enter: confirm · Esc: cancel` hint on the right via
|
|
284
290
|
// renderList's `footerRight`.
|
|
285
291
|
const SEARCH_HINT = 'Enter: confirm · Esc: cancel';
|
|
@@ -289,6 +295,23 @@ function searchFooter(s, base) {
|
|
|
289
295
|
return s.live ? `Search: ${shown}` : `Search: ${shown} · /: edit`;
|
|
290
296
|
}
|
|
291
297
|
|
|
298
|
+
// ── Model picker ordering ─────────────────────────────────────────────────────
|
|
299
|
+
// Models sorted by provider then id (the provider already leads the label, so
|
|
300
|
+
// this is also alphabetical by display order).
|
|
301
|
+
function sortModels(models) {
|
|
302
|
+
const key = (m) => `${String(m.providerID || '').toLowerCase()}/${String(m.id || '').toLowerCase()}`;
|
|
303
|
+
return [...(models || [])].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Index of `model` (as stored on a session: { providerID, id }) inside
|
|
307
|
+
// `models`, or -1 when unknown/unmatched — so a picker can open on it.
|
|
308
|
+
function indexOfModel(models, model) {
|
|
309
|
+
if (!model || !models || !models.length) return -1;
|
|
310
|
+
const key = (m) => `${String(m.providerID || '').toLowerCase()}/${String(m.id || '').toLowerCase()}`;
|
|
311
|
+
const target = key(model);
|
|
312
|
+
return models.findIndex((m) => key(m) === target);
|
|
313
|
+
}
|
|
314
|
+
|
|
292
315
|
// Sentinel returned by switchMenu on Ctrl+C so the caller fully exits the CLI
|
|
293
316
|
// (instead of just returning to the session menu).
|
|
294
317
|
const SWITCH_QUIT = Symbol('ocmux-switch-quit');
|
|
@@ -376,13 +399,14 @@ function labeledLine(label, value, cols, labelColor = '\x1b[1m') {
|
|
|
376
399
|
return `${labelColor}${lbl}\x1b[0m ${v}`;
|
|
377
400
|
}
|
|
378
401
|
|
|
379
|
-
// Info lines about the selected session: title + location
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
402
|
+
// Info lines about the selected session: title + location and a responsive
|
|
403
|
+
// grid. Fields are ALWAYS present (placeholders when unknown).
|
|
404
|
+
// Labels are brown (the same shade as the title/status bars); when `highlight`
|
|
405
|
+
// is true (the cursor is on the session currently selected in the TUI) they are
|
|
406
|
+
// light-yellow instead.
|
|
383
407
|
function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false) {
|
|
384
408
|
if (!s) return [];
|
|
385
|
-
const labelColor = highlight ?
|
|
409
|
+
const labelColor = highlight ? LIGHT_YELLOW : BROWN;
|
|
386
410
|
const title = (s.title && s.title.trim()) || 'Untitled';
|
|
387
411
|
const location = (s.location && s.location.directory) || 'Unknown';
|
|
388
412
|
const out = [
|
|
@@ -423,13 +447,13 @@ function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false)
|
|
|
423
447
|
}
|
|
424
448
|
|
|
425
449
|
// Render a scrollable list into the alternate screen.
|
|
426
|
-
// heading -> centered
|
|
450
|
+
// heading -> centered title bar (brown background)
|
|
427
451
|
// row(index, item) -> string | { text, reverse }
|
|
428
452
|
// extraLines -> prompt/confirm lines right after the list (normal colors)
|
|
429
|
-
// footer ->
|
|
453
|
+
// footer -> status bar (brown background), key hints right-aligned
|
|
430
454
|
// (left-aligned when footerLeft is set — used by the search line)
|
|
431
455
|
// footerRight-> optional hint pinned to the right end of a left-aligned footer
|
|
432
|
-
// infoLines -> details below the
|
|
456
|
+
// infoLines -> details below the status bar (colours supplied by the caller)
|
|
433
457
|
// A "(start-end/total)" range is appended to the heading when scrolling.
|
|
434
458
|
function renderList({ title, items, cursor, row, footer, infoLines = [], extraLines = [], cols: colsArg, rows: rowsArg, footerLeft = false, footerRight = null }) {
|
|
435
459
|
const cols = colsArg || process.stdout.columns || 80;
|
|
@@ -449,7 +473,7 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
449
473
|
}
|
|
450
474
|
|
|
451
475
|
let out = '\x1b[2J\x1b[H';
|
|
452
|
-
out +=
|
|
476
|
+
out += BAR_BG + centerIn(titleLine, cols) + '\x1b[0m\n\n';
|
|
453
477
|
|
|
454
478
|
let rendered = 0;
|
|
455
479
|
if (items.length === 0) {
|
|
@@ -461,8 +485,11 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
461
485
|
const r = row(idx, items[idx]);
|
|
462
486
|
const text = r && typeof r === 'object' ? r.text : r;
|
|
463
487
|
const reverse = r && typeof r === 'object' && r.reverse;
|
|
464
|
-
|
|
465
|
-
|
|
488
|
+
// The pointer sits outside the row's own styling so a light-yellow arrow
|
|
489
|
+
// stays visible even on a reverse-video row.
|
|
490
|
+
const ptr = idx === cursor ? `${LIGHT_YELLOW}▶\x1b[0m ` : ' ';
|
|
491
|
+
const body = text == null ? '' : text;
|
|
492
|
+
out += ptr + (reverse ? `\x1b[7m${body}\x1b[0m` : body) + '\n';
|
|
466
493
|
rendered++;
|
|
467
494
|
}
|
|
468
495
|
}
|
|
@@ -485,7 +512,7 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
485
512
|
} else {
|
|
486
513
|
hint = padLeft(footer, cols);
|
|
487
514
|
}
|
|
488
|
-
out +=
|
|
515
|
+
out += BAR_BG + hint + '\x1b[0m\n';
|
|
489
516
|
for (const l of infos) out += l + '\n';
|
|
490
517
|
return out.replace(/\n$/, '');
|
|
491
518
|
}
|
|
@@ -496,7 +523,9 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
496
523
|
// q/Ctrl+C quit. Input mode offers readline-style caret editing.
|
|
497
524
|
// Resolves on quit (null) or with the string 'switch-project' (caller then runs
|
|
498
525
|
// the project switcher and re-invokes this picker for the new project).
|
|
499
|
-
|
|
526
|
+
// `dir` names the project this list belongs to — it heads the title bar so a
|
|
527
|
+
// multi-project run always says which project's sessions are on screen.
|
|
528
|
+
function sessionMenu(sessions, currentSession, annotations, server, { onPick, onRename, onDelete, onReminder, runningIds, modelContext, onBroadcastChange, onInspectSession, dir, notices = [] } = {}) {
|
|
500
529
|
return new Promise((resolve) => {
|
|
501
530
|
if (!process.stdin.isTTY) return resolve(null);
|
|
502
531
|
|
|
@@ -618,7 +647,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
618
647
|
Math.max(8, cols - (showTime ? TIME_W + 10 : 8)),
|
|
619
648
|
);
|
|
620
649
|
const inBc = mode === 'broadcast' || mode === 'broadcast-confirm';
|
|
621
|
-
const extras = [];
|
|
650
|
+
const extras = [...notices];
|
|
622
651
|
if ((mode === 'confirm' || mode === 'broadcast-confirm') && confirmDel) {
|
|
623
652
|
extras.push(`Delete session "${confirmDel.title || confirmDel.id || ''}"? (y/N) `);
|
|
624
653
|
} else if (mode === 'input' && input) {
|
|
@@ -638,7 +667,9 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
638
667
|
}
|
|
639
668
|
footer = searchFooter(search, footer);
|
|
640
669
|
process.stderr.write(renderList({
|
|
641
|
-
title
|
|
670
|
+
// The project name heads the title so you always know which project's
|
|
671
|
+
// sessions are listed (matters when --all-projects moves you around).
|
|
672
|
+
title: dir ? `ocmux — ${path.basename(dir)} sessions` : 'ocmux — sessions',
|
|
642
673
|
items: view,
|
|
643
674
|
cursor,
|
|
644
675
|
row: (i, s) => {
|
|
@@ -649,8 +680,8 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
649
680
|
const hasNote = annotations && annotations[s.id] ? ' ◈' : '';
|
|
650
681
|
const spin = (runningIds && runningIds.has(s.id)) ? SPINNER[spinIdx % SPINNER.length] + ' ' : ' ';
|
|
651
682
|
const text = `${sel}${spin}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
|
|
652
|
-
//
|
|
653
|
-
return String(s.id) === String(current) ?
|
|
683
|
+
// Brown for the session currently selected in the TUI window.
|
|
684
|
+
return String(s.id) === String(current) ? `${BROWN}${text}\x1b[0m` : text;
|
|
654
685
|
},
|
|
655
686
|
footer,
|
|
656
687
|
footerLeft: search.active,
|
|
@@ -698,6 +729,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
698
729
|
applySearch();
|
|
699
730
|
const ci = view.indexOf(created);
|
|
700
731
|
cursor = ci >= 0 ? ci : 0;
|
|
732
|
+
current = created.id;
|
|
701
733
|
} else if (!row.new) {
|
|
702
734
|
current = row.id;
|
|
703
735
|
}
|
|
@@ -802,7 +834,9 @@ const toggleBroadcast = (idx) => {
|
|
|
802
834
|
draw();
|
|
803
835
|
};
|
|
804
836
|
|
|
805
|
-
|
|
837
|
+
// `startCursor` lets a picker open with the cursor already on the relevant
|
|
838
|
+
// entry (the model pickers open on the current model).
|
|
839
|
+
const openList = (title, items, format, onEnter, returnMode = 'normal', startCursor = 0) => {
|
|
806
840
|
if (!items || items.length === 0) {
|
|
807
841
|
process.stderr.write(`\nNo ${title.toLowerCase()} available.\n`);
|
|
808
842
|
draw();
|
|
@@ -810,7 +844,7 @@ const toggleBroadcast = (idx) => {
|
|
|
810
844
|
}
|
|
811
845
|
list = { title, items, view: items, format, onEnter, returnMode };
|
|
812
846
|
listSearch = newSearchState();
|
|
813
|
-
listCursor = 0;
|
|
847
|
+
listCursor = Math.max(0, Math.min(startCursor, items.length - 1));
|
|
814
848
|
mode = 'list';
|
|
815
849
|
draw();
|
|
816
850
|
};
|
|
@@ -820,6 +854,16 @@ const toggleBroadcast = (idx) => {
|
|
|
820
854
|
draw();
|
|
821
855
|
};
|
|
822
856
|
|
|
857
|
+
// Open the model picker with the list sorted by provider and the cursor
|
|
858
|
+
// already on `session`'s current model (so `m` starts where the change is).
|
|
859
|
+
const openModelPicker = (title, session, returnMode, onPick) => {
|
|
860
|
+
listModels(server).then((models) => {
|
|
861
|
+
const sorted = sortModels(models);
|
|
862
|
+
openList(title, sorted, (x) => `${x.label}${x.variant ? `#${x.variant}` : ''}`,
|
|
863
|
+
onPick, returnMode, indexOfModel(sorted, session && session.model));
|
|
864
|
+
}).catch(commandErr);
|
|
865
|
+
};
|
|
866
|
+
|
|
823
867
|
process.stdin.on('keypress', (str, key) => {
|
|
824
868
|
// Ctrl+C always fully exits ocmux, from any menu.
|
|
825
869
|
if (key.ctrl && key.name === 'c') {
|
|
@@ -950,11 +994,9 @@ const toggleBroadcast = (idx) => {
|
|
|
950
994
|
draw();
|
|
951
995
|
}
|
|
952
996
|
} else if (key.name === 'm') {
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
}, 'broadcast');
|
|
957
|
-
}).catch(commandErr);
|
|
997
|
+
openModelPicker('Broadcast model (all selected sessions)', view[cursor], 'broadcast', async (item) => {
|
|
998
|
+
await Promise.all([...broadcastSel].map(id => switchModel(server, id, item)));
|
|
999
|
+
});
|
|
958
1000
|
} else if (key.name === 'return' || key.name === 'enter') {
|
|
959
1001
|
if (cursor < view.length) {
|
|
960
1002
|
mode = 'normal';
|
|
@@ -1006,12 +1048,7 @@ const toggleBroadcast = (idx) => {
|
|
|
1006
1048
|
}
|
|
1007
1049
|
if (key.name === 'm') {
|
|
1008
1050
|
const target = view[cursor];
|
|
1009
|
-
if (target)
|
|
1010
|
-
listModels(server).then((models) => {
|
|
1011
|
-
openList('Models', models, (x) => `${x.label}${x.variant ? `#${x.variant}` : ''}`, (item) =>
|
|
1012
|
-
switchModel(server, target.id, item));
|
|
1013
|
-
}).catch(commandErr);
|
|
1014
|
-
}
|
|
1051
|
+
if (target) openModelPicker('Models', target, 'normal', (item) => switchModel(server, target.id, item));
|
|
1015
1052
|
return;
|
|
1016
1053
|
}
|
|
1017
1054
|
if (key.name === 'd') {
|
|
@@ -1053,8 +1090,19 @@ const toggleBroadcast = (idx) => {
|
|
|
1053
1090
|
});
|
|
1054
1091
|
}
|
|
1055
1092
|
|
|
1056
|
-
// Interactive project picker —
|
|
1057
|
-
|
|
1093
|
+
// Interactive project picker — never writes state (it only moves what a
|
|
1094
|
+
// registered dedicated/shared TUI displays).
|
|
1095
|
+
//
|
|
1096
|
+
// Default (safe): an INSPECTOR. Selecting a project/session row still focuses
|
|
1097
|
+
// the routed TUI and shows the session there, but the promise always resolves to
|
|
1098
|
+
// null, so the caller re-opens its own project's list. Nothing about the
|
|
1099
|
+
// caller's project changes.
|
|
1100
|
+
//
|
|
1101
|
+
// `{ allProjects: true }` (CLI `--all-projects`): a real SWITCHER. Selecting a
|
|
1102
|
+
// row also records the target directory, so the caller moves its picker there
|
|
1103
|
+
// and can then write that project's `.ocmux.json` — the file `agentp` reads in
|
|
1104
|
+
// that directory.
|
|
1105
|
+
function switchMenu(rows, { allProjects = false, onOpen } = {}) {
|
|
1058
1106
|
return new Promise((resolve) => {
|
|
1059
1107
|
if (!process.stdin.isTTY) {
|
|
1060
1108
|
console.error('Error: "switch" is interactive and requires a TTY.');
|
|
@@ -1074,22 +1122,53 @@ function switchMenu(rows, longMode) {
|
|
|
1074
1122
|
const lineWidth = Math.max(cols, nameW + 2 + stsW + 2 + ssnW + 6);
|
|
1075
1123
|
|
|
1076
1124
|
let cursor = 0;
|
|
1077
|
-
let
|
|
1078
|
-
let lastDir = null;
|
|
1125
|
+
let lastDir = null; // target dir to move to (only with allProjects)
|
|
1079
1126
|
let done = false;
|
|
1080
1127
|
let help = false;
|
|
1081
1128
|
const search = newSearchState();
|
|
1082
|
-
let view =
|
|
1129
|
+
let view = []; // flattened nodes: { kind:'project'|'session', ... }
|
|
1130
|
+
// Fold state per project directory: { open, sessions, loading, error }.
|
|
1131
|
+
const folds = new Map();
|
|
1132
|
+
|
|
1133
|
+
const foldOf = (p) => folds.get(p.dir) || { open: false, sessions: null, loading: false, error: null };
|
|
1134
|
+
const projectText = (p) => `${path.basename(p.dir)} ${p.dir} ${p.session || ''}`;
|
|
1135
|
+
const sessionText = (s) => `${s.title || ''} ${s.id || ''}`;
|
|
1136
|
+
const matches = (pattern, text) => filterByPattern([1], pattern, () => text).length > 0;
|
|
1137
|
+
|
|
1138
|
+
// Flatten projects (plus their unfolded sessions) honouring the '/' pattern.
|
|
1139
|
+
// A project header survives when it matches itself OR when one of its
|
|
1140
|
+
// unfolded sessions does, so a match deeper down is still navigable.
|
|
1141
|
+
const buildView = () => {
|
|
1142
|
+
const pattern = search.active ? search.value : '';
|
|
1143
|
+
const out = [];
|
|
1144
|
+
for (const p of rows) {
|
|
1145
|
+
const f = foldOf(p);
|
|
1146
|
+
const sessions = f.open && f.sessions ? f.sessions : [];
|
|
1147
|
+
const shown = sessions.filter((s) => matches(pattern, sessionText(s)));
|
|
1148
|
+
const header = { kind: 'project', project: p };
|
|
1149
|
+
if (sessions.length === 0) {
|
|
1150
|
+
if (matches(pattern, projectText(p))) out.push(header);
|
|
1151
|
+
} else if (matches(pattern, projectText(p)) || shown.length) {
|
|
1152
|
+
out.push(header);
|
|
1153
|
+
for (const s of shown) out.push({ kind: 'session', project: p, session: s });
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
return out;
|
|
1157
|
+
};
|
|
1083
1158
|
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
if (cursor >= view.length) cursor = Math.max(0, view.length - 1);
|
|
1159
|
+
const sameNode = (a, b) => {
|
|
1160
|
+
if (!a || !b || a.kind !== b.kind || a.project.dir !== b.project.dir) return false;
|
|
1161
|
+
return a.kind !== 'session' || a.session.id === b.session.id;
|
|
1088
1162
|
};
|
|
1089
1163
|
|
|
1090
|
-
const
|
|
1091
|
-
|
|
1164
|
+
const applySearch = () => {
|
|
1165
|
+
const prev = view[cursor];
|
|
1166
|
+
view = buildView();
|
|
1167
|
+
const idx = prev ? view.findIndex((n) => sameNode(n, prev)) : -1;
|
|
1168
|
+
if (idx >= 0) cursor = idx;
|
|
1169
|
+
else if (cursor >= view.length) cursor = Math.max(0, view.length - 1);
|
|
1092
1170
|
};
|
|
1171
|
+
applySearch();
|
|
1093
1172
|
|
|
1094
1173
|
const cleanup = () => {
|
|
1095
1174
|
process.stderr.write('\x1b[?25h');
|
|
@@ -1108,53 +1187,130 @@ function switchMenu(rows, longMode) {
|
|
|
1108
1187
|
resolve(value === undefined ? lastDir : value);
|
|
1109
1188
|
};
|
|
1110
1189
|
|
|
1190
|
+
// Mode-dependent wording: the switcher inspects by default and really
|
|
1191
|
+
// switches only under --all-projects.
|
|
1192
|
+
const switcherTitle = allProjects ? 'ocmux — project switcher (all projects)' : 'ocmux — project switcher';
|
|
1193
|
+
const enterHint = allProjects ? 'Enter: switch' : 'Enter: view';
|
|
1194
|
+
const modeNote = allProjects
|
|
1195
|
+
? ['--all-projects: selecting moves the picker to that project, so a',
|
|
1196
|
+
'session picked there updates ITS .ocmux.json — the file agentp',
|
|
1197
|
+
'reads when it runs in that directory.']
|
|
1198
|
+
: ['Selecting only moves the TUI view — .ocmux.json is never',
|
|
1199
|
+
'written, so agentp keeps prompting your own project’s session.',
|
|
1200
|
+
'You always come back to your own project’s list.'];
|
|
1201
|
+
|
|
1111
1202
|
const draw = () => {
|
|
1203
|
+
if (done) return;
|
|
1112
1204
|
if (help) {
|
|
1113
1205
|
let out = '\x1b[2J\x1b[H';
|
|
1114
1206
|
out += 'ocmux project switcher — help\n\n';
|
|
1115
|
-
out += ' j / k, arrows move the cursor\n';
|
|
1116
|
-
out +=
|
|
1207
|
+
out += ' j / k, arrows move the cursor (projects and sessions)\n';
|
|
1208
|
+
out += ` Enter ${allProjects ? 'switch the picker to the selected project' : 'focus the selected project (menu stays open)'}\n`;
|
|
1209
|
+
out += ' (on a session, show it in that project’s TUI)\n';
|
|
1210
|
+
out += ' Space fold/unfold a project’s sessions;\n';
|
|
1211
|
+
out += ' on a session, show it in the TUI as well\n';
|
|
1117
1212
|
out += ' / search/filter the list (Enter keeps, ESC clears)\n';
|
|
1118
1213
|
out += ' h toggle this help\n';
|
|
1119
1214
|
out += ' q return to the session menu\n';
|
|
1120
1215
|
out += ' Ctrl+C quit ocmux\n';
|
|
1216
|
+
out += '\n';
|
|
1217
|
+
for (const l of modeNote) out += `${l}\n`;
|
|
1121
1218
|
out += '\nPress any key to return.\n';
|
|
1122
1219
|
process.stderr.write(out);
|
|
1123
1220
|
return;
|
|
1124
1221
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1222
|
+
const extras = [];
|
|
1223
|
+
if (search.active && view.length === 0) extras.push(`No projects match "${search.value}"`);
|
|
1224
|
+
for (const n of view) {
|
|
1225
|
+
if (n.kind !== 'project') continue;
|
|
1226
|
+
const f = folds.get(n.project.dir);
|
|
1227
|
+
if (!f || !f.open) continue;
|
|
1228
|
+
const name = path.basename(n.project.dir);
|
|
1229
|
+
if (f.loading) extras.push(` … loading sessions of ${name} …`);
|
|
1230
|
+
else if (f.error) extras.push(` ! ${name}: ${f.error}`);
|
|
1231
|
+
}
|
|
1127
1232
|
process.stderr.write(renderList({
|
|
1128
|
-
title:
|
|
1233
|
+
title: switcherTitle,
|
|
1129
1234
|
items: view,
|
|
1130
1235
|
cursor,
|
|
1131
|
-
row: (i,
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1236
|
+
row: (i, node) => {
|
|
1237
|
+
if (node.kind === 'session') {
|
|
1238
|
+
const s = node.session;
|
|
1239
|
+
const id = String(s.id || '');
|
|
1240
|
+
const mark = String(s.id) === String(node.project.session) ? ' *' : '';
|
|
1241
|
+
const title = (s.title || id).slice(0, Math.max(8, nameW + 6));
|
|
1242
|
+
return ` ${title}${mark} ${id}`;
|
|
1243
|
+
}
|
|
1244
|
+
const p = node.project;
|
|
1245
|
+
const fold = foldOf(p).open ? '▾' : '▸';
|
|
1246
|
+
const session = p.session ? p.session.slice(0, 14) : '(none)';
|
|
1247
|
+
const text = `${fold} ${pad(path.basename(p.dir), nameW)} ${pad(p.status, stsW)} ${pad(session, ssnW)} ${p.dir}`;
|
|
1248
|
+
return text;
|
|
1135
1249
|
},
|
|
1136
|
-
footer: searchFooter(search,
|
|
1250
|
+
footer: searchFooter(search, `${enterHint} · Space: fold · /: search · h: help · q: back · Ctrl+C: quit`),
|
|
1137
1251
|
footerLeft: search.active,
|
|
1138
1252
|
footerRight: search.active ? SEARCH_HINT : null,
|
|
1139
1253
|
extraLines: extras,
|
|
1140
1254
|
}));
|
|
1141
1255
|
};
|
|
1142
1256
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1257
|
+
// Enter on a project row: show its current session in its routed TUI. With
|
|
1258
|
+
// allProjects off this stays a pure inspection and
|
|
1259
|
+
// `lastDir` is left null so the caller returns to its own project.
|
|
1260
|
+
const activate = (p) => {
|
|
1261
|
+
if (!p) return;
|
|
1145
1262
|
try {
|
|
1146
|
-
if (
|
|
1147
|
-
|
|
1148
|
-
} else {
|
|
1149
|
-
tmux(['select-window', '-t', `${SESSION}:${row.index}`]);
|
|
1150
|
-
}
|
|
1151
|
-
lastDir = row.dir;
|
|
1263
|
+
if (p.server && p.session && onOpen) onOpen(p, p.session);
|
|
1264
|
+
if (allProjects) lastDir = p.dir;
|
|
1152
1265
|
} catch (err) {
|
|
1153
1266
|
process.stderr.write(`\nError: ${err.message}\n`);
|
|
1154
1267
|
}
|
|
1155
1268
|
draw();
|
|
1156
1269
|
};
|
|
1157
1270
|
|
|
1271
|
+
// Enter/Space on a session row: move the TUI VIEW onto it. The switcher
|
|
1272
|
+
// itself never writes `.ocmux.json`; with allProjects on the caller only
|
|
1273
|
+
// moves its picker to that project — the write happens later, when a
|
|
1274
|
+
// session is picked there (and that file is what agentp reads there).
|
|
1275
|
+
const openSession = (node) => {
|
|
1276
|
+
const p = node.project;
|
|
1277
|
+
try {
|
|
1278
|
+
if (p.server && onOpen) onOpen(p, node.session.id);
|
|
1279
|
+
if (allProjects) lastDir = p.dir;
|
|
1280
|
+
} catch (err) {
|
|
1281
|
+
process.stderr.write(`\nError: ${err.message}\n`);
|
|
1282
|
+
}
|
|
1283
|
+
draw();
|
|
1284
|
+
};
|
|
1285
|
+
|
|
1286
|
+
// Space on a project row: fetch its sessions once, then flip the fold.
|
|
1287
|
+
const toggleFold = (p) => {
|
|
1288
|
+
const f = folds.get(p.dir) || { open: false, sessions: null, loading: false, error: null };
|
|
1289
|
+
folds.set(p.dir, f);
|
|
1290
|
+
if (!f.open && !f.sessions && !f.error && !f.loading) {
|
|
1291
|
+
if (!p.server) {
|
|
1292
|
+
f.error = 'no server recorded';
|
|
1293
|
+
} else {
|
|
1294
|
+
f.loading = true;
|
|
1295
|
+
f.open = true;
|
|
1296
|
+
applySearch();
|
|
1297
|
+
draw();
|
|
1298
|
+
listSessions(p.server, p.dir)
|
|
1299
|
+
.then((list) => {
|
|
1300
|
+
// Main sessions only: child/subagent sessions are work products,
|
|
1301
|
+
// not navigation targets (same rule as the session picker).
|
|
1302
|
+
f.sessions = sortSessionsByRecency((list || []).filter((s) => !s.parentID));
|
|
1303
|
+
})
|
|
1304
|
+
.catch((err) => { f.error = err.message; })
|
|
1305
|
+
.then(() => { f.loading = false; applySearch(); draw(); });
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
f.open = !f.open;
|
|
1310
|
+
applySearch();
|
|
1311
|
+
draw();
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1158
1314
|
const onSig = () => finish(SWITCH_QUIT);
|
|
1159
1315
|
process.on('SIGINT', onSig);
|
|
1160
1316
|
|
|
@@ -1167,14 +1323,19 @@ function switchMenu(rows, longMode) {
|
|
|
1167
1323
|
if (key.name === 'h') { help = true; draw(); return; }
|
|
1168
1324
|
if (key.name === 'q') return finish();
|
|
1169
1325
|
if (view.length === 0) return;
|
|
1326
|
+
const node = view[cursor];
|
|
1170
1327
|
if (key.name === 'up' || str === 'k') {
|
|
1171
1328
|
cursor = (cursor + view.length - 1) % view.length;
|
|
1172
1329
|
draw();
|
|
1173
1330
|
} else if (key.name === 'down' || str === 'j') {
|
|
1174
1331
|
cursor = (cursor + 1) % view.length;
|
|
1175
1332
|
draw();
|
|
1176
|
-
} else if (key.name === 'return' || key.name === 'enter'
|
|
1177
|
-
activate(
|
|
1333
|
+
} else if (key.name === 'return' || key.name === 'enter') {
|
|
1334
|
+
if (node.kind === 'project') activate(node.project);
|
|
1335
|
+
else openSession(node);
|
|
1336
|
+
} else if (key.name === 'space') {
|
|
1337
|
+
if (node.kind === 'project') toggleFold(node.project);
|
|
1338
|
+
else openSession(node);
|
|
1178
1339
|
}
|
|
1179
1340
|
});
|
|
1180
1341
|
|
|
@@ -1191,13 +1352,18 @@ function switchMenu(rows, longMode) {
|
|
|
1191
1352
|
// Subcommand: default — interactive session switcher for the current project
|
|
1192
1353
|
// ---------------------------------------------------------------------------
|
|
1193
1354
|
|
|
1194
|
-
|
|
1355
|
+
// `allProjects` (CLI `--all-projects`) unlocks cross-project switching: the
|
|
1356
|
+
// project switcher may then move this picker onto another project, whose
|
|
1357
|
+
// `.ocmux.json` gets written when a session is picked there. Without it the
|
|
1358
|
+
// switcher is inspection-only and this command always stays on `targetDir`'s
|
|
1359
|
+
// project.
|
|
1360
|
+
async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
1195
1361
|
let target = targetDir;
|
|
1196
1362
|
for (;;) {
|
|
1197
1363
|
const sf = findParentStatefile(target);
|
|
1198
1364
|
if (!sf) {
|
|
1199
1365
|
console.error(`Error: no .ocmux.json found in ${target} or any of its parents.`);
|
|
1200
|
-
|
|
1366
|
+
console.error(`Run 'ocmux serve' to initialize the project.`);
|
|
1201
1367
|
process.exit(1);
|
|
1202
1368
|
}
|
|
1203
1369
|
const state = projectState.readProjectState(sf);
|
|
@@ -1216,22 +1382,24 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
1216
1382
|
process.exit(1);
|
|
1217
1383
|
}
|
|
1218
1384
|
|
|
1219
|
-
const winIdx = windowByDir(dir);
|
|
1220
|
-
if (winIdx == null) {
|
|
1221
|
-
console.error(`Error: no tmux window for ${dir}.`);
|
|
1222
|
-
console.error(`Run 'ocmux serve' to create it.`);
|
|
1223
|
-
process.exit(1);
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
// Focus this project's window up-front so the picker (and any session
|
|
1227
|
-
// switch) never leaves another project's window selected.
|
|
1228
|
-
activateProject(dir, winIdx, server, state.session);
|
|
1229
|
-
|
|
1230
1385
|
const sessions = await listSessions(server, dir).catch((err) => {
|
|
1231
1386
|
console.error(`Error connecting to ${server}: ${err.message}`);
|
|
1232
1387
|
process.exit(1);
|
|
1233
1388
|
});
|
|
1234
1389
|
const sorted = sortSessionsByRecency(sessions);
|
|
1390
|
+
const notices = [];
|
|
1391
|
+
if (state.session && !sorted.some(s => String(s.id) === String(state.session))) {
|
|
1392
|
+
try {
|
|
1393
|
+
const moved = await getSession(server, state.session);
|
|
1394
|
+
const movedDir = moved && moved.location && moved.location.directory;
|
|
1395
|
+
if (movedDir && path.resolve(movedDir) !== path.resolve(dir)) {
|
|
1396
|
+
notices.push(`⚠ Current session moved to ${movedDir}`);
|
|
1397
|
+
notices.push(` Run: ocmux serve ${JSON.stringify(movedDir)} --server ${JSON.stringify(server)}`);
|
|
1398
|
+
}
|
|
1399
|
+
} catch {
|
|
1400
|
+
// A deleted/stale stored session is handled by the normal picker.
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1235
1403
|
// Only "main" sessions are listed in the picker: exclude child/subagent
|
|
1236
1404
|
// sessions (parentID set) — they are work products of a main session, not
|
|
1237
1405
|
// targets. Explicit --session can still reach one by id/title.
|
|
@@ -1246,7 +1414,7 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
1246
1414
|
|
|
1247
1415
|
const applySession = (id) => {
|
|
1248
1416
|
projectState.writeProjectState(sf, { session: id });
|
|
1249
|
-
|
|
1417
|
+
refreshTui(dir, server, id);
|
|
1250
1418
|
};
|
|
1251
1419
|
|
|
1252
1420
|
if (directRef) {
|
|
@@ -1262,8 +1430,7 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
1262
1430
|
}
|
|
1263
1431
|
|
|
1264
1432
|
if (!process.stdin.isTTY) {
|
|
1265
|
-
// Silent path (non-TTY):
|
|
1266
|
-
activateProject(dir, winIdx, server, state.session);
|
|
1433
|
+
// Silent path (non-TTY): project state already identifies the target.
|
|
1267
1434
|
return;
|
|
1268
1435
|
}
|
|
1269
1436
|
|
|
@@ -1273,10 +1440,12 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
1273
1440
|
let lastRef = (state.session && mainSessions.find(s => String(s.id) === String(state.session))) || mainSessions[0] || null;
|
|
1274
1441
|
const result = await new Promise((resolve) => {
|
|
1275
1442
|
sessionMenu(mainSessions, state.session, annotations, server, {
|
|
1443
|
+
dir,
|
|
1276
1444
|
runningIds,
|
|
1277
1445
|
modelContext,
|
|
1446
|
+
notices,
|
|
1278
1447
|
onBroadcastChange: (ids) => projectState.writeProjectState(sf, { broadcast: ids }),
|
|
1279
|
-
onInspectSession: (id) =>
|
|
1448
|
+
onInspectSession: (id) => refreshTui(dir, server, id),
|
|
1280
1449
|
onPick: async (row) => {
|
|
1281
1450
|
if (row.new) {
|
|
1282
1451
|
const created = await createSessionWithModel(server, row.name || undefined, dir, [lastRef, ...sorted]).catch((err) => {
|
|
@@ -1317,10 +1486,18 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
1317
1486
|
});
|
|
1318
1487
|
|
|
1319
1488
|
if (result === 'switch-project') {
|
|
1320
|
-
// 'p' pressed: open the project switcher.
|
|
1321
|
-
//
|
|
1322
|
-
|
|
1323
|
-
|
|
1489
|
+
// 'p' pressed: open the project switcher. Without --all-projects it only
|
|
1490
|
+
// inspects (picked stays null => re-run this picker for the same
|
|
1491
|
+
// project); with it, picking a project re-runs the picker there.
|
|
1492
|
+
// Ctrl+C fully exits.
|
|
1493
|
+
const rows = await configuredProjects(server).catch((err) => {
|
|
1494
|
+
console.error(`Error listing projects: ${err.message}`);
|
|
1495
|
+
return [];
|
|
1496
|
+
});
|
|
1497
|
+
const picked = await switchMenu(rows, {
|
|
1498
|
+
allProjects,
|
|
1499
|
+
onOpen: (project, id) => refreshTui(project.dir, project.server, id),
|
|
1500
|
+
});
|
|
1324
1501
|
if (picked === SWITCH_QUIT) break;
|
|
1325
1502
|
if (!picked) continue;
|
|
1326
1503
|
target = picked;
|
|
@@ -1331,7 +1508,7 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
1331
1508
|
}
|
|
1332
1509
|
|
|
1333
1510
|
// ---------------------------------------------------------------------------
|
|
1334
|
-
// Subcommand: serve —
|
|
1511
|
+
// Subcommand: serve — initialize project/session state (server is user-managed)
|
|
1335
1512
|
// ---------------------------------------------------------------------------
|
|
1336
1513
|
|
|
1337
1514
|
async function cmdServeNew(targetDir, serverArg, force) {
|
|
@@ -1340,7 +1517,7 @@ async function cmdServeNew(targetDir, serverArg, force) {
|
|
|
1340
1517
|
const sf = path.join(targetDir, '.ocmux.json');
|
|
1341
1518
|
if (fs.existsSync(sf)) {
|
|
1342
1519
|
if (!force) {
|
|
1343
|
-
console.error(`Error:
|
|
1520
|
+
console.error(`Error: project state already exists in ${targetDir}.`);
|
|
1344
1521
|
console.error(`Use 'ocmux' to switch sessions there, or 'ocmux serve --server <url> --force' to repoint it.`);
|
|
1345
1522
|
process.exit(1);
|
|
1346
1523
|
}
|
|
@@ -1350,11 +1527,10 @@ async function cmdServeNew(targetDir, serverArg, force) {
|
|
|
1350
1527
|
const parent = findParentStatefile(path.dirname(targetDir));
|
|
1351
1528
|
if (parent) {
|
|
1352
1529
|
const parentDir = path.dirname(parent);
|
|
1353
|
-
console.error(`Warning: there is already an
|
|
1354
|
-
console.error(`Run 'ocmux' and press 'p' to move between project windows.`);
|
|
1530
|
+
console.error(`Warning: there is already an ocmux project in ${parentDir}.`);
|
|
1355
1531
|
|
|
1356
1532
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
1357
|
-
rl.question('Are you sure you want to
|
|
1533
|
+
rl.question('Are you sure you want to initialize a nested project here? [y/N] ', (answer) => {
|
|
1358
1534
|
rl.close();
|
|
1359
1535
|
if (!/^[yY]([eE][sS])?$/.test(answer.trim())) {
|
|
1360
1536
|
console.error('Cancelled.');
|
|
@@ -1370,8 +1546,8 @@ async function cmdServeNew(targetDir, serverArg, force) {
|
|
|
1370
1546
|
return doServeNew(targetDir, serverArg);
|
|
1371
1547
|
}
|
|
1372
1548
|
|
|
1373
|
-
// Repoint an existing project to a (possibly new) server
|
|
1374
|
-
//
|
|
1549
|
+
// Repoint an existing project to a (possibly new) server. A registered TUI is
|
|
1550
|
+
// refreshed through the normal dedicated → shared routing rule.
|
|
1375
1551
|
async function doForceServe(targetDir, sf, server) {
|
|
1376
1552
|
const alive = await checkServer(server);
|
|
1377
1553
|
if (!alive) {
|
|
@@ -1392,14 +1568,8 @@ async function doForceServe(targetDir, sf, server) {
|
|
|
1392
1568
|
session = created.id;
|
|
1393
1569
|
}
|
|
1394
1570
|
projectState.writeProjectState(sf, { server, session });
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
relaunchTui(winIdx, targetDir, server, session);
|
|
1398
|
-
console.error(`Repointed ${targetDir} to ${server} (session ${session}).`);
|
|
1399
|
-
} else {
|
|
1400
|
-
createProjectWindow(targetDir, server, session);
|
|
1401
|
-
console.error(`Recreated project window for ${targetDir} → ${server} (session ${session}).`);
|
|
1402
|
-
}
|
|
1571
|
+
refreshTui(targetDir, server, session);
|
|
1572
|
+
console.error(`Repointed ${targetDir} to ${server} (session ${session}).`);
|
|
1403
1573
|
}
|
|
1404
1574
|
|
|
1405
1575
|
async function doServeNew(targetDir, serverArg) {
|
|
@@ -1431,108 +1601,181 @@ async function doServeNew(targetDir, serverArg) {
|
|
|
1431
1601
|
}
|
|
1432
1602
|
}
|
|
1433
1603
|
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1604
|
+
projectState.writeProjectState(path.join(targetDir, '.ocmux.json'), {
|
|
1605
|
+
directory: targetDir,
|
|
1606
|
+
server,
|
|
1607
|
+
session,
|
|
1608
|
+
status: 'active',
|
|
1609
|
+
});
|
|
1610
|
+
refreshTui(targetDir, server, session);
|
|
1611
|
+
console.error(`Initialized ${targetDir} → ${server} (session ${session}).`);
|
|
1441
1612
|
}
|
|
1442
1613
|
|
|
1443
1614
|
// ---------------------------------------------------------------------------
|
|
1444
|
-
// Subcommand:
|
|
1615
|
+
// Subcommand: tui — register this tmux pane and host an OpenCode TUI
|
|
1445
1616
|
// ---------------------------------------------------------------------------
|
|
1446
1617
|
|
|
1447
|
-
function
|
|
1448
|
-
const
|
|
1449
|
-
if (
|
|
1450
|
-
console.
|
|
1451
|
-
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
const dirW = Math.max(...rows.map(r => r.dir.length), 9);
|
|
1455
|
-
const stsW = Math.max(...rows.map(r => r.status.length), 6);
|
|
1456
|
-
const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
|
|
1457
|
-
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
1458
|
-
|
|
1459
|
-
console.log(`${pad('Directory', dirW)} ${pad('Status', stsW)} ${pad('Session', ssnW)}`);
|
|
1460
|
-
for (const row of rows) {
|
|
1461
|
-
console.log(`${pad(row.dir, dirW)} ${pad(row.status, stsW)} ${pad(row.session || '(none)', ssnW)}`);
|
|
1462
|
-
if (longMode && row.server) console.log(` server: ${row.server}`);
|
|
1618
|
+
async function cmdTui(targetDir, serverArg, shared, sessionArg) {
|
|
1619
|
+
const tmuxTarget = tuiRegistry.parseTmuxEnvironment();
|
|
1620
|
+
if (!tmuxTarget) {
|
|
1621
|
+
console.error("Error: 'ocmux tui' must run inside a tmux pane.");
|
|
1622
|
+
process.exit(1);
|
|
1463
1623
|
}
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
// ---------------------------------------------------------------------------
|
|
1467
|
-
// Subcommand: kill — close the project window but keep .ocmux.json
|
|
1468
|
-
// ---------------------------------------------------------------------------
|
|
1469
|
-
|
|
1470
|
-
function cmdKill(targetDir) {
|
|
1471
1624
|
const sf = findParentStatefile(targetDir);
|
|
1472
1625
|
if (!sf) {
|
|
1473
1626
|
console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
|
|
1627
|
+
console.error("Run 'ocmux serve' first.");
|
|
1628
|
+
process.exit(1);
|
|
1629
|
+
}
|
|
1630
|
+
const state = projectState.readProjectState(sf);
|
|
1631
|
+
const directory = state.directory;
|
|
1632
|
+
const server = serverArg || state.server || DEFAULT_SERVER;
|
|
1633
|
+
if (!await checkServer(server)) {
|
|
1634
|
+
console.error(`Error: OpenCode server not reachable at ${server}.`);
|
|
1474
1635
|
process.exit(1);
|
|
1475
1636
|
}
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
if (
|
|
1479
|
-
console.error(`Closing project window for ${serverDir}...`);
|
|
1637
|
+
|
|
1638
|
+
let session = sessionArg || state.session;
|
|
1639
|
+
if (session) {
|
|
1480
1640
|
try {
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1641
|
+
const found = await getSession(server, session);
|
|
1642
|
+
if (found.location && found.location.directory) targetDir = found.location.directory;
|
|
1643
|
+
} catch {
|
|
1644
|
+
session = null;
|
|
1485
1645
|
}
|
|
1486
|
-
} else {
|
|
1487
|
-
console.error(`Tmux window not found for ${serverDir} (state kept).`);
|
|
1488
1646
|
}
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1647
|
+
if (!session) {
|
|
1648
|
+
const sessions = await listSessions(server, directory);
|
|
1649
|
+
session = newestSessionId(sessions);
|
|
1650
|
+
if (!session) session = (await createSessionWithModel(server, undefined, directory, sessions)).id;
|
|
1651
|
+
projectState.writeProjectState(sf, { session, server });
|
|
1652
|
+
}
|
|
1493
1653
|
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1654
|
+
const registration = tuiRegistry.register({
|
|
1655
|
+
...tmuxTarget,
|
|
1656
|
+
shared,
|
|
1657
|
+
directory: shared ? targetDir : directory,
|
|
1658
|
+
server,
|
|
1659
|
+
session,
|
|
1660
|
+
pid: process.pid,
|
|
1661
|
+
});
|
|
1497
1662
|
|
|
1498
|
-
|
|
1663
|
+
const instance = registration.instance;
|
|
1664
|
+
const launchDir = targetDir || directory;
|
|
1665
|
+
const child = child_process.spawn('opencode', tuiArgs(server, session, launchDir), {
|
|
1666
|
+
cwd: launchDir,
|
|
1667
|
+
stdio: 'inherit',
|
|
1668
|
+
});
|
|
1669
|
+
tuiRegistry.updateInstance(instance.id, instance.token, { childPid: child.pid || null });
|
|
1670
|
+
|
|
1671
|
+
return new Promise((resolve, reject) => {
|
|
1672
|
+
let settled = false;
|
|
1673
|
+
const forward = (signal) => {
|
|
1674
|
+
try { child.kill(signal); } catch {}
|
|
1675
|
+
};
|
|
1676
|
+
const onHup = () => forward('SIGHUP');
|
|
1677
|
+
const onTerm = () => forward('SIGTERM');
|
|
1678
|
+
const onInt = () => forward('SIGINT');
|
|
1679
|
+
process.on('SIGHUP', onHup);
|
|
1680
|
+
process.on('SIGTERM', onTerm);
|
|
1681
|
+
process.on('SIGINT', onInt);
|
|
1682
|
+
const finish = (err) => {
|
|
1683
|
+
if (settled) return;
|
|
1684
|
+
settled = true;
|
|
1685
|
+
process.removeListener('SIGHUP', onHup);
|
|
1686
|
+
process.removeListener('SIGTERM', onTerm);
|
|
1687
|
+
process.removeListener('SIGINT', onInt);
|
|
1688
|
+
tuiRegistry.unregister(instance.id, instance.token);
|
|
1689
|
+
if (err) reject(err);
|
|
1690
|
+
else resolve();
|
|
1691
|
+
};
|
|
1692
|
+
child.once('error', finish);
|
|
1693
|
+
child.once('exit', () => finish());
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function tuiProjectDirectory(targetDir) {
|
|
1499
1698
|
const sf = findParentStatefile(targetDir);
|
|
1500
|
-
if (!sf)
|
|
1699
|
+
if (!sf) return null;
|
|
1700
|
+
const state = projectState.readProjectState(sf);
|
|
1701
|
+
return state && state.directory;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function formatTuiRegistration(instance) {
|
|
1705
|
+
return `${instance.shared ? 'shared' : 'project'} ${instance.pane} ${instance.directory || '(none)'} ${instance.server || '(none)'} ${instance.session || '(none)'}`;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
function cmdTuiList() {
|
|
1709
|
+
const instances = tuiRegistry.list();
|
|
1710
|
+
if (instances.length === 0) {
|
|
1711
|
+
console.log('No registered TUIs.');
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
console.log('Kind Pane Directory Server Session');
|
|
1715
|
+
for (const instance of instances) console.log(formatTuiRegistration(instance));
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
function cmdTuiStatus(targetDir, shared) {
|
|
1719
|
+
const directory = shared ? null : tuiProjectDirectory(targetDir);
|
|
1720
|
+
if (!shared && !directory) {
|
|
1501
1721
|
console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
|
|
1502
1722
|
process.exit(1);
|
|
1503
1723
|
}
|
|
1504
|
-
const
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1724
|
+
const instance = shared ? tuiRegistry.shared() : tuiRegistry.dedicated(directory);
|
|
1725
|
+
if (!instance) {
|
|
1726
|
+
console.log(shared ? 'Shared TUI: not registered' : `Project TUI: not registered (${directory})`);
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
console.log(formatTuiRegistration(instance));
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function cmdTuiDetach(targetDir, shared) {
|
|
1733
|
+
const directory = shared ? null : tuiProjectDirectory(targetDir);
|
|
1734
|
+
if (!shared && !directory) {
|
|
1735
|
+
console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
|
|
1508
1736
|
process.exit(1);
|
|
1509
1737
|
}
|
|
1510
|
-
const
|
|
1738
|
+
const instance = shared ? tuiRegistry.shared() : tuiRegistry.dedicated(directory);
|
|
1739
|
+
if (!instance) {
|
|
1740
|
+
console.log(shared ? 'Shared TUI was not registered.' : `Project TUI was not registered (${directory}).`);
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
tuiRegistry.unregister(instance.id, instance.token);
|
|
1744
|
+
console.log(`Detached ${shared ? 'shared' : 'project'} TUI from ${instance.pane}.`);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// ---------------------------------------------------------------------------
|
|
1748
|
+
// Subcommand: list
|
|
1749
|
+
// ---------------------------------------------------------------------------
|
|
1511
1750
|
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1751
|
+
async function cmdList(longMode, server) {
|
|
1752
|
+
const rows = await configuredProjects(server);
|
|
1753
|
+
if (rows.length === 0) {
|
|
1754
|
+
console.log('No configured ocmux projects found.');
|
|
1515
1755
|
return;
|
|
1516
1756
|
}
|
|
1517
1757
|
|
|
1518
|
-
const
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1758
|
+
const dirW = Math.max(...rows.map(r => r.dir.length), 9);
|
|
1759
|
+
const stsW = Math.max(...rows.map(r => r.status.length), 6);
|
|
1760
|
+
const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
|
|
1761
|
+
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
1762
|
+
|
|
1763
|
+
console.log(`${pad('Directory', dirW)} ${pad('Status', stsW)} ${pad('Session', ssnW)}`);
|
|
1764
|
+
for (const row of rows) {
|
|
1765
|
+
console.log(`${pad(row.dir, dirW)} ${pad(row.status, stsW)} ${pad(row.session || '(none)', ssnW)}`);
|
|
1766
|
+
if (longMode && row.server) console.log(` server: ${row.server}`);
|
|
1522
1767
|
}
|
|
1523
|
-
console.error(`Recreating project window for ${dir}...`);
|
|
1524
|
-
createProjectWindow(dir, server, state.session);
|
|
1525
1768
|
}
|
|
1526
1769
|
|
|
1527
1770
|
// ---------------------------------------------------------------------------
|
|
1528
1771
|
// Subcommand: migrate — rewrite legacy .ocmux.json files to v2
|
|
1529
1772
|
// ---------------------------------------------------------------------------
|
|
1530
1773
|
|
|
1531
|
-
function cmdMigrate() {
|
|
1532
|
-
const rows =
|
|
1774
|
+
async function cmdMigrate(server) {
|
|
1775
|
+
const rows = await configuredProjects(server);
|
|
1533
1776
|
let migrated = 0;
|
|
1534
1777
|
for (const row of rows) {
|
|
1535
|
-
const sf = statefileFor(row.dir);
|
|
1778
|
+
const sf = projectState.statefileFor(row.dir);
|
|
1536
1779
|
const state = projectState.readProjectState(sf);
|
|
1537
1780
|
if (!state) continue;
|
|
1538
1781
|
if (state.version >= 2) continue;
|
|
@@ -1553,31 +1796,39 @@ function cmdMigrate() {
|
|
|
1553
1796
|
// ---------------------------------------------------------------------------
|
|
1554
1797
|
|
|
1555
1798
|
function printHelp() {
|
|
1556
|
-
console.error(`Usage: ${SCRIPT} [-l] [<subcommand>] [<directory>]`);
|
|
1799
|
+
console.error(`Usage: ${SCRIPT} [-l] [--all-projects] [<subcommand>] [<directory>]`);
|
|
1557
1800
|
console.error('');
|
|
1558
|
-
console.error('Manage project
|
|
1559
|
-
console.error('
|
|
1801
|
+
console.error('Manage project/session routing and optional registered OpenCode TUIs.');
|
|
1802
|
+
console.error('The OpenCode server is user-managed: start it with `opencode serve`.');
|
|
1560
1803
|
console.error('');
|
|
1561
1804
|
console.error('Without arguments, searches upward from <directory> (default: $PWD)');
|
|
1562
1805
|
console.error('for .ocmux.json and opens an interactive SESSION picker for that');
|
|
1563
|
-
console.error('project (requires a TTY).
|
|
1806
|
+
console.error('project (requires a TTY). The title bar names the project whose');
|
|
1807
|
+
console.error('sessions are listed. In the picker:');
|
|
1564
1808
|
console.error(' Enter switch · n new · r rename · R remind · d delete · a agents · m model');
|
|
1565
1809
|
console.error(' p projects · h help · q quit');
|
|
1566
|
-
console.error('Selecting updates .ocmux.json and
|
|
1567
|
-
console.error('
|
|
1568
|
-
console.error('
|
|
1810
|
+
console.error('Selecting updates .ocmux.json and refreshes a dedicated TUI, falling');
|
|
1811
|
+
console.error('back to the shared TUI. Silent on success. (p opens projects; by');
|
|
1812
|
+
console.error('default it only inspects other projects and never modifies any');
|
|
1813
|
+
console.error('.ocmux.json — see --all-projects.)');
|
|
1569
1814
|
console.error('');
|
|
1570
1815
|
console.error('Subcommands:');
|
|
1571
|
-
console.error(' serve
|
|
1816
|
+
console.error(' serve Initialize project state for <directory> (default: $PWD).');
|
|
1572
1817
|
console.error(' --server <url> Server to record (default: $OCMUX_SERVER or http://localhost:4096).');
|
|
1573
1818
|
console.error(' --force Repoint an existing project to --server (overwrites .ocmux.json).');
|
|
1574
1819
|
console.error(' --git / --GIT Use the nearest parent with a .git entry / directory.');
|
|
1575
1820
|
console.error(' Aliased as "new" for backwards compatibility.');
|
|
1821
|
+
console.error(' tui [--shared] [--server <url>] [directory]');
|
|
1822
|
+
console.error(' Register the current tmux pane and run its OpenCode TUI.');
|
|
1823
|
+
console.error(' Without --shared it is dedicated to the current project;');
|
|
1824
|
+
console.error(' --shared makes it the single fallback for every project.');
|
|
1825
|
+
console.error(' --list lists all live registrations; --status inspects one;');
|
|
1826
|
+
console.error(' --detach unregisters one without closing its pane or TUI.');
|
|
1827
|
+
console.error(' With --status/--detach, --shared targets the shared slot;');
|
|
1828
|
+
console.error(' otherwise the slot dedicated to directory is targeted.');
|
|
1576
1829
|
console.error(' session <id|title>');
|
|
1577
1830
|
console.error(' Non-interactive session switch for the current project.');
|
|
1578
|
-
console.error(' list [-l] List
|
|
1579
|
-
console.error(' kill Close the current project window. KEEPS .ocmux.json (session memory).');
|
|
1580
|
-
console.error(' resurrect Recreate the project window from its .ocmux.json.');
|
|
1831
|
+
console.error(' list [-l] List configured projects and TUI routing status.');
|
|
1581
1832
|
console.error(' migrate Rewrite legacy .ocmux.json files to the v2 schema.');
|
|
1582
1833
|
console.error('');
|
|
1583
1834
|
console.error('Reminders: the R key in the session picker attaches a note to a');
|
|
@@ -1585,6 +1836,12 @@ function printHelp() {
|
|
|
1585
1836
|
console.error('');
|
|
1586
1837
|
console.error('Options:');
|
|
1587
1838
|
console.error(' -l Long output (list: also show server URL).');
|
|
1839
|
+
console.error(' --all-projects');
|
|
1840
|
+
console.error(' Let the picker’s project switcher (p) MOVE to another');
|
|
1841
|
+
console.error(' project, so picking a session there updates that');
|
|
1842
|
+
console.error(' project’s .ocmux.json (what agentp reads in it).');
|
|
1843
|
+
console.error(' Default: the switcher only inspects other projects');
|
|
1844
|
+
console.error(' and you always return to your own project’s list.');
|
|
1588
1845
|
console.error(' --version Show version');
|
|
1589
1846
|
console.error(' -h Show this help');
|
|
1590
1847
|
console.error('');
|
|
@@ -1602,8 +1859,13 @@ function main() {
|
|
|
1602
1859
|
let gitMode = null;
|
|
1603
1860
|
let serverArg = null;
|
|
1604
1861
|
let force = false;
|
|
1862
|
+
let allProjects = false;
|
|
1863
|
+
let sharedTui = false;
|
|
1864
|
+
let tuiSession = null;
|
|
1865
|
+
let tuiList = false;
|
|
1866
|
+
let tuiStatus = false;
|
|
1867
|
+
let tuiDetach = false;
|
|
1605
1868
|
const positional = [];
|
|
1606
|
-
let dirIsForced = false;
|
|
1607
1869
|
|
|
1608
1870
|
const parsedArgs = process.argv.slice(2);
|
|
1609
1871
|
for (let i = 0; i < parsedArgs.length; i++) {
|
|
@@ -1624,6 +1886,24 @@ function main() {
|
|
|
1624
1886
|
process.exit(1);
|
|
1625
1887
|
} else if (arg === '--force') {
|
|
1626
1888
|
force = true;
|
|
1889
|
+
} else if (arg === '--shared') {
|
|
1890
|
+
sharedTui = true;
|
|
1891
|
+
} else if (arg === '--session-id') {
|
|
1892
|
+
const val = parsedArgs[i + 1];
|
|
1893
|
+
if (!val || val.startsWith('-')) {
|
|
1894
|
+
console.error("Error: '--session-id' requires a session ID.");
|
|
1895
|
+
process.exit(1);
|
|
1896
|
+
}
|
|
1897
|
+
tuiSession = val;
|
|
1898
|
+
i++;
|
|
1899
|
+
} else if (arg === '--list') {
|
|
1900
|
+
tuiList = true;
|
|
1901
|
+
} else if (arg === '--status') {
|
|
1902
|
+
tuiStatus = true;
|
|
1903
|
+
} else if (arg === '--detach') {
|
|
1904
|
+
tuiDetach = true;
|
|
1905
|
+
} else if (arg === '--all-projects') {
|
|
1906
|
+
allProjects = true;
|
|
1627
1907
|
} else if (arg === '--git') {
|
|
1628
1908
|
if (gitMode && gitMode !== 'nearest') {
|
|
1629
1909
|
console.error("Error: cannot combine '--git' with '--GIT'.");
|
|
@@ -1642,13 +1922,19 @@ function main() {
|
|
|
1642
1922
|
} else if (arg === '-h' || arg === '--help') {
|
|
1643
1923
|
printHelp();
|
|
1644
1924
|
} else if (arg === '--') {
|
|
1645
|
-
|
|
1925
|
+
const directory = parsedArgs[i + 1];
|
|
1926
|
+
if (!directory) {
|
|
1927
|
+
console.error("Error: '--' requires a directory argument.");
|
|
1928
|
+
process.exit(1);
|
|
1929
|
+
}
|
|
1930
|
+
positional.push({ value: directory, forcedDirectory: true });
|
|
1931
|
+
i++;
|
|
1646
1932
|
} else if (arg.startsWith('-')) {
|
|
1647
1933
|
console.error(`Error: unknown option '${arg}'`);
|
|
1648
1934
|
console.error(`Run '${SCRIPT} -h' for help.`);
|
|
1649
1935
|
process.exit(1);
|
|
1650
1936
|
} else {
|
|
1651
|
-
positional.push(arg);
|
|
1937
|
+
positional.push({ value: arg, forcedDirectory: false });
|
|
1652
1938
|
}
|
|
1653
1939
|
}
|
|
1654
1940
|
|
|
@@ -1658,16 +1944,16 @@ function main() {
|
|
|
1658
1944
|
let targetDir = null;
|
|
1659
1945
|
let directRef = null;
|
|
1660
1946
|
|
|
1661
|
-
for (const
|
|
1662
|
-
|
|
1663
|
-
|
|
1947
|
+
for (const positionalArg of positional) {
|
|
1948
|
+
const arg = positionalArg.value;
|
|
1949
|
+
if (positionalArg.forcedDirectory) {
|
|
1664
1950
|
if (targetDir) {
|
|
1665
1951
|
console.error(`Error: unexpected argument '${arg}'`);
|
|
1666
1952
|
process.exit(1);
|
|
1667
1953
|
}
|
|
1668
1954
|
targetDir = resolveDir(arg);
|
|
1669
|
-
} else if (arg === 'serve' || arg === 'new' || arg === '
|
|
1670
|
-
|| arg === '
|
|
1955
|
+
} else if (arg === 'serve' || arg === 'new' || arg === 'list' || arg === 'tui'
|
|
1956
|
+
|| arg === 'session'
|
|
1671
1957
|
|| arg === 'migrate') {
|
|
1672
1958
|
if (subcommand) {
|
|
1673
1959
|
console.error(`Error: multiple subcommands specified: '${subcommand}' and '${arg}'`);
|
|
@@ -1687,6 +1973,23 @@ function main() {
|
|
|
1687
1973
|
|
|
1688
1974
|
if (!targetDir) targetDir = process.cwd();
|
|
1689
1975
|
|
|
1976
|
+
if (sharedTui && subcommand !== 'tui') {
|
|
1977
|
+
console.error("Error: '--shared' is only valid with 'ocmux tui'.");
|
|
1978
|
+
process.exit(1);
|
|
1979
|
+
}
|
|
1980
|
+
if (tuiSession && subcommand !== 'tui') {
|
|
1981
|
+
console.error("Error: '--session-id' is only valid with 'ocmux tui'.");
|
|
1982
|
+
process.exit(1);
|
|
1983
|
+
}
|
|
1984
|
+
if ((tuiList || tuiStatus || tuiDetach) && subcommand !== 'tui') {
|
|
1985
|
+
console.error("Error: '--list', '--status', and '--detach' are only valid with 'ocmux tui'.");
|
|
1986
|
+
process.exit(1);
|
|
1987
|
+
}
|
|
1988
|
+
if ([tuiList, tuiStatus, tuiDetach].filter(Boolean).length > 1) {
|
|
1989
|
+
console.error("Error: combine at most one of '--list', '--status', and '--detach'.");
|
|
1990
|
+
process.exit(1);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1690
1993
|
// Dispatch. Each branch may throw synchronously (EXIT) for validation errors
|
|
1691
1994
|
// or return a promise for async commands; main() returns the settled promise
|
|
1692
1995
|
// so tests can await it.
|
|
@@ -1701,28 +2004,45 @@ function main() {
|
|
|
1701
2004
|
console.error("Error: 'list' does not accept a directory path.");
|
|
1702
2005
|
process.exit(1);
|
|
1703
2006
|
}
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
2007
|
+
p = cmdList(longMode, serverArg || DEFAULT_SERVER);
|
|
2008
|
+
} else if (subcommand === 'tui') {
|
|
2009
|
+
if (gitMode || force || allProjects || longMode) {
|
|
2010
|
+
console.error("Error: 'tui' does not accept --git, --GIT, --force, --all-projects, or -l.");
|
|
1709
2011
|
process.exit(1);
|
|
1710
2012
|
}
|
|
1711
|
-
|
|
1712
|
-
|
|
2013
|
+
if (tuiList) {
|
|
2014
|
+
if (sharedTui || serverArg || tuiSession || targetDir !== process.cwd()) {
|
|
2015
|
+
console.error("Error: 'ocmux tui --list' does not accept --shared, --server, --session-id, or a directory.");
|
|
2016
|
+
process.exit(1);
|
|
2017
|
+
}
|
|
2018
|
+
cmdTuiList();
|
|
2019
|
+
} else if (tuiStatus) {
|
|
2020
|
+
if (serverArg || tuiSession) {
|
|
2021
|
+
console.error("Error: 'ocmux tui --status' does not accept --server or --session-id.");
|
|
2022
|
+
process.exit(1);
|
|
2023
|
+
}
|
|
2024
|
+
cmdTuiStatus(targetDir, sharedTui);
|
|
2025
|
+
} else if (tuiDetach) {
|
|
2026
|
+
if (serverArg || tuiSession) {
|
|
2027
|
+
console.error("Error: 'ocmux tui --detach' does not accept --server or --session-id.");
|
|
2028
|
+
process.exit(1);
|
|
2029
|
+
}
|
|
2030
|
+
cmdTuiDetach(targetDir, sharedTui);
|
|
2031
|
+
} else {
|
|
2032
|
+
p = cmdTui(targetDir, serverArg, sharedTui, tuiSession);
|
|
2033
|
+
}
|
|
2034
|
+
} else if (subcommand === 'session') {
|
|
1713
2035
|
if (gitMode) {
|
|
1714
2036
|
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
1715
2037
|
process.exit(1);
|
|
1716
2038
|
}
|
|
1717
|
-
|
|
1718
|
-
} else if (subcommand === 'resurrect') {
|
|
1719
|
-
p = cmdResurrect(targetDir);
|
|
2039
|
+
p = cmdSessionSwitch(targetDir, directRef, allProjects);
|
|
1720
2040
|
} else if (subcommand === 'migrate') {
|
|
1721
2041
|
if (targetDir !== process.cwd()) {
|
|
1722
2042
|
console.error("Error: 'migrate' does not accept a directory path.");
|
|
1723
2043
|
process.exit(1);
|
|
1724
2044
|
}
|
|
1725
|
-
cmdMigrate();
|
|
2045
|
+
p = cmdMigrate(serverArg || DEFAULT_SERVER);
|
|
1726
2046
|
} else if (subcommand === 'serve' || subcommand === 'new') {
|
|
1727
2047
|
if (gitMode) {
|
|
1728
2048
|
const flagLabel = gitMode === 'root' ? '--GIT' : '--git';
|
|
@@ -1735,7 +2055,7 @@ function main() {
|
|
|
1735
2055
|
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
1736
2056
|
process.exit(1);
|
|
1737
2057
|
}
|
|
1738
|
-
p = cmdSessionSwitch(targetDir, null);
|
|
2058
|
+
p = cmdSessionSwitch(targetDir, null, allProjects);
|
|
1739
2059
|
}
|
|
1740
2060
|
|
|
1741
2061
|
return p.catch((err) => {
|
|
@@ -1751,4 +2071,4 @@ if (require.main === module) {
|
|
|
1751
2071
|
main();
|
|
1752
2072
|
}
|
|
1753
2073
|
|
|
1754
|
-
module.exports = { main, checkServer, newestSessionId,
|
|
2074
|
+
module.exports = { main, checkServer, newestSessionId, configuredProjects, refreshTui, cmdTui, cmdTuiList, cmdTuiStatus, cmdTuiDetach, cmdList, cmdMigrate, windowFor, renderList, fmtClock, fmtDuration, fmtTokens, fmtCost, layoutCells, sessionInfoLines, centerIn, filterByPattern, newSearchState, handleSearch, searchFooter, sessionMenu, switchMenu, SWITCH_QUIT, SEARCH_HINT, sortModels, indexOfModel, BROWN, BAR_BG, LIGHT_YELLOW };
|