repoburg 1.3.199 → 1.3.202

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 (136) hide show
  1. package/agent-pane/backend/data.db +0 -0
  2. package/agent-pane/backend/data.db-shm +0 -0
  3. package/agent-pane/backend/data.db-wal +0 -0
  4. package/agent-pane/backend/dist/index.js +1658 -277
  5. package/agent-pane/backend/src/claude/claude-provider.test.ts +393 -3
  6. package/agent-pane/backend/src/claude/claude-provider.ts +53 -50
  7. package/agent-pane/backend/src/db/index.ts +12 -0
  8. package/agent-pane/backend/src/index.ts +5 -2
  9. package/agent-pane/backend/src/integration/threads-claude-fork.test.ts +241 -0
  10. package/agent-pane/backend/src/providers/index.ts +3 -1
  11. package/agent-pane/backend/src/providers/runtime-provider.ts +6 -0
  12. package/agent-pane/backend/src/repoburg/action-mapping.test.ts +122 -0
  13. package/agent-pane/backend/src/repoburg/action-mapping.ts +294 -0
  14. package/agent-pane/backend/src/repoburg/agent-runtime-client.test.ts +271 -0
  15. package/agent-pane/backend/src/repoburg/agent-runtime-client.ts +285 -0
  16. package/agent-pane/backend/src/repoburg/backend-resolver.test.ts +356 -0
  17. package/agent-pane/backend/src/repoburg/backend-resolver.ts +261 -0
  18. package/agent-pane/backend/src/repoburg/repoburg-client.test.ts +180 -0
  19. package/agent-pane/backend/src/repoburg/repoburg-client.ts +194 -0
  20. package/agent-pane/backend/src/repoburg/repoburg-provider.test.ts +780 -0
  21. package/agent-pane/backend/src/repoburg/repoburg-provider.ts +471 -0
  22. package/agent-pane/backend/src/repoburg/turn-projector.test.ts +621 -0
  23. package/agent-pane/backend/src/repoburg/turn-projector.ts +637 -0
  24. package/agent-pane/backend/src/routes/providers.test.ts +18 -1
  25. package/agent-pane/backend/src/routes/providers.ts +19 -1
  26. package/agent-pane/backend/src/routes/sessions.test.ts +215 -1
  27. package/agent-pane/backend/src/routes/sessions.ts +82 -9
  28. package/agent-pane/backend/src/routes/threads.ts +21 -6
  29. package/agent-pane/backend/src/routes/turns.test.ts +45 -1
  30. package/agent-pane/frontend/dist/assets/index-CJxD4YmJ.js +370 -0
  31. package/agent-pane/frontend/dist/assets/{index-Dwwhx7YN.css → index-r-xwfOKQ.css} +1 -1
  32. package/agent-pane/frontend/dist/index.html +2 -2
  33. package/agent-pane/frontend/src/api/sessions.test.ts +1 -0
  34. package/agent-pane/frontend/src/api/websocket.test.ts +2 -2
  35. package/agent-pane/frontend/src/api/websocket.ts +3 -1
  36. package/agent-pane/frontend/src/components/AskUserItem.test.tsx +143 -0
  37. package/agent-pane/frontend/src/components/AskUserItem.tsx +207 -0
  38. package/agent-pane/frontend/src/components/CommentableText.test.tsx +196 -0
  39. package/agent-pane/frontend/src/components/CommentableText.tsx +109 -21
  40. package/agent-pane/frontend/src/components/IdeWorkspace.test.tsx +39 -0
  41. package/agent-pane/frontend/src/components/IdeWorkspace.tsx +28 -2
  42. package/agent-pane/frontend/src/components/ResizeHandle.tsx +29 -0
  43. package/agent-pane/frontend/src/components/TurnContent.test.tsx +79 -0
  44. package/agent-pane/frontend/src/components/TurnContent.tsx +49 -3
  45. package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +35 -3
  46. package/agent-pane/frontend/src/components/panels/TurnsPanel.test.tsx +48 -0
  47. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +7 -2
  48. package/agent-pane/frontend/src/constants/models.ts +37 -0
  49. package/agent-pane/frontend/src/hooks/useResizable.test.ts +97 -0
  50. package/agent-pane/frontend/src/hooks/useResizable.ts +75 -0
  51. package/agent-pane/frontend/src/store/store.test.ts +54 -0
  52. package/agent-pane/frontend/src/store/uiSlice.ts +50 -0
  53. package/agent-pane/frontend/src/store/wsSlice.ts +10 -3
  54. package/agent-pane/frontend/src/styles/app.css +26 -1
  55. package/agent-pane/frontend/src/test/component-helpers.ts +2 -0
  56. package/agent-pane/frontend/src/test/fixtures.ts +1 -0
  57. package/agent-pane/frontend/src/types/index.ts +2 -0
  58. package/agent-pane/frontend/src/utils/duration.test.ts +1 -0
  59. package/agent-pane/shared/types.ts +17 -0
  60. package/agent-pane-bug-repro-no-agent-reply.png +0 -0
  61. package/askuser-after-step5-postgres-selected.png +0 -0
  62. package/askuser-after-step7-answer-sent.png +0 -0
  63. package/askuser-card-visible.png +0 -0
  64. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.d.ts +36 -0
  65. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js +199 -0
  66. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js.map +1 -0
  67. package/backend/dist/src/agent-runtime/agent-runtime.controller.d.ts +11 -0
  68. package/backend/dist/src/agent-runtime/agent-runtime.controller.js +91 -0
  69. package/backend/dist/src/agent-runtime/agent-runtime.controller.js.map +1 -0
  70. package/backend/dist/src/agent-runtime/agent-runtime.module.d.ts +2 -0
  71. package/backend/dist/src/agent-runtime/agent-runtime.module.js +26 -0
  72. package/backend/dist/src/agent-runtime/agent-runtime.module.js.map +1 -0
  73. package/backend/dist/src/agent-runtime/agent-runtime.types.d.ts +92 -0
  74. package/backend/dist/src/agent-runtime/agent-runtime.types.js +3 -0
  75. package/backend/dist/src/agent-runtime/agent-runtime.types.js.map +1 -0
  76. package/backend/dist/src/app.controller.d.ts +1 -0
  77. package/backend/dist/src/app.module.js +4 -0
  78. package/backend/dist/src/app.module.js.map +1 -1
  79. package/backend/dist/src/app.service.d.ts +1 -0
  80. package/backend/dist/src/app.service.js +4 -1
  81. package/backend/dist/src/app.service.js.map +1 -1
  82. package/backend/dist/src/interactive-chat/chat.module.js +4 -0
  83. package/backend/dist/src/interactive-chat/chat.module.js.map +1 -1
  84. package/backend/dist/src/interactive-chat/chat.service.d.ts +7 -1
  85. package/backend/dist/src/interactive-chat/chat.service.js +54 -7
  86. package/backend/dist/src/interactive-chat/chat.service.js.map +1 -1
  87. package/backend/dist/src/llm-orchestration/action-handlers/invoke-subagent.handler.js +12 -1
  88. package/backend/dist/src/llm-orchestration/action-handlers/invoke-subagent.handler.js.map +1 -1
  89. package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.d.ts +3 -1
  90. package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.js +20 -2
  91. package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.js.map +1 -1
  92. package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.d.ts +1 -0
  93. package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.js +1 -0
  94. package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.js.map +1 -1
  95. package/backend/dist/src/llm-orchestration/llm-orchestration.module.js +2 -0
  96. package/backend/dist/src/llm-orchestration/llm-orchestration.module.js.map +1 -1
  97. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.d.ts +4 -1
  98. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js +37 -2
  99. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js.map +1 -1
  100. package/backend/dist/src/llm-provider/ollama-llm.provider.d.ts +1 -0
  101. package/backend/dist/src/llm-provider/ollama-llm.provider.js +19 -1
  102. package/backend/dist/src/llm-provider/ollama-llm.provider.js.map +1 -1
  103. package/backend/dist/src/llm-responses/llm-responses.module.js +4 -0
  104. package/backend/dist/src/llm-responses/llm-responses.module.js.map +1 -1
  105. package/backend/dist/src/llm-responses/llm-responses.service.d.ts +4 -1
  106. package/backend/dist/src/llm-responses/llm-responses.service.js +52 -2
  107. package/backend/dist/src/llm-responses/llm-responses.service.js.map +1 -1
  108. package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
  109. package/daemon/dist/api/services.js +4 -0
  110. package/daemon/dist/index.js +0 -0
  111. package/daemon/dist/serviceManager.js +30 -11
  112. package/daemon/dist/serviceManager.spec.js +26 -0
  113. package/daemon/dist/workspace-validation.js +63 -0
  114. package/daemon/dist/workspace-validation.spec.js +63 -0
  115. package/package.json +1 -1
  116. package/r2-step4-apple-banana.png +0 -0
  117. package/r2-step5-after-rewind-apple-last.png +0 -0
  118. package/r2-step7-apple-cherry.png +0 -0
  119. package/r2-step8-cherry-menu-no-restore.png +0 -0
  120. package/r3-step3-live-task-card-running.png +0 -0
  121. package/r3-step4-after-completion.png +0 -0
  122. package/r3-step5-persistence-after-reload.png +0 -0
  123. package/r3-step6-tasks-tab-card.png +0 -0
  124. package/reverify-answered-state-answer-sent.png +0 -0
  125. package/reverify-card-visible-after-completion.png +0 -0
  126. package/rewind-step3-apple-banana-turns.png +0 -0
  127. package/rewind-step5-ui-unchanged-after-confirmed-rewind.png +0 -0
  128. package/rewind-step6-apple-menu-no-restore.png +0 -0
  129. package/rewind-step7-final-turnlist-unchanged.png +0 -0
  130. package/rewind-step7-negative-error-toast.png +0 -0
  131. package/rewind-step7-negative-error-toast2.png +0 -0
  132. package/subagent-r2-step5-turn-after-reload-no-card.png +0 -0
  133. package/subagent-r2-step6-tasks-panel-card.png +0 -0
  134. package/subagent-step3-tasks-tab-done-card.png +0 -0
  135. package/subagent-step4-turn-stuck-no-cards.png +0 -0
  136. package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +0 -362
