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,15 @@
# Kitty gx window terminal follow-up

## Why

The dmux-style `gx agents start --panel` shell can launch a single selected agent, but single-panel launches still leave the operator in the original shell without opening the new agent lane in Kitty. The visual flow should keep the same GitGuardex panel style while using Kitty as the terminal surface for launched lanes.

## What Changes

- Open a generated Kitty session after a successful single-lane panel launch.
- Preserve direct non-panel single starts so automation does not unexpectedly open a terminal.
- Keep `--terminal none` behavior routed through the existing Kitty launcher skip path.

## Impact

The change is limited to panel-driven agent starts and focused tests. It does not change branch creation, locks, session metadata, multi-agent behavior, or finish cleanup.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
## ADDED Requirements

### Requirement: Panel launch uses Kitty terminal surface

`gx agents start --panel` SHALL keep the GitGuardex launcher shell behavior and open launched agent lanes in Kitty when terminal launch is enabled.

#### Scenario: Single panel launch opens Kitty

- **WHEN** an operator launches one selected agent from `gx agents start --panel`
- **THEN** Guardex SHALL create the `agent/*` lane and session metadata first
- **AND** SHALL write a Kitty session file for the created lane
- **AND** SHALL launch Kitty from that session file.

#### Scenario: Non-panel single launch remains non-terminal

- **WHEN** an operator runs a direct single-agent `gx agents start "fix auth"` without `--panel`
- **THEN** Guardex SHALL keep the existing branch/worktree/session behavior
- **AND** SHALL NOT open Kitty automatically.
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
## 1. Spec

- [x] Capture panel-launched Kitty terminal behavior.

## 2. Tests

- [x] Cover single-agent panel launch opening Kitty.
- [x] Cover direct single-agent start staying non-terminal.

## 3. Implementation

- [x] Route successful single-lane panel starts through the existing Kitty session launcher.
- [x] Preserve existing multi-agent Kitty behavior and non-panel single-agent behavior.

## 4. Verification

- [x] Run focused Node tests for start/panel terminal behavior.
- Evidence: `node --test test/agents-start-kitty-panel.test.js test/agents-start.test.js test/agents-start-dry-run.test.js test/agents-selection-panel.test.js` passed 21/21.
- [x] Run OpenSpec validation.
- Evidence: `openspec validate agent-codex-kitty-gx-window-terminal-2026-04-30-13-36 --strict` passed.
- Evidence: `openspec validate --specs` passed with no spec items found.

## 5. Cleanup

