switchroom 0.18.11 → 0.18.13

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -30,6 +30,7 @@
30
30
 
31
31
  import {
32
32
  validateInlineKeyboard,
33
+ TELEGRAM_BUTTON_LIMITS,
33
34
  type AnyButton,
34
35
  type ButtonValidationError,
35
36
  } from './telegram-button-constraints.js'
@@ -60,10 +61,25 @@ export interface AgentButtonMeta {
60
61
  * removes the entire keyboard to prevent double-fire.
61
62
  */
62
63
  single_use?: boolean
64
+ /**
65
+ * Per-message override for the button-choice-confirmation annotation
66
+ * (#789). When true, tapping annotates the source message body with a
67
+ * "✅ You chose: <label> · HH:MM" line even if the agent default is off;
68
+ * when false, annotation is skipped even if the agent default is on.
69
+ * Undefined falls back to the agent's
70
+ * channels.telegram.button_choice_confirmation.enabled default. Only takes
71
+ * effect on single-use keyboards (re-tappable keyboards are never
72
+ * annotated).
73
+ */
74
+ inline_keyboard_confirm?: boolean
63
75
  }
64
76
 
65
77
  /** Fields the gateway adds to button objects — not valid Telegram API fields. */
66
- const AGENT_META_FIELDS: ReadonlyArray<keyof AgentButtonMeta> = ['ack_text', 'single_use']
78
+ const AGENT_META_FIELDS: ReadonlyArray<keyof AgentButtonMeta> = [
79
+ 'ack_text',
80
+ 'single_use',
81
+ 'inline_keyboard_confirm',
82
+ ]
67
83
 