@@ -2,14 +2,21 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import Fastify, { type FastifyInstance } from 'fastify';
3
3
  import { initDb, getDb } from '../db/index.ts';
4
4
  import { registerSessionRoutes } from './sessions.ts';
5
- // Mock registry to avoid spawning real provider processes
5
+ // Mock registry to avoid spawning real provider processes.
6
+ // rewindRuntime stays OUT of the default provider (only repoburg has it);
7
+ // tests that exercise the runtime-first path opt in via `useRewindRuntime()`.
6
8
  const mocks = vi.hoisted(() => ({
7
9
  clearThread: vi.fn(),
10
+ rewindRuntime: vi.fn(),
11
+ forkSession: vi.fn(),
12
+ rewindRuntimeActive: false,
8
13
  }));
9
14
 
10
15
  vi.mock('../providers/registry.ts', () => ({
11
16
  getProvider: vi.fn(() => ({
12
17
  clearThread: mocks.clearThread,
18
+ ...(mocks.rewindRuntimeActive ? { rewindRuntime: mocks.rewindRuntime } : {}),
19
+ forkSession: mocks.forkSession,
13
20
  startTurn: vi.fn(),
14
21
  interruptTurn: vi.fn(),
15
22
  clearAllThreads: vi.fn(),
@@ -65,6 +72,7 @@ describe('Session routes', () => {
65
72
 
66
73
  beforeEach(async () => {
67
74
  vi.clearAllMocks();
75
+ mocks.rewindRuntimeActive = false; // default provider has NO rewindRuntime
68
76
  initDb(':memory:');
69
77
  app = Fastify();
70
78
  await registerSessionRoutes(app);
@@ -384,6 +392,94 @@ describe('Session routes', () => {
384
392
  expect(res.statusCode).toBe(409);
385
393
  expect(res.json().error).toContain('Cannot switch provider');
386
394
  });
395
+ describe('PATCH /api/sessions/:id — cwd (workspace) change', () => {
396
+ it('clears the thread when cwd changes on a repoburg session', async () => {
397
+ const db = getDb();
398
+ insertSession(db, 's1', {
399
+ runtime_type: 'repoburg',
400
+ cwd: '/tmp/workspace-a',
401
+ external_thread_id: 'rs-1',
402
+ });
403
+ const res = await app.inject({
404
+ method: 'PATCH',
405
+ url: '/api/sessions/s1',
406
+ payload: { cwd: '/tmp/workspace-b' },
407
+ });
408
+ expect(res.statusCode).toBe(200);
409
+ const session = res.json();
410
+ expect(session.cwd).toBe('/tmp/workspace-b');
411
+ expect(session.external_thread_id).toBeNull();
412
+ expect(mocks.clearThread).toHaveBeenCalledWith('s1');
413
+ });
414
+
415
+ it('clears rewind markers along with the thread on a cwd change', async () => {
416
+ const db = getDb();
417
+ insertSession(db, 's1', { runtime_type: 'claude', cwd: '/tmp/a', external_thread_id: 'thread-1' });
418
+ db.prepare('UPDATE sessions SET rewound_at_turn_id = ?, pre_rewind_thread_id = ? WHERE id = ?')
419
+ .run('turn-1', 'orig-1', 's1');
420
+
421
+ const res = await app.inject({ method: 'PATCH', url: '/api/sessions/s1', payload: { cwd: '/tmp/b' } });
422
+ expect(res.statusCode).toBe(200);
423
+ const session = db.prepare(
424
+ 'SELECT rewound_at_turn_id, pre_rewind_thread_id, external_thread_id FROM sessions WHERE id = ?',
425
+ ).get('s1') as {
426
+ rewound_at_turn_id: string | null;
427
+ pre_rewind_thread_id: string | null;
428
+ external_thread_id: string | null;
429
+ };
430
+ expect(session.rewound_at_turn_id).toBeNull();
431
+ expect(session.pre_rewind_thread_id).toBeNull();
432
+ expect(session.external_thread_id).toBeNull();
433
+ });
434
+
435
+ it('returns 409 when changing cwd while a turn is running', async () => {
436
+ const db = getDb();
437
+ insertSession(db, 's1', {
438
+ runtime_type: 'repoburg',
439
+ cwd: '/tmp/a',
440
+ external_thread_id: 'rs-1',
441
+ is_running: 1,
442
+ });
443
+ const res = await app.inject({ method: 'PATCH', url: '/api/sessions/s1', payload: { cwd: '/tmp/b' } });
444
+ expect(res.statusCode).toBe(409);
445
+ expect(res.json().error).toContain('Cannot change workspace');
446
+ expect(mocks.clearThread).not.toHaveBeenCalled();
447
+ const session = db.prepare('SELECT cwd, external_thread_id FROM sessions WHERE id = ?').get('s1') as {
448
+ cwd: string | null;
449
+ external_thread_id: string | null;
450
+ };
451
+ expect(session.cwd).toBe('/tmp/a');
452
+ expect(session.external_thread_id).toBe('rs-1');
453
+ });
454
+
455
+ it('keeps the thread when the cwd is resolve-equal (trailing slash)', async () => {
456
+ const db = getDb();
457
+ insertSession(db, 's1', {
458
+ runtime_type: 'repoburg',
459
+ cwd: '/tmp/workspace-a',
460
+ external_thread_id: 'rs-1',
461
+ });
462
+ const res = await app.inject({
463
+ method: 'PATCH',
464
+ url: '/api/sessions/s1',
465
+ payload: { cwd: '/tmp/workspace-a/' },
466
+ });
467
+ expect(res.statusCode).toBe(200);
468
+ expect(res.json().cwd).toBe('/tmp/workspace-a/');
469
+ expect(res.json().external_thread_id).toBe('rs-1');
470
+ expect(mocks.clearThread).not.toHaveBeenCalled();
471
+ });
472
+
473
+ it('clears the thread when cwd is cleared to null', async () => {
474
+ const db = getDb();
475
+ insertSession(db, 's1', { runtime_type: 'repoburg', cwd: '/tmp/a', external_thread_id: 'rs-1' });
476
+ const res = await app.inject({ method: 'PATCH', url: '/api/sessions/s1', payload: { cwd: null } });
477
+ expect(res.statusCode).toBe(200);
478
+ expect(res.json().cwd).toBeNull();
479
+ expect(res.json().external_thread_id).toBeNull();
480
+ expect(mocks.clearThread).toHaveBeenCalledWith('s1');
481
+ });
482
+ });
387
483
  });
388
484
 
389
485
  describe('POST /api/sessions/:id/rewind', () => {
@@ -446,6 +542,104 @@ describe('Session routes', () => {
446
542
  expect(session.pre_rewind_thread_id).toBe('thread-123');
447
543
  expect(mocks.clearThread).toHaveBeenCalledWith('s1');
448
544
  });
545
+ it('calls provider.rewindRuntime (runtime-first), hard-deletes local turns after anchor, leaves NO rewind marker', async () => {
546
+ const db = getDb();
547
+ mocks.rewindRuntimeActive = true; // repoburg provider
548
+ insertSession(db, 's1', { runtime_type: 'repoburg', external_thread_id: 'rs-1' });
549
+ insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
550
+ insertTurn(db, 't2', 's1', '2025-01-01T01:00:00Z');
551
+ db.prepare('UPDATE turns SET runtime_input_ids = ? WHERE id = ?').run(
552
+ JSON.stringify(['in-1', 'in-2']),
553
+ 't1',
554
+ );
555
+ mocks.rewindRuntime.mockResolvedValueOnce(undefined);
556
+
557
+ const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't1' } });
558
+ expect(res.statusCode).toBe(200);
559
+ expect(mocks.rewindRuntime).toHaveBeenCalledTimes(1);
560
+ const [sessionArg, turnArg] = mocks.rewindRuntime.mock.calls[0];
561
+ expect(sessionArg.external_thread_id).toBe('rs-1');
562
+ expect(turnArg.id).toBe('t1');
563
+
564
+ // Local turns after the anchor are hard-deleted (platform parity).
565
+ const turns = db.prepare('SELECT id FROM turns WHERE session_id = ? ORDER BY created_at').all('s1') as { id: string }[];
566
+ expect(turns).toHaveLength(1);
567
+ expect(turns[0].id).toBe('t1');
568
+
569
+ // NO rewind marker — nothing is hidden or restorable for repoburg.
570
+ const session = db.prepare('SELECT rewound_at_turn_id, pre_rewind_thread_id FROM sessions WHERE id = ?').get('s1') as {
571
+ rewound_at_turn_id: string | null;
572
+ pre_rewind_thread_id: string | null;
573
+ };
574
+ expect(session.rewound_at_turn_id).toBeNull();
575
+ expect(session.pre_rewind_thread_id).toBeNull();
576
+ expect(mocks.clearThread).not.toHaveBeenCalled();
577
+ });
578
+
579
+ it('repoburg rewind: turns created AFTER the rewind stay visible in GET /:id (regression)', async () => {
580
+ const db = getDb();
581
+ mocks.rewindRuntimeActive = true; // repoburg provider
582
+ insertSession(db, 's1', { runtime_type: 'repoburg', external_thread_id: 'rs-1' });
583
+ insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
584
+ insertTurn(db, 't2', 's1', '2025-01-01T01:00:00Z');
585
+ mocks.rewindRuntime.mockResolvedValue(undefined);
586
+
587
+ const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't1' } });
588
+ expect(res.statusCode).toBe(200);
589
+
590
+ // A NEW turn created after the rewind must appear in the session view —
591
+ // a leftover rewound_at_turn_id marker would filter it out.
592
+ insertTurn(db, 't3', 's1', '2025-01-02T00:00:00Z');
593
+
594
+ const view = await app.inject({ method: 'GET', url: '/api/sessions/s1' });
595
+ expect(view.statusCode).toBe(200);
596
+ const turnIds = (view.json().turns as { id: string }[]).map((t) => t.id);
597
+ expect(turnIds).toEqual(['t1', 't3']);
598
+ });
599
+
600
+ it('rewindRuntime failure → NO local rewind (error passes through)', async () => {
601
+ const db = getDb();
602
+ mocks.rewindRuntimeActive = true; // repoburg provider
603
+ insertSession(db, 's1', { runtime_type: 'repoburg', external_thread_id: 'rs-1' });
604
+ insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
605
+ db.prepare('UPDATE turns SET runtime_input_ids = ? WHERE id = ?').run(
606
+ JSON.stringify(['in-1']),
607
+ 't1',
608
+ );
609
+ mocks.rewindRuntime.mockRejectedValueOnce(
610
+ new Error('DELETE /sessions/rs-1/inputs/in-1/until-here failed: 400 {"message":"Summaries can only be discarded, not deleted."}'),
611
+ );
612
+
613
+ const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't1' } });
614
+ expect(res.statusCode).toBe(500);
615
+ expect(res.json().message).toContain('Summaries can only be discarded');
616
+
617
+ // Local view untouched — no rewind markers, no clearThread
618
+ const session = db.prepare('SELECT rewound_at_turn_id, pre_rewind_thread_id FROM sessions WHERE id = ?').get('s1') as {
619
+ rewound_at_turn_id: string | null;
620
+ pre_rewind_thread_id: string | null;
621
+ };
622
+ expect(session.rewound_at_turn_id).toBeNull();
623
+ expect(session.pre_rewind_thread_id).toBeNull();
624
+ expect(mocks.clearThread).not.toHaveBeenCalled();
625
+ });
626
+
627
+ it('rewindRuntime NOT called when the provider does not implement it (codex)', async () => {
628
+ const db = getDb();
629
+ insertSession(db, 's1', { external_thread_id: 'thread-123' });
630
+ insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
631
+ const { getProvider } = await import('../providers/registry.ts');
632
+ vi.mocked(getProvider).mockReturnValueOnce({ clearThread: mocks.clearThread } as never);
633
+
634
+ const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't1' } });
635
+ expect(res.statusCode).toBe(200);
636
+ expect(mocks.rewindRuntime).not.toHaveBeenCalled();
637
+
638
+ const session = db.prepare('SELECT rewound_at_turn_id FROM sessions WHERE id = ?').get('s1') as {
639
+ rewound_at_turn_id: string | null;
640
+ };
641
+ expect(session.rewound_at_turn_id).toBe('t1');
642
+ });
449
643
 
