@oar-sh/oar 0.9.25 → 0.9.26

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 (33) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +9 -9
  3. package/package.json +3 -1
  4. package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
  5. package/server/claude-worker/claude-session-process.mjs +4 -0
  6. package/server/claude-worker/claude-turn-publisher.mjs +25 -11
  7. package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
  8. package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
  9. package/server/grok-worker/grok-turn-runner.mjs +3 -1
  10. package/server/public/app/context-usage-view.mjs +2 -2
  11. package/server/public/app/live-activity-cap.mjs +3 -3
  12. package/server/public/app/markdown-math.mjs +471 -0
  13. package/server/public/app/pwa-install.js +25 -4
  14. package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
  15. package/server/public/app/relay-restart-ui.js +2 -2
  16. package/server/public/app/remote-relays-settings.js +33 -6
  17. package/server/public/app/router.js +49 -237
  18. package/server/public/app/settings-tabs.js +10 -1
  19. package/server/public/app/store.js +2 -0
  20. package/server/public/app/stream-view-preference.mjs +1 -1
  21. package/server/public/app/thoughts-preference.mjs +1 -1
  22. package/server/public/app/tmux-inspector-view.js +29 -16
  23. package/server/public/index.html +134 -116
  24. package/server/routes/relay-board-routes.mjs +46 -1
  25. package/server/routes/remote-relay-routes.mjs +1 -1
  26. package/server/server-runtime.mjs +2 -0
  27. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  28. package/server/services/relay-restart-queue.mjs +1 -1
  29. package/server/services/remote-relay-dispatcher.mjs +23 -1
  30. package/server/services/remote-relay-registry-service.mjs +7 -1
  31. package/server/services/session-worker-stop-service.mjs +50 -18
  32. package/shared/math-instructions.mjs +30 -0
  33. package/server/public/app/math-dollars.mjs +0 -89
