switchroom 0.17.6 → 0.18.3

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 (197) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +65 -5
  3. package/dist/auth-broker/index.js +6623 -514
  4. package/dist/cli/notion-write-pretool.mjs +64 -4
  5. package/dist/cli/switchroom.js +1888 -1162
  6. package/dist/host-control/main.js +6306 -162
  7. package/dist/vault/approvals/kernel-server.js +6014 -202
  8. package/dist/vault/broker/server.js +6741 -940
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +218 -25
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +116 -0
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/skills/mental-model-curator/SKILL.md +162 -0
  18. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  19. package/telegram-plugin/bridge/bridge.ts +80 -1
  20. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  21. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  22. package/telegram-plugin/consolidation-legibility.ts +279 -0
  23. package/telegram-plugin/context-exhaustion.ts +124 -0
  24. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  25. package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
  26. package/telegram-plugin/dist/server.js +86 -2
  27. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  28. package/telegram-plugin/gateway/activity-card-store.ts +369 -0
  29. package/telegram-plugin/gateway/gateway.ts +1861 -172
  30. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  31. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  32. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  33. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  34. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  35. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  36. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  37. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  38. package/telegram-plugin/gateway/model-command.ts +70 -10
  39. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  40. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  41. package/telegram-plugin/memory-legibility.ts +217 -0
  42. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  43. package/telegram-plugin/package.json +6 -0
  44. package/telegram-plugin/quota-watch.ts +4 -6
  45. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  46. package/telegram-plugin/registry/turns-schema.ts +78 -0
  47. package/telegram-plugin/render/ir.ts +209 -0
  48. package/telegram-plugin/render/parse.ts +363 -0
  49. package/telegram-plugin/render/render.ts +440 -0
  50. package/telegram-plugin/render/rich-render.ts +72 -0
  51. package/telegram-plugin/scoped-approval.ts +59 -0
  52. package/telegram-plugin/silent-end.ts +78 -0
  53. package/telegram-plugin/stream-controller.ts +14 -3
  54. package/telegram-plugin/subagent-watcher.ts +60 -6
  55. package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
  56. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  57. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  58. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  59. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  60. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  61. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  62. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
  63. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  64. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  65. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  66. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  67. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  68. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  69. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  70. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  71. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  72. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  73. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  74. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  75. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  76. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  77. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  78. package/telegram-plugin/tests/model-command.test.ts +193 -16
  79. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  80. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  81. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  82. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  83. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  84. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  85. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  86. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  87. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  88. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  89. package/telegram-plugin/tests/render/render.test.ts +436 -0
  90. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  91. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  92. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  93. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  94. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  95. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  96. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
  97. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  98. package/telegram-plugin/uat/assertions.ts +88 -4
  99. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  100. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  101. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  102. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  103. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  104. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  105. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  106. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  107. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  109. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  110. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  111. package/telegram-plugin/worktree-watch-cwds.ts +135 -0
  112. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  113. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  119. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  132. package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
  133. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  134. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  135. package/vendor/hindsight-memory/scripts/recall.py +217 -10
  136. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  137. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  138. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  157. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  158. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  159. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  160. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  161. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  162. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  163. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  164. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  165. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  166. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  167. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  168. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  169. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  170. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  171. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  172. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  173. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  174. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  175. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  176. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  177. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  178. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  179. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  180. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  181. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  182. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  183. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  184. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  185. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  186. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  187. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  188. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  189. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  190. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  191. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  192. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  193. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  194. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  195. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  196. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
  197. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -0,0 +1,440 @@
