switchroom 0.18.11 → 0.18.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -83,6 +83,7 @@ import { getAuthBrokerClient } from './auth-broker-client.js'
83
83
  import { chatKey } from './chat-key.js'
84
84
  import { tryHostdDispatch, hostdRequestId } from './hostd-dispatch.js'
85
85
  import type { HostdRequest } from '../../src/host-control/protocol.js'
86
+ import type { OperatorEvent } from '../operator-events.js'
86
87
  import type { InboundMessage } from './ipc-protocol.js'
87
88
  import type { SweepableCardStore } from './approval-card-stores.js'
88
89
  import type { SweepableStore } from './pending-state-stores.js'
@@ -389,6 +390,13 @@ export interface CallbackQueryHandlersDeps {
389
390
  vaultKeyRegex: RegExp
390
391
  /** MENTAL_MODEL_PROPOSE_TTL_MS (config-driven approval-card lifetime). */
391
392
  mentalModelProposeTtlMs: number
393
+ /**
394
+ * Emit an operator-visible event through the gateway's single funnel
395
+ * (`emitGatewayOperatorEvent`: cooldown + record + broadcast). Used by the
396
+ * #2975 Stage-1 backstop to loudly surface an approved mental-model persist
397
+ * whose rate-window retry also failed.
398
+ */
399
+ emitOperatorEvent: (event: OperatorEvent) => void
392
400
  }
393
401
 
394
402
  // Freshness throttle for the /auth dashboard ↻ refresh button — one live
@@ -435,6 +443,7 @@ export function createCallbackQueryHandlers(deps: CallbackQueryHandlersDeps) {
435
443
  getAdminOnlyKeys,
436
444
  vaultKeyRegex: VAULT_KEY_REGEX,
437
445
  mentalModelProposeTtlMs: MENTAL_MODEL_PROPOSE_TTL_MS,
446
+ emitOperatorEvent,
438
447
  } = deps
439
448
  const bot = deps.bot as CallbackBotApi
440
449
  const lockedBot = deps.lockedBot as CallbackBotApi
@@ -1035,7 +1044,20 @@ async function handleMentalModelProposeCallback(ctx: Context, data: string): Pro
1035
1044
  return { state: 'error' as const, reason: 'hostd config-edit is not configured (host_control disabled or socket absent)' }
1036
1045
  }
1037
1046
  if (resp.result === 'completed') return { state: 'applied' as const }
1038
- if (resp.result === 'denied') return { state: 'denied' as const, reason: resp.error ?? 'operator/host denied the edit' }
1047
+ if (resp.result === 'denied') {
1048
+ // #2975 Stage 1 — a rate-limit denial carries a structured
1049
+ // `fix.retry_after` (hostd server.ts ~2270-2280). Surface it as a
1050
+ // distinct `rate_limited` state so the resolver schedules ONE retry
1051
+ // at the window-open time instead of dropping the approved change.
1052
+ const env = resp.error_envelope
1053
+ if (env?.code === 'E_RATE_LIMITED' && env.fix?.kind === 'retry_after') {
1054
+ const retryAtMs = Date.parse(env.fix.retry_at)
1055
+ if (!Number.isNaN(retryAtMs)) {
1056
+ return { state: 'rate_limited' as const, reason: resp.error ?? 'config_propose_edit rate limit exceeded', retryAtMs }
1057
+ }
1058
+ }
1059
+ return { state: 'denied' as const, reason: resp.error ?? 'operator/host denied the edit' }
1060
+ }
1039
1061
  return { state: 'error' as const, reason: resp.error ?? `hostd returned '${resp.result}'` }
1040
1062
  },
1041
1063
  // Ensure is delegated to reconcile: config_propose_edit's apply triggers a
@@ -1046,6 +1068,52 @@ async function handleMentalModelProposeCallback(ctx: Context, data: string): Pro
1046
1068
  injectInbound: (inbound: InboundMessage) => {
1047
1069
  deliverResumeSyntheticOrBuffer(pending.agent, inbound)
1048
1070
  },