450
644
  it('GET session filters turns when rewound', async () => {
451
645
  const db = getDb();
@@ -491,6 +685,26 @@ describe('Session routes', () => {
491
685
  const getRes = await app.inject({ method: 'GET', url: '/api/sessions/s1' });
492
686
  expect(getRes.json().turns).toHaveLength(2);
493
687
  });
688
+ it('restore returns 409 for repoburg sessions (runtime history permanently deleted)', async () => {
689
+ // Simulate a LEGACY rewound repoburg session (marker set by a
690
+ // pre-hard-delete rewind) — restore must still be refused.
691
+ const db = getDb();
692
+ insertSession(db, 's1', { runtime_type: 'repoburg', external_thread_id: 'rs-1' });
693
+ insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
694
+ db.prepare(
695
+ 'UPDATE sessions SET rewound_at_turn_id = ?, pre_rewind_thread_id = ? WHERE id = ?',
696
+ ).run('t1', 'rs-1', 's1');
697
+
698
+ const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/restore' });
699
+ expect(res.statusCode).toBe(409);
700
+ expect(res.json().error).toContain('permanently deleted');
701
+
702
+ // Rewind state stays intact
703
+ const session = db.prepare('SELECT rewound_at_turn_id FROM sessions WHERE id = ?').get('s1') as {
704
+ rewound_at_turn_id: string | null;
705
+ };
706
+ expect(session.rewound_at_turn_id).toBe('t1');
707
+ });
494
708
 
495
709
  it('restore returns 409 when not rewound', async () => {
496
710
  const db = getDb();
@@ -1,13 +1,16 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
+ import path from 'node:path';
2
3
  import { v4 as uuidv4 } from 'uuid';
3
4
  import { getDb } from '../db/index.ts';
4
5
  import { getProvider } from '../providers/registry.ts';
5
6
  import type { Session, SessionDetail, Turn, Item, DiscussionThread, Attachment } from '../db/schema.ts';
6
7
 
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'> {
8
+ /** Raw DB row for turns — token_usage, attachments and runtime_input_ids are
9
+ * JSON strings, not parsed; no items field. */
10
+ interface TurnRow extends Omit<Turn, 'token_usage' | 'items' | 'attachments' | 'runtime_input_ids'> {
9
11
  token_usage: string | null;
10
12
  attachments: string | null;
13
+ runtime_input_ids: string | null;
11
14
  }
12
15
 
13
16
  /** Raw DB row for items — data is a JSON string, not parsed. */
@@ -71,6 +74,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
71
74
  ...row,
72
75
  token_usage: row.token_usage ? JSON.parse(row.token_usage) : null,
73
76
  attachments: row.attachments ? (JSON.parse(row.attachments) as Attachment[]) : null,
77
+ runtime_input_ids: row.runtime_input_ids ? (JSON.parse(row.runtime_input_ids) as string[]) : null,
74
78
  items: itemsByTurn.get(row.id) ?? [],
75
79
  })),
76
80
  };
