sprag-cli 3.53.0 → 3.55.0

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 (44) hide show
  1. package/README.ko.md +54 -3
  2. package/README.md +63 -4
  3. package/bin/cli.js +30 -7
  4. package/docs/CODEX.ko.md +184 -0
  5. package/docs/CODEX.md +497 -0
  6. package/package.json +15 -4
  7. package/presets/ratchet-rules.json +4 -1
  8. package/src/agent.js +33 -0
  9. package/src/codex-brief.js +89 -0
  10. package/src/codex-budget.js +73 -0
  11. package/src/codex-cache-policy.js +203 -0
  12. package/src/codex-cache.js +46 -0
  13. package/src/codex-delegation.js +154 -0
  14. package/src/codex-doc2md-ledger.js +45 -0
  15. package/src/codex-doc2md.js +99 -0
  16. package/src/codex-doctor.js +53 -0
  17. package/src/codex-harness.js +200 -0
  18. package/src/codex-hooks.js +138 -0
  19. package/src/codex-installer.js +79 -0
  20. package/src/codex-ledger.js +143 -0
  21. package/src/codex-panel-launcher.js +48 -0
  22. package/src/codex-panel-runner.js +74 -0
  23. package/src/codex-panel-session.js +35 -0
  24. package/src/codex-panel-setup.js +98 -0
  25. package/src/codex-panel-shell.js +51 -0
  26. package/src/codex-panel-state.js +40 -0
  27. package/src/codex-panel.js +448 -0
  28. package/src/codex-parser.js +397 -0
  29. package/src/codex-route-scan.js +171 -0
  30. package/src/commands/codex-delegate.js +59 -0
  31. package/src/commands/codex.js +579 -0
  32. package/src/commands/harness.js +13 -2
  33. package/src/commands/install.js +106 -10
  34. package/src/commands/route-scan.js +4 -0
  35. package/src/commands/seed.js +16 -12
  36. package/src/commands/uninstall.js +23 -0
  37. package/src/commands/upgrade.js +13 -0
  38. package/src/doc2md.cjs +72 -28
  39. package/src/handoff.js +27 -7
  40. package/src/harness-templates.js +33 -1
  41. package/src/harness.js +51 -17
  42. package/src/preset-ratchet.js +133 -0
  43. package/src/seed-rules.js +88 -39
  44. package/src/update-check.js +37 -2
