@oar-sh/oar 0.9.8 → 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 (107) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/README.md +129 -39
  3. package/bin/bat/oar.bat +46 -46
  4. package/bin/oar.js +814 -228
  5. package/package.json +5 -6
  6. package/server/README.md +306 -4
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  8. package/server/claude-worker/claude-session-process.mjs +35 -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 +3 -1
  13. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  14. package/server/db-schema.mjs +44 -0
  15. package/server/migrations/0008-message-pins.mjs +85 -0
  16. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  17. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  18. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  19. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  20. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  21. package/server/public/app/api-client.js +91 -0
  22. package/server/public/app/archive-confirm.mjs +28 -0
  23. package/server/public/app/ask-user-view.js +3 -0
  24. package/server/public/app/attachments-view.js +1024 -131
  25. package/server/public/app/back-guard.mjs +58 -0
  26. package/server/public/app/background-tasks-view.mjs +18 -26
  27. package/server/public/app/bootstrap.js +230 -35
  28. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  29. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  30. package/server/public/app/composer-control-state.mjs +16 -0
  31. package/server/public/app/composer-keyboard.mjs +54 -0
  32. package/server/public/app/conversation-view.js +471 -110
  33. package/server/public/app/explorer-edit-state.mjs +68 -0
  34. package/server/public/app/external-link-policy.mjs +4 -1
  35. package/server/public/app/file-version.mjs +58 -0
  36. package/server/public/app/host-suspend-ui.js +6 -3
  37. package/server/public/app/journal-view.js +161 -27
  38. package/server/public/app/no-conversation-state.mjs +58 -0
  39. package/server/public/app/pinned-messages-view.mjs +253 -0
  40. package/server/public/app/provider-readiness.mjs +120 -0
  41. package/server/public/app/pwa-install.js +1 -1
  42. package/server/public/app/relay-board-view.js +3 -0
  43. package/server/public/app/relay-dot-state.mjs +8 -1
  44. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  45. package/server/public/app/relay-event-view.mjs +234 -0
  46. package/server/public/app/router.js +8 -2
  47. package/server/public/app/settings-modal.js +53 -1
  48. package/server/public/app/socket-handlers.js +65 -28
  49. package/server/public/app/store.js +25 -2
  50. package/server/public/app/subagent-row.mjs +138 -0
  51. package/server/public/app/subagent-timeline-view.mjs +186 -0
  52. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  53. package/server/public/app/subagent-viewer.mjs +534 -0
  54. package/server/public/app/task-row-format.mjs +29 -0
  55. package/server/public/app/thinking-follow.js +4 -0
  56. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  57. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  58. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  59. package/server/public/app/update-ui.js +1 -1
  60. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  61. package/server/public/app/viewer-controls-state.mjs +59 -0
  62. package/server/public/app/viewer-gallery.mjs +46 -0
  63. package/server/public/app/viewer-gestures.mjs +105 -0
  64. package/server/public/app/viewer-history.mjs +45 -0
  65. package/server/public/app/viewer-tap.mjs +112 -0
  66. package/server/public/index.html +700 -59
  67. package/server/repositories/message-pin-statements.mjs +39 -0
  68. package/server/repositories/message-repository.mjs +34 -3
  69. package/server/repositories/question-repository.mjs +9 -3
  70. package/server/repositories/remote-relay-repository.mjs +6 -2
  71. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  72. package/server/routes/ask-user-routes.mjs +3 -0
  73. package/server/routes/messages-routes.mjs +548 -111
  74. package/server/routes/relay-event-routes.mjs +157 -0
  75. package/server/routes/remote-relay-routes.mjs +15 -0
  76. package/server/routes/sessions-routes.mjs +164 -24
  77. package/server/server-runtime.mjs +274 -62
  78. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  79. package/server/services/explorer-entry-name.mjs +26 -0
  80. package/server/services/explorer-mutations.mjs +122 -0
  81. package/server/services/file-serving.mjs +155 -0
  82. package/server/services/message-pin-list.mjs +52 -0
  83. package/server/services/oar-cli-helpers.mjs +139 -4
  84. package/server/services/oar-cli-menu.mjs +240 -0
  85. package/server/services/oar-cli-wsl.mjs +129 -0
  86. package/server/services/preview-proxy-server.mjs +41 -23
  87. package/server/services/push-dispatch-service.mjs +18 -0
  88. package/server/services/relay-event-link-service.mjs +289 -0
  89. package/server/services/relay-event-service.mjs +1935 -0
  90. package/server/services/relay-shutdown-gate.mjs +31 -0
  91. package/server/services/remote-relay-caller-context.mjs +52 -7
  92. package/server/services/remote-relay-dispatcher.mjs +494 -2
  93. package/server/services/remote-relay-registry-service.mjs +11 -1
  94. package/server/services/safe-served-content.mjs +61 -11
  95. package/server/services/session-activity-tracker.mjs +487 -0
  96. package/server/services/session-history-refresh-service.mjs +31 -0
  97. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  98. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  99. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  100. package/server/services/subagent-record-retention.mjs +164 -0
  101. package/server/services/subagent-timeline-service.mjs +328 -0
  102. package/server/services/subagent-viewer-rooms.mjs +119 -0
  103. package/server/services/upgrade-socket-guard.mjs +16 -0
  104. package/shared/relay-events.mjs +371 -0
  105. package/shared/remote-relay-contract.mjs +157 -5
  106. package/shared/subagent-record-retention.mjs +31 -0
  107. package/shared/subagent-timeline.mjs +79 -0
