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
@@ -66,6 +66,7 @@ function makeMockClient(): { client: RepoburgRuntimeClient } {
66
66
  createInput: vi.fn(),
67
67
  getInputWithActions: vi.fn(),
68
68
  getSessionInputs: vi.fn(),
69
+ deleteInputsUntilHere: vi.fn().mockResolvedValue(undefined),
69
70
  cancel: vi.fn().mockResolvedValue({ success: true, cancelled: true }),
70
71
  getModels: vi.fn(),
71
72
  } as unknown as RepoburgRuntimeClient;
@@ -136,6 +137,58 @@ describe('repoburgProvider', () => {
136
137
  expect(repoburgProvider.getModels).toBeTypeOf('function');
137
138
  expect(repoburgProvider.shutdown).toBeTypeOf('function');
138
139
  });
140
+ it('implements all RuntimeProvider methods', () => {
141
+ expect(repoburgProvider.startTurn).toBeTypeOf('function');
142
+ expect(repoburgProvider.interruptTurn).toBeTypeOf('function');
143
+ expect(repoburgProvider.clearThread).toBeTypeOf('function');
144
+ expect(repoburgProvider.clearAllThreads).toBeTypeOf('function');
145
+ expect(repoburgProvider.getModels).toBeTypeOf('function');
146
+ expect(repoburgProvider.rewindRuntime).toBeTypeOf('function');
147
+ expect(repoburgProvider.shutdown).toBeTypeOf('function');
148
+ });
149
+
150
+ it('rewindRuntime truncates via until-here with the LAST runtime input id', async () => {
151
+ db.prepare('UPDATE sessions SET external_thread_id = ? WHERE id = ?').run('rs-1', 's1');
152
+ const { session } = seedTurn('t1');
153
+ db.prepare('UPDATE turns SET runtime_input_ids = ? WHERE id = ?').run(
154
+ JSON.stringify(['in-1', 'in-2', 'in-3']),
155
+ 't1',
156
+ );
157
+ // Re-load the turn row AFTER the update (seedTurn fetched a stale row).
158
+ const turn = db.prepare('SELECT * FROM turns WHERE id = ?').get('t1') as Turn;
159
+
160
+ await repoburgProvider.rewindRuntime!(session, turn);
161
+
162
+ expect(mock.client.deleteInputsUntilHere).toHaveBeenCalledWith('rs-1', 'in-3');
163
+ });
164
+
165
+ it('rewindRuntime throws when the turn has no runtime input ids', async () => {
166
+ db.prepare('UPDATE sessions SET external_thread_id = ? WHERE id = ?').run('rs-1', 's1');
167
+ const { session, turn } = seedTurn('t1');
168
+
169
+ await expect(repoburgProvider.rewindRuntime!(session, turn)).rejects.toThrow(
170
+ 'no runtime input ids',
171
+ );
172
+ expect(mock.client.deleteInputsUntilHere).not.toHaveBeenCalled();
173
+ });
174
+
175
+ it('rewindRuntime is a no-op when the session has no runtime thread', async () => {
176
+ const { session, turn } = seedTurn('t1');
177
+
178
+ await repoburgProvider.rewindRuntime!(session, turn);
179
+
180
+ expect(mock.client.deleteInputsUntilHere).not.toHaveBeenCalled();
181
+ });
182
+
183
+ it('rewindRuntime accepts the typed Turn array form of runtime_input_ids', async () => {
184
+ db.prepare('UPDATE sessions SET external_thread_id = ? WHERE id = ?').run('rs-1', 's1');
185
+ const { session, turn } = seedTurn('t1');
186
+ const typedTurn = { ...turn, runtime_input_ids: ['in-a', 'in-b'] } as Turn;
187
+
188
+ await repoburgProvider.rewindRuntime!(session, typedTurn);
189
+
190
+ expect(mock.client.deleteInputsUntilHere).toHaveBeenCalledWith('rs-1', 'in-b');
191
+ });
139
192
 
