Skip to content

Commit 42b9ac3

Browse files
NagyViktNagyViktOmX
authored
Make cockpit sessions scannable as fleet lanes (#569)
The terminal cockpit view was too close to raw session telemetry, which made active agent lanes hard to triage at a glance. This change keeps the existing plain-text render surface but adds state buckets, a fleet summary, compact readiness/progress rows, changed-file previews, and action/detail hints. Constraint: Keep the cockpit state schema and command flow unchanged. Rejected: Add a new fleet command | the current cockpit renderer already owns the terminal fleet surface. Confidence: high Scope-risk: narrow Directive: Keep cockpit output plain text and preserve branch/worktree/lock anchors for scripts and tests. Tested: node --test test/cockpit-render.test.js; openspec validate agent-codex-codex-task-2026-05-13-11-56 --type change --strict; openspec validate --specs Not-tested: Full npm test is not green on this worktree due unrelated branch/setup/metadata baseline failures. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: OmX <omx@oh-my-codex.dev>
1 parent 1e90ad4 commit 42b9ac3

6 files changed

Lines changed: 253 additions & 5 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-05-13
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
## Why
2+
3+
- The terminal fleet/cockpit view was too transcript-like: live agent state was mixed with verbose raw details, making it hard to scan which lanes are working, thinking, blocked, done, or stale.
4+
- Operators need a compact board that highlights lane state, finish readiness, branch/task context, and the files/PR evidence needed for follow-up.
5+
6+
## What Changes
7+
8+
- Update the cockpit renderer to present a fleet-style board with state buckets, a summary header, action hints, compact lane rows, and per-lane progress/readiness text.
9+
- Keep the existing text render surface and command flow intact; this change only improves the rendered terminal output.
10+
- Add focused regression coverage for the grouped fleet buckets and retained cockpit details.
11+
12+
## Impact
13+
14+
- Affected surface: `gx cockpit` / default interactive fleet rendering.
15+
- Risk is narrow: output text changes for cockpit snapshots, with no runtime/session schema changes.
16+
- Existing status payload and cockpit state readers remain unchanged.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Fleet cockpit scan view
4+
The system SHALL render cockpit sessions as a compact fleet board that is easy to scan by agent state.
5+
6+
#### Scenario: Active sessions are grouped by operator state
7+
- **WHEN** the cockpit renderer receives sessions with working, thinking, blocked, done, and stale states
8+
- **THEN** it SHALL include a summary count for each state
9+
- **AND** it SHALL render non-empty state groups with clear headings.
10+
11+
#### Scenario: Session rows preserve follow-up evidence
12+
- **WHEN** a session is rendered in the fleet board
13+
- **THEN** its row SHALL include branch, progress, worktree, lock, changed-file, task, Colony metadata, PR, and heartbeat details when available
14+
- **AND** existing cockpit text output consumers SHALL still receive a plain terminal string.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
## Definition of Done
2+
3+
This change is complete only when **all** of the following are true:
4+
5+
- Every checkbox below is checked.
6+
- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff.
7+
- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline.
8+
9+
## Handoff
10+
11+
- Handoff: change=`agent-codex-codex-task-2026-05-13-11-56`; branch=`agent/<your-name>/<branch-slug>`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`.
12+
- Copy prompt: Continue `agent-codex-codex-task-2026-05-13-11-56` on branch `agent/<your-name>/<branch-slug>`. Work inside the existing sandbox, review `openspec/changes/agent-codex-codex-task-2026-05-13-11-56/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`.
13+
14+
## 1. Specification
15+
16+
- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-codex-task-2026-05-13-11-56`.
17+
- [x] 1.2 Define normative requirements in `specs/codex-task/spec.md`.
18+
19+
## 2. Implementation
20+
21+
- [x] 2.1 Implement scoped behavior changes.
22+
- [x] 2.2 Add/update focused regression coverage.
23+
24+
## 3. Verification
25+
26+
- [x] 3.1 Run targeted project verification commands.
27+
- [x] 3.2 Run `openspec validate agent-codex-codex-task-2026-05-13-11-56 --type change --strict`.
28+
- [x] 3.3 Run `openspec validate --specs`.
29+
30+
## 4. Cleanup (mandatory; run before claiming completion)
31+
32+
- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation.
33+
- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff.
34+
- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch).