68
84
  /**
69
85
  * Wrap every callback_data field in a 2D inline-keyboard with the
@@ -99,6 +115,99 @@ export function wrapAgentCallbacks(keyboard: AnyButton[][]): AnyButton[][] {
99
115
  )
100
116
  }
101
117
 
118
+ /**
119
+ * Redact agent-authored free-text on an inline keyboard BEFORE it is sent to
120
+ * Telegram (#3148 fast-follow secret-scrub coverage). `wrapAgentCallbacks`
121
+ * rewrites only `callback_data`; the visible `text` label, the `ack_text`
122
+ * toast, and any `copy_text.text` clipboard payload pass through VERBATIM. An
123
+ * agent that puts a secret in any of those transmits it unmasked, and it
124
+ * resurfaces on tap — the label is echoed back (`button_text`), re-rendered in
125
+ * the "✅ You chose: <label>" annotation (#789), and `ack_text` is shown as the
126
+ * toast. `switch_inline_query` / `switch_inline_query_current_chat` /
127
+ * `switch_inline_query_chosen_chat.query` are also agent-authored free text
128
+ * that Telegram pastes into a chat's input box on tap (the current chat for
129
+ * the `_current_chat` variant, a user-picked chat for `_chosen_chat` — both
130
+ * user-visible), so they carry the same leak class. Route every one of these free-text fields
131
+ * through the SAME outbound redactor the reply `text` body uses, at the
132
+ * outbound boundary, so every downstream resurface reads already-masked bytes.
133
+ *
134
+ * `callback_data` is the routing key and is NEVER touched — redacting it would
135
+ * break tap round-tripping. Button structure/order is preserved; the redactor
136
+ * (`redact()`) only replaces detected secret byte-ranges with a non-empty
137
+ * marker, so it never empties a label (the non-empty-text invariant holds).
138
+ * Returns a fresh keyboard; does not mutate the input. `redactFn` is injected
139
+ * so this stays pure + unit-testable and carries no gateway import cycle.
140
+ *
141
+ * Only agent-authored keyboards flow through here (the `reply` tool path);
142
+ * framework-internal keyboards (approval cards, vault wizard, model menus) are
143
+ * built separately and are NOT redacted by this function.
144
+ */
145
+ export function redactAgentKeyboard(
146
+ keyboard: AnyButton[][],
147
+ redactFn: (s: string) => string,
148
+ ): AnyButton[][] {
149
+ // The keyboard is validated for length BEFORE redaction, but the redaction
150
+ // marker (`[REDACTED:...]`) can be longer than the secret it replaces, so a
151
+ // field that was within a Telegram cap can exceed it after masking. An
152
+ // over-limit button field makes sendMessage 400 → the WHOLE reply is dropped
153
+ // (worse than the leak we just closed), so clamp each masked free-text field
154
+ // to its cap. Truncating a marker is harmless — it's already non-secret.
155
+ const clamp = (s: string, max: number): string =>
156
+ s.length > max ? s.slice(0, max) : s
157
+ return keyboard.map((row) =>
158
+ row.map((btn) => {
159
+ const out: AnyButton = { ...btn }
160
+ if (typeof out.text === 'string') {
161
+ out.text = clamp(redactFn(out.text), TELEGRAM_BUTTON_LIMITS.TEXT_MAX)
162
+ }
163
+ // ack_text is a switchroom-side toast (answerCallbackQuery), not a
164
+ // sendMessage field, so an over-length value can't drop the reply — no
165
+ // clamp needed, but redact it all the same.
166
+ if (typeof out.ack_text === 'string') out.ack_text = redactFn(out.ack_text)
167
+ // switch_inline_query* paste agent free text into a chat input box on tap
168
+ // — same leak class as the label. URL-class fields (url/web_app/login_url)
169
+ // are deliberately left exact: a [REDACTED] marker would corrupt a URL.
170
+ const siq = (out as { switch_inline_query?: unknown }).switch_inline_query
171
+ if (typeof siq === 'string') {
172
+ (out as { switch_inline_query?: string }).switch_inline_query = clamp(
173
+ redactFn(siq), TELEGRAM_BUTTON_LIMITS.SWITCH_INLINE_QUERY_MAX)
174
+ }
175
+ const siqc = (out as { switch_inline_query_current_chat?: unknown })
176
+ .switch_inline_query_current_chat
177
+ if (typeof siqc === 'string') {
178
+ (out as { switch_inline_query_current_chat?: string })
179
+ .switch_inline_query_current_chat = clamp(
180
+ redactFn(siqc), TELEGRAM_BUTTON_LIMITS.SWITCH_INLINE_QUERY_MAX)
181
+ }
182
+ // switch_inline_query_chosen_chat.query is the third variant: agent free
183
+ // text pasted into a user-picked chat's input box on tap — same leak class.
184
+ const cc = (out as { switch_inline_query_chosen_chat?: unknown })
185
+ .switch_inline_query_chosen_chat
186
+ if (cc != null && typeof cc === 'object' &&
187
+ typeof (cc as { query?: unknown }).query === 'string') {
188
+ (out as { switch_inline_query_chosen_chat?: Record<string, unknown> })
189
+ .switch_inline_query_chosen_chat = {
190
+ ...(cc as Record<string, unknown>),
191
+ query: clamp(
192
+ redactFn((cc as { query: string }).query),
193
+ TELEGRAM_BUTTON_LIMITS.SWITCH_INLINE_QUERY_MAX),
194
+ }
195
+ }
196
+ const ct = out.copy_text
197
+ if (ct != null && typeof ct === 'object' &&
198
+ typeof (ct as { text?: unknown }).text === 'string') {
199
+ out.copy_text = {
200
+ ...(ct as Record<string, unknown>),
201
+ text: clamp(
202
+ redactFn((ct as { text: string }).text),
203
+ TELEGRAM_BUTTON_LIMITS.COPY_TEXT_MAX),
204
+ }
205
+ }
206
+ return out
207
+ }),
208
+ )
209
+ }
210
+
102
211
  /**
103
212
  * Extract per-button {@link AgentButtonMeta} from a raw (pre-wrap)
104
213
  * keyboard. Returns a map keyed by the raw (unprefixed) callback_data
@@ -116,7 +225,14 @@ export function extractAgentButtonMeta(
116
225
  const meta: AgentButtonMeta = {}
117
226
  if (typeof btn.ack_text === 'string') meta.ack_text = btn.ack_text
118
227
  if (typeof btn.single_use === 'boolean') meta.single_use = btn.single_use
119
- if (meta.ack_text != null || meta.single_use != null) {
228
+ if (typeof btn.inline_keyboard_confirm === 'boolean') {
229
+ meta.inline_keyboard_confirm = btn.inline_keyboard_confirm
230
+ }
231
+ if (
232
+ meta.ack_text != null ||
233
+ meta.single_use != null ||
234
+ meta.inline_keyboard_confirm != null
235
+ ) {
120
236
  out.set(btn.callback_data, meta)
121
237
  }
122
238
  }
@@ -149,6 +265,191 @@ export function parseAgentCallback(data: string): { raw: string } | null {
149
265
  return { raw: data.slice(AGENT_CALLBACK_PREFIX.length) }
150
266
  }
151
267
 
268
+ // ─── #789 button-choice-confirmation ("✅ You chose: X") ──────────────────
269
+ //
270
+ // When a user taps an agent-emitted single-use inline_keyboard button, the
271
+ // gateway can annotate the source message body with a
272
+ // "✅ You chose: <label> · HH:MM" line so the chat surface is
273
+ // self-documenting (mirrors the ask_user finalize UX). The DECISION and the
274
+ // rendered text are pure functions here so they can be unit-tested against
275
+ // the exact payload the gateway ships — the gateway owns only the Telegram
276
+ // I/O and the once-per-process warning dedupe.
277
+
278
+ /** Per-agent button-choice-confirmation config (projected into access.json). */
279
+ export interface ButtonChoiceConfirmationConfig {
280
+ enabled?: boolean
281
+ format?: string
282
+ timezone?: 'gateway' | 'utc'
283
+ }
284
+
285
+ /** Default annotation template. `{label}` and `{time}` are substituted. */
286
+ export const BUTTON_CONFIRM_DEFAULT_FORMAT = '✅ You chose: {label} · {time}'
287
+
288
+ /**
289
+ * HTML-entity escaper for the annotation payload (shipped with
290
+ * parse_mode: 'HTML'). Escapes exactly the three characters Telegram's HTML
291
+ * parser treats specially — `&`, `<`, `>` — so arbitrary button labels and
292
+ * source-message text can never 400 the editMessageText call. `&` is escaped
293
+ * first so freshly produced entities aren't double-escaped. This is NOT the
294
+ * GFM-markdown escaper (#2669) — that one escapes backticks/underscores and
295
+ * would garble text (`Do_it` → `Do\_it`) under HTML parse mode.
296
+ */
297
+ export function escapeHtmlEntities(s: string): string {
298
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
299
+ }
300
+
301
+ /**
302
+ * Dedup-strip regex: matches a prior DEFAULT-shape annotation appended to the
303
+ * body so a retap replaces rather than duplicates it. The `<b>`/`</b>` tags
304
+ * are optional because Telegram returns `message.text` as PLAIN text (entity
305
+ * markup lives in `message.entities`, not in the text) — on a real retap the
306
+ * prior annotation arrives without tags. The `s` (dotAll) flag tolerates a
307
+ * stray newline inside an older, un-stripped label (belt-and-braces alongside
308
+ * the newline-stripping applied to the label on the way in). NOTE: dedup only
309
+ * recognizes the DEFAULT annotation shape — a custom
310
+ * button_choice_confirmation.format that diverges from it accumulates one
311
+ * line per retap instead of replacing.
312
+ */
313
+ export const BUTTON_CONFIRM_STRIP_RE =
314
+ /\n\n✅ You chose: (?:<b>)?.*(?:<\/b>)? · \d{2}:\d{2}$/s
315
+
316
+ /**
317
+ * Build the confirmation line. `{label}` → the tapped button's text with
318
+ * newlines collapsed to spaces, trimmed to 60 chars (mirrors the toast trim),
319
+ * HTML-entity-escaped (`&`, `<`, `>`) and wrapped in `<b>`; `{time}` →
320
+ * HH:MM in the chosen timezone. `now`/`escapeLabel` are injectable for
321
+ * deterministic tests; `escapeLabel` defaults to the real
322
+ * {@link escapeHtmlEntities}. Substitutions use replacer FUNCTIONS so
323
+ * String.replace special patterns (`$&`, `$'`, …) in labels are inert.
324
+ */
325
+ export function buildButtonConfirmation(args: {
326
+ template: string
327
+ label: string
328
+ timezone: 'gateway' | 'utc'
329
+ escapeLabel?: (s: string) => string
330
+ now?: Date
331
+ }): string {
332
+ const escape = args.escapeLabel ?? escapeHtmlEntities
333
+ const cleanLabel = args.label.replace(/\n/g, ' ').slice(0, 60)
334
+ const now = args.now ?? new Date()
335
+ const hh = args.timezone === 'utc'
336
+ ? String(now.getUTCHours()).padStart(2, '0')
337
+ : String(now.getHours()).padStart(2, '0')
338
+ const mm = args.timezone === 'utc'
339
+ ? String(now.getUTCMinutes()).padStart(2, '0')
340
+ : String(now.getMinutes()).padStart(2, '0')
341
+ return args.template
342
+ .replace('{label}', () => `<b>${escape(cleanLabel)}</b>`)
343
+ .replace('{time}', () => `${hh}:${mm}`)
344
+ }
345
+
346
+ /** Outcome of the annotate-or-strip decision for a single tap. */
347
+ export interface TapAnnotationResult {
348
+ /** True → gateway should editMessageText with `text` (and strip keyboard). */
349
+ annotate: boolean
350
+ /** Annotated body text, present iff `annotate` is true. */
351
+ text?: string
352
+ /** True → gateway should emit the once-per-process parseMode warning. */
353
+ warnParseMode: boolean
354
+ /** True → gateway should emit the once-per-process single_use-mismatch warning. */
355
+ warnSingleUseMismatch: boolean
356
+ }
357
+
358
+ /**
359
+ * Pure decision for the #789 annotation. Resolves the per-message override
360
+ * (the tapped button's `inline_keyboard_confirm`) over the agent default,
361
+ * gates on single-use + presence of body text + a resolvable label + the
362
+ * `html` parse mode, and renders the annotated body (with any prior
363
+ * default-shape annotation stripped so a retap replaces it). Returns
364
+ * `annotate:false` for every skip path; the caller still performs the
365
+ * historical keyboard-only strip when the keyboard is single-use.
366
+ *
367
+ * The base body is HTML-entity-escaped too (Telegram hands us `message.text`
368
+ * as plain text, so `&`/`<`/`>` in it would otherwise 400 the HTML edit).
369
+ * Known limitation: because the body is rebuilt from `message.text`, any
370
+ * entities/formatting (bold, links, …) on the original message are lost on
371
+ * annotation. Documented in the config schema + CHANGELOG.
372
+ */
373
+ export function resolveTapAnnotation(args: {
374
+ perMessageOverride?: boolean
375
+ singleUse: boolean
376
+ config?: ButtonChoiceConfirmationConfig
377
+ parseMode: 'html' | 'markdownv2' | 'text'
378
+ sourceText?: string
379
+ label?: string
380
+ escapeLabel?: (s: string) => string
381
+ now?: Date
382
+ }): TapAnnotationResult {
383
+ const shouldAnnotate = args.perMessageOverride ?? args.config?.enabled ?? false
384
+ const warnSingleUseMismatch = shouldAnnotate && !args.singleUse
385
+
386
+ const wantAnnotate =
387
+ shouldAnnotate &&
388
+ args.singleUse &&
389
+ args.sourceText != null &&
390
+ args.label != null
391
+ if (!wantAnnotate) {
392
+ return { annotate: false, warnParseMode: false, warnSingleUseMismatch }
393
+ }
394
+ if (args.parseMode !== 'html') {
395
+ return { annotate: false, warnParseMode: true, warnSingleUseMismatch }
396
+ }
397
+ const escape = args.escapeLabel ?? escapeHtmlEntities
398
+ // Strip a prior annotation from the RAW text first (Telegram delivers it
399
+ // un-tagged), then entity-escape the remainder for the HTML edit.
400
+ const base = escape(
401
+ (args.sourceText as string).replace(BUTTON_CONFIRM_STRIP_RE, ''),
402
+ )
403
+ const formatted = buildButtonConfirmation({
404
+ template: args.config?.format ?? BUTTON_CONFIRM_DEFAULT_FORMAT,
405
+ label: args.label as string,
406
+ timezone: args.config?.timezone ?? 'gateway',
407
+ escapeLabel: escape,
408
+ ...(args.now != null ? { now: args.now } : {}),
409
+ })
410
+ return {
411
+ annotate: true,
412
+ text: `${base}\n\n${formatted}`,
413
+ warnParseMode: false,
414
+ warnSingleUseMismatch,
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Perform the annotation edit against Telegram, with a keyboard-strip
420
+ * fallback: if the editMessageText 400s/rejects for ANY reason (over-long
421
+ * body, HTML edge case, message too old, …), we still strip the inline
422
+ * keyboard via editMessageReplyMarkup so single-use protection holds even
423
+ * though the button meta has already been consumed. Extracted here (with the
424
+ * two Telegram calls injected) so the fallback is unit-testable.
425
+ *
426
+ * Returns 'annotated' | 'stripped-fallback' | 'failed' for observability.
427
+ */
428
+ export async function applyTapAnnotationEdit(io: {
429
+ editMessageText: (text: string, other: {
430
+ parse_mode: 'HTML'
431
+ reply_markup: { inline_keyboard: never[] }
432
+ }) => Promise<unknown>
433
+ editMessageReplyMarkup: (other: {
434
+ reply_markup: { inline_keyboard: never[] }
435
+ }) => Promise<unknown>
436
+ }, text: string): Promise<'annotated' | 'stripped-fallback' | 'failed'> {
437
+ try {
438
+ await io.editMessageText(text, {
439
+ parse_mode: 'HTML',
440
+ reply_markup: { inline_keyboard: [] },
441
+ })
442
+ return 'annotated'
443
+ } catch {
444
+ try {
445
+ await io.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } })
446
+ return 'stripped-fallback'
447
+ } catch {
448
+ return 'failed'
449
+ }
450
+ }
451
+ }
452
+
152
453
  /**
153
454
  * Convenience: validate + wrap in one call. Returns either the
154
455
  * wrapped keyboard or a structured error list — caller throws so the
@@ -41,6 +41,44 @@ export interface ModelUnavailableDetection {
41
41
  raw: string
42
42
  }
43
43
 
44
+ // ─── Transient-burst signals (canonical, single source of truth) ─────────────
45
+
46
+ /**
47
+ * Explicit markers of a TRANSIENT per-account burst / server-side throttle —
48
+ * a short-term RPM/burst 429 that Claude Code retries internally with backoff,
49
+ * NOT the 5h/7d subscription usage-limit wall. Anthropic emits these with a
50
+ * `rate_limit_error` whose wording explicitly NEGATES the account-quota reading
51
+ * ("not your usage limit" / "would exceed your account's rate limit … try again
52
+ * later"). Keyed on the explicit negation so a genuine wall that merely contains
53
+ * the word "limit" is never down-classified.
54
+ *
55
+ * Exported so `session-tail.ts` classifies a 429 by wording against THIS list
56
+ * rather than hand-rolling its own copy (keeps the two in sync — issue #2922).
57
+ */
58
+ export const transientUpstreamSignals = [
59
+ 'not your usage limit',
60
+ 'not your account',
61
+ "not your account's",
62
+ 'temporarily limiting requests',
63
+ 'temporarily rate',
64
+ 'server is temporarily',
65
+ 'would exceed your account’s rate limit',
66
+ "would exceed your account's rate limit",
67
+ ]
68
+
69
+ /**
70
+ * True when `text` carries an EXPLICIT transient-burst marker (see
71
+ * `transientUpstreamSignals`). Never throws on weird input. Used to keep the
72
+ * calm rate-limit path and the model-unavailable detector reading the same
73
+ * canonical signal list.
74
+ */
75
+ export function isTransientUpstreamSignal(text: string): boolean {
76
+ if (typeof text !== 'string' || text.length === 0) return false
77
+ const sample = text.length > 16_384 ? text.slice(0, 16_384) : text
78
+ const lower = sample.toLowerCase()
79
+ return transientUpstreamSignals.some(s => lower.includes(s))
80
+ }
81
+
44
82
  // ─── Detection ───────────────────────────────────────────────────────────────
