sprag-cli 3.53.0 → 3.54.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.
@@ -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,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
+ }