switchroom 0.18.15 → 0.18.18

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +16 -0
  2. package/dist/auth-broker/index.js +445 -10
  3. package/dist/cli/notion-write-pretool.mjs +16 -0
  4. package/dist/cli/switchroom.js +654 -479
  5. package/dist/host-control/main.js +20 -1
  6. package/dist/vault/approvals/kernel-server.js +16 -0
  7. package/dist/vault/broker/server.js +16 -0
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +81 -139
  10. package/telegram-plugin/bridge/bridge.ts +7 -1
  11. package/telegram-plugin/dist/bridge/bridge.js +26 -1
  12. package/telegram-plugin/dist/gateway/gateway.js +1758 -661
  13. package/telegram-plugin/dist/server.js +26 -1
  14. package/telegram-plugin/draft-stream.ts +78 -3
  15. package/telegram-plugin/fleet-fallback-resume.ts +26 -3
  16. package/telegram-plugin/gateway/approval-hold.ts +49 -0
  17. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +64 -22
  18. package/telegram-plugin/gateway/effort-command.ts +9 -7
  19. package/telegram-plugin/gateway/gateway.ts +627 -291
  20. package/telegram-plugin/gateway/linear-activity.ts +20 -4
  21. package/telegram-plugin/gateway/litellm-local-notice-wiring.ts +200 -0
  22. package/telegram-plugin/gateway/model-command.ts +96 -18
  23. package/telegram-plugin/gateway/pending-session-command.ts +10 -8
  24. package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
  25. package/telegram-plugin/gateway/session-model-file.ts +141 -172
  26. package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
  27. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
  28. package/telegram-plugin/litellm-local-notice.ts +189 -0
  29. package/telegram-plugin/llm-error-present.ts +436 -0
  30. package/telegram-plugin/operator-events.ts +7 -1
  31. package/telegram-plugin/permission-title.ts +172 -10
  32. package/telegram-plugin/premium-recovery.ts +101 -0
  33. package/telegram-plugin/quota-watch.ts +16 -4
  34. package/telegram-plugin/raw-error-scrub.ts +73 -0
  35. package/telegram-plugin/retry-api-call.ts +8 -2
  36. package/telegram-plugin/runtime-metrics.ts +16 -0
  37. package/telegram-plugin/send-gate-degraded.test.ts +161 -8
  38. package/telegram-plugin/send-gate-observability.test.ts +140 -0
  39. package/telegram-plugin/send-gate-observability.ts +65 -20
  40. package/telegram-plugin/send-gate.test.ts +143 -1
  41. package/telegram-plugin/send-gate.ts +246 -23
  42. package/telegram-plugin/session-tail.ts +16 -0
  43. package/telegram-plugin/shared/local-time.ts +69 -0
  44. package/telegram-plugin/stream-controller.ts +143 -20
  45. package/telegram-plugin/stream-reply-handler.ts +12 -2
  46. package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
  47. package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
  48. package/telegram-plugin/tests/bot-api.harness.ts +7 -2
  49. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
  50. package/telegram-plugin/tests/draft-stream.test.ts +110 -1
  51. package/telegram-plugin/tests/effort-command.test.ts +4 -4
  52. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
  53. package/telegram-plugin/tests/flood-windows-persistence.test.ts +5 -4
  54. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +33 -19
  55. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +47 -127
  56. package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
  57. package/telegram-plugin/tests/litellm-local-notice.test.ts +417 -0
  58. package/telegram-plugin/tests/llm-error-present.test.ts +380 -0
  59. package/telegram-plugin/tests/model-command.test.ts +84 -1
  60. package/telegram-plugin/tests/permission-title.test.ts +167 -4
  61. package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
  62. package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
  63. package/telegram-plugin/tests/quota-watch.test.ts +21 -0
  64. package/telegram-plugin/tests/reaction-gate-routing.test.ts +8 -3
  65. package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
  66. package/telegram-plugin/tests/session-model-file.test.ts +7 -155
  67. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +521 -0
  68. package/telegram-plugin/tests/stream-reply-handler.test.ts +44 -0
  69. package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
  70. package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
  71. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
  72. package/telegram-plugin/tests/worker-activity-feed.test.ts +212 -2
  73. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
  74. package/telegram-plugin/tier-downgrade.ts +198 -0
  75. package/telegram-plugin/tool-activity-summary.ts +99 -0
  76. package/telegram-plugin/worker-activity-feed.ts +543 -368