- [ ] Commit changes.
- [ ] Finish via PR, wait for merge, cleanup, and record `MERGED` evidence.
18 changes: 17 additions & 1 deletion src/agents/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,23 @@ function startSingleAgentLane(repoRoot, options, deps = {}) {
function startAgentLane(repoRoot, options, deps = {}) {
const launchOptions = buildLaunchOptions(options);
if (launchOptions.length === 1) {
return startSingleAgentLane(repoRoot, launchOptions[0], deps);
const result = startSingleAgentLane(repoRoot, launchOptions[0], deps);
if (result.status !== 0 || !result.session || !options.panel) {
return result;
}

const terminalResult = launchAgentTerminal(repoRoot, [result.session], {
terminal: options.terminal,
runner: deps.terminalRunner,
kittyBin: deps.kittyBin,
});

return {
...result,
stdout: `${String(result.stdout || '')}${String(terminalResult.stdout || '')}`,
stderr: `${String(result.stderr || '')}${String(terminalResult.stderr || '')}`,
terminal: terminalResult,
};
}

let stdout = renderAgentSelectionPanel({
Expand Down
131 changes: 131 additions & 0 deletions test/agents-start-kitty-panel.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Module = require('node:module');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');

function loadStartWithMocks({
runPackageAsset,
createAgentSession,
updateAgentSession,
currentBranchName,
listAgentSessions = () => [],
}) {
const startPath = require.resolve('../src/agents/start');
const runtimePath = require.resolve('../src/core/runtime');
const sessionsPath = require.resolve('../src/agents/sessions');
const terminalPath = require.resolve('../src/agents/terminal');
const gitPath = require.resolve('../src/git');
const originalLoad = Module._load;

delete require.cache[startPath];
delete require.cache[terminalPath];
Module._load = function mockLoad(request, parent, isMain) {
const resolved = Module._resolveFilename(request, parent, isMain);
if (resolved === runtimePath) {
return { runPackageAsset };
}
if (resolved === sessionsPath) {
return { createAgentSession, updateAgentSession, listAgentSessions };
}
if (resolved === gitPath) {
return { currentBranchName };
}
return originalLoad.apply(this, arguments);
};

try {
return require(startPath);
} finally {
Module._load = originalLoad;
delete require.cache[startPath];
delete require.cache[terminalPath];
}
}

function branchStartOutput(branch, worktreePath) {
return [
`[agent-branch-start] Created branch: ${branch}`,
`[agent-branch-start] Worktree: ${worktreePath}`,
'',
].join('\n');
}

test('panel-launched single agent opens a Kitty terminal session', () => {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-kitty-panel-'));
const worktreePath = path.join(repoRoot, '.omx/agent-worktrees/repo__codex__fix-auth');
const branch = 'agent/codex/fix-auth';
const runCalls = [];
const terminalCalls = [];
const start = loadStartWithMocks({
runPackageAsset(assetKey, args, options) {
runCalls.push({ assetKey, args, options });
return { status: 0, stdout: branchStartOutput(branch, worktreePath), stderr: '' };
},
createAgentSession(repoRootArg, payload) {
return payload;
},
updateAgentSession() {
throw new Error('unexpected update');
},
currentBranchName: () => 'main',
});

const result = start.startAgentLane(repoRoot, {
task: 'fix auth',
agent: 'codex',
base: 'main',
claims: [],
panel: true,
}, {
terminalRunner(cmd, args, options) {
terminalCalls.push({ cmd, args, options });
return { status: 0, stdout: args[0] === '--version' ? 'kitty 0.36\n' : '', stderr: '' };
},
});

assert.equal(result.status, 0);
assert.equal(runCalls.length, 1);
assert.match(result.stdout, /Agent session id: agent__codex__fix-auth/);
assert.match(result.stdout, /Kitty agent terminal:/);
assert.deepEqual(terminalCalls.map((call) => call.args[0]), ['--version', '--detach']);
const sessionFile = terminalCalls[1].args[2];
assert.match(sessionFile, /\.guardex\/agents\/terminals\/agent__codex__fix-auth-1\.kitty-session$/);
const sessionBody = fs.readFileSync(sessionFile, 'utf8');
assert.match(sessionBody, /new_tab '1: codex fix-auth'/);
assert.match(sessionBody, /cd '.*repo__codex__fix-auth'/);
assert.match(sessionBody, /launch --title '1: codex fix-auth' sh -lc 'cd/);
});

test('non-panel single agent start keeps terminal launch opt-in unchanged', () => {
const terminalCalls = [];
const start = loadStartWithMocks({
runPackageAsset() {
return { status: 0, stdout: branchStartOutput('agent/codex/fix-auth', '/repo/.omx/agent-worktrees/repo__codex__fix-auth'), stderr: '' };
},
createAgentSession(repoRootArg, payload) {
return payload;
},
updateAgentSession() {
throw new Error('unexpected update');
},
currentBranchName: () => 'main',
});

const result = start.startAgentLane('/repo', {
task: 'fix auth',
agent: 'codex',
base: 'main',
claims: [],
}, {
terminalRunner(cmd, args, options) {
terminalCalls.push({ cmd, args, options });
return { status: 0, stdout: '', stderr: '' };
},
});

assert.equal(result.status, 0);
assert.equal(terminalCalls.length, 0);
assert.doesNotMatch(result.stdout, /Kitty agent terminal:/);
});
Loading