Skip to content

Commit 4fdcb41

Browse files
committed
Polish Coven demo loop
1 parent 966a639 commit 4fdcb41

16 files changed

Lines changed: 400 additions & 50 deletions

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,20 @@ comux works as a standalone tmux/worktree cockpit. It also speaks to Coven when
105105

106106
Coven is the harness substrate. comux is the cockpit. OpenMeow and OpenClaw can sit above them as intake and orchestration layers.
107107

108+
Demo loop:
109+
110+
1. Open a project in comux.
111+
2. Launch a Coven-backed Codex or Claude Code session.
112+
3. Watch it as a visible pane/session.
113+
4. Inspect files and diffs.
114+
5. Merge, create a PR, archive, or clean up explicitly.
115+
116+
See [comux + Coven demo loop](./docs/COVEN-DEMO-LOOP.md) and the [OpenCoven public roadmap](https://github.com/OpenCoven/coven/blob/main/docs/ROADMAP.md).
117+
108118
## Docs
109119

110120
- [Documentation index](./docs/README.md)
121+
- [comux + Coven demo loop](./docs/COVEN-DEMO-LOOP.md)
111122
- [Product spec](./docs/PRODUCT-SPEC.md)
112123
- [Smoke test](./docs/SMOKE.md)
113124
- [Contributing](./CONTRIBUTING.md)

__tests__/covenSessions.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from 'node:path';
44
import { describe, expect, it } from 'vitest';
55
import {
66
filterCovenSessionsForProjectRoots,
7+
listCovenSessionsFromDaemon,
78
parseCovenSessionsJson,
89
} from '../src/utils/covenSessions.js';
910

@@ -51,6 +52,30 @@ describe('coven session adapter', () => {
5152
expect(sessions.map((session) => session.id)).toEqual(['session-2']);
5253
});
5354

55+
it('loads sessions from the current Coven daemon API by default', async () => {
56+
const result = await listCovenSessionsFromDaemon({
57+
client: {
58+
listSessions: async () => [
59+
{
60+
id: 'session-3',
61+
projectRoot: '/repo',
62+
harness: 'claude',
63+
title: 'Review branch',
64+
status: 'running',
65+
createdAt: '2026-05-10T08:00:00Z',
66+
updatedAt: '2026-05-10T08:01:00Z',
67+
},
68+
],
69+
},
70+
});
71+
72+
expect(result).toMatchObject({
73+
status: 'ready',
74+
source: 'coven daemon API',
75+
sessions: [{ id: 'session-3', harness: 'claude' }],
76+
});
77+
});
78+
5479
it('filters sessions to verified comux project roots', async () => {
5580
const root = await tempDir('comux-coven-root-');
5681
const child = await tempDir('comux-coven-root-child-');

__tests__/daemon/bridge.test.ts

Lines changed: 89 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,43 @@ describe('daemon bridge Coven helpers', () => {
243243
});
244244

245245
describe('daemon bridge Coven API client', () => {
246+
it('accepts the current Coven daemon v1 health contract without legacy supported versions', async () => {
247+
const server = http.createServer((req, res) => {
248+
res.setHeader('Content-Type', 'application/json');
249+
if (req.url === '/api/v1/health') {
250+
res.end(JSON.stringify({
251+
ok: true,
252+
apiVersion: 'coven.daemon.v1',
253+
capabilities: {
254+
sessions: true,
255+
events: true,
256+
eventCursor: 'sequence',
257+
structuredErrors: true,
258+
},
259+
daemon: null,
260+
}));
261+
return;
262+
}
263+
if (req.url === '/api/v1/sessions') {
264+
res.end(JSON.stringify([]));
265+
return;
266+
}
267+
res.statusCode = 404;
268+
res.end(JSON.stringify({ error: { code: 'not_found', message: 'not found' } }));
269+
});
270+
271+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
272+
try {
273+
const address = server.address();
274+
if (!address || typeof address === 'string') throw new Error('expected TCP server');
275+
const client = createCovenClient({ baseUrl: `http://127.0.0.1:${address.port}` });
276+
277+
await expect(client.listSessions()).resolves.toEqual([]);
278+
} finally {
279+
await new Promise<void>((resolve) => server.close(() => resolve()));
280+
}
281+
});
282+
246283
it('accepts newer daemon API versions when v1 remains supported', async () => {
247284
const server = http.createServer((req, res) => {
248285
res.setHeader('Content-Type', 'application/json');
@@ -325,17 +362,17 @@ describe('daemon bridge Coven API client', () => {
325362
}
326363
});
327364

328-
it('requests Coven events after a since cursor when provided', async () => {
365+
it('requests Coven events after a sequence cursor when provided', async () => {
329366
const requests: string[] = [];
330367
const server = http.createServer((req, res) => {
331368
requests.push(req.url || '/');
332369
res.setHeader('Content-Type', 'application/json');
333370
if (req.url === '/api/v1/health') {
334-
res.end(JSON.stringify({ ok: true, apiVersion: 'v1', supportedApiVersions: ['v1'], daemon: null }));
371+
res.end(JSON.stringify({ ok: true, apiVersion: 'coven.daemon.v1', capabilities: { eventCursor: 'sequence' }, daemon: null }));
335372
return;
336373
}
337374
if (req.url?.startsWith('/api/v1/events?')) {
338-
res.end(JSON.stringify([]));
375+
res.end(JSON.stringify({ events: [], nextCursor: null, hasMore: false }));
339376
return;
340377
}
341378
res.statusCode = 404;
@@ -348,12 +385,44 @@ describe('daemon bridge Coven API client', () => {
348385
if (!address || typeof address === 'string') throw new Error('expected TCP server');
349386
const client = createCovenClient({ baseUrl: `http://127.0.0.1:${address.port}` });
350387

351-
await expect(client.listEvents?.('session-1', { since: '2026-05-10T08:00:02Z' })).resolves.toEqual([]);
388+
await expect(client.listEvents?.('session-1', { afterSeq: 42 })).resolves.toEqual([]);
352389
} finally {
353390
await new Promise<void>((resolve) => server.close(() => resolve()));
354391
}
355392

356-
expect(requests).toContain('/api/v1/events?sessionId=session-1&since=2026-05-10T08%3A00%3A02Z');
393+
expect(requests).toContain('/api/v1/events?sessionId=session-1&afterSeq=42');
394+
});
395+
396+
it('surfaces structured Coven API errors by code and message', async () => {
397+
const server = http.createServer((req, res) => {
398+
res.setHeader('Content-Type', 'application/json');
399+
if (req.url === '/api/v1/health') {
400+
res.end(JSON.stringify({ ok: true, apiVersion: 'coven.daemon.v1', capabilities: { structuredErrors: true }, daemon: null }));
401+
return;
402+
}
403+
res.statusCode = 409;
404+
res.end(JSON.stringify({
405+
error: {
406+
code: 'session_not_live',
407+
message: 'Session is not live.',
408+
details: { sessionId: 'session-1' },
409+
},
410+
}));
411+
});
412+
413+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
414+
try {
415+
const address = server.address();
416+
if (!address || typeof address === 'string') throw new Error('expected TCP server');
417+
const client = createCovenClient({ baseUrl: `http://127.0.0.1:${address.port}` });
418+
419+
await expect(client.getSession?.('session-1')).rejects.toMatchObject({
420+
code: 'session_not_live',
421+
message: 'Session is not live.',
422+
});
423+
} finally {
424+
await new Promise<void>((resolve) => server.close(() => resolve()));
425+
}
357426
});
358427

359428
it('retries Coven health after a transient failure', async () => {
@@ -421,15 +490,20 @@ describe('daemon bridge Coven API client', () => {
421490
return;
422491
}
423492
if (req.url === '/api/v1/events?sessionId=session-1') {
424-
res.end(JSON.stringify([
425-
{
426-
id: 'event-1',
427-
session_id: 'session-1',
428-
kind: 'output',
429-
payload_json: '{"data":"hello"}',
430-
created_at: '2026-05-10T08:00:02Z',
431-
},
432-
]));
493+
res.end(JSON.stringify({
494+
events: [
495+
{
496+
seq: 42,
497+
id: 'event-1',
498+
session_id: 'session-1',
499+
kind: 'output',
500+
payload_json: '{"data":"hello"}',
501+
created_at: '2026-05-10T08:00:02Z',
502+
},
503+
],
504+
nextCursor: { afterSeq: 42 },
505+
hasMore: false,
506+
}));
433507
return;
434508
}
435509
if (req.url === '/api/v1/sessions/session-1/input') {
@@ -448,7 +522,7 @@ describe('daemon bridge Coven API client', () => {
448522
const client = createCovenClient({ baseUrl: `http://127.0.0.1:${address.port}` });
449523

450524
await expect(client.listSessions()).resolves.toMatchObject([{ id: 'session-1', projectRoot: '/repo' }]);
451-
await expect(client.listEvents?.('session-1')).resolves.toMatchObject([{ id: 'event-1', sessionId: 'session-1' }]);
525+
await expect(client.listEvents?.('session-1')).resolves.toMatchObject([{ seq: 42, id: 'event-1', sessionId: 'session-1' }]);
452526
await expect(client.sendInput?.('session-1', 'hello')).resolves.toBeUndefined();
453527
} finally {
454528
await new Promise<void>((resolve) => server.close(() => resolve()));

__tests__/useCovenDesktopUse.test.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ const session: CovenSessionSummary = {
1818
updatedAt: '2026-05-10T08:00:03Z',
1919
};
2020

21-
const event = (id: string, createdAt: string): CovenSessionEvent => ({
21+
const event = (id: string, createdAt: string, seq?: number): CovenSessionEvent => ({
22+
seq,
2223
id,
2324
sessionId: 'session-1',
2425
kind: 'tool_result',
@@ -73,7 +74,7 @@ describe('loadCovenDesktopUseStates', () => {
7374
it('prunes cached session events for desktop-use panes that are no longer active', async () => {
7475
const cache = createDesktopUseLoadCache();
7576
cache.eventsBySessionId.set('stale-session', [event('stale-event', '2026-05-10T07:59:00Z')]);
76-
cache.sinceBySessionId.set('stale-session', '2026-05-10T07:59:00Z');
77+
cache.cursorBySessionId.set('stale-session', { afterSeq: 1 });
7778

7879
const client: Pick<CovenClient, 'getSession' | 'listEvents'> = {
7980
getSession: vi.fn(async () => session),
@@ -83,29 +84,29 @@ describe('loadCovenDesktopUseStates', () => {
8384
await loadCovenDesktopUseStates([pane('pane-1')], client, cache);
8485

8586
expect(cache.eventsBySessionId.has('stale-session')).toBe(false);
86-
expect(cache.sinceBySessionId.has('stale-session')).toBe(false);
87+
expect(cache.cursorBySessionId.has('stale-session')).toBe(false);
8788
expect(cache.eventsBySessionId.has('session-1')).toBe(true);
8889
});
8990

90-
it('uses a since cursor and bounded cached events for subsequent refreshes', async () => {
91+
it('uses the Coven event sequence cursor and bounded cached events for subsequent refreshes', async () => {
9192
const cache = createDesktopUseLoadCache();
92-
const calls: Array<{ sessionId: string; since?: string }> = [];
93+
const calls: Array<{ sessionId: string; afterSeq?: number }> = [];
9394
const client: Pick<CovenClient, 'getSession' | 'listEvents'> = {
9495
getSession: vi.fn(async () => session),
95-
listEvents: vi.fn(async (sessionId: string, options?: { since?: string }) => {
96-
calls.push({ sessionId, since: options?.since });
96+
listEvents: vi.fn(async (sessionId: string, options?: { afterSeq?: number }) => {
97+
calls.push({ sessionId, afterSeq: options?.afterSeq });
9798
return calls.length === 1
98-
? [event('event-1', '2026-05-10T08:00:01Z')]
99-
: [event('event-2', '2026-05-10T08:00:02Z')];
99+
? [event('event-1', '2026-05-10T08:00:01Z', 41)]
100+
: [event('event-2', '2026-05-10T08:00:02Z', 42)];
100101
}),
101102
};
102103

103104
const first = await loadCovenDesktopUseStates([pane('pane-1')], client, cache);
104105
const second = await loadCovenDesktopUseStates([pane('pane-1')], client, cache);
105106

106107
expect(calls).toEqual([
107-
{ sessionId: 'session-1', since: undefined },
108-
{ sessionId: 'session-1', since: '2026-05-10T08:00:01Z' },
108+
{ sessionId: 'session-1', afterSeq: undefined },
109+
{ sessionId: 'session-1', afterSeq: 41 },
109110
]);
110111
expect(first.get('pane-1')?.screenshotPath).toBe('/tmp/event-1.png');
111112
expect(second.get('pane-1')?.actions.map((action) => action.id)).toEqual(['event-2', 'event-1']);

docs/COVEN-DEMO-LOOP.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# comux + Coven demo loop
2+
3+
This loop is the public OpenCoven story in the smallest useful form:
4+
5+
1. Open a project in comux.
6+
2. Launch or attach a Coven-backed Codex or Claude Code session.
7+
3. Watch the work as a visible pane/session.
8+
4. Inspect files and diffs from comux.
9+
5. Merge, create a PR, archive, or clean up explicitly.
10+
11+
The upstream OpenCoven roadmap tracks this same slice under the comux "Next" milestone:
12+
https://github.com/OpenCoven/coven/blob/main/docs/ROADMAP.md
13+
14+
## Prerequisites
15+
16+
Install comux and Coven, then verify both from the same shell:
17+
18+
```bash
19+
npm install -g comux
20+
npx @opencoven/cli doctor
21+
```
22+
23+
Coven is optional. comux still works as a standalone tmux/worktree cockpit when Coven is not installed or the daemon is stopped.
24+
25+
For the Coven-backed path, start the local daemon:
26+
27+
```bash
28+
coven doctor
29+
coven daemon start
30+
coven daemon status
31+
```
32+
33+
## Demo path
34+
35+
From the repository you want to work in:
36+
37+
```bash
38+
cd /path/to/project
39+
comux
40+
```
41+
42+
Inside comux:
43+
44+
1. Press `n` to create a normal comux agent pane, or press `d` to launch the desktop-use Coven pane.
45+
2. For a CLI-launched Coven session, run one of these in a terminal pane:
46+
47+
```bash
48+
coven run codex "fix the failing tests" --title "Fix tests"
49+
coven run claude "review this branch" --title "Review branch"
50+
```
51+
52+
3. The side panel shows matching Coven sessions for the active project when the daemon API is reachable.
53+
4. Use `j` to watch the pane, `f` to inspect files and diffs, and `m` to open the pane menu.
54+
5. Finish with an explicit action: merge, create a GitHub PR, close/archive the session, or clean up the worktree.
55+
56+
## Current Coven contract verified by comux
57+
58+
comux talks to the local Coven daemon through `/api/v1`:
59+
60+
- `GET /api/v1/health`
61+
- `GET /api/v1/sessions`
62+
- `POST /api/v1/sessions`
63+
- `GET /api/v1/sessions/:id`
64+
- `GET /api/v1/events?sessionId=...`
65+
- `POST /api/v1/sessions/:id/input`
66+
67+
The current stable daemon contract is `apiVersion: "coven.daemon.v1"`. Event reads use the paginated event envelope with `nextCursor.afterSeq`, and comux keeps polling from that sequence cursor instead of replaying the whole event log.
68+
69+
The older `coven sessions --json` adapter remains available only as an explicit legacy fallback for visibility-only compatibility. The default list, launch, open, and event paths use the local daemon API.
70+
71+
## Unavailable states
72+
73+
If Coven is missing or stopped, comux keeps running:
74+
75+
- the side panel shows a compact Coven unavailable state;
76+
- desktop-use launch failures point at `coven daemon start`;
77+
- ordinary comux panes, worktrees, file browsing, merge, PR, and cleanup flows still work.
78+
79+
Use this recovery checklist:
80+
81+
```bash
82+
command -v coven
83+
coven doctor
84+
coven daemon restart
85+
coven daemon status
86+
```

docs/COVEN-SESSIONS.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,29 @@
11
# Coven session visibility
22

3-
comux treats Coven as an optional local runtime. The first integration slice is deliberately thin: comux can render a read-only Coven sessions section in the side panel when a future `coven sessions --json` command is available.
3+
comux treats Coven as an optional local runtime. comux stays useful on its own, and when a local Coven daemon is available it can show, launch, and attach Coven-managed sessions beside normal comux panes.
44

55
## Adapter boundary
66

7-
The TUI calls:
7+
The preferred bridge path is the local daemon API:
8+
9+
```text
10+
GET /api/v1/health
11+
GET /api/v1/sessions
12+
POST /api/v1/sessions
13+
GET /api/v1/sessions/:id
14+
GET /api/v1/events?sessionId=...
15+
POST /api/v1/sessions/:id/input
16+
```
17+
18+
comux first checks `GET /api/v1/health` and accepts the current stable `apiVersion: "coven.daemon.v1"` contract. Event polling accepts the current paginated envelope and stores `nextCursor.afterSeq`-style sequence progress by reading event `seq` values.
19+
20+
The legacy visibility-only CLI fallback is still supported for tests and older local builds when explicitly configured:
821

922
```bash
1023
coven sessions --json
1124
```
1225

13-
If the command is missing, unsupported, invalid JSON, or too slow, comux keeps running and shows a compact unavailable state. No unpublished Coven APIs are imported.
26+
If the daemon or command is missing, unsupported, invalid JSON, or too slow, comux keeps running and shows a compact unavailable state. No unpublished Coven APIs are imported.
1427

1528
## Proposed JSON contract
1629

@@ -57,5 +70,7 @@ Required fields for comux visibility are `id` and `projectRoot`/`project_root`.
5770
- Sessions whose project roots cannot be verified are hidden.
5871
- The side panel renders a small `☾ Coven sessions` section under each project with matching sessions.
5972
- Empty and unavailable states are non-fatal and stay inside the side panel.
73+
- Desktop-use panes launch through the daemon API and attach with `coven attach <session-id>`.
74+
- Socket/daemon failures are reported as action-oriented messages, such as starting Coven with `coven daemon start`.
6075

61-
Future slices can add selection, attach/open actions, and live event timelines without changing this adapter boundary.
76+
See [comux + Coven demo loop](COVEN-DEMO-LOOP.md) for the end-to-end demo path and the [OpenCoven public roadmap](https://github.com/OpenCoven/coven/blob/main/docs/ROADMAP.md) for the upstream milestone.

0 commit comments

Comments
 (0)