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
@@ -193,6 +193,12 @@ function createTables(database: Database.Database): void {
193
193
  database.exec('ALTER TABLE turns ADD COLUMN attachments TEXT');
194
194
  }
195
195
 
196
+ // Migration: add 'runtime_input_ids' column to turns table (repoburg rewind
197
+ // anchor mapping — JSON array of the runtime input ids composing the turn)
198
+ if (!turnColumns.some((c) => c.name === 'runtime_input_ids')) {
199
+ database.exec('ALTER TABLE turns ADD COLUMN runtime_input_ids TEXT');
200
+ }
201
+
196
202
  // Migration: add 'external_thread_id' column to discussion_threads (thread branching)
197
203
  const threadColumns = database.prepare('PRAGMA table_info(discussion_threads)').all() as { name: string }[];
198
204
  if (!threadColumns.some((c) => c.name === 'external_thread_id')) {
@@ -29,6 +29,12 @@ export interface RuntimeProvider {
29
29
  * Returns the new provider-native session ID, or null if there is no thread to fork.
30
30
  * Optional — providers without fork support omit it. */
31
31
  forkSession?(session: Session, targetTurn: Turn): Promise<string | null>;
32
+ /** Truncate the runtime session's history IN PLACE after the target turn
33
+ * (platform-parity rewind: hard delete). Throws on failure — the caller
34
+ * must NOT apply any local rewind when this fails.
35
+ * Optional — providers without in-place truncation omit it (e.g. Claude
36
+ * uses forkSession instead). */
37
+ rewindRuntime?(session: Session, targetTurn: Turn): Promise<void>;
32
38
  /** Change the model for a running session without restarting the CLI process.
33
39
  * Only supported in streaming-input mode (Claude). If the session hasn't
34
40
  * started yet, the new model is read from the DB on next startTurn.
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { mapActionToItem, parseSelectionGroups } from './action-mapping.ts';
3
+ import type { RepoburgAction } from './repoburg-client.ts';
4
+
5
+ function askUserAction(selections: string | null): RepoburgAction {
6
+ return {
7
+ id: 'a1',
8
+ action_type: 'ask_user',
9
+ status: 'CONFIRMED_KEPT',
10
+ selections,
11
+ order_of_execution: 0,
12
+ };
13
+ }
14
+
15
+ function subagentAction(status: string, output = ''): RepoburgAction {
16
+ return {
17
+ id: 'act-1',
18
+ action_type: 'invoke_subagent',
19
+ status,
20
+ arguments: JSON.stringify({ agent_name: 'explore-codebase', prompt: 'Find auth files' }),
21
+ order_of_execution: 0,
22
+ executionLogs: [{ output }],
23
+ };
24
+ }
25
+
26
+ describe('mapActionToItem — invoke_subagent', () => {
27
+ it('maps to a completed background_task card (snapshot/reload shape)', () => {
28
+ const mapped = mapActionToItem(subagentAction('CONFIRMED_KEPT', 'auth files found'));
29
+ expect(mapped).not.toBeNull();
30
+ expect(mapped!.type).toBe('background_task');
31
+ expect(mapped!.status).toBe('completed');
32
+ expect(mapped!.data).toMatchObject({
33
+ task_type: 'subagent',
34
+ subagent_type: 'explore-codebase',
35
+ description: 'Find auth files',
36
+ status: 'completed',
37
+ summary: 'auth files found',
38
+ last_tool_name: null,
39
+ token_count: null,
40
+ });
41
+ });
42
+
43
+ it('maps a failed invoke_subagent to a failed background_task with the error as summary', () => {
44
+ const action = {
45
+ ...subagentAction('EXECUTION_FAILED'),
46
+ executionLogs: [{ error_message: 'boom' }],
47
+ };
48
+ const mapped = mapActionToItem(action);
49
+ expect(mapped!.status).toBe('failed');
50
+ expect(mapped!.data).toMatchObject({ status: 'failed', summary: 'boom' });
51
+ });
52
+ });
53
+ describe('parseSelectionGroups', () => {
54
+ it('parses canonical [{question, selections[]}] groups', () => {
55
+ const raw = JSON.stringify([
56
+ { question: 'Which database?', selections: ['Postgres', 'SQLite'] },
57
+ { question: 'Proceed?', selections: ['yes', 'no'] },
58
+ ]);
59
+ expect(parseSelectionGroups(raw)).toEqual([
60
+ { question: 'Which database?', selections: ['Postgres', 'SQLite'] },
61
+ { question: 'Proceed?', selections: ['yes', 'no'] },
62
+ ]);
63
+ });
64
+
65
+ it('coerces flat string arrays into single-option groups', () => {
66
+ expect(parseSelectionGroups(JSON.stringify(['Yes', 'No']))).toEqual([
67
+ { question: '', selections: ['Yes'] },
68
+ { question: '', selections: ['No'] },
69
+ ]);
70
+ });
71
+
72
+ it('drops invalid entries and non-string options', () => {
73
+ const raw = JSON.stringify([
74
+ { question: 'Q', selections: ['ok', 42] },
75
+ null,
76
+ { question: 'No options', selections: [] },
77
+ ]);
78
+ expect(parseSelectionGroups(raw)).toEqual([{ question: 'Q', selections: ['ok'] }]);
79
+ });
80
+
81
+ it('returns [] for malformed JSON, null, and non-array payloads', () => {
82
+ expect(parseSelectionGroups('not json')).toEqual([]);
83
+ expect(parseSelectionGroups(null)).toEqual([]);
84
+ expect(parseSelectionGroups('{"question":"q"}')).toEqual([]);
85
+ });
86
+ });
87
+
88
+ describe('mapActionToItem — ask_user', () => {
89
+ it('maps to an ask_user item with parsed groups', () => {
90
+ const raw = JSON.stringify([
91
+ { question: 'Which database?', selections: ['Postgres', 'SQLite'] },
92
+ ]);
93
+ const mapped = mapActionToItem(askUserAction(raw));
94
+ expect(mapped).not.toBeNull();
95
+ expect(mapped!.type).toBe('ask_user');
96
+ expect(mapped!.status).toBe('completed');
97
+ expect(mapped!.data).toEqual({
98
+ question: '',
99
+ groups: [{ question: 'Which database?', selections: ['Postgres', 'SQLite'] }],
100
+ });
101
+ });
102
+
103
+ it('falls back to a plain-text question card on malformed selections', () => {
104
+ const mapped = mapActionToItem(askUserAction('Plain question?'));
105
+ expect(mapped).not.toBeNull();
106
+ expect(mapped!.type).toBe('ask_user');
107
+ expect(mapped!.data).toEqual({ question: 'Plain question?', groups: [] });
108
+ });
109
+
110
+ it('maps without selections to a question card with the raw payload as text', () => {
111
+ const mapped = mapActionToItem(askUserAction('Which one?\n["A","B"]'));
112
+ expect(mapped).not.toBeNull();
113
+ expect(mapped!.data).toEqual({ question: 'Which one?\n["A","B"]', groups: [] });
114
+ });
115
+
116
+ it('maps a failed ask_user action to a failed item', () => {
117
+ const raw = JSON.stringify([{ question: 'Q?', selections: ['a', 'b'] }]);
118
+ const action = { ...askUserAction(raw), status: 'EXECUTION_FAILED' };
119
+ const mapped = mapActionToItem(action);
120
+ expect(mapped!.status).toBe('failed');
121
+ });
122
+ });
@@ -9,6 +9,7 @@ import { v4 as uuidv4 } from 'uuid';
9
9
  import type Database from 'better-sqlite3';
10
10
  import { broadcast } from '../ws/index.ts';
11
11
  import type { Item, ItemStatus } from '../db/schema.ts';
12
+ import type { AskUserSelectionGroup, AskUserItemData } from '@agentpane/shared';
12
13
  import type { RepoburgAction } from './repoburg-client.ts';
13
14
 
14
15
  /** Safely parse a JSON string field from an action row. */
@@ -22,6 +23,42 @@ export function parseJsonArgs(raw: string | null | undefined): Record<string, un
22
23
  }
23
24
  }
