switchroom 0.19.48 → 0.20.1

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 (60) hide show
  1. package/bin/handoff-briefing.sh +213 -74
  2. package/dist/agent-scheduler/index.js +18 -1
  3. package/dist/auth-broker/index.js +19 -2
  4. package/dist/buzz-gateway/index.js +9367 -0
  5. package/dist/cli/notion-write-pretool.mjs +18 -1
  6. package/dist/cli/switchroom.js +24734 -16371
  7. package/dist/host-control/main.js +59 -9
  8. package/dist/vault/approvals/kernel-server.js +19 -2
  9. package/dist/vault/broker/server.js +19 -2
  10. package/package.json +6 -4
  11. package/profiles/_base/start.sh.hbs +148 -2
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/dev-protocol/SKILL.md +30 -1
  14. package/skills/switchroom-architecture/SKILL.md +5 -0
  15. package/skills/switchroom-cli/SKILL.md +1 -1
  16. package/telegram-plugin/dist/bridge/bridge.js +7 -4
  17. package/telegram-plugin/dist/gateway/gateway.js +2376 -1039
  18. package/telegram-plugin/dist/server.js +7 -4
  19. package/telegram-plugin/gateway/access-store.test.ts +234 -0
  20. package/telegram-plugin/gateway/access-store.ts +194 -0
  21. package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
  22. package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
  23. package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
  24. package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
  25. package/telegram-plugin/gateway/buzz-mirror.ts +494 -0
  26. package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
  27. package/telegram-plugin/gateway/channel-route.ts +272 -0
  28. package/telegram-plugin/gateway/gateway.ts +115 -203
  29. package/telegram-plugin/gateway/inbound-router.ts +93 -3
  30. package/telegram-plugin/gateway/inbound-spool.ts +33 -1
  31. package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
  32. package/telegram-plugin/gateway/ipc-server.ts +197 -2
  33. package/telegram-plugin/gateway/outbound-send-path.ts +85 -2
  34. package/telegram-plugin/gateway/pending-turn-env.ts +70 -0
  35. package/telegram-plugin/gateway/stream-render.ts +21 -0
  36. package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
  37. package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
  38. package/telegram-plugin/history.ts +15 -0
  39. package/telegram-plugin/llm-error-present.ts +9 -4
  40. package/telegram-plugin/model-unavailable.ts +4 -0
  41. package/telegram-plugin/operator-events.fixtures.json +12 -12
  42. package/telegram-plugin/operator-events.ts +81 -9
  43. package/telegram-plugin/session-tail.ts +7 -1
  44. package/telegram-plugin/tests/boot-briefing-builder.test.ts +995 -0
  45. package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
  46. package/telegram-plugin/tests/buzz-mirror.test.ts +538 -0
  47. package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
  48. package/telegram-plugin/tests/channel-route.test.ts +306 -0
  49. package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
  50. package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
  51. package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
  52. package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
  53. package/telegram-plugin/tests/operator-events.test.ts +71 -7
  54. package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
  55. package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
  56. package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
  57. package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
  58. package/telegram-plugin/voice-normalize-text.ts +5 -0
  59. package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
  60. package/vendor/hindsight-memory/scripts/recall.py +7 -2
