@oar-sh/oar 0.9.10 → 0.9.12

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 (135) hide show
  1. package/CHANGELOG.md +440 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +112 -25
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +217 -11
  8. package/server/claude-worker/claude-session-worker.mjs +26 -1
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +68 -2
  15. package/server/grok-worker/acp-client.mjs +2 -1
  16. package/server/grok-worker/acp-host-services.mjs +46 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0013-previews.mjs +130 -0
  22. package/server/migrations/0014-conversation-groups.mjs +89 -0
  23. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  24. package/server/migrations/0016-share-access-log.mjs +110 -0
  25. package/server/migrations/0017-conversation-history-indexes.mjs +89 -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 +40 -101
  30. package/server/public/app/api-client.js +264 -16
  31. package/server/public/app/ask-user-view.js +17 -15
  32. package/server/public/app/background-tasks-view.mjs +285 -16
  33. package/server/public/app/bootstrap.js +253 -37
  34. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  35. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  36. package/server/public/app/context-menu.mjs +90 -6
  37. package/server/public/app/conversation-group-drag.mjs +191 -0
  38. package/server/public/app/conversation-group-order.mjs +265 -0
  39. package/server/public/app/conversation-group-state.mjs +100 -0
  40. package/server/public/app/conversation-list-drag.mjs +449 -0
  41. package/server/public/app/conversation-payload-cache.mjs +357 -0
  42. package/server/public/app/conversation-turn-state.mjs +81 -0
  43. package/server/public/app/conversation-view.js +567 -94
  44. package/server/public/app/conversation-window-retention.mjs +96 -0
  45. package/server/public/app/cwd-picker.js +1 -1
  46. package/server/public/app/hidden-messages-view.mjs +312 -0
  47. package/server/public/app/host-suspend-ui.js +7 -1
  48. package/server/public/app/journal-view.js +1033 -82
  49. package/server/public/app/preview-cards.mjs +117 -31
  50. package/server/public/app/preview-command.mjs +10 -1
  51. package/server/public/app/relay-board-view.js +19 -6
  52. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  53. package/server/public/app/relay-restart-error.mjs +16 -0
  54. package/server/public/app/settings-modal.js +150 -10
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/share-format.mjs +49 -0
  57. package/server/public/app/share-preview-marks.mjs +167 -0
  58. package/server/public/app/share-state.js +109 -0
  59. package/server/public/app/shared-conversations-settings.js +480 -0
  60. package/server/public/app/socket-handlers.js +138 -20
  61. package/server/public/app/store.js +121 -30
  62. package/server/public/app/subagent-row.mjs +3 -3
  63. package/server/public/app/subagent-viewer.mjs +37 -7
  64. package/server/public/app/task-console-render.mjs +237 -0
  65. package/server/public/app/task-console-watch.mjs +42 -0
  66. package/server/public/app/task-console.mjs +630 -0
  67. package/server/public/app/task-output-text.mjs +234 -0
  68. package/server/public/app/thinking-follow.js +0 -4
  69. package/server/public/app/transcript-island.mjs +73 -0
  70. package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
  71. package/server/public/app/transcript-jump-bottom.mjs +170 -22
  72. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  73. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  74. package/server/public/app/transcript-viewport-anchor.mjs +159 -0
  75. package/server/public/index.html +625 -21
  76. package/server/relay-tools.md +3 -1
  77. package/server/relay.mjs +2 -1
  78. package/server/repositories/conversation-group-repository.mjs +444 -0
  79. package/server/repositories/message-repository.mjs +9 -0
  80. package/server/repositories/question-repository.mjs +4 -0
  81. package/server/repositories/session-repository.mjs +12 -37
  82. package/server/repositories/share-repository.mjs +322 -0
  83. package/server/repositories/share-visibility-statements.mjs +10 -0
  84. package/server/routes/about-routes.mjs +15 -0
  85. package/server/routes/cache-routes.mjs +4 -0
  86. package/server/routes/conversation-group-routes.mjs +171 -0
  87. package/server/routes/messages-routes.mjs +433 -68
  88. package/server/routes/preview-routes.mjs +9 -1
  89. package/server/routes/sessions-routes.mjs +901 -253
  90. package/server/server-runtime.mjs +392 -99
  91. package/server/services/about-service.mjs +461 -0
  92. package/server/services/background-task-store.mjs +365 -0
  93. package/server/services/background-work-view.mjs +228 -0
  94. package/server/services/cache-rebuild-service.mjs +30 -0
  95. package/server/services/cli-process-runner.mjs +4 -21
  96. package/server/services/conversation-history-page.mjs +199 -0
  97. package/server/services/delete-archive-service.mjs +4 -1
  98. package/server/services/detached-worker-watch-service.mjs +150 -0
  99. package/server/services/hidden-message-list.mjs +21 -0
  100. package/server/services/host-suspend-command.mjs +2 -1
  101. package/server/services/message-pin-list.mjs +1 -1
  102. package/server/services/preview-health-probe.mjs +8 -1
  103. package/server/services/preview-proxy-server.mjs +8 -4
  104. package/server/services/preview-registry-service.mjs +676 -26
  105. package/server/services/preview-static-handler.mjs +40 -7
  106. package/server/services/preview-store.mjs +274 -0
  107. package/server/services/rate-limit-service.mjs +12 -1
  108. package/server/services/relay-auth.mjs +183 -0
  109. package/server/services/relay-cli-launcher-service.mjs +3 -1
  110. package/server/services/relay-event-service.mjs +17 -0
  111. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  112. package/server/services/remote-relay-dispatcher.mjs +187 -7
  113. package/server/services/request-boundary.mjs +183 -0
  114. package/server/services/session-activity-tracker.mjs +231 -19
  115. package/server/services/session-worker-launch-service.mjs +190 -6
  116. package/server/services/session-worker-process-service.mjs +10 -6
  117. package/server/services/share-access-log-service.mjs +123 -0
  118. package/server/services/share-path-redaction.mjs +424 -0
  119. package/server/services/status-event-service.mjs +7 -3
  120. package/server/services/task-output-rooms.mjs +259 -0
  121. package/server/services/task-output-service.mjs +562 -0
  122. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  123. package/server/services/windows-autostart-service.mjs +6 -1
  124. package/server/services/windows-boot-autostart-service.mjs +207 -23
  125. package/shared/claude-background-command-limit.mjs +83 -0
  126. package/shared/preview-tool-core.mjs +15 -5
  127. package/shared/remote-relay-contract.mjs +35 -3
  128. package/shared/share-path-privacy.mjs +30 -0
  129. package/shared/task-output-text.mjs +737 -0
  130. package/shared/windows-system-tools.mjs +106 -0
  131. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  132. package/shared/worker-runtime/heartbeat.mjs +24 -0
  133. package/shared/worker-runtime/kept-posts.mjs +83 -0
  134. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  135. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -37,10 +37,6 @@ const OTHER_ESCAPE_PATTERN = /\x1b[@-Z\\-_]/g;
