@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,424 @@
1
+ 'use strict';
2
+
3
+ // Path privacy for shared conversations: what a share link serves of the relay
4
+ // host's paths and file names. Pure functions; the share routes build one
5
+ // redactor per response and run it over the whole payload, so raw paths never
6
+ // leave the relay. The owner's own view never passes through here.
7
+ //
8
+ // Modes (shared/share-path-privacy.mjs): `show` leaves everything, `filename`
9
+ // drops the directories and keeps the file name, `hide` puts a placeholder in
10
+ // place of path and name.
11
+ //
12
+ // Two tiers:
13
+ // - Reliable: fields that are a path by their key (`path`, `cwd`, `file_path`,
14
+ // `…Path`, `…Root`…), attachment names, `@file:`/`@folder:` mentions (also
15
+ // relative ones), diff and patch file headers, and the input of a tool call
16
+ // stored as JSON. The whole value is a path, whatever it looks like.
17
+ // - Best effort: every other string (replies, thoughts, tool lines, code
18
+ // blocks, quoted tool output). In one pass, leftmost match first:
19
+ // 1. http(s)/ws(s) URLs and the relay's own `/api/…` and `/shared/…` URLs
20
+ // are kept as they are (the query of an `/api/` URL is filtered);
21
+ // 2. file:// URLs;
22
+ // 3. the known roots (conversation cwd, workspace roots, home, the relay's
23
+ // data and upload dirs) with whatever path follows them: these also
24
+ // match with spaces in the root, any case and either separator on
25
+ // Windows;
26
+ // 4. absolute Windows paths (C:\…, C:/…, \\server\share\…), `~/…` and
27
+ // multi-segment Unix paths (/a/b…);
28
+ // 5. the OS user name as a segment between separators.
29
+ // Relative paths are left alone, and so are single-segment ones (/tmp).
30
+ // Markdown and code fences keep their structure: a path never swallows
31
+ // brackets, parentheses, backticks, quotes, `*` or `|`.
32
+ //
33
+ // Preview (`annotate: true`): every replacement is wrapped in
34
+ // SHARE_PREVIEW_MARKS.redactedStart/End, and whatever still looks like a path
35
+ // afterwards (a relative path with a file extension, a `home/x` or
36
+ // `Users/x` segment, the user name as a word, and in `show` mode every
37
+ // absolute path) in suspectStart/End. The original is never sent. The marks
38
+ // are private-use characters; the browser turns them into highlights
39
+ // (public/app/share-preview-marks.mjs). Mark characters already in the input
40
+ // are dropped first, so they only ever mean what this module put there.
41
+
42
+ import {
43
+ DEFAULT_SHARE_PATH_PRIVACY_MODE,
44
+ SHARE_PATH_PRIVACY_MODES,
45
+ } from '../../shared/share-path-privacy.mjs';
46
+
47
+ export const SHARE_PATH_PLACEHOLDER = '‹hidden›';
48
+
49
+ export const SHARE_PREVIEW_MARKS = Object.freeze({
50
+ redactedStart: '\uE000',
51
+ redactedEnd: '\uE001',
52
+ suspectStart: '\uE002',
53
+ suspectEnd: '\uE003',
54
+ });
55
+
56
+ const MARK_CHARS_RE = /[\uE000-\uE003]/g;
57
+ const MARKED_REGION_SPLIT_RE = /(\uE000[^\uE001]*\uE001)/;
58
+
59
+ // What ends a path in free text: whitespace, separators, quotes, brackets and
60
+ // the markdown characters that may stand right after one.
61
+ const STOP_COMMON = '\\s\\\\/<>"\'`()\\[\\]{}|*,;\\uE000-\\uE003';
62
+ // A Unix segment may hold `:` (a line number, a route parameter).
63
+ const SEG_U = `[^${STOP_COMMON}]`;
64
+ // A Windows segment never holds `:` or `?`.
65
+ const SEG_W = `[^${STOP_COMMON}:?]`;
66
+ const NOT_AFTER = '(?<![\\w.\\-~/\\\\$%])';
67
+ const URL_TAIL = '[^\\s<>"\'`]+';
68
+ const RELAY_URL_TAIL = '[^\\s<>"\'`()\\[\\]{}|]*';
69
+
70
+ // Paths that name nothing of the host.
71
+ const NEUTRAL_PATHS = new Set(['/dev/null', '/dev/stdin', '/dev/stdout', '/dev/stderr']);
72
+ const TRAILING_PUNCTUATION_RE = /[.:!?]+$/;
73
+ const MIN_USER_NAME_LENGTH = 2;
74
+ const MIN_SUSPECT_USER_NAME_LENGTH = 3;
75
+ const MAX_WALK_DEPTH = 64;
76
+ const MAX_JSON_TEXT_CHARS = 256 * 1024;
77
+
78
+ // Keys whose value is a path, whatever it looks like.
79
+ const PATH_KEY_RE = /^(?:path|paths|cwd|dir|directory|folder|reference|file_path|notebook_path|filepath|filename)$|(?:Path|Paths|Root|Roots|Cwd|Dir|Directory)$/;
80
+ // Keys that never hold a path: ids, hashes, times.
81
+ const SKIP_KEY_RE = /^(?:id|sha256|token|timestamp|seq)$|(?:Id|Ids|At)$/;
82
+ // An attachment's own name.
83
+ const ATTACHMENT_NAME_KEY_RE = /^(?:name|fileName|file_name|originalName)$/;
84
+
85
+ function escapeRegExp(value) {
86
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
87
+ }
88
+
89
+ function caseless(value) {
90
+ return [...String(value)]
91
+ .map((ch) => (/[a-z]/i.test(ch) ? `[${ch.toLowerCase()}${ch.toUpperCase()}]` : escapeRegExp(ch)))
92
+ .join('');
93
+ }
94
+
95
+ function isWindowsStyle(value) {
96
+ return /^[A-Za-z]:/.test(value) || value.includes('\\');
97
+ }
98
+
99
+ /** The roots as they can be matched: absolute, without trailing separators, longest first. */
100
+ export function normalizeKnownRoots(roots = []) {
101
+ const seen = new Set();
102
+ const out = [];
103
+ for (const raw of Array.isArray(roots) ? roots : [roots]) {
104
+ let value = String(raw ?? '').trim();
105
+ if (!value) continue;
106
+ const windows = isWindowsStyle(value);
107
+ value = value.replace(/[\\/]+$/, '');
108
+ const absolute = windows ? /^(?:[A-Za-z]:[\\/]|\\\\)/.test(`${value}\\`) : value.startsWith('/');
109
+ if (!absolute) continue;
110
+ // A drive or the file system root alone says nothing and would match everywhere.
111
+ const segments = value.split(/[\\/]+/).filter((part) => part && !/^[A-Za-z]:$/.test(part));
112
+ if (!segments.length) continue;
113
+ const key = windows ? value.replace(/\//g, '\\').toLowerCase() : value;
114
+ if (seen.has(key)) continue;
115
+ seen.add(key);
116
+ out.push(value);
117
+ }
118
+ return out.sort((a, b) => b.length - a.length);
119
+ }
120
+
121
+ // A separator is one character, or two for a backslash escaped in JSON: an
122
+ // unbounded run would make every position of a run of slashes a new attempt
123
+ // over the whole run.
124
+ function rootSource(root) {
125
+ const windows = isWindowsStyle(root);
126
+ const parts = root.split(/[\\/]+/);
127
+ const sep = windows ? '(?:\\\\{1,2}|/)' : '/';
128
+ return parts.map((part) => (windows ? caseless(part) : escapeRegExp(part))).join(sep);
129
+ }
130
+
131
+ function normalizeUserNames(userNames = []) {
132
+ const seen = new Set();
133
+ const out = [];
134
+ for (const raw of Array.isArray(userNames) ? userNames : [userNames]) {
135
+ const value = String(raw ?? '').trim();
136
+ if (value.length < MIN_USER_NAME_LENGTH || /[\s\\/]/.test(value)) continue;
137
+ const key = value.toLowerCase();
138
+ if (seen.has(key)) continue;
139
+ seen.add(key);
140
+ out.push(value);
141
+ }
142
+ return out.sort((a, b) => b.length - a.length);
143
+ }
144
+
145
+ function normalizeRelayPathPrefix(value) {
146
+ const prefix = String(value || '').trim().replace(/\/+$/, '');
147
+ if (!prefix || prefix === '/') return '';
148
+ return prefix.startsWith('/') ? prefix : `/${prefix}`;
149
+ }
150
+
151
+ function normalizeMode(mode) {
152
+ const value = String(mode || '').trim().toLowerCase();
153
+ return SHARE_PATH_PRIVACY_MODES.includes(value) ? value : DEFAULT_SHARE_PATH_PRIVACY_MODE;
154
+ }
155
+
156
+ /** The last segment of a path; '' for a root, a drive or a bare `~`. */
157
+ export function pathBaseName(value) {
158
+ const trimmed = String(value || '').replace(/[\\/]+$/, '');
159
+ const parts = trimmed.split(/[\\/]+/);
160
+ const last = parts[parts.length - 1] || '';
161
+ if (/^[A-Za-z]:$/.test(last) || last === '~') return '';
162
+ return last;
163
+ }
164
+
165
+ /**
166
+ * A redactor for one response. `roots` are the known absolute roots, `userNames`
167
+ * the OS user name(s), `relayPathPrefix` the relay's mount path (its URLs are
168
+ * kept). Returns `{ mode, text, path, name, payload }`.
169
+ */
170
+ export function createSharePathRedactor({
171
+ mode = DEFAULT_SHARE_PATH_PRIVACY_MODE,
172
+ roots = [],
173
+ userNames = [],
174
+ relayPathPrefix = '',
175
+ annotate = false,
176
+ } = {}) {
177
+ const resolvedMode = normalizeMode(mode);
178
+ const knownRoots = normalizeKnownRoots(roots);
179
+ const names = normalizeUserNames(userNames);
180
+ const namesLower = new Set(names.map((name) => name.toLowerCase()));
181
+ const prefix = normalizeRelayPathPrefix(relayPathPrefix);
182
+ const prefixSource = prefix ? `(?:${escapeRegExp(prefix)})?` : '';
183
+ const marks = SHARE_PREVIEW_MARKS;
184
+
185
+ const httpUrl = `\\b(?:https?|wss?):\\/\\/${URL_TAIL}`;
186
+ const relayUrl = `${NOT_AFTER}${prefixSource}\\/(?:api|shared)\\/${RELAY_URL_TAIL}`;
187
+ const mention = `@(?:file|folder):[^\\s\`<>()\\[\\]"']+`;
188
+ const fileUrl = `\\bfile:\\/\\/[^\\s<>"'\`()\\[\\]{}|]+`;
189
+ const known = knownRoots.length
190
+ ? `(?<![\\w.\\-~])(?:${knownRoots.map(rootSource).join('|')})(?!${SEG_U})(?:[\\\\/]+${SEG_U}+)*[\\\\/]*`
191
+ : null;
192
+ const win = `(?<![\\w.\\-])[A-Za-z]:[\\\\/]+(?:${SEG_W}+[\\\\/]+)*${SEG_W}*`;
193
+ const unc = `(?<![\\w\\\\/])\\\\\\\\${SEG_W}+(?:\\\\+${SEG_W}+)+`;
194
+ const tilde = `(?<![\\w.\\-~/\\\\])~[\\w.\\-]*[\\\\/]+(?:${SEG_U}+[\\\\/]+)*${SEG_U}*`;
195
+ const unix = `${NOT_AFTER}\\/(?:${SEG_U}+\\/+)+${SEG_U}*`;
196
+ const userSegment = names.length
197
+ ? `(?<=[\\\\/])(?:${names.map(caseless).join('|')})(?=[\\\\/])`
198
+ : null;
199
+
200
+ const alternatives = [
201
+ ['http', httpUrl],
202
+ ['relay', relayUrl],
203
+ ['mention', mention],
204
+ ['fileUrl', fileUrl],
205
+ ...(known ? [['known', known]] : []),
206
+ ['win', win],
207
+ ['unc', unc],
208
+ ['tilde', tilde],
209
+ ['unix', unix],
210
+ ...(userSegment ? [['user', userSegment]] : []),
211
+ ];
212
+ const groupNames = alternatives.map(([name]) => name);
213
+ const freeTextSource = alternatives.map(([, source]) => `(${source})`).join('|');
214
+
215
+ // Diff and patch headers name a file whatever the path looks like.
216
+ const DIFF_GIT_RE = /^(diff --git )a\/(\S+) b\/(\S+)$/gm;
217
+ const DIFF_SIDE_RE = /^((?:---|\+\+\+) )((?:a\/|b\/)?)(\S*[\\/.]\S*)$/gm;
218
+ const PATCH_FILE_RE = /^(\*\*\* (?:Add|Update|Delete) File: |\*\*\* Move to: |Index: )(\S.*)$/gm;
219
+
220
+ const suspectNames = names.filter((entry) => entry.length >= MIN_SUSPECT_USER_NAME_LENGTH);
221
+ const suspectAlternatives = [
222
+ ['http', httpUrl],
223
+ ['relay', relayUrl],
224
+ ['absolute', [fileUrl, ...(known ? [known] : []), win, unc, tilde, unix].join('|')],
225
+ ['relative', '(?<![\\w.\\-/\\\\])[\\w.\\-]+(?:[\\\\/][\\w.\\-]+)+'],
226
+ ...(suspectNames.length ? [['user', `(?<![\\w.\\-])(?:${suspectNames.map(caseless).join('|')})(?![\\w\\-])`]] : []),
227
+ ];
228
+ const suspectKinds = suspectAlternatives.map(([kind]) => kind);
229
+ const suspectSource = suspectAlternatives.map(([, source]) => `(${source})`).join('|');
230
+
231
+ const markRedacted = (original, replacement) => {
232
+ if (!annotate || replacement === original) return replacement;
233
+ return `${marks.redactedStart}${replacement}${marks.redactedEnd}`;
234
+ };
235
+
236
+ const isUserName = (value) => namesLower.has(String(value || '').toLowerCase());
237
+
238
+ // One path as the mode shows it, marked in a preview when it changed.
239
+ // `strict` (a structured field): even a bare name is hidden in `hide` mode.
240
+ function renderPath(raw, { strict = false } = {}) {
241
+ const value = String(raw ?? '');
242
+ if (!value || resolvedMode === 'show') return value;
243
+ if (NEUTRAL_PATHS.has(value)) return value;
244
+ const base = pathBaseName(value);
245
+ if (!base) return strict && resolvedMode === 'hide' ? markRedacted(value, SHARE_PATH_PLACEHOLDER) : value;
246
+ if (resolvedMode === 'hide' || isUserName(base)) return markRedacted(value, SHARE_PATH_PLACEHOLDER);
247
+ return markRedacted(value, base);
248
+ }
249
+
250
+ // Trailing sentence punctuation stays outside the path.
251
+ function renderTrimmed(match, options) {
252
+ const tail = match.match(TRAILING_PUNCTUATION_RE)?.[0] || '';
253
+ const core = tail ? match.slice(0, -tail.length) : match;
254
+ if (!core) return match;
255
+ return `${renderPath(core, options)}${tail}`;
256
+ }
257
+
258
+ function renderFileUrl(match) {
259
+ const tail = match.match(TRAILING_PUNCTUATION_RE)?.[0] || '';
260
+ const core = tail ? match.slice(0, -tail.length) : match;
261
+ let target = core.replace(/^file:\/\//i, '');
262
+ try { target = decodeURIComponent(target); } catch {}
263
+ const base = pathBaseName(target);
264
+ let replacement;
265
+ if (resolvedMode === 'hide' || !base || isUserName(base)) replacement = SHARE_PATH_PLACEHOLDER;
266
+ else replacement = base;
267
+ return `${markRedacted(core, replacement)}${tail}`;
268
+ }
269
+
270
+ function renderMention(match) {
271
+ const colon = match.indexOf(':');
272
+ const head = match.slice(0, colon + 1);
273
+ const rest = match.slice(colon + 1);
274
+ const tail = rest.match(TRAILING_PUNCTUATION_RE)?.[0] || '';
275
+ const core = tail ? rest.slice(0, -tail.length) : rest;
276
+ if (!core) return match;
277
+ return `${head}${renderPath(core, { strict: true })}${tail}`;
278
+ }
279
+
280
+ // The path of a relay URL is the relay's; its query can carry a host path.
281
+ function renderRelayUrl(match) {
282
+ const query = match.indexOf('?');
283
+ if (query === -1) return match;
284
+ return `${match.slice(0, query + 1)}${redactFreeText(match.slice(query + 1))}`;
285
+ }
286
+
287
+ function redactFreeText(text) {
288
+ const re = new RegExp(freeTextSource, 'g');
289
+ return text.replace(re, (match, ...groups) => {
290
+ const index = groups.findIndex((group, position) => position < groupNames.length && group !== undefined);
291
+ const kind = groupNames[index];
292
+ switch (kind) {
293
+ case 'http': return match;
294
+ case 'relay': return renderRelayUrl(match);
295
+ case 'mention': return renderMention(match);
296
+ case 'fileUrl': return renderFileUrl(match);
297
+ case 'user': return markRedacted(match, SHARE_PATH_PLACEHOLDER);
298
+ default: return renderTrimmed(match);
299
+ }
300
+ });
301
+ }
302
+
303
+ function redactHeaders(text) {
304
+ if (!/^(?:diff --git |--- |\+\+\+ |\*\*\* |Index: )/m.test(text)) return text;
305
+ return text
306
+ .replace(DIFF_GIT_RE, (_match, head, left, right) => `${head}a/${renderPath(left, { strict: true })} b/${renderPath(right, { strict: true })}`)
307
+ .replace(DIFF_SIDE_RE, (_match, head, side, target) => `${head}${side}${renderPath(target, { strict: true })}`)
308
+ .replace(PATCH_FILE_RE, (_match, head, target) => `${head}${renderPath(target.trim(), { strict: true })}`);
309
+ }
310
+
311
+ function flagSuspects(text) {
312
+ const re = new RegExp(suspectSource, 'g');
313
+ const flag = (match) => {
314
+ const core = match.replace(TRAILING_PUNCTUATION_RE, '');
315
+ if (!core) return match;
316
+ return `${marks.suspectStart}${core}${marks.suspectEnd}${match.slice(core.length)}`;
317
+ };
318
+ const flagPart = (part) => part.replace(re, (match, ...groups) => {
319
+ const index = groups.findIndex((group, position) => position < suspectKinds.length && group !== undefined);
320
+ switch (suspectKinds[index]) {
321
+ case 'relay': {
322
+ const query = match.indexOf('?');
323
+ return query === -1 ? match : `${match.slice(0, query + 1)}${flagPart(match.slice(query + 1))}`;
324
+ }
325
+ case 'absolute':
326
+ return NEUTRAL_PATHS.has(match.replace(TRAILING_PUNCTUATION_RE, '')) ? match : flag(match);
327
+ case 'relative': {
328
+ const core = match.replace(TRAILING_PUNCTUATION_RE, '');
329
+ return /\.[A-Za-z0-9]{1,10}$/.test(core) || /(?:^|[\\/])(?:home|users)[\\/]/i.test(core) ? flag(match) : match;
330
+ }
331
+ case 'user': return flag(match);
332
+ default: return match;
333
+ }
334
+ });
335
+ return text
336
+ .split(MARKED_REGION_SPLIT_RE)
337
+ .map((part, index) => (index % 2 === 1 || !part ? part : flagPart(part)))
338
+ .join('');
339
+ }
340
+
341
+ /** Free text: replies, thoughts, tool lines, code blocks. */
342
+ function text(value) {
343
+ if (typeof value !== 'string' || !value) return value;
344
+ let out = annotate ? value.replace(MARK_CHARS_RE, '') : value;
345
+ if (resolvedMode !== 'show') out = redactFreeText(redactHeaders(out));
346
+ if (annotate) out = flagSuspects(out);
347
+ return out;
348
+ }
349
+
350
+ /** A field that is a path by its key: the whole value is one. */
351
+ function path(value) {
352
+ if (typeof value !== 'string' || !value) return value;
353
+ const clean = annotate ? value.replace(MARK_CHARS_RE, '') : value;
354
+ const trimmed = clean.trim();
355
+ if (!trimmed) return clean;
356
+ if (/^(?:https?|wss?):\/\//i.test(trimmed)) return text(clean);
357
+ if (/^file:\/\//i.test(trimmed)) return resolvedMode === 'show' ? text(clean) : renderFileUrl(trimmed);
358
+ if (trimmed.startsWith('@') && !/^@(?:file|folder):/i.test(trimmed)) {
359
+ const rendered = `@${renderPath(trimmed.slice(1), { strict: true })}`;
360
+ return resolvedMode === 'show' ? text(clean) : rendered;
361
+ }
362
+ if (/^@(?:file|folder):/i.test(trimmed)) return resolvedMode === 'show' ? text(clean) : renderMention(trimmed);
363
+ if (resolvedMode === 'show') return text(clean);
364
+ return renderPath(trimmed, { strict: true });
365
+ }
366
+
367
+ /** An attachment's file name: hidden only in `hide` mode. */
368
+ function name(value) {
369
+ if (typeof value !== 'string' || !value) return value;
370
+ const clean = annotate ? value.replace(MARK_CHARS_RE, '') : value;
371
+ if (resolvedMode === 'hide') return markRedacted(clean, SHARE_PATH_PLACEHOLDER);
372
+ return text(clean);
373
+ }
374
+
375
+ // A tool call's input stored as JSON: its path fields are known by key.
376
+ // Unchanged input keeps its own formatting.
377
+ function jsonText(value, depth) {
378
+ if (resolvedMode === 'show') return null;
379
+ const trimmed = value.trim();
380
+ if (trimmed.length > MAX_JSON_TEXT_CHARS || !trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
381
+ let parsed;
382
+ try { parsed = JSON.parse(trimmed); } catch { return null; }
383
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
384
+ const walked = walk(parsed, depth + 1, {});
385
+ if (JSON.stringify(walked) === JSON.stringify(parsed)) return annotate ? value.replace(MARK_CHARS_RE, '') : value;
386
+ return JSON.stringify(walked, null, trimmed.includes('\n') ? 2 : undefined);
387
+ }
388
+
389
+ function walk(node, depth, { key = '', inAttachments = false } = {}) {
390
+ if (depth > MAX_WALK_DEPTH) return node;
391
+ if (typeof node === 'string') {
392
+ if (!node) return node;
393
+ if (key && SKIP_KEY_RE.test(key)) return node;
394
+ if (key && PATH_KEY_RE.test(key)) return path(node);
395
+ if (inAttachments && ATTACHMENT_NAME_KEY_RE.test(key)) return name(node);
396
+ return jsonText(node, depth) ?? text(node);
397
+ }
398
+ if (!node || typeof node !== 'object') return node;
399
+ if (Array.isArray(node)) {
400
+ const pathList = key && PATH_KEY_RE.test(key);
401
+ return node.map((item) => (pathList && typeof item === 'string'
402
+ ? path(item)
403
+ : walk(item, depth + 1, { key: pathList ? '' : key, inAttachments })));
404
+ }
405
+ if (typeof node.toJSON === 'function') return walk(node.toJSON(), depth + 1, { key, inAttachments });
406
+ const out = {};
407
+ for (const [childKey, item] of Object.entries(node)) {
408
+ const attachmentList = childKey === 'attachments' || childKey === 'draftAttachments';
409
+ out[childKey] = walk(item, depth + 1, {
410
+ key: childKey,
411
+ inAttachments: attachmentList || (inAttachments && !Array.isArray(item) && typeof item !== 'object'),
412
+ });
413
+ }
414
+ return out;
415
+ }
416
+
417
+ /** Every string of a JSON-like value, by the rules above. Returns a copy. */
418
+ function payload(value) {
419
+ if (resolvedMode === 'show' && !annotate) return value;
420
+ return walk(value, 0, {});
421
+ }
422
+
423
+ return { mode: resolvedMode, text, path, name, payload };
424
+ }
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ // One STALE ATTEMPT warning per superseded attempt, not one per write. A
4
+ // worker whose turn was requeued under it keeps streaming for the old
5
+ // attempt until it learns of the refusal, several writes a second (live:
6
+ // 696 lines in four minutes after one recovery). The first refusal of an
7
+ // attempt is logged in full; the rest are counted and summarized in one
8
+ // line once the attempt has been quiet for `quietMs`.
9
+
10
+ export const STALE_ATTEMPT_SUMMARY_QUIET_MS = 60_000;
11
+ const MAX_TRACKED_ATTEMPTS = 500;
12
+
13
+ function short(value) {
14
+ return String(value || '').trim().slice(0, 8) || 'none';
15
+ }
16
+
17
+ /**
18
+ * `warn(line)` writes one line (without timestamp). `now`, `setTimer` and
19
+ * `clearTimer` are injectable for tests.
20
+ */
21
+ export function createStaleAttemptLogLimiter({
22
+ warn = (line) => console.warn(line),
23
+ now = () => Date.now(),
24
+ setTimer = (fn, ms) => {
25
+ const timer = setTimeout(fn, ms);
26
+ timer.unref?.();
27
+ return timer;
28
+ },
29
+ clearTimer = (timer) => clearTimeout(timer),
30
+ quietMs = STALE_ATTEMPT_SUMMARY_QUIET_MS,
31
+ maxTracked = MAX_TRACKED_ATTEMPTS,
32
+ } = {}) {
33
+ // `${messageId}:${attemptId}` → { messageId, attemptId, count, firstAt, lastAt, routes, current, status, timer }
34
+ const entries = new Map();
35
+
36
+ function summarize(key) {
37
+ const entry = entries.get(key);
38
+ if (!entry) return;
39
+ entries.delete(key);
40
+ if (entry.timer) clearTimer(entry.timer);
41
+ const more = entry.count - 1;
42
+ if (more <= 0) return;
43
+ const seconds = Math.max(1, Math.round((entry.lastAt - entry.firstAt) / 1000));
44
+ warn(`STALE ATTEMPT ${short(entry.messageId)} attempt=${short(entry.attemptId)}: ${more} more write${more === 1 ? '' : 's'} refused over ${seconds}s (routes ${[...entry.routes].join(',')}; current=${short(entry.current)} status=${entry.status || 'unknown'})`);
45
+ }
46
+
47
+ function arm(key, entry) {
48
+ if (entry.timer) clearTimer(entry.timer);
49
+ entry.timer = setTimer(() => {
50
+ entry.timer = null;
51
+ summarize(key);
52
+ }, quietMs);
53
+ }
54
+
55
+ /**
56
+ * A write of `attemptId` for `messageId` was refused because the row's
57
+ * attempt is `currentAttemptId`. Returns `{ logged, count }`.
58
+ */
59
+ function note({ messageId, attemptId, currentAttemptId = '', route = 'unknown', status = '' } = {}) {
60
+ const key = `${String(messageId || '')}:${String(attemptId || '')}`;
61
+ const at = now();
62
+ const existing = entries.get(key);
63
+ if (existing) {
64
+ existing.count += 1;
65
+ existing.lastAt = at;
66
+ existing.routes.add(String(route || 'unknown'));
67
+ existing.current = currentAttemptId;
68
+ existing.status = String(status || existing.status || '');
69
+ arm(key, existing);
70
+ return { logged: false, count: existing.count };
71
+ }
72
+ if (entries.size >= maxTracked) summarize(entries.keys().next().value);
73
+ const entry = {
74
+ messageId,
75
+ attemptId,
76
+ count: 1,
77
+ firstAt: at,
78
+ lastAt: at,
79
+ routes: new Set([String(route || 'unknown')]),
80
+ current: currentAttemptId,
81
+ status: String(status || ''),
82
+ timer: null,
83
+ };
84
+ entries.set(key, entry);
85
+ arm(key, entry);
86
+ warn(`STALE ATTEMPT ${short(messageId)} route=${route} claimed=${short(attemptId)} current=${short(currentAttemptId)} status=${status || 'unknown'} (further writes of this attempt are counted and summarized once it goes quiet)`);
87
+ return { logged: true, count: 1 };
88
+ }
89
+
90
+ /** Writes every pending summary now (shutdown, tests). */
91
+ function flush() {
92
+ for (const key of [...entries.keys()]) summarize(key);
93
+ }
94
+
95
+ return { note, flush, size: () => entries.size };
96
+ }
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ // Stale recovery after a wall-clock jump. The stale window is measured from
4
+ // the worker's last heartbeat: after the host slept for hours, every
5
+ // heartbeat is hours old the moment the relay's timer fires again, and the
6
+ // recovery requeued live turns (live: three messages recovered after a 6 h
7
+ // sleep, delivered a second time, and the live worker's replies refused as
8
+ // stale attempts, 696 warnings in 4 minutes).
9
+ //
10
+ // The guard notices the jump itself: the gap between two ticks of the
11
+ // recovery timer far larger than its interval (a sleep, a stopped VM, a
12
+ // blocked event loop, which stalls heartbeats just the same). After one, no
13
+ // recovery runs for a grace period, long enough for a live worker to
14
+ // heartbeat again; a dead worker's rows are recovered after it.
15
+
16
+ export const CLOCK_JUMP_FACTOR = 4;
17
+
18
+ /**
19
+ * `intervalMs` is the recovery timer's interval; a gap above
20
+ * `jumpThresholdMs` (default: four intervals) is a jump; `graceMs` is how
21
+ * long recovery then waits. `now()` is injectable for tests.
22
+ */
23
+ export function createStaleRecoveryClockGuard({
24
+ now = () => Date.now(),
25
+ intervalMs,
26
+ jumpThresholdMs = null,
27
+ graceMs,
28
+ log = () => {},
29
+ } = {}) {
30
+ const interval = Math.max(1, Number(intervalMs) || 1);
31
+ const threshold = Number(jumpThresholdMs) > 0 ? Number(jumpThresholdMs) : interval * CLOCK_JUMP_FACTOR;
32
+ const grace = Math.max(0, Number(graceMs) || 0);
33
+ let lastTickMs = null;
34
+ let graceUntilMs = 0;
35
+ let inGrace = false;
36
+
37
+ /**
38
+ * One tick of the recovery timer. `{ skip, jumped, gapMs,
39
+ * graceRemainingMs }`: `skip` says recovery must not run now.
40
+ */
41
+ function tick() {
42
+ const at = now();
43
+ const gapMs = lastTickMs === null ? 0 : Math.max(0, at - lastTickMs);
44
+ lastTickMs = at;
45
+ const jumped = gapMs > threshold;
46
+ if (jumped) {
47
+ graceUntilMs = Math.max(graceUntilMs, at + grace);
48
+ log(`Clock jump: ${Math.round(gapMs / 1000)}s passed between stale-recovery ticks (interval ${Math.round(interval / 1000)}s); stale recovery paused for ${Math.round(grace / 1000)}s so live workers can report`);
49
+ }
50
+ const skip = at < graceUntilMs;
51
+ if (inGrace && !skip) log('Stale recovery resumed after the clock jump');
52
+ inGrace = skip;
53
+ return { skip, jumped, gapMs, graceRemainingMs: Math.max(0, graceUntilMs - at) };
54
+ }
55
+
56
+ return { tick, thresholdMs: threshold, graceMs: grace };
57
+ }
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ // Sub-agent runs left `running` for good. A run's end is written by its
4
+ // worker (or by the worker-death and terminal-turn reconciliations); a run
5
+ // whose worker is gone without either leaves its row `running` forever, and
6
+ // such rows count as running work everywhere: the activity tracker, stalled
7
+ // and all_idle for a subscription to all sessions, the suspend-when-idle
8
+ // watchdog (live: rows two months old still `running`).
9
+ //
10
+ // The sweep closes them: a `running` row older than `minAgeMs` whose
11
+ // conversation has no live worker becomes `cancelled`. The age bound keeps a
12
+ // run that just started, with its worker still registering, out of it; the
13
+ // liveness check keeps a long background agent of a live worker in. It runs
14
+ // shortly after boot (the workers of the previous runtime have reconnected
15
+ // by then) and periodically.
16
+
17
+ export const STALE_SUBAGENT_RUN_MIN_AGE_MS = 10 * 60_000;
18
+ export const STALE_SUBAGENT_RUN_SWEEP_INTERVAL_MS = 10 * 60_000;
19
+ export const STALE_SUBAGENT_RUN_BOOT_DELAY_MS = 60_000;
20
+
21
+ function toText(value) {
22
+ return String(value ?? '').trim();
23
+ }
24
+
25
+ /**
26
+ * `isConversationWorkerAlive(conversationId)` says whether a live worker owns
27
+ * the conversation. `now()` is injectable for tests.
28
+ */
29
+ export function createStaleSubagentRunSweep({
30
+ db,
31
+ isConversationWorkerAlive,
32
+ now = () => Date.now(),
33
+ minAgeMs = STALE_SUBAGENT_RUN_MIN_AGE_MS,
34
+ log = (line) => console.log(line),
35
+ warn = (line) => console.warn(line),
36
+ } = {}) {
37
+ if (!db) throw new Error('createStaleSubagentRunSweep requires db');
38
+ if (typeof isConversationWorkerAlive !== 'function') throw new Error('createStaleSubagentRunSweep requires isConversationWorkerAlive');
39
+
40
+ let statements = null;
41
+ function prepare() {
42
+ if (statements) return statements;
43
+ try {
44
+ statements = {
45
+ listOld: db.prepare(`
46
+ SELECT id, conversation_id, started_at, updated_at
47
+ FROM subagent_runs
48
+ WHERE status = 'running' AND COALESCE(updated_at, started_at) < ?
49
+ ORDER BY conversation_id, started_at
50
+ `),
51
+ close: db.prepare(`
52
+ UPDATE subagent_runs
53
+ SET status = 'cancelled', updated_at = ?, completed_at = COALESCE(completed_at, ?)
54
+ WHERE id = ? AND status = 'running'
55
+ `),
56
+ };
57
+ } catch {
58
+ statements = null;
59
+ }
60
+ return statements;
61
+ }
62
+
63
+ /** One pass. Returns `{ checked, closed, conversations }`. */
64
+ function sweep() {
65
+ const prepared = prepare();
66
+ if (!prepared) return { checked: 0, closed: 0, conversations: 0, skipped: 'unsupported' };
67
+ const at = now();
68
+ const cutoff = new Date(at - minAgeMs).toISOString();
69
+ let rows = [];
70
+ try {
71
+ rows = prepared.listOld.all(cutoff);
72
+ } catch (error) {
73
+ warn(`STALE SUBAGENT RUNS sweep failed: ${error?.message || error}`);
74
+ return { checked: 0, closed: 0, conversations: 0, error: String(error?.message || error) };
75
+ }
76
+ if (!rows.length) return { checked: 0, closed: 0, conversations: 0 };
77
+ const aliveByConversation = new Map();
78
+ const alive = (conversationId) => {
79
+ if (!aliveByConversation.has(conversationId)) {
80
+ let live = false;
81
+ try { live = isConversationWorkerAlive(conversationId) === true; } catch { live = false; }
82
+ aliveByConversation.set(conversationId, live);
83
+ }
84
+ return aliveByConversation.get(conversationId);
85
+ };
86
+ const nowIso = new Date(at).toISOString();
87
+ const closedConversations = new Set();
88
+ let closed = 0;
89
+ let oldest = null;
90
+ db.transaction(() => {
91
+ for (const row of rows) {
92
+ const conversationId = toText(row.conversation_id);
93
+ if (alive(conversationId)) continue;
94
+ if (prepared.close.run(nowIso, nowIso, row.id).changes > 0) {
95
+ closed += 1;
96
+ closedConversations.add(conversationId);
97
+ const since = toText(row.updated_at) || toText(row.started_at);
98
+ if (since && (!oldest || since < oldest)) oldest = since;
99
+ }
100
+ }
101
+ })();
102
+ if (closed) {
103
+ log(`STALE SUBAGENT RUNS closed ${closed} run${closed === 1 ? '' : 's'} still marked running in ${closedConversations.size} conversation${closedConversations.size === 1 ? '' : 's'} without a live worker (oldest ${oldest})`);
104
+ }
105
+ return { checked: rows.length, closed, conversations: closedConversations.size };
106
+ }
107
+
108
+ return { sweep };
109
+ }