sneakoscope 2.0.18 → 3.0.1

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.
Files changed (41) hide show
  1. package/README.md +127 -71
  2. package/crates/sks-core/Cargo.lock +1 -1
  3. package/crates/sks-core/Cargo.toml +1 -1
  4. package/crates/sks-core/src/main.rs +1 -1
  5. package/dist/.sks-build-stamp.json +4 -4
  6. package/dist/bin/sks.js +1 -1
  7. package/dist/commands/mad-sks.js +2 -0
  8. package/dist/commands/zellij.js +58 -1
  9. package/dist/core/agents/agent-scheduler.js +32 -24
  10. package/dist/core/agents/native-cli-session-swarm.js +22 -2
  11. package/dist/core/codex-app/codex-app-handoff.js +30 -9
  12. package/dist/core/codex-app/codex-app-launcher.js +103 -0
  13. package/dist/core/codex-control/codex-0138-capability.js +42 -4
  14. package/dist/core/codex-control/codex-0139-capability.js +102 -0
  15. package/dist/core/codex-control/codex-model-capabilities.js +25 -4
  16. package/dist/core/codex-control/codex-model-metadata.js +91 -0
  17. package/dist/core/codex-plugins/codex-plugin-cache.js +38 -0
  18. package/dist/core/codex-plugins/codex-plugin-diff.js +73 -0
  19. package/dist/core/codex-plugins/codex-plugin-json.js +35 -11
  20. package/dist/core/commands/mad-sks-command.js +8 -0
  21. package/dist/core/commands/naruto-command.js +29 -0
  22. package/dist/core/commands/qa-loop-command.js +41 -6
  23. package/dist/core/fsx.js +1 -1
  24. package/dist/core/image/image-artifact-path-contract.js +2 -0
  25. package/dist/core/image/image-artifact-registry.js +33 -0
  26. package/dist/core/image-ux-review/imagegen-adapter.js +27 -16
  27. package/dist/core/pipeline-internals/runtime-core.js +4 -2
  28. package/dist/core/qa-loop/qa-loop-app-handoff-confirmation.js +51 -0
  29. package/dist/core/qa-loop/qa-loop-budget-policy.js +1 -1
  30. package/dist/core/qa-loop.js +44 -3
  31. package/dist/core/release/release-gate-cache-v2.js +47 -5
  32. package/dist/core/usage/codex-account-usage.js +77 -16
  33. package/dist/core/version.js +1 -1
  34. package/dist/core/zellij/zellij-slot-pane-renderer.js +5 -2
  35. package/dist/core/zellij/zellij-slot-telemetry.js +65 -12
  36. package/dist/core/zellij/zellij-ui-mode.js +8 -1
  37. package/dist/core/zellij/zellij-update.js +307 -0
  38. package/dist/core/zellij/zellij-worker-pane-manager.js +211 -145
  39. package/package.json +23 -2
  40. package/dist/core/naruto/naruto-work-stealing.js +0 -11
  41. package/dist/core/zellij/zellij-right-column-layout-proof.js +0 -42
@@ -5,19 +5,44 @@ export const RELEASE_GATE_CACHE_V2_SCHEMA = 'sks.release-gate-cache.v2';
5
5
  export function releaseGateCacheFile(root) {
6
6
  return path.join(root, '.sneakoscope', 'reports', 'release-gates', 'cache-v2.json');
7
7
  }
