sneakoscope 0.7.74 → 0.7.78
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/README.md +6 -1
- package/package.json +1 -1
- package/src/cli/install-helpers.mjs +178 -3
- package/src/cli/main.mjs +166 -27
- package/src/cli/maintenance-commands.mjs +40 -8
- package/src/core/fsx.mjs +1 -1
- package/src/core/hooks-runtime.mjs +24 -8
- package/src/core/init.mjs +12 -17
- package/src/core/pipeline.mjs +6 -1
- package/src/core/routes.mjs +3 -3
- package/src/core/team-live.mjs +81 -12
- package/src/core/tmux-ui.mjs +303 -61
- package/src/core/version-manager.mjs +61 -31
package/src/core/tmux-ui.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { getCodexInfo } from './codex-adapter.mjs';
|
|
|
7
7
|
import { codexAppIntegrationStatus, formatCodexAppStatus } from './codex-app.mjs';
|
|
8
8
|
import { REQUIRED_CODEX_MODEL, forceGpt55CodexArgs } from './codex-model-guard.mjs';
|
|
9
9
|
import { MIN_TEAM_REVIEWER_LANES } from './team-review-policy.mjs';
|
|
10
|
-
import { appendTeamEvent, readTeamControl, readTeamDashboard, teamCleanupRequested } from './team-live.mjs';
|
|
10
|
+
import { appendTeamEvent, isTerminalTeamAgentStatus, readTeamControl, readTeamDashboard, teamCleanupRequested } from './team-live.mjs';
|
|
11
11
|
|
|
12
12
|
const SKS_FIGLET_FONT = 'Standard';
|
|
13
13
|
|
|
@@ -101,27 +101,11 @@ export function tmuxCockpitStatePath(root = process.cwd()) {
|
|
|
101
101
|
return path.join(path.resolve(root || process.cwd()), '.sneakoscope', 'state', 'tmux-cockpit.json');
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
const TERMINAL_TEAM_AGENT_STATUSES = new Set([
|
|
105
|
-
'agent_closed',
|
|
106
|
-
'agent_done',
|
|
107
|
-
'cancelled',
|
|
108
|
-
'canceled',
|
|
109
|
-
'cleanup',
|
|
110
|
-
'cleanup_requested',
|
|
111
|
-
'closed',
|
|
112
|
-
'complete',
|
|
113
|
-
'completed',
|
|
114
|
-
'done',
|
|
115
|
-
'ended',
|
|
116
|
-
'failed',
|
|
117
|
-
'stopped',
|
|
118
|
-
'terminal',
|
|
119
|
-
'tmux_lane_closed'
|
|
120
|
-
]);
|
|
121
|
-
|
|
122
104
|
const LEGACY_TEAM_PANE_TITLE_RE = /^(?:overview: mission_overview|scout: analysis_scout|plan: (?:debate|consensus|planner|user)|exec: (?:executor|implementation|worker)|review: (?:reviewer|qa|validation)|safety:)/;
|
|
123
105
|
const GENERIC_TEAM_AGENT_IDS = new Set(['parent_orchestrator', 'analysis_scout', 'team_consensus', 'implementation_worker', 'db_safety_reviewer', 'qa_reviewer']);
|
|
124
106
|
const DYNAMIC_TEAM_TMUX_LAYOUT = 'main-vertical';
|
|
107
|
+
const TEAM_TMUX_MAIN_PANE_MIN_WIDTH = 48;
|
|
108
|
+
const TEAM_TMUX_MAIN_PANE_WIDTH_RATIO = 0.5;
|
|
125
109
|
|
|
126
110
|
export function isTmuxShellSession(env = process.env) {
|
|
127
111
|
return Boolean(String(env.TMUX || '').trim());
|
|
@@ -400,11 +384,6 @@ async function isRecordedSksTmuxSession(root, session) {
|
|
|
400
384
|
return { ok: true, record };
|
|
401
385
|
}
|
|
402
386
|
|
|
403
|
-
function isTerminalTeamAgentStatus(status = '') {
|
|
404
|
-
const normalized = String(status || '').trim().toLowerCase();
|
|
405
|
-
return TERMINAL_TEAM_AGENT_STATUSES.has(normalized) || /(?:^|_)(?:done|complete|completed|closed|cleanup|cancelled|canceled|failed|ended|stopped)(?:_|$)/.test(normalized);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
387
|
function teamCockpitAgentIds(plan = {}, dashboard = null, control = null, opts = {}) {
|
|
409
388
|
if (teamCleanupRequested(control)) return [];
|
|
410
389
|
const visible = teamViewAgentIds(plan).filter((id) => id && id !== 'mission_overview');
|
|
@@ -472,6 +451,35 @@ function parseTmuxPaneLines(stdout = '') {
|
|
|
472
451
|
}).filter((pane) => /^%\d+$/.test(pane.pane_id || ''));
|
|
473
452
|
}
|
|
474
453
|
|
|
454
|
+
function parseTmuxAllPaneLines(stdout = '') {
|
|
455
|
+
return String(stdout || '').split(/\r?\n/).filter(Boolean).map((line) => {
|
|
456
|
+
const [session, window_id, pane_id, title, command, managed, mission_id, agent, role] = line.split('\t');
|
|
457
|
+
return {
|
|
458
|
+
session: session || '',
|
|
459
|
+
window_id: window_id || '',
|
|
460
|
+
pane_id,
|
|
461
|
+
title: title || '',
|
|
462
|
+
command: command || '',
|
|
463
|
+
managed: managed === '1' || managed === 'true',
|
|
464
|
+
mission_id: mission_id || '',
|
|
465
|
+
agent: agent || '',
|
|
466
|
+
role: role || ''
|
|
467
|
+
};
|
|
468
|
+
}).filter((pane) => pane.session && /^@\d+$/.test(pane.window_id || '') && /^%\d+$/.test(pane.pane_id || ''));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function parseTmuxSessionLines(stdout = '') {
|
|
472
|
+
return String(stdout || '').split(/\r?\n/).filter(Boolean).map((line) => {
|
|
473
|
+
const [session, attached, created_at, last_attached_at] = line.split('\t');
|
|
474
|
+
return {
|
|
475
|
+
session: sanitizeTmuxSessionName(session || ''),
|
|
476
|
+
attached: Number(attached || 0) > 0,
|
|
477
|
+
created_at: Number(created_at || 0) || 0,
|
|
478
|
+
last_attached_at: Number(last_attached_at || 0) || 0
|
|
479
|
+
};
|
|
480
|
+
}).filter((entry) => entry.session);
|
|
481
|
+
}
|
|
482
|
+
|
|
475
483
|
function isLegacyTeamPane(pane = {}) {
|
|
476
484
|
return LEGACY_TEAM_PANE_TITLE_RE.test(String(pane.title || '').trim());
|
|
477
485
|
}
|
|
@@ -483,6 +491,30 @@ async function listTmuxWindowPanes(bin, windowId) {
|
|
|
483
491
|
return { ok: true, panes: parseTmuxPaneLines(run.stdout) };
|
|
484
492
|
}
|
|
485
493
|
|
|
494
|
+
async function listAllTmuxPanes(bin) {
|
|
495
|
+
const format = ['#{session_name}', '#{window_id}', '#{pane_id}', '#{pane_title}', '#{pane_current_command}', '#{@sks_team_managed}', '#{@sks_mission_id}', '#{@sks_agent_id}', '#{@sks_lane_role}'].join('\t');
|
|
496
|
+
const run = await tmuxRun(bin, ['list-panes', '-a', '-F', format], { timeoutMs: 5000, maxOutputBytes: 64 * 1024 });
|
|
497
|
+
if (run.code !== 0) return { ok: false, panes: [], stderr: run.stderr || run.stdout || 'tmux list-panes -a failed' };
|
|
498
|
+
return { ok: true, panes: parseTmuxAllPaneLines(run.stdout) };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function listAllTmuxSessions(bin) {
|
|
502
|
+
const format = ['#{session_name}', '#{session_attached}', '#{session_created}', '#{session_last_attached}'].join('\t');
|
|
503
|
+
const run = await tmuxRun(bin, ['list-sessions', '-F', format], { timeoutMs: 5000, maxOutputBytes: 64 * 1024 });
|
|
504
|
+
if (run.code !== 0) return { ok: false, sessions: [], stderr: run.stderr || run.stdout || 'tmux list-sessions failed' };
|
|
505
|
+
return { ok: true, sessions: parseTmuxSessionLines(run.stdout) };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function tmuxPaneExists(bin, paneId) {
|
|
509
|
+
if (!paneId || !String(paneId).startsWith('%')) return false;
|
|
510
|
+
const run = await tmuxRun(bin, ['list-panes', '-t', paneId, '-F', '#{pane_dead}\t#{pane_id}'], { timeoutMs: 5000, maxOutputBytes: 4096 });
|
|
511
|
+
if (run.code !== 0) return false;
|
|
512
|
+
return String(run.stdout || '').split(/\r?\n/).some((line) => {
|
|
513
|
+
const [dead = '', id = ''] = line.trim().split('\t');
|
|
514
|
+
return id === paneId && dead !== '1';
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
486
518
|
async function setTmuxPaneUserOptions(bin, paneId, options = {}) {
|
|
487
519
|
const applied = [];
|
|
488
520
|
const failed = [];
|
|
@@ -510,6 +542,56 @@ async function writeTmuxCockpitRecord(root, record = {}) {
|
|
|
510
542
|
return nextRecord;
|
|
511
543
|
}
|
|
512
544
|
|
|
545
|
+
async function readRecordedTeamSurfaceHints(root) {
|
|
546
|
+
const resolvedRoot = path.resolve(root || process.cwd());
|
|
547
|
+
const teamState = await readJson(tmuxTeamStatePath(resolvedRoot), {}).catch(() => ({}));
|
|
548
|
+
const cockpitState = await readJson(tmuxCockpitStatePath(resolvedRoot), {}).catch(() => ({}));
|
|
549
|
+
const missionRoot = path.join(resolvedRoot, '.sneakoscope', 'missions');
|
|
550
|
+
const records = [
|
|
551
|
+
...Object.values(teamState?.missions || {}),
|
|
552
|
+
...Object.values(cockpitState?.missions || {})
|
|
553
|
+
].filter((entry) => entry && typeof entry === 'object')
|
|
554
|
+
.filter((entry) => !entry.root || path.resolve(entry.root) === resolvedRoot);
|
|
555
|
+
const missionIds = new Set();
|
|
556
|
+
const sessions = new Set();
|
|
557
|
+
const paneKeys = new Set();
|
|
558
|
+
for (const record of records) {
|
|
559
|
+
const missionId = String(record.mission_id || '').trim();
|
|
560
|
+
const session = sanitizeTmuxSessionName(record.session || '');
|
|
561
|
+
if (missionId) missionIds.add(missionId);
|
|
562
|
+
if (session) sessions.add(session);
|
|
563
|
+
for (const pane of Array.isArray(record.panes) ? record.panes : []) {
|
|
564
|
+
if (session && pane?.pane_id) paneKeys.add(`${session}\t${pane.pane_id}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const missionDirs = await fsp.readdir(missionRoot).catch(() => []);
|
|
568
|
+
for (const name of missionDirs) {
|
|
569
|
+
if (!/^M-\d{8}-\d{6}-[A-Za-z0-9]+$/.test(name)) continue;
|
|
570
|
+
missionIds.add(name);
|
|
571
|
+
sessions.add(sanitizeTmuxSessionName(`sks-team-${name}`));
|
|
572
|
+
}
|
|
573
|
+
return { missionIds, sessions, paneKeys, records };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function paneBelongsToKeptMission(pane = {}, keepMissionIds = new Set()) {
|
|
577
|
+
if (pane.mission_id && keepMissionIds.has(pane.mission_id)) return true;
|
|
578
|
+
const match = String(pane.session || '').match(/^sks-team-(M-\d{8}-\d{6}-[A-Za-z0-9]+)$/);
|
|
579
|
+
return Boolean(match && keepMissionIds.has(match[1]));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function shouldSweepTeamPane(pane = {}, hints = {}, opts = {}) {
|
|
583
|
+
if (!pane?.pane_id || pane.pane_id === opts.currentPaneId) return false;
|
|
584
|
+
if (paneBelongsToKeptMission(pane, opts.keepMissionIds || new Set())) return false;
|
|
585
|
+
const missionIds = hints.missionIds || new Set();
|
|
586
|
+
const sessions = hints.sessions || new Set();
|
|
587
|
+
const paneKeys = hints.paneKeys || new Set();
|
|
588
|
+
if (pane.managed && pane.mission_id && missionIds.has(pane.mission_id)) return true;
|
|
589
|
+
if (paneKeys.has(`${pane.session}\t${pane.pane_id}`)) return true;
|
|
590
|
+
if (sessions.has(pane.session) && isLegacyTeamPane(pane)) return true;
|
|
591
|
+
const legacySession = String(pane.session || '').match(/^sks-team-(M-\d{8}-\d{6}-[A-Za-z0-9]+)$/);
|
|
592
|
+
return Boolean(legacySession && missionIds.has(legacySession[1]) && isLegacyTeamPane(pane));
|
|
593
|
+
}
|
|
594
|
+
|
|
513
595
|
function tmuxLayoutName(value = 'tiled') {
|
|
514
596
|
const layout = String(value || 'tiled').trim();
|
|
515
597
|
return /^(tiled|even-horizontal|even-vertical|main-horizontal|main-horizontal-mirrored|main-vertical|main-vertical-mirrored)$/.test(layout)
|
|
@@ -517,11 +599,52 @@ function tmuxLayoutName(value = 'tiled') {
|
|
|
517
599
|
: 'tiled';
|
|
518
600
|
}
|
|
519
601
|
|
|
602
|
+
function teamMainPaneWidthFromWindow(width) {
|
|
603
|
+
const n = Number(width);
|
|
604
|
+
if (!Number.isFinite(n) || n <= 0) return TEAM_TMUX_MAIN_PANE_MIN_WIDTH;
|
|
605
|
+
return Math.max(TEAM_TMUX_MAIN_PANE_MIN_WIDTH, Math.floor(n * TEAM_TMUX_MAIN_PANE_WIDTH_RATIO));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function applyStableTeamLayout(tmuxBin, target, mainPaneId = null, opts = {}) {
|
|
609
|
+
const layout = tmuxLayoutName(opts.layout || DYNAMIC_TEAM_TMUX_LAYOUT);
|
|
610
|
+
const windowTarget = target || mainPaneId;
|
|
611
|
+
const applied = [];
|
|
612
|
+
const failed = [];
|
|
613
|
+
const runAndRecord = async (args) => {
|
|
614
|
+
const run = await tmuxRun(tmuxBin, args, { timeoutMs: 5000 });
|
|
615
|
+
const command = [path.basename(tmuxBin), ...args].join(' ');
|
|
616
|
+
if (run.code === 0) applied.push(command);
|
|
617
|
+
else failed.push({ command, stderr: run.stderr || run.stdout || 'tmux command failed' });
|
|
618
|
+
return run;
|
|
619
|
+
};
|
|
620
|
+
if (mainPaneId) await runAndRecord(['select-pane', '-t', mainPaneId]);
|
|
621
|
+
const width = await tmuxRun(tmuxBin, ['display-message', '-p', '-t', windowTarget, '#{window_width}'], { timeoutMs: 5000, maxOutputBytes: 1024 });
|
|
622
|
+
if (width.code === 0) {
|
|
623
|
+
const mainWidth = teamMainPaneWidthFromWindow(String(width.stdout || '').trim());
|
|
624
|
+
await runAndRecord(['set-window-option', '-t', windowTarget, 'main-pane-width', String(mainWidth)]);
|
|
625
|
+
}
|
|
626
|
+
await runAndRecord(['select-layout', '-t', windowTarget, layout]);
|
|
627
|
+
return { ok: failed.length === 0, layout_name: layout, applied, failed };
|
|
628
|
+
}
|
|
629
|
+
|
|
520
630
|
async function enableTmuxDynamicResize(tmuxBin, session, opts = {}) {
|
|
521
631
|
const layout = tmuxLayoutName(opts.layout || 'tiled');
|
|
522
632
|
const safeSession = sanitizeTmuxSessionName(session);
|
|
523
633
|
const target = await tmuxWindowTarget(tmuxBin, safeSession);
|
|
524
|
-
const
|
|
634
|
+
const stableMainVertical = layout === DYNAMIC_TEAM_TMUX_LAYOUT && opts.stableTeamLayout;
|
|
635
|
+
const tmuxShell = shellEscape(tmuxBin || 'tmux');
|
|
636
|
+
const targetShell = shellEscape(target);
|
|
637
|
+
const stableRelayoutShell = [
|
|
638
|
+
`${tmuxShell} resize-window -t ${targetShell} -A >/dev/null 2>&1 || true`,
|
|
639
|
+
`${tmuxShell} set-window-option -t ${targetShell} window-size latest >/dev/null 2>&1 || true`,
|
|
640
|
+
`w=$(${tmuxShell} display-message -p -t ${targetShell} '#{window_width}' 2>/dev/null || printf 120)`,
|
|
641
|
+
`if [ "$w" -gt 0 ] 2>/dev/null; then ${tmuxShell} set-window-option -t ${targetShell} main-pane-width $((w / 2)) >/dev/null 2>&1 || true; fi`,
|
|
642
|
+
`${tmuxShell} select-layout -t ${targetShell} ${layout} >/dev/null 2>&1 || true`,
|
|
643
|
+
`${tmuxShell} set-window-option -t ${targetShell} window-size latest >/dev/null 2>&1 || true`
|
|
644
|
+
].join('; ');
|
|
645
|
+
const relayout = stableMainVertical
|
|
646
|
+
? `run-shell -b ${shellEscape(stableRelayoutShell)}`
|
|
647
|
+
: `resize-window -t ${target} -A; set-window-option -t ${target} window-size latest; select-layout -t ${target} ${layout}; select-layout -t ${target} -E; set-window-option -t ${target} window-size latest`;
|
|
525
648
|
const commands = [
|
|
526
649
|
['set-window-option', '-t', target, 'window-size', 'latest'],
|
|
527
650
|
['set-window-option', '-t', target, 'aggressive-resize', 'on'],
|
|
@@ -529,8 +652,8 @@ async function enableTmuxDynamicResize(tmuxBin, session, opts = {}) {
|
|
|
529
652
|
['set-hook', '-t', safeSession, 'client-resized', relayout],
|
|
530
653
|
['resize-window', '-t', target, '-A'],
|
|
531
654
|
['set-window-option', '-t', target, 'window-size', 'latest'],
|
|
532
|
-
['select-layout', '-t', target, layout],
|
|
533
|
-
['
|
|
655
|
+
...(stableMainVertical ? [] : [['select-layout', '-t', target, layout], ['select-layout', '-t', target, '-E']]),
|
|
656
|
+
...(stableMainVertical ? [['display-message', '-p', '-t', target, '#{window_width}'], ['select-layout', '-t', target, layout]] : []),
|
|
534
657
|
['set-window-option', '-t', target, 'window-size', 'latest']
|
|
535
658
|
];
|
|
536
659
|
const applied = [];
|
|
@@ -599,19 +722,21 @@ export async function createTmuxSession(plan = {}, panes = [], opts = {}) {
|
|
|
599
722
|
const create = await tmuxRun(tmuxBin, ['new-session', '-d', '-x', dimensions.width, '-y', dimensions.height, '-s', session, '-c', path.resolve(first.cwd || root), '-n', 'sks', '-P', '-F', '#{pane_id}', first.command || 'pwd']);
|
|
600
723
|
if (create.code !== 0) return { ok: false, session, panes: [], stderr: create.stderr || create.stdout || 'tmux new-session failed' };
|
|
601
724
|
const created = [{ pane_id: paneId(create.stdout), role: first.role || 'overview', title: first.title || 'overview' }];
|
|
602
|
-
let
|
|
725
|
+
let rightStackRootPaneId = null;
|
|
603
726
|
for (const pane of normalizedPanes.slice(1)) {
|
|
604
727
|
const direction = rightSidePanes ? (created.length === 1 ? '-h' : '-v') : (pane.vertical ? '-v' : '-h');
|
|
605
|
-
const splitTarget = rightSidePanes ?
|
|
728
|
+
const splitTarget = rightSidePanes ? (rightStackRootPaneId || created[0].pane_id || session) : session;
|
|
606
729
|
const split = await tmuxRun(tmuxBin, ['split-window', '-t', splitTarget, direction, '-d', '-P', '-F', '#{pane_id}', '-c', path.resolve(pane.cwd || root), pane.command || 'pwd']);
|
|
607
730
|
if (split.code !== 0) return { ok: false, session, panes: created, stderr: split.stderr || split.stdout || 'tmux split-window failed' };
|
|
608
731
|
const newPaneId = paneId(split.stdout);
|
|
732
|
+
if (newPaneId && !(await tmuxPaneExists(tmuxBin, newPaneId))) return { ok: false, session, panes: created, stderr: `tmux split-window returned pane ${newPaneId}, but the pane was not present after creation` };
|
|
609
733
|
created.push({ pane_id: newPaneId, role: pane.role || 'lane', title: pane.title || null });
|
|
610
|
-
if (rightSidePanes && newPaneId)
|
|
611
|
-
await tmuxRun(tmuxBin, ['select-layout', '-t', session, layout]).catch(() => null);
|
|
734
|
+
if (rightSidePanes && !rightStackRootPaneId && newPaneId) rightStackRootPaneId = newPaneId;
|
|
735
|
+
if (!rightSidePanes) await tmuxRun(tmuxBin, ['select-layout', '-t', session, layout]).catch(() => null);
|
|
612
736
|
}
|
|
613
|
-
const
|
|
614
|
-
|
|
737
|
+
const stable_layout = rightSidePanes ? await applyStableTeamLayout(tmuxBin, session, created[0].pane_id, { layout }) : null;
|
|
738
|
+
const dynamic_resize = await enableTmuxDynamicResize(tmuxBin, session, { layout, stableTeamLayout: rightSidePanes });
|
|
739
|
+
return { ok: true, reused: false, session, panes: created, attach_command: `tmux attach-session -t ${session}`, layout, initial_size: dimensions, stable_layout, dynamic_resize };
|
|
615
740
|
}
|
|
616
741
|
|
|
617
742
|
export async function launchTmuxUi(args = [], opts = {}) {
|
|
@@ -625,11 +750,16 @@ export async function launchTmuxUi(args = [], opts = {}) {
|
|
|
625
750
|
return { plan };
|
|
626
751
|
}
|
|
627
752
|
if (args.includes('--status-only')) return { plan };
|
|
753
|
+
const codexLbSweep = (opts.codexLbFreshSession || opts.codexLbBypassed)
|
|
754
|
+
? await sweepCodexLbTmuxSessions({ root: plan.root, keepSession: plan.session }).catch((err) => ({ ok: false, skipped: true, reason: err.message || 'codex-lb tmux cleanup failed', closed_session_count: 0 }))
|
|
755
|
+
: null;
|
|
628
756
|
const command = codexLaunchCommand(plan.root, plan.codex.bin, plan.codexArgs);
|
|
629
757
|
const created = await createTmuxSession({ ...plan, command }, [{ cwd: plan.root, command, focused: true, role: 'codex', title: 'Codex CLI' }]);
|
|
630
|
-
if (created.ok) await writeTmuxSessionRecord(plan.root, { session: created.session, attach_command: created.attach_command, panes: created.panes }).catch(() => null);
|
|
758
|
+
if (created.ok) await writeTmuxSessionRecord(plan.root, { session: created.session, attach_command: created.attach_command, panes: created.panes, mode: opts.codexLbBypassed ? 'codex_lb_bypassed_openai_session' : opts.codexLbFreshSession ? 'codex_lb_fresh_session' : 'codex_session' }).catch(() => null);
|
|
631
759
|
if (!args.includes('--quiet')) {
|
|
632
760
|
console.log(`SKS tmux session: ${created.session || plan.session}`);
|
|
761
|
+
if (codexLbSweep?.closed_session_count) console.log(`codex-lb tmux cleanup: closed ${codexLbSweep.closed_session_count} stale detached session(s)`);
|
|
762
|
+
else if (codexLbSweep && !codexLbSweep.ok && !codexLbSweep.skipped) console.log(`codex-lb tmux cleanup: skipped (${codexLbSweep.reason || 'tmux cleanup failed'})`);
|
|
633
763
|
if (created.ok && created.reused) console.log('tmux: reused existing session');
|
|
634
764
|
else if (created.ok) console.log(`tmux: created ${created.panes.length} pane(s)`);
|
|
635
765
|
else console.log(`tmux: not created (${created.stderr || 'tmux failed'})`);
|
|
@@ -644,7 +774,7 @@ export async function launchTmuxUi(args = [], opts = {}) {
|
|
|
644
774
|
process.exitCode = attached.status || 1;
|
|
645
775
|
}
|
|
646
776
|
}
|
|
647
|
-
return { plan, created: Boolean(created.ok), session: created.session || plan.session, opened: created, attached };
|
|
777
|
+
return { plan, created: Boolean(created.ok), session: created.session || plan.session, opened: created, attached, codex_lb_cleanup: codexLbSweep };
|
|
648
778
|
}
|
|
649
779
|
|
|
650
780
|
export async function launchMadTmuxUi(args = [], opts = {}) {
|
|
@@ -658,15 +788,20 @@ export async function launchMadTmuxUi(args = [], opts = {}) {
|
|
|
658
788
|
return { plan };
|
|
659
789
|
}
|
|
660
790
|
if (args.includes('--status-only')) return { plan };
|
|
791
|
+
const codexLbSweep = (opts.codexLbFreshSession || opts.codexLbBypassed)
|
|
792
|
+
? await sweepCodexLbTmuxSessions({ root: plan.root, keepSession: plan.session }).catch((err) => ({ ok: false, skipped: true, reason: err.message || 'codex-lb tmux cleanup failed', closed_session_count: 0 }))
|
|
793
|
+
: null;
|
|
661
794
|
const missionId = opts.missionId || opts.madMissionId || 'latest';
|
|
662
795
|
const mainCommand = codexLaunchCommand(plan.root, plan.codex.bin, plan.codexArgs);
|
|
663
796
|
const panes = [
|
|
664
797
|
{ cwd: plan.root, command: mainCommand, focused: true, role: 'codex', title: 'Codex CLI' }
|
|
665
798
|
];
|
|
666
799
|
const created = await createTmuxSession({ ...plan, command: mainCommand }, panes, { recreate: true });
|
|
667
|
-
if (created.ok) await writeTmuxSessionRecord(plan.root, { session: created.session, attach_command: created.attach_command, panes: created.panes, mode: 'mad_session', mission_id: missionId }).catch(() => null);
|
|
800
|
+
if (created.ok) await writeTmuxSessionRecord(plan.root, { session: created.session, attach_command: created.attach_command, panes: created.panes, mode: opts.codexLbBypassed ? 'mad_codex_lb_bypassed_openai_session' : opts.codexLbFreshSession ? 'mad_codex_lb_fresh_session' : 'mad_session', mission_id: missionId }).catch(() => null);
|
|
668
801
|
if (!args.includes('--quiet')) {
|
|
669
802
|
console.log(`SKS MAD tmux session: ${created.session || plan.session}`);
|
|
803
|
+
if (codexLbSweep?.closed_session_count) console.log(`codex-lb tmux cleanup: closed ${codexLbSweep.closed_session_count} stale detached session(s)`);
|
|
804
|
+
else if (codexLbSweep && !codexLbSweep.ok && !codexLbSweep.skipped) console.log(`codex-lb tmux cleanup: skipped (${codexLbSweep.reason || 'tmux cleanup failed'})`);
|
|
670
805
|
if (created.ok) console.log(`tmux: opened ${created.panes.length} pane(s)`);
|
|
671
806
|
else console.log(`tmux: not created (${created.stderr || 'tmux failed'})`);
|
|
672
807
|
if (created.ok) console.log(`Attach: ${created.attach_command}`);
|
|
@@ -680,7 +815,7 @@ export async function launchMadTmuxUi(args = [], opts = {}) {
|
|
|
680
815
|
process.exitCode = attached.status || 1;
|
|
681
816
|
}
|
|
682
817
|
}
|
|
683
|
-
return { plan, created: Boolean(created.ok), session: created.session || plan.session, opened: created, attached, mode: 'mad_session', mission_id: missionId };
|
|
818
|
+
return { plan, created: Boolean(created.ok), session: created.session || plan.session, opened: created, attached, mode: 'mad_session', mission_id: missionId, codex_lb_cleanup: codexLbSweep };
|
|
684
819
|
}
|
|
685
820
|
|
|
686
821
|
function printTmuxLaunchBlocked(plan, opts = {}) {
|
|
@@ -754,34 +889,53 @@ export async function reconcileTmuxTeamCockpit({ root, missionId, plan = {}, pro
|
|
|
754
889
|
const previousCockpit = cockpitState?.missions?.[id] || {};
|
|
755
890
|
const currentPane = paneList.panes.find((pane) => pane.pane_id === target.pane_id);
|
|
756
891
|
const mainPaneId = previousCockpit.main_pane_id || (currentPane?.managed && currentPane?.mission_id === id ? null : target.pane_id);
|
|
757
|
-
const
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
if (pane.agent && !byAgent.has(pane.agent)) byAgent.set(pane.agent, pane);
|
|
761
|
-
}
|
|
892
|
+
const allManaged = paneList.panes.filter((pane) => pane.managed);
|
|
893
|
+
const managed = allManaged.filter((pane) => pane.mission_id === id);
|
|
894
|
+
const staleManaged = allManaged.filter((pane) => pane.mission_id && pane.mission_id !== id);
|
|
762
895
|
const opened = [];
|
|
763
896
|
const closed = [];
|
|
764
897
|
const failed = [];
|
|
898
|
+
const closePane = async (pane, reason) => {
|
|
899
|
+
const kill = await tmuxRun(tmuxBin, ['kill-pane', '-t', pane.pane_id], { timeoutMs: 5000 });
|
|
900
|
+
if (kill.code === 0) closed.push({ pane_id: pane.pane_id, agent: pane.agent, role: pane.role, mission_id: pane.mission_id, reason });
|
|
901
|
+
else failed.push({ action: 'kill-pane', pane_id: pane.pane_id, agent: pane.agent, mission_id: pane.mission_id, reason, stderr: kill.stderr || kill.stdout || 'tmux kill-pane failed' });
|
|
902
|
+
};
|
|
903
|
+
for (const pane of staleManaged) {
|
|
904
|
+
await closePane(pane, 'stale_mission');
|
|
905
|
+
}
|
|
906
|
+
const keptAgents = new Set();
|
|
765
907
|
for (const pane of managed) {
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
908
|
+
const desired = desiredAgents.has(pane.agent);
|
|
909
|
+
const duplicate = Boolean(pane.agent && keptAgents.has(pane.agent));
|
|
910
|
+
if (desired && !duplicate) {
|
|
911
|
+
keptAgents.add(pane.agent);
|
|
912
|
+
} else {
|
|
913
|
+
await closePane(pane, duplicate ? 'duplicate_agent_lane' : 'not_desired');
|
|
770
914
|
}
|
|
771
915
|
}
|
|
772
|
-
const
|
|
773
|
-
|
|
916
|
+
const closedIds = new Set(closed.map((entry) => entry.pane_id));
|
|
917
|
+
const remainingManaged = managed.filter((pane) => desiredAgents.has(pane.agent) && !closedIds.has(pane.pane_id));
|
|
918
|
+
const byAgent = new Map();
|
|
919
|
+
for (const pane of remainingManaged) {
|
|
920
|
+
if (pane.agent && !byAgent.has(pane.agent)) byAgent.set(pane.agent, pane);
|
|
921
|
+
}
|
|
922
|
+
let rightStackRootPaneId = remainingManaged[0]?.pane_id || null;
|
|
774
923
|
for (const lane of lanes) {
|
|
775
924
|
if (byAgent.has(lane.agent)) continue;
|
|
776
925
|
const firstRightPane = remainingManaged.length === 0 && opened.length === 0;
|
|
777
926
|
const direction = firstRightPane ? '-h' : '-v';
|
|
778
|
-
const
|
|
927
|
+
const splitTarget = firstRightPane ? (mainPaneId || target.window_id) : (rightStackRootPaneId || mainPaneId || target.window_id);
|
|
928
|
+
const split = await tmuxRun(tmuxBin, ['split-window', direction, '-t', splitTarget, '-d', '-P', '-F', '#{pane_id}', '-c', resolvedRoot, lane.command || 'pwd'], { timeoutMs: 5000, maxOutputBytes: 4096 });
|
|
779
929
|
const pane_id = paneId(split.stdout);
|
|
780
930
|
if (split.code !== 0 || !pane_id) {
|
|
781
931
|
failed.push({ action: 'split-window', agent: lane.agent, role: lane.role, stderr: split.stderr || split.stdout || 'tmux split-window failed' });
|
|
782
932
|
continue;
|
|
783
933
|
}
|
|
784
|
-
|
|
934
|
+
if (!(await tmuxPaneExists(tmuxBin, pane_id))) {
|
|
935
|
+
failed.push({ action: 'verify-pane', pane_id, agent: lane.agent, role: lane.role, stderr: 'tmux split-window returned a pane id, but the pane was not present after creation' });
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
if (!rightStackRootPaneId) rightStackRootPaneId = pane_id;
|
|
785
939
|
const optionResult = await setTmuxPaneUserOptions(tmuxBin, pane_id, {
|
|
786
940
|
'@sks_team_managed': '1',
|
|
787
941
|
'@sks_mission_id': id,
|
|
@@ -793,13 +947,10 @@ export async function reconcileTmuxTeamCockpit({ root, missionId, plan = {}, pro
|
|
|
793
947
|
}
|
|
794
948
|
let relayout = null;
|
|
795
949
|
if (opened.length || closed.length) {
|
|
796
|
-
|
|
797
|
-
const tiled = await tmuxRun(tmuxBin, ['select-layout', '-t', target.window_id, DYNAMIC_TEAM_TMUX_LAYOUT], { timeoutMs: 5000 });
|
|
798
|
-
const even = await tmuxRun(tmuxBin, ['select-layout', '-t', target.window_id, '-E'], { timeoutMs: 5000 });
|
|
799
|
-
relayout = { ok: selectedMain.code === 0 && tiled.code === 0 && even.code === 0, selected_main: selectedMain.code, layout: tiled.code, even: even.code, layout_name: DYNAMIC_TEAM_TMUX_LAYOUT };
|
|
950
|
+
relayout = await applyStableTeamLayout(tmuxBin, target.window_id, mainPaneId, { layout: DYNAMIC_TEAM_TMUX_LAYOUT });
|
|
800
951
|
}
|
|
801
952
|
const nextPanes = [
|
|
802
|
-
...
|
|
953
|
+
...remainingManaged,
|
|
803
954
|
...opened
|
|
804
955
|
];
|
|
805
956
|
await writeTmuxCockpitRecord(resolvedRoot, {
|
|
@@ -835,6 +986,92 @@ export async function reconcileTmuxTeamCockpit({ root, missionId, plan = {}, pro
|
|
|
835
986
|
};
|
|
836
987
|
}
|
|
837
988
|
|
|
989
|
+
export async function sweepTmuxTeamSurfaces({ root, keepMissionId = null, env = process.env, tmux = null, dryRun = false } = {}) {
|
|
990
|
+
const resolvedRoot = path.resolve(root || await sksRoot());
|
|
991
|
+
const tmuxBin = tmux?.bin || await findTmuxBin() || 'tmux';
|
|
992
|
+
const hints = await readRecordedTeamSurfaceHints(resolvedRoot);
|
|
993
|
+
if (!hints.missionIds.size && !hints.sessions.size && !hints.paneKeys.size) {
|
|
994
|
+
return { ok: true, skipped: true, reason: 'no recorded SKS Team tmux surfaces for this repo', closed_lane_count: 0, targets: [] };
|
|
995
|
+
}
|
|
996
|
+
const paneList = await listAllTmuxPanes(tmuxBin);
|
|
997
|
+
if (!paneList.ok) return { ok: false, skipped: true, reason: paneList.stderr, closed_lane_count: 0, targets: [] };
|
|
998
|
+
const current = await currentTmuxTarget(tmuxBin, env).catch(() => ({ ok: false }));
|
|
999
|
+
const keepMissionIds = new Set(Array.isArray(keepMissionId) ? keepMissionId.filter(Boolean).map(String) : (keepMissionId ? [String(keepMissionId)] : []));
|
|
1000
|
+
const targets = paneList.panes
|
|
1001
|
+
.filter((pane) => shouldSweepTeamPane(pane, hints, { currentPaneId: current?.pane_id || '', keepMissionIds }))
|
|
1002
|
+
.filter((pane, index, panes) => panes.findIndex((candidate) => candidate.pane_id === pane.pane_id) === index);
|
|
1003
|
+
const closed = [];
|
|
1004
|
+
const failed = [];
|
|
1005
|
+
for (const pane of targets) {
|
|
1006
|
+
if (dryRun) {
|
|
1007
|
+
closed.push({ pane_id: pane.pane_id, session: pane.session, window_id: pane.window_id, agent: pane.agent, role: pane.role, mission_id: pane.mission_id, title: pane.title, dry_run: true });
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
const kill = await tmuxRun(tmuxBin, ['kill-pane', '-t', pane.pane_id], { timeoutMs: 5000 });
|
|
1011
|
+
if (kill.code === 0) closed.push({ pane_id: pane.pane_id, session: pane.session, window_id: pane.window_id, agent: pane.agent, role: pane.role, mission_id: pane.mission_id, title: pane.title });
|
|
1012
|
+
else failed.push({ pane_id: pane.pane_id, session: pane.session, window_id: pane.window_id, agent: pane.agent, mission_id: pane.mission_id, title: pane.title, stderr: kill.stderr || kill.stdout || 'tmux kill-pane failed' });
|
|
1013
|
+
}
|
|
1014
|
+
if (!dryRun && closed.length) {
|
|
1015
|
+
for (const windowId of Array.from(new Set(closed.map((pane) => pane.window_id).filter(Boolean)))) {
|
|
1016
|
+
await tmuxRun(tmuxBin, ['select-layout', '-t', windowId, DYNAMIC_TEAM_TMUX_LAYOUT], { timeoutMs: 5000 }).catch(() => null);
|
|
1017
|
+
await tmuxRun(tmuxBin, ['select-layout', '-t', windowId, '-E'], { timeoutMs: 5000 }).catch(() => null);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return {
|
|
1021
|
+
ok: failed.length === 0,
|
|
1022
|
+
skipped: false,
|
|
1023
|
+
root: resolvedRoot,
|
|
1024
|
+
keep_mission_id: keepMissionIds.size ? Array.from(keepMissionIds) : null,
|
|
1025
|
+
scanned_pane_count: paneList.panes.length,
|
|
1026
|
+
target_count: targets.length,
|
|
1027
|
+
closed_lane_count: closed.length,
|
|
1028
|
+
closed,
|
|
1029
|
+
failed,
|
|
1030
|
+
reason: closed.length
|
|
1031
|
+
? 'swept stale recorded SKS Team tmux panes for this repo.'
|
|
1032
|
+
: 'no stale recorded SKS Team tmux panes found for this repo.'
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
export async function sweepCodexLbTmuxSessions({ root, keepSession = null, env = process.env, tmux = null, dryRun = false } = {}) {
|
|
1037
|
+
const resolvedRoot = path.resolve(root || await sksRoot());
|
|
1038
|
+
const tmuxBin = tmux?.bin || await findTmuxBin() || 'tmux';
|
|
1039
|
+
const sessionList = await listAllTmuxSessions(tmuxBin);
|
|
1040
|
+
if (!sessionList.ok) return { ok: false, skipped: true, reason: sessionList.stderr, closed_session_count: 0, targets: [] };
|
|
1041
|
+
const current = await currentTmuxTarget(tmuxBin, env).catch(() => ({ ok: false }));
|
|
1042
|
+
const suffix = `-${defaultTmuxSessionName(resolvedRoot)}`;
|
|
1043
|
+
const keep = new Set([keepSession, current?.session].filter(Boolean).map((entry) => sanitizeTmuxSessionName(entry)));
|
|
1044
|
+
const targets = sessionList.sessions
|
|
1045
|
+
.filter((entry) => entry.session.startsWith('sks-codex-lb-') && entry.session.endsWith(suffix))
|
|
1046
|
+
.filter((entry) => !entry.attached && !keep.has(entry.session))
|
|
1047
|
+
.sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
|
|
1048
|
+
const closed = [];
|
|
1049
|
+
const failed = [];
|
|
1050
|
+
for (const target of targets) {
|
|
1051
|
+
if (dryRun) {
|
|
1052
|
+
closed.push({ session: target.session, dry_run: true, created_at: target.created_at, last_attached_at: target.last_attached_at });
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
const kill = await tmuxRun(tmuxBin, ['kill-session', '-t', target.session], { timeoutMs: 5000 });
|
|
1056
|
+
if (kill.code === 0) closed.push({ session: target.session, created_at: target.created_at, last_attached_at: target.last_attached_at });
|
|
1057
|
+
else failed.push({ session: target.session, stderr: kill.stderr || kill.stdout || 'tmux kill-session failed' });
|
|
1058
|
+
}
|
|
1059
|
+
return {
|
|
1060
|
+
ok: failed.length === 0,
|
|
1061
|
+
skipped: false,
|
|
1062
|
+
root: resolvedRoot,
|
|
1063
|
+
keep_session: keepSession || null,
|
|
1064
|
+
scanned_session_count: sessionList.sessions.length,
|
|
1065
|
+
target_count: targets.length,
|
|
1066
|
+
closed_session_count: closed.length,
|
|
1067
|
+
closed,
|
|
1068
|
+
failed,
|
|
1069
|
+
reason: closed.length
|
|
1070
|
+
? 'swept detached codex-lb tmux sessions for this repo.'
|
|
1071
|
+
: 'no stale detached codex-lb tmux sessions found for this repo.'
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
838
1075
|
export async function launchTmuxTeamView({ root, missionId, plan = {}, promptFile = null, json = false, attach = false, args = [] } = {}) {
|
|
839
1076
|
const launch = await buildTmuxLaunchPlan({ root, session: `sks-team-${missionId}` });
|
|
840
1077
|
const missionDir = path.join(launch.root, '.sneakoscope', 'missions', missionId);
|
|
@@ -878,6 +1115,7 @@ export async function launchTmuxTeamView({ root, missionId, plan = {}, promptFil
|
|
|
878
1115
|
attach_command: launch.attach_command
|
|
879
1116
|
};
|
|
880
1117
|
if (json || !launch.ready) return result;
|
|
1118
|
+
result.preflight_cleanup = await sweepTmuxTeamSurfaces({ root: launch.root, keepMissionId: missionId }).catch((err) => ({ ok: false, skipped: true, reason: err.message || 'tmux preflight sweep failed', closed_lane_count: 0 }));
|
|
881
1119
|
const wantsSeparateSession = args.includes('--separate-session') || args.includes('--new-session') || args.includes('--legacy-team-session') || args.includes('--no-dynamic-team-tmux');
|
|
882
1120
|
if (!wantsSeparateSession) {
|
|
883
1121
|
const cockpit = await reconcileTmuxTeamCockpit({ root: launch.root, missionId, plan, promptFile, dashboard, control, plannedFallback: true });
|
|
@@ -1026,14 +1264,16 @@ export async function cleanupTmuxTeamView({ root, missionId = 'latest', closeSes
|
|
|
1026
1264
|
const record = await readTmuxTeamRecord(resolvedRoot, missionId);
|
|
1027
1265
|
if (!record?.session) {
|
|
1028
1266
|
const legacy = await cleanupLegacyTmuxTeamSurfaces(resolvedRoot, missionId, { closeSession }).catch((err) => ({ ok: false, skipped: true, reason: err.message || 'legacy tmux cleanup failed' }));
|
|
1267
|
+
const sweep = await sweepTmuxTeamSurfaces({ root: resolvedRoot }).catch((err) => ({ ok: false, skipped: true, reason: err.message || 'tmux sweep failed', closed_lane_count: 0 }));
|
|
1029
1268
|
return {
|
|
1030
|
-
ok: legacy.ok,
|
|
1031
|
-
skipped: legacy.closed_lane_count === 0 && !legacy.killed_session,
|
|
1269
|
+
ok: legacy.ok && sweep.ok !== false,
|
|
1270
|
+
skipped: legacy.closed_lane_count === 0 && !legacy.killed_session && !sweep.closed_lane_count,
|
|
1032
1271
|
reason: legacy.reason,
|
|
1033
1272
|
mission_id: missionId,
|
|
1034
1273
|
legacy_cleanup: legacy,
|
|
1035
|
-
|
|
1036
|
-
|
|
1274
|
+
sweep_cleanup: sweep,
|
|
1275
|
+
requested_close_surfaces: (legacy.requested_close_surfaces || 0) + (sweep.target_count || 0),
|
|
1276
|
+
closed_surfaces: (legacy.closed_lane_count || (legacy.killed_session ? 1 : 0)) + (sweep.closed_lane_count || 0)
|
|
1037
1277
|
};
|
|
1038
1278
|
}
|
|
1039
1279
|
const dynamicCleanup = await reconcileTmuxTeamCockpit({ root: resolvedRoot, missionId: record.mission_id || missionId, close: true }).catch((err) => ({ ok: false, skipped: true, reason: err.message || 'dynamic tmux cleanup failed' }));
|
|
@@ -1043,6 +1283,7 @@ export async function cleanupTmuxTeamView({ root, missionId = 'latest', closeSes
|
|
|
1043
1283
|
const legacyCleanup = (dynamicCleanup?.closed_lane_count || recordedCleanup?.closed_lane_count)
|
|
1044
1284
|
? null
|
|
1045
1285
|
: await cleanupLegacyTmuxTeamSurfaces(resolvedRoot, record.mission_id || missionId, { closeSession: false }).catch((err) => ({ ok: false, skipped: true, reason: err.message || 'legacy tmux cleanup failed' }));
|
|
1286
|
+
const sweepCleanup = await sweepTmuxTeamSurfaces({ root: resolvedRoot }).catch((err) => ({ ok: false, skipped: true, reason: err.message || 'tmux sweep failed', closed_lane_count: 0 }));
|
|
1046
1287
|
let killed_session = false;
|
|
1047
1288
|
if ((closeSession || closeSession === true) && record.mode !== 'current_session_dynamic_panes') {
|
|
1048
1289
|
const tmuxBin = await findTmuxBin() || 'tmux';
|
|
@@ -1060,8 +1301,9 @@ export async function cleanupTmuxTeamView({ root, missionId = 'latest', closeSes
|
|
|
1060
1301
|
dynamic_cleanup: dynamicCleanup,
|
|
1061
1302
|
recorded_cleanup: recordedCleanup,
|
|
1062
1303
|
legacy_cleanup: legacyCleanup,
|
|
1063
|
-
|
|
1064
|
-
|
|
1304
|
+
sweep_cleanup: sweepCleanup,
|
|
1305
|
+
requested_close_surfaces: (closeSession ? 1 : (dynamicCleanup?.closed_lane_count || recordedCleanup?.closed_lane_count || legacyCleanup?.requested_close_surfaces || 0)) + (sweepCleanup?.target_count || 0),
|
|
1306
|
+
closed_surfaces: (killed_session ? 1 : (dynamicCleanup?.closed_lane_count || recordedCleanup?.closed_lane_count || legacyCleanup?.closed_lane_count || 0)) + (sweepCleanup?.closed_lane_count || 0),
|
|
1065
1307
|
reason: dynamicCleanup?.ok
|
|
1066
1308
|
? 'cleanup closed managed Team panes in the current SKS tmux session.'
|
|
1067
1309
|
: recordedCleanup?.ok
|