@@ -3,15 +3,20 @@
3
3
  //
4
4
  // The threat: a semi-trusted producer (an AI worker writing a workspace file, or
5
5
  // an uploaded attachment) can supply content whose MIME type the browser will
6
- // execute as markup/script on THIS origin — `text/html`, `image/svg+xml`, XML
7
- // with embedded script. Served inline, that is stored XSS against the app (and,
8
- // via the unauthenticated /api/shared/* routes, against share viewers). We
9
- // neutralize such types to `text/plain` and always send `nosniff`, and we attach
10
- // a locked-down CSP + `sandbox` so anything that still reaches the browser as a
6
+ // execute as markup/script on THIS origin — `text/html`, XML with embedded
7
+ // script. Served inline, that is stored XSS against the app (and, via the
8
+ // unauthenticated /api/shared/* routes, against share viewers). We neutralize
9
+ // such types to `text/plain` and always send `nosniff`, and we attach a
10
+ // locked-down CSP + `sandbox` so anything that still reaches the browser as a
11
11
  // document can neither run script nor navigate.
12
+ //
13
+ // SVG keeps its type: as an `<img>` it never runs script, and opened as a
14
+ // document the `sandbox` CSP denies it script and an origin, so the viewer
15
+ // and the file browser's thumbnails can show it.
12
16
 
13
17
  // Types the browser renders as active documents on this origin.
14
- const ACTIVE_MARKUP_PATTERN = /^text\/html\b|svg|xhtml|(^|\/)xml\b|\+xml\b/i;
18
+ const ACTIVE_MARKUP_PATTERN = /^text\/html\b|xhtml|(^|\/)xml\b|\+xml\b/i;
19
+ const SVG_TYPE = 'image/svg+xml';
15
20
 
16
21
  // Types safe to preview inline with their real Content-Type.
17
22
  const INLINE_SAFE_PREFIXES = ['image/', 'video/', 'audio/'];