8
+ // Files whose only release-to-release difference is the version literal.
9
+ // Hashing them version-neutrally keeps a pure `sks versioning bump` from
10
+ // invalidating every behavior gate: bumping the version rewrites
11
+ // package.json, package-lock.json, and the three PACKAGE_VERSION constant
12
+ // sources, which are inputs of ~280 gates (via `package.json` and `src/**`).
13
+ // Before this normalization every publish re-ran the entire DAG from zero
14
+ // (test:blackbox alone is ~11 minutes) even when no behavior changed.
15
+ // Version-CORRECTNESS gates (release:version-truth, release:metadata, ...)
16
+ // are declared with `cache.enabled: false`, so they always re-run and still
17
+ // catch version drift. Set SKS_RELEASE_CACHE_VERSION_SENSITIVE=1 to restore
18
+ // the old fully version-sensitive hashing.
19
+ const VERSION_NEUTRAL_CACHE_FILES = new Set([
20
+ 'package.json',
21
+ 'package-lock.json',
22
+ 'src/core/version.ts',
23
+ 'src/core/fsx.ts',
24
+ 'src/bin/sks.ts'
25
+ ]);
8
26
  export function releaseGateCacheKey(root, gate) {
9
27
  const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
28
+ const releaseVersion = String(pkg.version || '');
29
+ const versionSensitive = process.env.SKS_RELEASE_CACHE_VERSION_SENSITIVE === '1';
10
30
  const hash = crypto.createHash('sha256');
11
31
  hash.update(gate.id);
12
32
  hash.update(gate.command);
13
- hash.update(String(pkg.version || ''));
33
+ if (versionSensitive)
34
+ hash.update(releaseVersion);
14
35
  hash.update(process.version);
15
36
  hash.update(String(process.env.npm_config_user_agent || ''));
16
37
  hash.update(JSON.stringify(gate.resource || []));
17
38
  hash.update(JSON.stringify(gate.preset || []));
18
39
  hashFileIfPresent(hash, path.join(root, 'release-gates.v2.json'));
19
- hashFileIfPresent(hash, path.join(root, 'package.json'));
20
- hashFileIfPresent(hash, path.join(root, 'dist', 'build-manifest.json'));
40
+ if (versionSensitive || !gate.cache.inputs.length) {
41
+ // No declared inputs (or explicitly version-sensitive mode): fall back to
42
+ // the conservative global digests so such a gate cannot cache-hit forever.
43
+ hashFileIfPresent(hash, path.join(root, 'package.json'));
44
+ hashFileIfPresent(hash, path.join(root, 'dist', 'build-manifest.json'));
45
+ }
21
46
  for (const input of gate.cache.inputs) {
22
47
  const expanded = expandGlob(root, input);
23
48
  hash.update(`input:${input}`);
@@ -26,12 +51,29 @@ export function releaseGateCacheKey(root, gate) {
26
51
  continue;
27
52
  }
28
53
  for (const file of expanded) {
29
- hash.update(path.relative(root, file));
30
- hashFileIfPresent(hash, file);
54
+ const rel = path.relative(root, file);
55
+ hash.update(rel);
56
+ if (!versionSensitive && VERSION_NEUTRAL_CACHE_FILES.has(rel))
57
+ hashVersionNeutralFile(hash, file, releaseVersion);
58
+ else
59
+ hashFileIfPresent(hash, file);
31
60
  }
32
61
  }
33
62
  return hash.digest('hex');
34
63
  }
