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.
- 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 +363 -0
- package/agent-pane/backend/src/codex/item-mapper.ts +59 -0
- package/agent-pane/backend/src/db/index.ts +91 -0
- package/agent-pane/backend/src/db/schema.ts +76 -0
- package/agent-pane/backend/src/index.ts +57 -0
- package/agent-pane/backend/src/routes/sessions.ts +134 -0
- package/agent-pane/backend/src/routes/turns.ts +60 -0
- package/agent-pane/backend/src/routes/workspaces.ts +110 -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 +41 -0
- package/agent-pane/frontend/src/api/sessions.ts +47 -0
- package/agent-pane/frontend/src/api/websocket.ts +149 -0
- package/agent-pane/frontend/src/api/workspaces.ts +24 -0
- package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +50 -0
- package/agent-pane/frontend/src/components/CommandInputBar.tsx +42 -0
- package/agent-pane/frontend/src/components/CommandPalette.tsx +207 -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 +170 -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 +44 -0
- package/agent-pane/frontend/src/components/StatusBar.tsx +68 -0
- package/agent-pane/frontend/src/components/StatusDot.tsx +13 -0
- package/agent-pane/frontend/src/components/Toast.tsx +80 -0
- package/agent-pane/frontend/src/components/TurnContent.tsx +294 -0
- package/agent-pane/frontend/src/components/WorkspaceSidebar.tsx +350 -0
- package/agent-pane/frontend/src/components/panels/ChangesPanel.tsx +81 -0
- package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +25 -0
- package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +82 -0
- package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +45 -0
- package/agent-pane/frontend/src/main.tsx +10 -0
- package/agent-pane/frontend/src/store/store.ts +635 -0
- package/agent-pane/frontend/src/styles/app.css +365 -0
- package/agent-pane/frontend/src/styles/tokens.css +62 -0
- package/agent-pane/frontend/src/types/index.ts +153 -0
- package/agent-pane/frontend/tsconfig.json +22 -0
- package/agent-pane/frontend/vite.config.ts +16 -0
- 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,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();
|