repoburg 1.3.196 → 1.3.198

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 (74) hide show
  1. package/agent-pane/backend/data.db-shm +0 -0
  2. package/agent-pane/backend/data.db-wal +0 -0
  3. package/agent-pane/backend/dist/index.js +13399 -10390
  4. package/agent-pane/backend/package-lock.json +162 -92
  5. package/agent-pane/backend/scripts/backfill-from-jsonl.ts +206 -0
  6. package/agent-pane/backend/scripts/import-session-from-jsonl.ts +264 -0
  7. package/agent-pane/backend/src/claude/claude-message-mapper.test.ts +116 -8
  8. package/agent-pane/backend/src/claude/claude-message-mapper.ts +114 -9
  9. package/agent-pane/backend/src/claude/claude-provider.ts +918 -84
  10. package/agent-pane/backend/src/claude/claude-types.ts +16 -4
  11. package/agent-pane/backend/src/codex/app-server-client.ts +22 -0
  12. package/agent-pane/backend/src/db/index.ts +102 -0
  13. package/agent-pane/backend/src/db/schema.ts +4 -0
  14. package/agent-pane/backend/src/index.ts +3 -1
  15. package/agent-pane/backend/src/integration/helpers/test-app.ts +2 -0
  16. package/agent-pane/backend/src/integration/session-operations.test.ts +18 -11
  17. package/agent-pane/backend/src/integration/threads.test.ts +309 -0
  18. package/agent-pane/backend/src/providers/runtime-provider.ts +19 -1
  19. package/agent-pane/backend/src/routes/providers.ts +11 -0
  20. package/agent-pane/backend/src/routes/sessions.test.ts +143 -9
  21. package/agent-pane/backend/src/routes/sessions.ts +133 -28
  22. package/agent-pane/backend/src/routes/threads.ts +308 -0
  23. package/agent-pane/backend/src/routes/turns.ts +32 -9
  24. package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +362 -0
  25. package/agent-pane/frontend/dist/assets/index-Dwwhx7YN.css +1 -0
  26. package/agent-pane/frontend/dist/index.html +2 -2
  27. package/agent-pane/frontend/package-lock.json +51 -9
  28. package/agent-pane/frontend/src/__tests__/ThreadsPanel.test.tsx +192 -0
  29. package/agent-pane/frontend/src/api/providers.ts +6 -1
  30. package/agent-pane/frontend/src/api/sessions.test.ts +4 -0
  31. package/agent-pane/frontend/src/api/sessions.ts +13 -5
  32. package/agent-pane/frontend/src/api/websocket.ts +65 -1
  33. package/agent-pane/frontend/src/components/CollapsibleToolCalls.test.tsx +4 -4
  34. package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +13 -4
  35. package/agent-pane/frontend/src/components/CommandInputBar.test.tsx +2 -2
  36. package/agent-pane/frontend/src/components/CommandInputBar.tsx +292 -29
  37. package/agent-pane/frontend/src/components/CommandPalette.test.tsx +0 -4
  38. package/agent-pane/frontend/src/components/CommandPalette.tsx +1 -17
  39. package/agent-pane/frontend/src/components/CommentableText.tsx +291 -0
  40. package/agent-pane/frontend/src/components/RenderArea.tsx +9 -2
  41. package/agent-pane/frontend/src/components/RightPanel.test.tsx +5 -5
  42. package/agent-pane/frontend/src/components/RightPanel.tsx +20 -14
  43. package/agent-pane/frontend/src/components/SessionTabs.tsx +7 -2
  44. package/agent-pane/frontend/src/components/StatusBar.tsx +68 -1
  45. package/agent-pane/frontend/src/components/TurnContent.test.tsx +29 -18
  46. package/agent-pane/frontend/src/components/TurnContent.tsx +199 -28
  47. package/agent-pane/frontend/src/components/input/SlashPopup.tsx +24 -5
  48. package/agent-pane/frontend/src/components/panels/TasksPanel.test.tsx +97 -0
  49. package/agent-pane/frontend/src/components/panels/TasksPanel.tsx +208 -0
  50. package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +350 -0
  51. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +47 -25
  52. package/agent-pane/frontend/src/hooks/useSessionStatus.test.ts +2 -0
  53. package/agent-pane/frontend/src/store/sessionSlice.ts +29 -6
  54. package/agent-pane/frontend/src/store/store.test.ts +5 -5
  55. package/agent-pane/frontend/src/store/store.ts +4 -1
  56. package/agent-pane/frontend/src/store/threadSlice.ts +136 -0
  57. package/agent-pane/frontend/src/store/wsSlice.ts +72 -3
  58. package/agent-pane/frontend/src/styles/app.css +150 -19
  59. package/agent-pane/frontend/src/test/fixtures.ts +4 -0
  60. package/agent-pane/frontend/src/types/index.ts +5 -2
  61. package/agent-pane/frontend/src/utils/duration.test.ts +4 -0
  62. package/agent-pane/frontend/vite.config.ts +2 -2
  63. package/agent-pane/shared/provider-types.ts +14 -0
  64. package/agent-pane/shared/types.ts +60 -0
  65. package/backend/dist/src/llm-provider/ollama-llm.provider.js +1 -0
  66. package/backend/dist/src/llm-provider/ollama-llm.provider.js.map +1 -1
  67. package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
  68. package/package.json +1 -1
  69. package/agent-pane/frontend/dist/assets/index-Bg0PF2aV.css +0 -1
  70. package/agent-pane/frontend/dist/assets/index-BlybeX5e.js +0 -281
  71. package/agent-pane/frontend/src/components/panels/LogsPanel.test.tsx +0 -58
  72. package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +0 -25
  73. package/agent-pane/frontend/src/components/panels/MetricsPanel.test.tsx +0 -71
  74. package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +0 -114
