switchroom 0.15.0 → 0.15.2

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.
@@ -157,7 +157,7 @@ import {
157
157
  formatModelUnavailableCard,
158
158
  resolveModelUnavailableFromOperatorEvent,
159
159
  } from '../model-unavailable.js'
160
- import { runFleetAutoFallback } from '../auto-fallback-fleet.js'
160
+ import { runFleetAutoFallback, renderFallbackFailureNotice, evaluateFallbackFailureNotice, type FallbackFailureNoticeState } from '../auto-fallback-fleet.js'
161
161
  import { startRestartWatchdog } from './restart-watchdog.js'
162
162
  import { validateStringArray } from './access-validator.js'
163
163
 
@@ -258,6 +258,7 @@ import { DEFAULT_SLOT } from '../../src/auth/accounts.js'
258
258
  import { currentActiveSlot, type AuthCodeOutcome } from '../../src/auth/manager.js'
259
259
  import { injectSlashCommand as injectSlashCommandImpl } from '../../src/agents/inject.js'
260
260
  import { handleInjectCommand } from './inject-handler.js'
261
+ import { parseModelCommand, handleModelCommand } from './model-command.js'
261
262
  import { type BannerState } from '../slot-banner.js'
262
263
  import { refreshBanner } from '../slot-banner-driver.js'
263
264
  import { loadConfig as loadSwitchroomConfig, findConfigFile as findSwitchroomConfigFile } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
@@ -422,6 +423,9 @@ import {
422
423
  saveQuotaWatchState,
423
424
  patchQuotaWatchState,
424
425
  emptyAccountState,
426
+ resolveQuotaWatchTuning,
427
+ buildQuotaClaimKey,
428
+ QUOTA_WATCH_CLAIM_WINDOW_MS,
425
429
  } from '../quota-watch.js'
426
430
  import { buildSnapshotsFromState, buildSnapshotsFromCachedState } from '../auth-snapshot-format.js'
427
431
  import {
@@ -13918,6 +13922,30 @@ bot.command('inject', async ctx => {
13918
13922
  })
13919
13923
  })
13920
13924
 
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
13930
+ // 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,
13937
+ getAgentName: getMyAgentName,
13938
+ getConfiguredModel: () => {
13939
+ type AgentListResp = { agents: Array<{ name: string; model?: string | null }> }
13940
+ const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
13941
+ return data?.agents?.find(a => a.name === getMyAgentName())?.model ?? null
13942
+ },
13943
+ escapeHtml: escapeHtmlForTg,
13944
+ preBlock,
13945
+ })
13946
+ await switchroomReply(ctx, reply.text, { html: reply.html })
13947
+ })
13948
+
13921
13949
  bot.command('agentstart', async ctx => {
13922
13950
  if (!isAuthorizedSender(ctx)) return
13923
13951
  const name = ctx.match?.trim() || getMyAgentName()
@@ -14804,6 +14832,51 @@ async function fireFleetAutoFallback(triggerAgent: string, untilMs?: number): Pr
14804
14832
  )
14805
14833
  }
14806
14834
 