src/cockpit/render.js

Lines changed: 122 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ function line(label, value) {
22
return `${label}: ${value || '-'}`;
33
}
44

5+
const BUCKETS = [
6+
{ key: 'working', title: 'WORKING NOW' },
7+
{ key: 'thinking', title: 'THINKING' },
8+
{ key: 'blocked', title: 'BLOCKED' },
9+
{ key: 'done', title: 'DONE' },
10+
{ key: 'stale', title: 'STALE' },
11+
];
12+
513
function lockSummary(locks) {
614
if (!Array.isArray(locks) || locks.length === 0) {
715
return 'none';
@@ -20,6 +28,16 @@ function lockCountSummary(session) {
2028
return Number.isFinite(session.lockCount) ? String(session.lockCount) : 'none';
2129
}
2230

31+
function filePreview(files) {
32+
if (!Array.isArray(files) || files.length === 0) {
33+
return 'none';
34+
}
35+
36+
const preview = files.slice(0, 3).join(', ');
37+
const suffix = files.length > 3 ? `, +${files.length - 3} more` : '';
38+
return `${files.length} (${preview}${suffix})`;
39+
}
40+
2341
function metadataSummary(metadata) {
2442
if (!metadata || typeof metadata !== 'object') return '';
2543
return Object.entries(metadata)
@@ -39,14 +57,96 @@ function worktreeSummary(session) {
3957
return worktreePath;
4058
}
4159

60+
function normalizeState(value) {
61+
return String(value || '').trim().toLowerCase();
62+
}
63+
64+
function sessionBucket(session) {
65+
if (session.worktreeExists === false) {
66+
return 'stale';
67+
}
68+
69+
const status = normalizeState(session.status);
70+
const activity = normalizeState(session.activity);
71+
const prState = normalizeState(session.prState);
72+
const state = `${status} ${activity}`.trim();
73+
74+
if (prState === 'merged' || /\b(done|complete|completed|merged)\b/.test(state)) {
75+
return 'done';
76+
}
77+
if (/\b(blocked|failed|failing|error|errored|stalled|dead)\b/.test(state)) {
78+
return 'blocked';
79+
}
80+
if (/\b(thinking|pending|queued|idle|waiting)\b/.test(state)) {
81+
return 'thinking';
82+
}
83+
if (/\b(working|running|active|orbiting|symbioting|advising)\b/.test(state)) {
84+
return 'working';
85+
}
86+
return 'thinking';
87+
}
88+
89+
function groupSessions(sessions) {
90+
const grouped = new Map(BUCKETS.map((bucket) => [bucket.key, []]));
91+
sessions.forEach((session) => {
92+
grouped.get(sessionBucket(session)).push(session);
93+
});
94+
return grouped;
95+
}
96+
97+
function summaryLine(grouped) {
98+
return BUCKETS
99+
.map((bucket) => `${bucket.key}=${grouped.get(bucket.key).length}`)
100+
.join(' ');
101+
}
102+
103+
function stage(value) {
104+
return value ? 'ok' : 'todo';
105+
}
106+
107+
function progressSummary(session) {
108+
const metadata = session.metadata && typeof session.metadata === 'object' ? session.metadata : {};
109+
const hasSpec = Boolean(metadata['colony.plan'] || metadata['colony.subtask'] || metadata['colony.task_id']);
110+
const hasCode = (
111+
(Array.isArray(session.changedFiles) && session.changedFiles.length > 0) ||
112+
(Array.isArray(session.locks) && session.locks.length > 0) ||
113+
Number(session.lockCount || 0) > 0
114+
);
115+
const hasPr = Boolean(session.prUrl || session.prState);
116+
const merged = normalizeState(session.prState) === 'merged';
117+
118+
return [
119+
`Spec ${stage(hasSpec)}`,
120+
`Code ${hasCode ? 'active' : 'todo'}`,
121+
`Tests ${metadata['colony.verification'] ? 'ok' : 'todo'}`,
122+
`PR ${stage(hasPr)}`,
123+
`Merge ${stage(merged)}`,
124+
`Cleanup ${merged ? 'ready' : 'todo'}`,
125+
].join(' | ');
126+
}
127+
128+
function readinessSummary(session) {
129+
const bucket = sessionBucket(session);
130+
if (bucket === 'stale') return 'STALE';
131+
if (bucket === 'blocked') return 'BLOCKED';
132+
if (normalizeState(session.prState) === 'merged') return 'MERGED';
133+
if (session.prUrl) return 'PR OPEN';
134+
if (Array.isArray(session.changedFiles) && session.changedFiles.length > 0) return 'CHANGED';
135+
return 'OPEN';
136+
}
137+
42138
function renderSession(session, index) {
43139
const lines = [
44-
`${index + 1}. ${session.agentName || 'agent'} | ${session.status || 'unknown'}`,
140+
`${index + 1}. ${session.agentName || 'agent'} | ${readinessSummary(session)} | ${session.status || 'unknown'}`,
45141
` branch: ${session.branch || '-'}`,
142+
` progress: ${progressSummary(session)}`,
46143
` worktree: ${worktreeSummary(session)}`,
47144
` locks: ${lockCountSummary(session)}`,
48145
];
49146

147+
if (Array.isArray(session.changedFiles)) {
148+
lines.push(` changed: ${filePreview(session.changedFiles)}`);
149+
}
50150
if (session.task) {
51151
lines.push(` task: ${session.task}`);
52152
}
@@ -66,23 +166,35 @@ function renderSession(session, index) {
66166

67167
function renderCockpit(state) {
68168
const sessions = Array.isArray(state && state.sessions) ? state.sessions : [];
169+
const grouped = groupSessions(sessions);
69170
const lines = [
70-
'GitGuardex Cockpit',
171+
'GitGuardex Cockpit Fleet',
71172
line('repo', state && state.repoPath),
72173
line('base', state && state.baseBranch),
73174
line('active sessions', String(sessions.length)),
175+
line('summary', summaryLine(grouped)),
176+
'actions: Enter inspect | f finish | h handoff | r refresh',
74177
'',
75178
];
76179

77180
if (sessions.length === 0) {
78181
lines.push('No active agent sessions.');
79182
} else {
80-
sessions.forEach((session, index) => {
81-
if (index > 0) {
183+
let displayIndex = 0;
184+
BUCKETS.forEach((bucket) => {
185+
const bucketSessions = grouped.get(bucket.key);
186+
if (bucketSessions.length === 0) return;
187+
if (displayIndex > 0) {
82188
lines.push('');
83189
}
84-
lines.push(renderSession(session, index));
190+
lines.push(`${bucket.title} (${bucketSessions.length})`);
191+
bucketSessions.forEach((session) => {
192+
lines.push(renderSession(session, displayIndex));
193+
displayIndex += 1;
194+
});
85195
});
196+
lines.push('');
197+
lines.push('detail: selected lane shows branch, progress, claims, changed files, PR, heartbeat, and Colony metadata.');
86198
}
87199

88200
return `${lines.join('\n')}\n`;
@@ -91,6 +203,11 @@ function renderCockpit(state) {
91203
module.exports = {
92204
renderCockpit,
93205
renderSession,
206+
filePreview,
207+
groupSessions,
208+
progressSummary,
209+
readinessSummary,
210+
sessionBucket,
94211
lockSummary,
95212
lockCountSummary,
96213
metadataSummary,

test/cockpit-render.test.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,78 @@ test('renderCockpit returns a readable terminal string', () => {
4444
assert.match(output, /repo: \/repo\/example/);
4545
assert.match(output, /base: main/);
4646
assert.match(output, /active sessions: 1/);
47+
assert.match(output, /summary: working=1 thinking=0 blocked=0 done=0 stale=0/);
48+
assert.match(output, /WORKING NOW \(1\)/);
49+
assert.match(output, /progress: Spec ok \| Code active \| Tests todo \| PR todo \| Merge todo \| Cleanup todo/);
4750
assert.match(output, /branch: agent\/codex\/example/);
4851
assert.match(output, /worktree: \/repo\/example\/\.omx\/agent-worktrees\/example \(present\)/);
4952
assert.match(output, /locks: 4 \(src\/cockpit\/render\.js, src\/cockpit\/state\.js, test\/cockpit-render\.test\.js, \+1 more\)/);
5053
assert.match(output, /task: implement cockpit/);
5154
assert.match(output, /colony: colony\.plan=queen-plan colony\.subtask=1/);
5255
});
5356

57+
test('renderCockpit groups sessions into fleet buckets', () => {
58+
const output = renderCockpit({
59+
repoPath: '/repo/example',
60+
baseBranch: 'main',
61+
sessions: [
62+
{
63+
agentName: 'codex',
64+
branch: 'agent/codex/working',
65+
worktreePath: '/repo/example/.omx/agent-worktrees/working',
66+
worktreeExists: true,
67+
status: 'working',
68+
task: 'implement UI',
69+
changedFiles: ['src/cockpit/render.js'],
70+
},
71+
{
72+
agentName: 'claude',
73+
branch: 'agent/claude/pending',
74+
worktreePath: '/repo/example/.omx/agent-worktrees/pending',
75+
worktreeExists: true,
76+
status: 'thinking',
77+
task: 'review spec',
78+
},
79+
{
80+
agentName: 'codex',
81+
branch: 'agent/codex/blocked',
82+
worktreePath: '/repo/example/.omx/agent-worktrees/blocked',
83+
worktreeExists: true,
84+
status: 'blocked',
85+
task: 'fix test',
86+
},
87+
{
88+
agentName: 'codex',
89+
branch: 'agent/codex/merged',
90+
worktreePath: '/repo/example/.omx/agent-worktrees/merged',
91+
worktreeExists: true,
92+
status: 'complete',
93+
prState: 'MERGED',
94+
prUrl: 'https://github.com/example/repo/pull/1',
95+
},
96+
{
97+
agentName: 'codex',
98+
branch: 'agent/codex/stale',
99+
worktreePath: '/repo/example/.omx/agent-worktrees/stale',
100+
worktreeExists: false,
101+
status: 'working',
102+
},
103+
],
104+
});
105+
106+
assert.match(output, /summary: working=1 thinking=1 blocked=1 done=1 stale=1/);
107+
assert.match(output, /WORKING NOW \(1\)/);
108+
assert.match(output, /THINKING \(1\)/);
109+
assert.match(output, /BLOCKED \(1\)/);
110+
assert.match(output, /DONE \(1\)/);
111+
assert.match(output, /STALE \(1\)/);
112+
assert.match(output, /codex \| CHANGED \| working/);
113+
assert.match(output, /codex \| MERGED \| complete/);
114+
assert.match(output, /codex \| STALE \| working/);
115+
assert.match(output, /changed: 1 \(src\/cockpit\/render\.js\)/);
116+
assert.match(output, /detail: selected lane shows branch, progress, claims, changed files, PR, heartbeat, and Colony metadata\./);
117+
});
118+
54119
test('agents status payload and cockpit state see the same session', () => {
55120
const repoPath = initRepo();
56121
const worktreePath = path.join(repoPath, '.omx', 'agent-worktrees', 'example');

0 commit comments

Comments
 (0)