@@ -1,5 +1,6 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
2
  import type { ProviderDefinition } from '@agentpane/shared';
3
+ import { hasProvider, getProvider } from '../providers/registry.ts';
3
4
 
4
5
  /** Codex provider definition — all capabilities enabled. */
5
6
  const CODEX_PROVIDER: ProviderDefinition = {
@@ -54,4 +55,14 @@ export async function registerProviderRoutes(fastify: FastifyInstance): Promise<
54
55
  }
55
56
  return provider;
56
57
  });
58
+
59
+ // GET /api/providers/:id/commands — provider-native slash commands (dynamic, e.g. Claude CLI)
60
+ fastify.get<{ Params: { id: string } }>('/api/providers/:id/commands', async (request, reply) => {
61
+ const id = request.params.id;
62
+ if (!hasProvider(id)) {
63
+ return reply.code(404).send({ error: 'Provider not found' });
64
+ }
65
+ const provider = getProvider(id);
66
+ return provider.getCommands ? await provider.getCommands() : [];
67
+ });
57
68
  }
@@ -243,16 +243,94 @@ describe('Session routes', () => {
243
243
  expect(res.json().has_unreviewed).toBe(1);
244
244
  });
245
245
 
246
- it('clears thread when model changes and external_thread_id exists', async () => {
246
+ it('preserves thread when model changes (same provider)', async () => {
247
247
  const db = getDb();
248
248
  insertSession(db, 's1', { model: 'gpt-4', external_thread_id: 'thread-123' });
249
249
  const res = await app.inject({ method: 'PATCH', url: '/api/sessions/s1', payload: { model: 'claude-3' } });
250
250
  expect(res.statusCode).toBe(200);
251
- expect(mocks.clearThread).toHaveBeenCalledWith('s1');
252
- const session = db.prepare('SELECT external_thread_id FROM sessions WHERE id = ?').get('s1') as {
251
+ expect(mocks.clearThread).not.toHaveBeenCalled();
252
+ const session = db.prepare('SELECT external_thread_id, model FROM sessions WHERE id = ?').get('s1') as {
253
253
  external_thread_id: string | null;
254
+ model: string | null;
254
255
  };
255
- expect(session.external_thread_id).toBeNull();
256
+ expect(session.external_thread_id).toBe('thread-123');
257
+ expect(session.model).toBe('claude-3');
258
+ });
259
+
260
+ it('preserves thread when reasoning effort changes (same provider)', async () => {
261
+ const db = getDb();
262
+ insertSession(db, 's1', { model: 'claude-sonnet-4', model_reasoning_effort: 'medium', external_thread_id: 'thread-123' });
263
+ const res = await app.inject({
264
+ method: 'PATCH',
265
+ url: '/api/sessions/s1',
266
+ payload: { model: 'claude-sonnet-4', model_reasoning_effort: 'high' },
267
+ });
268
+ expect(res.statusCode).toBe(200);
269
+ expect(mocks.clearThread).not.toHaveBeenCalled();
270
+ const session = db.prepare('SELECT external_thread_id, model_reasoning_effort FROM sessions WHERE id = ?').get('s1') as {
271
+ external_thread_id: string | null;
272
+ model_reasoning_effort: string | null;
273
+ };
274
+ expect(session.external_thread_id).toBe('thread-123');
275
+ expect(session.model_reasoning_effort).toBe('high');
276
+ });
277
+
278
+ it('preserves thread when setting model from null to a value', async () => {
279
+ const db = getDb();
280
+ insertSession(db, 's1', { model: null, external_thread_id: 'thread-123' });
281
+ const res = await app.inject({ method: 'PATCH', url: '/api/sessions/s1', payload: { model: 'claude-sonnet-4' } });
282
+ expect(res.statusCode).toBe(200);
283
+ expect(mocks.clearThread).not.toHaveBeenCalled();
284
+ const session = db.prepare('SELECT external_thread_id, model FROM sessions WHERE id = ?').get('s1') as {
285
+ external_thread_id: string | null;
286
+ model: string | null;
287
+ };
288
+ expect(session.external_thread_id).toBe('thread-123');
289
+ expect(session.model).toBe('claude-sonnet-4');
290
+ });
291
+
292
+ it('preserves thread and rewind state when model changes on rewound session', async () => {
293
+ const db = getDb();
294
+ insertSession(db, 's1', { model: 'claude-sonnet-4', external_thread_id: 'forked-123' });
295
+ // Simulate a rewound session
296
+ db.prepare('UPDATE sessions SET rewound_at_turn_id = ?, pre_rewind_thread_id = ? WHERE id = ?')
297
+ .run('turn-1', 'original-123', 's1');
298
+
299
+ const res = await app.inject({
300
+ method: 'PATCH',
301
+ url: '/api/sessions/s1',
302
+ payload: { model: 'claude-opus-4' },
303
+ });
304
+ expect(res.statusCode).toBe(200);
305
+ expect(mocks.clearThread).not.toHaveBeenCalled();
306
+ const session = db.prepare('SELECT external_thread_id, rewound_at_turn_id, pre_rewind_thread_id, model FROM sessions WHERE id = ?').get('s1') as {
307
+ external_thread_id: string | null;
308
+ rewound_at_turn_id: string | null;
309
+ pre_rewind_thread_id: string | null;
310
+ model: string | null;
311
+ };
312
+ expect(session.external_thread_id).toBe('forked-123');
313
+ expect(session.rewound_at_turn_id).toBe('turn-1');
314
+ expect(session.pre_rewind_thread_id).toBe('original-123');
315
+ expect(session.model).toBe('claude-opus-4');
316
+ });
317
+
318
+ it('preserves thread when model changes on finished session', async () => {
319
+ const db = getDb();
320
+ insertSession(db, 's1', { model: 'claude-sonnet-4', external_thread_id: 'thread-123', lifecycle: 'finished' });
321
+ const res = await app.inject({
322
+ method: 'PATCH',
323
+ url: '/api/sessions/s1',
324
+ payload: { model: 'claude-opus-4' },
325
+ });
326
+ expect(res.statusCode).toBe(200);
327
+ expect(mocks.clearThread).not.toHaveBeenCalled();
328
+ const session = db.prepare('SELECT external_thread_id, model FROM sessions WHERE id = ?').get('s1') as {
329
+ external_thread_id: string | null;
330
+ model: string | null;
331
+ };
332
+ expect(session.external_thread_id).toBe('thread-123');
333
+ expect(session.model).toBe('claude-opus-4');
256
334
  });
257
335
 
258
336
  it('does not clear thread when model is unchanged', async () => {
@@ -342,7 +420,7 @@ describe('Session routes', () => {
342
420
  expect(res.json().error).toBe('Turn not found');
343
421
  });
344
422
 
345
- it('deletes turns after target and nulls external_thread_id', async () => {
423
+ it('marks session as rewound (non-destructive) and saves pre_rewind_thread_id', async () => {
346
424
  const db = getDb();
347
425
  insertSession(db, 's1', { external_thread_id: 'thread-123' });
348
426
  insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
@@ -352,18 +430,74 @@ describe('Session routes', () => {
352
430
  const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't1' } });
353
431
  expect(res.statusCode).toBe(200);
354
432
 
433
+ // Turns are NOT deleted — all 3 still exist
355
434
  const turns = db.prepare('SELECT id FROM turns WHERE session_id = ? ORDER BY created_at').all('s1') as {
356
435
  id: string;
357
436
  }[];
358
- expect(turns).toHaveLength(1);
359
- expect(turns[0].id).toBe('t1');
437
+ expect(turns).toHaveLength(3);
360
438
 
361
- const session = db.prepare('SELECT external_thread_id FROM sessions WHERE id = ?').get('s1') as {
439
+ // Session is marked as rewound
440
+ const session = db.prepare('SELECT rewound_at_turn_id, pre_rewind_thread_id, external_thread_id FROM sessions WHERE id = ?').get('s1') as {
441
+ rewound_at_turn_id: string | null;
442
+ pre_rewind_thread_id: string | null;
362
443
  external_thread_id: string | null;
363
444
  };
364
- expect(session.external_thread_id).toBeNull();
445
+ expect(session.rewound_at_turn_id).toBe('t1');
446
+ expect(session.pre_rewind_thread_id).toBe('thread-123');
365
447
  expect(mocks.clearThread).toHaveBeenCalledWith('s1');
366
448
  });
449
+
450
+ it('GET session filters turns when rewound', async () => {
451
+ const db = getDb();
452
+ insertSession(db, 's1');
453
+ insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
454
+ insertTurn(db, 't2', 's1', '2025-01-01T01:00:00Z');
455
+ insertTurn(db, 't3', 's1', '2025-01-01T02:00:00Z');
456
+
457
+ // Rewind to t2
458
+ await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't2' } });
459
+
460
+ // GET should only show t1 and t2 (not t3)
461
+ const res = await app.inject({ method: 'GET', url: '/api/sessions/s1' });
462
+ expect(res.statusCode).toBe(200);
463
+ expect(res.json().turns).toHaveLength(2);
464
+ expect(res.json().turns[0].id).toBe('t1');
465
+ expect(res.json().turns[1].id).toBe('t2');
466
+ });
467
+
468
+ it('restore undoes rewind and shows all turns', async () => {
469
+ const db = getDb();
470
+ insertSession(db, 's1', { external_thread_id: 'thread-123' });
471
+ insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
472
+ insertTurn(db, 't2', 's1', '2025-01-01T01:00:00Z');
473
+
474
+ // Rewind
475
+ await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't1' } });
476
+
477
+ // Restore
478
+ const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/restore' });
479
+ expect(res.statusCode).toBe(200);
480
+
481
+ const session = db.prepare('SELECT rewound_at_turn_id, pre_rewind_thread_id, external_thread_id FROM sessions WHERE id = ?').get('s1') as {
482
+ rewound_at_turn_id: string | null;
483
+ pre_rewind_thread_id: string | null;
484
+ external_thread_id: string | null;
485
+ };
486
+ expect(session.rewound_at_turn_id).toBeNull();
487
+ expect(session.pre_rewind_thread_id).toBeNull();
488
+ expect(session.external_thread_id).toBe('thread-123');
489
+
490
+ // GET shows all turns again
491
+ const getRes = await app.inject({ method: 'GET', url: '/api/sessions/s1' });
492
+ expect(getRes.json().turns).toHaveLength(2);
493
+ });
494
+
495
+ it('restore returns 409 when not rewound', async () => {
496
+ const db = getDb();
497
+ insertSession(db, 's1');
498
+ const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/restore' });
499
+ expect(res.statusCode).toBe(409);
500
+ });
367
501
  });