45
83
 
46
84
  /**
@@ -77,17 +115,9 @@ export function detectModelUnavailable(
77
115
  // failover that self-cancels and leaves the turn dead. These are upstream
78
116
  // throttles Claude Code retries internally with backoff — classify them as
79
117
  // `overload` (the calm rate-limit path) BEFORE the quota substrings run, so
80
- // the negation is honoured and no failover is announced.
81
- const transientUpstreamSignals = [
82
- 'not your usage limit',
83
- 'not your account',
84
- "not your account's",
85
- 'temporarily limiting requests',
86
- 'temporarily rate',
87
- 'server is temporarily',
88
- 'would exceed your account’s rate limit',
89
- "would exceed your account's rate limit",
90
- ]
118
+ // the negation is honoured and no failover is announced. The signal list is
119
+ // module-level (`transientUpstreamSignals`) so session-tail.ts classifies a
120
+ // 429 against the SAME canonical wording.
91
121
  if (transientUpstreamSignals.some(s => lower.includes(s))) {
92
122
  const resetAt = parseResetTime(sample)
93
123
  return resetAt !== undefined
@@ -28,6 +28,7 @@ export type OperatorEventKind =
28
28
  | 'unknown-5xx'
29
29
  | 'config-warning'
30
30
  | 'always-allow-persist-failed'
31
+ | 'mental-model-persist-failed'
31
32
 
32
33
  export interface OperatorEvent {
33
34
  kind: OperatorEventKind
@@ -420,6 +421,28 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
420
421
  ],
421
422
  },
422
423
  }
424
+
425
+ // #2975 Stage 1 — an operator-APPROVED mental-model persist hit the
426
+ // config_propose_edit rate limit, and the ONE scheduled retry at the
427
+ // window-open time ALSO failed. MUST be a NEW message (not a card edit):
428
+ // the proposal card was already edited to the "applying at HH:MM" state
429
+ // and card edits don't ping, so a silent edit here would leave the lost
430
+ // approval unnoticed. Ask the operator to re-propose so it isn't dropped.
431
+ case 'mental-model-persist-failed':
432
+ return {
433
+ text: [
434
+ `⚠️ **${agent}**'s approved mental model didn't save.`,
435
+ detail ? `_${detail}_` : '',
436
+ `The retry after the rate window also failed — ask the agent to re-propose it.`,
437
+ ]
438
+ .filter(Boolean)
439
+ .join('\n'),
440
+ keyboard: {
441
+ inline_keyboard: [
442
+ [{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
443
+ ],
444
+ },
445
+ }
423
446
  }
424
447
  }
425
448
 
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Structured-payload outbound redaction (#2044 coverage extension).
3
+ *
4
+ * The original #2044 outbound scrub masks agent-authored FREE TEXT on the
5
+ * reply / edit_message / progress_update paths (see gateway.ts
6
+ * `redactOutboundText`). But several other outbound surfaces carry
7
+ * agent-authored text to Telegram inside STRUCTURED payloads and had no
8
+ * redaction at all:
9
+ *
10
+ * - ask_user → the question text + every option/button label
11
+ * - send_checklist / update_checklist → the title + every task's text
12
+ *
13
+ * An agent that echoes a token or a DATABASE_URL it just read into an
14
+ * ask_user question, a button label, or a checklist task would transmit it
15
+ * unmasked — defeating the same scrub invariant the reply `text` path
16
+ * honors.
17
+ *
18
+ * These helpers apply the SAME redactor to each such field. The redactor is
19
+ * INJECTED (the gateway passes its `redactOutboundText`; tests pass the real
20
+ * `redact`) so the masking is deterministic and unit-testable at the OUTCOME
21
+ * level: the object returned here is exactly what the downstream send call
22
+ * receives, so a test can assert the secret is gone from the sent payload —
23
+ * not merely that a redactor was invoked.
24
+ */
25
+
26
+ /** A pure text redactor: maps input text to a masked copy. */
27
+ export type FieldRedactor = (text: string) => string
28
+
29
+ /**
30
+ * Redact the two agent-authored outbound fields of an ask_user prompt: the
31
+ * question and each option label. Returns fresh values (the input arrays are
32
+ * not mutated) so the caller can assign them onto the validated args.
33
+ */
34
+ export function redactAskUserFields(
35
+ question: string,
36
+ options: readonly string[],
37
+ redact: FieldRedactor,
38
+ ): { question: string; options: string[] } {
39
+ return {
40
+ question: redact(question),
41
+ options: options.map((opt) => redact(opt)),
42
+ }
43
+ }
44
+
45
+ /** Minimal shape of a checklist task — only `text` is redacted; every other
46
+ * field (id, done, …) is carried through untouched. */
47
+ export interface ChecklistTaskLike {
48
+ text?: string
49
+ [key: string]: unknown
50
+ }
51
+
52
+ /**
53
+ * Redact the agent-authored outbound fields of a checklist: the title and
54
+ * each task's `text`. `title`/`tasks` may be undefined (update_checklist
55
+ * allows partial patches); undefined passes through untouched. A task whose
56
+ * `text` is undefined (an id-only patch) is left as-is.
57
+ */
58
+ export function redactChecklistFields<T extends ChecklistTaskLike>(
59
+ title: string | undefined,
60
+ tasks: readonly T[] | undefined,
61
+ redact: FieldRedactor,
62
+ ): { title: string | undefined; tasks: T[] | undefined } {
63
+ return {
64
+ title: title != null ? redact(title) : title,
65
+ tasks: tasks?.map((task) =>
66
+ task.text != null ? { ...task, text: redact(task.text) } : task,
67
+ ),
68
+ }
69
+ }
@@ -30,7 +30,6 @@
30
30
  "@mtcute/node": "^0.30.1",
