@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
@@ -24,6 +24,16 @@ export function createSessionRepository(db) {
24
24
  ? 'rs.claude_cloud_cost_usd AS runtime_claude_cloud_cost_usd'
25
25
  : 'NULL AS runtime_claude_cloud_cost_usd',
26
26
  ].join(', ');
27
+ // Session groups (migration 0014). NULL on a schema that predates it.
28
+ const conversationsHaveGroup = db.prepare(`PRAGMA table_info(conversations)`).all()
29
+ .some((column) => String(column?.name || '').trim() === 'group_id');
30
+ // The hand order inside a group (migration 0015).
31
+ const conversationsHaveGroupPosition = db.prepare(`PRAGMA table_info(conversations)`).all()
32
+ .some((column) => String(column?.name || '').trim() === 'group_position');
33
+ const conversationGroupSelect = [
34
+ conversationsHaveGroup ? 'c.group_id' : 'NULL AS group_id',
35
+ conversationsHaveGroupPosition ? 'c.group_position' : 'NULL AS group_position',
36
+ ].join(', ');
27
37
  return {
28
38
  // conversations
29
39
  getConv: db.prepare(`SELECT * FROM conversations WHERE id = ? AND status != 'deleted'`),
@@ -31,7 +41,7 @@ export function createSessionRepository(db) {
31
41
  getConvBySdkSessionId: db.prepare(`SELECT * FROM conversations WHERE sdk_session_id = ? AND status != 'deleted' ORDER BY updated_at DESC LIMIT 1`),
32
42
  listConvIdsMissingRuntimeSession: db.prepare(`SELECT c.id AS id FROM conversations c LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE rs.id IS NULL AND c.status != 'deleted'`),
33
43
  runtimeSessionsSupportProviders,
34
- listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, ${conversationCloudSelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = ?) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
44
+ listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, ${conversationCloudSelect}, ${conversationGroupSelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = ?) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
35
45
  insertConv: db.prepare(`INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`),
36
46
  updateConvTime: db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`),
37
47
  updateConvTitle: db.prepare(`UPDATE conversations SET title = ?, title_source = 'manual' WHERE id = ?`),
@@ -301,47 +311,12 @@ export function createSessionRepository(db) {
301
311
  FROM conversations
302
312
  WHERE status = 'deleted' AND sdk_session_id = ?
303
313
  `),
304
- // public conversation shares
305
- getConversationShareByConversationId: db.prepare(`
306
- SELECT token, conversation_id, created_at, last_accessed_at, revoked_at
307
- FROM conversation_shares
308
- WHERE conversation_id = ?
309
- AND (revoked_at IS NULL OR revoked_at = '')
310
- ORDER BY created_at DESC
311
- LIMIT 1
312
- `),
313
- getConversationShareByToken: db.prepare(`
314
- SELECT token, conversation_id, created_at, last_accessed_at, revoked_at
315
- FROM conversation_shares
316
- WHERE token = ?
317
- LIMIT 1
318
- `),
319
- insertConversationShare: db.prepare(`
320
- INSERT INTO conversation_shares (
321
- token,
322
- conversation_id,
323
- created_at,
324
- last_accessed_at,
325
- revoked_at
326
- ) VALUES (?, ?, ?, ?, NULL)
327
- `),
314
+ // public conversation shares (the rest lives in share-repository.mjs)
328
315
  touchConversationShare: db.prepare(`
329
316
  UPDATE conversation_shares
330
317
  SET last_accessed_at = ?
331
318
  WHERE token = ?
332
319
  AND (revoked_at IS NULL OR revoked_at = '')
333
320
  `),
334
- revokeConversationShareByToken: db.prepare(`
335
- UPDATE conversation_shares
336
- SET revoked_at = ?
337
- WHERE token = ?
338
- AND (revoked_at IS NULL OR revoked_at = '')
339
- `),
340
- revokeConversationSharesByConversationId: db.prepare(`
341
- UPDATE conversation_shares
342
- SET revoked_at = ?
343
- WHERE conversation_id = ?
344
- AND (revoked_at IS NULL OR revoked_at = '')
345
- `),
346
321
  };