368
502
 
369
503
  describe('POST /api/sessions/:id/branch', () => {
@@ -2,11 +2,12 @@ import type { FastifyInstance } from 'fastify';
2
2
  import { v4 as uuidv4 } from 'uuid';
3
3
  import { getDb } from '../db/index.ts';
4
4
  import { getProvider } from '../providers/registry.ts';
5
- import type { Session, SessionDetail, Turn, Item } from '../db/schema.ts';
5
+ import type { Session, SessionDetail, Turn, Item, DiscussionThread, Attachment } from '../db/schema.ts';
6
6
 
7
- /** Raw DB row for turns — token_usage is a JSON string, not parsed; no items field. */
8
- interface TurnRow extends Omit<Turn, 'token_usage' | 'items'> {
7
+ /** Raw DB row for turns — token_usage and attachments are JSON strings, not parsed; no items field. */
8
+ interface TurnRow extends Omit<Turn, 'token_usage' | 'items' | 'attachments'> {
9
9
  token_usage: string | null;
10
+ attachments: string | null;
10
11
  }
11
12
 
12
13
  /** Raw DB row for items — data is a JSON string, not parsed. */
@@ -31,14 +32,26 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
31
32
  return reply.code(404).send({ error: 'Session not found' });
32
33
  }
33
34
 
34
- const turnRows = db
35
- .prepare('SELECT * FROM turns WHERE session_id = ? ORDER BY created_at ASC')
36
- .all(id) as TurnRow[];
35
+ // If the session is rewound, only show turns up to and including the rewind point
36
+ const turnFilter = session.rewound_at_turn_id
37
+ ? db.prepare('SELECT created_at FROM turns WHERE id = ?').get(session.rewound_at_turn_id) as { created_at: string } | undefined
38
+ : null;
39
+
40
+ const turnRows = turnFilter
41
+ ? db
42
+ .prepare('SELECT * FROM turns WHERE session_id = ? AND created_at <= ? ORDER BY created_at ASC')
43
+ .all(id, turnFilter.created_at) as TurnRow[]
44
+ : db
45
+ .prepare('SELECT * FROM turns WHERE session_id = ? ORDER BY created_at ASC')
46
+ .all(id) as TurnRow[];
47
+
37
48
  const itemRows = db
38
49
  .prepare(
39
- 'SELECT * FROM items WHERE turn_id IN (SELECT id FROM turns WHERE session_id = ?) ORDER BY created_at ASC',
50
+ 'SELECT * FROM items WHERE turn_id IN (SELECT id FROM turns WHERE session_id = ?' +
51
+ (turnFilter ? ' AND created_at <= ?' : '') +
52
+ ') ORDER BY created_at ASC',
40
53
  )
41
- .all(id) as ItemRow[];
54
+ .all(...(turnFilter ? [id, turnFilter.created_at] : [id])) as ItemRow[];
42
55
 
43
56
  // Parse item data from JSON string and group by turn_id
44
57
  const itemsByTurn = new Map<string, Item[]>();
@@ -57,10 +70,23 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
57
70
  turns: turnRows.map((row) => ({
58
71
  ...row,
59
72
  token_usage: row.token_usage ? JSON.parse(row.token_usage) : null,
73
+ attachments: row.attachments ? (JSON.parse(row.attachments) as Attachment[]) : null,
60
74
  items: itemsByTurn.get(row.id) ?? [],
61
75
  })),
62
76
  };
63
77
 
78
+ // Fetch discussion threads for this session and attach to the response
79
+ const threadRows = db
80
+ .prepare('SELECT * FROM discussion_threads WHERE session_id = ? ORDER BY created_at ASC')
81
+ .all(id) as DiscussionThread[];
82
+
83
+ if (threadRows.length > 0) {
84
+ detail.threads = threadRows.map((tr) => ({
85
+ ...tr,
86
+ turns: detail.turns.filter((t) => t.thread_id === tr.id),
87
+ }));
88
+ }
89
+
64
90
  return detail;
65
91
  });
