switchroom 0.16.47 → 0.17.1

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 (117) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +24 -8
  3. package/dist/cli/drive-write-pretool.mjs +0 -5
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +1359 -1030
  6. package/dist/cli/ui/index.html +84 -12
  7. package/dist/host-control/main.js +53 -17
  8. package/dist/vault/approvals/kernel-server.js +4 -1
  9. package/dist/vault/broker/server.js +201 -56
  10. package/package.json +3 -3
  11. package/profiles/_base/cron-session.sh.hbs +1 -1
  12. package/profiles/_base/start.sh.hbs +54 -3
  13. package/skills/switchroom-architecture/telegram.md +8 -15
  14. package/skills/switchroom-cli/SKILL.md +4 -5
  15. package/skills/telegram-test-harness/SKILL.md +1 -1
  16. package/telegram-plugin/README.md +18 -29
  17. package/telegram-plugin/bridge/bridge.ts +1 -41
  18. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  19. package/telegram-plugin/dist/bridge/bridge.js +8 -43
  20. package/telegram-plugin/dist/gateway/gateway.js +842 -832
  21. package/telegram-plugin/dist/server.js +8 -43
  22. package/telegram-plugin/format.ts +119 -17
  23. package/telegram-plugin/gateway/approvals-commands.ts +6 -2
  24. package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
  25. package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
  26. package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
  27. package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
  28. package/telegram-plugin/gateway/gateway.ts +535 -627
  29. package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
  30. package/telegram-plugin/gateway/inbound-spool.ts +108 -10
  31. package/telegram-plugin/gateway/model-command.ts +51 -3
  32. package/telegram-plugin/gateway/ms365-write-approval.test.ts +13 -0
  33. package/telegram-plugin/gateway/ms365-write-approval.ts +5 -1
  34. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  35. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  36. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  37. package/telegram-plugin/gateway/vault-request-access-card.ts +5 -1
  38. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  39. package/telegram-plugin/history.ts +5 -0
  40. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  41. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  42. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  43. package/telegram-plugin/registry/turns-schema.ts +65 -1
  44. package/telegram-plugin/session-tail.ts +26 -4
  45. package/telegram-plugin/slot-banner-driver.ts +42 -2
  46. package/telegram-plugin/status-query-telemetry.ts +100 -0
  47. package/telegram-plugin/stream-reply-handler.ts +15 -16
  48. package/telegram-plugin/subagent-watcher.ts +182 -30
  49. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  50. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  51. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  52. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  53. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  54. package/telegram-plugin/tests/format-consistency.test.ts +79 -0
  55. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  56. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  57. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  58. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  59. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  60. package/telegram-plugin/tests/model-command.test.ts +54 -1
  61. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  62. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  63. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  64. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  65. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  66. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  67. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  68. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  69. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  70. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  71. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  72. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  73. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  74. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  75. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  76. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  77. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  78. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  79. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  80. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  81. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  82. package/telegram-plugin/tests/vault-request-access-card.test.ts +17 -0
  83. package/telegram-plugin/tests/welcome-text.test.ts +64 -0
  84. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  85. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  86. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  87. package/telegram-plugin/tool-activity-summary.ts +19 -0
  88. package/telegram-plugin/turn-flush-safety.ts +16 -1
  89. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  90. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  91. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  92. package/telegram-plugin/welcome-text.ts +13 -9
  93. package/telegram-plugin/worker-activity-feed.ts +75 -15
  94. package/vendor/hindsight-memory/CHANGELOG.md +66 -0
  95. package/vendor/hindsight-memory/README.md +5 -0
  96. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  97. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  98. package/vendor/hindsight-memory/scripts/lib/content.py +43 -4
  99. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  100. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  101. package/vendor/hindsight-memory/scripts/retain.py +79 -11
  102. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  103. package/vendor/hindsight-memory/scripts/tests/test_recall_context_slice.py +126 -0
  104. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  105. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  106. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +261 -0
  107. package/vendor/hindsight-memory/settings.json +4 -0
  108. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  109. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  110. package/vendor/hindsight-memory/tests/test_content.py +123 -0
  111. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  112. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  113. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  114. package/telegram-plugin/silent-reply.ts +0 -58
  115. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  116. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  117. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -17208,7 +17208,7 @@ function findActiveSessionFile(projectsDir) {
17208
17208
  }
