switchroom 0.15.2 → 0.15.4

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.
@@ -247,6 +247,7 @@ import {
247
247
  import {
248
248
  fetchQuota,
249
249
  formatQuotaBlock,
250
+ formatQuotaLine,
250
251
  type QuotaResult,
251
252
  } from '../quota-check.js'
252
253
  import {
@@ -258,7 +259,17 @@ import { DEFAULT_SLOT } from '../../src/auth/accounts.js'
258
259
  import { currentActiveSlot, type AuthCodeOutcome } from '../../src/auth/manager.js'
259
260
  import { injectSlashCommand as injectSlashCommandImpl } from '../../src/agents/inject.js'
260
261
  import { handleInjectCommand } from './inject-handler.js'
261
- import { parseModelCommand, handleModelCommand } from './model-command.js'
262
+ import {
263
+ parseModelCommand,
264
+ handleModelCommand,
265
+ buildModelMenu,
266
+ handleModelMenuCallback,
267
+ MODEL_CALLBACK_PREFIX,
268
+ type ModelMenuDeps,
269
+ type ModelCommandDeps,
270
+ type ModelMenuReply,
271
+ } from './model-command.js'
272
+ import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
262
273
  import { type BannerState } from '../slot-banner.js'
263
274
  import { refreshBanner } from '../slot-banner-driver.js'
264
275
  import { loadConfig as loadSwitchroomConfig, findConfigFile as findSwitchroomConfigFile } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
@@ -6598,7 +6609,7 @@ if (!STATIC) {
6598
6609
  // promise EXPLICITLY (honest failure) instead of letting it sit
6599
6610
  // forever. This is what makes the guarantee deterministic: every
6600
6611
  // queued message ends either delivered or visibly retracted.
6601
- inboundSpool?.sweepEscalations((e) => {
6612
+ inboundSpool?.sweepEscalations((e, { postNotice }) => {
6602
6613
  const chat = e.msg.chatId
6603
6614
  const escThread =
6604
6615
  typeof e.msg.meta?.threadId === 'string' && e.msg.meta.threadId
@@ -6609,7 +6620,14 @@ if (!STATIC) {
6609
6620
  // the message is being declared undeliverable, so the queued-status must
6610
6621
  // not dangle beside the "couldn't deliver" notice (idempotent best-effort;
6611
6622
  // a normal turn-start/turn-end reaps far sooner — this is the 15-min edge).
6623
+ // Reaping happens for EVERY dropped entry; only the user-facing notice is
6624
+ // coalesced (postNotice), so a burst of undeliverable inbounds doesn't
6625
+ // leave dangling placeholders even when its notice is suppressed.
6612
6626
  reapQueuedStatus(chat, escThread)
6627
+ // Coalesced per chat by the spool's sliding window — a multi-restart
6628
+ // outage that re-ages a synthetic into the bound every 15 min posts ONE
6629
+ // notice, not one per cycle (the 2026-06-09 marko "please resend" spam).
6630
+ if (!postNotice) return
6613
6631
  void swallowingApiCall(
6614
6632
  () =>
6615
6633
  bot.api.sendMessage(
@@ -13756,6 +13774,14 @@ function buildAgentAudit(agentName: string): AgentAudit | undefined {
13756
13774
  // broker's fleet-wide `ListStateData` payload via
13757
13775
  // `buildAuthSummaryFromBroker`, with billingType pulled from the
13758
13776
  // agent's `.claude.json` (the broker doesn't track plan tier).
13777
+ /**
13778
+ * Live session-model override set by the `/model` picker (session-only). Held
13779
+ * in gateway memory so it clears on restart, the same point at which claude's
13780
+ * session reverts to the configured model — keeping `/status` honest without
13781
+ * a persisted store. Null when no session switch is active.
13782
+ */
13783
+ let activeSessionModelOverride: string | null = null
13784
+
13759
13785
  async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
13760
13786
  type AgentListResp = {
13761
13787
  agents: Array<{
@@ -13784,6 +13810,7 @@ async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
13784
13810
  return {
13785
13811
  agentName,
13786
13812
  model: a?.model ?? null,
13813
+ sessionModel: activeSessionModelOverride,
13787
13814
  extendsProfile: (a?.extends ?? a?.template) ?? null,
13788
13815
  topicName: a?.topic_name ?? null,
13789
13816
  topicEmoji: a?.topic_emoji ?? null,
@@ -13922,19 +13949,39 @@ bot.command('inject', async ctx => {
13922
13949
  })
13923
13950
  })
13924
13951
 
13925
- // /model — show or switch the Claude model for this agent's live
13926
- // session. The argument form rides the same allowlisted inject
13927
- // primitive as /inject (claude's native `/model <name>` REPL command);
13928
- // the bare form never injects (the no-arg picker is an undriveable TUI
13929
- // modal from Telegram). Implementation in model-command.ts so it's
13952
+ // /model — model dashboard + switch for this agent's live session.
13953
+ // Bare form: drives claude's own /model picker (open → parse → Esc,
13954
+ // src/agents/model-picker.ts) to discover the live option list no
13955
+ // hardcoded model names and renders it as an inline-keyboard menu
13956
+ // with the current model + a brief quota line. A tap re-opens the
13957
+ // picker fresh and applies session-only (`s`). Kill-switch
13958
+ // SWITCHROOM_MODEL_MENU=0 reverts the bare form to the static v1
13959
+ // text. The typed argument form rides the allowlisted inject
13960
+ // primitive unchanged. Implementation in model-command.ts so it's
13930
13961
  // unit-testable without booting the bot.
13931
- bot.command('model', async ctx => {
13932
- if (!isAuthorizedSender(ctx)) return
13933
- const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
13934
- const parsed = parseModelCommand(text) ?? { kind: 'show' as const }
13935
- const reply = await handleModelCommand(parsed, {
13936
- inject: injectSlashCommandImpl,
13962
+ function buildModelDeps(): ModelMenuDeps & ModelCommandDeps {
13963
+ return {
13964
+ discover: (a) => discoverModels(a),
13965
+ select: (a, label) => selectModel(a, label),
13966
+ isBusy: () => currentTurn !== null,
13937
13967
  getAgentName: getMyAgentName,
13968
+ getQuotaBrief: async () => {
13969
+ // Broker-routed probe first (authoritative), local headers as
13970
+ // fallback — same ladder as the boot card / legacy /usage.
13971
+ try {
13972
+ const probed = await probeQuotaForBootCard(getMyAgentName(), 4000)
13973
+ if (probed?.ok) return formatQuotaLine(probed.data)
13974
+ } catch { /* fall through */ }
13975
+ try {
13976
+ const agentDir = resolveAgentDirFromEnv()
13977
+ if (agentDir) {
13978
+ const local = await fetchQuota({ claudeConfigDir: join(agentDir, '.claude') })
13979
+ if (local.ok) return formatQuotaLine(local.data)
13980
+ }
13981
+ } catch { /* quota is garnish — never block the menu on it */ }
13982
+ return null
13983
+ },
13984
+ inject: injectSlashCommandImpl,
13938
13985
  getConfiguredModel: () => {
13939
13986
  type AgentListResp = { agents: Array<{ name: string; model?: string | null }> }
13940
13987
  const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
@@ -13942,7 +13989,30 @@ bot.command('model', async ctx => {
13942
13989
  },
13943
13990
  escapeHtml: escapeHtmlForTg,
13944
13991
  preBlock,
13945
- })
13992
+ }
13993
+ }
13994
+
13995
+ function modelMenuReplyMarkup(reply: ModelMenuReply): InlineKeyboard | undefined {
13996
+ if (!reply.keyboard) return undefined
13997
+ const kb = new InlineKeyboard()
13998
+ for (const row of reply.keyboard) {
13999
+ for (const btn of row) kb.text(btn.text, btn.callback_data)
14000
+ kb.row()
14001
+ }
14002
+ return kb
14003
+ }
14004
+
14005
+ bot.command('model', async ctx => {
14006
+ if (!isAuthorizedSender(ctx)) return
14007
+ const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
14008
+ const parsed = parseModelCommand(text) ?? { kind: 'show' as const }
14009
+ const deps = buildModelDeps()
14010
+ if (parsed.kind === 'show' && process.env.SWITCHROOM_MODEL_MENU !== '0') {
14011
+ const menu = await buildModelMenu(deps)
14012
+ await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) })
14013
+ return
14014
+ }
14015
+ const reply = await handleModelCommand(parsed, deps)
13946
14016
  await switchroomReply(ctx, reply.text, { html: reply.html })
13947
14017
  })
13948
14018
 
@@ -18330,6 +18400,74 @@ bot.on('callback_query:data', async ctx => {
18330
18400
  return
18331
18401
  }
18332
18402
 
18403
+ // `mdl:*` — model-menu taps (/model dashboard). `mdl:s:<tag>`
18404
+ // selects a model by label-tag via a fresh picker discovery (never
18405
+ // a stale index); `mdl:r` re-renders. Strict allowFrom gate like
18406
+ // every other mutating callback family — a model switch changes the
18407
+ // fleet's quota burn profile.
18408
+ if (data.startsWith(MODEL_CALLBACK_PREFIX)) {
18409
+ const access = loadAccess()
18410
+ const senderId = String(ctx.from?.id ?? '')
18411
+ if (!access.allowFrom.includes(senderId)) {
18412
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' })
18413
+ return
18414
+ }
18415
+ // Kill-switch covers the callback family too — stale menus keep
18416
+ // their buttons after the flag flips, and the flag exists exactly
18417
+ // for "picker-driving is misbehaving" (#2263 review blocker 2).
18418
+ if (process.env.SWITCHROOM_MODEL_MENU === '0') {
18419
+ await ctx.answerCallbackQuery({ text: 'Model menu is disabled (SWITCHROOM_MODEL_MENU=0).' }).catch(() => {})
18420
+ await ctx
18421
+ .editMessageText('Model menu is disabled on this agent. Use <code>/model &lt;name&gt;</code>.', {
18422
+ parse_mode: 'HTML',
18423
+ reply_markup: { inline_keyboard: [] },
18424
+ })
18425
+ .catch(() => {})
18426
+ return
18427
+ }
18428
+ const modelDeps = buildModelDeps()
18429
+ // Mid-turn refusal is INSTANT (a sync isBusy() check, no picker drive),
18430
+ // so handle it before the "Working…" ack: toast WHY and leave the menu
18431
+ // message untouched (buttons intact) so the operator taps again when
18432
+ // idle. Editing the menu into a button-less "try again" line was the
18433
+ // "nothing happened" report — the menu looked dead.
18434
+ if (modelDeps.isBusy()) {
18435
+ await ctx
18436
+ .answerCallbackQuery({ text: '⏳ Agent is mid-turn — tap again when it’s idle', show_alert: false })
18437
+ .catch(() => {})
18438
+ return
18439
+ }
18440
+ // Ack IMMEDIATELY — the select path drives the picker (multi-second);
18441
+ // leaving the tap spinning invites a double-tap, which queues a second
18442
+ // drive behind the pane lock. A callback can only be answered once, so
18443
+ // the rich result (what was set / why it failed) is conveyed by the
18444
+ // message edit — which now ALWAYS keeps the menu buttons.
18445
+ await ctx.answerCallbackQuery({ text: 'Switching…' }).catch(() => {})
18446
+ try {
18447
+ const outcome = await handleModelMenuCallback(data, modelDeps)
18448
+ // Record a successful session switch so /status reflects what's
18449
+ // actually running. In-memory only → clears when the gateway (and thus
18450
+ // claude's session) restarts, exactly matching the session-only scope.
18451
+ if (outcome.selectedModel) {
18452
+ activeSessionModelOverride = outcome.selectedModel
18453
+ }
18454
+ // toastOnly: a no-op outcome that should not disturb the menu (defence
18455
+ // in depth — the isBusy() short-circuit above is the live path).
18456
+ if (outcome.toastOnly) return
18457
+ await ctx
18458
+ .editMessageText(outcome.reply.text, {
18459
+ parse_mode: 'HTML',
18460
+ reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
18461
+ })
18462
+ .catch(() => {})
18463
+ } catch (err) {
18464
+ process.stderr.write(
18465
+ `telegram gateway: model-menu callback failed: ${(err as Error)?.message ?? String(err)}\n`,
18466
+ )
18467
+ }
18468
+ return
18469
+ }
18470
+
18333
18471
  // `cn:cancel:<key>` — cancel a pending Microsoft connect flow (the
18334
18472
  // Cancel button on the /connect card). RFC #1873 Phase 2.
18335
18473
  if (data.startsWith('cn:')) {
@@ -102,14 +102,31 @@ export function spoolId(msg: InboundMessage): string {
102
102
  }
103
103
 
104
104
  interface SpoolRecord {
105
- t: 'put' | 'ack'
106
- id: string
105
+ t: 'put' | 'ack' | 'esc'
106
+ /** Present on `put`/`ack` (spoolId). Absent on `esc`. */
107
+ id?: string
107
108
  /** Present only on `put`. The full inbound to replay. */
108
109
  msg?: InboundMessage
109
110
  /** Present only on `put`. Owning agent (replay re-pushes per agent). */
110
111
  agent?: string
111
112
  /** Present only on `put`. ms epoch first-spooled — drives escalation. */
112
113
  firstAt?: number
114
+ /** Present only on `esc` — the chat the give-up notice was/would be
115
+ * posted to, and when. Durably records the per-chat escalation-notice
116
+ * window so a burst of undeliverable inbounds (or a multi-restart
117
+ * outage) produces ONE "couldn't deliver" notice per chat, not one
118
+ * per dropped entry. */
119
+ chat?: string | number
120
+ thread?: string
121
+ at?: number
122
+ }
123
+
124
+ /** Stable per-(chat,thread) key for coalescing give-up notices. */
125
+ function escChatKey(msg: InboundMessage): string {
126
+ const threadRaw = msg.meta?.threadId
127
+ const thread =
128
+ typeof threadRaw === 'string' && threadRaw.length > 0 ? threadRaw : '-'
129
+ return `${msg.chatId}:${thread}`
113
130
  }
114
131
 
115
132
  export interface InboundSpoolFsSeam {
@@ -134,6 +151,14 @@ export interface InboundSpoolOptions {
134
151
  escalateAfterMs?: number
135
152
  /** Rewrite-compact the JSONL once it exceeds this. Default 256 KiB. */
136
153
  compactAtBytes?: number
154
+ /** Coalescing window for the user-facing "couldn't deliver" notice,
155
+ * per chat. The window SLIDES on every escalation attempt (posted or
156
+ * suppressed), so a sustained burst posts exactly one notice and only
157
+ * re-notifies after the burst goes quiet for this long. Must exceed
158
+ * the rate at which undeliverable entries age out (the 15-min
159
+ * `escalateAfterMs` here) or back-to-back attempts wouldn't coalesce.
160
+ * Default 30 min. */
161
+ escalateNoticeCooldownMs?: number
137
162
  }
138
163
 
139
164
  export interface ReplayEntry {
@@ -165,10 +190,20 @@ export interface InboundSpool {
165
190
  * finished could land on top of the handback turn. Tombstones the
166
191
  * dropped entries durably. */
167
192
  dropMatching: (predicate: (id: string) => boolean) => number
168
- /** Escalate+drop entries older than `escalateAfterMs`. Calls
169
- * `onEscalate` once per dropped entry (post the "couldn't deliver"
170
- * card there). Returns the count escalated. Safe to call on a timer. */
171
- sweepEscalations: (onEscalate: (e: ReplayEntry) => void) => number
193
+ /** Escalate+drop entries older than `escalateAfterMs`. Every dropped
194
+ * entry is tombstoned (the promise is retracted deterministically),
195
+ * but the user-facing notice is COALESCED per chat: `onEscalate` is
196
+ * called for every dropped entry with `postNotice` indicating whether
197
+ * to actually post the "couldn't deliver" card. `postNotice` is true
198
+ * only for the first escalation to a given chat within
199
+ * `escalateNoticeCooldownMs` — a burst of undeliverable inbounds (e.g.
200
+ * a synthetic re-created every 15 min while the agent is down, across
201
+ * restarts) yields ONE notice, not one per entry. The window is
202
+ * persisted, so it holds across a gateway restart. Returns the count
203
+ * of entries dropped. Safe to call on a timer. */
204
+ sweepEscalations: (
205
+ onEscalate: (e: ReplayEntry, opts: { postNotice: boolean }) => void,
206
+ ) => number
172
207
  /** Test/observability: count of live (un-acked) ids. */
173
208
  liveCount: () => number
174
209
  }
@@ -179,11 +214,18 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
179
214
  const log = opts.log ?? ((l: string) => process.stderr.write(l))
180
215
  const escalateAfterMs = opts.escalateAfterMs ?? 15 * 60 * 1000
181
216
  const compactAtBytes = opts.compactAtBytes ?? 256 * 1024
217
+ const escalateNoticeCooldownMs = opts.escalateNoticeCooldownMs ?? 30 * 60 * 1000
182
218
 
183
219
  // In-memory projection of the on-disk log, rebuilt from the file at
184
220
  // construction. `live` maps spoolId → the put record (insertion order
185
221
  // preserved via the Map). An `ack` deletes from `live`.
186
222
  const live = new Map<string, { agent: string; msg: InboundMessage; firstAt: number }>()
223
+ // Per-chat last escalation-ATTEMPT time (posted or suppressed). Drives
224
+ // the sliding coalescing window so a burst of give-up escalations posts
225
+ // one notice. Rebuilt from durable `esc` records at construction so the
226
+ // window survives a gateway restart (the actual 2026-06-09 spam: a
227
+ // synthetic re-aged into the bound every 15 min across many restarts).
228
+ const escAttemptByChat = new Map<string, number>()
187
229
 
188
230
  function parseLine(line: string): SpoolRecord | null {
189
231
  const s = line.trim()
@@ -196,7 +238,13 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
196
238
  }
197
239
  if (rec == null || typeof rec !== 'object') return null
198
240
  const r = rec as Record<string, unknown>
199
- if (r.t !== 'put' && r.t !== 'ack') return null
241
+ if (r.t !== 'put' && r.t !== 'ack' && r.t !== 'esc') return null
242
+ if (r.t === 'esc') {
243
+ // esc records key on chat, not a spoolId.
244
+ if (typeof r.chat !== 'string' && typeof r.chat !== 'number') return null
245
+ if (typeof r.at !== 'number') return null
246
+ return r as unknown as SpoolRecord
247
+ }
200
248
  if (typeof r.id !== 'string' || r.id.length === 0) return null
201
249
  if (r.t === 'put') {
202
250
  if (r.msg == null || typeof r.msg !== 'object') return null
@@ -209,6 +257,7 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
209
257
  // Rebuild `live` from the file. Tolerates a torn last line.
210
258
  function hydrate(): void {
211
259
  live.clear()
260
+ escAttemptByChat.clear()
212
261
  if (!fs.existsSync(path)) return
213
262
  let raw = ''
214
263
  try {
@@ -221,13 +270,17 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
221
270
  if (rec == null) continue
222
271
  if (rec.t === 'put') {
223
272
  // Last put for an id wins; an ack later removes it.
224
- live.set(rec.id, {
273
+ live.set(rec.id as string, {
225
274
  agent: rec.agent as string,
226
275
  msg: rec.msg as InboundMessage,
227
276
  firstAt: rec.firstAt as number,
228
277
  })
278
+ } else if (rec.t === 'esc') {
279
+ // Last escalation-attempt time per chat wins (records are in
280
+ // append order). Restores the sliding window across a restart.
281
+ escAttemptByChat.set(`${rec.chat}:${rec.thread ?? '-'}`, rec.at as number)
229
282
  } else {
230
- live.delete(rec.id)
283
+ live.delete(rec.id as string)
231
284
  }
232
285
  }
233
286
  }
@@ -269,6 +322,22 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
269
322
  JSON.stringify({ t: 'put', id, agent: e.agent, msg: e.msg, firstAt: e.firstAt } satisfies SpoolRecord),
270
323
  )
271
324
  }
325
+ // Preserve the latest escalation-attempt time per chat so the sliding
326
+ // coalescing window isn't reset by compaction (which would let the next
327
+ // burst re-spam). One record per chat — bounded by the chat count.
328
+ for (const [key, at] of escAttemptByChat) {
329
+ const sep = key.lastIndexOf(':')
330
+ const chat = key.slice(0, sep)
331
+ const thread = key.slice(sep + 1)
332
+ lines.push(
333
+ JSON.stringify({
334
+ t: 'esc',
335
+ chat,
336
+ ...(thread !== '-' ? { thread } : {}),
337
+ at,
338
+ } satisfies SpoolRecord),
339
+ )
340
+ }
272
341
  const tmp = path + '.compact.tmp'
273
342
  try {
274
343
  fs.writeFileSync(tmp, lines.length ? lines.join('\n') + '\n' : '')
@@ -328,24 +397,46 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
328
397
  return n
329
398
  },
330
399
  sweepEscalations(onEscalate) {
331
- const cutoff = now() - escalateAfterMs
332
- let n = 0
400
+ const tNow = now()
401
+ const cutoff = tNow - escalateAfterMs
402
+ let dropped = 0
403
+ let posted = 0
333
404
  for (const [id, e] of [...live.entries()]) {
334
405
  if (e.firstAt > cutoff) continue
335
406
  live.delete(id)
336
407
  appendRecord({ t: 'ack', id }) // tombstone — promise retracted
408
+ // Coalesce the user-facing notice per chat on a SLIDING window:
409
+ // post only when the last attempt to this chat was longer ago than
410
+ // the cooldown; every attempt (posted or not) slides the window, so
411
+ // a sustained burst stays quiet after the first notice and only
412
+ // re-notifies once the burst goes quiet. Durable via `esc` records.
413
+ const key = escChatKey(e.msg)
414
+ const lastAttempt = escAttemptByChat.get(key)
415
+ const postNotice =
416
+ lastAttempt === undefined || tNow - lastAttempt >= escalateNoticeCooldownMs
417
+ escAttemptByChat.set(key, tNow)
418
+ const threadRaw = e.msg.meta?.threadId
419
+ const thread =
420
+ typeof threadRaw === 'string' && threadRaw.length > 0 ? threadRaw : undefined
421
+ appendRecord({ t: 'esc', chat: e.msg.chatId, thread, at: tNow })
337
422
  try {
338
- onEscalate({ agent: e.agent, msg: e.msg })
423
+ onEscalate({ agent: e.agent, msg: e.msg }, { postNotice })
339
424
  } catch (err) {
340
425
  log(`inbound-spool: onEscalate threw id=${id}: ${(err as Error).message}\n`)
341
426
  }
342
- n++
427
+ if (postNotice) posted++
428
+ dropped++
343
429
  }
344
- if (n > 0) {
345
- log(`inbound-spool: escalated+dropped ${n} undelivered entr${n === 1 ? 'y' : 'ies'} (older than ${escalateAfterMs}ms)\n`)
430
+ if (dropped > 0) {
431
+ const suppressed = dropped - posted
432
+ log(
433
+ `inbound-spool: escalated+dropped ${dropped} undelivered entr${dropped === 1 ? 'y' : 'ies'} ` +
434
+ `(older than ${escalateAfterMs}ms; ${posted} notice${posted === 1 ? '' : 's'} posted` +
435
+ `${suppressed > 0 ? `, ${suppressed} coalesced` : ''})\n`,
436
+ )
346
437
  maybeCompact()
347
438
  }
348
- return n
439
+ return dropped
349
440
  },
350
441
  liveCount() {
351
442
  return live.size