14835
+ /**
14836
+ * Broadcast a fleet-fallback FAILURE notice to every authorized chat.
14837
+ *
14838
+ * Why this exists: the model-unavailable card renders "Auto-failover in
14839
+ * progress — see the announcement below" BEFORE the dispatcher's outcome
14840
+ * is known. When the dispatcher errors (broker down, listState throw,
14841
+ * markExhausted failure), the success announcement never lands and the
14842
+ * card's promise is broken — the 2026-06-06→07 incident sent 12 such
14843
+ * broken-promise cards while every fallback errored "set-active requires
14844
+ * admin". The admin-gating root cause is fixed (#2206), but ANY future
14845
+ * dispatcher error reproduces the broken promise. This notice closes the
14846
+ * loop deterministically: card promised an announcement → an
14847
+ * announcement ALWAYS arrives, success or failure.
14848
+ *
14849
+ * Disable with SWITCHROOM_FLEET_FALLBACK_FAILURE_NOTICE=0 (log-only,
14850
+ * pre-fix behaviour).
14851
+ */
14852
+ let fallbackFailureNoticeState: FallbackFailureNoticeState = { lastSentAtMs: 0 }
14853
+
14854
+ function broadcastFleetFallbackFailure(triggerAgent: string, reason: string): void {
14855
+ if (process.env.SWITCHROOM_FLEET_FALLBACK_FAILURE_NOTICE === '0') return
14856
+ // Notice-level cooldown (30 min, per gateway). The fleetFallbackGate's
14857
+ // dedup window only arms on SUCCESSFUL swaps, so it bounds nothing
14858
+ // here — and the card-less quota_wall_detected trigger re-fires every
14859
+ // ~60s during a wall. Without this, a persistent broker outage would
14860
+ // stream failure notices for days. See evaluateFallbackFailureNotice.
14861
+ const verdict = evaluateFallbackFailureNotice(fallbackFailureNoticeState, Date.now())
14862
+ if (!verdict.send) {
14863
+ process.stderr.write(
14864
+ `telegram gateway: [fleet-fallback] failure notice suppressed (cooldown) agent=${triggerAgent}: ${reason}\n`,
14865
+ )
14866
+ return
14867
+ }
14868
+ fallbackFailureNoticeState = verdict.next
14869
+ const access = loadAccess()
14870
+ if (access.allowFrom.length === 0) return
14871
+ const html = renderFallbackFailureNotice(triggerAgent, reason)
14872
+ for (const chat_id of access.allowFrom) {
14873
+ void swallowingApiCall(
14874
+ () => bot.api.sendMessage(chat_id, html, { parse_mode: 'HTML' as const }),
14875
+ { chat_id, verb: 'fleet-fallback:failure-notify' },
14876
+ )
14877
+ }
14878
+ }
14879
+
14807
14880
  /** Returns true iff the dispatcher actually performed a swap (and the
14808
14881
  * user-visible announcement was broadcast). False on no-op /
14809
14882
  * error / idempotent-skip — caller uses this to decide whether to
@@ -14815,6 +14888,9 @@ async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number):
14815
14888
  process.stderr.write(
14816
14889
  `telegram gateway: [fleet-fallback] skipped agent=${triggerAgent} reason=no-broker-client\n`,
14817
14890
  )
14891
+ // The model-unavailable card may have promised an announcement —
14892
+ // keep the promise even though nothing could run.
14893
+ broadcastFleetFallbackFailure(triggerAgent, 'auth-broker unreachable (no client).')
14818
14894
  return false
14819
14895
  }
14820
14896
  const state = await client.listState()
@@ -14878,6 +14954,10 @@ async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number):
14878
14954
  process.stderr.write(
14879
14955
  `telegram gateway: [fleet-fallback] error agent=${triggerAgent}: ${(err as Error)?.message ?? err}\n`,
14880
14956
  )
14957
+ // Keep the card's "see the announcement below" promise on the error
14958
+ // path — the 06-06→07 incident sent 12 cards whose promised
14959
+ // announcement never arrived because this catch was log-only.
14960
+ broadcastFleetFallbackFailure(triggerAgent, (err as Error)?.message ?? String(err))
14881
14961
  return false
14882
14962
  }
14883
14963
  }
@@ -14969,9 +15049,34 @@ async function runCreditWatch(): Promise<void> {
14969
15049
  * State persists across restarts via `<stateDir>/quota-watch.json`.
14970
15050
  * Mirrors runCreditWatch's structure and notification routing.
14971
15051
  */
