switchroom 0.19.26 → 0.19.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 (92) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +60 -2
  3. package/dist/auth-broker/index.js +244 -13
  4. package/dist/cli/autoaccept-poll.js +225 -17
  5. package/dist/cli/notion-write-pretool.mjs +60 -2
  6. package/dist/cli/switchroom.js +2843 -1220
  7. package/dist/host-control/main.js +245 -14
  8. package/dist/vault/approvals/kernel-server.js +242 -13
  9. package/dist/vault/broker/server.js +242 -13
  10. package/package.json +7 -2
  11. package/profiles/_base/cron-session.sh.hbs +8 -0
  12. package/profiles/_base/start.sh.hbs +175 -15
  13. package/telegram-plugin/card-layout.ts +328 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +94 -1
  15. package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
  16. package/telegram-plugin/dist/server.js +97 -1
  17. package/telegram-plugin/edit-flood-fuse.ts +841 -57
  18. package/telegram-plugin/flood-429-ledger.ts +526 -0
  19. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  20. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  21. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  22. package/telegram-plugin/gateway/gateway.ts +67 -70
  23. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  24. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  25. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  26. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  27. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  28. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  29. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  30. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  31. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  32. package/telegram-plugin/llm-error-present.ts +61 -2
  33. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  34. package/telegram-plugin/model-unavailable.ts +8 -0
  35. package/telegram-plugin/operator-events.ts +110 -5
  36. package/telegram-plugin/outbound-class.ts +81 -0
  37. package/telegram-plugin/provider-credit.ts +237 -0
  38. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  39. package/telegram-plugin/send-gate.ts +24 -2
  40. package/telegram-plugin/status-no-truncate.ts +10 -48
  41. package/telegram-plugin/status-pin-driver.ts +33 -45
  42. package/telegram-plugin/status-pin.ts +18 -1
  43. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  44. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  45. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  46. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  47. package/telegram-plugin/tests/card-variants.ts +366 -0
  48. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +373 -0
  49. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  50. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  51. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  52. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  53. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  54. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  55. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  56. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  57. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  58. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  59. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  60. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  61. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  62. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  63. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  64. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  65. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  66. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  67. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  68. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  69. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  70. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  71. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  72. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  73. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  74. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  75. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  76. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  77. package/telegram-plugin/tool-activity-summary.ts +239 -365
  78. package/telegram-plugin/uat/assertions.ts +22 -11
  79. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  80. package/telegram-plugin/worker-activity-feed.ts +105 -47
  81. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  82. package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
  83. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  84. package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
  85. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  86. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  90. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  91. package/vendor/hindsight-memory/settings.json +1 -1
  92. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -0,0 +1,168 @@