140
193
  describe('mapReasoningEffort', () => {
141
194
  it('passes valid Ollama levels through and clamps xhigh to high', () => {
@@ -442,6 +495,7 @@ describe('repoburgProvider', () => {
442
495
  tool_name: null,
443
496
  arguments: null,
444
497
  plain: null,
498
+ selections: null,
445
499
  order_of_execution: 0,
446
500
  },
447
501
  },
@@ -460,6 +514,7 @@ describe('repoburgProvider', () => {
460
514
  tool_name: null,
461
515
  arguments: null,
462
516
  plain: null,
517
+ selections: null,
463
518
  order_of_execution: 1,
464
519
  },
465
520
  },
@@ -606,6 +661,7 @@ describe('repoburgProvider', () => {
606
661
  tool_name: null,
607
662
  arguments: null,
608
663
  plain: null,
664
+ selections: null,
609
665
  order_of_execution: 0,
610
666
  },
611
667
  },
@@ -641,6 +697,7 @@ describe('repoburgProvider', () => {
641
697
  tool_name: null,
642
698
  arguments: null,
643
699
  plain: null,
700
+ selections: null,
644
701
  order_of_execution: 0,
645
702
  },
646
703
  },
@@ -262,6 +262,16 @@ export async function startTurn(session: Session, turn: Turn): Promise<void> {
262
262
  return;
263
263
  }
264
264
 
265
+ // Pinned-session cross-check (belt and braces): a daemon reshuffle may
266
+ // resolve a different URL than the one pinned in the session entry. The
267
+ // pinned entry wins until the session ends (deliberate) — log the mismatch.
268
+ const pinned = sessionMap.get(session.id);
269
+ if (pinned && pinned.baseUrl !== baseUrl) {
270
+ console.warn(
271
+ `[repoburg] resolved backend ${baseUrl} differs from pinned ${pinned.baseUrl} for session ${session.id} — keeping the pinned entry.`,
272
+ );
273
+ }
274
+
265
275
  // Phase 1: ensure the runtime session exists.
266
276
  let entry: SessionEntry;
267
277
  try {
@@ -372,6 +382,44 @@ export function clearThread(sessionId: string): void {
372
382
  sessionMap.delete(sessionId);
373
383
  }
374
384
 
385
+ /** Parse a turn row's runtime_input_ids (JSON string in DB, array in typed Turn). */
386
+ function parseRuntimeInputIds(turn: Turn): string[] {
387
+ const raw = (turn as Turn & { runtime_input_ids?: string | string[] | null }).runtime_input_ids;
388
+ if (Array.isArray(raw)) return raw;
389
+ if (typeof raw === 'string' && raw) {
390
+ try {
391
+ const parsed = JSON.parse(raw);
392
+ if (Array.isArray(parsed)) return parsed as string[];
393
+ } catch {
394
+ // Corrupt JSON — no anchor.
395
+ }
396
+ }
397
+ return [];
398
+ }
399
+
400
+ /** Truncate the runtime session IN PLACE (platform-parity rewind): hard-delete
401
+ * every runtime input after the target turn's LAST runtime input via the
402
+ * until-here endpoint. Throws on failure — the caller skips the local rewind.
403
+ * forkSession is intentionally NOT implemented for repoburg (a hard-deleted
404
+ * session cannot be forked; truncation is the rewind primitive). */
405
+ export async function rewindRuntime(session: Session, targetTurn: Turn): Promise<void> {
406
+ if (!session.external_thread_id) return; // no runtime session yet — nothing to truncate
407
+ const anchor = parseRuntimeInputIds(targetTurn).pop();
408
+ if (!anchor) {
409
+ throw new Error(
410
+ 'Cannot truncate runtime history — the target turn has no runtime input ids (turn predates rewind support or never ran on repoburg).',
411
+ );
412
+ }
413
+ const entry = sessionMap.get(session.id);
414
+ let client = entry?.client ?? null;
415
+ if (!client) {
416
+ const resolved = await resolveBackendUrl(session.cwd ?? process.cwd());
417
+ if (!resolved) throw new Error('No repoburg backend resolved.');
418
+ client = new RepoburgHttpClient(resolved.baseUrl);
419
+ }
420
+ await client.deleteInputsUntilHere(session.external_thread_id, anchor);
421
+ }
422
+
375
423
  /** Clear all session state. Used in test cleanup. */
376
424
  export function clearAllThreads(): void {
377
425
  for (const entry of sessionMap.values()) {
@@ -418,5 +466,6 @@ export const repoburgProvider: RuntimeProvider = {
418
466
  clearThread,
419
467
  clearAllThreads,
420
468
  getModels,
469
+ rewindRuntime,
421
470
  shutdown,
422
471
  };
@@ -34,6 +34,7 @@ function actionInfo(overrides: Partial<AgentRuntimeActionInfo> = {}): AgentRunti
34
34
  tool_name: null,
35
35
  arguments: null,
36
36
  plain: null,
37
+ selections: null,
37
38
  order_of_execution: 0,
38
39
  ...overrides,
39
40
  };
@@ -116,6 +117,36 @@ describe('TurnProjector', () => {
116
117
  expect(delta).toBeDefined();
117
118
  expect(delta!.data).toMatchObject({ turnId: TURN_ID, itemId: 'in-1-text', delta: 'lo' });
118
119
  });
120
+ it('records runtime input ids into turns.runtime_input_ids (rewind anchor mapping)', () => {
121
+ const p = makeProjector();
122
+
123
+ // Round 1
124
+ p.handle({
125
+ type: 'assistant',
126
+ sessionId: RUNTIME_SESSION_ID,
127
+ inputId: 'in-1',
128
+ content: [{ type: 'text', text: 'Round one' }],
129
+ });
130
+ // Round 2 (multi-round turn)
131
+ p.handle({
132
+ type: 'assistant',
133
+ sessionId: RUNTIME_SESSION_ID,
134
+ inputId: 'in-2',
135
+ content: [{ type: 'text', text: 'Round two' }],
136
+ });
137
+ // Replay of an existing round must NOT duplicate the id
138
+ p.handle({
139
+ type: 'stream_event',
140
+ sessionId: RUNTIME_SESSION_ID,
141
+ inputId: 'in-2',
142
+ delta: { type: 'text', text: ' again' },
143
+ });
144
+
145
+ const row = db
146
+ .prepare('SELECT runtime_input_ids FROM turns WHERE id = ?')
147
+ .get(TURN_ID) as { runtime_input_ids: string | null };
148
+ expect(JSON.parse(row.runtime_input_ids!)).toEqual(['in-1', 'in-2']);
149
+ });
119
150
 
120
151
  it('completes streamed items when the assistant message arrives', () => {
121
152
  const p = makeProjector();
@@ -292,6 +323,10 @@ describe('TurnProjector', () => {
292
323
  cached_input_tokens: 2,
293
324
  });
294
325
  expect(captured.find((b) => b.event === 'turn:completed')).toBeDefined();
326
+ // The completion broadcast carries the final text — the FE renders it
327
+ // for final-action turns that have no agent_message item.
328
+ const completed = captured.find((b) => b.event === 'turn:completed');
329
+ expect(completed!.data).toMatchObject({ response: 'All done' });
295
330
 
296
331
  // Session marked not-running with unreviewed actions.
297
332
  const session = db.prepare('SELECT is_running, status, has_unreviewed FROM sessions WHERE id = ?').get(SESSION_ID) as never;
@@ -453,7 +488,118 @@ describe('TurnProjector', () => {
453
488
  expect(row.error).toMatch(/still running/);
454
489
  });
455
490
 
456
- it('a live result cancels the watchdog', async () => {
491
+ describe('subagent lifecycle → background_task', () => {
492
+ /** One registered sub-agent event message. */
493
+ function subagent(event: 'started' | 'progress' | 'completed' | 'failed', extra: Record<string, unknown> = {}): AgentRuntimeMessage {
494
+ return {
495
+ type: 'subagent',
496
+ sessionId: RUNTIME_SESSION_ID,
497
+ event,
498
+ task_id: 'child-1',
499
+ agent_name: 'explore-codebase',
500
+ description: 'Find auth files',
501
+ input_id: 'in-1',
502
+ action_id: 'act-1',
503
+ ...extra,
504
+ };
505
+ }
506
+
507
+ it('started/progress/completed converge on ONE background_task item', () => {
508
+ const p = makeProjector();
509
+
510
+ p.handle(subagent('started'));
511
+ let rows = items();
512
+ expect(rows).toHaveLength(1);
513
+ expect(rows[0]).toMatchObject({
514
+ external_item_id: 'in-1-action-act-1',
515
+ type: 'background_task',
516
+ status: 'in_progress',
517
+ });
518
+ expect(JSON.parse(rows[0].data)).toMatchObject({
519
+ task_id: 'child-1',
520
+ description: 'Find auth files',
521
+ task_type: 'subagent',
522
+ subagent_type: 'explore-codebase',
523
+ status: 'running',
524
+ });
525
+
526
+ p.handle(subagent('progress', { description: 'create_file: src/auth.ts', last_tool_name: 'create_file', token_count: 120 }));
527
+ rows = items();
528
+ expect(rows).toHaveLength(1); // SAME item — no duplicates
529
+ expect(JSON.parse(rows[0].data)).toMatchObject({
530
+ description: 'create_file: src/auth.ts',
531
+ last_tool_name: 'create_file',
532
+ token_count: 120,
533
+ status: 'running',
534
+ });
535
+ const updated = captured.filter((b) => b.event === 'item:updated');
536
+ expect(updated).toHaveLength(1);
537
+
538
+ p.handle(subagent('completed', { summary: 'auth files found', usage: { input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } }));
539
+ rows = items();
540
+ expect(rows).toHaveLength(1);
541
+ expect(rows[0].status).toBe('completed');
542
+ expect(JSON.parse(rows[0].data)).toMatchObject({
543
+ status: 'completed',
544
+ summary: 'auth files found',
545
+ });
546
+ });
547
+
548
+ it('failed settlement marks the card failed', () => {
549
+ const p = makeProjector();
550
+
551
+ p.handle(subagent('started'));
552
+ p.handle(subagent('failed', { summary: 'boom' }));
553
+
554
+ const rows = items();
555
+ expect(rows).toHaveLength(1);
556
+ expect(rows[0].status).toBe('failed');
557
+ expect(JSON.parse(rows[0].data)).toMatchObject({ status: 'failed', summary: 'boom' });
558
+ });
559
+
560
+ it('invoke_subagent tool_result with the same action id does NOT duplicate the live card', () => {
561
+ const p = makeProjector();
562
+
563
+ p.handle(subagent('started'));
564
+ p.handle({
565
+ type: 'user',
566
+ sessionId: RUNTIME_SESSION_ID,
567
+ inputId: 'in-1',
568
+ content: [
569
+ {
570
+ type: 'tool_result',
571
+ tool_use_id: 'act-1',
572
+ content: 'child output',
573
+ is_error: false,
574
+ action: actionInfo({
575
+ id: 'act-1',
576
+ action_type: 'invoke_subagent',
577
+ arguments: JSON.stringify({ agent_name: 'explore-codebase', prompt: 'Find auth files' }),
578
+ file_path: null,
579
+ }),
580
+ },
581
+ ],
582
+ });
583
+
584
+ const rows = items();
585
+ expect(rows).toHaveLength(1);
586
+ expect(rows[0].external_item_id).toBe('in-1-action-act-1');
587
+ expect(rows[0].type).toBe('background_task');
588
+ });
589
+
590
+ it('a completed card from subagent events survives a later completed event idempotently', () => {
591
+ const p = makeProjector();
592
+
593
+ p.handle(subagent('started'));
594
+ p.handle(subagent('completed', { summary: 'done' }));
595
+ p.handle(subagent('completed', { summary: 'done' })); // replay
596
+
597
+ const rows = items();
598
+ expect(rows).toHaveLength(1);
599
+ expect(rows[0].status).toBe('completed');
600
+ });
601
+ });
602
+ it('a live result cancels the watchdog', async () => {
457
603
  vi.useFakeTimers();
458
604
  const p = makeProjector();
459
605
  p.handle({
@@ -26,12 +26,14 @@ import {
26
26
  mapActionToItem,
27
27
  mapUsage,
28
28
  setSessionStatus,
29
+ updateItem,
29
30
  } from './action-mapping.ts';
30
31
  import type {
31
32
  AgentRuntimeMessage,
32
33
  AssistantMessage,
33
34
  ResultMessage,
34
35
  StreamEventMessage,
36
+ SubagentMessage,
35
37
  UserMessage,
36
38
  } from './agent-runtime-client.ts';
37
39
 
@@ -89,6 +91,9 @@ export class TurnProjector {
89
91
  case 'user':
90
92
  this.handleUser(message);
91
93
  break;
94
+ case 'subagent':
95
+ this.handleSubagent(message);
96
+ break;
92
97
  case 'result':
93
98
  this.handleResult(message);
94
99
  break;
@@ -124,10 +129,36 @@ export class TurnProjector {
124
129
  finalPlain: null,
125
130
  };
126
131
  this.rounds.set(inputId, info);
132
+ this.recordRuntimeInput(inputId);
127
133
  }
128
134
  return info;
129
135
  }
130
136
 
137
+ /** Append a runtime input id to the turn row's runtime_input_ids column —
138
+ * the rewind anchor mapping (anchor = LAST id of the target turn). */
139
+ private recordRuntimeInput(inputId: string): void {
140
+ try {
141
+ const db = getDb();
142
+ const row = db.prepare('SELECT runtime_input_ids FROM turns WHERE id = ?').get(this.deps.turnId) as
143
+ | { runtime_input_ids: string | null }
144
+ | undefined;
145
+ if (!row) return; // turn row gone (deleted session) — skip silently
146
+ let ids: string[] = [];
147
+ if (row.runtime_input_ids) {
148
+ const parsed = JSON.parse(row.runtime_input_ids);
149
+ if (Array.isArray(parsed)) ids = parsed;
150
+ }
151
+ if (ids.includes(inputId)) return;
152
+ ids.push(inputId);
153
+ db.prepare('UPDATE turns SET runtime_input_ids = ? WHERE id = ?').run(
154
+ JSON.stringify(ids),
155
+ this.deps.turnId,
156
+ );
157
+ } catch {
158
+ // Best-effort recording — rewind only loses anchor precision.
159
+ }
160
+ }
161
+
131
162
  private handleStreamEvent(msg: StreamEventMessage): void {
132
163
  const db = getDb();
133
164
  const info = this.round(msg.inputId);
@@ -271,7 +302,72 @@ export class TurnProjector {
271
302
  this.touch();
272
303
  }
273
304
 
274
- private handleResult(msg: ResultMessage): void {
305
+ /** Sub-agent lifecycle → background_task item (Claude data shape),
306
+ * idempotent upsert keyed `<inputId>-action-<actionId>` — the same
307
+ * external id the invoke_subagent tool_result/reconcile uses, so all
308
+ * paths converge on ONE card. */
309
+ private handleSubagent(msg: SubagentMessage): void {
310
+ const db = getDb();
311
+ const externalId = `${msg.input_id}-action-${msg.action_id}`;
312
+ const terminal = msg.event === 'completed' || msg.event === 'failed';
313
+ const itemStatus: Item['status'] = terminal
314
+ ? msg.event === 'completed'
315
+ ? 'completed'
316
+ : 'failed'
317
+ : 'in_progress';
318
+
319
+ const row = db
320
+ .prepare('SELECT * FROM items WHERE turn_id = ? AND external_item_id = ?')
321
+ .get(this.deps.turnId, externalId) as
322
+ | (Omit<Item, 'data'> & { data: string | null })
323
+ | undefined;
324
+
325
+ if (row) {
326
+ const prev = row.data ? (JSON.parse(row.data) as Record<string, unknown>) : {};
327
+ const item: Item = { ...row, data: prev } as unknown as Item;
328
+ updateItem(
329
+ db,
330
+ this.deps.agentPaneSessionId,
331
+ this.deps.turnId,
332
+ item,
333
+ {
334
+ ...prev,
335
+ description: msg.description,
336
+ subagent_type: msg.agent_name,
337
+ status: terminal ? msg.event : 'running',
338
+ last_tool_name: msg.last_tool_name ?? prev.last_tool_name ?? null,
339
+ token_count: msg.token_count ?? prev.token_count ?? null,
340
+ ...(terminal ? { summary: msg.summary ?? '' } : {}),
341
+ },
342
+ itemStatus,
343
+ );
344
+ } else {
345
+ insertItem(
346
+ db,
347
+ this.deps.agentPaneSessionId,
348
+ this.deps.turnId,
349
+ externalId,
350
+ 'background_task',
351
+ {
352
+ task_id: msg.task_id,
353
+ description: msg.description,
354
+ task_type: 'subagent',
355
+ subagent_type: msg.agent_name,
356
+ prompt: null,
357
+ status: terminal ? msg.event : 'running',
358
+ summary: terminal ? msg.summary ?? '' : null,
359
+ output_file: null,
360
+ output: '',
361
+ token_count: msg.token_count ?? null,
362
+ last_tool_name: msg.last_tool_name ?? null,
363
+ },
364
+ itemStatus,
365
+ terminal ? 'item:completed' : 'item:started',
366
+ );
367
+ }
368
+ this.touch();
369
+ }
370
+ private handleResult(msg: ResultMessage): void {
275
371
  switch (msg.subtype) {
276
372
  case 'success':
277
373
  this.finishSuccess(msg);
@@ -315,6 +411,9 @@ export class TurnProjector {
315
411
  turnId: this.deps.turnId,
316
412
  ...(usage ? { usage } : {}),
317
413
  hasUnreviewed: true,
414
+ // Final text — the FE renders it when the turn has no agent_message
415
+ // item (final-action rounds).
416
+ response: responseText || null,
318
417
  });
319
418
  void this.syncTitle();
320
419
  this.finalize();
@@ -464,6 +563,7 @@ export class TurnProjector {
464
563
  turnId: this.deps.turnId,
465
564
  ...(usage ? { usage } : {}),
466
565
  hasUnreviewed: true,
566
+ response: responseText || null,
467
567
  });
468
568
  void this.syncTitle();
469
569
  this.finalize();