switchroom 0.16.28 → 0.16.38
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.
- package/dist/agent-scheduler/index.js +13 -2
- package/dist/auth-broker/index.js +13 -2
- package/dist/cli/notion-write-pretool.mjs +13 -2
- package/dist/cli/switchroom.js +60648 -59800
- package/dist/host-control/main.js +730 -18
- package/dist/vault/approvals/kernel-server.js +51 -37
- package/dist/vault/broker/server.js +72 -58
- package/package.json +2 -1
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/card-format.ts +52 -2
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
- package/telegram-plugin/dist/server.js +0 -1
- package/telegram-plugin/format.ts +264 -7
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1293 -97
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -1
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +51 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +88 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
- package/telegram-plugin/tool-activity-summary.ts +11 -8
- package/telegram-plugin/uat/driver.ts +8 -1
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +138 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +13 -4
|
@@ -16994,7 +16994,6 @@ var init_plugin_logger = __esm(() => {
|
|
|
16994
16994
|
DEFAULT_LOG_PATH2 = join2(homedir2(), ".switchroom", "logs", "telegram-plugin.log");
|
|
16995
16995
|
ROTATE_AT_BYTES2 = 50 * 1024 * 1024;
|
|
16996
16996
|
});
|
|
16997
|
-
|
|
16998
16997
|
// operator-events.ts
|
|
16999
16998
|
function classifyClaudeError(raw) {
|
|
17000
16999
|
try {
|
|
@@ -53,6 +53,24 @@ export function escapeMarkdown(text: string): string {
|
|
|
53
53
|
return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Make a string safe to interpolate INSIDE a `code span`.
|
|
58
|
+
*
|
|
59
|
+
* Inside a GFM code span the content is LITERAL — backslash escaping does
|
|
60
|
+
* NOT apply, so `escapeMarkdown` is exactly wrong there: it emits visible
|
|
61
|
+
* backslashes (e.g. `openai\_key` for an identifier containing `_`). The
|
|
62
|
+
* only character that can prematurely CLOSE the span is a backtick, so the
|
|
63
|
+
* sole transform needed is to defuse embedded backticks. We insert a
|
|
64
|
+
* zero-width space after each backtick so the raw ``` ` ``` can no longer
|
|
65
|
+
* terminate the surrounding span while remaining visually identical.
|
|
66
|
+
*
|
|
67
|
+
* This is the canonical home for the helper (#2695 regression fix); other
|
|
68
|
+
* modules re-export from here so there's one implementation.
|
|
69
|
+
*/
|
|
70
|
+
export function codeSpanSafe(s: string): string {
|
|
71
|
+
return s.replace(/`/g, '`')
|
|
72
|
+
}
|
|
73
|
+
|
|
56
74
|
/**
|
|
57
75
|
* Repair LLM-side JSON escape bungles.
|
|
58
76
|
*
|
|
@@ -190,11 +208,63 @@ function maskCodeRegions(text: string, nonce: string): MaskedCode {
|
|
|
190
208
|
* Code fences and inline code are masked out before any of this runs, so their
|
|
191
209
|
* interior `\n`s are never touched.
|
|
192
210
|
*/
|
|
211
|
+
/**
|
|
212
|
+
* Split a collapsed inline bullet list onto separate lines.
|
|
213
|
+
*
|
|
214
|
+
* Agents sometimes emit an entire bullet list on ONE line using interior
|
|
215
|
+
* `•`/`·` separators, e.g.:
|
|
216
|
+
*
|
|
217
|
+
* • Master Bath 1, clean • <b>Master Bath 2</b>, 33% loss • Cabinet, clean
|
|
218
|
+
*
|
|
219
|
+
* which renders as a single run-on line instead of stacked bullets (the GFM
|
|
220
|
+
* rich path treats `•`/`·` as ordinary text, not list syntax, so nothing
|
|
221
|
+
* stacks). This deterministically inserts a newline before each interior
|
|
222
|
+
* unicode-bullet separator so each bullet lands on its own line.
|
|
223
|
+
*
|
|
224
|
+
* Conservative by construction:
|
|
225
|
+
* - Only a line that STARTS (after optional leading whitespace) with a bullet
|
|
226
|
+
* marker (`•`, `·`, `-`, or `*` followed by a space) is eligible — prose
|
|
227
|
+
* with a mid-sentence `•` is left untouched.
|
|
228
|
+
* - Only the unicode bullets `•`/`·` are split on (an interior whitespace +
|
|
229
|
+
* `•`/`·` + whitespace). `-`/`*` are NEVER used as interior split points —
|
|
230
|
+
* too many false positives (hyphens, ranges, "a * b" multiplication).
|
|
231
|
+
* They are only accepted as the LEADING marker.
|
|
232
|
+
* - The bullet glyphs are left as-is; we only insert `\n` before each split
|
|
233
|
+
* bullet and normalize the inter-bullet whitespace to a single space.
|
|
234
|
+
* - Idempotent: once split, each bullet begins its own line, so the
|
|
235
|
+
* interior-separator pattern (whitespace + bullet + whitespace) no longer
|
|
236
|
+
* matches anywhere on those lines.
|
|
237
|
+
*
|
|
238
|
+
* Runs on already-code-masked text, so a `•` inside a code span/fence is safe.
|
|
239
|
+
*/
|
|
240
|
+
export function splitCollapsedInlineBullets(text: string): string {
|
|
241
|
+
if (!/[•·]/.test(text)) return text
|
|
242
|
+
// Leading marker: optional indent, then • · - or * followed by a space.
|
|
243
|
+
// Interior separator to split on: whitespace + • or · + whitespace.
|
|
244
|
+
const interiorSep = /[ \t]+([•·])[ \t]+/g
|
|
245
|
+
return text
|
|
246
|
+
.split('\n')
|
|
247
|
+
.map((line) => {
|
|
248
|
+
if (!/^[ \t]*[•·*-] /.test(line)) return line
|
|
249
|
+
if (!/[ \t][•·][ \t]/.test(line)) return line
|
|
250
|
+
return line.replace(interiorSep, '\n$1 ')
|
|
251
|
+
})
|
|
252
|
+
.join('\n')
|
|
253
|
+
}
|
|
254
|
+
|
|
193
255
|
export function normalizeParagraphBreaks(text: string): string {
|
|
194
|
-
|
|
256
|
+
// A text with no newline still needs the inline-bullet split (a collapsed
|
|
257
|
+
// bullet list is a SINGLE line). Only bail early when there is neither a
|
|
258
|
+
// newline nor a unicode bullet to potentially split.
|
|
259
|
+
if (!text.includes('\n') && !/[•·]/.test(text)) return text
|
|
195
260
|
|
|
196
261
|
const nonce = Math.random().toString(36).slice(2)
|
|
197
|
-
const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
|
|
262
|
+
const { masked: maskedRaw, restore, placeholder } = maskCodeRegions(text, nonce)
|
|
263
|
+
|
|
264
|
+
// Step 0: split a collapsed inline bullet list onto separate lines. Done on
|
|
265
|
+
// the code-masked text so a `•` inside a fenced block or inline code span is
|
|
266
|
+
// never touched. See splitCollapsedInlineBullets for the exact rule.
|
|
267
|
+
const masked = splitCollapsedInlineBullets(maskedRaw)
|
|
198
268
|
|
|
199
269
|
// Step 1: collapse 3+ newlines to exactly two. This also normalizes runs that
|
|
200
270
|
// contain interleaved spaces only between the newlines is NOT done here —
|
|
@@ -236,8 +306,10 @@ export function normalizeParagraphBreaks(text: string): string {
|
|
|
236
306
|
// Step 3: guarantee a blank line (`\n\n`) at BLOCK BOUNDARIES. The
|
|
237
307
|
// prose-promotion above keeps lists/tables tight by leaving their single
|
|
238
308
|
// `\n` separators alone — but GFM's rich renderer needs a blank line to
|
|
239
|
-
// START a new block
|
|
240
|
-
//
|
|
309
|
+
// START a new block. A properly block-separated table DOES render as a
|
|
310
|
+
// real table (Bot API 10.1 rich messages); the failure mode this pass
|
|
311
|
+
// fixes is the *glued* case — a block joined to the previous line by a
|
|
312
|
+
// single `\n` (an un-separated table degrades to literal pipe text, prose
|
|
241
313
|
// after a list is absorbed as a lazy list continuation). This pass inserts
|
|
242
314
|
// the missing blank line at those transitions only, on the same masked text,
|
|
243
315
|
// never touching code interiors, never collapsing/expanding existing `\n\n`,
|
|
@@ -247,6 +319,156 @@ export function normalizeParagraphBreaks(text: string): string {
|
|
|
247
319
|
return restore(out)
|
|
248
320
|
}
|
|
249
321
|
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Paragraph spacers — restore a VISIBLE blank line between prose paragraphs
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The non-collapsible spacer paragraph injected between two prose paragraphs.
|
|
328
|
+
*
|
|
329
|
+
* Telegram's Bot API 10.1 rich-message renderer (the GFM/CommonMark engine
|
|
330
|
+
* behind `sendRichMessage` / `editMessageText({ markdown })`) renders a `\n\n`
|
|
331
|
+
* paragraph break TIGHT — the two paragraphs sit on adjacent lines with no
|
|
332
|
+
* visible empty line between them. The legacy markdown→HTML path (removed in
|
|
333
|
+
* #2669) sent `\n\n` literally with `parse_mode:"HTML"`, where two newlines
|
|
334
|
+
* render as a real blank line. That regression is the operator-confirmed
|
|
335
|
+
* "paragraphs jammed together" symptom.
|
|
336
|
+
*
|
|
337
|
+
* CommonMark discards blank lines made of ASCII whitespace, but a line whose
|
|
338
|
+
* only content is a NON-breaking space (U+00A0) is a genuine, non-empty
|
|
339
|
+
* paragraph — it renders as a visible empty line. So `A\n\n \n\nB`
|
|
340
|
+
* renders as three paragraphs: A, a blank-looking line, then B — the visible
|
|
341
|
+
* gap the HTML path used to produce.
|
|
342
|
+
*/
|
|
343
|
+
export const PARAGRAPH_SPACER = ' '
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Insert a visible blank-line spacer into each genuine `\n\n` paragraph gap so
|
|
347
|
+
* the rich GFM renderer shows a real empty line between paragraphs (matching
|
|
348
|
+
* the pre-#2669 HTML behaviour). See PARAGRAPH_SPACER for why a U+00A0 line is
|
|
349
|
+
* the reliable trick.
|
|
350
|
+
*
|
|
351
|
+
* Conservative by construction — a spacer is inserted into a `\n\n` gap ONLY
|
|
352
|
+
* when BOTH the block ENDING above the gap and the block STARTING below it are
|
|
353
|
+
* ordinary prose. A gap adjacent to a structural block (list item, table row,
|
|
354
|
+
* fenced code, blockquote, heading, indented code) is left exactly as it was:
|
|
355
|
+
* those blocks already carry their own vertical rhythm, and a spacer paragraph
|
|
356
|
+
* wedged against a tight list / table would either re-introduce the cramped
|
|
357
|
+
* look from the other side or break the block's contiguity.
|
|
358
|
+
*
|
|
359
|
+
* Runs on code-masked text (so a blank line inside a fenced block is never
|
|
360
|
+
* touched) and is idempotent — a gap that already contains a U+00A0 spacer
|
|
361
|
+
* paragraph is recognised and never doubled.
|
|
362
|
+
*
|
|
363
|
+
* Intended to run in the outbound send path AFTER normalizeParagraphBreaks,
|
|
364
|
+
* which has already collapsed 3+ newline runs to `\n\n`, promoted lone prose
|
|
365
|
+
* breaks, and guaranteed block-boundary blank lines. normalizeParagraphBreaks
|
|
366
|
+
* itself deliberately does NOT do this so its (well-tested) `\n\n`-preserving
|
|
367
|
+
* contract is unchanged.
|
|
368
|
+
*/
|
|
369
|
+
export function addParagraphSpacers(text: string): string {
|
|
370
|
+
if (!text.includes('\n\n')) return text
|
|
371
|
+
|
|
372
|
+
const nonce = Math.random().toString(36).slice(2)
|
|
373
|
+
const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
|
|
374
|
+
|
|
375
|
+
if (!masked.includes('\n\n')) return restore(masked)
|
|
376
|
+
|
|
377
|
+
// The line we inject for a spacer paragraph (its only content is U+00A0).
|
|
378
|
+
const spacerLine = PARAGRAPH_SPACER
|
|
379
|
+
|
|
380
|
+
// CRITICAL: `String.prototype.trim()` strips U+00A0, so a spacer line would
|
|
381
|
+
// read as "blank" and the pass would lose idempotency (re-spacing an
|
|
382
|
+
// already-spaced gap). Detect blank-ness with an ASCII-whitespace-only test
|
|
383
|
+
// so the U+00A0 spacer line is correctly seen as NON-blank.
|
|
384
|
+
const isBlankLine = (line: string): boolean => /^[ \t\r\f\v]*$/.test(line)
|
|
385
|
+
// Trim ASCII-only (preserve U+00A0) so the spacer line is recognisable.
|
|
386
|
+
const asciiTrim = (line: string): string => line.replace(/^[ \t\r\f\v]+|[ \t\r\f\v]+$/g, '')
|
|
387
|
+
|
|
388
|
+
// A line is "prose" for spacing purposes when it is non-blank, not a
|
|
389
|
+
// structural block marker, not the spacer line itself. Only a gap with prose
|
|
390
|
+
// on BOTH sides earns a visible spacer.
|
|
391
|
+
const isProseLine = (line: string): boolean => {
|
|
392
|
+
if (isBlankLine(line)) return false
|
|
393
|
+
if (asciiTrim(line) === spacerLine) return false
|
|
394
|
+
if (isMarkerLine(line, placeholder)) return false
|
|
395
|
+
// A standalone masked fenced block line is structural.
|
|
396
|
+
if (isFenceOpenLine(line, placeholder)) return false
|
|
397
|
+
return true
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Split into blank-line-delimited segments, then re-join inserting a spacer
|
|
401
|
+
// paragraph between two adjacent NON-blank segments whose facing lines are
|
|
402
|
+
// both prose and which are not already separated by a spacer.
|
|
403
|
+
// A `\n\n` paragraph gap is a SINGLE blank entry between two content lines
|
|
404
|
+
// (`"A\n\nB".split('\n')` → `["A", "", "B"]`). normalizeParagraphBreaks has
|
|
405
|
+
// already collapsed 3+ newline runs to exactly `\n\n`, so we only ever see a
|
|
406
|
+
// one-blank gap here; a multi-blank run is handled defensively the same way
|
|
407
|
+
// (the FIRST blank of the run carries the spacer decision).
|
|
408
|
+
const lines = masked.split('\n')
|
|
409
|
+
const out: string[] = []
|
|
410
|
+
for (let i = 0; i < lines.length; i++) {
|
|
411
|
+
const line = lines[i]
|
|
412
|
+
const isBlank = isBlankLine(line)
|
|
413
|
+
// The spacer decision is made at the FIRST blank of a gap, i.e. when the
|
|
414
|
+
// previously emitted line is non-blank prose. Inject the spacer BEFORE the
|
|
415
|
+
// blank so the result is `above \n\n \n\n below`.
|
|
416
|
+
if (isBlank) {
|
|
417
|
+
const prevEmitted = out.length > 0 ? out[out.length - 1] : null
|
|
418
|
+
const prevIsBlank = prevEmitted != null && isBlankLine(prevEmitted)
|
|
419
|
+
if (!prevIsBlank) {
|
|
420
|
+
const above = lastNonBlank(out, isBlankLine)
|
|
421
|
+
const below = nextNonBlank(lines, i + 1, isBlankLine)
|
|
422
|
+
const alreadySpaced =
|
|
423
|
+
(above != null && asciiTrim(above) === spacerLine) ||
|
|
424
|
+
(below != null && asciiTrim(below) === spacerLine)
|
|
425
|
+
if (
|
|
426
|
+
!alreadySpaced &&
|
|
427
|
+
above != null &&
|
|
428
|
+
below != null &&
|
|
429
|
+
isProseLine(above) &&
|
|
430
|
+
isProseLine(below)
|
|
431
|
+
) {
|
|
432
|
+
// Emit: blank, spacer paragraph, blank — a U+00A0 paragraph wedged
|
|
433
|
+
// between two real blank lines so CommonMark renders it as a visible
|
|
434
|
+
// empty line between the two prose paragraphs.
|
|
435
|
+
out.push('')
|
|
436
|
+
out.push(spacerLine)
|
|
437
|
+
out.push('')
|
|
438
|
+
continue
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
out.push(line)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return restore(out.join('\n'))
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Last non-blank entry already emitted into `arr`, or null. `isBlank` is the
|
|
450
|
+
* caller's blank test (ASCII-only, so a U+00A0 spacer line counts as
|
|
451
|
+
* non-blank — `String.trim()` would wrongly strip it).
|
|
452
|
+
*/
|
|
453
|
+
function lastNonBlank(arr: string[], isBlank: (s: string) => boolean): string | null {
|
|
454
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
455
|
+
if (!isBlank(arr[i])) return arr[i]
|
|
456
|
+
}
|
|
457
|
+
return null
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** First non-blank line at or after index `from` in `lines`, or null. */
|
|
461
|
+
function nextNonBlank(
|
|
462
|
+
lines: string[],
|
|
463
|
+
from: number,
|
|
464
|
+
isBlank: (s: string) => boolean,
|
|
465
|
+
): string | null {
|
|
466
|
+
for (let i = from; i < lines.length; i++) {
|
|
467
|
+
if (!isBlank(lines[i])) return lines[i]
|
|
468
|
+
}
|
|
469
|
+
return null
|
|
470
|
+
}
|
|
471
|
+
|
|
250
472
|
// ---------------------------------------------------------------------------
|
|
251
473
|
// Block-boundary blank-line guarantee (Step 3 of normalizeParagraphBreaks)
|
|
252
474
|
// ---------------------------------------------------------------------------
|
|
@@ -495,11 +717,46 @@ export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHAR
|
|
|
495
717
|
break
|
|
496
718
|
}
|
|
497
719
|
|
|
498
|
-
chunks.push(rest.slice(0, cut))
|
|
499
|
-
rest = rest.slice(cut)
|
|
720
|
+
chunks.push(stripBoundarySpacers(rest.slice(0, cut), 'trailing'))
|
|
721
|
+
rest = stripBoundarySpacers(rest.slice(cut), 'leading')
|
|
500
722
|
}
|
|
501
723
|
|
|
502
|
-
return chunks
|
|
724
|
+
return chunks.map((c) => stripBoundarySpacers(c, 'trailing'))
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Strip stray paragraph-spacer / blank lines off a chunk boundary so a cut that
|
|
729
|
+
* lands inside an injected spacer gap (`\n\n${PARAGRAPH_SPACER}\n\n`, see
|
|
730
|
+
* addParagraphSpacers) never leaves a continuation chunk that OPENS with a bare
|
|
731
|
+
* U+00A0 spacer line, nor a prior chunk that ENDS with one.
|
|
732
|
+
*
|
|
733
|
+
* A "boundary blank run" is any sequence of newlines and spacer-only lines (a
|
|
734
|
+
* line whose only content is the U+00A0 spacer, optionally surrounded by ASCII
|
|
735
|
+
* spaces/tabs) in ANY interleaving — `\n \n`, ` \n\n`, `\n\n \n`, etc. The
|
|
736
|
+
* legacy behaviour (strip leading ASCII `\n+` only) is a strict subset, so a
|
|
737
|
+
* boundary with NO spacer is unaffected. Idempotent: a chunk already trimmed
|
|
738
|
+
* has nothing left to strip.
|
|
739
|
+
*
|
|
740
|
+
* - `'leading'` → strip the run from the START (the continuation chunk).
|
|
741
|
+
* - `'trailing'` → strip the run from the END (the just-emitted prior chunk).
|
|
742
|
+
*/
|
|
743
|
+
function stripBoundarySpacers(chunk: string, side: 'leading' | 'trailing'): string {
|
|
744
|
+
// One blank-or-spacer line: optional ASCII ws, optional one U+00A0, optional
|
|
745
|
+
// ASCII ws — i.e. a line that renders empty. A run of these (joined by \n,
|
|
746
|
+
// with leading/trailing \n) is what we peel off the boundary.
|
|
747
|
+
const sp = PARAGRAPH_SPACER
|
|
748
|
+
if (side === 'leading') {
|
|
749
|
+
// Leading: one-or-more newlines, optionally with spacer-only lines mixed in.
|
|
750
|
+
return chunk.replace(
|
|
751
|
+
new RegExp(`^(?:[ \\t]*${sp}?[ \\t]*\\n+)+`),
|
|
752
|
+
'',
|
|
753
|
+
)
|
|
754
|
+
}
|
|
755
|
+
// Trailing: a newline run, optionally with spacer-only lines, at the very end.
|
|
756
|
+
return chunk.replace(
|
|
757
|
+
new RegExp(`(?:\\n+[ \\t]*${sp}?[ \\t]*)+$`),
|
|
758
|
+
'',
|
|
759
|
+
)
|
|
503
760
|
}
|
|
504
761
|
|
|
505
762
|
/**
|
|
@@ -14,8 +14,13 @@
|
|
|
14
14
|
* param = (for ttl) 1h | 24h | 7d
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { escapeMarkdown, codeSpanSafe } from '../format.js';
|
|
17
18
|
import { InlineKeyboard } from "grammy";
|
|
18
19
|
|
|
20
|
+
// Re-export so existing importers (e.g. vault-request-access-card.ts) keep
|
|
21
|
+
// resolving `codeSpanSafe` from here; the canonical impl lives in format.ts.
|
|
22
|
+
export { codeSpanSafe };
|
|
23
|
+
|
|
19
24
|
export interface ApprovalCardOptions {
|
|
20
25
|
request_id: string; // 8-hex from kernel.requestApproval
|
|
21
26
|
agent: string; // shown in the title
|
|
@@ -37,7 +42,7 @@ export interface BuiltApprovalCard {
|
|
|
37
42
|
*/
|
|
38
43
|
export function buildApprovalCard(opts: ApprovalCardOptions): BuiltApprovalCard {
|
|
39
44
|
const lines: string[] = [];
|
|
40
|
-
lines.push(`🔐 **${
|
|
45
|
+
lines.push(`🔐 **${escapeMarkdown(opts.agent)}** wants approval`);
|
|
41
46
|
// The scope rides in a `code span` — content there is LITERAL, so it must
|
|
42
47
|
// NOT be markdown-escaped (escaping would leak visible backslashes, e.g.
|
|
43
48
|
// `secret:OPENAI\_API\_KEY`). Only an embedded backtick could break out of
|
|
@@ -45,7 +50,7 @@ export function buildApprovalCard(opts: ApprovalCardOptions): BuiltApprovalCard
|
|
|
45
50
|
lines.push(`\`${codeSpanSafe(opts.scope_humanized)}\``);
|
|
46
51
|
if (opts.why && opts.why.trim().length > 0) {
|
|
47
52
|
lines.push("");
|
|
48
|
-
lines.push(
|
|
53
|
+
lines.push(escapeMarkdown(opts.why.trim()));
|
|
49
54
|
}
|
|
50
55
|
const text = lines.join("\n");
|
|
51
56
|
|
|
@@ -123,15 +128,3 @@ export function ttlMsFromToken(token: string): number | null {
|
|
|
123
128
|
return null;
|
|
124
129
|
}
|
|
125
130
|
|
|
126
|
-
function escapeHtml(s: string): string {
|
|
127
|
-
return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Make a string safe to interpolate INSIDE a `code span` without escaping
|
|
132
|
-
* (content there is literal). The only character that can prematurely close
|
|
133
|
-
* the span is a backtick, so split it with a zero-width space.
|
|
134
|
-
*/
|
|
135
|
-
function codeSpanSafe(s: string): string {
|
|
136
|
-
return s.replace(/`/g, "`");
|
|
137
|
-
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* add on top of the same client. Tracked in the migration TODO inline.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { escapeMarkdown, codeSpanSafe } from '../format.js';
|
|
16
17
|
import type { Bot, Context } from "grammy";
|
|
17
18
|
import { richMessage } from "../rich-send.js";
|
|
18
19
|
import {
|
|
@@ -65,7 +66,7 @@ export function registerApprovalsCommands(
|
|
|
65
66
|
const byAgent = new Map<string, number>();
|
|
66
67
|
for (const d of decisions) byAgent.set(d.agent_unit, (byAgent.get(d.agent_unit) ?? 0) + 1);
|
|
67
68
|
const summary = Array.from(byAgent.entries())
|
|
68
|
-
.map(([a, n]) => `• **${
|
|
69
|
+
.map(([a, n]) => `• **${escapeMarkdown(a)}**: ${n}`)
|
|
69
70
|
.join("\n");
|
|
70
71
|
const detail = decisions
|
|
71
72
|
.slice(0, 20)
|
|
@@ -76,10 +77,10 @@ export function registerApprovalsCommands(
|
|
|
76
77
|
: `until ${new Date(d.ttl_expires_at).toISOString().slice(0, 16).replace("T", " ")}`;
|
|
77
78
|
return (
|
|
78
79
|
`\`${d.id.slice(0, 8)}\` ` +
|
|
79
|
-
`${
|
|
80
|
-
`\`${d.scope}\` ` +
|
|
81
|
-
`(${
|
|
82
|
-
`· /approvals revoke ${
|
|
80
|
+
`${escapeMarkdown(d.agent_unit)} → ` +
|
|
81
|
+
`\`${codeSpanSafe(d.scope)}\` ` +
|
|
82
|
+
`(${escapeMarkdown(d.action)}, ${ttl}) ` +
|
|
83
|
+
`· /approvals revoke ${escapeMarkdown(d.id)}`
|
|
83
84
|
);
|
|
84
85
|
})
|
|
85
86
|
.join("\n");
|
|
@@ -114,7 +115,3 @@ export function registerApprovalsCommands(
|
|
|
114
115
|
));
|
|
115
116
|
});
|
|
116
117
|
}
|
|
117
|
-
|
|
118
|
-
function escapeHtml(s: string): string {
|
|
119
|
-
return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
|
|
120
|
-
}
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* rather than throwing.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { escapeMarkdown, codeSpanSafe } from '../format.js'
|
|
31
32
|
import type { ListStateData, AccountState } from './auth-line.js'
|
|
32
33
|
import {
|
|
33
34
|
buildSnapshotsFromState,
|
|
@@ -158,7 +159,7 @@ export function parseAuthCommand(text: string): ParsedAuthCommand | null {
|
|
|
158
159
|
if (tail.length > 0) {
|
|
159
160
|
return {
|
|
160
161
|
kind: 'help',
|
|
161
|
-
reason: `Unknown \`rm\` modifier: \`${
|
|
162
|
+
reason: `Unknown \`rm\` modifier: \`${codeSpanSafe(tail)}\`. Use \`/auth rm <label> confirm\` to confirm.`,
|
|
162
163
|
}
|
|
163
164
|
}
|
|
164
165
|
return { kind: 'rm-prompt', label }
|
|
@@ -180,7 +181,7 @@ export function parseAuthCommand(text: string): ParsedAuthCommand | null {
|
|
|
180
181
|
if (sub !== 'override') {
|
|
181
182
|
return {
|
|
182
183
|
kind: 'help',
|
|
183
|
-
reason: `Unknown \`agent\` subcommand: \`${
|
|
184
|
+
reason: `Unknown \`agent\` subcommand: \`${codeSpanSafe(sub || '(none)')}\`. Try \`/auth agent override <agent> <label|clear>\`.`,
|
|
184
185
|
}
|
|
185
186
|
}
|
|
186
187
|
const agent = parts[2]
|
|
@@ -201,7 +202,7 @@ export function parseAuthCommand(text: string): ParsedAuthCommand | null {
|
|
|
201
202
|
case 'help':
|
|
202
203
|
return { kind: 'help' }
|
|
203
204
|
default:
|
|
204
|
-
return { kind: 'help', reason: `Unknown verb: \`${
|
|
205
|
+
return { kind: 'help', reason: `Unknown verb: \`${codeSpanSafe(verb)}\`` }
|
|
205
206
|
}
|
|
206
207
|
}
|
|
207
208
|
|
|
@@ -417,7 +418,7 @@ export async function handleAuthCommand(
|
|
|
417
418
|
}
|
|
418
419
|
} catch (err) {
|
|
419
420
|
return {
|
|
420
|
-
text: `**/auth show failed:** ${
|
|
421
|
+
text: `**/auth show failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
|
|
421
422
|
html: true,
|
|
422
423
|
}
|
|
423
424
|
}
|
|
@@ -432,7 +433,7 @@ export async function handleAuthCommand(
|
|
|
432
433
|
if (!agent) {
|
|
433
434
|
return {
|
|
434
435
|
text:
|
|
435
|
-
`**/auth show:** no agent named \`${
|
|
436
|
+
`**/auth show:** no agent named \`${codeSpanSafe(agentName)}\` in broker view.\n` +
|
|
436
437
|
`Run \`/auth show\` for the fleet snapshot.`,
|
|
437
438
|
html: true,
|
|
438
439
|
}
|
|
@@ -440,7 +441,7 @@ export async function handleAuthCommand(
|
|
|
440
441
|
return { text: renderAgentDetail(state, agent), html: true }
|
|
441
442
|
} catch (err) {
|
|
442
443
|
return {
|
|
443
|
-
text: `**/auth show failed:** ${
|
|
444
|
+
text: `**/auth show failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
|
|
444
445
|
html: true,
|
|
445
446
|
}
|
|
446
447
|
}
|
|
@@ -476,13 +477,13 @@ export async function handleAuthCommand(
|
|
|
476
477
|
const result = await ctx.client.setActive(parsed.label)
|
|
477
478
|
return {
|
|
478
479
|
text:
|
|
479
|
-
`**Active account →** \`${
|
|
480
|
+
`**Active account →** \`${codeSpanSafe(result.active)}\`\n` +
|
|
480
481
|
`Re-mirrored credentials for ${result.fanned.length} agent${result.fanned.length === 1 ? '' : 's'}.`,
|
|
481
482
|
html: true,
|
|
482
483
|
}
|
|
483
484
|
} catch (err) {
|
|
484
485
|
return {
|
|
485
|
-
text: `**/auth use failed:** ${
|
|
486
|
+
text: `**/auth use failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
|
|
486
487
|
html: true,
|
|
487
488
|
}
|
|
488
489
|
}
|
|
@@ -504,13 +505,13 @@ export async function handleAuthCommand(
|
|
|
504
505
|
const result = await ctx.client.setActive(nextLabel)
|
|
505
506
|
return {
|
|
506
507
|
text:
|
|
507
|
-
`**Rotated:** active → \`${
|
|
508
|
+
`**Rotated:** active → \`${codeSpanSafe(result.active)}\`\n` +
|
|
508
509
|
`Re-mirrored credentials for ${result.fanned.length} agent${result.fanned.length === 1 ? '' : 's'}.`,
|
|
509
510
|
html: true,
|
|
510
511
|
}
|
|
511
512
|
} catch (err) {
|
|
512
513
|
return {
|
|
513
|
-
text: `**/auth rotate failed:** ${
|
|
514
|
+
text: `**/auth rotate failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
|
|
514
515
|
html: true,
|
|
515
516
|
}
|
|
516
517
|
}
|
|
@@ -525,7 +526,7 @@ export async function handleAuthCommand(
|
|
|
525
526
|
state = await ctx.client.listState()
|
|
526
527
|
} catch (err) {
|
|
527
528
|
return {
|
|
528
|
-
text: `**/auth rm failed:** ${
|
|
529
|
+
text: `**/auth rm failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
|
|
529
530
|
html: true,
|
|
530
531
|
}
|
|
531
532
|
}
|
|
@@ -533,7 +534,7 @@ export async function handleAuthCommand(
|
|
|
533
534
|
if (!exists) {
|
|
534
535
|
return {
|
|
535
536
|
text:
|
|
536
|
-
`**/auth rm:** no account named \`${
|
|
537
|
+
`**/auth rm:** no account named \`${codeSpanSafe(parsed.label)}\`. ` +
|
|
537
538
|
`Run \`/auth show\` for the current list.`,
|
|
538
539
|
html: true,
|
|
539
540
|
}
|
|
@@ -541,7 +542,7 @@ export async function handleAuthCommand(
|
|
|
541
542
|
if (state.active === parsed.label) {
|
|
542
543
|
return {
|
|
543
544
|
text:
|
|
544
|
-
`**/auth rm refused.** \`${
|
|
545
|
+
`**/auth rm refused.** \`${codeSpanSafe(parsed.label)}\` is the fleet active. ` +
|
|
545
546
|
`Switch with \`/auth use <other>\` or \`/auth rotate\` first.`,
|
|
546
547
|
html: true,
|
|
547
548
|
}
|
|
@@ -560,9 +561,9 @@ export async function handleAuthCommand(
|
|
|
560
561
|
}
|
|
561
562
|
return {
|
|
562
563
|
text:
|
|
563
|
-
`**⚠ /auth rm** — about to remove \`${
|
|
564
|
-
`The fleet active is unchanged. Any agent override pointing at \`${
|
|
565
|
-
`Send \`/auth rm ${
|
|
564
|
+
`**⚠ /auth rm** — about to remove \`${codeSpanSafe(parsed.label)}\` from the broker.\n` +
|
|
565
|
+
`The fleet active is unchanged. Any agent override pointing at \`${codeSpanSafe(parsed.label)}\` will stop working.\n\n` +
|
|
566
|
+
`Send \`/auth rm ${codeSpanSafe(parsed.label)} confirm\` within ${Math.round(
|
|
566
567
|
AUTH_RM_CONFIRM_TTL_MS / 1000,
|
|
567
568
|
)}s to proceed.`,
|
|
568
569
|
html: true,
|
|
@@ -578,8 +579,8 @@ export async function handleAuthCommand(
|
|
|
578
579
|
}
|
|
579
580
|
return {
|
|
580
581
|
text:
|
|
581
|
-
`**/auth rm:** no pending confirm for \`${
|
|
582
|
-
`Send \`/auth rm ${
|
|
582
|
+
`**/auth rm:** no pending confirm for \`${codeSpanSafe(parsed.label)}\` (expired or not started). ` +
|
|
583
|
+
`Send \`/auth rm ${codeSpanSafe(parsed.label)}\` first.`,
|
|
583
584
|
html: true,
|
|
584
585
|
}
|
|
585
586
|
}
|
|
@@ -589,12 +590,12 @@ export async function handleAuthCommand(
|
|
|
589
590
|
try {
|
|
590
591
|
const data = await ctx.client.rmAccount(parsed.label)
|
|
591
592
|
return {
|
|
592
|
-
text: `**Removed** \`${
|
|
593
|
+
text: `**Removed** \`${codeSpanSafe(data.label)}\` from the broker.`,
|
|
593
594
|
html: true,
|
|
594
595
|
}
|
|
595
596
|
} catch (err) {
|
|
596
597
|
return {
|
|
597
|
-
text: `**/auth rm failed:** ${
|
|
598
|
+
text: `**/auth rm failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
|
|
598
599
|
html: true,
|
|
599
600
|
}
|
|
600
601
|
}
|
|
@@ -609,7 +610,7 @@ export async function handleAuthCommand(
|
|
|
609
610
|
if (parsed.label && targets.length === 0) {
|
|
610
611
|
return {
|
|
611
612
|
text:
|
|
612
|
-
`**/auth refresh:** no account named \`${
|
|
613
|
+
`**/auth refresh:** no account named \`${codeSpanSafe(parsed.label)}\`.`,
|
|
613
614
|
html: true,
|
|
614
615
|
}
|
|
615
616
|
}
|
|
@@ -629,13 +630,13 @@ export async function handleAuthCommand(
|
|
|
629
630
|
])
|
|
630
631
|
} catch (err) {
|
|
631
632
|
failures.push(
|
|
632
|
-
`${label}: ${
|
|
633
|
+
`${label}: ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
|
|
633
634
|
)
|
|
634
635
|
}
|
|
635
636
|
}
|
|
636
637
|
const head =
|
|
637
638
|
targets.length === 1
|
|
638
|
-
? `**Refreshed** \`${
|
|
639
|
+
? `**Refreshed** \`${codeSpanSafe(targets[0]!)}\``
|
|
639
640
|
: `**Refreshed** ${rows.length - 1}/${targets.length} account${targets.length === 1 ? '' : 's'}`
|
|
640
641
|
const table = rows.length > 1
|
|
641
642
|
? "\n```\n" + alignTable(rows) + "\n```"
|
|
@@ -646,7 +647,7 @@ export async function handleAuthCommand(
|
|
|
646
647
|
return { text: head + table + failBlock, html: true }
|
|
647
648
|
} catch (err) {
|
|
648
649
|
return {
|
|
649
|
-
text: `**/auth refresh failed:** ${
|
|
650
|
+
text: `**/auth refresh failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
|
|
650
651
|
html: true,
|
|
651
652
|
}
|
|
652
653
|
}
|
|
@@ -657,13 +658,13 @@ export async function handleAuthCommand(
|
|
|
657
658
|
const data = await ctx.client.setOverride(parsed.agent, parsed.label)
|
|
658
659
|
return {
|
|
659
660
|
text:
|
|
660
|
-
`**Override set.** \`${
|
|
661
|
-
`\`${
|
|
661
|
+
`**Override set.** \`${codeSpanSafe(data.agent)}\` is now pinned to ` +
|
|
662
|
+
`\`${codeSpanSafe(data.account ?? parsed.label)}\`.`,
|
|
662
663
|
html: true,
|
|
663
664
|
}
|
|
664
665
|
} catch (err) {
|
|
665
666
|
return {
|
|
666
|
-
text: `**/auth agent override failed:** ${
|
|
667
|
+
text: `**/auth agent override failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
|
|
667
668
|
html: true,
|
|
668
669
|
}
|
|
669
670
|
}
|
|
@@ -674,13 +675,13 @@ export async function handleAuthCommand(
|
|
|
674
675
|
const data = await ctx.client.setOverride(parsed.agent, null)
|
|
675
676
|
return {
|
|
676
677
|
text:
|
|
677
|
-
`**Override cleared** on \`${
|
|
678
|
+
`**Override cleared** on \`${codeSpanSafe(data.agent)}\` ` +
|
|
678
679
|
`— back to fleet active.`,
|
|
679
680
|
html: true,
|
|
680
681
|
}
|
|
681
682
|
} catch (err) {
|
|
682
683
|
return {
|
|
683
|
-
text: `**/auth agent override failed:** ${
|
|
684
|
+
text: `**/auth agent override failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
|
|
684
685
|
html: true,
|
|
685
686
|
}
|
|
686
687
|
}
|
|
@@ -852,7 +853,7 @@ function formatAccountsTable(state: ListStateData, now: number, demo = false): s
|
|
|
852
853
|
? formatRelativeMs(acc.exhausted_until - now)
|
|
853
854
|
: '—'
|
|
854
855
|
rows.push([
|
|
855
|
-
`${marker} ${
|
|
856
|
+
`${marker} ${escapeMarkdown(demo ? maskEmail(acc.label) : acc.label)}`,
|
|
856
857
|
status,
|
|
857
858
|
expires,
|
|
858
859
|
formatQuotaUtilCell(acc, now),
|
|
@@ -892,7 +893,7 @@ function formatAgentsTable(state: ListStateData, demo = false): string {
|
|
|
892
893
|
: a.account === state.active
|
|
893
894
|
? 'fleet-active'
|
|
894
895
|
: 'pinned'
|
|
895
|
-
rows.push([
|
|
896
|
+
rows.push([escapeMarkdown(a.name), escapeMarkdown(demo ? maskEmail(a.account) : a.account), source])
|
|
896
897
|
}
|
|
897
898
|
return alignTable(rows)
|
|
898
899
|
}
|
|
@@ -909,10 +910,10 @@ export function renderAgentDetail(
|
|
|
909
910
|
now: number = Date.now(),
|
|
910
911
|
): string {
|
|
911
912
|
const lines: string[] = []
|
|
912
|
-
lines.push(`**${
|
|
913
|
+
lines.push(`**${escapeMarkdown(agent.name)}**`)
|
|
913
914
|
const source = agent.override ? 'override' : 'fleet-active'
|
|
914
915
|
lines.push(
|
|
915
|
-
`Active account: \`${
|
|
916
|
+
`Active account: \`${codeSpanSafe(agent.account)}\` (${source})`,
|
|
916
917
|
)
|
|
917
918
|
const acct = state.accounts.find((a) => a.label === agent.account)
|
|
918
919
|
if (acct) {
|
|
@@ -948,17 +949,13 @@ function formatConsumersTable(state: ListStateData, now: number, demo = false):
|
|
|
948
949
|
c.last_seen_at == null
|
|
949
950
|
? 'socket bound'
|
|
950
951
|
: `socket bound (last seen ${formatRelativeMs(now - c.last_seen_at)} ago)`
|
|
951
|
-
rows.push([
|
|
952
|
+
rows.push([escapeMarkdown(c.name), escapeMarkdown(demo ? maskEmail(c.account) : c.account), status])
|
|
952
953
|
}
|
|
953
954
|
return alignTable(rows)
|
|
954
955
|
}
|
|
955
956
|
|
|
956
957
|
// ─── Plain-text helpers ────────────────────────────────────────────────────
|
|
957
958
|
|
|
958
|
-
function escapeHtml(s: string): string {
|
|
959
|
-
return s.replace(/([\\`*_~=\[\]|])/g, '\\$1')
|
|
960
|
-
}
|
|
961
|
-
|
|
962
959
|
function formatRelativeMs(ms: number): string {
|
|
963
960
|
if (ms <= 0) return '0s'
|
|
964
961
|
const totalSec = Math.floor(ms / 1000)
|