@@ -0,0 +1,143 @@
1
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { randomBytes, randomUUID } from 'node:crypto';
4
+ import { userDataDir } from './paths.js';
5
+ import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
6
+ import { codexRunCost, readCodexPrices } from './codex-cache-policy.js';
7
+
8
+ /*
9
+ * Codex routing savings, kept apart from Claude's delegation ledger.
10
+ *
11
+ * A child rollout only counts when Sprag itself routed it: the spawn rewrite
12
+ * writes a random route id into the child's message and records the same id
13
+ * here at that moment. The marker text alone proves nothing, since a model can
14
+ * write it by hand; the pending record is what shows a rule existed at spawn time.
15
+ */
16
+
17
+ export const ROUTE_ID_RE = /<!-- sprag:codex:route id=([0-9a-f]{16}) -->/;
18
+ const PENDING_TTL_MS = 7 * 86400000;
19
+ const pendingFile = (dir) => join(dir, 'codex-delegations.jsonl');
20
+ const ledgerFile = (dir) => join(dir, 'codex-delegation-ledger.json');
21
+
22
+ export function newRouteId() {
23
+ return randomBytes(8).toString('hex');
24
+ }
25
+
26
+ /** One append per spawn. Each line stays well under PIPE_BUF, so parallel spawns do not interleave. */
27
+ export function recordCodexDelegation(entry, { dir = userDataDir() } = {}) {
28
+ mkdirSync(dir, { recursive: true });
29
+ const line = JSON.stringify({ ...entry, at: entry.at ?? Date.now() });
30
+ if (line.length > 3500) throw new Error('Codex delegation record too large');
31
+ appendFileSync(pendingFile(dir), line + '\n', { mode: 0o600 });
32
+ }
33
+
34
+ export function readCodexDelegations({ dir = userDataDir() } = {}) {
35
+ let text;
36
+ try { text = readFileSync(pendingFile(dir), 'utf8'); } catch { return []; }
37
+ const out = [];
38
+ for (const line of text.split('\n')) {
39
+ try {
40
+ const r = JSON.parse(line);
41
+ if (r && /^[0-9a-f]{16}$/.test(r.id) && Number.isFinite(r.at) && typeof r.to === 'string') out.push(r);
42
+ } catch { /* Partial trailing line. */ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ export function loadCodexLedger({ dir = userDataDir() } = {}) {
48
+ try {
49
+ const data = JSON.parse(readFileSync(ledgerFile(dir), 'utf8'));
50
+ if (data?.version === 1 && data.events && typeof data.events === 'object') return data;
51
+ } catch { /* Missing or unreadable reads as empty. */ }
52
+ return { version: 1, events: {} };
53
+ }
54
+
55
+ function saveLedger(data, dir) {
56
+ mkdirSync(dir, { recursive: true });
57
+ const file = ledgerFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
58
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
59
+ renameSync(tmp, file);
60
+ }
61
+
62
+ /** The route id a child rollout carries, and its own tokens, or null. */
63
+ async function childRun(path, cutoffMs) {
64
+ const s = await parseCodexTurns(path, { cutoffMs });
65
+ if (!s.isSubagent) return null;
66
+ let id = null;
67
+ const tokens = { input: 0, cached: 0, cacheWrite: 0, output: 0 };
68
+ let model = null, startedAt = null, endedAt = null, complete = false, usageKnown = false, mixedModels = false;
69
+ for (const t of s.turns) {
70
+ // Only the child's own first message may carry the id; copied history does not count.
71
+ if (!id && t.text) { const m = ROUTE_ID_RE.exec(t.text); if (m) id = m[1]; }
72
+ tokens.input += t.input; tokens.cached += t.cached; tokens.cacheWrite += t.cacheWrite; tokens.output += t.out;
73
+ if (model && t.model && model !== t.model) mixedModels = true;
74
+ model ??= t.model;
75
+ usageKnown ||= t.usageKnown;
76
+ if (Number.isFinite(t.startedAt)) startedAt = Math.min(startedAt ?? t.startedAt, t.startedAt);
77
+ if (Number.isFinite(t.endedAt)) { endedAt = Math.max(endedAt ?? t.endedAt, t.endedAt); complete = !t.aborted; }
78
+ }
79
+ return id ? { id, sessionId: s.sessionId, parentThreadId: s.parentThreadId, provider: s.provider,
80
+ model, tokens, startedAt, endedAt, complete, usageKnown: usageKnown && !mixedModels } : null;
81
+ }
82
+
83
+ /**
84
+ * Join child rollouts to pending routes and price them. Savings hold the
85
+ * child's tokens constant and price them at the parent model, the same
86
+ * approximation Claude's ledger states. A route to a pricier model is a loss,
87
+ * shown as one rather than clamped away.
88
+ */
89
+ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date.now(), prices = readCodexPrices({ now, home, dir }) } = {}) {
90
+ const pending = readCodexDelegations({ dir }).filter((r) => now - r.at < PENDING_TTL_MS);
91
+ const ledger = loadCodexLedger({ dir });
92
+ if (!pending.length) return ledger;
93
+ const byId = new Map(pending.map((r) => [r.id, r]));
94
+ const oldest = Math.min(...pending.map((r) => r.at));
95
+ const files = await discoverCodexSessionFiles({ home, days: Math.max(1, (now - oldest) / 86400000 + 1) });
96
+ let changed = false;
97
+ const seen = new Set();
98
+ for (const f of files.slice().reverse()) {
99
+ if (f.mtime < oldest) continue;
100
+ let run;
101
+ try { run = await childRun(f.path, oldest - 60000); } catch { continue; }
102
+ const route = run && byId.get(run.id);
103
+ // The id must come from a spawn this parent made, after the record was written.
104
+ if (!route || (route.parentSessionId && run.parentThreadId && route.parentSessionId !== run.parentThreadId)) continue;
105
+ if (route.provider && route.provider !== run.provider) continue;
106
+ if (Number.isFinite(run.startedAt) && run.startedAt < route.at - 5000) continue;
107
+ if (seen.has(route.id)) continue;
108
+ seen.add(route.id);
109
+ const to = run.model || route.to;
110
+ const previous = ledger.events[route.id];
111
+ const provider = route.provider || run.provider;
112
+ const sameRun = previous?.childSessionId === run.sessionId && previous.to === to && previous.from === route.from;
113
+ const rates = sameRun && previous.rates ? previous.rates
114
+ : prices?.provider === provider ? { from: prices.prices[route.from], to: prices.prices[to], checkedAt: prices.checkedAt } : null;
115
+ const actual = rates && run.usageKnown ? codexRunCost(run.tokens, rates.to) : null;
116
+ const counterfactual = rates && run.usageKnown ? codexRunCost(run.tokens, rates.from) : null;
117
+ const usd = actual === null || counterfactual === null ? null : counterfactual - actual;
118
+ const event = { ts: run.endedAt ?? route.at, parentSessionId: route.parentSessionId || run.parentThreadId || null,
119
+ childSessionId: run.sessionId, source: route.source, category: route.category ?? null, scope: route.scope ?? null,
120
+ from: route.from, to, provider, tokens: run.tokens, usd, rates: usd === null ? null : rates,
121
+ priceCheckedAt: rates?.checkedAt ?? null, complete: run.complete };
122
+ // A transient price outage must not erase a completed, unchanged priced run.
123
+ if (usd === null && sameRun && previous.complete && Number.isFinite(previous.usd) &&
124
+ JSON.stringify(previous.tokens) === JSON.stringify(run.tokens)) continue;
125
+ if (JSON.stringify(ledger.events[route.id]) !== JSON.stringify(event)) { ledger.events[route.id] = event; changed = true; }
126
+ }
127
+ if (changed) saveLedger(ledger, dir);
128
+ return ledger;
129
+ }
130
+
131
+ /** Signed totals over priced runs; unpriced runs are counted, never summed as zero. */
132
+ export function codexRoutingSavedTotals({ dir = userDataDir(), now = Date.now() } = {}) {
133
+ const events = Object.values(loadCodexLedger({ dir }).events);
134
+ const totals = { week: 0, month: 0, total: 0, runs: events.length, priced: 0 };
135
+ for (const e of events) {
136
+ if (!Number.isFinite(e.usd)) continue;
137
+ totals.priced++;
138
+ totals.total += e.usd;
139
+ if (now - e.ts < 7 * 86400000) totals.week += e.usd;
140
+ if (now - e.ts < 30 * 86400000) totals.month += e.usd;
141
+ }
142
+ return totals;
143
+ }
@@ -0,0 +1,48 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { resolve } from 'node:path';
5
+ import { codexUserDir } from './agent.js';
6
+
7
+ const quote = (value) => `'${String(value).replace(/'/g, `'"'"'`)}'`;
8
+
9
+ export function panelLaunchCommand(root, { node = process.execPath, cli = fileURLToPath(new URL('../bin/cli.js', import.meta.url)), home = codexUserDir(), sessionId, sessionFile } = {}) {
10
+ return ['env', `CODEX_HOME=${home}`, node, cli, 'panel', '--agent', 'codex', '--project', resolve(root),
11
+ ...(sessionId ? ['--session', sessionId] : []), ...(sessionFile ? ['--session-file', sessionFile] : [])]
12
+ .map(quote).join(' ');
13
+ }
14
+
15
+ export function openCodexPanel(root, { platform = process.platform, exec = execFileSync, ...options } = {}) {
16
+ if (platform !== 'darwin') throw new Error('Automatic panel windows currently require macOS Terminal. Run sprag panel --agent codex in a separate terminal.');
17
+ const identity = [options.home || codexUserDir(), options.sessionId ? `session\0${options.sessionId}` : `project\0${resolve(root)}`].join('\0');
18
+ const title = `Sprag | Codex | ${createHash('sha256').update(identity).digest('hex').slice(0, 16)}`;
19
+ // Arguments travel through osascript argv, never through AppleScript source.
20
+ // Reuse only the same session (or an explicitly project-following window).
21
+ const script = `on run argv
22
+ set panelTitle to item 1 of argv
23
+ set panelCommand to item 2 of argv
24
+ tell application "Terminal"
25
+ repeat with w in windows
26
+ repeat with t in tabs of w
27
+ if custom title of t is panelTitle then
28
+ set miniaturized of w to false
29
+ set visible of w to true
30
+ set selected tab of w to t
31
+ set index of w to 1
32
+ activate
33
+ if busy of t then return "already-open"
34
+ do script panelCommand in t
35
+ return "restarted"
36
+ end if
37
+ end repeat
38
+ end repeat
39
+ set t to do script panelCommand
40
+ set custom title of t to panelTitle
41
+ activate
42
+ return "opened"
43
+ end tell
44
+ end run`;
45
+ return exec('/usr/bin/osascript', ['-e', script, title, panelLaunchCommand(root, options)], {
46
+ encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'pipe'],
47
+ }).trim();
48
+ }
@@ -0,0 +1,74 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir, homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { panelLaunchCommand } from './codex-panel-launcher.js';
7
+ import { createPanelBinding } from './codex-panel-session.js';
8
+
9
+ const quote = (s) => `'${String(s).replace(/'/g, `'"'"'`)}'`;
10
+
11
+ export function resizeInlinePanel(rows, { exec = execFileSync, env = process.env, state } = {}) {
12
+ if (env.SPRAG_CODEX_PANEL !== '1' || !env.TMUX_PANE || !env.TMUX) return;
13
+ try {
14
+ const info = exec('tmux', ['display-message', '-p', '-t', env.TMUX_PANE, '#{window_height} #{pane_height} #{window_width}'], { encoding: 'utf8', timeout: 1000 }).trim().split(/\s+/).map(Number);
15
+ const size = `${info[2]}x${info[0]}`;
16
+ // Keep the high-water mark until the terminal is resized. Timer labels and
17
+ // disappearing warnings must not repeatedly resize the prompt above us.
18
+ const contentRows = state && state.size === size ? Math.max(rows, state.rows) : rows;
19
+ const wanted = Math.max(1, Math.min(contentRows + 1, info[0] - 11));
20
+ if (state) { state.size = size; state.rows = contentRows; }
21
+ if (Number.isFinite(wanted) && wanted !== info[1]) exec('tmux', ['resize-pane', '-t', env.TMUX_PANE, '-y', String(wanted)], { stdio: 'ignore' });
22
+ } catch { /* A disappearing client must not stop the panel. */ }
23
+ }
24
+
25
+ export function createInlinePanelResizer(options = {}) {
26
+ const state = {};
27
+ return (rows) => resizeInlinePanel(rows, { ...options, state });
28
+ }
29
+
30
+ // A private server leaves the user's tmux configuration and sessions untouched.
31
+ export function runCodexWithPanel(args, { root = process.cwd(), codex = 'codex', tmux = 'tmux',
32
+ exec = execFileSync, spawn = spawnSync, tty = !!process.stdin.isTTY && !!process.stdout.isTTY,
33
+ env = process.env, columns = process.stdout.columns || 100, rows = process.stdout.rows || 30,
34
+ keepNativeStatusline = false } = {}) {
35
+ if (!tty) throw new Error('Panel run requires an interactive terminal. Use codex directly for scripts.');
36
+ const options = args.slice(0, args.indexOf('--') < 0 ? args.length : args.indexOf('--'));
37
+ if (options.some((arg) => arg === '--remote' || arg.startsWith('--remote='))) {
38
+ throw new Error('Inline panels require a local Codex process. For remote Codex, run codex directly and use a separate panel with --session ID and local logs.');
39
+ }
40
+ try { exec(tmux, ['-V'], { stdio: 'pipe' }); }
41
+ catch { throw new Error('The inline Codex panel requires tmux. Install it with brew install tmux on macOS.'); }
42
+ const socket = `sprag-${randomUUID()}`;
43
+ const base = ['-L', socket, '-f', '/dev/null'];
44
+ const dir = mkdtempSync(join(tmpdir(), 'sprag-codex-'));
45
+ const status = join(dir, 'exit');
46
+ const sessionFile = join(dir, 'session.json');
47
+ const home = resolve(env.CODEX_HOME || join(env.HOME || homedir(), '.codex'));
48
+ const childEnv = { ...env, TMUX: '', SPRAG_CODEX_PANEL: '1', SPRAG_CODEX_PANEL_BINDING: sessionFile };
49
+ // Hide duplicate model/effort only in this layout. Later caller overrides win;
50
+ // putting the default before args also preserves subcommands and `--` prompts.
51
+ const launchArgs = [...(options.includes('--no-daemon') ? [] : ['--no-daemon']),
52
+ ...(keepNativeStatusline ? [] : ['-c', 'tui.status_line=[]']), ...args];
53
+ const call = (argv) => exec(tmux, [...base, ...argv], { env: childEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
54
+ try {
55
+ createPanelBinding(sessionFile);
56
+ const tmuxCommand = [tmux, ...base].map(quote).join(' ');
57
+ const command = `${tmuxCommand} wait-for ready; ${[codex, ...launchArgs].map(quote).join(' ')}; result=$?; printf '%s' "$result" > ${quote(status)}; ${tmuxCommand} kill-session -t codex`;
58
+ const top = call(['new-session', '-d', '-P', '-F', '#{pane_id}', '-s', 'codex', '-c', process.cwd(),
59
+ '-x', String(columns), '-y', String(Math.max(16, rows)), command]).trim();
60
+ call(['set-option', '-t', 'codex', 'status', 'off']);
61
+ call(['set-option', '-t', 'codex', 'destroy-unattached', 'off']);
62
+ call(['split-window', '-d', '-v', '-l', '6', '-t', top,
63
+ `${panelLaunchCommand(resolve(root), { home, sessionFile })} --compact`]);
64
+ call(['select-pane', '-t', top]);
65
+ call(['wait-for', '-S', 'ready']);
66
+ const attached = spawn(tmux, [...base, 'attach-session', '-t', 'codex'], { env: childEnv, stdio: 'inherit' });
67
+ if (attached.error) throw attached.error;
68
+ try { return Number(readFileSync(status, 'utf8')); }
69
+ catch { return attached.status || 0; }
70
+ } finally {
71
+ try { call(['kill-server']); } catch { /* Already gone after normal exit. */ }
72
+ rmSync(dir, { recursive: true, force: true });
73
+ }
74
+ }
@@ -0,0 +1,35 @@
1
+ import { readFileSync, writeFileSync, renameSync } from 'node:fs';
2
+ import { isAbsolute } from 'node:path';
3
+
4
+ export function createPanelBinding(file) {
5
+ writeFileSync(file, JSON.stringify({ version: 1, sessionId: null, transcriptPath: null }), { mode: 0o600 });
6
+ }
7
+
8
+ export function readPanelBinding(file) {
9
+ try {
10
+ const data = JSON.parse(readFileSync(file, 'utf8'));
11
+ if (data.version !== 1) return null;
12
+ if (typeof data.sessionId !== 'string' || !data.sessionId.trim()) return null;
13
+ return { sessionId: data.sessionId,
14
+ transcriptPath: typeof data.transcriptPath === 'string' && isAbsolute(data.transcriptPath) ? data.transcriptPath : null };
15
+ } catch { return null; }
16
+ }
17
+
18
+ // The inline runner uses --no-daemon so this path belongs to this Codex process,
19
+ // not an environment inherited by a shared server from a different terminal.
20
+ export function bindPanelSession(event, payload, { file = process.env.SPRAG_CODEX_PANEL_BINDING } = {}) {
21
+ if (!file || !['session-start', 'panel-start'].includes(event)) return false;
22
+ if (!['startup', 'resume', 'clear', 'compact'].includes(payload?.source)) return false;
23
+ if (typeof payload.session_id !== 'string' || !payload.session_id.trim()) return false;
24
+ try {
25
+ // Never recreate a binding after its launcher has exited and cleaned up.
26
+ const current = JSON.parse(readFileSync(file, 'utf8'));
27
+ if (current.version !== 1) return false;
28
+ if (payload.source === 'compact' && current.sessionId && current.sessionId !== payload.session_id) return false;
29
+ const tmp = `${file}.${process.pid}.tmp`;
30
+ writeFileSync(tmp, JSON.stringify({ version: 1, sessionId: payload.session_id,
31
+ transcriptPath: typeof payload.transcript_path === 'string' && isAbsolute(payload.transcript_path) ? payload.transcript_path : null }), { mode: 0o600 });
32
+ renameSync(tmp, file);
33
+ return true;
34
+ } catch { return false; }
35
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Sets up the Codex companion panel to run inline, inside the same terminal,
3
+ * instead of opening a separate macOS Terminal window. Codex's native footer
4
+ * cannot run a command, so the panel is the closest thing Codex has to a
5
+ * statusline; showing it below the Codex pane in tmux (via a zsh `codex()`
6
+ * wrapper) means a user does not get a new window per session. Install falls
7
+ * back to the separate window when tmux cannot be set up, the login shell is
8
+ * not zsh, or the user already has their own `codex` alias/function.
9
+ */
10
+
11
+ import { execFileSync } from 'node:child_process';
12
+ import { existsSync } from 'node:fs';
13
+ import { basename, dirname, join } from 'node:path';
14
+ import { installPanelShell } from './codex-panel-shell.js';
15
+
16
+ /** Whether a tmux binary answers `-V`. */
17
+ export function tmuxAvailable({ exec = execFileSync } = {}) {
18
+ try { exec('tmux', ['-V'], { stdio: 'pipe' }); return true; }
19
+ catch { return false; }
20
+ }
21
+
22
+ export function ensureTmux({ platform = process.platform, env = process.env, exec = execFileSync, exists = existsSync } = {}) {
23
+ // Checked first, before probing anything, so tests and CI never spawn brew.
24
+ if (env.CTS_NO_TMUX === '1' || (env.CI && env.CI !== 'false')) return { status: 'skipped' };
25
+ if (tmuxAvailable({ exec })) return { status: 'present' };
26
+ if (platform !== 'darwin') {
27
+ return { status: 'unavailable', detail: 'install tmux with your package manager (e.g. sudo apt install tmux)' };
28
+ }
29
+ let brew;
30
+ try { exec('brew', ['--prefix'], { stdio: 'pipe' }); brew = 'brew'; }
31
+ catch {
32
+ for (const p of ['/opt/homebrew/bin/brew', '/usr/local/bin/brew']) {
33
+ if (exists(p)) { brew = p; break; }
34
+ }
35
+ }
36
+ if (!brew) {
37
+ return { status: 'unavailable', detail: 'Homebrew not found; install tmux from https://github.com/tmux/tmux/wiki/Installing or MacPorts (sudo port install tmux)' };
38
+ }
39
+ try {
40
+ // brew update alone can take minutes; postinstall cannot afford that here.
41
+ exec(brew, ['install', 'tmux'], { stdio: 'inherit', timeout: 600_000,
42
+ env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1', HOMEBREW_NO_INSTALL_CLEANUP: '1' } });
43
+ } catch (e) {
44
+ return { status: 'failed', via: 'brew', detail: String(e.message).split('\n')[0] };
45
+ }
46
+ // No npm-package fallback: no official npm tmux exists, and a third-party
47
+ // prebuilt binary would be an unaudited supply-chain risk.
48
+ const fallback = join(dirname(brew === 'brew' ? '/opt/homebrew/bin/brew' : brew), 'tmux');
49
+ if (tmuxAvailable({ exec }) || exists(fallback)) return { status: 'installed', via: 'brew' };
50
+ return { status: 'failed', via: 'brew', detail: 'tmux not on PATH after brew install' };
51
+ }
52
+
53
+ export function setupCodexPanel({ platform = process.platform, env = process.env, config = {}, noPanel = false,
54
+ exec, exists, installShell = installPanelShell, tmux = ensureTmux } = {}) {
55
+ if (noPanel) return { panelAuto: false, mode: 'off' };
56
+ // Panel auto and the zsh integration are macOS-only today.
57
+ if (platform !== 'darwin') return { panelAuto: false, mode: 'off' };
58
+ const windowAuto = config?.codex?.panelAuto !== false;
59
+ if (config?.codex?.panelShell === false) {
60
+ return { panelAuto: windowAuto, mode: windowAuto ? 'window' : 'off', reason: 'shell integration turned off' };
61
+ }
62
+ if (basename(env.SHELL || '') !== 'zsh') {
63
+ return { panelAuto: windowAuto, mode: windowAuto ? 'window' : 'off', reason: 'login shell is not zsh' };
64
+ }
65
+ const t = tmux({ platform, env, ...(exec && { exec }), ...(exists && { exists }) });
66
+ if (!['present', 'installed'].includes(t.status)) {
67
+ return { panelAuto: windowAuto, mode: windowAuto ? 'window' : 'off', reason: t.detail || t.status, tmux: t };
68
+ }
69
+ let shellFile;
70
+ try { shellFile = installShell(); }
71
+ catch (e) {
72
+ return { panelAuto: windowAuto, mode: windowAuto ? 'window' : 'off', reason: e.message, tmux: t };
73
+ }
74
+ // The inline panel replaces the separate window by default; an explicit
75
+ // `panel auto on` keeps both.
76
+ return { panelAuto: config?.codex?.panelAuto === true, mode: 'inline', tmux: t, shellFile };
77
+ }
78
+
79
+ /** Formats a `setupCodexPanel` result as lines a caller prefixes and prints. */
80
+ export function describePanelSetup(result, lang = 'en') {
81
+ if (result.mode === 'off') return [];
82
+ if (result.mode === 'inline') {
83
+ const lines = [lang === 'ko'
84
+ ? `패널: tmux 인라인 (${result.shellFile}). 새 터미널에서 codex 를 실행하면 같은 창 아래쪽에 패널이 붙습니다.`
85
+ : `panel: inline tmux (${result.shellFile}). Open a new terminal and run codex; the panel attaches below it.`];
86
+ if (result.tmux?.status === 'installed') {
87
+ lines.push(lang === 'ko' ? 'tmux 를 Homebrew 로 설치했습니다.' : 'installed tmux with Homebrew.');
88
+ }
89
+ return lines;
90
+ }
91
+ const lines = [lang === 'ko'
92
+ ? `패널: 별도 Terminal 창${result.reason ? ` (${result.reason})` : ''}`
93
+ : `panel: separate Terminal window${result.reason ? ` (${result.reason})` : ''}`];
94
+ if (result.tmux?.detail && ['unavailable', 'failed'].includes(result.tmux.status)) {
95
+ lines.push(`tmux: ${result.tmux.detail}`);
96
+ }
97
+ return lines;
98
+ }
@@ -0,0 +1,51 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ const BEGIN = '# >>> sprag codex panel >>>';
6
+ const END = '# <<< sprag codex panel <<<';
7
+
8
+ export const PANEL_SHELL_BLOCK = `${BEGIN}
9
+ function codex() {
10
+ local sprag_root="$PWD" sprag_open=1 sprag_next=0 sprag_arg
11
+ case "\${1-}" in
12
+ exec|e|review|login|logout|mcp|plugin|app-server|remote-control|completion|update|doctor|sandbox|debug|apply|queue|archive|delete|migrate-rollouts|unarchive|cloud|exec-server|features|help|agents|app) sprag_open=0 ;;
13
+ esac
14
+ for sprag_arg in "$@"; do
15
+ if (( sprag_next )); then
16
+ sprag_root="$sprag_arg"
17
+ sprag_next=0
18
+ continue
19
+ fi
20
+ case "$sprag_arg" in
21
+ -C|--cd) sprag_next=1 ;;
22
+ --cd=*) sprag_root="\${sprag_arg#--cd=}" ;;
23
+ --help|-h|--version|-V) sprag_open=0 ;;
24
+ esac
25
+ done
26
+ if [[ -t 0 && -t 1 && "\${SPRAG_CODEX_PANEL-}" != 1 ]] && (( sprag_open )); then
27
+ command sprag panel run --agent codex -- "$@"
28
+ return $?
29
+ fi
30
+ command codex "$@"
31
+ }
32
+ ${END}
33
+ `;
34
+
35
+ export function installPanelShell({ file = join(process.env.ZDOTDIR || homedir(), '.zshrc'), remove = false } = {}) {
36
+ const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
37
+ const start = text.indexOf(BEGIN);
38
+ const end = text.indexOf(END);
39
+ if ((start < 0) !== (end < 0) || (start >= 0 && end < start)) throw new Error('Malformed Sprag shell block; repair it before installation.');
40
+ if (start < 0 && !remove && /(?:function\s+codex\b|\bcodex\s*\(\)|\balias\s+codex=)/m.test(text)) {
41
+ throw new Error('An existing codex alias/function is present; keeping it unchanged.');
42
+ }
43
+ const next = start >= 0 ? text.slice(0, start) + (remove ? '' : PANEL_SHELL_BLOCK.trimEnd()) + text.slice(end + END.length)
44
+ : remove ? text : text + (text.endsWith('\n') || !text ? '' : '\n') + PANEL_SHELL_BLOCK;
45
+ if (next !== text) {
46
+ mkdirSync(dirname(file), { recursive: true });
47
+ if (text) writeFileSync(`${file}.bak-${Date.now()}`, text);
48
+ writeFileSync(file, next);
49
+ }
50
+ return file;
51
+ }
@@ -0,0 +1,40 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { userDataDir } from './paths.js';
5
+ import { codexUserDir } from './agent.js';
6
+
7
+ export function panelStatePath(root, kind, dir = userDataDir(), sessionId) {
8
+ const key = createHash('sha256').update(codexUserDir() + '\0' + resolve(root) + (sessionId ? `\0${sessionId}` : '')).digest('hex');
9
+ return join(dir, `codex-panel-${key}-${kind}.json`);
10
+ }
11
+
12
+ export function recordPanelState(root, kind, value, dir = userDataDir()) {
13
+ try {
14
+ mkdirSync(dir, { recursive: true });
15
+ const file = panelStatePath(root, kind, dir);
16
+ const tmp = `${file}.${process.pid}.tmp`;
17
+ const text = JSON.stringify({ ...value, at: new Date().toISOString() });
18
+ writeFileSync(tmp, text);
19
+ renameSync(tmp, file);
20
+ if (value.sessionId) {
21
+ const sessionFile = panelStatePath(root, kind, dir, value.sessionId);
22
+ const sessionTmp = `${sessionFile}.${process.pid}.tmp`;
23
+ writeFileSync(sessionTmp, text);
24
+ renameSync(sessionTmp, sessionFile);
25
+ }
26
+ } catch { /* Diagnostics must not stop a session or panel. */ }
27
+ }
28
+
29
+ export function readPanelState(root, kind, dir = userDataDir(), sessionId) {
30
+ try { return JSON.parse(readFileSync(panelStatePath(root, kind, dir, sessionId), 'utf8')); }
31
+ catch { return null; }
32
+ }
33
+
34
+ export function panelHealth(root, { now = Date.now(), dir, sessionId } = {}) {
35
+ const hook = readPanelState(root, 'hook', dir, sessionId);
36
+ const frame = readPanelState(root, 'frame', dir, sessionId);
37
+ const age = frame ? now - Date.parse(frame.at) : Infinity;
38
+ const live = frame?.status === 'rendered' && age >= 0 && age < 15000;
39
+ return { hook, frame, live };
40
+ }