switchroom 0.18.17 → 0.18.19

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 (67) hide show
  1. package/dist/agent-scheduler/index.js +13 -0
  2. package/dist/auth-broker/index.js +13 -0
  3. package/dist/cli/notion-write-pretool.mjs +13 -0
  4. package/dist/cli/switchroom.js +605 -479
  5. package/dist/host-control/main.js +17 -1
  6. package/dist/vault/approvals/kernel-server.js +13 -0
  7. package/dist/vault/broker/server.js +13 -0
  8. package/package.json +1 -1
  9. package/telegram-plugin/bridge/bridge.ts +7 -1
  10. package/telegram-plugin/dist/bridge/bridge.js +26 -1
  11. package/telegram-plugin/dist/gateway/gateway.js +1544 -619
  12. package/telegram-plugin/dist/server.js +32 -1
  13. package/telegram-plugin/fleet-fallback-resume.ts +26 -3
  14. package/telegram-plugin/format.ts +137 -213
  15. package/telegram-plugin/gateway/approval-hold.ts +49 -0
  16. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
  17. package/telegram-plugin/gateway/gateway.ts +399 -85
  18. package/telegram-plugin/gateway/linear-activity.ts +20 -4
  19. package/telegram-plugin/gateway/outbound-send-path.ts +9 -7
  20. package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
  21. package/telegram-plugin/gateway/session-model-file.ts +103 -0
  22. package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
  23. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
  24. package/telegram-plugin/llm-error-present.ts +474 -0
  25. package/telegram-plugin/operator-events.ts +7 -1
  26. package/telegram-plugin/permission-title.ts +172 -10
  27. package/telegram-plugin/premium-recovery.ts +101 -0
  28. package/telegram-plugin/raw-error-scrub.ts +73 -0
  29. package/telegram-plugin/retry-api-call.ts +8 -2
  30. package/telegram-plugin/send-gate-degraded.test.ts +152 -1
  31. package/telegram-plugin/send-gate-observability.test.ts +140 -0
  32. package/telegram-plugin/send-gate-observability.ts +65 -20
  33. package/telegram-plugin/send-gate.test.ts +143 -1
  34. package/telegram-plugin/send-gate.ts +212 -19
  35. package/telegram-plugin/session-tail.ts +16 -0
  36. package/telegram-plugin/shared/local-time.ts +69 -0
  37. package/telegram-plugin/stream-reply-handler.ts +5 -14
  38. package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
  39. package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
  40. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
  41. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
  42. package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
  43. package/telegram-plugin/tests/format-consistency.test.ts +68 -53
  44. package/telegram-plugin/tests/formatting-parse-regression.test.ts +5 -6
  45. package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
  46. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
  47. package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
  48. package/telegram-plugin/tests/llm-error-present.test.ts +481 -0
  49. package/telegram-plugin/tests/outbound-send-path.test.ts +4 -3
  50. package/telegram-plugin/tests/paragraph-normalizer.test.ts +42 -100
  51. package/telegram-plugin/tests/permission-title.test.ts +167 -4
  52. package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
  53. package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
  54. package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
  55. package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
  56. package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -12
  57. package/telegram-plugin/tests/telegram-format.test.ts +86 -31
  58. package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
  59. package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
  60. package/telegram-plugin/tests/turn-flush-safety.test.ts +17 -21
  61. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
  62. package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
  63. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
  64. package/telegram-plugin/tier-downgrade.ts +198 -0
  65. package/telegram-plugin/tool-activity-summary.ts +99 -0
  66. package/telegram-plugin/turn-flush-safety.ts +4 -3
  67. package/telegram-plugin/worker-activity-feed.ts +509 -409
