From 5a8b5d3b55cd856f430e5a19f48f17a71a73d0d4 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 29 Apr 2026 21:29:28 +0200 Subject: [PATCH] Show live agent sessions in a terminal cockpit The repo already emits active-session JSON and file-lock state, so the cockpit reads those lightweight files and renders a plain terminal summary without adding UI dependencies. Constraint: No React/Ink and no keyboard shortcuts for this slice Rejected: Wire a new CLI command now | the requested surface is the renderable cockpit module and minimal loop Confidence: high Scope-risk: narrow Directive: Keep cockpit output dependency-free and readable in plain terminals Tested: node --test test/cockpit-render.test.js Tested: npm test --- src/cockpit/index.js | 33 +++++++++ src/cockpit/render.js | 61 +++++++++++++++++ src/cockpit/state.js | 133 ++++++++++++++++++++++++++++++++++++ test/cockpit-render.test.js | 91 ++++++++++++++++++++++++ 4 files changed, 318 insertions(+) create mode 100644 src/cockpit/index.js create mode 100644 src/cockpit/render.js create mode 100644 src/cockpit/state.js create mode 100644 test/cockpit-render.test.js diff --git a/src/cockpit/index.js b/src/cockpit/index.js new file mode 100644 index 00000000..a65c159e --- /dev/null +++ b/src/cockpit/index.js @@ -0,0 +1,33 @@ +const { readCockpitState } = require('./state'); +const { renderCockpit } = require('./render'); + +function render(repoPath = process.cwd()) { + return renderCockpit(readCockpitState(repoPath)); +} + +function startCockpit(options = {}) { + const repoPath = options.repoPath || process.cwd(); + const refreshMs = Number.isFinite(options.refreshMs) && options.refreshMs > 0 + ? options.refreshMs + : 2000; + + const paint = () => { + process.stdout.write('\x1Bc'); + process.stdout.write(render(repoPath)); + }; + + paint(); + return setInterval(paint, refreshMs); +} + +if (require.main === module) { + startCockpit({ + repoPath: process.argv[2] || process.cwd(), + refreshMs: Number.parseInt(process.env.GUARDEX_COCKPIT_REFRESH_MS || '2000', 10), + }); +} + +module.exports = { + render, + startCockpit, +}; diff --git a/src/cockpit/render.js b/src/cockpit/render.js new file mode 100644 index 00000000..9ca26a79 --- /dev/null +++ b/src/cockpit/render.js @@ -0,0 +1,61 @@ +function line(label, value) { + return `${label}: ${value || '-'}`; +} + +function lockSummary(locks) { + if (!Array.isArray(locks) || locks.length === 0) { + return 'none'; + } + + const preview = locks.slice(0, 3).join(', '); + const suffix = locks.length > 3 ? `, +${locks.length - 3} more` : ''; + return `${locks.length} (${preview}${suffix})`; +} + +function renderSession(session, index) { + const lines = [ + `${index + 1}. ${session.agentName || 'agent'} | ${session.status || 'unknown'}`, + ` branch: ${session.branch || '-'}`, + ` worktree: ${session.worktreePath || '-'}`, + ` locks: ${lockSummary(session.locks)}`, + ]; + + if (session.task) { + lines.push(` task: ${session.task}`); + } + if (session.lastHeartbeatAt) { + lines.push(` heartbeat: ${session.lastHeartbeatAt}`); + } + + return lines.join('\n'); +} + +function renderCockpit(state) { + const sessions = Array.isArray(state && state.sessions) ? state.sessions : []; + const lines = [ + 'GitGuardex Cockpit', + line('repo', state && state.repoPath), + line('base', state && state.baseBranch), + line('active sessions', String(sessions.length)), + '', + ]; + + if (sessions.length === 0) { + lines.push('No active agent sessions.'); + } else { + sessions.forEach((session, index) => { + if (index > 0) { + lines.push(''); + } + lines.push(renderSession(session, index)); + }); + } + + return `${lines.join('\n')}\n`; +} + +module.exports = { + renderCockpit, + renderSession, + lockSummary, +}; diff --git a/src/cockpit/state.js b/src/cockpit/state.js new file mode 100644 index 00000000..1872f7e8 --- /dev/null +++ b/src/cockpit/state.js @@ -0,0 +1,133 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const cp = require('node:child_process'); + +const ACTIVE_SESSIONS_DIR = path.join('.omx', 'state', 'active-sessions'); +const LOCK_FILE = path.join('.omx', 'state', 'agent-file-locks.json'); + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (_error) { + return null; + } +} + +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 readGitValue(repoPath, args) { + try { + return cp.execFileSync('git', ['-C', repoPath, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch (_error) { + return ''; + } +} + +function readBaseBranch(repoPath) { + const configured = readGitValue(repoPath, ['config', '--get', 'multiagent.baseBranch']); + if (configured) { + return configured; + } + + const originHead = readGitValue(repoPath, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']); + return originHead.replace(/^origin\//, ''); +} + +function normalizeSession(input, filePath) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return null; + } + + const branch = text(input.branch); + const worktreePath = text(input.worktreePath || input.worktree_path); + if (!branch && !worktreePath) { + return null; + } + + return { + agentName: text(input.agentName || input.agent || input.cliName, 'agent'), + branch: branch || '(unknown branch)', + worktreePath: worktreePath || '(unknown worktree)', + status: text(input.status || input.state || input.activity, 'unknown'), + task: text(input.latestTaskPreview || input.taskName || input.task), + lastHeartbeatAt: text(input.lastHeartbeatAt || input.updatedAt || input.updated_at), + filePath, + locks: [], + }; +} + +function readActiveSessions(repoPath) { + const sessionsDir = path.join(repoPath, ACTIVE_SESSIONS_DIR); + if (!fs.existsSync(sessionsDir)) { + return []; + } + + return fs.readdirSync(sessionsDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => { + const filePath = path.join(sessionsDir, entry.name); + return normalizeSession(readJson(filePath), filePath); + }) + .filter(Boolean) + .sort((left, right) => left.branch.localeCompare(right.branch)); +} + +function readLocksByBranch(repoPath) { + const parsed = readJson(path.join(repoPath, LOCK_FILE)); + const locks = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed.locks : null; + const byBranch = new Map(); + if (!locks || typeof locks !== 'object' || Array.isArray(locks)) { + return byBranch; + } + + for (const [relativePath, entry] of Object.entries(locks)) { + const branch = text(entry && entry.branch); + if (!branch) { + continue; + } + if (!byBranch.has(branch)) { + byBranch.set(branch, []); + } + byBranch.get(branch).push(relativePath); + } + + for (const entries of byBranch.values()) { + entries.sort((left, right) => left.localeCompare(right)); + } + return byBranch; +} + +function readCockpitState(repoPath = process.cwd()) { + const resolvedRepoPath = path.resolve(repoPath); + const locksByBranch = readLocksByBranch(resolvedRepoPath); + const sessions = readActiveSessions(resolvedRepoPath).map((session) => ({ + ...session, + locks: locksByBranch.get(session.branch) || [], + })); + + return { + repoPath: resolvedRepoPath, + baseBranch: readBaseBranch(resolvedRepoPath), + sessions, + }; +} + +module.exports = { + ACTIVE_SESSIONS_DIR, + LOCK_FILE, + readCockpitState, + readActiveSessions, + readBaseBranch, + readLocksByBranch, +}; diff --git a/test/cockpit-render.test.js b/test/cockpit-render.test.js new file mode 100644 index 00000000..ebca2f80 --- /dev/null +++ b/test/cockpit-render.test.js @@ -0,0 +1,91 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const cp = require('node:child_process'); + +const { renderCockpit } = require('../src/cockpit/render'); +const { readCockpitState } = require('../src/cockpit/state'); +const { render } = require('../src/cockpit'); + +function initRepo() { + const repoPath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-cockpit-')); + cp.execFileSync('git', ['init', '-b', 'main'], { cwd: repoPath, stdio: 'ignore' }); + cp.execFileSync('git', ['config', 'multiagent.baseBranch', 'main'], { cwd: repoPath, stdio: 'ignore' }); + return repoPath; +} + +test('renderCockpit returns a readable terminal string', () => { + const output = renderCockpit({ + repoPath: '/repo/example', + baseBranch: 'main', + sessions: [ + { + agentName: 'codex', + branch: 'agent/codex/example', + worktreePath: '/repo/example/.omx/agent-worktrees/example', + status: 'working', + task: 'implement cockpit', + lastHeartbeatAt: '2026-04-29T19:00:00.000Z', + locks: ['src/cockpit/render.js', 'src/cockpit/state.js', 'test/cockpit-render.test.js', 'README.md'], + }, + ], + }); + + assert.match(output, /GitGuardex Cockpit/); + assert.match(output, /repo: \/repo\/example/); + assert.match(output, /base: main/); + assert.match(output, /active sessions: 1/); + assert.match(output, /branch: agent\/codex\/example/); + assert.match(output, /worktree: \/repo\/example\/\.omx\/agent-worktrees\/example/); + assert.match(output, /locks: 4 \(src\/cockpit\/render\.js, src\/cockpit\/state\.js, test\/cockpit-render\.test\.js, \+1 more\)/); + assert.match(output, /task: implement cockpit/); +}); + +test('readCockpitState reads active sessions and lock summaries', () => { + const repoPath = initRepo(); + const sessionsDir = path.join(repoPath, '.omx', 'state', 'active-sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.writeFileSync( + path.join(sessionsDir, 'agent__codex__example.json'), + JSON.stringify({ + agentName: 'codex', + branch: 'agent/codex/example', + worktreePath: path.join(repoPath, '.omx', 'agent-worktrees', 'example'), + state: 'working', + latestTaskPreview: 'implement cockpit', + lastHeartbeatAt: '2026-04-29T19:00:00.000Z', + }), + 'utf8', + ); + fs.writeFileSync( + path.join(repoPath, '.omx', 'state', 'agent-file-locks.json'), + JSON.stringify({ + locks: { + 'src/cockpit/render.js': { branch: 'agent/codex/example' }, + 'src/cockpit/state.js': { branch: 'agent/codex/example' }, + 'README.md': { branch: 'agent/other/example' }, + }, + }), + 'utf8', + ); + + const state = readCockpitState(repoPath); + + assert.equal(state.repoPath, repoPath); + assert.equal(state.baseBranch, 'main'); + assert.equal(state.sessions.length, 1); + assert.equal(state.sessions[0].status, 'working'); + assert.deepEqual(state.sessions[0].locks, ['src/cockpit/render.js', 'src/cockpit/state.js']); +}); + +test('non-interactive render returns a string', () => { + const repoPath = initRepo(); + + const output = render(repoPath); + + assert.equal(typeof output, 'string'); + assert.match(output, /GitGuardex Cockpit/); + assert.match(output, /No active agent sessions\./); +});