repoburg 1.3.183 → 1.3.186
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.
- package/agent-pane/backend/package-lock.json +2359 -0
- package/agent-pane/backend/package.json +29 -0
- package/agent-pane/backend/src/codex/codex-provider.ts +260 -0
- package/agent-pane/backend/src/codex/item-mapper.ts +59 -0
- package/agent-pane/backend/src/db/index.ts +67 -0
- package/agent-pane/backend/src/db/schema.ts +65 -0
- package/agent-pane/backend/src/index.ts +55 -0
- package/agent-pane/backend/src/routes/sessions.ts +97 -0
- package/agent-pane/backend/src/routes/turns.ts +60 -0
- package/agent-pane/backend/src/ws/index.ts +43 -0
- package/agent-pane/backend/tsconfig.json +23 -0
- package/agent-pane/frontend/index.html +18 -0
- package/agent-pane/frontend/package-lock.json +1948 -0
- package/agent-pane/frontend/package.json +28 -0
- package/agent-pane/frontend/src/App.tsx +44 -0
- package/agent-pane/frontend/src/AppInitializer.tsx +46 -0
- package/agent-pane/frontend/src/api/client.ts +29 -0
- package/agent-pane/frontend/src/api/sessions.ts +31 -0
- package/agent-pane/frontend/src/api/websocket.ts +139 -0
- package/agent-pane/frontend/src/components/CommandInputBar.tsx +42 -0
- package/agent-pane/frontend/src/components/CommandPalette.tsx +196 -0
- package/agent-pane/frontend/src/components/DiffViewer.tsx +27 -0
- package/agent-pane/frontend/src/components/IdeWorkspace.tsx +25 -0
- package/agent-pane/frontend/src/components/NewSessionDialog.tsx +84 -0
- package/agent-pane/frontend/src/components/RenderArea.tsx +157 -0
- package/agent-pane/frontend/src/components/RightPanel.tsx +40 -0
- package/agent-pane/frontend/src/components/RuntimeIcon.tsx +16 -0
- package/agent-pane/frontend/src/components/SessionTabs.tsx +41 -0
- package/agent-pane/frontend/src/components/StatusBar.tsx +60 -0
- package/agent-pane/frontend/src/components/StatusDot.tsx +13 -0
- package/agent-pane/frontend/src/components/Toast.tsx +34 -0
- package/agent-pane/frontend/src/components/TurnContent.tsx +202 -0
- package/agent-pane/frontend/src/components/WorkspaceSidebar.tsx +60 -0
- package/agent-pane/frontend/src/components/panels/ChangesPanel.tsx +77 -0
- package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +25 -0
- package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +69 -0
- package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +84 -0
- package/agent-pane/frontend/src/main.tsx +10 -0
- package/agent-pane/frontend/src/store/store.ts +492 -0
- package/agent-pane/frontend/src/styles/app.css +288 -0
- package/agent-pane/frontend/src/styles/tokens.css +62 -0
- package/agent-pane/frontend/src/types/index.ts +115 -0
- package/agent-pane/frontend/tsconfig.json +22 -0
- package/agent-pane/frontend/vite.config.ts +16 -0
- package/backend/dist/src/notifications/notifications.service.js +1 -1
- package/backend/dist/src/notifications/notifications.service.js.map +1 -1
- package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
- 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,260 @@
|
|
|
1
|
+
import { Codex, type Thread, type ThreadEvent, type ThreadItem, type ThreadOptions } from '@openai/codex-sdk';
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
import { getDb } from '../db/index.ts';
|
|
4
|
+
import { broadcast } from '../ws/index.ts';
|
|
5
|
+
import { mapItemData, getItemStatus } from './item-mapper.ts';
|
|
6
|
+
import type { Session, Turn, Item, ItemStatus } from '../db/schema.ts';
|
|
7
|
+
|
|
8
|
+
interface ThreadEntry {
|
|
9
|
+
codex: Codex;
|
|
10
|
+
thread: Thread;
|
|
11
|
+
abortController: AbortController | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const threadMap = new Map<string, ThreadEntry>();
|
|
15
|
+
|
|
16
|
+
function getThreadOptions(session: Session): ThreadOptions {
|
|
17
|
+
return {
|
|
18
|
+
model: session.model ?? undefined,
|
|
19
|
+
workingDirectory: session.cwd ?? undefined,
|
|
20
|
+
sandboxMode: 'workspace-write',
|
|
21
|
+
approvalPolicy: 'never',
|
|
22
|
+
skipGitRepoCheck: true,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getOrCreateThread(session: Session): { codex: Codex; thread: Thread } {
|
|
27
|
+
const existing = threadMap.get(session.id);
|
|
28
|
+
if (existing) {
|
|
29
|
+
return { codex: existing.codex, thread: existing.thread };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const codex = new Codex();
|
|
33
|
+
let thread: Thread;
|
|
34
|
+
|
|
35
|
+
if (session.external_thread_id) {
|
|
36
|
+
thread = codex.resumeThread(session.external_thread_id, getThreadOptions(session));
|
|
37
|
+
} else {
|
|
38
|
+
thread = codex.startThread(getThreadOptions(session));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { codex, thread };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Start a Codex turn. Fire-and-forget — streams events via WS. */
|
|
45
|
+
export async function startTurn(session: Session, turn: Turn): Promise<void> {
|
|
46
|
+
const db = getDb();
|
|
47
|
+
|
|
48
|
+
// Phase 1: create or resume thread
|
|
49
|
+
let codex: Codex;
|
|
50
|
+
let thread: Thread;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const result = getOrCreateThread(session);
|
|
54
|
+
codex = result.codex;
|
|
55
|
+
thread = result.thread;
|
|
56
|
+
} catch (err) {
|
|
57
|
+
const errMsg = `Failed to create Codex thread: ${(err as Error).message}`;
|
|
58
|
+
const now = new Date().toISOString();
|
|
59
|
+
db.prepare('UPDATE turns SET status = ?, error = ?, updated_at = ? WHERE id = ?')
|
|
60
|
+
.run('failed', errMsg, now, turn.id);
|
|
61
|
+
db.prepare('UPDATE sessions SET is_running = 0, status = ?, updated_at = ? WHERE id = ?')
|
|
62
|
+
.run('failed', now, session.id);
|
|
63
|
+
broadcast('turn:failed', { sessionId: session.id, turnId: turn.id, error: errMsg });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Phase 2: set up abort controller and update status
|
|
68
|
+
const abortController = new AbortController();
|
|
69
|
+
threadMap.set(session.id, { codex, thread, abortController });
|
|
70
|
+
|
|
71
|
+
const now = new Date().toISOString();
|
|
72
|
+
db.prepare('UPDATE turns SET status = ?, updated_at = ? WHERE id = ?')
|
|
73
|
+
.run('running', now, turn.id);
|
|
74
|
+
db.prepare('UPDATE sessions SET is_running = 1, status = ?, updated_at = ? WHERE id = ?')
|
|
75
|
+
.run('running', now, session.id);
|
|
76
|
+
|
|
77
|
+
broadcast('turn:started', { sessionId: session.id, turnId: turn.id });
|
|
78
|
+
|
|
79
|
+
// Phase 3: run the turn and handle events
|
|
80
|
+
try {
|
|
81
|
+
const { events } = await thread.runStreamed(turn.user_prompt, { signal: abortController.signal });
|
|
82
|
+
|
|
83
|
+
for await (const event of events) {
|
|
84
|
+
handleEvent(event, session.id, turn.id);
|
|
85
|
+
}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
const isAbort = abortController.signal.aborted || (err as Error).name === 'AbortError';
|
|
88
|
+
const errMsg = (err as Error).message;
|
|
89
|
+
const now2 = new Date().toISOString();
|
|
90
|
+
|
|
91
|
+
if (isAbort) {
|
|
92
|
+
db.prepare('UPDATE turns SET status = ?, updated_at = ? WHERE id = ?')
|
|
93
|
+
.run('aborted', now2, turn.id);
|
|
94
|
+
db.prepare('UPDATE sessions SET is_running = 0, status = ?, updated_at = ? WHERE id = ?')
|
|
95
|
+
.run('active', now2, session.id);
|
|
96
|
+
broadcast('turn:failed', { sessionId: session.id, turnId: turn.id, error: 'Turn cancelled by user' });
|
|
97
|
+
} else {
|
|
98
|
+
db.prepare('UPDATE turns SET status = ?, error = ?, updated_at = ? WHERE id = ?')
|
|
99
|
+
.run('failed', errMsg, now2, turn.id);
|
|
100
|
+
db.prepare('UPDATE sessions SET is_running = 0, status = ?, updated_at = ? WHERE id = ?')
|
|
101
|
+
.run('failed', now2, session.id);
|
|
102
|
+
broadcast('turn:failed', { sessionId: session.id, turnId: turn.id, error: errMsg });
|
|
103
|
+
}
|
|
104
|
+
} finally {
|
|
105
|
+
const entry = threadMap.get(session.id);
|
|
106
|
+
if (entry) {
|
|
107
|
+
threadMap.set(session.id, { ...entry, abortController: null });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Cancel a running turn by session ID. Returns true if a turn was cancelled. */
|
|
113
|
+
export function interruptTurn(sessionId: string): boolean {
|
|
114
|
+
const entry = threadMap.get(sessionId);
|
|
115
|
+
if (entry?.abortController) {
|
|
116
|
+
entry.abortController.abort();
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function handleEvent(event: ThreadEvent, sessionId: string, turnId: string): void {
|
|
123
|
+
const db = getDb();
|
|
124
|
+
const now = new Date().toISOString();
|
|
125
|
+
|
|
126
|
+
switch (event.type) {
|
|
127
|
+
case 'thread.started': {
|
|
128
|
+
db.prepare('UPDATE sessions SET external_thread_id = ?, updated_at = ? WHERE id = ?')
|
|
129
|
+
.run(event.thread_id, now, sessionId);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
case 'turn.started': {
|
|
134
|
+
// Already broadcasted turn:started before the stream started.
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case 'item.started': {
|
|
139
|
+
const item = event.item;
|
|
140
|
+
const itemId = uuidv4();
|
|
141
|
+
const data = mapItemData(item);
|
|
142
|
+
db.prepare(
|
|
143
|
+
'INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
144
|
+
).run(itemId, turnId, item.id, item.type, JSON.stringify(data), 'in_progress', now, now);
|
|
145
|
+
|
|
146
|
+
broadcast('item:started', { sessionId, turnId, item: makeItem(itemId, turnId, item, 'in_progress', now, now) });
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case 'item.updated': {
|
|
151
|
+
const item = event.item;
|
|
152
|
+
const data = mapItemData(item);
|
|
153
|
+
const result = db.prepare('UPDATE items SET data = ?, updated_at = ? WHERE external_item_id = ? AND turn_id = ?')
|
|
154
|
+
.run(JSON.stringify(data), now, item.id, turnId);
|
|
155
|
+
|
|
156
|
+
// SDK may skip item.started — insert if missing
|
|
157
|
+
if (result.changes === 0) {
|
|
158
|
+
const itemId = uuidv4();
|
|
159
|
+
db.prepare(
|
|
160
|
+
'INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
161
|
+
).run(itemId, turnId, item.id, item.type, JSON.stringify(data), 'in_progress', now, now);
|
|
162
|
+
broadcast('item:updated', { sessionId, turnId, item: makeItem(itemId, turnId, item, 'in_progress', now, now) });
|
|
163
|
+
} else {
|
|
164
|
+
const row = db.prepare('SELECT id, status, created_at FROM items WHERE external_item_id = ? AND turn_id = ?')
|
|
165
|
+
.get(item.id, turnId) as { id: string; status: ItemStatus; created_at: string } | undefined;
|
|
166
|
+
if (row) {
|
|
167
|
+
broadcast('item:updated', { sessionId, turnId, item: makeItem(row.id, turnId, item, row.status, row.created_at, now) });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
case 'item.completed': {
|
|
174
|
+
const item = event.item;
|
|
175
|
+
const status = getItemStatus(item);
|
|
176
|
+
const data = mapItemData(item);
|
|
177
|
+
const result = db.prepare('UPDATE items SET data = ?, status = ?, updated_at = ? WHERE external_item_id = ? AND turn_id = ?')
|
|
178
|
+
.run(JSON.stringify(data), status, now, item.id, turnId);
|
|
179
|
+
|
|
180
|
+
// SDK may skip item.started — insert if missing
|
|
181
|
+
let itemId: string;
|
|
182
|
+
let createdAt: string;
|
|
183
|
+
if (result.changes === 0) {
|
|
184
|
+
itemId = uuidv4();
|
|
185
|
+
createdAt = now;
|
|
186
|
+
db.prepare(
|
|
187
|
+
'INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
188
|
+
).run(itemId, turnId, item.id, item.type, JSON.stringify(data), status, now, now);
|
|
189
|
+
} else {
|
|
190
|
+
const row = db.prepare('SELECT id, created_at FROM items WHERE external_item_id = ? AND turn_id = ?')
|
|
191
|
+
.get(item.id, turnId) as { id: string; created_at: string };
|
|
192
|
+
itemId = row.id;
|
|
193
|
+
createdAt = row.created_at;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Update turn-level fields
|
|
197
|
+
if (item.type === 'agent_message') {
|
|
198
|
+
db.prepare('UPDATE turns SET agent_response = ?, updated_at = ? WHERE id = ?')
|
|
199
|
+
.run(item.text, now, turnId);
|
|
200
|
+
}
|
|
201
|
+
if (item.type === 'reasoning') {
|
|
202
|
+
const existing = db.prepare('SELECT thoughts FROM turns WHERE id = ?').get(turnId) as { thoughts: string | null };
|
|
203
|
+
const thoughts = existing.thoughts ? existing.thoughts + '\n' + item.text : item.text;
|
|
204
|
+
db.prepare('UPDATE turns SET thoughts = ?, updated_at = ? WHERE id = ?')
|
|
205
|
+
.run(thoughts, now, turnId);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
broadcast('item:completed', { sessionId, turnId, item: makeItem(itemId, turnId, item, status, createdAt, now) });
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
case 'turn.completed': {
|
|
213
|
+
const usage = event.usage;
|
|
214
|
+
db.prepare('UPDATE turns SET status = ?, token_usage = ?, updated_at = ? WHERE id = ?')
|
|
215
|
+
.run('completed', JSON.stringify(usage), now, turnId);
|
|
216
|
+
db.prepare('UPDATE sessions SET is_running = 0, status = ?, updated_at = ? WHERE id = ?')
|
|
217
|
+
.run('active', now, sessionId);
|
|
218
|
+
broadcast('turn:completed', { sessionId, turnId, usage });
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case 'turn.failed': {
|
|
223
|
+
db.prepare('UPDATE turns SET status = ?, error = ?, updated_at = ? WHERE id = ?')
|
|
224
|
+
.run('failed', event.error.message, now, turnId);
|
|
225
|
+
db.prepare('UPDATE sessions SET is_running = 0, status = ?, updated_at = ? WHERE id = ?')
|
|
226
|
+
.run('failed', now, sessionId);
|
|
227
|
+
broadcast('turn:failed', { sessionId, turnId, error: event.error.message });
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
case 'error': {
|
|
232
|
+
db.prepare('UPDATE turns SET status = ?, error = ?, updated_at = ? WHERE id = ?')
|
|
233
|
+
.run('failed', event.message, now, turnId);
|
|
234
|
+
db.prepare('UPDATE sessions SET is_running = 0, status = ?, updated_at = ? WHERE id = ?')
|
|
235
|
+
.run('failed', now, sessionId);
|
|
236
|
+
broadcast('turn:failed', { sessionId, turnId, error: event.message });
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function makeItem(
|
|
243
|
+
id: string,
|
|
244
|
+
turnId: string,
|
|
245
|
+
sdkItem: ThreadItem,
|
|
246
|
+
status: ItemStatus,
|
|
247
|
+
createdAt: string,
|
|
248
|
+
updatedAt: string,
|
|
249
|
+
): Item {
|
|
250
|
+
return {
|
|
251
|
+
id,
|
|
252
|
+
turn_id: turnId,
|
|
253
|
+
external_item_id: sdkItem.id,
|
|
254
|
+
type: sdkItem.type,
|
|
255
|
+
data: mapItemData(sdkItem),
|
|
256
|
+
status,
|
|
257
|
+
created_at: createdAt,
|
|
258
|
+
updated_at: updatedAt,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
@@ -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,67 @@
|
|
|
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 INDEX IF NOT EXISTS idx_turns_session_id ON turns(session_id);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_items_turn_id ON items(turn_id);
|
|
66
|
+
`);
|
|
67
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
created_at: string; // ISO
|
|
26
|
+
updated_at: string; // ISO
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TokenUsage {
|
|
30
|
+
input_tokens: number;
|
|
31
|
+
cached_input_tokens: number;
|
|
32
|
+
cache_write_input_tokens: number;
|
|
33
|
+
output_tokens: number;
|
|
34
|
+
reasoning_output_tokens: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Turn {
|
|
38
|
+
id: string;
|
|
39
|
+
session_id: string;
|
|
40
|
+
user_prompt: string;
|
|
41
|
+
agent_response: string | null;
|
|
42
|
+
thoughts: string | null;
|
|
43
|
+
token_usage: TokenUsage | null;
|
|
44
|
+
external_turn_id: string | null;
|
|
45
|
+
status: TurnStatus;
|
|
46
|
+
error: string | null;
|
|
47
|
+
created_at: string;
|
|
48
|
+
updated_at: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Item {
|
|
52
|
+
id: string;
|
|
53
|
+
turn_id: string;
|
|
54
|
+
external_item_id: string | null;
|
|
55
|
+
type: ItemType;
|
|
56
|
+
data: Record<string, unknown> | null;
|
|
57
|
+
status: ItemStatus;
|
|
58
|
+
created_at: string;
|
|
59
|
+
updated_at: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Nested session detail for GET /api/sessions/:id
|
|
63
|
+
export interface SessionDetail extends Session {
|
|
64
|
+
turns: (Turn & { items: Item[] })[];
|
|
65
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
|
|
8
|
+
const PORT = 3001;
|
|
9
|
+
|
|
10
|
+
async function main(): Promise<void> {
|
|
11
|
+
// Initialize database
|
|
12
|
+
initDb('data.db');
|
|
13
|
+
|
|
14
|
+
// Create Fastify server
|
|
15
|
+
const fastify = Fastify({ logger: true });
|
|
16
|
+
|
|
17
|
+
// Register CORS (allow Vite dev server)
|
|
18
|
+
await fastify.register(cors, { origin: true });
|
|
19
|
+
|
|
20
|
+
// Register routes
|
|
21
|
+
await registerSessionRoutes(fastify);
|
|
22
|
+
await registerTurnRoutes(fastify);
|
|
23
|
+
|
|
24
|
+
// Initialize WebSocket server (noServer mode)
|
|
25
|
+
const wss = initWs();
|
|
26
|
+
|
|
27
|
+
// Handle HTTP upgrade for /ws path
|
|
28
|
+
fastify.server.on('upgrade', (request, socket, head) => {
|
|
29
|
+
const wsServer = getWss();
|
|
30
|
+
if (wsServer) {
|
|
31
|
+
handleUpgrade(wsServer, request, socket, head);
|
|
32
|
+
} else {
|
|
33
|
+
socket.destroy();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
wss.on('connection', (ws) => {
|
|
38
|
+
fastify.log.info('WebSocket client connected');
|
|
39
|
+
ws.on('close', () => {
|
|
40
|
+
fastify.log.info('WebSocket client disconnected');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Start server
|
|
45
|
+
try {
|
|
46
|
+
await fastify.listen({ port: PORT, host: '0.0.0.0' });
|
|
47
|
+
fastify.log.info(`AgentPane backend running on http://localhost:${PORT}`);
|
|
48
|
+
fastify.log.info(`WebSocket endpoint: ws://localhost:${PORT}/ws`);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
fastify.log.error(err);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
main();
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
import { getDb } from '../db/index.ts';
|
|
4
|
+
import type { Session, SessionDetail, Turn, Item } from '../db/schema.ts';
|
|
5
|
+
|
|
6
|
+
/** Raw DB row for turns — token_usage is a JSON string, not parsed. */
|
|
7
|
+
interface TurnRow extends Omit<Turn, 'token_usage'> {
|
|
8
|
+
token_usage: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Raw DB row for items — data is a JSON string, not parsed. */
|
|
12
|
+
interface ItemRow extends Omit<Item, 'data'> {
|
|
13
|
+
data: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function registerSessionRoutes(fastify: FastifyInstance): Promise<void> {
|
|
17
|
+
// GET /api/sessions — list all sessions
|
|
18
|
+
fastify.get('/api/sessions', async () => {
|
|
19
|
+
const rows = getDb().prepare('SELECT * FROM sessions ORDER BY created_at DESC').all() as Session[];
|
|
20
|
+
return rows;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// GET /api/sessions/:id — session with turns + items
|
|
24
|
+
fastify.get<{ Params: { id: string } }>('/api/sessions/:id', async (request, reply) => {
|
|
25
|
+
const { id } = request.params;
|
|
26
|
+
const db = getDb();
|
|
27
|
+
|
|
28
|
+
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session | undefined;
|
|
29
|
+
if (!session) {
|
|
30
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const turnRows = db.prepare('SELECT * FROM turns WHERE session_id = ? ORDER BY created_at ASC').all(id) as TurnRow[];
|
|
34
|
+
const itemRows = db.prepare(
|
|
35
|
+
'SELECT * FROM items WHERE turn_id IN (SELECT id FROM turns WHERE session_id = ?) ORDER BY created_at ASC',
|
|
36
|
+
).all(id) as ItemRow[];
|
|
37
|
+
|
|
38
|
+
// Parse item data from JSON string and group by turn_id
|
|
39
|
+
const itemsByTurn = new Map<string, Item[]>();
|
|
40
|
+
for (const row of itemRows) {
|
|
41
|
+
const item: Item = {
|
|
42
|
+
...row,
|
|
43
|
+
data: row.data ? JSON.parse(row.data) : null,
|
|
44
|
+
};
|
|
45
|
+
const list = itemsByTurn.get(item.turn_id) ?? [];
|
|
46
|
+
list.push(item);
|
|
47
|
+
itemsByTurn.set(item.turn_id, list);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const detail: SessionDetail = {
|
|
51
|
+
...session,
|
|
52
|
+
turns: turnRows.map((row) => ({
|
|
53
|
+
...row,
|
|
54
|
+
token_usage: row.token_usage ? JSON.parse(row.token_usage) : null,
|
|
55
|
+
items: itemsByTurn.get(row.id) ?? [],
|
|
56
|
+
})),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return detail;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// POST /api/sessions — create session
|
|
63
|
+
fastify.post<{ Body: { title?: string; runtime_type?: string; model?: string; cwd?: string } }>(
|
|
64
|
+
'/api/sessions',
|
|
65
|
+
async (request) => {
|
|
66
|
+
const { title = null, runtime_type = 'codex', model = null, cwd = null } = request.body || {};
|
|
67
|
+
const id = uuidv4();
|
|
68
|
+
const now = new Date().toISOString();
|
|
69
|
+
|
|
70
|
+
getDb().prepare(
|
|
71
|
+
`INSERT INTO sessions (id, title, runtime_type, model, cwd, external_thread_id, status, is_running, created_at, updated_at)
|
|
72
|
+
VALUES (?, ?, ?, ?, ?, NULL, 'active', 0, ?, ?)`,
|
|
73
|
+
).run(id, title, runtime_type, model, cwd, now, now);
|
|
74
|
+
|
|
75
|
+
const session = getDb().prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session;
|
|
76
|
+
return session;
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// DELETE /api/sessions/:id — delete session (cascade)
|
|
81
|
+
fastify.delete<{ Params: { id: string } }>('/api/sessions/:id', async (request, reply) => {
|
|
82
|
+
const { id } = request.params;
|
|
83
|
+
const db = getDb();
|
|
84
|
+
|
|
85
|
+
const existing = db.prepare('SELECT id FROM sessions WHERE id = ?').get(id);
|
|
86
|
+
if (!existing) {
|
|
87
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Delete items first (turns cascade via FK, but better-sqlite3 needs explicit cascade if pragma is set)
|
|
91
|
+
db.prepare('DELETE FROM items WHERE turn_id IN (SELECT id FROM turns WHERE session_id = ?)').run(id);
|
|
92
|
+
db.prepare('DELETE FROM turns WHERE session_id = ?').run(id);
|
|
93
|
+
db.prepare('DELETE FROM sessions WHERE id = ?').run(id);
|
|
94
|
+
|
|
95
|
+
return { success: true };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
import { getDb } from '../db/index.ts';
|
|
4
|
+
import { startTurn, interruptTurn } from '../codex/codex-provider.ts';
|
|
5
|
+
import type { Session, Turn } from '../db/schema.ts';
|
|
6
|
+
|
|
7
|
+
export async function registerTurnRoutes(fastify: FastifyInstance): Promise<void> {
|
|
8
|
+
// POST /api/sessions/:id/turns — submit prompt, starts Codex turn
|
|
9
|
+
fastify.post<{ Params: { id: string }; Body: { user_prompt: string } }>(
|
|
10
|
+
'/api/sessions/:id/turns',
|
|
11
|
+
async (request, reply) => {
|
|
12
|
+
const { id } = request.params;
|
|
13
|
+
const { user_prompt } = request.body || {};
|
|
14
|
+
|
|
15
|
+
if (!user_prompt || !user_prompt.trim()) {
|
|
16
|
+
return reply.code(400).send({ error: 'user_prompt is required' });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const db = getDb();
|
|
20
|
+
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session | undefined;
|
|
21
|
+
if (!session) {
|
|
22
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (session.is_running) {
|
|
26
|
+
return reply.code(409).send({ error: 'A turn is already running for this session' });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Create turn in DB
|
|
30
|
+
const turnId = uuidv4();
|
|
31
|
+
const now = new Date().toISOString();
|
|
32
|
+
db.prepare(
|
|
33
|
+
`INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, created_at, updated_at)
|
|
34
|
+
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 'pending', NULL, ?, ?)`,
|
|
35
|
+
).run(turnId, id, user_prompt, now, now);
|
|
36
|
+
|
|
37
|
+
const turn = db.prepare('SELECT * FROM turns WHERE id = ?').get(turnId) as Turn;
|
|
38
|
+
|
|
39
|
+
// Fire and forget — events stream via WebSocket
|
|
40
|
+
startTurn(session, turn).catch((err) => {
|
|
41
|
+
console.error(`[codex-provider] Unhandled error for session ${id}:`, err);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return turn;
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// POST /api/sessions/:id/cancel — cancel running turn
|
|
49
|
+
fastify.post<{ Params: { id: string } }>(
|
|
50
|
+
'/api/sessions/:id/cancel',
|
|
51
|
+
async (request, reply) => {
|
|
52
|
+
const { id } = request.params;
|
|
53
|
+
const cancelled = interruptTurn(id);
|
|
54
|
+
if (!cancelled) {
|
|
55
|
+
return reply.code(409).send({ error: 'No running turn to cancel' });
|
|
56
|
+
}
|
|
57
|
+
return { success: true };
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
}
|