Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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).
10 changes: 10 additions & 0 deletions src/cli/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
108 changes: 108 additions & 0 deletions src/cockpit/index.js
Original file line number Diff line number Diff line change
@@ -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));
Expand All @@ -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(),
Expand All @@ -28,6 +133,9 @@ if (require.main === module) {
}

module.exports = {
DEFAULT_SESSION_NAME,
parseCockpitArgs,
openCockpit,
render,
startCockpit,
};
2 changes: 2 additions & 0 deletions src/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ const SUGGESTIBLE_COMMANDS = [
'hook',
'migrate',
'install-agent-skills',
'cockpit',
'agents',
'merge',
'finish',
Expand Down Expand Up @@ -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)'],
Expand Down
2 changes: 1 addition & 1 deletion src/tmux/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
6 changes: 6 additions & 0 deletions src/tmux/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -75,6 +80,7 @@ function sendKeys(paneId, command) {
}

module.exports = {
ensureTmuxAvailable,
sessionExists,
createSession,
attachSession,
Expand Down
95 changes: 95 additions & 0 deletions test/cockpit-command.test.js
Original file line number Diff line number Diff line change
@@ -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\./);
});
Loading