@@ -87,6 +91,18 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
87
91
  }));
88
92
  }
89
93
 
94
+ // Restore persisted context-window usage so the status-bar counter survives reload.
95
+ const cuRow = db.prepare('SELECT context_usage FROM sessions WHERE id = ?').get(id) as
96
+ | { context_usage: string | null }
97
+ | undefined;
98
+ if (cuRow?.context_usage) {
99
+ try {
100
+ detail.contextUsage = JSON.parse(cuRow.context_usage) as SessionDetail['contextUsage'];
101
+ } catch {
102
+ // Corrupt JSON — leave contextUsage unset.
103
+ }
104
+ }
105
+
90
106
  return detail;
91
107
  });
92
108
 
@@ -148,7 +164,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
148
164
  return { success: true };
149
165
  });
150
166
 
151
- // PATCH /api/sessions/:id — update session (title, lifecycle, has_unreviewed, model, model_reasoning_effort, runtime_config)
167
+ // PATCH /api/sessions/:id — update session (title, lifecycle, has_unreviewed, model, model_reasoning_effort, runtime_config, cwd)
152
168
  fastify.patch<{
153
169
  Params: { id: string };
154
170
  Body: {
@@ -159,6 +175,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
159
175
  model_reasoning_effort?: string | null;
160
176
  runtime_config?: string | null;
161
177
  runtime_type?: string;
178
+ cwd?: string | null;
162
179
  };
163
180
  }>('/api/sessions/:id', {
164
181
  schema: {
@@ -172,12 +189,13 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
172
189
  model_reasoning_effort: { type: ['string', 'null'] },
173
190
  runtime_config: { type: ['string', 'null'] },
174
191
  runtime_type: { type: 'string' },
192
+ cwd: { type: ['string', 'null'] },
175
193
  },
176
194
  },
177
195
  },
