@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
@@ -0,0 +1,234 @@
1
+ // Browser copy of the parts of shared/task-output-text.mjs the task console
2
+ // needs: the incremental line folder, the SGR colour segments (with the
3
+ // control stripping they use) and the stall minutes. The browser cannot
4
+ // import shared/ (only server/public is served), so these are hand-kept
5
+ // copies; task-output-text.test.mjs pins them to the original. Scrubbing and
6
+ // runner parsing stay on the relay: what reaches the page is already clean.
7
+
8
+ export const TASK_OUTPUT_LINE_CHARS_MAX = 4000;
9
+
10
+ function toMillis(value) {
11
+ if (value == null || value === '') return NaN;
12
+ const number = Number(value);
13
+ if (Number.isFinite(number)) return number;
14
+ return Date.parse(String(value));
15
+ }
16
+
17
+ /** Whole minutes since the last output, or null when unknown. */
18
+ export function stallMinutes(lastOutputAt, now = Date.now()) {
19
+ const last = toMillis(lastOutputAt);
20
+ const current = toMillis(now);
21
+ if (!Number.isFinite(last) || !Number.isFinite(current) || last <= 0) return null;
22
+ return Math.max(0, Math.floor((current - last) / 60_000));
23
+ }
24
+
25
+ // SGR (colours and text style) is the only escape kept. Its parameters are
26
+ // digits and semicolons; the colon form is rare and goes with the rest.
27
+ const SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
28
+ // OSC (titles, hyperlinks, clipboard): ESC ] … BEL | ST, stripped whole. An
29
+ // unterminated one at the end is stripped to the end.
30
+ const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g;
31
+ // Every CSI (cursor moves, erases, modes) that is not SGR.
32
+ const CSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
33
+ // Character-set designations (ESC ( B) and the remaining two-byte escapes.
34
+ const CHARSET_PATTERN = /\x1b[()*+\-./][0-~]/g;
35
+ // The remaining two-byte escapes (ESC 7, ESC =, ESC M, a CSI cut short) and
36
+ // a lone ESC; never the start of a kept SGR.
37
+ const OTHER_ESCAPE_PATTERN = /\x1b(?!\[[0-9;]*m)[0-~]?/g;
38
+ // C0 except \t \n \r, DEL, C1, and a lone ESC left over.
39
+ const CONTROL_PATTERN = /[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f-\x9f]/g;
40
+
41
+ /**
42
+ * Drops OSC, every CSI but SGR, the other escapes, C1 and C0 controls except
43
+ * `\n`, `\r` and `\t`; keeps `ESC [ … m`.
44
+ */
45
+ export function stripControlKeepSgr(text) {
46
+ const value = String(text ?? '');
47
+ if (!/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f-\x9f]/.test(value)) return value;
48
+ return value
49
+ .replace(OSC_PATTERN, '')
50
+ .replace(CSI_PATTERN, (match) => (/^\x1b\[[0-9;]*m$/.test(match) ? match : ''))
51
+ .replace(CHARSET_PATTERN, '')
52
+ .replace(OTHER_ESCAPE_PATTERN, '')
53
+ .replace(CONTROL_PATTERN, '');
54
+ }
55
+
56
+ // ─── Lines ───────────────────────────────────────────────────────────────────
57
+
58
+ function cutLine(text) {
59
+ return text.length > TASK_OUTPUT_LINE_CHARS_MAX ? text.slice(0, TASK_OUTPUT_LINE_CHARS_MAX) : text;
60
+ }
61
+
62
+ // A line's final state: a `\r` not followed by `\n` discards what came before
63
+ // it (a trailing one still waits for what follows).
64
+ function finalLineState(raw) {
65
+ const body = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
66
+ const at = body.lastIndexOf('\r');
67
+ return at === -1 ? body : body.slice(at + 1);
68
+ }
69
+
70
+ /**
71
+ * Lines from a stream fed in arbitrary pieces. `feed(text)` returns
72
+ * `{ lines, partial, changedFrom }`: every completed line so far as
73
+ * `{ text, count }` (adjacent identical lines folded, `count` unbounded), the
74
+ * open partial line in its current state ('' when there is none), and the
75
+ * index of the first line this feed added or changed (a folded repeat bumps
76
+ * the count of the line before). `end()` closes the partial line and returns
77
+ * the same shape. `lines` is the folder's own array: copy it to keep it.
78
+ */
79
+ export function createLineFolder() {
80
+ const lines = [];
81
+ let partial = '';
82
+ let changedFrom = 0;
83
+
84
+ function push(text) {
85
+ const line = cutLine(text);
86
+ const last = lines[lines.length - 1];
87
+ if (last && last.text === line) {
88
+ last.count += 1;
89
+ changedFrom = Math.min(changedFrom, lines.length - 1);
90
+ } else {
91
+ changedFrom = Math.min(changedFrom, lines.length);
92
+ lines.push({ text: line, count: 1 });
93
+ }
94
+ }
95
+
96
+ function result() {
97
+ const out = { lines, partial: finalLineState(partial), changedFrom };
98
+ changedFrom = lines.length;
99
+ return out;
100
+ }
101
+
102
+ return {
103
+ feed(text) {
104
+ const chunk = String(text ?? '');
105
+ if (chunk) {
106
+ const parts = (partial + chunk).split('\n');
107
+ partial = parts.pop();
108
+ for (const raw of parts) push(finalLineState(raw));
109
+ // The open line stays small: a progress bar redraws itself
110
+ // thousands of times on one line.
111
+ const keepCr = partial.endsWith('\r');
112
+ partial = cutLine(finalLineState(partial)) + (keepCr ? '\r' : '');
113
+ }
114
+ return result();
115
+ },
116
+ end() {
117
+ const last = finalLineState(partial);
118
+ partial = '';
119
+ if (last) push(last);
120
+ return result();
121
+ },
122
+ get lines() {
123
+ return lines;
124
+ },
125
+ };
126
+ }
127
+
128
+ // ─── Colours ─────────────────────────────────────────────────────────────────
129
+
130
+ // The 16-colour palette (xterm defaults), for mapping 256 and truecolor.
131
+ const PALETTE_16 = [
132
+ [0, 0, 0], [205, 0, 0], [0, 205, 0], [205, 205, 0], [0, 0, 238], [205, 0, 205], [0, 205, 205], [229, 229, 229],
133
+ [127, 127, 127], [255, 0, 0], [0, 255, 0], [255, 255, 0], [92, 92, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255],
134
+ ];
135
+
136
+ function nearest16([r, g, b]) {
137
+ let best = 0;
138
+ let bestDistance = Infinity;
139
+ PALETTE_16.forEach(([pr, pg, pb], index) => {
140
+ const distance = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2;
141
+ if (distance < bestDistance) {
142
+ best = index;
143
+ bestDistance = distance;
144
+ }
145
+ });
146
+ return best;
147
+ }
148
+
149
+ function rgbOf256(n) {
150
+ if (n < 16) return PALETTE_16[n];
151
+ if (n >= 232) {
152
+ const level = 8 + (n - 232) * 10;
153
+ return [level, level, level];
154
+ }
155
+ const index = n - 16;
156
+ const steps = [0, 95, 135, 175, 215, 255];
157
+ return [steps[Math.floor(index / 36)], steps[Math.floor(index / 6) % 6], steps[index % 6]];
158
+ }
159
+
160
+ function clampByte(value) {
161
+ const number = Number(value);
162
+ return Number.isFinite(number) ? Math.min(255, Math.max(0, number)) : 0;
163
+ }
164
+
165
+ function plainStyle() {
166
+ return { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false };
167
+ }
168
+
169
+ function applySgr(state, params) {
170
+ const codes = params === '' ? [0] : params.split(';').map((part) => (part === '' ? 0 : Number(part)));
171
+ for (let i = 0; i < codes.length; i += 1) {
172
+ const code = codes[i];
173
+ if (code === 0) Object.assign(state, plainStyle());
174
+ else if (code === 1) state.bold = true;
175
+ else if (code === 2) state.dim = true;
176
+ else if (code === 3) state.italic = true;
177
+ else if (code === 4) state.underline = true;
178
+ else if (code === 22) {
179
+ state.bold = false;
180
+ state.dim = false;
181
+ } else if (code === 23) state.italic = false;
182
+ else if (code === 24) state.underline = false;
183
+ else if (code >= 30 && code <= 37) state.fg = code - 30;
184
+ else if (code === 39) state.fg = null;
185
+ else if (code >= 40 && code <= 47) state.bg = code - 40;
186
+ else if (code === 49) state.bg = null;
187
+ else if (code >= 90 && code <= 97) state.fg = code - 90 + 8;
188
+ else if (code >= 100 && code <= 107) state.bg = code - 100 + 8;
189
+ else if (code === 38 || code === 48) {
190
+ const key = code === 38 ? 'fg' : 'bg';
191
+ if (codes[i + 1] === 5 && i + 2 < codes.length) {
192
+ state[key] = nearest16(rgbOf256(Math.min(255, Math.max(0, codes[i + 2] || 0))));
193
+ i += 2;
194
+ } else if (codes[i + 1] === 2 && i + 4 < codes.length) {
195
+ state[key] = nearest16([clampByte(codes[i + 2]), clampByte(codes[i + 3]), clampByte(codes[i + 4])]);
196
+ i += 4;
197
+ } else {
198
+ break;
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * One line as styled segments `{ text, fg, bg, bold, dim, italic, underline }`,
206
+ * colours 0–15 or null (256 and truecolor mapped to the nearest of the 16).
207
+ * Adjacent segments never share a style. Any escape that is not SGR is
208
+ * dropped from the text.
209
+ */
210
+ export function ansiToSegments(lineText) {
211
+ const text = String(lineText ?? '');
212
+ const state = plainStyle();
213
+ const segments = [];
214
+ const pushText = (value) => {
215
+ const clean = stripControlKeepSgr(value).replace(/\x1b/g, '');
216
+ if (!clean) return;
217
+ const last = segments[segments.length - 1];
218
+ if (last && last.fg === state.fg && last.bg === state.bg && last.bold === state.bold
219
+ && last.dim === state.dim && last.italic === state.italic && last.underline === state.underline) {
220
+ last.text += clean;
221
+ return;
222
+ }
223
+ segments.push({ text: clean, ...state });
224
+ };
225
+ let at = 0;
226
+ const pattern = new RegExp(SGR_PATTERN.source, 'g');
227
+ for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
228
+ pushText(text.slice(at, match.index));
229
+ applySgr(state, match[1]);
230
+ at = match.index + match[0].length;
231
+ }
232
+ pushText(text.slice(at));
233
+ return segments;
234
+ }
@@ -7,7 +7,6 @@
7
7
  // per conversation for the life of the page.
8
8
 
9
9
  import { isChatInteractionHeld } from './selection-guard.mjs';
10
- import { AGENT_EXCHANGE_CLOSED_CLASS } from './agent-exchange-fold-view.mjs';
11
10
 
12
11
  export const THINKING_FOLLOW_MODES = Object.freeze(['thoughts', 'stream', 'tools']);
13
12
  export const THINKING_FOLLOW_BOTTOM_GAP_PX = 16;
@@ -109,9 +108,6 @@ function resolveTarget(mode) {
109
108
  const el = document.querySelector(selector);
110
109
  if (!el) return null;
111
110
  if (el.hidden || el.closest?.('[hidden]')) return null;
112
- // Nor does a live bubble folded into an agent exchange: it has no box, and
113
- // following it would scroll the pane to the top.
114
- if (el.closest?.(`.${AGENT_EXCHANGE_CLOSED_CLASS}`)) return null;
115
111
  // A collapsed <details> or an empty list has nothing to follow yet.
116
112
  const details = el.closest?.('details');
117
113
  if (details && !details.open) return null;
@@ -0,0 +1,73 @@
1
+ // Live rows for a window that ends above the conversation's end, without the
2
+ // DOM. Such a window (an "island": a jump to a pin or a search result, a
3
+ // reload around the place read, a kept window more was written below than one
4
+ // page holds) has rows between its newest row and the end that are not on the
5
+ // page. A live row put under it would hide that gap: the newer pages load
6
+ // from the window's newest row, and nothing would load what lies between. So
7
+ // the row is held back: the window keeps saying "newer rows exist", the
8
+ // jump-to-bottom button counts the row, and the newer pages bring it in their
9
+ // turn. Once the window meets the end, live rows are put on the page again.
10
+ //
11
+ // conversation-view.js keeps one of these for the window on the page and
12
+ // applies what it decides; transcript-jump-bottom.mjs draws the count.
13
+
14
+ /**
15
+ * The rows held back for the window on the page. `follow` is told of every
16
+ * change of that window: one that meets the end, or another conversation's,
17
+ * holds none.
18
+ */
19
+ export function createHeldBackLiveRows() {
20
+ let conversationId = '';
21
+ let ids = [];
22
+ let unnamed = 0;
23
+ return {
24
+ /** A live row for `conversation` that was not put on the page. */
25
+ note(conversation, messageId) {
26
+ const id = String(conversation || '').trim();
27
+ if (!id) return 0;
28
+ if (id !== conversationId) {
29
+ conversationId = id;
30
+ ids = [];
31
+ unnamed = 0;
32
+ }
33
+ const rowId = String(messageId || '').trim();
34
+ if (!rowId) unnamed += 1;
35
+ else if (!ids.includes(rowId)) ids.push(rowId);
36
+ return ids.length + unnamed;
37
+ },
38
+ /** How many rows wait for `conversation`'s window. */
39
+ count(conversation) {
40
+ return String(conversation || '').trim() === conversationId ? ids.length + unnamed : 0;
41
+ },
42
+ /** The ids of the rows that wait, oldest first. */
43
+ ids(conversation) {
44
+ return String(conversation || '').trim() === conversationId ? ids.slice() : [];
45
+ },
46
+ /** The window on the page now. True when rows were let go. */
47
+ follow({ conversationId: conversation = '', atTail = true } = {}) {
48
+ const id = String(conversation || '').trim();
49
+ if (!atTail && id === conversationId) return false;
50
+ const had = ids.length + unnamed > 0;
51
+ conversationId = atTail ? '' : id;
52
+ ids = [];
53
+ unnamed = 0;
54
+ return had;
55
+ },
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Whether a newer page that says it is the last one really brings the window
61
+ * to the end. It can have been fetched before a held-back row was written
62
+ * (a page waiting in the prefetch buffer, a request answered while the row's
63
+ * event was on its way): then a row the page lacks, and the page does not
64
+ * show either, is still to come, and one more page is asked for. An empty
65
+ * page ends it: a row that is still missing was deleted since.
66
+ */
67
+ export function newerPageReachesEnd({ pageHasMore = false, pageMessageIds = [], heldBackIds = [], renderedIds = [] } = {}) {
68
+ if (pageHasMore) return false;
69
+ const page = (pageMessageIds || []).map((id) => String(id || '').trim()).filter(Boolean);
70
+ if (!page.length) return true;
71
+ const known = new Set([...page, ...(renderedIds || []).map((id) => String(id || '').trim())]);
72
+ return (heldBackIds || []).every((id) => known.has(String(id || '').trim()));
73
+ }