66
92
 
@@ -168,7 +194,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
168
194
  const updates: string[] = ['updated_at = ?'];
169
195
  const params: (string | number | null)[] = [now];
170
196
 
171
- let modelChanged = false;
197
+ let providerChanged = false;
172
198
 
173
199
  if (title !== undefined) {
174
200
  updates.push('title = ?');
@@ -185,7 +211,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
185
211
 
186
212
  // Provider switch — clear model/reasoning if not explicitly provided
187
213
  if (runtime_type !== undefined && runtime_type !== existing.runtime_type) {
188
- modelChanged = true; // old model is invalid for new provider
214
+ providerChanged = true; // different provider can't resume the old thread
189
215
  updates.push('runtime_type = ?');
190
216
  params.push(runtime_type);
191
217
 
@@ -201,15 +227,28 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
201
227
  }
202
228
  }
203
229
 
230
+ // Model and reasoning changes update DB columns but do NOT clear the thread.
231
+ // The Claude SDK supports query({ resume: sessionId, model: newModel }) —
232
+ // changing the model within the same provider preserves conversation context.
233
+ // In streaming-input mode, also call query.setModel() on the live session
234
+ // so the CLI switches models without restarting.
204
235
  if (model !== undefined) {
205
236
  updates.push('model = ?');
206
237
  params.push(model);
207
- if (model !== existing.model) modelChanged = true;
238
+ // If the provider supports mid-session model switching and the model
239
+ // actually changed (same provider), update the live query.
240
+ if (model !== existing.model && !providerChanged) {
241
+ const provider = getProvider(existing.runtime_type);
242
+ if (provider.setModel) {
243
+ provider.setModel(id, model ?? '').catch((err) => {
244
+ console.error(`[patch] setModel failed for session ${id}:`, err);
245
+ });
246
+ }
247
+ }
208
248
  }
