From 60e53e5d82b05f843f0b73b1f90f7eb1bc1e2a7f Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 5 May 2026 08:50:02 +0200 Subject: [PATCH] Add gx cockpit --host to bootstrap a Kitty cockpit window Lets gx cockpit spawn its own Kitty process with allow_remote_control and a private listen socket, then injects --to= into every @ launch / focus-window / send-text plan command so all panes target the spawned host. Default behavior is unchanged; --host (alias --bootstrap-kitty) and --socket are opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/agents-cockpit.md | 44 ++++- .../proposal.md | 33 ++++ .../specs/cockpit-kitty-layout/spec.md | 52 ++++++ .../tasks.md | 33 ++++ src/cockpit/index.js | 45 +++++ src/cockpit/kitty-layout.js | 67 +++++++- src/terminal/kitty.js | 120 +++++++++++++- test/cockpit-kitty-bootstrap.test.js | 156 ++++++++++++++++++ 8 files changed, 542 insertions(+), 8 deletions(-) create mode 100644 openspec/changes/agent-claude-kitty-cockpit-host-2026-05-05-08-35/proposal.md create mode 100644 openspec/changes/agent-claude-kitty-cockpit-host-2026-05-05-08-35/specs/cockpit-kitty-layout/spec.md create mode 100644 openspec/changes/agent-claude-kitty-cockpit-host-2026-05-05-08-35/tasks.md create mode 100644 test/cockpit-kitty-bootstrap.test.js diff --git a/docs/agents-cockpit.md b/docs/agents-cockpit.md index d50f1cb..6ab57e7 100644 --- a/docs/agents-cockpit.md +++ b/docs/agents-cockpit.md @@ -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:` + detached, with the repo root as `--directory`. +2. Waits for the listen socket to appear, then prepends + `--to=unix:` 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 ` 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 diff --git a/openspec/changes/agent-claude-kitty-cockpit-host-2026-05-05-08-35/proposal.md b/openspec/changes/agent-claude-kitty-cockpit-host-2026-05-05-08-35/proposal.md new file mode 100644 index 0000000..19a46b3 --- /dev/null +++ b/openspec/changes/agent-claude-kitty-cockpit-host-2026-05-05-08-35/proposal.md @@ -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:`. +- Wait for the listen socket to appear, then prepend `--to=unix:` + to every `kitty @ launch | focus-window | send-text` issued by the + cockpit plan so all subsequent panes target the spawned host. +- Add `--socket ` 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. diff --git a/openspec/changes/agent-claude-kitty-cockpit-host-2026-05-05-08-35/specs/cockpit-kitty-layout/spec.md b/openspec/changes/agent-claude-kitty-cockpit-host-2026-05-05-08-35/specs/cockpit-kitty-layout/spec.md new file mode 100644 index 0000000..6d3be4b --- /dev/null +++ b/openspec/changes/agent-claude-kitty-cockpit-host-2026-05-05-08-35/specs/cockpit-kitty-layout/spec.md @@ -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:`, 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:` and + `--directory ` +- **AND** waits for `` to exist before issuing any + `kitty @ launch | focus-window | send-text` commands +- **AND** prepends `--to=unix:` 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. diff --git a/openspec/changes/agent-claude-kitty-cockpit-host-2026-05-05-08-35/tasks.md b/openspec/changes/agent-claude-kitty-cockpit-host-2026-05-05-08-35/tasks.md new file mode 100644 index 0000000..96724a5 --- /dev/null +++ b/openspec/changes/agent-claude-kitty-cockpit-host-2026-05-05-08-35/tasks.md @@ -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. diff --git a/src/cockpit/index.js b/src/cockpit/index.js index bb21305..4fd3943 100644 --- a/src/cockpit/index.js +++ b/src/cockpit/index.js @@ -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) { @@ -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('-')) { @@ -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 }); diff --git a/src/cockpit/kitty-layout.js b/src/cockpit/kitty-layout.js index 24492af..d9f1c1f 100644 --- a/src/cockpit/kitty-layout.js +++ b/src/cockpit/kitty-layout.js @@ -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; @@ -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()), @@ -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, @@ -470,6 +534,7 @@ function openKittyCockpit(options = {}) { sessionName: plan.sessionName, repoRoot: plan.repoRoot, dryRun: plan.dryRun, + host: host || null, plan, execution, }; diff --git a/src/terminal/kitty.js b/src/terminal/kitty.js index c776804..755f8d4 100644 --- a/src/terminal/kitty.js +++ b/src/terminal/kitty.js @@ -1,13 +1,20 @@ 'use strict'; const cp = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); const DEFAULT_KITTY_BIN = 'kitty'; const DEFAULT_COCKPIT_TITLE = 'gx cockpit'; const DEFAULT_AGENT_TITLE = 'agent'; const DEFAULT_TERMINAL_TITLE = 'terminal'; +const DEFAULT_HOST_SOCKET_PREFIX = 'gx-cockpit-'; +const DEFAULT_HOST_READY_TIMEOUT_MS = 5000; +const DEFAULT_HOST_POLL_INTERVAL_MS = 50; const KITTY_MISSING_MESSAGE = 'Kitty is not installed or not on PATH. Install Kitty or run gx cockpit --backend tmux.'; const KITTY_REMOTE_CONTROL_MESSAGE = 'Kitty is installed, but remote control is not available. Enable allow_remote_control in kitty.conf or run gx cockpit --backend tmux.'; +const KITTY_HOST_SOCKET_TIMEOUT_MESSAGE = 'Bootstrap Kitty host did not expose a remote-control socket in time.'; function text(value, fallback = '') { if (typeof value === 'string') return value.trim() || fallback; @@ -42,20 +49,71 @@ function kittyBin(config = {}, options = {}) { return text(config.kittyBin || envValue, DEFAULT_KITTY_BIN); } +function hostSocket(config = {}) { + return text(config.hostSocket || config.socket || config.to, ''); +} + +function injectRemoteControl(args, socket) { + const value = text(socket); + if (!value) return args; + if (args.length === 0 || args[0] !== '@') return args; + if (args.some((arg) => typeof arg === 'string' && arg.startsWith('--to='))) return args; + return ['@', `--to=${value}`, ...args.slice(1)]; +} + function commandShape(args, config = {}) { return { cmd: kittyBin(config), - args, + args: injectRemoteControl(args, hostSocket(config)), }; } function commandShapeWithEnv(args, config = {}) { + return { + cmd: kittyBin(config, { allowEnv: true }), + args: injectRemoteControl(args, hostSocket(config)), + }; +} + +function defaultHostSocketPath(prefix = DEFAULT_HOST_SOCKET_PREFIX) { + const random = Math.random().toString(36).slice(2, 10); + return path.join(os.tmpdir(), `${prefix}${process.pid}-${Date.now()}-${random}.sock`); +} + +function buildKittyHostBootstrapCommand(options = {}, config = {}) { + const repoRoot = requireText(options.repoRoot || options.cwd, 'kitty host repoRoot'); + const socket = requireText(options.socket || options.listenOn, 'kitty host socket'); + const listenOn = socket.includes(':') ? socket : `unix:${socket}`; + const args = [ + '-o', 'allow_remote_control=yes', + '-o', `listen_on=${listenOn}`, + '--listen-on', listenOn, + '--directory', repoRoot, + '--title', text(options.title, DEFAULT_COCKPIT_TITLE), + ]; + if (options.hold) args.push('--hold'); + if (options.detach !== false) args.push('--detach'); + appendCommandArgv(args, options); return { cmd: kittyBin(config, { allowEnv: true }), args, + socket, + listenOn, }; } +function socketReady(socket, options = {}) { + if (!socket) return false; + const candidate = socket.startsWith('unix:') ? socket.slice('unix:'.length) : socket; + const fsImpl = options.fs || fs; + if (typeof fsImpl.existsSync !== 'function') return false; + try { + return Boolean(fsImpl.existsSync(candidate)); + } catch (_error) { + return false; + } +} + function appendOption(args, flag, value) { const normalized = text(value); if (normalized) args.push(flag, normalized); @@ -418,6 +476,56 @@ function createKittyBackend(config = {}) { return assertResult(run(shape, { ...options, input }), message); } + function spawnHost(command, options = {}) { + const spawn = options.spawn || cp.spawn; + const env = mergeEnv(config, options) || process.env; + const child = spawn(command.cmd, command.args, { + cwd: options.cwd || process.cwd(), + env: { ...process.env, ...env }, + detached: options.detached !== false, + stdio: options.stdio || 'ignore', + }); + if (options.detached !== false && typeof child.unref === 'function') child.unref(); + return child; + } + + function bootstrapHost(options = {}) { + const socket = options.socket || defaultHostSocketPath(options.socketPrefix); + const command = buildKittyHostBootstrapCommand({ ...options, socket }, config); + if (dryRun) { + return makeDryRunPlan('bootstrap-kitty-host', command, { + socket: command.socket, + listenOn: command.listenOn, + }); + } + const child = spawnHost(command, options); + const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 + ? options.timeoutMs + : DEFAULT_HOST_READY_TIMEOUT_MS; + const intervalMs = Number.isFinite(options.intervalMs) && options.intervalMs > 0 + ? options.intervalMs + : DEFAULT_HOST_POLL_INTERVAL_MS; + const fsImpl = options.fs || fs; + const sleep = options.sleep || ((ms) => { + const end = Date.now() + ms; + while (Date.now() < end) { /* busy wait, intentionally sync */ } + }); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (socketReady(command.socket, { fs: fsImpl })) { + return { + action: 'bootstrap-kitty-host', + command, + socket: command.socket, + listenOn: command.listenOn, + pid: child && child.pid, + }; + } + sleep(intervalMs); + } + throw new Error(KITTY_HOST_SOCKET_TIMEOUT_MESSAGE); + } + return { name: 'kitty', isAvailable() { @@ -430,6 +538,11 @@ function createKittyBackend(config = {}) { dryRunPlan(action, commands, extra = {}) { return makeDryRunPlan(action, commands, extra); }, + bootstrapHost, + buildHostCommand(options = {}) { + const socket = options.socket || defaultHostSocketPath(options.socketPrefix); + return buildKittyHostBootstrapCommand({ ...options, socket }, config); + }, openCockpitLayout(options = {}) { return execute( 'open-cockpit-layout', @@ -482,6 +595,7 @@ module.exports = { DEFAULT_KITTY_BIN, KITTY_MISSING_MESSAGE, KITTY_REMOTE_CONTROL_MESSAGE, + KITTY_HOST_SOCKET_TIMEOUT_MESSAGE, buildKittyLaunchCommand, buildKittyFocusCommand, buildKittyCloseCommand, @@ -497,6 +611,10 @@ module.exports = { buildFocusPaneCommand, buildClosePaneCommand, buildSendTextCommand, + buildKittyHostBootstrapCommand, + defaultHostSocketPath, + injectRemoteControl, + socketReady, createBackend, createKittyBackend, sendTextInput, diff --git a/test/cockpit-kitty-bootstrap.test.js b/test/cockpit-kitty-bootstrap.test.js new file mode 100644 index 0000000..1a80fdf --- /dev/null +++ b/test/cockpit-kitty-bootstrap.test.js @@ -0,0 +1,156 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const cockpit = require('../src/cockpit'); +const { openKittyCockpit } = require('../src/cockpit/kitty-layout'); +const kittyTerminal = require('../src/terminal/kitty'); + +function fakeSession(id, extra = {}) { + return { + id, + agent: 'codex', + branch: `agent/codex/${id}`, + status: 'active', + worktreePath: `/repo/.omx/agent-worktrees/${id}`, + worktreeExists: true, + metadata: {}, + launchCommand: 'exec codex', + ...extra, + }; +} + +function fakeState(sessions) { + return { + repoPath: '/repo/gitguardex', + baseBranch: 'main', + agentsStatus: { + schemaVersion: 1, + repoRoot: '/repo/gitguardex', + sessions, + }, + }; +} + +function fakeBackendStub({ socket = '/tmp/gx-test.sock', spawned = [] } = {}) { + return { + name: 'kitty', + isAvailable() { return true; }, + bootstrapHost(options = {}) { + spawned.push(options); + return { + action: 'bootstrap-kitty-host', + socket: options.socket || socket, + listenOn: `unix:${options.socket || socket}`, + pid: 4321, + command: { cmd: 'kitty', args: ['-o', 'allow_remote_control=yes'] }, + }; + }, + }; +} + +test('injectRemoteControl prepends --to= to @ commands once', () => { + const args = kittyTerminal.injectRemoteControl( + ['@', 'launch', '--type=window', '--cwd', '/repo'], + 'unix:/tmp/x.sock', + ); + assert.deepEqual(args, ['@', '--to=unix:/tmp/x.sock', 'launch', '--type=window', '--cwd', '/repo']); +}); + +test('injectRemoteControl is idempotent for already-tagged args', () => { + const args = kittyTerminal.injectRemoteControl( + ['@', '--to=unix:/tmp/old.sock', 'launch'], + 'unix:/tmp/new.sock', + ); + assert.deepEqual(args, ['@', '--to=unix:/tmp/old.sock', 'launch']); +}); + +test('injectRemoteControl skips non-@ commands', () => { + const args = kittyTerminal.injectRemoteControl( + ['--version'], + 'unix:/tmp/x.sock', + ); + assert.deepEqual(args, ['--version']); +}); + +test('buildKittyHostBootstrapCommand emits allow_remote_control + listen_on', () => { + const command = kittyTerminal.buildKittyHostBootstrapCommand({ + repoRoot: '/repo/gitguardex', + socket: '/tmp/cockpit.sock', + title: 'gx cockpit', + }); + assert.equal(command.cmd, 'kitty'); + assert.equal(command.socket, '/tmp/cockpit.sock'); + assert.equal(command.listenOn, 'unix:/tmp/cockpit.sock'); + assert.deepEqual(command.args.slice(0, 4), [ + '-o', 'allow_remote_control=yes', + '-o', 'listen_on=unix:/tmp/cockpit.sock', + ]); + assert.ok(command.args.includes('--listen-on')); + assert.ok(command.args.includes('--directory')); + assert.ok(command.args.includes('/repo/gitguardex')); + assert.ok(command.args.includes('--detach')); +}); + +test('openKittyCockpit with bootstrap injects --to= into every plan command', () => { + const spawned = []; + const backend = fakeBackendStub({ socket: '/tmp/gx-bootstrap.sock', spawned }); + const result = openKittyCockpit({ + repoRoot: '/repo/gitguardex', + state: fakeState([fakeSession('alpha')]), + settings: {}, + readSettings: () => ({}), + sessionName: 'guardex-host', + dryRun: true, + bootstrap: true, + backend, + runner() { /* should not run kitty in dry-run */ }, + }); + + assert.equal(spawned.length, 1, 'host bootstrap was invoked'); + assert.equal(result.host.socket, '/tmp/gx-bootstrap.sock'); + assert.equal(result.plan.host.socket, '/tmp/gx-bootstrap.sock'); + for (const command of result.plan.commands) { + assert.equal(command.args[0], '@'); + assert.equal(command.args[1], '--to=/tmp/gx-bootstrap.sock'); + } +}); + +test('openKittyCockpit without bootstrap leaves args untouched', () => { + const spawned = []; + const backend = fakeBackendStub({ spawned }); + const result = openKittyCockpit({ + repoRoot: '/repo/gitguardex', + state: fakeState([fakeSession('alpha')]), + settings: {}, + readSettings: () => ({}), + sessionName: 'guardex-noop', + dryRun: true, + bootstrap: false, + backend, + }); + + assert.equal(spawned.length, 0, 'host bootstrap must not run when bootstrap=false'); + assert.equal(result.host, null); + for (const command of result.plan.commands) { + assert.equal(command.args[0], '@'); + assert.notEqual(command.args[1] && command.args[1].startsWith && command.args[1].startsWith('--to='), true); + } +}); + +test('parseCockpitArgs accepts --host and --socket', () => { + const opts1 = cockpit.parseCockpitArgs(['--host']); + assert.equal(opts1.host, true); + assert.equal(opts1.backend, 'kitty'); + + const opts2 = cockpit.parseCockpitArgs(['--socket', '/tmp/foo.sock']); + assert.equal(opts2.host, true); + assert.equal(opts2.socket, '/tmp/foo.sock'); + assert.equal(opts2.backend, 'kitty'); + + const opts3 = cockpit.parseCockpitArgs(['--no-host']); + assert.equal(opts3.host, false); + + assert.throws(() => cockpit.parseCockpitArgs(['--socket']), /--socket requires/); +});