64
+ function hashVersionNeutralFile(hash, file, releaseVersion) {
65
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile())
66
+ return;
67
+ const text = fs.readFileSync(file, 'utf8');
68
+ if (!releaseVersion) {
69
+ hash.update(text);
70
+ return;
71
+ }
72
+ // Replace exact occurrences of the current release version literal so a
73
+ // version-only bump hashes identically. Any other content change in these
74
+ // files still alters the key.
75
+ hash.update(text.split(releaseVersion).join('__SKS_RELEASE_VERSION__'));
76
+ }
35
77
  export function expandGlob(root, input) {
36
78
  const absolute = path.join(root, input);
37
79
  if (!/[*!?[\]{}]/.test(input)) {
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
- import { nowIso, writeJsonAtomic } from '../fsx.js';
2
+ import { findCodexBinary } from '../codex-adapter.js';
3
+ import { nowIso, runProcess, writeJsonAtomic } from '../fsx.js';
3
4
  export async function collectCodexAccountUsage() {
4
5
  if (process.env.SKS_CODEX_ACCOUNT_USAGE_FAKE === '1') {
5
6
  return {
@@ -15,22 +16,42 @@ export async function collectCodexAccountUsage() {
15
16
  reset_at: null
16
17
  },
17
18
  usage_limit_tokens: 100000,
19
+ attempted_sources: ['fake'],
18
20
  blockers: []
19
21
  };
20
22
  }
21
- const url = String(process.env.SKS_CODEX_APP_SERVER_USAGE_URL || process.env.CODEX_APP_SERVER_USAGE_URL || '').trim();
22
- if (!url)
23
- return unavailable(['codex_app_server_usage_endpoint_unavailable']);
24
- try {
25
- const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
26
- if (!response.ok)
27
- return unavailable([`codex_app_server_usage_http_${response.status}`]);
28
- const payload = await response.json();
29
- return normalizeUsagePayload(payload, 'app-server');
30
- }
31
- catch (err) {
32
- return unavailable([`codex_app_server_usage_fetch_failed:${err?.message || String(err)}`]);
23
+ const attemptedSources = [];
24
+ const urls = [
25
+ ['CODEX_APP_SERVER_USAGE_URL', process.env.CODEX_APP_SERVER_USAGE_URL],
26
+ ['SKS_CODEX_APP_SERVER_USAGE_URL', process.env.SKS_CODEX_APP_SERVER_USAGE_URL],
27
+ ...localWellKnownUsageUrls().map((url) => [`local:${url}`, url])
28
+ ];
29
+ const blockers = [];
30
+ for (const [label, rawUrl] of urls) {
31
+ const url = String(rawUrl || '').trim();
32
+ if (!url)
33
+ continue;
34
+ attemptedSources.push(label);
35
+ try {
36
+ const response = await fetch(url, { signal: AbortSignal.timeout(label.startsWith('local:') ? 800 : 5000) });
37
+ if (!response.ok) {
38
+ blockers.push(`codex_app_server_usage_http_${response.status}:${label}`);
39
+ continue;
40
+ }
41
+ const payload = await response.json();
42
+ return normalizeUsagePayload(payload, 'app-server', attemptedSources);
43
+ }
44
+ catch (err) {
45
+ blockers.push(`codex_app_server_usage_fetch_failed:${label}:${err?.message || String(err)}`);
46
+ }
33
47
  }
48
+ const cli = await collectUsageFromCodexCli(attemptedSources).catch((err) => {
49
+ blockers.push(`codex_cli_usage_probe_failed:${err?.message || String(err)}`);
50
+ return null;
51
+ });
52
+ if (cli)
53
+ return cli;
54
+ return unavailable(attemptedSources.length ? blockers : ['codex_app_server_usage_endpoint_unavailable'], attemptedSources);
34
55
  }
35
56
  export async function writeCodexAccountUsageArtifacts(root, input = {}) {
36
57
  const snapshot = await collectCodexAccountUsage();
@@ -43,7 +64,7 @@ export async function writeCodexAccountUsageArtifacts(root, input = {}) {
43
64
  }
44
65
  return { snapshot, root_artifact: rootArtifact, mission_artifact: missionArtifact };
45
66
  }
46
- function normalizeUsagePayload(payload, source) {
67
+ function normalizeUsagePayload(payload, source, attemptedSources) {
47
68
  const usage = payload?.token_usage || payload?.usage || payload;
48
69
  const input = Number(usage?.input_tokens || usage?.inputTokens || 0);
49
70
  const output = Number(usage?.output_tokens || usage?.outputTokens || 0);
@@ -61,18 +82,58 @@ function normalizeUsagePayload(payload, source) {
61
82
  reset_at: usage?.reset_at || usage?.resetAt || null
62
83
  },
63
84
  usage_limit_tokens: Number.isFinite(Number(payload?.usage_limit_tokens || payload?.usageLimitTokens)) ? Number(payload?.usage_limit_tokens || payload?.usageLimitTokens) : null,
85
+ attempted_sources: attemptedSources,
64
86
  blockers: []
65
87
  };
66
88
  }
67
- function unavailable(blockers) {
89
+ function unavailable(blockers, attemptedSources = []) {
68
90
  return {
69
91
  schema: 'sks.codex-account-usage.v1',
70
92
  generated_at: nowIso(),
71
- ok: true,
93
+ ok: false,
72
94
  source: 'unavailable',
73
95
  token_usage: null,
74
96
  usage_limit_tokens: null,
97
+ attempted_sources: attemptedSources,
75
98
  blockers
76
99
  };
77
100
  }
101
+ function localWellKnownUsageUrls() {
102
+ const ports = [
103
+ process.env.CODEX_APP_SERVER_PORT,
104
+ process.env.SKS_CODEX_APP_SERVER_PORT,
105
+ 1455,
106
+ 1456,
107
+ 3000
108
+ ].map((value) => Number(value)).filter((value, index, rows) => Number.isFinite(value) && value > 0 && rows.indexOf(value) === index);
109
+ return ports.flatMap((port) => [
110
+ `http://127.0.0.1:${port}/usage`,
111
+ `http://127.0.0.1:${port}/api/usage`,
112
+ `http://127.0.0.1:${port}/.well-known/codex/usage`
113
+ ]);
114
+ }
115
+ async function collectUsageFromCodexCli(attemptedSources) {
116
+ const bin = await findCodexBinary();
117
+ if (!bin)
118
+ return null;
119
+ const commands = [
120
+ ['account', 'usage', '--json'],
121
+ ['usage', '--json'],
122
+ ['app-server', 'status', '--json']
123
+ ];
124
+ for (const args of commands) {
125
+ const label = `codex-cli:${args.join(' ')}`;
126
+ attemptedSources.push(label);
127
+ const result = await runProcess(bin, args, { timeoutMs: 3000, maxOutputBytes: 64 * 1024 }).catch(() => null);
128
+ if (!result || result.code !== 0)
129
+ continue;
130
+ try {
131
+ const payload = JSON.parse(`${result.stdout || ''}${result.stderr || ''}`.trim() || '{}');
132
+ const normalized = normalizeUsagePayload(payload, 'app-server', attemptedSources);
133
+ return { ...normalized, source: 'app-server' };
134
+ }
135
+ catch { }
136
+ }
137
+ return null;
138
+ }
78
139
  //# sourceMappingURL=codex-account-usage.js.map
@@ -1,2 +1,2 @@
1
- export const PACKAGE_VERSION = '2.0.18';
1
+ export const PACKAGE_VERSION = '3.0.1';
2
2
  //# sourceMappingURL=version.js.map
@@ -215,7 +215,10 @@ async function tryRenderTelemetrySlotPane(input) {
215
215
  return null;
216
216
  const staleRows = staleTelemetryRows(telemetryStatus(snapshot).telemetry_age_ms);
217
217
  const fallbackRows = artifactFallbackRows(input.artifactRender);
218
- const liveRows = staleRows.length || !slot.progress ? fallbackRows : [];
218
+ // Always surface the live artifact rows (current file, tool events, stdout
219
+ // tail). Telemetry freshness only tells us the worker is alive — the user
220
+ // still needs to see WHAT the worker is doing right now.
221
+ const liveRows = fallbackRows;
219
222
  if (slot.status === 'failed') {
220
223
  return [
221
224
  `${slot.slot_id} gen-${slot.generation_index} · FAILED`,
@@ -254,7 +257,7 @@ function artifactFallbackRows(text) {
254
257
  .map((line) => line.replace(/^\|\s?/, '').replace(/\s?\|$/, '').trim())
255
258
  .filter((line) => /^(heartbeat|doing|files|event|out|err):\s+/i.test(line))
256
259
  .filter((line) => !/unknown|waiting for worker intake|no changed file yet/i.test(line))
257
- .slice(-4)
260
+ .slice(-7)
258
261
  .map((line) => `live: ${trimInline(line, 72)}`);
259
262
  }
260
263
  function findTelemetrySlot(snapshot, slotId, generationIndex) {
@@ -7,6 +7,7 @@ const telemetrySnapshotCache = new Map();
7
7
  const telemetrySnapshotWriteCounts = new Map();
8
8
  const telemetrySnapshotFlushCounts = new Map();
9
9
  const telemetrySnapshotLastFlushMs = new Map();
10
+ const telemetrySnapshotDiskStat = new Map();
10
11
  export function slotTelemetryEventPath(root, missionId) {
11
12
  return path.join(inferMissionDir(root, missionId), 'zellij', 'slot-telemetry.events.jsonl');
12
13
  }
@@ -33,24 +34,68 @@ export async function appendZellijSlotTelemetry(root, event) {
33
34
  await rebuildZellijSlotTelemetrySnapshot(root, missionId);
34
35
  }
35
36
  export async function readZellijSlotTelemetrySnapshot(root, missionId) {
36
- const snapshotPath = slotTelemetrySnapshotPath(root, missionId);
37
- const cached = telemetrySnapshotCache.get(snapshotPath);
38
- if (cached?.schema === ZELLIJ_SLOT_TELEMETRY_SNAPSHOT_SCHEMA)
39
- return cached;
40
- const existing = await readJson(snapshotPath, null);
41
- if (existing?.schema === ZELLIJ_SLOT_TELEMETRY_SNAPSHOT_SCHEMA)
42
- return existing;
37
+ const fresh = await readZellijSlotTelemetrySnapshotNoRebuild(root, missionId);
38
+ if (fresh)
39
+ return fresh;
43
40
  return rebuildZellijSlotTelemetrySnapshot(root, missionId);
44
41
  }
45
42
  export async function readZellijSlotTelemetrySnapshotNoRebuild(root, missionId) {
46
43
  const snapshotPath = slotTelemetrySnapshotPath(root, missionId);
47
44
  const cached = telemetrySnapshotCache.get(snapshotPath);
48
- if (cached?.schema === ZELLIJ_SLOT_TELEMETRY_SNAPSHOT_SCHEMA)
45
+ const stat = await statTelemetryFile(snapshotPath);
46
+ const recorded = telemetrySnapshotDiskStat.get(snapshotPath);
47
+ const diskChanged = Boolean(stat) && (!recorded || recorded.mtimeMs !== stat.mtimeMs || recorded.size !== stat.size);
48
+ // CRITICAL: never serve a process-local cache forever. Long-lived reader
49
+ // processes (zellij slot pane renderers in --watch mode) must observe
50
+ // snapshot flushes performed by the orchestrator and worker processes,
51
+ // otherwise the pane renders the same frame for the entire mission.
52
+ if (!diskChanged && cached?.schema === ZELLIJ_SLOT_TELEMETRY_SNAPSHOT_SCHEMA)
49
53
  return cached;
50
54
  const existing = await readJson(snapshotPath, null);
51
- if (existing?.schema === ZELLIJ_SLOT_TELEMETRY_SNAPSHOT_SCHEMA)
52
- telemetrySnapshotCache.set(snapshotPath, existing);
53
- return existing?.schema === ZELLIJ_SLOT_TELEMETRY_SNAPSHOT_SCHEMA ? existing : null;
55
+ if (stat)
56
+ telemetrySnapshotDiskStat.set(snapshotPath, stat);
57
+ if (existing?.schema !== ZELLIJ_SLOT_TELEMETRY_SNAPSHOT_SCHEMA) {
58
+ return cached?.schema === ZELLIJ_SLOT_TELEMETRY_SNAPSHOT_SCHEMA ? cached : null;
59
+ }
60
+ const disk = existing;
61
+ // Merge with any locally cached (possibly not-yet-flushed) slot state so a
62
+ // writer process does not lose its pending events when another process
63
+ // flushed the snapshot file in the meantime. The DISK updated_at stays
64
+ // authoritative on the read path: it reflects the last real flush, which is
65
+ // what stale-telemetry detection must measure.
66
+ const merged = cached?.schema === ZELLIJ_SLOT_TELEMETRY_SNAPSHOT_SCHEMA ? mergeTelemetrySnapshots(disk, cached, { updatedAt: 'base' }) : disk;
67
+ telemetrySnapshotCache.set(snapshotPath, merged);
68
+ return merged;
69
+ }
70
+ export function mergeTelemetrySnapshots(base, overlay, opts = {}) {
71
+ const slots = { ...(base.slots || {}) };
72
+ for (const [key, row] of Object.entries(overlay.slots || {})) {
73
+ const existing = slots[key];
74
+ slots[key] = !existing || telemetryTsMs(row.latest_ts) >= telemetryTsMs(existing.latest_ts) ? row : existing;
75
+ }
76
+ const baseTs = Date.parse(String(base.updated_at || '')) || 0;
77
+ const overlayTs = Date.parse(String(overlay.updated_at || '')) || 0;
78
+ return {
79
+ schema: ZELLIJ_SLOT_TELEMETRY_SNAPSHOT_SCHEMA,
80
+ mission_id: base.mission_id || overlay.mission_id,
81
+ updated_at: opts.updatedAt === 'base' ? base.updated_at : overlayTs > baseTs ? overlay.updated_at : base.updated_at,
82
+ flush_count: Math.max(Number(base.flush_count || 0), Number(overlay.flush_count || 0)),
83
+ slots,
84
+ counts: countSlotTelemetry(slots)
85
+ };
86
+ }
87
+ function telemetryTsMs(value) {
88
+ const parsed = Date.parse(String(value || ''));
89
+ return Number.isFinite(parsed) ? parsed : 0;
90
+ }
91
+ async function statTelemetryFile(file) {
92
+ try {
93
+ const st = await fsp.stat(file);
94
+ return { mtimeMs: st.mtimeMs, size: st.size };
95
+ }
96
+ catch {
97
+ return null;
98
+ }
54
99
  }
55
100
  export function applyTelemetryEventToSnapshot(snapshot, event) {
56
101
  const key = slotTelemetryKey(event.slot_id || event.worker_id, event.generation_index);
@@ -216,9 +261,17 @@ async function writeTelemetrySnapshotFast(file, snapshot) {
216
261
  const flushCount = Number(telemetrySnapshotFlushCounts.get(file) || 0) + 1;
217
262
  telemetrySnapshotFlushCounts.set(file, flushCount);
218
263
  telemetrySnapshotLastFlushMs.set(file, Date.now());
219
- const next = { ...snapshot, flush_count: flushCount };
264
+ // Merge with the on-disk snapshot before overwriting: multiple processes
265
+ // (orchestrator + worker children) flush this file concurrently and a plain
266
+ // overwrite would drop slots that only the other process has observed.
267
+ const disk = await readJson(file, null);
268
+ const merged = disk?.schema === ZELLIJ_SLOT_TELEMETRY_SNAPSHOT_SCHEMA ? mergeTelemetrySnapshots(disk, snapshot) : snapshot;
269
+ const next = { ...merged, flush_count: Math.max(flushCount, Number(merged.flush_count || 0)) };
220
270
  telemetrySnapshotCache.set(file, next);
221
271
  await fsp.writeFile(file, `${JSON.stringify(next)}\n`, 'utf8');
272
+ const stat = await statTelemetryFile(file);
273
+ if (stat)
274
+ telemetrySnapshotDiskStat.set(file, stat);
222
275
  }
223
276
  function shouldFlushTelemetrySnapshot(file, event) {
224
277
  const next = (telemetrySnapshotWriteCounts.get(file) || 0) + 1;
@@ -2,7 +2,14 @@ export function resolveZellijUiMode(args = [], env = process.env) {
2
2
  return resolveExplicitZellijUiMode(args, env) || 'compact-slots';
3
3
  }
4
4
  export function resolveZellijWorkerPaneUiMode(args = [], env = process.env) {
5
- return resolveExplicitZellijUiMode(args, env) || 'full-debug';
5
+ // Default worker panes to the live slot renderer (compact-slots). In
6
+ // 'full-debug' the pane runs the worker process itself, but the worker is
7
+ // invoked with --json and the codex SDK streams events to JSONL files — so
8
+ // the pane stays blank until the worker exits. The slot renderer re-reads
9
+ // heartbeat/event/stdout artifacts every second and actually shows what each
10
+ // parallel worker is doing in real time. 'full-debug' remains available via
11
+ // --zellij-full-debug or SKS_ZELLIJ_UI_MODE=full-debug.
12
+ return resolveExplicitZellijUiMode(args, env) || 'compact-slots';
6
13
  }
7
14
  function resolveExplicitZellijUiMode(args = [], env = process.env) {
8
15
  const fromEnv = String(env.SKS_ZELLIJ_UI_MODE || '').trim();