@@ -297,11 +297,20 @@ export async function emitLinearAgentActivity(
297
297
  return { content: [{ type: 'text', text: `Linear ${type} emitted on session ${sessionId}` }] }
298
298
  }
299
299
 
300
+ /** The exact HTML-comment carrying a capture's dedup key. Both the embed
301
+ * (captureDedupMarker) and the dedup lookup match on this precise string, so a
302
+ * re-capture is confirmed by exact-key equality, not fuzzy search relevance.
303
+ * The trailing ` -->` and leading `: ` anchor the key so `abc` never matches
304
+ * `abcd`. */
305
+ export function captureDedupComment(dedupKey: string): string {
306
+ return `<!-- switchroom-capture: ${dedupKey} -->`
307
+ }
308
+
300
309
  /** Hidden marker appended to a captured issue's description so a re-capture of
301
310
  * the same Telegram message can be detected (dedup backstop; the gateway-side
302
311
  * seen-set is the primary, race-free guard). */
303
312
  export function captureDedupMarker(dedupKey: string): string {
304
- return `\n\n<!-- switchroom-capture: ${dedupKey} -->`
313
+ return `\n\n${captureDedupComment(dedupKey)}`
305
314
  }
306
315
 
307
316
  /**
@@ -390,19 +399,26 @@ export async function createLinearIssue(
390
399
  }
391
400
 
392
401
  // Dedup backstop: search for a prior capture of the same Telegram message.
402
+ // `searchIssues` is a relevance-ranked full-text search, so its top hit for a
403
+ // key can be an unrelated issue that merely shares tokens. We therefore only
404
+ // treat a result as a dedup match when its description carries the EXACT
405
+ // capture marker for this key, never the raw top hit.
393
406
  if (dedupKey) {
407
+ const marker = captureDedupComment(dedupKey)
394
408
  const search = await gql(
395
- 'query($term: String!) { searchIssues(term: $term) { nodes { id url title } } }',
409
+ 'query($term: String!) { searchIssues(term: $term, first: 25) { nodes { id url title description } } }',
396
410
  { term: dedupKey },
397
411
  )
398
412
  if (search.ok) {
399
- const hit = (search.data?.searchIssues?.nodes ?? [])[0] as { url?: string } | undefined
413
+ const nodes = (search.data?.searchIssues?.nodes ?? []) as Array<{ url?: string; description?: string }>
414
+ const hit = nodes.find((n) => typeof n.description === 'string' && n.description.includes(marker))
400
415
  if (hit?.url) {
401
416
  log(`telegram gateway: linear_create_issue: dedup hit key=${dedupKey} agent=${agent}\n`)
402
417
  return { content: [{ type: 'text', text: `Already filed: ${hit.url}` }] }
403
418
  }
404
419
  }
405
- // a failed search is non-fatal — fall through to create (gateway seen-set is primary).
420
+ // a failed search or no exact-marker match is non-fatal — fall through to
421
+ // create (gateway seen-set is the primary, race-free guard).
406
422
  }
407
423
 
408
424
  // Resolve the team.
@@ -0,0 +1,200 @@
1
+ /**
2
+ * litellm-local-notice-wiring.ts — side-effect runner for the debounced
3
+ * litellm-local 429 notice.
4
+ *
5
+ * The state machine, notice text, config parsing, and metric payload live in
6
+ * ../litellm-local-notice.ts (pure). This module owns the sequencing, with
7
+ * every dependency injected so the wiring is unit-testable without importing
8
+ * gateway.ts (same shape as throttle-tier-wiring.ts):
9
+ *
10
+ * 1. Guard — only the `litellm-local` classification is eligible
11
+ * (isLitellmLocalNoticeEligible). account-scoped keeps the throttle
12
+ * tier; generic-transient keeps the calm rate-limited card. The guard
13
+ * lives HERE (not in the caller) so "a notice never fires for
14
+ * non-litellm-local classifications" is mechanism, not discipline.
15
+ * 2. Per-agent cooldown — evaluate against the resolved window
16
+ * (deps.windowMs(), re-read per event so an operator-tuned
17
+ * channels.telegram.litellm_notice.window_ms takes effect after
18
+ * apply+restart without re-creating the runner). Suppressed events are
19
+ * counted silently.
20
+ * 3. ONE calm notice — broadcast to every authorized chat via the injected
21
+ * send (the gateway binds it to swallowingApiCall, the standard
22
+ * retry-wrapped path). The window is armed, the
23
+ * `litellm_local_429_notice` metric emitted, and "posted" logged ONLY
24
+ * when at least one send was actually ISSUED — an empty allowFrom or a
25
+ * send callback that throws for every chat leaves the state untouched,
26
+ * so the next event retries instead of silently claiming delivery.
27
+ *
28
+ * Deliberately NO broker calls, NO quota-ledger writes, NO failover, NO
29
+ * retry nudge: the litellm-local calm path's invariant is that account state
30
+ * is never touched (the request never reached Anthropic). Claude Code's own
31
+ * retry handles the turn.
32
+ */
33
+
34
+ import {
35
+ buildLitellmLocalNoticeMetric,
36
+ evaluateLitellmLocalNotice,
37
+ initialLitellmLocalNoticeState,
38
+ isLitellmLocalNoticeEligible,
39
+ renderLitellmLocalNotice,
40
+ type LitellmLocalNoticeState,
41
+ } from '../litellm-local-notice.js'
42
+ import type { RateLimit429Classification } from '../throttle-tier.js'
43
+ import type { RuntimeMetricEvent } from '../runtime-metrics.js'
44
+
45
+ /**
46
+ * The user-facing surface decision for one terminal non-account-scoped
47
+ * `rate-limited` operator event — the gateway's ordering contract with the
48
+ * shared per-agent-per-kind card cooldown (shouldEmitOperatorEvent in
49
+ * operator-events.ts), extracted so it is pinnable by tests:
50
+ *
51
+ * - `litellm-local` resolves BEFORE the shared gate is consulted. Two
52
+ * load-bearing consequences: (a) it never ARMS `${agent}:rate-limited`
53
+ * (shouldEmitOperatorEvent's true-return records the timestamp), so a
54
+ * proxy-cap burst can't suppress a later genuine transient's card; and
55
+ * (b) it is never SUPPRESSED by a cooldown that a recent 529 / generic
56
+ * card armed — the notice runner owns its own per-agent debounce
57
+ * instead.
58
+ * - Every other classification consults (and on true, arms) the shared
59
+ * gate exactly once — the caller must NOT re-consult it downstream.
60
+ */
61
+ export function decideRateLimitedSurface(opts: {
62
+ classification: RateLimit429Classification
63
+ agent: string
64
+ /** The shared cooldown gate — gateway binds
65
+ * (a) => shouldEmitOperatorEvent(a, 'rate-limited'). Consulted (and on
66
+ * true, armed) only for non-litellm-local classifications. */
67
+ shouldEmitCard: (agent: string) => boolean
68
+ }): 'litellm-local-notice' | 'generic-card' | 'cooldown-suppressed' {
69
+ if (isLitellmLocalNoticeEligible(opts.classification)) return 'litellm-local-notice'
70
+ return opts.shouldEmitCard(opts.agent) ? 'generic-card' : 'cooldown-suppressed'
71
+ }
72
+
73
+ export interface LitellmLocalNoticeRunnerDeps {
74
+ /** Chats the notice broadcasts to (access.allowFrom, resolved per call). */
75
+ listNoticeChats(): Array<string | number>
76
+ /** Fire-and-forget rich send (gateway wraps swallowingApiCall). */
77
+ sendNotice(chatId: string | number, markdown: string): void
78
+ /** Resolved cooldown window (ms), re-read per event —
79
+ * parseLitellmNoticeWindowMs(loadAccess().litellmNoticeWindowMs). */
80
+ windowMs(): number
81
+ /** Runtime-metric sink (gateway binds emitRuntimeMetric). */
82
+ emitMetric(event: RuntimeMetricEvent): void
83
+ log(msg: string): void
84
+ now?: () => number
85
+ }
86
+
87
+ /**
88
+ * What one event produced:
89
+ * - `sent` — a notice was issued to ≥1 chat (window armed, metric
90
+ * emitted). The gateway records the event into the
91
+ * operator-event history ONLY on this outcome, so a
92
+ * suppressed low-stakes proxy throttle never overwrites a
93
+ * more important most-recent event (e.g.
94
+ * credentials-expired) in /status enrichment.
95
+ * - `suppressed` — inside the cooldown window; counted silently.
96
+ * - `skipped` — ineligible classification, no authorized recipients,
97
+ * every send failed synchronously, or an internal error
98
+ * (logged). No debounce state change.
99
+ */
100
+ export type LitellmLocalNoticeOutcome = 'sent' | 'suppressed' | 'skipped'
101
+
102
+ export interface LitellmLocalNoticeRunner {
103
+ /**
104
+ * Run the notice path for one terminal rate-limited operator event.
105
+ * No-ops (returns 'skipped') unless `classification` is `litellm-local`.
106
+ * Never throws.
107
+ */
108
+ onRateLimited(
109
+ classification: RateLimit429Classification,
110
+ agent: string,
111
+ ): LitellmLocalNoticeOutcome
112
+ /** Test/debug view of internal state. */
113
+ inspect(): { state: LitellmLocalNoticeState }
114
+ }
115
+
116
+ export function createLitellmLocalNoticeRunner(
117
+ deps: LitellmLocalNoticeRunnerDeps,
118
+ ): LitellmLocalNoticeRunner {
119
+ const now = deps.now ?? (() => Date.now())
120
+ // In-memory only — the debounce resets on gateway restart, so a sustained
121
+ // throttle can produce one extra notice per restart. Accepted trade-off:
122
+ // persisting a courtesy-surface cooldown isn't worth state-file plumbing,
123
+ // and the failure mode is one duplicate calm message, not a storm.
124
+ let state = initialLitellmLocalNoticeState()
125
+
126
+ function onRateLimited(
127
+ classification: RateLimit429Classification,
128
+ agent: string,
129
+ ): LitellmLocalNoticeOutcome {
130
+ try {
131
+ if (!isLitellmLocalNoticeEligible(classification)) return 'skipped'
132
+ const windowMs = deps.windowMs()
133
+ const verdict = evaluateLitellmLocalNotice(state, agent, now(), windowMs)
134
+ if (!verdict.send) {
135
+ state = verdict.next
136
+ deps.log(
137
+ `[litellm-local-notice] suppressed (cooldown) agent=${agent} ` +
138
+ `suppressedSoFar=${state.suppressedCountByAgent[agent] ?? 0}`,
139
+ )
140
+ return 'suppressed'
141
+ }
142
+ const chats = deps.listNoticeChats()
143
+ const markdown = renderLitellmLocalNotice({
144
+ agent,
145
+ suppressedSinceLastNotice: verdict.suppressedSinceLastNotice,
146
+ })
147
+ let issued = 0
148
+ for (const chatId of chats) {
149
+ try {
150
+ deps.sendNotice(chatId, markdown)
151
+ issued++
152
+ } catch (err) {
153
+ deps.log(
154
+ `[litellm-local-notice] send failed chat=${chatId} agent=${agent}: ` +
155
+ `${(err as Error)?.message ?? err}`,
156
+ )
157
+ }
158
+ }
159
+ if (issued === 0) {
160
+ // Nothing actually went out (no authorized chats, or every send
161
+ // threw synchronously). Do NOT arm the window, emit the metric, or
162
+ // claim "posted" — the next event retries instead of the debounce
163
+ // silently absorbing events no one will ever hear about.
164
+ deps.log(
165
+ `[litellm-local-notice] no sends issued (chats=${chats.length}) agent=${agent} — ` +
166
+ `window not armed, will retry on the next event`,
167
+ )
168
+ return 'skipped'
169
+ }
170
+ state = verdict.next
171
+ deps.emitMetric(
172
+ buildLitellmLocalNoticeMetric({
173
+ agent,
174
+ suppressedCount: verdict.suppressedSinceLastNotice,
175
+ windowMs,
176
+ }),
177
+ )
178
+ deps.log(
179
+ `[litellm-local-notice] posted agent=${agent} chats=${issued} ` +
180
+ `suppressedSinceLast=${verdict.suppressedSinceLastNotice} windowMs=${windowMs}`,
181
+ )
182
+ return 'sent'
183
+ } catch (err) {
184
+ // The notice is a courtesy surface — a failure here must never break
185
+ // the operator-event path that invoked it. The log itself is wrapped
186
+ // too: a dead stderr sink must not void the never-throws contract.
187
+ try {
188
+ deps.log(
189
+ `[litellm-local-notice] error agent=${agent}: ${(err as Error)?.message ?? err}`,
190
+ )
191
+ } catch { /* sink dead — nothing left to do */ }
192
+ return 'skipped'
193
+ }
194
+ }
195
+
196
+ return {
197
+ onRateLimited,
198
+ inspect: () => ({ state }),
199
+ }
200
+ }
@@ -108,6 +108,82 @@ export function parseModelCommand(text: string): ParsedModelCommand | null {
108
108
  return { kind: 'set', model: arg }
109
109
  }
