switchroom 0.13.65 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +96 -81
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +1883 -1479
- package/dist/host-control/main.js +149 -149
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +1 -1
- package/profiles/_shared/telegram-style.md.hbs +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +47 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +1226 -696
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/boot-card.ts +100 -0
- package/telegram-plugin/gateway/config-snapshot.ts +274 -0
- package/telegram-plugin/gateway/gateway.ts +256 -36
- package/telegram-plugin/operator-events.ts +2 -10
- package/telegram-plugin/quota-watch.ts +276 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +133 -1
- package/telegram-plugin/tests/boot-card-render.test.ts +93 -0
- package/telegram-plugin/tests/config-snapshot.test.ts +409 -0
- package/telegram-plugin/tests/operator-events.test.ts +12 -6
- package/telegram-plugin/tests/quota-watch.test.ts +366 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +66 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +48 -0
- package/telegram-plugin/tool-activity-summary.ts +137 -0
- package/telegram-plugin/turn-flush-safety.ts +47 -0
- package/telegram-plugin/uat/assertions.ts +4 -4
|
@@ -57,6 +57,7 @@ import { allocateDraftId } from '../draft-transport.js'
|
|
|
57
57
|
import {
|
|
58
58
|
makeEmptyActivityState,
|
|
59
59
|
registerAndRender,
|
|
60
|
+
describeToolUse,
|
|
60
61
|
type ActivityState,
|
|
61
62
|
} from '../tool-activity-summary.js'
|
|
62
63
|
import { toolLabel } from '../tool-labels.js'
|
|
@@ -372,6 +373,14 @@ import {
|
|
|
372
373
|
loadCreditState,
|
|
373
374
|
saveCreditState,
|
|
374
375
|
} from '../credits-watch.js'
|
|
376
|
+
import {
|
|
377
|
+
evaluateQuotaWatchAccount,
|
|
378
|
+
loadQuotaWatchState,
|
|
379
|
+
saveQuotaWatchState,
|
|
380
|
+
patchQuotaWatchState,
|
|
381
|
+
emptyAccountState,
|
|
382
|
+
} from '../quota-watch.js'
|
|
383
|
+
import { buildSnapshotsFromState, buildSnapshotsFromCachedState } from '../auth-snapshot-format.js'
|
|
375
384
|
import {
|
|
376
385
|
writeTurnActiveMarker,
|
|
377
386
|
touchTurnActiveMarker,
|
|
@@ -3897,11 +3906,12 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3897
3906
|
const updateOutcomeLine = (() => {
|
|
3898
3907
|
try { return maybeRenderUpdateAnnouncement() ?? undefined } catch { return undefined }
|
|
3899
3908
|
})()
|
|
3909
|
+
const resolvedAgentDirForCard = agentDir ?? (process.env.TELEGRAM_STATE_DIR ? require('path').dirname(process.env.TELEGRAM_STATE_DIR) : '/tmp')
|
|
3900
3910
|
startBootCard(chatId, threadId, botApiForCard, {
|
|
3901
3911
|
agentName: agentDisplayName,
|
|
3902
3912
|
agentSlug,
|
|
3903
3913
|
version: formatBootVersion(),
|
|
3904
|
-
agentDir:
|
|
3914
|
+
agentDir: resolvedAgentDirForCard,
|
|
3905
3915
|
gatewayInfo: { pid: process.pid, startedAtMs: GATEWAY_STARTED_AT_MS },
|
|
3906
3916
|
restartReason: reason,
|
|
3907
3917
|
restartAgeMs: markerAgeMs,
|
|
@@ -3910,6 +3920,7 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3910
3920
|
probeQuotaViaBroker: (t) => probeQuotaForBootCard(agentSlug, t),
|
|
3911
3921
|
tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
|
|
3912
3922
|
dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
|
|
3923
|
+
configSnapshotPath: join(resolvedAgentDirForCard, '.config-snapshot.json'),
|
|
3913
3924
|
...(updateOutcomeLine ? { updateOutcomeLine } : {}),
|
|
3914
3925
|
}, ackMsgId).then(handle => {
|
|
3915
3926
|
activeBootCard = handle
|
|
@@ -6837,7 +6848,12 @@ async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
|
|
|
6837
6848
|
while (turn.activityPendingRender !== turn.activityLastSentRender) {
|
|
6838
6849
|
const target = turn.activityPendingRender
|
|
6839
6850
|
if (target == null) break
|
|
6840
|
-
|
|
6851
|
+
// Escape before wrapping in <i> + parse_mode HTML. The legacy
|
|
6852
|
+
// verb-count summaries were safe ASCII, but the draft-mirror's
|
|
6853
|
+
// describeToolUse content (file names, Bash descriptions, search
|
|
6854
|
+
// queries) can contain <, >, & — which would break HTML parsing
|
|
6855
|
+
// and surface literal tags (the exact #1942 bug class).
|
|
6856
|
+
const html = `<i>${escapeHtmlForTg(target)}</i>`
|
|
6841
6857
|
const chat = turn.sessionChatId
|
|
6842
6858
|
const thread = turn.sessionThreadId
|
|
6843
6859
|
// sendMessageDraft doesn't support forum threads.
|
|
@@ -7130,14 +7146,21 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
7130
7146
|
// exactly once at a time and re-running until pending matches
|
|
7131
7147
|
// the last-sent. Captures `turn` so a late drain after turn-swap
|
|
7132
7148
|
// can't corrupt the next turn's atom.
|
|
7133
|
-
// DRAFT_MIRROR (RFC draft-mirror-preview
|
|
7134
|
-
//
|
|
7135
|
-
//
|
|
7136
|
-
//
|
|
7137
|
-
// the
|
|
7138
|
-
//
|
|
7139
|
-
|
|
7140
|
-
|
|
7149
|
+
// DRAFT_MIRROR (RFC draft-mirror-preview): render each tool_use as a
|
|
7150
|
+
// human-friendly line in the live preview, using the model-authored
|
|
7151
|
+
// descriptive field (Bash.description, Read/Edit file basename,
|
|
7152
|
+
// hindsight→"Searching memory", etc. — see describeToolUse). Latest
|
|
7153
|
+
// action wins (the draft shows "doing X" live), clears on reply.
|
|
7154
|
+
// Never surfaces raw shell/query syntax — option A, uniform across
|
|
7155
|
+
// code + non-code agents.
|
|
7156
|
+
//
|
|
7157
|
+
// Flag OFF (default): the legacy generic verb-count summary
|
|
7158
|
+
// ("Ran 5 commands") via registerAndRender — byte-identical to
|
|
7159
|
+
// pre-draft-mirror behavior.
|
|
7160
|
+
if (!turn.replyCalled && !isTelegramSurfaceTool(name)) {
|
|
7161
|
+
const rendered = DRAFT_MIRROR_ENABLED
|
|
7162
|
+
? describeToolUse(name, ev.input)
|
|
7163
|
+
: registerAndRender(turn.toolActivity, name)
|
|
7141
7164
|
if (rendered != null) {
|
|
7142
7165
|
turn.activityPendingRender = rendered
|
|
7143
7166
|
if (turn.activityInFlight == null) {
|
|
@@ -7185,19 +7208,19 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
7185
7208
|
isPrivateChat: turn.isDm,
|
|
7186
7209
|
threadId: turn.sessionThreadId,
|
|
7187
7210
|
// Transport selection:
|
|
7188
|
-
// -
|
|
7189
|
-
//
|
|
7190
|
-
//
|
|
7191
|
-
//
|
|
7192
|
-
//
|
|
7193
|
-
//
|
|
7194
|
-
//
|
|
7195
|
-
//
|
|
7196
|
-
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
|
|
7200
|
-
|
|
7211
|
+
// #869-Phase1 visible-answer-stream: omit the draft API so
|
|
7212
|
+
// the lane edits a user-visible chat-timeline message
|
|
7213
|
+
// (minInitialChars:1 opens it on the first chunk). The
|
|
7214
|
+
// draft-mirror does NOT touch this lane — the canary proved
|
|
7215
|
+
// the model emits almost no interstitial assistant.text
|
|
7216
|
+
// (it thinks→tool→reply), so routing it to the draft just
|
|
7217
|
+
// emptied the preview. The draft-mirror instead renders the
|
|
7218
|
+
// tool_use stream (case 'tool_use' above) where the real
|
|
7219
|
+
// signal lives. assistant.text keeps its visible-message
|
|
7220
|
+
// home; the reply tool stays the canonical answer.
|
|
7221
|
+
...(ANSWER_STREAM_VISIBLE_ENABLED
|
|
7222
|
+
? { minInitialChars: 1 }
|
|
7223
|
+
: { sendMessageDraft: sendMessageDraftFn }),
|
|
7201
7224
|
// #1075: route through robustApiCall so flood-wait,
|
|
7202
7225
|
// benign-400, and THREAD_NOT_FOUND are handled uniformly
|
|
7203
7226
|
// instead of crashing the answer-stream loop on a deleted
|
|
@@ -11849,6 +11872,183 @@ async function runCreditWatch(): Promise<void> {
|
|
|
11849
11872
|
}
|
|
11850
11873
|
}
|
|
11851
11874
|
|
|
11875
|
+
/**
|
|
11876
|
+
* Quota threshold-tier push loop body (#E4). Reads the broker's in-memory
|
|
11877
|
+
* cached utilization (populated by previous probeQuota calls from /auth,
|
|
11878
|
+
* auto-fallback, and boot cards) via `listState.accounts[].last_quota`.
|
|
11879
|
+
* Classifies each account via classifyHealth, and fires one Telegram message
|
|
11880
|
+
* per healthy ↔ throttling transition (edge-triggered, not level-triggered).
|
|
11881
|
+
* Does NOT fire on healthy → blocked or blocked → healthy — credits-watch.ts
|
|
11882
|
+
* owns those.
|
|
11883
|
+
*
|
|
11884
|
+
* Probe discipline:
|
|
11885
|
+
* - Steady-state polls: ONE broker `listState` IPC call only (no network).
|
|
11886
|
+
* - Accounts with no cached snapshot (null last_quota): skipped silently
|
|
11887
|
+
* (classifyHealth returns 'unknown'). Cache populates from /auth, boot
|
|
11888
|
+
* card, and auto-fallback probes in normal use.
|
|
11889
|
+
* - State transition detected: ONE targeted `probeQuota` call for ONLY the
|
|
11890
|
+
* crossing account, immediately before sending the notification, to get
|
|
11891
|
+
* fresh numbers for the message body. All other steady-state accounts
|
|
11892
|
+
* are not probed.
|
|
11893
|
+
*
|
|
11894
|
+
* This replaces the previous implementation that called probeQuota for ALL
|
|
11895
|
+
* accounts unconditionally on every 15-minute poll (~768 live Anthropic
|
|
11896
|
+
* network calls/day for an 8-account fleet). The corrected version makes
|
|
11897
|
+
* 0 network calls on steady-state polls and at most 1 call per crossing
|
|
11898
|
+
* event (which is also when we need to notify the user anyway).
|
|
11899
|
+
*
|
|
11900
|
+
* State persists across restarts via `<stateDir>/quota-watch.json`.
|
|
11901
|
+
* Mirrors runCreditWatch's structure and notification routing.
|
|
11902
|
+
*/
|
|
11903
|
+
async function runQuotaWatch(): Promise<void> {
|
|
11904
|
+
const agentName = getMyAgentName()
|
|
11905
|
+
const stateDir = STATE_DIR
|
|
11906
|
+
|
|
11907
|
+
// Read broker state. The listState response now includes last_quota
|
|
11908
|
+
// per account — the broker's in-memory cache from previous probeQuota
|
|
11909
|
+
// calls. This is a local IPC call: no network, no Anthropic contact.
|
|
11910
|
+
const brokerClient = await getAuthBrokerClient(agentName)
|
|
11911
|
+
if (!brokerClient) {
|
|
11912
|
+
process.stderr.write('telegram gateway: quota-watch: broker client unavailable — skipping\n')
|
|
11913
|
+
return
|
|
11914
|
+
}
|
|
11915
|
+
|
|
11916
|
+
let listStateData: Awaited<ReturnType<typeof brokerClient.listState>>
|
|
11917
|
+
try {
|
|
11918
|
+
listStateData = await brokerClient.listState()
|
|
11919
|
+
} catch (err) {
|
|
11920
|
+
process.stderr.write(`telegram gateway: quota-watch: listState failed: ${err}\n`)
|
|
11921
|
+
return
|
|
11922
|
+
}
|
|
11923
|
+
|
|
11924
|
+
if (!listStateData.accounts || listStateData.accounts.length === 0) {
|
|
11925
|
+
return // No accounts — nothing to watch.
|
|
11926
|
+
}
|
|
11927
|
+
|
|
11928
|
+
// Build AccountSnapshot[] from cached broker state only — no live probe.
|
|
11929
|
+
// Accounts with null last_quota produce quota=null snapshots; classifyHealth
|
|
11930
|
+
// returns 'unknown'; evaluateQuotaWatchAccount skips — no false alarms.
|
|
11931
|
+
const snapshots = buildSnapshotsFromCachedState(listStateData)
|
|
11932
|
+
|
|
11933
|
+
// Load persisted per-account state.
|
|
11934
|
+
let watchState = loadQuotaWatchState(stateDir)
|
|
11935
|
+
const now = Date.now()
|
|
11936
|
+
const access = loadAccess()
|
|
11937
|
+
|
|
11938
|
+
// First pass: evaluate all accounts against cached state. Collect
|
|
11939
|
+
// labels that need a live probe (i.e. accounts with a detected transition
|
|
11940
|
+
// that we're about to notify about). We probe those to get fresh
|
|
11941
|
+
// utilization numbers for the notification body — not for classification.
|
|
11942
|
+
const pendingTransitions: Array<{
|
|
11943
|
+
accountLabel: string
|
|
11944
|
+
snapIndex: number
|
|
11945
|
+
decision: ReturnType<typeof evaluateQuotaWatchAccount>
|
|
11946
|
+
}> = []
|
|
11947
|
+
|
|
11948
|
+
const labelToSnapIndex = new Map<string, number>(
|
|
11949
|
+
snapshots.map((s, i) => [s.label, i]),
|
|
11950
|
+
)
|
|
11951
|
+
|
|
11952
|
+
for (const snap of snapshots) {
|
|
11953
|
+
const prev = watchState[snap.label] ?? emptyAccountState()
|
|
11954
|
+
const decision = evaluateQuotaWatchAccount({ agentName, snap, prev, now })
|
|
11955
|
+
if (decision.kind !== 'skip') {
|
|
11956
|
+
pendingTransitions.push({
|
|
11957
|
+
accountLabel: snap.label,
|
|
11958
|
+
snapIndex: labelToSnapIndex.get(snap.label) ?? -1,
|
|
11959
|
+
decision,
|
|
11960
|
+
})
|
|
11961
|
+
}
|
|
11962
|
+
}
|
|
11963
|
+
|
|
11964
|
+
if (pendingTransitions.length === 0) {
|
|
11965
|
+
return // Steady-state: no notifications, no probes, no state write.
|
|
11966
|
+
}
|
|
11967
|
+
|
|
11968
|
+
// Transition detected: probe ONLY the crossing accounts to get fresh
|
|
11969
|
+
// numbers for the notification message bodies. One batched RPC for all
|
|
11970
|
+
// crossing accounts (typically 1, rarely 2+).
|
|
11971
|
+
const crossingLabels = pendingTransitions.map(t => t.accountLabel)
|
|
11972
|
+
let freshProbeMap = new Map<string, Awaited<ReturnType<typeof brokerClient.probeQuota>>['results'][number]['result']>()
|
|
11973
|
+
try {
|
|
11974
|
+
const probeData = await brokerClient.probeQuota(crossingLabels, 8000)
|
|
11975
|
+
for (const entry of probeData.results) {
|
|
11976
|
+
freshProbeMap.set(entry.label, entry.result)
|
|
11977
|
+
}
|
|
11978
|
+
} catch (err) {
|
|
11979
|
+
// Probe failed — still send notifications using cached data.
|
|
11980
|
+
// Don't abort: the user should know about the threshold crossing
|
|
11981
|
+
// even if the message body shows slightly stale numbers.
|
|
11982
|
+
process.stderr.write(`telegram gateway: quota-watch: probe for crossing accounts failed: ${err}\n`)
|
|
11983
|
+
}
|
|
11984
|
+
|
|
11985
|
+
// Build final notifications, enriching the snapshot with fresh probe
|
|
11986
|
+
// data where available.
|
|
11987
|
+
let mutatedState = watchState
|
|
11988
|
+
const notifications: Array<{ message: string; accountLabel: string }> = []
|
|
11989
|
+
|
|
11990
|
+
for (const { accountLabel, snapIndex, decision } of pendingTransitions) {
|
|
11991
|
+
// Re-evaluate with fresh probe data to get an accurate message body.
|
|
11992
|
+
// If the fresh probe succeeded, replace the snap's quota with live data.
|
|
11993
|
+
const freshResult = freshProbeMap.get(accountLabel)
|
|
11994
|
+
let enrichedDecision = decision
|
|
11995
|
+
// pendingTransitions only ever holds notify decisions (pushed under
|
|
11996
|
+
// `decision.kind !== 'skip'`). Narrow explicitly so `decision.transition`
|
|
11997
|
+
// type-checks below; this continue never fires at runtime.
|
|
11998
|
+
if (decision.kind !== 'notify') continue
|
|
11999
|
+
if (freshResult && freshResult.ok && snapIndex >= 0) {
|
|
12000
|
+
const enrichedSnap = { ...snapshots[snapIndex]!, quota: freshResult.data }
|
|
12001
|
+
const prev = watchState[accountLabel] ?? emptyAccountState()
|
|
12002
|
+
const re = evaluateQuotaWatchAccount({ agentName, snap: enrichedSnap, prev, now })
|
|
12003
|
+
// If the fresh probe still shows the same transition, use the
|
|
12004
|
+
// enriched message. If it no longer shows a transition (e.g. the
|
|
12005
|
+
// account recovered in the 100ms between listState and probe),
|
|
12006
|
+
// fall through to skip this notification.
|
|
12007
|
+
if (re.kind === 'notify' && re.transition === decision.transition) {
|
|
12008
|
+
enrichedDecision = re
|
|
12009
|
+
} else if (re.kind === 'skip') {
|
|
12010
|
+
// State normalised by the time of the probe — don't notify.
|
|
12011
|
+
continue
|
|
12012
|
+
}
|
|
12013
|
+
}
|
|
12014
|
+
|
|
12015
|
+
if (enrichedDecision.kind !== 'notify') continue
|
|
12016
|
+
notifications.push({ message: enrichedDecision.message, accountLabel })
|
|
12017
|
+
mutatedState = patchQuotaWatchState(mutatedState, accountLabel, enrichedDecision.newAccountState)
|
|
12018
|
+
}
|
|
12019
|
+
|
|
12020
|
+
if (notifications.length === 0) {
|
|
12021
|
+
return // All transitions resolved by the time of the live probe.
|
|
12022
|
+
}
|
|
12023
|
+
|
|
12024
|
+
// Send all notifications (one message per crossing account).
|
|
12025
|
+
for (const { message, accountLabel } of notifications) {
|
|
12026
|
+
for (const chat_id of access.allowFrom) {
|
|
12027
|
+
// Quota-watch notify — best-effort. Wrap via swallowingApiCall so
|
|
12028
|
+
// flood-wait / deleted-chat / not-found surface as a stderr log
|
|
12029
|
+
// rather than a thrown exception that aborts the loop and leaves
|
|
12030
|
+
// half the allowFrom chats unnotified. Matches the wrapping
|
|
12031
|
+
// contract enforced by scripts/check-bot-api-wrapping.sh (#1075).
|
|
12032
|
+
await swallowingApiCall(
|
|
12033
|
+
() =>
|
|
12034
|
+
bot.api.sendMessage(chat_id, message, {
|
|
12035
|
+
parse_mode: 'HTML',
|
|
12036
|
+
link_preview_options: { is_disabled: true },
|
|
12037
|
+
}),
|
|
12038
|
+
{ chat_id, verb: 'quota-watch.notify' },
|
|
12039
|
+
)
|
|
12040
|
+
}
|
|
12041
|
+
process.stderr.write(`telegram gateway: quota-watch: notified transition for account=${accountLabel}\n`)
|
|
12042
|
+
}
|
|
12043
|
+
|
|
12044
|
+
// Persist updated state regardless of whether sends succeeded.
|
|
12045
|
+
try {
|
|
12046
|
+
saveQuotaWatchState(stateDir, mutatedState)
|
|
12047
|
+
} catch (err) {
|
|
12048
|
+
process.stderr.write(`telegram gateway: quota-watch state persist failed: ${err}\n`)
|
|
12049
|
+
}
|
|
12050
|
+
}
|
|
12051
|
+
|
|
11852
12052
|
bot.command("auth", async ctx => {
|
|
11853
12053
|
// sec WS7-F2b (#1394): `/auth` drives the auth-broker credential
|
|
11854
12054
|
// lifecycle (`/auth add` mints/attaches an Anthropic account token,
|
|
@@ -12133,8 +12333,7 @@ function resolveAgentDirForName(agent: string): string | null {
|
|
|
12133
12333
|
* restart — systemctl --user restart switchroom-<agent>
|
|
12134
12334
|
* reauth — delegate to runSwitchroomAuthCommand (same flow as /auth reauth)
|
|
12135
12335
|
* logs — post last 30 lines of journalctl for the agent
|
|
12136
|
-
*
|
|
12137
|
-
* equivalent CLI command for the user to run manually.
|
|
12336
|
+
* slot management buttons — removed (E5); use /auth use or /auth add instead.
|
|
12138
12337
|
*/
|
|
12139
12338
|
/**
|
|
12140
12339
|
* Issue #44: handle taps on the deferred-secret card's inline buttons.
|
|
@@ -13763,15 +13962,6 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
|
|
|
13763
13962
|
}
|
|
13764
13963
|
return
|
|
13765
13964
|
}
|
|
13766
|
-
case 'swap-slot':
|
|
13767
|
-
case 'add-slot': {
|
|
13768
|
-
await ctx.answerCallbackQuery({ text: 'Phase 4c will wire this' }).catch(() => {})
|
|
13769
|
-
const cmd = action === 'swap-slot' ? `auth use ${agent} <slot-name>` : `auth add ${agent}`
|
|
13770
|
-
await ctx.reply(`Phase 4c will wire ${action} buttons. Until then, run in terminal: <code>switchroom ${cmd}</code>`, {
|
|
13771
|
-
parse_mode: 'HTML',
|
|
13772
|
-
})
|
|
13773
|
-
return
|
|
13774
|
-
}
|
|
13775
13965
|
default: {
|
|
13776
13966
|
await ctx.answerCallbackQuery({ text: `Unknown action: ${action}` }).catch(() => {})
|
|
13777
13967
|
return
|
|
@@ -14666,7 +14856,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
14666
14856
|
|
|
14667
14857
|
// op:<action>:<encoded-agent> callbacks from operator-events.ts
|
|
14668
14858
|
// renderOperatorEvent(). Agent name is URL-encoded at emit (issue #24).
|
|
14669
|
-
// Actions: dismiss, restart, reauth,
|
|
14859
|
+
// Actions: dismiss, restart, reauth, logs.
|
|
14670
14860
|
if (data.startsWith('op:')) {
|
|
14671
14861
|
await handleOperatorEventCallback(ctx, data)
|
|
14672
14862
|
return
|
|
@@ -16628,11 +16818,12 @@ void (async () => {
|
|
|
16628
16818
|
const updateOutcomeLine = (() => {
|
|
16629
16819
|
try { return maybeRenderUpdateAnnouncement() ?? undefined } catch { return undefined }
|
|
16630
16820
|
})()
|
|
16821
|
+
const resolvedAgentDirForBootCard = agentDir ?? join(homedir(), '.switchroom', 'agents', agentSlug)
|
|
16631
16822
|
const handle = await startBootCard(chatId, threadId, botApiForCard, {
|
|
16632
16823
|
agentName: agentDisplayName,
|
|
16633
16824
|
agentSlug,
|
|
16634
16825
|
version: formatBootVersion(),
|
|
16635
|
-
agentDir:
|
|
16826
|
+
agentDir: resolvedAgentDirForBootCard,
|
|
16636
16827
|
gatewayInfo: { pid: process.pid, startedAtMs: GATEWAY_STARTED_AT_MS },
|
|
16637
16828
|
restartReason: reason,
|
|
16638
16829
|
restartAgeMs: markerAgeMs,
|
|
@@ -16641,6 +16832,7 @@ void (async () => {
|
|
|
16641
16832
|
probeQuotaViaBroker: (t) => probeQuotaForBootCard(agentSlug, t),
|
|
16642
16833
|
tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
|
|
16643
16834
|
dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
|
|
16835
|
+
configSnapshotPath: join(resolvedAgentDirForBootCard, '.config-snapshot.json'),
|
|
16644
16836
|
...(updateOutcomeLine ? { updateOutcomeLine } : {}),
|
|
16645
16837
|
}, ackMsgId)
|
|
16646
16838
|
activeBootCard = handle
|
|
@@ -16692,6 +16884,34 @@ void (async () => {
|
|
|
16692
16884
|
}, CREDIT_WATCH_POLL_MS).unref()
|
|
16693
16885
|
}
|
|
16694
16886
|
|
|
16887
|
+
// Proactive quota threshold-tier push (#E4). Reads broker cached
|
|
16888
|
+
// quota for all accounts in the pool, classifies health via
|
|
16889
|
+
// classifyHealth, and fires one Telegram message per
|
|
16890
|
+
// healthy ↔ throttling transition (edge-triggered). Does NOT
|
|
16891
|
+
// cover healthy → blocked or blocked → healthy — credits-watch
|
|
16892
|
+
// handles those fatal-billing transitions above.
|
|
16893
|
+
//
|
|
16894
|
+
// Cadence: 15 min by default (same as credit-watch). Each poll
|
|
16895
|
+
// calls broker listState (local IPC, cheap) + probeQuota only
|
|
16896
|
+
// when a state-change is detected (to get fresh numbers for
|
|
16897
|
+
// the notification body). SWITCHROOM_QUOTA_WATCH_POLL_MS=0 disables.
|
|
16898
|
+
const QUOTA_WATCH_POLL_MS = Number(process.env.SWITCHROOM_QUOTA_WATCH_POLL_MS ?? 15 * 60_000)
|
|
16899
|
+
if (QUOTA_WATCH_POLL_MS > 0) {
|
|
16900
|
+
// Delay the initial run by 30 s to let the broker connection
|
|
16901
|
+
// settle after boot (avoids a probe race with the boot-card
|
|
16902
|
+
// quota probe that fires in the first few seconds).
|
|
16903
|
+
setTimeout(() => {
|
|
16904
|
+
void runQuotaWatch().catch((err) => {
|
|
16905
|
+
process.stderr.write(`telegram gateway: quota-watch initial run failed: ${err}\n`)
|
|
16906
|
+
})
|
|
16907
|
+
}, 30_000)
|
|
16908
|
+
setInterval(() => {
|
|
16909
|
+
void runQuotaWatch().catch((err) => {
|
|
16910
|
+
process.stderr.write(`telegram gateway: quota-watch scheduled run failed: ${err}\n`)
|
|
16911
|
+
})
|
|
16912
|
+
}, QUOTA_WATCH_POLL_MS).unref()
|
|
16913
|
+
}
|
|
16914
|
+
|
|
16695
16915
|
// Restart-watchdog: poll systemd's NRestarts for the agent unit.
|
|
16696
16916
|
// When the count ticks up without a corresponding restart-pending
|
|
16697
16917
|
// marker (= user-initiated /restart), emit an operator event.
|
|
@@ -257,16 +257,12 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
257
257
|
text: [
|
|
258
258
|
`💳 <b>Credit balance too low</b> for <b>${agent}</b>.`,
|
|
259
259
|
detail ? `<i>${detail}</i>` : '',
|
|
260
|
-
`
|
|
260
|
+
`Use <code>/auth use <label></code> to switch account slot or <code>/auth add</code> to add one.`,
|
|
261
261
|
]
|
|
262
262
|
.filter(Boolean)
|
|
263
263
|
.join('\n'),
|
|
264
264
|
keyboard: {
|
|
265
265
|
inline_keyboard: [
|
|
266
|
-
[
|
|
267
|
-
{ text: '🔄 Swap slot', callback_data: `op:swap-slot:${encodeURIComponent(ev.agent)}` },
|
|
268
|
-
{ text: '➕ Add slot', callback_data: `op:add-slot:${encodeURIComponent(ev.agent)}` },
|
|
269
|
-
],
|
|
270
266
|
[{ text: '⏳ Wait', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
|
|
271
267
|
],
|
|
272
268
|
},
|
|
@@ -280,16 +276,12 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
280
276
|
text: [
|
|
281
277
|
`⚠️ <b>Quota exhausted</b> for <b>${agent}</b>.`,
|
|
282
278
|
detail ? `<i>${detail}</i>` : '',
|
|
283
|
-
`All account slots are at the usage limit. Switchroom will auto-fallback when another slot is available.`,
|
|
279
|
+
`All account slots are at the usage limit. Switchroom will auto-fallback when another slot is available. Use <code>/auth use <label></code> to switch manually.`,
|
|
284
280
|
]
|
|
285
281
|
.filter(Boolean)
|
|
286
282
|
.join('\n'),
|
|
287
283
|
keyboard: {
|
|
288
284
|
inline_keyboard: [
|
|
289
|
-
[
|
|
290
|
-
{ text: '🔄 Swap slot', callback_data: `op:swap-slot:${encodeURIComponent(ev.agent)}` },
|
|
291
|
-
{ text: '➕ Add slot', callback_data: `op:add-slot:${encodeURIComponent(ev.agent)}` },
|
|
292
|
-
],
|
|
293
285
|
[{ text: '⏳ Wait', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
|
|
294
286
|
],
|
|
295
287
|
},
|