@@ -25,7 +30,7 @@ const INLINE_SAFE_EXACT = new Set(['application/pdf']);
25
30
  export function safeServedContentType(mimeType) {
26
31
  const type = String(mimeType || '').trim().toLowerCase().split(';')[0].slice(0, 127)
27
32
  || 'application/octet-stream';
28
- if (ACTIVE_MARKUP_PATTERN.test(type)) {
33
+ if (type !== SVG_TYPE && ACTIVE_MARKUP_PATTERN.test(type)) {
29
34
  // Render markup/script content as its own source text instead of executing it.
30
35
  return { contentType: 'text/plain; charset=utf-8', inlineSafe: false, neutralized: true };
31
36
  }
@@ -34,6 +39,45 @@ export function safeServedContentType(mimeType) {
34
39
  return { contentType: type, inlineSafe, neutralized: false };
35
40
  }
36
41
 
42
+ // Printable ASCII, the only characters a quoted `filename="…"` may hold once
43
+ // quotes and backslashes are gone. Anything else goes into `filename*`.
44
+ const NON_ASCII_PATTERN = /[^\x20-\x7e]/g;
45
+ // A surrogate without its partner: not a character, and encodeURIComponent
46
+ // throws on it.
47
+ const LONE_SURROGATE_PATTERN = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
48
+
49
+ /** RFC 5987 `ext-value` encoding: UTF-8 percent-encoded, attr-char kept. */
50
+ function encodeRfc5987(value) {
51
+ return encodeURIComponent(value)
52
+ // encodeURIComponent leaves these alone, but RFC 5987 does not allow them bare.
53
+ .replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
54
+ }
55
+
56
+ /**
57
+ * The `Content-Disposition` value for `fileName`, RFC 6266 style: a quoted
58
+ * `filename` every client can read, built from the name's ASCII characters
59
+ * (never empty: `file` plus the extension when nothing is left), and, when the
60
+ * name has characters outside ASCII, `filename*=UTF-8''…` carrying the whole
61
+ * name percent-encoded. The result holds nothing a header may not: no
62
+ * control characters, no quotes inside the quoted string, and no character
63
+ * outside Latin-1, which Node's `setHeader` rejects (and which used to make a
64
+ * file with a Japanese or emoji name unservable).
65
+ */
66
+ export function contentDispositionValue(disposition, fileName) {
67
+ const name = String(fileName || '')
68
+ .replace(LONE_SURROGATE_PATTERN, '')
69
+ .replace(/[\u0000-\u001f\u007f"\\]/g, '')
70
+ .slice(0, 255);
71
+ if (!name) return disposition;
72
+ const ascii = name.replace(NON_ASCII_PATTERN, '').trim();
73
+ if (ascii === name) return `${disposition}; filename="${name}"`;
74
+ const dot = name.lastIndexOf('.');
75
+ const stem = (dot > 0 ? name.slice(0, dot) : name).replace(NON_ASCII_PATTERN, '').replace(/^[\s.]+|[\s.]+$/g, '') || 'file';
76
+ const ext = (dot > 0 ? name.slice(dot) : '').replace(NON_ASCII_PATTERN, '').trim();
77
+ const fallback = `${stem}${ext.length > 1 ? ext : ''}`;
78
+ return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeRfc5987(name)}`;
79
+ }
80
+
37
81
  /**
38
82
  * Apply the safe Content-Type plus the hardening headers to a response that is
39
83
  * about to stream stored bytes. Returns the resolved descriptor so callers can
@@ -46,12 +90,18 @@ export function applySafeServedContentHeaders(res, mimeType, { fileName = '' } =
46
90
  // Belt-and-braces: a served file is never part of the app UI, so forbid it
47
91
  // from loading anything or being framed, and sandbox it so it cannot run
48
92
  // script or navigate even if a browser would otherwise treat it as active.
49
- res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox; frame-ancestors 'none'");
50
- const safeName = String(fileName || '').replace(/[\r\n"\\]/g, '').slice(0, 255);
51
- const disposition = resolved.inlineSafe ? 'inline' : 'attachment';
93
+ // A PDF is the exception: browsers refuse to show a sandboxed PDF in their
94
+ // built-in viewer, so it gets no `sandbox` and may be framed by this origin.
95
+ // PDF viewers do not run document script, so the sandbox bought nothing there.
96
+ res.setHeader(
97
+ 'Content-Security-Policy',
98
+ resolved.contentType === 'application/pdf'
99
+ ? "default-src 'none'; frame-ancestors 'self'"
100
+ : "default-src 'none'; sandbox; frame-ancestors 'none'",
101
+ );
52
102
  res.setHeader(
53
103
  'Content-Disposition',
54
- safeName ? `${disposition}; filename="${safeName}"` : disposition,
104
+ contentDispositionValue(resolved.inlineSafe ? 'inline' : 'attachment', fileName),
55
105
  );
56
106
  return resolved;
57
107
  }
@@ -0,0 +1,487 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * What each session of this relay is doing right now: a turn, background
5
+ * tasks only, or nothing, with the tasks themselves and when anything last
6
+ * moved.
7
+ *
8
+ * `list_sessions` / `read_session` read it as `state` and `tasks[]`; the
9
+ * relay event service reads its edges (a task started or ended) and its quiet
10
+ * time (stalled). Memory only: the background-task set is republished by the
11
+ * workers on every change, sub-agent runs are read from the database, and a
12
+ * restart forgets only the recently finished tasks and the activity clock
13
+ * (which then starts at the restart, so nothing reads as stalled at boot).
14
+ *
15
+ * Sources:
16
+ * - `noteTaskSet` — every POST /api/background-tasks (REPLACE semantics):
17
+ * a new task id is a start, a task gone from the set has ended, a changed
18
+ * task (tool call, summary, tokens) is activity.
19
+ * - `noteTaskSettled` — how a task ended, where the worker says so (the
20
+ * Claude worker's task_notification).
21
+ * - `noteSubagentRun` — every sub-agent run status the relay stores; a
22
+ * background run's terminal status is how its task ended, and a
23
+ * background run with no task in the set (Copilot, Cursor) ends here.
24
+ * - `touch` — any activity, thought, stream or timeline post.
25
+ */
26
+
27
+ import { isTerminalSubagentRunStatus, normalizeSubagentRunStatus } from '../../shared/subagent-timeline.mjs';
28
+
29
+ export const SESSION_STATES = Object.freeze(['running_turn', 'background_only', 'idle']);
30
+
31
+ // How long a task gone from the set waits for the word on how it ended.
32
+ export const TASK_END_GRACE_MS = 5_000;
33
+ // A `running` run row not touched for this long is not running work: its
34
+ // worker's record of its end never arrived (live: rows months old still
35
+ // `running`, counted as a running task by every reader, so a subscription
36
+ // to all sessions never saw all_idle and reported the session stalled
37
+ // again and again). The sweep in stale-subagent-run-sweep.mjs closes such
38
+ // rows; this bound keeps them out of the state meanwhile.
39
+ export const STALE_RUNNING_RUN_MAX_AGE_MS = 24 * 60 * 60_000;
40
+ const RECENT_TASKS_MAX = 5;
41
+ const RECENT_TASK_TTL_MS = 60 * 60_000;
42
+ const SHELL_TASK_TYPES = new Set(['local_bash', 'bash', 'shell', 'monitor', 'local_monitor']);
43
+ const EXIT_CODE_PATTERN = /exit(?:ed)?(?: with)?(?: code| status)?[:\s]+(-?\d{1,3})\b/i;
44
+
45
+ function toText(value) {
46
+ return String(value ?? '').trim();
47
+ }
48
+
49
+ function isoOf(ms) {
50
+ return Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : null;
51
+ }
52
+
53
+ function msOf(value) {
54
+ if (value === null || value === undefined || value === '') return 0;
55
+ const number = Number(value);
56
+ if (Number.isFinite(number) && number > 0) return number;
57
+ const parsed = Date.parse(String(value));
58
+ return Number.isFinite(parsed) ? parsed : 0;
59
+ }
60
+
61
+ export function taskKindOf(taskType) {
62
+ return SHELL_TASK_TYPES.has(toText(taskType).toLowerCase()) ? 'shell' : 'subagent';
63
+ }
64
+
65
+ /** An exit code a task summary names ("Exit code 2", "exited with 1"), else null. */
66
+ export function exitCodeFromSummary(summary) {
67
+ const match = EXIT_CODE_PATTERN.exec(String(summary || ''));
68
+ if (!match) return null;
69
+ const code = Number(match[1]);
70
+ return Number.isInteger(code) ? code : null;
71
+ }
72
+
73
+ /** The status words of a task: running, completed, failed, stopped. */
74
+ export function normalizeTaskStatus(status) {
75
+ const value = normalizeSubagentRunStatus(status);
76
+ if (value === 'cancelled') return 'stopped';
77
+ if (value === 'running' || value === 'completed' || value === 'failed') return value;
78
+ return value ? 'completed' : 'running';
79
+ }
80
+
81
+ function taskSignature(task) {
82
+ return JSON.stringify([
83
+ task.summary || null,
84
+ task.lastToolName || null,
85
+ task.lastToolCall || null,
86
+ task.totalTokens ?? null,
87
+ task.workflowProgress ? JSON.stringify(task.workflowProgress).length : 0,
88
+ ]);
89
+ }
90
+
91
+ export function createSessionActivityTracker({
92
+ db = null,
93
+ now = () => Date.now(),
94
+ setTimer = (fn, ms) => {
95
+ const timer = setTimeout(fn, ms);
96
+ timer.unref?.();
97
+ return timer;
98
+ },
99
+ clearTimer = (timer) => clearTimeout(timer),
100
+ onEdge = () => {},
101
+ logger = console,
102
+ } = {}) {
103
+ const startedAtMs = now();
104
+ // conversationId → { live: Map<taskId, entry>, recent: [], lastActivityAt }
105
+ const sessions = new Map();
106
+ // Edges already reported for an end, by `${conversationId}:${key}`.
107
+ const endedKeys = new Map();
108
+
109
+ let listRuns = null;
110
+ let getQueueRows = null;
111
+ if (db) {
112
+ try {
113
+ listRuns = db.prepare(`
114
+ SELECT * FROM subagent_runs
115
+ WHERE conversation_id = ? AND (status = 'running' OR COALESCE(completed_at, updated_at) >= ?)
116
+ AND parent_subagent_id IS NULL
117
+ ORDER BY started_at ASC
118
+ LIMIT 50
119
+ `);
120
+ } catch {}
121
+ try {
122
+ getQueueRows = db.prepare(`
123
+ SELECT id, status, timestamp, processing_at, next_attempt_at, usage_limit_pause
124
+ FROM queue WHERE conversation_id = ? AND status IN ('pending', 'processing', 'parked')
125
+ `);
126
+ } catch {}
127
+ }
128
+
129
+ function session(conversationId) {
130
+ const id = toText(conversationId);
131
+ let entry = sessions.get(id);
132
+ if (!entry) {
133
+ entry = { live: new Map(), recent: [], lastActivityAt: 0 };
134
+ sessions.set(id, entry);
135
+ }
136
+ return entry;
137
+ }
138
+
139
+ function emitEdge(type, conversationId, task) {
140
+ try {
141
+ onEdge({ type, conversationId, task: publicTask(task) });
142
+ } catch (error) {
143
+ try { logger?.warn?.(`[activity] edge handler failed: ${error?.message || error}`); } catch {}
144
+ }
145
+ }
146
+
147
+ function markEnded(conversationId, task) {
148
+ const at = now();
149
+ for (const key of [task.taskId, task.toolUseId ? `run:${task.toolUseId}` : '']) {
150
+ if (key) endedKeys.set(`${conversationId}:${key}`, at);
151
+ }
152
+ if (endedKeys.size > 2000) {
153
+ for (const [key, when] of endedKeys) {
154
+ if (at - when > RECENT_TASK_TTL_MS) endedKeys.delete(key);
155
+ }
156
+ }
157
+ }
158
+
159
+ function wasEnded(conversationId, task) {
160
+ return [task.taskId, task.toolUseId ? `run:${task.toolUseId}` : '']
161
+ .some((key) => key && endedKeys.has(`${conversationId}:${key}`));
162
+ }
163
+
164
+ function publicTask(task) {
165
+ return {
166
+ id: task.taskId || task.toolUseId || null,
167
+ kind: task.kind,
168
+ type: task.taskType || null,
169
+ description: task.description || '',
170
+ startedAt: isoOf(task.startedAtMs),
171
+ lastActivityAt: isoOf(task.lastActivityAtMs || task.startedAtMs),
172
+ status: task.status,
173
+ ...(task.exitCode !== null && task.exitCode !== undefined ? { exitCode: task.exitCode } : {}),
174
+ ...(task.status !== 'running' && task.statusReported === false ? { statusReported: false } : {}),
175
+ ...(task.summary ? { summary: task.summary } : {}),
176
+ ...(task.endedAtMs ? { endedAt: isoOf(task.endedAtMs) } : {}),
177
+ ...(task.toolUseId ? { runId: task.toolUseId } : {}),
178
+ ...(task.model ? { model: task.model } : {}),
179
+ };
180
+ }
181
+
182
+ function finishTask(conversationId, task, { status, summary = null, exitCode = null, reported = true } = {}) {
183
+ if (task.graceTimer) {
184
+ clearTimer(task.graceTimer);
185
+ task.graceTimer = null;
186
+ }
187
+ const entry = session(conversationId);
188
+ entry.live.delete(task.taskId);
189
+ if (wasEnded(conversationId, task)) return;
190
+ task.status = normalizeTaskStatus(status || 'completed');
191
+ task.statusReported = reported;
192
+ if (summary) task.summary = toText(summary).slice(0, 500);
193
+ const code = Number.isInteger(exitCode) ? exitCode : exitCodeFromSummary(task.summary);
194
+ task.exitCode = code;
195
+ task.endedAtMs = now();
196
+ markEnded(conversationId, task);
197
+ entry.recent.unshift(task);
198
+ entry.recent.length = Math.min(entry.recent.length, RECENT_TASKS_MAX);
199
+ entry.lastActivityAt = Math.max(entry.lastActivityAt, task.endedAtMs);
200
+ const failed = task.status === 'failed' || task.status === 'stopped' || (code !== null && code !== 0);
201
+ emitEdge(failed ? 'task_failed' : 'task_finished', conversationId, task);
202
+ }
203
+
204
+ function runStatus(runId) {
205
+ if (!db || !runId) return null;
206
+ try {
207
+ const row = db.prepare(`SELECT status FROM subagent_runs WHERE id = ?`).get(runId);
208
+ return row ? normalizeSubagentRunStatus(row.status) : null;
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ /** A task left the set: settle it now if its end is known, else after the grace. */
215
+ function taskLeft(conversationId, task) {
216
+ if (task.settled) {
217
+ finishTask(conversationId, task, task.settled);
218
+ return;
219
+ }
220
+ const status = runStatus(task.toolUseId);
221
+ if (status && isTerminalSubagentRunStatus(status)) {
222
+ finishTask(conversationId, task, { status });
223
+ return;
224
+ }
225
+ task.leftAtMs = now();
226
+ task.graceTimer = setTimer(() => {
227
+ task.graceTimer = null;
228
+ const late = runStatus(task.toolUseId);
229
+ if (late && isTerminalSubagentRunStatus(late)) finishTask(conversationId, task, { status: late });
230
+ else finishTask(conversationId, task, { status: 'completed', reported: false });
231
+ }, TASK_END_GRACE_MS);
232
+ }
233
+
234
+ function noteTaskSet(conversationId, tasks) {
235
+ const id = toText(conversationId);
236
+ if (!id) return;
237
+ const entry = session(id);
238
+ const at = now();
239
+ const seen = new Set();
240
+ for (const raw of Array.isArray(tasks) ? tasks : []) {
241
+ const taskId = toText(raw?.taskId);
242
+ if (!taskId) continue;
243
+ seen.add(taskId);
244
+ const signature = taskSignature(raw);
245
+ const known = entry.live.get(taskId);
246
+ if (known && !known.leftAtMs) {
247
+ if (known.signature !== signature) {
248
+ known.signature = signature;
249
+ known.lastActivityAtMs = at;
250
+ entry.lastActivityAt = at;
251
+ }
252
+ known.description = toText(raw.description) || known.description;
253
+ known.toolUseId = toText(raw.toolUseId) || known.toolUseId;
254
+ known.summary = toText(raw.summary) || known.summary;
255
+ known.model = toText(raw.model) || known.model;
256
+ continue;
257
+ }
258
+ if (known?.leftAtMs) {
259
+ // Back in the set after it left: it did not end after all.
260
+ if (known.graceTimer) clearTimer(known.graceTimer);
261
+ known.graceTimer = null;
262
+ known.leftAtMs = 0;
263
+ continue;
264
+ }
265
+ // Settled already (its notice came before the set dropped it).
266
+ if (wasEnded(id, { taskId, toolUseId: toText(raw.toolUseId) })) continue;
267
+ const task = {
268
+ taskId,
269
+ toolUseId: toText(raw.toolUseId) || null,
270
+ taskType: toText(raw.taskType),
271
+ kind: taskKindOf(raw.taskType),
272
+ description: toText(raw.description).slice(0, 500),
273
+ startedAtMs: msOf(raw.startedAt) || at,
274
+ lastActivityAtMs: at,
275
+ summary: toText(raw.summary) || null,
276
+ model: toText(raw.model) || null,
277
+ status: 'running',
278
+ signature,
279
+ settled: null,
280
+ graceTimer: null,
281
+ leftAtMs: 0,
282
+ };
283
+ entry.live.set(taskId, task);
284
+ entry.lastActivityAt = at;
285
+ emitEdge('task_started', id, task);
286
+ }
287
+ for (const task of [...entry.live.values()]) {
288
+ if (seen.has(task.taskId) || task.leftAtMs) continue;
289
+ taskLeft(id, task);
290
+ }
291
+ }
292
+
293
+ function noteTaskSettled(conversationId, { taskId, toolUseId, status, summary, exitCode } = {}) {
294
+ const id = toText(conversationId);
295
+ if (!id) return;
296
+ const entry = session(id);
297
+ const key = toText(taskId);
298
+ const run = toText(toolUseId);
299
+ const task = (key && entry.live.get(key))
300
+ || [...entry.live.values()].find((candidate) => run && candidate.toolUseId === run)
301
+ || null;
302
+ const settled = {
303
+ status: normalizeTaskStatus(status || 'completed'),
304
+ summary: toText(summary) || null,
305
+ exitCode: Number.isInteger(exitCode) ? exitCode : null,
306
+ };
307
+ entry.lastActivityAt = now();
308
+ if (!task) return;
309
+ task.settled = settled;
310
+ // Gone from the set already, or about to: settle now either way. A task
311
+ // still listed would leave with this status anyway.
312
+ finishTask(id, task, settled);
313
+ }
314
+
315
+ function noteSubagentRun(run) {
316
+ const id = toText(run?.conversation_id || run?.conversationId);
317
+ const runId = toText(run?.id || run?.subagentRunId);
318
+ if (!id || !runId) return;
319
+ const entry = session(id);
320
+ entry.lastActivityAt = now();
321
+ const status = normalizeSubagentRunStatus(run?.status);
322
+ if (!isTerminalSubagentRunStatus(status)) return;
323
+ const task = [...entry.live.values()].find((candidate) => candidate.toolUseId === runId) || null;
324
+ if (task) {
325
+ // The run's end is the task's: finish it once it has left the set,
326
+ // which the grace timer is waiting for; a task still listed keeps
327
+ // waiting for its own departure.
328
+ if (task.leftAtMs) finishTask(id, task, { status });
329
+ else task.settled = task.settled || { status };
330
+ return;
331
+ }
332
+ const background = Number(run?.background) === 1 || run?.background === true;
333
+ if (!background || toText(run?.parent_subagent_id || run?.parentSubagentId)) return;
334
+ const synthetic = {
335
+ taskId: `run:${runId}`,
336
+ toolUseId: runId,
337
+ taskType: toText(run?.subagent_type) || 'agent',
338
+ kind: 'subagent',
339
+ description: toText(run?.display_name || run?.displayName) || 'sub-agent',
340
+ startedAtMs: msOf(run?.started_at) || now(),
341
+ lastActivityAtMs: now(),
342
+ summary: null,
343
+ model: toText(run?.model) || null,
344
+ status: 'running',
345
+ };
346
+ if (wasEnded(id, synthetic)) return;
347
+ finishTask(id, synthetic, { status });
348
+ }
349
+
350
+ function touch(conversationId) {
351
+ const id = toText(conversationId);
352
+ if (!id) return;
353
+ session(id).lastActivityAt = now();
354
+ }
355
+
356
+ function forget(conversationId) {
357
+ const id = toText(conversationId);
358
+ const entry = sessions.get(id);
359
+ if (!entry) return;
360
+ for (const task of entry.live.values()) {
361
+ if (task.graceTimer) clearTimer(task.graceTimer);
362
+ }
363
+ sessions.delete(id);
364
+ }
365
+
366
+ /** The worker died: its tasks went with it, with no edge (the error says it). */
367
+ function noteWorkerDied(conversationId) {
368
+ const id = toText(conversationId);
369
+ const entry = sessions.get(id);
370
+ if (!entry) return;
371
+ for (const task of [...entry.live.values()]) {
372
+ if (task.graceTimer) clearTimer(task.graceTimer);
373
+ entry.live.delete(task.taskId);
374
+ task.status = 'stopped';
375
+ task.statusReported = false;
376
+ task.endedAtMs = now();
377
+ markEnded(id, task);
378
+ entry.recent.unshift(task);
379
+ }
380
+ entry.recent.length = Math.min(entry.recent.length, RECENT_TASKS_MAX);
381
+ }
382
+
383
+ function turnState(conversationId) {
384
+ if (!getQueueRows) return { running: false, sinceMs: 0 };
385
+ let rows = [];
386
+ try {
387
+ rows = getQueueRows.all(conversationId);
388
+ } catch {
389
+ return { running: false, sinceMs: 0 };
390
+ }
391
+ const at = now();
392
+ let running = false;
393
+ let sinceMs = 0;
394
+ for (const row of rows) {
395
+ const held = row.usage_limit_pause && msOf(row.next_attempt_at) > at;
396
+ if (held) continue;
397
+ running = true;
398
+ const since = msOf(row.processing_at) || msOf(row.timestamp);
399
+ sinceMs = Math.max(sinceMs, since);
400
+ }
401
+ return { running, sinceMs };
402
+ }
403
+
404
+ function runTasks(conversationId, coveredRunIds) {
405
+ if (!listRuns) return [];
406
+ let rows = [];
407
+ try {
408
+ rows = listRuns.all(conversationId, new Date(now() - RECENT_TASK_TTL_MS).toISOString());
409
+ } catch {
410
+ return [];
411
+ }
412
+ const staleBefore = now() - STALE_RUNNING_RUN_MAX_AGE_MS;
413
+ const staleRunning = (row) => normalizeTaskStatus(row.status) === 'running'
414
+ && msOf(row.updated_at || row.started_at) < staleBefore;
415
+ return rows
416
+ .filter((row) => !coveredRunIds.has(toText(row.id)) && !staleRunning(row))
417
+ .map((row) => {
418
+ const status = normalizeTaskStatus(row.status);
419
+ return {
420
+ id: toText(row.task_id) || toText(row.id),
421
+ kind: 'subagent',
422
+ type: toText(row.subagent_type) || null,
423
+ description: toText(row.display_name) || 'sub-agent',
424
+ startedAt: row.started_at || null,
425
+ lastActivityAt: row.updated_at || row.started_at || null,
426
+ status,
427
+ ...(status !== 'running' && row.completed_at ? { endedAt: row.completed_at } : {}),
428
+ runId: toText(row.id),
429
+ ...(row.model ? { model: row.model } : {}),
430
+ ...(Number(row.background) === 1 ? {} : { inTurn: true }),
431
+ };
432
+ });
433
+ }
434
+
435
+ /**
436
+ * `{ state, tasks, lastActivityAt, turnSince }` of one conversation.
437
+ * `tasks` lists the running ones, then up to five that ended within the
438
+ * hour, newest first.
439
+ */
440
+ function describe(conversationId) {
441
+ const id = toText(conversationId);
442
+ const entry = sessions.get(id) || null;
443
+ const at = now();
444
+ const live = entry ? [...entry.live.values()].filter((task) => !task.leftAtMs) : [];
445
+ const recent = entry
446
+ ? entry.recent.filter((task) => at - (task.endedAtMs || 0) <= RECENT_TASK_TTL_MS)
447
+ : [];
448
+ const covered = new Set([...live, ...recent, ...(entry ? [...entry.live.values()] : [])]
449
+ .map((task) => toText(task.toolUseId))
450
+ .filter(Boolean));
451
+ const fromRuns = runTasks(id, covered);
452
+ const tasks = [
453
+ ...live.map(publicTask),
454
+ ...fromRuns.filter((task) => task.status === 'running'),
455
+ ...[...recent.map(publicTask), ...fromRuns.filter((task) => task.status !== 'running')]
456
+ .sort((a, b) => msOf(b.endedAt) - msOf(a.endedAt))
457
+ .slice(0, RECENT_TASKS_MAX),
458
+ ];
459
+ const turn = turnState(id);
460
+ const runningTasks = tasks.filter((task) => task.status === 'running');
461
+ const state = turn.running ? 'running_turn' : (runningTasks.length ? 'background_only' : 'idle');
462
+ const taskActivity = Math.max(0, ...runningTasks.map((task) => msOf(task.lastActivityAt)));
463
+ const lastActivityMs = Math.max(entry?.lastActivityAt || 0, taskActivity, turn.sinceMs, startedAtMs);
464
+ return {
465
+ state,
466
+ tasks,
467
+ runningTaskCount: runningTasks.length,
468
+ lastActivityAt: isoOf(lastActivityMs),
469
+ turnSince: isoOf(turn.sinceMs),
470
+ };
471
+ }
472
+
473
+ function knownConversationIds() {
474
+ return [...sessions.keys()];
475
+ }
476
+
477
+ return {
478
+ noteTaskSet,
479
+ noteTaskSettled,
480
+ noteSubagentRun,
481
+ noteWorkerDied,
482
+ touch,
483
+ forget,
484
+ describe,
485
+ knownConversationIds,
486
+ };
487
+ }
@@ -144,6 +144,22 @@ export function createSessionHistoryRefreshService({
144
144
  WHERE conversation_id = ? AND hidden_from_shares = 1
145
145
  `)
146
146
  : null;
147
+ // Pins are carried across the replace the same way as hidden marks.
148
+ const messagesSupportPins = messageColumns.has('pinned_at');
149
+ const listPinnedMessageMarks = messagesSupportPins
150
+ ? db.prepare(`
151
+ SELECT id, role, text, timestamp, pinned_at
152
+ FROM messages
153
+ WHERE conversation_id = ? AND pinned_at IS NOT NULL
154
+ `)
155
+ : null;
156
+ const restoreMessagePin = messagesSupportPins
157
+ ? db.prepare(`
158
+ UPDATE messages
159
+ SET pinned_at = ?
160
+ WHERE conversation_id = ? AND id = ?
161
+ `)
162
+ : null;
147
163
  const restoreHiddenMessageVisibility = messagesSupportShareVisibility
148
164
  ? db.prepare(`
149
165
  UPDATE messages
@@ -218,6 +234,11 @@ export function createSessionHistoryRefreshService({
218
234
  // replaces those ids, so the rows must go with the messages they annotated
219
235
  // (same lifecycle as subagent_runs). The fallback guards dbs opened before
220
236
  // the table existed.
237
+ // The sub-agent timeline goes with its runs. Null on a database the 0009
238
+ // migration has not reached (no table, nothing to delete).
239
+ const deleteConversationSubagentTimeline = typeof stmts?.deleteConvSubagentTimeline?.run === 'function'
240
+ ? stmts.deleteConvSubagentTimeline
241
+ : null;
221
242
  const deleteConversationWorkflowRuns = typeof stmts?.deleteConvWorkflowRuns?.run === 'function'
222
243
  ? stmts.deleteConvWorkflowRuns
223
244
  : db.prepare(`DELETE FROM workflow_runs WHERE conversation_id = ?`);
@@ -409,6 +430,7 @@ export function createSessionHistoryRefreshService({
409
430
  deleteConversationThoughts.run(conversationId);
410
431
  deleteConversationStreamEvents.run(conversationId);
411
432
  deleteConversationSubagentRuns.run(conversationId);
433
+ deleteConversationSubagentTimeline?.run(conversationId);
412
434
  deleteConversationWorkflowRuns.run(conversationId);
413
435
  });
414
436
 
@@ -418,12 +440,14 @@ export function createSessionHistoryRefreshService({
418
440
 
419
441
  const replaceRetrievableHistoryTx = db.transaction((conversationId, messages = []) => {
420
442
  const hiddenMessages = listHiddenMessageVisibility?.all(conversationId) || [];
443
+ const pinnedMessages = listPinnedMessageMarks?.all(conversationId) || [];
421
444
  const attachmentCandidates = collectAttachmentCandidates(conversationId);
422
445
  deleteConversationMessages.run(conversationId);
423
446
  deleteConversationActivity.run(conversationId);
424
447
  deleteConversationThoughts.run(conversationId);
425
448
  deleteConversationStreamEvents.run(conversationId);
426
449
  deleteConversationSubagentRuns.run(conversationId);
450
+ deleteConversationSubagentTimeline?.run(conversationId);
427
451
  deleteConversationWorkflowRuns.run(conversationId);
428
452
  insertRebuiltMessages(conversationId, messages, attachmentCandidates);
429
453
  const rebuiltMessages = ensureArray(messages);
@@ -437,6 +461,13 @@ export function createSessionHistoryRefreshService({
437
461
  resolvedId,
438
462
  );
439
463
  }
464
+ // Its own consumed set: a message can be both hidden and pinned.
465
+ const consumedPinnedIds = new Set();
466
+ for (const pinnedMessage of pinnedMessages) {
467
+ const resolvedId = resolveHiddenMessageId(pinnedMessage, rebuiltMessages, consumedPinnedIds);
468
+ if (!resolvedId) continue;
469
+ restoreMessagePin.run(pinnedMessage.pinned_at, conversationId, resolvedId);
470
+ }
440
471
  });
441
472
 
442
473
  function evaluateRefreshIdleState(conversationId) {