@@ -0,0 +1,172 @@
1
+ /**
2
+ * user-failure-notices.ts — gateway-side handling of the plain-language
3
+ * "couldn't complete that" user notice, plus the `transport-transient`
4
+ * classifier kind (a mid-response stream abort reaching Anthropic).
5
+ *
6
+ * Two concerns, one subsystem (the plain user-failure-notice send side):
7
+ *
8
+ * 1. `emitTransportTransientEvent` — the calm path for a `transport-transient`
9
+ * operator event. Claude Code emits `error: "server_error"` with NO HTTP
10
+ * status on a mid-response stream abort; that is a transport failure, not an
11
+ * account/auth/quota fault, and Claude retries it internally. So there is NO
12
+ * broadcast card (a Reauth card would be the wrong remedy and reach every
13
+ * chat). Instead: record for `/status` history, and DEFER one plain user
14
+ * notice to the turn-end gate (`pending-user-notice.ts`) so a genuinely
15
+ * reply-less dead turn still gets one calm line while a recovered retry
16
+ * sends nothing. A BURST — >=3 terminal transport-transient events for one
17
+ * agent within ~10min — escalates to exactly ONE operator-only, Dismiss-only
18
+ * card ("repeated stream failures reaching Anthropic"), so honest
19
+ * suppression never hides a genuinely degraded pipe.
20
+ *
21
+ * 2. `flushDeferredUserNotices` — the turn-end send loop for notices the gate
22
+ * released (moved here from the gateway; see #3293 finding 1).
23
+ *
24
+ * Pure orchestration: every side effect (record, schedule, resolve, send, log,
25
+ * clock) crosses in through `UserFailureNoticeDeps`, so the outcomes are
26
+ * unit-testable with fakes and no gateway/grammy/IPC coupling leaks in.
27
+ */
28
+
29
+ import { escapeMarkdown } from '../format.js'
30
+ import type { InlineKeyboardMarkup, OperatorEvent } from '../operator-events.js'
31
+ import type { PendingUserNotice } from '../pending-user-notice.js'
32
+
33
+ // ─── transport-transient escalation counter (pure) ───────────────────────────
34
+
35
+ const ESCALATION_WINDOW_MS = 10 * 60_000
36
+ const ESCALATION_THRESHOLD = 3
37
+
38
+ /** Per-agent sliding window of terminal transport-transient event timestamps. */
39
+ const transportEscalation = new Map<string, number[]>()
40
+
41
+ /**
42
+ * Record one terminal transport-transient event for `agent` at `now` and decide
43
+ * whether it crosses the escalation threshold. Returns `true` at most once per
44
+ * burst: crossing the threshold RESETS the window so a sustained outage emits
45
+ * one operator card per ~`windowMs`, not one per event.
46
+ */
47
+ export function noteTransportTransientAndShouldEscalate(
48
+ agent: string,
49
+ now: number,
50
+ opts?: { windowMs?: number; threshold?: number },
51
+ ): boolean {
52
+ const windowMs = opts?.windowMs ?? ESCALATION_WINDOW_MS
53
+ const threshold = opts?.threshold ?? ESCALATION_THRESHOLD
54
+ const recent = (transportEscalation.get(agent) ?? []).filter((t) => now - t < windowMs)
55
+ recent.push(now)
56
+ if (recent.length >= threshold) {
57
+ transportEscalation.set(agent, [])
58
+ return true
59
+ }
60
+ transportEscalation.set(agent, recent)
61
+ return false
62
+ }
63
+
64
+ /** Test/reauth-recovery helper: forget the escalation window (all or one agent). */
65
+ export function resetTransportTransientEscalation(agent?: string): void {
66
+ if (agent == null) transportEscalation.clear()
67
+ else transportEscalation.delete(agent)
68
+ }
69
+
70
+ /**
71
+ * The ONE operator-only card a transport-transient BURST escalates to. Dismiss-
72
+ * only — no Reauth (a dropped stream is not a credential fault), no failover
73
+ * (every account hits the same degraded pipe).
74
+ */
75
+ export function renderTransportEscalationCard(agent: string): {
76
+ text: string
77
+ keyboard: InlineKeyboardMarkup
78
+ } {
79
+ const a = escapeMarkdown(agent)
80
+ return {
81
+ text: [
82
+ `🔌 **Repeated stream failures** reaching Anthropic for **${a}**.`,
83
+ `3+ mid-response aborts within ~10 min. Turns retry automatically; if this persists, Anthropic's API may be degraded.`,
84
+ ].join('\n'),
85
+ keyboard: {
86
+ inline_keyboard: [
87
+ [{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(agent)}` }],
88
+ ],
89
+ },
90
+ }
91
+ }
92
+
93
+ // ─── Deps + orchestration ────────────────────────────────────────────────────
94
+
95
+ export interface UserFailureNoticeDeps {
96
+ /** Injectable clock. */
97
+ now(): number
98
+ /** Allowlist chats (broadcast audience). */
99
+ allowFrom(): readonly string[]
100
+ /** Topic key of the live turn, or undefined (agent-level / between turns). */
101
+ liveTurnKey(): string | undefined
102
+ /** Persist to /status history (best-effort — must swallow its own errors). */
103
+ record(event: OperatorEvent): void
104
+ /** Schedule the turn-end-gated plain user notice for `chatIds`. */
105
+ scheduleUserNotice(input: {
106
+ chatIds: string[]
107
+ agent: string
108
+ kind: string
109
+ key: string | undefined
110
+ atMs: number
111
+ }): void
112
+ /** Release the notices resolved by this turn end (may be empty). */
113
+ resolveNotices(turnDeliveredReply: boolean, turnKey: string): PendingUserNotice[]
114
+ /** Topic-aware raw send (fire-and-forget). `keyboard` omitted → plain message. */
115
+ send(chatId: string, text: string, keyboard?: InlineKeyboardMarkup): void
116
+ /** stderr line (the gateway prefixes "telegram gateway: "). */
117
+ log(msg: string): void
118
+ }
119
+
120
+ /**
121
+ * Handle a `transport-transient` operator event: record it, defer the plain
122
+ * user notice to turn-end, and — on a burst — post exactly one operator-only
123
+ * escalation card. Never sends a broadcast card.
124
+ */
125
+ export function emitTransportTransientEvent(
126
+ event: OperatorEvent,
127
+ deps: UserFailureNoticeDeps,
128
+ ): void {
129
+ const now = deps.now()
130
+ deps.record(event)
131
+ const escalate = noteTransportTransientAndShouldEscalate(event.agent, now)
132
+ deps.log(
133
+ `transport-transient agent=${event.agent} escalate=${escalate} ` +
134
+ `(no broadcast card; user-notice deferred to turn-end)`,
135
+ )
136
+ const allowFrom = deps.allowFrom()
137
+ if (allowFrom.length === 0) return
138
+ deps.scheduleUserNotice({
139
+ chatIds: [...allowFrom],
140
+ agent: event.agent,
141
+ kind: event.kind,
142
+ key: deps.liveTurnKey(),
143
+ atMs: now,
144
+ })
145
+ if (escalate) {
146
+ const card = renderTransportEscalationCard(event.agent)
147
+ deps.send(allowFrom[0], card.text, card.keyboard)
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Turn-end resolution of deferred user failure notices (#3293 finding 1, moved
153
+ * out of gateway.ts). `turnDeliveredReply` is `finalAnswerDelivered ||
154
+ * replyCalled` — a delivered reply DROPS the notices (turn recovered); a
155
+ * reply-less end flushes them (turn genuinely died). `turnKey` scopes resolution
156
+ * to the ending turn's topic under keyed liveness.
157
+ */
158
+ export function flushDeferredUserNotices(
159
+ turnDeliveredReply: boolean,
160
+ turnKey: string,
161
+ deps: UserFailureNoticeDeps,
162
+ ): void {
163
+ const notices = deps.resolveNotices(turnDeliveredReply, turnKey)
164
+ if (notices.length === 0) return
165
+ for (const notice of notices) {
166
+ deps.log(
167
+ `user-notice flush (turn died reply-less) agent=${notice.agent} ` +
168
+ `kind=${notice.kind} chats=${notice.chatIds.length}`,
169
+ )
170
+ for (const chatId of notice.chatIds) deps.send(chatId, notice.text)
171
+ }
172
+ }
@@ -373,6 +373,21 @@ export function verifyHistoryWritable(): { ok: boolean; error?: string } {
373
373
  }
374
374
  }
375
375
 
376
+ /**
377
+ * Read-only handle for the gateway boot-briefing builder
378
+ * (`gateway/boot-briefing-wiring.ts`). Exposes only the `prepare(...).all`
379
+ * subset the builder's `BriefingDb` seam needs; returns null before
380
+ * `initHistory` (or when history is disabled) so the briefing degrades to
381
+ * empty instead of throwing. Do NOT use for writes — every write path goes
382
+ * through the record* functions above so redaction and validity checks
383
+ * cannot be bypassed.
384
+ */
385
+ export function getHistoryDbForBriefing(): {
386
+ prepare(sql: string): { all(...params: unknown[]): unknown[] }
387
+ } | null {
388
+ return db
389
+ }
390
+
376
391
  /**
377
392
  * For tests — close the singleton and forget it. Production code never
378
393
  * needs this; the DB is held open for the lifetime of the process.
@@ -252,10 +252,15 @@ function classifyKindAndSource(text: string): { kind: LlmErrorKind; source: LlmE
252
252
  if (claudeKind === 'rate-limited') {
253
253
  return { kind: 'rate_limit', source: 'anthropic' }
254
254
  }
255
- if (claudeKind === 'unknown-5xx') {
256
- return { kind: 'overload_529', source: 'anthropic' }
257
- }
258
-
255
+ // NOTE: we deliberately do NOT map the taxonomy's `unknown-5xx` to
256
+ // `overload_529` here. `classifyClaudeError` is called above with only
257
+ // {message, type} (no HTTP status), so `unknown-5xx` at this point is the
258
+ // taxonomy's neutral no-status DEFAULT — not evidence of a genuine 529/5xx.
259
+ // A real Anthropic overload/5xx always carries recognizable wording and is
260
+ // resolved earlier by detectModelUnavailable (step 3). Fabricating
261
+ // "Anthropic is overloaded (529) — retrying automatically" for an
262
+ // unrecognized error would be a false diagnosis, so it falls through to the
263
+ // honest `unknown` kind.
259
264
  return { kind: 'unknown', source: 'anthropic' }
260
265
  }
261
266
 
@@ -474,6 +474,10 @@ export function detectModelUnavailable(
474
474
  'socket hang up',
475
475
  'request timed out',
476
476
  'connection refused',
477
+ // Mid-response stream aborts reaching Anthropic surface these markers
478
+ // (a transport failure, not an account/quota fault) — treat as network.
479
+ 'connection closed',
480
+ 'mid-response',
477
481
  'getaddrinfo',
478
482
  ]
479
483
  if (networkSignals.some(s => lower.includes(s))) {
@@ -119,18 +119,6 @@
119
119
  "_source": "Bare 403 with no recognised type",
120
120
  "status": 403,
121
121
  "message": "Forbidden"
122
- },
123
- {
124
- "_source": "null input — always unknown-4xx",
125
- "_value": null
126
- },
127
- {
128
- "_source": "Completely empty object",
129
- "_value": {}
130
- },
131
- {
132
- "_source": "Non-object string",
133
- "_value": "something went wrong"
134
122
  }
135
123
  ],
136
124
  "unknown-5xx": [
@@ -146,6 +134,18 @@
146
134
  "type": "service_unavailable",
147
135
  "message": "Service temporarily unavailable"
148
136
  }
137
+ },
138
+ {
139
+ "_source": "null input — no status, neutral 5xx default (never a fabricated 4xx)",
140
+ "_value": null
141
+ },
142
+ {
143
+ "_source": "Completely empty object — no status → unknown-5xx",
144
+ "_value": {}
145
+ },
146
+ {
147
+ "_source": "Non-object string — no status → unknown-5xx",
148
+ "_value": "something went wrong"
149
149
  }
150
150
  ]
151
151
  }
@@ -73,6 +73,17 @@ export const OPERATOR_EVENT_KINDS = [
73
73
  'mcp-dependency-blocked',
74
74
  'quota-exhausted',
75
75
  'rate-limited',
76
+ /**
77
+ * A TRANSPORT / network failure reaching Anthropic — Claude Code emits
78
+ * `error: "server_error"` (or `api_error`, which Anthropic documents as an
79
+ * HTTP 500) with NO `apiErrorStatus` on a mid-response stream abort. It is a
80
+ * pipe/transport failure, NOT an account, auth, or quota fault: retryable, and
81
+ * recovered by Claude's own internal retry. Handled by the gateway's dedicated
82
+ * calm path (no broadcast card; record for /status + a turn-end-gated plain
83
+ * user notice; a burst escalates to ONE operator-only card) — deliberately
84
+ * NEVER a Reauth card. See `gateway/user-failure-notices.ts`.
85
+ */
86
+ 'transport-transient',
76
87
  'agent-crashed',
77
88
  'agent-restarted-unexpectedly',
78
89
  'unknown-4xx',
@@ -110,19 +121,21 @@ export interface InlineKeyboardMarkup {
110
121
  * Classify an error value from any source (Anthropic SDK throw, JSONL error
111
122
  * field, etc.) into an OperatorEventKind.
112
123
  *
113
- * CONTRACT: never throws. Unfamiliar shapes fall through to unknown-4xx or
114
- * unknown-5xx based on HTTP status, defaulting to unknown-4xx.
124
+ * CONTRACT: never throws. Unfamiliar shapes fall through to unknown-5xx or
125
+ * unknown-4xx based on HTTP status. With NO status the neutral default is
126
+ * unknown-5xx — an unrecognized shape with no HTTP status must NOT fabricate a
127
+ * "4xx" label (whose card historically carried a wrong Reauth remedy).
115
128
  */
116
129
  export function classifyClaudeError(raw: unknown): OperatorEventKind {
117
130
  try {
118
131
  return classifyInner(raw)
119
132
  } catch {
120
- return 'unknown-4xx'
133
+ return 'unknown-5xx'
121
134
  }
122
135
  }
123
136
 
124
137
  function classifyInner(raw: unknown): OperatorEventKind {
125
- if (raw == null) return 'unknown-4xx'
138
+ if (raw == null) return 'unknown-5xx'
126
139
 
127
140
  // Extract common fields defensively — never throw on bad shapes.
128
141
  const obj = typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
@@ -258,13 +271,48 @@ function classifyInner(raw: unknown): OperatorEventKind {
258
271
  return 'agent-restarted-unexpectedly'
259
272
  }
260
273
 
274
+ // TRANSPORT / network failure reaching Anthropic. Claude Code emits
275
+ // `error: "server_error"` (and `api_error`, documented as HTTP 500) with NO
276
+ // `apiErrorStatus` on a mid-response stream abort — a pipe/transport failure,
277
+ // not an account/auth/quota fault. Without this branch it fell through to the
278
+ // status fallback below and FABRICATED `unknown-4xx`, whose card carried a
279
+ // wrong Reauth remedy and broadcast to every chat. It is retryable (Claude
280
+ // retries internally), so it maps to the calm `transport-transient` kind.
281
+ //
282
+ // Placed AFTER the auth-wording matches above ON PURPOSE: a genuine auth
283
+ // failure that upstream mislabels `server_error` still classifies as a
284
+ // credential fault. Matched only on EXACT type/code equality (never a message
285
+ // substring) so an unrelated error whose text merely mentions "server error"
286
+ // is untouched.
287
+ //
288
+ // GUARDED to status-less OR 5xx shapes (`status == null || status >= 500`): a
289
+ // mid-response abort carries NO status, and a genuine 500 is server-side. But
290
+ // LiteLLM stamps a generic `type: "api_error"` onto WRAPPED upstream faults
291
+ // that DO carry a 4xx status (e.g. a 401), and session-tail threads that
292
+ // `apiErrorStatus` through as `status`. Without this guard such a 401 would be
293
+ // swallowed as transport-transient (silent, no card) when it must reach the
294
+ // status fallback below and surface an operator card. The status wins.
295
+ if (
296
+ (status == null || status >= 500) &&
297
+ (errorType === 'server_error' ||
298
+ errorCode === 'server_error' ||
299
+ sdkCode === 'server_error' ||
300
+ errorType === 'api_error' ||
301
+ errorCode === 'api_error' ||
302
+ sdkCode === 'api_error')
303
+ ) {
304
+ return 'transport-transient'
305
+ }
306
+
261
307
  // Fallback: HTTP status-based.
262
308
  if (status != null) {
263
309
  if (status >= 400 && status < 500) return 'unknown-4xx'
264
310
  if (status >= 500 && status < 600) return 'unknown-5xx'
265
311
  }
266
312
 
267
- return 'unknown-4xx'
313
+ // No recognized shape and NO status: neutral 5xx default. Do NOT claim 4xx —
314
+ // a fabricated client-error label mis-routed a Reauth card (the bug this fixes).
315
+ return 'unknown-5xx'
268
316
  }
269
317
 
270
318
  function extractString(obj: Record<string, unknown>, key: string): string | null {
@@ -508,6 +556,12 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
508
556
  },
509
557
  }
510
558
 
559
+ // Dismiss-only, matching unknown-5xx / proxy-misconfig. The Reauth button
560
+ // was REMOVED: unknown-4xx is a catch-all for unrecognized client-error
561
+ // shapes, and re-auth is the wrong remedy for almost all of them. Post-RFC-H
562
+ // the auth-broker is the SOLE writer of `.credentials.json`, so a per-agent
563
+ // reauth is re-mirrored over anyway. Reauth buttons now live ONLY on the
564
+ // credentials-* cards, where the diagnosis IS a login fault.
511
565
  case 'unknown-4xx':
512
566
  return {
513
567
  text: [
@@ -518,10 +572,28 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
518
572
  .join('\n'),
519
573
  keyboard: {
520
574
  inline_keyboard: [
521
- [
522
- { text: '🔐 Reauth', callback_data: `op:reauth:${encodeURIComponent(ev.agent)}` },
523
- { text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` },
524
- ],
575
+ [{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
576
+ ],
577
+ },
578
+ }
579
+
580
+ // transport-transient is normally handled by the gateway's dedicated calm
581
+ // path (no broadcast card; a burst escalates via renderTransportEscalationCard
582
+ // in gateway/user-failure-notices.ts). This case exists so the switch stays
583
+ // exhaustive and any incidental render is calm + Dismiss-only — NEVER a
584
+ // Reauth button (a dropped stream is not a credential fault).
585
+ case 'transport-transient':
586
+ return {
587
+ text: [
588
+ `🔌 **Transient transport error** for **${agent}**.`,
589
+ detail ? `\`${detail}\`` : '',
590
+ `A mid-response stream to Anthropic dropped. Will retry automatically.`,
591
+ ]
592
+ .filter(Boolean)
593
+ .join('\n'),
594
+ keyboard: {
595
+ inline_keyboard: [
596
+ [{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
525
597
  ],
526
598
  },
527
599
  }
@@ -1075,7 +1075,13 @@ export function detectErrorInTranscriptLine(
1075
1075
  // with no retry state is ambiguous → treat as in-flight and suppress
1076
1076
  // (the silence-poke covers a genuinely stuck turn; a false card is
1077
1077
  // the bug we are fixing, a missed ambiguous card costs nothing).
1078
- const transient = kind === 'rate-limited'
1078
+ // `transport-transient` joins the transient family so Claude's retry
1079
+ // annotations gate `terminal`: a mid-response abort Claude is still retrying
1080
+ // (retryAttempt < maxRetries) is in-flight, not terminal, and must NOT count
1081
+ // toward the operator escalation counter (which is bounded to ≥3 TERMINAL
1082
+ // events). The primary bug shape (`isApiErrorMessage`) returns earlier with
1083
+ // terminal:true by construction, so it is unaffected by this.
1084
+ const transient = kind === 'rate-limited' || kind === 'transport-transient'
1079
1085
  const retry = extractRetryState(obj)
1080
1086
  const terminal = !transient
1081
1087
  ? true