switchroom 0.16.24 → 0.16.28

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 (28) hide show
  1. package/dist/cli/switchroom.js +135 -37
  2. package/dist/host-control/main.js +13 -7
  3. package/package.json +2 -2
  4. package/telegram-plugin/answer-stream.ts +7 -6
  5. package/telegram-plugin/bridge/bridge.ts +1 -1
  6. package/telegram-plugin/dist/bridge/bridge.js +1 -1
  7. package/telegram-plugin/dist/gateway/gateway.js +255 -30
  8. package/telegram-plugin/dist/server.js +1 -1
  9. package/telegram-plugin/format.ts +335 -27
  10. package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
  11. package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
  12. package/telegram-plugin/gateway/gateway.ts +169 -12
  13. package/telegram-plugin/gateway/ipc-server.ts +11 -6
  14. package/telegram-plugin/gateway/permission-timeout.ts +76 -0
  15. package/telegram-plugin/permission-title.ts +3 -0
  16. package/telegram-plugin/retry-api-call.ts +27 -0
  17. package/telegram-plugin/rich-send.ts +29 -0
  18. package/telegram-plugin/shared/bot-runtime.ts +6 -1
  19. package/telegram-plugin/silent-reply-anchor.ts +9 -2
  20. package/telegram-plugin/status-no-truncate.ts +11 -5
  21. package/telegram-plugin/stream-reply-handler.ts +9 -1
  22. package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
  23. package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
  24. package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
  25. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
  26. package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
  27. package/telegram-plugin/tests/permission-title.test.ts +43 -0
  28. package/telegram-plugin/tests/poll-health.test.ts +64 -0
@@ -72,6 +72,11 @@ import {
72
72
  timeoutDenyMessage,
73
73
  duplicateDenyMessage,
74
74
  isRecentTimeoutDuplicate,
75
+ PERMISSION_TTL_MS,
76
+ ttlForTool,
77
+ buildTimedOutCardEdits,
78
+ STALE_TAP_NOTICE,
79
+ type PermissionCardRef,
75
80
  } from './permission-timeout.js'
76
81
  import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
77
82
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
@@ -89,6 +94,7 @@ import {
89
94
  createSwallowingRetryApiCall,
90
95
  retryWithThreadFallback,
91
96
  isHtmlParseRejectError,
97
+ isMessageTooLongError,
92
98
  } from '../retry-api-call.js'
93
99
  import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
94
100
  import { buildAttachmentPath, assertInsideInbox } from '../attachment-path.js'
@@ -189,7 +195,7 @@ const REPLY_TO_TEXT_MAX = 200
189
195
  const SILENT_END_FALLBACK_TEXT =
190
196
  '⚠️ The agent finished working but didn’t send a reply — your last ' +
191
197
  'message may not have been answered. Please try asking again.'