1
+ /**
2
+ * flood-reply-queue.ts — durably queue a USER-FACING reply that Telegram's
3
+ * flood window refused, instead of throwing its content away.
4
+ *
5
+ * The hole this closes (#3861). `retryApiCall` short-circuits BEFORE the wire
6
+ * when the persisted breaker reports a window longer than its in-process sleep
7
+ * ceiling, throwing the `FLOOD_WAIT_ACTIVE` marker (`retry-api-call.ts:198`,
8
+ * `:252`). That is the right call for the WIRE — nothing can succeed while the
9
+ * ban is open — but it is a bare `throw`, and the reply path's chunk-loop catch
10
+ * only re-wrapped it:
11
+ *
12
+ * reply failed after 0 of 1 chunk(s) sent: FLOOD_WAIT_ACTIVE
13
+ *
14
+ * The composed answer died in that string. Reproduced live on 2026-07-28: the
15
+ * error came back to the MCP caller and NO record appeared in the agent's
16
+ * `telegram/outbox/`. The durable-outbox work-loss guarantee was real for the
17
+ * outbox-delivered paths and false for the interactive reply path — so during a
18
+ * multi-hour ban the agent's actual answers to the operator were the one class
19
+ * of content that was silently lost.
20
+ *
21
+ * What this module does: classify the failure, and for anything that is not
22
+ * droppable (`cosmetic`) write the text into the SAME durable outbox the sweep
23
+ * already drains, under a CONTENT-derived nonce. The sweep (which since #3854
24
+ * defers while the window is open) delivers it when the window closes.
25
+ *
26
+ * Two design choices worth stating, because both are load-bearing:
27
+ *
28
+ * 1. CONTENT-derived nonce, not the turn nonce. `writeOutboxRecordAtomic` is
29
+ * idempotent per nonce and the sweep's delivered-keys journal is keyed by
30
+ * nonce, so a content nonce gives caller-retry dedup (same text ⇒ same file
31
+ * ⇒ one delivery, and after delivery the journal makes a re-queue a no-op)
32
+ * WITHOUT inheriting the turn nonce's failure mode: an earlier successful
33
+ * reply in the same turn journals `turn.turnId` as delivered, which would
34
+ * make a later flood-blocked answer queued under that same nonce
35
+ * `skip-journaled` — i.e. dropped. Never drop the answer.
36
+ *
37
+ * 2. `cosmetic` is NOT queued. A typing indicator or a card edit that missed
38
+ * its moment is worthless (and actively confusing) delivered hours later;
39
+ * the priority classes already in the tree say so. `critical` / `useful` /
40
+ * untagged are queued.
41
+ */
42
+
43
+ import { sha256Hex, writeOutboxRecordAtomic, type OutboxRecord } from '../outbox.js'
44
+ import { isFloodWaitActiveError } from '../retry-api-call.js'
45
+
46
+ /** The `source` stamped on records this module writes (diagnostic only). */
47
+ export const FLOOD_QUEUED_SOURCE = 'flood-deferred-reply'
48
+
49
+ /** Priority classes as used by `retry-api-call.ts` / `outbound-class.ts`. */
50
+ export type QueuePriorityClass = 'critical' | 'useful' | 'cosmetic' | undefined
51
+
52
+ /**
53
+ * True when `err` is a Telegram flood rejection — the local `FLOOD_WAIT_ACTIVE`
54
+ * fail-fast, a raw 429, or a wrapper whose message carries either.
55
+ *
56
+ * Deliberately duck-typed as well as `instanceof`-checked: the marker error
57
+ * carries Telegram's own `{ error_code: 429, parameters: { retry_after } }`
58
+ * shape, several surfaces re-wrap it in a plain `Error`, and the reply path's
59
+ * own partial-failure contract wraps it in
60
+ * `reply failed after N of M chunk(s) sent: …`.
61
+ */
62
+ export function isFloodRejection(err: unknown): boolean {
63
+ if (isFloodWaitActiveError(err)) return true
64
+ const e = err as { error_code?: number; parameters?: { retry_after?: number } } | null
65
+ if (e != null && typeof e === 'object') {
66
+ if (e.error_code === 429) return true
67
+ if (typeof e.parameters?.retry_after === 'number') return true
68
+ }
69
+ const msg = err instanceof Error ? err.message : String(err ?? '')
70
+ if (msg.includes('FLOOD_WAIT_ACTIVE')) return true
71
+ return /too many requests/i.test(msg) && /retry[ _-]?after/i.test(msg)
72
+ }
73
+
74
+ /** Telegram's stated `retry_after` (seconds) if the error carries one. */
75
+ export function floodRetryAfterSec(err: unknown): number | null {
76
+ const e = err as { retryAfterSec?: number; parameters?: { retry_after?: number } } | null
77
+ if (e != null && typeof e === 'object') {
78
+ if (typeof e.retryAfterSec === 'number') return e.retryAfterSec
79
+ if (typeof e.parameters?.retry_after === 'number') return e.parameters.retry_after
80
+ }
81
+ const msg = err instanceof Error ? err.message : String(err ?? '')
82
+ const m = /retry[ _-]?after[^0-9]{0,4}(\d+)/i.exec(msg)
83
+ return m != null ? Number(m[1]) : null
84
+ }
85
+
86
+ /**
87
+ * Stable, content-derived outbox nonce for a flood-deferred send. Same
88
+ * chat + thread + text ⇒ same nonce ⇒ exactly one delivery however many times
89
+ * the caller retries. Prefixed so a record's provenance is obvious on disk.
90
+ */
91
+ export function floodQueueNonce(
92
+ chatId: string,
93
+ threadId: number | null,
94
+ text: string,
95
+ ): string {
96
+ return `flood-${sha256Hex(`${chatId}\u0000${threadId ?? '_'}\u0000${text}`).slice(0, 40)}`
97
+ }
98
+
99
+ export interface FloodQueueInput {
100
+ /** The error that killed the send. */
101
+ err: unknown
102
+ chatId: string
103
+ threadId: number | null
104
+ /** The UNDELIVERED text (for a partial failure, only the chunks that never landed). */
105
+ text: string
106
+ /** Priority class of the send. `cosmetic` is never queued. */
107
+ priorityClass?: QueuePriorityClass
108
+ /** Per-session origin chat, for the sweep's F2 scoped routing fallback. */
109
+ originChatId?: string | null
110
+ originThreadId?: number | null
111
+ createdAt?: number
112
+ stateDir?: string
113
+ /** Injected for tests; defaults to the real atomic outbox writer. */
114
+ write?: (record: OutboxRecord, stateDir?: string) => boolean
115
+ }
116
+
117
+ export interface FloodQueueResult {
118
+ turnNonce: string
119
+ retryAfterSec: number | null
120
+ /** Human-readable outcome for the MCP caller, so the model does NOT recompose. */
121
+ notice: string
122
+ }
123
+
124
+ /**
125
+ * Queue a flood-blocked send to the durable outbox.
126
+ *
127
+ * Returns `null` — meaning "not handled, let the caller throw" — when the error
128
+ * is not a flood rejection, when the class is `cosmetic` (legitimately
129
+ * droppable), when there is no text left to deliver, or when the disk write
130
+ * failed. A `null` return must never be mistaken for "delivered".
131
+ */
132
+ export function queueFloodBlockedReply(input: FloodQueueInput): FloodQueueResult | null {
133
+ const { err, chatId, threadId, text } = input
134
+ if (!isFloodRejection(err)) return null
135
+ if (input.priorityClass === 'cosmetic') return null
136
+ if (chatId === '' || text === '') return null
137
+
138
+ const turnNonce = floodQueueNonce(chatId, threadId, text)
139
+ const createdAt = input.createdAt ?? Date.now()
140
+ const record: OutboxRecord = {
141
+ turnNonce,
142
+ chatId,
143
+ threadId,
144
+ text,
145
+ textSha256: sha256Hex(text),
146
+ createdAt,
147
+ source: FLOOD_QUEUED_SOURCE,
148
+ ...(input.originChatId != null ? { originChatId: input.originChatId } : {}),
149
+ ...(input.originThreadId != null ? { originThreadId: input.originThreadId } : {}),
150
+ }
151
+ const write = input.write ?? writeOutboxRecordAtomic
152
+ if (!write(record, input.stateDir)) return null
153
+
154
+ const retryAfterSec = floodRetryAfterSec(err)
155
+ const when =
156
+ retryAfterSec != null && retryAfterSec > 0
157
+ ? ` Telegram's flood window has ~${retryAfterSec}s left`
158
+ : ' A Telegram flood window is open'
159
+ return {
160
+ turnNonce,
161
+ retryAfterSec,
162
+ notice:
163
+ `reply QUEUED (not yet visible to the user):${when}, so this answer was written ` +
164
+ `to the durable outbox and will be delivered automatically when the window ` +
165
+ `closes. Do NOT resend it — a resend is deduplicated, and every extra call ` +
166
+ `extends the ban. (outbox nonce ${turnNonce})`,
167
+ }
168
+ }
@@ -180,7 +180,7 @@ import {
180
180
  import { fmtLocalStamp, resolveEnvTimezone, renderLogTimestampsLocal } from '../shared/local-time.js'
181
181
  import { StatusReactionController } from '../status-reactions.js'
182
182
  import { DeferredDoneReactions } from '../reaction-defer.js'
183
- import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
183
+ import { createWorkerActivityFeed, isWorkerActivityFeedEnabled, workerFeedEditPriorityClass } from '../worker-activity-feed.js'
184
184
  import {
185
185
  detectMemoryLegibilityEvent,
186
186
  isMemoryLegibilityEnabled,
@@ -195,8 +195,8 @@ import {
195
195
  renderConsolidationLine,
196
196
  } from '../consolidation-legibility.js'
197
197
  import type { WebhookGatewayRecord } from '../../src/web/webhook-gateway-record.js'
198
- import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
199
- import type { PinState, DesiredPin } from '../status-pin.js'
198
+ import { executePinLeg, type PinBotApi } from '../status-pin-driver.js'
199
+ import type { PinState, DesiredPin, PinLegAction } from '../status-pin.js'
200
200
  import { PinRightsCache } from '../status-pin.js'
201
201
  import { formatTurnLifecycle, detectStatusSurfaceDegraded } from './status-surface-log.js'
202
202
  import { parseSourceMessageId } from './source-message-id.js'
@@ -294,7 +294,7 @@ import {
294
294
  retryWithThreadFallback,
295
295
  isFloodWaitActiveError,
296
296
  } from '../retry-api-call.js'
297
- import { installEditFloodFuse } from '../edit-flood-fuse.js'
297
+ import { installEditFloodFuse, editFloodFuseConfigFromEnv } from '../edit-flood-fuse.js'
298
298
  import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
299
299
  import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
300
300
  import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags } from '../shared/bot-runtime.js'
@@ -404,6 +404,7 @@ import {
404
404
  type OperatorEvent,
405
405
  type OperatorEventKind,
406
406
  } from '../operator-events.js'
407
+ import { createMcpFailureHook } from './mcp-failure-hook.js'
407
408
  import { pendingUserNoticeGate } from '../pending-user-notice.js'
408
409
  import { recordOperatorEvent } from '../operator-events-history.js'
409
410
  import {
@@ -637,7 +638,7 @@ import type { HostdRequest, HostdResponse } from '../../src/host-control/protoco
637
638
  import type { AgentAudit } from '../welcome-text.js'
638
639
  import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
639
640
  import { createBootSweepGate, runBootPinSweepSteps } from './boot-sweep-gate.js'
640
- import { runStatusPinReconcile } from './status-pin-retarget.js'
641
+ import { runStatusPinReconcile, type StatusPinClaim } from './status-pin-retarget.js'
641
642
  import { createPeriodicSweepGuard } from './periodic-sweep-guard.js'
642
643
  import { withDeadline } from './with-deadline.js'
643
644
  import { createStatusPinApi, type PinCapableBot, type RobustApiSeam } from './status-pin-api.js'
@@ -691,6 +692,7 @@ import {
691
692
  } from './queued-card-store.js'
692
693
  import {
693
694
  decideWorkerPinReaps,
695
+ groupPinStatus,
694
696
  storeOnlyWorkerPinCandidates,
695
697
  WORKER_PIN_TTL_MS_DEFAULT,
696
698
  } from './worker-pin-reaper.js'
@@ -5865,7 +5867,7 @@ const redactAuthCodeApi = {
5865
5867
  * flood sleep and added a pre-call gate for LONG open windows. This wrapper
5866
5868
  * pre-dates neither mechanism nor fights them — it sits under the emitter's own
5867
5869
  * flood gate, which short-circuits earlier still (a typing ping during a ban
5868
- * never even reaches the retry layer). Defense in depth, in that order.
5870
+ * never even reaches the retry layer; #3853 adds the READ hook here too).
5869
5871
  */
5870
5872
  // No `log` hook: retryApiCall's flood line reads "waiting Ns", which would be a
5871
5873
  // lie here — we never wait, we drop. And the error the caller finally sees names
@@ -5875,7 +5877,7 @@ const redactAuthCodeApi = {
5875
5877
  // the real value is still in hand.
5876
5878
  const recordTypingFloodWait = makeFloodWaitRecorder(FLOOD_STATE_PATH)
5877
5879
  const nonEssentialApiCall = createRetryApiCall({
5878
- maxRetries: 1,
5880
+ maxRetries: 1, floodWaitRemainingMs: probeFloodWaitRemainingMs, // #3853 read side
5879
5881
  sleep: async () => {},
5880
5882
  onFloodWait: (retryAfterSec) => {
5881
5883
  recordTypingFloodWait(retryAfterSec)
@@ -8524,19 +8526,18 @@ const PIN_STATUS_WHILE_WORKING = (() => {
8524
8526
  // It does NOT touch the reply / stream_reply send handlers (the v1 bug was send
8525
8527
  // handlers unconditionally unpinning on every send) and runs NO polling
8526
8528
  // watchdog / getChat().pinned_message reconciler.
8527
- const statusPinState = new Map<string, PinState>()
8529
+ // ONE registry, keyed by pinKey → { messageId, chatId, pinnedAt } (#3809).
8530
+ // This was three parallel Maps (state / chatIds / pinnedAt), written on the
8531
+ // same commit but free to diverge; a `wk:` claim that lost its chatId entry was
8532
+ // skipped by the mid-session reaper on every pass AND excluded from the durable
8533
+ // store-orphan net, i.e. silently unreapable until the next boot. One record
8534
+ // makes the invariant structural — see StatusPinClaim in status-pin-retarget.ts.
8535
+ //
8528
8536
  // F2 serialization: same-pinKey reconciles run one-at-a-time via
8529
8537
  // `withPinReconcileLock` (status-pin-store.ts — kept there so it's
8530
8538
  // unit-testable) so each reads a fresh `prev`; see its doc for the stale-`prev`
8531
8539
  // race it closes. Adds zero Telegram API calls (a serialized noop still no-ops).
8532
- // Companion registry: pinKey → chatId, so the pre-restart sweep can unpin
8533
- // owned pins without threading the chat id through every call site. Written on
8534
- // every desired-pinned reconcile, cleared alongside the state on unpin.
8535
- const statusPinChatIds = new Map<string, string>()
8536
- // Companion registry: pinKey → wall-clock ms the claim was FIRST taken (a
8537
- // re-pin of the same key keeps the original timestamp). Feeds the TTL gate of
8538
- // the mid-session `wk:` pin reaper (#3001); cleared alongside the state.
8539
- const statusPinPinnedAt = new Map<string, number>()
8540
+ const statusPinClaims = new Map<string, StatusPinClaim>()
8540
8541
  // Rights-aware negative cache (#3024): chats where an auto status-pin attempt
8541
8542
  // failed with the permanent "not enough rights to manage pinned messages" 400.
8542
8543
  // Per-process only — a restart clears it so a later-granted pin right re-enables
@@ -8660,7 +8661,7 @@ const TOOL_PIN_TTL_MS = (() => {
8660
8661
  // mutation never rejects (persist is fail-open, load is fail-open).
8661
8662
  function persistBannerRow(row: PersistedStatusPin | null): void {
8662
8663
  if (!bannerPinPersistEnabled) return
8663
- void mutateStatusPinRow(
8664
+ void mutateStatusPinRow( // allow-raw-pin-store: banner row bookkeeping — the slot banner has its own sanctioned driver; this only mirrors its outcome into the shared store file.
8664
8665
  STATUS_PIN_STORE_PATH,
8665
8666
  statusPinStoreFs,
8666
8667
  BANNER_PIN_KEY,
@@ -8700,7 +8701,7 @@ async function statusPinBootCleanup(): Promise<void> {
8700
8701
  const { cleared, retained, kept, total } = await runStatusPinBootCleanup({
8701
8702
  path: STATUS_PIN_STORE_PATH,
8702
8703
  fs: statusPinStoreFs,
8703
- unpin: (chatId, messageId) => api.unpinChatMessage(chatId, messageId),
8704
+ unpin: (chatId, messageId) => api.unpinChatMessage(chatId, messageId), // allow-raw-pin: boot cleanup runs BEFORE any claim exists — it reconciles the durable rows themselves, so there is nothing to route through reconcileStatusPin.
8704
8705
  })
8705
8706
  if (total > 0) {
8706
8707
  process.stderr.write(
@@ -8763,7 +8764,7 @@ async function activityCardBootReaper(): Promise<void> {
8763
8764
  ),
8764
8765
  unpinCard: (record) =>
8765
8766
  robustApiCall(
8766
- () => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
8767
+ () => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId), // allow-raw-pin: activity-card boot reaper — unpins a card recovered from the CARD store, which has no status-pin claim to reconcile through.
8767
8768
  {
8768
8769
  chat_id: record.chatId,
8769
8770
  ...(record.threadId != null ? { threadId: record.threadId } : {}),
@@ -8942,12 +8943,12 @@ async function runMidSessionCardReaper(): Promise<void> {
8942
8943
  // claim is tracked (e.g. claim already dropped out-of-band).
8943
8944
  unpinCard: async (record) => {
8944
8945
  const pinKey = `fg:${record.turnKey}`
8945
- if (statusPinState.has(pinKey)) {
8946
+ if (statusPinClaims.has(pinKey)) {
8946
8947
  await reconcileStatusPin(pinKey, record.chatId, { pinned: false })
8947
8948
  return true
8948
8949
  }
8949
8950
  return robustApiCall(
8950
- () => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
8951
+ () => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId), // allow-raw-pin: activity-card mid-session reaper — same as the boot reaper: a card-store record, not a status-pin claim.
8951
8952
  {
8952
8953
  chat_id: record.chatId,
8953
8954
  ...(record.threadId != null ? { threadId: record.threadId } : {}),
@@ -8975,17 +8976,12 @@ async function runMidSessionCardReaper(): Promise<void> {
8975
8976
  // the durable store row clear together.
8976
8977
  if (WORKER_PIN_REAPER_ENABLED && PIN_STATUS_WHILE_WORKING) {
8977
8978
  try {
8978
- const inMemoryKeys = new Set(
8979
- [...statusPinState.keys()].filter((k) => k.startsWith('wk:')),
8980
- )
8981
- const inMemoryCandidates = [...inMemoryKeys].map((k) => ({
8982
- pinKey: k,
8983
- chatId: statusPinChatIds.get(k) ?? '',
8984
- // A missing timestamp (should not happen — set on every claim)
8985
- // degrades to "claimed just now": terminality can still reap it,
8986
- // the TTL gate never can. Conservative, never a spurious unpin.
8987
- pinnedAt: statusPinPinnedAt.get(k) ?? now,
8988
- }))
8979
+ // One record per claim (#3809): chatId and pinnedAt are fields of the
8980
+ // claim, so neither can go missing and strand the key as unreapable.
8981
+ const inMemoryCandidates = [...statusPinClaims.entries()]
8982
+ .filter(([k]) => k.startsWith('wk:'))
8983
+ .map(([pinKey, claim]) => ({ pinKey, chatId: claim.chatId, pinnedAt: claim.pinnedAt }))
8984
+ const inMemoryKeys = new Set(inMemoryCandidates.map((c) => c.pinKey))
8989
8985
  // #3001 durable group net: fold in `wk:` rows that live in the DURABLE
8990
8986
  // store but have NO in-memory claim — the divergence window where the
8991
8987
  // claim was lost but the Telegram pin + store row survive. These would
@@ -9012,8 +9008,11 @@ async function runMidSessionCardReaper(): Promise<void> {
9012
9008
  // missed unpin → 'terminal' (reap now). The feed's own group-empty
9013
9009
  // unpin is the primary path; this is the missed-unpin backstop.
9014
9010
  if (agentId.startsWith('group:')) {
9015
- const feedKey = agentId.slice('group:'.length)
9016
- return workerActivityFeed?.hasRunningInFeed(feedKey) ? 'running' : 'terminal'
9011
+ // groupPinStatus distinguishes "no feed to ask" ('unknown', TTL
9012
+ // only) from "the feed says this group is done" (→ 'terminal',
9013
+ // reap now). The `?.` shorthand conflated them and could unpin a
9014
+ // LIVE group pin during a feed-null window (#3811).
9015
+ return groupPinStatus(workerActivityFeed, agentId.slice('group:'.length))
9017
9016
  }
9018
9017
  if (turnsDb == null) return 'unknown'
9019
9018
  try {
@@ -9044,7 +9043,7 @@ async function runMidSessionCardReaper(): Promise<void> {
9044
9043
  // are best-effort/idempotent; a failed unpin still drops the row so
9045
9044
  // the next boot's cleanup is the final backstop.
9046
9045
  try {
9047
- await statusPinApi().unpinChatMessage(reap.chatId, reap.messageId)
9046
+ await statusPinApi().unpinChatMessage(reap.chatId, reap.messageId) // allow-raw-pin: store-orphan reap — by definition there is NO in-memory claim, so this unpins the exact tracked message id (group-safe, never unpin-all).
9048
9047
  } catch (err) {
9049
9048
  process.stderr.write(
9050
9049
  `telegram gateway: worker-pin reaper store-orphan unpin failed ` +
@@ -9052,7 +9051,7 @@ async function runMidSessionCardReaper(): Promise<void> {
9052
9051
  `${(err as Error).message}\n`,
9053
9052
  )
9054
9053
  }
9055
- await mutateStatusPinRow(
9054
+ await mutateStatusPinRow( // allow-raw-pin-store: drops the row for the store orphan unpinned immediately above; there is no claim to reconcile.
9056
9055
  STATUS_PIN_STORE_PATH,
9057
9056
  statusPinStoreFs,
9058
9057
  reap.pinKey,
@@ -9142,14 +9141,15 @@ async function reconcileStatusPinInner(
9142
9141
  // settled — always the true current claim. Closes the stale-`prev` race (a
9143
9142
  // turn-end clear dropping the disk row under a flood-delayed open-pin) and the
9144
9143
  // older duplicate-pin concern (two edits both reading prev=null).
9145
- const prev = statusPinState.get(pinKey) ?? null
9144
+ const claim = statusPinClaims.get(pinKey)
9145
+ const prev: PinState | null = claim == null ? null : { messageId: claim.messageId }
9146
9146
 
9147
- const runReconcileFrom = (from: PinState | null, want: DesiredPin) =>
9148
- reconcilePin({
9147
+ const runReconcileFrom = (action: PinLegAction, from: PinState | null) =>
9148
+ executePinLeg({
9149
9149
  api: statusPinApi(),
9150
9150
  chatId,
9151
9151
  prevState: from,
9152
- desired: want,
9152
+ action,
9153
9153
  rightsCache: statusPinRightsCache,
9154
9154
  onPinRightsDisabled: (chat) => {
9155
9155
  // Logged ONCE per chat per process (#3024). Every subsequent auto-pin
@@ -9179,11 +9179,7 @@ async function reconcileStatusPinInner(
9179
9179
  ? { path: STATUS_PIN_STORE_PATH, fs: statusPinStoreFs }
9180
9180
  : null,
9181
9181
  runPin: runReconcileFrom,
9182
- registries: {
9183
- state: statusPinState,
9184
- chatIds: statusPinChatIds,
9185
- pinnedAt: statusPinPinnedAt,
9186
- },
9182
+ claims: statusPinClaims,
9187
9183
  })
9188
9184
  }
9189
9185
 
@@ -9200,14 +9196,10 @@ async function reconcileStatusPinInner(
9200
9196
  * crash / interrupt never leaves a permanent pin behind. Best-effort;
9201
9197
  * clears the claim regardless of the unpin outcome (drop-on-unpin). */
9202
9198
  async function unpinAllStatusPins(): Promise<void> {
9203
- const keys = [...statusPinState.keys()]
9204
- for (const key of keys) {
9205
- const st = statusPinState.get(key)
9206
- if (st == null) continue
9207
- // Recover the chat id from the state map's companion key registry.
9208
- const chatId = statusPinChatIds.get(key)
9209
- if (chatId == null) { statusPinState.delete(key); statusPinPinnedAt.delete(key); continue }
9210
- await reconcileStatusPin(key, chatId, { pinned: false })
9199
+ // The chat id is a FIELD of the claim (#3809), so every claim is unpinnable —
9200
+ // there is no "claim we know about but cannot address" branch to fall through.
9201
+ for (const [key, claim] of [...statusPinClaims.entries()]) {
9202
+ await reconcileStatusPin(key, claim.chatId, { pinned: false })
9211
9203
  }
9212
9204
  }
9213
9205
 
@@ -9226,14 +9218,14 @@ async function unpinAllStatusPins(): Promise<void> {
9226
9218
  let dmPinSweepEligible = false
9227
9219
  const dmPinSweeper: DmPinSweeper = createDmPinSweeper({
9228
9220
  unpinAll: (chatId) =>
9229
- robustApiCall(() => lockedBot.api.unpinAllChatMessages(chatId), {
9221
+ robustApiCall(() => lockedBot.api.unpinAllChatMessages(chatId), { // allow-raw-pin: DM-only boot sweep — clears pins this process cannot enumerate (pre-restart orphans) and immediately re-pins the live tracked ids below.
9230
9222
  chat_id: chatId,
9231
9223
  verb: 'dm-pin-sweep.unpin-all',
9232
9224
  }),
9233
9225
  pinSilent: (chatId, messageId) =>
9234
9226
  robustApiCall(
9235
9227
  () =>
9236
- lockedBot.api.pinChatMessage(chatId, messageId, {
9228
+ lockedBot.api.pinChatMessage(chatId, messageId, { // allow-raw-pin: the re-pin half of the DM boot sweep — restores ids the sweep just cleared; the claims themselves are untouched.
9237
9229
  disable_notification: true,
9238
9230
  }),
9239
9231
  { chat_id: chatId, verb: 'dm-pin-sweep.repin' },
@@ -9248,8 +9240,8 @@ const dmPinSweeper: DmPinSweeper = createDmPinSweeper({
9248
9240
  // degrades to in-memory-only. The sweeper dedupes.
9249
9241
  liveTrackedMessageIds: (chatId) => {
9250
9242
  const ids: number[] = []
9251
- for (const [key, st] of statusPinState.entries()) {
9252
- if (statusPinChatIds.get(key) === chatId) ids.push(st.messageId)
9243
+ for (const claim of statusPinClaims.values()) {
9244
+ if (claim.chatId === chatId) ids.push(claim.messageId)
9253
9245
  }
9254
9246
  if (statusPinPersistEnabled || bannerPinPersistEnabled || toolPinPersistEnabled) {
9255
9247
  try {
@@ -13341,7 +13333,7 @@ async function executePinMessage(args: Record<string, unknown>): Promise<unknown
13341
13333
  // failure the agent should see.
13342
13334
  const pinMsgId = Number(args.message_id)
13343
13335
  await robustApiCall(
13344
- () => lockedBot.api.pinChatMessage(pinChatId, pinMsgId),
13336
+ () => lockedBot.api.pinChatMessage(pinChatId, pinMsgId), // allow-raw-pin: MCP `pin_message` tool — an explicit, agent-requested pin of an arbitrary message, not a progress surface with a claim.
13345
13337
  { chat_id: pinChatId, verb: 'pin_message' },
13346
13338
  )
13347
13339
  // An explicit pin succeeded here, so the bot demonstrably HAS pin rights in
@@ -13358,7 +13350,7 @@ async function executePinMessage(args: Record<string, unknown>): Promise<unknown
13358
13350
  // failure must never fail the tool call the pin already landed for.
13359
13351
  if (toolPinPersistEnabled) {
13360
13352
  const toolPinKey = `tool:${pinChatId}:${pinMsgId}`
13361
- void mutateStatusPinRow(STATUS_PIN_STORE_PATH, statusPinStoreFs, toolPinKey, {
13353
+ void mutateStatusPinRow(STATUS_PIN_STORE_PATH, statusPinStoreFs, toolPinKey, { // allow-raw-pin-store: records the TTL-scoped `tool:` row for the explicit pin above so the boot sweep can expire it.
13362
13354
  pinKey: toolPinKey,
13363
13355
  chatId: pinChatId,
13364
13356
  messageId: pinMsgId,
@@ -14016,7 +14008,13 @@ function surfaceConsolidationLegibility(
14016
14008
  })
14017
14009
  }
14018
14010
 
14011
+ /** Blocked-paid-dependency alerting — policy in `mcp-credential-failure.ts`,
14012
+ * seam in `gateway/mcp-failure-hook.ts`. Tees the SAME
14013
+ * `emitGatewayOperatorEvent` path so there is ONE operator-alert mechanism. */
14014
+ const noteMcpDependencyFailure = createMcpFailureHook({ agent: AGENT_NAME, emit: emitGatewayOperatorEvent })
14015
+
14019
14016
  function handleSessionEvent(ev: SessionEvent): void {
14017
+ noteMcpDependencyFailure(ev)
14020
14018
  handleSessionEventCore(gatewayStreamRenderDeps(), ev)
14021
14019
  }
14022
14020
 
@@ -21957,7 +21955,7 @@ bot.on('callback_query:data', async ctx => {
21957
21955
  // ALREADY dispatched above (independently of this host round-trip)
21958
21956
  // so the turn never blocked — finalizeCallback here only edits the
21959
21957
  // card; no synthInbound (would double-fire the verdict).
21960
- await finalizeCallback(ctx, {
21958
+ await finalizeCallback(ctx, { apiCall: robustApiCall,
21961
21959
  ackText: 'Rule applied for this session; saving durably in background…'.slice(0, 200),
21962
21960
  newText: baseText ? `${baseText}\n\n${interimLabel}` : interimLabel,
21963
21961
  })
@@ -22269,7 +22267,7 @@ bot.on('callback_query:data', async ctx => {
22269
22267
  // decision. Operator could re-tap and flip Deny → Allow after the
22270
22268
  // permission was already broadcast. Routing through finalizeCallback
22271
22269
  // strips the keyboard atomically with the status-line edit.
22272
- await finalizeCallback(ctx, {
22270
+ await finalizeCallback(ctx, { apiCall: robustApiCall,
22273
22271
  ackText: ackText.slice(0, 200),
22274
22272
  newText: baseText ? `${baseText}\n\n${labelWithResume}` : labelWithResume,
22275
22273
  synthInbound: () => {
@@ -22349,8 +22347,7 @@ const voiceHandlerDeps: VoiceHandlerDeps = {
22349
22347
  // site (check-bot-api-wrapping stays satisfied — the raw call remains in
22350
22348
  // gateway.ts, already inside the retry policy).
22351
22349
  const pinnedMessageHandlerDeps: PinnedMessageHandlerDeps = {
22352
- statusPinState,
22353
- statusPinChatIds,
22350
+ statusPinClaims,
22354
22351
  deleteServiceMessage: (chatId, serviceMsgId) =>
22355
22352
  robustApiCall(
22356
22353
  () => lockedBot.api.deleteMessage(chatId, serviceMsgId),
@@ -22965,8 +22962,8 @@ async function initGatewayBot(): Promise<void> {
22965
22962
  // outbound call can bypass (grammY has no route to the network that skips the
22966
22963
  // transformer stack). Kill-switch SWITCHROOM_EDIT_FUSE=0; see edit-flood-fuse.ts.
22967
22964
  installEditFloodFuse(bot, {
22968
- enabled: process.env.SWITCHROOM_EDIT_FUSE !== '0',
22969
- onTrip: (i) => process.stderr.write(`edit-flood-fuse ${i.action} method=${i.method} key=${i.key}\n`),
22965
+ ...editFloodFuseConfigFromEnv(process.env),
22966
+ onTrip: (i) => process.stderr.write(`edit-flood-fuse ${i.action} method=${i.method} key=${i.key} class=${i.cls}\n`),
22970
22967
  })
22971
22968
 
22972
22969
  // Diagnostic update tap (#3300): one compact line per received update, logged
@@ -23986,7 +23983,7 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23986
23983
  )
23987
23984
  return sent as { message_id: number }
23988
23985
  },
23989
- editMessageText: (cid, mid, text, editOpts) =>
23986
+ editMessageText: (cid, mid, text, editOpts, editMeta) =>
23990
23987
  robustApiCall(
23991
23988
  () =>
23992
23989
  lockedBot.api.editMessageText(
@@ -23995,12 +23992,12 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23995
23992
  richMessage(text),
23996
23993
  editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
23997
23994
  ),
23998
- // Worker-feed EDITs are COSMETIC pass messageId/editPayload
23999
- // so the per-message floor + coalescing + no-op skip engage.
23995
+ // Repaints COSMETIC, terminal frame USEFUL (#3848); messageId/
23996
+ // editPayload engage the floor + coalescing + no-op skip.
24000
23997
  {
24001
23998
  chat_id: cid,
24002
23999
  verb: 'worker-feed',
24003
- priorityClass: 'cosmetic',
24000
+ priorityClass: workerFeedEditPriorityClass(editMeta),
24004
24001
  messageId: mid,
24005
24002
  editPayload: text,
24006
24003
  },
@@ -24058,7 +24055,7 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24058
24055
  if (messageId != null) {
24059
24056
  void reconcileStatusPin(key, chatId, { pinned: true, messageId })
24060
24057
  } else {
24061
- const unpinChat = chatId || statusPinChatIds.get(key)
24058
+ const unpinChat = chatId || statusPinClaims.get(key)?.chatId
24062
24059
  if (unpinChat != null && unpinChat.length > 0) {
24063
24060
  void reconcileStatusPin(key, unpinChat, { pinned: false })
24064
24061
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * mcp-failure-hook.ts — the gateway-side wiring that turns a blocked PAID MCP
3
+ * DEPENDENCY into an operator alert.
4
+ *
5
+ * Ken: "If perplexity fails for any agent we should have hard warning via
6
+ * message here per other failures."
7
+ *
8
+ * MCP providers (Perplexity, Eraser, Brevo, Postiz, Meta/Google Ads,
9
+ * Cloudflare) never touch LiteLLM, so the operator-event path that catches an
10
+ * OpenRouter 402 never sees them — an expired Perplexity key would otherwise
11
+ * die as a transient red step in the live feed and nobody would learn the fleet
12
+ * had lost a paid capability.
13
+ *
14
+ * This tees the SAME `emitGatewayOperatorEvent` path (redact → per-kind
15
+ * cooldown → operator-only audience via `OPERATOR_ACTIONABLE_KINDS` → brief
16
+ * user notice), so there is ONE operator-alert mechanism in switchroom, not
17
+ * two. All the policy — what counts as a key problem, what stays silent, the
18
+ * 6-hour re-notify cadence — lives in `../mcp-credential-failure.ts`; this file
19
+ * is only the seam.
20
+ *
21
+ * Extracted from `gateway.ts` rather than inlined there, per the
22
+ * switchroom#2996 anti-inflation ratchet (`scripts/check-gateway-line-ratchet.mjs`).
23
+ */
24
+
25
+ import type { SessionEvent } from '../session-tail.js'
26
+ import type { OperatorEvent } from '../operator-events.js'
27
+ import { McpFailureWatcher, renderMcpFailureDetail } from '../mcp-credential-failure.js'
28
+
29
+ export interface McpFailureHookDeps {
30
+ agent: string
31
+ emit: (event: OperatorEvent) => void
32
+ now?: () => number
33
+ }
34
+
35
+ /**
36
+ * Build the `handleSessionEvent` side-hook. One watcher per gateway process,
37
+ * captured in the closure.
38
+ *
39
+ * Silent by default: only a credit/credential/quota class raises anything, and
40
+ * only once per (server, class) per re-notify window. A bad query, a 404, a
41
+ * timeout, a transient 429 or a successful call all return without emitting.
42
+ *
43
+ * Never throws — alerting is a side channel and must not break the live feed.
44
+ */
45
+ export function createMcpFailureHook(deps: McpFailureHookDeps): (ev: SessionEvent) => void {
46
+ const watcher = new McpFailureWatcher()
47
+ const clock = deps.now ?? (() => Date.now())
48
+ return function noteMcpDependencyFailure(ev: SessionEvent): void {
49
+ try {
50
+ if (ev.kind === 'tool_use') {
51
+ watcher.onToolUse(ev.toolUseId, ev.toolName)
52
+ return
53
+ }
54
+ if (ev.kind !== 'tool_result') return
55
+ const alert = watcher.onToolResult({
56
+ toolUseId: ev.toolUseId,
57
+ isError: ev.isError,
58
+ errorText: ev.errorText,
59
+ agent: deps.agent,
60
+ now: clock(),
61
+ })
62
+ if (alert == null) return
63
+ deps.emit({
64
+ kind: 'mcp-dependency-blocked',
65
+ agent: deps.agent,
66
+ detail: renderMcpFailureDetail(alert),
67
+ suggestedActions: [],
68
+ firstSeenAt: new Date(clock()),
69
+ })
70
+ } catch {
71
+ // Side channel — never let alerting break the live feed.
72
+ }
73
+ }
74
+ }
@@ -838,6 +838,20 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
838
838
  id,
839
839
  )
840
840
  }
841
+ // #3812 — release the status-pin claim BEFORE the message goes away,
842
+ // symmetric with the durable card-record drop above. `turn-end.ts` also
843
+ // unpins `fg:<statusKey>`, but that runs LATER: on the
844
+ // CLEAR_STATUS_ON_COMPLETION path the message is DELETED here, so by turn
845
+ // end the claim named a dead id and the unpin was a guaranteed-4xx API
846
+ // call every single turn. Worse, the durable status-pins.json row pointed
847
+ // at a deleted message for that whole window, so a crash there handed the
848
+ // next boot's sweep a row it would also fail to unpin — burning an
849
+ // attempt off the BOOT_UNPIN_MAX_ATTEMPTS forfeit ladder for nothing.
850
+ //
851
+ // AWAITED so the unpin (and its durable-row clear) is ordered strictly
852
+ // before the delete. turn-end stays the idempotent backstop: it no-ops
853
+ // once nothing is claimed for the key.
854
+ await reconcileStatusPin(`fg:${statusKey(chat, thread)}`, chat, { pinned: false })
841
855
  if (CLEAR_STATUS_ON_COMPLETION) {
842
856
  try {
843
857
  await robustApiCall(