110
110
 
111
+ /**
112
+ * The busy state a typed `/model` disposition is decided against. Two
113
+ * independent signals are folded here (#3177): `currentTurnActive` is the
114
+ * gateway's per-turn atom (`currentTurn !== null`), which is NULLED at
115
+ * `turn_end`; `turnInFlight` is the authoritative delivery-machine +
116
+ * pending-approval gate (`turnInFlightForGate()`). The atom can read idle
117
+ * while the session is still busy (the "recovered-late" / premature-turn-end
118
+ * window seen in finn's 2026-07-12 log), so a switch decided on the atom ALONE
119
+ * takes the direct-inject path and types `/model <name>` into a still-busy
120
+ * pane, where claude swallows it as literal text — a silent no-op. Folding
121
+ * BOTH signals means a session busy by EITHER measure ack+queues instead.
122
+ */
123
+ export interface ModelCommandContext {
124
+ /** Gateway per-turn atom is non-null (`currentTurn !== null`). */
125
+ currentTurnActive: boolean
126
+ /** Authoritative busy gate (`turnInFlightForGate()`): machine-in-turn OR a pending approval. */
127
+ turnInFlight: boolean
128
+ /** Interactive picker menu is enabled (`SWITCHROOM_MODEL_MENU !== '0'`). */
129
+ menuEnabled: boolean
130
+ }
131
+
132
+ /**
133
+ * True when the session is busy by EITHER busy signal. A typed `/model` set
134
+ * MUST NOT take the direct-inject path while this holds — it would type into a
135
+ * busy pane and be swallowed as text. See ModelCommandContext.
136
+ */
137
+ export function isModelCommandBusy(ctx: Pick<ModelCommandContext, 'currentTurnActive' | 'turnInFlight'>): boolean {
138
+ return ctx.currentTurnActive || ctx.turnInFlight
139
+ }
140
+
141
+ /**
142
+ * What the gateway should do with a parsed `/model` command. Pure so the
143
+ * routing decision is unit-testable without booting the bot. Every parsed
144
+ * shape maps to exactly one visible action — there is NO branch that silently
145
+ * does nothing (the #3177 swallow):
146
+ *
147
+ * - `menu` — bare `/model` with the picker enabled → render the dashboard.
148
+ * - `queue` — a `set` while busy (by EITHER signal) → ack + enqueue for
149
+ * apply-on-idle. `target` is the alias-expanded token.
150
+ * - `apply` — run the handler now (idle `set`, or `show`/`help` text paths).
151
+ */
152
+ export type ModelCommandDisposition =
153
+ | { kind: 'menu' }
154
+ | { kind: 'queue'; target: string }
155
+ | { kind: 'apply'; parsed: ParsedModelCommand }
156
+
157
+ export function planModelCommand(
158
+ parsed: ParsedModelCommand,
159
+ ctx: ModelCommandContext,
160
+ ): ModelCommandDisposition {
161
+ if (parsed.kind === 'show' && ctx.menuEnabled) return { kind: 'menu' }
162
+ if (parsed.kind === 'set' && isModelCommandBusy(ctx)) {
163
+ return { kind: 'queue', target: expandSrAlias(parsed.model) }
164
+ }
165
+ return { kind: 'apply', parsed }
166
+ }
167
+
168
+ /**
169
+ * The unconditional durable log line the gateway writes at `/model` entry,
170
+ * BEFORE any branch or reply attempt (#3177 guarantee (b)). Even if every
171
+ * downstream reply is shed/dropped, this line — plus the paired history row —
172
+ * guarantees a typed `/model` is never invisible. Kept pure + shape-stable so
173
+ * the format is testable and greppable (`grep 'gw /model received'`).
174
+ */
175
+ export function modelCommandReceiptLine(
176
+ agent: string,
177
+ parsed: ParsedModelCommand,
178
+ busy: boolean,
179
+ ): string {
180
+ const arg =
181
+ parsed.kind === 'set' ? parsed.model
182
+ : parsed.kind === 'show' ? '(show)'
183
+ : '(help)'
184
+ return `telegram gateway: gw /model received agent=${agent} kind=${parsed.kind} arg=${arg} busy=${busy}`
185
+ }
186
+
111
187
  export interface ModelCommandDeps {
112
188
  /** Inject primitive — wired to injectSlashCommand in the gateway. */
113
189
  inject: (agent: string, command: string) => Promise<InjectResult>
@@ -175,7 +251,7 @@ export interface ModelCommandReply {
175
251
  }
176
252
 
177
253
  const PERSIST_NOTE =
178
- '_Sticky — persists across restarts, deploys, and crashes until \`/model default\` clears it (or the configured \`model:\` in switchroom.yaml changes, which resets it and notifies you). To change the default, set \`model:\` in switchroom.yaml._'
254
+ '_Session-only — this override lasts until the agent’s next restart, then reverts to the configured \`model:\`. \`/model default\` clears it now. To change the default permanently, set \`model:\` in switchroom.yaml._'
179
255
 
180
256
  function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
181
257
  const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `\`${a}\``).join(' · ')