178
196
  }, async (request, reply) => {
179
197
  const { id } = request.params;
180
- const { title, lifecycle, has_unreviewed, model, model_reasoning_effort, runtime_config, runtime_type } = request.body || {};
198
+ const { title, lifecycle, has_unreviewed, model, model_reasoning_effort, runtime_config, runtime_type, cwd } = request.body || {};
181
199
  const db = getDb();
182
200
 
183
201
  const existing = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session | undefined;
@@ -227,6 +245,23 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
227
245
  }
228
246
  }
229
247
 
248
+ // Workspace (cwd) change — the runtime thread belongs to the old
249
+ // workspace backend, so it is cleared like a provider switch; the next
250
+ // turn creates a fresh runtime session on the new workspace.
251
+ // Compare resolve-normalized paths — cosmetic differences (trailing
252
+ // slash) are not a workspace change.
253
+ let workspaceChanged = false;
254
+ if (cwd !== undefined) {
255
+ const oldAbs = existing.cwd ? path.resolve(existing.cwd) : null;
256
+ const newAbs = cwd ? path.resolve(cwd) : null;
257
+ workspaceChanged = oldAbs !== newAbs;
258
+ if (workspaceChanged && existing.is_running) {
259
+ return reply.code(409).send({ error: 'Cannot change workspace while a turn is running' });
260
+ }
261
+ updates.push('cwd = ?');
262
+ params.push(cwd);
263
+ }
264
+
230
265
  // Model and reasoning changes update DB columns but do NOT clear the thread.
