@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
@@ -79,17 +79,21 @@ export function createStatusEventService(db, {
79
79
  for (const [key] of oldest) sharedAccessDedupe.delete(key);
80
80
  }
81
81
 
82
+ // `viewerIp` null or empty: the relay does not store viewer addresses, and
83
+ // the event carries none. `viewerKey` (a hash of address and browser) then
84
+ // keeps the dedupe per viewer instead of folding every viewer into one.
82
85
  function recordSharedAccess({
83
86
  shareToken,
84
87
  viewerIp,
88
+ viewerKey = '',
85
89
  conversationId,
86
90
  timestamp = Date.now(),
87
91
  } = {}) {
88
92
  const now = normalizeTimestamp(timestamp);
89
93
  const token = String(shareToken || '').trim();
90
- const ip = String(viewerIp || '').trim() || 'unknown';
94
+ const ip = String(viewerIp || '').trim();
91
95
  const normalizedConversationId = String(conversationId || '').trim();
92
- const dedupeKey = `${token}:${ip}`;
96
+ const dedupeKey = `${token}:${String(viewerKey || '').trim() || ip || 'unknown'}`;
93
97
  const previousSeenAt = sharedAccessDedupe.get(dedupeKey);
94
98
  if (Number.isFinite(previousSeenAt) && (now - previousSeenAt) <= normalizedDedupeTtlMs) {
95
99
  // A dedupe hit slides the window — a persisted event already backs it.
@@ -105,7 +109,7 @@ export function createStatusEventService(db, {
105
109
  source: 'server',
106
110
  details: {
107
111
  shareId: shareIdentifier(token),
108
- viewerIp: ip,
112
+ viewerIp: ip || undefined,
109
113
  conversationId: normalizedConversationId || undefined,
110
114
  },
111
115
  };
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ // Retention for sub-agent records. After the configured number of days a
4
+ // finished run's tool results lose their text and its tool calls keep only
5
+ // their one-line summary; both keep their row, marked
6
+ // `{ pruned: true, prunedAt, originalChars, retentionDays }` so the viewer
7
+ // can say what happened. Thoughts, reply text, notes, child rows and the
8
+ // runs stay. The sweep runs at relay start and once a day, a few thousand
9
+ // rows per transaction with the event loop free in between, and logs one
10
+ // line. No VACUUM: SQLite reuses the freed pages.
11
+
12
+ import { subagentToolCallSummary } from '../../shared/subagent-timeline.mjs';
13
+
14
+ export const SUBAGENT_RETENTION_BATCH_SIZE = 2000;
15
+ const DAY_MS = 24 * 60 * 60 * 1000;
16
+ const METADATA_MAX_CHARS = 4096;
17
+
18
+ function parseMetadata(raw) {
19
+ if (!raw) return {};
20
+ try {
21
+ const value = JSON.parse(raw);
22
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ /** What a stripped entry keeps: text and metadata, from its stored row. */
29
+ export function prunedTimelineEntry(row, { prunedAt, retentionDays }) {
30
+ const original = String(row?.text ?? '');
31
+ const text = row?.kind === 'tool_call' ? subagentToolCallSummary(original) : '';
32
+ const marker = { pruned: true, prunedAt, originalChars: original.length, retentionDays };
33
+ let metadataJson = JSON.stringify({ ...parseMetadata(row?.metadata_json), ...marker });
34
+ if (metadataJson.length > METADATA_MAX_CHARS) metadataJson = JSON.stringify(marker);
35
+ return { text, metadataJson, freedChars: Math.max(0, original.length - text.length) };
36
+ }
37
+
38
+ function columnExists(db, table, column) {
39
+ try {
40
+ return db.prepare(`PRAGMA table_info(${table})`).all().some((entry) => entry.name === column);
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * `getRetentionDays()` reads the setting (0 = off). `now()` and
48
+ * `yieldToLoop()` are injectable for tests.
49
+ */
50
+ export function createSubagentRecordRetention({
51
+ db,
52
+ getRetentionDays,
53
+ now = () => Date.now(),
54
+ batchSize = SUBAGENT_RETENTION_BATCH_SIZE,
55
+ yieldToLoop = () => new Promise((resolve) => setImmediate(resolve)),
56
+ isStopped = () => false,
57
+ log = (line) => console.log(line),
58
+ warn = (line) => console.warn(line),
59
+ } = {}) {
60
+ if (!db) throw new Error('createSubagentRecordRetention requires db');
61
+ if (typeof getRetentionDays !== 'function') throw new Error('createSubagentRecordRetention requires getRetentionDays');
62
+
63
+ let statements = null;
64
+ let running = null;
65
+ let scheduled = null;
66
+
67
+ function prepare() {
68
+ if (statements) return statements;
69
+ if (!columnExists(db, 'subagent_timeline', 'pruned_at')) return null;
70
+ statements = {
71
+ // The first condition repeats the partial index's WHERE word for word.
72
+ // A run still running keeps its whole record, however old.
73
+ candidates: db.prepare(`
74
+ SELECT t.id, t.kind, t.text, t.metadata_json
75
+ FROM subagent_timeline t
76
+ WHERE t.pruned_at IS NULL AND t.kind IN ('tool_call', 'tool_result')
77
+ AND t.updated_at < @cutoff
78
+ AND NOT EXISTS (
79
+ SELECT 1 FROM subagent_runs r
80
+ WHERE r.id = t.run_id AND r.status IN ('running', 'pending', 'processing')
81
+ )
82
+ ORDER BY t.updated_at
83
+ LIMIT @limit
84
+ `),
85
+ prune: db.prepare(`
86
+ UPDATE subagent_timeline
87
+ SET text = @text, metadata_json = @metadataJson, pruned_at = @prunedAt
88
+ WHERE id = @id AND pruned_at IS NULL
89
+ `),
90
+ };
91
+ return statements;
92
+ }
93
+
94
+ function pruneBatch(cutoff, prunedAt, retentionDays) {
95
+ const { candidates, prune } = statements;
96
+ return db.transaction(() => {
97
+ const rows = candidates.all({ cutoff, limit: batchSize });
98
+ let pruned = 0;
99
+ let freedChars = 0;
100
+ for (const row of rows) {
101
+ const next = prunedTimelineEntry(row, { prunedAt, retentionDays });
102
+ pruned += prune.run({ id: row.id, text: next.text, metadataJson: next.metadataJson, prunedAt }).changes;
103
+ freedChars += next.freedChars;
104
+ }
105
+ return { seen: rows.length, pruned, freedChars };
106
+ })();
107
+ }
108
+
109
+ async function runSweep() {
110
+ const retentionDays = Number(getRetentionDays()) || 0;
111
+ if (retentionDays <= 0) return { retentionDays: 0, pruned: 0, freedChars: 0, batches: 0, skipped: 'off' };
112
+ if (!prepare()) return { retentionDays, pruned: 0, freedChars: 0, batches: 0, skipped: 'unsupported' };
113
+ const startedAt = now();
114
+ const cutoff = new Date(startedAt - retentionDays * DAY_MS).toISOString();
115
+ const prunedAt = new Date(startedAt).toISOString();
116
+ let pruned = 0;
117
+ let freedChars = 0;
118
+ let batches = 0;
119
+ for (;;) {
120
+ if (isStopped()) break;
121
+ const batch = pruneBatch(cutoff, prunedAt, retentionDays);
122
+ if (!batch.seen) break;
123
+ batches += 1;
124
+ pruned += batch.pruned;
125
+ freedChars += batch.freedChars;
126
+ if (batch.seen < batchSize) break;
127
+ await yieldToLoop();
128
+ }
129
+ const elapsedMs = Math.max(0, now() - startedAt);
130
+ log(pruned
131
+ ? `SUBAGENT RETENTION stripped ${pruned} tool entr${pruned === 1 ? 'y' : 'ies'} (${freedChars} chars) older than ${retentionDays} d in ${batches} batch${batches === 1 ? '' : 'es'}, ${elapsedMs} ms`
132
+ : `SUBAGENT RETENTION nothing older than ${retentionDays} d to strip`);
133
+ return { retentionDays, pruned, freedChars, batches, cutoff };
134
+ }
135
+
136
+ /** One sweep at a time; a call while one runs gets that one. */
137
+ function sweep() {
138
+ if (running) return running;
139
+ running = runSweep()
140
+ .catch((error) => {
141
+ warn(`SUBAGENT RETENTION sweep failed: ${error?.message || error}`);
142
+ return { error: String(error?.message || error) };
143
+ })
144
+ .finally(() => { running = null; });
145
+ return running;
146
+ }
147
+
148
+ /** A sweep in `delayMs` (the relay's start, a changed setting); later calls move it. */
149
+ function scheduleSweep(delayMs = 0) {
150
+ if (scheduled) clearTimeout(scheduled);
151
+ scheduled = setTimeout(() => {
152
+ scheduled = null;
153
+ if (!isStopped()) void sweep();
154
+ }, Math.max(0, delayMs));
155
+ if (typeof scheduled.unref === 'function') scheduled.unref();
156
+ }
157
+
158
+ function stop() {
159
+ if (scheduled) clearTimeout(scheduled);
160
+ scheduled = null;
161
+ }
162
+
163
+ return { sweep, scheduleSweep, stop, DAY_MS };
164
+ }
@@ -0,0 +1,328 @@
1
+ 'use strict';
2
+
3
+ import {
4
+ SUBAGENT_TIMELINE_MAX_ENTRIES,
5
+ SUBAGENT_TIMELINE_TOOL_NAME_MAX_CHARS,
6
+ cutSubagentTimelineText,
7
+ isSubagentTimelineKind,
8
+ normalizeSubagentRunStatus,
9
+ } from '../../shared/subagent-timeline.mjs';
10
+ import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
11
+
12
+ export const TIMELINE_READ_DEFAULT_LIMIT = 500;
13
+ export const TIMELINE_READ_MAX_LIMIT = 2000;
14
+ const ENTRY_KEY_MAX_CHARS = 200;
15
+ const METADATA_MAX_CHARS = 4096;
16
+ const LEGACY_ROW_LIMIT = 2000;
17
+ export const TRUNCATED_NOTE_KEY = 'relay:truncated';
18
+
19
+ function parseJson(raw) {
20
+ if (!raw) return null;
21
+ try {
22
+ const value = JSON.parse(raw);
23
+ return value && typeof value === 'object' ? value : null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function finiteOrNull(value) {
30
+ const number = Number(value);
31
+ return value !== null && value !== undefined && value !== '' && Number.isFinite(number) ? number : null;
32
+ }
33
+
34
+ function cleanText(value, max) {
35
+ const text = String(value ?? '').trim();
36
+ return text ? text.slice(0, max) : null;
37
+ }
38
+
39
+ /** The run as the browser sees it: the row panel, bubble and viewer render. */
40
+ export function mapSubagentRunHeader(row, { stoppable = false, legacy = false } = {}) {
41
+ const id = String(row?.id || '').trim();
42
+ if (!id) return null;
43
+ return {
44
+ subagentRunId: id,
45
+ messageId: String(row?.queue_message_id || '').trim() || null,
46
+ conversationId: String(row?.conversation_id || '').trim() || null,
47
+ parentSubagentId: row?.parent_subagent_id ? String(row.parent_subagent_id).trim() : null,
48
+ displayName: row?.display_name ? String(row.display_name).trim() : null,
49
+ status: String(row?.status || 'running').trim().toLowerCase() || 'running',
50
+ startedAt: row?.started_at || null,
51
+ updatedAt: row?.updated_at || null,
52
+ completedAt: row?.completed_at || null,
53
+ model: row?.model ? String(row.model) : null,
54
+ modelInherited: row?.model_inherited === null || row?.model_inherited === undefined ? null : Number(row.model_inherited) === 1,
55
+ subagentType: row?.subagent_type ? String(row.subagent_type) : null,
56
+ taskId: row?.task_id ? String(row.task_id) : null,
57
+ totalTokens: finiteOrNull(row?.total_tokens),
58
+ background: Number(row?.background || 0) === 1,
59
+ truncated: Number(row?.truncated || 0) === 1,
60
+ timelineOwner: row?.timeline_owner ? String(row.timeline_owner) : null,
61
+ stoppable: !!stoppable,
62
+ ...(legacy ? { legacy: true } : {}),
63
+ };
64
+ }
65
+
66
+ function mapTimelineRow(row) {
67
+ return {
68
+ seq: Number(row.seq),
69
+ kind: String(row.kind),
70
+ text: row.text === null || row.text === undefined ? '' : String(row.text),
71
+ toolName: row.tool_name || null,
72
+ toolUseId: row.tool_use_id || null,
73
+ metadata: parseJson(row.metadata_json),
74
+ done: Number(row.done) === 1,
75
+ createdAt: row.created_at || null,
76
+ updatedAt: row.updated_at || null,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * One entry as a writer sent it, made safe to store: a known kind, a key, the
82
+ * text cut to its kind's limit (the original length goes into the metadata),
83
+ * small metadata only. Null for anything that cannot be stored.
84
+ */
85
+ export function sanitizeTimelineEntry(raw) {
86
+ if (!raw || typeof raw !== 'object') return null;
87
+ const kind = String(raw.kind || '').trim();
88
+ if (!isSubagentTimelineKind(kind)) return null;
89
+ const key = String(raw.key ?? '').trim().slice(0, ENTRY_KEY_MAX_CHARS);
90
+ if (!key) return null;
91
+ let metadata = raw.metadata && typeof raw.metadata === 'object' && !Array.isArray(raw.metadata) ? { ...raw.metadata } : null;
92
+ const priorCut = finiteOrNull(metadata?.cut);
93
+ const { text, cut } = cutSubagentTimelineText(kind, raw.text, { cut: priorCut });
94
+ if (cut !== null) metadata = { ...(metadata || {}), cut };
95
+ let metadataJson = metadata ? JSON.stringify(metadata) : null;
96
+ if (metadataJson && metadataJson.length > METADATA_MAX_CHARS) metadataJson = cut !== null ? JSON.stringify({ cut }) : null;
97
+ return {
98
+ key,
99
+ kind,
100
+ text,
101
+ toolName: cleanText(raw.toolName, SUBAGENT_TIMELINE_TOOL_NAME_MAX_CHARS),
102
+ toolUseId: sanitizeSubagentRunId(raw.toolUseId) || null,
103
+ metadataJson,
104
+ done: raw.done === false ? 0 : 1,
105
+ };
106
+ }
107
+
108
+ /** A header patch as a writer sent it. Unknown or empty fields stay null. */
109
+ export function sanitizeRunHeaderPatch(raw) {
110
+ if (!raw || typeof raw !== 'object') return null;
111
+ const totalTokens = finiteOrNull(raw.totalTokens);
112
+ const patch = {
113
+ model: cleanText(raw.model, 120),
114
+ modelInherited: typeof raw.modelInherited === 'boolean' ? (raw.modelInherited ? 1 : 0) : null,
115
+ subagentType: cleanText(raw.subagentType, 120),
116
+ taskId: cleanText(raw.taskId, 200),
117
+ totalTokens: totalTokens === null ? null : Math.max(0, Math.round(totalTokens)),
118
+ background: raw.background === true ? 1 : null,
119
+ truncated: raw.truncated === true ? 1 : null,
120
+ timelineOwner: cleanText(raw.timelineOwner, 40),
121
+ status: raw.status ? normalizeSubagentRunStatus(raw.status) : null,
122
+ };
123
+ return Object.values(patch).some((value) => value !== null) ? patch : null;
124
+ }
125
+
126
+ export function createSubagentTimelineService({ db, stmts, now = () => new Date().toISOString() } = {}) {
127
+ const supported = () => !!stmts?.subagentTimelineSupported && typeof stmts?.insertTimelineEntry?.run === 'function';
128
+ const headerSupported = () => typeof stmts?.updateSubagentRunHeader?.run === 'function';
129
+
130
+ /**
131
+ * Write a header patch onto a run. A status names the run's end (or its
132
+ * return to running); the end time is stamped once. Returns the updated row,
133
+ * or null when nothing was written.
134
+ */
135
+ function applyHeader(runId, patch) {
136
+ if (!patch) return null;
137
+ const at = now();
138
+ const { status, ...fields } = patch;
139
+ const hasFields = Object.values(fields).some((value) => value !== null);
140
+ if (hasFields && headerSupported()) {
141
+ stmts.updateSubagentRunHeader.run({ id: runId, updatedAt: at, ...fields });
142
+ }
143
+ if (status) {
144
+ const terminal = status === 'completed' || status === 'failed' || status === 'cancelled';
145
+ stmts.updateSubagentRunStatus?.run?.(status, at, terminal ? at : null, runId);
146
+ }
147
+ return stmts.getSubagentRun?.get?.(runId) || null;
148
+ }
149
+
150
+ /**
151
+ * Store a batch of entries for a run. A known key updates its entry in
152
+ * place; a new key takes the next seq. Past the per-run limit one note says
153
+ * the record was cut, the run is marked truncated, and later new entries are
154
+ * dropped (updates to entries already kept still land). Returns the entries
155
+ * as stored, for the live event.
156
+ */
157
+ function applyEntries(run, rawEntries) {
158
+ if (!supported() || !run) return { entries: [], truncated: false };
159
+ const runId = String(run.id);
160
+ const conversationId = String(run.conversation_id);
161
+ const queueMessageId = run.queue_message_id ? String(run.queue_message_id) : null;
162
+ const entries = (Array.isArray(rawEntries) ? rawEntries : []).map(sanitizeTimelineEntry).filter(Boolean);
163
+ const stored = [];
164
+ let truncated = false;
165
+ const tx = db.transaction(() => {
166
+ let maxSeq = Number(stmts.getMaxTimelineSeq.get(runId)?.max_seq || 0);
167
+ for (const entry of entries) {
168
+ const at = now();
169
+ const existing = stmts.getTimelineEntryByKey.get(runId, entry.key);
170
+ if (existing) {
171
+ stmts.updateTimelineEntryByKey.run({
172
+ runId,
173
+ entryKey: entry.key,
174
+ text: entry.text,
175
+ toolName: entry.toolName,
176
+ toolUseId: entry.toolUseId,
177
+ metadataJson: entry.metadataJson,
178
+ done: entry.done,
179
+ updatedAt: at,
180
+ });
181
+ stored.push(Number(existing.seq));
182
+ continue;
183
+ }
184
+ if (maxSeq >= SUBAGENT_TIMELINE_MAX_ENTRIES) {
185
+ if (!stmts.getTimelineEntryByKey.get(runId, TRUNCATED_NOTE_KEY)) {
186
+ maxSeq += 1;
187
+ stmts.insertTimelineEntry.run({
188
+ runId,
189
+ conversationId,
190
+ queueMessageId,
191
+ entryKey: TRUNCATED_NOTE_KEY,
192
+ seq: maxSeq,
193
+ kind: 'note',
194
+ text: `The record stops here: a sub-agent keeps at most ${SUBAGENT_TIMELINE_MAX_ENTRIES} entries.`,
195
+ toolName: null,
196
+ toolUseId: null,
197
+ metadataJson: JSON.stringify({ truncated: true }),
198
+ done: 1,
199
+ createdAt: at,
200
+ updatedAt: at,
201
+ });
202
+ stmts.setSubagentRunTruncated?.run?.(at, runId);
203
+ stored.push(maxSeq);
204
+ }
205
+ truncated = true;
206
+ continue;
207
+ }
208
+ maxSeq += 1;
209
+ stmts.insertTimelineEntry.run({
210
+ runId,
211
+ conversationId,
212
+ queueMessageId,
213
+ entryKey: entry.key,
214
+ seq: maxSeq,
215
+ kind: entry.kind,
216
+ text: entry.text,
217
+ toolName: entry.toolName,
218
+ toolUseId: entry.toolUseId,
219
+ metadataJson: entry.metadataJson,
220
+ done: entry.done,
221
+ createdAt: at,
222
+ updatedAt: at,
223
+ });
224
+ stored.push(maxSeq);
225
+ }
226
+ });
227
+ tx.immediate ? tx.immediate() : tx();
228
+ if (!stored.length) return { entries: [], truncated };
229
+ const seqs = new Set(stored);
230
+ const minSeq = Math.min(...stored);
231
+ const rows = stmts.listTimelineEntries.all({ runId, afterSeq: minSeq - 1, limit: TIMELINE_READ_MAX_LIMIT })
232
+ .filter((row) => seqs.has(Number(row.seq)));
233
+ return { entries: rows.map(mapTimelineRow), truncated };
234
+ }
235
+
236
+ /**
237
+ * A frame Copilot or Cursor published on a sub-agent lane, copied into the
238
+ * run's timeline (the Claude worker writes its own). The key keeps one entry
239
+ * per thought and one reply text per run, updated in place.
240
+ */
241
+ function mirrorFrame({ runId, kind, key, text, done = true, toolName = null, metadata = null }) {
242
+ if (!supported()) return null;
243
+ const run = stmts.getSubagentRun?.get?.(runId) || null;
244
+ if (!run || String(run.timeline_owner || '') === 'worker') return null;
245
+ const result = applyEntries(run, [{ key, kind, text, done, toolName, metadata }]);
246
+ if (!result.entries.length) return null;
247
+ return { run, entries: result.entries, truncated: result.truncated };
248
+ }
249
+
250
+ function legacyEntries(runId) {
251
+ const rows = [];
252
+ for (const row of stmts.listLegacyThoughtsBySubagentRun?.all?.(runId, LEGACY_ROW_LIMIT) || []) {
253
+ rows.push({ kind: 'thought', text: String(row.text || ''), done: Number(row.done) === 1, createdAt: row.created_at, order: 1 });
254
+ }
255
+ for (const row of stmts.listLegacyActivityBySubagentRun?.all?.(runId, LEGACY_ROW_LIMIT) || []) {
256
+ const text = String(row.text || '');
257
+ const toolMatch = /^Tool \(([^)]+)\)/.exec(text);
258
+ rows.push({
259
+ kind: toolMatch ? 'tool_call' : 'note',
260
+ text,
261
+ toolName: toolMatch ? toolMatch[1] : null,
262
+ done: true,
263
+ createdAt: row.created_at,
264
+ order: 2,
265
+ metadata: { legacy: true },
266
+ });
267
+ }
268
+ for (const row of stmts.listLegacyStreamBySubagentRun?.all?.(runId, LEGACY_ROW_LIMIT) || []) {
269
+ const text = String(row.text || '');
270
+ if (!text.trim()) continue;
271
+ rows.push({ kind: 'text', text, done: Number(row.done) === 1, createdAt: row.created_at, order: 3 });
272
+ }
273
+ rows.sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')) || a.order - b.order);
274
+ return rows.map((row, index) => ({
275
+ seq: index + 1,
276
+ kind: row.kind,
277
+ text: row.text,
278
+ toolName: row.toolName || null,
279
+ toolUseId: null,
280
+ metadata: row.metadata || null,
281
+ done: row.done,
282
+ createdAt: row.createdAt || null,
283
+ updatedAt: row.createdAt || null,
284
+ }));
285
+ }
286
+
287
+ /**
288
+ * A run's record for the viewer: entries after `afterSeq` (plus open ones),
289
+ * its child runs, and whether this page reached the end. A run with no
290
+ * timeline rows that no worker owns is served from what it recorded before
291
+ * the timeline existed.
292
+ */
293
+ function readTimeline(run, { afterSeq = 0, limit = TIMELINE_READ_DEFAULT_LIMIT, isStoppable = () => false } = {}) {
294
+ const runId = String(run.id);
295
+ const pageLimit = Math.max(1, Math.min(TIMELINE_READ_MAX_LIMIT, Number(limit) || TIMELINE_READ_DEFAULT_LIMIT));
296
+ const after = Math.max(0, Number(afterSeq) || 0);
297
+ const children = (stmts.listChildSubagentRuns?.all?.(runId) || [])
298
+ .map((row) => mapSubagentRunHeader(row, { stoppable: isStoppable(row) }))
299
+ .filter(Boolean);
300
+ let entries = [];
301
+ let legacy = false;
302
+ let complete = true;
303
+ if (supported()) {
304
+ const rows = stmts.listTimelineEntries.all({ runId, afterSeq: after, limit: pageLimit + 1 });
305
+ complete = rows.length <= pageLimit;
306
+ entries = rows.slice(0, pageLimit).map(mapTimelineRow);
307
+ const hasAny = after > 0 || entries.length > 0 || Number(stmts.getMaxTimelineSeq.get(runId)?.max_seq || 0) > 0;
308
+ if (!hasAny && !run.timeline_owner) {
309
+ entries = after > 0 ? [] : legacyEntries(runId);
310
+ legacy = entries.length > 0;
311
+ complete = true;
312
+ }
313
+ } else if (after === 0) {
314
+ entries = legacyEntries(runId);
315
+ legacy = entries.length > 0;
316
+ }
317
+ const lastSeq = entries.reduce((max, entry) => Math.max(max, entry.seq), after);
318
+ return {
319
+ run: mapSubagentRunHeader(run, { stoppable: isStoppable(run), legacy }),
320
+ entries,
321
+ children,
322
+ nextAfter: lastSeq,
323
+ complete,
324
+ };
325
+ }
326
+
327
+ return { applyHeader, applyEntries, mirrorFrame, readTimeline, supported };
328
+ }
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ // Who receives a sub-agent's record live: only the pages whose viewer shows
4
+ // that run. The viewer names its run with `subagent_viewer_watch` (on open,
5
+ // on switching to a child or its parent, and after every reconnect) and
6
+ // leaves with `subagent_viewer_unwatch`; the relay keeps each socket in at
7
+ // most one viewer room. `emitSubagentTimeline` sends volatile to that room,
8
+ // so a page without the viewer gets nothing and a connection that cannot take
9
+ // a batch drops it — the viewer fetches whatever it misses.
10
+
11
+ import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
12
+
13
+ const ROOM_PREFIX = 'subagent-viewer:';
14
+
15
+ export function subagentViewerRoom(runId) {
16
+ return `${ROOM_PREFIX}${String(runId || '')}`;
17
+ }
18
+
19
+ /**
20
+ * Sends a run's record batches to its viewers. socket.io drops a volatile
21
+ * packet whenever the connection is still writing an earlier one, even one
22
+ * sent in the same tick, and a worker post that changes the run's header
23
+ * emits the run's status just before its batch. So batches wait one tick,
24
+ * merged per run (the newest copy of an entry wins), and go out together:
25
+ * dropped only when the connection is really backed up.
26
+ */
27
+ export function createSubagentTimelinePublisher(io, { defer = setImmediate } = {}) {
28
+ const pending = new Map();
29
+ let scheduled = false;
30
+
31
+ function flush() {
32
+ scheduled = false;
33
+ const batches = [...pending.values()];
34
+ pending.clear();
35
+ for (const batch of batches) {
36
+ const entries = [...batch.entries.values()].sort((a, b) => Number(a.seq) - Number(b.seq));
37
+ try {
38
+ io.to(subagentViewerRoom(batch.subagentRunId)).volatile.emit('subagent_timeline', { ...batch.head, entries });
39
+ } catch {}
40
+ }
41
+ }
42
+
43
+ return function publish(payload) {
44
+ const runId = String(payload?.subagentRunId || '');
45
+ if (!runId || !payload?.entries?.length) return;
46
+ let batch = pending.get(runId);
47
+ if (!batch) {
48
+ batch = { subagentRunId: runId, head: null, entries: new Map() };
49
+ pending.set(runId, batch);
50
+ }
51
+ const { entries, ...head } = payload;
52
+ batch.head = { ...head, truncated: !!(batch.head?.truncated || head.truncated) };
53
+ for (const entry of entries) batch.entries.set(Number(entry.seq), entry);
54
+ if (!scheduled) {
55
+ scheduled = true;
56
+ defer(flush);
57
+ }
58
+ };
59
+ }
60
+
61
+ function safeAck(ack, payload) {
62
+ if (typeof ack !== 'function') return;
63
+ try { ack(payload); } catch {}
64
+ }
65
+
66
+ /**
67
+ * `getRun(runId)` returns the run row (`id`, `conversation_id`) or null.
68
+ * Sockets reaching `registerSocket` are already authenticated (the `io.use`
69
+ * gate, and the re-check of recovered sockets in the connection handler).
70
+ */
71
+ export function createSubagentViewerRooms({ getRun } = {}) {
72
+ if (typeof getRun !== 'function') throw new Error('createSubagentViewerRooms requires getRun');
73
+
74
+ function leave(socket) {
75
+ const current = socket.data?.subagentViewerRoom;
76
+ if (current) socket.leave(current);
77
+ if (socket.data) socket.data.subagentViewerRoom = null;
78
+ }
79
+
80
+ function watch(socket, payload) {
81
+ const conversationId = String(payload?.conversationId || '').trim();
82
+ const runId = sanitizeSubagentRunId(payload?.subagentRunId);
83
+ if (!conversationId || !runId) return { ok: false, code: 'missing-id' };
84
+ let run = null;
85
+ try {
86
+ run = getRun(runId);
87
+ } catch {
88
+ return { ok: false, code: 'unavailable' };
89
+ }
90
+ if (!run) return { ok: false, code: 'not-found' };
91
+ if (String(run.conversation_id || '') !== conversationId) return { ok: false, code: 'conversation-mismatch' };
92
+ const room = subagentViewerRoom(runId);
93
+ if (socket.data?.subagentViewerRoom !== room) {
94
+ leave(socket);
95
+ socket.join(room);
96
+ if (socket.data) socket.data.subagentViewerRoom = room;
97
+ } else if (!socket.rooms?.has?.(room)) {
98
+ // A recovered socket keeps its data; its rooms may or may not be back.
99
+ socket.join(room);
100
+ }
101
+ return { ok: true, subagentRunId: runId };
102
+ }
103
+
104
+ function registerSocket(socket) {
105
+ if (!socket || typeof socket.on !== 'function') return;
106
+ socket.on('subagent_viewer_watch', (payload, ack) => {
107
+ const result = watch(socket, payload);
108
+ // A refused watch leaves no stale room behind: the page shows another run now.
109
+ if (!result.ok) leave(socket);
110
+ safeAck(ack, result);
111
+ });
112
+ socket.on('subagent_viewer_unwatch', (_payload, ack) => {
113
+ leave(socket);
114
+ safeAck(ack, { ok: true });
115
+ });
116
+ }
117
+
118
+ return { registerSocket };
119
+ }