1071
+ // #2975 Stage 1 — schedule EXACTLY ONE re-dispatch of a rate-limited but
1072
+ // operator-approved persist. A bare setTimeout (bounded, single-shot; the
1073
+ // resolver never re-schedules). Stage-1 caveat: this timer lives only in
1074
+ // gateway memory, so a gateway restart before it fires LOSES the retry —
1075
+ // acceptable for Stage 1 (Stage 2's hostd checkPreApproved bypass removes
1076
+ // the collision). Clamp the delay to a signed-32-bit setTimeout ceiling so
1077
+ // a far-future window doesn't overflow into an immediate fire.
1078
+ scheduleRetry: (delayMs: number, fn: () => void | Promise<void>) => {
1079
+ const clamped = Math.min(Math.max(0, delayMs), 2_147_483_647)
1080
+ setTimeout(() => {
1081
+ void (async () => {
1082
+ try {
1083
+ await fn()
1084
+ } catch (err) {
1085
+ process.stderr.write(
1086
+ `telegram gateway: mental_model_propose retry threw: ${(err as Error).message}\n`,
1087
+ )
1088
+ }
1089
+ })()
1090
+ }, clamped)
1091
+ },
1092
+ editProposalCardRateWindow: (retryAtMs: number) => {
1093
+ if (pending.card_message_id == null) return
1094
+ const at = new Date(retryAtMs)
1095
+ const hh = String(at.getHours()).padStart(2, '0')
1096
+ const mm = String(at.getMinutes()).padStart(2, '0')
1097
+ void ctx.api
1098
+ .editMessageText(
1099
+ pending.chat_id,
1100
+ pending.card_message_id,
1101
+ richMessage(
1102
+ `⏳ _Approved — applying **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` at ${hh}:${mm} (config-edit rate window). It isn't lost; it'll persist automatically when the window opens._`,
1103
+ ),
1104
+ { reply_markup: { inline_keyboard: [] }, link_preview_options: { is_disabled: true } },
1105
+ )
1106
+ .catch(() => {})
1107
+ },
1108
+ notifyPersistFailed: (reason: string) => {
1109
+ emitOperatorEvent({
1110
+ kind: 'mental-model-persist-failed',
1111
+ agent: pending.agent,
1112
+ detail: `Mental model "${pending.spec.name}" — ${reason}`,
1113
+ suggestedActions: [],
1114
+ firstSeenAt: new Date(),
1115
+ })
1116
+ },
1049
1117
  log: (m: string) => process.stderr.write(`telegram gateway: ${m}\n`),
1050
1118
  }
1051
1119
 
@@ -1088,6 +1156,13 @@ async function handleMentalModelProposeCallback(ctx: Context, data: string): Pro
1088
1156
  process.stderr.write(`telegram gateway: mental_model_propose approve threw: ${(err as Error).message}\n`)
1089
1157
  result = { outcome: 'failed' as const, reason: (err as Error).message }
1090
1158
  }
1159
+ // #2975 Stage 1 — a rate-limited persist already edited the card to the
1160
+ // "applying at HH:MM (rate window)" state and scheduled its own retry,
1161
+ // which will resolve the turn via its own inbound. Leave that card intact;
1162
+ // do NOT overwrite it with a success/failure label here.
1163
+ if (result.outcome === 'scheduled_retry') {
1164
+ return
1165
+ }
1091
1166
  if (pending.card_message_id != null) {
1092
1167
  const label =
1093
1168
  result.outcome === 'applied'
@@ -2459,6 +2534,18 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
2459
2534
  // stub so any stale pinned message that fires an `auth:*` tap is
2460
2535
  // silently dismissed instead of crashing the gateway.
2461
2536
  async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
2537
+ // Strict allowFrom gate, identical to every other mutating handler in
2538
+ // this file (handleOperatorEventCallback, the vra:/vrs:/vd:/vg: families).
2539
+ // Its absence was a security hole: `auth:use:<label>` drives
2540
+ // `client.setActive(label)` — a fleet-wide OAuth account swap — so an
2541
+ // ungated handler let any tapper (e.g. a member of an admin forum/
2542
+ // supergroup with an empty group allowFrom) swap the active account.
2543
+ const senderId = String(ctx.from?.id ?? '')
2544
+ const access = loadAccess()
2545
+ if (!access.allowFrom.includes(senderId)) {
2546
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
2547
+ return
2548
+ }
2462
2549
  const data = ctx.callbackQuery?.data ?? ''
2463
2550
  const currentAgent = getMyAgentName()
2464
2551
 
@@ -474,7 +474,12 @@ export async function handleRequestConfigFinalize(
474
474
  const body =
475
475
  msg.outcome === "applied"
476
476
  ? `✅ **Applied**${msg.detail ? `\n${escapeMarkdown(msg.detail)}` : ""}${liveNote}`
477
- : `⚠️ **Reconcile failed; rolled back**${msg.detail ? `\n${escapeMarkdown(msg.detail)}` : ""}`;
477
+ : msg.outcome === "aborted_config_changed"
478
+ ? // Nothing was written: the config drifted during the approval window
479
+ // and the apply aborted rather than land a different effect than the
480
+ // operator approved (#3121 follow-up).
481
+ `🚫 **Not applied — config changed since proposal**${msg.detail ? `\n${escapeMarkdown(msg.detail)}` : ""}`
482
+ : `⚠️ **Reconcile failed; rolled back**${msg.detail ? `\n${escapeMarkdown(msg.detail)}` : ""}`;
478
483
  try {
479
484
  // Finalize is terminal — strip the keyboard so the buttons are gone.
480
485
  await deps.editCard({
@@ -57,6 +57,15 @@ export interface DisconnectFlushDeps<Ctrl extends { finalize: (reason?: 'done' |
57
57
  /** Progress driver — disposed with `preservePending: true` for sub-agent JTBDs (#393). */
58
58
  disposeProgressDriver: () => void
59
59
 
60
+ /** #2650: stop EVERY live turn-typing loop (the per-(chat,thread)
61
+ * `setInterval` started by `turn-typing-loop.ts`). The bridge just died
62
+ * mid-turn; its turn-long `typing…` loop is never stopped by the canonical
63
+ * turn-end (which will never arrive), so a stale "typing…" shows to the user
64
+ * until the next turn's `start()` self-heals it. Wire to
65
+ * `() => turnTypingLoop.stopAll()`. Called ONLY on a registered-agent
66
+ * disconnect — an anonymous one-shot never owned a turn-typing loop. */
67
+ stopTurnTypingLoops: () => void
68
+
60
69
  /** Optional: called when the registered-agent disconnect found dangling
61
70
  * `activeTurnStartedAt` entries the controller loop did not clear (i.e.
62
71
  * `finalize()` already ran on the canonical reply path, leaving
@@ -92,6 +101,7 @@ export function flushOnAgentDisconnect<
92
101
  activeDraftStreams,
93
102
  clearActiveReactions,
94
103
  disposeProgressDriver,
104
+ stopTurnTypingLoops,
95
105
  onDanglingTurnsSwept,
96
106
  log,
97
107
  } = deps
@@ -181,6 +191,15 @@ export function flushOnAgentDisconnect<
181
191
  // deferred-completion-timeout path remain active. Fix for #393.
182
192
  disposeProgressDriver()
183
193
 
194
+ // #2650: sweep the turn-typing loops. The bridge died mid-turn, so the
195
+ // canonical turn-end that would normally call `turnTypingLoop.stop` will
196
+ // never arrive — every live per-(chat,thread) `typing…` interval is now
197
+ // orphaned and would show a stale "typing…" to the user until the next
198
+ // turn's `start()` self-heals it. This is gated to a real registered-agent
199
+ // disconnect (we returned early above for anonymous clients), so an
200
+ // anonymous one-shot never triggers the sweep.
201
+ stopTurnTypingLoops()
202
+
184
203
  // Finalize any open draft streams so they don't hang mid-edit.
185
204
  for (const [key, stream] of activeDraftStreams.entries()) {
186
205
  if (!stream.isFinal()) void stream.finalize().catch(() => {})
@@ -0,0 +1,251 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ isDmChatId,
4
+ collectDmChatIdsFromStores,
5
+ createDmPinSweeper,
6
+ unexpiredStoreRepinIds,
7
+ type DmPinSweeperDeps,
8
+ } from './dm-pin-sweep'
9
+
10
+ // Synthetic chat IDs only (check-no-pii-secrets): positive = DM, negative =
11
+ // group/supergroup.
12
+ const DM_A = '5000001'
13
+ const DM_B = '5000002'
14
+ const GROUP = '-1002000000001'
15
+
16
+ describe('isDmChatId', () => {
17
+ it('true for positive integer DM ids', () => {
18
+ expect(isDmChatId(DM_A)).toBe(true)
19
+ expect(isDmChatId('1')).toBe(true)
20
+ })
21
+ it('false for group/supergroup (negative), zero, non-integer, non-numeric', () => {
22
+ expect(isDmChatId(GROUP)).toBe(false)
23
+ expect(isDmChatId('-1')).toBe(false)
24
+ expect(isDmChatId('0')).toBe(false)
25
+ expect(isDmChatId('1.5')).toBe(false)
26
+ expect(isDmChatId('abc')).toBe(false)
27
+ expect(isDmChatId('')).toBe(false)
28
+ expect(isDmChatId('Infinity')).toBe(false)
29
+ })
30
+ })
31
+
32
+ describe('collectDmChatIdsFromStores', () => {
33
+ it('collects distinct DM chat ids across all three stores and drops non-DMs', () => {
34
+ const ids = collectDmChatIdsFromStores({
35
+ statusPins: [{ chatId: DM_A }, { chatId: GROUP }, { chatId: DM_A }],
36
+ activityCards: [{ chatId: DM_B }, { chatId: '0' }],
37
+ queuedCards: [{ chatId: DM_A }, { chatId: '-77' }],
38
+ })
39
+ expect(new Set(ids)).toEqual(new Set([DM_A, DM_B]))
40
+ expect(ids).toHaveLength(2)
41
+ })
42
+ it('handles missing store arrays', () => {
43
+ expect(collectDmChatIdsFromStores({})).toEqual([])
44
+ expect(collectDmChatIdsFromStores({ statusPins: [{ chatId: GROUP }] })).toEqual([])
45
+ })
46
+ })
47
+
48
+ function makeDeps(overrides: Partial<DmPinSweeperDeps> = {}): {
49
+ deps: DmPinSweeperDeps
50
+ unpinAll: ReturnType<typeof vi.fn>
51
+ pinSilent: ReturnType<typeof vi.fn>
52
+ } {
53
+ const unpinAll = vi.fn(async () => undefined)
54
+ const pinSilent = vi.fn(async () => undefined)
55
+ const deps: DmPinSweeperDeps = {
56
+ unpinAll,
57
+ pinSilent,
58
+ liveTrackedMessageIds: () => [],
59
+ eligible: () => true,
60
+ log: () => {},
61
+ ...overrides,
62
+ }
63
+ return { deps, unpinAll, pinSilent }
64
+ }
65
+
66
+ describe('createDmPinSweeper', () => {
67
+ it('DM with tracked pins: unpin-all exactly once, then re-pin exactly the live ids', async () => {
68
+ const { deps, unpinAll, pinSilent } = makeDeps({
69
+ liveTrackedMessageIds: (chatId) => (chatId === DM_A ? [111, 222] : []),
70
+ })
71
+ const sweeper = createDmPinSweeper(deps)
72
+ await sweeper.sweep(DM_A)
73
+
74
+ expect(unpinAll).toHaveBeenCalledTimes(1)
75
+ expect(unpinAll).toHaveBeenCalledWith(DM_A)
76
+ expect(pinSilent.mock.calls).toEqual([
77
+ [DM_A, 111],
78
+ [DM_A, 222],
79
+ ])
80
+ expect(sweeper.hasSwept(DM_A)).toBe(true)
81
+ })
82
+
83
+ it('DM with no tracked pins: unpin-all once, no re-pin', async () => {
84
+ const { deps, unpinAll, pinSilent } = makeDeps()
85
+ const sweeper = createDmPinSweeper(deps)
86
+ await sweeper.sweep(DM_A)
87
+ expect(unpinAll).toHaveBeenCalledTimes(1)
88
+ expect(pinSilent).not.toHaveBeenCalled()
89
+ })
90
+
91
+ it('group/supergroup id: never unpin-all (keeps human pins)', async () => {
92
+ const { deps, unpinAll, pinSilent } = makeDeps()
93
+ const sweeper = createDmPinSweeper(deps)
94
+ await sweeper.sweep(GROUP)
95
+ await sweeper.sweep('-1')
96
+ expect(unpinAll).not.toHaveBeenCalled()
97
+ expect(pinSilent).not.toHaveBeenCalled()
98
+ })
99
+
100
+ it('not eligible (lost startup mutex): does nothing', async () => {
101
+ const { deps, unpinAll } = makeDeps({ eligible: () => false })
102
+ const sweeper = createDmPinSweeper(deps)
103
+ await sweeper.sweep(DM_A)
104
+ expect(unpinAll).not.toHaveBeenCalled()
105
+ expect(sweeper.hasSwept(DM_A)).toBe(false)
106
+ })
107
+
108
+ it('second sweep of same chat does not re-sweep', async () => {
109
+ const { deps, unpinAll } = makeDeps()
110
+ const sweeper = createDmPinSweeper(deps)
111
+ await sweeper.sweep(DM_A)
112
+ await sweeper.sweep(DM_A)
113
+ expect(unpinAll).toHaveBeenCalledTimes(1)
114
+ })
115
+
116
+ it('concurrent triggers on the same chat fire unpin-all only once', async () => {
117
+ let resolveUnpin: () => void = () => {}
118
+ const unpinAll = vi.fn(
119
+ () =>
120
+ new Promise<void>((res) => {
121
+ resolveUnpin = res
122
+ }),
123
+ )
124
+ const { deps } = makeDeps({ unpinAll })
125
+ const sweeper = createDmPinSweeper(deps)
126
+ const p1 = sweeper.sweep(DM_A)
127
+ const p2 = sweeper.sweep(DM_A)
128
+ resolveUnpin()
129
+ await Promise.all([p1, p2])
130
+ expect(unpinAll).toHaveBeenCalledTimes(1)
131
+ })
132
+
133
+ it('429 / unpin-all rejects: degrades without throwing, still re-pins live ids', async () => {
134
+ const unpinAll = vi.fn(async () => {
135
+ throw new Error('429: Too Many Requests: retry after 30')
136
+ })
137
+ const { deps, pinSilent } = makeDeps({
138
+ unpinAll,
139
+ liveTrackedMessageIds: () => [333],
140
+ })
141
+ const sweeper = createDmPinSweeper(deps)
142
+ // Must not reject.
143
+ await expect(sweeper.sweep(DM_A)).resolves.toBeUndefined()
144
+ expect(unpinAll).toHaveBeenCalledTimes(1)
145
+ expect(pinSilent).toHaveBeenCalledWith(DM_A, 333)
146
+ })
147
+
148
+ it('re-pin failure is absorbed and does not reject', async () => {
149
+ const pinSilent = vi.fn(async () => {
150
+ throw new Error('pin failed')
151
+ })
152
+ const { deps } = makeDeps({
153
+ pinSilent,
154
+ liveTrackedMessageIds: () => [444],
155
+ })
156
+ const sweeper = createDmPinSweeper(deps)
157
+ await expect(sweeper.sweep(DM_A)).resolves.toBeUndefined()
158
+ })
159
+
160
+ it('no unhandled rejection escapes a failing sweep (fire-and-forget safe)', async () => {
161
+ const unhandled: unknown[] = []
162
+ const onUnhandled = (r: unknown): void => {
163
+ unhandled.push(r)
164
+ }
165
+ process.on('unhandledRejection', onUnhandled)
166
+ try {
167
+ const { deps } = makeDeps({
168
+ unpinAll: async () => {
169
+ throw new Error('boom')
170
+ },
171
+ liveTrackedMessageIds: () => [555],
172
+ })
173
+ const sweeper = createDmPinSweeper(deps)
174
+ // Fire-and-forget, as the gateway does.
175
+ void sweeper.sweep(DM_A)
176
+ await new Promise((r) => setTimeout(r, 10))
177
+ } finally {
178
+ process.off('unhandledRejection', onUnhandled)
179
+ }
180
+ expect(unhandled).toEqual([])
181
+ })
182
+ })
183
+
184
+ describe('tool-pin survival (#3001 contract, review blocker on #3074)', () => {
185
+ const NOW = 1_752_000_000_000
186
+
187
+ it('unexpiredStoreRepinIds keeps unexpired tool rows for the chat, drops expired / work-scoped / other-chat rows', () => {
188
+ const rows = [
189
+ // Unexpired tool pin in this chat — MUST survive.
190
+ { chatId: DM_A, messageId: 10, expiresAt: NOW + 60_000 },
191
+ // Expired tool pin — due for sweeping, NOT re-pinned.
192
+ { chatId: DM_A, messageId: 11, expiresAt: NOW - 1 },
193
+ // Work-scoped row (no expiresAt) — stale after restart, NOT re-pinned.
194
+ { chatId: DM_A, messageId: 12 },
195
+ // Unexpired tool pin in a DIFFERENT chat — not this chat's re-pin set.
196
+ { chatId: DM_B, messageId: 13, expiresAt: NOW + 60_000 },
197
+ ]
198
+ expect(unexpiredStoreRepinIds(rows, DM_A, NOW)).toEqual([10])
199
+ expect(unexpiredStoreRepinIds(rows, DM_B, NOW)).toEqual([13])
200
+ })
201
+
202
+ // Composition test mirroring the gateway wiring: liveTrackedMessageIds
203
+ // unions in-memory claims with unexpired store rows.
204
+ function gatewayStyleDeps(args: {
205
+ inMemory: number[]
206
+ storeRows: { chatId: string; messageId: number; expiresAt?: number }[]
207
+ }): ReturnType<typeof makeDeps> {
208
+ return makeDeps({
209
+ liveTrackedMessageIds: (chatId) => [
210
+ ...args.inMemory,
211
+ ...unexpiredStoreRepinIds(args.storeRows, chatId, NOW),
212
+ ],
213
+ })
214
+ }
215
+
216
+ it('(a) boot sweep: DM with an unexpired tool: row → unpinAll once, then that messageId re-pinned', async () => {
217
+ const { deps, unpinAll, pinSilent } = gatewayStyleDeps({
218
+ inMemory: [], // fresh boot — in-memory claims empty
219
+ storeRows: [{ chatId: DM_A, messageId: 777, expiresAt: NOW + 60_000 }],
220
+ })
221
+ const sweeper = createDmPinSweeper(deps)
222
+ await sweeper.sweep(DM_A)
223
+ expect(unpinAll).toHaveBeenCalledTimes(1)
224
+ expect(pinSilent.mock.calls).toEqual([[DM_A, 777]])
225
+ })
226
+
227
+ it('(b) expired tool: row is NOT re-pinned', async () => {
228
+ const { deps, unpinAll, pinSilent } = gatewayStyleDeps({
229
+ inMemory: [],
230
+ storeRows: [{ chatId: DM_A, messageId: 778, expiresAt: NOW - 1 }],
231
+ })
232
+ const sweeper = createDmPinSweeper(deps)
233
+ await sweeper.sweep(DM_A)
234
+ expect(unpinAll).toHaveBeenCalledTimes(1)
235
+ expect(pinSilent).not.toHaveBeenCalled()
236
+ })
237
+
238
+ it('(c) first-inbound sweep preserves an unexpired tool pin alongside a live in-memory claim, deduped', async () => {
239
+ const { deps, pinSilent } = gatewayStyleDeps({
240
+ inMemory: [500, 777], // live fg: claim + the tool pin also claimed in-memory
241
+ storeRows: [{ chatId: DM_A, messageId: 777, expiresAt: NOW + 60_000 }],
242
+ })
243
+ const sweeper = createDmPinSweeper(deps)
244
+ await sweeper.sweep(DM_A)
245
+ // 777 appears in BOTH sources but is re-pinned exactly once.
246
+ expect(pinSilent.mock.calls).toEqual([
247
+ [DM_A, 500],
248
+ [DM_A, 777],
249
+ ])
250
+ })
251
+ })
@@ -0,0 +1,178 @@
1
+ /**
2
+ * DM pin sweep (#3026) — durable clearing of stale bot-authored pins in
3
+ * user DM chats.
4
+ *
5
+ * Why this exists (two independent gaps the probe-based boot sweep can't
6
+ * close for DMs):
7
+ *
8
+ * 1. The boot pin sweep (`shouldSweepChatAtBoot`) deliberately SKIPS
9
+ * positive (DM) chat IDs — the Bot API returns `400 chat not found`
10
+ * for a user who never messaged the bot. So DM chats were never
11
+ * boot-swept at all, and stale pins from a prior session lingered
12
+ * across restarts (observed fleet-wide 2026-07-11).
13
+ *
14
+ * 2. Even when a DM chat IS reachable, `getChat()` exposes ONLY the
15
+ * NEWEST pinned message. Telegram DMs STACK pins and the Bot API has
16
+ * no list-pins method, so older orphan pins are invisible to a
17
+ * probe-based (unpin-one-by-message-id) sweep forever.
18
+ *
19
+ * The durable fix, DM-only: call `unpinAllChatMessages` ONCE per DM chat
20
+ * per gateway boot (clearing the whole stack — safe in a DM, where
21
+ * virtually every pin is bot-authored), then immediately RE-PIN the live
22
+ * tracked cards from the in-memory pin claims (silent, `disable_notification`)
23
+ * so a genuinely in-flight status/worker pin survives the sweep.
24
+ *
25
+ * Groups/supergroups (negative IDs) are NOT touched here — an unpin-all
26
+ * there would nuke human pins. They keep the existing probe-based path.
27
+ *
28
+ * This module is the PURE half (chat-id classification, store→DM-chat-id
29
+ * collection, and a dependency-injected sweeper with the once-per-boot
30
+ * guard). The gateway binds the live Bot API + in-memory claim map.
31
+ */
32
+
33
+ /**
34
+ * True iff `chatId` is a user DM chat: a positive integer. Group and
35
+ * supergroup IDs are negative; zero / non-numeric / non-integer are
36
+ * malformed and never DMs.
37
+ */
38
+ export function isDmChatId(chatId: string): boolean {
39
+ const n = Number(chatId)
40
+ return Number.isFinite(n) && Number.isInteger(n) && n > 0
41
+ }
42
+
43
+ /**
44
+ * Collect the DISTINCT DM chat IDs that appear in ANY of the persisted pin
45
+ * stores — the set of DM chats the gateway has a record of having pinned
46
+ * something in during a prior session. These are the DM chats worth
47
+ * sweeping at boot (an unpin-all is wasted on a DM we never pinned in).
48
+ *
49
+ * Pure over its input records; callers pass the loaded store snapshots
50
+ * (status pins, activity cards, queued/busy-ack cards). Non-DM chat IDs
51
+ * are filtered out.
52
+ */
53
+ export function collectDmChatIdsFromStores(input: {
54
+ statusPins?: readonly { chatId: string }[]
55
+ activityCards?: readonly { chatId: string }[]
56
+ queuedCards?: readonly { chatId: string }[]
57
+ }): string[] {
58
+ const out = new Set<string>()
59
+ const add = (rows?: readonly { chatId: string }[]): void => {
60
+ if (rows == null) return
61
+ for (const r of rows) if (isDmChatId(r.chatId)) out.add(r.chatId)
62
+ }
63
+ add(input.statusPins)
64
+ add(input.activityCards)
65
+ add(input.queuedCards)
66
+ return [...out]
67
+ }
68
+
69
+ /**
70
+ * The messageIds in `chatId` that must SURVIVE a DM unpin-all because they
71
+ * belong to deliberately-retained store rows: unexpired time-scoped `tool:`
72
+ * pins (the `pin_message` MCP tool, #3001), which `runStatusPinBootCleanup`
73
+ * intentionally KEEPS across restarts. Work-scoped rows (no `expiresAt`) are
74
+ * stale by definition after a restart and are NOT re-pin candidates; expired
75
+ * time-scoped rows are due for sweeping.
76
+ *
77
+ * Pure over a loaded store snapshot; the gateway binds a LIVE store read into
78
+ * `liveTrackedMessageIds` so BOTH the boot sweep and a first-inbound sweep
79
+ * see the kept rows (the in-memory claim maps are empty at boot).
80
+ */
81
+ export function unexpiredStoreRepinIds(
82
+ rows: readonly { chatId: string; messageId: number; expiresAt?: number }[],
83
+ chatId: string,
84
+ now: number,
85
+ ): number[] {
86
+ const out: number[] = []
87
+ for (const r of rows) {
88
+ if (r.chatId !== chatId) continue
89
+ if (r.expiresAt != null && r.expiresAt > now) out.push(r.messageId)
90
+ }
91
+ return out
92
+ }
93
+
94
+ export interface DmPinSweeperDeps {
95
+ /** Clear EVERY pinned message in a chat. Bound to the gateway's
96
+ * robust/retry API wrapper so a 429 flood-wait is retried, then
97
+ * degrades (rejects) rather than crashing — the sweeper absorbs it. */
98
+ unpinAll: (chatId: string) => Promise<unknown>
99
+ /** Silently re-pin an EXISTING message (disable_notification). Bound to
100
+ * the gateway's robust/retry API wrapper. */
101
+ pinSilent: (chatId: string, messageId: number) => Promise<unknown>
102
+ /** Pin message ids in this chat that must survive the unpin-all: the
103
+ * in-memory status-pin claims still owned by an in-flight turn/worker,
104
+ * UNIONED with the deliberately-retained store rows (unexpired `tool:`
105
+ * pins, #3001 — see unexpiredStoreRepinIds). The gateway binds both
106
+ * sources; duplicates are deduped by the sweeper before re-pinning. */
107
+ liveTrackedMessageIds: (chatId: string) => number[]
108
+ /** Gate: sweep ONLY when the gateway won the startup mutex. The stores
109
+ * and the chat's pins are shared per-agent state; a LOSING double-boot
110
+ * must never unpin the live holder's legitimate pins. */
111
+ eligible: () => boolean
112
+ /** Optional structured log sink. */
113
+ log?: (line: string) => void
114
+ }
115
+
116
+ export interface DmPinSweeper {
117
+ /**
118
+ * Sweep a single DM chat: unpin-all then re-pin live tracked cards.
119
+ * At most ONCE per chat per sweeper lifetime (one gateway boot). No-op
120
+ * for non-DM chats, when not eligible, or when already swept. Never
121
+ * throws — a failed unpin-all/re-pin degrades and is logged.
122
+ */
123
+ sweep(chatId: string): Promise<void>
124
+ /** True if this chat has already been swept this boot (test/observability). */
125
+ hasSwept(chatId: string): boolean
126
+ }
127
+
128
+ export function createDmPinSweeper(deps: DmPinSweeperDeps): DmPinSweeper {
129
+ const swept = new Set<string>()
130
+ return {
131
+ hasSwept: (chatId) => swept.has(chatId),
132
+ async sweep(chatId: string): Promise<void> {
133
+ if (!isDmChatId(chatId)) return
134
+ if (!deps.eligible()) return
135
+ if (swept.has(chatId)) return
136
+ // Claim the once-guard BEFORE the first await so two concurrent
137
+ // triggers (boot + first-inbound landing in the same tick) can't
138
+ // both fire the unpin-all.
139
+ swept.add(chatId)
140
+
141
+ // Snapshot live claims BEFORE the unpin-all so we know exactly which
142
+ // messages to restore. Dedupe — the in-memory and store-row sources
143
+ // can both report the same messageId.
144
+ const liveIds = [...new Set(deps.liveTrackedMessageIds(chatId))]
145
+
146
+ try {
147
+ await deps.unpinAll(chatId)
148
+ } catch (err) {
149
+ const msg = err instanceof Error ? err.message : String(err)
150
+ deps.log?.(
151
+ `telegram gateway: dm-pin-sweep: unpin-all failed (chat=${chatId}): ${msg}\n`,
152
+ )
153
+ // Degrade: the stack may still hold stale pins, but we don't
154
+ // crash and we don't retry this boot. Fall through to re-pin the
155
+ // live claims (harmless if the unpin didn't land).
156
+ }
157
+
158
+ for (const messageId of liveIds) {
159
+ try {
160
+ await deps.pinSilent(chatId, messageId)
161
+ } catch (err) {
162
+ const msg = err instanceof Error ? err.message : String(err)
163
+ deps.log?.(
164
+ `telegram gateway: dm-pin-sweep: re-pin failed ` +
165
+ `(chat=${chatId} msg=${messageId}): ${msg}\n`,
166
+ )
167
+ }
168
+ }
169
+
170
+ if (liveIds.length > 0) {
171
+ deps.log?.(
172
+ `telegram gateway: dm-pin-sweep: cleared stacked pins in DM ${chatId}, ` +
173
+ `re-pinned ${liveIds.length} live card(s)\n`,
174
+ )
175
+ }
176
+ },
177
+ }
178
+ }