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

Multi-agent `gx agents start` already creates isolated `agent/*` lanes, but the recent launcher panel still points operators back through cockpit/tmux for terminal panes. The requested operator flow needs the safety model to stay unchanged while opening the created lanes in a Kitty window by default.

## What Changes

- Add a Kitty-backed terminal launcher for multi-agent starts.
- Add `--terminal kitty|none` with `GUARDEX_AGENT_TERMINAL` defaulting to `kitty`.
- Keep branch/worktree creation, lock claiming, and PR finish flow unchanged.
- Update launcher panel terminal copy to Kitty-first language.

## Impact

- Multi-agent starts can open one Kitty session after all lanes are created.
- Missing Kitty reports a recovery command and session file path instead of failing lane creation.
- `--terminal none` keeps the old no-terminal behavior.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## ADDED Requirements

### Requirement: Kitty external terminal launcher

`gx agents start` SHALL use `kitty` as the default external terminal launcher for multi-agent starts while preserving the existing branch, worktree, lock, and PR-only finish safety model.

#### Scenario: Multi-agent start launches Kitty after lanes exist

- **WHEN** an operator starts more than one agent lane with `gx agents start "fix auth tests" --panel --codex-accounts 3 --base main`
- **THEN** Guardex SHALL create each `agent/*` lane before terminal launch
- **AND** SHALL write a Kitty session file containing each lane worktree and launch command
- **AND** SHALL launch one Kitty window from that session file.

#### Scenario: Terminal launch disabled

- **WHEN** an operator passes `--terminal none`
- **THEN** Guardex SHALL create the requested lanes
- **AND** SHALL skip external terminal launch.

#### Scenario: Kitty unavailable

- **WHEN** Kitty is not available on PATH
- **THEN** Guardex SHALL keep created lanes and session metadata intact
- **AND** SHALL print the Kitty session file path and recovery command.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## 1. Spec

- [x] Record Kitty-first multi-agent terminal launcher scope.

## 2. Tests

- [x] Cover default `kitty` parsing.
- [x] Cover `--terminal none` skipping terminal launch.
- [x] Cover missing Kitty recovery output.
- [x] Cover launcher panel copy update.

## 3. Implementation

- [x] Add `src/agents/terminal.js`.
- [x] Wire `gx agents start` multi-lane success into Kitty launch after lane creation.
- [x] Add parser support for `--terminal`.
- [x] Update panel text from tmux-focused wording to Kitty-first wording.

## 4. Verification

- [x] Run focused Node tests for parser, launcher, panel.
- [x] Run `openspec validate --specs`.

Evidence:

- `node --test test/cli-args-dispatch.test.js test/agents-start.test.js test/agents-selection-panel.test.js test/agents-start-dry-run.test.js` -> 30 pass.
- `openspec validate agent-codex-kitty-default-agent-terminal-2026-04-30-13-14 --strict` -> valid.
- `openspec validate --specs` -> no spec items found.
- `npm test` -> 423 pass, 9 fail, 1 skip; failures were pre-existing-looking baseline mismatches outside this touched scope (`test/agents-launch.test.js`, `test/agents-lifecycle.test.js`, `test/agents-sessions.test.js`, `test/cockpit-command.test.js`).

## 5. Cleanup