37
37
  // differently-sized terminal this degrades to the pre-wrap behaviour.
38
38
  const PTY_WRAP_COLUMNS = 80;
39
39
 
40
- function normalizeText(value) {
41
- return String(value == null ? '' : value).trim();
42
- }
43
-
44
40
  /**
45
41
  * Removes OSC sequences (including OSC-8 hyperlink wrappers), ANSI CSI
46
42
  * sequences and the CR the PTY adds to every line, leaving the human-visible
@@ -74,23 +70,10 @@ export function joinWrappedLines(value) {
74
70
  return joined.join('\n');
75
71
  }
76
72
 
77
- /**
78
- * Redacts anything token-shaped plus an exact secret the caller knows it
79
- * handed over (the pasted Claude login code), so an error tail can be surfaced
80
- * to the UI without leaking a credential. Long PKCE segments in an authorize
81
- * URL get caught by the generic rule too, which is fine: the URL is already
82
- * carried in the login payload.
83
- */
84
- export function scrubSecrets(text, submittedCode = '') {
85
- let output = String(text == null ? '' : text);
86
- const code = normalizeText(submittedCode);
87
- if (code.length >= 4) {
88
- output = output.split(code).join('[redacted]');
89
- }
90
- return output
91
- .replace(/sk-ant-[A-Za-z0-9_-]+/g, '[redacted]')
92
- .replace(/[A-Za-z0-9_-]{32,}/g, '[redacted]');
93
- }
73
+ // Redacts anything token-shaped plus an exact secret the caller handed over.
74
+ // The rules live in the shared text pipeline, which also scrubs background
75
+ // task output (browser-safe, so it cannot import this module).
76
+ export { scrubSecrets } from '../../shared/task-output-text.mjs';
94
77
 
