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.
@@ -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 managed = paneList.panes.filter((pane) => pane.managed && pane.mission_id === id);
813
- const byAgent = new Map();
814
- for (const pane of managed) {
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
- if (!desiredAgents.has(pane.agent)) {
822
- const kill = await tmuxRun(tmuxBin, ['kill-pane', '-t', pane.pane_id], { timeoutMs: 5000 });
823
- if (kill.code === 0) closed.push({ pane_id: pane.pane_id, agent: pane.agent, role: pane.role });
824
- else failed.push({ action: 'kill-pane', pane_id: pane.pane_id, agent: pane.agent, stderr: kill.stderr || kill.stdout || 'tmux kill-pane failed' });
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 remainingManaged = managed.filter((pane) => desiredAgents.has(pane.agent) && !closed.some((entry) => entry.pane_id === pane.pane_id));
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
- ...managed.filter((pane) => desiredAgents.has(pane.agent) && !closed.some((entry) => entry.pane_id === pane.pane_id)),
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
- requested_close_surfaces: legacy.requested_close_surfaces || 0,
1093
- closed_surfaces: legacy.closed_lane_count || (legacy.killed_session ? 1 : 0)
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
- requested_close_surfaces: closeSession ? 1 : (dynamicCleanup?.closed_lane_count || recordedCleanup?.closed_lane_count || legacyCleanup?.requested_close_surfaces || 0),
1121
- closed_surfaces: killed_session ? 1 : (dynamicCleanup?.closed_lane_count || recordedCleanup?.closed_lane_count || legacyCleanup?.closed_lane_count || 0),
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 { ensureDir, exists, nowIso, readJson, runProcess, writeJsonAtomic, writeTextAtomic } from './fsx.mjs';
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, installed: false, reason: git.reason || 'not_git' };
12
- const hookPath = git.hook_path;
13
- const block = versionHookBlock(commandPrefix);
14
- const current = await readFileMaybe(hookPath);
15
- const next = mergeShellBlock(current, VERSION_HOOK_MARKER, block);
16
- await ensureDir(path.dirname(hookPath));
17
- await writeTextAtomic(hookPath, next);
18
- await fsp.chmod(hookPath, 0o755).catch(() => {});
19
- return { ok: true, installed: true, hook_path: hookPath };
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 !== false,
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 the automatic SKS version guard advances the package version.\n`;
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 versionHookBlock(commandPrefix) {
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 managed = `${begin}\n${block.trim()}\n${end}\n`;
352
- if (!current.trim()) return `#!/bin/sh\n${managed}`;
353
- const withShebang = current.startsWith('#!') ? current : `#!/bin/sh\n${current}`;
354
- const beginIdx = withShebang.indexOf(begin);
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) {