@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
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ // Sub-agent runs left `running` for good. A run's end is written by its
4
+ // worker (or by the worker-death and terminal-turn reconciliations); a run
5
+ // whose worker is gone without either leaves its row `running` forever, and
6
+ // such rows count as running work everywhere: the activity tracker, stalled
7
+ // and all_idle for a subscription to all sessions, the suspend-when-idle
8
+ // watchdog (live: rows two months old still `running`).
9
+ //
10
+ // The sweep closes them: a `running` row older than `minAgeMs` whose
11
+ // conversation has no live worker becomes `cancelled`. The age bound keeps a
12
+ // run that just started, with its worker still registering, out of it; the
13
+ // liveness check keeps a long background agent of a live worker in. It runs
14
+ // shortly after boot (the workers of the previous runtime have reconnected
15
+ // by then) and periodically.
16
+
17
+ export const STALE_SUBAGENT_RUN_MIN_AGE_MS = 10 * 60_000;
18
+ export const STALE_SUBAGENT_RUN_SWEEP_INTERVAL_MS = 10 * 60_000;
19
+ export const STALE_SUBAGENT_RUN_BOOT_DELAY_MS = 60_000;
20
+
21
+ function toText(value) {
22
+ return String(value ?? '').trim();
23
+ }
24
+
25
+ /**
26
+ * `isConversationWorkerAlive(conversationId)` says whether a live worker owns
27
+ * the conversation. `now()` is injectable for tests.
28
+ */
29
+ export function createStaleSubagentRunSweep({
30
+ db,
31
+ isConversationWorkerAlive,
32
+ now = () => Date.now(),
33
+ minAgeMs = STALE_SUBAGENT_RUN_MIN_AGE_MS,
34
+ log = (line) => console.log(line),
35
+ warn = (line) => console.warn(line),
36
+ } = {}) {
37
+ if (!db) throw new Error('createStaleSubagentRunSweep requires db');
38
+ if (typeof isConversationWorkerAlive !== 'function') throw new Error('createStaleSubagentRunSweep requires isConversationWorkerAlive');
39
+
40
+ let statements = null;
41
+ function prepare() {
42
+ if (statements) return statements;
43
+ try {
44
+ statements = {
45
+ listOld: db.prepare(`
46
+ SELECT id, conversation_id, started_at, updated_at
47
+ FROM subagent_runs
48
+ WHERE status = 'running' AND COALESCE(updated_at, started_at) < ?
49
+ ORDER BY conversation_id, started_at
50
+ `),
51
+ close: db.prepare(`
52
+ UPDATE subagent_runs
53
+ SET status = 'cancelled', updated_at = ?, completed_at = COALESCE(completed_at, ?)
54
+ WHERE id = ? AND status = 'running'
55
+ `),
56
+ };
57
+ } catch {
58
+ statements = null;
59
+ }
60
+ return statements;
61
+ }
62
+
63
+ /** One pass. Returns `{ checked, closed, conversations }`. */
64
+ function sweep() {
65
+ const prepared = prepare();
66
+ if (!prepared) return { checked: 0, closed: 0, conversations: 0, skipped: 'unsupported' };
67
+ const at = now();
68
+ const cutoff = new Date(at - minAgeMs).toISOString();
69
+ let rows = [];
70
+ try {
71
+ rows = prepared.listOld.all(cutoff);
72
+ } catch (error) {
73
+ warn(`STALE SUBAGENT RUNS sweep failed: ${error?.message || error}`);
74
+ return { checked: 0, closed: 0, conversations: 0, error: String(error?.message || error) };
75
+ }
76
+ if (!rows.length) return { checked: 0, closed: 0, conversations: 0 };
77
+ const aliveByConversation = new Map();
78
+ const alive = (conversationId) => {
79
+ if (!aliveByConversation.has(conversationId)) {
80
+ let live = false;
81
+ try { live = isConversationWorkerAlive(conversationId) === true; } catch { live = false; }
82
+ aliveByConversation.set(conversationId, live);
83
+ }
84
+ return aliveByConversation.get(conversationId);
85
+ };
86
+ const nowIso = new Date(at).toISOString();
87
+ const closedConversations = new Set();
88
+ let closed = 0;
89
+ let oldest = null;
90
+ db.transaction(() => {
91
+ for (const row of rows) {
92
+ const conversationId = toText(row.conversation_id);
93
+ if (alive(conversationId)) continue;
94
+ if (prepared.close.run(nowIso, nowIso, row.id).changes > 0) {
95
+ closed += 1;
96
+ closedConversations.add(conversationId);
97
+ const since = toText(row.updated_at) || toText(row.started_at);
98
+ if (since && (!oldest || since < oldest)) oldest = since;
99
+ }
100
+ }
101
+ })();
102
+ if (closed) {
103
+ log(`STALE SUBAGENT RUNS closed ${closed} run${closed === 1 ? '' : 's'} still marked running in ${closedConversations.size} conversation${closedConversations.size === 1 ? '' : 's'} without a live worker (oldest ${oldest})`);
104
+ }
105
+ return { checked: rows.length, closed, conversations: closedConversations.size };
106
+ }
107
+
108
+ return { sweep };
109
+ }
@@ -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
+ }