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
44 changes: 38 additions & 6 deletions docs/agents-cockpit.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,44 @@ gx cockpit --backend kitty
gx cockpit --backend tmux
```

`gx cockpit` supports `--backend auto|kitty|tmux`. The default remains
tmux unless `GUARDEX_COCKPIT_BACKEND` is set. `auto` uses Kitty when
Kitty remote control answers and otherwise falls back to tmux. Kitty
mode requires Kitty remote control. tmux remains supported, and the
backend choice does not change the safety model: branches, worktrees,
locks, PR-only finish, and cleanup rules stay the same.
`gx cockpit` supports `--backend auto|kitty|tmux`. `auto` is the
default; it uses Kitty when Kitty remote control answers and otherwise
falls back to tmux. `GUARDEX_COCKPIT_BACKEND` overrides the default.
The backend choice does not change the safety model: branches,
worktrees, locks, PR-only finish, and cleanup rules stay the same.

### Kitty host bootstrap (`--host`)

Kitty mode normally assumes the cockpit is launched from inside a Kitty
window with `allow_remote_control yes` already set in `kitty.conf`. If
you want `gx cockpit` to spawn its own Kitty host instead — the
"dmux-style" experience where one command opens a fresh Kitty window
and tiles agent lanes inside it — pass `--host`:

```bash
gx cockpit --host
gx cockpit --host --socket /tmp/gx-cockpit.sock
gx cockpit --host --session guardex-dev
```

`--host` (alias `--bootstrap-kitty`) does the following:

1. Spawns `kitty -o allow_remote_control=yes -o listen_on=unix:<sock>`
detached, with the repo root as `--directory`.
2. Waits for the listen socket to appear, then prepends
`--to=unix:<sock>` to every subsequent `kitty @ launch`,
`@ focus-window`, `@ send-text`, etc.
3. Falls back through the normal Kitty layout plan: control pane,
one pane per active `agent/*` lane, optional details pane.

Pass `--socket <path>` to pin a stable socket path (useful if other
tools — or `gx agents start` in a follow-up shell — should target the
same Kitty host). Pass `--no-host` to force the legacy "must already be
inside Kitty" mode.

`--host` requires the `kitty` binary on `PATH` (or `GUARDEX_KITTY_BIN`
set). It does not require `allow_remote_control` to be enabled in
`kitty.conf`, because the spawned host is configured inline via `-o`.

## Start agent lanes

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Kitty cockpit host bootstrap

## Why

`gx cockpit --backend kitty` today only works when the user is already
inside a Kitty window with `allow_remote_control` enabled in
`kitty.conf`. There is no path for `gx cockpit` to spawn its own Kitty
host and tile agent lanes inside it (the dmux-style experience users
expect).

## What changes

- Add `--host` (alias `--bootstrap-kitty`) to `gx cockpit` that spawns a
detached `kitty` with `allow_remote_control=yes` and a private
`listen_on=unix:<sock>`.
- Wait for the listen socket to appear, then prepend `--to=unix:<sock>`
to every `kitty @ launch | focus-window | send-text` issued by the
cockpit plan so all subsequent panes target the spawned host.
- Add `--socket <path>` to pin a stable listen socket path. Add
`--no-host` to force the legacy "must already be inside Kitty" mode.
- Bootstrap behavior is opt-in only; `gx cockpit` and `gx cockpit
--backend kitty` keep their current behavior when `--host` is absent.

## Impact

- New `bootstrapHost()` API on the kitty terminal backend, plus
`buildKittyHostBootstrapCommand`, `injectRemoteControl`,
`defaultHostSocketPath`, `socketReady` exports.
- New plan-level `host: { socket }` field and per-command `--to=`
injection in `src/cockpit/kitty-layout.js`.
- Doc update in `docs/agents-cockpit.md`.
- No change to safety model: branches, worktrees, locks, PR-only
finish, and cleanup rules are untouched.
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
## ADDED Requirements

### Requirement: gx cockpit can spawn its own Kitty host
`gx cockpit` SHALL accept a `--host` flag (alias `--bootstrap-kitty`)
that spawns a detached `kitty` process configured with
`allow_remote_control=yes` and a private `listen_on=unix:<sock>`, then
targets all subsequent remote-control commands at that socket.

#### Scenario: --host bootstraps a fresh Kitty window
- **WHEN** `gx cockpit --host` is invoked
- **THEN** the process spawns `kitty` with
`-o allow_remote_control=yes -o listen_on=unix:<sock>` and
`--directory <repo-root>`
- **AND** waits for `<sock>` to exist before issuing any
`kitty @ launch | focus-window | send-text` commands
- **AND** prepends `--to=unix:<sock>` to every cockpit plan command
argument list.

#### Scenario: --socket pins a stable listen path
- **WHEN** `gx cockpit --host --socket /tmp/gx-cockpit.sock` is invoked
- **THEN** the spawned host listens on `/tmp/gx-cockpit.sock`
- **AND** every plan command targets that socket via `--to=`.

#### Scenario: --no-host preserves legacy behavior
- **WHEN** `gx cockpit --no-host` is invoked
- **THEN** no fresh Kitty host is spawned
- **AND** plan commands carry no `--to=` argument
- **AND** the cockpit assumes the parent shell is already inside a
Kitty session with remote control enabled.

#### Scenario: Bootstrap is opt-in
- **WHEN** `gx cockpit` runs with no host-related flag
- **THEN** the cockpit behaves exactly as before this change
- **AND** no `--to=` argument is injected by default.

### Requirement: Kitty backend exposes a host bootstrap API
The Kitty terminal backend SHALL expose a `bootstrapHost(options)`
method, a `buildKittyHostBootstrapCommand` builder, and an
`injectRemoteControl(args, socket)` helper so callers can spawn a
fresh Kitty host and route remote-control traffic to it.

#### Scenario: bootstrapHost returns socket and pid
- **WHEN** `kittyBackend.bootstrapHost({ repoRoot, socket })` is invoked
- **THEN** it spawns kitty with allow_remote_control + listen_on
- **AND** returns `{ action: 'bootstrap-kitty-host', socket, listenOn,
pid, command }` once the socket is ready.

#### Scenario: injectRemoteControl is idempotent
- **WHEN** an args list already contains `--to=...`
- **THEN** `injectRemoteControl` returns the args unchanged
- **AND** non-`@` argument lists (e.g. `['--version']`) are returned
unchanged.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Tasks

## 1. Spec
- [x] 1.1 Capture proposal in `proposal.md`
- [x] 1.2 Capture spec delta in `specs/cockpit-kitty-layout/spec.md`

## 2. Tests
- [x] 2.1 Add `test/cockpit-kitty-bootstrap.test.js` covering
`injectRemoteControl`, `buildKittyHostBootstrapCommand`,
`openKittyCockpit({ bootstrap: true })` plan injection, and
`parseCockpitArgs` for `--host` / `--socket` / `--no-host`.
- [x] 2.2 Verify existing kitty/cockpit tests still pass
(`cockpit-kitty-layout`, `cockpit-kitty-integration`,
`cockpit-terminal-backend`).

## 3. Implementation
- [x] 3.1 Add bootstrap helpers to `src/terminal/kitty.js`
(`buildKittyHostBootstrapCommand`, `bootstrapHost`,
`injectRemoteControl`, `defaultHostSocketPath`, `socketReady`).
- [x] 3.2 Wire `bootstrap` / `socket` / `host` plumbing into
`src/cockpit/kitty-layout.js` `openKittyCockpit`, plus
`injectRemoteControlIntoPlan` to prepend `--to=` per command.
- [x] 3.3 Add `--host`, `--bootstrap-kitty`, `--no-host`, and
`--socket` to `parseCockpitArgs` in `src/cockpit/index.js` and
thread them through `openWithBackend` to `openKittyCockpit`.
- [x] 3.4 Update `docs/agents-cockpit.md` with a `--host` section and
correct the default-backend description (`auto`, not `tmux`).

## 4. Cleanup
- [ ] 4.1 Commit changes on the agent branch.
- [ ] 4.2 Push branch and open a PR.
- [ ] 4.3 Run `gx branch finish ... --via-pr --wait-for-merge --cleanup`.
- [ ] 4.4 Record PR URL and `MERGED` evidence.
45 changes: 45 additions & 0 deletions src/cockpit/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ function parseCockpitArgs(rawArgs = []) {
backend: process.env.GUARDEX_COCKPIT_BACKEND || DEFAULT_BACKEND,
attach: false,
target: process.cwd(),
host: undefined,
socket: undefined,
};

for (let index = 0; index < rawArgs.length; index += 1) {
Expand All @@ -28,6 +30,40 @@ function parseCockpitArgs(rawArgs = []) {
options.backend = 'kitty';
continue;
}
if (arg === '--host' || arg === '--bootstrap-kitty') {
options.host = true;
if (!options.backend || options.backend === 'auto' || options.backend === DEFAULT_BACKEND) {
options.backend = 'kitty';
}
continue;
}
if (arg === '--no-host' || arg === '--no-bootstrap-kitty') {
options.host = false;
continue;
}
if (arg === '--socket') {
const next = rawArgs[index + 1];
if (!next || next.startsWith('-')) {
throw new Error('--socket requires a path');
}
options.socket = next;
if (options.host !== false) options.host = true;
if (!options.backend || options.backend === 'auto' || options.backend === DEFAULT_BACKEND) {
options.backend = 'kitty';
}
index += 1;
continue;
}
if (arg.startsWith('--socket=')) {
const next = arg.slice('--socket='.length);
if (!next) throw new Error('--socket requires a path');
options.socket = next;
if (options.host !== false) options.host = true;
if (!options.backend || options.backend === 'auto' || options.backend === DEFAULT_BACKEND) {
options.backend = 'kitty';
}
continue;
}
if (arg === '--session') {
const next = rawArgs[index + 1];
if (!next || next.startsWith('-')) {
Expand Down Expand Up @@ -176,6 +212,15 @@ function openWithBackend(backend, options, repoRoot, controlCommand, deps = {})
runner: deps.kittyRunner || deps.runner,
kittyBin: deps.kittyBin || env.GUARDEX_KITTY_BIN,
env,
backend,
bootstrap: options.host === true ? true : options.host === false ? false : undefined,
bootstrapWhenHostless: false,
socket: options.socket,
hostRunner: deps.kittyHostRunner,
hostRuntime: deps.kittyHostRuntime,
spawn: deps.spawn,
fs: deps.fs,
sleep: deps.sleep,
});
const action = result && result.action ? result.action : 'created';
writeOpenedCockpitMessage({ backend, action, options, repoRoot, controlCommand, stdout, toolName });
Expand Down
67 changes: 66 additions & 1 deletion src/cockpit/kitty-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const { readCockpitSettings } = require('./settings');
const { readCockpitState } = require('./state');
const kittyRuntime = require('../kitty/runtime');
const kittyTerminal = require('../terminal/kitty');

const DEFAULT_SESSION_NAME = 'guardex';
const DEFAULT_COLUMNS = 120;
Expand Down Expand Up @@ -435,6 +436,66 @@ function createKittyCockpitPlan(options = {}) {
};
}

function shouldBootstrapHost(options = {}) {
if (options.bootstrap === true) return true;
if (options.bootstrap === false) return false;
if (options.host === true) return true;
if (options.host === false) return false;
const env = options.env && typeof options.env === 'object' ? options.env : process.env;
if (firstText(env.KITTY_LISTEN_ON)) return false;
return Boolean(options.bootstrapWhenHostless);
}

function injectRemoteControlIntoPlan(plan, socket) {
if (!socket || !plan || !Array.isArray(plan.commands)) return plan;
const inject = (args) => kittyTerminal.injectRemoteControl(args, socket);
const updatedCommands = plan.commands.map((command) => {
if (!command || !Array.isArray(command.args)) return command;
return { ...command, args: inject(command.args) };
});
const updatedSteps = Array.isArray(plan.steps)
? plan.steps.map((step) => {
if (!step || !step.command || !Array.isArray(step.command.args)) return step;
return {
...step,
command: { ...step.command, args: inject(step.command.args) },
};
})
: plan.steps;
return {
...plan,
host: { socket },
commands: updatedCommands,
steps: updatedSteps,
};
}

function bootstrapHostIfRequested(options, repoRoot) {
if (!shouldBootstrapHost(options)) return null;
const sessionName = text(options.sessionName, DEFAULT_SESSION_NAME);
const dryRun = Boolean(options.dryRun);
const backend = options.backend && typeof options.backend.bootstrapHost === 'function'
? options.backend
: kittyTerminal.createKittyBackend({
kittyBin: options.kittyBin,
env: options.env,
runtime: options.hostRuntime,
runner: options.hostRunner,
dryRun,
});
return backend.bootstrapHost({
repoRoot,
socket: options.socket,
socketPrefix: options.socketPrefix,
title: text(options.controlTitle, `${sessionName}: cockpit`),
fs: options.fs,
spawn: options.spawn,
timeoutMs: options.hostTimeoutMs,
intervalMs: options.hostIntervalMs,
sleep: options.sleep,
});
}

function openKittyCockpit(options = {}) {
const repoRoot = requireText(
firstText(options.repoRoot, options.repoPath, options.target, process.cwd()),
Expand All @@ -455,7 +516,10 @@ function openKittyCockpit(options = {}) {
dryRun: options.dryRun,
focusControl: options.focusControl,
};
const plan = buildKittyCockpitPlan(state, settings);
const host = bootstrapHostIfRequested(options, repoRoot);
const socket = host && host.socket ? host.socket : '';
const basePlan = buildKittyCockpitPlan(state, settings);
const plan = socket ? injectRemoteControlIntoPlan(basePlan, socket) : basePlan;
const execution = kittyRuntime.openKittyCockpit({
plan,
dryRun: plan.dryRun,
Expand All @@ -470,6 +534,7 @@ function openKittyCockpit(options = {}) {
sessionName: plan.sessionName,
repoRoot: plan.repoRoot,
dryRun: plan.dryRun,
host: host || null,
plan,
execution,
};
Expand Down
Loading
Loading