209
249
  if (model_reasoning_effort !== undefined) {
210
250
  updates.push('model_reasoning_effort = ?');
211
251
  params.push(model_reasoning_effort);
212
- if (model_reasoning_effort !== existing.model_reasoning_effort) modelChanged = true;
213
252
  }
214
253
  if (runtime_config !== undefined) {
215
254
  updates.push('runtime_config = ?');
@@ -218,9 +257,10 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
218
257
 
219
258
  db.prepare(`UPDATE sessions SET ${updates.join(', ')} WHERE id = ?`).run(...params, id);
220
259
 
221
- // If provider or model changed, clear the cached thread and clean up old provider
222
- if (modelChanged && existing.external_thread_id) {
223
- db.prepare('UPDATE sessions SET external_thread_id = NULL, updated_at = ? WHERE id = ?').run(now, id);
260
+ // Only clear the thread when the PROVIDER changes (different provider can't resume).
261
+ // Model/reasoning changes within the same provider keep the thread intact.
262
+ if (providerChanged && existing.external_thread_id) {
263
+ db.prepare('UPDATE sessions SET external_thread_id = NULL, rewound_at_turn_id = NULL, pre_rewind_thread_id = NULL, updated_at = ? WHERE id = ?').run(now, id);
224
264
  // Use old provider to clear its in-memory thread state
225
265
  getProvider(existing.runtime_type).clearThread(id);
226
266
  }
@@ -229,7 +269,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
229
269
  return session;
230
270
  });