@@ -0,0 +1,471 @@
1
+ // Maths in Markdown: which stretches of a reply are formulas.
2
+ //
3
+ // A formula is read from its delimiters, never guessed from what stands in a
4
+ // parenthesis: `$…$` and `\(…\)` inline, `$$…$$` and `\[…\]` as a display, and
5
+ // a fenced `math` block. The rules are tokenizers of the Markdown library, so
6
+ // code spans, code blocks, link targets and HTML are out of reach by
7
+ // construction, and the formula text reaches KaTeX as it was written (Markdown
8
+ // never sees its `\,`, `*` or `_`).
9
+ //
10
+ // The rules, in full:
11
+ // - A boundary stands before an opening delimiter: the start of the text,
12
+ // whitespace, one of `( [ { " ' -`, a non-ASCII character, a line break or
13
+ // an HTML tag. `C:\Program Files\(x86)` and `A$B` open nothing.
14
+ // - `$…$`: a non-space after the opening sign; only the next unescaped `$`
15
+ // closes, after a non-space, and before the end, whitespace, punctuation, an
16
+ // HTML tag, a non-ASCII character or an ordinal ending (`$n$th`). At most one line break
17
+ // inside, no backtick. `$5 and $9`, `$HOME/$PATH`, `$(a)$(b)` and the two
18
+ // signs of `/^a$/ or /^b$/` stay text.
19
+ // - `$5 or 10$`: a number first, a plain word between spaces and nothing of
20
+ // TeX is a pair of prices. This is the one rule that looks at the content.
21
+ // - `\(…\)`: closes within one line break.
22
+ // - `$$…$$` inside a line: no space inside either end.
23
+ // - `\[ … \]` on one line, wherever it stands: a space inside both ends, as
24
+ // `\[1\]`, `\[x\]` and `## \[Unreleased\]` are escaped brackets.
25
+ // - A display that opens at the start of a line runs to a line that ends with
26
+ // its closer. One that opens at the end of a line ("So: $$") runs to a line
27
+ // that is the closer alone. One of `. , ; :` may follow the closer.
28
+ // - Nothing but dots is no formula: `$...$` is talk about the notation.
29
+ // What KaTeX then refuses is shown as the source it was (router.js).
30
+
31
+ const MAX_INLINE_CHARS = 1000;
32
+ const MAX_BRACKET_LINE_CHARS = 2000;
33
+ const MAX_DISPLAY_CHARS = 10_000;
34
+ /** A longer message is rendered without maths, at the Markdown library's own speed. */
35
+ export const MATH_MESSAGE_MAX_CHARS = 100_000;
36
+
37
+ const OPEN_BOUNDARY = /[\s(\[{"'\-]/;
38
+ const CLOSE_BOUNDARY = /^[\s.,;:!?)\]}"'\-<]/;
39
+ const ORDINAL_ENDING = /^(?:th|st|nd|rd)\b/;
40
+ const ONLY_DOTS = /^(?:[.\u2026\s]|\\l?dots)+$/;
41
+ const PRICE_START = /^\d/;
42
+ const TEX_CHARACTER = /[\\^_{}]/;
43
+ const PLAIN_WORD = /(?:^|\s)[A-Za-z]{2,}(?=\s|$)/;
44
+ const BLANK_REST = /^[ \t]*(?:\n|$)/;
45
+ const FENCE_LINE = /^\s*(?:`{3,}|~{3,})/;
46
+ const CLOSING_LINE = /^(.*?)(\$\$|\\\])([.,;:]?)[ \t]*$/;
47
+ const MATH_FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})[ \t]*math[ \t]*(?:\n|$)/;
48
+
49
+ function isSpace(char) {
50
+ return char === undefined || char === '' || /\s/.test(char);
51
+ }
52
+
53
+ function boundaryBefore(prev) {
54
+ return !prev || OPEN_BOUNDARY.test(prev) || prev.charCodeAt(0) > 127;
55
+ }
56
+
57
+ function boundaryAfter(rest) {
58
+ return rest === '' || CLOSE_BOUNDARY.test(rest) || rest.charCodeAt(0) > 127 || ORDINAL_ENDING.test(rest);
59
+ }
60
+
61
+ function isFormula(tex) {
62
+ return Boolean(tex) && !ONLY_DOTS.test(tex);
63
+ }
64
+
65
+ function looksLikePrices(tex) {
66
+ return PRICE_START.test(tex) && !TEX_CHARACTER.test(tex) && PLAIN_WORD.test(tex);
67
+ }
68
+
69
+ /**
70
+ * The index of `closer` behind `from`, or -1. `closer` is `$`, `$$`, `\)` or
71
+ * `\]`. A backslash escapes the next character. The search ends at the cap,
72
+ * at a second line break (`lineBreaks` allows that many), at a blank line and,
73
+ * for the dollar closers, at a backtick or at a `$` that is not the closer.
74
+ */
75
+ function findCloser(src, from, closer, maxChars, lineBreaks) {
76
+ const escapedCloser = closer[0] === '\\';
77
+ const end = Math.min(src.length, from + maxChars);
78
+ let breaks = 0;
79
+ for (let i = from; i < end; i += 1) {
80
+ const char = src[i];
81
+ if (char === '\n') {
82
+ breaks += 1;
83
+ if (breaks > lineBreaks || BLANK_REST.test(src.slice(i + 1, i + 200))) return -1;
84
+ continue;
85
+ }
86
+ if (char === '\\') {
87
+ if (escapedCloser && src[i + 1] === closer[1]) return i;
88
+ i += 1;
89
+ continue;
90
+ }
91
+ if (escapedCloser) continue;
92
+ if (char === '`') return -1;
93
+ if (char === '$') {
94
+ if (closer === '$') return i;
95
+ return src[i + 1] === '$' ? i : -1;
96
+ }
97
+ }
98
+ return -1;
99
+ }
100
+
101
+ /** The closing line `line` is for `closer`, as { texLength, trail }, or null. */
102
+ function closingOf(line, closer, closerAlone) {
103
+ const closing = CLOSING_LINE.exec(line);
104
+ if (!closing || closing[2] !== closer || closing[1].includes(closer)) return null;
105
+ if (closerAlone && closing[1].trim()) return null;
106
+ return { texLength: closing[1].length, trail: closing[3] };
107
+ }
108
+
109
+ /**
110
+ * Walks whole lines from `from` to the one that ends the display.
111
+ * @returns {{ texEnd: number, end: number, trail: string } | { stoppedAt: number, hard: boolean }}
112
+ * the hit, or where the search gave up. `hard`: a blank line, a code
113
+ * fence, a stray closer or the end of the text ended it, not the size cap.
114
+ */
115
+ function findClosingLine(src, from, closer, { closerAlone = false, limitFrom = from } = {}) {
116
+ let lineStart = from;
117
+ while (lineStart < src.length) {
118
+ if (lineStart - limitFrom > MAX_DISPLAY_CHARS) return { stoppedAt: lineStart, hard: false };
119
+ let lineEnd = src.indexOf('\n', lineStart);
120
+ if (lineEnd < 0) lineEnd = src.length;
121
+ const line = src.slice(lineStart, lineEnd);
122
+ if (!line.trim() || FENCE_LINE.test(line)) return { stoppedAt: lineStart, hard: true };
123
+ const closing = closingOf(line, closer, closerAlone);
124
+ if (closing) return { texEnd: lineStart + closing.texLength, end: lineEnd, trail: closing.trail };
125
+ // A closer in the middle of a line belongs to another formula.
126
+ if (line.includes(closer)) return { stoppedAt: lineStart, hard: true };
127
+ lineStart = lineEnd + 1;
128
+ }
129
+ return { stoppedAt: src.length, hard: true };
130
+ }
131
+
132
+ /**
133
+ * A display over lines, `src` starting with its opener: from the opener's
134
+ * own line (`closerAlone` false: it may close there, as in `$$ x $$`) to
135
+ * the line that ends with the closer.
136
+ *
137
+ * `memo` remembers, per closer, the whole lines a failed search found without
138
+ * a closing line, so a paragraph of many unclosed openers is walked once.
139
+ * Places are kept as the length of the text still ahead, which names a place
140
+ * whatever `src` is cut to.
141
+ */
142
+ function matchDisplayOverLines(src, closer, { closerAlone = false, memo = null } = {}) {
143
+ let firstEnd = src.indexOf('\n', 2);
144
+ if (firstEnd < 0) firstEnd = src.length;
145
+ const firstLine = src.slice(2, firstEnd);
146
+ let hit = null;
147
+ let closingFirst = closerAlone ? null : closingOf(firstLine, closer, false);
148
+ if (closingFirst && closer === '\\]') {
149
+ // On one line, brackets without blanks inside are escaped brackets.
150
+ const inner = firstLine.slice(0, closingFirst.texLength);
151
+ if (!(isSpace(inner[0]) && isSpace(inner[inner.length - 1]))) return null;
152
+ }
153
+ if (closingFirst) {
154
+ hit = { texEnd: 2 + closingFirst.texLength, end: firstEnd, trail: closingFirst.trail };
155
+ } else {
156
+ if (firstLine.includes(closer) || firstEnd >= src.length) return null;
157
+ let from = firstEnd + 1;
158
+ const ahead = src.length - from;
159
+ const known = closerAlone ? null : memo?.[closer];
160
+ const covered = Boolean(known) && known.until < ahead && ahead <= known.from;
161
+ if (covered) {
162
+ if (known.hard) return null;
163
+ from = src.length - known.until;
164
+ }
165
+ hit = findClosingLine(src, from, closer, { closerAlone, limitFrom: 2 });
166
+ if (hit.texEnd === undefined) {
167
+ if (memo && !closerAlone) {
168
+ memo[closer] = { from: covered ? known.from : ahead, until: src.length - hit.stoppedAt, hard: hit.hard };
169
+ }
170
+ return null;
171
+ }
172
+ }
173
+ const tex = src.slice(2, hit.texEnd).trim();
174
+ if (!isFormula(tex)) return null;
175
+ return { raw: src.slice(0, hit.texEnd + closer.length), rawEnd: hit.end, tex, display: true, trail: hit.trail };
176
+ }
177
+
178
+ /**
179
+ * The formula `src` starts with, or null. `prev` is the character before it
180
+ * ('' at the start of the text). `atLineStart` says whether only blanks stand
181
+ * between the last line break and `src`.
182
+ * @returns {{ raw: string, tex: string, display: boolean } | null}
183
+ */
184
+ export function matchInlineMath(src, prev = '', { atLineStart = !prev || prev === '\n', memo = null } = {}) {
185
+ const text = String(src ?? '');
186
+ const first = text[0];
187
+ if (first !== '$' && first !== '\\') return null;
188
+ if (!boundaryBefore(prev)) return null;
189
+ const opener = text.slice(0, 2);
190
+
191
+ if (opener === '\\(') {
192
+ const close = findCloser(text, 2, '\\)', MAX_INLINE_CHARS, 1);
193
+ if (close < 0) return null;
194
+ const tex = text.slice(2, close).trim();
195
+ return isFormula(tex) ? { raw: text.slice(0, close + 2), tex, display: false } : null;
196
+ }
197
+
198
+ if (opener === '$$' || opener === '\\[') {
199
+ const closer = opener === '$$' ? '$$' : '\\]';
200
+ // The opener starts its line, or ends it ("So: $$"): the display runs over lines.
201
+ const opensAtLineEnd = !atLineStart && BLANK_REST.test(text.slice(2, 200)) && text.indexOf('\n', 2) >= 0;
202
+ if (atLineStart || opensAtLineEnd) {
203
+ const overLines = matchDisplayOverLines(text, closer, { closerAlone: opensAtLineEnd, memo });
204
+ if (overLines) return { raw: overLines.raw, tex: overLines.tex, display: true };
205
+ }
206
+ const close = findCloser(text, 2, closer, opener === '$$' ? MAX_DISPLAY_CHARS : MAX_BRACKET_LINE_CHARS, 0);
207
+ if (close < 0) return null;
208
+ const inner = text.slice(2, close);
209
+ if (opener === '$$') {
210
+ if (isSpace(inner[0]) || isSpace(inner[inner.length - 1])) return null;
211
+ if (text[close + 2] === '$' || !boundaryAfter(text.slice(close + 2, close + 5))) return null;
212
+ } else if (!(isSpace(inner[0]) && isSpace(inner[inner.length - 1]))) {
213
+ return null;
214
+ }
215
+ const tex = inner.trim();
216
+ return isFormula(tex) ? { raw: text.slice(0, close + 2), tex, display: true } : null;
217
+ }
218
+
219
+ if (first !== '$' || isSpace(text[1])) return null;
220
+ const close = findCloser(text, 1, '$', MAX_INLINE_CHARS, 1);
221
+ if (close < 0 || isSpace(text[close - 1]) || text[close + 1] === '$') return null;
222
+ if (!boundaryAfter(text.slice(close + 1, close + 4))) return null;
223
+ const tex = text.slice(1, close);
224
+ if (!isFormula(tex) || looksLikePrices(tex)) return null;
225
+ return { raw: text.slice(0, close + 1), tex, display: false };
226
+ }
227
+
228
+ function matchMathFence(src) {
229
+ const open = MATH_FENCE_OPEN.exec(src);
230
+ if (!open) return null;
231
+ const fence = open[1];
232
+ let lineStart = open[0].length;
233
+ while (lineStart < src.length) {
234
+ let lineEnd = src.indexOf('\n', lineStart);
235
+ if (lineEnd < 0) lineEnd = src.length;
236
+ const line = src.slice(lineStart, lineEnd).trim();
237
+ if (line.length >= fence.length && line[0] === fence[0] && line === line[0].repeat(line.length)) {
238
+ const tex = src.slice(open[0].length, lineStart).trim();
239
+ if (!tex) return null;
240
+ return { raw: src.slice(0, Math.min(src.length, lineEnd + 1)), source: src.slice(0, lineEnd), tex, display: true, trail: '' };
241
+ }
242
+ lineStart = lineEnd + 1;
243
+ }
244
+ // Not closed yet (a reply still streaming): the library shows it as code.
245
+ return null;
246
+ }
247
+
248
+ /**
249
+ * The display block `src` starts with, or null: `$$` or `\[` at the start of
250
+ * a line up to the line that ends with the closer, or a fenced `math` block.
251
+ * `raw` takes the rest of the closing line with it; `source` is the formula
252
+ * as written, up to its closer, and `trail` the punctuation behind that.
253
+ * @returns {{ raw: string, source: string, tex: string, display: true, trail: string } | null}
254
+ */
255
+ export function matchMathBlock(src) {
256
+ const text = String(src ?? '');
257
+ const fence = matchMathFence(text);
258
+ if (fence) return fence;
259
+ const open = /^ {0,3}(\$\$|\\\[)/.exec(text);
260
+ if (!open) return null;
261
+ const closer = open[1] === '$$' ? '$$' : '\\]';
262
+ const indent = open[0].length - 2;
263
+ const match = matchDisplayOverLines(text.slice(indent), closer);
264
+ if (!match) return null;
265
+ return {
266
+ raw: text.slice(0, Math.min(text.length, indent + match.rawEnd + 1)),
267
+ source: match.raw,
268
+ tex: match.tex,
269
+ display: true,
270
+ trail: match.trail,
271
+ };
272
+ }
273
+
274
+ /**
275
+ * The TeX handed to KaTeX. In a one-line formula a bare `%` is escaped: TeX
276
+ * reads it as the start of a comment, so `$50%$` came out as "50".
277
+ */
278
+ export function texForKatex(tex) {
279
+ const text = String(tex ?? '');
280
+ if (text.includes('\n') || !text.includes('%')) return text;
281
+ return text.replace(/\\[\s\S]|%/g, (match) => (match === '%' ? '\\%' : match));
282
+ }
283
+
284
+ /** Whether `source` holds anything a formula could start with. */
285
+ export function sourceHasMathDelimiters(source) {
286
+ const text = String(source ?? '');
287
+ return text.includes('$') || text.includes('\\(') || text.includes('\\[') || /(?:`{3,}|~{3,})[ \t]*math/.test(text);
288
+ }
289
+
290
+ /** Maths applies to `source`: it has a delimiter and is not over the size cap. */
291
+ export function mathAppliesTo(source) {
292
+ const text = String(source ?? '');
293
+ return text.length <= MATH_MESSAGE_MAX_CHARS && sourceHasMathDelimiters(text);
294
+ }
295
+
296
+ let mathEnabled = true;
297
+
298
+ /** Switches the tokenizers off for the next parse (a message without maths, or one over the cap). */
299
+ export function setMathEnabled(enabled) {
300
+ mathEnabled = Boolean(enabled);
301
+ }
302
+
303
+ function escapeHtml(text) {
304
+ return String(text ?? '')
305
+ .replace(/&/g, '&amp;')
306
+ .replace(/</g, '&lt;')
307
+ .replace(/>/g, '&gt;')
308
+ .replace(/"/g, '&quot;');
309
+ }
310
+
311
+ /**
312
+ * A formula as a placeholder: its source as text, its TeX in `data-tex`.
313
+ * KaTeX fills it in after the HTML is sanitised (router.js); where KaTeX is
314
+ * missing or refuses the formula, the placeholder stays, and reads as the
315
+ * source that was written.
316
+ */
317
+ export function mathPlaceholderHtml({ raw, tex, display }) {
318
+ const mode = display ? 'display' : 'inline';
319
+ return `<span data-math="${mode}" data-tex="${escapeHtml(tex)}">${escapeHtml(String(raw ?? '').trim())}</span>`;
320
+ }
321
+
322
+ // Per list of inline tokens: the text the tokenizer was handed on its last
323
+ // call, and what failed searches for a closing line found. The library asks
324
+ // the tokenizer at every step of its inline loop, so the text handed over
325
+ // last ends where the current one begins, and the character before `src` is
326
+ // read in constant time. (The last token's own `raw` grows by concatenation;
327
+ // reading its end is quadratic over a long paragraph.)
328
+ const inlineRuns = new WeakMap();
329
+
330
+ function inlineMathTokenizer(src, tokens) {
331
+ if (!mathEnabled) return undefined;
332
+ let run = inlineRuns.get(tokens);
333
+ const before = run ? run.src : null;
334
+ if (!run) {
335
+ run = { src, memo: {}, nextOpenerAhead: undefined };
336
+ inlineRuns.set(tokens, run);
337
+ } else {
338
+ run.src = src;
339
+ }
340
+ activeRun = run;
341
+ const first = src[0];
342
+ if (first !== '$' && first !== '\\') return undefined;
343
+
344
+ let prev = '';
345
+ let atLineStart = true;
346
+ const last = tokens.length ? tokens[tokens.length - 1] : null;
347
+ if (before !== null && !(last && (last.type === 'html' || last.type === 'br'))) {
348
+ let index = before.length - src.length - 1;
349
+ prev = index >= 0 ? before[index] : '';
350
+ while (index >= 0 && (before[index] === ' ' || before[index] === '\t')) index -= 1;
351
+ atLineStart = index < 0 ? !prev : before[index] === '\n';
352
+ }
353
+ const match = matchInlineMath(src, prev, { atLineStart, memo: run.memo });
354
+ return match ? { type: 'mathInline', ...match } : undefined;
355
+ }
356
+
357
+ /** The index of the next `$`, `\(` or `\[` in `src`, or undefined. */
358
+ function nextOpenerIndex(src) {
359
+ const dollar = src.indexOf('$');
360
+ let backslash = src.indexOf('\\');
361
+ while (backslash >= 0 && (dollar < 0 || backslash < dollar) && src[backslash + 1] !== '(' && src[backslash + 1] !== '[') {
362
+ // `\\(` is an escaped backslash before a parenthesis, not an opener.
363
+ backslash = src.indexOf('\\', backslash + (src[backslash + 1] === '\\' ? 2 : 1));
364
+ }
365
+ if (backslash >= 0 && dollar >= 0 && backslash > dollar) backslash = -1;
366
+ if (dollar < 0) return backslash < 0 ? undefined : backslash;
367
+ return backslash < 0 ? dollar : Math.min(dollar, backslash);
368
+ }
369
+
370
+ // The run whose tokenizer call came last. The library asks `start` in the
371
+ // same step of the same inline loop, with that call's text less its first
372
+ // character, which is how the run is recognised here.
373
+ let activeRun = null;
374
+
375
+ /**
376
+ * Where the library's text rule has to stop: the next possible opener. The
377
+ * library asks at every step of a paragraph; the answer is kept per run as
378
+ * the length of the text behind the opener, so a long paragraph is searched
379
+ * once and not once per step.
380
+ */
381
+ function inlineMathStart(src) {
382
+ if (!mathEnabled) return undefined;
383
+ const run = activeRun && activeRun.src.length === src.length + 1 && activeRun.src[1] === src[0] ? activeRun : null;
384
+ if (run && run.nextOpenerAhead !== undefined) {
385
+ if (run.nextOpenerAhead < 0) return undefined;
386
+ if (run.nextOpenerAhead <= src.length) return src.length - run.nextOpenerAhead;
387
+ }
388
+ const index = nextOpenerIndex(src);
389
+ if (run) run.nextOpenerAhead = index === undefined ? -1 : src.length - index;
390
+ return index;
391
+ }
392
+
393
+ function blockMathTokenizer(src) {
394
+ if (!mathEnabled) return undefined;
395
+ const first = src[0];
396
+ if (first !== '$' && first !== '\\' && first !== ' ' && first !== '`' && first !== '~') return undefined;
397
+ const match = matchMathBlock(src);
398
+ return match ? { type: 'mathBlock', ...match } : undefined;
399
+ }
400
+
401
+ function holdsMath(tokens) {
402
+ return Array.isArray(tokens)
403
+ && tokens.some((token) => token.type === 'mathInline' || token.type === 'mathBlock' || holdsMath(token.tokens));
404
+ }
405
+
406
+ /**
407
+ * An image's alternative text is an attribute: a formula placeholder in it
408
+ * would end the attribute and spill its rest into the page. Such an alt text
409
+ * is read again without the maths rules, as the library reads it alone.
410
+ *
411
+ * Runs once over the finished token tree. (The library's own `walkTokens`
412
+ * gathers its callbacks' results by concatenation, which is quadratic in the
413
+ * number of tokens of a long message.)
414
+ */
415
+ function imageAltsWithoutMath(tokens, lexInline) {
416
+ if (!Array.isArray(tokens)) return;
417
+ for (const token of tokens) {
418
+ if (token.type === 'image') {
419
+ if (holdsMath(token.tokens)) token.tokens = lexInline(token.text);
420
+ continue;
421
+ }
422
+ imageAltsWithoutMath(token.tokens, lexInline);
423
+ imageAltsWithoutMath(token.items, lexInline);
424
+ if (token.type === 'table') {
425
+ for (const cell of token.header) imageAltsWithoutMath(cell.tokens, lexInline);
426
+ for (const row of token.rows) for (const cell of row) imageAltsWithoutMath(cell.tokens, lexInline);
427
+ }
428
+ }
429
+ }
430
+
431
+ /** Installs the maths rules on a Markdown library; false when it cannot take them. */
432
+ export function useMathMarkdown(markdown) {
433
+ if (!markdown || typeof markdown.use !== 'function') return false;
434
+ const Lexer = markdown.Lexer;
435
+ markdown.use({
436
+ hooks: {
437
+ processAllTokens(tokens) {
438
+ if (!mathEnabled) return tokens;
439
+ imageAltsWithoutMath(tokens, (text) => {
440
+ if (typeof Lexer !== 'function') return [{ type: 'text', raw: text, text }];
441
+ mathEnabled = false;
442
+ try {
443
+ // With the document's link definitions, as the first reading had them.
444
+ const lexer = new Lexer(this.options);
445
+ if (tokens.links) lexer.tokens.links = tokens.links;
446
+ return lexer.inlineTokens(text);
447
+ } finally {
448
+ mathEnabled = true;
449
+ }
450
+ });
451
+ return tokens;
452
+ },
453
+ },
454
+ extensions: [
455
+ {
456
+ name: 'mathBlock',
457
+ level: 'block',
458
+ tokenizer: blockMathTokenizer,
459
+ renderer: (token) => `<p>${mathPlaceholderHtml({ ...token, raw: token.source })}${escapeHtml(token.trail || '')}</p>\n`,
460
+ },
461
+ {
462
+ name: 'mathInline',
463
+ level: 'inline',
464
+ start: inlineMathStart,
465
+ tokenizer: inlineMathTokenizer,
466
+ renderer: (token) => mathPlaceholderHtml(token),
467
+ },
468
+ ],
469
+ });
470
+ return true;
471
+ }
@@ -17,6 +17,7 @@ let pwaAppName = '';
17
17
  // background GET must never repaint over a just-saved name, and a second edit
18
18
  // must never be dropped in favor of an older in-flight save.
19
19
  let pwaAppNameRequestSeq = 0;
20
+ let pwaAppNameSavesInFlight = 0;
20
21
 
21
22
  function matchesDisplayMode(query) {
22
23
  try {
@@ -354,20 +355,40 @@ export function syncPwaAppNameInput() {
354
355
  });
355
356
  }
356
357
 
358
+ /** The relay name as this page last read or saved it ('' for none). */
359
+ export function currentPwaAppName() {
360
+ return pwaAppName;
361
+ }
362
+
363
+ /** True while this page's own rename is on its way to the relay. */
364
+ export function isPwaAppNameSaveInFlight() {
365
+ return pwaAppNameSavesInFlight > 0;
366
+ }
367
+
357
368
  export async function updatePwaAppName(rawValue) {
358
369
  const seq = ++pwaAppNameRequestSeq;
359
- const result = await updatePwaAppNameSetting(rawValue);
370
+ pwaAppNameSavesInFlight += 1;
371
+ let result;
372
+ try {
373
+ result = await updatePwaAppNameSetting(rawValue);
374
+ } finally {
375
+ pwaAppNameSavesInFlight -= 1;
376
+ }
360
377
  if (!result || typeof result.appName !== 'string') {
361
378
  alert('Failed to update the relay name.');
362
379
  if (seq === pwaAppNameRequestSeq) paintPwaAppNameInput();
363
380
  return;
364
381
  }
365
- if (seq !== pwaAppNameRequestSeq) return; // a newer edit owns the UI now
366
- pwaAppName = result.appName;
382
+ // The rename is saved whatever came in meanwhile (a newer edit, or a read
383
+ // the relay's own announcement of it started): say so either way. Only the
384
+ // field is left to whichever request is newest.
385
+ if (seq === pwaAppNameRequestSeq) {
386
+ pwaAppName = result.appName;
387
+ paintPwaAppNameInput();
388
+ }
367
389
  showTransientRelayNotice(result.appName
368
390
  ? `Relay name updated to "${result.appName}".`
369
391
  : 'Relay name reset to default.');
370
- paintPwaAppNameInput();
371
392
  // The same name is what paired relays see (GET /api/relay/identity); the
372
393
  // Relays tab re-reads it, including the host-name fallback for an empty one.
373
394
  window.dispatchEvent(new CustomEvent('oar:relay-name-changed', { detail: { name: result.appName } }));
@@ -123,6 +123,8 @@ function stripAttachmentPromptArtifacts(text) {
123
123
  // served to the browser and cannot import shared/, so the block is matched
124
124
  // structurally — the heading plus its single paragraph — rather than verbatim.
125
125
  const MEDIA_EMBED_BLOCK_PATTERN = /##\s*Embedding media in replies\s+[\s\S]*?(?=\n\s*\n|$)/gi;
126
+ // The formula guidance (shared/math-instructions.mjs) follows it, in the same shape.
127
+ const MATH_BLOCK_PATTERN = /##\s*Formulas in replies\s+[\s\S]*?(?=\n\s*\n|$)/gi;
126
128
 
127
129
  // A steered message's hidden note (shared/steer-note.mjs) leads the prompt the
128
130
  // runtime stored, right after any mode marker. Matched by its stable opening
@@ -166,6 +168,7 @@ export function stripRelayPromptContext(text, relayMode = '', options = {}) {
166
168
  const origin = options && typeof options === 'object' ? options.origin : null;
167
169
  const value = stripAttachmentPromptArtifacts(text)
168
170
  .replace(MEDIA_EMBED_BLOCK_PATTERN, '')
171
+ .replace(MATH_BLOCK_PATTERN, '')
169
172
  .trim();
170
173
  if (!value) return '';
171
174
  const patterns = buildPromptPrefixPatterns(relayMode);
@@ -265,7 +265,7 @@ export function renderRestartConfirmBody(picture = {}, { maxWaitMs = 0 } = {}) {
265
265
  parts.push(`<p>The restart waits until these running turns end:</p>`);
266
266
  parts.push(listHtml(sessions.map((entry) => sessionItemHtml(entry, Number(entry?.turns) > 1 ? `(${entry.turns} turns)` : ''))));
267
267
  parts.push(`<p>Until then new messages are held${heldCount ? ` (${plural(heldCount, 'message')} already waiting)` : ''}; they run after the restart. `
268
- + `${Number(maxWaitMs) > 0 ? `After ${escHtml(formatMaxWait(maxWaitMs))} the banner asks whether to restart now, keep waiting or cancel.` : 'There is no time limit on the wait (Settings › General).'}</p>`);
268
+ + `${Number(maxWaitMs) > 0 ? `After ${escHtml(formatMaxWait(maxWaitMs))} the banner asks whether to restart now, keep waiting or cancel.` : 'There is no time limit on the wait (Settings › Relays).'}</p>`);
269
269
  } else {
270
270
  parts.push(`<p>Nothing is running: the relay restarts at once${heldCount ? `, and the ${plural(heldCount, 'waiting message')} run after it` : ''}.</p>`);
271
271
  }
@@ -503,7 +503,7 @@ export async function cancelQueuedRelayRestart() {
503
503
  });
504
504
  }
505
505
 
506
- // ─── Settings › General: the maximum wait ────────────────────────────────────
506
+ // ─── Settings › Relays: the maximum wait ─────────────────────────────────────
507
507
  // Mirrors server/services/relay-restart-queue.mjs (the browser cannot import
508
508
  // it); the select's options are the allowed values.
509
509
  let maxWaitMinutes = 30;
@@ -23,6 +23,8 @@ import {
23
23
  upsertRemoteRelay,
24
24
  } from './remote-relays-store.mjs';
25
25
  import { REMOTE_RELAY_PERMISSIONS, normalizeRemoteRelayPermission } from './remote-relay-shared.mjs';
26
+ import { currentPwaAppName, isPwaAppNameSaveInFlight, syncPwaAppNameInput } from './pwa-install.js';
27
+ import { SETTINGS_TAB_SHOWN_EVENT } from './settings-tabs.js';
26
28
 
27
29
  export const REMOTE_RELAY_PERMISSION_LABELS = Object.freeze({
28
30
  read: 'Read only',
@@ -54,6 +56,9 @@ let listLoadFailed = false;
54
56
  const rowBusy = new Map();
55
57
  let listRenderDeferred = false;
56
58
  let listFocusBound = false;
59
+ // The relay's name as the last snapshot had it: a change made elsewhere is
60
+ // read into the Relay name field.
61
+ let lastSelfName = null;
57
62
 
58
63
  function el(id) {
59
64
  return document.getElementById(id);
@@ -164,12 +169,10 @@ function setStatusLine(id, state, text) {
164
169
  }
165
170
 
166
171
  function renderSelfSection() {
167
- const snapshot = getRemoteRelaysSnapshot();
168
- const nameEl = el('remote-relays-self-name');
169
- if (nameEl) {
170
- const name = String(snapshot?.self?.name || '').trim();
171
- nameEl.textContent = name || '—';
172
- }
172
+ // Empty, the field shows the name the relay goes by then (its host name).
173
+ const nameInput = el('pwa-app-name-input');
174
+ const defaultName = String(getRemoteRelaysSnapshot()?.self?.defaultName || '').trim();
175
+ if (nameInput && defaultName) nameInput.placeholder = defaultName;
173
176
  const input = el('remote-relays-public-url-input');
174
177
  if (input) {
175
178
  input.placeholder = browserSelfUrl() || 'https://…';
@@ -590,9 +593,27 @@ export function resetRemoteRelaysSettingsForTests() {
590
593
  addInFlight = false;
591
594
  listLoadFailed = false;
592
595
  listRenderDeferred = false;
596
+ lastSelfName = null;
593
597
  rowBusy.clear();
594
598
  }
595
599
 
600
+ // A rename on another device (the relay announces it with the list) or the
601
+ // Relays tab coming into view reads the name again; the field keeps what is
602
+ // being typed in it (pwa-install.js).
603
+ function refreshRelayNameOnChange(snapshot) {
604
+ const name = typeof snapshot?.self?.name === 'string' ? snapshot.self.name : null;
605
+ if (name === null) return;
606
+ const changed = lastSelfName !== null && name !== lastSelfName;
607
+ lastSelfName = name;
608
+ if (!changed) return;
609
+ // This page's own rename: its answer paints the field.
610
+ if (isPwaAppNameSaveInFlight()) return;
611
+ // A name this page already has (an empty one goes by the default).
612
+ const known = currentPwaAppName() || String(snapshot.self.defaultName || '');
613
+ if (name === known) return;
614
+ syncPwaAppNameInput();
615
+ }
616
+
596
617
  // The relay's own settings ride along in the list payload's `self` (GET and
597
618
  // remote_relays_updated alike), so a change made on another device shows here
598
619
  // too — except while this page is saving the same field.
@@ -608,6 +629,7 @@ function applySelfSettingsFromSnapshot(snapshot) {
608
629
  // Live: a remote_relays_updated event (or any other store change) repaints.
609
630
  subscribeRemoteRelays((snapshot) => {
610
631
  applySelfSettingsFromSnapshot(snapshot);
632
+ refreshRelayNameOnChange(snapshot);
611
633
  renderRemoteRelaysSection();
612
634
  });
613
635
 
@@ -615,4 +637,9 @@ if (typeof window !== 'undefined' && typeof window.addEventListener === 'functio
615
637
  window.addEventListener(RELAY_NAME_CHANGED_EVENT, () => {
616
638
  void refreshRemoteRelays();
617
639
  });
640
+ window.addEventListener(SETTINGS_TAB_SHOWN_EVENT, (event) => {
641
+ if (event?.detail?.tab !== 'relays') return;
642
+ syncPwaAppNameInput();
643
+ void refreshRemoteRelays();
644
+ });
618
645
  }