@oar-sh/oar 0.9.9 → 0.9.10

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 (85) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/README.md +78 -9
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +2 -2
  5. package/server/README.md +286 -3
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +35 -1
  8. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  9. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  10. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  12. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  13. package/server/db-schema.mjs +36 -0
  14. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  15. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  16. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  17. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  18. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  19. package/server/public/app/api-client.js +61 -0
  20. package/server/public/app/archive-confirm.mjs +28 -0
  21. package/server/public/app/ask-user-view.js +3 -0
  22. package/server/public/app/attachments-view.js +516 -6
  23. package/server/public/app/background-tasks-view.mjs +18 -26
  24. package/server/public/app/bootstrap.js +186 -35
  25. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  26. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  27. package/server/public/app/composer-control-state.mjs +16 -0
  28. package/server/public/app/composer-keyboard.mjs +54 -0
  29. package/server/public/app/conversation-view.js +361 -85
  30. package/server/public/app/explorer-edit-state.mjs +68 -0
  31. package/server/public/app/host-suspend-ui.js +6 -3
  32. package/server/public/app/journal-view.js +146 -26
  33. package/server/public/app/no-conversation-state.mjs +58 -0
  34. package/server/public/app/provider-readiness.mjs +120 -0
  35. package/server/public/app/relay-board-view.js +3 -0
  36. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  37. package/server/public/app/relay-event-view.mjs +234 -0
  38. package/server/public/app/settings-modal.js +53 -1
  39. package/server/public/app/socket-handlers.js +59 -28
  40. package/server/public/app/store.js +19 -2
  41. package/server/public/app/subagent-row.mjs +138 -0
  42. package/server/public/app/subagent-timeline-view.mjs +186 -0
  43. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  44. package/server/public/app/subagent-viewer.mjs +534 -0
  45. package/server/public/app/task-row-format.mjs +29 -0
  46. package/server/public/app/thinking-follow.js +4 -0
  47. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  48. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  49. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  50. package/server/public/app/update-ui.js +1 -1
  51. package/server/public/app/viewer-history.mjs +5 -3
  52. package/server/public/index.html +396 -5
  53. package/server/repositories/message-repository.mjs +29 -3
  54. package/server/repositories/question-repository.mjs +9 -3
  55. package/server/repositories/remote-relay-repository.mjs +6 -2
  56. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  57. package/server/routes/ask-user-routes.mjs +3 -0
  58. package/server/routes/messages-routes.mjs +515 -50
  59. package/server/routes/relay-event-routes.mjs +157 -0
  60. package/server/routes/remote-relay-routes.mjs +15 -0
  61. package/server/routes/sessions-routes.mjs +72 -0
  62. package/server/server-runtime.mjs +245 -62
  63. package/server/services/explorer-entry-name.mjs +26 -0
  64. package/server/services/explorer-mutations.mjs +122 -0
  65. package/server/services/preview-proxy-server.mjs +41 -23
  66. package/server/services/push-dispatch-service.mjs +18 -0
  67. package/server/services/relay-event-link-service.mjs +289 -0
  68. package/server/services/relay-event-service.mjs +1935 -0
  69. package/server/services/relay-shutdown-gate.mjs +31 -0
  70. package/server/services/remote-relay-caller-context.mjs +52 -7
  71. package/server/services/remote-relay-dispatcher.mjs +494 -2
  72. package/server/services/remote-relay-registry-service.mjs +11 -1
  73. package/server/services/session-activity-tracker.mjs +487 -0
  74. package/server/services/session-history-refresh-service.mjs +7 -0
  75. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  76. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  77. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  78. package/server/services/subagent-record-retention.mjs +164 -0
  79. package/server/services/subagent-timeline-service.mjs +328 -0
  80. package/server/services/subagent-viewer-rooms.mjs +119 -0
  81. package/server/services/upgrade-socket-guard.mjs +16 -0
  82. package/shared/relay-events.mjs +371 -0
  83. package/shared/remote-relay-contract.mjs +157 -5
  84. package/shared/subagent-record-retention.mjs +31 -0
  85. package/shared/subagent-timeline.mjs +79 -0