- [x] Commit changes.
- [ ] Finish via PR, wait for merge, cleanup, and record `MERGED` evidence.
10 changes: 5 additions & 5 deletions src/agents/selection-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ const DEFAULT_PANEL_HEIGHT = 30;
const SIDEBAR_WIDTH = 36;
const PANEL_ACTIONS = [
['n', 'New agent', 'create an agent pane in this repo'],
['t', 'Terminal', 'open a shell pane from gx cockpit'],
['t', 'Terminal', 'open Kitty agent terminal'],
['p', 'Project', 'create pane in another project'],
['Alt+Shift+M', 'Pane menu', 'act on the focused tmux pane'],
['Alt+Shift+M', 'Pane menu', 'act on the selected pane'],
['j/k', 'Jump', 'move between panes in the list'],
['m', 'Menu', 'open pane context actions'],
['x', 'Close', 'close selected pane'],
Expand All @@ -27,10 +27,10 @@ const PANEL_ACTIONS = [

const PANEL_SHORTCUT_MESSAGES = {
'?': 'Shortcut map is shown on the right.',
t: 'Terminal panes are managed in gx cockpit; open cockpit, then press t.',
t: 'Kitty agent terminals open after multi-agent launch; pass --terminal none to skip.',
p: 'Project panes are managed in gx cockpit; open cockpit, then press p.',
m: 'Pane menu is available in gx cockpit with m or Alt+Shift+M.',
'alt-shift-m': 'Pane menu is available in gx cockpit for the focused tmux pane.',
'alt-shift-m': 'Pane menu is available in gx cockpit for the selected pane.',
x: 'Close is available from gx cockpit pane menu.',
b: 'Child worktrees are available from gx cockpit pane menu.',
f: 'File browser is available from gx cockpit pane menu.',
Expand Down Expand Up @@ -422,7 +422,7 @@ function renderSidebarRows(options, selections, definitions, width, height) {
'─'.repeat(width),
' [l]ogs [p]rojects [s]ettings',
' Press [?] for keyboard shortcuts',
' Tip: live panes: gx cockpit',
' Tip: multi-agent terminals: Kitty',
'',
);

Expand Down
18 changes: 16 additions & 2 deletions src/agents/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const {
listAgentSessions,
updateAgentSession,
} = require('./sessions');
const { launchAgentTerminal } = require('./terminal');

function sanitizeSlug(value, fallback = 'task') {
const slug = String(value || '')
Expand Down Expand Up @@ -473,7 +474,7 @@ function startSingleAgentLane(repoRoot, options, deps = {}) {
const session = writeAgentSession(repoRoot, options, metadata, 'active');
stdout = appendSessionId(stdout, session);
if (options.claims.length === 0) {
return { status: 0, stdout, stderr };
return { status: 0, stdout, stderr, session };
}

if (!metadata.branch || !metadata.worktreePath) {
Expand All @@ -492,7 +493,7 @@ function startSingleAgentLane(repoRoot, options, deps = {}) {
stdout += String(claimResult.stdout || '');
stderr += String(claimResult.stderr || '');
if (!isSpawnFailure(claimResult) && claimResult.status === 0) {
return { status: 0, stdout, stderr };
return { status: 0, stdout, stderr, session };
}

if (isSpawnFailure(claimResult)) {
Expand Down Expand Up @@ -520,6 +521,7 @@ function startAgentLane(repoRoot, options, deps = {}) {
selections: normalizeAgentSelections(options),
});
let stderr = '';
const sessions = [];

for (const launchOption of launchOptions) {
const result = startSingleAgentLane(repoRoot, launchOption, deps);
Expand All @@ -532,12 +534,24 @@ function startAgentLane(repoRoot, options, deps = {}) {
stderr,
};
}
if (result.session) {
sessions.push(result.session);
}
}

const terminalResult = launchAgentTerminal(repoRoot, sessions, {
terminal: options.terminal,
runner: deps.terminalRunner,
kittyBin: deps.kittyBin,
});
stdout += String(terminalResult.stdout || '');
stderr += String(terminalResult.stderr || '');

return {
status: 0,
stdout,
stderr,
terminal: terminalResult,
};
}

Expand Down
140 changes: 140 additions & 0 deletions src/agents/terminal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
'use strict';

const fs = require('node:fs');
const path = require('node:path');

const { TOOL_NAME } = require('../context');
const { run } = require('../core/runtime');
const { shellQuote } = require('./launch');

const DEFAULT_AGENT_TERMINAL = 'kitty';
const SUPPORTED_AGENT_TERMINALS = new Set(['kitty', 'none']);

function normalizeAgentTerminal(value) {
const terminal = String(value || DEFAULT_AGENT_TERMINAL).trim().toLowerCase();
return terminal || DEFAULT_AGENT_TERMINAL;
}

function sanitizeFileSegment(value) {
return String(value || 'agents')
.replace(/[^a-zA-Z0-9._-]+/g, '__')
.replace(/^_+|_+$/g, '')
.slice(0, 120) || 'agents';
}

function terminalSessionDir(repoRoot) {
return path.join(repoRoot, '.guardex', 'agents', 'terminals');
}

function terminalSessionFilePath(repoRoot, sessions, terminal = DEFAULT_AGENT_TERMINAL) {
const firstSession = sessions[0] || {};
const sessionId = sanitizeFileSegment(firstSession.id || firstSession.branch || 'agents');
return path.join(terminalSessionDir(repoRoot), `${sessionId}-${sessions.length}.${terminal}-session`);
}

function sessionTitle(session, index) {
const branch = String(session.branch || session.id || `agent-${index + 1}`);
const leaf = branch.split('/').filter(Boolean).pop() || branch;
return `${index + 1}: ${session.agent || 'agent'} ${leaf}`;
}

function buildKittySession(sessions) {
const lines = ['# Generated by gx agents start.'];
sessions.forEach((session, index) => {
const title = sessionTitle(session, index);
lines.push(
'',
`new_tab ${shellQuote(title)}`,
`cd ${shellQuote(session.worktreePath)}`,
`launch --title ${shellQuote(title)} sh -lc ${shellQuote(session.launchCommand)}`,
);
});
return `${lines.join('\n')}\n`;
}

function writeKittySessionFile(repoRoot, sessions) {
const filePath = terminalSessionFilePath(repoRoot, sessions, 'kitty');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, buildKittySession(sessions), { encoding: 'utf8', mode: 0o600 });
return filePath;
}

function recoveryLines(sessionFilePath, reason) {
const detail = reason ? `: ${reason}` : '.';
return [
`[${TOOL_NAME}] Kitty terminal not launched${detail}`,
`[${TOOL_NAME}] Kitty session file: ${sessionFilePath}`,
`[${TOOL_NAME}] Recovery: kitty --detach --session ${shellQuote(sessionFilePath)}`,
`[${TOOL_NAME}] Agent lanes are intact; run the recovery command when Kitty is available.`,
'',
].join('\n');
}

function resultReason(result, fallback) {
if (result?.error?.message) return result.error.message;
if (typeof result?.status === 'number') return `${fallback} exited ${result.status}`;
return fallback;
}

function launchAgentTerminal(repoRoot, sessions, options = {}) {
const terminal = normalizeAgentTerminal(options.terminal);
if (terminal === 'none' || !Array.isArray(sessions) || sessions.length === 0) {
return { status: 'skipped', stdout: '', stderr: '', sessionFilePath: '' };
}
if (!SUPPORTED_AGENT_TERMINALS.has(terminal)) {
return {
status: 'unsupported',
stdout: '',
stderr: `[${TOOL_NAME}] Unsupported agent terminal '${terminal}'. Supported terminals: kitty, none.\n`,
sessionFilePath: '',
};
}

const sessionFilePath = writeKittySessionFile(repoRoot, sessions);
const runner = options.runner || run;
if (typeof runner !== 'function') {
return {
status: 'missing',
stdout: '',
stderr: recoveryLines(sessionFilePath, 'terminal runner unavailable'),
sessionFilePath,
};
}
const kittyBin = options.kittyBin || process.env.GUARDEX_KITTY_BIN || 'kitty';
const probe = runner(kittyBin, ['--version'], { cwd: repoRoot, stdio: 'pipe' });
if (probe?.error || probe?.status !== 0) {
return {
status: 'missing',
stdout: '',
stderr: recoveryLines(sessionFilePath, resultReason(probe, `${kittyBin} --version`)),
sessionFilePath,
};
}

const launch = runner(kittyBin, ['--detach', '--session', sessionFilePath], { cwd: repoRoot, stdio: 'ignore' });
if (launch?.error || launch?.status !== 0) {
return {
status: 'failed',
stdout: '',
stderr: recoveryLines(sessionFilePath, resultReason(launch, `${kittyBin} --detach`)),
sessionFilePath,
};
}

return {
status: 'launched',
stdout: `[${TOOL_NAME}] Kitty agent terminal: ${sessionFilePath}\n`,
stderr: '',
sessionFilePath,
};
}

module.exports = {
DEFAULT_AGENT_TERMINAL,
buildKittySession,
launchAgentTerminal,
normalizeAgentTerminal,
recoveryLines,
terminalSessionFilePath,
writeKittySessionFile,
};
16 changes: 14 additions & 2 deletions src/cli/args.js
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ function parseAgentsArgs(rawArgs) {
count: 1,
agentSelectionSpecs: [],
panel: false,
terminal: process.env.GUARDEX_AGENT_TERMINAL || 'kitty',
dryRun: false,
reviewIntervalSeconds: 30,
cleanupIntervalSeconds: 60,
Expand All @@ -287,6 +288,7 @@ function parseAgentsArgs(rawArgs) {
finishArgs: [],
metadata: {},
};
let terminalProvided = false;

for (let index = 0; index < rest.length; index += 1) {
const arg = rest[index];
Expand Down Expand Up @@ -451,6 +453,16 @@ function parseAgentsArgs(rawArgs) {
options.panel = true;
continue;
}
if (arg === '--terminal') {
const next = rest[index + 1];
if (!next || next.startsWith('-')) {
throw new Error('--terminal requires kitty or none');
}
options.terminal = next;
terminalProvided = true;
index += 1;
continue;
}
if (arg === '--base') {
const next = rest[index + 1];
if (!next || next.startsWith('-')) {
Expand Down Expand Up @@ -501,10 +513,10 @@ function parseAgentsArgs(rawArgs) {
throw new Error('--pid is only supported with `gx agents stop`');
}
if (
(options.task || options.agent || options.base || options.claims.length > 0 || Object.keys(options.metadata).length > 0) &&
(options.task || options.agent || options.base || options.claims.length > 0 || Object.keys(options.metadata).length > 0 || terminalProvided) &&
options.subcommand !== 'start'
) {
throw new Error('--task, --agent, --agents, --count, --base, --claim, --meta, and --panel are only supported with `gx agents start`');
throw new Error('--task, --agent, --agents, --count, --base, --claim, --meta, --terminal, and --panel are only supported with `gx agents start`');
}
if (
(options.agentSelectionSpecs.length > 0 || options.count !== 1 || options.panel) &&
Expand Down
5 changes: 3 additions & 2 deletions test/agents-selection-panel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ test('renderAgentSelectionPanel shows a dmux-style GitGuardex shell', () => {
assert.match(output, /gitguardex/);
assert.match(output, /\[n\] launch/);
assert.match(output, /\[t\] terminal/);
assert.match(output, /multi-agent terminals: Kitty/);
assert.match(output, /Alt\+Shift\+M/);
assert.match(output, /Files/);
assert.match(output, /Selected: 3\/10/);
Expand Down Expand Up @@ -124,10 +125,10 @@ test('interactive panel keys move focus, toggle agents, and adjust codex account
assert.equal(countForAgent(selectionsFromPanelState(state), 'codex'), 2);
const terminalHelp = applyAgentSelectionKey(state, 't');
assert.equal(terminalHelp.action, 'render');
assert.match(terminalHelp.state.message, /Terminal panes are managed in gx cockpit/);
assert.match(terminalHelp.state.message, /Kitty agent terminals open after multi-agent launch/);
const paneMenuHelp = applyAgentSelectionKey(state, '\u001bM');
assert.equal(paneMenuHelp.action, 'render');
assert.match(paneMenuHelp.state.message, /Pane menu is available in gx cockpit/);
assert.match(paneMenuHelp.state.message, /selected pane/);
assert.equal(applyAgentSelectionKey(state, 'n').action, 'launch');
assert.equal(applyAgentSelectionKey(state, '\r').action, 'launch');
assert.equal(applyAgentSelectionKey(state, '\u001b').action, 'cancel');
Expand Down
Loading
Loading