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
7 changes: 5 additions & 2 deletions src/agents/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function normalizeSessionForStatus(session, lockCounts) {
};
}

function buildAgentsStatus(repoRoot) {
function buildAgentsStatusPayload(repoRoot) {
const lockCounts = readLockCounts(repoRoot);
return {
schemaVersion: 1,
Expand All @@ -47,6 +47,8 @@ function buildAgentsStatus(repoRoot) {
};
}

const buildAgentsStatus = buildAgentsStatusPayload;

function formatValue(value) {
const text = String(value || '');
return text || '-';
Expand All @@ -72,10 +74,11 @@ function renderAgentsStatus(payload, options = {}) {
}

function runStatusCommand(repoRoot, options = {}) {
return renderAgentsStatus(buildAgentsStatus(repoRoot), options);
return renderAgentsStatus(buildAgentsStatusPayload(repoRoot), options);
}

module.exports = {
buildAgentsStatusPayload,
buildAgentsStatus,
renderAgentsStatus,
runStatusCommand,
Expand Down
25 changes: 23 additions & 2 deletions src/cockpit/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,31 @@ function lockSummary(locks) {
return `${locks.length} (${preview}${suffix})`;
}

function lockCountSummary(session) {
if (Array.isArray(session.locks)) {
return lockSummary(session.locks);
}

return Number.isFinite(session.lockCount) ? String(session.lockCount) : 'none';
}

function worktreeSummary(session) {
const worktreePath = session.worktreePath || '-';
if (session.worktreeExists === false) {
return `${worktreePath} (missing)`;
}
if (session.worktreeExists === true) {
return `${worktreePath} (present)`;
}
return worktreePath;
}

function renderSession(session, index) {
const lines = [
`${index + 1}. ${session.agentName || 'agent'} | ${session.status || 'unknown'}`,
` branch: ${session.branch || '-'}`,
` worktree: ${session.worktreePath || '-'}`,
` locks: ${lockSummary(session.locks)}`,
` worktree: ${worktreeSummary(session)}`,
` locks: ${lockCountSummary(session)}`,
];

if (session.task) {
Expand Down Expand Up @@ -58,4 +77,6 @@ module.exports = {
renderCockpit,
renderSession,
lockSummary,
lockCountSummary,
worktreeSummary,
};
124 changes: 15 additions & 109 deletions src/cockpit/state.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,6 @@
const fs = require('node:fs');
const path = require('node:path');
const cp = require('node:child_process');
const {
listAgentSessions,
sessionFilePath,
} = require('../agents/sessions');

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;
}
}
const { buildAgentsStatusPayload } = require('../agents/status');

function text(value, fallback = '') {
if (typeof value === 'string') {
Expand Down Expand Up @@ -48,113 +33,34 @@ function readBaseBranch(repoPath) {
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;
}

function cockpitSessionFromStatus(session) {
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: [],
id: text(session.id),
agentName: text(session.agent, 'agent'),
branch: text(session.branch, '(unknown branch)'),
base: text(session.base),
worktreePath: text(session.worktreePath, '(unknown worktree)'),
worktreeExists: Boolean(session.worktreeExists),
status: text(session.status, 'unknown'),
task: text(session.task),
lockCount: Number.isFinite(session.lockCount) ? session.lockCount : 0,
};
}

function readLegacyActiveSessions(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)
}

function readCanonicalActiveSessions(repoPath) {
return listAgentSessions(repoPath)
.map((session) => normalizeSession(session, sessionFilePath(repoPath, session.id)))
.filter(Boolean);
}

function sessionKey(session) {
return `${session.branch}\0${session.worktreePath}`;
}

function readActiveSessions(repoPath) {
const byKey = new Map();
for (const session of readLegacyActiveSessions(repoPath)) {
byKey.set(sessionKey(session), session);
}
for (const session of readCanonicalActiveSessions(repoPath)) {
byKey.set(sessionKey(session), session);
}

return Array.from(byKey.values())
.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) || [],
}));
const statusPayload = buildAgentsStatusPayload(resolvedRepoPath);

return {
repoPath: resolvedRepoPath,
baseBranch: readBaseBranch(resolvedRepoPath),
sessions,
agentsStatus: statusPayload,
sessions: statusPayload.sessions.map(cockpitSessionFromStatus),
};
}