95
78
  /** Collapses captured output into one short single-line tail for an error. */
96
79
  export function tailOf(text, limit = MAX_ERROR_TAIL_CHARS) {
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ // One page of a conversation's history, selected in SQL.
4
+ //
5
+ // The history route used to load every message, hydrate every reply and only
6
+ // then cut the page out (selectConversationHistoryPage in sessions-routes.mjs),
7
+ // so each page and each live poll paid for the whole conversation. This module
8
+ // selects the page's rows in SQL with the same cursor rules and the same
9
+ // pageInfo, so only the page is hydrated.
10
+ //
11
+ // The JS order is (timestamp in ms, then id by localeCompare). SQL orders by
12
+ // (timestamp text, id text). The two agree when every timestamp has the fixed
13
+ // ISO shape the relay writes (YYYY-MM-DDTHH:MM:SS.mmmZ) and every id is made
14
+ // of lowercase letters, digits and dashes (the relay's UUIDs). A conversation
15
+ // with any other row is not paged here: the selector returns null and the
16
+ // caller keeps the in-memory path, which handles every shape.
17
+
18
+ const ISO_TIMESTAMP_GLOB = '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9]Z';
19
+ const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
20
+ const ORDERABLE_ID_PATTERN = /^[-0-9a-z]+$/;
21
+
22
+ export function normalizeConversationTimestampMs(value) {
23
+ const text = String(value || '').trim();
24
+ if (!text) return 0;
25
+ const sqliteUtcLike = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?)$/;
26
+ const hasExplicitTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(text);
27
+ let normalized = text;
28
+ if (!hasExplicitTimezone && sqliteUtcLike.test(text)) {
29
+ const [, day, time] = text.match(sqliteUtcLike) || [];
30
+ normalized = `${day}T${time}Z`;
31
+ }
32
+ const parsed = Date.parse(normalized);
33
+ return Number.isFinite(parsed) ? parsed : 0;
34
+ }
35
+
36
+ export function normalizeConversationHistoryLimit(value, fallback = 20) {
37
+ const numeric = Number(value);
38
+ if (!Number.isFinite(numeric)) return fallback;
39
+ return Math.min(100, Math.max(1, Math.trunc(numeric)));
40
+ }
41
+
42
+ /**
43
+ * The pageInfo of a history page: `pageMessages` in conversation order, and
44
+ * whether the conversation has rows before the first and after the last.
45
+ */
46
+ export function buildConversationHistoryPageInfo(pageMessages = [], { hasMoreOlder = false, hasMoreNewer = false } = {}) {
47
+ const firstMessage = pageMessages[0] || null;
48
+ const lastMessage = pageMessages[pageMessages.length - 1] || null;
49
+ return {
50
+ hasMore: hasMoreOlder,
51
+ hasMoreOlder,
52
+ hasMoreNewer,
53
+ nextCursor: firstMessage ? {
54
+ beforeMessageId: String(firstMessage.id || '').trim(),
55
+ beforeTimestamp: String(firstMessage.timestamp || '').trim() || null,
56
+ } : null,
57
+ olderCursor: firstMessage ? {
58
+ beforeMessageId: String(firstMessage.id || '').trim(),
59
+ beforeTimestamp: String(firstMessage.timestamp || '').trim() || null,
60
+ } : null,
61
+ newerCursor: lastMessage ? {
62
+ afterMessageId: String(lastMessage.id || '').trim(),
63
+ afterTimestamp: String(lastMessage.timestamp || '').trim() || null,
64
+ } : null,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * The prepared statements for paging one conversation. `sharedOnly` pages
70
+ * over the rows a shared view shows (not hidden from shares).
71
+ */
72
+ export function createConversationHistoryPageStatements(db) {
73
+ const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
74
+ const sharedFilter = messageColumns.has('hidden_from_shares') ? 'AND hidden_from_shares = 0' : '';
75
+ const hasResendColumns = messageColumns.has('source_message_id') && messageColumns.has('resend_of_message_id');
76
+ const build = (visibility) => {
77
+ const base = `FROM messages WHERE conversation_id = @conversationId ${visibility}`;
78
+ const older = `(timestamp < @timestamp OR (timestamp = @timestamp AND @id IS NOT NULL AND id < @id))`;
79
+ const newer = `(timestamp > @timestamp OR (timestamp = @timestamp AND @id IS NOT NULL AND id > @id))`;
80
+ return {
81
+ irregular: db.prepare(`
82
+ SELECT EXISTS (
83
+ SELECT 1 ${base}
84
+ AND NOT (timestamp GLOB '${ISO_TIMESTAMP_GLOB}' AND id <> '' AND id NOT GLOB '*[^-0-9a-z]*')
85
+ ) AS irregular
86
+ `),
87
+ findById: db.prepare(`SELECT id, timestamp ${base} AND id = @id`),
88
+ older: db.prepare(`SELECT * ${base} AND ${older} ORDER BY timestamp DESC, id DESC LIMIT @limit`),
89
+ newer: db.prepare(`SELECT * ${base} AND ${newer} ORDER BY timestamp ASC, id ASC LIMIT @limit`),
90
+ fromInclusive: db.prepare(`
91
+ SELECT * ${base} AND (timestamp > @timestamp OR (timestamp = @timestamp AND id >= @id))
92
+ ORDER BY timestamp ASC, id ASC LIMIT @limit
93
+ `),
94
+ tail: db.prepare(`SELECT * ${base} ORDER BY timestamp DESC, id DESC LIMIT @limit`),
95
+ existsOlder: db.prepare(`SELECT EXISTS (SELECT 1 ${base} AND ${older}) AS found`),
96
+ existsNewer: db.prepare(`SELECT EXISTS (SELECT 1 ${base} AND ${newer}) AS found`),
97
+ // What liveResendsByOriginal reads, for the whole conversation: a
98
+ // stopped marker on the page may have its Resend outside it.
99
+ resendIndex: hasResendColumns
100
+ ? db.prepare(`SELECT id, role, source_message_id, resend_of_message_id ${base} ORDER BY timestamp ASC`)
101
+ : null,
102
+ };
103
+ };
104
+ const all = build('');
105
+ const shared = build(sharedFilter);
106
+ return {
107
+ selectConversationHistoryPage(conversationId, options = {}) {
108
+ return selectConversationHistoryPageFromStatements(options.sharedOnly ? shared : all, conversationId, options);
109
+ },
110
+ listConversationResendIndex(conversationId, { sharedOnly = false } = {}) {
111
+ const statement = (sharedOnly ? shared : all).resendIndex;
112
+ return statement ? statement.all({ conversationId }) : [];
113
+ },
114
+ };
115
+ }
116
+
117
+ // A cursor as the in-memory selector resolves it, in the SQL shape: the
118
+ // message's own timestamp when its id is in the conversation, else the given
119
+ // timestamp. Null when there is no cursor; `unsupported` when the given
120
+ // values cannot be compared in SQL the way the selector compares them.
121
+ function resolveCursor(statements, conversationId, { messageId = '', timestamp = '' } = {}) {
122
+ const cursorMessageId = String(messageId || '').trim();
123
+ const cursorTimestampText = String(timestamp || '').trim();
124
+ if (cursorMessageId) {
125
+ const row = statements.findById.get({ conversationId, id: cursorMessageId });
126
+ if (row) return { id: cursorMessageId, timestamp: String(row.timestamp) };
127
+ }
128
+ if (!cursorTimestampText) return null;
129
+ if (cursorMessageId && !ORDERABLE_ID_PATTERN.test(cursorMessageId)) return 'unsupported';
130
+ let canonical = cursorTimestampText;
131
+ if (!ISO_TIMESTAMP_PATTERN.test(canonical)) {
132
+ canonical = new Date(normalizeConversationTimestampMs(cursorTimestampText)).toISOString();
133
+ if (!ISO_TIMESTAMP_PATTERN.test(canonical)) return 'unsupported';
134
+ }
135
+ return { id: cursorMessageId || null, timestamp: canonical };
136
+ }
137
+
138
+ /**
139
+ * The page of rows (raw message rows, in conversation order) and its
140
+ * pageInfo, by the rules of selectConversationHistoryPage; null when the
141
+ * conversation or the cursor cannot be paged in SQL (see the top note).
142
+ */
143
+ function selectConversationHistoryPageFromStatements(statements, conversationId, {
144
+ limit = 20,
145
+ beforeMessageId = '',
146
+ beforeTimestamp = '',
147
+ afterMessageId = '',
148
+ afterTimestamp = '',
149
+ aroundMessageId = '',
150
+ } = {}) {
151
+ if (statements.irregular.get({ conversationId })?.irregular) return null;
152
+ const pageLimit = normalizeConversationHistoryLimit(limit);
153
+ const aroundId = String(aroundMessageId || '').trim();
154
+ const requestedAroundWindow = !!aroundId;
155
+ const requestedAfterWindow = !!String(afterMessageId || '').trim();
156
+ const beforeCursor = resolveCursor(statements, conversationId, { messageId: beforeMessageId, timestamp: beforeTimestamp });
157
+ const afterCursor = resolveCursor(statements, conversationId, { messageId: afterMessageId, timestamp: afterTimestamp });
158
+ if (beforeCursor === 'unsupported' || afterCursor === 'unsupported') return null;
159
+
160
+ let rows = [];
161
+ if (aroundId) {
162
+ const anchor = statements.findById.get({ conversationId, id: aroundId });
163
+ if (anchor) {
164
+ // Enough rows on both sides for the in-memory window rules to give the
165
+ // same window as over the whole conversation.
166
+ const before = statements.older
167
+ .all({ conversationId, timestamp: anchor.timestamp, id: anchor.id, limit: pageLimit - 1 })
168
+ .reverse();
169
+ const fromAnchor = statements.fromInclusive
170
+ .all({ conversationId, timestamp: anchor.timestamp, id: anchor.id, limit: pageLimit });
171
+ const local = [...before, ...fromAnchor];
172
+ const aroundIndex = before.length;
173
+ const halfWindow = Math.floor((pageLimit - 1) / 2);
174
+ let start = Math.max(0, aroundIndex - halfWindow);
175
+ const end = Math.min(local.length, start + pageLimit);
176
+ if ((end - start) < pageLimit) start = Math.max(0, end - pageLimit);
177
+ rows = local.slice(start, end);
178
+ }
179
+ }
180
+ if (!rows.length && !requestedAroundWindow && afterCursor) {
181
+ rows = statements.newer.all({ conversationId, ...afterCursor, limit: pageLimit });
182
+ }
183
+ if (!rows.length && !requestedAfterWindow && !requestedAroundWindow) {
184
+ rows = (beforeCursor
185
+ ? statements.older.all({ conversationId, ...beforeCursor, limit: pageLimit })
186
+ : statements.tail.all({ conversationId, limit: pageLimit })
187
+ ).reverse();
188
+ }
189
+
190
+ const first = rows[0] || null;
191
+ const last = rows[rows.length - 1] || null;
192
+ const hasMoreOlder = first
193
+ ? !!statements.existsOlder.get({ conversationId, timestamp: first.timestamp, id: first.id })?.found
194
+ : false;
195
+ const hasMoreNewer = last
196
+ ? !!statements.existsNewer.get({ conversationId, timestamp: last.timestamp, id: last.id })?.found
197
+ : false;
198
+ return { rows, pageInfo: buildConversationHistoryPageInfo(rows, { hasMoreOlder, hasMoreNewer }) };
199
+ }
@@ -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,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 {
@@ -2,7 +2,7 @@
2
2
 
3
3
  import net from 'net';
4
4
 
5
- // Liveness for preview cards. Previews never auto-close, so the card has to say
5
+ // Liveness for preview cards. Previews never expire, so the card has to say
6
6
  // whether the dev server behind it is still up — a TCP connect is enough and,
7
7
  // unlike an HTTP request, leaves nothing in the app's request log.
8
8
 
@@ -55,6 +55,10 @@ export function createPreviewHealthProbe({
55
55
  // A slow sweep must not overlap itself; with the registry capped at 8
56
56
  // previews and a 250ms connect budget, one pass is bounded at ~2s anyway.
57
57
  if (sweeping) return;
58
+ // Store writes that failed (a locked database) are retried on this beat,
59
+ // so the table catches up with memory without a timer of its own, and
60
+ // rows a restore could not judge get their check again.
61
+ try { registry.maintain?.(); } catch {}
58
62
  const previews = registry.list().filter((entry) => entry.mode !== 'static');
59
63
  if (previews.length === 0) return;
60
64
  sweeping = true;
@@ -70,6 +74,9 @@ export function createPreviewHealthProbe({
70
74
  timer = setIntervalImpl(() => { sweep().catch(() => {}); }, intervalMs);
71
75
  // An idle relay should not be held awake by a probe that has nothing to do.
72
76
  if (typeof timer?.unref === 'function') timer.unref();
77
+ // One pass right away: port previews restored at boot start without a
78
+ // health state, and their cards should not wait a full interval for one.
79
+ sweep().catch(() => {});
73
80
  }
74
81
 
75
82
  function stop() {
@@ -2,7 +2,7 @@
2
2
 
3
3
  import http from 'http';
4
4
 
5
- import { parsePreviewPath } from './preview-registry-service.mjs';
5
+ import { parsePreviewPath, previewLogId } from './preview-registry-service.mjs';
6
6
  import { serveStaticPreview } from './preview-static-handler.mjs';
7
7
 
8
8
  // The preview listener. A bare http.Server on its own loopback port whose
@@ -270,12 +270,16 @@ export function createPreviewProxyServer({
270
270
  socket.once('error', forget);
271
271
  }
272
272
 
273
- function logHit(entry, req) {
273
+ // The token is the capability and the request URL carries it, so the line
274
+ // names the preview by a hash and logs only the upstream path, without query.
275
+ function logHit(entry, req, upstreamPath) {
274
276
  const now = Date.now();
275
277
  const last = lastHitLogAt.get(entry.token) || 0;
276
278
  if (now - last < 60_000) return;
277
279
  lastHitLogAt.set(entry.token, now);
278
- logger?.log?.(`[preview] ${entry.token.slice(0, 8)} → :${entry.targetPort} ${req.method} ${req.url} from ${clientIp(req)}`);
280
+ const target = entry.mode === 'static' ? 'static' : `:${entry.targetPort}`;
281
+ const pathOnly = String(upstreamPath || '/').split('?')[0];
282
+ logger?.log?.(`[preview] ${previewLogId(entry.token)} → ${target} ${req.method} ${pathOnly} from ${clientIp(req)}`);
279
283
  }
280
284
 
281
285
  function resolveRequest(req) {
@@ -309,7 +313,7 @@ export function createPreviewProxyServer({
309
313
 
310
314
  const { entry, upstreamPath } = resolved;
311
315
  registry.recordHit(entry.token);
312
- logHit(entry, req);
316
+ logHit(entry, req, upstreamPath);
313
317
 
314
318
  // Static previews are answered in-process — no upstream, no forwarding.
315
319
  if (entry.mode === 'static') {