14972
- async function runQuotaWatch(): Promise<void> {
15052
+
15053
+ /**
15054
+ * Ask the broker for the fleet-wide dedup claim on one notification key.
15055
+ * FAIL-OPEN on any error: a broker that predates the `claim-notification`
15056
+ * op (skewed rollout) rejects at the protocol layer, and a transient IPC
15057
+ * failure must degrade to duplicated notifications, never dropped ones.
15058
+ */
15059
+ async function claimQuotaNotification(
15060
+ brokerClient: NonNullable<Awaited<ReturnType<typeof getAuthBrokerClient>>>,
15061
+ key: string,
15062
+ ): Promise<boolean> {
15063
+ try {
15064
+ const res = await brokerClient.claimNotification(key, QUOTA_WATCH_CLAIM_WINDOW_MS)
15065
+ return res.granted
15066
+ } catch (err) {
15067
+ process.stderr.write(`telegram gateway: quota-watch: claim failed (fail-open): ${err}\n`)
15068
+ return true
15069
+ }
15070
+ }
15071
+
15072
+ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
14973
15073
  const agentName = getMyAgentName()
14974
15074
  const stateDir = STATE_DIR
15075
+ const bootTick = opts.bootTick ?? false
15076
+ // Hardening knobs (2026-06-09 incident: fleet bounce released stale
15077
+ // recovery latches on all 11 agents at once → 26 duplicate sends).
15078
+ // See QuotaWatchTuning in quota-watch.ts for the env contract.
15079
+ const tuning = resolveQuotaWatchTuning(process.env)
14975
15080
 
14976
15081
  // Read broker state. The listState response now includes last_quota
14977
15082
  // per account — the broker's in-memory cache from previous probeQuota
@@ -15019,6 +15124,20 @@ async function runQuotaWatch(): Promise<void> {
15019
15124
  })