module.exports = {
ACTIVE_SESSIONS_DIR,
LOCK_FILE,
readCockpitState,
readActiveSessions,
readLegacyActiveSessions,
readBaseBranch,
readLocksByBranch,
cockpitSessionFromStatus,
};
59 changes: 35 additions & 24 deletions test/cockpit-render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const cp = require('node:child_process');
const { renderCockpit } = require('../src/cockpit/render');
const { readCockpitState } = require('../src/cockpit/state');
const { render } = require('../src/cockpit');
const { buildAgentsStatusPayload } = require('../src/agents/status');
const { createAgentSession } = require('../src/agents/sessions');

function initRepo() {
Expand All @@ -26,6 +27,7 @@ test('renderCockpit returns a readable terminal string', () => {
agentName: 'codex',
branch: 'agent/codex/example',
worktreePath: '/repo/example/.omx/agent-worktrees/example',
worktreeExists: true,
status: 'working',
task: 'implement cockpit',
lastHeartbeatAt: '2026-04-29T19:00:00.000Z',
Expand All @@ -39,18 +41,20 @@ test('renderCockpit returns a readable terminal string', () => {
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, /worktree: \/repo\/example\/\.omx\/agent-worktrees\/example \(present\)/);
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 canonical sessions and lock summaries', () => {
test('agents status payload and cockpit state see the same session', () => {
const repoPath = initRepo();
const worktreePath = path.join(repoPath, '.omx', 'agent-worktrees', 'example');
fs.mkdirSync(worktreePath, { recursive: true });
createAgentSession(repoPath, {
id: 'canonical-cockpit',
agent: 'codex',
branch: 'agent/codex/example',
worktreePath: path.join(repoPath, '.omx', 'agent-worktrees', 'example'),
worktreePath,
status: 'working',
task: 'implement cockpit',
});
Expand All @@ -67,38 +71,40 @@ test('readCockpitState reads canonical sessions and lock summaries', () => {
'utf8',
);

const statusPayload = buildAgentsStatusPayload(repoPath);
const state = readCockpitState(repoPath);

assert.equal(state.repoPath, repoPath);
assert.equal(state.baseBranch, 'main');
assert.deepEqual(state.agentsStatus, statusPayload);
assert.equal(state.sessions.length, 1);
assert.equal(state.sessions[0].id, statusPayload.sessions[0].id);
assert.equal(state.sessions[0].branch, statusPayload.sessions[0].branch);
assert.equal(state.sessions[0].worktreePath, statusPayload.sessions[0].worktreePath);
assert.equal(state.sessions[0].status, 'working');
assert.equal(state.sessions[0].task, 'implement cockpit');
assert.deepEqual(state.sessions[0].locks, ['src/cockpit/render.js', 'src/cockpit/state.js']);
assert.equal(state.sessions[0].worktreeExists, true);
assert.equal(state.sessions[0].lockCount, 2);
});

test('readCockpitState still reads legacy .omx active sessions', () => {
test('cockpit marks missing worktrees and renders lock count', () => {
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',
);
const missingWorktree = path.join(repoPath, '.omx', 'agent-worktrees', 'missing');
createAgentSession(repoPath, {
id: 'missing-cockpit',
agent: 'codex',
branch: 'agent/codex/missing',
worktreePath: missingWorktree,
status: 'stalled',
task: 'repair cockpit',
});
fs.mkdirSync(path.join(repoPath, '.omx', 'state'), { recursive: true });
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' },
'src/cockpit/render.js': { branch: 'agent/codex/missing' },
'src/cockpit/state.js': { branch: 'agent/codex/missing' },
'README.md': { branch: 'agent/other/example' },
},
}),
Expand All @@ -110,16 +116,21 @@ test('readCockpitState still reads legacy .omx active sessions', () => {
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']);
assert.equal(state.sessions[0].worktreeExists, false);
assert.equal(state.sessions[0].lockCount, 2);

const output = renderCockpit(state);
assert.match(output, new RegExp(`worktree: ${missingWorktree.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} \\(missing\\)`));
assert.match(output, /locks: 2/);
});

test('non-interactive render returns a string', () => {
test('empty cockpit state renders cleanly', () => {
const repoPath = initRepo();

const output = render(repoPath);

assert.equal(typeof output, 'string');
assert.match(output, /GitGuardex Cockpit/);
assert.match(output, /active sessions: 0/);
assert.match(output, /No active agent sessions\./);
});
Loading