switchroom 0.15.45 → 0.16.5
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 +56 -15
- package/dist/auth-broker/index.js +383 -97
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +7 -4
- package/dist/cli/notion-write-pretool.mjs +35 -4
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/switchroom.js +2894 -841
- package/dist/host-control/main.js +2685 -207
- package/dist/vault/approvals/kernel-server.js +7453 -7413
- package/dist/vault/broker/server.js +11428 -11388
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +97 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +0 -19
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +55 -12
- package/telegram-plugin/dist/gateway/gateway.js +2938 -977
- package/telegram-plugin/dist/server.js +55 -12
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1857 -292
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/model-command.ts +115 -4
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-command.test.ts +134 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- package/telegram-plugin/tests/draft-transport.test.ts +0 -211
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume-the-turn-after-swap gate (THE load-bearing half of the
|
|
3
|
+
* auth-failover-stall fix).
|
|
4
|
+
*
|
|
5
|
+
* Background — the stall this kills:
|
|
6
|
+
* When a Claude account hits a rate limit MID-TURN (HTTP 429, including a
|
|
7
|
+
* *session* cap like "You've hit your session limit · resets 5pm"), the
|
|
8
|
+
* failover machinery correctly detects it (session-tail → model-unavailable
|
|
9
|
+
* → gateway.fireFleetAutoFallback → doFireFleetAutoFallback) and swaps the
|
|
10
|
+
* fleet to a spare account. But the swap only takes effect on the NEXT
|
|
11
|
+
* claude invocation — the turn that died on the 429 is never resumed. The
|
|
12
|
+
* agent goes silent until the user manually sends a fresh message
|
|
13
|
+
* ("Resume"). Symptom: a mid-conversation stall that needs a manual re-poke.
|
|
14
|
+
*
|
|
15
|
+
* The fix: after a SUCCESSFUL swap (outcome.kind === 'switched'), the gateway
|
|
16
|
+
* re-runs the dead turn on the freshly-active account.
|
|
17
|
+
*
|
|
18
|
+
* Mechanism choice — restart, NOT redeliver:
|
|
19
|
+
* The inbound that died on the 429 was already DELIVERED (the turn started,
|
|
20
|
+
* then the model rejected it). It is therefore NOT sitting in the
|
|
21
|
+
* pending-inbound buffer (`redeliverBufferedInbound` only drains inbounds
|
|
22
|
+
* that never reached a live bridge). The canonical way to re-run an
|
|
23
|
+
* already-delivered, interrupted turn is `triggerSelfRestart`: on the next
|
|
24
|
+
* boot the gateway's boot-resume path re-injects the LATEST interrupted turn
|
|
25
|
+
* from the turn registry — exactly the turn the 429 killed — and the fresh
|
|
26
|
+
* claude process picks up the swapped-to account. `redeliverBufferedInbound`
|
|
27
|
+
* would have nothing to redeliver here, so it is the wrong seam.
|
|
28
|
+
*
|
|
29
|
+
* This module owns ONLY the decision + the guards, kept pure so it is testable
|
|
30
|
+
* without restarting a process (the gateway module-state pattern mirrors
|
|
31
|
+
* `fleet-fallback-gate.ts`). The gateway wires the actual `triggerSelfRestart`
|
|
32
|
+
* call to `decide()` returning `'resume'`.
|
|
33
|
+
*
|
|
34
|
+
* Guards (all REQUIRED by the fix spec):
|
|
35
|
+
* 1. all-blocked / no-swap no-op — `decide()` is only ever consulted by the
|
|
36
|
+
* caller on `outcome.kind === 'switched'`; on any other outcome the caller
|
|
37
|
+
* does not call us, preserving the existing all-blocked cooldown path.
|
|
38
|
+
* 2. Single-flight — once a resume is armed, a second swap (a 429 storm, a
|
|
39
|
+
* repeated quota event) within the same window does NOT re-arm. Only ONE
|
|
40
|
+
* restart fires per swap. Without this a swap-storm could loop-restart the
|
|
41
|
+
* agent.
|
|
42
|
+
* NOTE: this latch is IN-MEMORY and does NOT survive the self-restart it
|
|
43
|
+
* arms — so it only dedups multiple swaps within a SINGLE process
|
|
44
|
+
* lifetime; it cannot by itself bound a cross-restart loop. Cross-restart
|
|
45
|
+
* loops are bounded separately, broker-side, by account exhaustion:
|
|
46
|
+
* `markExhausted` persists across restarts, so once every spare is benched
|
|
47
|
+
* the outcome becomes all-blocked, which does NOT restart.
|
|
48
|
+
* 3. Staleness failsafe — an ancient interrupted turn is NOT resurrected. If
|
|
49
|
+
* the failed turn started longer ago than `maxAgeMs` (default 3h, matching
|
|
50
|
+
* the boot-resume RESUME_MAX_AGE_MS failsafe), `decide()` returns
|
|
51
|
+
* `'skip-stale'` so a day-old buffered turn isn't replayed unprompted.
|
|
52
|
+
* `null` failedTurnStartedAtMs (no turn timestamp known) is treated as
|
|
53
|
+
* "resume" — the boot-resume path applies its own 3h guard as a second
|
|
54
|
+
* line of defence.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
export interface FleetFallbackResumeOptions {
|
|
58
|
+
/** Max age of the failed turn before a resume is suppressed as stale.
|
|
59
|
+
* Default 3h — mirrors the gateway boot-resume RESUME_MAX_AGE_MS. */
|
|
60
|
+
maxAgeMs?: number;
|
|
61
|
+
/** Time source (overridable in tests). */
|
|
62
|
+
nowFn?: () => number;
|
|
63
|
+
/** Cooldown after an armed resume during which a second swap will NOT
|
|
64
|
+
* re-arm. Dedups a 429 storm WITHIN one process lifetime (this latch is
|
|
65
|
+
* in-memory and does not survive the restart it arms — cross-restart loops
|
|
66
|
+
* are bounded broker-side by persisted account exhaustion, not here).
|
|
67
|
+
* Default 60s — comfortably longer than a restart + boot takes, so the
|
|
68
|
+
* in-flight restart settles before another resume can arm. */
|
|
69
|
+
singleFlightMs?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type ResumeDecision =
|
|
73
|
+
/** Fire the resume (restart so the boot path replays the dead turn). */
|
|
74
|
+
| 'resume'
|
|
75
|
+
/** Suppressed: a resume is already in flight (single-flight guard). */
|
|
76
|
+
| 'skip-inflight'
|
|
77
|
+
/** Suppressed: the failed turn is older than maxAgeMs (staleness guard). */
|
|
78
|
+
| 'skip-stale';
|
|
79
|
+
|
|
80
|
+
export interface FleetFallbackResumeGate {
|
|
81
|
+
/**
|
|
82
|
+
* Decide whether to resume the dead turn after a successful swap. Call ONLY
|
|
83
|
+
* when the swap outcome was `'switched'`. Records the arm time on a 'resume'
|
|
84
|
+
* verdict so a follow-on swap within `singleFlightMs` is suppressed.
|
|
85
|
+
*
|
|
86
|
+
* @param failedTurnStartedAtMs epoch-ms the failed turn began, or null when
|
|
87
|
+
* unknown (then the staleness guard is deferred to the boot path).
|
|
88
|
+
*/
|
|
89
|
+
decide(failedTurnStartedAtMs: number | null): ResumeDecision;
|
|
90
|
+
/** Test seam — reset to fresh state. Production code should not call this. */
|
|
91
|
+
reset(): void;
|
|
92
|
+
/** Test/debug — current internal state. */
|
|
93
|
+
inspect(): { lastResumedAtMs: number };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const DEFAULT_RESUME_MAX_AGE_MS = 10_800_000; // 3h
|
|
97
|
+
export const DEFAULT_RESUME_SINGLE_FLIGHT_MS = 60_000; // 60s
|
|
98
|
+
|
|
99
|
+
export function createFleetFallbackResumeGate(
|
|
100
|
+
opts: FleetFallbackResumeOptions = {},
|
|
101
|
+
): FleetFallbackResumeGate {
|
|
102
|
+
const nowFn = opts.nowFn ?? (() => Date.now());
|
|
103
|
+
const maxAgeMs = opts.maxAgeMs ?? DEFAULT_RESUME_MAX_AGE_MS;
|
|
104
|
+
const singleFlightMs = opts.singleFlightMs ?? DEFAULT_RESUME_SINGLE_FLIGHT_MS;
|
|
105
|
+
// -Infinity = never resumed. A concrete value arms the single-flight window.
|
|
106
|
+
let lastResumedAtMs = Number.NEGATIVE_INFINITY;
|
|
107
|
+
|
|
108
|
+
function decide(failedTurnStartedAtMs: number | null): ResumeDecision {
|
|
109
|
+
const now = nowFn();
|
|
110
|
+
// Guard 2 — single-flight. A second swap within the window does not
|
|
111
|
+
// re-arm; the in-flight restart owns the resume.
|
|
112
|
+
if (now - lastResumedAtMs < singleFlightMs) return 'skip-inflight';
|
|
113
|
+
// Guard 3 — staleness. A known-old failed turn is not resurrected. An
|
|
114
|
+
// unknown (null) timestamp defers to the boot-resume 3h failsafe.
|
|
115
|
+
if (failedTurnStartedAtMs != null && now - failedTurnStartedAtMs > maxAgeMs) {
|
|
116
|
+
return 'skip-stale';
|
|
117
|
+
}
|
|
118
|
+
lastResumedAtMs = now;
|
|
119
|
+
return 'resume';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
decide,
|
|
124
|
+
reset() {
|
|
125
|
+
lastResumedAtMs = Number.NEGATIVE_INFINITY;
|
|
126
|
+
},
|
|
127
|
+
inspect() {
|
|
128
|
+
return { lastResumedAtMs };
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -563,34 +563,71 @@ function escapeUnescapedEntities(inner: string): string {
|
|
|
563
563
|
* The message then ships to Telegram intact and the user sees literal
|
|
564
564
|
* `\n\n` in the chat instead of paragraph breaks.
|
|
565
565
|
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
566
|
+
* Unescape literal `\n`, `\r`, `\t`, and `\"` sequences everywhere EXCEPT
|
|
567
|
+
* inside code spans (inline backtick spans and fenced ``` blocks). Those
|
|
568
|
+
* regions are masked with placeholders before the unescape pass so that a
|
|
569
|
+
* literal `\n` a user typed inside a shell snippet or regex is preserved
|
|
570
|
+
* verbatim. The genuine escaped-backslash sequence `\\n` (which the user
|
|
571
|
+
* intended as a literal backslash + n, not a newline) is handled by
|
|
572
|
+
* protecting `\\` before touching `\n`.
|
|
572
573
|
*
|
|
573
|
-
* This
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
* wrap them in backticks, and this runs before markdown→HTML so the
|
|
578
|
-
* unescape has no effect on text inside fenced code blocks if it already
|
|
579
|
-
* has real newlines around them.
|
|
574
|
+
* This deliberately fires even when the message contains real newlines —
|
|
575
|
+
* the old whole-message heuristic ("bail if any real newline exists") was
|
|
576
|
+
* too broad and prevented repair of mixed messages that had both real
|
|
577
|
+
* newlines and stray literal `\n` escape sequences outside code spans.
|
|
580
578
|
*/
|
|
581
579
|
export function repairEscapedWhitespace(text: string): string {
|
|
582
|
-
if (text.includes('\n') || text.includes('\r')) return text
|
|
583
580
|
if (!/\\[nrt"\\]/.test(text)) return text
|
|
584
|
-
|
|
585
|
-
//
|
|
586
|
-
|
|
587
|
-
|
|
581
|
+
|
|
582
|
+
// Per-call random nonce prevents sentinel collision: if user text happens to
|
|
583
|
+
// contain our placeholder bytes, the restore step would look up an out-of-range
|
|
584
|
+
// index and produce "undefined" in the Telegram output. A nonce that is unique
|
|
585
|
+
// per invocation makes the sentinel statistically impossible to collide with.
|
|
586
|
+
const nonce = Math.random().toString(36).slice(2)
|
|
587
|
+
const CODE_MASK_PH = `\x00RM${nonce}_`
|
|
588
|
+
const BACKSLASH_PH = `\x00BK${nonce}_`
|
|
589
|
+
|
|
590
|
+
// Mask fenced code blocks (``` ... ```) and inline code spans (` ... `)
|
|
591
|
+
// so the unescape pass never touches their content.
|
|
592
|
+
//
|
|
593
|
+
// Fenced blocks are extracted first. Only CLOSED fenced blocks (with a
|
|
594
|
+
// matching closing ```) are masked — an unclosed fence is left as-is so the
|
|
595
|
+
// inline-code pass below won't misparse the two leading backticks as an empty
|
|
596
|
+
// inline span and expose the block's interior.
|
|
597
|
+
//
|
|
598
|
+
// Inline code uses `[^\`\n]+` (one or more non-backtick, non-newline chars)
|
|
599
|
+
// matching the same definition that markdownToHtml uses, so the masked regions
|
|
600
|
+
// are consistent with what the downstream pipeline treats as code.
|
|
601
|
+
const codeMasks: string[] = []
|
|
602
|
+
|
|
603
|
+
const masked = text
|
|
604
|
+
// Closed fenced code blocks only (``` ... ``` with a matching closer).
|
|
605
|
+
.replace(/```[\s\S]*?```/g, (m) => {
|
|
606
|
+
const idx = codeMasks.length
|
|
607
|
+
codeMasks.push(m)
|
|
608
|
+
return `${CODE_MASK_PH}${idx}\x00`
|
|
609
|
+
})
|
|
610
|
+
// Inline code spans: at least one character between backticks, no embedded
|
|
611
|
+
// backtick or newline (matches markdownToHtml's /`([^`\n]+)`/ definition).
|
|
612
|
+
.replace(/`[^`\n]+`/g, (m) => {
|
|
613
|
+
const idx = codeMasks.length
|
|
614
|
+
codeMasks.push(m)
|
|
615
|
+
return `${CODE_MASK_PH}${idx}\x00`
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
// Order matters: protect existing `\\` first so `\\n` stays as a literal
|
|
619
|
+
// backslash + n and doesn't become a newline.
|
|
620
|
+
const unescaped = masked
|
|
588
621
|
.replace(/\\\\/g, BACKSLASH_PH)
|
|
589
622
|
.replace(/\\n/g, '\n')
|
|
590
623
|
.replace(/\\r/g, '\r')
|
|
591
624
|
.replace(/\\t/g, '\t')
|
|
592
625
|
.replace(/\\"/g, '"')
|
|
593
|
-
.replace(new RegExp(BACKSLASH_PH, 'g'), '\\')
|
|
626
|
+
.replace(new RegExp(BACKSLASH_PH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '\\')
|
|
627
|
+
|
|
628
|
+
// Restore masked code spans verbatim.
|
|
629
|
+
const restoreRe = new RegExp(`${CODE_MASK_PH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)\x00`, 'g')
|
|
630
|
+
return unescaped.replace(restoreRe, (_m, idx) => codeMasks[Number(idx)] ?? _m)
|
|
594
631
|
}
|
|
595
632
|
|
|
596
633
|
// ---------------------------------------------------------------------------
|