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
@@ -16,7 +16,8 @@
16
16
  *
17
17
  * | Thrown error | This wrapper does |
18
18
  * |-----------------------------------------------|--------------------------------------------|
19
- * | GrammyError 429 | sleep retry_after seconds, retry |
19
+ * | GrammyError 429 (retry_after <= ceiling) | sleep retry_after seconds, retry |
20
+ * | GrammyError 429 (retry_after > ceiling) | record window, throw `FLOOD_WAIT_ACTIVE` |
20
21
  * | GrammyError 400 "message is not modified" | swallow, return undefined |
21
22
  * | GrammyError 400 "message to edit not found" | swallow, return undefined |
22
23
  * | GrammyError 400 "message to delete not found" | swallow, return undefined |
@@ -43,6 +44,39 @@ export interface RetryCallOpts {
43
44
  * call sites to self-document. Future: surface in retry logs / metrics.
44
45
  */
45
46
  verb?: string
47
+ /**
48
+ * Chat type of the destination (private / group / supergroup / channel).
49
+ * Consumed by the outbound send gate (#3084, send-gate.ts) to key the
50
+ * per-group bucket. Optional and additive — the retry policy ignores it.
51
+ */
52
+ chatType?: 'private' | 'group' | 'supergroup' | 'channel'
53
+ /**
54
+ * For edit calls: the target message id. Enables the send gate's
55
+ * per-message edit floor + last-write-wins coalescing. Retry policy ignores.
56
+ */
57
+ messageId?: number
58
+ /**
59
+ * For edit calls: the rendered payload the send gate hashes to skip no-op
60
+ * edits (identical → dropped) and to coalesce. Retry policy ignores it.
61
+ */
62
+ editPayload?: unknown
63
+ /**
64
+ * Priority class for the outbound send gate's shedding + degraded mode
65
+ * (#3084 PR 2, send-gate.ts). The retry policy itself ignores it; it only
66
+ * governs how the gate treats the call when it is under pressure or a flood
67
+ * window is open:
68
+ * - `critical` — final reply chunks, approval / vault cards, error
69
+ * notices. Never shed; queued unbounded. In degraded mode a critical
70
+ * send waits for a short window but fails fast (structured
71
+ * `FLOOD_WAIT_ACTIVE`) when the remaining window is long.
72
+ * - `useful` — progress-card creation, worker handbacks, checklists,
73
+ * boot/config cards. Queued with a TTL; dropped when stale. DEFAULT
74
+ * when unset.
75
+ * - `cosmetic` — typing, reactions, all card EDITS, stream updates,
76
+ * heartbeats. Shed immediately when no token is free OR any flood
77
+ * window is open.
78
+ */
79
+ priorityClass?: 'critical' | 'useful' | 'cosmetic'
46
80
  }
47
81
 
