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
33 changes: 33 additions & 0 deletions src/cockpit/index.js
Original file line number Diff line number Diff line change
@@ -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,
};
61 changes: 61 additions & 0 deletions src/cockpit/render.js
Original file line number Diff line number Diff line change
@@ -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,
};
133 changes: 133 additions & 0 deletions src/cockpit/state.js
Original file line number Diff line number Diff line change
@@ -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,
};
91 changes: 91 additions & 0 deletions test/cockpit-render.test.js
Original file line number Diff line number Diff line change
@@ -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\./);
});
Loading