@oar-sh/oar 0.9.9 → 0.9.11

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 (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -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 opens it too, read-only,
64
+ * through the share's sub-agent route. `context` lands in a class so the
65
+ * bubble and the viewer can 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
+ }