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.
- package/dist/cli/switchroom.js +134 -36
- package/dist/host-control/main.js +13 -7
- package/package.json +2 -2
- package/telegram-plugin/answer-stream.ts +7 -6
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +233 -28
- package/telegram-plugin/dist/server.js +1 -1
- package/telegram-plugin/format.ts +335 -27
- package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
- package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
- package/telegram-plugin/gateway/gateway.ts +132 -11
- package/telegram-plugin/gateway/ipc-server.ts +11 -6
- package/telegram-plugin/gateway/permission-timeout.ts +76 -0
- package/telegram-plugin/permission-title.ts +3 -0
- package/telegram-plugin/retry-api-call.ts +27 -0
- package/telegram-plugin/rich-send.ts +29 -0
- package/telegram-plugin/shared/bot-runtime.ts +6 -1
- package/telegram-plugin/silent-reply-anchor.ts +9 -2
- package/telegram-plugin/status-no-truncate.ts +11 -5
- package/telegram-plugin/stream-reply-handler.ts +9 -1
- package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
- package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
- package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
- package/telegram-plugin/tests/permission-title.test.ts +43 -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 {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
).
|
|
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
|
-
|
|
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,
|
|
8761
|
-
|
|
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
|
-
|
|
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
|
|
234
|
-
//
|
|
235
|
-
//
|
|
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 —
|
|
274
|
-
//
|
|
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 >
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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.
|
|
34
|
-
* cap is
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
42
|
-
|
|
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", () => {
|