231
271
 
232
- // POST /api/sessions/:id/rewind — delete turns after target turn, reset Codex thread
272
+ // POST /api/sessions/:id/rewind — non-destructive rewind: hide turns after target, fork provider session
233
273
  fastify.post<{ Params: { id: string }; Body: { turn_id: string } }>(
234
274
  '/api/sessions/:id/rewind',
235
275
  {
@@ -259,27 +299,80 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
259
299
  return reply.code(409).send({ error: 'Cannot rewind while a turn is running' });
260
300
  }
261
301
 
262
- const targetTurn = db.prepare('SELECT created_at FROM turns WHERE id = ? AND session_id = ?').get(turn_id, id) as
263
- { created_at: string } | undefined;
264
- if (!targetTurn) {
302
+ const targetTurnFull = db.prepare('SELECT * FROM turns WHERE id = ? AND session_id = ?').get(turn_id, id) as
303
+ | Turn
304
+ | undefined;
305
+ if (!targetTurnFull) {
265
306
  return reply.code(404).send({ error: 'Turn not found' });
266
307
  }
267
308
 
268
- // Delete turns after the target (cascade deletes items via FK)
269
- db.prepare('DELETE FROM turns WHERE session_id = ? AND created_at > ?').run(id, targetTurn.created_at);
270
-
271
- // Null out external_thread_id — next turn creates a fresh Codex thread
272
309
  const now = new Date().toISOString();
273
- db.prepare('UPDATE sessions SET external_thread_id = NULL, updated_at = ? WHERE id = ?').run(now, id);
274
310
 
275
- // Clear in-memory thread entry
276
- getProvider(session.runtime_type).clearThread(id);
311
+ // Mark the session as rewound (non-destructive — turns are NOT deleted, just hidden)
312
+ // Save the original external_thread_id only on the first rewind (don't overwrite on subsequent rewinds)
313
+ // Use explicit two-step logic instead of COALESCE to avoid any evaluation-order ambiguity
314
+ if (!session.pre_rewind_thread_id && session.external_thread_id) {
315
+ db.prepare(
316
+ 'UPDATE sessions SET rewound_at_turn_id = ?, pre_rewind_thread_id = ?, updated_at = ? WHERE id = ?',
317
+ ).run(turn_id, session.external_thread_id, now, id);
318
+ console.log(`[rewind] saved pre_rewind_thread_id=${session.external_thread_id} for session ${id}`);
319
+ } else {
320
+ db.prepare(
321
+ 'UPDATE sessions SET rewound_at_turn_id = ?, updated_at = ? WHERE id = ?',
322
+ ).run(turn_id, now, id);
323
+ console.log(`[rewind] pre_rewind_thread_id already set (${session.pre_rewind_thread_id}) or external_thread_id is null for session ${id}`);
324
+ }
325
+
326
+ // Fork the provider-native session so the provider has conversation context up to the target turn
327
+ const provider = getProvider(session.runtime_type);
328
+ if (provider.forkSession) {
329
+ try {
330
+ const forkedId = await provider.forkSession(session, targetTurnFull);
331
+ if (forkedId) {
332
+ db.prepare('UPDATE sessions SET external_thread_id = ?, updated_at = ? WHERE id = ?')
333
+ .run(forkedId, now, id);
334
+ console.log(`[rewind] forked to ${forkedId} for session ${id}`);
335
+ } else {
336
+ console.warn(`[rewind] forkSession returned null for session ${id} (external_thread_id=${session.external_thread_id}, upToMessageId=${targetTurnFull.external_turn_id})`);
337
+ }
338
+ } catch (err) {
339
+ console.error(`[rewind] forkSession threw for session ${id}:`, err);
340
+ }
341
+ }
342
+
343
+ // Clear in-memory thread entry (the Claude session ID changed)
344
+ provider.clearThread(id);
277
345
 
278
346
  const updated = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session;
279
347
  return updated;
280
348
  },
281
349
  );
