From ae4dc244f5eaab47d8f1bce4fbd0322392623249 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 5 May 2026 08:57:22 +0200 Subject: [PATCH] Auto-bootstrap Kitty host when gx cockpit runs from a non-Kitty TTY gx cockpit on the kitty backend now defaults to --host when stdout is a TTY and KITTY_LISTEN_ON is unset, so plain `gx cockpit` opens a fresh Kitty window with tiled agent lanes. Disabled by --no-host, by being inside a Kitty session, when stdout is not a TTY (CI/scripted tests), or when GUARDEX_AUTO_HOST=0. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/agents-cockpit.md | 19 +++++++ src/cockpit/index.js | 15 ++++- test/cockpit-kitty-bootstrap.test.js | 82 ++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/docs/agents-cockpit.md b/docs/agents-cockpit.md index 6ab57e7..a9ac39c 100644 --- a/docs/agents-cockpit.md +++ b/docs/agents-cockpit.md @@ -82,6 +82,25 @@ inside Kitty" mode. set). It does not require `allow_remote_control` to be enabled in `kitty.conf`, because the spawned host is configured inline via `-o`. +### Auto-host default + +When you run `gx cockpit` from an interactive terminal that is **not** +already a Kitty session (e.g. from gnome-terminal, alacritty, or any +shell where `KITTY_LISTEN_ON` is unset) and the kitty backend is +selected, `gx cockpit` auto-bootstraps a Kitty host as if you had +passed `--host`. + +Auto-host is disabled when any of the following hold: +- The cockpit is invoked with `--no-host`. +- `KITTY_LISTEN_ON` is already exported (you are inside a Kitty + session — remote control is reachable through the parent socket). +- `stdout` is not a TTY (CI, piped output, scripted tests). +- `GUARDEX_AUTO_HOST=0` (or `false`/`no`/`off`) is exported. + +This makes the dmux-style "type one command, get a guarded multi-agent +window" UX work out of the box while keeping the legacy "I am already +inside Kitty with RC enabled" path untouched. + ## Start agent lanes Start Codex: diff --git a/src/cockpit/index.js b/src/cockpit/index.js index 4fd3943..6c1e776 100644 --- a/src/cockpit/index.js +++ b/src/cockpit/index.js @@ -193,11 +193,22 @@ function writeOpenedCockpitMessage({ backend, action, options, repoRoot, control stdout.write(`[${toolName}] Control pane: ${controlCommand}\n`); } +function shouldAutoHost(options = {}, context = {}) { + if (options.host === true || options.host === false) return false; + const env = context.env || process.env; + const optOut = String(env.GUARDEX_AUTO_HOST || '').trim().toLowerCase(); + if (optOut === '0' || optOut === 'false' || optOut === 'no' || optOut === 'off') return false; + if (env.KITTY_LISTEN_ON) return false; + const stdout = context.stdout || process.stdout; + return Boolean(stdout && stdout.isTTY); +} + function openWithBackend(backend, options, repoRoot, controlCommand, deps = {}) { const stdout = deps.stdout || process.stdout; const toolName = deps.toolName || 'gitguardex'; const env = deps.env || process.env; if (backend.name === 'kitty') { + const autoHost = shouldAutoHost(options, { env, stdout }); const result = openKittyCockpit({ repoRoot, sessionName: options.sessionName, @@ -213,7 +224,9 @@ function openWithBackend(backend, options, repoRoot, controlCommand, deps = {}) kittyBin: deps.kittyBin || env.GUARDEX_KITTY_BIN, env, backend, - bootstrap: options.host === true ? true : options.host === false ? false : undefined, + bootstrap: options.host === true || (options.host === undefined && autoHost) + ? true + : options.host === false ? false : undefined, bootstrapWhenHostless: false, socket: options.socket, hostRunner: deps.kittyHostRunner, diff --git a/test/cockpit-kitty-bootstrap.test.js b/test/cockpit-kitty-bootstrap.test.js index 1a80fdf..686f669 100644 --- a/test/cockpit-kitty-bootstrap.test.js +++ b/test/cockpit-kitty-bootstrap.test.js @@ -139,6 +139,88 @@ test('openKittyCockpit without bootstrap leaves args untouched', () => { } }); +test('auto-host bootstraps when stdout is a TTY and KITTY_LISTEN_ON is unset', () => { + const result = cockpit.openCockpit(['--target', '/repo/gitguardex'], { + resolveRepoRoot: (target) => target, + toolName: 'gx', + stdout: { isTTY: true, write() {} }, + env: {}, + dryRun: true, + readState: () => fakeState([fakeSession('alpha')]), + readSettings: () => ({}), + terminalBackends: { kitty: fakeBackendStub({ socket: '/tmp/auto-host.sock' }) }, + }); + + assert.equal(result.backend, 'kitty'); + assert.ok(result.plan.host, 'plan.host should be populated'); + assert.equal(result.plan.host.socket, '/tmp/auto-host.sock'); + for (const cmd of result.plan.commands) { + assert.equal(cmd.args[1], '--to=/tmp/auto-host.sock'); + } +}); + +test('auto-host stays off when stdout is not a TTY', () => { + const result = cockpit.openCockpit(['--target', '/repo/gitguardex'], { + resolveRepoRoot: (target) => target, + toolName: 'gx', + stdout: { isTTY: false, write() {} }, + env: {}, + dryRun: true, + readState: () => fakeState([fakeSession('alpha')]), + readSettings: () => ({}), + terminalBackends: { kitty: fakeBackendStub() }, + }); + + assert.equal(result.backend, 'kitty'); + assert.equal(result.plan.host || null, null); +}); + +test('auto-host stays off when KITTY_LISTEN_ON is set', () => { + const result = cockpit.openCockpit(['--target', '/repo/gitguardex'], { + resolveRepoRoot: (target) => target, + toolName: 'gx', + stdout: { isTTY: true, write() {} }, + env: { KITTY_LISTEN_ON: 'unix:/tmp/parent.sock' }, + dryRun: true, + readState: () => fakeState([fakeSession('alpha')]), + readSettings: () => ({}), + terminalBackends: { kitty: fakeBackendStub() }, + }); + + assert.equal(result.backend, 'kitty'); + assert.equal(result.plan.host || null, null); +}); + +test('--no-host overrides the auto-host default', () => { + const result = cockpit.openCockpit(['--no-host', '--target', '/repo/gitguardex'], { + resolveRepoRoot: (target) => target, + toolName: 'gx', + stdout: { isTTY: true, write() {} }, + env: {}, + dryRun: true, + readState: () => fakeState([fakeSession('alpha')]), + readSettings: () => ({}), + terminalBackends: { kitty: fakeBackendStub() }, + }); + + assert.equal(result.plan.host || null, null); +}); + +test('GUARDEX_AUTO_HOST=0 disables the auto-host default', () => { + const result = cockpit.openCockpit(['--target', '/repo/gitguardex'], { + resolveRepoRoot: (target) => target, + toolName: 'gx', + stdout: { isTTY: true, write() {} }, + env: { GUARDEX_AUTO_HOST: '0' }, + dryRun: true, + readState: () => fakeState([fakeSession('alpha')]), + readSettings: () => ({}), + terminalBackends: { kitty: fakeBackendStub() }, + }); + + assert.equal(result.plan.host || null, null); +}); + test('parseCockpitArgs accepts --host and --socket', () => { const opts1 = cockpit.parseCockpitArgs(['--host']); assert.equal(opts1.host, true);