@@ -0,0 +1,474 @@
1
+ /**
2
+ * llm-error-present.ts — humanized, cross-surface-deduped presentation of an
3
+ * LLM/API error, so a raw `b'{"type":"error",…}'` line never reaches a user.
4
+ *
5
+ * THE PROBLEM this closes
6
+ * -----------------------
7
+ * One Anthropic error JSONL line fans out to THREE independent render surfaces,
8
+ * none consulting each other, each historically printing the raw `detail`
9
+ * string with its `b'{…}'` byte-blob attached:
10
+ * 1. the agent turn-end "done" card (tool-activity-summary result block),
11
+ * 2. the operator-event "🚦 Rate limited" card (raw `detail`), and
12
+ * 3. the reply/answer passthrough (the synthetic-assistant error text relayed
13
+ * as the turn's answer).
14
+ *
15
+ * This module owns the ONE clean rendering + the ONE dedup authority the three
16
+ * surfaces consult, so a fan-out produces EXACTLY ONE user-facing message with
17
+ * NO raw JSON. It reuses the existing classifiers rather than re-deriving them:
18
+ * `classify429Detail` (throttle-tier.ts), `detectModelUnavailable` /
19
+ * `parseResetTime` (model-unavailable.ts), `isLitellmProxyLocal429` /
20
+ * `isTransientUpstreamSignal` (model-unavailable.ts), `classifyClaudeError`
21
+ * (operator-events.ts). `coreText` is ALWAYS built from a per-kind template —
22
+ * never from the raw `detail` — and `stripRawErrorBytes` is the belt-and-braces
23
+ * scrub so even the enrichment `reason` can carry no JSON.
24
+ *
25
+ * Pure module: no IPC, no bot, no FS. The only mutable state is the
26
+ * `ErrorPresenceGate` singleton (an in-memory dedup ledger), driven by an
27
+ * injectable `now`.
28
+ */
29
+
30
+ import {
31
+ detectModelUnavailable,
32
+ isLitellmProxyLocal429,
33
+ parseResetTime,
34
+ } from './model-unavailable.js'
35
+ import { classify429Detail } from './throttle-tier.js'
36
+ import { classifyClaudeError } from './operator-events.js'
37
+ import { stripRawErrorBytes, extractRequestId } from './raw-error-scrub.js'
38
+ import { fmtLocalClock, tzAbbrev } from './shared/local-time.js'
39
+
40
+ export { stripRawErrorBytes, extractRequestId } from './raw-error-scrub.js'
41
+
42
+ // ─── Types ──────────────────────────────────────────────────────────────────
43
+
44
+ export type LlmErrorKind =
45
+ | 'rate_limit'
46
+ | 'overload_529'
47
+ | 'quota_wall'
48
+ | 'auth'
49
+ | 'transient'
50
+ | 'unknown'
51
+
52
+ export type LlmErrorSource = 'anthropic' | 'litellm-local' | 'network'
53
+
54
+ export interface ParsedLlmError {
55
+ kind: LlmErrorKind
56
+ /** Human, JSON-STRIPPED one-liner built from a per-kind template. Never raw. */
57
+ coreText: string
58
+ /** Parsed reset instant, when the source carried one. */
59
+ resetAt?: Date
60
+ /** Parsed retry-after window in ms, when the source carried one. */
61
+ retryAfterMs?: number
62
+ /** Resolved model id, when the source named one. */
63
+ model?: string
64
+ /** Anthropic `request_id`, when present — the strongest dedup key. */
65
+ requestId?: string
66
+ source: LlmErrorSource
67
+ /** True when the harness is still retrying this error internally (mid-retry). */
68
+ autoRetrying: boolean
69
+ /** True when the failure is final (NOT an in-flight retry). */
70
+ terminal: boolean
71
+ }
72
+
73
+ /** Optional retry-state annotations Claude Code stamps on a retried error line. */
74
+ export interface LlmErrorRetryState {
75
+ retryAttempt: number | null
76
+ maxRetries: number | null
77
+ }
78
+
79
+ // ─── model extraction ────────────────────────────────────────────────────────
80
+
81
+ /** Pull a resolved model id (`claude-…`, `sr-…`) out of a raw string, or undefined. */
82
+ function extractModel(raw: string): string | undefined {
83
+ const m = raw.match(/["']?model["']?\s*[=:]\s*["']?((?:claude|sr)[A-Za-z0-9._-]+)/i)
84
+ return m ? m[1] : undefined
85
+ }
86
+
87
+ // ─── Parser ──────────────────────────────────────────────────────────────────
88
+
89
+ const TRANSIENT_KINDS: ReadonlySet<LlmErrorKind> = new Set<LlmErrorKind>([
90
+ 'rate_limit',
91
+ 'overload_529',
92
+ 'transient',
93
+ ])
94
+
95
+ /** The always-actionable kinds — NEVER silenced, even inside a collapse window. */
96
+ export function isActionableKind(kind: LlmErrorKind): boolean {
97
+ return kind === 'auth' || kind === 'quota_wall'
98
+ }
99
+
100
+ /**
101
+ * Parse a raw error string (a session-tail `detail`, an isApiErrorMessage
102
+ * text, or a raw stderr line) into a structured, JSON-free ParsedLlmError.
103
+ * Reuses the existing wording classifiers; maps their kinds into this union.
104
+ */
105
+ export function parseLlmError(
106
+ raw: string,
107
+ retryState?: LlmErrorRetryState,
108
+ ): ParsedLlmError {
109
+ const text = typeof raw === 'string' ? raw : ''
110
+ const requestId = extractRequestId(text)
111
+ const model = extractModel(text)
112
+
113
+ // Reset / retry-after best-effort (Anthropic + relative wordings).
114
+ const resetAt = parseResetTime(text)
115
+ const retryAfterMs =
116
+ resetAt != null ? Math.max(0, resetAt.getTime() - Date.now()) : undefined
117
+
118
+ const { kind, source } = classifyKindAndSource(text)
119
+
120
+ // Retry / terminal semantics. Only the transient family can be mid-retry;
121
+ // auth / quota_wall / model_unavailable are terminal by construction.
122
+ let autoRetrying = false
123
+ let terminal = true
124
+ if (TRANSIENT_KINDS.has(kind)) {
125
+ const { retryAttempt, maxRetries } = retryState ?? { retryAttempt: null, maxRetries: null }
126
+ if (retryAttempt != null && maxRetries != null) {
127
+ autoRetrying = retryAttempt < maxRetries
128
+ terminal = retryAttempt >= maxRetries
129
+ } else {
130
+ // No retry annotation: treat as a surfaced (terminal) failure — Claude
131
+ // writes the user-facing error shape only after its own retries are done.
132
+ autoRetrying = false
133
+ terminal = true
134
+ }
135
+ }
136
+
137
+ return {
138
+ kind,
139
+ coreText: buildCoreText(kind, source),
140
+ ...(resetAt != null ? { resetAt } : {}),
141
+ ...(retryAfterMs != null ? { retryAfterMs } : {}),
142
+ ...(model != null ? { model } : {}),
143
+ ...(requestId != null ? { requestId } : {}),
144
+ source,
145
+ autoRetrying,
146
+ terminal,
147
+ }
148
+ }
149
+
150
+ function classifyKindAndSource(text: string): { kind: LlmErrorKind; source: LlmErrorSource } {
151
+ const lower = text.toLowerCase()
152
+
153
+ // 1. Auth — always terminal, always actionable.
154
+ const claudeKind = classifyClaudeError({ message: text, type: text })
155
+ if (claudeKind === 'credentials-expired' || claudeKind === 'credentials-invalid') {
156
+ return { kind: 'auth', source: 'anthropic' }
157
+ }
158
+
159
+ // 2. LiteLLM-proxy-LOCAL 429 — the proxy's own limiter, never Anthropic.
160
+ // Checked before the generic quota/overload matchers because some LiteLLM
161
+ // bodies contain the word "limit" that a quota matcher could seize on.
162
+ if (isLitellmProxyLocal429(text)) {
163
+ return { kind: 'rate_limit', source: 'litellm-local' }
164
+ }
165
+
166
+ // 3. Model-unavailable detector owns quota-wall / overload / network wording.
167
+ const mu = detectModelUnavailable(text)
168
+ if (mu != null) {
169
+ if (mu.kind === 'quota_exhausted') return { kind: 'quota_wall', source: 'anthropic' }
170
+ if (mu.kind === 'network') return { kind: 'transient', source: 'network' }
171
+ // mu.kind === 'overload' | 'rate_limited' — split 529 overload from a 429.
172
+ if (
173
+ lower.includes('529') ||
174
+ lower.includes('overloaded')
175
+ ) {
176
+ return { kind: 'overload_529', source: 'anthropic' }
177
+ }
178
+ // A 429-family transient. Three-way classify picks the source nuance; all
179
+ // land on the calm rate_limit kind here.
180
+ const c = classify429Detail(text)
181
+ return {
182
+ kind: 'rate_limit',
183
+ source: c === 'litellm-local' ? 'litellm-local' : 'anthropic',
184
+ }
185
+ }
186
+
187
+ // 4. Credit balance is a quota-family wall (actionable).
188
+ if (claudeKind === 'credit-exhausted') {
189
+ return { kind: 'quota_wall', source: 'anthropic' }
190
+ }
191
+
192
+ // 5. Bare rate-limit classification from the operator taxonomy.
193
+ if (claudeKind === 'rate-limited') {
194
+ return { kind: 'rate_limit', source: 'anthropic' }
195
+ }
196
+ if (claudeKind === 'unknown-5xx') {
197
+ return { kind: 'overload_529', source: 'anthropic' }
198
+ }
199
+
200
+ return { kind: 'unknown', source: 'anthropic' }
201
+ }
202
+
203
+ function buildCoreText(kind: LlmErrorKind, source: LlmErrorSource): string {
204
+ switch (kind) {
205
+ case 'rate_limit':
206
+ return source === 'litellm-local'
207
+ ? 'Hit the local proxy rate limit — retrying automatically.'
208
+ : 'Rate limited by Anthropic — retrying automatically.'
209
+ case 'overload_529':
210
+ return 'Anthropic is overloaded (529) — retrying automatically.'
211
+ case 'quota_wall':
212
+ return 'Usage limit reached on this Claude subscription.'
213
+ case 'auth':
214
+ return 'Claude login needs re-authentication.'
215
+ case 'transient':
216
+ return source === 'network'
217
+ ? "Couldn't reach Anthropic (network) — retrying automatically."
218
+ : 'A temporary upstream hiccup — retrying automatically.'
219
+ case 'unknown':
220
+ return 'The model returned an error.'
221
+ }
222
+ }
223
+
224
+ // ─── Rendering ───────────────────────────────────────────────────────────────
225
+
226
+ export interface RenderedLlmError {
227
+ text: string
228
+ }
229
+
230
+ /**
231
+ * Format a reset instant in the operator's local tz plus a relative tail, e.g.
232
+ * `clears ~4:52pm AEST (~in 38m)`. Returns '' when there is no reset to show.
233
+ */
234
+ export function formatResetLocal(resetAt: Date | undefined, tz: string, now: Date = new Date()): string {
235
+ if (resetAt == null) return ''
236
+ const ms = resetAt.getTime()
237
+ if (!Number.isFinite(ms)) return ''
238
+ const clock = fmtLocalClock(ms, tz)
239
+ const abbrev = tzAbbrev(ms, tz)
240
+ const rel = formatRelativeTail(ms - now.getTime())
241
+ return rel ? `clears ~${clock} ${abbrev} (${rel})` : `clears ~${clock} ${abbrev}`
242
+ }
243
+
244
+ function formatRelativeTail(deltaMs: number): string {
245
+ if (deltaMs <= 0) return '~now'
246
+ const totalMin = Math.round(deltaMs / 60_000)
247
+ if (totalMin < 1) return '~in <1m'
248
+ if (totalMin < 60) return `~in ${totalMin}m`
249
+ const hours = Math.floor(totalMin / 60)
250
+ const mins = totalMin % 60
251
+ if (hours < 24) return mins > 0 ? `~in ${hours}h ${mins}m` : `~in ${hours}h`
252
+ const days = Math.floor(hours / 24)
253
+ const remH = hours % 24
254
+ return remH > 0 ? `~in ${days}d ${remH}h` : `~in ${days}d`
255
+ }
256
+
257
+ /**
258
+ * A short local-tz clock+abbrev for a reset instant (`4:52pm AEST`), or '' when
259
+ * there is no finite reset to name. Used to embed the reset in the recommendation
260
+ * line. Shares the same tz-formatting primitives as {@link formatResetLocal}, so
261
+ * an invalid IANA tz throws here too (guarded by {@link renderLlmErrorSafe}).
262
+ */
263
+ function formatResetClock(resetAt: Date | undefined, tz: string): string {
264
+ if (resetAt == null) return ''
265
+ const ms = resetAt.getTime()
266
+ if (!Number.isFinite(ms)) return ''
267
+ return `${fmtLocalClock(ms, tz)} ${tzAbbrev(ms, tz)}`
268
+ }
269
+
270
+ /**
271
+ * The plain-text "what to DO" line for the actionable error classes. Replaces
272
+ * the former dead action buttons (auth → Reauth, quota_wall → Wait): those
273
+ * inline_keyboard callbacks were only ever exercised in tests — the gateway
274
+ * wiring routes renderLlmError solely for the transient rate_limit/overload_529
275
+ * kinds, so the auth/quota buttons were unreachable in production (Ken, CPO,
276
+ * 2026-07: drop the buttons, recommend in text). Transient kinds return undefined
277
+ * — their coreText already says "retrying automatically", no operator action.
278
+ */
279
+ function buildRecommendation(parsed: ParsedLlmError, tz: string): string | undefined {
280
+ switch (parsed.kind) {
281
+ case 'auth':
282
+ return '→ Re-authenticate this account to continue.'
283
+ case 'quota_wall': {
284
+ const reset = formatResetClock(parsed.resetAt, tz)
285
+ return reset
286
+ ? `→ Switch to another account, or wait for the quota to reset at ${reset}.`
287
+ : '→ Switch to another account, or wait for the quota to reset.'
288
+ }
289
+ default:
290
+ return undefined
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Render ONE clean card for a parsed LLM error. No action buttons — the
296
+ * actionable kinds (auth, quota_wall) carry a plain-text recommendation line
297
+ * instead (see {@link buildRecommendation}). `agent` is the headline label; `tz`
298
+ * localizes the reset. Throws if `tz` is an invalid IANA zone AND a reset is
299
+ * present (Intl.DateTimeFormat rejects the zone at construction) — call
300
+ * {@link renderLlmErrorSafe} from any crash-sensitive surface.
301
+ */
302
+ export function renderLlmError(
303
+ parsed: ParsedLlmError,
304
+ agent: string,
305
+ tz: string,
306
+ now: Date = new Date(),
307
+ ): RenderedLlmError {
308
+ const safeAgent = escapeAgent(agent)
309
+ const emoji = kindEmoji(parsed.kind)
310
+ const lines: string[] = [`${emoji} ${parsed.coreText} (**${safeAgent}**)`]
311
+
312
+ const resetLine = formatResetLocal(parsed.resetAt, tz, now)
313
+ if (resetLine) lines.push(`_${resetLine}_`)
314
+
315
+ if (parsed.model) lines.push(`_model: ${escapeAgent(parsed.model)}_`)
316
+
317
+ const recommendation = buildRecommendation(parsed, tz)
318
+ if (recommendation) lines.push(recommendation)
319
+
320
+ return { text: lines.join('\n') }
321
+ }
322
+
323
+ /**
324
+ * Crash-guarded wrapper around {@link renderLlmError}. An invalid IANA timezone
325
+ * makes Intl.DateTimeFormat throw a RangeError at construction time — the
326
+ * local-time.ts "never throws" contract does NOT cover construction-time zone
327
+ * validation, so a bad `SWITCHROOM_TIMEZONE`/`TZ` would otherwise crash the
328
+ * operator-event turn. On ANY formatting failure, degrade to a minimal, tz-free
329
+ * safe line built only from the JSON-stripped coreText + agent. Total — never
330
+ * throws.
331
+ */
332
+ export function renderLlmErrorSafe(
333
+ parsed: ParsedLlmError,
334
+ agent: string,
335
+ tz: string,
336
+ now: Date = new Date(),
337
+ ): RenderedLlmError {
338
+ try {
339
+ return renderLlmError(parsed, agent, tz, now)
340
+ } catch {
341
+ const safeAgent = escapeAgent(agent)
342
+ return { text: `${kindEmoji(parsed.kind)} ${parsed.coreText} (**${safeAgent}**)` }
343
+ }
344
+ }
345
+
346
+ function kindEmoji(kind: LlmErrorKind): string {
347
+ switch (kind) {
348
+ case 'rate_limit':
349
+ return '🚦'
350
+ case 'overload_529':
351
+ return '🔥'
352
+ case 'quota_wall':
353
+ return '⚠️'
354
+ case 'auth':
355
+ return '🔑'
356
+ case 'transient':
357
+ return '🌐'
358
+ case 'unknown':
359
+ return '⚠️'
360
+ }
361
+ }
362
+
363
+ /** Minimal markdown-safe agent rendering (mirrors operator-events escapeMarkdown intent). */
364
+ function escapeAgent(s: string): string {
365
+ return s.replace(/([_*`\[\]])/g, '\\$1')
366
+ }
367
+
368
+ // ─── Cross-surface dedup: ErrorPresenceGate ──────────────────────────────────
369
+
370
+ /**
371
+ * How long ONE terminal error owns the "already surfaced" claim across all
372
+ * three surfaces. Distinct from — and additional to — the 5-minute per-kind
373
+ * `shouldEmitOperatorEvent` cooldown (operator-events.ts): that debounces an
374
+ * error STORM on one surface; this collapses ONE error's FAN-OUT across
375
+ * surfaces within a single turn.
376
+ */
377
+ export const ERROR_COLLAPSE_WINDOW_MS = 60_000
378
+
379
+ /**
380
+ * A tiny in-memory dedup ledger. The FIRST surface to `claim(key)` within the
381
+ * collapse window wins (returns true) and renders; every later surface loses
382
+ * (returns false) and suppresses. Key preference: the Anthropic `request_id`
383
+ * when present (exact), else a `${kind}:${agent}:${windowBucket}` coarse key so
384
+ * two distinct-but-unlabelled errors of the same kind within 60s still collapse.
385
+ */
386
+ export class ErrorPresenceGate {
387
+ private readonly claims = new Map<string, number>()
388
+
389
+ /** Build the dedup key for a parsed error + agent. */
390
+ keyFor(parsed: Pick<ParsedLlmError, 'kind' | 'requestId'>, agent: string, now: number): string {
391
+ if (parsed.requestId) return `rid:${parsed.requestId}`
392
+ const bucket = Math.floor(now / ERROR_COLLAPSE_WINDOW_MS)
393
+ return `${parsed.kind}:${agent}:${bucket}`
394
+ }
395
+
396
+ /**
397
+ * Attempt to claim ownership of `key`. Returns true for the first caller
398
+ * within the window, false for every subsequent caller. Expired claims are
399
+ * pruned lazily on each call.
400
+ */
401
+ claim(key: string, now: number = Date.now()): boolean {
402
+ this.prune(now)
403
+ const existing = this.claims.get(key)
404
+ if (existing != null && now - existing < ERROR_COLLAPSE_WINDOW_MS) {
405
+ return false
406
+ }
407
+ this.claims.set(key, now)
408
+ return true
409
+ }
410
+
411
+ /** True when `key` is currently claimed (does NOT claim). */
412
+ isClaimed(key: string, now: number = Date.now()): boolean {
413
+ const existing = this.claims.get(key)
414
+ return existing != null && now - existing < ERROR_COLLAPSE_WINDOW_MS
415
+ }
416
+
417
+ private prune(now: number): void {
418
+ for (const [k, at] of this.claims) {
419
+ if (now - at >= ERROR_COLLAPSE_WINDOW_MS) this.claims.delete(k)
420
+ }
421
+ }
422
+
423
+ /** Test-only: forget every claim. */
424
+ reset(): void {
425
+ this.claims.clear()
426
+ }
427
+ }
428
+
429
+ /** The process-wide gate the three surfaces consult. */
430
+ export const errorPresenceGate = new ErrorPresenceGate()
431
+
432
+ /**
433
+ * The single decision a surface makes before rendering a parsed LLM error.
434
+ * Returns:
435
+ * - 'render' — this surface owns the humanized card, render it.
436
+ * - 'suppress' — another surface already owns it (dedup) OR it is a
437
+ * transient error the harness is still auto-retrying — stay silent.
438
+ *
439
+ * ACTIONABLE kinds (auth / quota_wall) ALWAYS return 'render' — they are never
440
+ * deduped away and never silenced mid-retry, because the operator must always
441
+ * see (and be able to act on) a login/quota wall.
442
+ */
443
+ export function decideErrorSurface(
444
+ parsed: ParsedLlmError,
445
+ agent: string,
446
+ opts: { claim: boolean; now?: number; gate?: ErrorPresenceGate } = { claim: true },
447
+ ): 'render' | 'suppress' {
448
+ const now = opts.now ?? Date.now()
449
+ const gate = opts.gate ?? errorPresenceGate
450
+
451
+ if (isActionableKind(parsed.kind)) {
452
+ // Always render — but still record the claim so a redundant transient
453
+ // surface for the same key stays collapsed.
454
+ if (opts.claim) gate.claim(gate.keyFor(parsed, agent, now), now)
455
+ return 'render'
456
+ }
457
+
458
+ // Auto-retry silence: a transient error still being retried internally is
459
+ // NOT surfaced on ANY surface until it goes terminal. NOTE: in the current
460
+ // production wiring the operator surface reaches here only AFTER session-tail
461
+ // (readNew → `errEvent.terminal || !errEvent.transient`) has already dropped
462
+ // in-flight transients, and the gateway calls `parseLlmError(detail)` with no
463
+ // retryState (the raw retry annotations don't survive the IPC hop) — so a
464
+ // production `parsed.autoRetrying` is always false here. This branch is the
465
+ // module's own guarantee for ANY caller that DOES pass retryState (unit-tested
466
+ // as such); prod silence is owned upstream at session-tail, not re-derived here.
467
+ if (parsed.autoRetrying && !parsed.terminal) return 'suppress'
468
+
469
+ const key = gate.keyFor(parsed, agent, now)
470
+ if (opts.claim) {
471
+ return gate.claim(key, now) ? 'render' : 'suppress'
472
+ }
473
+ return gate.isClaimed(key, now) ? 'suppress' : 'render'
474
+ }
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { escapeMarkdown } from './format.js'
16
+ import { stripRawErrorBytes } from './raw-error-scrub.js'
16
17
 
17
18
  // ─── Taxonomy ────────────────────────────────────────────────────────────────
18
19
 
@@ -216,7 +217,12 @@ export interface RenderResult {
216
217
  */
217
218
  export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
218
219
  const agent = escapeMarkdown(ev.agent)
219
- const detail = escapeMarkdown(ev.detail)
220
+ // #llm-error-surfacing: NEVER let a raw API-error byte-blob (`· b'{…}'`,
221
+ // trailing `{"type":"error"…}` JSON, `API Error:` prefix) reach a user. A
222
+ // clean human detail passes through unchanged; only smuggled raw bytes are
223
+ // stripped. This is the belt-and-braces scrub for every card kind — the
224
+ // rate-limited card in particular used to relay the raw synthetic-error text.
225
+ const detail = escapeMarkdown(stripRawErrorBytes(ev.detail))
220
226
 
221
227
  switch (ev.kind) {
222
228
  case 'credentials-expired':