@@ -507,18 +583,19 @@ export const SR_MODEL_ALIASES: Record<string, string> = {
507
583
 
508
584
  /** Expand a short alias (case-insensitive) to its full sr-* id, or return the original. */
509
585
  /**
510
- * #3042 review blocker 2a: can `token` be trusted for a DURABLE, boot-applied
511
- * `.session-model` persist WITHOUT a live confirmation from claude?
586
+ * #3042 review blocker 2a: can `token` be trusted for a boot-applied
587
+ * `.session-model` carrier persist WITHOUT a live confirmation from claude?
512
588
  *
513
589
  * A queued typed `/model <arg>` that is persisted at shutdown was never
514
- * validated by claude's picker — and under the keep-by-default boot (#3039)
515
- * a garbage-but-shape-valid token (e.g. `claude-nonexistnet-9`) would make
516
- * every boot run `claude --model <garbage>` with the gateway dead. Only
517
- * tokens with a switchroom-known meaning are offline-trustable: the static
518
- * Claude aliases (claude resolves them itself) and the curated sr-* alias
519
- * TARGETS (present in the LiteLLM config by construction). Full `claude-*` /
520
- * arbitrary `sr-*` ids typed by hand are refused — they need the live
521
- * session to verify, so the operator is asked to re-issue after boot.
590
+ * validated by claude's picker. Under the consume-once carrier (rev 4) a
591
+ * garbage-but-shape-valid token (e.g. `claude-nonexistnet-9`) can crash at
592
+ * most ONE boot before the carrier is gone and the agent reverts — but we
593
+ * still avoid even that crash-boot. Only tokens with a switchroom-known
594
+ * meaning are offline-trustable: the static Claude aliases (claude resolves
595
+ * them itself) and the curated sr-* alias TARGETS (present in the LiteLLM
596
+ * config by construction). Full `claude-*` / arbitrary `sr-*` ids typed by
597
+ * hand are refused — they need the live session to verify, so the operator is
598
+ * asked to re-issue after boot.
522
599
  */
523
600
  export function isOfflineTrustedModelToken(token: string): boolean {
524
601
  // #3043 item 1: the live accept path normalizes case (isClaudeModel /
@@ -833,18 +910,19 @@ export interface ModelCallbackOutcome {
833
910
  /**
834
911
  * The canonical `claude --model` token (alias or full `claude-*` id) for a
835
912
  * Claude selection, when derivable — distinct from `selectedModel` (a display
836
- * name for /status). The gateway persists this to the durable
837
- * `.session-model` override so the confirmed switch survives
838
- * switchroom-managed relaunches (and, on an sr-* → Claude transition, its own
913
+ * name for /status). Session-scoped (rev 4): a live Claude selection persists
914
+ * NO carrier (the switch applies in-session and reverts on the next boot);
915
+ * the gateway uses this token ONLY on an sr-* → Claude transition, writing it
916
+ * to the consume-once `.session-model` carrier so that transition's own
917
+ * apply-relaunch boots the tapped model (then reverts on the following
839
918
  * restart). Absent when the target has no derivable token.
840
919
  */
841
920
  selectedModelToken?: string
842
921
  /**
843
922
  * True when the confirmed selection was the "Default (recommended)" row —
844
- * i.e. the session is now on the configured default and any sticky
845
- * `.session-model` override must be CLEARED (there is no token to persist;
846
- * persisting nothing while leaving a stale override would re-apply the old
847
- * model on the next keep-relaunch).
923
+ * i.e. the session is now on the configured default and any leftover
924
+ * `.session-model` carrier must be CLEARED (a stale carrier would be
925
+ * consumed — mis-applied — by the next boot).
848
926
  */
849
927
  clearedDefault?: boolean
850
928
  /** Short toast for answerCallbackQuery. */
@@ -244,15 +244,17 @@ export interface PendingCommandCardEdit {
244
244
  }
245
245
 
246
246
  /**
247
- * What the gateway should DURABLY record for a queued command that can't
248
- * apply live because the session is going away (gateway shutdown or a pending
249
- * session relaunch). #3039: instead of telling the operator to re-issue, the
250
- * choice is persisted to the durable carriers (`.session-model` /
251
- * `.session-effort`) that start.sh honors on every boot — so the queued
252
- * command still deterministically applies, just via the relaunch.
247
+ * What the gateway should record for a queued command that can't apply live
248
+ * because the session is going away (gateway shutdown or a pending session
249
+ * relaunch). Instead of telling the operator to re-issue, the choice is
250
+ * persisted to the CONSUME-ONCE boot carriers (`.session-model` /
251
+ * `.session-effort`, #3184/#3186): start.sh applies each on the single boot
252
+ * that reads it — the queued command's apply-relaunch — and then deletes it,
253
+ * so the queued command still deterministically applies via the relaunch and
254
+ * any SUBSEQUENT restart reverts to the configured default.
253
255
  *
254
- * - 'model' — persist `arg` as the `.session-model` override
255
- * - 'effort' — persist `arg` as the `.session-effort` override
256
+ * - 'model' — persist `arg` as the `.session-model` carrier
257
+ * - 'effort' — persist `arg` as the `.session-effort` carrier
256
258
  * - 'clear-model' — the queued command was `/model default`: clear the carrier
257
259
  * - 'clear-effort' — the queued command was `/effort default`: clear the carrier
258
260
  * - null — not offline-resolvable (a `mdl:s:<tag>` menu selection
@@ -0,0 +1,122 @@
1
+ /**
2
+ * premium-recovery-wiring.ts — the gateway GLUE for the "premium model
3
+ * recovered" ping, behind injected deps so the never-storm orchestration is
4
+ * unit-testable without importing the whole gateway (mirrors
5
+ * tier-downgrade-wiring.ts).
6
+ *
7
+ * The pure recovery predicate (`decidePremiumRecovery`), the honest wording +
8
+ * button (`renderPremiumRecoveryPing`), and the fleet-dedup key
9
+ * (`premiumRecoveryClaimKey`) all live in premium-recovery.ts. This module owns
10
+ * the ORDER-SENSITIVE side effects the gateway performs off that verdict — the
11
+ * P0 at-most-once guarantee a review cares about:
12
+ *
13
+ * - the marker is READ first; a missing/corrupt marker is a no-op (no send);
14
+ * - the pure decision gates the send: `fire === false` → NO clear, NO send
15
+ * (the marker survives for a later tick once the tier actually recovers);
16
+ * - a fleet-wide `claim-notification` gates against a bounce / concurrent
17
+ * tick; on `!granted` the marker is cleared (so it can't linger and
18
+ * re-attempt every tick) and we bail with NO send;
19
+ * - on a GRANTED claim the marker is cleared BEFORE the send (never-storm:
20
+ * at-most-once is the hard requirement — a transient send fault is covered
21
+ * by the caller's retry policy, a double-send is not recoverable);
22
+ * - EXACTLY ONE send per recorded chat, each carrying the session-scoped
23
+ * `mdl:alias:<premium>` switch-back button (the SAME apply path as the model
24
+ * menu — it does not bypass handleModelMenuCallback).
25
+ *
26
+ * Behaviour is a faithful extraction of the former inline
27
+ * `maybePremiumRecoveryPing`; the ordering above is preserved exactly.
28
+ */
29
+
30
+ import {
31
+ renderPremiumRecoveryPing,
32
+ premiumRecoveryClaimKey,
33
+ type PremiumRecoveryPing,
34
+ } from '../premium-recovery.js'
35
+ import { MODEL_CALLBACK_ALIAS } from './model-command.js'
36
+
37
+ /** The one-tap switch-back keyboard (single callback button). */
38
+ export interface PremiumRecoveryKeyboard {
39
+ inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
40
+ }
41
+
42
+ /** The minimal marker view the ping orchestration needs. */
43
+ export interface PremiumRecoveryMarkerView {
44
+ /** The dropped premium `/model` token to offer switching back to. */
45
+ premiumModel: string
46
+ /** Chat ids to ping (the downgrade notice's allowFrom); may be empty → fallback. */
47
+ chats: string[]
48
+ }
49
+
50
+ export interface PremiumRecoveryPingDeps {
51
+ /** Bind-mounted agent state dir, or null when unresolvable (→ no-op). */
52
+ getAgentDir: () => string | null
53
+ /** Read the parsed `.premium-recovery` marker, or null when absent/corrupt. */
54
+ readMarker: (agentDir: string) => PremiumRecoveryMarkerView | null
55
+ /** Delete the marker (consume-once). Best-effort. */
56
+ clearMarker: (agentDir: string) => void
57
+ /** The agent name (for the fleet-dedup claim key). */
58
+ getAgent: () => string
59
+ /** The pure recovery predicate, bound to THIS tick's live accounts. Returns
60
+ * whether to fire (the marker is already known present when this is called). */
61
+ decide: () => boolean
62
+ /** Fleet-wide at-most-once claim for this premium token. Returns granted
63
+ * (fail-open: true on any broker error → degrades to at-least-once). */
64
+ claimNotification: (claimKey: string) => Promise<boolean>
65
+ /** Fallback chats when the marker records none (parity with the inline path). */
66
+ fallbackChats: () => string[]
67
+ /** Send the recovery ping (with the switch-back keyboard) to ONE chat. */
68
+ sendToChat: (
69
+ chatId: string,
70
+ ping: PremiumRecoveryPing,
71
+ keyboard: PremiumRecoveryKeyboard,
72
+ ) => void
73
+ /** Structured logger (stderr in prod, captured in test). */
74
+ log: (msg: string) => void
75
+ }
76
+
77
+ /**
78
+ * Run the premium-recovery ping glue. No-op unless a marker is pending, the
79
+ * pure decision says fire, and the fleet claim is granted — then clears the
80
+ * marker BEFORE fanning out exactly one send per chat. Async because the claim
81
+ * is a broker round-trip; the caller `.catch`es (a convenience ping is never
82
+ * allowed to throw the quota-watch tick).
83
+ */
84
+ export async function runPremiumRecoveryPing(deps: PremiumRecoveryPingDeps): Promise<void> {
85
+ const agentDir = deps.getAgentDir()
86
+ if (!agentDir) return
87
+ const marker = deps.readMarker(agentDir)
88
+ if (marker == null) return
89
+ // Pure decision gate: on `false` leave the marker in place (a later tick fires
90
+ // once the tier recovers) — no clear, no claim, no send.
91
+ if (!deps.decide()) return
92
+ const agent = deps.getAgent()
93
+ // Fleet-wide dedup: a fresh gateway boot or a second tick inside the window
94
+ // must not re-send. Fail-open — a convenience ping degrades to at-least-once,
95
+ // never lost.
96
+ const granted = await deps.claimNotification(
97
+ premiumRecoveryClaimKey(agent, marker.premiumModel),
98
+ )
99
+ if (!granted) {
100
+ // Another gateway already owns this recovery ping — consume our marker so it
101
+ // can't linger and re-attempt every tick, and bail (NO send).
102
+ deps.clearMarker(agentDir)
103
+ return
104
+ }
105
+ // Consume the marker BEFORE sending (never-storm: at-most-once is the hard
106
+ // requirement for this ping; a transient send fault is covered by the
107
+ // caller's retry policy, whereas a double-send is not recoverable).
108
+ deps.clearMarker(agentDir)
109
+ const ping = renderPremiumRecoveryPing(marker.premiumModel)
110
+ const keyboard: PremiumRecoveryKeyboard = {
111
+ inline_keyboard: [
112
+ [{ text: ping.buttonText, callback_data: `${MODEL_CALLBACK_ALIAS}${marker.premiumModel}` }],
113
+ ],
114
+ }
115
+ const chats = marker.chats.length > 0 ? marker.chats : deps.fallbackChats()
116
+ for (const chatId of chats) {
117
+ deps.sendToChat(chatId, ping, keyboard)
118
+ }
119
+ deps.log(
120
+ `[premium-recovery] ${marker.premiumModel} servable again — ping sent agent=${agent} chats=${chats.length}`,
121
+ )
122
+ }