switchroom 0.16.29 → 0.16.46

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 (130) hide show
  1. package/dist/agent-scheduler/index.js +101 -84
  2. package/dist/auth-broker/index.js +100 -83
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +102 -85
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +62124 -60406
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +1000 -256
  10. package/dist/vault/approvals/kernel-server.js +141 -121
  11. package/dist/vault/broker/server.js +163 -143
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +3 -2
  15. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  16. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  17. package/skills/switchroom-status/SKILL.md +1 -1
  18. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  19. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  20. package/telegram-plugin/bridge/bridge.ts +2 -1
  21. package/telegram-plugin/card-format.ts +59 -3
  22. package/telegram-plugin/credits-watch.ts +4 -7
  23. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  24. package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
  25. package/telegram-plugin/dist/server.js +180 -163
  26. package/telegram-plugin/format.ts +551 -20
  27. package/telegram-plugin/gateway/approval-card.ts +7 -14
  28. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  29. package/telegram-plugin/gateway/auth-command.ts +35 -38
  30. package/telegram-plugin/gateway/boot-card.ts +7 -2
  31. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  32. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  33. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  34. package/telegram-plugin/gateway/gateway.ts +1524 -129
  35. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  36. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  37. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  38. package/telegram-plugin/gateway/model-command.ts +173 -19
  39. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  40. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  41. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  42. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  43. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  44. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  45. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  46. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  47. package/telegram-plugin/history.ts +17 -7
  48. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  49. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  50. package/telegram-plugin/idle-footer.ts +2 -2
  51. package/telegram-plugin/issues-card.ts +12 -7
  52. package/telegram-plugin/model-unavailable.ts +3 -6
  53. package/telegram-plugin/operator-events.ts +4 -6
  54. package/telegram-plugin/package.json +1 -1
  55. package/telegram-plugin/quota-check.ts +2 -2
  56. package/telegram-plugin/quota-watch.ts +7 -10
  57. package/telegram-plugin/server.ts +3 -1
  58. package/telegram-plugin/session-tail.ts +47 -1
  59. package/telegram-plugin/status-pin-driver.ts +102 -0
  60. package/telegram-plugin/status-pin.ts +76 -0
  61. package/telegram-plugin/stream-reply-handler.ts +33 -2
  62. package/telegram-plugin/subagent-watcher.ts +6 -3
  63. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  64. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  65. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  66. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  67. package/telegram-plugin/tests/card-format.test.ts +79 -0
  68. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  69. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  70. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  71. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  72. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  73. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  74. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  75. package/telegram-plugin/tests/history.test.ts +38 -2
  76. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  77. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  78. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  79. package/telegram-plugin/tests/model-command.test.ts +213 -47
  80. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  81. package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
  82. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  83. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  84. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  85. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  86. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  87. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  88. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  89. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  90. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  93. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  94. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  95. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  96. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  97. package/telegram-plugin/tests/telegram-format.test.ts +121 -8
  98. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  99. package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
  100. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  101. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  102. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  103. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  104. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  105. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  106. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  107. package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
  108. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  109. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  110. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  111. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  112. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  113. package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
  114. package/telegram-plugin/text-voice-scrub.ts +68 -18
  115. package/telegram-plugin/tool-activity-summary.ts +31 -116
  116. package/telegram-plugin/tts-normalize.ts +377 -0
  117. package/telegram-plugin/uat/driver.ts +474 -17
  118. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  119. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  120. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  121. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  122. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  123. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  124. package/telegram-plugin/voice-normalize-text.ts +340 -0
  125. package/telegram-plugin/voice-ondemand.ts +289 -0
  126. package/telegram-plugin/voice-presynth.ts +242 -0
  127. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  128. package/telegram-plugin/voice-synthesize.ts +128 -0
  129. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  130. package/telegram-plugin/worker-activity-feed.ts +22 -5