24
25
 
26
+ /** Parse a raw selections JSON string into validated groups (platform parity:
27
+ * ActionItemContent.parseSelectionGroups). Never throws; flat-string arrays
28
+ * coerce into single-option groups; invalid entries are dropped. */
29
+ export function parseSelectionGroups(raw: string | null | undefined): AskUserSelectionGroup[] {
30
+ if (!raw) return [];
31
+ let parsed: unknown;
32
+ try {
33
+ parsed = JSON.parse(raw);
34
+ } catch {
35
+ return [];
36
+ }
37
+ if (!Array.isArray(parsed)) return [];
38
+ return parsed
39
+ .map((item): AskUserSelectionGroup | null => {
40
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
41
+ const obj = item as Record<string, unknown>;
42
+ if (Array.isArray(obj.selections)) {
43
+ const opts = obj.selections.filter((s): s is string => typeof s === 'string');
44
+ if (opts.length > 0) {
45
+ return {
46
+ question: typeof obj.question === 'string' ? obj.question : '',
47
+ selections: opts,
48
+ };
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+ // Graceful coercion: flat string → single-option group.
54
+ if (typeof item === 'string') {
55
+ return { question: '', selections: [item] };
56
+ }
57
+ return null;
58
+ })
59
+ .filter((g): g is AskUserSelectionGroup => g !== null);
60
+ }
61
+
25
62
  /** Map a repoburg AIAction (+ its first execution log) to an agent-pane item
26
63
  * shape. Returns null for actions that carry no displayable content
27
64
  * (final/html_final — their text is part of the LLM response). */
@@ -69,19 +106,26 @@ export function mapActionToItem(
69
106
  };
70
107
 
71
108
  case 'invoke_subagent': {
109
+ // background_task completed form — Claude-provider data shape. Serves
110
+ // snapshot reconcile + reload; the live projector finalizes the same
111
+ // card through the identical external id `<inputId>-action-<actionId>`.
72
112
  const args = parseJsonArgs(action.arguments);
113
+ const agentName = (args?.agent_name as string) ?? (args?.agentName as string) ?? 'Sub-agent';
114
+ const prompt = (args?.prompt as string) ?? '';
73
115
  return {
74
- type: 'mcp_tool_call',
116
+ type: 'background_task',
75
117
  data: {
76
- server: null,
77
- tool: 'Agent',
78
- arguments: {
79
- description: (args?.agent_name as string) ?? (args?.agentName as string) ?? 'Subagent',
80
- prompt: (args?.prompt as string) ?? '',
81
- },
82
- result: output ? [{ type: 'text', text: output }] : null,
83
- error: failed ? errorMsg || 'Sub-agent failed' : null,
84
- status,
118
+ task_id: (args?.childSessionId as string) ?? '',
119
+ description: prompt || agentName,
120
+ task_type: 'subagent',
121
+ subagent_type: agentName,
122
+ prompt: prompt || null,
123
+ status: failed ? 'failed' : 'completed',
124
+ summary: output || (failed ? errorMsg || 'Sub-agent failed' : ''),
125
+ output_file: null,
126
+ output: '',
127
+ token_count: null,
128
+ last_tool_name: null,
85
129
  },
86
130
  status,
87
131
  };
@@ -92,9 +136,23 @@ export function mapActionToItem(
92
136
  return { type: 'web_search', data: { query: (args?.query as string) ?? '' }, status };
93
137
  }
94
138
 
139
+ case 'ask_user': {
140
+ // Rendered as clickable selection buttons in the frontend.
141
+ // Malformed payload → fallback plain-text question card.
142
+ const groups = parseSelectionGroups(action.selections);
143
+ const data: AskUserItemData = {
144
+ question: groups.length ? '' : (action.selections ?? ''),
145
+ groups,
146
+ };
147
+ return {
148
+ type: 'ask_user',
149
+ data: { question: data.question, groups: data.groups },
150
+ status,
151
+ };
152
+ }
153
+
95
154
  case 'final':
96
155
  case 'html_final':
97
- case 'ask_user':
98
156
  // Their text is part of the LLM response — no separate item.
99
157
  return null;
100
158
 
@@ -138,6 +196,26 @@ export function mapUsage(u: {
138
196
  };
139
197
  }
140
198
 
199
+ /** Update an item's data + status and broadcast the updated item. */
200
+ export function updateItem(
201
+ db: Database.Database,
202
+ sessionId: string,
203
+ turnId: string,
204
+ item: Item,
205
+ data: Record<string, unknown>,
206
+ status: ItemStatus,
207
+ ): void {
208
+ const now = new Date().toISOString();
209
+ db.prepare('UPDATE items SET data = ?, status = ?, updated_at = ? WHERE id = ?').run(
210
+ JSON.stringify(data),
211
+ status,
212
+ now,
213
+ item.id,
214
+ );
215
+ const updated: Item = { ...item, data, status, updated_at: now };
216
+ broadcast('item:updated', { sessionId, turnId, item: updated });
217
+ }
218
+
141
219
  /** Update the running state and status of a session. */
142
220
  export function setSessionStatus(
143
221
  db: Database.Database,
@@ -30,6 +30,8 @@ export interface AgentRuntimeActionInfo {
30
30
  tool_name: string | null;
31
31
  arguments: string | null;
32
32
  plain: string | null;
33
+ /** Raw selections JSON string (ask_user/final) — parsed by the client. */
34
+ selections: string | null;
33
35
  order_of_execution: number;
34
36
  }
35
37
 
@@ -80,6 +82,26 @@ export interface UserMessage {
80
82
  content: AgentRuntimeToolResultBlock[];
81
83
  }
82
84
 
85
+ /** Sub-agent lifecycle event on the PARENT session channel (Claude task
86
+ * parity) — compact; child content never crosses the wire. */
87
+ export interface SubagentMessage {
88
+ type: 'subagent';
89
+ /** PARENT session id (routing). */
90
+ sessionId: string;
91
+ event: 'started' | 'progress' | 'completed' | 'failed';
92
+ /** Child session id — the task identity. */
93
+ task_id: string;
94
+ agent_name: string;
95
+ /** Task prompt on `started`; the child's latest action on `progress`. */
96
+ description: string;
97
+ /** Parent input + invoke_subagent action — the card key. */
98
+ input_id: string;
99
+ action_id: string;
100
+ last_tool_name?: string | null;
101
+ token_count?: number;
102
+ summary?: string;
103
+ usage?: AgentRuntimeUsage;
104
+ }
83
105
  export interface ResultMessage {
84
106
  type: 'result';
85
107
  subtype: 'success' | 'error_during_execution' | 'aborted';
@@ -96,6 +118,7 @@ export type AgentRuntimeMessage =
96
118
  | StreamEventMessage
97
119
  | AssistantMessage
98
120
  | UserMessage
121
+ | SubagentMessage
99
122
  | ResultMessage;
100
123
 
101
124
  // ---------------------------------------------------------------------------
@@ -268,4 +268,89 @@ describe('backend-resolver', () => {
268
268
  await vi.advanceTimersByTimeAsync(31_000);
269
269
  await assertion;
270
270
  });
271
+ it('accepts a healthy running backend whose health projectPath matches the workspace', async () => {
272
+ const abs = path.resolve('/tmp/proj');
273
+ mockFetchRoutes([
274
+ { match: (u) => u === `${DAEMON}/health`, respond: () => jsonResponse({ status: 'ok' }) },
275
+ {
276
+ match: (u) => u === `${DAEMON}/services`,
277
+ respond: () => jsonResponse([service({ path: abs, port: 3402, name: 'proj' })]),
278
+ },
279
+ { match: (u) => u === 'http://localhost:3402/health', respond: () => jsonResponse({ status: 'ok', projectPath: abs }) },
280
+ ]);
281
+
282
+ const resolved = await resolveBackendUrl('/tmp/proj');
283
+ expect(resolved?.baseUrl).toBe('http://localhost:3402');
284
+ });
285
+
286
+ it('treats a projectPath mismatch as unhealthy (autoStart=false → null, no restart)', async () => {
287
+ const abs = path.resolve('/tmp/proj');
288
+ const fetchMock = mockFetchRoutes([
289
+ { match: (u) => u === `${DAEMON}/health`, respond: () => jsonResponse({ status: 'ok' }) },
290
+ {
291
+ match: (u) => u === `${DAEMON}/services`,
292
+ respond: () => jsonResponse([service({ path: abs, port: 3402, name: 'proj' })]),
293
+ },
294
+ { match: (u) => u === 'http://localhost:3402/health', respond: () => jsonResponse({ status: 'ok', projectPath: '/tmp/other-workspace' }) },
295
+ ]);
296
+
297
+ expect(await resolveBackendUrl('/tmp/proj', { autoStart: false })).toBeNull();
298
+ expect(fetchMock.mock.calls.some(([u]) => String(u).includes('restart'))).toBe(false);
299
+ });
300
+
301
+ it('restarts a running backend that serves a different workspace, then resolves it', async () => {
302
+ const abs = path.resolve('/tmp/proj');
303
+ let servingWrongWorkspace = true;
304
+ const restartMock = vi.fn();
305
+ mockFetchRoutes([
306
+ { match: (u) => u === `${DAEMON}/health`, respond: () => jsonResponse({ status: 'ok' }) },
307
+ {
308
+ match: (u) => u === `${DAEMON}/services`,
309
+ respond: () => jsonResponse([service({ path: abs, port: 3402, name: 'proj' })]),
310
+ },
311
+ {
312
+ match: (u) => u === 'http://localhost:3402/health',
313
+ respond: () =>
314
+ servingWrongWorkspace
315
+ ? jsonResponse({ status: 'ok', projectPath: '/tmp/other-workspace' })
316
+ : jsonResponse({ status: 'ok', projectPath: abs }),
317
+ },
318
+ {
319
+ match: (u, m) => u === `${DAEMON}/services/proj/restart` && m === 'POST',
320
+ respond: () => {
321
+ restartMock();
322
+ servingWrongWorkspace = false;
323
+ return jsonResponse(service({ path: abs, port: 3402, name: 'proj' }));
324
+ },
325
+ },
326
+ ]);
327
+
328
+ const resolved = await resolveBackendUrl('/tmp/proj');
329
+ expect(restartMock).toHaveBeenCalledTimes(1);
330
+ expect(resolved?.baseUrl).toBe('http://localhost:3402');
331
+ });
332
+
333
+ it('re-consults the daemon when the cached backend starts serving a different workspace', async () => {
334
+ const abs = path.resolve('/tmp/proj');
335
+ let projectPath = abs;
336
+ const fetchMock = mockFetchRoutes([
337
+ { match: (u) => u === `${DAEMON}/health`, respond: () => jsonResponse({ status: 'ok' }) },
338
+ {
339
+ match: (u) => u === `${DAEMON}/services`,
340
+ respond: () => jsonResponse([service({ path: abs, port: 3402, name: 'proj' })]),
341
+ },
342
+ { match: (u) => u === 'http://localhost:3402/health', respond: () => jsonResponse({ status: 'ok', projectPath }) },
343
+ ]);
344
+
345
+ // First resolve caches the backend (projectPath matches).
346
+ await resolveBackendUrl('/tmp/proj');
347
+
348
+ // The row got mis-mapped — the backend now serves another workspace.
349
+ projectPath = '/tmp/other-workspace';
350
+ fetchMock.mockClear();
351
+ await resolveBackendUrl('/tmp/proj', { autoStart: false });
352
+ // The cache was rejected and the daemon re-consulted (health probe rejected).
353
+ expect(fetchMock.mock.calls.some(([u]) => String(u) === 'http://localhost:3402/health')).toBe(true);
354
+ expect(fetchMock.mock.calls.some(([u]) => String(u) === `${DAEMON}/services`)).toBe(true);
355
+ });
271
356
  });
@@ -32,6 +32,12 @@ export interface DaemonService {
32
32
  pid: number | null;
33
33
  }
34
34
 
35
+ /** Root backend /health payload (projectPath is additive — absent on pre-v2 builds). */
36
+ interface HealthPayload {
37
+ status: string;
38
+ projectPath?: string;
39
+ }
40
+
35
41
  /** A resolved backend instance to connect to. */
36
42
  export interface ResolvedBackend {
37
43
  baseUrl: string;
@@ -124,21 +130,29 @@ export function invalidateBackend(baseUrl: string): void {
124
130
  }
125
131
  }
126
132
 
127
- /** Probe a backend's /health endpoint (short timeout). */
128
- async function backendHealthy(baseUrl: string): Promise<boolean> {
133
+ /**
134
+ * Probe a backend's /health endpoint (short timeout). With expectedPath, a
135
+ * projectPath mismatch means unhealthy: the backend serves a different
136
+ * workspace than the daemon row claims (mis-mapped service).
137
+ */
138
+ async function backendHealthy(baseUrl: string, expectedPath?: string): Promise<boolean> {
129
139
  try {
130
- await fetchJson(`${baseUrl}/health`, undefined, 2000);
140
+ const health = await fetchJson<HealthPayload>(`${baseUrl}/health`, undefined, 2000);
141
+ if (health.status !== 'ok') return false;
142
+ if (expectedPath && health.projectPath) {
143
+ return path.resolve(health.projectPath) === path.resolve(expectedPath);
144
+ }
131
145
  return true;
132
146
  } catch {
133
147
  return false;
134
148
  }
135
149
  }
136
150
 
137
- /** Wait until the backend on baseUrl reports healthy. */
138
- async function waitForHealthy(baseUrl: string, name: string): Promise<void> {
151
+ /** Wait until the backend on baseUrl reports healthy (for expectedPath). */
152
+ async function waitForHealthy(baseUrl: string, name: string, expectedPath?: string): Promise<void> {
139
153
  const deadline = Date.now() + HEALTH_TIMEOUT_MS;
140
154
  while (Date.now() < deadline) {
141
- if (await backendHealthy(baseUrl)) return;
155
+ if (await backendHealthy(baseUrl, expectedPath)) return;
142
156
  await new Promise((resolve) => setTimeout(resolve, HEALTH_POLL_INTERVAL_MS));
143
157
  }
144
158
  throw new Error(
@@ -202,9 +216,9 @@ export async function resolveBackendUrl(
202
216
  const absPath = path.resolve(projectPath);
203
217
  const daemon = daemonBaseUrl();
204
218
 
205
- // 2. Cached resolution, re-verified healthy.
219
+ // 2. Cached resolution, re-verified healthy (and serving this workspace).
206
220
  const cached = resolvedCache.get(absPath);
207
- if (cached && (await backendHealthy(cached.baseUrl))) {
221
+ if (cached && (await backendHealthy(cached.baseUrl, absPath))) {
208
222
  return cached;
209
223
  }
210
224
 
@@ -221,13 +235,14 @@ export async function resolveBackendUrl(
221
235
  const match = services.find((s) => s.path === absPath);
222
236
 
223
237
  if (match && match.status === 'running') {
224
- if (await backendHealthy(serviceBaseUrl(match))) {
238
+ if (await backendHealthy(serviceBaseUrl(match), absPath)) {
225
239
  return cache(absPath, match);
226
240
  }
227
241
  if (!autoStart) return null;
228
- // Healthy check failed — the process died. Restart via the daemon.
242
+ // Unhealthy or serving a different workspace — the process is wrong
243
+ // for this path. Restart via the daemon.
229
244
  await restartService(daemon, match.name);
230
- await waitForHealthy(serviceBaseUrl(match), match.name);
245
+ await waitForHealthy(serviceBaseUrl(match), match.name, absPath);
231
246
  return cache(absPath, match);
232
247
  }
233
248
 
@@ -235,12 +250,12 @@ export async function resolveBackendUrl(
235
250
 
236
251
  if (match) {
237
252
  await restartService(daemon, match.name);
238
- await waitForHealthy(serviceBaseUrl(match), match.name);
253
+ await waitForHealthy(serviceBaseUrl(match), match.name, absPath);
239
254
  return cache(absPath, match);
240
255
  }
241
256
 
242
257
  // No service for this path yet — start one.
243
258
  const service = await startService(daemon, absPath);
244
- await waitForHealthy(serviceBaseUrl(service), service.name);
259
+ await waitForHealthy(serviceBaseUrl(service), service.name, absPath);
245
260
  return cache(absPath, service);
246
261
  }
@@ -130,6 +130,27 @@ describe('RepoburgHttpClient (HTTP control surface)', () => {
130
130
 
131
131
  expect(models).toEqual([]);
132
132
  });
133
+ it('deleteInputsUntilHere DELETEs the anchor until-here endpoint (204 empty body)', async () => {
134
+ // The root endpoint replies 204 No Content — must resolve, not throw.
135
+ fetchMock.mockResolvedValue({ ok: true, status: 204, text: () => Promise.resolve('') } as never);
136
+
137
+ await client.deleteInputsUntilHere('rs-1', 'in-9');
138
+
139
+ expect(fetchMock).toHaveBeenCalledWith(
140
+ 'http://repoburg.test/sessions/rs-1/inputs/in-9/until-here',
141
+ expect.objectContaining({ method: 'DELETE', body: undefined }),
142
+ );
143
+ });
144
+
145
+ it('deleteInputsUntilHere surfaces the 400 summary-in-range error', async () => {
146
+ fetchMock.mockResolvedValue(
147
+ error(400, { message: 'Summaries can only be discarded, not deleted.' }),
148
+ );
149
+
150
+ await expect(client.deleteInputsUntilHere('rs-1', 'in-9')).rejects.toThrow(
151
+ 'DELETE /sessions/rs-1/inputs/in-9/until-here failed: 400 {"message":"Summaries can only be discarded, not deleted."}',
152
+ );
153
+ });
133
154
 
134
155
  it('throws with status + body text on non-OK responses', async () => {
135
156
  fetchMock.mockResolvedValue(error(400, 'Bad request'));
@@ -60,6 +60,8 @@ export interface RepoburgAction {
60
60
  tool_name?: string | null;
61
61
  arguments?: string | null;
62
62
  plain?: string | null;
63
+ /** Raw selections JSON string (ask_user/final) — parsed by the client. */
64
+ selections?: string | null;
63
65
  reason?: string | null;
64
66
  order_of_execution?: number;
65
67
  executionLogs?: RepoburgExecutionLog[];
@@ -88,6 +90,8 @@ export interface RepoburgRuntimeClient {
88
90
  getInputWithActions(sessionId: string, inputId: string): Promise<RepoburgSessionInput>;
89
91
  /** Full snapshot: all inputs of the session with actions + logs (ASC). */
90
92
  getSessionInputs(sessionId: string): Promise<RepoburgSessionInput[]>;
93
+ /** Hard-delete every input AFTER the anchor (platform rewind parity). */
94
+ deleteInputsUntilHere(sessionId: string, anchorInputId: string): Promise<void>;
91
95
  cancel(sessionId: string): Promise<{ success: boolean; cancelled: boolean }>;
92
96
  getModels(): Promise<RepoburgModel[]>;
93
97
  }
@@ -131,6 +135,8 @@ export class RepoburgHttpClient implements RepoburgRuntimeClient {
131
135
  }
132
136
  throw new Error(`${method} ${path} failed: ${response.status} ${text}`);
133
137
  }
138
+ // Success with no payload — e.g. 204 No Content from the until-here delete.
139
+ if (text.length === 0) return undefined as unknown as T;
134
140
  try {
135
141
  return JSON.parse(text) as T;
136
142
  } catch {
@@ -167,6 +173,13 @@ export class RepoburgHttpClient implements RepoburgRuntimeClient {
167
173
  return this.request<RepoburgSessionInput[]>('GET', `/sessions/${sessionId}/inputs`);
168
174
  }
169
175
 
176
+ async deleteInputsUntilHere(sessionId: string, anchorInputId: string): Promise<void> {
177
+ await this.request<unknown>(
178
+ 'DELETE',
179
+ `/sessions/${sessionId}/inputs/${anchorInputId}/until-here`,
180
+ );
181
+ }
182
+
170
183
  async cancel(sessionId: string): Promise<{ success: boolean; cancelled: boolean }> {
171
184
  return this.request<{ success: boolean; cancelled: boolean }>(
172
185
  'POST',