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 @@
## Why

- Kitty cockpit needs a deterministic command plan before wiring real pane execution.
- The control/welcome area must stay visible while agent terminals launch on the other side.

## What Changes

- Add a pure `createKittyCockpitPlan` module for Kitty cockpit layout planning.
- Emit ordered command steps for control launch, agent-area launch, each agent terminal, and final control focus.
- Preserve branch, worktree, and lock creation in existing GitGuardEx flows.

## Impact

- Adds planner-only Kitty cockpit behavior under `src/cockpit`.
- Does not execute Kitty commands, create worktrees, claim locks, or change tmux behavior.
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
## ADDED Requirements

### Requirement: Kitty cockpit layout command plans
GitGuardEx SHALL provide a pure Kitty cockpit layout planner that returns a deterministic command plan for a persistent control/welcome area and right-side agent terminals.

#### Scenario: One agent layout
- **GIVEN** a repo root, session name, control command, welcome command, and one agent with a worktree
- **WHEN** the Kitty cockpit layout planner is called
- **THEN** the plan includes one control launch command
- **AND** the plan includes one agent terminal launch command rooted at that agent worktree.

#### Scenario: Many agent layout
- **GIVEN** multiple agents in a fixed input order
- **WHEN** the Kitty cockpit layout planner is called more than once with the same input
- **THEN** the plan uses stable ordered titles for the agents
- **AND** both planner calls return identical output.

#### Scenario: Safety ownership remains external
- **GIVEN** agents that already have cwd or worktree paths
- **WHEN** the Kitty cockpit layout planner builds commands
- **THEN** the plan preserves those cwd values
- **AND** the planner does not create branches, worktrees, locks, or other GitGuardEx ownership state.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## Definition of Done

This change is complete only when all of the following are true:

- Every 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, append a `BLOCKED:` line under section 4 explaining the blocker and stop.

## 1. Specification

- [x] 1.1 Capture Kitty cockpit planner behavior.
- [x] 1.2 Define normative requirements in `specs/cockpit-kitty-layout/spec.md`.

## 2. Implementation

- [x] 2.1 Add a pure Kitty cockpit layout planner.
- [x] 2.2 Emit deterministic command steps for control, agent area, agent terminals, and focus.
- [x] 2.3 Keep worktree and lock creation outside the planner.
- [x] 2.4 Add focused regression coverage.

## 3. Verification

- [x] 3.1 Run focused Node tests for Kitty cockpit layout and tmux cockpit compatibility.
- Evidence: `node --test test/cockpit-kitty-layout.test.js test/cockpit-layout.test.js test/tmux-session.test.js test/cockpit-terminal-backend.test.js` passed 20/20.
- [x] 3.2 Run `openspec validate agent-codex-kitty-cockpit-layout-planning-2026-04-30-14-12 --type change --strict`.
- Evidence: command passed.
- [x] 3.3 Run `openspec validate --specs`.
- Evidence: command passed with `No items found to validate.`

## 4. Cleanup

- [ ] 4.1 Run `gx branch finish --branch agent/codex/kitty-cockpit-layout-planning-2026-04-30-14-12 --base main --via-pr --wait-for-merge --cleanup`.
- [ ] 4.2 Record PR URL and final merge state.
- [ ] 4.3 Confirm sandbox worktree cleanup.
227 changes: 227 additions & 0 deletions src/cockpit/kitty-layout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
'use strict';

const DEFAULT_SESSION_NAME = 'guardex';
const DEFAULT_COLUMNS = 120;
const DEFAULT_KITTY_BIN = 'kitty';
const DEFAULT_WELCOME_COMMAND = 'gx';

function text(value, fallback = '') {
if (typeof value === 'string') return value.trim() || fallback;
if (value === null || value === undefined) return fallback;
return String(value).trim() || fallback;
}

function requireText(value, name) {
const normalized = text(value);
if (!normalized) {
throw new TypeError(`${name} must be a non-empty string`);
}
return normalized;
}

function firstText(...values) {
for (const value of values) {
const normalized = text(value);
if (normalized) return normalized;
}
return '';
}

function positiveInteger(value, fallback) {
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}

function shellQuote(value) {
return `'${String(value).replace(/'/g, "'\\''")}'`;
}

function commandShape(args, kittyBin = DEFAULT_KITTY_BIN) {
return {
cmd: text(kittyBin, DEFAULT_KITTY_BIN),
args,
};
}

function appendShellCommand(args, command) {
const normalized = text(command);
if (normalized) {
args.push('--', 'sh', '-lc', normalized);
}
return args;
}

