From 01e2841d70bd2242adafe5bc819ff635c1a9d8c7 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 29 Apr 2026 21:27:34 +0200 Subject: [PATCH] Add cockpit tmux entry point Operators need a repo-scoped tmux surface that can be opened without launching agents or committing to cockpit keybindings yet. This adds a minimal gx cockpit command, a small cockpit/tmux seam, and fake-tmux coverage for create, attach, --session, --attach, and missing-tmux behavior. Constraint: Initial cockpit scope is session creation/attachment only; no keyboard shortcuts or agent launch behavior. Rejected: Put tmux process calls directly in src/cli/main.js | would grow the CLI monolith and make tests harder to isolate. Confidence: high Scope-risk: narrow Directive: Do not add agent launching to gx cockpit without extending the cockpit spec and tests first. Tested: node --test test/cockpit-command.test.js test/cli-args-dispatch.test.js test/metadata.test.js Tested: openspec validate --specs Tested: openspec validate agent-codex-add-gx-cockpit-tmux-session-command-2026-04-29-21-22 --type change --strict Not-tested: Live interactive tmux attach in a real terminal --- .../proposal.md | 16 +++ .../specs/cli-cockpit/spec.md | 36 ++++++ .../tasks.md | 38 ++++++ src/cli/main.js | 10 ++ src/cockpit/index.js | 108 ++++++++++++++++++ src/context.js | 2 + src/tmux/command.js | 2 +- src/tmux/session.js | 6 + test/cockpit-command.test.js | 95 +++++++++++++++ 9 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/agent-codex-add-gx-cockpit-tmux-session-command-2026-04-29-21-22/proposal.md create mode 100644 openspec/changes/agent-codex-add-gx-cockpit-tmux-session-command-2026-04-29-21-22/specs/cli-cockpit/spec.md create mode 100644 openspec/changes/agent-codex-add-gx-cockpit-tmux-session-command-2026-04-29-21-22/tasks.md create mode 100644 test/cockpit-command.test.js diff --git a/openspec/changes/agent-codex-add-gx-cockpit-tmux-session-command-2026-04-29-21-22/proposal.md b/openspec/changes/agent-codex-add-gx-cockpit-tmux-session-command-2026-04-29-21-22/proposal.md new file mode 100644 index 00000000..f658fbf4 --- /dev/null +++ b/openspec/changes/agent-codex-add-gx-cockpit-tmux-session-command-2026-04-29-21-22/proposal.md @@ -0,0 +1,16 @@ +## Why + +Operators need a tiny `gx cockpit` entry point that opens a repo-scoped tmux workspace without starting any Guardex agents or adding cockpit keybindings yet. + +## What Changes + +- Add a `gx cockpit` CLI command. +- Create the default `guardex` tmux session in the resolved repo root when it does not exist. +- Attach to an existing session, or attach after creation when `--attach` is passed. +- Start the initial control pane with `gx agents status`. +- Report a helpful error when tmux is not installed. + +## Impact + +- Affected surfaces: `src/cli/main.js`, `src/context.js`, `src/cockpit/index.js`, `src/tmux/session.js`, `test/cockpit-command.test.js`. +- No agents are launched and no cockpit keyboard shortcuts are added. diff --git a/openspec/changes/agent-codex-add-gx-cockpit-tmux-session-command-2026-04-29-21-22/specs/cli-cockpit/spec.md b/openspec/changes/agent-codex-add-gx-cockpit-tmux-session-command-2026-04-29-21-22/specs/cli-cockpit/spec.md new file mode 100644 index 00000000..470a1955 --- /dev/null +++ b/openspec/changes/agent-codex-add-gx-cockpit-tmux-session-command-2026-04-29-21-22/specs/cli-cockpit/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: cockpit opens a repo tmux session + +Guardex SHALL provide a `gx cockpit` command that creates or attaches to a tmux session for the resolved repo root. + +#### Scenario: missing default session + +- **GIVEN** tmux is installed +- **AND** the default `guardex` session does not exist +- **WHEN** the user runs `gx cockpit` +- **THEN** Guardex SHALL create the `guardex` tmux session with its working directory set to the repo root +- **AND** the initial pane SHALL run `gx agents status` +- **AND** Guardex SHALL NOT launch agents or install cockpit keyboard shortcuts. + +#### Scenario: named missing session with attach requested + +- **GIVEN** tmux is installed +- **AND** the requested session does not exist +- **WHEN** the user runs `gx cockpit --session guardex --attach` +- **THEN** Guardex SHALL create the requested tmux session in the repo root +- **AND** Guardex SHALL attach to it after creation. + +#### Scenario: existing session + +- **GIVEN** tmux is installed +- **AND** the requested tmux session exists +- **WHEN** the user runs `gx cockpit` +- **THEN** Guardex SHALL attach to the existing session +- **AND** Guardex SHALL NOT create a duplicate session. + +#### Scenario: tmux unavailable + +- **GIVEN** tmux is not available on PATH +- **WHEN** the user runs `gx cockpit` +- **THEN** Guardex SHALL print a helpful error telling the user tmux is required. diff --git a/openspec/changes/agent-codex-add-gx-cockpit-tmux-session-command-2026-04-29-21-22/tasks.md b/openspec/changes/agent-codex-add-gx-cockpit-tmux-session-command-2026-04-29-21-22/tasks.md new file mode 100644 index 00000000..96af9fef --- /dev/null +++ b/openspec/changes/agent-codex-add-gx-cockpit-tmux-session-command-2026-04-29-21-22/tasks.md @@ -0,0 +1,38 @@ +## Definition of Done + +This change is complete only when all of the following are true: + +- Every applicable checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and STOP. + +Handoff: 2026-04-29 19:22Z codex owns `src/cli/main.js`, `src/cli/args.js`, `src/cockpit/index.js`, `src/tmux/session.js`, `test/cockpit-command.test.js`, and this change workspace to add the minimal `gx cockpit` tmux session command. + +## 1. Specification + +- [x] 1.1 Define minimal cockpit command scope and non-goals. +- [x] 1.2 Add normative CLI cockpit requirements. + +## 2. Implementation + +- [x] 2.1 Wire `gx cockpit` into CLI dispatch and help/suggestion metadata. +- [x] 2.2 Add cockpit argument handling for `--session`, `--attach`, and target repo resolution. +- [x] 2.3 Add tmux session helpers for availability checks, create, exists, and attach. +- [x] 2.4 Start the first control pane with `gx agents status` and no agent launch/keybinding behavior. + +## 3. Verification + +- [x] 3.1 Run `node --test test/cockpit-command.test.js test/tmux-command.test.js test/tmux-session.test.js test/cli-args-dispatch.test.js test/metadata.test.js`. +- [x] 3.2 Run `openspec validate --specs`. +- [x] 3.3 Run `openspec validate agent-codex-add-gx-cockpit-tmux-session-command-2026-04-29-21-22 --type change --strict`. + +Verification notes: +- `node --test test/cockpit-command.test.js test/tmux-command.test.js test/tmux-session.test.js test/cli-args-dispatch.test.js test/metadata.test.js` passed 51/51 after rebasing onto current `origin/main`. +- `openspec validate --specs` returned `No items found to validate.` in this checkout. +- `openspec validate agent-codex-add-gx-cockpit-tmux-session-command-2026-04-29-21-22 --type change --strict` returned `Change 'agent-codex-add-gx-cockpit-tmux-session-command-2026-04-29-21-22' is valid`. + +## 4. Cleanup + +- [ ] 4.1 Run `gx branch finish --branch "agent/codex/add-gx-cockpit-tmux-session-command-2026-04-29-21-22" --base main --via-pr --wait-for-merge --cleanup`. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; local/remote branch refs are cleaned up). diff --git a/src/cli/main.js b/src/cli/main.js index 2bb68a15..12e1224e 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -6,6 +6,7 @@ const toolchainModule = require('../toolchain'); const finishCommands = require('../finish'); const doctorModule = require('../doctor'); const sessionSeverityReport = require('../report/session-severity'); +const cockpitModule = require('../cockpit'); const { fs, path, @@ -3616,6 +3617,14 @@ function sync(rawArgs) { return finishCommands.sync(rawArgs); } +function cockpit(rawArgs) { + cockpitModule.openCockpit(rawArgs, { + resolveRepoRoot, + toolName: TOOL_NAME, + }); + process.exitCode = 0; +} + function protect(rawArgs) { const parsed = parseTargetFlag(rawArgs, process.cwd()); const [subcommand, ...rest] = parsed.args; @@ -3758,6 +3767,7 @@ async function main() { if (command === 'install-agent-skills') return installAgentSkills(rest); if (command === 'internal') return internal(rest); if (command === 'agents') return agents(rest); + if (command === 'cockpit') return cockpit(rest); if (command === 'merge') return merge(rest); if (command === 'finish') return finish(rest); if (command === 'report') return report(rest); diff --git a/src/cockpit/index.js b/src/cockpit/index.js index a65c159e..397889f4 100644 --- a/src/cockpit/index.js +++ b/src/cockpit/index.js @@ -1,5 +1,58 @@ const { readCockpitState } = require('./state'); const { renderCockpit } = require('./render'); +const { + ensureTmuxAvailable, + sessionExists, + createSession, + attachSession, + sendKeys, +} = require('../tmux/session'); + +const DEFAULT_SESSION_NAME = 'guardex'; + +function parseCockpitArgs(rawArgs = []) { + const options = { + sessionName: DEFAULT_SESSION_NAME, + attach: false, + target: process.cwd(), + }; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--attach') { + options.attach = true; + continue; + } + if (arg === '--session') { + const next = rawArgs[index + 1]; + if (!next || next.startsWith('-')) { + throw new Error('--session requires a tmux session name'); + } + options.sessionName = next; + index += 1; + continue; + } + if (arg.startsWith('--session=')) { + options.sessionName = arg.slice('--session='.length); + if (!options.sessionName) { + throw new Error('--session requires a tmux session name'); + } + continue; + } + if (arg === '--target' || arg === '-t') { + const next = rawArgs[index + 1]; + if (!next || next.startsWith('-')) { + throw new Error(`${arg} requires a repo path`); + } + options.target = next; + index += 1; + continue; + } + throw new Error(`Unknown cockpit option: ${arg}`); + } + + return options; +} function render(repoPath = process.cwd()) { return renderCockpit(readCockpitState(repoPath)); @@ -20,6 +73,58 @@ function startCockpit(options = {}) { return setInterval(paint, refreshMs); } +function openCockpit(rawArgs, deps = {}) { + const { + resolveRepoRoot, + toolName = 'gitguardex', + stdout = process.stdout, + tmux = { + ensureTmuxAvailable, + sessionExists, + createSession, + attachSession, + sendKeys, + }, + } = deps; + if (typeof resolveRepoRoot !== 'function') { + throw new Error('openCockpit requires resolveRepoRoot'); + } + + const options = parseCockpitArgs(rawArgs); + const repoRoot = resolveRepoRoot(options.target); + const controlCommand = 'gx agents status'; + + tmux.ensureTmuxAvailable(); + + if (tmux.sessionExists(options.sessionName)) { + stdout.write(`[${toolName}] Attaching tmux session '${options.sessionName}'.\n`); + tmux.attachSession(options.sessionName); + return { action: 'attached', sessionName: options.sessionName, repoRoot }; + } + + const createResult = tmux.createSession(options.sessionName, repoRoot); + if (createResult.error) throw createResult.error; + if (createResult.status !== 0) { + const detail = String(createResult.stderr || createResult.stdout || '').trim(); + throw new Error(`tmux could not create session '${options.sessionName}'${detail ? `: ${detail}` : '.'}`); + } + const sendResult = tmux.sendKeys(options.sessionName, controlCommand); + if (sendResult.error) throw sendResult.error; + if (sendResult.status !== 0) { + const detail = String(sendResult.stderr || sendResult.stdout || '').trim(); + throw new Error(`tmux could not start cockpit control pane${detail ? `: ${detail}` : '.'}`); + } + stdout.write(`[${toolName}] Created tmux session '${options.sessionName}' in ${repoRoot}.\n`); + stdout.write(`[${toolName}] Control pane: gx agents status\n`); + + if (options.attach) { + tmux.attachSession(options.sessionName); + return { action: 'created-attached', sessionName: options.sessionName, repoRoot }; + } + + return { action: 'created', sessionName: options.sessionName, repoRoot }; +} + if (require.main === module) { startCockpit({ repoPath: process.argv[2] || process.cwd(), @@ -28,6 +133,9 @@ if (require.main === module) { } module.exports = { + DEFAULT_SESSION_NAME, + parseCockpitArgs, + openCockpit, render, startCockpit, }; diff --git a/src/context.js b/src/context.js index c49c3f2d..9c97604d 100644 --- a/src/context.js +++ b/src/context.js @@ -346,6 +346,7 @@ const SUGGESTIBLE_COMMANDS = [ 'hook', 'migrate', 'install-agent-skills', + 'cockpit', 'agents', 'merge', 'finish', @@ -410,6 +411,7 @@ const CLI_COMMAND_GROUPS = [ description: 'Review / cleanup bots, AI setup prompts, and safety reports.', commands: [ ['agents', 'Start/stop repo-scoped review + cleanup bots'], + ['cockpit', 'Create or attach to a repo tmux cockpit session'], ['install-agent-skills', 'Install Guardex Codex/Claude skills into the user home'], ['prompt', 'Print AI setup checklist or named slices (--exec, --part, --list-parts, --snippet)'], ['report', 'Security/safety reports (e.g. OpenSSF scorecard, session severity)'], diff --git a/src/tmux/command.js b/src/tmux/command.js index f4d50c54..d292f444 100644 --- a/src/tmux/command.js +++ b/src/tmux/command.js @@ -13,7 +13,7 @@ function assertArgs(args) { function runTmux(args, options = {}) { assertArgs(args); - return runtime.run('tmux', args, options); + return runtime.run(process.env.GUARDEX_TMUX_BIN || 'tmux', args, options); } function isTmuxAvailable() { diff --git a/src/tmux/session.js b/src/tmux/session.js index 60c3d3de..d42ad776 100644 --- a/src/tmux/session.js +++ b/src/tmux/session.js @@ -16,6 +16,11 @@ function addCwd(args, cwd) { } } +function ensureTmuxAvailable() { + if (tmux.isTmuxAvailable()) return; + throw new Error('tmux is required for gx cockpit. Install tmux and retry.'); +} + function sessionExists(name) { const result = tmux.runTmux(['has-session', '-t', requireName(name)], { stdio: 'pipe', @@ -75,6 +80,7 @@ function sendKeys(paneId, command) { } module.exports = { + ensureTmuxAvailable, sessionExists, createSession, attachSession, diff --git a/test/cockpit-command.test.js b/test/cockpit-command.test.js new file mode 100644 index 00000000..1ca640ba --- /dev/null +++ b/test/cockpit-command.test.js @@ -0,0 +1,95 @@ +const { + test, + assert, + fs, + os, + path, + runNodeWithEnv, + initRepo, +} = require('./helpers/install-test-helpers'); + +function fakeTmux(scriptBody) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-fake-tmux-')); + const bin = path.join(dir, 'tmux'); + const log = path.join(dir, 'tmux.log'); + fs.writeFileSync(bin, `#!/usr/bin/env bash\nset -euo pipefail\nLOG=${JSON.stringify(log)}\n${scriptBody}\n`, 'utf8'); + fs.chmodSync(bin, 0o755); + return { bin, log }; +} + +test('cockpit creates the default tmux session in the repo root', () => { + const repoDir = initRepo(); + const { bin, log } = fakeTmux( + 'printf "%s\\n" "$PWD :: $*" >> "$LOG"\n' + + 'if [[ "$1" == "-V" ]]; then echo "tmux 3.4"; exit 0; fi\n' + + 'if [[ "$1" == "has-session" ]]; then exit 1; fi\n' + + 'if [[ "$1" == "new-session" ]]; then exit 0; fi\n' + + 'if [[ "$1" == "send-keys" ]]; then exit 0; fi\n' + + 'exit 9\n', + ); + + const result = runNodeWithEnv(['cockpit', '--target', repoDir], repoDir, { GUARDEX_TMUX_BIN: bin }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Created tmux session 'guardex'/); + assert.match(result.stdout, /Control pane: gx agents status/); + const lines = fs.readFileSync(log, 'utf8').trim().split('\n'); + assert.match(lines[1], /^.* :: has-session -t guardex$/); + assert.match(lines[2], new RegExp(`^${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} :: new-session -d -s guardex -c ${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`)); + assert.match(lines[3], /^.* :: send-keys -t guardex gx agents status C-m$/); +}); + +test('cockpit attaches when the tmux session already exists', () => { + const repoDir = initRepo(); + const { bin, log } = fakeTmux( + 'printf "%s\\n" "$PWD :: $*" >> "$LOG"\n' + + 'if [[ "$1" == "-V" ]]; then exit 0; fi\n' + + 'if [[ "$1" == "has-session" ]]; then exit 0; fi\n' + + 'if [[ "$1" == "attach-session" ]]; then exit 0; fi\n' + + 'exit 9\n', + ); + + const result = runNodeWithEnv(['cockpit', '--session', 'guardex-dev', '--target', repoDir], repoDir, { + GUARDEX_TMUX_BIN: bin, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Attaching tmux session 'guardex-dev'/); + const logged = fs.readFileSync(log, 'utf8'); + assert.match(logged, /has-session -t guardex-dev/); + assert.match(logged, /attach-session -t guardex-dev/); + assert.doesNotMatch(logged, /new-session/); +}); + +test('cockpit --attach creates then attaches when the session is missing', () => { + const repoDir = initRepo(); + const { bin, log } = fakeTmux( + 'printf "%s\\n" "$PWD :: $*" >> "$LOG"\n' + + 'if [[ "$1" == "-V" ]]; then exit 0; fi\n' + + 'if [[ "$1" == "has-session" ]]; then exit 1; fi\n' + + 'if [[ "$1" == "new-session" ]]; then exit 0; fi\n' + + 'if [[ "$1" == "send-keys" ]]; then exit 0; fi\n' + + 'if [[ "$1" == "attach-session" ]]; then exit 0; fi\n' + + 'exit 9\n', + ); + + const result = runNodeWithEnv(['cockpit', '--session=guardex-dev', '--attach', '--target', repoDir], repoDir, { + GUARDEX_TMUX_BIN: bin, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + const logged = fs.readFileSync(log, 'utf8'); + assert.match(logged, /new-session -d -s guardex-dev/); + assert.match(logged, /send-keys -t guardex-dev gx agents status C-m/); + assert.match(logged, /attach-session -t guardex-dev/); +}); + +test('cockpit reports a helpful error when tmux is unavailable', () => { + const repoDir = initRepo(); + const missingTmux = path.join(os.tmpdir(), `missing-tmux-${process.pid}-${Date.now()}`); + + const result = runNodeWithEnv(['cockpit', '--target', repoDir], repoDir, { GUARDEX_TMUX_BIN: missingTmux }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /tmux is required for gx cockpit\. Install tmux and retry\./); +});