1
+ // IR -> Telegram rich-markdown renderer for the Telegram HTML render engine
2
+ // (name kept for continuity with render/ir.ts + render/parse.ts; the actual
3
+ // wire format is GFM markdown, NOT HTML — see the note below).
4
+ //
5
+ // Increment 2 of the render pipeline: takes the typed IR produced by
6
+ // `parse.ts` (per `ir.ts`) and emits a string suitable for the `markdown`
7
+ // field of `InputRichMessageMarkdown` (`telegram-plugin/rich-send.ts`).
8
+ //
9
+ // IMPORTANT — why this emits markdown, not HTML:
10
+ // `ir.ts`'s block-type doc comment (written during Increment 1) sketches an
11
+ // HTML tag mapping "for the next increment." That comment predates the Bot
12
+ // API 10.1 migration (#2669, `telegram-plugin/rich-send.ts`) becoming the
13
+ // live send path: `sendRichMessage` / `editMessageText` are called with
14
+ // `{ markdown: string }` — raw GFM markdown — not with `{ text, parse_mode:
15
+ // "HTML" }`. There is no HTML anywhere on the current outbound path (see
16
+ // `reference/telegram-formatting-guide.md`). This renderer therefore targets
17
+ // the ACTUAL contract: GFM markdown with the Bot API 10.1 extensions
18
+ // documented in the formatting guide (expandable blockquote via `**> `,
19
+ // spoiler via `||…||`, GFM pipe tables, etc). This module is NOT wired into
20
+ // the live send path yet — that is a later increment, per the RFC's phased
21
+ // rollout (rich rendering stays gated off by default until then).
22
+ //
23
+ // Round-trip note: `parse.ts` folds inline text (`PlainNode.text`,
24
+ // `CodeNode.text`, `code-block` `text`, link `href`) into DECODED strings —
25
+ // entity references and escapes are already resolved by mdast. Rendering
26
+ // back to markdown therefore re-escapes GFM-special characters in prose
27
+ // context with `escapeMarkdown` (same helper `format.ts` uses for
28
+ // dynamic-content interpolation) and re-defuses embedded backticks in code
29
+ // spans with `codeSpanSafe`, so the emitted markdown parses back to
30
+ // equivalent entities rather than accidentally re-triggering formatting or
31
+ // breaking out of a code span.
32
+
33
+ import { escapeMarkdown, codeSpanSafe, RICH_MESSAGE_MAX_CHARS } from "../format.js";
34
+ import type {
35
+ Block,
36
+ BlockquoteNode,
37
+ CodeBlockNode,
38
+ Document,
39
+ Inline,
40
+ ListItem,
41
+ ListNode,
42
+ TableNode,
43
+ TableRow,
44
+ } from "./ir.js";
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Inline rendering
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function renderInline(node: Inline): string {
51
+ switch (node.type) {
52
+ case "plain":
53
+ return escapeMarkdown(node.text);
54
+ case "bold":
55
+ return `**${renderInlineChildren(node.children)}**`;
56
+ case "italic":
57
+ return `*${renderInlineChildren(node.children)}*`;
58
+ case "underline":
59
+ return `__${renderInlineChildren(node.children)}__`;
60
+ case "strike":
61
+ return `~~${renderInlineChildren(node.children)}~~`;
62
+ case "spoiler":
63
+ return `||${renderInlineChildren(node.children)}||`;
64
+ case "highlight":
65
+ return `==${renderInlineChildren(node.children)}==`;
66
+ case "code":
67
+ return `\`${codeSpanSafe(node.text)}\``;
68
+ case "link":
69
+ return `[${renderInlineChildren(node.children)}](${node.href})`;
70
+ default: {
71
+ // Exhaustiveness guard — the IR union is closed; a new variant must be
72
+ // handled above rather than silently dropped.
73
+ const _exhaustive: never = node;
74
+ return escapeMarkdown((_exhaustive as Inline & { text?: string }).text ?? "");
75
+ }
76
+ }
77
+ }
78
+
79
+ function renderInlineChildren(children: Inline[]): string {
80
+ return children.map(renderInline).join("");
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Block rendering
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /** Prefix every line of `text` with `prefix` (used for blockquote nesting). */
88
+ function prefixLines(text: string, prefix: string): string {
89
+ return text
90
+ .split("\n")
91
+ .map((line) => (line.length > 0 ? `${prefix}${line}` : prefix.trimEnd()))
92
+ .join("\n");
93
+ }
94
+
95
+ function renderBlockquote(node: BlockquoteNode): string {
96
+ const inner = renderBlocks(node.children);
97
+ // Bot API 10.1 expandable blockquote: `**> ` on the first quoted line.
98
+ // Plain blockquote: `> ` on every line.
99
+ if (node.expandable) {
100
+ const lines = inner.split("\n");
101
+ return lines
102
+ .map((line, i) => {
103
+ const marker = i === 0 ? "**> " : "> ";
104
+ return line.length > 0 ? `${marker}${line}` : marker.trimEnd();
105
+ })
106
+ .join("\n");
107
+ }
108
+ return prefixLines(inner, "> ");
109
+ }
110
+
111
+ function renderCodeBlock(node: CodeBlockNode): string {
112
+ const lang = node.language ?? "";
113
+ // Fenced code content is verbatim per the formatting guide; the only
114
+ // hazard is an embedded ``` closing the fence early. Widen the fence to a
115
+ // run of backticks one longer than the longest run already present in the
116
+ // content, mirroring the guide's `preBlock` defusal strategy.
117
+ const longestRun = Math.max(
118
+ 0,
119
+ ...(node.text.match(/`+/g) ?? []).map((run) => run.length),
120
+ );
121
+ const fence = "`".repeat(Math.max(3, longestRun + 1));
122
+ return `${fence}${lang}\n${node.text}\n${fence}`;
123
+ }
124
+
125
+ function renderListItem(item: ListItem, ordered: boolean, index: number): string {
126
+ const checkbox =
127
+ item.checked === null ? "" : item.checked ? "[x] " : "[ ] ";
128
+ const marker = ordered ? `${index}. ` : "- ";
129
+ // Tight item (spread=false): join the item's block children with a single
130
+ // newline so a paragraph and its nested sub-list stay adjacent — no spurious
131
+ // blank line injected before the sub-list. Loose item (spread=true): keep the
132
+ // blank-line separation the source author intended.
133
+ const body = renderBlocksJoined(item.children, item.spread ? "\n\n" : "\n");
134
+ const lines = body.split("\n");
135
+ const first = `${marker}${checkbox}${lines[0] ?? ""}`;
136
+ const contIndent = " ".repeat(marker.length);
137
+ const rest = lines
138
+ .slice(1)
139
+ .map((line) => (line.length > 0 ? `${contIndent}${line}` : line));
140
+ return [first, ...rest].join("\n");
141
+ }
142
+
143
+ function renderList(node: ListNode): string {
144
+ const start = node.startNumber ?? 1;
145
+ // Loose list (spread=true): a blank line between top-level items, as the
146
+ // source author wrote them. Tight list (spread=false, the common case and
147
+ // all nested sub-lists): siblings on adjacent lines, single newline.
148
+ const sep = node.spread ? "\n\n" : "\n";
149
+ return node.items
150
+ .map((item, i) => renderListItem(item, node.ordered, start + i))
151
+ .join(sep);
152
+ }
153
+
154
+ /** Render a single cell's inline content for a table (no line breaks — GFM
155
+ * table cells can't contain them; pipes are escaped defensively). */
156
+ function renderTableCell(cells: TableRow["cells"][number]): string {
157
+ return renderInlineChildren(cells.children).replace(/\n+/g, " ");
158
+ }
159
+
160
+ function alignSeparator(align: TableNode["align"][number]): string {
161
+ switch (align) {
162
+ case "left":
163
+ return ":---";
164
+ case "right":
165
+ return "---:";
166
+ case "center":
167
+ return ":---:";
168
+ default:
169
+ return "---";
170
+ }
171
+ }
172
+
173
+ function renderTable(node: TableNode): string {
174
+ const headerCells = node.header.cells.map(renderTableCell);
175
+ const colCount = headerCells.length;
176
+ const align = node.align.length > 0 ? node.align : new Array(colCount).fill(null);
177
+ const headerLine = `| ${headerCells.join(" | ")} |`;
178
+ const sepLine = `| ${align
179
+ .slice(0, colCount)
180
+ .map(alignSeparator)
181
+ .join(" | ")} |`;
182
+ const bodyLines = node.rows.map(
183
+ (row) => `| ${row.cells.map(renderTableCell).join(" | ")} |`,
184
+ );
185
+ return [headerLine, sepLine, ...bodyLines].join("\n");
186
+ }
187
+
188
+ function renderBlock(node: Block): string {
189
+ switch (node.type) {
190
+ case "paragraph":
191
+ return renderInlineChildren(node.children);
192
+ case "heading":
193
+ return `${"#".repeat(node.level)} ${renderInlineChildren(node.children)}`;
194
+ case "blockquote":
195
+ return renderBlockquote(node);
196
+ case "code-block":
197
+ return renderCodeBlock(node);
198
+ case "list":
199
+ return renderList(node);
200
+ case "thematic-break":
201
+ return "---";
202
+ case "table":
203
+ return renderTable(node);
204
+ default: {
205
+ const _exhaustive: never = node;
206
+ return escapeMarkdown((_exhaustive as Block & { text?: string }).text ?? "");
207
+ }
208
+ }
209
+ }
210
+
211
+ /** Join a run of blocks with an explicit separator. Blockquote children and
212
+ * the top-level document use a blank line (`\n\n`); tight list items pass a
213
+ * single newline so a nested sub-list stays adjacent to its item's paragraph
214
+ * (see `renderListItem`). */
215
+ function renderBlocksJoined(blocks: Block[], sep: string): string {
216
+ return blocks.map(renderBlock).join(sep);
217
+ }
218
+
219
+ function renderBlocks(blocks: Block[]): string {
220
+ return renderBlocksJoined(blocks, "\n\n");
221
+ }
222
+
223
+ /** Render a full IR `Document` to Bot API 10.1 rich GFM markdown. */
224
+ export function render(doc: Document): string {
225
+ return renderBlocks(doc.blocks);
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Construct allowlist (gap #2: escape/degrade anything unsupported)
230
+ // ---------------------------------------------------------------------------
231
+
232
+ // The IR node types this renderer emits NATIVE Telegram markup for. The IR
233
+ // union is closed and TypeScript's exhaustiveness `default` branches in
234
+ // `renderInline` / `renderBlock` already escape any unknown variant to literal
235
+ // text rather than emitting a broken tag — so the allowlist is enforced at
236
+ // COMPILE time. These runtime constants make the allowlist inspectable (and
237
+ // unit-testable: a test asserts every IR `type` appears here, so adding an IR
238
+ // node without teaching the renderer to render it fails the build), which is
239
+ // the concrete "construct-allowlist validator" that pairs with the expandable-
240
+ // blockquote / spoiler / underline additions.
241
+ export const SUPPORTED_INLINE = [
242
+ "plain",
243
+ "bold",
244
+ "italic",
245
+ "underline",
246
+ "strike",
247
+ "spoiler",
248
+ "highlight",
249
+ "code",
250
+ "link",
251
+ ] as const;
252
+
253
+ export const SUPPORTED_BLOCK = [
254
+ "paragraph",
255
+ "heading",
256
+ "blockquote",
257
+ "code-block",
258
+ "list",
259
+ "thematic-break",
260
+ "table",
261
+ ] as const;
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Oversized / malformed-content safe fallback
265
+ // ---------------------------------------------------------------------------
266
+
267
+ /** Which construct degraded. `document` = the whole message fell back to
268
+ * plain text. */
269
+ export type DegradationConstruct = "table" | "code-block" | "blockquote" | "document";
270
+
271
+ /** Why a construct could not be emitted as a proper rich construct.
272
+ * - `table-malformed` : the table's structure is not renderable as a rich
273
+ * GFM table (e.g. no columns, ragged rows); emitted
274
+ * as a preformatted code fence instead.
275
+ * - `oversize` : a single atomic block alone exceeds the wire cap;
276
+ * emitted as plain source text.
277
+ * - `document-oversize` : the whole document still exceeds the cap after
278
+ * per-block substitution; sent as plain text with no
279
+ * rich wrapper. */
280
+ export type DegradationReason = "table-malformed" | "oversize" | "document-oversize";
281
+
282
+ /** A tracked degradation — WHY a construct fell back from its native rich form
283
+ * to a plain/code rendering. Returned on `RenderResult` so the caller can log
284
+ * / surface it instead of the fallback happening silently. */
285
+ export interface Degradation {
286
+ construct: DegradationConstruct;
287
+ reason: DegradationReason;
288
+ /** Human-readable detail for logs / telemetry. */
289
+ detail: string;
290
+ }
291
+
292
+ export interface RenderResult {
293
+ /** The rendered text to send. */
294
+ text: string;
295
+ /** "markdown": send as rich `{ markdown }`. "plain": send as a literal
296
+ * string with no rich wrapper (structure stripped, content preserved). */
297
+ mode: "markdown" | "plain";
298
+ /** Constructs that could not be emitted in their native rich form, each with
299
+ * a tracked reason. Empty when everything rendered natively. Never silently
300
+ * degrade — a caller can log/telemeter these. */
301
+ degradations: Degradation[];
302
+ }
303
+
304
+ /**
305
+ * Classify a table that cannot render as a proper rich GFM table. Returns a
306
+ * human-readable reason string, or null when the table is well-formed.
307
+ *
308
+ * mdast normally normalises tables, but a table folded from degraded/adversarial
309
+ * source (or a future IR producer) can still be structurally unrenderable — no
310
+ * header columns, or body rows whose cell count doesn't match the header. Such a
311
+ * table would emit pipes that Telegram renders as broken/literal text, so we
312
+ * catch it here and fall back to a preformatted code fence (content preserved,
313
+ * degradation tracked) rather than shipping a broken row.
314
+ */
315
+ function tableDegradationReason(node: TableNode): string | null {
316
+ const cols = node.header.cells.length;
317
+ if (cols === 0) return "table has no header columns";
318
+ for (let i = 0; i < node.rows.length; i++) {
319
+ const n = node.rows[i].cells.length;
320
+ if (n !== cols) {
321
+ return `row ${i + 1} has ${n} cell(s), expected ${cols} to match the header`;
322
+ }
323
+ }
324
+ return null;
325
+ }
326
+
327
+ /** Wrap a block's raw source text in a widened preformatted code fence — the
328
+ * degraded rendering for a malformed table (content verbatim, no broken
329
+ * markup). Fence-widening mirrors `renderCodeBlock`. */
330
+ function degradeToCodeFence(source: string, node: Block): string {
331
+ const raw = source.slice(node.start, node.end);
332
+ const longestRun = Math.max(0, ...(raw.match(/`+/g) ?? []).map((run) => run.length));
333
+ const fence = "`".repeat(Math.max(3, longestRun + 1));
334
+ return `${fence}\n${raw}\n${fence}`;
335
+ }
336
+
337
+ /**
338
+ * A block is "atomic" for chunking purposes when splitting it mid-construct
339
+ * would break the wire format — an unterminated fence, a half table row, or
340
+ * a blockquote whose closing marker got separated from its opener. These are
341
+ * exactly the constructs `splitMarkdownChunks` (format.ts) already knows how
342
+ * to avoid bisecting for a MULTI-block body, but a SINGLE such block that by
343
+ * itself exceeds the cap has nowhere left to cut without breaking mid-tag —
344
+ * that's the case this module must catch before send.
345
+ */
346
+ function isAtomicBlock(node: Block): boolean {
347
+ return (
348
+ node.type === "code-block" ||
349
+ node.type === "table" ||
350
+ node.type === "blockquote"
351
+ );
352
+ }
353
+
354
+ /** Render `node`'s raw source text as a plain literal (escaped so none of
355
+ * the source's own markdown-special characters accidentally re-trigger
356
+ * formatting when sent WITHOUT the rich wrapper — a plain send has no
357
+ * parser, but downstream literal display should still show the author's
358
+ * intended characters, not stray backslashes, so we use the raw slice
359
+ * as-is: a plain send does not interpret markdown at all). */
360
+ function plainSlice(source: string, node: Block): string {
361
+ return source.slice(node.start, node.end);
362
+ }
363
+
364
+ /**
365
+ * Render `doc` to Bot API 10.1 rich markdown, honoring the wire cap
366
+ * (`RICH_MESSAGE_MAX_CHARS` by default). Behaviour:
367
+ *
368
+ * 1. If the full rendered markdown fits under `maxLen`, return it as-is
369
+ * (`mode: "markdown"`).
370
+ * 2. Otherwise, any individual ATOMIC block (table / code-block /
371
+ * blockquote — constructs that cannot be safely bisected without
372
+ * producing a broken tag/fence/row) whose OWN rendered form alone
373
+ * exceeds `maxLen` is replaced by its raw plain-text source slice
374
+ * instead of being emitted as rich markdown that a later chunker would
375
+ * have to cut mid-construct.
376
+ * 3. If the document *still* exceeds `maxLen` after that per-block
377
+ * substitution (i.e. the oversized content isn't isolated to one
378
+ * swappable block, or the whole document is one giant atomic
379
+ * construct), the ENTIRE document falls back to plain text — the raw
380
+ * source, verbatim, sent with no rich wrapper at all. This never
381
+ * truncates: the caller's ordinary length-based chunker
382
+ * (`splitMarkdownChunks` and `hardSliceToCap`) is what applies size
383
+ * limits to plain text before send; this function's only job is to
384
+ * guarantee it is never handed a rich-markdown body with a mid-tag cut.
385
+ */
386
+ export function renderSafe(
387
+ doc: Document,
388
+ source: string,
389
+ maxLen: number = RICH_MESSAGE_MAX_CHARS,
390
+ ): RenderResult {
391
+ const degradations: Degradation[] = [];
392
+
393
+ // Per-block render, degrading a MALFORMED table (any size) to a preformatted
394
+ // code fence up front so a structurally-unrenderable table never ships as
395
+ // broken pipes — and the reason is recorded, not swallowed.
396
+ const rendered = doc.blocks.map((block) => {
397
+ if (block.type === "table") {
398
+ const reason = tableDegradationReason(block);
399
+ if (reason != null) {
400
+ degradations.push({ construct: "table", reason: "table-malformed", detail: reason });
401
+ return { block, text: degradeToCodeFence(source, block) };
402
+ }
403
+ }
404
+ return { block, text: renderBlock(block) };
405
+ });
406
+
407
+ const full = rendered.map((r) => r.text).join("\n\n");
408
+ if (full.length <= maxLen) {
409
+ return { text: full, mode: "markdown", degradations };
410
+ }
411
+
412
+ // Oversized: swap any atomic block whose OWN rendered form alone exceeds the
413
+ // cap for its raw plain-text source (a construct that can't be safely
414
+ // bisected), recording an `oversize` degradation for each.
415
+ const swapped = rendered.map(({ block, text }) => {
416
+ if (isAtomicBlock(block) && text.length > maxLen) {
417
+ degradations.push({
418
+ construct: block.type as DegradationConstruct,
419
+ reason: "oversize",
420
+ detail: `${block.type} of ${text.length} chars exceeds the ${maxLen}-char cap; sent as plain source text`,
421
+ });
422
+ return escapeMarkdown(plainSlice(source, block));
423
+ }
424
+ return text;
425
+ });
426
+ const patched = swapped.join("\n\n");
427
+ if (patched.length <= maxLen) {
428
+ return { text: patched, mode: "markdown", degradations };
429
+ }
430
+
431
+ // Still oversized (or a giant atomic block never fit even as plain text) —
432
+ // fall all the way back to plain text for the whole document. Never
433
+ // truncate here; that is the length-based chunker's job downstream.
434
+ degradations.push({
435
+ construct: "document",
436
+ reason: "document-oversize",
437
+ detail: `rendered document of ${patched.length} chars exceeds the ${maxLen}-char cap; sent as plain text without the rich wrapper`,
438
+ });
439
+ return { text: source, mode: "plain", degradations };
440
+ }
@@ -0,0 +1,72 @@
1
+ // Flag-gated wiring for the Bot API 10.1 rich renderer (parse.ts + render.ts)
2
+ // into the live outbound send path.
3
+ //
4
+ // The renderer (`render/render.ts`, PR #2930) and parser (`render/parse.ts`)
5
+ // have full unit coverage but were, until this module, wired into NOTHING —
6
+ // no outbound message ever flowed through them. This module is the single,
7
+ // feature-flagged bridge: the gateway's rich send path (stream-controller.ts)
8
+ // runs the assistant's raw markdown through `parse → renderSafe` before it
9
+ // reaches `sendRichMessage`, but ONLY when the flag is on.
10
+ //
11
+ // Feature flag — `SWITCHROOM_RICH_RENDER`, default OFF:
12
+ // Mirrors the `SWITCHROOM_VISIBLE_ANSWER_STREAM` convention
13
+ // (`answer-stream-flag.ts`): an env var read at runtime, default off, opted
14
+ // in PER AGENT via the `env:` block in `switchroom.yaml` (propagated into
15
+ // the container `environment:` by `src/agents/compose.ts`). When off,
16
+ // `maybeRenderOutbound` returns the input untouched, so the live send path
17
+ // is byte-for-byte unchanged — no agent's behaviour moves until an operator
18
+ // explicitly flips the flag for a specific agent. Accepts `1/true/on/yes`.
19
+ //
20
+ // Why route through `renderSafe` and not bare `render`:
21
+ // `renderSafe` guarantees the returned body is never a rich-markdown string
22
+ // with a mid-construct cut, and degrades a document it can't safely fit to
23
+ // plain-text mode (structure stripped, content preserved). The send path
24
+ // honours that `mode` — a `plain` result is sent WITHOUT the rich wrapper.
25
+
26
+ import { parse } from "./parse.js";
27
+ import { renderSafe, type RenderResult } from "./render.js";
28
+ import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
29
+
30
+ /** Parse the `SWITCHROOM_RICH_RENDER` flag value. Default OFF; accepts the
31
+ * same truthy tokens as the other switchroom env flags. Pure so the default
32
+ * + parsing are unit-testable. */
33
+ export function parseRichRenderEnabled(raw: string | undefined): boolean {
34
+ if (raw == null) return false;
35
+ const v = raw.trim().toLowerCase();
36
+ return v === "1" || v === "true" || v === "on" || v === "yes";
37
+ }
38
+
39
+ /** Is the rich renderer enabled in this process? Reads the env flag live so a
40
+ * test can set/unset it per-case; defaults OFF. */
41
+ export function richRenderEnabled(
42
+ env: NodeJS.ProcessEnv = process.env,
43
+ ): boolean {
44
+ return parseRichRenderEnabled(env.SWITCHROOM_RICH_RENDER);
45
+ }
46
+
47
+ /** Unconditionally run `text` through `parse → renderSafe` (ignores the flag).
48
+ * Exposed for tests and callers that have already gated on the flag. */
49
+ export function renderOutbound(
50
+ text: string,
51
+ maxLen: number = RICH_MESSAGE_MAX_CHARS,
52
+ ): RenderResult {
53
+ return renderSafe(parse(text), text, maxLen);
54
+ }
55
+
56
+ /**
57
+ * Flag-gated transform for the live send path.
58
+ *
59
+ * - flag OFF (default): returns the input untouched, `mode: "markdown"` —
60
+ * identical to the pre-existing behaviour (raw transcript markdown sent
61
+ * straight to `sendRichMessage`). No behavioural change for any agent.
62
+ * - flag ON: returns `parse → renderSafe` output. `mode: "plain"` signals
63
+ * the caller to send WITHOUT the rich wrapper (oversized/unsafe content).
64
+ */
65
+ export function maybeRenderOutbound(
66
+ text: string,
67
+ env: NodeJS.ProcessEnv = process.env,
68
+ maxLen: number = RICH_MESSAGE_MAX_CHARS,
69
+ ): RenderResult {
70
+ if (!richRenderEnabled(env)) return { text, mode: "markdown", degradations: [] };
71
+ return renderOutbound(text, maxLen);
72
+ }
@@ -214,6 +214,65 @@ export function sweepScopedGrants(store: ScopedGrantStore, now: number): void {
214
214
  }
215
215
  }
216
216
 
217
+ /** Total number of live-or-not grant entries across all agents. Cheap change
218
+ * signal for "did a sweep remove anything?" (sweeps only ever remove). */
219
+ export function countScopedGrants(store: ScopedGrantStore): number {
220
+ let n = 0;
221
+ for (const list of store.values()) n += list.length;
222
+ return n;
223
+ }
224
+
225
+ /**
226
+ * A single grant flattened for on-disk persistence. Carries the agent it
227
+ * belongs to (the store is keyed by agent), the narrow rule/signature, and
228
+ * the ABSOLUTE wall-clock expiry — never a relative TTL, so a reload can
229
+ * never extend a window (the restart-doesn't-extend invariant). No grant
230
+ * time is stored because nothing needs it: expiry is the only gate.
231
+ */
232
+ export interface SerializedScopedGrant {
233
+ readonly agent: string;
234
+ readonly rule: string;
235
+ readonly expiresAt: number;
236
+ }
237
+
238
+ /** Flatten the per-agent store to a plain array for JSON persistence. */
239
+ export function serializeScopedGrants(store: ScopedGrantStore): SerializedScopedGrant[] {
240
+ const out: SerializedScopedGrant[] = [];
241
+ for (const [agent, list] of store) {
242
+ for (const g of list) out.push({ agent, rule: g.rule, expiresAt: g.expiresAt });
243
+ }
244
+ return out;
245
+ }
246
+
247
+ /**
248
+ * Rebuild the store from persisted entries at gateway boot. Entries already
249
+ * at/past `now` are DROPPED (expiry is absolute — a restart never revives or
250
+ * extends a window). Malformed rows are skipped defensively (fail-closed:
251
+ * an unparseable grant simply doesn't exist → the action re-cards). A
252
+ * duplicate rule for one agent collapses to the latest expiry, mirroring
253
+ * `recordScopedGrant`'s replace-not-accumulate behaviour.
254
+ */
255
+ export function deserializeScopedGrants(
256
+ data: readonly unknown[],
257
+ now: number,
258
+ ): ScopedGrantStore {
259
+ const store: ScopedGrantStore = new Map();
260
+ if (!Array.isArray(data)) return store;
261
+ for (const raw of data) {
262
+ if (!raw || typeof raw !== "object") continue;
263
+ const entry = raw as Partial<SerializedScopedGrant>;
264
+ if (typeof entry.agent !== "string" || entry.agent.length === 0) continue;
265
+ if (typeof entry.rule !== "string" || entry.rule.length === 0) continue;
266
+ if (typeof entry.expiresAt !== "number" || !Number.isFinite(entry.expiresAt)) continue;
267
+ if (entry.expiresAt <= now) continue; // absolute expiry already elapsed → drop
268
+ const list = store.get(entry.agent) ?? [];
269
+ const others = list.filter((g) => g.rule !== entry.rule);
270
+ others.push({ rule: entry.rule, expiresAt: entry.expiresAt });
271
+ store.set(entry.agent, others);
272
+ }
273
+ return store;
274
+ }
275
+
217
276
  /**
218
277
  * Heuristic destructive/irreversible-command detector. FAIL-CLOSED: when
219
278
  * a command can't be read or looks risky, return `true` so it is never
@@ -49,6 +49,17 @@ export interface SilentEndDeps {
49
49
  stateDir?: string
50
50
  /** stderr writer (defaults to `process.stderr.write`). */
51
51
  log?: (line: string) => void
52
+ /**
53
+ * Has a genuine assistant reply been delivered to this chat (optionally
54
+ * scoped to thread) at or after `sinceMs`? Same predicate shape as
55
+ * `history.hasOutboundDeliveredSince` / the represent-guard's dep
56
+ * (`gateway/represent-guard.ts`) — injected here so the exhaustion check
57
+ * below is a pure, testable decision. When omitted (history unavailable,
58
+ * or callers that don't wire it), the check is skipped and the guard
59
+ * falls back to the pre-existing turnKey/retryCount bookkeeping only —
60
+ * never suppress on doubt.
61
+ */
62
+ hasOutboundDeliveredSince?: (chatId: string, sinceMs: number, threadId?: number | null) => boolean
52
63
  }
53
64
 
54
65
  /**
@@ -69,6 +80,35 @@ export interface SilentEndDeps {
69
80
  */
70
81
  export const SILENT_END_MAX_RETRIES = 2
71
82
 
83
+ /**
84
+ * User-facing fallback text delivered when a user-message turn ends with no
85
+ * final answer AND the deterministic Stop-hook re-prompt has already been
86
+ * exhausted (#1161). Without this the user only sees the progress card
87
+ * vanish; silence must never be the failure mode.
88
+ *
89
+ * PR #2892 (reference/rfcs/deterministic-turn-liveness.md Phase 2
90
+ * hardening): include the turn's elapsed so the fallback is honest about
91
+ * how long the user actually waited, instead of a generic apology with no
92
+ * timing. A degenerate/unknown duration (missing, non-finite, or <= 0 —
93
+ * e.g. a turn whose `startedAt` was never stamped) omits the waited clause
94
+ * rather than printing a nonsensical "(waited 0s)".
95
+ *
96
+ * Lives here (not gateway.ts) so the transport-boundary tests exercise the
97
+ * REAL string (tests/silent-end-transport.test.ts), not a hand-maintained
98
+ * copy — gateway.ts is not importable in tests.
99
+ */
100
+ export function silentEndFallbackText(turnDurationMs: number | undefined): string {
101
+ const elapsed =
102
+ typeof turnDurationMs === 'number' && Number.isFinite(turnDurationMs) && turnDurationMs > 0
103
+ ? ` (waited ${Math.round(turnDurationMs / 1000)}s)`
104
+ : ''
105
+ return (
106
+ '⚠️ The agent finished working but didn’t send a reply' +
107
+ elapsed +
108
+ ' — your last message may not have been answered. Please try asking again.'
109
+ )
110
+ }
111
+
72
112
  function resolveStateDir(deps?: SilentEndDeps): string {
73
113
  if (deps?.stateDir != null) return deps.stateDir
74
114
  const env = process.env.TELEGRAM_STATE_DIR
@@ -255,6 +295,44 @@ export function recordSilentTurnEnd(
255
295
  prev.turnKey === args.turnKey &&
256
296
  prev.retryCount >= SILENT_END_MAX_RETRIES
257
297
  ) {
298
+ // Staleness guard (mirrors the represent-guard's #2472 fix,
299
+ // `gateway/represent-guard.ts:shouldSuppressRepresent`): `turnKey` here
300
+ // is `statusKey(chatId, threadId)` — stable across every turn on the
301
+ // same chat/thread, NOT a per-turn nonce (unlike the obligation
302
+ // ledger's `originTurnId`). The whole mechanism depends on a reply
303
+ // ALWAYS clearing this state file via `clearSilentEndState` at the
304
+ // send-site. `clearSilentEndState` is fail-silent by design (state
305
+ // corruption / a write race must never crash the gateway), so if a
306
+ // clear is ever missed, a stale `retryCount >= MAX_RETRIES` record
307
+ // from an OLD, already-answered turn would be misread as "this
308
+ // brand-new dark turn already exhausted its re-prompt budget" —
309
+ // firing the user-facing fallback immediately, without the turn ever
310
+ // going through the Stop-hook re-prompt ladder. Before trusting that
311
+ // reading, verify against real delivery history: has a genuine reply
312
+ // landed on this chat/thread since the stale record was last written?
313
+ // If so, the record is satisfied-but-misdetected — drop it silently
314
+ // and let this dark turn start its OWN fresh retry cycle instead of
315
+ // inheriting someone else's spent budget.
316
+ if (deps?.hasOutboundDeliveredSince?.(args.chatId, prev.timestamp, args.threadId)) {
317
+ emitLog(
318
+ deps,
319
+ `silent-end: stale exhausted record for turnKey=${args.turnKey} ` +
320
+ `(retryCount=${prev.retryCount}) but a reply was delivered since ` +
321
+ `${prev.timestamp} — treating as satisfied-but-misdetected, not exhausted\n`,
322
+ )
323
+ // MUST clear before writing (adversarial review of #2892):
324
+ // writeSilentEndState re-inherits retryCount whenever the on-disk
325
+ // turnKey matches — which it ALWAYS does here, since turnKey is the
326
+ // stable statusKey(chatId, threadId). Writing over the stale record
327
+ // directly would start the "fresh" ladder at retryCount=MAX: the
328
+ // Stop hook would see retryCount >= MAX_RETRIES and never re-prompt,
329
+ // while this call just returned exhausted:false so no fallback fires
330
+ // either — pure silence, strictly worse than the pre-fix behaviour.
331
+ // Clearing first makes the new record genuinely start at retryCount=0.
332
+ clearSilentEndState(args.turnKey, deps)
333
+ writeSilentEndState(args, deps)
334
+ return { exhausted: false }
335
+ }
258
336
  clearSilentEndState(args.turnKey, deps)
259
337
  emitLog(
260
338
  deps,