347
322
  }
@@ -0,0 +1,322 @@
1
+ 'use strict';
2
+
3
+ import { randomBytes } from 'node:crypto';
4
+
5
+ // Public conversation shares and their access log (migration 0016).
6
+ //
7
+ // A conversation has at most one active share: its newest unrevoked row in
8
+ // `conversation_shares`. A share is a lineage of tokens: renewing it adds a
9
+ // row with a new token and the same `lineage_id` and revokes the old token in
10
+ // the same transaction, so the access log, keyed to the lineage, carries over.
11
+ // Stopping a share revokes it and deletes its log.
12
+ //
13
+ // Every reader tolerates a schema without the migration: a share then has its
14
+ // token as its lineage, nothing is logged, and the log reads as empty.
15
+
16
+ export const SHARE_STORE_VIEWER_IP_SETTING_KEY = 'shares_store_viewer_ip';
17
+ const SHARE_VIEWER_KEY_SECRET_SETTING_KEY = 'shares_viewer_key_secret';
18
+
19
+ export const SHARE_OPENS_PAGE_DEFAULT = 200;
20
+ export const SHARE_OPENS_PAGE_MAX = 1000;
21
+
22
+ function tableExists(db, name) {
23
+ return !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name);
24
+ }
25
+
26
+ function isRevoked(row) {
27
+ return !!String(row?.revoked_at || '').trim();
28
+ }
29
+
30
+ function mapShareRow(row) {
31
+ if (!row?.token) return null;
32
+ return {
33
+ token: String(row.token),
34
+ conversationId: String(row.conversation_id || ''),
35
+ lineageId: String(row.lineage_id || '').trim() || String(row.token),
36
+ createdAt: row.created_at || null,
37
+ revokedAt: row.revoked_at || null,
38
+ };
39
+ }
40
+
41
+ function mapOpenRow(row) {
42
+ return {
43
+ openedAt: row.opened_at,
44
+ ip: row.ip || null,
45
+ country: row.country || null,
46
+ userAgent: row.user_agent || null,
47
+ // Enough to tell viewers apart in a list; the key itself stays here.
48
+ viewer: String(row.viewer_key || '').slice(0, 8) || null,
49
+ };
50
+ }
51
+
52
+ export function createShareRepository(db) {
53
+ const shareColumns = new Set(db.prepare(`PRAGMA table_info(conversation_shares)`).all().map((column) => column.name));
54
+ const hasLineage = shareColumns.has('lineage_id');
55
+ const hasLog = tableExists(db, 'conversation_share_opens');
56
+ const lineageSelect = hasLineage ? 'lineage_id' : 'NULL AS lineage_id';
57
+ const shareSelect = `token, conversation_id, created_at, revoked_at, ${lineageSelect}`;
58
+
59
+ const stmts = {
60
+ activeByConversation: db.prepare(`
61
+ SELECT ${shareSelect}
62
+ FROM conversation_shares
63
+ WHERE conversation_id = ?
64
+ AND (revoked_at IS NULL OR revoked_at = '')
65
+ ORDER BY created_at DESC
66
+ LIMIT 1
67
+ `),
68
+ byToken: db.prepare(`SELECT ${shareSelect} FROM conversation_shares WHERE token = ? LIMIT 1`),
69
+ insert: hasLineage
70
+ ? db.prepare(`
71
+ INSERT INTO conversation_shares (token, conversation_id, created_at, last_accessed_at, revoked_at, lineage_id)
72
+ VALUES (?, ?, ?, NULL, NULL, ?)
73
+ `)
74
+ : db.prepare(`
75
+ INSERT INTO conversation_shares (token, conversation_id, created_at, last_accessed_at, revoked_at)
76
+ VALUES (?, ?, ?, NULL, NULL)
77
+ `),
78
+ revokeOthers: db.prepare(`
79
+ UPDATE conversation_shares
80
+ SET revoked_at = ?
81
+ WHERE conversation_id = ?
82
+ AND token <> ?
83
+ AND (revoked_at IS NULL OR revoked_at = '')
84
+ `),
85
+ revokeAll: db.prepare(`
86
+ UPDATE conversation_shares
87
+ SET revoked_at = ?
88
+ WHERE conversation_id = ?
89
+ AND (revoked_at IS NULL OR revoked_at = '')
90
+ `),
91
+ listActive: db.prepare(`
92
+ SELECT s.token, s.conversation_id, s.created_at, s.revoked_at, ${hasLineage ? 's.lineage_id' : 'NULL AS lineage_id'},
93
+ c.title AS conversation_title, c.archived AS conversation_archived
94
+ FROM conversation_shares s
95
+ JOIN conversations c ON c.id = s.conversation_id
96
+ WHERE (s.revoked_at IS NULL OR s.revoked_at = '')
97
+ AND c.status != 'deleted'
98
+ ORDER BY s.created_at DESC
99
+ `),
100
+ conversationStatus: db.prepare(`SELECT id, status FROM conversations WHERE id = ?`),
101
+ getSetting: db.prepare(`SELECT value FROM app_settings WHERE key = ? LIMIT 1`),
102
+ upsertSetting: db.prepare(`
103
+ INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
104
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
105
+ `),
106
+ };
107
+ // Addresses already stored: the access log's `ip` and the `viewerIp` of the
108
+ // status log's shared reads. A row whose payload is not JSON is left alone.
109
+ const hasEvents = tableExists(db, 'status_events');
110
+ const EVENT_IP = `CASE WHEN json_valid(payload_json) THEN json_extract(payload_json, '$.viewerIp') END`;
111
+ const ipSources = [
112
+ ...(hasLog ? [`SELECT ip AS address FROM conversation_share_opens WHERE ip IS NOT NULL AND ip <> ''`] : []),
113
+ ...(hasEvents ? [`SELECT ${EVENT_IP} AS address FROM status_events WHERE type = 'shared-access-opened' AND COALESCE(${EVENT_IP}, '') <> ''`] : []),
114
+ ];
115
+ const ipStmts = {
116
+ count: ipSources.length ? db.prepare(`SELECT COUNT(*) AS n FROM (${ipSources.join(' UNION ')})`) : null,
117
+ clearOpens: hasLog ? db.prepare(`UPDATE conversation_share_opens SET ip = NULL WHERE ip IS NOT NULL`) : null,
118
+ clearEvents: hasEvents ? db.prepare(`
119
+ UPDATE status_events
120
+ SET payload_json = json_remove(payload_json, '$.viewerIp')
121
+ WHERE type = 'shared-access-opened' AND ${EVENT_IP} IS NOT NULL
122
+ `) : null,
123
+ };
124
+
125
+ const logStmts = hasLog ? {
126
+ insert: db.prepare(`
127
+ INSERT INTO conversation_share_opens (lineage_id, conversation_id, opened_at, ip, user_agent, country, viewer_key)
128
+ VALUES (?, ?, ?, ?, ?, ?, ?)
129
+ `),
130
+ stats: db.prepare(`
131
+ SELECT COUNT(*) AS opens, COUNT(DISTINCT viewer_key) AS viewers, MAX(opened_at) AS last_opened_at
132
+ FROM conversation_share_opens
133
+ WHERE lineage_id = ?
134
+ `),
135
+ page: db.prepare(`
136
+ SELECT opened_at, ip, user_agent, country, viewer_key
137
+ FROM conversation_share_opens
138
+ WHERE lineage_id = ?
139
+ ORDER BY opened_at DESC, id DESC
140
+ LIMIT ?
141
+ `),
142
+ lastOpenOf: db.prepare(`
143
+ SELECT MAX(opened_at) AS opened_at
144
+ FROM conversation_share_opens
145
+ WHERE lineage_id = ? AND viewer_key = ?
146
+ `),
147
+ deleteForConversation: db.prepare(`DELETE FROM conversation_share_opens WHERE conversation_id = ?`),
148
+ } : null;
149
+
150
+ function findActiveShare(conversationId) {
151
+ return mapShareRow(stmts.activeByConversation.get(String(conversationId || '').trim()));
152
+ }
153
+
154
+ function findShareByToken(token) {
155
+ return mapShareRow(stmts.byToken.get(String(token || '').trim()));
156
+ }
157
+
158
+ /**
159
+ * The share a public request may read: the token's row, neither revoked nor
160
+ * renewed, of a conversation that still exists. Null otherwise, and every
161
+ * share route answers that with a 404.
162
+ */
163
+ function findLiveShare(token) {
164
+ const row = stmts.byToken.get(String(token || '').trim());
165
+ if (!row || isRevoked(row)) return null;
166
+ const share = mapShareRow(row);
167
+ const conversation = stmts.conversationStatus.get(share.conversationId);
168
+ if (!conversation || String(conversation.status || '').trim().toLowerCase() === 'deleted') return null;
169
+ return share;
170
+ }
171
+
172
+ function insertShare(conversationId, token, now, lineageId) {
173
+ if (hasLineage) stmts.insert.run(token, conversationId, now, lineageId || token);
174
+ else stmts.insert.run(token, conversationId, now);
175
+ }
176
+
177
+ /** The active share, or a new one; `created` tells which. */
178
+ const getOrCreateShare = db.transaction((conversationId, makeToken, now) => {
179
+ const existing = findActiveShare(conversationId);
180
+ if (existing) return { share: existing, created: false };
181
+ const token = makeToken();
182
+ insertShare(conversationId, token, now, token);
183
+ return { share: findShareByToken(token), created: true };
184
+ });
185
+
186
+ /**
187
+ * A new token for the conversation's active share, same lineage; every other
188
+ * token of the conversation is revoked with it. Null without an active share.
189
+ */
190
+ const renewShare = db.transaction((conversationId, makeToken, now) => {
191
+ const existing = findActiveShare(conversationId);
192
+ if (!existing) return null;
193
+ const token = makeToken();
194
+ insertShare(conversationId, token, now, existing.lineageId);
195
+ stmts.revokeOthers.run(now, conversationId, token);
196
+ return { share: findShareByToken(token), previousToken: existing.token };
197
+ });
198
+
199
+ /** Revokes every token of the conversation and deletes its access log. */
200
+ const stopSharing = db.transaction((conversationId, now) => {
201
+ const revoked = stmts.revokeAll.run(now, conversationId).changes;
202
+ const opensDeleted = logStmts ? logStmts.deleteForConversation.run(conversationId).changes : 0;
203
+ return { revokedCount: Number(revoked || 0), opensDeleted: Number(opensDeleted || 0) };
204
+ });
205
+
206
+ function forgetConversation(conversationId) {
207
+ return logStmts ? Number(logStmts.deleteForConversation.run(conversationId).changes || 0) : 0;
208
+ }
209
+
210
+ function shareStats(lineageId) {
211
+ const row = logStmts ? logStmts.stats.get(lineageId) : null;
212
+ return {
213
+ openCount: Number(row?.opens || 0),
214
+ viewerCount: Number(row?.viewers || 0),
215
+ lastOpenedAt: row?.last_opened_at || null,
216
+ };
217
+ }
218
+
219
+ /** One entry per conversation with an active share, newest share first. */
220
+ function listActiveShares() {
221
+ const seen = new Set();
222
+ const shares = [];
223
+ for (const row of stmts.listActive.all()) {
224
+ const conversationId = String(row.conversation_id || '');
225
+ if (seen.has(conversationId)) continue;
226
+ seen.add(conversationId);
227
+ const share = mapShareRow(row);
228
+ // The lineage started with its first token; that row holds the date.
229
+ const first = share.lineageId === share.token ? row : stmts.byToken.get(share.lineageId);
230
+ shares.push({
231
+ ...share,
232
+ title: String(row.conversation_title || '').trim(),
233
+ archived: Number(row.conversation_archived || 0) === 1,
234
+ sharedSince: first?.created_at || share.createdAt,
235
+ ...shareStats(share.lineageId),
236
+ });
237
+ }
238
+ return shares;
239
+ }
240
+
241
+ function recordOpen({ lineageId, conversationId, openedAt, ip = null, userAgent = null, country = null, viewerKey }) {
242
+ if (!logStmts) return false;
243
+ logStmts.insert.run(lineageId, conversationId, openedAt, ip || null, userAgent || null, country || null, viewerKey);
244
+ return true;
245
+ }
246
+
247
+ function lastOpenAt(lineageId, viewerKey) {
248
+ return logStmts ? logStmts.lastOpenOf.get(lineageId, viewerKey)?.opened_at || null : null;
249
+ }
250
+
251
+ function listOpens(lineageId, { limit = SHARE_OPENS_PAGE_DEFAULT } = {}) {
252
+ const pageSize = Math.max(1, Math.min(SHARE_OPENS_PAGE_MAX, Math.trunc(Number(limit) || SHARE_OPENS_PAGE_DEFAULT)));
253
+ if (!logStmts) return { opens: [], total: 0, limit: pageSize };
254
+ return {
255
+ opens: logStmts.page.all(lineageId, pageSize).map(mapOpenRow),
256
+ total: shareStats(lineageId).openCount,
257
+ limit: pageSize,
258
+ };
259
+ }
260
+
261
+ // Default on: a relay records the address of a public reader unless told not to.
262
+ function readStoreViewerIp() {
263
+ return String(stmts.getSetting.get(SHARE_STORE_VIEWER_IP_SETTING_KEY)?.value || '').trim() !== 'false';
264
+ }
265
+
266
+ function writeStoreViewerIp(enabled) {
267
+ stmts.upsertSetting.run(SHARE_STORE_VIEWER_IP_SETTING_KEY, enabled ? 'true' : 'false', new Date().toISOString());
268
+ return readStoreViewerIp();
269
+ }
270
+
271
+ /** How many distinct viewer addresses the relay still holds. */
272
+ function countStoredViewerIps() {
273
+ return Number(ipStmts.count?.get()?.n || 0);
274
+ }
275
+
276
+ /**
277
+ * Drops every stored viewer address: the access log keeps its rows and
278
+ * viewer keys (so viewer counts stay), the status log its events.
279
+ */
280
+ const forgetStoredViewerIps = db.transaction(() => ({
281
+ opens: Number(ipStmts.clearOpens?.run().changes || 0),
282
+ events: Number(ipStmts.clearEvents?.run().changes || 0),
283
+ }));
284
+
285
+ // The key of the viewer hash, made once per relay. Kept next to the log, so
286
+ // it keeps addresses out of the log but does not hide them from someone
287
+ // holding the whole database.
288
+ let viewerKeySecret = '';
289
+ function readViewerKeySecret() {
290
+ if (viewerKeySecret) return viewerKeySecret;
291
+ const stored = String(stmts.getSetting.get(SHARE_VIEWER_KEY_SECRET_SETTING_KEY)?.value || '').trim();
292
+ if (stored) {
293
+ viewerKeySecret = stored;
294
+ return stored;
295
+ }
296
+ const created = randomBytes(32).toString('hex');
297
+ stmts.upsertSetting.run(SHARE_VIEWER_KEY_SECRET_SETTING_KEY, created, new Date().toISOString());
298
+ viewerKeySecret = created;
299
+ return created;
300
+ }
301
+
302
+ return {
303
+ logAvailable: hasLog,
304
+ findActiveShare,
305
+ findShareByToken,
306
+ findLiveShare,
307
+ getOrCreateShare,
308
+ renewShare,
309
+ stopSharing,
310
+ forgetConversation,
311
+ shareStats,
312
+ listActiveShares,
313
+ recordOpen,
314
+ lastOpenAt,
315
+ listOpens,
316
+ readStoreViewerIp,
317
+ writeStoreViewerIp,
318
+ countStoredViewerIps,
319
+ forgetStoredViewerIps,
320
+ readViewerKeySecret,
321
+ };
322
+ }
@@ -20,5 +20,15 @@ export function createShareVisibilityStatements(db) {
20
20
  WHERE id = ? AND conversation_id = ?
21
21
  `)
22
22
  : null,
23
+ // Conversation order, like the pin list. Null on a database without the
24
+ // columns: every caller treats that as "nothing hidden".
25
+ listHiddenMessages: messagesSupportShareVisibility
26
+ ? db.prepare(`
27
+ SELECT id, role, text, mode, attachments, timestamp, share_hidden_at
28
+ FROM messages
29
+ WHERE conversation_id = ? AND hidden_from_shares = 1
30
+ ORDER BY timestamp ASC
31
+ `)
32
+ : null,
23
33
  };
24
34
  }
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ // Sub-agent viewer: the timeline table, the run header columns and their SQL
4
+ // in one place. On a database the 0009 migration has not reached yet the
5
+ // timeline statements are null and the header columns read as absent; every
6
+ // caller treats that as "no timeline" and falls back to the activity, thought
7
+ // and stream rows a run has always had.
8
+
9
+ const HEADER_COLUMNS = [
10
+ 'model',
11
+ 'model_inherited',
12
+ 'subagent_type',
13
+ 'task_id',
14
+ 'total_tokens',
15
+ 'background',
16
+ 'truncated',
17
+ 'timeline_owner',
18
+ ];
19
+
20
+ const BASE_RUN_COLUMNS = [
21
+ 'id',
22
+ 'queue_message_id',
23
+ 'conversation_id',
24
+ 'parent_subagent_id',
25
+ 'display_name',
26
+ 'status',
27
+ 'started_at',
28
+ 'updated_at',
29
+ 'completed_at',
30
+ ];
31
+
32
+ export function createSubagentTimelineStatements(db) {
33
+ const tableNames = new Set(
34
+ db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((row) => String(row.name)),
35
+ );
36
+ if (!tableNames.has('subagent_runs')) {
37
+ return { subagentRunsSupportHeader: false, subagentTimelineSupported: false, subagentRunSelectColumns: BASE_RUN_COLUMNS.join(', ') };
38
+ }
39
+ const runColumns = new Set(db.prepare(`PRAGMA table_info(subagent_runs)`).all().map((column) => String(column.name)));
40
+ const subagentRunsSupportHeader = HEADER_COLUMNS.every((name) => runColumns.has(name));
41
+ const subagentTimelineSupported = subagentRunsSupportHeader && tableNames.has('subagent_timeline');
42
+ const subagentRunSelectColumns = [...BASE_RUN_COLUMNS, ...(subagentRunsSupportHeader ? HEADER_COLUMNS : [])].join(', ');
43
+
44
+ const statements = {
45
+ subagentRunsSupportHeader,
46
+ subagentTimelineSupported,
47
+ // The column list every run read uses, so the header travels with the
48
+ // run wherever runs are listed (conversation payload, in-flight snapshot).
49
+ subagentRunSelectColumns,
50
+ updateSubagentRunHeader: null,
51
+ setSubagentRunTruncated: null,
52
+ listChildSubagentRuns: null,
53
+ getSubagentRunByTaskId: null,
54
+ listRunningBackgroundSubagentRunsForSession: null,
55
+ getTimelineEntryByKey: null,
56
+ getMaxTimelineSeq: null,
57
+ countTimelineEntries: null,
58
+ insertTimelineEntry: null,
59
+ updateTimelineEntryByKey: null,
60
+ listTimelineEntries: null,
61
+ deleteConvSubagentTimeline: null,
62
+ listLegacyActivityBySubagentRun: null,
63
+ listLegacyThoughtsBySubagentRun: null,
64
+ listLegacyStreamBySubagentRun: null,
65
+ };
66
+
67
+ // What a run recorded before the timeline existed: its tagged activity
68
+ // lines, thoughts and reply text. The viewer reads these for an old run.
69
+ const hasRunColumn = (table) => tableNames.has(table)
70
+ && db.prepare(`PRAGMA table_info(${table})`).all().some((column) => column.name === 'subagent_run_id');
71
+ if (hasRunColumn('relay_activity')) {
72
+ statements.listLegacyActivityBySubagentRun = db.prepare(`
73
+ SELECT id, text, metadata_json, created_at FROM relay_activity
74
+ WHERE subagent_run_id = ? ORDER BY id ASC LIMIT ?
75
+ `);
76
+ }
77
+ if (hasRunColumn('relay_thought')) {
78
+ statements.listLegacyThoughtsBySubagentRun = db.prepare(`
79
+ SELECT reasoning_id, seq, text, done, created_at FROM relay_thought
80
+ WHERE subagent_run_id = ? ORDER BY seq ASC, id ASC LIMIT ?
81
+ `);
82
+ }
83
+ if (hasRunColumn('relay_stream_events')) {
84
+ statements.listLegacyStreamBySubagentRun = db.prepare(`
85
+ SELECT text, done, created_at FROM relay_stream_events
86
+ WHERE subagent_run_id = ? ORDER BY seq ASC, id ASC LIMIT ?
87
+ `);
88
+ }
89
+ if (!subagentRunsSupportHeader) return statements;
90
+
91
+ // A patch, not a replace: every field the writer did not name (NULL) keeps
92
+ // what the row has. `background` and `truncated` only ever turn on, and the
93
+ // timeline owner is claimed once.
94
+ statements.updateSubagentRunHeader = db.prepare(`
95
+ UPDATE subagent_runs
96
+ SET model = COALESCE(@model, model),
97
+ model_inherited = COALESCE(@modelInherited, model_inherited),
98
+ subagent_type = COALESCE(@subagentType, subagent_type),
99
+ task_id = COALESCE(@taskId, task_id),
100
+ total_tokens = COALESCE(@totalTokens, total_tokens),
101
+ background = MAX(background, COALESCE(@background, 0)),
102
+ truncated = MAX(truncated, COALESCE(@truncated, 0)),
103
+ timeline_owner = COALESCE(timeline_owner, @timelineOwner),
104
+ updated_at = @updatedAt
105
+ WHERE id = @id
106
+ `);
107
+ statements.setSubagentRunTruncated = db.prepare(`UPDATE subagent_runs SET truncated = 1, updated_at = ? WHERE id = ?`);
108
+ statements.listChildSubagentRuns = db.prepare(`
109
+ SELECT ${subagentRunSelectColumns}
110
+ FROM subagent_runs
111
+ WHERE parent_subagent_id = ?
112
+ ORDER BY started_at ASC, id ASC
113
+ `);
114
+ // A worker's live background agents: what dies with it.
115
+ if (tableNames.has('conversations')) {
116
+ statements.listRunningBackgroundSubagentRunsForSession = db.prepare(`
117
+ SELECT ${subagentRunSelectColumns}
118
+ FROM subagent_runs
119
+ WHERE status = 'running' AND background = 1
120
+ AND conversation_id IN (SELECT id FROM conversations WHERE sdk_session_id = ? OR id = ?)
121
+ `);
122
+ }
123
+ statements.getSubagentRunByTaskId = db.prepare(`
124
+ SELECT ${subagentRunSelectColumns}
125
+ FROM subagent_runs
126
+ WHERE conversation_id = ? AND task_id = ?
127
+ ORDER BY started_at DESC
128
+ LIMIT 1
129
+ `);
130
+
131
+ if (!subagentTimelineSupported) return statements;
132
+
133
+ statements.getTimelineEntryByKey = db.prepare(`SELECT id, seq, done FROM subagent_timeline WHERE run_id = ? AND entry_key = ?`);
134
+ statements.getMaxTimelineSeq = db.prepare(`SELECT COALESCE(MAX(seq), 0) AS max_seq FROM subagent_timeline WHERE run_id = ?`);
135
+ statements.countTimelineEntries = db.prepare(`SELECT COUNT(*) AS cnt FROM subagent_timeline WHERE run_id = ?`);
136
+ statements.insertTimelineEntry = db.prepare(`
137
+ INSERT INTO subagent_timeline (
138
+ run_id, conversation_id, queue_message_id, entry_key, seq, kind, text, tool_name, tool_use_id,
139
+ metadata_json, done, created_at, updated_at
140
+ ) VALUES (
141
+ @runId, @conversationId, @queueMessageId, @entryKey, @seq, @kind, @text, @toolName, @toolUseId,
142
+ @metadataJson, @done, @createdAt, @updatedAt
143
+ )
144
+ `);
145
+ // In place: the entry keeps its seq (its place in the order). A closed
146
+ // entry stays closed, though its text may still be corrected (the complete
147
+ // copy of a block that streamed).
148
+ statements.updateTimelineEntryByKey = db.prepare(`
149
+ UPDATE subagent_timeline
150
+ SET text = @text,
151
+ tool_name = COALESCE(@toolName, tool_name),
152
+ tool_use_id = COALESCE(@toolUseId, tool_use_id),
153
+ metadata_json = COALESCE(@metadataJson, metadata_json),
154
+ done = MAX(done, @done),
155
+ updated_at = @updatedAt
156
+ WHERE run_id = @runId AND entry_key = @entryKey
157
+ `);
158
+ // Everything after `afterSeq`, plus whatever is still open before it: an
159
+ // open entry changes in place, so a reader that already has its seq still
160
+ // needs its newer text.
161
+ statements.listTimelineEntries = db.prepare(`
162
+ SELECT seq, kind, text, tool_name, tool_use_id, metadata_json, done, created_at, updated_at
163
+ FROM subagent_timeline
164
+ WHERE run_id = @runId AND (seq > @afterSeq OR done = 0)
165
+ ORDER BY seq ASC
166
+ LIMIT @limit
167
+ `);
168
+ statements.deleteConvSubagentTimeline = db.prepare(`DELETE FROM subagent_timeline WHERE conversation_id = ?`);
169
+ return statements;
170
+ }
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+
3
+ // GET /api/about: the About OAR modal's data (about-service.mjs). The owner's
4
+ // only; a shared viewer never sees it.
5
+
6
+ export function registerAboutRoutes(app, { auth, aboutService = null } = {}) {
7
+ app.get('/api/about', auth, async (_req, res) => {
8
+ if (!aboutService) return res.status(503).json({ error: 'About is unavailable on this relay' });
9
+ try {
10
+ return res.json(await aboutService.describe());
11
+ } catch (error) {
12
+ return res.status(500).json({ error: `Reading the relay's facts failed: ${String(error?.message || error)}` });
13
+ }
14
+ });
15
+ }
@@ -116,6 +116,8 @@ export function registerAskUserRoutes(app, deps) {
116
116
  DEFAULT_RELAY_MODE,
117
117
  sessionWorkerRegistry,
118
118
  pushDispatchService,
119
+ // Relay event subscriptions: a question is an event for its watchers.
120
+ relayEventService = null,
119
121
  // Remote relays: another relay's agent answering a question is refused
120
122
  // while the inbound switch is off. Absent in older wiring (no-op).
121
123
  remoteRelayInbound = null,
@@ -246,6 +248,7 @@ export function registerAskUserRoutes(app, deps) {
246
248
  console.log(`[${ts()}] QUESTION ${questionId.slice(0,8)} conv=${question.conversationId.slice(0,8)} mode=${relayMode} fields=${fieldCount} prompt="${promptText.slice(0,60)}"`);
247
249
  io.emit('relay_question', { question });
248
250
  void pushDispatchService?.notifyQuestion?.(question);
251
+ try { relayEventService?.noteQuestion?.(question); } catch {}
249
252
  res.json({ question });
250
253
  });
251
254
 
@@ -14,6 +14,8 @@ export function registerCacheRoutes(app, deps) {
14
14
  bootstrapRuntimeSessionBindings,
15
15
  collectOrphanedUploadsFromConversation,
16
16
  deleteOrphanedUploads,
17
+ closeConversationPreviews,
18
+ conversationGroups = null,
17
19
  } = deps;
18
20
 
19
21
  const cacheRebuildService = createCacheRebuildService({
@@ -26,6 +28,8 @@ export function registerCacheRoutes(app, deps) {
26
28
  bootstrapRuntimeSessionBindings,
27
29
  collectOrphanedUploadsFromConversation,
28
30
  deleteOrphanedUploads,
31
+ closeConversationPreviews,
32
+ conversationGroups,
29
33
  });
30
34
 
31
35
  app.post('/api/cache/rebuild', auth, (req, res) => {