17209
17209
  function parseChannelMeta(content) {
17210
17210
  const grab = (key) => {
17211
- const m = content.match(new RegExp(`${key}="([^"]+)"`));
17211
+ const m = content.match(new RegExp(`(?:^|[\\s"'])${key}="([^"]+)"`));
17212
17212
  return m ? m[1] : null;
17213
17213
  };
17214
17214
  return {
@@ -17414,7 +17414,12 @@ function projectSubagentLine(line, agentId, state) {
17414
17414
  if (ct === "tool_use") {
17415
17415
  const name = c.name ?? "";
17416
17416
  if (name === "Agent" || name === "Task") {
17417
- events.push({ kind: "sub_agent_nested_spawn", agentId });
17417
+ events.push({
17418
+ kind: "sub_agent_nested_spawn",
17419
+ agentId,
17420
+ toolUseId: c.id ?? null,
17421
+ input: c.input ?? undefined
17422
+ });
17418
17423
  } else {
17419
17424
  events.push({
17420
17425
  kind: "sub_agent_tool_use",
@@ -24098,7 +24103,6 @@ var ALWAYS_LOAD_TOOLS, LINEAR_TOOLS, LINEAR_ENV = "SWITCHROOM_TELEGRAM_LINEAR";
24098
24103
  var init_tool_filter = __esm(() => {
24099
24104
  ALWAYS_LOAD_TOOLS = new Set([
24100
24105
  "reply",
24101
- "stream_reply",
24102
24106
  "get_recent_messages",
24103
24107
  "react",
24104
24108
  "edit_message",
@@ -24347,7 +24351,7 @@ var init_bridge = __esm(async () => {
24347
24351
  instructions: [
24348
24352
  "The sender reads Telegram, not this session. Anything you want them to see must go through the reply tool \u2014 your transcript output never reaches their chat.",
24349
24353
  "",
24350
- 'Messages from Telegram arrive as <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file \u2014 it is a photo the sender attached. If the tag has attachment_file_id, call download_attachment with that file_id to fetch the file, then Read the returned path. A single message may carry SEVERAL attachments (a forwarded album or a text+multi-image burst): when attachment_count is set (>1), also handle the numbered siblings \u2014 image_path_2, image_path_3, \u2026 (Read each) and attachment_file_id_2, attachment_file_id_3, \u2026 (download_attachment each). Process every one, not just the first. Reply with the reply tool \u2014 pass chat_id back. The reply and stream_reply tools quote-reply to the latest inbound user message by default, so you do NOT need to pass reply_to for normal responses. Pass reply_to (a message_id) only when quoting a specific earlier message, or pass quote:false to send a bare (non-quoted) message.',
24354
+ 'Messages from Telegram arrive as <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file \u2014 it is a photo the sender attached. If the tag has attachment_file_id, call download_attachment with that file_id to fetch the file, then Read the returned path. A single message may carry SEVERAL attachments (a forwarded album or a text+multi-image burst): when attachment_count is set (>1), also handle the numbered siblings \u2014 image_path_2, image_path_3, \u2026 (Read each) and attachment_file_id_2, attachment_file_id_3, \u2026 (download_attachment each). Process every one, not just the first. Reply with the reply tool \u2014 pass chat_id back. The reply tool quote-replies to the latest inbound user message by default, so you do NOT need to pass reply_to for normal responses. Pass reply_to (a message_id) only when quoting a specific earlier message, or pass quote:false to send a bare (non-quoted) message.',
24351
24355
  "",
24352
24356
  `reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
24353
24357
  "",
@@ -24402,45 +24406,6 @@ var init_bridge = __esm(async () => {
24402
24406
  required: ["chat_id", "text"]
24403
24407
  }
24404
24408
  },
24405
- {
24406
- name: "stream_reply",
24407
- description: "Post the final answer for this turn. The plugin renders an event-driven progress card (Plan \u2192 Run \u2192 Done with live tool bullets, elapsed time, and status emoji) for free while the turn is in-flight, so you do not need to narrate intermediate progress. Call `stream_reply` exactly once per turn with done=true and the complete answer text. Hard cap is 32768 chars (the rich-message wire limit) \u2014 longer text is dropped by a defensive guard, so use `reply` for anything that long (it chunks). Calling with done=false is an error in this environment (the progress card already owns the mid-turn surface). inline_keyboard adds tappable buttons under the final message \u2014 see `reply` for shape and constraints.",
24408
- inputSchema: {
24409
- type: "object",
24410
- properties: {
24411
- chat_id: { type: "string" },
24412
- text: { type: "string", description: "Full text snapshot. NOT a delta \u2014 pass the complete current content each call." },
24413
- done: { type: "boolean", description: "Must be true. Posts this text as the final answer for the turn and locks the message." },
24414
- message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." },
24415
- origin_turn_id: { type: "string", description: "In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message's topic even if another topic's turn started meanwhile. Omit in DMs / single-topic chats." },
24416
- format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
24417
- reply_to: { type: "string", description: "Message ID to quote-reply to. Overrides the default (latest inbound)." },
24418
- quote: { type: "boolean", description: "Opt out of the default quote-reply behavior. Default: true. Ignored when reply_to is explicitly set." },
24419
- protect_content: { type: "boolean", description: "When true, Telegram prevents the message from being forwarded or saved." },
24420
- quote_text: { type: "string", description: "Surgical quote: specific text to highlight from the reply_to message. Requires reply_to." },
24421
- disable_notification: { type: "boolean", description: "When true, the INITIAL message send is silent (no device ping). Has no effect on subsequent edits \u2014 Telegram never pings on editMessageText. Default false. Use for mid-turn stream starts you do not want to ping; omit on the final answer." },
24422
- inline_keyboard: {
24423
- type: "array",
24424
- description: '2D array of tappable buttons under the final message. Same shape and constraints as `reply.inline_keyboard` \u2014 each button has `text` and EXACTLY ONE of `url` or `callback_data`, plus optional `ack_text` (custom tap-toast; default "\u2713 received") and `single_use` (default true; set false to keep the keyboard tappable after a tap). Tap on a callback_data button is delivered to this agent as an inbound channel event with meta.button_callback_data set.',
24425
- items: {
24426
- type: "array",
24427
- items: {
24428
- type: "object",
24429
- properties: {
24430
- text: { type: "string" },
24431
- url: { type: "string" },
24432
- callback_data: { type: "string" },
24433
- ack_text: { type: "string", description: 'Toast text shown on tap. Default "\u2713 received".' },
24434
- single_use: { type: "boolean", description: "Default true. Set false to keep the keyboard tappable after this button is tapped." }
24435
- },
24436
- required: ["text"]
24437
- }
24438
- }
24439
- }
24440
- },
24441
- required: ["chat_id", "text"]
24442
- }
24443
- },
24444
24409
  {
24445
24410
  name: "react",
24446
24411
  description: "Add an emoji reaction to a Telegram message. Telegram only accepts a fixed whitelist (\uD83D\uDC4D \uD83D\uDC4E \u2764 \uD83D\uDD25 \uD83D\uDC40 \uD83C\uDF89 etc) \u2014 non-whitelisted emoji will be rejected.",
@@ -132,8 +132,18 @@ export function repairEscapedWhitespace(text: string): string {
132
132
  interface MaskedCode {
133
133
  masked: string
134
134
  restore: (s: string) => string
135
- /** The placeholder prefix injected for each masked region (fence or span). */
135
+ /**
136
+ * The placeholder prefix injected for FENCED-BLOCK masks only. A masked
137
+ * fenced block occupies a whole line, so `isFenceOpenLine` / `isMarkerLine`
138
+ * treat a line that STARTS with this prefix as a block construct. INLINE
139
+ * code spans get a DISTINCT prefix (see maskCodeRegions) that deliberately
140
+ * does NOT start with this one — so a line that merely opens with an inline
141
+ * span (e.g. `\`key\` = \`value\``) reads as ordinary prose and still gets
142
+ * its line break hardened.
143
+ */
136
144
  placeholder: string
145
+ /** Remove EVERY mask (fenced + inline) — used to measure visible length. */
146
+ stripPlaceholders: (s: string) => string
137
147
  }
138
148
 
139
149
  /**
@@ -144,31 +154,40 @@ interface MaskedCode {
144
154
  * Fenced blocks are extracted FIRST and only when CLOSED (matching ```), so an
145
155
  * unclosed fence is left intact rather than misparsed by the inline pass. Inline
146
156
  * spans use `[^\`\n]+` — the same definition the chunker treats as code.
157
+ *
158
+ * Fenced and inline masks carry DISTINCT prefixes (`\x00RMF…` vs `\x00RMI…`).
159
+ * This matters because the fenced prefix is what the block-structure predicates
160
+ * (`isFenceOpenLine`, `isMarkerLine`) use to recognise a standalone masked code
161
+ * block. Sharing one prefix (the pre-fix bug) made a line that merely STARTS
162
+ * with an inline code span look like a fenced block, so its lone `\n` was never
163
+ * hardened and the card collapsed into one run-on line (real victim:
164
+ * `/vault get` rendering `\`key\` = \`value\``).
147
165
  */
148
166
  function maskCodeRegions(text: string, nonce: string): MaskedCode {
149
- const CODE_MASK_PH = `\x00RM${nonce}_`
167
+ const FENCE_MASK_PH = `\x00RMF${nonce}_`
168
+ const INLINE_MASK_PH = `\x00RMI${nonce}_`
150
169
  const codeMasks: string[] = []
151
170
 
152
171
  const masked = text
153
172
  .replace(/```[\s\S]*?```/g, (m) => {
154
173
  const idx = codeMasks.length
155
174
  codeMasks.push(m)
156
- return `${CODE_MASK_PH}${idx}\x00`
175
+ return `${FENCE_MASK_PH}${idx}\x00`
157
176
  })
158
177
  .replace(/`[^`\n]+`/g, (m) => {
159
178
  const idx = codeMasks.length
160
179
  codeMasks.push(m)
161
- return `${CODE_MASK_PH}${idx}\x00`
180
+ return `${INLINE_MASK_PH}${idx}\x00`
162
181
  })
163
182
 
164
- const restoreRe = new RegExp(
165
- `${CODE_MASK_PH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)\x00`,
166
- 'g',
167
- )
183
+ // Restore / strip match EITHER prefix, keyed on the shared index space.
184
+ const escNonce = nonce.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
185
+ const anyMaskRe = new RegExp(`\x00RM[FI]${escNonce}_(\\d+)\x00`, 'g')
168
186
  const restore = (s: string): string =>
169
- s.replace(restoreRe, (_m, idx) => codeMasks[Number(idx)] ?? _m)
187
+ s.replace(anyMaskRe, (_m, idx) => codeMasks[Number(idx)] ?? _m)
188
+ const stripPlaceholders = (s: string): string => s.replace(anyMaskRe, '')
170
189
 
171
- return { masked, restore, placeholder: CODE_MASK_PH }
190
+ return { masked, restore, placeholder: FENCE_MASK_PH, stripPlaceholders }
172
191
  }
173
192
 
174
193
  // ---------------------------------------------------------------------------
@@ -344,6 +363,92 @@ export function normalizeParagraphBreaks(text: string): string {
344
363
  return restore(out)
345
364
  }
346
365
 
366
+ // ---------------------------------------------------------------------------
367
+ // Card line-break hardener — for DETERMINISTIC command/card bodies
368
+ // ---------------------------------------------------------------------------
369
+
370
+ /**
371
+ * Harden the lone `\n` line breaks of a DETERMINISTIC card body into GFM hard
372
+ * breaks (` \n`, two trailing spaces) so every field lands on its own line
373
+ * under Telegram's Bot API 10.1 rich-message (GFM) renderer.
374
+ *
375
+ * Why this exists (the run-on-blob bug): the rich path (#2669) renders a lone
376
+ * `\n` between two non-blank lines as a *soft* break — the two lines collapse
377
+ * onto the same visual line with a space between them. Agent PROSE is repaired
378
+ * on the reply path by `normalizeParagraphBreaks`, but the ~98 slash-command
379
+ * card replies dispatched through `switchroomReply(…, { html: true })` are sent
380
+ * as RAW markdown with no normalization. Their builders stack short labelled
381
+ * fields (`**5h window** …`, `**Model** …`, `Auth: ✓ Max …`) joined by a single
382
+ * `\n`, so the whole card renders as one run-on blob.
383
+ *
384
+ * A deterministic card is NOT free prose — every newline its builder emits is
385
+ * an INTENDED line break. So this hardener promotes UNCONDITIONALLY (no
386
+ * sentence-terminal-punctuation gate, unlike `normalizeParagraphBreaks`) with
387
+ * one exception: a line that participates in a genuine GFM block construct
388
+ * (list / table / blockquote / heading / fenced code) keeps its single `\n` so
389
+ * its native stacking / contiguity survives — a monospace table inside a ```
390
+ * fence is never touched (it is code-masked AND the fence lines are excluded).
391
+ * Real `\n\n` paragraph gaps (a builder's block separators) are preserved.
392
+ *
393
+ * This is the string-level sibling of `stackCardLines` (card-format.ts), which
394
+ * does the same promotion from a pre-split `string[]` of guaranteed
395
+ * single-line, non-block entries. Use `hardenCardBreaks` where the card body is
396
+ * already an assembled string (e.g. the `switchroomReply` chokepoint) and may
397
+ * legitimately contain GFM block constructs.
398
+ *
399
+ * Runs on code-masked text and is idempotent — a break already hardened to
400
+ * ` \n` re-hardens to the same ` \n`.
401
+ */
402
+ export function hardenCardBreaks(text: string): string {
403
+ if (!text.includes('\n')) return text
404
+
405
+ const nonce = Math.random().toString(36).slice(2)
406
+ const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
407
+
408
+ // Collapse 3+ newline runs to a single clean `\n\n` gap (mirrors
409
+ // normalizeParagraphBreaks step 1) so a stray extra blank line never becomes
410
+ // an oversized gap. A genuine one-blank-line `\n\n` block gap is preserved.
411
+ const out = masked.replace(/\n{3,}/g, '\n\n')
412
+
413
+ // A line participating in a GFM block construct whose single-`\n` contiguity
414
+ // must survive (its interior must NOT get a hard break).
415
+ const isBlockConstructLine = (line: string): boolean =>
416
+ isListItemLine(line) ||
417
+ isTableRowLine(line) ||
418
+ isTableDelimiterLine(line) ||
419
+ isBlockquoteLine(line) ||
420
+ isHeadingLine(line) ||
421
+ isFenceOpenLine(line, placeholder)
422
+
423
+ const lines = out.split('\n')
424
+ const pieces: string[] = []
425
+ for (let i = 0; i < lines.length; i++) {
426
+ let line = lines[i]
427
+ const isLast = i === lines.length - 1
428
+ const next = isLast ? '' : lines[i + 1]
429
+ // Promote only between two non-blank content lines where NEITHER is a GFM
430
+ // block-construct line (so lists / tables / quotes / headings / fences keep
431
+ // their native single-`\n` stacking). A blank current/next line is a `\n\n`
432
+ // paragraph gap — never promote across it.
433
+ const promote =
434
+ !isLast &&
435
+ line.trim() !== '' &&
436
+ next.trim() !== '' &&
437
+ !isBlockConstructLine(line) &&
438
+ !isBlockConstructLine(next)
439
+ if (promote) {
440
+ // Strip trailing whitespace so a re-run emits exactly one ` \n` (never
441
+ // accumulate spaces). Include `\r` for CRLF sources.
442
+ line = line.replace(/[ \t\r]+$/, '')
443
+ }
444
+ pieces.push(line)
445
+ if (isLast) break
446
+ pieces.push(promote ? ' \n' : '\n')
447
+ }
448
+
449
+ return restore(pieces.join(''))
450
+ }
451
+
347
452
  // ---------------------------------------------------------------------------
348
453
  // Paragraph spacers — restore a VISIBLE blank line between prose paragraphs
349
454
  // ---------------------------------------------------------------------------
@@ -623,14 +728,11 @@ export function stripExcessBold(text: string): string {
623
728
  if (!text.includes('**')) return text
624
729
 
625
730
  const nonce = Math.random().toString(36).slice(2)
626
- const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
731
+ const { masked, restore, placeholder, stripPlaceholders } = maskCodeRegions(text, nonce)
627
732
 
628
- // Non-code character budget: masked text with the placeholders removed.
629
- const placeholderRe = new RegExp(
630
- `${placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\d+\x00`,
631
- 'g',
632
- )
633
- const visible = masked.replace(placeholderRe, '')
733
+ // Non-code character budget: masked text with BOTH fenced + inline masks
734
+ // removed (stripPlaceholders handles the two distinct prefixes).
735
+ const visible = stripPlaceholders(masked)
634
736
  if (visible.length < 100) return restore(masked)
635
737
 
636
738
  let boldChars = 0
@@ -13,7 +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
+ import { escapeMarkdown, codeSpanSafe, hardenCardBreaks } from '../format.js';
17
17
  import type { Bot, Context } from "grammy";
18
18
  import { richMessage } from "../rich-send.js";
19
19
  import {
@@ -84,7 +84,11 @@ export function registerApprovalsCommands(
84
84
  );
85
85
  })
86
86
  .join("\n");
87
- await ctx.replyWithRichMessage(richMessage(`**Active approvals**\n\n${summary}\n\n${detail}`));
87
+ // hardenCardBreaks: the per-agent `summary` rows and per-decision
88
+ // `detail` rows are single-`\n`-joined field lines that would soft-
89
+ // collapse into one blob under the GFM rich renderer. Harden them into
90
+ // GFM hard breaks (block gaps between the three sections preserved).
91
+ await ctx.replyWithRichMessage(richMessage(hardenCardBreaks(`**Active approvals**\n\n${summary}\n\n${detail}`)));
88
92
  return;
89
93
  }
90
94
 
@@ -0,0 +1,113 @@
1
+ /**
2
+ * #2787 Mechanism B — the `claudeBusyKeys` orphan-reaper lifecycle, extracted
3
+ * so the gateway glue that mutates the busy-key set stays in lockstep with its
4
+ * shadow insertion-timestamp map and is unit-testable WITHOUT importing
5
+ * `gateway.ts` (which boots the bot + IPC listener on module load and so cannot
6
+ * run inside a unit test). The gateway keeps ownership of the two containers
7
+ * (`claudeBusyKeys: Set`, `claudeBusyKeySince: Map`) and calls these helpers at
8
+ * every mark / reap site; `disconnect-flush.ts` clears them at bridge death.
9
+ * The test harness (`tests/busy-key-reaper.test.ts`) wires the SAME containers,
10
+ * the SAME delivery queue, and the real `flushOnAgentDisconnect` to reproduce
11
+ * the disconnect → reconnect → re-mark and slow-vs-orphan delivery sequences.
12
+ *
13
+ * Two invariants this module enforces:
14
+ *
15
+ * 1. LOCKSTEP. The timestamp map shadows actual SET MEMBERSHIP, not its own
16
+ * prior presence. `markBusyKeyLockstep` stamps a fresh timestamp whenever a
17
+ * key transitions idle→busy (`claudeBusyKeys.has(key) === false`). Gating on
18
+ * the set — not on `since.has` — is load-bearing: a disconnect flush clears
19
+ * `claudeBusyKeys` directly, so if the shadow map ever lagged, a `!since.has`
20
+ * guard would decline to re-stamp on the reconnect→re-mark and leave a
21
+ * stale, >TTL-old timestamp on a freshly-marked key — which the reaper would
22
+ * then reap out from under a live delivery.
23
+ *
24
+ * 2. SLOW-DELIVERY SAFETY (the #1922 hazard). `busy` is marked EAGERLY at
25
+ * delivery, and the gateway→claude enqueue-ack lag can be up to ~5 MINUTES
26
+ * under load (#1922). During that eager-mark→enqueue window the gateway's
27
+ * `currentTurn` is null yet the turn is real-and-merely-slow. A bare
28
+ * time-grace reaper cannot tell "slow" from "orphaned" and would reap a
29
+ * genuine delivery, re-opening the idle-drain gate while claude is about to
30
+ * process the very inbound whose key it just reaped (duplicate / concurrent
31
+ * delivery on the CORE inbound path). So the reap is PROOF-GATED: a key is
32
+ * reaped ONLY when it has NO entry in the delivery-confirm queue. A
33
+ * slow-but-real inbound is tracked there from delivery until claude's
34
+ * `enqueue` ack (via the never-drop re-deliver loop), so any key still
35
+ * awaiting its turn is present and skipped — no matter how slow. The time
36
+ * grace is retained as defense-in-depth, defaulting well above the observed
37
+ * ack-lag tail. Only a busy-marked key with NO pending delivery AND age past
38
+ * the grace is a true orphan (its turn acked and should have cleared busy at
39
+ * turn_end, or it was a steer/interrupt inbound excluded from tracking that
40
+ * amends a now-absent turn) — reaping those can never clobber a slow one.
41
+ */
42
+
43
+ /** Lockstep mark: add to the busy set and, on the idle→busy transition only,
44
+ * stamp the insertion time. Keyed on set membership so a re-mark after a
45
+ * disconnect flush (which cleared the set directly) always re-stamps fresh. */
46
+ export function markBusyKeyLockstep(
47
+ keys: Set<string>,
48
+ since: Map<string, number>,
49
+ key: string,
50
+ now: number,
51
+ ): void {
52
+ const wasBusy = keys.has(key)
53
+ keys.add(key)
54
+ if (!wasBusy) since.set(key, now)
55
+ }
56
+
57
+ /** Lockstep clear: delete from both containers. */
58
+ export function clearBusyKeyLockstep(
59
+ keys: Set<string>,
60
+ since: Map<string, number>,
61
+ key: string,
62
+ ): void {
63
+ keys.delete(key)
64
+ since.delete(key)
65
+ }
66
+
67
+ export interface ReapOptions {
68
+ /** Grace before a busy-marked key with no pending delivery is a true orphan. */
69
+ ttlMs: number
70
+ /** True iff a delivery-confirm entry is still tracked for this key — i.e. a
71
+ * slow-but-real inbound awaiting its enqueue ack. Such keys are NEVER reaped,
72
+ * regardless of age (the #1922 slow-delivery guarantee). */
73
+ hasPendingDelivery: (key: string) => boolean
74
+ /** Optional line logger for each reaped key (observability). */
75
+ log?: (msg: string) => void
76
+ }
77
+
78
+ /**
79
+ * Reap orphaned busy markers. Call ONLY once the gateway has asserted no turn is
80
+ * in flight (`currentTurn == null`). Returns the keys reaped (for the caller's
81
+ * metrics / assertions). Also prunes shadow-map entries whose key already left
82
+ * the set, so the map can't grow unbounded.
83
+ */
84
+ export function reapOrphanBusyKeys(
85
+ keys: Set<string>,
86
+ since: Map<string, number>,
87
+ now: number,
88
+ opts: ReapOptions,
89
+ ): string[] {
90
+ const reaped: string[] = []
91
+ for (const key of [...since.keys()]) {
92
+ if (!keys.has(key)) {
93
+ since.delete(key)
94
+ continue
95
+ }
96
+ // Proof gate: a key with a pending delivery is a slow-but-real inbound still
97
+ // awaiting its enqueue ack (the never-drop re-deliver loop owns it) — NEVER
98
+ // an orphan, regardless of age. Skip so a multi-minute (#1922) delivery
99
+ // can't be reaped out from under claude.
100
+ if (opts.hasPendingDelivery(key)) continue
101
+ const stampedAt = since.get(key) ?? now
102
+ if (now - stampedAt >= opts.ttlMs) {
103
+ keys.delete(key)
104
+ since.delete(key)
105
+ reaped.push(key)
106
+ opts.log?.(
107
+ `telegram gateway: reaped orphan busy key=${key} (no turn in flight, ` +
108
+ `no pending delivery, >${opts.ttlMs}ms) — unwedging idle-drain`,
109
+ )
110
+ }
111
+ }
112
+ return reaped
113
+ }
@@ -42,6 +42,12 @@ export interface DisconnectFlushDeps<Ctrl extends { finalize: (reason?: 'done' |
42
42
  * activeTurnStartedAt — the bridge just died, every turn it
43
43
  * was handed is dead by definition. */
44
44
  claudeBusyKeys: Set<string>
45
+ /** #2787: insertion-timestamp map for `claudeBusyKeys`, backing the orphan
46
+ * reaper. MUST stay in lockstep with `claudeBusyKeys` at every clear site —
47
+ * a stale timestamp surviving a clear makes a later re-marked key look
48
+ * >TTL-old and get reaped against a live delivery. Deleted alongside every
49
+ * `claudeBusyKeys.delete`/`.clear` below. */
50
+ claudeBusyKeySince: Map<string, number>
45
51
 
46
52
  /** Open draft-stream handles keyed by chat:thread:replyId. */
47
53
  activeDraftStreams: Map<string, Stream>
@@ -82,6 +88,7 @@ export function flushOnAgentDisconnect<
82
88
  activeReactionMsgIds,
83
89
  activeTurnStartedAt,
84
90
  claudeBusyKeys,
91
+ claudeBusyKeySince,
85
92
  activeDraftStreams,
86
93
  clearActiveReactions,
87
94
  disposeProgressDriver,
@@ -109,6 +116,7 @@ export function flushOnAgentDisconnect<
109
116
  activeReactionMsgIds.delete(key)
110
117
  activeTurnStartedAt.delete(key)
111
118
  claudeBusyKeys.delete(key)
119
+ claudeBusyKeySince.delete(key) // #2787: keep orphan-TTL map in lockstep
112
120
  }
113
121
  clearActiveReactions()
114
122
 
@@ -129,6 +137,7 @@ export function flushOnAgentDisconnect<
129
137
  activeTurnStartedAt.delete(k)
130
138
  activeReactionMsgIds.delete(k)
131
139
  claudeBusyKeys.delete(k)
140
+ claudeBusyKeySince.delete(k) // #2787: keep orphan-TTL map in lockstep
132
141
  }
133
142
  log(
134
143
  `telegram gateway: disconnect-flush swept ${danglingKeys.length} dangling turn key(s) ` +
@@ -156,6 +165,8 @@ export function flushOnAgentDisconnect<
156
165
  const orphanCount = claudeBusyKeys.size
157
166
  const orphanKeys = [...claudeBusyKeys]
158
167
  claudeBusyKeys.clear()
168
+ // #2787: keep the orphan-TTL map in lockstep with the set it shadows.
169
+ for (const k of orphanKeys) claudeBusyKeySince.delete(k)
159
170
  log(
160
171
  `telegram gateway: disconnect-flush cleared ${orphanCount} orphan claudeBusyKeys ` +
161
172
  `entr${orphanCount === 1 ? 'y' : 'ies'} (synthetic-inbound deliveries that never turn_ended)` +
@@ -0,0 +1,46 @@
1
+ /**
2
+ * escalation-bridge-gate.ts — the bridge-alive gate for the obligation sweep's
3
+ * ESCALATE branch, extracted from obligationSweep so the "do NOT direct-send the
4
+ * 'I may have missed this' nudge while the Telegram bridge is down" decision
5
+ * (#2788 Gap A) is EXECUTABLE in a pure unit test.
6
+ *
7
+ * The bug (#2788 Gap A): the obligation sweep keeps running while the Telegram
8
+ * bridge is disconnected. Unlike the represent branch (which goes through the
9
+ * bridge → buffer and is naturally stranded behind a dead bridge), the escalate
10
+ * branch DIRECT-SENDS via `bot.api.sendMessage`, bypassing the bridge. So during
11
+ * a transient bridge flap the user could receive a false "I may have missed this"
12
+ * even though the real reply is simply queued behind the outage and will land the
13
+ * moment the bridge recovers.
14
+ *
15
+ * The fix is a deferral, not a drop: obligations already survive bridge death
16
+ * (they live in the durable ledger and are re-evaluated every sweep), so gating
17
+ * the escalate SEND on bridge-alive and returning without closing leaves the
18
+ * obligation OPEN. The next sweep after the bridge reconnects re-drives it — the
19
+ * nudge is deferred until it can reflect a real, post-recovery miss. This adds NO
20
+ * unbounded liveness dependency: the entire gateway's inbound delivery already
21
+ * rests on the bridge eventually reconnecting / the process restarting.
22
+ *
23
+ * PURE — no Telegram, no IPC; the gateway injects `bridgeAlive` (read from
24
+ * `ipcServer.getClient(agent)?.isAlive()`).
25
+ */
26
+
27
+ export interface EscalationBridgeGateDeps {
28
+ /**
29
+ * True when this agent's bridge sidecar is currently registered and alive on
30
+ * the IPC server — i.e. a direct escalate send can actually reflect delivery
31
+ * reality rather than racing a queued-behind-a-dead-bridge reply.
32
+ */
33
+ bridgeAlive: boolean
34
+ }
35
+
36
+ /**
37
+ * Decide whether the escalate branch should DEFER (skip this sweep, leave the
38
+ * obligation OPEN) because the bridge is down.
39
+ *
40
+ * Returns true ⇒ defer: do not send the nudge, do not close — the durable
41
+ * obligation is re-driven on a later sweep once the bridge recovers.
42
+ * Returns false ⇒ the bridge is alive; proceed with escalation.
43
+ */
44
+ export function shouldDeferEscalationForBridge(deps: EscalationBridgeGateDeps): boolean {
45
+ return !deps.bridgeAlive
46
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Gate-parity drift probe — scaffolding for the inbound-delivery
3
+ * state-machine cutover (#2794, RFC PR3b→PR4).
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * The inbound-delivery pipeline is mid-migration and TRIPLE-maintained:
8
+ *
9
+ * 1. the pure state machine (`inbound-delivery-machine.ts`) — the model,
10
+ * 2. the shadow (`inbound-delivery-machine-shadow.ts`) — module-scope
11
+ * machine state advanced by `shadowEmit`, now AUTHORITATIVE for the
12
+ * turn-in-flight gate via `isMachineInTurn()`,
13
+ * 3. the legacy imperative `claudeBusyKeys` set — still fully maintained
14
+ * in parallel and read as the kill-switch fallback.
15
+ *
16
+ * Per #2794 the standing risk is DRIFT: because the same turn-lifecycle
17
+ * fact ("is a turn in flight?") is tracked in two live places, a fix in
18
+ * one can silently diverge from the other. The machine is authoritative
19
+ * today, but `claudeBusyKeys` is still wired and still read on the
20
+ * kill-switch path — so the two MUST NOT drift in the dangerous direction.
21
+ *
22
+ * ## What "agree" means (and the ONE intended divergence)
23
+ *
24
+ * The machine and `claudeBusyKeys` are designed to agree on every
25
+ * WELL-FORMED schedule (every turnStart has a matching turnEnd). They are
26
+ * deliberately allowed to diverge in exactly ONE direction on a MALFORMED
27
+ * schedule — an orphaned turnStart (turn B opens before turn A's turnEnd
28
+ * ever lands, the gymbro/clerk 5-min dangle of 2026-05-28):
29
+ *
30
+ * - `busykeys_dangle` — machine reads idle (self-healed via TTL tick or
31
+ * single-activeTurn reopen) while `claudeBusyKeys`
32
+ * still holds an orphan key. This is EXPECTED and
33
+ * GOOD: it is precisely the wedge the machine was
34
+ * made authoritative to kill. Not a drift alarm.
35
+ *
36
+ * - `machine_over_holds` — machine reads in-flight while `claudeBusyKeys`
37
+ * is empty. This is the DANGEROUS direction: the
38
+ * now-authoritative gate would hold closed while
39
+ * the imperative view says idle — a NEW wedge
40
+ * class the cutover must never introduce. This
41
+ * is the drift #2794 warns about; surface it.
42
+ *
43
+ * This module is a pure classifier plus a log-only runtime probe. It
44
+ * performs NO I/O of its own beyond an optional injected log sink and
45
+ * changes NO delivery behaviour — it only observes. Deleting it once the
46
+ * `claudeBusyKeys` shadow is removed in PR4 is a no-op for behaviour.
47
+ */
48
+
49
+ export type GateParityDivergence = 'none' | 'busykeys_dangle' | 'machine_over_holds'
50
+
51
+ /**
52
+ * Classify the relationship between the machine's authoritative
53
+ * turn-in-flight read and the legacy imperative `claudeBusyKeys` size.
54
+ * Pure — no side effects.
55
+ */
56
+ export function gateParityDivergence(
57
+ machineInTurn: boolean,
58
+ busyKeysSize: number,
59
+ ): GateParityDivergence {
60
+ const busy = busyKeysSize > 0
61
+ if (machineInTurn === busy) return 'none'
62
+ // machine idle, busyKeys non-empty → the orphan dangle the machine heals.
63
+ if (!machineInTurn && busy) return 'busykeys_dangle'
64
+ // machine in-flight, busyKeys empty → the dangerous over-hold.
65
+ return 'machine_over_holds'
66
+ }
67
+
68
+ /**
69
+ * True only for divergences that indicate a real cutover regression
70
+ * (the machine holding a gate the imperative shadow believes is open).
71
+ * The benign `busykeys_dangle` — the very wedge the machine fixes — is
72
+ * NOT flagged, so this probe has zero false positives on the known-good
73
+ * self-heal path.
74
+ */
75
+ export function isDangerousGateDivergence(d: GateParityDivergence): boolean {
76
+ return d === 'machine_over_holds'
77
+ }
78
+
79
+ /**
80
+ * Log-only runtime drift canary. Call at the authoritative gate read.
81
+ * Emits a single grep-friendly `gw-trace gate-drift` line ONLY on the
82
+ * dangerous over-hold direction. Returns the machine value UNCHANGED so
83
+ * it can wrap the gate read without altering behaviour:
84
+ *
85
+ * return probeGateParity(isMachineInTurn(), claudeBusyKeys.size)
86
+ *
87
+ * @param log optional sink (default stderr) — test hook.
88
+ */
89
+ export function probeGateParity(
90
+ machineInTurn: boolean,
91
+ busyKeysSize: number,
92
+ log: (line: string) => void = (line) => process.stderr.write(line),
93
+ ): boolean {
94
+ const d = gateParityDivergence(machineInTurn, busyKeysSize)
95
+ if (isDangerousGateDivergence(d)) {
96
+ log(
97
+ `gw-trace gate-drift kind=${d} machineInTurn=${machineInTurn} ` +
98
+ `busyKeys=${busyKeysSize} note=machine-authoritative-gate-holds-while-imperative-idle\n`,
99
+ )
100
+ }
101
+ return machineInTurn
102
+ }