@@ -65,12 +65,31 @@ const dispatchCallsites = LINES.flatMap((line, i) =>
65
65
  const isSilentNoCardVerdict = (idx: number): boolean =>
66
66
  LINES.slice(Math.max(0, idx - 3), idx + 1).some((l) => /no-card-verdict/.test(l))
67
67
 
68
+ // A CARD-FOLDED-RESUME path (the "Allow once" tap, card-ux fix 3) does NOT
69
+ // post a separate `postPermissionResumeMessage()` — the agent-voiced "got it,
70
+ // continuing: <work>" line is folded INTO the same card edit (below the ✅
71
+ // label) via `finalizeCallback`, collapsing the old split second message. The
72
+ // turn still resumes (`dispatchPermissionVerdict` + `resumeReactionAfterVerdict`
73
+ // both fire) and the operator still sees the continuation — it just rides the
74
+ // tapped card instead of fanning out to every card target. Such callsites carry
75
+ // the `card-folded-resume` sentinel within the RESUME_WINDOW below the dispatch
76
+ // and are exempt ONLY from the post-message pairing (still required to flip the
77
+ // glyph). The invariant the guard protects (a verdict that un-parks a card must
78
+ // visibly resume it) still holds — the resume is in the card body.
79
+ const isCardFoldedResume = (idx: number): boolean =>
80
+ LINES.slice(idx, idx + POST_WINDOW + 1).some((l) => /card-folded-resume/.test(l))
81
+
68
82
  // How far below the dispatch the resume call is allowed to live. The
69
83
  // widest real gap today is ~9 lines (the slash-command path); 15 gives
70
84
  // refactor headroom without letting an unrelated resume "cover" a
71
85
  // dispatch from a different block.
72
86
  const RESUME_WINDOW = 15
73
87
 
88
+ // postPermissionResumeMessage rides ~1–2 lines after resumeReactionAfterVerdict
89
+ // on every path, so it can sit a touch further from the dispatch than the
90
+ // resume call — give it a little more headroom.
91
+ const POST_WINDOW = 20
92
+
74
93
  describe('permission verdict → resume reaction wiring', () => {
75
94
  it('there is at least one verdict-dispatch path to guard', () => {
76
95
  expect(dispatchCallsites.length).toBeGreaterThan(0)
@@ -102,15 +121,13 @@ describe('permission verdict → resume reaction wiring', () => {
102
121
  )
103
122
  })
104
123
 
105
- // postPermissionResumeMessage rides ~1–2 lines after resumeReactionAfterVerdict
106
- // on every path, so it can sit a touch further from the dispatch than the
107
- // resume call — give it a little more headroom.
108
- const POST_WINDOW = 20
109
-
110
- it('every dispatchPermissionVerdict() callsite posts the agent-voiced resume message via postPermissionResumeMessage()', () => {
124
+ it('every dispatchPermissionVerdict() callsite posts the agent-voiced resume message via postPermissionResumeMessage() or folds it into the card', () => {
111
125
  const unpaired: number[] = []
112
126
  for (const idx of dispatchCallsites) {
113
127
  if (isSilentNoCardVerdict(idx)) continue
128
+ // Exempt: card-ux fix 3 folds the resume line into the card edit
129
+ // instead of a separate message (still visibly resumes the operator).
130
+ if (isCardFoldedResume(idx)) continue
114
131
  const window = LINES.slice(idx, idx + POST_WINDOW + 1).join('\n')
115
132
  if (!/\bpostPermissionResumeMessage\s*\(/.test(window)) {
116
133
  unpaired.push(idx + 1)
@@ -92,11 +92,18 @@ describe('formatQuotaBlock', () => {
92
92
  now,
93
93
  )
94
94
  expect(block).toContain('**Claude plan quota**')
95
- expect(block).toContain('**5h window** 29% · resets in 2h 30m')
96
- expect(block).toContain('**7d window** 33% · resets in 3d')
95
+ expect(block).toContain('**5h window** `29%` · `resets in 2h 30m`')
96
+ expect(block).toContain('**7d window** `33%` · `resets in 3d`')
97
97
  expect(block).toContain('Binding window: five hour')
98
98
  // overage=allowed should not be surfaced
99
99
  expect(block).not.toContain('Overage:')
100
+ // Both windows: the **bold label** stays bold AND the percentage +
101
+ // reset ETA are each wrapped in a `code` span. Locks the nit-fix that
102
+ // the value moved into backticks without dropping the label markup.
103
+ const fiveLine = block.split('\n').find((l) => l.includes('5h window'))!
104
+ const sevenLine = block.split('\n').find((l) => l.includes('7d window'))!
105
+ expect(fiveLine).toMatch(/^\*\*5h window\*\*\s+`\d+%` · `resets in .+?`$/)
106
+ expect(sevenLine).toMatch(/^\*\*7d window\*\*\s+`\d+%` · `resets in .+?`$/)
100
107
  })
101
108
 
102
109
  it('surfaces overage status when not allowed', () => {
@@ -249,11 +249,11 @@ describe("evaluateQuotaWatchAccount — message content", () => {
249
249
  expect(d.message).toContain("30%");
250
250
  });
251
251
 
252
- it("throttling message markdown-escapes the account label (#2669)", () => {
253
- // The label is interpolated inside a code span — the renderer escapes
254
- // inline-markdown specials (\ ` * _ ~ = [ ] |), not HTML entities. A label
255
- // with an emphasis special (e.g. an underscore) is backslash-escaped; the
256
- // literal `<` / `>` pass through (they are not markdown specials).
252
+ it("throttling message renders the account label literally in a code span (#2695)", () => {
253
+ // The label is interpolated INSIDE a `code span`, where content is literal
254
+ // and backslash escaping is WRONG (#2695 regression: escapeMarkdown here
255
+ // emitted a visible `ev\_il@…`). The correct rendering is via codeSpanSafe,
256
+ // so an underscore label appears verbatim with no stray backslash.
257
257
  const d = evaluateQuotaWatchAccount({
258
258
  agentName: "lawgpt",
259
259
  snap: makeSnap("ev_il@example.com", makeQuota(85, 40)),
@@ -262,8 +262,8 @@ describe("evaluateQuotaWatchAccount — message content", () => {
262
262
  });
263
263
  expect(d.kind).toBe("notify");
264
264
  if (d.kind !== "notify") return;
265
- // The underscore is escaped so it can't open an italic run inside the span.
266
- expect(d.message).toContain("ev\\_il@example.com");
265
+ expect(d.message).toContain("`ev_il@example.com`");
266
+ expect(d.message).not.toContain("ev\\_il@example.com");
267
267
  });
268
268
 
269
269
  it("throttling message for active account mentions /auth use", () => {
@@ -0,0 +1,469 @@
1
+ /**
2
+ * rich-markdown-oracle — an INDEPENDENT CommonMark/GFM validator + entity
3
+ * extractor for the Bot API 10.1 rich-message path (`sendRichMessage`,
4
+ * `editMessageText({ markdown })`).
5
+ *
6
+ * ── Why this exists ────────────────────────────────────────────────────────
7
+ * Every outbound message now ships as raw GFM markdown through the rich path
8
+ * (see `format.ts` / `rich-send.ts`). A malformed body makes Telegram throw a
9
+ * 400 ("can't parse entities" / "can't find end of the entity" — see
10
+ * `isParseEntitiesError` in `rich-send.ts`), which drops or corrupts the
11
+ * message. Our existing format tests string-compare the text we *emit*; they
12
+ * do NOT verify that the emitted markdown is well-formed enough that Telegram
13
+ * won't reject it, nor that it parses into the entity structure we intended.
14
+ *
15
+ * This oracle closes that gap with TWO signals:
16
+ * (a) PARSE-ACCEPT: `validateRichMarkdown(md)` returns [] when the body is
17
+ * well-formed on every axis whose violation actually produces a rich-path
18
+ * 400 or a corrupt render; otherwise it returns a list of concrete
19
+ * problems. A test asserting `[]` fails loudly on output Telegram would
20
+ * reject.
21
+ * (b) STRUCTURE: `parseRichEntities(md)` extracts the entity spans (bold,
22
+ * italic, code, pre/fence, link, strikethrough) so a test can assert the
23
+ * body parses into the intended shape.
24
+ *
25
+ * ── The non-circularity guarantee (READ THIS) ──────────────────────────────
26
+ * The oracle is a HAND-WRITTEN tokenizer that follows the CommonMark/GFM
27
+ * grammar and Telegram's documented entity-closure rules. It shares NO code
28
+ * with `format.ts` — it does not import, echo, or re-derive the formatter's
29
+ * output. It is a second, independent implementation, so asserting the
30
+ * formatter's output against it is a genuine cross-check, not a formatter
31
+ * comparing itself to itself.
32
+ *
33
+ * ── Scope boundary (documented, deliberate) ────────────────────────────────
34
+ * A byte-exact port of the full CommonMark reference parser is ~3000 lines and
35
+ * not worth hand-rolling reliably. So:
36
+ * - Signal (a) is RIGOROUS for the constructs whose malformedness is what
37
+ * actually 400s / corrupts on the rich path: unbalanced fenced-code
38
+ * delimiters, unterminated inline code spans, malformed / unclosed link
39
+ * syntax, and structurally-incomplete table rows. These are precisely the
40
+ * failure modes `format.ts`'s chunker + block-boundary passes are built to
41
+ * avoid, so they are the ones a regression test must guard.
42
+ * - Signal (b) covers the COMMON entity types (bold `**`/`__`, italic
43
+ * `*`/`_`, strikethrough `~~`, inline code, fenced code, links). It uses a
44
+ * simplified-but-faithful delimiter model. It deliberately does NOT model
45
+ * every CommonMark emphasis edge case (intraword `_`, triple-run
46
+ * `***bold italic***` nesting split points) — those are called out inline
47
+ * and the fixtures avoid depending on them. When in doubt the extractor is
48
+ * CONSERVATIVE: it reports a span only when the open/close is unambiguous.
49
+ *
50
+ * CommonMark itself never "fails to parse" (every string is valid CommonMark),
51
+ * so a naive "does it parse" check would be vacuous. That is exactly why signal
52
+ * (a) is defined as *structural well-formedness of the emitted constructs*
53
+ * rather than "did a CommonMark parser throw" — it is the real predictor of a
54
+ * rich-path 400.
55
+ */
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Entity model
59
+ // ---------------------------------------------------------------------------
60
+
61
+ export type EntityType =
62
+ | 'bold'
63
+ | 'italic'
64
+ | 'strikethrough'
65
+ | 'code' // inline code span
66
+ | 'pre' // fenced code block
67
+ | 'link'
68
+
69
+ export interface RichEntity {
70
+ readonly type: EntityType
71
+ /** The rendered (delimiter-stripped) inner text of the entity. */
72
+ readonly text: string
73
+ /** For a link, the destination URL. */
74
+ readonly url?: string
75
+ /** For a fenced block, the info string (language), if any. */
76
+ readonly lang?: string
77
+ }
78
+
79
+ export interface ParseIssue {
80
+ readonly kind:
81
+ | 'unbalanced-fence'
82
+ | 'unterminated-code-span'
83
+ | 'malformed-link'
84
+ | 'incomplete-table-row'
85
+ | 'unbalanced-emphasis'
86
+ readonly detail: string
87
+ /** 1-based line number where the problem was detected, when known. */
88
+ readonly line?: number
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Fence / code-region masking (independent re-implementation — NOT imported
93
+ // from format.ts, on purpose). We mask fenced blocks and inline code so the
94
+ // emphasis/link scanners never see their interior, matching how a CommonMark
95
+ // parser treats code as verbatim.
96
+ // ---------------------------------------------------------------------------
97
+
98
+ interface Fence {
99
+ readonly lang: string
100
+ readonly body: string
101
+ readonly startLine: number
102
+ readonly closed: boolean
103
+ }
104
+
105
+ /**
106
+ * Split the document into fenced-code regions and everything else, tracking
107
+ * whether each fence is CLOSED. A fence opens on a line whose first non-space
108
+ * run is ``` (or longer) or ~~~; it closes on a later line whose fence marker
109
+ * is the same char and at least as long, with no trailing info string.
110
+ *
111
+ * This is the CommonMark fenced-code rule, scoped to the two markers Telegram
112
+ * emits (``` predominantly). An unclosed fence is the single most common cause
113
+ * of a corrupt rich render (it swallows the rest of the message), so we track
114
+ * `closed` explicitly for signal (a).
115
+ */
116
+ export function scanFences(md: string): {
117
+ fences: Fence[]
118
+ /** Line indices (0-based) that belong to a fence (open, body, close). */
119
+ fenceLineSet: Set<number>
120
+ } {
121
+ const lines = md.split('\n')
122
+ const fences: Fence[] = []
123
+ const fenceLineSet = new Set<number>()
124
+ let i = 0
125
+ const openRe = /^(\s*)(`{3,}|~{3,})\s*([^\n`]*)$/
126
+ while (i < lines.length) {
127
+ const m = lines[i].match(openRe)
128
+ if (m == null) {
129
+ i++
130
+ continue
131
+ }
132
+ const indent = m[1].length
133
+ const marker = m[2]
134
+ const fenceChar = marker[0]
135
+ const lang = m[3].trim()
136
+ const startLine = i
137
+ fenceLineSet.add(i)
138
+ const bodyLines: string[] = []
139
+ let j = i + 1
140
+ let closed = false
141
+ // Close marker: same char, length >= open marker, only whitespace after.
142
+ const closeRe = new RegExp(`^\\s*${fenceChar === '`' ? '`' : '~'}{${marker.length},}\\s*$`)
143
+ for (; j < lines.length; j++) {
144
+ if (closeRe.test(lines[j])) {
145
+ fenceLineSet.add(j)
146
+ closed = true
147
+ break
148
+ }
149
+ bodyLines.push(lines[j].slice(indent))
150
+ fenceLineSet.add(j)
151
+ }
152
+ fences.push({ lang, body: bodyLines.join('\n'), startLine, closed })
153
+ i = closed ? j + 1 : j
154
+ }
155
+ return { fences, fenceLineSet }
156
+ }
157
+
158
+ /**
159
+ * Replace fenced regions with blank lines (preserving line count) so the
160
+ * inline scanners never see fence interiors. Returns the masked doc plus the
161
+ * fence list.
162
+ */
163
+ function maskFences(md: string): { masked: string; fences: Fence[]; fenceLineSet: Set<number> } {
164
+ const { fences, fenceLineSet } = scanFences(md)
165
+ const lines = md.split('\n')
166
+ const masked = lines.map((l, idx) => (fenceLineSet.has(idx) ? '' : l)).join('\n')
167
+ return { masked, fences, fenceLineSet }
168
+ }
169
+
170
+ /**
171
+ * Extract inline code spans from a single line (fence interiors already
172
+ * masked). CommonMark inline code is a run of N backticks, closed by the next
173
+ * run of exactly N backticks. We support the common single- and double-tick
174
+ * forms our formatter emits. Returns the spans and the line with each span
175
+ * blanked (same length) so downstream emphasis scanning skips code content.
176
+ */
177
+ function extractInlineCode(line: string): { spans: string[]; blanked: string; unterminated: boolean } {
178
+ const spans: string[] = []
179
+ let out = ''
180
+ let i = 0
181
+ let unterminated = false
182
+ while (i < line.length) {
183
+ if (line[i] === '`') {
184
+ // Measure opening run length.
185
+ let n = 0
186
+ while (line[i + n] === '`') n++
187
+ const openEnd = i + n
188
+ // Find a closing run of EXACTLY n backticks.
189
+ let k = openEnd
190
+ let closeStart = -1
191
+ while (k < line.length) {
192
+ if (line[k] === '`') {
193
+ let m = 0
194
+ while (line[k + m] === '`') m++
195
+ if (m === n) {
196
+ closeStart = k
197
+ break
198
+ }
199
+ k += m
200
+ } else {
201
+ k++
202
+ }
203
+ }
204
+ if (closeStart === -1) {
205
+ // No matching close on this line → unterminated inline code span.
206
+ unterminated = true
207
+ out += line.slice(i)
208
+ break
209
+ }
210
+ const inner = line.slice(openEnd, closeStart)
211
+ spans.push(inner)
212
+ // Blank out the whole span (ticks + inner) with spaces of equal length.
213
+ out += ' '.repeat(closeStart + n - i)
214
+ i = closeStart + n
215
+ } else {
216
+ out += line[i]
217
+ i++
218
+ }
219
+ }
220
+ return { spans, blanked: out, unterminated }
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Signal (a): PARSE-ACCEPT validation
225
+ // ---------------------------------------------------------------------------
226
+
227
+ /**
228
+ * Validate that `md` is well-formed enough that Telegram's rich-message path
229
+ * would NOT 400 on it. Returns an empty array on success, else a list of the
230
+ * concrete problems found. See the module doc for the exact scope of "well
231
+ * formed" and why it is the right predictor of a rich-path reject.
232
+ */
233
+ export function validateRichMarkdown(md: string): ParseIssue[] {
234
+ const issues: ParseIssue[] = []
235
+ const { masked, fences } = maskFences(md)
236
+
237
+ // --- Unbalanced fence: an unclosed fenced block corrupts the render. -----
238
+ for (const f of fences) {
239
+ if (!f.closed) {
240
+ issues.push({
241
+ kind: 'unbalanced-fence',
242
+ detail: `fenced code block opened at line ${f.startLine + 1} is never closed`,
243
+ line: f.startLine + 1,
244
+ })
245
+ }
246
+ }
247
+ // Defensive: an ODD number of lone ``` fence lines (that scanFences somehow
248
+ // did not pair) is also an imbalance. scanFences already pairs greedily, so
249
+ // this catches only truly stray markers.
250
+ const strayFenceLines = (md.match(/^\s*(`{3,}|~{3,})\s*$/gm) ?? []).length
251
+ const openWithInfo = (md.match(/^\s*(`{3,}|~{3,})[^\n`~]+$/gm) ?? []).length
252
+ if ((strayFenceLines + openWithInfo) % 2 !== 0 && fences.every((f) => f.closed)) {
253
+ issues.push({
254
+ kind: 'unbalanced-fence',
255
+ detail: `odd number of fence delimiter lines (${strayFenceLines + openWithInfo})`,
256
+ })
257
+ }
258
+
259
+ const maskedLines = masked.split('\n')
260
+ maskedLines.forEach((rawLine, idx) => {
261
+ const { blanked, unterminated } = extractInlineCode(rawLine)
262
+ if (unterminated) {
263
+ issues.push({
264
+ kind: 'unterminated-code-span',
265
+ detail: `inline code span opened but not closed on line ${idx + 1}`,
266
+ line: idx + 1,
267
+ })
268
+ }
269
+ // --- Malformed link: a `[label](` that never closes its `)` ------------
270
+ // Only flag an OPENED inline-link that fails to close — a lone `[` used as
271
+ // literal text (no following `(`) is valid CommonMark and renders fine.
272
+ checkLinks(blanked, idx + 1, issues)
273
+ })
274
+
275
+ // --- Incomplete table row: a GFM table row must be a complete `| … |`. ---
276
+ checkTableRows(md, issues)
277
+
278
+ // --- Emphasis balance (common-case): odd count of unescaped `**` runs etc.
279
+ checkEmphasisBalance(masked, issues)
280
+
281
+ return issues
282
+ }
283
+
284
+ /**
285
+ * Flag an inline-link open `[...](` whose `(...)` destination never closes on
286
+ * the same line. A closed `[a](b)` is fine; a bare `[a]` with no `(` is fine
287
+ * (literal text). Nested `()` inside the destination are balanced-counted.
288
+ */
289
+ function checkLinks(line: string, lineNo: number, issues: ParseIssue[]): void {
290
+ const re = /\[[^\]]*\]\(/g
291
+ let m: RegExpExecArray | null
292
+ while ((m = re.exec(line)) != null) {
293
+ // Start scanning the destination just after `](`.
294
+ let depth = 1
295
+ let k = m.index + m[0].length
296
+ let closed = false
297
+ for (; k < line.length; k++) {
298
+ if (line[k] === '(') depth++
299
+ else if (line[k] === ')') {
300
+ depth--
301
+ if (depth === 0) {
302
+ closed = true
303
+ break
304
+ }
305
+ }
306
+ }
307
+ if (!closed) {
308
+ issues.push({
309
+ kind: 'malformed-link',
310
+ detail: `inline link opened at line ${lineNo} col ${m.index + 1} has an unclosed destination`,
311
+ line: lineNo,
312
+ })
313
+ }
314
+ }
315
+ }
316
+
317
+ /**
318
+ * A GFM table is a header row, a delimiter row (`| --- | --- |`), then body
319
+ * rows. Every row that participates must be a complete pipe row. We flag a
320
+ * delimiter row that is NOT flanked by a complete header above and at least a
321
+ * well-formed body/structure — i.e. the specific corruption `format.ts`'s
322
+ * chunker guards against (a bisected half-row). A row is "complete" when, after
323
+ * trimming, it starts and ends with `|` OR is a headerless GFM row with a
324
+ * matching delimiter. We keep this conservative: only flag a row that opens
325
+ * with `|` but does not close with `|` (a clearly bisected row).
326
+ */
327
+ function checkTableRows(md: string, issues: ParseIssue[]): void {
328
+ const { fenceLineSet } = maskFences(md)
329
+ const lines = md.split('\n')
330
+ lines.forEach((line, idx) => {
331
+ if (fenceLineSet.has(idx)) return
332
+ const t = line.trim()
333
+ if (t.length === 0) return
334
+ const looksLikeRow = t.startsWith('|')
335
+ if (!looksLikeRow) return
336
+ // A leading-pipe row must also close with a pipe to be a complete row.
337
+ if (!t.endsWith('|')) {
338
+ issues.push({
339
+ kind: 'incomplete-table-row',
340
+ detail: `table row opens with '|' but does not close with '|' on line ${idx + 1}`,
341
+ line: idx + 1,
342
+ })
343
+ }
344
+ })
345
+ }
346
+
347
+ /**
348
+ * Common-case emphasis balance. Counts unescaped `**` and `__` (bold) runs and
349
+ * standalone `*`/`_` (italic) and `~~` (strikethrough) on the fence+code-masked
350
+ * text; an odd count means an unterminated emphasis entity — the thing that
351
+ * yields "can't find end of the entity" on the rich path.
352
+ *
353
+ * NOTE (scope): this is a COUNT check, not a full delimiter-run resolver. It
354
+ * intentionally does not model CommonMark's left/right-flanking rules, so it
355
+ * can miss an exotic imbalance or (rarely) over-flag a legitimately
356
+ * asymmetric-but-valid construct. The torture-set fixtures avoid such exotic
357
+ * cases so this check stays a reliable regression signal. Escaped markers
358
+ * (`\*`) and code (already masked) are excluded.
359
+ */
360
+ function checkEmphasisBalance(masked: string, issues: ParseIssue[]): void {
361
+ // Strip inline code per-line first (reuse extractInlineCode's blanking).
362
+ const codeStripped = masked
363
+ .split('\n')
364
+ .map((l) => extractInlineCode(l).blanked)
365
+ .join('\n')
366
+ // Remove escaped markers so `\*` doesn't count.
367
+ const noEsc = codeStripped.replace(/\\[*_~`\\]/g, '')
368
+
369
+ // Bold `**` and `__`: count runs.
370
+ const doubleStar = (noEsc.match(/\*\*/g) ?? []).length
371
+ if (doubleStar % 2 !== 0) {
372
+ issues.push({ kind: 'unbalanced-emphasis', detail: `odd number of '**' bold delimiters (${doubleStar})` })
373
+ }
374
+ const strike = (noEsc.match(/~~/g) ?? []).length
375
+ if (strike % 2 !== 0) {
376
+ issues.push({ kind: 'unbalanced-emphasis', detail: `odd number of '~~' strikethrough delimiters (${strike})` })
377
+ }
378
+ // Single `*` italic: count SINGLE stars that are not part of a `**` pair.
379
+ // Remove `**` pairs first, then count leftover lone `*`.
380
+ const noBold = noEsc.replace(/\*\*/g, '')
381
+ const loneStar = (noBold.match(/\*/g) ?? []).length
382
+ if (loneStar % 2 !== 0) {
383
+ issues.push({ kind: 'unbalanced-emphasis', detail: `odd number of lone '*' italic delimiters (${loneStar})` })
384
+ }
385
+ }
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // Signal (b): STRUCTURE extraction
389
+ // ---------------------------------------------------------------------------
390
+
391
+ /**
392
+ * Parse the common entity structure out of a rich-markdown body. Fenced blocks
393
+ * are extracted first (as `pre`), then inline code, links, bold, italic and
394
+ * strikethrough from the remaining text. See the module doc for the scope of
395
+ * "common entities". Order of returned entities is document order per type is
396
+ * NOT guaranteed across types; assert on membership, not absolute index, in
397
+ * tests (helpers below make that easy).
398
+ */
399
+ export function parseRichEntities(md: string): RichEntity[] {
400
+ const entities: RichEntity[] = []
401
+ const { masked, fences } = maskFences(md)
402
+
403
+ for (const f of fences) {
404
+ entities.push({ type: 'pre', text: f.body, lang: f.lang.length > 0 ? f.lang : undefined })
405
+ }
406
+
407
+ // Inline code + links + emphasis on the masked (fence-free) text, line-wise.
408
+ for (const rawLine of masked.split('\n')) {
409
+ const { spans, blanked } = extractInlineCode(rawLine)
410
+ for (const s of spans) entities.push({ type: 'code', text: s })
411
+
412
+ // Links: [label](url). Extract before emphasis so a bracketed label with
413
+ // emphasis inside is handled as a link (label text preserved verbatim).
414
+ let line = blanked
415
+ const linkRe = /\[([^\]]*)\]\(([^)]*)\)/g
416
+ let lm: RegExpExecArray | null
417
+ const linkBlank: string[] = []
418
+ while ((lm = linkRe.exec(line)) != null) {
419
+ entities.push({ type: 'link', text: lm[1], url: lm[2] })
420
+ linkBlank.push(lm[0])
421
+ }
422
+ for (const lb of linkBlank) line = line.replace(lb, ' '.repeat(lb.length))
423
+
424
+ // Strikethrough ~~...~~
425
+ line = collect(line, /~~([^~]+)~~/g, (mtext) => entities.push({ type: 'strikethrough', text: mtext }))
426
+ // Bold **...** (and __...__)
427
+ line = collect(line, /\*\*([^*]+)\*\*/g, (mtext) => entities.push({ type: 'bold', text: mtext }))
428
+ line = collect(line, /__([^_]+)__/g, (mtext) => entities.push({ type: 'bold', text: mtext }))
429
+ // Italic *...* / _..._ (bold already removed above)
430
+ line = collect(line, /\*([^*]+)\*/g, (mtext) => entities.push({ type: 'italic', text: mtext }))
431
+ line = collect(line, /(?<![A-Za-z0-9])_([^_]+)_(?![A-Za-z0-9])/g, (mtext) =>
432
+ entities.push({ type: 'italic', text: mtext }),
433
+ )
434
+ }
435
+
436
+ return entities
437
+ }
438
+
439
+ /** Apply `re` globally, invoke `cb` with capture group 1, blank the match. */
440
+ function collect(line: string, re: RegExp, cb: (inner: string) => void): string {
441
+ let out = line
442
+ let m: RegExpExecArray | null
443
+ const toBlank: string[] = []
444
+ while ((m = re.exec(line)) != null) {
445
+ cb(m[1])
446
+ toBlank.push(m[0])
447
+ }
448
+ for (const b of toBlank) out = out.replace(b, ' '.repeat(b.length))
449
+ return out
450
+ }
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // Test-facing convenience helpers
454
+ // ---------------------------------------------------------------------------
455
+
456
+ /** True when the body is parse-accept clean (signal a). */
457
+ export function isRichMarkdownValid(md: string): boolean {
458
+ return validateRichMarkdown(md).length === 0
459
+ }
460
+
461
+ /** All entities of a given type, in document order. */
462
+ export function entitiesOfType(md: string, type: EntityType): RichEntity[] {
463
+ return parseRichEntities(md).filter((e) => e.type === type)
464
+ }
465
+
466
+ /** The set of rendered inner texts for a given entity type. */
467
+ export function entityTexts(md: string, type: EntityType): string[] {
468
+ return entitiesOfType(md, type).map((e) => e.text)
469
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * #2726 — structural wiring guard for the rollout status IPC verbs. The
3
+ * gateway handlers live in the createIpcServer({...}) IIFE block, which can't
4
+ * be imported in a unit test (same constraint as the send_outbound /
5
+ * inject_inbound wiring tests), so the load-bearing invariants are pinned by
6
+ * reading the source:
7
+ * 1. ipc-server routes rollout_status_post → onRolloutStatusPost and
8
+ * rollout_status_edit → onRolloutStatusEdit, with validator arms.
9
+ * 2. the POST handler fences agent (agentName === self), posts to the
10
+ * OPERATOR chat (allowFrom[0]) as an ORDINARY message (no pin, no
11
+ * inline_keyboard card), and replies with the message_id.
12
+ * 3. the EDIT handler edits in place via the wrapped retry path
13
+ * (swallowingApiCall) — fire-and-forget, never a raw bot.api call.
14
+ * 4. NEITHER handler pins a message — this is in-chat narration, not the
15
+ * retired pinned card (chat-is-the-single-source-of-truth).
16
+ */
17
+ import { describe, it, expect } from "vitest";
18
+ import { readFileSync } from "node:fs";
19
+
20
+ const gw = readFileSync(new URL("../gateway/gateway.ts", import.meta.url), "utf8");
21
+ const ipcServer = readFileSync(new URL("../gateway/ipc-server.ts", import.meta.url), "utf8");
22
+
23
+ describe("rollout_status_* — ipc-server routing", () => {
24
+ it("validates + dispatches rollout_status_post", () => {
25
+ expect(ipcServer).toMatch(/case "rollout_status_post": \{/); // validator arm
26
+ expect(ipcServer).toMatch(/onRolloutStatusPost\(client, msg as RolloutStatusPostMessage\)/);
27
+ });
28
+ it("validates + dispatches rollout_status_edit", () => {
29
+ expect(ipcServer).toMatch(/case "rollout_status_edit": \{/);
30
+ expect(ipcServer).toMatch(/onRolloutStatusEdit\(client, msg as RolloutStatusEditMessage\)/);
31
+ });
32
+ });
33
+
34
+ describe("onRolloutStatusPost — gateway handler invariants", () => {
35
+ const start = gw.indexOf("async onRolloutStatusPost(client: IpcClient, msg: RolloutStatusPostMessage)");
36
+ const next = gw.indexOf("onRolloutStatusEdit(", start);
37
+ const handler = gw.slice(start, next > start ? next : start + 2600);
38
+
39
+ it("the handler exists", () => {
40
+ expect(start).toBeGreaterThan(0);
41
+ });
42
+ it("fences the agent (agentName must match this gateway's own)", () => {
43
+ expect(handler).toMatch(/msg\.agentName !== self/);
44
+ });
45
+ it("posts to the OPERATOR chat (allowFrom[0])", () => {
46
+ expect(handler).toMatch(/loadAccess\(\)\.allowFrom\[0\]/);
47
+ });
48
+ it("posts through the wrapped retry path, NOT a raw bot.api", () => {
49
+ expect(handler).toMatch(/robustApiCall\(/);
50
+ expect(handler).toMatch(/bot\.api\.sendRichMessage\(operator/);
51
+ });
52
+ it("replies with the message_id so hostd can EDIT it later", () => {
53
+ expect(handler).toMatch(/rollout_status_posted/);
54
+ expect(handler).toMatch(/messageId/);
55
+ });
56
+ it("does NOT pin the message and renders NO inline-keyboard card (narration, not the retired card)", () => {
57
+ expect(handler).not.toMatch(/pinChatMessage/);
58
+ expect(handler).not.toMatch(/reply_markup/);
59
+ expect(handler).not.toMatch(/InlineKeyboard/);
60
+ });
61
+ });
62
+
63
+ describe("onRolloutStatusEdit — gateway handler invariants", () => {
64
+ const start = gw.indexOf("onRolloutStatusEdit(_client: IpcClient, msg: RolloutStatusEditMessage)");
65
+ const next = gw.indexOf("onInjectInbound(", start);
66
+ const handler = gw.slice(start, next > start ? next : start + 2000);
67
+
68
+ it("the handler exists", () => {
69
+ expect(start).toBeGreaterThan(0);
70
+ });
71
+ it("fences the agent", () => {
72
+ expect(handler).toMatch(/msg\.agentName !== self/);
73
+ });
74
+ it("edits in place via the wrapped retry path (fire-and-forget)", () => {
75
+ expect(handler).toMatch(/swallowingApiCall\(/);
76
+ expect(handler).toMatch(/bot\.api\.editMessageText\(operator, msg\.messageId/);
77
+ });
78
+ it("does NOT pin (edits an ordinary message)", () => {
79
+ expect(handler).not.toMatch(/pinChatMessage/);
80
+ });
81
+ });