function launchCommand(window, kittyBin) {
const args = [
'@',
'launch',
'--type=window',
];
if (window.location) {
args.push(`--location=${window.location}`);
}
args.push(
'--cwd',
window.cwd,
'--title',
window.title,
);
appendShellCommand(args, window.command);
return commandShape(args, kittyBin);
}

function focusCommand(window, kittyBin) {
return commandShape(['@', 'focus-window', '--match', window.match], kittyBin);
}

function matchTitle(title) {
return `title:${title}`;
}

function agentId(agent, index) {
return firstText(
agent.id,
agent.sessionId,
agent.agentId,
agent.branch,
`agent-${index + 1}`,
);
}

function agentLabel(agent, index) {
const explicitTitle = text(agent.title);
if (explicitTitle) return explicitTitle;
const id = agentId(agent, index);
const label = firstText(
agent.label,
agent.agentName,
agent.agent,
agent.name,
);
if (label && id && label !== id) return `${label} ${id}`;
return firstText(
label,
id,
`agent-${index + 1}`,
);
}

function agentTitle(agent, index) {
return `${String(index + 1).padStart(2, '0')}: ${agentLabel(agent, index)}`;
}

function normalizeAgent(agent, index, repoRoot, total) {
const source = agent && typeof agent === 'object' ? agent : {};
const cwd = requireText(
firstText(source.cwd, source.worktree, source.worktreePath, source.path, repoRoot),
`agents[${index}].cwd`,
);
const title = agentTitle(source, index);
return {
id: agentId(source, index),
index,
total,
title,
cwd,
worktree: firstText(source.worktree, source.worktreePath, source.path, source.cwd),
command: firstText(source.command, source.launchCommand, source.shellCommand, 'exec ${SHELL:-bash}'),
branch: text(source.branch),
match: matchTitle(title),
};
}

function createKittyCockpitPlan(options = {}) {
const repoRoot = requireText(options.repoRoot, 'repoRoot');
const sessionName = text(options.sessionName, DEFAULT_SESSION_NAME);
const agents = Array.isArray(options.agents) ? options.agents : [];
const columns = positiveInteger(options.columns, DEFAULT_COLUMNS);
const kittyBin = text(options.kittyBin, DEFAULT_KITTY_BIN);
const controlCommand = text(
options.controlCommand,
`gx cockpit control --target ${shellQuote(repoRoot)}`,
);
const welcomeCommand = text(options.welcomeCommand, DEFAULT_WELCOME_COMMAND);

const controlTitle = `${sessionName}: control`;
const agentAreaTitle = `${sessionName}: agents`;
const controlWindow = {
id: 'control',
role: 'control',
title: controlTitle,
cwd: repoRoot,
command: controlCommand,
match: matchTitle(controlTitle),
persistent: true,
};
const agentAreaWindow = {
id: 'agent-area',
role: 'agent-area',
title: agentAreaTitle,
cwd: repoRoot,
command: welcomeCommand,
match: matchTitle(agentAreaTitle),
location: 'vsplit',
};
const agentWindows = agents.map((agent, index) => ({
...normalizeAgent(agent, index, repoRoot, agents.length),
role: 'agent',
location: 'vsplit',
}));

const steps = [
{
id: 'launch-control',
role: 'control',
action: 'launch',
window: controlWindow,
command: launchCommand(controlWindow, kittyBin),
},
{
id: 'launch-agent-area',
role: 'agent-area',
action: 'launch',
window: agentAreaWindow,
command: launchCommand(agentAreaWindow, kittyBin),
},
...agentWindows.map((window) => ({
id: `launch-agent-${window.index + 1}`,
role: 'agent',
action: 'launch',
agentId: window.id,
window,
command: launchCommand(window, kittyBin),
})),
];

if (options.focusControl !== false) {
steps.push({
id: 'focus-control',
role: 'control',
action: 'focus',
window: controlWindow,
command: focusCommand(controlWindow, kittyBin),
});
}

return {
schemaVersion: 1,
backend: 'kitty',
dryRun: Boolean(options.dryRun),
sessionName,
repoRoot,
columns,
layout: {
control: controlWindow,
agentArea: agentAreaWindow,
agents: agentWindows,
},
steps,
commands: steps.map((step) => step.command),
};
}

module.exports = {
DEFAULT_COLUMNS,
DEFAULT_SESSION_NAME,
createKittyCockpitPlan,
};
Loading
Loading