231
266
  // The Claude SDK supports query({ resume: sessionId, model: newModel }) —
232
267
  // changing the model within the same provider preserves conversation context.
@@ -257,11 +292,14 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
257
292
 
258
293
  db.prepare(`UPDATE sessions SET ${updates.join(', ')} WHERE id = ?`).run(...params, id);
259
294
 
260
- // Only clear the thread when the PROVIDER changes (different provider can't resume).
295
+ // Clear the thread when the PROVIDER or WORKSPACE changes — the runtime
296
+ // session belongs to the old provider/backend and cannot be resumed.
261
297
  // 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);
264
- // Use old provider to clear its in-memory thread state
298
+ if (providerChanged || workspaceChanged) {
299
+ if (existing.external_thread_id) {
300
+ 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);
301
+ }
302
+ // Drop the in-memory entry even without a persisted thread id (stale state).
265
303
  getProvider(existing.runtime_type).clearThread(id);
266
304
  }
267
305
 
@@ -306,6 +344,35 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
306
344
  return reply.code(404).send({ error: 'Turn not found' });
307
345
  }
308
346
 
347
+ const provider = getProvider(session.runtime_type);
348
+
349
+ // RUNTIME-FIRST (repoburg in-place truncation): hard-delete the runtime
350
+ // history after the target turn BEFORE any local change. Errors pass
351
+ // through — on failure the local view stays untouched and consistent
352
+ // with the runtime (e.g. 400 summary-in-range from until-here).
353
+ if (provider.rewindRuntime) {
354
+ await provider.rewindRuntime(session, targetTurnFull);
355
+
356
+ // Platform-parity HARD DELETE: the runtime history after the anchor
357
+ // is permanently gone, so mirror it locally — delete the local turns
358
+ // after the target and leave NO rewind marker (nothing to restore).
359
+ // Stale markers are cleared too: a marker would hide every turn
360
+ // created after the rewind (GET /:id filters by rewound_at_turn_id).
361
+ const now2 = new Date().toISOString();
362
+ db.prepare(
363
+ 'DELETE FROM items WHERE turn_id IN (SELECT id FROM turns WHERE session_id = ? AND created_at > ?)',
364
+ ).run(id, targetTurnFull.created_at);
365
+ db.prepare('DELETE FROM turns WHERE session_id = ? AND created_at > ?').run(
366
+ id,
367
+ targetTurnFull.created_at,
368
+ );
369
+ db.prepare(
370
+ 'UPDATE sessions SET rewound_at_turn_id = NULL, pre_rewind_thread_id = NULL, updated_at = ? WHERE id = ?',
371
+ ).run(now2, id);
372
+ const truncated = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session;
373
+ return truncated;
374
+ }
375
+
309
376
  const now = new Date().toISOString();
