repoburg 1.3.185 → 1.3.188

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/agent-pane/backend/package-lock.json +2359 -0
  2. package/agent-pane/backend/package.json +29 -0
  3. package/agent-pane/backend/src/codex/codex-provider.ts +363 -0
  4. package/agent-pane/backend/src/codex/item-mapper.ts +59 -0
  5. package/agent-pane/backend/src/db/index.ts +91 -0
  6. package/agent-pane/backend/src/db/schema.ts +76 -0
  7. package/agent-pane/backend/src/index.ts +57 -0
  8. package/agent-pane/backend/src/routes/sessions.ts +134 -0
  9. package/agent-pane/backend/src/routes/turns.ts +60 -0
  10. package/agent-pane/backend/src/routes/workspaces.ts +110 -0
  11. package/agent-pane/backend/src/ws/index.ts +43 -0
  12. package/agent-pane/backend/tsconfig.json +23 -0
  13. package/agent-pane/frontend/index.html +18 -0
  14. package/agent-pane/frontend/package-lock.json +1948 -0
  15. package/agent-pane/frontend/package.json +28 -0
  16. package/agent-pane/frontend/src/App.tsx +44 -0
  17. package/agent-pane/frontend/src/AppInitializer.tsx +46 -0
  18. package/agent-pane/frontend/src/api/client.ts +41 -0
  19. package/agent-pane/frontend/src/api/sessions.ts +47 -0
  20. package/agent-pane/frontend/src/api/websocket.ts +149 -0
  21. package/agent-pane/frontend/src/api/workspaces.ts +24 -0
  22. package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +50 -0
  23. package/agent-pane/frontend/src/components/CommandInputBar.tsx +42 -0
  24. package/agent-pane/frontend/src/components/CommandPalette.tsx +207 -0
  25. package/agent-pane/frontend/src/components/DiffViewer.tsx +27 -0
  26. package/agent-pane/frontend/src/components/IdeWorkspace.tsx +25 -0
  27. package/agent-pane/frontend/src/components/NewSessionDialog.tsx +84 -0
  28. package/agent-pane/frontend/src/components/RenderArea.tsx +170 -0
  29. package/agent-pane/frontend/src/components/RightPanel.tsx +40 -0
  30. package/agent-pane/frontend/src/components/RuntimeIcon.tsx +16 -0
  31. package/agent-pane/frontend/src/components/SessionTabs.tsx +44 -0
  32. package/agent-pane/frontend/src/components/StatusBar.tsx +68 -0
  33. package/agent-pane/frontend/src/components/StatusDot.tsx +13 -0
  34. package/agent-pane/frontend/src/components/Toast.tsx +80 -0
  35. package/agent-pane/frontend/src/components/TurnContent.tsx +294 -0
  36. package/agent-pane/frontend/src/components/WorkspaceSidebar.tsx +350 -0
  37. package/agent-pane/frontend/src/components/panels/ChangesPanel.tsx +81 -0
  38. package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +25 -0
  39. package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +82 -0
  40. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +45 -0
  41. package/agent-pane/frontend/src/main.tsx +10 -0
  42. package/agent-pane/frontend/src/store/store.ts +635 -0
  43. package/agent-pane/frontend/src/styles/app.css +365 -0
  44. package/agent-pane/frontend/src/styles/tokens.css +62 -0
  45. package/agent-pane/frontend/src/types/index.ts +153 -0
  46. package/agent-pane/frontend/tsconfig.json +22 -0
  47. package/agent-pane/frontend/vite.config.ts +16 -0
  48. package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
  49. package/package.json +2 -1
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "agent-pane-backend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "tsx src/index.ts",
8
+ "build": "esbuild src/index.ts --bundle --platform=node --outfile=dist/index.js --format=esm",
9
+ "start": "node dist/index.js",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@fastify/cors": "^10.0.0",
14
+ "@openai/codex-sdk": "^0.147.0",
15
+ "better-sqlite3": "^11.8.0",
16
+ "fastify": "^5.2.0",
17
+ "uuid": "^11.1.0",
18
+ "ws": "^8.18.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/better-sqlite3": "^7.6.12",
22
+ "@types/node": "^22.10.0",
23
+ "@types/uuid": "^10.0.0",
24
+ "@types/ws": "^8.5.13",
25
+ "esbuild": "^0.24.0",
26
+ "tsx": "^4.19.0",
27
+ "typescript": "^5.7.0"
28
+ }
29
+ }
@@ -0,0 +1,363 @@
1
+ import { Codex, type Thread, type ThreadEvent, type ThreadItem, type ThreadOptions } from '@openai/codex-sdk';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import Database from 'better-sqlite3';
4
+ import { readFileSync, writeFileSync, unlinkSync } from 'node:fs';
5
+ import { getDb } from '../db/index.ts';
6
+ import { broadcast } from '../ws/index.ts';
7
+ import { mapItemData, getItemStatus } from './item-mapper.ts';
8
+ import type { Session, Turn, Item, ItemStatus } from '../db/schema.ts';
9
+
10
+ interface ThreadEntry {
11
+ codex: Codex;
12
+ thread: Thread;
13
+ abortController: AbortController | null;
14
+ }
15
+
16
+ const threadMap = new Map<string, ThreadEntry>();
17
+
18
+ function getThreadOptions(session: Session): ThreadOptions {
19
+ return {
20
+ model: session.model ?? undefined,
21
+ workingDirectory: session.cwd ?? undefined,
22
+ sandboxMode: 'workspace-write',
23
+ approvalPolicy: 'never',
24
+ skipGitRepoCheck: true,
25
+ };
26
+ }
27
+
28
+ function getOrCreateThread(session: Session): { codex: Codex; thread: Thread } {
29
+ const existing = threadMap.get(session.id);
30
+ if (existing) {
31
+ return { codex: existing.codex, thread: existing.thread };
32
+ }
33
+
34
+ const codex = new Codex();
35
+ let thread: Thread;
36
+
37
+ if (session.external_thread_id) {
38
+ thread = codex.resumeThread(session.external_thread_id, getThreadOptions(session));
39
+ } else {
40
+ thread = codex.startThread(getThreadOptions(session));
41
+ }
42
+
43
+ return { codex, thread };
44
+ }
45
+
46
+ /** Start a Codex turn. Fire-and-forget — streams events via WS. */
47
+ export async function startTurn(session: Session, turn: Turn): Promise<void> {
48
+ const db = getDb();
49
+
50
+ // Phase 1: create or resume thread
51
+ let codex: Codex;
52
+ let thread: Thread;
53
+
54
+ try {
55
+ const result = getOrCreateThread(session);
56
+ codex = result.codex;
57
+ thread = result.thread;
58
+ } catch (err) {
59
+ const errMsg = `Failed to create Codex thread: ${(err as Error).message}`;
60
+ const now = new Date().toISOString();
61
+ db.prepare('UPDATE turns SET status = ?, error = ?, updated_at = ? WHERE id = ?')
62
+ .run('failed', errMsg, now, turn.id);
63
+ db.prepare('UPDATE sessions SET is_running = 0, status = ?, updated_at = ? WHERE id = ?')
64
+ .run('failed', now, session.id);
65
+ broadcast('turn:failed', { sessionId: session.id, turnId: turn.id, error: errMsg });
66
+ return;
67
+ }
68
+
69
+ // Phase 2: set up abort controller and update status
70
+ const abortController = new AbortController();
71
+ threadMap.set(session.id, { codex, thread, abortController });
72
+
73
+ const now = new Date().toISOString();
74
+ db.prepare('UPDATE turns SET status = ?, updated_at = ? WHERE id = ?')
75
+ .run('running', now, turn.id);
76
+ db.prepare('UPDATE sessions SET is_running = 1, status = ?, updated_at = ? WHERE id = ?')
77
+ .run('running', now, session.id);
78
+
79
+ broadcast('turn:started', { sessionId: session.id, turnId: turn.id });
80
+
81
+ // Phase 3: run the turn and handle events
82
+ try {
83
+ const { events } = await thread.runStreamed(turn.user_prompt, { signal: abortController.signal });
84
+
85
+ for await (const event of events) {
86
+ handleEvent(event, session.id, turn.id);
87
+ }
88
+ } catch (err) {
89
+ const isAbort = abortController.signal.aborted || (err as Error).name === 'AbortError';
90
+ const errMsg = (err as Error).message;
91
+ const now2 = new Date().toISOString();
92
+
93
+ if (isAbort) {
94
+ db.prepare('UPDATE turns SET status = ?, updated_at = ? WHERE id = ?')
95
+ .run('aborted', now2, turn.id);
96
+ db.prepare('UPDATE sessions SET is_running = 0, status = ?, updated_at = ? WHERE id = ?')
97
+ .run('active', now2, session.id);
98
+ broadcast('turn:failed', { sessionId: session.id, turnId: turn.id, error: 'Turn cancelled by user' });
99
+ } else {
100
+ db.prepare('UPDATE turns SET status = ?, error = ?, updated_at = ? WHERE id = ?')
101
+ .run('failed', errMsg, now2, turn.id);
102
+ db.prepare('UPDATE sessions SET is_running = 0, status = ?, updated_at = ? WHERE id = ?')
103
+ .run('failed', now2, session.id);
104
+ broadcast('turn:failed', { sessionId: session.id, turnId: turn.id, error: errMsg });
105
+ }
106
+ } finally {
107
+ const entry = threadMap.get(session.id);
108
+ if (entry) {
109
+ threadMap.set(session.id, { ...entry, abortController: null });
110
+ }
111
+ }
112
+ }
113
+
114
+ /** Cancel a running turn by session ID. Returns true if a turn was cancelled. */
115
+ export function interruptTurn(sessionId: string): boolean {
116
+ const entry = threadMap.get(sessionId);
117
+ if (entry?.abortController) {
118
+ entry.abortController.abort();
119
+ return true;
120
+ }
121
+ return false;
122
+ }
123
+
124
+ function handleEvent(event: ThreadEvent, sessionId: string, turnId: string): void {
125
+ const db = getDb();
126
+ const now = new Date().toISOString();
127
+
128
+ switch (event.type) {
129
+ case 'thread.started': {
130
+ db.prepare('UPDATE sessions SET external_thread_id = ?, updated_at = ? WHERE id = ?')
131
+ .run(event.thread_id, now, sessionId);
132
+ break;
133
+ }
134
+
135
+ case 'turn.started': {
136
+ // Already broadcasted turn:started before the stream started.
137
+ break;
138
+ }
139
+
140
+ case 'item.started': {
141
+ const item = event.item;
142
+ const itemId = uuidv4();
143
+ const data = mapItemData(item);
144
+ db.prepare(
145
+ 'INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
146
+ ).run(itemId, turnId, item.id, item.type, JSON.stringify(data), 'in_progress', now, now);
147
+
148
+ broadcast('item:started', { sessionId, turnId, item: makeItem(itemId, turnId, item, 'in_progress', now, now) });
149
+ break;
150
+ }
151
+
152
+ case 'item.updated': {
153
+ const item = event.item;
154
+ const data = mapItemData(item);
155
+ const result = db.prepare('UPDATE items SET data = ?, updated_at = ? WHERE external_item_id = ? AND turn_id = ?')
156
+ .run(JSON.stringify(data), now, item.id, turnId);
157
+
158
+ // SDK may skip item.started — insert if missing
159
+ if (result.changes === 0) {
160
+ const itemId = uuidv4();
161
+ db.prepare(
162
+ 'INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
163
+ ).run(itemId, turnId, item.id, item.type, JSON.stringify(data), 'in_progress', now, now);
164
+ broadcast('item:updated', { sessionId, turnId, item: makeItem(itemId, turnId, item, 'in_progress', now, now) });
165
+ } else {
166
+ const row = db.prepare('SELECT id, status, created_at FROM items WHERE external_item_id = ? AND turn_id = ?')
167
+ .get(item.id, turnId) as { id: string; status: ItemStatus; created_at: string } | undefined;
168
+ if (row) {
169
+ broadcast('item:updated', { sessionId, turnId, item: makeItem(row.id, turnId, item, row.status, row.created_at, now) });
170
+ }
171
+ }
172
+ break;
173
+ }
174
+
175
+ case 'item.completed': {
176
+ const item = event.item;
177
+ const status = getItemStatus(item);
178
+ const data = mapItemData(item);
179
+ const result = db.prepare('UPDATE items SET data = ?, status = ?, updated_at = ? WHERE external_item_id = ? AND turn_id = ?')
180
+ .run(JSON.stringify(data), status, now, item.id, turnId);
181
+
182
+ // SDK may skip item.started — insert if missing
183
+ let itemId: string;
184
+ let createdAt: string;
185
+ if (result.changes === 0) {
186
+ itemId = uuidv4();
187
+ createdAt = now;
188
+ db.prepare(
189
+ 'INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
190
+ ).run(itemId, turnId, item.id, item.type, JSON.stringify(data), status, now, now);
191
+ } else {
192
+ const row = db.prepare('SELECT id, created_at FROM items WHERE external_item_id = ? AND turn_id = ?')
193
+ .get(item.id, turnId) as { id: string; created_at: string };
194
+ itemId = row.id;
195
+ createdAt = row.created_at;
196
+ }
197
+
198
+ // Update turn-level fields
199
+ if (item.type === 'agent_message') {
200
+ db.prepare('UPDATE turns SET agent_response = ?, updated_at = ? WHERE id = ?')
201
+ .run(item.text, now, turnId);
202
+ }
203
+ if (item.type === 'reasoning') {
204
+ const existing = db.prepare('SELECT thoughts FROM turns WHERE id = ?').get(turnId) as { thoughts: string | null };
205
+ const thoughts = existing.thoughts ? existing.thoughts + '\n' + item.text : item.text;
206
+ db.prepare('UPDATE turns SET thoughts = ?, updated_at = ? WHERE id = ?')
207
+ .run(thoughts, now, turnId);
208
+ }
209
+
210
+ broadcast('item:completed', { sessionId, turnId, item: makeItem(itemId, turnId, item, status, createdAt, now) });
211
+ break;
212
+ }
213
+
214
+ case 'turn.completed': {
215
+ const usage = event.usage;
216
+ db.prepare('UPDATE turns SET status = ?, token_usage = ?, updated_at = ? WHERE id = ?')
217
+ .run('completed', JSON.stringify(usage), now, turnId);
218
+ db.prepare('UPDATE sessions SET is_running = 0, status = ?, has_unreviewed = 1, updated_at = ? WHERE id = ?')
219
+ .run('active', now, sessionId);
220
+ broadcast('turn:completed', { sessionId, turnId, usage, hasUnreviewed: true });
221
+
222
+ // Auto-generate session title from first turn (if title is still null)
223
+ syncThreadTitle(sessionId);
224
+ break;
225
+ }
226
+
227
+ case 'turn.failed': {
228
+ db.prepare('UPDATE turns SET status = ?, error = ?, updated_at = ? WHERE id = ?')
229
+ .run('failed', event.error.message, now, turnId);
230
+ db.prepare('UPDATE sessions SET is_running = 0, status = ?, updated_at = ? WHERE id = ?')
231
+ .run('failed', now, sessionId);
232
+ broadcast('turn:failed', { sessionId, turnId, error: event.error.message });
233
+ break;
234
+ }
235
+
236
+ case 'error': {
237
+ db.prepare('UPDATE turns SET status = ?, error = ?, updated_at = ? WHERE id = ?')
238
+ .run('failed', event.message, now, turnId);
239
+ db.prepare('UPDATE sessions SET is_running = 0, status = ?, updated_at = ? WHERE id = ?')
240
+ .run('failed', now, sessionId);
241
+ broadcast('turn:failed', { sessionId, turnId, error: event.message });
242
+ break;
243
+ }
244
+ }
245
+ }
246
+
247
+ function makeItem(
248
+ id: string,
249
+ turnId: string,
250
+ sdkItem: ThreadItem,
251
+ status: ItemStatus,
252
+ createdAt: string,
253
+ updatedAt: string,
254
+ ): Item {
255
+ return {
256
+ id,
257
+ turn_id: turnId,
258
+ external_item_id: sdkItem.id,
259
+ type: sdkItem.type,
260
+ data: mapItemData(sdkItem),
261
+ status,
262
+ created_at: createdAt,
263
+ updated_at: updatedAt,
264
+ };
265
+ }
266
+
267
+ /** Tracks sessions currently generating titles — prevents duplicate throwaway threads. */
268
+ const titleGenerating = new Set<string>();
269
+
270
+ /** Generate a title for the session using a throwaway Codex thread.
271
+ * Only runs once per session — guarded by title === null AND titleGenerating set. */
272
+ function syncThreadTitle(sessionId: string): void {
273
+ const db = getDb();
274
+ const session = db.prepare('SELECT title FROM sessions WHERE id = ?')
275
+ .get(sessionId) as { title: string | null } | undefined;
276
+ if (!session || session.title !== null) return; // Already has a title
277
+ if (titleGenerating.has(sessionId)) return; // Already generating
278
+
279
+ // Get the first turn's user_prompt and agent_response
280
+ const turn = db.prepare('SELECT user_prompt, agent_response FROM turns WHERE session_id = ? ORDER BY created_at ASC LIMIT 1')
281
+ .get(sessionId) as { user_prompt: string; agent_response: string | null } | undefined;
282
+ if (!turn) return;
283
+
284
+ // Mark as generating — prevents concurrent title generation for same session
285
+ titleGenerating.add(sessionId);
286
+
287
+ // Fire-and-forget — don't block the turn:completed handler
288
+ generateTitleAsync(sessionId, turn.user_prompt, turn.agent_response).finally(() => {
289
+ titleGenerating.delete(sessionId);
290
+ });
291
+ }
292
+
293
+ /** Create a throwaway Codex thread, ask for a title, update the session, then clean up. */
294
+ async function generateTitleAsync(sessionId: string, userPrompt: string, agentResponse: string | null): Promise<void> {
295
+ let throwawayThreadId: string | null = null;
296
+ try {
297
+ const codex = new Codex();
298
+ const thread = codex.startThread({ skipGitRepoCheck: true });
299
+ throwawayThreadId = thread.id;
300
+
301
+ const truncatedPrompt = userPrompt.slice(0, 500);
302
+ const truncatedResponse = (agentResponse || '').slice(0, 500);
303
+
304
+ const titlePrompt = `Generate a concise 3-5 word title for this conversation. Reply with only the title, nothing else.\n\nUser asked: "${truncatedPrompt}"\n\nAgent responded: "${truncatedResponse}"`;
305
+
306
+ const result = await thread.run(titlePrompt);
307
+ const title = result.finalResponse.trim().replace(/^["']|["']$/g, '').slice(0, 100);
308
+
309
+ if (title) {
310
+ const db = getDb();
311
+ const now = new Date().toISOString();
312
+ db.prepare('UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?').run(title, now, sessionId);
313
+ broadcast('session:title', { sessionId, title });
314
+ }
315
+ } catch {
316
+ // Silently skip on error — session keeps null title
317
+ } finally {
318
+ // Clean up the throwaway thread
319
+ if (throwawayThreadId) {
320
+ cleanupThrowawayThread(throwawayThreadId);
321
+ }
322
+ }
323
+ }
324
+
325
+ /** Delete throwaway thread from Codex state DB, rollout file, and session_index.jsonl. */
326
+ function cleanupThrowawayThread(threadId: string): void {
327
+ const home = process.env.HOME || process.env.USERPROFILE || '';
328
+
329
+ // 1. Delete from state DB and get rollout path
330
+ try {
331
+ const codexDb = new Database(`${home}/.codex/state_5.sqlite`);
332
+ const row = codexDb.prepare('SELECT rollout_path FROM threads WHERE id = ?')
333
+ .get(threadId) as { rollout_path: string } | undefined;
334
+
335
+ codexDb.prepare('DELETE FROM thread_spawn_edges WHERE child_thread_id = ? OR parent_thread_id = ?').run(threadId, threadId);
336
+ codexDb.prepare('DELETE FROM threads WHERE id = ?').run(threadId);
337
+ codexDb.close();
338
+
339
+ // 2. Delete rollout file
340
+ if (row?.rollout_path) {
341
+ try { unlinkSync(row.rollout_path); } catch { /* file may not exist */ }
342
+ }
343
+ } catch {
344
+ // DB not found — skip
345
+ }
346
+
347
+ // 3. Remove from session_index.jsonl
348
+ try {
349
+ const indexPath = `${home}/.codex/session_index.jsonl`;
350
+ const content = readFileSync(indexPath, 'utf-8');
351
+ const lines = content.trim().split('\n').filter((line) => {
352
+ try {
353
+ const entry = JSON.parse(line) as { id: string };
354
+ return entry.id !== threadId;
355
+ } catch {
356
+ return true; // keep unparseable lines
357
+ }
358
+ });
359
+ writeFileSync(indexPath, lines.join('\n') + '\n');
360
+ } catch {
361
+ // File not found — skip
362
+ }
363
+ }
@@ -0,0 +1,59 @@
1
+ import type { ThreadItem } from '@openai/codex-sdk';
2
+
3
+ /** Map a Codex ThreadItem to the JSON `data` field for the items table. */
4
+ export function mapItemData(item: ThreadItem): Record<string, unknown> {
5
+ switch (item.type) {
6
+ case 'agent_message':
7
+ return { text: item.text };
8
+
9
+ case 'reasoning':
10
+ return { text: item.text };
11
+
12
+ case 'command_execution':
13
+ return {
14
+ command: item.command,
15
+ aggregated_output: item.aggregated_output,
16
+ exit_code: item.exit_code ?? null,
17
+ status: item.status,
18
+ };
19
+
20
+ case 'file_change':
21
+ return {
22
+ changes: item.changes,
23
+ status: item.status,
24
+ };
25
+
26
+ case 'mcp_tool_call':
27
+ return {
28
+ server: item.server,
29
+ tool: item.tool,
30
+ arguments: item.arguments,
31
+ result: item.result ?? null,
32
+ error: item.error ?? null,
33
+ status: item.status,
34
+ };
35
+
36
+ case 'web_search':
37
+ return { query: item.query };
38
+
39
+ case 'todo_list':
40
+ return { items: item.items };
41
+
42
+ case 'error':
43
+ return { message: item.message };
44
+
45
+ default:
46
+ return {};
47
+ }
48
+ }
49
+
50
+ /** Determine the DB status from a completed ThreadItem. */
51
+ export function getItemStatus(item: ThreadItem): 'in_progress' | 'completed' | 'failed' {
52
+ if (item.type === 'command_execution' || item.type === 'file_change' || item.type === 'mcp_tool_call') {
53
+ return item.status;
54
+ }
55
+ if (item.type === 'error') {
56
+ return 'failed';
57
+ }
58
+ return 'completed';
59
+ }
@@ -0,0 +1,91 @@
1
+ import Database from 'better-sqlite3';
2
+ import type { Database as DB } from 'better-sqlite3';
3
+
4
+ let db: DB;
5
+
6
+ export function initDb(path: string = 'data.db'): DB {
7
+ db = new Database(path);
8
+ db.pragma('journal_mode = WAL');
9
+ db.pragma('foreign_keys = ON');
10
+
11
+ createTables(db);
12
+ return db;
13
+ }
14
+
15
+ export function getDb(): DB {
16
+ if (!db) {
17
+ throw new Error('Database not initialized. Call initDb() first.');
18
+ }
19
+ return db;
20
+ }
21
+
22
+ function createTables(database: DB): void {
23
+ database.exec(`
24
+ CREATE TABLE IF NOT EXISTS sessions (
25
+ id TEXT PRIMARY KEY,
26
+ title TEXT,
27
+ runtime_type TEXT NOT NULL DEFAULT 'codex',
28
+ model TEXT,
29
+ cwd TEXT,
30
+ external_thread_id TEXT,
31
+ status TEXT NOT NULL DEFAULT 'active',
32
+ is_running INTEGER NOT NULL DEFAULT 0,
33
+ created_at TEXT NOT NULL,
34
+ updated_at TEXT NOT NULL
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS turns (
38
+ id TEXT PRIMARY KEY,
39
+ session_id TEXT NOT NULL,
40
+ user_prompt TEXT NOT NULL,
41
+ agent_response TEXT,
42
+ thoughts TEXT,
43
+ token_usage TEXT,
44
+ external_turn_id TEXT,
45
+ status TEXT NOT NULL DEFAULT 'pending',
46
+ error TEXT,
47
+ created_at TEXT NOT NULL,
48
+ updated_at TEXT NOT NULL,
49
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
50
+ );
51
+
52
+ CREATE TABLE IF NOT EXISTS items (
53
+ id TEXT PRIMARY KEY,
54
+ turn_id TEXT NOT NULL,
55
+ external_item_id TEXT,
56
+ type TEXT NOT NULL,
57
+ data TEXT,
58
+ status TEXT NOT NULL DEFAULT 'in_progress',
59
+ created_at TEXT NOT NULL,
60
+ updated_at TEXT NOT NULL,
61
+ FOREIGN KEY (turn_id) REFERENCES turns(id) ON DELETE CASCADE
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS workspaces (
65
+ id TEXT PRIMARY KEY,
66
+ path TEXT NOT NULL UNIQUE,
67
+ label TEXT,
68
+ deleted INTEGER NOT NULL DEFAULT 0,
69
+ created_at TEXT NOT NULL
70
+ );
71
+
72
+ CREATE INDEX IF NOT EXISTS idx_turns_session_id ON turns(session_id);
73
+ CREATE INDEX IF NOT EXISTS idx_items_turn_id ON items(turn_id);
74
+ CREATE INDEX IF NOT EXISTS idx_workspaces_path ON workspaces(path);
75
+ `);
76
+
77
+ // Migration: add 'deleted' column to existing workspaces table if missing
78
+ const wsColumns = database.prepare("PRAGMA table_info(workspaces)").all() as { name: string }[];
79
+ if (!wsColumns.some((c) => c.name === 'deleted')) {
80
+ database.exec('ALTER TABLE workspaces ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0');
81
+ }
82
+
83
+ // Migration: add 'lifecycle' + 'has_unreviewed' columns to existing sessions table
84
+ const sessColumns = database.prepare("PRAGMA table_info(sessions)").all() as { name: string }[];
85
+ if (!sessColumns.some((c) => c.name === 'lifecycle')) {
86
+ database.exec("ALTER TABLE sessions ADD COLUMN lifecycle TEXT NOT NULL DEFAULT 'working'");
87
+ }
88
+ if (!sessColumns.some((c) => c.name === 'has_unreviewed')) {
89
+ database.exec('ALTER TABLE sessions ADD COLUMN has_unreviewed INTEGER NOT NULL DEFAULT 0');
90
+ }
91
+ }
@@ -0,0 +1,76 @@
1
+ // Database schema — TypeScript interfaces (no ORM, raw SQL).
2
+
3
+ export type SessionStatus = 'active' | 'running' | 'completed' | 'failed' | 'aborted';
4
+ export type TurnStatus = 'pending' | 'running' | 'completed' | 'failed' | 'aborted';
5
+ export type ItemType =
6
+ | 'agent_message'
7
+ | 'reasoning'
8
+ | 'command_execution'
9
+ | 'file_change'
10
+ | 'mcp_tool_call'
11
+ | 'web_search'
12
+ | 'todo_list'
13
+ | 'error';
14
+ export type ItemStatus = 'in_progress' | 'completed' | 'failed';
15
+
16
+ export interface Session {
17
+ id: string;
18
+ title: string | null;
19
+ runtime_type: string; // 'codex' (future: 'claude', 'cursor')
20
+ model: string | null;
21
+ cwd: string | null;
22
+ external_thread_id: string | null; // Codex thread ID for resume
23
+ status: SessionStatus;
24
+ is_running: number; // 0 | 1
25
+ lifecycle: string; // 'working' | 'finished'
26
+ has_unreviewed: number; // 0 | 1
27
+ created_at: string; // ISO
28
+ updated_at: string; // ISO
29
+ }
30
+
31
+ export interface TokenUsage {
32
+ input_tokens: number;
33
+ cached_input_tokens: number;
34
+ cache_write_input_tokens: number;
35
+ output_tokens: number;
36
+ reasoning_output_tokens: number;
37
+ }
38
+
39
+ export interface Turn {
40
+ id: string;
41
+ session_id: string;
42
+ user_prompt: string;
43
+ agent_response: string | null;
44
+ thoughts: string | null;
45
+ token_usage: TokenUsage | null;
46
+ external_turn_id: string | null;
47
+ status: TurnStatus;
48
+ error: string | null;
49
+ created_at: string;
50
+ updated_at: string;
51
+ }
52
+
53
+ export interface Item {
54
+ id: string;
55
+ turn_id: string;
56
+ external_item_id: string | null;
57
+ type: ItemType;
58
+ data: Record<string, unknown> | null;
59
+ status: ItemStatus;
60
+ created_at: string;
61
+ updated_at: string;
62
+ }
63
+
64
+ // Workspaces — directory paths for grouping sessions in sidebar
65
+ export interface Workspace {
66
+ id: string;
67
+ path: string; // directory path, e.g. "/Users/me/projects/my-app"
68
+ label: string | null; // optional custom label, null = use basename
69
+ deleted: number; // 0 = active, 1 = soft-deleted (hidden from sidebar)
70
+ created_at: string; // ISO
71
+ }
72
+
73
+ // Nested session detail for GET /api/sessions/:id
74
+ export interface SessionDetail extends Session {
75
+ turns: (Turn & { items: Item[] })[];
76
+ }
@@ -0,0 +1,57 @@
1
+ import Fastify from 'fastify';
2
+ import cors from '@fastify/cors';
3
+ import { initDb } from './db/index.ts';
4
+ import { initWs, handleUpgrade, getWss } from './ws/index.ts';
5
+ import { registerSessionRoutes } from './routes/sessions.ts';
6
+ import { registerTurnRoutes } from './routes/turns.ts';
7
+ import { registerWorkspaceRoutes } from './routes/workspaces.ts';
8
+
9
+ const PORT = 3001;
10
+
11
+ async function main(): Promise<void> {
12
+ // Initialize database
13
+ initDb('data.db');
14
+
15
+ // Create Fastify server
16
+ const fastify = Fastify({ logger: true });
17
+
18
+ // Register CORS (allow Vite dev server)
19
+ await fastify.register(cors, { origin: true });
20
+
21
+ // Register routes
22
+ await registerSessionRoutes(fastify);
23
+ await registerTurnRoutes(fastify);
24
+ await registerWorkspaceRoutes(fastify);
25
+
26
+ // Initialize WebSocket server (noServer mode)
27
+ const wss = initWs();
28
+
29
+ // Handle HTTP upgrade for /ws path
30
+ fastify.server.on('upgrade', (request, socket, head) => {
31
+ const wsServer = getWss();
32
+ if (wsServer) {
33
+ handleUpgrade(wsServer, request, socket, head);
34
+ } else {
35
+ socket.destroy();
36
+ }
37
+ });
38
+
39
+ wss.on('connection', (ws) => {
40
+ fastify.log.info('WebSocket client connected');
41
+ ws.on('close', () => {
42
+ fastify.log.info('WebSocket client disconnected');
43
+ });
44
+ });
45
+
46
+ // Start server
47
+ try {
48
+ await fastify.listen({ port: PORT, host: '0.0.0.0' });
49
+ fastify.log.info(`AgentPane backend running on http://localhost:${PORT}`);
50
+ fastify.log.info(`WebSocket endpoint: ws://localhost:${PORT}/ws`);
51
+ } catch (err) {
52
+ fastify.log.error(err);
53
+ process.exit(1);
54
+ }
55
+ }
56
+
57
+ main();