@oar-sh/oar 0.9.9 → 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * What the relay can say about background work whose details it may not
5
+ * hold, because its task store and activity tracker live in memory and
6
+ * start empty at every runtime start while the workers keep running:
7
+ *
8
+ * - the placeholder: a live worker that says it has background work (the
9
+ * Claude worker's `autoCompactWindow.backgroundWork` on its heartbeat)
10
+ * while this runtime was never told its task set. Workers that answer a
11
+ * resync request fill the real set in within one heartbeat; this is for
12
+ * the ones still running older code. It is read, never stored: it goes
13
+ * when the set arrives, when the flag goes false and when the worker is
14
+ * gone, and it raises no start or end event.
15
+ * - the suspend blockers for background work, from the store and from the
16
+ * activity tracker (which also reads running sub-agent runs from the
17
+ * database), and the blocker for a runtime that only just started.
18
+ */
19
+
20
+ import {
21
+ UNREPORTED_BACKGROUND_TASK_DESCRIPTION,
22
+ UNREPORTED_BACKGROUND_TASK_ID,
23
+ } from './session-activity-tracker.mjs';
24
+
25
+ // Providers whose worker lists every running background sub-agent in its
26
+ // task set, by the run's id. Others may run background agents no set lists
27
+ // (their run rows are their only trace), so a set says nothing about a row.
28
+ const RUN_LISTING_PROVIDERS = new Set(['claude']);
29
+
30
+ function toText(value) {
31
+ return String(value ?? '').trim();
32
+ }
33
+
34
+ /** The placeholder as a row of the task panel: no details, no Stop button. */
35
+ export function unreportedBackgroundTaskCard() {
36
+ return {
37
+ taskId: UNREPORTED_BACKGROUND_TASK_ID,
38
+ taskType: 'unreported',
39
+ description: UNREPORTED_BACKGROUND_TASK_DESCRIPTION,
40
+ startedAt: null,
41
+ stoppable: false,
42
+ placeholder: true,
43
+ };
44
+ }
45
+
46
+ export function createBackgroundWorkView({
47
+ store,
48
+ db = null,
49
+ getRegistry = () => null,
50
+ getTracker = () => null,
51
+ isWorkerAlive = () => false,
52
+ // A conversation's id and its worker's session id are two ids, usually
53
+ // equal and not always. The tracker and the database speak of
54
+ // conversations; the registry, the store and the workers' own headers of
55
+ // sessions. These two map between them through the runtime session.
56
+ sessionIdOf = () => '',
57
+ conversationIdOf = () => '',
58
+ // conversationId → its provider ('claude', 'github', …).
59
+ providerOf = () => '',
60
+ titleOf = (conversationId) => toText(conversationId).slice(0, 8),
61
+ } = {}) {
62
+ let listRunningRuns = null;
63
+ if (db) {
64
+ try {
65
+ listRunningRuns = db.prepare(`
66
+ SELECT DISTINCT conversation_id FROM subagent_runs
67
+ WHERE status = 'running' AND background = 1 AND parent_subagent_id IS NULL
68
+ `);
69
+ } catch {}
70
+ }
71
+
72
+ function mapped(resolve, id) {
73
+ try { return toText(resolve(id)); } catch { return ''; }
74
+ }
75
+
76
+ /** The worker of a session id or of a conversation id. */
77
+ function workerOf(key) {
78
+ const registry = getRegistry();
79
+ const id = toText(key);
80
+ if (!registry || !id) return null;
81
+ // The registry's own indexes first; the mapping is a database read.
82
+ const direct = registry.getWorker?.(id) || registry.getWorkerByConversationId?.(id) || null;
83
+ if (direct) return direct;
84
+ const sessionId = mapped(sessionIdOf, id);
85
+ return (sessionId && sessionId !== id && registry.getWorker?.(sessionId)) || null;
86
+ }
87
+
88
+ /** The conversation a worker serves; the registry's own field may hold its session id. */
89
+ function conversationOf(worker, fallback = '') {
90
+ return mapped(conversationIdOf, worker?.sdkSessionId) || toText(worker?.conversationId) || toText(fallback);
91
+ }
92
+
93
+ function workerAlive(key) {
94
+ const worker = workerOf(key);
95
+ return !!worker && isWorkerAlive(worker.sdkSessionId) === true;
96
+ }
97
+
98
+ /** Whether the placeholder stands for this worker (by session or conversation id). */
99
+ function hasUnreportedWork(key) {
100
+ const worker = workerOf(key);
101
+ if (!worker || worker.steering?.autoCompactWindow?.backgroundWork !== true) return false;
102
+ const keys = [worker.sdkSessionId, worker.conversationId, conversationOf(worker), key];
103
+ if (store.hasReport(...keys)) return false;
104
+ if (keys.some((entry) => store.get(entry).length > 0)) return false;
105
+ return isWorkerAlive(worker.sdkSessionId) === true;
106
+ }
107
+
108
+ function listUnreportedConversationIds() {
109
+ const workers = getRegistry()?.listWorkers?.() || [];
110
+ return workers
111
+ .filter((worker) => worker?.steering?.autoCompactWindow?.backgroundWork === true && hasUnreportedWork(worker.sdkSessionId))
112
+ .map((worker) => conversationOf(worker, worker.sdkSessionId))
113
+ .filter(Boolean);
114
+ }
115
+
116
+ /**
117
+ * Whether the task set of a conversation's worker lists a sub-agent run
118
+ * (by the run's id or its task id): true or false, and null where the set
119
+ * cannot answer, because the worker has reported none in this runtime
120
+ * or its provider's sets do not list every background run. The tracker
121
+ * drops a `running` row that is not listed and has long been quiet.
122
+ */
123
+ function runListed(conversationId, row) {
124
+ const conversation = toText(conversationId);
125
+ if (!RUN_LISTING_PROVIDERS.has(mapped(providerOf, conversation).toLowerCase())) return null;
126
+ const keys = [...new Set([conversation, mapped(sessionIdOf, conversation), toText(workerOf(conversation)?.sdkSessionId)].filter(Boolean))];
127
+ if (!store.hasReport(...keys)) return null;
128
+ const runId = toText(row?.id);
129
+ const taskId = toText(row?.task_id);
130
+ return keys.some((key) => store.get(key).some((task) => (runId && toText(task?.toolUseId) === runId)
131
+ || (taskId && toText(task?.taskId) === taskId)));
132
+ }
133
+
134
+ /** The task panel's rows for a conversation: its set, else the placeholder. */
135
+ function panelTasks(...keys) {
136
+ for (const key of keys) {
137
+ const tasks = store.get(key);
138
+ if (tasks.length) return tasks;
139
+ }
140
+ return keys.some((key) => toText(key) && hasUnreportedWork(key)) ? [unreportedBackgroundTaskCard()] : [];
141
+ }
142
+
143
+ /**
144
+ * One `background` blocker per conversation with running background work
145
+ * of a live worker: the tasks of its published set, the running tasks the
146
+ * tracker knows (a background sub-agent run in the database, the
147
+ * placeholder), each task once.
148
+ */
149
+ function backgroundBlockers() {
150
+ const byConversation = new Map();
151
+ const add = (conversationId, id, description) => {
152
+ const conversation = toText(conversationId);
153
+ if (!conversation) return;
154
+ let tasks = byConversation.get(conversation);
155
+ if (!tasks) byConversation.set(conversation, tasks = new Map());
156
+ const taskId = toText(id) || `task-${tasks.size + 1}`;
157
+ if (!tasks.has(taskId)) tasks.set(taskId, toText(description) || 'task');
158
+ };
159
+ for (const [key, entry] of store.sets) {
160
+ const tasks = Array.isArray(entry?.tasks) ? entry.tasks : [];
161
+ if (!tasks.length || !workerAlive(key)) continue;
162
+ const conversationId = conversationOf(workerOf(key), mapped(conversationIdOf, key) || key);
163
+ for (const task of tasks) add(conversationId, task?.taskId, task?.description || task?.taskType);
164
+ }
165
+ const tracker = getTracker();
166
+ if (tracker) {
167
+ const candidates = new Set();
168
+ try { for (const id of tracker.knownConversationIds?.() || []) candidates.add(toText(id)); } catch {}
169
+ try { for (const row of listRunningRuns?.all() || []) candidates.add(toText(row.conversation_id)); } catch {}
170
+ for (const conversationId of candidates) {
171
+ if (!conversationId || !workerAlive(conversationId)) continue;
172
+ let view = null;
173
+ try { view = tracker.describe(conversationId); } catch {}
174
+ for (const task of view?.tasks || []) {
175
+ // A run inside a turn is the turn's work; the turn is the blocker.
176
+ if (task.status !== 'running' || task.inTurn) continue;
177
+ add(conversationId, task.id, task.description || task.type);
178
+ }
179
+ }
180
+ }
181
+ return [...byConversation].map(([conversationId, tasks]) => {
182
+ // The placeholder's text names the way out; it is not cut short.
183
+ const what = [...tasks].slice(0, 3)
184
+ .map(([id, text]) => (id === UNREPORTED_BACKGROUND_TASK_ID ? text : text.slice(0, 80)))
185
+ .join('; ');
186
+ return {
187
+ kind: 'background',
188
+ conversationId,
189
+ title: titleOf(conversationId),
190
+ count: tasks.size,
191
+ detail: `${tasks.size} background agent${tasks.size === 1 ? '' : 's'}: ${what}`,
192
+ };
193
+ });
194
+ }
195
+
196
+ /**
197
+ * The blocker of a runtime that only just started: workers that outlived
198
+ * the one before it may not have reported their background work yet, so
199
+ * "nothing runs" is not known. Null once the window is over. Its text
200
+ * does not change while it stands: every change of a blocker goes out to
201
+ * every page.
202
+ */
203
+ function bootBlocker() {
204
+ let boot = null;
205
+ try { boot = getTracker()?.bootReporting?.() || null; } catch {}
206
+ if (!boot?.active) return null;
207
+ return {
208
+ kind: 'boot',
209
+ detail: 'the relay just started, sessions are still reporting',
210
+ };
211
+ }
212
+
213
+ /** What holds a host suspend back for background work: the two above. */
214
+ function blockers() {
215
+ const boot = bootBlocker();
216
+ return [...backgroundBlockers(), ...(boot ? [boot] : [])];
217
+ }
218
+
219
+ return {
220
+ hasUnreportedWork,
221
+ listUnreportedConversationIds,
222
+ runListed,
223
+ panelTasks,
224
+ backgroundBlockers,
225
+ bootBlocker,
226
+ blockers,
227
+ };
228
+ }
@@ -62,6 +62,12 @@ export function createCacheRebuildService({
62
62
  bootstrapRuntimeSessionBindings,
63
63
  collectOrphanedUploadsFromConversation,
64
64
  deleteOrphanedUploads,
65
+ // (conversationId) => void: a hard-deleted conversation takes its preview
66
+ // links with it, as a delete from the UI does. Archiving keeps them.
67
+ closeConversationPreviews = null,
68
+ // Session groups: a purged head leaves its group, and a wipe is followed
69
+ // by a repair of what the wiped rows headed. Absent in older wiring.
70
+ conversationGroups = null,
65
71
  }) {
66
72
  const legacyConversationList = buildLegacyConversationList(db);
67
73
  const boundConversationList = buildBoundConversationList(db);
@@ -81,6 +87,7 @@ export function createCacheRebuildService({
81
87
  if (!sid) return [];
82
88
  const orphanedUploads = collectOrphanedUploadsFromConversation(sid);
83
89
  db.transaction(() => {
90
+ try { conversationGroups?.forgetConversation?.(sid); } catch {}
84
91
  db.prepare(`DELETE FROM relay_questions WHERE conversation_id = ?`).run(sid);
85
92
  db.prepare(`DELETE FROM relay_boards WHERE conversation_id = ?`).run(sid);
86
93
  db.prepare(`DELETE FROM relay_activity WHERE conversation_id = ?`).run(sid);
@@ -91,10 +98,19 @@ export function createCacheRebuildService({
91
98
  db.prepare(`DELETE FROM runtime_sessions WHERE conversation_id = ?`).run(sid);
92
99
  db.prepare(`DELETE FROM conversations WHERE id = ?`).run(sid);
93
100
  })();
101
+ closePreviews(sid);
94
102
  deleteOrphanedUploads(orphanedUploads);
95
103
  return orphanedUploads;
96
104
  }
97
105
 
106
+ function closePreviews(conversationId) {
107
+ try {
108
+ closeConversationPreviews?.(conversationId);
109
+ } catch (error) {
110
+ console.warn(`[preview] closing the previews of a purged conversation failed: ${error?.message || error}`);
111
+ }
112
+ }
113
+
98
114
  function archiveConversation(conversationId) {
99
115
  const sid = normalizeId(conversationId);
100
116
  if (!sid) return false;
@@ -112,6 +128,12 @@ export function createCacheRebuildService({
112
128
  AND title_source = 'manual'
113
129
  `).all().map((row) => normalizeId(row?.id)).filter(Boolean);
114
130
  const manualTitleConversationSet = new Set(manualTitleConversations);
131
+ // The groups headed by a row about to go keep the head's title as their
132
+ // name, so a group whose hand-titled members survive is not nameless; the
133
+ // head link stays for a re-import under the same id.
134
+ try {
135
+ conversationGroups?.freezeNamesForHeads?.(conversationIds.filter((id) => !manualTitleConversationSet.has(id)), now);
136
+ } catch {}
115
137
  const uploadHashes = [];
116
138
  for (const conversationId of conversationIds) {
117
139
  for (const hash of collectOrphanedUploadsFromConversation(conversationId)) {
@@ -147,8 +169,16 @@ export function createCacheRebuildService({
147
169
  db.prepare(`DELETE FROM sdk_session_imports`).run();
148
170
  db.prepare(`DELETE FROM upload_refs`).run();
149
171
  db.prepare(`DELETE FROM uploaded_files`).run();
172
+ // Groups whose rows are all gone go too; one whose head was wiped but
173
+ // whose hand-titled members stay keeps the frozen name and the head
174
+ // link (a re-imported head takes it up again).
175
+ try { conversationGroups?.sweepEmpty?.(); } catch {}
150
176
  })();
151
177
 
178
+ for (const conversationId of conversationIds) {
179
+ if (!manualTitleConversationSet.has(conversationId)) closePreviews(conversationId);
180
+ }
181
+
152
182
  deleteOrphanedUploads(uploadHashes);
153
183
 
154
184
  if (uploadsDir && fs?.existsSync?.(uploadsDir)) {
@@ -25,7 +25,7 @@ function createSdkDeleteSessionCaller(sdkClient) {
25
25
 
26
26
  import { cleanupGeneratedImagesForConversation } from './generated-image-cleanup-service.mjs';
27
27
 
28
- export function createDeleteArchiveService(db, sdkClient, { resolveSessionStateRoot = null } = {}) {
28
+ export function createDeleteArchiveService(db, sdkClient, { resolveSessionStateRoot = null, conversationGroups = null } = {}) {
29
29
  const callDeleteSession = createSdkDeleteSessionCaller(sdkClient);
30
30
 
31
31
  const stmts = {
@@ -87,6 +87,9 @@ export function createDeleteArchiveService(db, sdkClient, { resolveSessionStateR
87
87
  messageRows: stmts.listConversationMessages.all(conversationId),
88
88
  resolveSessionStateRoot,
89
89
  });
90
+ // A head leaves its session group (title frozen as the name) before its
91
+ // row goes; a group left empty goes with it.
92
+ try { conversationGroups?.forgetConversation?.(conversationId); } catch {}
90
93
  stmts.hardDeleteConversation(conversationId);
91
94
  }
92
95
 
@@ -0,0 +1,150 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Detached-worker watch
3
+ //
4
+ // A worker whose process lives on but whose websocket to the relay is gone
5
+ // gets no deliveries, and the relay spawns no replacement while its process
6
+ // is alive: a message for its session would wait for ever. A worker on Node
7
+ // 22 ended up like that after a relay restart, its one reconnect having met a
8
+ // runtime that was not ready yet.
9
+ //
10
+ // The watch looks at the sessions that have a message waiting. One whose
11
+ // worker process is found alive with no socket, no turn running and no
12
+ // background work is replaced once it has been detached for the grace time:
13
+ // the old process is stopped and a new worker spawned, which takes the
14
+ // waiting message at its hello. The grace time counts from the latest of the
15
+ // watch's start (a relay start), the session's last socket end and its last
16
+ // spawn; a worker still booting gets twice as long.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export const DETACHED_WORKER_GRACE_MS = 60_000;
20
+ // A replacement that did not help (the new worker cannot connect either)
21
+ // is not repeated at once.
22
+ export const DETACHED_WORKER_REPLACE_COOLDOWN_MS = 5 * 60_000;
23
+
24
+ function normalizeSessionId(value) {
25
+ return String(value || '').trim();
26
+ }
27
+
28
+ export function createDetachedWorkerWatch({
29
+ graceMs = DETACHED_WORKER_GRACE_MS,
30
+ replaceCooldownMs = DETACHED_WORKER_REPLACE_COOLDOWN_MS,
31
+ now = () => Date.now(),
32
+ // Whether the watch may act at all (routing on, no shutdown under way).
33
+ isEnabled = () => true,
34
+ // The session ids that have a message waiting for their worker.
35
+ listWaitingSessionIds = () => [],
36
+ hasWorkerSocket = () => true,
37
+ hasRunningTurn = () => true,
38
+ hasBackgroundWork = () => true,
39
+ // A kill just asked for, or a launch in flight: not the watch's business.
40
+ isLaunchBlocked = () => false,
41
+ // ({ launchMode, launchAtMs, awaitingHeartbeat }) for the session, or null.
42
+ getLaunchState = () => null,
43
+ // Resolves to whether a process of the session's worker is running.
44
+ isWorkerProcessAlive = async () => false,
45
+ // Stops the session's worker processes and spawns a new worker; resolves
46
+ // to { ok, error }.
47
+ replaceWorker = async () => ({ ok: false, error: 'not-configured' }),
48
+ log = () => {},
49
+ } = {}) {
50
+ const startedAtMs = Number(now());
51
+ const socketEndedAtMs = new Map();
52
+ const replacedAtMs = new Map();
53
+ const replacing = new Set();
54
+ let sweepInFlight = false;
55
+
56
+ function nowMs() {
57
+ const value = Number(now());
58
+ return Number.isFinite(value) ? value : Date.now();
59
+ }
60
+
61
+ function noteSocketEnded(sessionId) {
62
+ const sid = normalizeSessionId(sessionId);
63
+ if (sid) socketEndedAtMs.set(sid, nowMs());
64
+ }
65
+
66
+ function forgetOld(atMs) {
67
+ const horizon = Math.max(graceMs * 2, replaceCooldownMs);
68
+ for (const [sid, at] of socketEndedAtMs) {
69
+ if (atMs - at > horizon) socketEndedAtMs.delete(sid);
70
+ }
71
+ for (const [sid, at] of replacedAtMs) {
72
+ if (atMs - at > horizon) replacedAtMs.delete(sid);
73
+ }
74
+ }
75
+
76
+ // How long the session's worker has been detached for the watch's
77
+ // purposes, and the grace it gets.
78
+ function detachedFor(sid, atMs) {
79
+ const launch = getLaunchState(sid) || null;
80
+ const launchAtMs = Number(launch?.launchAtMs || 0);
81
+ const spawnedAtMs = launch?.launchMode === 'spawned' ? launchAtMs : 0;
82
+ const anchorMs = Math.max(startedAtMs, Number(socketEndedAtMs.get(sid) || 0), spawnedAtMs);
83
+ const booting = launch?.launchMode === 'spawned' && launch?.awaitingHeartbeat === true;
84
+ return { detachedMs: atMs - anchorMs, graceMs: booting ? graceMs * 2 : graceMs };
85
+ }
86
+
87
+ function stillDetached(sid) {
88
+ return !hasWorkerSocket(sid) && !hasRunningTurn(sid) && !hasBackgroundWork(sid) && !isLaunchBlocked(sid);
89
+ }
90
+
91
+ async function considerSession(sid, atMs) {
92
+ if (replacing.has(sid)) return false;
93
+ if (!stillDetached(sid)) return false;
94
+ const lastReplacedAtMs = replacedAtMs.get(sid);
95
+ if (lastReplacedAtMs && atMs - lastReplacedAtMs < replaceCooldownMs) return false;
96
+ const { detachedMs, graceMs: grace } = detachedFor(sid, atMs);
97
+ if (detachedMs < grace) return false;
98
+ if (!(await isWorkerProcessAlive(sid))) return false;
99
+ // The process probe can take a moment (a process list): the worker may
100
+ // have reconnected or taken a turn meanwhile.
101
+ if (!isEnabled() || !stillDetached(sid)) return false;
102
+
103
+ replacing.add(sid);
104
+ replacedAtMs.set(sid, atMs);
105
+ log(`WORKER DETACHED session=${sid.slice(0, 8)} detachedMs=${detachedMs} — no socket while a message waits; replacing the worker`);
106
+ try {
107
+ const result = await replaceWorker(sid, { detachedMs });
108
+ if (result?.ok) {
109
+ log(`WORKER DETACHED session=${sid.slice(0, 8)} replaced`);
110
+ return true;
111
+ }
112
+ log(`WORKER DETACHED session=${sid.slice(0, 8)} replace failed: ${String(result?.error || 'unknown')}`);
113
+ return false;
114
+ } catch (error) {
115
+ log(`WORKER DETACHED session=${sid.slice(0, 8)} replace failed: ${error?.message || error}`);
116
+ return false;
117
+ } finally {
118
+ replacing.delete(sid);
119
+ }
120
+ }
121
+
122
+ // One sweep at a time: a slow process list must not pile sweeps up.
123
+ async function sweep() {
124
+ if (sweepInFlight) return 0;
125
+ if (!isEnabled()) return 0;
126
+ sweepInFlight = true;
127
+ try {
128
+ const atMs = nowMs();
129
+ forgetOld(atMs);
130
+ const sessionIds = [...new Set((listWaitingSessionIds() || []).map(normalizeSessionId).filter(Boolean))];
131
+ let replaced = 0;
132
+ for (const sid of sessionIds) {
133
+ if (!isEnabled()) break;
134
+ try {
135
+ if (await considerSession(sid, atMs)) replaced += 1;
136
+ } catch (error) {
137
+ log(`WORKER DETACHED check failed session=${sid.slice(0, 8)} err=${error?.message || error}`);
138
+ }
139
+ }
140
+ return replaced;
141
+ } finally {
142
+ sweepInFlight = false;
143
+ }
144
+ }
145
+
146
+ return {
147
+ noteSocketEnded,
148
+ sweep,
149
+ };
150
+ }
@@ -0,0 +1,26 @@
1
+ // The one rule set for a file or folder name typed in the explorer: a single
2
+ // path segment, nothing that could leave the parent, and on Windows nothing
3
+ // the filesystem itself refuses. The Windows set applies only when the relay
4
+ // runs there; a Linux name such as `a:b` is legal and stays legal.
5
+
6
+ const MAX_NAME_BYTES = 255;
7
+ const WINDOWS_FORBIDDEN_CHARS = /[<>:"|?*\u0000-\u001f]/;
8
+ const WINDOWS_RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
9
+
10
+ /**
11
+ * Validate a name. Returns `{ ok: true, name }` with the trimmed name, or
12
+ * `{ ok: false, error }` with a message fit for the UI.
13
+ */
14
+ export function validateEntryName(rawName, { platform = process.platform } = {}) {
15
+ const name = String(rawName ?? '').trim();
16
+ if (!name) return { ok: false, error: 'Enter a name' };
17
+ if (name === '.' || name === '..') return { ok: false, error: 'That name is not allowed' };
18
+ if (/[/\\\0]/.test(name)) return { ok: false, error: 'A name cannot contain / or \\' };
19
+ if (Buffer.byteLength(name, 'utf8') > MAX_NAME_BYTES) return { ok: false, error: 'That name is too long' };
20
+ if (platform === 'win32') { // host-platform: Windows filesystem naming rules
21
+ if (WINDOWS_FORBIDDEN_CHARS.test(name)) return { ok: false, error: 'A name cannot contain < > : " | ? *' };
22
+ if (/[. ]$/.test(name)) return { ok: false, error: 'A name cannot end with a dot or a space' };
23
+ if (WINDOWS_RESERVED_NAMES.test(name)) return { ok: false, error: 'That name is reserved by Windows' };
24
+ }
25
+ return { ok: true, name };
26
+ }
@@ -0,0 +1,122 @@
1
+ // Filesystem changes made from the explorer: create a folder, rename an
2
+ // entry, delete an entry, and count what a delete would remove. The callers
3
+ // (the routes) have already resolved the absolute target inside a browsable
4
+ // root; this module only touches the filesystem and turns its errors into
5
+ // `{ error, statusCode }` answers the routes can send as they are.
6
+ //
7
+ // Symbolic links are refused everywhere. The listings skip them, so the user
8
+ // never saw one in the explorer, and a rename or delete through a link would
9
+ // act on something outside the folder they are looking at.
10
+
11
+ import fsDefault from 'node:fs';
12
+ import pathDefault from 'node:path';
13
+
14
+ export const DEFAULT_COUNT_CAP = 10000;
15
+
16
+ function failure(error, fallback, statusCode = 500) {
17
+ const code = String(error?.code || '');
18
+ if (code === 'ENOENT' || code === 'ENOTDIR') return { error: 'Not found', statusCode: 404 };
19
+ if (code === 'EEXIST' || code === 'ENOTEMPTY') return { error: 'That name is already taken', statusCode: 409 };
20
+ if (code === 'EACCES' || code === 'EPERM' || code === 'EBUSY') return { error: 'Permission denied', statusCode: 403 };
21
+ return { error: error?.message || fallback, statusCode };
22
+ }
23
+
24
+ function lstatOrNull(fs, target) {
25
+ try {
26
+ return fs.lstatSync(target);
27
+ } catch (error) {
28
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null;
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ /** Create `target`; missing parents are created with it. */
34
+ export function createDirectory(target, { fs = fsDefault } = {}) {
35
+ try {
36
+ if (lstatOrNull(fs, target)) return { error: 'That name is already taken', statusCode: 409 };
37
+ fs.mkdirSync(target, { recursive: true });
38
+ return { ok: true };
39
+ } catch (error) {
40
+ return failure(error, 'Failed to create the folder');
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Rename `target` to `newName` inside its own parent. A name that differs
46
+ * only in case is allowed: on a case-insensitive filesystem the destination
47
+ * stat finds the source itself, which is not a collision.
48
+ */
49
+ export function renameEntry(target, newName, { fs = fsDefault, path = pathDefault } = {}) {
50
+ const destination = path.join(path.dirname(target), newName);
51
+ try {
52
+ const source = lstatOrNull(fs, target);
53
+ if (!source) return { error: 'Not found', statusCode: 404 };
54
+ if (source.isSymbolicLink()) return { error: 'Links cannot be changed here', statusCode: 409 };
55
+ if (destination === target) return { ok: true, path: destination };
56
+ const existing = lstatOrNull(fs, destination);
57
+ const sameEntry = existing && existing.ino === source.ino && existing.dev === source.dev && existing.ino !== 0;
58
+ if (existing && !sameEntry) return { error: 'That name is already taken', statusCode: 409 };
59
+ fs.renameSync(target, destination);
60
+ return { ok: true, path: destination };
61
+ } catch (error) {
62
+ return failure(error, 'Failed to rename');
63
+ }
64
+ }
65
+
66
+ /** Remove `target`; a folder goes with everything inside it. */
67
+ export function removeEntry(target, { fs = fsDefault } = {}) {
68
+ try {
69
+ const stat = lstatOrNull(fs, target);
70
+ if (!stat) return { error: 'Not found', statusCode: 404 };
71
+ if (stat.isSymbolicLink()) return { error: 'Links cannot be changed here', statusCode: 409 };
72
+ fs.rmSync(target, { recursive: true, force: false, maxRetries: 2 });
73
+ return { ok: true };
74
+ } catch (error) {
75
+ return failure(error, 'Failed to delete');
76
+ }
77
+ }
78
+
79
+ /**
80
+ * What a delete of `target` would remove: `{ kind, files, dirs, capped }`.
81
+ * `dirs` excludes the target itself. The walk stops once `files + dirs`
82
+ * reaches `cap`, so a huge tree answers quickly with `capped: true`. Links
83
+ * inside count as files and are not followed.
84
+ */
85
+ export function countEntries(target, { fs = fsDefault, path = pathDefault, cap = DEFAULT_COUNT_CAP } = {}) {
86
+ let stat = null;
87
+ try {
88
+ stat = lstatOrNull(fs, target);
89
+ } catch (error) {
90
+ return failure(error, 'Failed to read the entry');
91
+ }
92
+ if (!stat) return { error: 'Not found', statusCode: 404 };
93
+ if (stat.isSymbolicLink()) return { error: 'Links cannot be changed here', statusCode: 409 };
94
+ if (!stat.isDirectory()) return { ok: true, kind: 'file', files: 0, dirs: 0, capped: false };
95
+
96
+ let files = 0;
97
+ let dirs = 0;
98
+ let capped = false;
99
+ const stack = [target];
100
+ while (stack.length && !capped) {
101
+ const dir = stack.pop();
102
+ let entries = [];
103
+ try {
104
+ entries = fs.readdirSync(dir, { withFileTypes: true });
105
+ } catch {
106
+ continue; // an unreadable branch is still removed by rm; it just stays uncounted
107
+ }
108
+ for (const entry of entries) {
109
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
110
+ dirs += 1;
111
+ stack.push(path.join(dir, entry.name));
112
+ } else {
113
+ files += 1;
114
+ }
115
+ if (files + dirs >= cap) {
116
+ capped = true;
117
+ break;
118
+ }
119
+ }
120
+ }
121
+ return { ok: true, kind: 'dir', files, dirs, capped };
122
+ }
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ // The list of messages a conversation hides from its shared viewers: one
4
+ // short plain-text preview per message, the same shape as the pin list, so
5
+ // the owner's 🙈 list and its count cover messages outside the loaded page.
6
+ // Owner-only: the shared payload never carries it.
7
+
8
+ import { buildPinPreview, countAttachments } from './message-pin-list.mjs';
9
+
10
+ export function buildHiddenMessageList(rows) {
11
+ return (Array.isArray(rows) ? rows : [])
12
+ .filter((row) => String(row?.id || '').trim())
13
+ .map((row) => ({
14
+ messageId: String(row.id).trim(),
15
+ role: String(row.role || '').trim(),
16
+ preview: buildPinPreview(row.text, row.mode),
17
+ timestamp: row.timestamp || null,
18
+ hiddenAt: row.share_hidden_at || null,
19
+ attachmentCount: countAttachments(row.attachments),
20
+ }));
21
+ }
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  import { spawn } from 'node:child_process';
4
+ import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
4
5
 
5
6
  /**
6
7
  * The actual suspend-to-RAM call. Windows only: `SetSuspendState 0,0,0` puts
@@ -25,7 +26,7 @@ export function runHostSuspendToRam({
25
26
  return { ok: true, command, dryRun: true };
26
27
  }
27
28
  try {
28
- const child = spawnImpl('rundll32.exe', ['powrprof.dll,SetSuspendState', '0,0,0'], {
29
+ const child = spawnImpl(windowsSystemToolPath('rundll32.exe'), ['powrprof.dll,SetSuspendState', '0,0,0'], {
29
30
  detached: true,
30
31
  stdio: 'ignore',
31
32
  windowsHide: true,
@@ -9,7 +9,7 @@ import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
9
9
  export const MESSAGE_PIN_LIMIT = 100;
10
10
  export const PIN_PREVIEW_MAX_LENGTH = 160;
11
11
 
12
- function countAttachments(raw) {
12
+ export function countAttachments(raw) {
13
13
  if (Array.isArray(raw)) return raw.length;
14
14
  if (typeof raw !== 'string' || !raw) return 0;
15
15
  try {