switchroom 0.16.24 → 0.16.27

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.
@@ -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 {
@@ -4312,8 +4318,10 @@ const STATUS_QUERY_RE = /^\s*status\??\s*$/i
4312
4318
 
4313
4319
  // ─── Permission handling ──────────────────────────────────────────────────
4314
4320
  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
4321
+ const pendingPermissions = new Map<string, { tool_name: string; description: string; input_preview: string; startedAt: number; card_text: string; cards: { chatId: string; messageId: number }[] }>()
4322
+ // PERMISSION_TTL_MS / ttlForTool / the timed-out card builder now live in
4323
+ // ./permission-timeout.ts (pure + unit-testable). hostd gated verbs get a
4324
+ // 30-min window; everything else keeps the 10-min default.
4317
4325
  // No-repeat-on-timeout (marko Rentals-budget loop, 2026-06-17). When a card
4318
4326
  // auto-denies on TTL, the model is told it was a TIMEOUT (not a denial) so it
4319
4327
  // doesn't retry; if it retries the identical (tool, input) anyway while the
@@ -4944,7 +4952,10 @@ const pendingStateReaper = setInterval(() => {
4944
4952
  if (now - v.startedAt > VAULT_INPUT_TTL_MS) pendingVaultOps.delete(k)
4945
4953
  }
4946
4954
  for (const [k, v] of pendingPermissions) {
4947
- if (now - v.startedAt > PERMISSION_TTL_MS) {
4955
+ // hostd gated fleet-mutation verbs get a longer (30-min) human-scale
4956
+ // decision window than the 10-min default (Bug 2 fix #2).
4957
+ const ttl = ttlForTool(v.tool_name)
4958
+ if (now - v.startedAt > ttl) {
4948
4959
  // Don't just drop it: the claude turn is suspended INSIDE the MCP
4949
4960
  // permission call waiting for a verdict. A silent delete left it
4950
4961
  // wedged forever when the operator never tapped — permanent
@@ -4956,13 +4967,20 @@ const pendingStateReaper = setInterval(() => {
4956
4967
  // Carry a TIMEOUT reason to the model (claude renders it as "…the user
4957
4968
  // said: …") so it can tell a timeout from a real denial and not retry
4958
4969
  // the identical call — the duplicate-card loop this series closes.
4959
- const timeoutMinutes = Math.round(PERMISSION_TTL_MS / 60000)
4970
+ const timeoutMinutes = Math.round(ttl / 60000)
4960
4971
  dispatchPermissionVerdict({
4961
4972
  type: 'permission',
4962
4973
  requestId: k,
4963
4974
  behavior: 'deny',
4964
4975
  message: timeoutDenyMessage(timeoutMinutes),
4965
4976
  })
4977
+ // Strip the card's inline keyboard + mark it timed out (Bug 2 fix #1).
4978
+ // Before this, the reaper deleted the pending entry but left a LIVE
4979
+ // Approve button — a late operator tap then dispatched a verdict for an
4980
+ // already-resolved/dead request_id, which Claude Code ignores, so the
4981
+ // operator "approved" but work never continued. Stripping the keyboard
4982
+ // makes the timeout legible and removes the stale tappable button.
4983
+ void stripTimedOutPermissionCards(v.card_text, v.cards)
4966
4984
  // The auto-deny un-parks the suspended turn — flip 🙏 → working so
4967
4985
  // it doesn't sit on the awaiting glyph (or stall) after the timeout.
4968
4986
  resumeReactionAfterVerdict()
@@ -6565,6 +6583,32 @@ function buildPermissionActionRow(
6565
6583
  return kb
6566
6584
  }
6567
6585
 
6586
+ /**
6587
+ * Strip the inline keyboard from one or more timed-out permission cards and
6588
+ * append a "timed out — re-request to act" line (Bug 2 fix #1). Called from
6589
+ * the pendingStateReaper, which has no grammy `ctx`, so it edits via the
6590
+ * bot.api directly — routed through `swallowingApiCall` so a deleted card
6591
+ * (operator removed the message) is swallowed rather than crashing the sweep.
6592
+ *
6593
+ * A single `editMessageText` re-renders the original card body plus a
6594
+ * timed-out footer; omitting `reply_markup` drops the keyboard atomically
6595
+ * with the text edit (same approach the normal tap finalizer uses, which
6596
+ * strips the keyboard alongside its status-line edit). Best-effort: a failed
6597
+ * edit never blocks the auto-deny that already fired.
6598
+ */
6599
+ async function stripTimedOutPermissionCards(
6600
+ cardText: string,
6601
+ cards: PermissionCardRef[],
6602
+ ): Promise<void> {
6603
+ for (const edit of buildTimedOutCardEdits(cardText, cards)) {
6604
+ await swallowingApiCall(
6605
+ // 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.
6606
+ () => bot.api.editMessageText(edit.chatId, edit.messageId, richMessage(edit.text), {}),
6607
+ { chat_id: edit.chatId, verb: 'permission_timeout.strip' },
6608
+ )
6609
+ }
6610
+ }
6611
+
6568
6612
  function dispatchPermissionVerdict(ev: PermissionEvent): void {
6569
6613
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
6570
6614
  const delivered = ipcServer.sendToAgent(selfAgent, ev)
@@ -6996,7 +7040,6 @@ const ipcServer: IpcServer = createIpcServer({
6996
7040
  return
6997
7041
  }
6998
7042
  }
6999
- pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() })
7000
7043
  // Natural-language card body — a plain sentence ("Gymbro wants to
7001
7044
  // edit: supplement-log.md" + a why-line), never a raw tool id.
7002
7045
  // The operator sees what is being requested and why at a glance.
@@ -7008,6 +7051,10 @@ const ipcServer: IpcServer = createIpcServer({
7008
7051
  description,
7009
7052
  agentName: _client.agentName,
7010
7053
  })
7054
+ // `card_text` is retained so the TTL reaper can re-edit the SAME body
7055
+ // with a "timed out" footer while stripping the keyboard atomically
7056
+ // (Bug 2 fix #1) — the reaper has no grammy ctx to read the live text.
7057
+ pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now(), card_text: text, cards: [] })
7011
7058
  // Compact action row: ❌ Deny · ✅ Allow · 🔁 Always… — the scope of an
7012
7059
  // "always" grant stays hidden until the operator taps "🔁 Always…",
7013
7060
  // which swaps the row for a scope choice (this file / any file ⚠️). The
@@ -7042,7 +7089,17 @@ const ipcServer: IpcServer = createIpcServer({
7042
7089
  ...(tid != null ? { message_thread_id: tid } : {}),
7043
7090
  }),
7044
7091
  { threadId, chat_id: chatId, verb: 'permission_request' },
7045
- ).catch(e => {
7092
+ ).then(sent => {
7093
+ // Record the live card's (chat, message) so the TTL reaper can strip
7094
+ // its inline keyboard on auto-deny — a stale Approve button left
7095
+ // tappable dispatches a verdict for a dead request_id (Bug 2). The
7096
+ // entry may already be gone (operator tapped before this resolved);
7097
+ // guard the lookup.
7098
+ const pend = pendingPermissions.get(requestId)
7099
+ if (pend && sent && typeof sent.message_id === 'number') {
7100
+ pend.cards.push({ chatId, messageId: sent.message_id })
7101
+ }
7102
+ }).catch(e => {
7046
7103
  process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}\n`)
7047
7104
  })
7048
7105
  }
@@ -8087,7 +8144,10 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8087
8144
  })()
8088
8145
  const rawText = args.text as string | undefined
8089
8146
  if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
8090
- let text = repairEscapedWhitespace(rawText)
8147
+ // Repair LLM JSON-escape bungles, then promote lone prose paragraph breaks
8148
+ // into GFM hard breaks so the rich path doesn't collapse them (lists/tables/
8149
+ // code are left untouched — see normalizeParagraphBreaks).
8150
+ let text = normalizeParagraphBreaks(repairEscapedWhitespace(rawText))
8091
8151
  // Outbound secret scrub (#2044): mask any secret the agent echoed BEFORE
8092
8152
  // the stderr preview below, the dedup key, the send, and the history
8093
8153
  // record. Mutates `text` so every downstream consumer sees the masked
@@ -8744,6 +8804,41 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8744
8804
  return lockedBot.api.sendRichMessage(chat_id, richMessage(chunks[i]), richOpts as never)
8745
8805
  }
8746
8806
 
8807
+ // Length-error recovery: a single pre-computed chunk can still exceed the
8808
+ // wire cap when splitMarkdownChunks hit an indivisible region and emitted
8809
+ // it whole (a giant fenced block, a no-boundary blob). Telegram answers
8810
+ // with RICH_MESSAGE_TEXT_TOO_LONG / MESSAGE_TOO_LONG. Re-split this chunk
8811
+ // at a harder boundary and send each piece, rather than misclassifying it
8812
+ // as a parse-reject (which would resend the same oversized payload as
8813
+ // plain text) or surfacing the raw 400.
8814
+ const sendChunkResplit = async (opts: Record<string, unknown>): Promise<void> => {
8815
+ // Re-split at the same cap; for a truly indivisible block this still
8816
+ // yields one oversized piece, but a hard character-cut on the rendered
8817
+ // markdown at least keeps each delivered piece under the wire cap.
8818
+ const subPieces = splitMarkdownChunks(chunks[i], RICH_MESSAGE_MAX_CHARS)
8819
+ const pieces =
8820
+ subPieces.length > 1
8821
+ ? subPieces
8822
+ : hardSliceToCap(chunks[i], RICH_MESSAGE_MAX_CHARS)
8823
+ for (let p = 0; p < pieces.length; p++) {
8824
+ let sent: { message_id: number }
8825
+ if (literalText) {
8826
+ // 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.
8827
+ sent = await lockedBot.api.sendMessage(chat_id, pieces[p], opts as never)
8828
+ } else {
8829
+ const ro = { ...opts }
8830
+ delete (ro as { link_preview_options?: unknown }).link_preview_options
8831
+ // 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.
8832
+ sent = await lockedBot.api.sendRichMessage(chat_id, richMessage(pieces[p]), ro as never)
8833
+ }
8834
+ sentIds.push(sent.message_id)
8835
+ logOutbound('reply', chat_id, sent.message_id, pieces[p].length, `chunk=${i + 1}/${chunks.length} resplit=${p + 1}/${pieces.length}`)
8836
+ }
8837
+ process.stderr.write(
8838
+ `telegram gateway: rich body too long — re-split chunk ${i + 1}/${chunks.length} into ${pieces.length} piece(s)\n`,
8839
+ )
8840
+ }
8841
+
8747
8842
  try {
8748
8843
  const sent = await robustApiCall(() => sendChunk(sendOpts), { threadId, chat_id })
8749
8844
  sentIds.push(sent.message_id)
@@ -8757,10 +8852,14 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8757
8852
  const sent = await sendChunk(retryOpts)
8758
8853
  sentIds.push(sent.message_id)
8759
8854
  } catch (retryErr) {
8760
- // Thread dropped, but the markdown is also unparseable — go plain.
8761
- if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
8855
+ // Thread dropped, AND another failure: length re-split,
8856
+ // parse-reject plain text, else propagate.
8857
+ if (isMessageTooLongError(retryErr)) await sendChunkResplit(retryOpts)
8858
+ else if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
8762
8859
  else throw retryErr
8763
8860
  }
8861
+ } else if (isMessageTooLongError(err)) {
8862
+ await sendChunkResplit(sendOpts)
8764
8863
  } else if (isHtmlParseRejectError(err)) {
8765
8864
  await sendChunkPlainText(sendOpts)
8766
8865
  } else {
@@ -9259,6 +9358,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9259
9358
  bot: lockedBot as unknown as { api: import('../stream-controller.js').StreamBotApi },
9260
9359
  retry: robustApiCall,
9261
9360
  repairEscapedWhitespace,
9361
+ normalizeParagraphBreaks,
9262
9362
  assertAllowedChat,
9263
9363
  resolveThreadId,
9264
9364
  disableLinkPreview: access.disableLinkPreview !== false,
@@ -10508,7 +10608,11 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
10508
10608
  // Single rich-markdown path (#2669): `format:'text'` edits as a literal
10509
10609
  // plain string; everything else edits via the rich-markdown path.
10510
10610
  const editLiteralText = editFormat === 'text'
10611
+ // Match the reply path: repair JSON-escape bungles, then promote lone prose
10612
+ // paragraph breaks for the rich path. A literal-text edit (`format:'text'`)
10613
+ // skips paragraph normalization — it must edit byte-for-byte as given.
10511
10614
  let editRawText = repairEscapedWhitespace(args.text as string)
10615
+ if (!editLiteralText) editRawText = normalizeParagraphBreaks(editRawText)
10512
10616
  // Outbound secret scrub (#2044): an edit must not re-introduce a raw
10513
10617
  // secret into a live bubble or the history row. Mask before scrub/send.
10514
10618
  editRawText = redactOutboundText(editRawText, 'edit_message')
@@ -14687,7 +14791,10 @@ function switchroomExecCombined(args: string[], timeoutMs = 15000): string {
14687
14791
  })
14688
14792
  }
14689
14793
 
14690
- function formatSwitchroomOutput(output: string, maxLen = 4000): string {
14794
+ // Default truncation budget for CLI output bound for Telegram. The rich-message
14795
+ // wire cap is RICH_MESSAGE_MAX_CHARS (32768) post-#2669, not the legacy 4096
14796
+ // plain-text limit. Mirrors shared/bot-runtime.ts formatSwitchroomOutput.
14797
+ function formatSwitchroomOutput(output: string, maxLen = RICH_MESSAGE_MAX_CHARS): string {
14691
14798
  const trimmed = output.trim()
14692
14799
  if (trimmed.length <= maxLen) return trimmed
14693
14800
  return trimmed.slice(0, maxLen - 20) + '\n... (truncated)'
@@ -21812,6 +21919,20 @@ bot.on('callback_query:data', async ctx => {
21812
21919
  // scopes (resolveTimeBox → null) and the disabled tier (ttl<=0) stay truly
21813
21920
  // once. The verdict is still dispatched WITHOUT a `rule` (below), so the
21814
21921
  // bridge never caches it untimed — the window lives only in scopedGrants.
21922
+ // Stale-id tap (#2469 follow-up): the pendingStateReaper auto-DENIES a
21923
+ // permission whose TTL expired and DELETES its pending entry — but a late
21924
+ // operator tap on the (now keyboard-stripped, but possibly still-cached)
21925
+ // card would otherwise dispatch a verdict for an already-resolved/dead
21926
+ // request_id, which Claude Code silently ignores. Net: operator THINKS
21927
+ // they approved, but the work was auto-denied and never continues. Be
21928
+ // honest: tell the operator the request already resolved and do NOT
21929
+ // dispatch a verdict for the dead id (no buffering, no resume message).
21930
+ // LIVE ids fall through to the normal dispatch path below, preserving the
21931
+ // dispatchPermissionVerdict buffering/offline-redelivery contract.
21932
+ if (!pendingPermissions.has(request_id)) {
21933
+ await ctx.answerCallbackQuery({ text: STALE_TAP_NOTICE }).catch(() => {})
21934
+ return
21935
+ }
21815
21936
  // Operator tapped a verdict ⇒ they are present; reset no-repeat suppression
21816
21937
  // so a later identical ask is shown fresh rather than silently short-circuited.
21817
21938
  clearPermissionTimeoutSuppression('operator answered a permission card')
@@ -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) {
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { describe, it, expect } from "vitest";
9
9
  import { validateClientMessage } from "../gateway/ipc-server.js";
10
+ import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
10
11
 
11
12
  const base = { type: "send_outbound", agentName: "clerk", chatId: "12345", text: "Daily heartbeat" };
12
13
 
@@ -38,8 +39,11 @@ describe("validateClientMessage — send_outbound", () => {
38
39
  expect(validateClientMessage({ ...base, text: undefined })).toBe(false);
39
40
  expect(validateClientMessage({ ...base, text: "" })).toBe(false);
40
41
  expect(validateClientMessage({ ...base, text: 5 })).toBe(false);
41
- expect(validateClientMessage({ ...base, text: "x".repeat(4096) })).toBe(true); // at the cap
42
- expect(validateClientMessage({ ...base, text: "x".repeat(4097) })).toBe(false); // over Telegram's limit
42
+ // send_outbound posts via sendRichMessage (rich path), so the cap is the
43
+ // rich-message wire limit (RICH_MESSAGE_MAX_CHARS, 32768) post-#2669, not
44
+ // the legacy 4096 plain-text limit.
45
+ expect(validateClientMessage({ ...base, text: "x".repeat(RICH_MESSAGE_MAX_CHARS) })).toBe(true); // at the cap
46
+ expect(validateClientMessage({ ...base, text: "x".repeat(RICH_MESSAGE_MAX_CHARS + 1) })).toBe(false); // over the cap
43
47
  });
44
48
 
45
49
  it("rejects a non-integer threadId", () => {