15020
15125
  if (fleetDecision.kind === 'notify') {
15021
15126
  for (const chat_id of access.allowFrom) {
15127
+ // Fleet-level dedup: all 11 gateways detect this same edge within
15128
+ // one poll cycle — only the broker-claim winner sends per chat.
15129
+ if (tuning.fleetDedup) {
15130
+ const granted = await claimQuotaNotification(
15131
+ brokerClient,
15132
+ buildQuotaClaimKey(FLEET_ALL_EXHAUSTED_KEY, fleetDecision.transition, chat_id),
15133
+ )
15134
+ if (!granted) {
15135
+ process.stderr.write(
15136
+ `telegram gateway: quota-watch: fleet-all-exhausted claim denied chat=${chat_id} — another agent notified\n`,
15137
+ )
15138
+ continue
15139
+ }
15140
+ }
15022
15141
  await swallowingApiCall(
15023
15142
  () =>
15024
15143
  bot.api.sendMessage(chat_id, fleetDecision.message, {
@@ -15056,10 +15175,21 @@ async function runQuotaWatch(): Promise<void> {
15056
15175
  snapshots.map((s, i) => [s.label, i]),
15057
15176
  )
15058
15177
 
15178
+ // Reconciled transitions: state advances (latch clears) but nothing is
15179
+ // sent — boot-tick and late recoveries (see QuotaWatchDecision docs).
15180
+ let reconciledCount = 0
15181
+ let mutatedState = watchState
15182
+
15059
15183
  for (const snap of snapshots) {
15060
15184
  const prev = watchState[snap.label] ?? emptyAccountState()
15061
- const decision = evaluateQuotaWatchAccount({ agentName, snap, prev, now })
15062
- if (decision.kind !== 'skip') {
15185
+ const decision = evaluateQuotaWatchAccount({ agentName, snap, prev, now, bootTick, tuning })
15186
+ if (decision.kind === 'reconcile') {
15187
+ mutatedState = patchQuotaWatchState(mutatedState, decision.accountLabel, decision.newAccountState)
15188
+ reconciledCount++
15189
+ process.stderr.write(
15190
+ `telegram gateway: quota-watch: reconciled ${decision.transition} for account=${decision.accountLabel} (${decision.reason}) — no notification\n`,
15191
+ )
15192
+ } else if (decision.kind !== 'skip') {
15063
15193
  pendingTransitions.push({
15064
15194
  accountLabel: snap.label,
15065
15195
  snapIndex: labelToSnapIndex.get(snap.label) ?? -1,
@@ -15069,7 +15199,16 @@ async function runQuotaWatch(): Promise<void> {
15069
15199
  }
15070
15200
 
15071
15201
  if (pendingTransitions.length === 0) {
15072
- return // Steady-state: no notifications, no probes, no state write.
15202
+ // Steady-state: no notifications, no probes. Persist only if a
15203
+ // reconcile advanced the latch (otherwise no state write at all).
15204
+ if (reconciledCount > 0) {
15205
+ try {
15206
+ saveQuotaWatchState(stateDir, mutatedState)
15207
+ } catch (err) {
15208
+ process.stderr.write(`telegram gateway: quota-watch state persist failed: ${err}\n`)
15209
+ }
15210
+ }
15211
+ return
15073
15212
  }
15074
15213
 
15075
15214
  // Transition detected: probe ONLY the crossing accounts to get fresh
@@ -15083,16 +15222,31 @@ async function runQuotaWatch(): Promise<void> {
15083
15222
  freshProbeMap.set(entry.label, entry.result)
15084
15223
  }
15085
15224
  } catch (err) {
15086
- // Probe failed — still send notifications using cached data.
15087
- // Don't abort: the user should know about the threshold crossing
15088
- // even if the message body shows slightly stale numbers.
15089
15225
  process.stderr.write(`telegram gateway: quota-watch: probe for crossing accounts failed: ${err}\n`)
15226
+ if (!tuning.sendOnProbeFail) {
15227
+ // A quota notification must never carry numbers we could not verify
15228
+ // live. Leave the crossing accounts' state untouched — the
15229
+ // transition re-evaluates (and re-probes) on the next 15-min tick.
15230
+ // Persist any reconciles already applied, then bail.
15231
+ if (reconciledCount > 0) {
15232
+ try {
15233
+ saveQuotaWatchState(stateDir, mutatedState)
15234
+ } catch (saveErr) {
15235
+ process.stderr.write(`telegram gateway: quota-watch state persist failed: ${saveErr}\n`)
15236
+ }
15237
+ }
15238
+ process.stderr.write(
15239
+ `telegram gateway: quota-watch: deferring ${pendingTransitions.length} notification(s) until probe succeeds\n`,
15240
+ )
15241
+ return
15242
+ }
15243
+ // Legacy (SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL=1): fall through
15244
+ // and send from cached data.
15090
15245
  }
15091
15246
 
15092
15247
  // Build final notifications, enriching the snapshot with fresh probe
15093
15248
  // data where available.
15094
- let mutatedState = watchState
15095
- const notifications: Array<{ message: string; accountLabel: string }> = []
15249
+ const notifications: Array<{ message: string; accountLabel: string; transition: string }> = []
15096
15250
 
15097
15251
  for (const { accountLabel, snapIndex, decision } of pendingTransitions) {
15098
15252
  // Re-evaluate with fresh probe data to get an accurate message body.
@@ -15100,37 +15254,88 @@ async function runQuotaWatch(): Promise<void> {
15100
15254
  const freshResult = freshProbeMap.get(accountLabel)
15101
15255
  let enrichedDecision = decision
15102
15256
  // pendingTransitions only ever holds notify decisions (pushed under
15103
- // `decision.kind !== 'skip'`). Narrow explicitly so `decision.transition`
15104
- // type-checks below; this continue never fires at runtime.
15257
+ // `decision.kind !== 'skip'` / `!== 'reconcile'`). Narrow explicitly so
15258
+ // `decision.transition` type-checks below; this continue never fires
15259
+ // at runtime.
15105
15260
  if (decision.kind !== 'notify') continue
15106
15261
  if (freshResult && freshResult.ok && snapIndex >= 0) {
15107
- const enrichedSnap = { ...snapshots[snapIndex]!, quota: freshResult.data }
15262
+ // Live numbers replace the cache and capturedAtMs is cleared so the
15263
+ // staleness gate never misfires on data we JUST probed.
15264
+ const enrichedSnap = { ...snapshots[snapIndex]!, quota: freshResult.data, capturedAtMs: undefined }
15108
15265
  const prev = watchState[accountLabel] ?? emptyAccountState()
15109
- const re = evaluateQuotaWatchAccount({ agentName, snap: enrichedSnap, prev, now })
15266
+ const re = evaluateQuotaWatchAccount({ agentName, snap: enrichedSnap, prev, now, bootTick, tuning })
15110
15267
  // If the fresh probe still shows the same transition, use the
15111
15268
  // enriched message. If it no longer shows a transition (e.g. the
15112
15269
  // account recovered in the 100ms between listState and probe),
15113
15270
  // fall through to skip this notification.
15114
15271
  if (re.kind === 'notify' && re.transition === decision.transition) {
15115
15272
  enrichedDecision = re
15273
+ } else if (re.kind === 'reconcile') {
15274
+ // Fresh data confirms the transition but it isn't news (boot-tick /
15275
+ // late recovery) — advance the latch silently.
15276
+ mutatedState = patchQuotaWatchState(mutatedState, accountLabel, re.newAccountState)
15277
+ reconciledCount++
15278
+ process.stderr.write(
15279
+ `telegram gateway: quota-watch: reconciled ${re.transition} for account=${accountLabel} (${re.reason}) — no notification\n`,
15280
+ )
15281
+ continue
15116
15282
  } else if (re.kind === 'skip') {
15117
15283
  // State normalised by the time of the probe — don't notify.
15118
15284
  continue
15119
15285
  }
15286
+ } else if (!tuning.sendOnProbeFail) {
15287
+ // No verified fresh data for this account (per-account probe failure
15288
+ // or label missing from the batch result). Same rule as the batch
15289
+ // throw above: never send unverified numbers. State untouched —
15290
+ // re-evaluated (and re-probed) next tick.
15291
+ process.stderr.write(
15292
+ `telegram gateway: quota-watch: probe unavailable for account=${accountLabel} — deferring notification\n`,
15293
+ )
15294
+ continue
15120
15295
  }
15121
15296
 
15122
15297
  if (enrichedDecision.kind !== 'notify') continue
15123
- notifications.push({ message: enrichedDecision.message, accountLabel })
15298
+ notifications.push({
15299
+ message: enrichedDecision.message,
15300
+ accountLabel,
15301
+ transition: enrichedDecision.transition,
15302
+ })
15124
15303
  mutatedState = patchQuotaWatchState(mutatedState, accountLabel, enrichedDecision.newAccountState)
15125
15304
  }
15126
15305
 
15127
15306
  if (notifications.length === 0) {
15128
- return // All transitions resolved by the time of the live probe.
15307
+ // All transitions resolved/deferred by the time of the live probe.
15308
+ // Reconciles may still have advanced the latch — persist those.
15309
+ if (reconciledCount > 0) {
15310
+ try {
15311
+ saveQuotaWatchState(stateDir, mutatedState)
15312
+ } catch (err) {
15313
+ process.stderr.write(`telegram gateway: quota-watch state persist failed: ${err}\n`)
15314
+ }
15315
+ }
15316
+ return
15129
15317
  }
15130
15318
 
15131
15319
  // Send all notifications (one message per crossing account).
15132
- for (const { message, accountLabel } of notifications) {
15320
+ for (const { message, accountLabel, transition } of notifications) {
15133
15321
  for (const chat_id of access.allowFrom) {
15322
+ // Fleet-level dedup: every agent gateway independently detects the
15323
+ // same account transition within one poll cycle. The broker claim
15324
+ // grants exactly one sender per (account, transition, chat) per
15325
+ // window — the other ten agents advance their local state silently.
15326
+ // Fail-open on claim error (see claimQuotaNotification).
15327
+ if (tuning.fleetDedup) {
15328
+ const granted = await claimQuotaNotification(
15329
+ brokerClient,
15330
+ buildQuotaClaimKey(accountLabel, transition, chat_id),
15331
+ )
15332
+ if (!granted) {
15333
+ process.stderr.write(
15334
+ `telegram gateway: quota-watch: claim denied account=${accountLabel} chat=${chat_id} — another agent notified\n`,
15335
+ )
15336
+ continue
15337
+ }
15338
+ }
15134
15339
  // Quota-watch notify — best-effort. Wrap via swallowingApiCall so
15135
15340
  // flood-wait / deleted-chat / not-found surface as a stderr log
15136
15341
  // rather than a thrown exception that aborts the loop and leaves
@@ -15260,9 +15465,11 @@ bot.command('connect', async ctx => {
15260
15465
  let isAdmin = false
15261
15466
  try {
15262
15467
  const cfg = loadSwitchroomConfig()
15263
- const me = (cfg as unknown as { agents?: Record<string, { admin?: boolean }> })
15468
+ const me = (cfg as unknown as { agents?: Record<string, { admin?: boolean; root?: boolean }> })
15264
15469
  ?.agents?.[getMyAgentName()]
15265
- isAdmin = me?.admin === true
15470
+ // `root: true` (the root-tier debugging agent) is above admin and
15471
+ // carries admin authority — see docs/root-agent.md.
15472
+ isAdmin = me?.admin === true || me?.root === true
15266
15473
  } catch { /* non-admin is the safe default */ }
15267
15474
  if (!isAuthAdmin({ isAdmin })) {
15268
15475
  await switchroomReply(
@@ -15422,8 +15629,10 @@ bot.command("auth", async ctx => {
15422
15629
  let isAdmin = false
15423
15630
  try {
15424
15631
  const cfg = loadSwitchroomConfig()
15425
- const me = (cfg as unknown as { agents?: Record<string, { admin?: boolean }> })?.agents?.[currentAgent]
15426
- isAdmin = me?.admin === true
15632
+ const me = (cfg as unknown as { agents?: Record<string, { admin?: boolean; root?: boolean }> })?.agents?.[currentAgent]
15633
+ // `root: true` (the root-tier debugging agent) is above admin and
15634
+ // carries admin authority — see docs/root-agent.md.
15635
+ isAdmin = me?.admin === true || me?.root === true
15427
15636
  } catch { /* best-effort — non-admin is the safe default */ }
15428
15637
 
15429
15638
  // `/auth add` and `/auth cancel` are gateway-routed (drive a
@@ -20453,7 +20662,12 @@ void (async () => {
20453
20662
  // settle after boot (avoids a probe race with the boot-card
20454
20663
  // quota probe that fires in the first few seconds).
20455
20664
  setTimeout(() => {
20456
- void runQuotaWatch().catch((err) => {
20665
+ // bootTick: recovery edges observed on the FIRST post-boot tick
20666
+ // reconcile silently — a fleet bounce synchronizes all agents'
20667
+ // first ticks, and a just-booted gateway can't tell "just
20668
+ // recovered" from "recovered while we were down" (the
20669
+ // 2026-06-09 26-message flood). Warnings still notify.
20670
+ void runQuotaWatch({ bootTick: true }).catch((err) => {
20457
20671
  process.stderr.write(`telegram gateway: quota-watch initial run failed: ${err}\n`)
20458
20672
  })
20459
20673
  }, 30_000)
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Telegram `/model` command — show or switch the Claude model for this
3
+ * agent's live session.
4
+ *
5
+ * `/model` (bare) shows the configured model and the switch options.
6
+ * It deliberately NEVER injects the bare `/model` verb into the claude
7
+ * pane: with no argument the CLI renders an interactive picker modal
8
+ * that nothing on the Telegram side can drive (no arrow keys, no Esc),
9
+ * which would wedge the pane — the same TUI-modal class of wedge as
10
+ * the /rate-limit-options incident. Only the argument form is ever
11
+ * injected.
12
+ *
13
+ * `/model <alias|full-id>` types claude's own `/model <name>` into the
14
+ * agent's tmux pane via the existing allowlisted inject primitive
15
+ * (`src/agents/inject.ts` — `/model` is already on the allowlist) and
16
+ * relays the captured response. This is the Claude-native mechanism:
17
+ * the unmodified CLI's REPL command, no API, no SDK, no config
18
+ * mutation. The switch is session-scoped — it lasts until the agent
19
+ * restarts; persisting requires `model:` in switchroom.yaml (cascade)
20
+ * and a restart, which the reply spells out.
21
+ *
22
+ * Split parser/handler shape mirrors `auth-command.ts` so the logic is
23
+ * unit-testable without booting the bot.
24
+ */
25
+
26
+ import type { InjectResult } from '../../src/agents/inject.js'
27
+
28
+ /**
29
+ * Aliases the claude CLI resolves natively. Listed in help text only —
30
+ * the handler does NOT restrict to these (a full model id like
31
+ * `claude-opus-4-8` passes through and claude itself validates it, so
32
+ * new aliases/models work without a switchroom release).
33
+ */
34
+ export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'default'] as const
35
+
36
+ /**
37
+ * Shape gate for the model argument. This string is typed literally
38
+ * into the agent's tmux pane, so the gate is strict by construction:
39
+ * one token, alphanumeric start, then alphanumerics plus the chars
40
+ * that appear in real model ids (`.` `_` `-` and the `[1m]`-style
41
+ * variant brackets). No whitespace means no second token can ride
42
+ * along; no control characters means no newline/Enter smuggling.
43
+ */
44
+ const MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._\[\]-]{0,99}$/
45
+
46
+ export function isValidModelArg(arg: string): boolean {
47
+ return MODEL_ARG_RE.test(arg)
48
+ }
49
+
50
+ export type ParsedModelCommand =
51
+ | { kind: 'show' }
52
+ | { kind: 'set'; model: string }
53
+ | { kind: 'help'; reason?: string }
54
+
55
+ /**
56
+ * Parse a `/model` message. Returns null when the text isn't a /model
57
+ * command at all (caller bug — bot.command should pre-filter).
58
+ */
59
+ export function parseModelCommand(text: string): ParsedModelCommand | null {
60
+ const m = text.match(/^\/model(?:@[A-Za-z0-9_]+)?(?:\s+([\s\S]*))?$/)
61
+ if (!m) return null
62
+ const rest = (m[1] ?? '').trim()
63
+ if (rest.length === 0) return { kind: 'show' }
64
+ const parts = rest.split(/\s+/)
65
+ if (parts.length > 1) {
66
+ return { kind: 'help', reason: 'model takes a single argument' }
67
+ }
68
+ const arg = parts[0]
69
+ if (arg.toLowerCase() === 'help') return { kind: 'help' }
70
+ if (!isValidModelArg(arg)) {
71
+ return { kind: 'help', reason: `not a valid model name: ${arg}` }
72
+ }
73
+ return { kind: 'set', model: arg }
74
+ }
75
+
76
+ export interface ModelCommandDeps {
77
+ /** Inject primitive — wired to injectSlashCommand in the gateway. */
78
+ inject: (agent: string, command: string) => Promise<InjectResult>
79
+ getAgentName: () => string
80
+ /**
81
+ * The agent's configured model from `switchroom agent list` (the
82
+ * cascade-resolved `model:` field). Null when unset / unreadable —
83
+ * rendered as "default".
84
+ */
85
+ getConfiguredModel: () => string | null
86
+ escapeHtml: (s: string) => string
87
+ preBlock: (s: string) => string
88
+ }
89
+
90
+ export interface ModelCommandReply {
91
+ text: string
92
+ html: true
93
+ }
94
+
95
+ const PERSIST_NOTE =
96
+ '<i>Session-only — lasts until restart. To persist, set <code>model:</code> in switchroom.yaml and restart.</i>'
97
+
98
+ function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
99
+ const lines: string[] = []
100
+ if (reason) lines.push(`⚠️ ${deps.escapeHtml(reason)}`)
101
+ lines.push(
102
+ '<b>/model</b> — show or switch the Claude model',
103
+ '<code>/model</code> — show the configured model',
104
+ `<code>/model &lt;name&gt;</code> — switch the live session (${MODEL_ALIASES.map(a => `<code>${a}</code>`).join(' · ')} or a full model id)`,
105
+ PERSIST_NOTE,
106
+ )
107
+ return { text: lines.join('\n'), html: true }
108
+ }
109
+
110
+ export async function handleModelCommand(
111
+ parsed: ParsedModelCommand,
112
+ deps: ModelCommandDeps,
113
+ ): Promise<ModelCommandReply> {
114
+ if (parsed.kind === 'help') return helpText(deps, parsed.reason)
115
+
116
+ if (parsed.kind === 'show') {
117
+ const configured = deps.getConfiguredModel()
118
+ const shown = configured && configured.length > 0 ? configured : 'default'
119
+ return {
120
+ text: [
121
+ `<b>Model — ${deps.escapeHtml(deps.getAgentName())}</b>`,
122
+ `Configured: <code>${deps.escapeHtml(shown)}</code>`,
123
+ `Switch the live session: ${MODEL_ALIASES.map(a => `<code>/model ${a}</code>`).join(' · ')}`,
124
+ 'or <code>/model &lt;full-model-id&gt;</code>',
125
+ PERSIST_NOTE,
126
+ ].join('\n'),
127
+ html: true,
128
+ }
129
+ }
130
+
131
+ // kind === 'set' — re-gate at the seam so a caller that skipped the
132
+ // parser can't type arbitrary keys into the pane.
133
+ if (!isValidModelArg(parsed.model)) {
134
+ return helpText(deps, `not a valid model name: ${parsed.model}`)
135
+ }
136
+ const verbHtml = `<code>/model ${deps.escapeHtml(parsed.model)}</code>`
137
+ let result: InjectResult
138
+ try {
139
+ result = await deps.inject(deps.getAgentName(), `/model ${parsed.model}`)
140
+ } catch (err) {
141
+ const msg = err instanceof Error ? err.message : String(err)
142
+ return {
143
+ text: `❌ ${verbHtml} — inject failed: ${deps.escapeHtml(msg)}`,
144
+ html: true,
145
+ }
146
+ }
147
+
148
+ if (result.outcome === 'ok') {
149
+ return {
150
+ text: [
151
+ `${verbHtml}`,
152
+ deps.preBlock(result.output),
153
+ ...(result.truncated ? ['<i>truncated</i>'] : []),
154
+ PERSIST_NOTE,
155
+ ].join('\n'),
156
+ html: true,
157
+ }
158
+ }
159
+
160
+ if (result.outcome === 'ok_no_output') {
161
+ return {
162
+ text: [
163
+ `${verbHtml} — sent, but no response captured. The agent may be mid-turn; check <code>/inject /status</code> to confirm the active model.`,
164
+ PERSIST_NOTE,
165
+ ].join('\n'),
166
+ html: true,
167
+ }
168
+ }
169
+
170
+ // outcome === 'failed'
171
+ if (result.errorCode === 'session_missing') {
172
+ return {
173
+ text:
174
+ '❌ tmux session not found — the agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.',
175
+ html: true,
176
+ }
177
+ }
178
+ return {
179
+ text: `❌ ${verbHtml} — ${deps.escapeHtml(result.errorMessage ?? 'inject failed')}`,
180
+ html: true,
181
+ }
182
+ }