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 +8 -29
- package/package.json +1 -1
- package/src/cli/install-helpers.mjs +7 -1
- package/src/cli/main.mjs +24 -4
- package/src/cli/maintenance-commands.mjs +2 -2
- package/src/core/db-safety.mjs +15 -2
- package/src/core/fsx.mjs +1 -1
- package/src/core/hooks-runtime.mjs +9 -6
- package/src/core/init.mjs +33 -1
- package/src/core/pipeline.mjs +13 -6
- package/src/core/team-live.mjs +56 -11
- package/src/core/tmux-ui.mjs +151 -8
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
4
4
|
|
|
5
|
-
Sneakoscope Codex (`sks
|
|
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
|
|
54
|
-
| Image UX Review | Uses `$Image-UX-Review` / `$UX-Review` for UI/UX audits
|
|
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 |
|
|
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
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
2016
|
-
|
|
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('
|
|
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
|
|
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));
|
package/src/core/db-safety.mjs
CHANGED
|
@@ -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')
|
|
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.
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
&&
|
|
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
|
|
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));
|
package/src/core/pipeline.mjs
CHANGED
|
@@ -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
|
-
|
|
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}
|
|
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}
|
|
1033
|
+
const title = `SKS ${routeName} is paused for explicit user answers.`;
|
|
1035
1034
|
return `${title}
|
|
1036
|
-
Do not
|
|
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
|
-
|
|
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 = {}) {
|
package/src/core/team-live.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
539
|
-
const
|
|
540
|
-
const
|
|
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: ${
|
|
553
|
-
`- phase: ${
|
|
554
|
-
`- last_seen: ${
|
|
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
|
-
`##
|
|
566
|
-
...(
|
|
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' };
|
package/src/core/tmux-ui.mjs
CHANGED
|
@@ -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
|
|
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;
|
|
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)
|
|
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
|
-
|
|
983
|
-
|
|
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
|
|
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
|
|