310
377
 
311
378
  // Mark the session as rewound (non-destructive — turns are NOT deleted, just hidden)
@@ -324,7 +391,6 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
324
391
  }
325
392
 
326
393
  // Fork the provider-native session so the provider has conversation context up to the target turn
327
- const provider = getProvider(session.runtime_type);
328
394
  if (provider.forkSession) {
329
395
  try {
330
396
  const forkedId = await provider.forkSession(session, targetTurnFull);
@@ -359,6 +425,13 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
359
425
  if (!session.rewound_at_turn_id) {
360
426
  return reply.code(409).send({ error: 'Session is not rewound' });
361
427
  }
428
+ // Repoburg rewind = platform-parity HARD DELETE — the runtime history was
429
+ // permanently truncated, so local turns can never be restored.
430
+ if (session.runtime_type === 'repoburg') {
431
+ return reply.code(409).send({
432
+ error: 'Restore is not available for repoburg sessions — the rewind permanently deleted the runtime history.',
433
+ });
434
+ }
362
435
 
363
436
  const now = new Date().toISOString();
364
437
  // Restore the original external_thread_id and clear rewind markers
@@ -72,16 +72,31 @@ export async function registerThreadRoutes(fastify: FastifyInstance): Promise<vo
72
72
  const provider = getProvider(session.runtime_type);
73
73
  if (provider.forkSession) {
74
74
  try {
75
- const anchorTurn = db.prepare('SELECT * FROM turns WHERE id = ?').get(turnId) as Turn | undefined;
76
- if (anchorTurn) {
77
- const forkedSessionId = await provider.forkSession(session, anchorTurn);
78
- if (forkedSessionId) {
79
- db.prepare('UPDATE discussion_threads SET external_thread_id = ? WHERE id = ?').run(forkedSessionId, threadId);
75
+ if (!session.external_thread_id) {
76
+ // No main session to fork from — the thread will start a fresh
77
+ // isolated session (startTurn skips the resume fallback), so the
78
+ // main session is never contaminated.
79
+ console.warn(`[threads] cannot fork for thread ${threadId}: session ${sessionId} has no external_thread_id — thread will start a fresh isolated session`);
80
+ } else {
81
+ const anchorTurn = db.prepare('SELECT * FROM turns WHERE id = ?').get(turnId) as Turn | undefined;
82
+ if (anchorTurn) {
83
+ const forkedSessionId = await provider.forkSession(session, anchorTurn);
84
+ if (forkedSessionId) {
85
+ db.prepare('UPDATE discussion_threads SET external_thread_id = ? WHERE id = ?').run(forkedSessionId, threadId);
86
+ console.log(`[threads] forked to ${forkedSessionId} for thread ${threadId} (session ${sessionId})`);
87
+ } else {
88
+ // forkSession returned null — thread will start a fresh isolated
89
+ // session in startTurn (NOT the main session), so isolation holds.
90
+ console.warn(`[threads] forkSession returned null for thread ${threadId} (external_thread_id=${session.external_thread_id}, upToMessageId=${anchorTurn.external_turn_id}) — thread will start a fresh isolated session`);
91
+ }
92
+ } else {
93
+ console.warn(`[threads] anchor turn ${turnId} not found for thread ${threadId} — cannot fork`);
80
94
  }
81
95
  }
82
96
  } catch (err) {
83
97
  console.error(`[threads] forkSession failed for thread ${threadId}:`, err);
84
- // Non-fatal — thread falls back to main session conversation
98
+ // Non-fatal — thread falls back to a fresh isolated session (NOT the
99
+ // main session) so the main conversation is never contaminated.
85
100
  }
86
101
  }
87
102
 
@@ -54,7 +54,9 @@ describe('Turn routes', () => {
54
54
  beforeEach(async () => {
55
55
  vi.clearAllMocks();
56
56
  initDb(':memory:');
57
- app = Fastify();
57
+ // Mirror the production Fastify config (index.ts) — bodyLimit must exceed the
58
+ // default 1 MiB so base64 image attachments fit in the JSON body.
59
+ app = Fastify({ bodyLimit: 50 * 1024 * 1024 });
58
60
  await registerTurnRoutes(app);
59
61
  });
60
62
 
@@ -122,6 +124,48 @@ describe('Turn routes', () => {
122
124
  expect(sessionArg.id).toBe('s1');
123
125
  expect(turnArg.user_prompt).toBe('hello');
124
126
  });
127
+
128
+ it('accepts a large base64 image attachment (>1 MiB) — regression for 413 Payload Too Large', async () => {
129
+ const db = getDb();
130
+ insertSession(db, 's1');
131
+ // ~2 MB of base64 — exceeds Fastify's default 1 MiB bodyLimit; the production
132
+ // server (index.ts) raises it to 50 MB so image attachments fit.
133
+ const bigBase64 = 'A'.repeat(2 * 1024 * 1024);
134
+ const res = await app.inject({
135
+ method: 'POST',
136
+ url: '/api/sessions/s1/turns',
137
+ payload: {
138
+ user_prompt: 'look at this',
139
+ attachments: [{ name: 'shot.png', media_type: 'image/png', data: bigBase64 }],
140
+ },
141
+ });
142
+ expect(res.statusCode).toBe(200);
143
+ const turn = res.json();
144
+ expect(turn.attachments).toEqual([{ name: 'shot.png', media_type: 'image/png', data: bigBase64 }]);
145
+ const [, turnArg] = mocks.startTurn.mock.calls[0];
146
+ // startTurn receives the raw DB row — attachments is still a JSON string there.
147
+ expect(typeof turnArg.attachments).toBe('string');
148
+ expect(JSON.parse(turnArg.attachments)).toEqual([{ name: 'shot.png', media_type: 'image/png', data: bigBase64 }]);
149
+ });
150
+
151
+ it('returns 413 on the default 1 MiB bodyLimit — documents why index.ts raises it', async () => {
152
+ const db = getDb();
153
+ insertSession(db, 's1');
154
+ const defaultApp = Fastify(); // default bodyLimit = 1 MiB
155
+ await registerTurnRoutes(defaultApp);
156
+ const bigBase64 = 'A'.repeat(2 * 1024 * 1024);
157
+ const res = await defaultApp.inject({
158
+ method: 'POST',
159
+ url: '/api/sessions/s1/turns',
160
+ payload: {
161
+ user_prompt: 'look at this',
162
+ attachments: [{ name: 'shot.png', media_type: 'image/png', data: bigBase64 }],
163
+ },
164
+ });
165
+ expect(res.statusCode).toBe(413);
166
+ expect(mocks.startTurn).not.toHaveBeenCalled();
167
+ await defaultApp.close();
168
+ });
125
169
  });
126
170
 
127
171
  describe('POST /api/sessions/:id/cancel', () => {