282
350
 
351
+ // POST /api/sessions/:id/restore — undo rewind: show all turns again, restore original thread
352
+ fastify.post<{ Params: { id: string } }>('/api/sessions/:id/restore', async (request, reply) => {
353
+ const { id } = request.params;
354
+ const db = getDb();
355
+ const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session | undefined;
356
+ if (!session) {
357
+ return reply.code(404).send({ error: 'Session not found' });
358
+ }
359
+ if (!session.rewound_at_turn_id) {
360
+ return reply.code(409).send({ error: 'Session is not rewound' });
361
+ }
362
+
363
+ const now = new Date().toISOString();
364
+ // Restore the original external_thread_id and clear rewind markers
365
+ db.prepare(
366
+ 'UPDATE sessions SET rewound_at_turn_id = NULL, pre_rewind_thread_id = NULL, external_thread_id = COALESCE(pre_rewind_thread_id, external_thread_id), updated_at = ? WHERE id = ?',
367
+ ).run(now, id);
368
+
369
+ // Clear in-memory thread entry (the Claude session ID changed back)
370
+ getProvider(session.runtime_type).clearThread(id);
371
+
372
+ const updated = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session;
373
+ return updated;
374
+ });
375
+
283
376
  // POST /api/sessions/:id/branch — create new session with turns up to and including target turn
284
377
  fastify.post<{ Params: { id: string }; Body: { turn_id: string; title?: string } }>(
285
378
  '/api/sessions/:id/branch',
@@ -342,8 +435,8 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
342
435
  for (const turn of turnRows) {
343
436
  const newTurnId = uuidv4();
344
437
  db.prepare(
345
- `INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, created_at, updated_at)
346
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
438
+ `INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, attachments, created_at, updated_at)
439
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
347
440
  ).run(
348
441
  newTurnId,
349
442
  newSessionId,
@@ -354,6 +447,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
354
447
  turn.external_turn_id,
355
448
  turn.status,
356
449
  turn.error,
450
+ turn.attachments,
357
451
  turn.created_at,
358
452
  turn.updated_at,
359
453
  );
@@ -380,6 +474,17 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
380
474
  }
381
475
  }
382
476
 
477
+ // Fork the provider-native session so the provider has conversation context
478
+ const provider = getProvider(session.runtime_type);
479
+ if (provider.forkSession) {
480
+ const targetTurnFull = db.prepare('SELECT * FROM turns WHERE id = ?').get(turn_id) as Turn;
481
+ const forkedId = await provider.forkSession(session, targetTurnFull);
482
+ if (forkedId) {
483
+ db.prepare('UPDATE sessions SET external_thread_id = ?, updated_at = ? WHERE id = ?')
484
+ .run(forkedId, now, newSessionId);
485
+ }
486
+ }
487
+
383
488
  const newSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get(newSessionId) as Session;
384
489
  return newSession;
385
490
  },