192
- import { splitMarkdownChunks, repairEscapedWhitespace, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
198
+ import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
193
199
  import { richMessage } from '../rich-send.js'
194
200
  import { scrubVoice } from '../text-voice-scrub.js'
195
201
  import {
@@ -779,6 +785,26 @@ const AGENT_ADMIN = process.env.SWITCHROOM_AGENT_ADMIN === 'true'
779
785
  const bot = new Bot(TOKEN)
780
786
  installTgPostLogger(bot)
781
787
 
788
+ // ─── getUpdates heartbeat ─────────────────────────────────────────────────
789
+ // Tracks the last time getUpdates completed (success OR error). Used by
790
+ // the poll health check as a secondary stall signal: if getMe succeeds but
791
+ // getUpdates hasn't responded in a while, the grammy runner loop is frozen
792
+ // without the network being down (2026-06-30: clerk/klanker deaf for 2h
793
+ // after a single timeout — getMe stayed green so the getMe-only check
794
+ // never fired). Initialized to now so the first health-check interval
795
+ // doesn't false-positive before the runner makes its first poll.
796
+ let lastGetUpdatesHeartbeatMs = Date.now()
797
+ bot.api.config.use(async (prev, method, payload, signal) => {
798
+ try {
799
+ const result = await prev(method, payload, signal)
800
+ if (method === 'getUpdates') lastGetUpdatesHeartbeatMs = Date.now()
801
+ return result
802
+ } catch (err) {
803
+ if (method === 'getUpdates') lastGetUpdatesHeartbeatMs = Date.now()
804
+ throw err
805
+ }
806
+ })
807
+
782
808
  const GRAMMY_VERSION: string = (() => {
783
809
  try {
784
810
  const raw = readFileSync(new URL('../../node_modules/grammy/package.json', import.meta.url), 'utf8')
@@ -4312,8 +4338,10 @@ const STATUS_QUERY_RE = /^\s*status\??\s*$/i
4312
4338
 
4313
4339
  // ─── Permission handling ──────────────────────────────────────────────────
4314
4340
  const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
4315
- const pendingPermissions = new Map<string, { tool_name: string; description: string; input_preview: string; startedAt: number }>()
4316
- const PERMISSION_TTL_MS = 10 * 60_000
4341
+ const pendingPermissions = new Map<string, { tool_name: string; description: string; input_preview: string; startedAt: number; card_text: string; cards: { chatId: string; messageId: number }[] }>()
4342
+ // PERMISSION_TTL_MS / ttlForTool / the timed-out card builder now live in
4343
+ // ./permission-timeout.ts (pure + unit-testable). hostd gated verbs get a
4344
+ // 30-min window; everything else keeps the 10-min default.
4317
4345
  // No-repeat-on-timeout (marko Rentals-budget loop, 2026-06-17). When a card
4318
4346
  // auto-denies on TTL, the model is told it was a TIMEOUT (not a denial) so it
4319
4347
  // doesn't retry; if it retries the identical (tool, input) anyway while the
@@ -4944,7 +4972,10 @@ const pendingStateReaper = setInterval(() => {
4944
4972
  if (now - v.startedAt > VAULT_INPUT_TTL_MS) pendingVaultOps.delete(k)
4945
4973
  }
4946
4974
  for (const [k, v] of pendingPermissions) {
4947
- if (now - v.startedAt > PERMISSION_TTL_MS) {
4975
+ // hostd gated fleet-mutation verbs get a longer (30-min) human-scale
4976
+ // decision window than the 10-min default (Bug 2 fix #2).
4977
+ const ttl = ttlForTool(v.tool_name)
4978
+ if (now - v.startedAt > ttl) {
4948
4979
  // Don't just drop it: the claude turn is suspended INSIDE the MCP
4949
4980
  // permission call waiting for a verdict. A silent delete left it
4950
4981
  // wedged forever when the operator never tapped — permanent
@@ -4956,13 +4987,20 @@ const pendingStateReaper = setInterval(() => {
4956
4987
  // Carry a TIMEOUT reason to the model (claude renders it as "…the user
4957
4988
  // said: …") so it can tell a timeout from a real denial and not retry
4958
4989
  // the identical call — the duplicate-card loop this series closes.
4959
- const timeoutMinutes = Math.round(PERMISSION_TTL_MS / 60000)
4990
+ const timeoutMinutes = Math.round(ttl / 60000)
4960
4991
  dispatchPermissionVerdict({
4961
4992
  type: 'permission',
4962
4993
  requestId: k,
4963
4994
  behavior: 'deny',
4964
4995
  message: timeoutDenyMessage(timeoutMinutes),
4965
4996
  })
4997
+ // Strip the card's inline keyboard + mark it timed out (Bug 2 fix #1).
4998
+ // Before this, the reaper deleted the pending entry but left a LIVE
4999
+ // Approve button — a late operator tap then dispatched a verdict for an
5000
+ // already-resolved/dead request_id, which Claude Code ignores, so the
5001
+ // operator "approved" but work never continued. Stripping the keyboard
5002
+ // makes the timeout legible and removes the stale tappable button.
5003
+ void stripTimedOutPermissionCards(v.card_text, v.cards)
4966
5004
  // The auto-deny un-parks the suspended turn — flip 🙏 → working so
4967
5005
  // it doesn't sit on the awaiting glyph (or stall) after the timeout.
4968
5006
  resumeReactionAfterVerdict()
@@ -6565,6 +6603,32 @@ function buildPermissionActionRow(
6565
6603
  return kb
6566
6604
  }
6567
6605
 
6606
+ /**
6607
+ * Strip the inline keyboard from one or more timed-out permission cards and
6608
+ * append a "timed out — re-request to act" line (Bug 2 fix #1). Called from
6609
+ * the pendingStateReaper, which has no grammy `ctx`, so it edits via the
6610
+ * bot.api directly — routed through `swallowingApiCall` so a deleted card
6611
+ * (operator removed the message) is swallowed rather than crashing the sweep.
6612
+ *
6613
+ * A single `editMessageText` re-renders the original card body plus a
6614
+ * timed-out footer; omitting `reply_markup` drops the keyboard atomically
6615
+ * with the text edit (same approach the normal tap finalizer uses, which
6616
+ * strips the keyboard alongside its status-line edit). Best-effort: a failed
6617
+ * edit never blocks the auto-deny that already fired.
6618
+ */
6619
+ async function stripTimedOutPermissionCards(
6620
+ cardText: string,
6621
+ cards: PermissionCardRef[],
6622
+ ): Promise<void> {
6623
+ for (const edit of buildTimedOutCardEdits(cardText, cards)) {
6624
+ await swallowingApiCall(
6625
+ // allow-raw-bot-api: routed through swallowingApiCall (retry policy); message-id-targeted edit (no thread to lose). Passing {} as opts (no reply_markup) strips the stale Allow/Deny keyboard atomically with the text edit.
6626
+ () => bot.api.editMessageText(edit.chatId, edit.messageId, richMessage(edit.text), {}),
6627
+ { chat_id: edit.chatId, verb: 'permission_timeout.strip' },
6628
+ )
6629
+ }
6630
+ }
6631
+
6568
6632
  function dispatchPermissionVerdict(ev: PermissionEvent): void {
6569
6633
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
6570
6634
  const delivered = ipcServer.sendToAgent(selfAgent, ev)
@@ -6996,7 +7060,6 @@ const ipcServer: IpcServer = createIpcServer({
6996
7060
  return
6997
7061
  }
6998
7062
  }
6999
- pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() })
7000
7063
  // Natural-language card body — a plain sentence ("Gymbro wants to
7001
7064
  // edit: supplement-log.md" + a why-line), never a raw tool id.
7002
7065
  // The operator sees what is being requested and why at a glance.
@@ -7008,6 +7071,10 @@ const ipcServer: IpcServer = createIpcServer({
7008
7071
  description,
7009
7072
  agentName: _client.agentName,
7010
7073
  })
7074
+ // `card_text` is retained so the TTL reaper can re-edit the SAME body
7075
+ // with a "timed out" footer while stripping the keyboard atomically
7076
+ // (Bug 2 fix #1) — the reaper has no grammy ctx to read the live text.
7077
+ pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now(), card_text: text, cards: [] })
7011
7078
  // Compact action row: ❌ Deny · ✅ Allow · 🔁 Always… — the scope of an
7012
7079
  // "always" grant stays hidden until the operator taps "🔁 Always…",
7013
7080
  // which swaps the row for a scope choice (this file / any file ⚠️). The
@@ -7042,7 +7109,17 @@ const ipcServer: IpcServer = createIpcServer({
7042
7109
  ...(tid != null ? { message_thread_id: tid } : {}),
7043
7110
  }),
7044
7111
  { threadId, chat_id: chatId, verb: 'permission_request' },
7045
- ).catch(e => {
7112
+ ).then(sent => {
7113
+ // Record the live card's (chat, message) so the TTL reaper can strip
7114
+ // its inline keyboard on auto-deny — a stale Approve button left
7115
+ // tappable dispatches a verdict for a dead request_id (Bug 2). The
7116
+ // entry may already be gone (operator tapped before this resolved);
7117
+ // guard the lookup.
7118
+ const pend = pendingPermissions.get(requestId)
7119
+ if (pend && sent && typeof sent.message_id === 'number') {
7120
+ pend.cards.push({ chatId, messageId: sent.message_id })
7121
+ }
7122
+ }).catch(e => {
7046
7123
  process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}\n`)
7047
7124
  })
7048
7125
  }
@@ -8087,7 +8164,10 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8087
8164
  })()
8088
8165
  const rawText = args.text as string | undefined
8089
8166
  if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
8090
- let text = repairEscapedWhitespace(rawText)
8167
+ // Repair LLM JSON-escape bungles, then promote lone prose paragraph breaks
8168
+ // into GFM hard breaks so the rich path doesn't collapse them (lists/tables/
8169
+ // code are left untouched — see normalizeParagraphBreaks).
8170
+ let text = normalizeParagraphBreaks(repairEscapedWhitespace(rawText))
8091
8171
  // Outbound secret scrub (#2044): mask any secret the agent echoed BEFORE
8092
8172
  // the stderr preview below, the dedup key, the send, and the history
8093
8173
  // record. Mutates `text` so every downstream consumer sees the masked
@@ -8744,6 +8824,41 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8744
8824
  return lockedBot.api.sendRichMessage(chat_id, richMessage(chunks[i]), richOpts as never)
8745
8825
  }
8746
8826
 
8827
+ // Length-error recovery: a single pre-computed chunk can still exceed the
8828
+ // wire cap when splitMarkdownChunks hit an indivisible region and emitted
8829
+ // it whole (a giant fenced block, a no-boundary blob). Telegram answers
8830
+ // with RICH_MESSAGE_TEXT_TOO_LONG / MESSAGE_TOO_LONG. Re-split this chunk
8831
+ // at a harder boundary and send each piece, rather than misclassifying it
8832
+ // as a parse-reject (which would resend the same oversized payload as
8833
+ // plain text) or surfacing the raw 400.
8834
+ const sendChunkResplit = async (opts: Record<string, unknown>): Promise<void> => {
8835
+ // Re-split at the same cap; for a truly indivisible block this still
8836
+ // yields one oversized piece, but a hard character-cut on the rendered
8837
+ // markdown at least keeps each delivered piece under the wire cap.
8838
+ const subPieces = splitMarkdownChunks(chunks[i], RICH_MESSAGE_MAX_CHARS)
8839
+ const pieces =
8840
+ subPieces.length > 1
8841
+ ? subPieces
8842
+ : hardSliceToCap(chunks[i], RICH_MESSAGE_MAX_CHARS)
8843
+ for (let p = 0; p < pieces.length; p++) {
8844
+ let sent: { message_id: number }
8845
+ if (literalText) {
8846
+ // allow-raw-bot-api: length-error re-split last resort (literal text); wrapping would re-enter the chunk-loop's own classification on an already-classified length failure.
8847
+ sent = await lockedBot.api.sendMessage(chat_id, pieces[p], opts as never)
8848
+ } else {
8849
+ const ro = { ...opts }
8850
+ delete (ro as { link_preview_options?: unknown }).link_preview_options
8851
+ // allow-raw-bot-api: length-error re-split last resort (rich); wrapping would re-enter the chunk-loop's own classification on an already-classified length failure.
8852
+ sent = await lockedBot.api.sendRichMessage(chat_id, richMessage(pieces[p]), ro as never)
8853
+ }
8854
+ sentIds.push(sent.message_id)
8855
+ logOutbound('reply', chat_id, sent.message_id, pieces[p].length, `chunk=${i + 1}/${chunks.length} resplit=${p + 1}/${pieces.length}`)
8856
+ }
8857
+ process.stderr.write(
8858
+ `telegram gateway: rich body too long — re-split chunk ${i + 1}/${chunks.length} into ${pieces.length} piece(s)\n`,
8859
+ )
8860
+ }
8861
+
8747
8862
  try {
8748
8863
  const sent = await robustApiCall(() => sendChunk(sendOpts), { threadId, chat_id })
8749
8864
  sentIds.push(sent.message_id)
@@ -8757,10 +8872,14 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8757
8872
  const sent = await sendChunk(retryOpts)
8758
8873
  sentIds.push(sent.message_id)
8759
8874
  } catch (retryErr) {
8760
- // Thread dropped, but the markdown is also unparseable — go plain.
8761
- if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
8875
+ // Thread dropped, AND another failure: length re-split,
8876
+ // parse-reject plain text, else propagate.
8877
+ if (isMessageTooLongError(retryErr)) await sendChunkResplit(retryOpts)
8878
+ else if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
8762
8879
  else throw retryErr
8763
8880
  }
8881
+ } else if (isMessageTooLongError(err)) {
8882
+ await sendChunkResplit(sendOpts)
8764
8883
  } else if (isHtmlParseRejectError(err)) {
8765
8884
  await sendChunkPlainText(sendOpts)
8766
8885
  } else {
@@ -9259,6 +9378,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9259
9378
  bot: lockedBot as unknown as { api: import('../stream-controller.js').StreamBotApi },
9260
9379
  retry: robustApiCall,
9261
9380
  repairEscapedWhitespace,
9381
+ normalizeParagraphBreaks,
9262
9382
  assertAllowedChat,
9263
9383
  resolveThreadId,
9264
9384
  disableLinkPreview: access.disableLinkPreview !== false,
@@ -10508,7 +10628,11 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
10508
10628
  // Single rich-markdown path (#2669): `format:'text'` edits as a literal
10509
10629
  // plain string; everything else edits via the rich-markdown path.
10510
10630
  const editLiteralText = editFormat === 'text'
10631
+ // Match the reply path: repair JSON-escape bungles, then promote lone prose
10632
+ // paragraph breaks for the rich path. A literal-text edit (`format:'text'`)
10633
+ // skips paragraph normalization — it must edit byte-for-byte as given.
10511
10634
  let editRawText = repairEscapedWhitespace(args.text as string)
10635
+ if (!editLiteralText) editRawText = normalizeParagraphBreaks(editRawText)
10512
10636
  // Outbound secret scrub (#2044): an edit must not re-introduce a raw
10513
10637
  // secret into a live bubble or the history row. Mask before scrub/send.
10514
10638
  editRawText = redactOutboundText(editRawText, 'edit_message')
@@ -14687,7 +14811,10 @@ function switchroomExecCombined(args: string[], timeoutMs = 15000): string {
14687
14811
  })
14688
14812
  }
14689
14813
 
14690
- function formatSwitchroomOutput(output: string, maxLen = 4000): string {
14814
+ // Default truncation budget for CLI output bound for Telegram. The rich-message
14815
+ // wire cap is RICH_MESSAGE_MAX_CHARS (32768) post-#2669, not the legacy 4096
14816
+ // plain-text limit. Mirrors shared/bot-runtime.ts formatSwitchroomOutput.
14817
+ function formatSwitchroomOutput(output: string, maxLen = RICH_MESSAGE_MAX_CHARS): string {
14691
14818
  const trimmed = output.trim()
14692
14819
  if (trimmed.length <= maxLen) return trimmed
14693
14820
  return trimmed.slice(0, maxLen - 20) + '\n... (truncated)'
@@ -21812,6 +21939,20 @@ bot.on('callback_query:data', async ctx => {
21812
21939
  // scopes (resolveTimeBox → null) and the disabled tier (ttl<=0) stay truly
21813
21940
  // once. The verdict is still dispatched WITHOUT a `rule` (below), so the
21814
21941
  // bridge never caches it untimed — the window lives only in scopedGrants.
21942
+ // Stale-id tap (#2469 follow-up): the pendingStateReaper auto-DENIES a
21943
+ // permission whose TTL expired and DELETES its pending entry — but a late
21944
+ // operator tap on the (now keyboard-stripped, but possibly still-cached)
21945
+ // card would otherwise dispatch a verdict for an already-resolved/dead
21946
+ // request_id, which Claude Code silently ignores. Net: operator THINKS
21947
+ // they approved, but the work was auto-denied and never continues. Be
21948
+ // honest: tell the operator the request already resolved and do NOT
21949
+ // dispatch a verdict for the dead id (no buffering, no resume message).
21950
+ // LIVE ids fall through to the normal dispatch path below, preserving the
21951
+ // dispatchPermissionVerdict buffering/offline-redelivery contract.
21952
+ if (!pendingPermissions.has(request_id)) {
21953
+ await ctx.answerCallbackQuery({ text: STALE_TAP_NOTICE }).catch(() => {})
21954
+ return
21955
+ }
21815
21956
  // Operator tapped a verdict ⇒ they are present; reset no-repeat suppression
21816
21957
  // so a later identical ask is shown fresh rather than silently short-circuited.
21817
21958
  clearPermissionTimeoutSuppression('operator answered a permission card')
@@ -23198,7 +23339,23 @@ const POLL_HEALTH_THRESHOLD = Number(
23198
23339
  let pollHealthCheck: PollHealthCheckHandle | null = null
23199
23340
  if (POLL_HEALTH_INTERVAL_MS > 0) {
23200
23341
  pollHealthCheck = createPollHealthCheck({
23201
- ping: () => bot.api.getMe(),
23342
+ ping: async () => {
23343
+ await bot.api.getMe()
23344
+ // Secondary: if getMe passes but getUpdates hasn't responded in
23345
+ // `threshold × interval` ms, the grammy runner loop is frozen without
23346
+ // the network being down. Throw so the failure counter increments and
23347
+ // stall recovery fires after `failureThreshold` consecutive misses.
23348
+ // (2026-06-30 incident: one getUpdates TimeoutError → runner silently
23349
+ // froze; getMe stayed green; fleet deaf for 2 h until manual restart.)
23350
+ const staleMs = Date.now() - lastGetUpdatesHeartbeatMs
23351
+ const staleThresholdMs = POLL_HEALTH_INTERVAL_MS * POLL_HEALTH_THRESHOLD
23352
+ if (staleMs > staleThresholdMs) {
23353
+ throw new Error(
23354
+ `getUpdates heartbeat stale: last seen ${Math.round(staleMs / 1000)}s ago ` +
23355
+ `(threshold ${Math.round(staleThresholdMs / 1000)}s) — runner loop frozen`,
23356
+ )
23357
+ }
23358
+ },
23202
23359
  onStall: async () => {
23203
23360
  // Exit non-zero → _switchroom_supervise restarts the gateway sidecar
23204
23361
  // with a fresh runner. Never awaits runnerHandle.stop() (it hangs on a
@@ -20,6 +20,7 @@ import type {
20
20
  ToolCallMessage,
21
21
  ToolCallResult,
22
22
  } from "./ipc-protocol.js";
23
+ import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
23
24
 
24
25
  export interface IpcServerOptions {
25
26
  socketPath: string;
@@ -230,9 +231,11 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
230
231
  case "pty_partial":
231
232
  // Extracted reply text from PTY-tail. May be empty (the extractor
232
233
  // returns empty strings for "no text yet" snapshots — gateway
233
- // handler dedups on lastPtyPreviewByChat). Capped at 8192 to
234
- // give some headroom over Telegram's 4096-char wire limit while
235
- // still bounding buffer growth from a runaway extractor.
234
+ // handler dedups on lastPtyPreviewByChat). Capped at 8192 — this is a
235
+ // preview-buffer bound on the PTY tail, not the outbound wire cap (which
236
+ // is now RICH_MESSAGE_MAX_CHARS / 32768 on the rich path post-#2669) —
237
+ // sized to bound buffer growth from a runaway extractor while still
238
+ // carrying a useful preview.
236
239
  return typeof m.text === "string"
237
240
  && (m.text as string).length <= 8192;
238
241
  case "update_placeholder":
@@ -270,10 +273,12 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
270
273
  if (typeof m.agentName !== "string"
271
274
  || !AGENT_NAME_RE.test(m.agentName as string)) return false;
272
275
  if (typeof m.chatId !== "string" || (m.chatId as string).length === 0) return false;
273
- // text non-empty and bounded — Telegram caps a message at 4096 chars;
274
- // reject over-long here (defense in depth against a malformed payload).
276
+ // text non-empty and bounded — the send_outbound handler posts via
277
+ // sendRichMessage (rich path), whose wire cap is RICH_MESSAGE_MAX_CHARS
278
+ // (32768) post-#2669, not the legacy 4096 plain-text limit. Reject
279
+ // over-long here (defense in depth against a malformed payload).
275
280
  if (typeof m.text !== "string" || (m.text as string).length === 0
276
- || (m.text as string).length > 4096) return false;
281
+ || (m.text as string).length > RICH_MESSAGE_MAX_CHARS) return false;
277
282
  if (m.threadId !== undefined
278
283
  && (typeof m.threadId !== "number" || !Number.isInteger(m.threadId as number))) return false;
279
284
  if (m.parseMode !== undefined && m.parseMode !== "html" && m.parseMode !== "text") return false;
@@ -68,3 +68,79 @@ export function isRecentTimeoutDuplicate(
68
68
  const at = timeouts.get(sig)
69
69
  return at != null && now - at <= windowMs
70
70
  }
71
+
72
+ // ─── Bug 2 — per-tool TTL + timed-out card hygiene + stale-tap honesty ──────
73
+
74
+ /** Default operator approval-card lifetime. */
75
+ export const PERMISSION_TTL_MS = 10 * 60_000
76
+
77
+ /**
78
+ * hostd gated fleet-mutation verbs (rollout / update_apply / agent_* /
79
+ * config_propose_edit) surface an OPERATOR approval card and demand a
80
+ * human-scale decision window — 10 min is too tight when the operator is
81
+ * mid-task. The `mcp__hostd__*` family gets 30 min; everything else keeps
82
+ * the 10-min default.
83
+ */
84
+ export const HOSTD_PERMISSION_TTL_MS = 30 * 60_000
85
+
86
+ /** Per-tool approval-card TTL. */
87
+ export function ttlForTool(toolName: string | undefined): number {
88
+ return toolName && toolName.startsWith('mcp__hostd__')
89
+ ? HOSTD_PERMISSION_TTL_MS
90
+ : PERMISSION_TTL_MS
91
+ }
92
+
93
+ /** Suffix appended to a card body when its approval window times out. */
94
+ export const TIMED_OUT_FOOTER = '\n\n⏱ Timed out — re-request to act'
95
+
96
+ export interface PermissionCardRef {
97
+ chatId: string
98
+ messageId: number
99
+ }
100
+
101
+ export interface TimedOutCardEdit {
102
+ chatId: string
103
+ messageId: number
104
+ /** New body text (original card body + the timed-out footer). */
105
+ text: string
106
+ /**
107
+ * Always true: the edit MUST drop the inline keyboard (omit reply_markup)
108
+ * so the stale Allow/Deny buttons are no longer tappable — the core of
109
+ * Bug 2 fix #1. A stale tappable Approve dispatches a verdict for an
110
+ * already-resolved request_id, which Claude Code ignores → "operator
111
+ * approved but work never continued".
112
+ */
113
+ stripKeyboard: true
114
+ }
115
+
116
+ /**
117
+ * Build the (pure) list of card edits the reaper applies on TTL auto-deny:
118
+ * re-render each recorded card's original body with the timed-out footer and
119
+ * mark it for keyboard-strip. One entry per card (a permission may have been
120
+ * broadcast to several operator surfaces).
121
+ */
122
+ export function buildTimedOutCardEdits(
123
+ cardText: string,
124
+ cards: readonly PermissionCardRef[],
125
+ ): TimedOutCardEdit[] {
126
+ return cards.map(({ chatId, messageId }) => ({
127
+ chatId,
128
+ messageId,
129
+ text: `${cardText}${TIMED_OUT_FOOTER}`,
130
+ stripKeyboard: true,
131
+ }))
132
+ }
133
+
134
+ /**
135
+ * A tap on a permission card is STALE when no pending entry exists for its
136
+ * request_id — the reaper already auto-denied + deleted it on TTL. A stale
137
+ * tap must NOT dispatch a verdict (the dead id is ignored by Claude Code);
138
+ * the operator instead gets an honest "already resolved" notice.
139
+ */
140
+ export function isStaleTap(hasPending: boolean): boolean {
141
+ return !hasPending
142
+ }
143
+
144
+ /** Operator-facing notice shown when a stale (timed-out) card is tapped. */
145
+ export const STALE_TAP_NOTICE =
146
+ 'This request already resolved (timed out) — ask again to act.'
@@ -63,6 +63,9 @@ const MCP_TOOL_DESCRIPTIONS: Record<string, string> = {
63
63
  "mcp__hostd__agent_exec": "Run a read-only inspection inside another agent",
64
64
  "mcp__hostd__update_check": "Check what a fleet-wide update would do",
65
65
  "mcp__hostd__update_apply": "Apply a fleet-wide update (pull + recreate)",
66
+ "mcp__hostd__rollout": "Roll the fleet to a pinned version",
67
+ "mcp__hostd__config_propose_edit": "Propose an edit to switchroom.yaml",
68
+ "mcp__hostd__get_status": "Read the last fleet-update status",
66
69
  // hindsight — memory
67
70
  "mcp__hindsight__recall": "Recall relevant memories",
68
71
  "mcp__hindsight__retain": "Retain a memory",
@@ -266,6 +266,10 @@ export async function retryWithThreadFallback<T>(
266
266
  */
267
267
  export function isHtmlParseRejectError(err: unknown): boolean {
268
268
  if (!(err instanceof GrammyError) || err.error_code !== 400) return false
269
+ // A too-long rejection is a LENGTH error (see isMessageTooLongError) — never
270
+ // route it through the plain-text parse-reject fallback, which would resend
271
+ // the same oversized body and fail again. The caller re-splits instead.
272
+ if (isMessageTooLongError(err)) return false
269
273
  const d = (err.description || '').toLowerCase()
270
274
  return (
271
275
  d.includes("can't parse entities") ||
@@ -282,3 +286,26 @@ export function isHtmlParseRejectError(err: unknown): boolean {
282
286
  d.includes('expected end tag')
283
287
  )
284
288
  }
289
+
290
+ /**
291
+ * True when Telegram rejected the message because the BODY WAS TOO LONG (over
292
+ * the rich-message wire cap), not because the markdown failed to parse.
293
+ *
294
+ * The rich path surfaces this as `RICH_MESSAGE_TEXT_TOO_LONG` (empirically the
295
+ * description for 32769+ chars); the legacy plain-text path used
296
+ * `MESSAGE_TOO_LONG` / "message is too long". A caller that hits this should
297
+ * re-split the body (`splitMarkdownChunks` at a smaller cap) and resend, NOT
298
+ * treat it as a parse-reject (which resends the same oversized payload as
299
+ * plain text). Mirrors rich-send.ts `isLengthError`.
300
+ */
301
+ export function isMessageTooLongError(err: unknown): boolean {
302
+ if (!(err instanceof GrammyError) || err.error_code !== 400) return false
303
+ const d = (err.description || '').toLowerCase()
304
+ return (
305
+ d.includes('rich_message_text_too_long') ||
306
+ d.includes('message_too_long') ||
307
+ d.includes('text_too_long') ||
308
+ d.includes('message is too long') ||
309
+ d.includes('text is too long')
310
+ )
311
+ }
@@ -39,6 +39,11 @@ export function richMessage(markdown: string): InputRichMessageMarkdown {
39
39
  */
40
40
  export function isParseEntitiesError(err: unknown): boolean {
41
41
  if (!(err instanceof GrammyError) || err.error_code !== 400) return false
42
+ // A too-long rejection is a LENGTH error, not a parse error — never let the
43
+ // length case fall through here (it would be "recovered" by resending the
44
+ // same oversized body as plain text, which fails again). Classify it
45
+ // separately via isLengthError so the caller re-splits instead.
46
+ if (isLengthError(err)) return false
42
47
  const d = (err.description || '').toLowerCase()
43
48
  return (
44
49
  d.includes("can't parse entities") ||
@@ -55,3 +60,27 @@ export function isParseEntitiesError(err: unknown): boolean {
55
60
  d.includes('expected end tag')
56
61
  )
57
62
  }
63
+
64
+ /**
65
+ * True when Telegram rejected the message because the BODY WAS TOO LONG (over
66
+ * the rich-message wire cap), as opposed to a markdown-parse failure.
67
+ *
68
+ * The rich path surfaces this distinctly: `RICH_MESSAGE_TEXT_TOO_LONG`
69
+ * (empirically the description for a body of 32769+ chars). The legacy
70
+ * plain-text path used `MESSAGE_TOO_LONG` / "message is too long" — both are
71
+ * matched here so a caller that hits either re-splits the body
72
+ * (`splitMarkdownChunks`) and resends, instead of misclassifying it as a parse
73
+ * error (and resending the same oversized payload as plain text) or surfacing
74
+ * the raw 400.
75
+ */
76
+ export function isLengthError(err: unknown): boolean {
77
+ if (!(err instanceof GrammyError) || err.error_code !== 400) return false
78
+ const d = (err.description || '').toLowerCase()
79
+ return (
80
+ d.includes('rich_message_text_too_long') ||
81
+ d.includes('message_too_long') ||
82
+ d.includes('text_too_long') ||
83
+ d.includes('message is too long') ||
84
+ d.includes('text is too long')
85
+ )
86
+ }
@@ -29,6 +29,7 @@ import { createHash } from 'crypto'
29
29
  import { AsyncLocalStorage } from 'async_hooks'
30
30
  import { clearStaleTelegramPollingState } from '../startup-reset.js'
31
31
  import { createRetryApiCall } from '../retry-api-call.js'
32
+ import { RICH_MESSAGE_MAX_CHARS } from '../format.js'
32
33
 
33
34
  // ─── tg-post tag plumbing ─────────────────────────────────────────────────
34
35
 
@@ -179,7 +180,11 @@ export function stripAnsi(text: string): string {
179
180
  return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
180
181
  }
181
182
 
182
- export function formatSwitchroomOutput(output: string, maxLen = 4000): string {
183
+ // Default truncation budget for CLI output bound for Telegram. Post-#2669 the
184
+ // rich-message wire cap is RICH_MESSAGE_MAX_CHARS (32768), not the legacy 4096
185
+ // plain-text limit; the preBlock fence framing (~8 chars) easily fits the
186
+ // remaining headroom.
187
+ export function formatSwitchroomOutput(output: string, maxLen = RICH_MESSAGE_MAX_CHARS): string {
183
188
  const trimmed = output.trim()
184
189
  if (trimmed.length <= maxLen) return trimmed
185
190
  return trimmed.slice(0, maxLen - 20) + '\n... (truncated)'
@@ -38,8 +38,15 @@
38
38
  * the safety net off; reverts to per-reply fresh send.
39
39
  */
40
40
 
41
- /** Telegram caption / text limit. The accumulator stays under this. */
42
- export const TELEGRAM_MSG_CAP = 4000
41
+ import { RICH_MESSAGE_MAX_CHARS } from './format.js'
42
+
43
+ /**
44
+ * Telegram rich-message text limit. The accumulator stays under this before
45
+ * it rolls to a fresh anchor. Post-#2669 every reply renders as GFM markdown
46
+ * via `sendRichMessage`, so the cap is `RICH_MESSAGE_MAX_CHARS` (32768), not
47
+ * the legacy 4096 plain-text limit.
48
+ */
49
+ export const TELEGRAM_MSG_CAP = RICH_MESSAGE_MAX_CHARS
43
50
 
44
51
  export interface SilentReplyAnchorDecisionInput {
45
52
  /** True when the model passed `disable_notification: true` for
@@ -8,6 +8,10 @@
8
8
  * holds the tuning constants that primitive (and its internal helpers)
9
9
  * read, so a forked renderer never re-derives them.
10
10
  *
11
+ * Wire cap: since the Bot API 10.1 rich-message migration (#2669) every card
12
+ * renders as GFM markdown via `sendRichMessage`, whose limit is
13
+ * `RICH_MESSAGE_MAX_CHARS` (32768), not the legacy 4096 plain-text cap.
14
+ *
11
15
  * The former `SWITCHROOM_STATUS_NO_TRUNCATE` feature flag was retired:
12
16
  * rolling-window-with-char-budget is now the only behaviour. The per-line
13
17
  * cap (`STATUS_LINE_MAX`) and rolling window (`STATUS_ROLLING_LINES`) apply
@@ -15,6 +19,8 @@
15
19
  * (`STATUS_CARD_CHAR_BUDGET`) is the wire-limit backstop.
16
20
  */
17
21
 
22
+ import { RICH_MESSAGE_MAX_CHARS } from './format.js'
23
+
18
24
  /**
19
25
  * Number of trailing narrative/step lines shown in the rolling window.
20
26
  * The feed is a fixed-height rolling window: oldest drops off as new arrive.
@@ -30,15 +36,15 @@ export const STATUS_ROLLING_LINES = 5
30
36
  export const STATUS_LINE_MAX = 200
31
37
 
32
38
  /**
33
- * The safe char budget for a rendered Telegram status card. Telegram's hard
34
- * cap is 4096; we use 4000 to leave 96 chars of headroom for HTML framing,
35
- * emoji, and escape expansion matching the convention in
36
- * pending-work-progress.ts (TELEGRAM_MSG_CAP = 4000).
39
+ * The safe char budget for a rendered Telegram status card. The rich-message
40
+ * wire cap is `RICH_MESSAGE_MAX_CHARS` (32768) post-#2669 the legacy 4096
41
+ * plain-text cap no longer applies on the rich path. We use the constant
42
+ * directly as the backstop.
37
43
  *
38
44
  * With STATUS_ROLLING_LINES=5 lines each ≤ STATUS_LINE_MAX this backstop
39
45
  * effectively never fires in practice, but is kept as a wire-limit safety net.
40
46
  */
41
- export const STATUS_CARD_CHAR_BUDGET = 4000
47
+ export const STATUS_CARD_CHAR_BUDGET = RICH_MESSAGE_MAX_CHARS
42
48
 
43
49
  /** Indent marker for a nested (foreground sub-agent) step line. */
44
50
  export const NESTED_PREFIX = ' ↳ '
@@ -152,6 +152,12 @@ export interface StreamReplyDeps {
152
152
  retry?: RetryPolicy
153
153
  /** Whitespace repair applied to the raw caller text. */
154
154
  repairEscapedWhitespace: (text: string) => string
155
+ /**
156
+ * Promote lone prose paragraph breaks into GFM hard breaks so the rich
157
+ * path doesn't collapse them. Optional for backward compat; when omitted,
158
+ * the raw (repaired) text is sent unchanged.
159
+ */
160
+ normalizeParagraphBreaks?: (text: string) => string
155
161
  /** Validates the chat id against the access list. Throws on deny. */
156
162
  assertAllowedChat: (chatId: string) => void
157
163
  /** Resolves the effective thread id (explicit, last-inbound, or undefined). */
@@ -302,7 +308,9 @@ export async function handleStreamReply(
302
308
  deps: StreamReplyDeps,
303
309
  ): Promise<StreamReplyResult> {
304
310
  const chat_id = args.chat_id
305
- const rawText = deps.repairEscapedWhitespace(args.text)
311
+ const rawText = deps.normalizeParagraphBreaks
312
+ ? deps.normalizeParagraphBreaks(deps.repairEscapedWhitespace(args.text))
313
+ : deps.repairEscapedWhitespace(args.text)
306
314
  const done = Boolean(args.done)
307
315
  const format = args.format ?? deps.defaultFormat
308
316
  if (done) {