@@ -16,14 +16,13 @@ import {
16
16
  setRelayOnline,
17
17
  setCliOnline,
18
18
  setCloudflaredTunnelState,
19
- setCurrentConv,
20
19
  updateWorkspaceRootHints,
21
20
  updateCompactButton,
22
- updateSessionPill,
23
21
  clearPendingUserMessage,
24
22
  hasPendingUserMessageDuplicate,
25
23
  isMessagesAtBottom,
26
24
  upsertSubagentRun,
25
+ getSubagentRun,
27
26
  addSubagentActivity,
28
27
  addSubagentThought,
29
28
  clearSubagentCancelInFlight,
@@ -36,7 +35,8 @@ import {
36
35
  import { scheduleContextUsageRefresh } from './api-client.js';
37
36
  import { capLiveActivityEntries } from './activity-replay-state.mjs';
38
37
  import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
39
- import { renderConvList, refreshConversations, openConversation } from './journal-view.js';
38
+ import { clearOpenConversation, renderConvList, refreshConversations, openConversation } from './journal-view.js';
39
+ import { applyRelayEventSubscriptions, loadRelayEventSubscriptions } from './relay-event-subscriptions-ui.js';
40
40
  import {
41
41
  upsertRelayQuestion,
42
42
  loadRelayQuestions,
@@ -54,6 +54,7 @@ import { applyClaudeAuthState } from './claude-auth-ui.js';
54
54
  import { applyGrokAuthState } from './grok-auth-ui.js';
55
55
  import { applyClaudeCloudSettingsState, refreshClaudeCloudSettingsState } from './claude-cloud-settings-ui.js';
56
56
  import { applyClaudeCloudSessionEvent } from './claude-cloud-conversation-ui.js';
57
+ import { updateProviderReadinessInputs } from './provider-readiness.mjs';
57
58
  import { applyCliInstallState } from './cli-install-ui.js';
58
59
  import { renderUpdateSection } from './update-ui.js';
59
60
  import { applyHostSuspendState, applyRelayShutdownState } from './host-suspend-ui.js';
@@ -74,6 +75,7 @@ import {
74
75
  renderMessages,
75
76
  appendMessage,
76
77
  replaceRenderedMessage,
78
+ noteTranscriptLiveRow,
77
79
  applyIncomingConversationDraftUpdate,
78
80
  getRenderedConversationMessageFingerprints,
79
81
  clearBubbleCancelState,
@@ -88,6 +90,12 @@ import { clearMessageSearchRuntimeState } from './message-search-view.js';
88
90
  import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
89
91
  import { isLikelyLiveDuplicateMessage } from './live-message-dedupe.mjs';
90
92
  import { mergeRelayThoughts } from './relay-thoughts.mjs';
93
+ import {
94
+ handleSubagentStatusEventForViewer,
95
+ handleSubagentTimelineEvent,
96
+ handleSubagentViewerReconnect,
97
+ refreshSubagentRows,
98
+ } from './subagent-viewer.mjs';
91
99
 
92
100
  const FALLBACK_MODE = 'agent';
93
101
 
@@ -327,6 +335,10 @@ export async function connectSocket(overrideDeps) {
327
335
  resyncAfterConnect();
328
336
  refreshSessionWorkerStatus().catch(() => {});
329
337
  refreshModelCatalog().catch(() => {});
338
+ // An open sub-agent viewer fetches whatever it missed while away.
339
+ handleSubagentViewerReconnect();
340
+ // Subscriptions that changed while away.
341
+ void loadRelayEventSubscriptions();
330
342
  });
331
343
  socket.on('connect_error', (e) => {
332
344
  setRelayOnline(false);
@@ -357,8 +369,11 @@ export async function connectSocket(overrideDeps) {
357
369
  updateModelCatalogState(payload || {});
358
370
  void reconcileOpenModelVariantModal();
359
371
  });
372
+ // Each settings event also feeds provider readiness (provider-readiness.mjs),
373
+ // so a Settings save changes the empty screen without a reload.
360
374
  socket.on('openai_settings_updated', (payload) => {
361
- deps?.applyOpenAISettingsState?.(payload || {});
375
+ const state = deps?.applyOpenAISettingsState?.(payload || {});
376
+ updateProviderReadinessInputs({ openai: state || payload || null });
362
377
  if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
363
378
  void Promise.resolve()
364
379
  .then(() => deps?.refreshCurrentView?.())
@@ -371,7 +386,8 @@ export async function connectSocket(overrideDeps) {
371
386
  deps?.applyCopilotSettingsState?.(payload || {});
372
387
  });
373
388
  socket.on('claude_settings_updated', (payload) => {
374
- deps?.applyClaudeSettingsState?.(payload || {});
389
+ const state = deps?.applyClaudeSettingsState?.(payload || {});
390
+ updateProviderReadinessInputs({ claude: state || payload || null });
375
391
  if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
376
392
  void Promise.resolve()
377
393
  .then(() => deps?.refreshCurrentView?.())
@@ -421,8 +437,13 @@ export async function connectSocket(overrideDeps) {
421
437
  // carries the same shape as GET /api/settings/claude-cloud; one without a
422
438
  // body is read as "something changed" and answered with a fresh read.
423
439
  socket.on('claude_cloud_settings_updated', (payload) => {
424
- if (payload && typeof payload === 'object') applyClaudeCloudSettingsState(payload);
425
- else void refreshClaudeCloudSettingsState();
440
+ if (payload && typeof payload === 'object') {
441
+ updateProviderReadinessInputs({ claudeCloud: applyClaudeCloudSettingsState(payload) || null });
442
+ } else {
443
+ void refreshClaudeCloudSettingsState().then((state) => {
444
+ updateProviderReadinessInputs({ claudeCloud: state || null });
445
+ });
446
+ }
426
447
  });
427
448
  // A cloud session got its id, pushed a branch or reported a new cost: the
428
449
  // conversation's `cloud` field changed, and with it the cloud line.
@@ -430,7 +451,8 @@ export async function connectSocket(overrideDeps) {
430
451
  if (applyClaudeCloudSessionEvent(payload || null)) deps?.syncChatTitleControls?.();
431
452
  });
432
453
  socket.on('grok_settings_updated', (payload) => {
433
- deps?.applyGrokSettingsState?.(payload || {});
454
+ const state = deps?.applyGrokSettingsState?.(payload || {});
455
+ updateProviderReadinessInputs({ grok: state || payload || null });
434
456
  if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
435
457
  void Promise.resolve()
436
458
  .then(() => deps?.refreshCurrentView?.())
@@ -438,7 +460,8 @@ export async function connectSocket(overrideDeps) {
438
460
  }
439
461
  });
440
462
  socket.on('cursor_settings_updated', (payload) => {
441
- deps?.applyCursorSettingsState?.(payload || {});
463
+ const state = deps?.applyCursorSettingsState?.(payload || {});
464
+ updateProviderReadinessInputs({ cursor: state || payload || null });
442
465
  if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
443
466
  void Promise.resolve()
444
467
  .then(() => deps?.refreshCurrentView?.())
@@ -485,7 +508,11 @@ export async function connectSocket(overrideDeps) {
485
508
  })) {
486
509
  return;
487
510
  }
488
- appendMessage(normalizedMessage, true, messageId);
511
+ // Another client's message (an agent's prompt included) follows the
512
+ // end like any live row: a reader scrolled up stays where they are,
513
+ // and the jump-to-bottom badge counts it.
514
+ appendMessage(normalizedMessage, isMessagesAtBottom(), messageId);
515
+ noteTranscriptLiveRow();
489
516
  }
490
517
  });
491
518
  socket.on('assistant_message', ({ conversationId, message, messageId, sourceMessageId }) => {
@@ -512,6 +539,7 @@ export async function connectSocket(overrideDeps) {
512
539
  if (messageId && seenMessageIds?.has(messageId)) return;
513
540
  if (isCurrentConversation) {
514
541
  appendMessage(message, autoScroll, messageId || null, false, sourceMessageId || null);
542
+ noteTranscriptLiveRow();
515
543
  scheduleContextUsageRefresh(conversationId, 120);
516
544
  }
517
545
  if (sourceMessageId) relayActivities.delete(sourceMessageId);
@@ -532,6 +560,7 @@ export async function connectSocket(overrideDeps) {
532
560
  if (!replaceRenderedMessage(message, messageId)) {
533
561
  appendMessage(message, isMessagesAtBottom(), messageId, false);
534
562
  }
563
+ noteTranscriptLiveRow();
535
564
  });
536
565
  socket.on('relay_question', ({ question }) => upsertRelayQuestion(question));
537
566
  socket.on('relay_question_updated', ({ question }) => upsertRelayQuestion(question));
@@ -543,6 +572,10 @@ export async function connectSocket(overrideDeps) {
543
572
  socket.on('relay_board_changed', () => {
544
573
  loadRelayBoards();
545
574
  });
575
+ // Relay event subscriptions: the whole active list, after every change.
576
+ socket.on('relay_event_subscriptions', ({ subscriptions }) => {
577
+ applyRelayEventSubscriptions(subscriptions);
578
+ });
546
579
  socket.on('background_tasks', ({ conversationId, tasks }) => {
547
580
  setConversationBackgroundTasks(conversationId, tasks);
548
581
  });
@@ -623,24 +656,28 @@ export async function connectSocket(overrideDeps) {
623
656
  appendThinkingThought(key, String(text || ''), !!done, subagentRunId, autoScroll);
624
657
  }
625
658
  });
626
- socket.on('subagent_status', ({ conversationId, messageId, subagentRunId, parentSubagentId, displayName, status, timestamp, stopUnsupported }) => {
627
- if (!messageId || !subagentRunId) return;
628
- upsertSubagentRun({
629
- subagentRunId,
630
- messageId,
631
- conversationId,
632
- parentSubagentId,
633
- displayName,
634
- status,
635
- timestamp,
636
- });
659
+ socket.on('subagent_status', (payload = {}) => {
660
+ const { conversationId, subagentRunId, status, stopUnsupported } = payload;
661
+ if (!subagentRunId) return;
662
+ // An event without its turn is placed on the run the store already
663
+ // knows; one for an unknown run without a turn cannot be placed.
664
+ const messageId = payload.messageId || getSubagentRun(subagentRunId)?.messageId || null;
665
+ if (!messageId) return;
666
+ upsertSubagentRun({ ...payload, messageId });
637
667
  clearSubagentCancelInFlight(subagentRunId);
638
668
  // The provider answered "not supported": pin the state so the button does
639
669
  // not re-arm into an endless click-and-fail loop.
640
670
  if (stopUnsupported) markSubagentStopUnsupported(subagentRunId);
641
671
  if (conversationId === currentConvId) {
642
672
  updateSubagentBubbleFromStatus(subagentRunId, status);
673
+ refreshSubagentRows(subagentRunId);
643
674
  }
675
+ handleSubagentStatusEventForViewer({ ...payload, messageId });
676
+ });
677
+ // Entries of a sub-agent's record; the relay sends them only while this
678
+ // page's viewer watches that run.
679
+ socket.on('subagent_timeline', (payload) => {
680
+ handleSubagentTimelineEvent(payload);
644
681
  });
645
682
  socket.on('conversation_compacted', async ({ sourceConversationId, targetConversationId }) => {
646
683
  if (!sourceConversationId || !targetConversationId) return;
@@ -811,13 +848,7 @@ export async function connectSocket(overrideDeps) {
811
848
  renderRelayBoards();
812
849
  renderConvList();
813
850
  if (currentConvId === conversationId) {
814
- setCurrentConv(null);
815
- renderMessages([]);
816
- document.getElementById('chat-title').textContent = 'Select or start a conversation';
817
- syncChatTitleControls();
818
- updateSessionPill(null, null);
819
- updateCompactButton();
820
- scheduleContextUsageRefresh(null);
851
+ clearOpenConversation();
821
852
  } else {
822
853
  updateCompactButton();
823
854
  }
@@ -1498,6 +1498,17 @@ export function isSubagentCancelInFlight(subagentRunId) {
1498
1498
  return id ? subagentCancelInFlight.has(id) : false;
1499
1499
  }
1500
1500
 
1501
+ const SUBAGENT_HEADER_FIELDS = ['completedAt', 'model', 'modelInherited', 'subagentType', 'taskId', 'totalTokens', 'background', 'truncated', 'stoppable'];
1502
+
1503
+ function subagentHeaderFields(payload, existing) {
1504
+ const fields = {};
1505
+ for (const name of SUBAGENT_HEADER_FIELDS) {
1506
+ const value = payload?.[name];
1507
+ fields[name] = value === undefined ? (existing?.[name] ?? null) : value;
1508
+ }
1509
+ return fields;
1510
+ }
1511
+
1501
1512
  export function upsertSubagentRun(payload) {
1502
1513
  const subagentRunId = normalizeSubagentRunId(payload?.subagentRunId);
1503
1514
  if (!subagentRunId) return null;
@@ -1511,8 +1522,14 @@ export function upsertSubagentRun(payload) {
1511
1522
  parentSubagentId: normalizeSubagentRunId(payload?.parentSubagentId) || existing?.parentSubagentId || null,
1512
1523
  displayName: String(payload?.displayName || existing?.displayName || '').trim() || null,
1513
1524
  status: nextSubagentStatus({ reported: payload?.status, known: existing?.status, normalize: normalizeSubagentStatus }),
1514
- startedAt: existing?.startedAt || payload?.timestamp || now,
1515
- updatedAt: payload?.timestamp || now,
1525
+ startedAt: payload?.startedAt || existing?.startedAt || payload?.timestamp || now,
1526
+ updatedAt: payload?.updatedAt || payload?.timestamp || now,
1527
+ // The run header (relay 0009): what the sub-agent rows and the viewer
1528
+ // show. An event that does not name a field keeps the known value.
1529
+ ...subagentHeaderFields(payload, existing),
1530
+ // Whether `status` came from the relay rather than the default a live
1531
+ // lane's first frame starts with.
1532
+ statusReported: Boolean(String(payload?.status || '').trim()) || existing?.statusReported === true,
1516
1533
  activities: existing?.activities || [],
1517
1534
  thoughts: existing?.thoughts || [],
1518
1535
  // Cumulative streamed text for this run, replayed when its bubble is
@@ -0,0 +1,138 @@
1
+ // One sub-agent as a row: the same look as a background-task row in the
2
+ // composer panel (icon, title, detail line, model, type badge, tokens, time),
3
+ // used in the finished reply bubble and inside the sub-agent viewer for child
4
+ // agents. The title opens the agent's record in the viewer.
5
+ //
6
+ // Pure: markup in, markup out, so the bubble renderer and the viewer share it
7
+ // and tests run without a DOM.
8
+
9
+ import { formatTaskModelLabel, formatTaskTokens } from './task-row-format.mjs';
10
+
11
+ const STATUS_LABELS = {
12
+ running: 'Running',
13
+ pending: 'Starting',
14
+ processing: 'Running',
15
+ completed: 'Completed',
16
+ failed: 'Failed',
17
+ cancelled: 'Stopped',
18
+ };
19
+
20
+ const STATUS_ICONS = {
21
+ completed: '✓',
22
+ failed: '✗',
23
+ cancelled: '■',
24
+ };
25
+
26
+ function escHtml(value) {
27
+ return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
28
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
29
+ }[ch]));
30
+ }
31
+
32
+ function parseTime(value) {
33
+ if (value === null || value === undefined || value === '') return NaN;
34
+ if (typeof value === 'number') return value;
35
+ return Date.parse(String(value));
36
+ }
37
+
38
+ /** "42s", "3m 5s", "1h 4m": how long a run ran, or has been running. */
39
+ export function formatRunDuration(startedAt, endedAt = null, now = Date.now()) {
40
+ const start = parseTime(startedAt);
41
+ if (!Number.isFinite(start)) return '';
42
+ const end = Number.isFinite(parseTime(endedAt)) ? parseTime(endedAt) : now;
43
+ const seconds = Math.max(0, Math.floor((end - start) / 1000));
44
+ if (seconds < 60) return `${seconds}s`;
45
+ const minutes = Math.floor(seconds / 60);
46
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
47
+ const hours = Math.floor(minutes / 60);
48
+ return `${hours}h ${minutes % 60}m`;
49
+ }
50
+
51
+ export function subagentStatusLabel(status) {
52
+ const key = String(status || 'running').trim().toLowerCase();
53
+ return STATUS_LABELS[key] || (key ? key.charAt(0).toUpperCase() + key.slice(1) : 'Running');
54
+ }
55
+
56
+ export function isRunningSubagentStatus(status) {
57
+ const key = String(status || 'running').trim().toLowerCase();
58
+ return key === 'running' || key === 'pending' || key === 'processing';
59
+ }
60
+
61
+ /**
62
+ * The row for one run. `clickable` makes the title the button that opens the
63
+ * viewer (`data-subagent-open`); a shared view, which cannot read the record,
64
+ * passes false. `context` lands in a class so the bubble and the viewer can
65
+ * size the row differently.
66
+ */
67
+ export function subagentRowHtml(run, { context = 'bubble', clickable = true, now = Date.now() } = {}) {
68
+ const runId = String(run?.subagentRunId || '').trim();
69
+ if (!runId) return '';
70
+ const status = String(run?.status || 'running').trim().toLowerCase() || 'running';
71
+ const running = isRunningSubagentStatus(status);
72
+ const title = String(run?.displayName || '').trim() || `Subagent ${runId.slice(0, 8)}`;
73
+ const modelLabel = formatTaskModelLabel(run?.model);
74
+ const modelTitle = run?.modelInherited === true ? `${run.model} (inherited from the session)` : (run?.model || '');
75
+ const kind = String(run?.subagentType || '').trim() || 'Subagent';
76
+ const tokens = formatTaskTokens(run?.totalTokens);
77
+ const time = formatRunDuration(run?.startedAt, running ? null : (run?.completedAt || run?.updatedAt), now);
78
+ const statusIcon = STATUS_ICONS[status] ? `${STATUS_ICONS[status]} ` : '';
79
+ const detailParts = [`${statusIcon}${subagentStatusLabel(status)}`];
80
+ if (run?.background) detailParts.push('in the background');
81
+ if (run?.truncated) detailParts.push('record cut');
82
+ const titleHtml = clickable
83
+ ? `<button type="button" class="subagent-open bg-task-desc" data-subagent-open="${escHtml(runId)}" title="Open this sub-agent's thoughts, tool calls and reply">${escHtml(title)}</button>`
84
+ : `<span class="bg-task-desc">${escHtml(title)}</span>`;
85
+ return `
86
+ <div class="bg-task-row subagent-row subagent-row-${escHtml(context)}${running ? ' is-running' : ''}" data-subagent-row="${escHtml(runId)}" data-status="${escHtml(status)}">
87
+ <span class="bg-task-icon" title="Sub-agent">🤖</span>
88
+ <span class="bg-task-main">
89
+ ${titleHtml}
90
+ <span class="bg-task-detail subagent-row-status subagent-status-${escHtml(status)}">${escHtml(detailParts.join(' · '))}</span>
91
+ </span>
92
+ <span class="bg-task-side">
93
+ <span class="bg-task-side-head">
94
+ ${modelLabel ? `<span class="bg-task-model" title="${escHtml(modelTitle)}">${escHtml(modelLabel)}</span>` : ''}
95
+ <span class="bg-task-badge">${escHtml(kind)}</span>
96
+ </span>
97
+ <span class="bg-task-side-status">
98
+ ${tokens ? `<span class="bg-task-tokens">${escHtml(tokens)}</span>` : ''}
99
+ ${time ? `<span class="bg-task-elapsed subagent-row-time"${running ? ` data-started-at="${escHtml(run?.startedAt || '')}"` : ''}>${escHtml(time)}</span>` : ''}
100
+ </span>
101
+ </span>
102
+ </div>`;
103
+ }
104
+
105
+ /**
106
+ * A run from the conversation payload, brought up to date with what the page
107
+ * has learned since (the store, fed by status events). Only what the store
108
+ * actually knows wins: an entry a live lane created has no header yet, and
109
+ * its status is a guess unless a status event reported it.
110
+ */
111
+ export function mergeLiveSubagentRun(base, live) {
112
+ if (!live) return base;
113
+ const merged = { ...base };
114
+ for (const [key, value] of Object.entries(live)) {
115
+ if (key === 'status' || key === 'statusReported' || key === 'activities' || key === 'thoughts' || key === 'streamText') continue;
116
+ if (value === null || value === undefined || value === '') continue;
117
+ merged[key] = value;
118
+ }
119
+ if (live.statusReported && live.status) merged.status = live.status;
120
+ return merged;
121
+ }
122
+
123
+ /**
124
+ * The runs a reply bubble lists: top-level runs only (a child shows inside
125
+ * its parent's record), in the order they were spawned.
126
+ */
127
+ export function topLevelSubagentRuns(runs) {
128
+ return (Array.isArray(runs) ? runs : [])
129
+ .filter((run) => run && String(run.subagentRunId || '').trim() && !run.parentSubagentId)
130
+ .sort((a, b) => (parseTime(a.startedAt) || 0) - (parseTime(b.startedAt) || 0));
131
+ }
132
+
133
+ /** The bubble's list of sub-agent rows, or '' when it spawned none. */
134
+ export function subagentRowsMarkup(runs, { clickable = true, now = Date.now() } = {}) {
135
+ const roots = topLevelSubagentRuns(runs);
136
+ if (!roots.length) return '';
137
+ return `<div class="subagent-rows" data-subagent-rows>${roots.map((run) => subagentRowHtml(run, { context: 'bubble', clickable, now })).join('')}</div>`;
138
+ }
@@ -0,0 +1,186 @@
1
+ // The sub-agent viewer's timeline, without a DOM: the merge of fetched and
2
+ // live entries into one ordered record, gap detection, and the markup of each
3
+ // entry. subagent-viewer.mjs owns the modal and calls into this.
4
+
5
+ import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
6
+ import { subagentRowHtml } from './subagent-row.mjs';
7
+
8
+ function escHtml(value) {
9
+ return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
10
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
11
+ }[ch]));
12
+ }
13
+
14
+ /** One run's record as the viewer holds it. */
15
+ export function createTimelineState(runId) {
16
+ return { runId: String(runId || ''), entries: new Map(), lastSeq: 0, maxSeq: 0 };
17
+ }
18
+
19
+ /**
20
+ * Fold entries (a fetched page or a live event) into the record. An entry the
21
+ * record already has is replaced (open entries grow in place). Returns the
22
+ * seqs that changed and whether the record now has a hole: the relay numbers
23
+ * a run's entries 1, 2, 3…, so a missing number means an event was missed and
24
+ * the viewer should fetch from `lastSeq`.
25
+ */
26
+ export function mergeTimelineEntries(state, entries) {
27
+ const changed = [];
28
+ for (const raw of Array.isArray(entries) ? entries : []) {
29
+ const seq = Number(raw?.seq);
30
+ if (!Number.isInteger(seq) || seq <= 0) continue;
31
+ const previous = state.entries.get(seq);
32
+ // A closed entry never reopens: a late open snapshot is stale.
33
+ if (previous?.done && raw.done === false) continue;
34
+ state.entries.set(seq, { ...raw, seq });
35
+ changed.push(seq);
36
+ if (seq > state.maxSeq) state.maxSeq = seq;
37
+ }
38
+ while (state.entries.has(state.lastSeq + 1)) state.lastSeq += 1;
39
+ return { changed, gap: state.lastSeq < state.maxSeq };
40
+ }
41
+
42
+ export function orderedTimelineEntries(state) {
43
+ return [...state.entries.values()].sort((a, b) => a.seq - b.seq);
44
+ }
45
+
46
+ function parseJsonObject(text) {
47
+ const value = String(text || '').trim();
48
+ if (!value.startsWith('{')) return null;
49
+ try {
50
+ const parsed = JSON.parse(value);
51
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ // Same list as shared/subagent-timeline.mjs (the relay's retention sweep
58
+ // keeps this line when it strips a call's input); a test keeps them in step.
59
+ export const SUMMARY_FIELDS = ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'description', 'prompt', 'notebook_path', 'skill'];
60
+
61
+ /** The one line a tool call is known by: its command, path, pattern… */
62
+ export function toolCallSummary(text) {
63
+ const input = parseJsonObject(text);
64
+ if (input) {
65
+ for (const field of SUMMARY_FIELDS) {
66
+ const value = input[field];
67
+ if (typeof value === 'string' && value.trim()) return value.replace(/\s+/g, ' ').trim().slice(0, 200);
68
+ }
69
+ const keys = Object.keys(input);
70
+ return keys.length ? keys.slice(0, 4).join(', ') : '';
71
+ }
72
+ return String(text || '').split('\n')[0].trim().slice(0, 200);
73
+ }
74
+
75
+ /** A tool call's input, readable: pretty JSON, or the text as it came. */
76
+ export function formatToolInput(text) {
77
+ const input = parseJsonObject(text);
78
+ return input ? JSON.stringify(input, null, 2) : String(text || '');
79
+ }
80
+
81
+ export function formatByteSize(length) {
82
+ const n = Number(length);
83
+ if (!Number.isFinite(n) || n < 0) return '';
84
+ if (n < 1024) return `${n} chars`;
85
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10 * 1024 ? 1 : 0)} KB`;
86
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
87
+ }
88
+
89
+ /** "removed after 90 days": what the relay's retention sweep left a note of. */
90
+ function prunedLabel(entry, what) {
91
+ const days = Number(entry?.metadata?.retentionDays);
92
+ return Number.isFinite(days) && days > 0 ? `${what} removed after ${days} days` : `${what} removed`;
93
+ }
94
+
95
+ function firstLine(text, max = 140) {
96
+ const line = String(text || '').split('\n').map((part) => part.trim()).find(Boolean) || '';
97
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line;
98
+ }
99
+
100
+ /**
101
+ * One entry's markup. `renderMarkdown(text)` renders reply text (and
102
+ * thoughts); `children` maps a child run id to its header so a child entry
103
+ * renders as a clickable row; `openDetails` keeps a fold the reader opened
104
+ * open across re-renders.
105
+ */
106
+ export function timelineEntryHtml(entry, { renderMarkdown = (text) => `<p>${escHtml(text)}</p>`, children = new Map(), openDetails = false, clickable = true, now = Date.now() } = {}) {
107
+ const kind = String(entry?.kind || '');
108
+ const seq = Number(entry?.seq) || 0;
109
+ const open = entry?.done === false;
110
+ const text = String(entry?.text || '');
111
+ const wrap = (inner, extraClass = '') => `<div class="sav-entry sav-entry-${escHtml(kind)}${open ? ' is-open' : ''}${extraClass}" data-seq="${seq}">${inner}</div>`;
112
+ const openAttr = openDetails ? ' open' : '';
113
+
114
+ if (kind === 'thought') {
115
+ const label = open && !text.trim() ? 'Thinking…' : (firstLine(text, 110) || 'Thought');
116
+ return wrap(`<details class="sav-fold"${open || openDetails ? ' open' : ''}><summary><span class="sav-kind">💭</span> <span class="sav-summary-text">${escHtml(label)}</span></summary><div class="sav-thought-body markdown-body">${renderMarkdown(text)}</div></details>`);
117
+ }
118
+
119
+ if (kind === 'text') {
120
+ return wrap(`<div class="sav-text markdown-body">${text.trim() ? renderMarkdown(text) : '<span class="sav-muted">Writing…</span>'}</div>`);
121
+ }
122
+
123
+ if (kind === 'tool_call') {
124
+ const toolName = String(entry?.toolName || 'tool');
125
+ const headline = prefixToolActivityEmoji(`Tool (${toolName}): x`).split(' Tool (')[0] || '🔧';
126
+ const summary = toolCallSummary(text);
127
+ const input = formatToolInput(text);
128
+ const hasMore = input.trim() && input.trim() !== summary;
129
+ const legacy = entry?.metadata?.legacy === true;
130
+ if (entry?.metadata?.pruned === true) {
131
+ return wrap(`<div class="sav-call-line"><span class="sav-kind">${escHtml(headline)}</span> <span class="sav-tool-name">${escHtml(toolName)}</span> <span class="sav-tool-summary">${escHtml(summary)}</span> <span class="sav-muted sav-pruned">· ${escHtml(prunedLabel(entry, 'Input'))}</span></div>`, ' is-pruned');
132
+ }
133
+ if (legacy || !hasMore) {
134
+ return wrap(`<div class="sav-call-line"><span class="sav-kind">${escHtml(headline)}</span> <span class="sav-tool-name">${escHtml(toolName)}</span> <span class="sav-tool-summary">${escHtml(legacy ? text.replace(/^Tool \([^)]+\):?\s*/, '') : summary)}</span></div>`);
135
+ }
136
+ return wrap(`<details class="sav-fold"${openAttr}><summary><span class="sav-kind">${escHtml(headline)}</span> <span class="sav-tool-name">${escHtml(toolName)}</span> <span class="sav-tool-summary">${escHtml(summary)}</span></summary><pre class="sav-pre">${escHtml(input)}</pre></details>`);
137
+ }
138
+
139
+ if (kind === 'tool_result') {
140
+ const isError = entry?.metadata?.isError === true;
141
+ const cut = Number(entry?.metadata?.cut);
142
+ const parts = [isError ? 'Error' : 'Result'];
143
+ if (text) parts.push(formatByteSize(text.length));
144
+ if (Number.isFinite(cut) && cut > text.length) parts.push(`cut from ${formatByteSize(cut)}`);
145
+ const preview = firstLine(text, 120);
146
+ if (entry?.metadata?.pruned === true) {
147
+ const size = formatByteSize(entry.metadata.originalChars);
148
+ const label = [isError ? 'Error' : 'Result', size, prunedLabel(entry, 'output')].filter(Boolean).join(' · ');
149
+ return wrap(`<div class="sav-result-line"><span class="sav-kind">↳</span> <span class="sav-muted sav-pruned">${escHtml(label)}</span></div>`, `${isError ? ' is-error' : ''} is-pruned`);
150
+ }
151
+ if (!text.trim()) {
152
+ return wrap(`<div class="sav-result-line"><span class="sav-kind">↳</span> <span class="sav-muted">${escHtml(parts.join(' · '))} · empty</span></div>`, isError ? ' is-error' : '');
153
+ }
154
+ return wrap(`<details class="sav-fold"${openAttr}><summary><span class="sav-kind">↳</span> <span class="sav-result-label">${escHtml(parts.join(' · '))}</span>${preview ? ` <span class="sav-summary-text sav-muted">${escHtml(preview)}</span>` : ''}</summary><pre class="sav-pre">${escHtml(text)}</pre></details>`, isError ? ' is-error' : '');
155
+ }
156
+
157
+ if (kind === 'child') {
158
+ const childId = String(entry?.toolUseId || '');
159
+ const child = children.get(childId) || {
160
+ subagentRunId: childId,
161
+ displayName: text || 'Subagent',
162
+ subagentType: entry?.metadata?.subagentType || null,
163
+ status: 'running',
164
+ };
165
+ return wrap(childId ? subagentRowHtml(child, { context: 'timeline', clickable, now }) : `<div class="sav-muted">🤖 ${escHtml(text)}</div>`);
166
+ }
167
+
168
+ // note
169
+ const status = String(entry?.metadata?.status || '');
170
+ return wrap(`<div class="sav-note${status ? ` sav-note-${escHtml(status)}` : ''}">${escHtml(text)}</div>`);
171
+ }
172
+
173
+ /** The header's meta line: model · type · status · tokens · time. */
174
+ export function headerMetaParts(run, { formatModel, formatTokens, formatDuration, statusLabel, now = Date.now() } = {}) {
175
+ const parts = [];
176
+ if (run?.model) parts.push(formatModel ? formatModel(run.model) : run.model);
177
+ parts.push(String(run?.subagentType || 'Subagent'));
178
+ parts.push(statusLabel ? statusLabel(run?.status) : String(run?.status || 'running'));
179
+ if (run?.background) parts.push('background');
180
+ const tokens = formatTokens ? formatTokens(run?.totalTokens) : '';
181
+ if (tokens) parts.push(tokens);
182
+ const running = ['running', 'pending', 'processing'].includes(String(run?.status || 'running'));
183
+ const time = formatDuration ? formatDuration(run?.startedAt, running ? null : (run?.completedAt || run?.updatedAt), now) : '';
184
+ if (time) parts.push(time);
185
+ return parts.filter(Boolean);
186
+ }
@@ -0,0 +1,50 @@
1
+ // Tells the relay which run the sub-agent viewer shows, so its record
2
+ // arrives live on this page only (`subagent_viewer_watch` / `_unwatch`). The
3
+ // viewer waits for the watch's answer before it reads the record: a batch
4
+ // sent between the read and the join would otherwise be lost with no later
5
+ // hole in `seq` to show it. The relay's rooms may or may not survive a
6
+ // reconnect, so the viewer watches again on every connect.
7
+
8
+ const WATCH_TIMEOUT_MS = 3000;
9
+
10
+ export function createSubagentViewerWatch({ getSocket, timeoutMs = WATCH_TIMEOUT_MS } = {}) {
11
+ let watched = null;
12
+ // The relay did not know the run yet (a panel row can come before its run
13
+ // row): the next status event of that run asks again.
14
+ let refusedNotFound = false;
15
+
16
+ async function watch(conversationId, runId) {
17
+ watched = { conversationId: String(conversationId || ''), runId: String(runId || '') };
18
+ refusedNotFound = false;
19
+ const socket = getSocket?.();
20
+ if (!socket?.connected || !watched.conversationId || !watched.runId) return { ok: false, code: 'offline' };
21
+ let result;
22
+ try {
23
+ result = await socket.timeout(timeoutMs).emitWithAck('subagent_viewer_watch', {
24
+ conversationId: watched.conversationId,
25
+ subagentRunId: watched.runId,
26
+ });
27
+ } catch {
28
+ return { ok: false, code: 'timeout' };
29
+ }
30
+ if (watched?.runId === String(runId || '') && result?.ok !== true && result?.code === 'not-found') refusedNotFound = true;
31
+ return result && typeof result === 'object' ? result : { ok: false, code: 'bad-answer' };
32
+ }
33
+
34
+ function unwatch() {
35
+ if (!watched) return;
36
+ watched = null;
37
+ refusedNotFound = false;
38
+ const socket = getSocket?.();
39
+ if (socket?.connected) socket.emit('subagent_viewer_unwatch', {});
40
+ }
41
+
42
+ return {
43
+ watch,
44
+ unwatch,
45
+ /** The run the relay should be sending, or null. */
46
+ current: () => (watched ? { ...watched } : null),
47
+ /** True while the relay refused the run on screen as unknown. */
48
+ refused: () => refusedNotFound,
49
+ };
50
+ }