31
31
  "@secretlint/core": "^12.2.0",
32
32
  "@secretlint/secretlint-rule-preset-recommend": "^12.2.0",
33
- "@secretlint/types": "^12.2.0",
34
33
  "@xterm/headless": "^6.0.0",
35
34
  "grammy": "^1.44",
36
35
  "mdast-util-from-markdown": "^2.0.2",
@@ -62,6 +62,7 @@ const BROAD_ONLY_TOOLS = new Set([
62
62
  "WebSearch",
63
63
  "Task",
64
64
  "Agent",
65
+ "Workflow",
65
66
  "TodoWrite",
66
67
  "ExitPlanMode",
67
68
  ]);
@@ -212,6 +212,7 @@ export function naturalAction(
212
212
  }
213
213
  case "Task":
214
214
  case "Agent":
215
+ case "Workflow":
215
216
  return "dispatch a sub-agent";
216
217
  case "TodoWrite":
217
218
  return "update its task list";
@@ -30,7 +30,7 @@
30
30
  // equivalent entities rather than accidentally re-triggering formatting or
31
31
  // breaking out of a code span.
32
32
 
33
- import { escapeMarkdown, codeSpanSafe, RICH_MESSAGE_MAX_CHARS } from "../format.js";
33
+ import { escapeMarkdown, codeSpanSafe, escapeLinkHref, RICH_MESSAGE_MAX_CHARS } from "../format.js";
34
34
  import type {
35
35
  Block,
36
36
  BlockquoteNode,
@@ -47,26 +47,44 @@ import type {
47
47
  // Inline rendering
48
48
  // ---------------------------------------------------------------------------
49
49
 
50
- function renderInline(node: Inline): string {
50
+ /** Rendering context threaded through the inline walk. `inTableCell` is set
51
+ * while rendering the inline content of a GFM table cell, where a literal `|`
52
+ * — even inside an inline-code span — would be read as a column separator and
53
+ * tear the row (F4). Plain-text `|` is already neutralised by `escapeMarkdown`
54
+ * (`|` is one of its specials); the only gap is the code span, whose content
55
+ * is otherwise verbatim, so we backslash-escape `|` there in the table
56
+ * context. GFM strips the `\` and keeps the pipe literal inside the span. */
57
+ interface InlineCtx {
58
+ inTableCell?: boolean;
59
+ }
60
+
61
+ function renderInline(node: Inline, ctx: InlineCtx = {}): string {
51
62
  switch (node.type) {
52
63
  case "plain":
53
64
  return escapeMarkdown(node.text);
54
65
  case "bold":
55
- return `**${renderInlineChildren(node.children)}**`;
66
+ return `**${renderInlineChildren(node.children, ctx)}**`;
56
67
  case "italic":
57
- return `*${renderInlineChildren(node.children)}*`;
68
+ return `*${renderInlineChildren(node.children, ctx)}*`;
58
69
  case "underline":
59
- return `__${renderInlineChildren(node.children)}__`;
70
+ return `__${renderInlineChildren(node.children, ctx)}__`;
60
71
  case "strike":
61
- return `~~${renderInlineChildren(node.children)}~~`;
72
+ return `~~${renderInlineChildren(node.children, ctx)}~~`;
62
73
  case "spoiler":
63
- return `||${renderInlineChildren(node.children)}||`;
74
+ return `||${renderInlineChildren(node.children, ctx)}||`;
64
75
  case "highlight":
65
- return `==${renderInlineChildren(node.children)}==`;
66
- case "code":
67
- return `\`${codeSpanSafe(node.text)}\``;
76
+ return `==${renderInlineChildren(node.children, ctx)}==`;
77
+ case "code": {
78
+ const safe = codeSpanSafe(node.text);
79
+ // In a table cell, an unescaped `|` inside the span closes the cell early
80
+ // and corrupts the row; `\|` survives (GFM keeps the pipe literal in the
81
+ // span and drops the backslash). Elsewhere the span content is verbatim.
82
+ return `\`${ctx.inTableCell ? safe.replace(/\|/g, "\\|") : safe}\``;
83
+ }
68
84
  case "link":
69
- return `[${renderInlineChildren(node.children)}](${node.href})`;
85
+ // Escape the href so a literal `)` in the URL can't terminate the
86
+ // destination early and break the link (F3).
87
+ return `[${renderInlineChildren(node.children, ctx)}](${escapeLinkHref(node.href)})`;
70
88
  default: {
71
89
  // Exhaustiveness guard — the IR union is closed; a new variant must be
72
90
  // handled above rather than silently dropped.
@@ -76,8 +94,8 @@ function renderInline(node: Inline): string {
76
94
  }
77
95
  }
78
96
 
79
- function renderInlineChildren(children: Inline[]): string {
80
- return children.map(renderInline).join("");
97
+ function renderInlineChildren(children: Inline[], ctx: InlineCtx = {}): string {
98
+ return children.map((child) => renderInline(child, ctx)).join("");
81
99
  }
82
100
 
83
101
  // ---------------------------------------------------------------------------
@@ -154,7 +172,7 @@ function renderList(node: ListNode): string {
154
172
  /** Render a single cell's inline content for a table (no line breaks — GFM
155
173
  * table cells can't contain them; pipes are escaped defensively). */
156
174
  function renderTableCell(cells: TableRow["cells"][number]): string {
157
- return renderInlineChildren(cells.children).replace(/\n+/g, " ");
175
+ return renderInlineChildren(cells.children, { inTableCell: true }).replace(/\n+/g, " ");
158
176
  }
159
177
 
160
178
  function alignSeparator(align: TableNode["align"][number]): string {