repoburg 1.3.201 → 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 (86) 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 +478 -250
  4. package/agent-pane/backend/src/db/index.ts +6 -0
  5. package/agent-pane/backend/src/providers/runtime-provider.ts +6 -0
  6. package/agent-pane/backend/src/repoburg/action-mapping.test.ts +122 -0
  7. package/agent-pane/backend/src/repoburg/action-mapping.ts +89 -11
  8. package/agent-pane/backend/src/repoburg/agent-runtime-client.ts +23 -0
  9. package/agent-pane/backend/src/repoburg/backend-resolver.test.ts +85 -0
  10. package/agent-pane/backend/src/repoburg/backend-resolver.ts +28 -13
  11. package/agent-pane/backend/src/repoburg/repoburg-client.test.ts +21 -0
  12. package/agent-pane/backend/src/repoburg/repoburg-client.ts +13 -0
  13. package/agent-pane/backend/src/repoburg/repoburg-provider.test.ts +57 -0
  14. package/agent-pane/backend/src/repoburg/repoburg-provider.ts +49 -0
  15. package/agent-pane/backend/src/repoburg/turn-projector.test.ts +147 -1
  16. package/agent-pane/backend/src/repoburg/turn-projector.ts +101 -1
  17. package/agent-pane/backend/src/routes/sessions.test.ts +215 -1
  18. package/agent-pane/backend/src/routes/sessions.ts +70 -9
  19. package/agent-pane/frontend/dist/assets/index-CJxD4YmJ.js +370 -0
  20. package/agent-pane/frontend/dist/assets/{index-34WaZYsW.css → index-r-xwfOKQ.css} +1 -1
  21. package/agent-pane/frontend/dist/index.html +2 -2
  22. package/agent-pane/frontend/src/api/sessions.test.ts +1 -0
  23. package/agent-pane/frontend/src/api/websocket.test.ts +2 -2
  24. package/agent-pane/frontend/src/api/websocket.ts +3 -1
  25. package/agent-pane/frontend/src/components/AskUserItem.test.tsx +143 -0
  26. package/agent-pane/frontend/src/components/AskUserItem.tsx +207 -0
  27. package/agent-pane/frontend/src/components/TurnContent.test.tsx +79 -0
  28. package/agent-pane/frontend/src/components/TurnContent.tsx +49 -3
  29. package/agent-pane/frontend/src/components/panels/TurnsPanel.test.tsx +48 -0
  30. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +7 -2
  31. package/agent-pane/frontend/src/store/store.test.ts +19 -0
  32. package/agent-pane/frontend/src/store/wsSlice.ts +10 -3
  33. package/agent-pane/frontend/src/styles/app.css +16 -0
  34. package/agent-pane/frontend/src/test/fixtures.ts +1 -0
  35. package/agent-pane/frontend/src/types/index.ts +2 -0
  36. package/agent-pane/frontend/src/utils/duration.test.ts +1 -0
  37. package/agent-pane/shared/types.ts +17 -0
  38. package/askuser-after-step5-postgres-selected.png +0 -0
  39. package/askuser-after-step7-answer-sent.png +0 -0
  40. package/askuser-card-visible.png +0 -0
  41. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.d.ts +16 -0
  42. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js +87 -0
  43. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js.map +1 -1
  44. package/backend/dist/src/agent-runtime/agent-runtime.types.d.ts +16 -1
  45. package/backend/dist/src/app.controller.d.ts +1 -0
  46. package/backend/dist/src/app.service.d.ts +1 -0
  47. package/backend/dist/src/app.service.js +4 -1
  48. package/backend/dist/src/app.service.js.map +1 -1
  49. package/backend/dist/src/interactive-chat/chat.service.d.ts +4 -0
  50. package/backend/dist/src/interactive-chat/chat.service.js +21 -1
  51. package/backend/dist/src/interactive-chat/chat.service.js.map +1 -1
  52. package/backend/dist/src/llm-orchestration/action-handlers/invoke-subagent.handler.js +12 -1
  53. package/backend/dist/src/llm-orchestration/action-handlers/invoke-subagent.handler.js.map +1 -1
  54. package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.d.ts +1 -0
  55. package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.js +1 -0
  56. package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.js.map +1 -1
  57. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js +2 -0
  58. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js.map +1 -1
  59. package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
  60. package/daemon/dist/api/services.js +4 -0
  61. package/daemon/dist/serviceManager.js +30 -11
  62. package/daemon/dist/serviceManager.spec.js +26 -0
  63. package/daemon/dist/workspace-validation.js +63 -0
  64. package/daemon/dist/workspace-validation.spec.js +63 -0
  65. package/package.json +1 -1
  66. package/r2-step4-apple-banana.png +0 -0
  67. package/r2-step5-after-rewind-apple-last.png +0 -0
  68. package/r2-step7-apple-cherry.png +0 -0
  69. package/r2-step8-cherry-menu-no-restore.png +0 -0
  70. package/r3-step3-live-task-card-running.png +0 -0
  71. package/r3-step4-after-completion.png +0 -0
  72. package/r3-step5-persistence-after-reload.png +0 -0
  73. package/r3-step6-tasks-tab-card.png +0 -0
  74. package/reverify-answered-state-answer-sent.png +0 -0
  75. package/reverify-card-visible-after-completion.png +0 -0
  76. package/rewind-step3-apple-banana-turns.png +0 -0
  77. package/rewind-step5-ui-unchanged-after-confirmed-rewind.png +0 -0
  78. package/rewind-step6-apple-menu-no-restore.png +0 -0
  79. package/rewind-step7-final-turnlist-unchanged.png +0 -0
  80. package/rewind-step7-negative-error-toast.png +0 -0
  81. package/rewind-step7-negative-error-toast2.png +0 -0
  82. package/subagent-r2-step5-turn-after-reload-no-card.png +0 -0
  83. package/subagent-r2-step6-tasks-panel-card.png +0 -0
  84. package/subagent-step3-tasks-tab-done-card.png +0 -0
  85. package/subagent-step4-turn-stuck-no-cards.png +0 -0
  86. package/agent-pane/frontend/dist/assets/index-y7xlT86e.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
  };
