sneakoscope 0.7.75 → 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 +22 -7
- 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/routes.mjs +3 -3
- package/src/core/team-live.mjs +81 -12
- package/src/core/tmux-ui.mjs +230 -45
- 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,24 +101,6 @@ 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';
|
|
@@ -402,11 +384,6 @@ async function isRecordedSksTmuxSession(root, session) {
|
|
|
402
384
|
return { ok: true, record };
|
|
403
385
|
}
|
|
404
386
|
|
|
405
|
-
function isTerminalTeamAgentStatus(status = '') {
|
|
406
|
-
const normalized = String(status || '').trim().toLowerCase();
|
|
407
|
-
return TERMINAL_TEAM_AGENT_STATUSES.has(normalized) || /(?:^|_)(?:done|complete|completed|closed|cleanup|cancelled|canceled|failed|ended|stopped)(?:_|$)/.test(normalized);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
387
|
function teamCockpitAgentIds(plan = {}, dashboard = null, control = null, opts = {}) {
|
|
411
388
|
if (teamCleanupRequested(control)) return [];
|
|
412
389
|
const visible = teamViewAgentIds(plan).filter((id) => id && id !== 'mission_overview');
|
|
@@ -474,6 +451,35 @@ function parseTmuxPaneLines(stdout = '') {
|
|
|
474
451
|
}).filter((pane) => /^%\d+$/.test(pane.pane_id || ''));
|
|
475
452
|
}
|
|
476
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
|
+
|
|
477
483
|
function isLegacyTeamPane(pane = {}) {
|
|
478
484
|
return LEGACY_TEAM_PANE_TITLE_RE.test(String(pane.title || '').trim());
|
|
479
485
|
}
|
|
@@ -485,6 +491,20 @@ async function listTmuxWindowPanes(bin, windowId) {
|
|
|
485
491
|
return { ok: true, panes: parseTmuxPaneLines(run.stdout) };
|
|
486
492
|
}
|
|
487
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
|
+
|
|
488
508
|
async function tmuxPaneExists(bin, paneId) {
|
|
489
509
|
if (!paneId || !String(paneId).startsWith('%')) return false;
|
|
490
510
|
const run = await tmuxRun(bin, ['list-panes', '-t', paneId, '-F', '#{pane_dead}\t#{pane_id}'], { timeoutMs: 5000, maxOutputBytes: 4096 });
|
|
@@ -522,6 +542,56 @@ async function writeTmuxCockpitRecord(root, record = {}) {
|
|
|
522
542
|
return nextRecord;
|
|
523
543
|
}
|
|
524
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
|
+
|
|
525
595
|
function tmuxLayoutName(value = 'tiled') {
|
|
526
596
|
const layout = String(value || 'tiled').trim();
|
|
527
597
|
return /^(tiled|even-horizontal|even-vertical|main-horizontal|main-horizontal-mirrored|main-vertical|main-vertical-mirrored)$/.test(layout)
|
|
@@ -680,11 +750,16 @@ export async function launchTmuxUi(args = [], opts = {}) {
|
|
|
680
750
|
return { plan };
|
|
681
751
|
}
|
|
682
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;
|
|
683
756
|
const command = codexLaunchCommand(plan.root, plan.codex.bin, plan.codexArgs);
|
|
684
757
|
const created = await createTmuxSession({ ...plan, command }, [{ cwd: plan.root, command, focused: true, role: 'codex', title: 'Codex CLI' }]);
|
|
685
|
-
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);
|
|
686
759
|
if (!args.includes('--quiet')) {
|
|
687
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'})`);
|
|
688
763
|
if (created.ok && created.reused) console.log('tmux: reused existing session');
|
|
689
764
|
else if (created.ok) console.log(`tmux: created ${created.panes.length} pane(s)`);
|
|
690
765
|
else console.log(`tmux: not created (${created.stderr || 'tmux failed'})`);
|
|
@@ -699,7 +774,7 @@ export async function launchTmuxUi(args = [], opts = {}) {
|
|
|
699
774
|
process.exitCode = attached.status || 1;
|
|
700
775
|
}
|
|
701
776
|
}
|
|
702
|
-
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 };
|
|
703
778
|
}
|
|
704
779
|
|
|
705
780
|
export async function launchMadTmuxUi(args = [], opts = {}) {
|
|
@@ -713,15 +788,20 @@ export async function launchMadTmuxUi(args = [], opts = {}) {
|
|
|
713
788
|
return { plan };
|
|
714
789
|
}
|
|
715
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;
|
|
716
794
|
const missionId = opts.missionId || opts.madMissionId || 'latest';
|
|
717
795
|
const mainCommand = codexLaunchCommand(plan.root, plan.codex.bin, plan.codexArgs);
|
|
718
796
|
const panes = [
|
|
719
797
|
{ cwd: plan.root, command: mainCommand, focused: true, role: 'codex', title: 'Codex CLI' }
|
|
720
798
|
];
|
|
721
799
|
const created = await createTmuxSession({ ...plan, command: mainCommand }, panes, { recreate: true });
|
|
722
|
-
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);
|
|
723
801
|
if (!args.includes('--quiet')) {
|
|
724
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'})`);
|
|
725
805
|
if (created.ok) console.log(`tmux: opened ${created.panes.length} pane(s)`);
|
|
726
806
|
else console.log(`tmux: not created (${created.stderr || 'tmux failed'})`);
|
|
727
807
|
if (created.ok) console.log(`Attach: ${created.attach_command}`);
|
|
@@ -735,7 +815,7 @@ export async function launchMadTmuxUi(args = [], opts = {}) {
|
|
|
735
815
|
process.exitCode = attached.status || 1;
|
|
736
816
|
}
|
|
737
817
|
}
|
|
738
|
-
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 };
|
|
739
819
|
}
|
|
740
820
|
|
|
741
821
|
function printTmuxLaunchBlocked(plan, opts = {}) {
|
|
@@ -809,22 +889,36 @@ export async function reconcileTmuxTeamCockpit({ root, missionId, plan = {}, pro
|
|
|
809
889
|
const previousCockpit = cockpitState?.missions?.[id] || {};
|
|
810
890
|
const currentPane = paneList.panes.find((pane) => pane.pane_id === target.pane_id);
|
|
811
891
|
const mainPaneId = previousCockpit.main_pane_id || (currentPane?.managed && currentPane?.mission_id === id ? null : target.pane_id);
|
|
812
|
-
const
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
if (pane.agent && !byAgent.has(pane.agent)) byAgent.set(pane.agent, pane);
|
|
816
|
-
}
|
|
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);
|
|
817
895
|
const opened = [];
|
|
818
896
|
const closed = [];
|
|
819
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();
|
|
820
907
|
for (const pane of managed) {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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');
|
|
825
914
|
}
|
|
826
915
|
}
|
|
827
|
-
const
|
|
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
|
+
}
|
|
828
922
|
let rightStackRootPaneId = remainingManaged[0]?.pane_id || null;
|
|
829
923
|
for (const lane of lanes) {
|
|
830
924
|
if (byAgent.has(lane.agent)) continue;
|
|
@@ -856,7 +950,7 @@ export async function reconcileTmuxTeamCockpit({ root, missionId, plan = {}, pro
|
|
|
856
950
|
relayout = await applyStableTeamLayout(tmuxBin, target.window_id, mainPaneId, { layout: DYNAMIC_TEAM_TMUX_LAYOUT });
|
|
857
951
|
}
|
|
858
952
|
const nextPanes = [
|
|
859
|
-
...
|
|
953
|
+
...remainingManaged,
|
|
860
954
|
...opened
|
|
861
955
|
];
|
|
862
956
|
await writeTmuxCockpitRecord(resolvedRoot, {
|
|
@@ -892,6 +986,92 @@ export async function reconcileTmuxTeamCockpit({ root, missionId, plan = {}, pro
|
|
|
892
986
|
};
|
|
893
987
|
}
|
|
894
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
|
+
|
|
895
1075
|
export async function launchTmuxTeamView({ root, missionId, plan = {}, promptFile = null, json = false, attach = false, args = [] } = {}) {
|
|
896
1076
|
const launch = await buildTmuxLaunchPlan({ root, session: `sks-team-${missionId}` });
|
|
897
1077
|
const missionDir = path.join(launch.root, '.sneakoscope', 'missions', missionId);
|
|
@@ -935,6 +1115,7 @@ export async function launchTmuxTeamView({ root, missionId, plan = {}, promptFil
|
|
|
935
1115
|
attach_command: launch.attach_command
|
|
936
1116
|
};
|
|
937
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 }));
|
|
938
1119
|
const wantsSeparateSession = args.includes('--separate-session') || args.includes('--new-session') || args.includes('--legacy-team-session') || args.includes('--no-dynamic-team-tmux');
|
|
939
1120
|
if (!wantsSeparateSession) {
|
|
940
1121
|
const cockpit = await reconcileTmuxTeamCockpit({ root: launch.root, missionId, plan, promptFile, dashboard, control, plannedFallback: true });
|
|
@@ -1083,14 +1264,16 @@ export async function cleanupTmuxTeamView({ root, missionId = 'latest', closeSes
|
|
|
1083
1264
|
const record = await readTmuxTeamRecord(resolvedRoot, missionId);
|
|
1084
1265
|
if (!record?.session) {
|
|
1085
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 }));
|
|
1086
1268
|
return {
|
|
1087
|
-
ok: legacy.ok,
|
|
1088
|
-
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,
|
|
1089
1271
|
reason: legacy.reason,
|
|
1090
1272
|
mission_id: missionId,
|
|
1091
1273
|
legacy_cleanup: legacy,
|
|
1092
|
-
|
|
1093
|
-
|
|
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)
|
|
1094
1277
|
};
|
|
1095
1278
|
}
|
|
1096
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' }));
|
|
@@ -1100,6 +1283,7 @@ export async function cleanupTmuxTeamView({ root, missionId = 'latest', closeSes
|
|
|
1100
1283
|
const legacyCleanup = (dynamicCleanup?.closed_lane_count || recordedCleanup?.closed_lane_count)
|
|
1101
1284
|
? null
|
|
1102
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 }));
|
|
1103
1287
|
let killed_session = false;
|
|
1104
1288
|
if ((closeSession || closeSession === true) && record.mode !== 'current_session_dynamic_panes') {
|
|
1105
1289
|
const tmuxBin = await findTmuxBin() || 'tmux';
|
|
@@ -1117,8 +1301,9 @@ export async function cleanupTmuxTeamView({ root, missionId = 'latest', closeSes
|
|
|
1117
1301
|
dynamic_cleanup: dynamicCleanup,
|
|
1118
1302
|
recorded_cleanup: recordedCleanup,
|
|
1119
1303
|
legacy_cleanup: legacyCleanup,
|
|
1120
|
-
|
|
1121
|
-
|
|
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),
|
|
1122
1307
|
reason: dynamicCleanup?.ok
|
|
1123
1308
|
? 'cleanup closed managed Team panes in the current SKS tmux session.'
|
|
1124
1309
|
: recordedCleanup?.ok
|
|
@@ -1,22 +1,38 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fsp from 'node:fs/promises';
|
|
3
|
-
import {
|
|
3
|
+
import { exists, nowIso, readJson, runProcess, writeJsonAtomic, writeTextAtomic } from './fsx.mjs';
|
|
4
4
|
|
|
5
5
|
const VERSION_HOOK_MARKER = 'Sneakoscope Codex Version Guard';
|
|
6
6
|
const VERSION_STATE_FILE = 'sks-version-state.json';
|
|
7
7
|
const DEFAULT_BUMP = 'patch';
|
|
8
8
|
|
|
9
9
|
export async function installVersionGitHook(root, commandPrefix = 'sks') {
|
|
10
|
+
void root;
|
|
11
|
+
void commandPrefix;
|
|
12
|
+
return {
|
|
13
|
+
ok: false,
|
|
14
|
+
installed: false,
|
|
15
|
+
reason: 'pre_commit_hooks_unsupported',
|
|
16
|
+
message: 'SKS no longer installs Git pre-commit hooks. Use `sks versioning bump` and release checks explicitly.'
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function disableVersionGitHook(root) {
|
|
21
|
+
await setVersionPolicyEnabled(root, false);
|
|
10
22
|
const git = await gitPaths(root);
|
|
11
|
-
if (!git.ok) return { ok: true,
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
if (!git.ok) return { ok: true, disabled: true, hook_removed: false, reason: git.reason || 'not_git' };
|
|
24
|
+
const current = await readFileMaybe(git.hook_path);
|
|
25
|
+
if (!current.includes(`BEGIN ${VERSION_HOOK_MARKER}`)) {
|
|
26
|
+
return { ok: true, disabled: true, hook_removed: false, hook_path: git.hook_path, reason: 'managed_hook_not_installed' };
|
|
27
|
+
}
|
|
28
|
+
const next = removeShellBlock(current, VERSION_HOOK_MARKER);
|
|
29
|
+
if (next.trim() === '#!/bin/sh' || next.trim() === '#!/usr/bin/env sh' || !next.trim()) {
|
|
30
|
+
await fsp.rm(git.hook_path, { force: true });
|
|
31
|
+
return { ok: true, disabled: true, hook_removed: true, hook_path: git.hook_path };
|
|
32
|
+
}
|
|
33
|
+
await writeTextAtomic(git.hook_path, next);
|
|
34
|
+
await fsp.chmod(git.hook_path, 0o755).catch(() => {});
|
|
35
|
+
return { ok: true, disabled: true, hook_removed: true, hook_path: git.hook_path };
|
|
20
36
|
}
|
|
21
37
|
|
|
22
38
|
export async function versioningStatus(root) {
|
|
@@ -40,7 +56,7 @@ export async function versioningStatus(root) {
|
|
|
40
56
|
state_path: path.join(git.common_dir, VERSION_STATE_FILE),
|
|
41
57
|
last_version: state.last_version || null,
|
|
42
58
|
runtime_drift: runtimeDrift,
|
|
43
|
-
reason: version ? null : 'package_json_version_missing'
|
|
59
|
+
reason: !policy.enabled ? 'disabled_by_policy' : (version ? null : 'package_json_version_missing')
|
|
44
60
|
};
|
|
45
61
|
}
|
|
46
62
|
|
|
@@ -190,11 +206,38 @@ export async function verifyProjectVersion(root, opts = {}) {
|
|
|
190
206
|
async function versionPolicy(root) {
|
|
191
207
|
const policy = await readJson(path.join(root, '.sneakoscope', 'policy.json'), {});
|
|
192
208
|
return {
|
|
193
|
-
enabled: policy.versioning?.enabled
|
|
209
|
+
enabled: policy.versioning?.enabled === true,
|
|
194
210
|
bump: policy.versioning?.bump || DEFAULT_BUMP
|
|
195
211
|
};
|
|
196
212
|
}
|
|
197
213
|
|
|
214
|
+
async function setVersionPolicyEnabled(root, enabled) {
|
|
215
|
+
const policyPath = path.join(root, '.sneakoscope', 'policy.json');
|
|
216
|
+
const policy = await readJson(policyPath, {});
|
|
217
|
+
await writeJsonAtomic(policyPath, {
|
|
218
|
+
...policy,
|
|
219
|
+
git: {
|
|
220
|
+
...(policy.git || {}),
|
|
221
|
+
versioning: {
|
|
222
|
+
...(policy.git?.versioning || {}),
|
|
223
|
+
enabled: Boolean(enabled),
|
|
224
|
+
bump: policy.git?.versioning?.bump || policy.versioning?.bump || DEFAULT_BUMP,
|
|
225
|
+
lock: 'git-common-dir/sks-version.lock',
|
|
226
|
+
state: 'git-common-dir/sks-version-state.json'
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
versioning: {
|
|
230
|
+
...(policy.versioning || {}),
|
|
231
|
+
enabled: Boolean(enabled),
|
|
232
|
+
bump: policy.versioning?.bump || DEFAULT_BUMP,
|
|
233
|
+
trigger: 'manual',
|
|
234
|
+
lock_scope: 'git-common-dir',
|
|
235
|
+
managed_files: policy.versioning?.managed_files || ['package.json', 'package-lock.json', 'npm-shrinkwrap.json'],
|
|
236
|
+
collision_policy: policy.versioning?.collision_policy || 'explicit_bump_only'
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
198
241
|
async function gitPaths(root) {
|
|
199
242
|
const top = await git(root, ['rev-parse', '--show-toplevel']);
|
|
200
243
|
if (top.code !== 0) return { ok: false, reason: 'not_git' };
|
|
@@ -287,7 +330,7 @@ async function syncChangelogVersionSection(root, version) {
|
|
|
287
330
|
text = text.replace(/^#\s+Changelog\s*$/m, (title) => `${title}\n\n## [Unreleased]`);
|
|
288
331
|
}
|
|
289
332
|
|
|
290
|
-
const managedSection = `\n## [${version}] - ${date}\n\n### Fixed\n\n- Keep release metadata aligned after
|
|
333
|
+
const managedSection = `\n## [${version}] - ${date}\n\n### Fixed\n\n- Keep release metadata aligned after an explicit SKS version bump advances the package version.\n`;
|
|
291
334
|
const next = text.replace(/^##\s+\[Unreleased\]\s*$/m, (heading) => `${heading}\n${managedSection}`);
|
|
292
335
|
if (next === text) return { files: [], relative_files: [] };
|
|
293
336
|
await writeTextAtomic(file, next);
|
|
@@ -341,26 +384,13 @@ function bumpSemver(v, bump = DEFAULT_BUMP) {
|
|
|
341
384
|
return { major: v.major, minor: v.minor, patch: v.patch + 1 };
|
|
342
385
|
}
|
|
343
386
|
|
|
344
|
-
function
|
|
345
|
-
return `# SKS keeps package versions unique across worker commits.\n${commandPrefix} versioning pre-commit\nstatus=$?\nif [ $status -ne 0 ]; then\n echo \"SKS versioning blocked commit. Run: sks versioning status\" >&2\n exit $status\nfi`;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function mergeShellBlock(current, marker, block) {
|
|
387
|
+
function removeShellBlock(current, marker) {
|
|
349
388
|
const begin = `# BEGIN ${marker}`;
|
|
350
389
|
const end = `# END ${marker}`;
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const endIdx = withShebang.indexOf(end);
|
|
356
|
-
if (beginIdx >= 0 && endIdx >= beginIdx) {
|
|
357
|
-
return `${withShebang.slice(0, beginIdx)}${managed}${withShebang.slice(endIdx + end.length).replace(/^\n/, '')}`;
|
|
358
|
-
}
|
|
359
|
-
const lines = withShebang.split('\n');
|
|
360
|
-
if (lines[0]?.startsWith('#!')) {
|
|
361
|
-
return `${lines[0]}\n${managed}${lines.slice(1).join('\n').replace(/^\n/, '')}`.replace(/\s*$/, '\n');
|
|
362
|
-
}
|
|
363
|
-
return `${managed}${withShebang.replace(/^\n/, '').replace(/\s*$/, '\n')}`;
|
|
390
|
+
const beginIdx = current.indexOf(begin);
|
|
391
|
+
const endIdx = current.indexOf(end);
|
|
392
|
+
if (beginIdx < 0 || endIdx < beginIdx) return current;
|
|
393
|
+
return `${current.slice(0, beginIdx)}${current.slice(endIdx + end.length).replace(/^\n/, '')}`.replace(/\s*$/, '\n');
|
|
364
394
|
}
|
|
365
395
|
|
|
366
396
|
async function readFileMaybe(file) {
|