48
82
  export interface RetryObserver {
@@ -57,6 +91,24 @@ export interface RetryObserver {
57
91
  export interface RetryApiCallConfig {
58
92
  /** Max retries before giving up. Defaults to 3. */
59
93
  maxRetries?: number
94
+ /**
95
+ * Ceiling (ms) on how long a single 429 flood-wait may be slept IN-PROCESS.
96
+ * A `retry_after` above this is not slept at all — the window is recorded and
97
+ * `FLOOD_WAIT_ACTIVE` is thrown instead (see #3084). Defaults to
98
+ * `DEFAULT_MAX_FLOOD_SLEEP_MS`.
99
+ */
100
+ maxFloodSleepMs?: number
101
+ /**
102
+ * Remaining ms of a KNOWN-OPEN per-bot flood window, or 0 when none.
103
+ *
104
+ * Wired to the #2923 circuit-breaker state file (see
105
+ * `makeFloodWaitProbe`). When the remaining window is LONGER than
106
+ * `maxFloodSleepMs`, `retryApiCall` short-circuits BEFORE issuing the call:
107
+ * the ban is per-bot-token, so nothing can succeed while it's open, and
108
+ * every attempt only feeds the flood counter (#3084). Fails OPEN — a
109
+ * missing/corrupt/throwing probe means "no window", never a silenced bot.
110
+ */
111
+ floodWaitRemainingMs?: () => number
60
112
  /** Sleep helper — injected so tests can use fake timers. */
61
113
  sleep?: (ms: number) => Promise<void>
62
114
  /** Optional observer hooks. */
@@ -105,6 +157,107 @@ export function isLocalResourceError(err: unknown): boolean {
105
157
  */
106
158
  export const LOCAL_RESOURCE_EXHAUSTED = 'LOCAL_RESOURCE_EXHAUSTED'
107
159
 
160
+ /**
161
+ * Thrown when every retry is exhausted — a network partition, a Telegram 5xx
162
+ * outage, or a short-but-persistent 429 (only 429s longer than the in-process
163
+ * sleep ceiling raise FLOOD_WAIT_ACTIVE; shorter ones are slept, retried, and
164
+ * end here). Exported as a constant because `approval-hold.ts:holdReasonFor`
165
+ * classifies on it to decide whether an undeliverable approval is HELD — a
166
+ * string that drifts would silently reopen the auto-deny hole.
167
+ */
168
+ export const GIVE_UP_MESSAGE = 'retryApiCall: max retries exceeded'
169
+
170
+ /**
171
+ * Marker error thrown when Telegram's reported `retry_after` exceeds the
172
+ * in-process sleep ceiling (#3084) — i.e. the bot is under a LONG per-bot
173
+ * flood ban, not a momentary rate-limit blip.
174
+ *
175
+ * Carries `{ retryAfterSec, untilTs, original }` so a caller can render a
176
+ * useful degraded state ("rate-limited by Telegram until HH:MM") instead of
177
+ * being parked on an await for hours.
178
+ */
179
+ export const FLOOD_WAIT_ACTIVE = 'FLOOD_WAIT_ACTIVE'
180
+
181
+ /**
182
+ * Shape of the error thrown for an over-ceiling flood-wait.
183
+ *
184
+ * It deliberately carries Telegram's OWN 429 field shape (`error_code: 429`,
185
+ * `parameters.retry_after`) in addition to the friendlier fields. Several
186
+ * best-effort card surfaces duck-type their rate-limit cooldown on exactly
187
+ * those two fields rather than on `instanceof GrammyError`:
188
+ *
189
+ * - `worker-activity-feed.ts` `extractRetryAfterSecs` → `noteRateLimited`
190
+ * - `issues-card.ts` `extractRetryAfterSecs`
191
+ * - `pending-work-progress.ts` (its catch assumes upstream retry_after backoff)
192
+ *
193
+ * All three route through `robustApiCall`, so once retryApiCall stops sleeping
194
+ * a long ban and starts THROWING, they see this marker instead of the raw
195
+ * GrammyError. Without `error_code`/`parameters` they would classify it as a
196
+ * generic 'transient' error, arm NO cooldown, and let their 5-6s heartbeats
197
+ * re-drive a fresh Bot API call into the still-open ban — thousands of requests
198
+ * over a 4.6h window, feeding the very flood counter #2923 exists to starve.
199
+ * Carrying the 429 shape makes the existing cooldown gates fire on the full
200
+ * window with no change to those modules.
201
+ *
202
+ * It remains a plain `Error` (NOT a `GrammyError`), so the `instanceof`-based
203
+ * probes — `isHtmlParseRejectError`, `isMessageTooLongError`,
204
+ * `isPhotoDimensionRejectError` — still correctly do not match it.
205
+ */
206
+ export interface FloodWaitActiveError extends Error {
207
+ /** Telegram's reported retry_after, in seconds. */
208
+ retryAfterSec: number
209
+ /** Epoch ms at which the flood window is expected to expire. */
210
+ untilTs: number
211
+ /** Telegram's 429 code — see the duck-typing note above. */
212
+ error_code: 429
213
+ /** Telegram's 429 payload — see the duck-typing note above. */
214
+ parameters: { retry_after: number }
215
+ /** The originating GrammyError, or null for a pre-call short-circuit. */
216
+ original: unknown
217
+ }
218
+
219
+ /**
220
+ * Build the marker with the full Telegram-429 duck-type shape.
221
+ *
222
+ * Exported so the outbound send gate (#3084 PR 2) can COMPOSE the same
223
+ * structured error for its own degraded-mode fail-fast (a critical send into a
224
+ * long open flood window) rather than duplicating the 429 duck-type shape. One
225
+ * error shape means every downstream cooldown gate + the MCP reply path treat a
226
+ * gate fail-fast identically to a retry-layer flood-wait.
227
+ */
228
+ export function makeFloodWaitActiveError(
229
+ retryAfterSec: number,
230
+ untilTs: number,
231
+ original: unknown,
232
+ ): FloodWaitActiveError {
233
+ return Object.assign(new Error(FLOOD_WAIT_ACTIVE), {
234
+ retryAfterSec,
235
+ untilTs,
236
+ error_code: 429 as const,
237
+ parameters: { retry_after: retryAfterSec },
238
+ original,
239
+ })
240
+ }
241
+
242
+ /** True when `err` is the `FLOOD_WAIT_ACTIVE` marker thrown by `retryApiCall`. */
243
+ export function isFloodWaitActiveError(err: unknown): err is FloodWaitActiveError {
244
+ return err instanceof Error && err.message === FLOOD_WAIT_ACTIVE
245
+ }
246
+
247
+ /**
248
+ * Default ceiling on a single in-process flood-wait sleep: 120s.
249
+ *
250
+ * Why 120s and not 30-60s: the flood-waits this bot has historically ridden out
251
+ * successfully were 28s and 75s — sleeping through those is the RIGHT behaviour
252
+ * and must not regress, so the ceiling has to sit above 75s with headroom. Why
253
+ * not higher: the ban this guards against (#3084, overlord 2026-07-11) reported
254
+ * retry_after = 16739s (~4.6h). A 120s ceiling bounds the worst case in-process
255
+ * block to maxRetries × 120s (6s→6min at the default 3) instead of hours, which
256
+ * is short enough that a caller — and the human waiting on a reply — gets a
257
+ * degraded answer rather than an apparently-wedged agent.
258
+ */
259
+ export const DEFAULT_MAX_FLOOD_SLEEP_MS = 120_000
260
+
108
261
  const DEFAULT_SLEEP = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
109
262
 
110
263
  /**
@@ -120,16 +273,56 @@ export function createRetryApiCall(
120
273
  config: RetryApiCallConfig = {},
121
274
  ): <T>(fn: () => Promise<T>, opts?: RetryCallOpts) => Promise<T> {
122
275
  const maxRetries = config.maxRetries ?? 3
276
+ const maxFloodSleepMs = config.maxFloodSleepMs ?? DEFAULT_MAX_FLOOD_SLEEP_MS
123
277
  const sleep = config.sleep ?? DEFAULT_SLEEP
124
278
  const observer = config.observer
125
279
  const log = config.log
126
280
  const onFloodWait = config.onFloodWait
281
+ const floodWaitRemainingMs = config.floodWaitRemainingMs
282
+
283
+ /**
284
+ * Remaining ms of a known-open flood window. FAILS OPEN: any throw from the
285
+ * probe (unreadable state dir, corrupt JSON, clock weirdness) is treated as
286
+ * "no window" — a broken marker file must never permanently silence the bot.
287
+ */
288
+ function openWindowMs(): number {
289
+ if (!floodWaitRemainingMs) return 0
290
+ try {
291
+ const ms = floodWaitRemainingMs()
292
+ return Number.isFinite(ms) && ms > 0 ? ms : 0
293
+ } catch {
294
+ return 0
295
+ }
296
+ }
127
297
 
128
298
  return async function retryApiCall<T>(
129
299
  fn: () => Promise<T>,
130
300
  opts?: RetryCallOpts,
131
301
  ): Promise<T> {
132
302
  for (let attempt = 0; attempt < maxRetries; attempt++) {
303
+ // #3084 — do not send INTO a known-open long ban. Failing the call fast
304
+ // (rather than sleeping it) is only safe if the caller can't just turn
305
+ // around and re-drive it: the card surfaces re-attempt on a 5-6s
306
+ // heartbeat, which over a 4.6h ban would be thousands of requests into
307
+ // the open window — the exact ban-extension the #2923 breaker exists to
308
+ // prevent. So the policy itself refuses to issue the call while the
309
+ // window is open. The ban is per-bot-TOKEN: nothing can succeed during
310
+ // it, so short-circuiting loses no delivery that would otherwise land.
311
+ //
312
+ // Only LONG windows short-circuit (remaining > the sleep ceiling). A
313
+ // short window is left to the normal path — it may already have expired
314
+ // server-side, and today's sleep-and-retry rides it out.
315
+ const remaining = openWindowMs()
316
+ if (remaining > maxFloodSleepMs) {
317
+ const retryAfterSec = Math.ceil(remaining / 1000)
318
+ log?.(
319
+ `telegram gateway: flood window still open for ${retryAfterSec}s — ` +
320
+ `not issuing the ${opts?.verb ?? 'api-call'} (would feed the ban)\n`,
321
+ )
322
+ const gated = makeFloodWaitActiveError(retryAfterSec, Date.now() + remaining, null)
323
+ observer?.onGiveUp?.({ attempts: attempt + 1, error: gated })
324
+ throw gated
325
+ }
133
326
  try {
134
327
  return await fn()
135
328
  } catch (err) {
@@ -164,6 +357,23 @@ export function createRetryApiCall(
164
357
  } catch {
165
358
  /* best-effort — never let the breaker hook break the retry path */
166
359
  }
360
+ // #3084: a LONG ban must not be slept in-process. On 2026-07-11
361
+ // overlord got retry_after = 16739s (~4.6h); `await sleep(delayMs)`
362
+ // parked the send path — and every caller awaiting it — for hours.
363
+ // That is a wedge primitive, not a retry. Above the ceiling we've
364
+ // already recorded the window (the breaker above suppresses
365
+ // non-essential sends for its duration); now fail FAST with a
366
+ // distinct non-retryable marker so the caller can surface a degraded
367
+ // state. Under the ceiling, behaviour is unchanged: sleep and retry.
368
+ if (delayMs > maxFloodSleepMs) {
369
+ log?.(
370
+ `telegram gateway: 429 flood ban of ${retryAfter}s exceeds the ` +
371
+ `${Math.round(maxFloodSleepMs / 1000)}s in-process sleep ceiling — ` +
372
+ `not sleeping it; surfacing degraded state\n`,
373
+ )
374
+ observer?.onGiveUp?.({ attempts: attempt + 1, error: err })
375
+ throw makeFloodWaitActiveError(retryAfter, Date.now() + delayMs, err)
376
+ }
167
377
  log?.(`telegram gateway: 429 rate limited, waiting ${retryAfter}s\n`)
168
378
  observer?.onRetry?.({ attempt, reason: 'flood_wait', delayMs })
169
379
  await sleep(delayMs)
@@ -227,7 +437,7 @@ export function createRetryApiCall(
227
437
  throw err
228
438
  }
229
439
  }
230
- const giveUpErr = new Error('retryApiCall: max retries exceeded')
440
+ const giveUpErr = new Error(GIVE_UP_MESSAGE)
231
441
  observer?.onGiveUp?.({ attempts: maxRetries, error: giveUpErr })
232
442
  throw giveUpErr
233
443
  }
@@ -307,9 +307,18 @@ export function isDestructiveBashCommand(command: string): boolean {
307
307
  if (/\b(chmod|chown|chgrp)\b[^|;&]*(\s-(-recursive|[a-z]*r[a-z]*)\b)/.test(c)) return true;
308
308
  // redirection clobbering devices or system dirs
309
309
  if (/>\s*\/(dev|etc|boot|sys|proc)\b/.test(c)) return true;
310
- // destructive git
310
+ // destructive git — discards or rewrites history / working-tree state.
311
+ // checkout: `-f`/`--force` (force-overwrite), `git checkout .` / `./`
312
+ // / `./<path>` (path-restore of the whole tree or a subtree) and
313
+ // `checkout … -- <path>` all discard uncommitted work. A plain
314
+ // `git checkout <branch>` (branch switch, reversible) is deliberately
315
+ // NOT flagged. NOTE: a bare single-path discard without `--`
316
+ // (`git checkout src/x.ts`) is syntactically ambiguous with a branch
317
+ // name and is a known uncaught form — not full coverage.
318
+ // stash: `drop`/`clear`/`pop` remove stash state irreversibly
319
+ // (`stash`/`list`/`show`/`apply` keep it and stay unflagged).
311
320
  if (/\bgit\b/.test(c) &&
312
- /(push\b[^|;&]*(--force|-f\b|--force-with-lease)|push\s+[^\s]*\s+\+|reset\s+--hard|clean\s+-[a-z]*[fd]|filter-branch|reflog\s+expire|update-ref\s+-d|branch\s+-d{1,2}\b|checkout\s+--\s|restore\b)/.test(c)) return true;
321
+ /(push\b[^|;&]*(--force|-f\b|--force-with-lease)|push\s+[^\s]*\s+\+|reset\s+--hard|clean\s+-[a-z]*[fd]|filter-branch|reflog\s+expire|update-ref\s+-d|branch\s+-d{1,2}\b|checkout\b[^|;&]*(\s-f\b|\s--force\b|\s--(\s|$)|\s\.(\s|$|\/))|stash\s+(drop|clear|pop)\b|restore\b)/.test(c)) return true;
313
322
  // power / process control
314
323
  if (/(^|\s|;|&&|\|\||\()(shutdown|reboot|halt|poweroff|kill|killall|pkill)\b/.test(c)) return true;
315
324
  if (/(^|\s)init\s+0\b/.test(c)) return true;
@@ -1,13 +1,21 @@
1
1
  /**
2
2
  * Sliding-window chunker for ReDoS-bounded detection.
3
3
  *
4
- * Inputs larger than 32 KB are split into 16 KB windows with 1 KB overlap.
4
+ * Inputs larger than 32 KB are split into 16 KB windows with 8 KB overlap.
5
5
  * Each window is scanned independently; the caller is responsible for
6
6
  * dedupe-by-byte-offset when merging per-window hits back together.
7
7
  *
8
8
  * The overlap exists so a secret that straddles a window boundary is still
9
- * matched by at least one scan (provided the secret is 1 KB, which covers
10
- * every known token format plus typical PEM private keys).
9
+ * fully contained in at least one scan. The guarantee is exact: a secret is
10
+ * only missed if its length EXCEEDS the overlap (if length ≤ OVERLAP, a
11
+ * boundary-straddling secret is wholly inside the next window, which starts
12
+ * OVERLAP bytes before the boundary). The old 1 KB overlap was smaller than
13
+ * a real PEM private key — a 4096-bit RSA key in PEM armor is ~3.2 KB, so a
14
+ * boundary-straddling RSA key in a >32 KB payload slipped through unmasked
15
+ * (2026-07 secret-scrub review, tp-support F2). At 8 KB the overlap clears
16
+ * a 4096-bit RSA PEM (~3.2 KB) with >2x margin and also covers larger EC /
17
+ * certificate blobs. Cost: windows advance by WINDOW_SIZE−OVERLAP = 8 KB,
18
+ * so a big payload is scanned in ~2x as many windows — bounded and cheap.
11
19
  */
12
20
 
13
21
  export interface Window {
@@ -19,7 +27,13 @@ export interface Window {
19
27
 
20
28
  export const CHUNK_THRESHOLD = 32 * 1024
21
29
  export const WINDOW_SIZE = 16 * 1024
22
- export const OVERLAP = 1024
30
+ /**
31
+ * 8 KB. MUST be ≥ the largest secret we need to catch across a window
32
+ * boundary; a 4096-bit RSA private key in PEM armor is ~3.2 KB, so this
33
+ * clears it with >2x headroom. See the module header for the exact
34
+ * "missed only if secret length > OVERLAP" guarantee.
35
+ */
36
+ export const OVERLAP = 8 * 1024
23
37
 
24
38
  export function chunk(text: string): Window[] {
25
39
  if (text.length <= CHUNK_THRESHOLD) {
@@ -11,17 +11,23 @@
11
11
  * PEM blocks, CLI flags)
12
12
  * 3. KEY=VALUE heuristic with Shannon-entropy gate (≥ 4.0)
13
13
  *
14
- * Big inputs (>32 KB) are chunked into 16 KB windows with 1 KB overlap
15
- * (chunker.ts) for ReDoS bounding; we dedupe by byte-offset after.
14
+ * Big inputs (>32 KB) are chunked into 16 KB windows with 8 KB overlap
15
+ * (chunker.ts) for ReDoS bounding; we dedupe by byte-offset after. The
16
+ * overlap must exceed the largest single secret (an 8192-bit RSA PEM is
17
+ * ~6.5 KB) so a boundary-straddling key is never split across windows.
16
18
  *
17
19
  * Nearby test/mock/example/fixture/dummy markers (within 40 chars) demote
18
20
  * a hit to `suppressed: true`. The caller decides what that means (our
19
21
  * convention: suppressed high-confidence → ambiguous, user is asked).
20
22
  *
21
- * Secretlint is integrated as an async supplementary source via
22
- * `detectSecretsAsync`. The sync `detectSecrets` keeps the fast vendored-
23
- * pattern path for callers on the hot path (Telegram message ingest).
24
- * Gitleaks TOML is loaded via `gitleaks-loader.ts`.
23
+ * Detection is vendored-patterns-only and synchronous: every live caller
24
+ * (inbound gate, outbound scrub, `redact.ts`, `pipeline.ts`) runs
25
+ * `detectSecrets`. There is deliberately NO Secretlint/async "safety net"
26
+ * layered underneath an earlier `detectSecretsAsync` + Secretlint wrapper
27
+ * was never wired into any live path (test-only), so it was a false safety
28
+ * net and has been removed (2026-07 secret-scrub review, tp-support F1).
29
+ * Arming a new async scanner belongs in its own validated change, not the
30
+ * scrub-coverage PR. Gitleaks TOML is loaded via `gitleaks-loader.ts`.
25
31
  */
26
32
  import { ALL_PATTERNS } from './patterns.js'
27
33
  import { scanKeyValue, type RawHit } from './kv-scanner.js'
@@ -209,53 +215,3 @@ function dropOverlaps(hits: RawHit[]): RawHit[] {
209
215
  export { maskToken } from './mask.js'
210
216
  export { redactUrls } from './url-redact.js'
211
217
  export { deriveSlug } from './slug.js'
212
- export { detectViaSecretlint } from './secretlint-source.js'
213
-
214
- /**
215
- * Async detection pipeline — runs `detectSecrets` (fast vendored engine)
216
- * and Secretlint in parallel, then merges the results by deduping on
217
- * `[start, end)` byte ranges. If Secretlint and a vendored pattern both
218
- * match the same span, the first one wins (vendored, since it's listed
219
- * first in the merge array below).
220
- *
221
- * Slug collisions are re-resolved on the merged list so the overall
222
- * output has unique `suggested_slug` values.
223
- */
224
- export async function detectSecretsAsync(text: string): Promise<Detection[]> {
225
- if (!text || text.length === 0) return []
226
- const [vendored, viaSecretlint] = await Promise.all([
227
- Promise.resolve(detectSecrets(text)),
228
- // Lazy-import keeps the sync `detectSecrets` path free of Secretlint
229
- // initialization cost; paid once on first async call.
230
- import('./secretlint-source.js').then((m) => m.detectViaSecretlint(text)),
231
- ])
232
-
233
- // Merge with range-based dedupe. On an exact-range tie, prefer the
234
- // higher-confidence detection (else vendored-first). This matters since
235
- // the vendored generic high-entropy fallback emits `ambiguous` — without
236
- // the confidence tie-break it would shadow a Secretlint `high` provider
237
- // hit on the same span and silently downgrade it (mirrors the sync
238
- // dedupeRaw's high-over-ambiguous rule).
239
- const seen = new Map<string, Detection>()
240
- const consider = (d: Detection): void => {
241
- const key = `${d.start}:${d.end}`
242
- const existing = seen.get(key)
243
- if (!existing || (existing.confidence === 'ambiguous' && d.confidence === 'high')) {
244
- seen.set(key, d)
245
- }
246
- }
247
- for (const d of vendored) consider(d)
248
- for (const d of viaSecretlint) consider(d)
249
-
250
- // Re-derive slugs against the merged set (Secretlint and vendored each
251
- // had independent `existing` sets; we coalesce here).
252
- const existing = new Set<string>()
253
- const out: Detection[] = Array.from(seen.values())
254
- .sort((a, b) => a.start - b.start)
255
- .map((d) => {
256
- const slug = deriveSlug({ key_name: d.key_name, rule_id: d.rule_id }, existing)
257
- existing.add(slug)
258
- return { ...d, suggested_slug: slug }
259
- })
260
- return out
261
- }