@@ -160,7 +164,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
160
164
  return { success: true };
161
165
  });
162
166
 
163
- // 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)
164
168
  fastify.patch<{
165
169
  Params: { id: string };
166
170
  Body: {
@@ -171,6 +175,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
171
175
  model_reasoning_effort?: string | null;
172
176
  runtime_config?: string | null;
173
177
  runtime_type?: string;
178
+ cwd?: string | null;
174
179
  };
175
180
  }>('/api/sessions/:id', {
176
181
  schema: {
@@ -184,12 +189,13 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
184
189
  model_reasoning_effort: { type: ['string', 'null'] },
185
190
  runtime_config: { type: ['string', 'null'] },
186
191
  runtime_type: { type: 'string' },
192
+ cwd: { type: ['string', 'null'] },
187
193
  },
188
194
  },
189
195
  },
190
196
  }, async (request, reply) => {
191
197
  const { id } = request.params;
192
- 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 || {};
193
199
  const db = getDb();
194
200
 
195
201
  const existing = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session | undefined;
@@ -239,6 +245,23 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
239
245
  }
240
246
  }
241
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
+
242
265
  // Model and reasoning changes update DB columns but do NOT clear the thread.
243
266
  // The Claude SDK supports query({ resume: sessionId, model: newModel }) —
244
267
  // changing the model within the same provider preserves conversation context.
@@ -269,11 +292,14 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
269
292
 
270
293
  db.prepare(`UPDATE sessions SET ${updates.join(', ')} WHERE id = ?`).run(...params, id);
271
294
 
272
- // 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.
273
297
  // Model/reasoning changes within the same provider keep the thread intact.
274
- if (providerChanged && existing.external_thread_id) {
275
- 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);
276
- // 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).
277
303
  getProvider(existing.runtime_type).clearThread(id);
278
304
  }
279
305
 
@@ -318,6 +344,35 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
318
344
  return reply.code(404).send({ error: 'Turn not found' });
319
345
  }
320
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
+
321
376
  const now = new Date().toISOString();
322
377
 
323
378
  // Mark the session as rewound (non-destructive — turns are NOT deleted, just hidden)
@@ -336,7 +391,6 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
336
391
  }
337
392
 
338
393
  // Fork the provider-native session so the provider has conversation context up to the target turn
339
- const provider = getProvider(session.runtime_type);
340
394
  if (provider.forkSession) {
341
395
  try {
342
396
  const forkedId = await provider.forkSession(session, targetTurnFull);
@@ -371,6 +425,13 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
371
425
  if (!session.rewound_at_turn_id) {
372
426
  return reply.code(409).send({ error: 'Session is not rewound' });
373
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
+ }
374
435
 
375
436
  const now = new Date().toISOString();
376
437
  // Restore the original external_thread_id and clear rewind markers