sneakoscope 0.7.64 → 0.7.66

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ![](https://github.com/mandarange/Sneakoscope-Codex/raw/dev/docs/assets/sneakoscope-codex-logo.png)
4
4
 
5
- Sneakoscope Codex (`sks`, displayed as `ㅅㅋㅅ`) is a Codex CLI/App harness for repeatable agent workflows. It adds terminal commands, Codex App `$` prompt commands, tmux-native CLI workspaces, Team/QA/Research routes, inspectable pipeline plans, a maximum-speed Computer Use lane, an imagegen/gpt-image-2 UI/UX review route, a fast Goal bridge for native `/goal` persistence, Context7 evidence checks, DB safety, TriWiki context tracking, design-system SSOT routing, lightweight skill dreaming, Honest Mode, and release-readiness gates.
5
+ Sneakoscope Codex (`sks`) is a Codex CLI/App harness for repeatable workflows. It adds terminal commands, Codex App `$` commands, tmux workspaces, Team/QA/Research routes, pipeline plans, Computer Use, imagegen UI/UX review, Goal, Context7, DB safety, TriWiki, design-system routing, skill dreaming, Honest Mode.
6
6
 
7
7
  ## Quick Start
8
8
 
@@ -50,15 +50,15 @@ sks selftest --mock
50
50
  | Skill dreaming | Records cheap generated-skill usage counters in JSON and only periodically scans `.agents/skills` for keep, merge, prune, and improvement candidates. Reports are recommendation-only and never delete skills automatically. |
51
51
  | From-Chat-IMG | Turns chat screenshots plus original attachments into source-bound work orders, then requires scoped QA evidence before completion. |
52
52
  | QA loop | Dogfoods UI/API behavior with safety gates, Codex Computer Use-only UI evidence, safe fixes, and rechecks. |
53
- | PPT pipeline | Uses `$PPT` for simple, restrained, information-first HTML/PDF presentation artifacts, first asking delivery context, audience profile, STP strategy, decision context, and 3+ pain-point to solution/aha mappings before source research, design-system work, HTML/PDF export, and render QA. Independent strategy/render/file-write phases run in parallel where inputs allow and are recorded in `ppt-parallel-report.json`; editable source HTML is preserved under `source-html/`, PPT-only temporary build files are cleaned after completion, installed skills/MCPs outside the `$PPT` allowlist are ignored, generated image assets must use real `$imagegen`/`gpt-image-2` output when sealed in the contract, and `ppt-style-tokens.json` records the design SSOT plus fused source inputs. |
54
- | Image UX Review | Uses `$Image-UX-Review` / `$UX-Review` for UI/UX audits where source screenshots are first turned into generated annotated review images through Codex App `$imagegen`/`gpt-image-2`; those generated images are then read back into `image-ux-issue-ledger.json`, optional requested fixes are rechecked, and missing generated review images or text-only screenshot critique cannot pass `image-ux-review-gate.json`. |
53
+ | PPT pipeline | Uses `$PPT` for restrained HTML/PDF presentation artifacts with sealed delivery context, audience, STP, decision context, source research, design SSOT, export QA, editable source HTML, and real `$imagegen` assets when required. |
54
+ | Image UX Review | Uses `$Image-UX-Review` / `$UX-Review` for UI/UX audits that require generated annotated review images through Codex App `$imagegen`/`gpt-image-2` before issue extraction and optional rechecks. |
55
55
  | Computer Use fast lane | Uses `$Computer-Use` / `$CU` for UI/browser/visual work that needs maximum speed: skip Team debate and upfront TriWiki loops, use Codex Computer Use directly, then refresh/validate TriWiki and run Honest Mode at final closeout. |
56
- | Goal | Provides a fast SKS bridge overlay for Codex native persisted `/goal` create, pause, resume, and clear controls; an ambient non-disruptive Goal continuation overlay is also recorded in normal pipeline plans while implementation continues through the selected SKS execution route. |
56
+ | Goal | Bridges Codex native `/goal` create, pause, resume, and clear controls while implementation continues through the selected SKS route. |
57
57
  | TriWiki voxels | Maintains `.sneakoscope/wiki/context-pack.json` as the context SSOT with coordinate anchors, voxel metadata, `attention.use_first`, `attention.hydrate_first`, and prompt-bound mistake recall ledgers. |
58
58
  | Context7 | Requires current docs for external packages, APIs, MCPs, SDKs, and framework/runtime behavior when correctness depends on current guidance. |
59
59
  | Design SSOT | Treats `design.md` as the only design decision source of truth. `docs/Design-Sys-Prompt.md` is the builder prompt; getdesign.md, official getdesign docs, and curated DESIGN.md examples from `VoltAgent/awesome-design-md` are source inputs that must be fused into `design.md` or route-local style tokens instead of becoming parallel authorities. |
60
60
  | DB safety | Treats SQL, migrations, Supabase, RLS, and destructive operations as high risk. |
61
- | Release hygiene | Checks versioning, changelog, package contents, tarball size, syntax, selftests, and dry-run publishing. |
61
+ | Release hygiene | Checks versioning, changelog, package size, syntax, selftests. |
62
62
 
63
63
  ## Requirements
64
64
 
@@ -171,7 +171,7 @@ Bare `sks` creates or reuses the default named tmux session for Codex CLI and at
171
171
 
172
172
  Before opening tmux, SKS checks the installed Codex CLI against npm `@openai/codex@latest`. If a newer version exists, it asks `Y/n`; answering `y` updates automatically with `npm i -g @openai/codex@latest` and then opens tmux with the updated Codex CLI.
173
173
 
174
- If you use [codex-lb](https://github.com/Soju06/codex-lb), start it first, create an API key in its dashboard, then run:
174
+ For [codex-lb](https://github.com/Soju06/codex-lb), start the server, create a dashboard API key, then run:
175
175
 
176
176
  ```sh
177
177
  sks codex-lb setup --host https://your-codex-lb.example.com --api-key "sk-clb-..."
@@ -179,30 +179,9 @@ sks codex-lb repair
179
179
  sks
180
180
  ```
181
181
 
182
- Bare `sks` asks this before opening Codex when codex-lb is not configured:
182
+ Bare `sks` can also prompt for codex-lb auth before launch; SKS stores the key in `~/.codex/sks-codex-lb.env`, syncs `codex login --with-api-key`, and loads it into a fresh tmux session.
183
183
 
184
- ```text
185
- Authenticate and route Codex through codex-lb? [y/N]
186
- ```
187
-
188
- Answering `y` asks for the hosted domain and API key, writes `~/.codex/config.toml`, stores the key in `~/.codex/sks-codex-lb.env` with mode `0600`, syncs Codex CLI API-key auth through `codex login --with-api-key`, and sources that env file before launching Codex in tmux. When codex-lb is configured from this prompt, SKS opens a fresh tmux session for that launch so the new key is loaded by the Codex process immediately. SKS keeps Codex App Fast mode visible and defaulted by writing top-level `model = "gpt-5.5"`, `service_tier = "fast"`, `[features].fast_mode = true`, and the `sks-fast-high` profile while removing legacy top-level reasoning locks; route-specific reasoning stays in named profiles or explicit tmux launch args.
189
-
190
- If Codex CLI auth drifts after a tmux/MAD launch, run `sks codex-lb repair` or `sks auth repair`. This reuses the stored `~/.codex/sks-codex-lb.env` key and re-syncs Codex CLI API-key auth without asking for the key again. To replace the key or host, run `sks codex-lb reconfigure --host <domain> --api-key <key>`.
191
-
192
- The generated provider config follows the codex-lb README's Codex CLI API-key setup:
193
-
194
- ```toml
195
- model_provider = "codex-lb"
196
- service_tier = "fast"
197
-
198
- [model_providers.codex-lb]
199
- name = "OpenAI"
200
- base_url = "http://127.0.0.1:2455/backend-api/codex"
201
- wire_api = "responses"
202
- env_key = "CODEX_LB_API_KEY"
203
- supports_websockets = true
204
- requires_openai_auth = true
205
- ```
184
+ If Codex CLI auth drifts after a launch or reinstall, run `sks codex-lb repair`; to replace it, run `sks codex-lb reconfigure --host <domain> --api-key <key>`.
206
185
 
207
186
  ### MAD tmux Launch
208
187
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sneakoscope",
3
3
  "displayName": "ㅅㅋㅅ",
4
- "version": "0.7.64",
4
+ "version": "0.7.66",
5
5
  "description": "Sneakoscope Codex: database-safe Codex CLI/App harness with Team, Goal, AutoResearch, TriWiki, and Honest Mode.",
6
6
  "type": "module",
7
7
  "homepage": "https://github.com/mandarange/Sneakoscope-Codex#readme",
@@ -6,7 +6,7 @@ import { stdin as input, stdout as output } from 'node:process';
6
6
  import { ensureDir, exists, globalSksRoot, packageRoot, readText, runProcess, which, writeTextAtomic } from '../core/fsx.mjs';
7
7
  import { getCodexInfo } from '../core/codex-adapter.mjs';
8
8
  import { formatHarnessConflictReport, llmHarnessCleanupPrompt, scanHarnessConflicts } from '../core/harness-conflicts.mjs';
9
- import { installSkills } from '../core/init.mjs';
9
+ import { initProject, installSkills } from '../core/init.mjs';
10
10
  import { context7ConfigToml, DOLLAR_SKILL_NAMES, GETDESIGN_REFERENCE, hasContext7ConfigText, RECOMMENDED_SKILLS } from '../core/routes.mjs';
11
11
  import { codexLaunchCommand, platformTmuxInstallHint, tmuxReadiness } from '../core/tmux-ui.mjs';
12
12
 
@@ -869,6 +869,12 @@ export async function selftestCodexLb(tmp) {
869
869
  const codexLbEnv = await safeReadText(path.join(codexLbHome, '.codex', 'sks-codex-lb.env'));
870
870
  const codexLbAuth = await safeReadText(path.join(codexLbHome, '.codex', 'auth.json'));
871
871
  if (!codexLbSetupJson.ok || codexLbSetupJson.base_url !== 'https://lb.example.test/backend-api/codex' || !codexLbConfig.includes('model_provider = "codex-lb"') || !codexLbConfig.includes('[model_providers.codex-lb]') || !codexLbEnv.includes("CODEX_LB_API_KEY='sk-test'") || !/(\"auth_mode\"\s*:\s*\"apikey\")/.test(codexLbAuth)) throw new Error('selftest failed: codex-lb setup did not write provider config, env key, and Codex API-key auth');
872
+ await writeTextAtomic(path.join(codexLbHome, '.codex', 'config.toml'), `${codexLbConfig}\n[mcp_servers.supabase]\nurl = "https://mcp.supabase.com/mcp?project_ref=ref&read_only=true&features=database,docs"\n`);
873
+ const ptmp = path.join(tmp, 'codex-lb-project-config'), prevHome = process.env.HOME;
874
+ try { process.env.HOME = codexLbHome; await initProject(ptmp, { installScope: 'global' }); }
875
+ finally { if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; }
876
+ const pcfg = await safeReadText(path.join(ptmp, '.codex', 'config.toml'));
877
+ if (!pcfg.includes('model_provider = "codex-lb"') || !pcfg.includes('[model_providers.codex-lb]') || !pcfg.includes('[mcp_servers.supabase]') || !pcfg.includes('read_only=true')) throw new Error('selftest failed: project bootstrap lost global codex-lb or MCP config');
872
878
  await writeTextAtomic(path.join(codexLbHome, '.codex', 'auth.json'), '{"auth_mode":"browser"}\n');
873
879
  const codexLbRepair = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'auth', 'repair', '--json'], { cwd: tmp, env: codexLbEnvForSelftest, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
874
880
  if (codexLbRepair.code !== 0) throw new Error(`selftest failed: codex-lb repair exited ${codexLbRepair.code}: ${codexLbRepair.stderr}`);
package/src/cli/main.mjs CHANGED
@@ -2010,10 +2010,26 @@ async function selftest() {
2010
2010
  };
2011
2011
  for (let i = 0; i < 5; i++) {
2012
2012
  const stop = await evaluateStop(tmp, clarificationState, { last_assistant_message: 'continuing implementation without visible questions' });
2013
- if (stop?.gate === 'clarification' || /ambiguity|clarification|question/i.test(String(stop?.reason || ''))) throw new Error('selftest failed: stale clarification gate still hard-paused without visible questions');
2013
+ if (stop?.decision !== 'block' || stop?.gate !== 'clarification' || !/paused|answers|pipeline answer/i.test(String(stop?.reason || ''))) throw new Error('selftest failed: clarification not paused');
2014
2014
  }
2015
- const visibleQuestionStop = await evaluateStop(tmp, clarificationState, { last_assistant_message: 'Required questions still pending:\n1. GOAL_PRECISE: What should be changed?\n\nReply by slot id; I will seal the contract with sks pipeline answer latest --stdin.' });
2016
- if (visibleQuestionStop?.gate === 'clarification' || /ambiguity|clarification/i.test(String(visibleQuestionStop?.reason || ''))) throw new Error('selftest failed: visible stale clarification wording still blocked stop');
2015
+ if (await exists(path.join(clarificationMission.dir, 'hard-blocker.json'))) throw new Error('selftest failed: clarification wrote hard-blocker');
2016
+ const visibleQuestionStop = await evaluateStop(tmp, clarificationState, { last_assistant_message: 'Required questions\n1. GOAL_PRECISE\nsks pipeline answer latest --stdin' });
2017
+ if (visibleQuestionStop?.continue !== true) throw new Error('selftest failed: visible clarification did not wait');
2018
+ const cg = await projectGateStatus(tmp, clarificationState);
2019
+ if (!cg.blockers.includes('clarification-gate:explicit_user_answers') || !cg.blockers.includes('clarification-gate:pipeline_answer')) throw new Error('selftest failed: missing clarification blockers');
2020
+ await setCurrent(tmp, clarificationState);
2021
+ const hookPath = path.join(packageRoot(), 'bin', 'sks.mjs');
2022
+ const blockedPre = await runProcess(process.execPath, [hookPath, 'hook', 'pre-tool'], { cwd: tmp, input: JSON.stringify({ cwd: tmp, tool_name: 'Bash', tool_input: { command: 'npm run selftest' } }), timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
2023
+ if (blockedPre.code !== 0) throw new Error(`selftest failed: pre-tool exit ${blockedPre.code}: ${blockedPre.stderr}`);
2024
+ const bp = JSON.parse(blockedPre.stdout || '{}');
2025
+ if (bp.decision !== 'block' || !String(bp.reason || '').includes('waiting for explicit user answers')) throw new Error('selftest failed: pre-tool not blocked');
2026
+ const deniedPermission = await runProcess(process.execPath, [hookPath, 'hook', 'permission-request'], { cwd: tmp, input: JSON.stringify({ cwd: tmp, command: 'npm run selftest', action: 'Run command' }), timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
2027
+ if (deniedPermission.code !== 0) throw new Error(`selftest failed: permission exit ${deniedPermission.code}: ${deniedPermission.stderr}`);
2028
+ const dp = JSON.parse(deniedPermission.stdout || '{}');
2029
+ if (dp.hookSpecificOutput?.decision?.behavior !== 'deny' || !String(dp.hookSpecificOutput?.decision?.message || '').includes('waiting for explicit user answers')) throw new Error('selftest failed: permission not denied');
2030
+ const answerTool = await runProcess(process.execPath, [hookPath, 'hook', 'pre-tool'], { cwd: tmp, input: JSON.stringify({ cwd: tmp, tool_name: 'Bash', tool_input: { command: `sks pipeline answer ${clarificationMission.id} --stdin` } }), timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
2031
+ if (answerTool.code !== 0) throw new Error(`selftest failed: answer hook exit ${answerTool.code}: ${answerTool.stderr}`);
2032
+ if (JSON.parse(answerTool.stdout || '{}').decision === 'block') throw new Error('selftest failed: answer command blocked');
2017
2033
  await setCurrent(tmp, loopState);
2018
2034
  const dfixPromptHook = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'hook', 'user-prompt-submit'], {
2019
2035
  cwd: tmp,
@@ -3275,7 +3291,9 @@ async function selftest() {
3275
3291
  await writeTextAtomic(fakeTmuxBin, `#!/usr/bin/env node\nconst fs = require('node:fs');\nconst log = process.env.SKS_FAKE_TMUX_LOG;\nif (log) fs.appendFileSync(log, process.argv.slice(2).join(' ') + '\\n');\nconst cmd = process.argv[2];\nif (cmd === 'has-session') process.exit(0);\nif (cmd === 'kill-session') process.exit(0);\nif (cmd === 'kill-pane') process.exit(0);\nif (cmd === 'new-session') { console.log('%1'); process.exit(0); }\nif (cmd === 'split-window') { console.log(process.env.SKS_FAKE_TMUX_SPLIT_ID || '%2'); process.exit(0); }\nif (cmd === 'list-windows') { console.log('@1'); process.exit(0); }\nif (cmd === 'display-message') { console.log(process.env.SKS_FAKE_TMUX_DISPLAY || 'sks-existing-selftest\\t@1\\t%1'); process.exit(0); }\nif (cmd === 'list-panes') { console.log(process.env.SKS_FAKE_TMUX_LIST || ''); process.exit(0); }\nif (cmd === 'set-option' || cmd === 'select-layout' || cmd === 'resize-window' || cmd === 'set-window-option' || cmd === 'set-hook') process.exit(0);\nprocess.exit(0);\n`);
3276
3292
  await fsp.chmod(fakeTmuxBin, 0o755);
3277
3293
  const previousFakeTmuxLog = process.env.SKS_FAKE_TMUX_LOG;
3294
+ const previousPath = process.env.PATH;
3278
3295
  process.env.SKS_FAKE_TMUX_LOG = fakeTmuxLog;
3296
+ process.env.PATH = `${fakeTmuxDir}${path.delimiter}${previousPath || ''}`;
3279
3297
  const recreatedTmux = await createTmuxSession({ root: tmp, session: 'sks-existing-selftest', tmux: { bin: fakeTmuxBin }, codex: { bin: process.execPath } }, [
3280
3298
  { cwd: tmp, command: 'pwd', role: 'overview' },
3281
3299
  { cwd: tmp, command: 'pwd', role: 'lane' }
@@ -3332,6 +3350,8 @@ async function selftest() {
3332
3350
  if (!madCockpit.created || madCockpit.mode !== 'mad_session' || madCockpit.opened?.panes?.length !== 1 || !madTmuxLogText.includes('new-session') || madTmuxLogText.includes('split-window')) throw new Error('selftest failed: MAD tmux launch should create one pane and leave split panes to Team lanes');
3333
3351
  if (previousFakeTmuxLog === undefined) delete process.env.SKS_FAKE_TMUX_LOG;
3334
3352
  else process.env.SKS_FAKE_TMUX_LOG = previousFakeTmuxLog;
3353
+ if (previousPath === undefined) delete process.env.PATH;
3354
+ else process.env.PATH = previousPath;
3335
3355
  const codexLaunchArgs = defaultCodexLaunchArgs({ SKS_CODEX_REASONING: 'low' }).join(' ');
3336
3356
  if (!codexLaunchArgs.includes('service_tier="fast"') || !codexLaunchArgs.includes('model_reasoning_effort="low"')) throw new Error('selftest failed: Codex tmux launch args do not force Fast service tier plus dynamic reasoning');
3337
3357
  await initTeamLive(teamId, teamDir, '역할 팀 테스트', { agentSessions: roleTeamPlan.agent_session_count, roleCounts: roleTeamPlan.role_counts, roster: roleTeamPlan.roster });
@@ -3422,7 +3442,7 @@ async function selftest() {
3422
3442
  if (!teamLive.includes('Context tracking SSOT: TriWiki')) throw new Error('selftest failed: team live transcript missing TriWiki context tracking');
3423
3443
  if (!(await readTeamTranscriptTail(teamDir, 1)).join('\n').includes('selftest mapped options')) throw new Error('selftest failed: team transcript tail missing event');
3424
3444
  const teamLane = await renderTeamAgentLane(teamDir, { missionId: teamId, agent: 'analysis_scout_1', lines: 4 });
3425
- if (!teamLane.includes('SKS Team Agent Lane') || !teamLane.includes('analysis_scout_1') || !teamLane.includes('selftest mapped repo slice')) throw new Error('selftest failed: team agent lane missing agent event context');
3445
+ if (!teamLane.includes('selftest mapped repo slice')) throw new Error('selftest failed: team agent lane missing event context');
3426
3446
  const teamLaneCli = await runProcess(process.execPath, [hookBin, 'team', 'lane', teamId, '--agent', 'analysis_scout_1', '--lines', '4'], { cwd: tmp, env: { SKS_DISABLE_UPDATE_CHECK: '1' }, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
3427
3447
  if (teamLaneCli.code !== 0 || !String(teamLaneCli.stdout || '').includes('SKS Team Agent Lane') || !String(teamLaneCli.stdout || '').includes('analysis_scout_1')) throw new Error('selftest failed: sks team lane CLI did not render an agent lane');
3428
3448
  await writeTextAtomic(path.join(teamDir, 'team-analysis.md'), '- claim: analysis scout mapped route registry | source: src/core/routes.mjs | risk: high | confidence: supported\n');
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import fsp from 'node:fs/promises';
3
- import { readJson, writeJsonAtomic, writeTextAtomic, appendJsonlBounded, nowIso, exists, ensureDir, packageRoot, dirSize, formatBytes, PACKAGE_VERSION, sksRoot } from '../core/fsx.mjs';
3
+ import { readJson, writeJsonAtomic, writeTextAtomic, appendJsonlBounded, nowIso, exists, ensureDir, packageRoot, dirSize, formatBytes, PACKAGE_VERSION, sksRoot, readStdin } from '../core/fsx.mjs';
4
4
  import { initProject } from '../core/init.mjs';
5
5
  import { getCodexInfo, runCodexExec } from '../core/codex-adapter.mjs';
6
6
  import { createMission, loadMission, findLatestMission, missionDir, setCurrent, stateFile } from '../core/mission.mjs';
@@ -640,7 +640,7 @@ export async function dbCommand(sub, args = []) {
640
640
  return;
641
641
  }
642
642
  if (sub === 'scan-payload') {
643
- const raw = await fsp.readFile(0, 'utf8');
643
+ const raw = await readStdin();
644
644
  const payload = raw.trim() ? JSON.parse(raw) : {};
645
645
  const decision = await checkDbOperation(root, {}, payload, { duringNoQuestion: false });
646
646
  console.log(JSON.stringify(decision, null, 2));
@@ -204,7 +204,7 @@ function recursivelyCollectStrings(obj, out = [], depth = 0) {
204
204
  if (Array.isArray(obj)) { for (const x of obj) recursivelyCollectStrings(x, out, depth + 1); return out; }
205
205
  if (typeof obj === 'object') {
206
206
  for (const [k, v] of Object.entries(obj)) {
207
- if (/^(sql|query|statement|command|migration|body|input|text)$/i.test(k) || typeof v === 'object') recursivelyCollectStrings(v, out, depth + 1);
207
+ if (/^(sql|query|statement|command|migration|body|input|text|action|purpose|intent|description)$/i.test(k) || typeof v === 'object') recursivelyCollectStrings(v, out, depth + 1);
208
208
  }
209
209
  }
210
210
  return out;
@@ -216,6 +216,12 @@ function looksLikeSqlText(text = '') {
216
216
  || /;\s*(select|with|show|explain|describe|insert|update|delete|drop|truncate|alter|create|grant|revoke)\b/i.test(s);
217
217
  }
218
218
 
219
+ function hasReadOnlyDbInspectionIntent(text = '') {
220
+ const s = String(text || '').toLowerCase();
221
+ if (/\b(insert|update|delete|drop|truncate|alter|create|grant|revoke|write|mutate|migration|apply|push|reset|repair)\b|삭제|수정|변경|쓰기|삽입|생성|초기화|적용/i.test(s)) return false;
222
+ return /\b(read.?only|select|with|show|explain|describe|inspect|list|get|fetch|count|schema)\b|조회|확인|읽|보기|목록|스키마/i.test(s);
223
+ }
224
+
219
225
  export function classifyToolPayload(payload = {}) {
220
226
  const strings = recursivelyCollectStrings(payload).slice(0, 200);
221
227
  const toolName = [payload.tool_name, payload.toolName, payload.name, payload.tool?.name, payload.server, payload.mcp_tool, payload.tool, payload.type].filter(Boolean).join(' ').toLowerCase();
@@ -241,7 +247,14 @@ export function classifyToolPayload(payload = {}) {
241
247
  }
242
248
  if (toolReasons.includes('dangerous_supabase_management_tool')) level = 'destructive';
243
249
  if (toolReasons.includes('migration_apply_tool') && level !== 'destructive') level = 'write';
244
- if (toolReasons.includes('database_tool') && level === 'none') level = 'possible_db';
250
+ if (toolReasons.includes('database_tool') && level === 'none') {
251
+ if (hasReadOnlyDbInspectionIntent([toolName, ...strings].join(' '))) {
252
+ level = 'safe';
253
+ reasons.push('read_only_database_inspection_intent');
254
+ } else {
255
+ level = 'possible_db';
256
+ }
257
+ }
245
258
  return { level, toolName, toolReasons, reasons, sql: sqlClass, command: commandClass, stringsExamined: strings.length };
246
259
  }
247
260
 
package/src/core/fsx.mjs CHANGED
@@ -5,7 +5,7 @@ import os from 'node:os';
5
5
  import crypto from 'node:crypto';
6
6
  import { spawn } from 'node:child_process';
7
7
 
8
- export const PACKAGE_VERSION = '0.7.64';
8
+ export const PACKAGE_VERSION = '0.7.66';
9
9
  export const DEFAULT_PROCESS_TAIL_BYTES = 256 * 1024;
10
10
  export const DEFAULT_PROCESS_TIMEOUT_MS = 30 * 60 * 1000;
11
11
 
@@ -181,14 +181,17 @@ async function hookUserPrompt(root, state, payload, noQuestion) {
181
181
  }
182
182
 
183
183
  function isClarificationAwaiting(state = {}) {
184
- return Boolean(state.clarification_required && String(state.phase || '').includes('CLARIFICATION_AWAITING_ANSWERS'))
185
- || ['QALOOP_CLARIFICATION_AWAITING_ANSWERS'].includes(String(state.phase || ''));
184
+ const phase = String(state.phase || '');
185
+ const stopGate = String(state.stop_gate || '');
186
+ const gateAwaiting = phase.includes('CLARIFICATION_AWAITING_ANSWERS') || stopGate === 'clarification-gate';
187
+ if (!gateAwaiting) return false;
188
+ if (!state?.mission_id) return false;
189
+ if (state.ambiguity_gate_required !== true || state.ambiguity_gate_passed === true) return false;
190
+ return Boolean(state.clarification_required || state.implementation_allowed === false);
186
191
  }
187
192
 
188
193
  function isBlockingClarificationAwaiting(state = {}) {
189
- if (!isClarificationAwaiting(state)) return false;
190
- return ['QALoop', 'PPT'].includes(String(state.route || ''))
191
- || ['QALOOP', 'PPT'].includes(String(state.mode || ''));
194
+ return isClarificationAwaiting(state);
192
195
  }
193
196
 
194
197
  function looksLikeClarificationCancel(prompt = '') {
@@ -316,7 +319,7 @@ function clarificationGateLocked(state = {}) {
316
319
  && state.implementation_allowed === false
317
320
  && state.ambiguity_gate_required === true
318
321
  && state.ambiguity_gate_passed !== true
319
- && isBlockingClarificationAwaiting(state)
322
+ && (String(state.phase || '').includes('CLARIFICATION_AWAITING_ANSWERS') || String(state.stop_gate || '') === 'clarification-gate')
320
323
  );
321
324
  }
322
325
 
package/src/core/init.mjs CHANGED
@@ -462,6 +462,34 @@ function mergeManagedCodexConfigToml(existingContent = '') {
462
462
  return `${next.trim()}\n`;
463
463
  }
464
464
 
465
+ async function mergeGlobalCodexConfigIfAvailable(configText = '', configPath = '') {
466
+ const selectedRe = /(^|\n)\s*model_provider\s*=\s*"codex-lb"\s*(?:#.*)?(?=\n|$)/;
467
+ const home = process.env.HOME || '';
468
+ if (!home) return configText;
469
+ const globalConfigPath = path.join(home, '.codex', 'config.toml');
470
+ if (configPath && path.resolve(configPath) === path.resolve(globalConfigPath)) return configText;
471
+ const globalConfig = await readText(globalConfigPath, '');
472
+ let next = mergeGlobalMcpServers(configText, globalConfig);
473
+ if (selectedRe.test(next) && /\[model_providers\.codex-lb\]/.test(next)) return `${String(next || '').trim()}\n`;
474
+ if (!(await exists(path.join(home, '.codex', 'sks-codex-lb.env')))) return next;
475
+ const baseUrl = globalConfig.match(/(^|\n)\[model_providers\.codex-lb\][\s\S]*?\n\s*base_url\s*=\s*"([^"]+)"/)?.[2];
476
+ if (!selectedRe.test(globalConfig) || !baseUrl) return next;
477
+ next = upsertTopLevelTomlString(next, 'model_provider', 'codex-lb');
478
+ next = upsertTomlTable(next, 'model_providers.codex-lb', `[model_providers.codex-lb]\nname = "OpenAI"\nbase_url = "${baseUrl}"\nwire_api = "responses"\nenv_key = "CODEX_LB_API_KEY"\nsupports_websockets = true\nrequires_openai_auth = true`);
479
+ return `${next.trim()}\n`;
480
+ }
481
+
482
+ function mergeGlobalMcpServers(configText = '', globalConfig = '') {
483
+ let next = configText;
484
+ const re = /(?:^|\n)(\[(mcp_servers\.[^\]\r\n]+)\][\s\S]*?)(?=\n\[[^\]]+\]|\s*$)/g;
485
+ for (const match of String(globalConfig || '').matchAll(re)) {
486
+ const block = match[1].trim();
487
+ const table = match[2].trim();
488
+ if (!new RegExp(`(^|\\n)\\[${escapeRegExp(table)}\\]`).test(next)) next = upsertTomlTable(next, table, block);
489
+ }
490
+ return next;
491
+ }
492
+
465
493
  function removeLegacyTopLevelCodexModeLocks(text = '') {
466
494
  const legacy = {
467
495
  model_reasoning_effort: new Set(['high'])
@@ -619,7 +647,11 @@ function upsertTomlTable(text, table, block) {
619
647
 
620
648
  const generatedCodexConfigPath = path.join(root, '.codex', 'config.toml');
621
649
  const existingCodexConfig = await readText(generatedCodexConfigPath, '');
622
- await writeTextAtomic(generatedCodexConfigPath, mergeManagedCodexConfigToml(existingCodexConfig));
650
+ const managedCodexConfig = await mergeGlobalCodexConfigIfAvailable(
651
+ mergeManagedCodexConfigToml(existingCodexConfig),
652
+ generatedCodexConfigPath
653
+ );
654
+ await writeTextAtomic(generatedCodexConfigPath, managedCodexConfig);
623
655
  created.push('.codex/config.toml');
624
656
 
625
657
  await writeTextAtomic(path.join(root, '.codex', 'SNEAKOSCOPE.md'), codexAppQuickReference(installScope, hookCommandPrefix));
@@ -509,8 +509,7 @@ export async function activeRouteContext(root, state) {
509
509
  return `SKS Honest Mode found unresolved gaps for ${state.route_command || state.route || state.mode}. Do not ask ambiguity questions again. Continue from the sealed decision-contract.json, inspect .sneakoscope/missions/${state.mission_id}/honest-loopback.json, fix gaps, rerun verification, refresh/validate TriWiki, then retry final Honest Mode.${reasoningNote}${planNote}`;
510
510
  }
511
511
  if (state.clarification_required && String(state.phase || '').includes('CLARIFICATION_AWAITING_ANSWERS')) {
512
- if (['QALoop', 'PPT'].includes(String(state.route || '')) || ['QALOOP', 'PPT'].includes(String(state.mode || ''))) return clarificationAwaitingAnswersContext(root, state);
513
- return `Previous ${state.route_command || state.route || state.mode || 'SKS'} clarification state is non-blocking. Do not reprint old question sheets; prepare the current prompt normally and replace stale route state when needed.`;
512
+ return clarificationAwaitingAnswersContext(root, state);
514
513
  }
515
514
  if (state.clarification_passed && String(state.phase || '').includes('CLARIFICATION_CONTRACT_SEALED')) {
516
515
  return `Route contract sealed for ${state.route_command || state.route || state.mode}. Use decision-contract.json and ${PIPELINE_PLAN_ARTIFACT} before executing the route. Before the next route phase, read relevant TriWiki context, hydrate low-trust claims from source, and refresh/validate TriWiki again after new findings or artifact changes. Next atomic action: continue the original route lifecycle with the inferred goal, constraints, non-goals, risk boundary, and test scope.${planNote}`;
@@ -993,7 +992,7 @@ async function clarificationAwaitingAnswersContext(root, state) {
993
992
  const id = state.mission_id;
994
993
  if (!id) return '';
995
994
  const planNote = await activePipelinePlanNote(root, state);
996
- return `Active SKS route ${state.route_command || state.route || state.mode} has stale prequestion state. Do not reprint old question sheets. Re-prepare the current prompt so the route auto-seals from prompt, TriWiki/current-code defaults, and conservative SKS policy, or seal the existing mission internally with "sks pipeline answer ${id} --stdin" using inferred answers.${planNote}`;
995
+ return `Active SKS route ${state.route_command || state.route || state.mode} is paused at its ambiguity gate and waiting for explicit user answers. Do not advance to implementation, tests, route materialization, or a new pipeline stage. If the user's reply is now available, seal it with "sks pipeline answer ${id} --stdin"; otherwise show only the missing slot ids from .sneakoscope/missions/${id}/questions.md and wait.${planNote}`;
997
996
  }
998
997
 
999
998
  function clarificationVisibleResponseContract(id) {
@@ -1031,9 +1030,9 @@ async function clarificationStopReason(root, state, kind) {
1031
1030
  const files = state?.mission_id ? `
1032
1031
  Answer schema: .sneakoscope/missions/${state.mission_id}/required-answers.schema.json` : '';
1033
1032
  const command = `sks pipeline answer ${id} --stdin`;
1034
- const title = `SKS ${routeName} has stale prequestion state.`;
1033
+ const title = `SKS ${routeName} is paused for explicit user answers.`;
1035
1034
  return `${title}
1036
- Do not reprint old question sheets. Seal internally with inferred answers using "${command}", or re-prepare the current prompt so the route auto-seals.${files}
1035
+ Do not continue to implementation or the next pipeline stage until the ambiguity gate is sealed. Ask only the missing slot ids if they have not already been shown, then wait for the user. When the user's reply is available, seal it with "${command}".${files}
1037
1036
 
1038
1037
  After the contract is sealed, continue the original ${routeName} route.`;
1039
1038
  }
@@ -1329,7 +1328,15 @@ export async function evaluateStop(root, state, payload, opts = {}) {
1329
1328
  }
1330
1329
 
1331
1330
  function clarificationGatePending(state = {}) {
1332
- return false;
1331
+ const phase = String(state.phase || '');
1332
+ return Boolean(state?.clarification_required && phase.includes('CLARIFICATION_AWAITING_ANSWERS'))
1333
+ || Boolean(
1334
+ state?.mission_id
1335
+ && state.implementation_allowed === false
1336
+ && state.ambiguity_gate_required === true
1337
+ && state.ambiguity_gate_passed !== true
1338
+ && (phase.includes('CLARIFICATION_AWAITING_ANSWERS') || state.stop_gate === 'clarification-gate')
1339
+ );
1333
1340
  }
1334
1341
 
1335
1342
  async function complianceBlock(root, state = {}, reason = '', detail = {}) {
@@ -6,6 +6,7 @@ export { MIN_TEAM_REVIEWER_LANES, MIN_TEAM_REVIEW_POLICY_TEXT, MIN_TEAM_REVIEW_S
6
6
 
7
7
  const MAX_LIVE_BYTES = 192 * 1024;
8
8
  const TEAM_RUNTIME_TASKS_ARTIFACT = 'team-runtime-tasks.json';
9
+ const TEAM_SESSION_CLEANUP_ARTIFACT = 'team-session-cleanup.json';
9
10
  const DEFAULT_AGENTS = ['parent_orchestrator', 'analysis_scout', 'team_consensus', 'implementation_worker', 'db_safety_reviewer', 'qa_reviewer'];
10
11
  export const DEFAULT_TEAM_ROLE_COUNTS = { user: 1, planner: 1, reviewer: MIN_TEAM_REVIEWER_LANES, executor: 3 };
11
12
  export const DEFAULT_MAX_TEAM_AGENT_SESSIONS = 6;
@@ -471,7 +472,19 @@ async function reconcileTeamTmuxFromEvent(dir, record = {}) {
471
472
  }
472
473
 
473
474
  export async function readTeamControl(dir) {
474
- return readJson(teamLogPaths(dir).control, defaultTeamControl(path.basename(dir)));
475
+ const control = await readJson(teamLogPaths(dir).control, defaultTeamControl(path.basename(dir)));
476
+ const cleanup = await readJson(path.join(dir, TEAM_SESSION_CLEANUP_ARTIFACT), null).catch(() => null);
477
+ if (!cleanup || (cleanup.passed !== true && cleanup.live_transcript_finalized !== true && cleanup.all_sessions_closed !== true)) return control;
478
+ return {
479
+ ...defaultTeamControl(path.basename(dir)),
480
+ ...control,
481
+ status: 'ended',
482
+ cleanup_requested: true,
483
+ cleanup_requested_at: cleanup.updated_at || cleanup.completed_at || cleanup.closed_at || control.cleanup_requested_at || 'artifact',
484
+ cleanup_requested_by: cleanup.agent || control.cleanup_requested_by || 'parent_orchestrator',
485
+ cleanup_reason: cleanup.reason || control.cleanup_reason || `${TEAM_SESSION_CLEANUP_ARTIFACT} passed.`,
486
+ final_message: cleanup.final_message || control.final_message || 'Team session ended. Lane follow loops stop and managed tmux Team panes should close.'
487
+ };
475
488
  }
476
489
 
477
490
  export async function requestTeamSessionCleanup(dir, opts = {}) {
@@ -532,26 +545,30 @@ export async function renderTeamAgentLane(dir, opts = {}) {
532
545
  const missionId = opts.missionId || dashboard?.mission_id || runtime?.mission_id || path.basename(dir);
533
546
  const status = dashboard?.agents?.[agent] || {};
534
547
  const runtimeTasks = Array.isArray(runtime?.tasks) ? runtime.tasks : Array.isArray(runtime) ? runtime : [];
535
- const assignedTasks = runtimeTasks.filter((task) => task?.worker === agent || task?.agent_hint === agent);
536
548
  const eventWindow = await readTeamTranscriptTail(dir, Math.max(lines * 8, 80));
537
549
  const parsedWindow = eventWindow.map(parseTranscriptLine).filter(Boolean);
538
- const agentEvents = parsedWindow.filter((event) => event?.agent === agent || eventAddressedTo(event, agent)).slice(-lines);
539
- const directMessages = parsedWindow.filter((event) => event?.type === 'message' && eventAddressedTo(event, agent)).slice(-lines);
540
- const globalTail = (await readTeamTranscriptTail(dir, lines)).map(parseTranscriptLine).filter(Boolean);
550
+ const aliases = teamLaneAliases(agent, parsedWindow, dashboard, runtimeTasks);
551
+ const aliasSet = new Set(aliases);
552
+ const statusAliases = aliases.length > 1 ? [...aliases.slice(1), aliases[0]] : aliases;
553
+ const laneStatus = statusAliases.map((id) => dashboard?.agents?.[id]).find((entry) => entry && entry.status && entry.status !== 'pending') || status;
554
+ const assignedTasks = runtimeTasks.filter((task) => aliasSet.has(task?.worker) || aliasSet.has(task?.agent_hint));
555
+ const agentEvents = parsedWindow.filter((event) => aliasSet.has(event?.agent) || aliases.some((id) => eventAddressedTo(event, id))).slice(-lines);
556
+ const directMessages = parsedWindow.filter((event) => event?.type === 'message' && aliases.some((id) => eventAddressedTo(event, id))).slice(-lines);
541
557
  const laneStyle = teamLaneTextStyle(agent);
542
558
  return [
543
559
  `# SKS Team Agent Lane`,
544
560
  '',
545
561
  `Mission: ${missionId}`,
546
562
  `Agent: ${agent}`,
563
+ aliases.length > 1 ? `Mirrored agents: ${aliases.slice(1).join(', ')}` : null,
547
564
  `Lane color: ${laneStyle.color_name}`,
548
565
  `Requested phase: ${phase || 'any'}`,
549
566
  teamCleanupRequested(control) ? `Cleanup: requested at ${control.cleanup_requested_at || 'unknown'}` : null,
550
567
  '',
551
568
  `## Agent Status`,
552
- `- status: ${status.status || 'pending'}`,
553
- `- phase: ${status.phase || 'unknown'}`,
554
- `- last_seen: ${status.last_seen || 'never'}`,
569
+ `- status: ${laneStatus.status || 'pending'}`,
570
+ `- phase: ${laneStatus.phase || 'unknown'}`,
571
+ `- last_seen: ${laneStatus.last_seen || 'never'}`,
555
572
  '',
556
573
  `## Assigned Runtime Tasks`,
557
574
  ...(runtime ? formatRuntimeTasks(assignedTasks) : ['- team-runtime-tasks.json not available yet.']),
@@ -561,9 +578,11 @@ export async function renderTeamAgentLane(dir, opts = {}) {
561
578
  '',
562
579
  `## Direct Messages`,
563
580
  ...(directMessages.length ? directMessages.map(formatTranscriptEvent) : ['- No direct or broadcast messages in the bounded tail.']),
564
- '',
565
- `## Fallback Global Tail`,
566
- ...(globalTail.length ? globalTail.map(formatTranscriptEvent) : ['- No transcript events yet.']),
581
+ opts.includeGlobalTail ? '' : null,
582
+ opts.includeGlobalTail ? `## Global Tail` : null,
583
+ ...(opts.includeGlobalTail
584
+ ? (await readTeamTranscriptTail(dir, lines)).map(parseTranscriptLine).filter(Boolean).map(formatTranscriptEvent)
585
+ : []),
567
586
  teamCleanupRequested(control) ? ['', renderTeamCleanupSummary(control)].join('\n') : null
568
587
  ].filter((line) => line !== null).join('\n');
569
588
  }
@@ -685,6 +704,32 @@ function eventAddressedTo(event = {}, agent = '') {
685
704
  return target === name || target === 'all' || target === '*' || target === 'broadcast';
686
705
  }
687
706
 
707
+ function teamLaneAliases(agent = '', events = [], dashboard = null, runtimeTasks = []) {
708
+ const primary = String(agent || '').trim();
709
+ if (!primary) return [];
710
+ const aliases = [primary];
711
+ const ordinal = numberedLaneOrdinal(primary);
712
+ if (!ordinal) return aliases;
713
+ const role = teamLaneTextStyle(primary).role;
714
+ const candidates = uniqueAgentIds([
715
+ ...Object.keys(dashboard?.agents || {}),
716
+ ...events.map((event) => event?.agent).filter(Boolean),
717
+ ...runtimeTasks.flatMap((task) => [task?.worker, task?.agent_hint]).filter(Boolean)
718
+ ])
719
+ .filter((id) => id !== primary)
720
+ .filter((id) => !DEFAULT_AGENTS.includes(id))
721
+ .filter((id) => teamLaneTextStyle(id).role === role)
722
+ .filter((id) => !numberedLaneOrdinal(id));
723
+ const concrete = candidates[ordinal - 1];
724
+ if (concrete) aliases.push(concrete);
725
+ return aliases;
726
+ }
727
+
728
+ function numberedLaneOrdinal(agent = '') {
729
+ const match = String(agent || '').match(/_(\d+)$/);
730
+ return match ? Number(match[1]) : 0;
731
+ }
732
+
688
733
  function teamLaneTextStyle(agentId = '') {
689
734
  const id = String(agentId || '').toLowerCase();
690
735
  if (!id || id === 'mission_overview' || id === 'overview') return { role: 'overview', color_name: 'Blue' };
@@ -119,6 +119,9 @@ const TERMINAL_TEAM_AGENT_STATUSES = new Set([
119
119
  'tmux_lane_closed'
120
120
  ]);
121
121
 
122
+ 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
+ const GENERIC_TEAM_AGENT_IDS = new Set(['parent_orchestrator', 'analysis_scout', 'team_consensus', 'implementation_worker', 'db_safety_reviewer', 'qa_reviewer']);
124
+
122
125
  export function isTmuxShellSession(env = process.env) {
123
126
  return Boolean(String(env.TMUX || '').trim());
124
127
  }
@@ -395,6 +398,11 @@ function teamCockpitAgentIds(plan = {}, dashboard = null, control = null, opts =
395
398
  const visible = teamViewAgentIds(plan).filter((id) => id && id !== 'mission_overview');
396
399
  const agents = dashboard?.agents && typeof dashboard.agents === 'object' ? dashboard.agents : null;
397
400
  if (!agents) return opts.plannedFallback ? visible : [];
401
+ const concrete = concreteDashboardAgentIds(agents, visible).filter((id) => {
402
+ const status = String(agents[id]?.status || '').trim().toLowerCase();
403
+ return status && status !== 'pending' && !isTerminalTeamAgentStatus(status);
404
+ });
405
+ if (concrete.length) return uniqueAgentIds(concrete);
398
406
  const active = [];
399
407
  for (const id of visible) {
400
408
  const entry = agents[id] || {};
@@ -407,6 +415,17 @@ function teamCockpitAgentIds(plan = {}, dashboard = null, control = null, opts =
407
415
  return uniqueAgentIds(active);
408
416
  }
409
417
 
418
+ function concreteDashboardAgentIds(agents = {}, planned = []) {
419
+ const plannedSet = new Set(planned);
420
+ const concrete = Object.keys(agents)
421
+ .filter((id) => id && !plannedSet.has(id))
422
+ .filter((id) => !GENERIC_TEAM_AGENT_IDS.has(id))
423
+ .filter((id) => !/_(?:\d+)$/.test(id));
424
+ if (!concrete.length) return [];
425
+ const plannedRoles = new Set(planned.map((id) => teamLaneStyle(id).role));
426
+ return concrete.filter((id) => plannedRoles.has(teamLaneStyle(id).role));
427
+ }
428
+
410
429
  function teamCockpitLanes(plan = {}, dashboard = null, control = null, opts = {}) {
411
430
  const agents = teamCockpitAgentIds(plan, dashboard, control, opts);
412
431
  if (!agents.length) return [];
@@ -441,6 +460,10 @@ function parseTmuxPaneLines(stdout = '') {
441
460
  }).filter((pane) => /^%\d+$/.test(pane.pane_id || ''));
442
461
  }
443
462
 
463
+ function isLegacyTeamPane(pane = {}) {
464
+ return LEGACY_TEAM_PANE_TITLE_RE.test(String(pane.title || '').trim());
465
+ }
466
+
444
467
  async function listTmuxWindowPanes(bin, windowId) {
445
468
  const format = ['#{pane_id}', '#{pane_title}', '#{pane_current_command}', '#{@sks_team_managed}', '#{@sks_mission_id}', '#{@sks_agent_id}', '#{@sks_lane_role}'].join('\t');
446
469
  const run = await tmuxRun(bin, ['list-panes', '-t', windowId, '-F', format], { timeoutMs: 5000, maxOutputBytes: 32 * 1024 });
@@ -784,7 +807,13 @@ export async function reconcileTmuxTeamCockpit({ root, missionId, plan = {}, pro
784
807
 
785
808
  export async function launchTmuxTeamView({ root, missionId, plan = {}, promptFile = null, json = false, attach = false, args = [] } = {}) {
786
809
  const launch = await buildTmuxLaunchPlan({ root, session: `sks-team-${missionId}` });
787
- const visibleAgents = teamViewAgentIds(plan);
810
+ const missionDir = path.join(launch.root, '.sneakoscope', 'missions', missionId);
811
+ const dashboard = await readTeamDashboard(missionDir).catch(() => null);
812
+ const control = await readTeamControl(missionDir).catch(() => null);
813
+ const plannedAgents = teamViewAgentIds(plan);
814
+ const concreteAgents = concreteDashboardAgentIds(dashboard?.agents || {}, plannedAgents);
815
+ const cleanupRequested = teamCleanupRequested(control);
816
+ const visibleAgents = cleanupRequested ? [] : (json ? plannedAgents : (concreteAgents.length ? concreteAgents : plannedAgents));
788
817
  const commands = visibleAgents.map((agentId) => ({
789
818
  agent: agentId,
790
819
  command: teamAgentCommand(launch.root, missionId, agentId, teamLanePhase(agentId), promptFile),
@@ -792,7 +821,7 @@ export async function launchTmuxTeamView({ root, missionId, plan = {}, promptFil
792
821
  title: teamLaneTitle(agentId)
793
822
  }));
794
823
  const overview = { agent: 'mission_overview', role: 'overview', command: teamOverviewCommand(launch.root, missionId), style: teamLaneStyle('mission_overview'), title: teamLaneTitle('mission_overview') };
795
- const lanes = [overview, ...commands.map((entry) => ({ ...entry, role: entry.style.role }))];
824
+ const lanes = cleanupRequested ? [] : [overview, ...commands.map((entry) => ({ ...entry, role: entry.style.role }))];
796
825
  const splitUi = {
797
826
  mode: 'single_window_split_panes',
798
827
  window: 'sks',
@@ -813,14 +842,14 @@ export async function launchTmuxTeamView({ root, missionId, plan = {}, promptFil
813
842
  agents: commands,
814
843
  lanes,
815
844
  split_ui: splitUi,
816
- cleanup_policy: 'mark-complete; tmux panes remain user controlled',
845
+ cleanup_policy: 'mark-complete; close SKS-managed Team panes; main Codex pane remains user controlled',
817
846
  blockers: launch.blockers,
818
847
  attach_command: launch.attach_command
819
848
  };
820
849
  if (json || !launch.ready) return result;
821
850
  const wantsSeparateSession = args.includes('--separate-session') || args.includes('--new-session') || args.includes('--legacy-team-session') || args.includes('--no-dynamic-team-tmux');
822
851
  if (!wantsSeparateSession) {
823
- const cockpit = await reconcileTmuxTeamCockpit({ root: launch.root, missionId, plan, promptFile, plannedFallback: true });
852
+ const cockpit = await reconcileTmuxTeamCockpit({ root: launch.root, missionId, plan, promptFile, dashboard, control, plannedFallback: true });
824
853
  result.dynamic_cockpit = cockpit;
825
854
  if (cockpit.ok) {
826
855
  result.created = true;
@@ -962,8 +991,25 @@ async function readTmuxTeamRecord(root, missionId) {
962
991
  export async function cleanupTmuxTeamView({ root, missionId = 'latest', closeSession = false } = {}) {
963
992
  const resolvedRoot = path.resolve(root || await sksRoot());
964
993
  const record = await readTmuxTeamRecord(resolvedRoot, missionId);
965
- if (!record?.session) return { ok: false, skipped: true, reason: 'no recorded tmux Team session', mission_id: missionId };
994
+ if (!record?.session) {
995
+ const legacy = await cleanupLegacyTmuxTeamSurfaces(resolvedRoot, missionId, { closeSession }).catch((err) => ({ ok: false, skipped: true, reason: err.message || 'legacy tmux cleanup failed' }));
996
+ return {
997
+ ok: legacy.ok,
998
+ skipped: legacy.closed_lane_count === 0 && !legacy.killed_session,
999
+ reason: legacy.reason,
1000
+ mission_id: missionId,
1001
+ legacy_cleanup: legacy,
1002
+ requested_close_surfaces: legacy.requested_close_surfaces || 0,
1003
+ closed_surfaces: legacy.closed_lane_count || (legacy.killed_session ? 1 : 0)
1004
+ };
1005
+ }
966
1006
  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' }));
1007
+ const recordedCleanup = dynamicCleanup?.ok
1008
+ ? null
1009
+ : await cleanupRecordedTmuxTeamPanes(resolvedRoot, record.mission_id || missionId, record).catch((err) => ({ ok: false, skipped: true, reason: err.message || 'recorded tmux cleanup failed' }));
1010
+ const legacyCleanup = (dynamicCleanup?.closed_lane_count || recordedCleanup?.closed_lane_count)
1011
+ ? null
1012
+ : await cleanupLegacyTmuxTeamSurfaces(resolvedRoot, record.mission_id || missionId, { closeSession: false }).catch((err) => ({ ok: false, skipped: true, reason: err.message || 'legacy tmux cleanup failed' }));
967
1013
  let killed_session = false;
968
1014
  if ((closeSession || closeSession === true) && record.mode !== 'current_session_dynamic_panes') {
969
1015
  const tmuxBin = await findTmuxBin() || 'tmux';
@@ -979,13 +1025,110 @@ export async function cleanupTmuxTeamView({ root, missionId = 'latest', closeSes
979
1025
  close_session: Boolean(closeSession),
980
1026
  killed_session,
981
1027
  dynamic_cleanup: dynamicCleanup,
982
- requested_close_surfaces: closeSession ? 1 : (dynamicCleanup?.closed_lane_count || 0),
983
- closed_surfaces: killed_session ? 1 : (dynamicCleanup?.closed_lane_count || 0),
1028
+ recorded_cleanup: recordedCleanup,
1029
+ legacy_cleanup: legacyCleanup,
1030
+ requested_close_surfaces: closeSession ? 1 : (dynamicCleanup?.closed_lane_count || recordedCleanup?.closed_lane_count || legacyCleanup?.requested_close_surfaces || 0),
1031
+ closed_surfaces: killed_session ? 1 : (dynamicCleanup?.closed_lane_count || recordedCleanup?.closed_lane_count || legacyCleanup?.closed_lane_count || 0),
984
1032
  reason: dynamicCleanup?.ok
985
1033
  ? 'cleanup closed managed Team panes in the current SKS tmux session.'
1034
+ : recordedCleanup?.ok
1035
+ ? 'cleanup closed recorded managed Team panes by stored tmux pane ids.'
1036
+ : legacyCleanup?.ok
1037
+ ? legacyCleanup.reason
986
1038
  : closeSession
987
1039
  ? 'tmux kill-session requested for recorded Team session.'
988
- : 'cleanup marks the SKS tmux Team record complete; panes remain user-controlled.'
1040
+ : 'cleanup marks the SKS tmux Team record complete; no managed panes were reachable.'
1041
+ };
1042
+ }
1043
+
1044
+ async function cleanupLegacyTmuxTeamSurfaces(root, missionId, opts = {}) {
1045
+ const id = String(missionId || '').trim();
1046
+ const tmuxBin = await findTmuxBin() || 'tmux';
1047
+ const current = await currentTmuxTarget(tmuxBin).catch(() => ({ ok: false }));
1048
+ const closed = [];
1049
+ const failed = [];
1050
+ let killed_session = false;
1051
+ let session_kill_requested = false;
1052
+ const session = id && id !== 'latest' ? sanitizeTmuxSessionName(`sks-team-${id}`) : '';
1053
+ if (session && await hasTmuxSession(tmuxBin, session)) {
1054
+ if (current.ok && current.session === session) {
1055
+ const panes = await listTmuxWindowPanes(tmuxBin, current.window_id);
1056
+ if (panes.ok) {
1057
+ for (const pane of panes.panes.filter((entry) => entry.pane_id !== current.pane_id && isLegacyTeamPane(entry))) {
1058
+ const kill = await tmuxRun(tmuxBin, ['kill-pane', '-t', pane.pane_id], { timeoutMs: 5000 });
1059
+ if (kill.code === 0) closed.push({ pane_id: pane.pane_id, title: pane.title });
1060
+ else failed.push({ pane_id: pane.pane_id, title: pane.title, stderr: kill.stderr || kill.stdout || 'tmux kill-pane failed' });
1061
+ }
1062
+ }
1063
+ } else {
1064
+ session_kill_requested = true;
1065
+ const kill = await tmuxRun(tmuxBin, ['kill-session', '-t', session], { timeoutMs: 5000 });
1066
+ killed_session = kill.code === 0;
1067
+ if (!killed_session) failed.push({ session, stderr: kill.stderr || kill.stdout || 'tmux kill-session failed' });
1068
+ }
1069
+ }
1070
+ if (current.ok) {
1071
+ const panes = await listTmuxWindowPanes(tmuxBin, current.window_id);
1072
+ if (panes.ok) {
1073
+ for (const pane of panes.panes.filter((entry) => !entry.managed && entry.pane_id !== current.pane_id && isLegacyTeamPane(entry))) {
1074
+ if (closed.some((entry) => entry.pane_id === pane.pane_id)) continue;
1075
+ const kill = await tmuxRun(tmuxBin, ['kill-pane', '-t', pane.pane_id], { timeoutMs: 5000 });
1076
+ if (kill.code === 0) closed.push({ pane_id: pane.pane_id, title: pane.title });
1077
+ else failed.push({ pane_id: pane.pane_id, title: pane.title, stderr: kill.stderr || kill.stdout || 'tmux kill-pane failed' });
1078
+ }
1079
+ if (closed.length) {
1080
+ await tmuxRun(tmuxBin, ['select-layout', '-t', current.window_id, 'tiled'], { timeoutMs: 5000 }).catch(() => null);
1081
+ await tmuxRun(tmuxBin, ['select-layout', '-t', current.window_id, '-E'], { timeoutMs: 5000 }).catch(() => null);
1082
+ }
1083
+ }
1084
+ }
1085
+ return {
1086
+ ok: failed.length === 0,
1087
+ skipped: !killed_session && closed.length === 0,
1088
+ session: session || null,
1089
+ killed_session,
1090
+ closed_lane_count: closed.length,
1091
+ requested_close_surfaces: (session_kill_requested ? 1 : 0) + closed.length,
1092
+ closed,
1093
+ failed,
1094
+ reason: killed_session
1095
+ ? 'cleanup closed legacy Team tmux session by mission id.'
1096
+ : closed.length
1097
+ ? 'cleanup closed legacy Team panes by lane title.'
1098
+ : 'cleanup found no legacy Team panes for this mission.'
1099
+ };
1100
+ }
1101
+
1102
+ async function cleanupRecordedTmuxTeamPanes(root, missionId, record = {}) {
1103
+ const id = record.mission_id || missionId;
1104
+ const cockpitState = await readJson(tmuxCockpitStatePath(root), {}).catch(() => ({}));
1105
+ const cockpit = cockpitState?.missions?.[id] || {};
1106
+ const target = cockpit.window_id || record.window_id || cockpit.session || record.session;
1107
+ if (!target) return { ok: false, skipped: true, reason: 'no recorded tmux target', closed_lane_count: 0 };
1108
+ const tmuxBin = await findTmuxBin() || 'tmux';
1109
+ const paneList = await listTmuxWindowPanes(tmuxBin, target);
1110
+ if (!paneList.ok) return { ok: false, skipped: true, reason: paneList.stderr, closed_lane_count: 0 };
1111
+ const managed = paneList.panes.filter((pane) => pane.managed && pane.mission_id === id);
1112
+ const closed = [];
1113
+ const failed = [];
1114
+ for (const pane of managed) {
1115
+ const kill = await tmuxRun(tmuxBin, ['kill-pane', '-t', pane.pane_id], { timeoutMs: 5000 });
1116
+ if (kill.code === 0) closed.push({ pane_id: pane.pane_id, agent: pane.agent, role: pane.role });
1117
+ else failed.push({ pane_id: pane.pane_id, agent: pane.agent, stderr: kill.stderr || kill.stdout || 'tmux kill-pane failed' });
1118
+ }
1119
+ if (closed.length) {
1120
+ await tmuxRun(tmuxBin, ['select-layout', '-t', target, 'tiled'], { timeoutMs: 5000 }).catch(() => null);
1121
+ await tmuxRun(tmuxBin, ['select-layout', '-t', target, '-E'], { timeoutMs: 5000 }).catch(() => null);
1122
+ }
1123
+ return {
1124
+ ok: failed.length === 0,
1125
+ skipped: false,
1126
+ session: cockpit.session || record.session,
1127
+ window_id: cockpit.window_id || record.window_id || null,
1128
+ closed_lane_count: closed.length,
1129
+ closed,
1130
+ failed,
1131
+ reason: closed.length ? 'closed recorded managed panes' : 'no recorded managed panes found'
989
1132
  };
990
1133
  }
991
1134