Skip to content

Commit 6086efc

Browse files
NagyViktNagyVikt
andauthored
Open launched agent lanes where operators can use them (#497)
Multi-agent start already creates isolated agent branches and worktrees, but the launcher panel still sent operators back through cockpit/tmux wording. This adds a Kitty session launcher after every requested lane is created and recorded, so terminal startup remains outside the safety boundary. Constraint: Branch/worktree creation and lock claiming must remain unchanged before terminal launch Rejected: Make cockpit/tmux the default launch surface | the requested behavior is one Kitty window for the created agent lanes Confidence: high Scope-risk: moderate Directive: Do not move terminal launch before lane/session creation; terminal failure must never invalidate created lanes Tested: 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) Tested: openspec validate agent-codex-kitty-default-agent-terminal-2026-04-30-13-14 --strict Tested: openspec validate --specs (no spec items found) Not-tested: live GUI launch in a real Kitty desktop session Not-tested: npm test remains red with 9 baseline failures outside this touched scope Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent 29d0477 commit 6086efc

10 files changed

Lines changed: 374 additions & 14 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
## Why
2+
3+
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.
4+
5+
## What Changes
6+
7+
- Add a Kitty-backed terminal launcher for multi-agent starts.
8+
- Add `--terminal kitty|none` with `GUARDEX_AGENT_TERMINAL` defaulting to `kitty`.
9+
- Keep branch/worktree creation, lock claiming, and PR finish flow unchanged.
10+
- Update launcher panel terminal copy to Kitty-first language.
11+
12+
## Impact
13+
14+
- Multi-agent starts can open one Kitty session after all lanes are created.
15+
- Missing Kitty reports a recovery command and session file path instead of failing lane creation.
16+
- `--terminal none` keeps the old no-terminal behavior.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Kitty external terminal launcher
4+
5+
`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.
6+
7+
#### Scenario: Multi-agent start launches Kitty after lanes exist
8+
9+
- **WHEN** an operator starts more than one agent lane with `gx agents start "fix auth tests" --panel --codex-accounts 3 --base main`
10+
- **THEN** Guardex SHALL create each `agent/*` lane before terminal launch
11+
- **AND** SHALL write a Kitty session file containing each lane worktree and launch command
12+
- **AND** SHALL launch one Kitty window from that session file.
13+
14+
#### Scenario: Terminal launch disabled
15+
16+
- **WHEN** an operator passes `--terminal none`
17+
- **THEN** Guardex SHALL create the requested lanes
18+
- **AND** SHALL skip external terminal launch.
19+
20+
#### Scenario: Kitty unavailable
21+
22+
- **WHEN** Kitty is not available on PATH
23+
- **THEN** Guardex SHALL keep created lanes and session metadata intact
24+
- **AND** SHALL print the Kitty session file path and recovery command.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
## 1. Spec
2+
3+
- [x] Record Kitty-first multi-agent terminal launcher scope.
4+
5+
## 2. Tests
6+
7+
- [x] Cover default `kitty` parsing.
8+
- [x] Cover `--terminal none` skipping terminal launch.
9+
- [x] Cover missing Kitty recovery output.
10+
- [x] Cover launcher panel copy update.
11+
12+
## 3. Implementation
13+
14+
- [x] Add `src/agents/terminal.js`.
15+
- [x] Wire `gx agents start` multi-lane success into Kitty launch after lane creation.
16+
- [x] Add parser support for `--terminal`.
17+
- [x] Update panel text from tmux-focused wording to Kitty-first wording.
18+
19+
## 4. Verification
20+
21+
- [x] Run focused Node tests for parser, launcher, panel.
22+
- [x] Run `openspec validate --specs`.
23+
24+
Evidence:
25+
26+
- `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.
27+
- `openspec validate agent-codex-kitty-default-agent-terminal-2026-04-30-13-14 --strict` -> valid.
28+
- `openspec validate --specs` -> no spec items found.
29+
- `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`).
30+
31+
## 5. Cleanup
32+
33+
- [x] Commit changes.
34+
- [ ] Finish via PR, wait for merge, cleanup, and record `MERGED` evidence.

src/agents/selection-panel.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ const DEFAULT_PANEL_HEIGHT = 30;
1111
const SIDEBAR_WIDTH = 36;
1212
const PANEL_ACTIONS = [
1313
['n', 'New agent', 'create an agent pane in this repo'],
14-
['t', 'Terminal', 'open a shell pane from gx cockpit'],
14+
['t', 'Terminal', 'open Kitty agent terminal'],
1515
['p', 'Project', 'create pane in another project'],
16-
['Alt+Shift+M', 'Pane menu', 'act on the focused tmux pane'],
16+
['Alt+Shift+M', 'Pane menu', 'act on the selected pane'],
1717
['j/k', 'Jump', 'move between panes in the list'],
1818
['m', 'Menu', 'open pane context actions'],
1919
['x', 'Close', 'close selected pane'],
@@ -27,10 +27,10 @@ const PANEL_ACTIONS = [
2727

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

src/agents/start.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const {
2121
listAgentSessions,
2222
updateAgentSession,
2323
} = require('./sessions');
24+
const { launchAgentTerminal } = require('./terminal');
2425

2526
function sanitizeSlug(value, fallback = 'task') {
2627
const slug = String(value || '')
@@ -473,7 +474,7 @@ function startSingleAgentLane(repoRoot, options, deps = {}) {
473474
const session = writeAgentSession(repoRoot, options, metadata, 'active');
474475
stdout = appendSessionId(stdout, session);
475476
if (options.claims.length === 0) {
476-
return { status: 0, stdout, stderr };
477+
return { status: 0, stdout, stderr, session };
477478
}
478479

479480
if (!metadata.branch || !metadata.worktreePath) {
@@ -492,7 +493,7 @@ function startSingleAgentLane(repoRoot, options, deps = {}) {
492493
stdout += String(claimResult.stdout || '');
493494
stderr += String(claimResult.stderr || '');
494495
if (!isSpawnFailure(claimResult) && claimResult.status === 0) {
495-
return { status: 0, stdout, stderr };
496+
return { status: 0, stdout, stderr, session };
496497
}
497498

498499
if (isSpawnFailure(claimResult)) {
@@ -520,6 +521,7 @@ function startAgentLane(repoRoot, options, deps = {}) {
520521
selections: normalizeAgentSelections(options),
521522
});
522523
let stderr = '';
524+
const sessions = [];
523525

524526
for (const launchOption of launchOptions) {
525527
const result = startSingleAgentLane(repoRoot, launchOption, deps);
@@ -532,12 +534,24 @@ function startAgentLane(repoRoot, options, deps = {}) {
532534
stderr,
533535
};
534536
}
537+
if (result.session) {
538+
sessions.push(result.session);
539+
}
535540
}
536541

542+
const terminalResult = launchAgentTerminal(repoRoot, sessions, {
543+
terminal: options.terminal,
544+
runner: deps.terminalRunner,
545+
kittyBin: deps.kittyBin,
546+
});
547+
stdout += String(terminalResult.stdout || '');
548+
stderr += String(terminalResult.stderr || '');
549+
537550
return {
538551
status: 0,
539552
stdout,
540553
stderr,
554+
terminal: terminalResult,
541555
};
542556
}
543557

src/agents/terminal.js

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
'use strict';
2+
3+
const fs = require('node:fs');
4+
const path = require('node:path');
5+
6+
const { TOOL_NAME } = require('../context');
7+
const { run } = require('../core/runtime');
8+
const { shellQuote } = require('./launch');
9+
10+
const DEFAULT_AGENT_TERMINAL = 'kitty';
11+
const SUPPORTED_AGENT_TERMINALS = new Set(['kitty', 'none']);
12+
13+
function normalizeAgentTerminal(value) {
14+
const terminal = String(value || DEFAULT_AGENT_TERMINAL).trim().toLowerCase();
15+
return terminal || DEFAULT_AGENT_TERMINAL;
16+
}
17+
18+
function sanitizeFileSegment(value) {
19+
return String(value || 'agents')
20+
.replace(/[^a-zA-Z0-9._-]+/g, '__')
21+
.replace(/^_+|_+$/g, '')
22+
.slice(0, 120) || 'agents';
23+
}
24+
25+
function terminalSessionDir(repoRoot) {
26+
return path.join(repoRoot, '.guardex', 'agents', 'terminals');
27+
}
28+
29+
function terminalSessionFilePath(repoRoot, sessions, terminal = DEFAULT_AGENT_TERMINAL) {
30+
const firstSession = sessions[0] || {};
31+
const sessionId = sanitizeFileSegment(firstSession.id || firstSession.branch || 'agents');
32+
return path.join(terminalSessionDir(repoRoot), `${sessionId}-${sessions.length}.${terminal}-session`);
33+
}
34+
35+
function sessionTitle(session, index) {
36+
const branch = String(session.branch || session.id || `agent-${index + 1}`);
37+
const leaf = branch.split('/').filter(Boolean).pop() || branch;
38+
return `${index + 1}: ${session.agent || 'agent'} ${leaf}`;
39+
}
40+
41+
function buildKittySession(sessions) {
42+
const lines = ['# Generated by gx agents start.'];
43+
sessions.forEach((session, index) => {
44+
const title = sessionTitle(session, index);
45+
lines.push(
46+
'',
47+
`new_tab ${shellQuote(title)}`,
48+
`cd ${shellQuote(session.worktreePath)}`,
49+
`launch --title ${shellQuote(title)} sh -lc ${shellQuote(session.launchCommand)}`,
50+
);
51+
});
52+
return `${lines.join('\n')}\n`;
53+
}
54+
55+
function writeKittySessionFile(repoRoot, sessions) {
56+
const filePath = terminalSessionFilePath(repoRoot, sessions, 'kitty');
57+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
58+
fs.writeFileSync(filePath, buildKittySession(sessions), { encoding: 'utf8', mode: 0o600 });
59+
return filePath;
60+
}
61+
62+
function recoveryLines(sessionFilePath, reason) {
63+
const detail = reason ? `: ${reason}` : '.';
64+
return [
65+
`[${TOOL_NAME}] Kitty terminal not launched${detail}`,
66+
`[${TOOL_NAME}] Kitty session file: ${sessionFilePath}`,
67+
`[${TOOL_NAME}] Recovery: kitty --detach --session ${shellQuote(sessionFilePath)}`,
68+
`[${TOOL_NAME}] Agent lanes are intact; run the recovery command when Kitty is available.`,
69+
'',
70+
].join('\n');
71+
}
72+
73+
function resultReason(result, fallback) {
74+
if (result?.error?.message) return result.error.message;
75+
if (typeof result?.status === 'number') return `${fallback} exited ${result.status}`;
76+
return fallback;
77+
}
78+
79+
function launchAgentTerminal(repoRoot, sessions, options = {}) {
80+
const terminal = normalizeAgentTerminal(options.terminal);
81+
if (terminal === 'none' || !Array.isArray(sessions) || sessions.length === 0) {
82+
return { status: 'skipped', stdout: '', stderr: '', sessionFilePath: '' };
83+
}
84+
if (!SUPPORTED_AGENT_TERMINALS.has(terminal)) {
85+
return {
86+
status: 'unsupported',
87+
stdout: '',
88+
stderr: `[${TOOL_NAME}] Unsupported agent terminal '${terminal}'. Supported terminals: kitty, none.\n`,
89+
sessionFilePath: '',
90+
};
91+
}
92+
93+
const sessionFilePath = writeKittySessionFile(repoRoot, sessions);
94+
const runner = options.runner || run;
95+
if (typeof runner !== 'function') {
96+
return {
97+
status: 'missing',
98+
stdout: '',
99+
stderr: recoveryLines(sessionFilePath, 'terminal runner unavailable'),
100+
sessionFilePath,
101+
};
102+
}
103+
const kittyBin = options.kittyBin || process.env.GUARDEX_KITTY_BIN || 'kitty';
104+
const probe = runner(kittyBin, ['--version'], { cwd: repoRoot, stdio: 'pipe' });
105+
if (probe?.error || probe?.status !== 0) {
106+
return {
107+
status: 'missing',
108+
stdout: '',
109+
stderr: recoveryLines(sessionFilePath, resultReason(probe, `${kittyBin} --version`)),
110+
sessionFilePath,
111+
};
112+
}
113+
114+
const launch = runner(kittyBin, ['--detach', '--session', sessionFilePath], { cwd: repoRoot, stdio: 'ignore' });
115+
if (launch?.error || launch?.status !== 0) {
116+
return {
117+
status: 'failed',
118+
stdout: '',
119+
stderr: recoveryLines(sessionFilePath, resultReason(launch, `${kittyBin} --detach`)),
120+
sessionFilePath,
121+
};
122+
}
123+
124+
return {
125+
status: 'launched',
126+
stdout: `[${TOOL_NAME}] Kitty agent terminal: ${sessionFilePath}\n`,
127+
stderr: '',
128+
sessionFilePath,
129+
};
130+
}
131+
132+
module.exports = {
133+
DEFAULT_AGENT_TERMINAL,
134+
buildKittySession,
135+
launchAgentTerminal,
136+
normalizeAgentTerminal,
137+
recoveryLines,
138+
terminalSessionFilePath,
139+
writeKittySessionFile,
140+
};

src/cli/args.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ function parseAgentsArgs(rawArgs) {
275275
count: 1,
276276
agentSelectionSpecs: [],
277277
panel: false,
278+
terminal: process.env.GUARDEX_AGENT_TERMINAL || 'kitty',
278279
dryRun: false,
279280
reviewIntervalSeconds: 30,
280281
cleanupIntervalSeconds: 60,
@@ -287,6 +288,7 @@ function parseAgentsArgs(rawArgs) {
287288
finishArgs: [],
288289
metadata: {},
289290
};
291+
let terminalProvided = false;
290292

291293
for (let index = 0; index < rest.length; index += 1) {
292294
const arg = rest[index];
@@ -451,6 +453,16 @@ function parseAgentsArgs(rawArgs) {
451453
options.panel = true;
452454
continue;
453455
}
456+
if (arg === '--terminal') {
457+
const next = rest[index + 1];
458+
if (!next || next.startsWith('-')) {
459+
throw new Error('--terminal requires kitty or none');
460+
}
461+
options.terminal = next;
462+
terminalProvided = true;
463+
index += 1;
464+
continue;
465+
}
454466
if (arg === '--base') {
455467
const next = rest[index + 1];
456468
if (!next || next.startsWith('-')) {
@@ -501,10 +513,10 @@ function parseAgentsArgs(rawArgs) {
501513
throw new Error('--pid is only supported with `gx agents stop`');
502514
}
503515
if (
504-
(options.task || options.agent || options.base || options.claims.length > 0 || Object.keys(options.metadata).length > 0) &&
516+
(options.task || options.agent || options.base || options.claims.length > 0 || Object.keys(options.metadata).length > 0 || terminalProvided) &&
505517
options.subcommand !== 'start'
506518
) {
507-
throw new Error('--task, --agent, --agents, --count, --base, --claim, --meta, and --panel are only supported with `gx agents start`');
519+
throw new Error('--task, --agent, --agents, --count, --base, --claim, --meta, --terminal, and --panel are only supported with `gx agents start`');
508520
}
509521
if (
510522
(options.agentSelectionSpecs.length > 0 || options.count !== 1 || options.panel) &&

test/agents-selection-panel.test.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ test('renderAgentSelectionPanel shows a dmux-style GitGuardex shell', () => {
4949
assert.match(output, /gitguardex/);
5050
assert.match(output, /\[n\] launch/);
5151
assert.match(output, /\[t\] terminal/);
52+
assert.match(output, /multi-agent terminals: Kitty/);
5253
assert.match(output, /Alt\+Shift\+M/);
5354
assert.match(output, /Files/);
5455
assert.match(output, /Selected: 3\/10/);
@@ -124,10 +125,10 @@ test('interactive panel keys move focus, toggle agents, and adjust codex account
124125
assert.equal(countForAgent(selectionsFromPanelState(state), 'codex'), 2);
125126
const terminalHelp = applyAgentSelectionKey(state, 't');
126127
assert.equal(terminalHelp.action, 'render');
127-
assert.match(terminalHelp.state.message, /Terminal panes are managed in gx cockpit/);
128+
assert.match(terminalHelp.state.message, /Kitty agent terminals open after multi-agent launch/);
128129
const paneMenuHelp = applyAgentSelectionKey(state, '\u001bM');
129130
assert.equal(paneMenuHelp.action, 'render');
130-
assert.match(paneMenuHelp.state.message, /Pane menu is available in gx cockpit/);
131+
assert.match(paneMenuHelp.state.message, /selected pane/);
131132
assert.equal(applyAgentSelectionKey(state, 'n').action, 'launch');
132133
assert.equal(applyAgentSelectionKey(state, '\r').action, 'launch');
133134
assert.equal(applyAgentSelectionKey(state, '\u001b').action, 'cancel');

0 commit comments

Comments
 (0)