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.
- package/dist/agent-scheduler/index.js +23 -1
- package/dist/auth-broker/index.js +43 -3
- package/dist/cli/drive-write-pretool.mjs +23 -2
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +375 -18
- package/dist/cli/ui/index.html +67 -1
- package/dist/host-control/main.js +5 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +18 -0
- package/telegram-plugin/auth-snapshot-format.ts +9 -0
- package/telegram-plugin/auto-fallback-fleet.ts +59 -0
- package/telegram-plugin/dist/gateway/gateway.js +347 -21
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -0
- package/telegram-plugin/gateway/auth-command.ts +35 -2
- package/telegram-plugin/gateway/gateway.ts +236 -22
- package/telegram-plugin/gateway/model-command.ts +182 -0
- package/telegram-plugin/quota-watch.ts +141 -3
- package/telegram-plugin/tests/auth-quota-util-cell.test.ts +23 -0
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +71 -0
- package/telegram-plugin/tests/model-command.test.ts +205 -0
- package/telegram-plugin/tests/quota-watch.test.ts +266 -0
- package/telegram-plugin/welcome-text.ts +7 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
15104
|
-
// type-checks below; this continue never fires
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 <name></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 <full-model-id></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
|
+
}
|