switchroom 0.18.9 → 0.18.10
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 +1 -0
- package/dist/auth-broker/index.js +198 -13
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +28 -4
- package/dist/host-control/main.js +3 -2
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +119 -37
- package/profiles/_shared/dev-protocol.md.hbs +42 -0
- package/skills/dev-protocol/SKILL.md +131 -0
- package/telegram-plugin/README.md +2 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
- package/telegram-plugin/admin-commands/index.ts +6 -1
- package/telegram-plugin/bridge/bridge.ts +23 -1
- package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
- package/telegram-plugin/chat-lock.ts +13 -0
- package/telegram-plugin/dist/bridge/bridge.js +24 -1
- package/telegram-plugin/dist/gateway/gateway.js +1831 -263
- package/telegram-plugin/dist/server.js +29 -2
- package/telegram-plugin/fallback-card-collapse.ts +131 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
- package/telegram-plugin/gateway/effort-command.ts +47 -3
- package/telegram-plugin/gateway/gateway.ts +1435 -211
- package/telegram-plugin/gateway/model-command.ts +94 -8
- package/telegram-plugin/gateway/pending-session-command.ts +365 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
- package/telegram-plugin/gateway/session-model-file.ts +166 -23
- package/telegram-plugin/gateway/stop-command.ts +56 -0
- package/telegram-plugin/photo-precheck.ts +201 -0
- package/telegram-plugin/quota-watch.ts +141 -2
- package/telegram-plugin/registry/subagents-schema.ts +26 -3
- package/telegram-plugin/registry/subagents.test.ts +67 -0
- package/telegram-plugin/retry-api-call.ts +31 -0
- package/telegram-plugin/subagent-watcher.ts +392 -1
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
- package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
- package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
- package/telegram-plugin/tests/effort-command.test.ts +59 -2
- package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
- package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
- package/telegram-plugin/tests/model-command.test.ts +46 -3
- package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
- package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
- package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
- package/telegram-plugin/tests/quota-watch.test.ts +225 -0
- package/telegram-plugin/tests/session-model-file.test.ts +101 -2
- package/telegram-plugin/tests/stop-command.test.ts +234 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
- package/telegram-plugin/welcome-text.ts +4 -3
- package/telegram-plugin/worker-activity-feed.ts +27 -0
|
@@ -7,17 +7,26 @@
|
|
|
7
7
|
* - `.session-model` — the DURABLE session override written on every
|
|
8
8
|
* positively-confirmed `/model` switch. One-line JSON
|
|
9
9
|
* `{"model","configuredDefaultAtWrite","ts"}`. It is NOT consumed by a
|
|
10
|
-
* keep-path boot;
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* keep-path boot; it survives every restart/deploy/crash and is cleared
|
|
11
|
+
* only by explicit user action (`/model default`) or invalidation
|
|
12
|
+
* (corruption / the configured yaml default changed) — every clearing
|
|
13
|
+
* path notifies the operator chat via `.session-model-alert` (#3039).
|
|
13
14
|
*
|
|
14
15
|
* - `.relaunch-model-intent` — the ONE-SHOT intent bit for the next boot.
|
|
15
16
|
* One-line JSON `{"intent":"keep"|"revert","reason","ts"}`, atomic
|
|
16
|
-
* write, last-writer-wins. Boot default is
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* write, last-writer-wins. Boot default is KEEP (#3039 — operator
|
|
18
|
+
* decision 2026-07-11, superseding the earlier revert-by-default): a
|
|
19
|
+
* raw `docker restart` / host reboot / deploy / crash keeps a valid
|
|
20
|
+
* override. Only an explicit fresh "revert" intent reverts. start.sh
|
|
21
|
+
* consumes the file (rm -f) every boot; a stale (>10 min by the
|
|
22
|
+
* embedded ts) or corrupt intent counts as no intent (→ keep).
|
|
23
|
+
*
|
|
24
|
+
* - `.session-effort` — the DURABLE effort override (#3039), same shape
|
|
25
|
+
* and lifecycle as `.session-model` with `level` in place of `model`:
|
|
26
|
+
* `{"level","configuredDefaultAtWrite","ts"}`. Written on every
|
|
27
|
+
* positively-confirmed `/effort` apply, resolved by start.sh into the
|
|
28
|
+
* relaunch's `--effort`, cleared only by `/effort default` or
|
|
29
|
+
* invalidation (with a boot alert).
|
|
21
30
|
*
|
|
22
31
|
* The `model` token is always a canonical `claude --model` token (alias,
|
|
23
32
|
* `claude-*` id, or `sr-*` id) — NEVER a display label like "Opus 4.8".
|
|
@@ -41,20 +50,18 @@ export interface SessionModelRecord {
|
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
53
|
+
* Classify a triggerSelfRestart reason into the intent the boot should honor.
|
|
54
|
+
*
|
|
55
|
+
* Since #3039 (operator contract 2026-07-11) EVERY restart reason keeps the
|
|
56
|
+
* override: a restart — user-tapped, watchdog, deploy, or crash — is not
|
|
57
|
+
* "clear my model". The override is cleared only by explicit user action
|
|
58
|
+
* (`/model default`) or invalidation at boot, both of which run their own
|
|
59
|
+
* paths. The 'revert' intent value remains recognised by start.sh (and this
|
|
60
|
+
* classifier's signature keeps it) so an older gateway's stamp still parses,
|
|
61
|
+
* but current code never emits it.
|
|
52
62
|
*/
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
/** Classify a triggerSelfRestart reason into the intent the boot should honor. */
|
|
56
|
-
export function intentForRestartReason(reason: string): RelaunchModelIntent {
|
|
57
|
-
return REVERT_RESTART_REASONS.has(reason) ? 'revert' : 'keep'
|
|
63
|
+
export function intentForRestartReason(_reason: string): RelaunchModelIntent {
|
|
64
|
+
return 'keep'
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
function atomicWrite(path: string, content: string): void {
|
|
@@ -132,6 +139,10 @@ export function readSessionModelFile(agentDir: string): SessionModelRecord | nul
|
|
|
132
139
|
export function clearSessionModelFile(agentDir: string): void {
|
|
133
140
|
try {
|
|
134
141
|
rmSync(join(agentDir, SESSION_MODEL_FILE), { force: true })
|
|
142
|
+
// #3042 item 4: also drop the kept-alert dedup sentinel so a future
|
|
143
|
+
// override of the same name re-alerts on its first kept boot.
|
|
144
|
+
rmSync(join(agentDir, '.session-model-kept-notified'), { force: true })
|
|
145
|
+
rmSync(join(agentDir, '.session-model-boot-attempts'), { force: true })
|
|
135
146
|
} catch {
|
|
136
147
|
/* best-effort */
|
|
137
148
|
}
|
|
@@ -154,8 +165,8 @@ export function restoreSessionModelFileRaw(agentDir: string, raw: string | null)
|
|
|
154
165
|
* Stamp the one-shot relaunch intent. MUST be called synchronously BEFORE
|
|
155
166
|
* the restart signal/dispatch it describes (write-before-kill invariant —
|
|
156
167
|
* the next start.sh boot reads this to decide keep vs revert). Best-effort:
|
|
157
|
-
* a failed write means the boot falls back to the default (
|
|
158
|
-
* is
|
|
168
|
+
* a failed write means the boot falls back to the default (keep, #3039) —
|
|
169
|
+
* the user's choice is preserved either way.
|
|
159
170
|
*/
|
|
160
171
|
export function writeRelaunchModelIntent(
|
|
161
172
|
agentDir: string,
|
|
@@ -174,6 +185,61 @@ export function writeRelaunchModelIntent(
|
|
|
174
185
|
}
|
|
175
186
|
}
|
|
176
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Reason prefix the gateway's SIGTERM/SIGINT shutdown handler stamps on its
|
|
190
|
+
* deploy-survival keep-intent (#3017/#3018). Distinguishable on purpose:
|
|
191
|
+
* a gateway-only bounce (supervisor relaunch, bare gateway-unit restart)
|
|
192
|
+
* leaves that stamp UNCONSUMED on disk — start.sh only runs on a container
|
|
193
|
+
* boot — and the next gateway boot uses this prefix to recognise and clear
|
|
194
|
+
* the stale stamp (see clearStaleGatewayShutdownIntent).
|
|
195
|
+
*/
|
|
196
|
+
export const GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX = 'gateway-shutdown:'
|
|
197
|
+
|
|
198
|
+
export interface RelaunchModelIntentRecord {
|
|
199
|
+
intent: RelaunchModelIntent
|
|
200
|
+
reason: string
|
|
201
|
+
ts: number
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Parsed `.relaunch-model-intent`, or null when absent / corrupt / malformed. */
|
|
205
|
+
export function readRelaunchModelIntent(agentDir: string): RelaunchModelIntentRecord | null {
|
|
206
|
+
try {
|
|
207
|
+
const raw = readFileSync(join(agentDir, RELAUNCH_MODEL_INTENT_FILE), 'utf8')
|
|
208
|
+
const parsed = JSON.parse(raw) as Partial<RelaunchModelIntentRecord>
|
|
209
|
+
if (
|
|
210
|
+
(parsed.intent !== 'keep' && parsed.intent !== 'revert') ||
|
|
211
|
+
typeof parsed.reason !== 'string' ||
|
|
212
|
+
typeof parsed.ts !== 'number'
|
|
213
|
+
) {
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
return { intent: parsed.intent, reason: parsed.reason, ts: parsed.ts }
|
|
217
|
+
} catch {
|
|
218
|
+
return null
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Boot-time cleanup for the gateway-only-bounce hole (#3018 finding 4).
|
|
224
|
+
*
|
|
225
|
+
* A container-level stop/deploy consumes `.relaunch-model-intent` in start.sh
|
|
226
|
+
* BEFORE any gateway boots. So if a freshly-booting GATEWAY still sees an
|
|
227
|
+
* intent that a gateway shutdown handler stamped (reason carries
|
|
228
|
+
* GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX), the preceding bounce was
|
|
229
|
+
* gateway-only — the container never restarted and the stamp is stale.
|
|
230
|
+
* Left in place, it could convert a genuine crash within the 10-min
|
|
231
|
+
* freshness window into a "keep", breaking the crash-reverts policy.
|
|
232
|
+
* Clear it. Never touches a triggerSelfRestart / user-slash stamp (those
|
|
233
|
+
* use their own un-prefixed reasons and precede a container bounce).
|
|
234
|
+
* Returns true when a stale stamp was cleared.
|
|
235
|
+
*/
|
|
236
|
+
export function clearStaleGatewayShutdownIntent(agentDir: string): boolean {
|
|
237
|
+
const rec = readRelaunchModelIntent(agentDir)
|
|
238
|
+
if (rec == null || !rec.reason.startsWith(GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX)) return false
|
|
239
|
+
clearRelaunchModelIntent(agentDir)
|
|
240
|
+
return true
|
|
241
|
+
}
|
|
242
|
+
|
|
177
243
|
/** Remove a stamped intent (rollback of a failed dispatch). Best-effort. */
|
|
178
244
|
export function clearRelaunchModelIntent(agentDir: string): void {
|
|
179
245
|
try {
|
|
@@ -196,3 +262,80 @@ export function readConfiguredDefaultModel(agentDir: string): string | null {
|
|
|
196
262
|
return null
|
|
197
263
|
}
|
|
198
264
|
}
|
|
265
|
+
|
|
266
|
+
// ─── Durable session-effort override (#3039) ────────────────────────────────
|
|
267
|
+
//
|
|
268
|
+
// The `/effort` sibling of `.session-model`. Same lifecycle: written on every
|
|
269
|
+
// positively-confirmed effort apply, honored by start.sh on every boot
|
|
270
|
+
// (`--effort <level>`), cleared only by `/effort default` or invalidation
|
|
271
|
+
// (configured `thinking_effort:` changed / corrupt file — both alert once).
|
|
272
|
+
|
|
273
|
+
export const SESSION_EFFORT_FILE = '.session-effort'
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The level allowlist, duplicated from effort-command.ts to avoid a cycle
|
|
277
|
+
* (effort-command must stay gateway-agnostic; this module already imports
|
|
278
|
+
* from model-command). Kept in sync by the cross-check regression test.
|
|
279
|
+
*/
|
|
280
|
+
const EFFORT_LEVEL_RE = /^(low|medium|high|xhigh|max)$/
|
|
281
|
+
|
|
282
|
+
export interface SessionEffortRecord {
|
|
283
|
+
level: string
|
|
284
|
+
/** The cascade-resolved `thinking_effort` at write time ('' when unset). */
|
|
285
|
+
configuredDefaultAtWrite: string
|
|
286
|
+
ts: number
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Parse `.session-effort` content. Null on corrupt JSON / bad shape / non-allowlisted level. */
|
|
290
|
+
export function parseSessionEffort(text: string): SessionEffortRecord | null {
|
|
291
|
+
try {
|
|
292
|
+
const raw = JSON.parse(text) as Partial<SessionEffortRecord>
|
|
293
|
+
if (
|
|
294
|
+
typeof raw.level !== 'string' ||
|
|
295
|
+
typeof raw.configuredDefaultAtWrite !== 'string' ||
|
|
296
|
+
typeof raw.ts !== 'number' ||
|
|
297
|
+
!EFFORT_LEVEL_RE.test(raw.level)
|
|
298
|
+
) {
|
|
299
|
+
return null
|
|
300
|
+
}
|
|
301
|
+
return { level: raw.level, configuredDefaultAtWrite: raw.configuredDefaultAtWrite, ts: raw.ts }
|
|
302
|
+
} catch {
|
|
303
|
+
return null
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Write the durable effort override. Throws on a non-allowlisted level —
|
|
309
|
+
* the value is passed verbatim to `claude --effort` at the next boot.
|
|
310
|
+
*/
|
|
311
|
+
export function writeSessionEffortFile(
|
|
312
|
+
agentDir: string,
|
|
313
|
+
level: string,
|
|
314
|
+
configuredDefaultAtWrite: string | null,
|
|
315
|
+
): void {
|
|
316
|
+
if (!EFFORT_LEVEL_RE.test(level)) {
|
|
317
|
+
throw new Error(`refusing to persist non-allowlisted effort level: ${JSON.stringify(level)}`)
|
|
318
|
+
}
|
|
319
|
+
atomicWrite(
|
|
320
|
+
join(agentDir, SESSION_EFFORT_FILE),
|
|
321
|
+
`${JSON.stringify({ level, configuredDefaultAtWrite: configuredDefaultAtWrite ?? '', ts: Date.now() })}\n`,
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Parsed durable effort override, or null when absent/corrupt. */
|
|
326
|
+
export function readSessionEffortFile(agentDir: string): SessionEffortRecord | null {
|
|
327
|
+
try {
|
|
328
|
+
return parseSessionEffort(readFileSync(join(agentDir, SESSION_EFFORT_FILE), 'utf8'))
|
|
329
|
+
} catch {
|
|
330
|
+
return null
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Delete the durable effort override (`/effort default`). Best-effort. */
|
|
335
|
+
export function clearSessionEffortFile(agentDir: string): void {
|
|
336
|
+
try {
|
|
337
|
+
rmSync(join(agentDir, SESSION_EFFORT_FILE), { force: true })
|
|
338
|
+
} catch {
|
|
339
|
+
/* best-effort */
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram `/stop` command + bare operator "stop" keyword (#3020) — the
|
|
3
|
+
* mid-turn kill switch. Both cancel the agent's in-flight claude turn via
|
|
4
|
+
* the same halt sequence the empty-`!` interrupt uses (safe-boundary
|
|
5
|
+
* deferral per the interrupt config, tmux C-c, obligation cancel).
|
|
6
|
+
*
|
|
7
|
+
* This module is the pure, grammY-free core — parser + reply builder —
|
|
8
|
+
* mirroring the `effort-command.ts` split so the logic is unit-testable
|
|
9
|
+
* without booting the bot. The side-effectful halt itself
|
|
10
|
+
* (`executeHaltNow`) lives in gateway.ts next to the interrupt machinery
|
|
11
|
+
* it shares.
|
|
12
|
+
*
|
|
13
|
+
* NOTE: `/stop` used to be the hostd container-stop verb; that moved to
|
|
14
|
+
* `/agentstop` (pairing with `/agentstart`) so the intuitive word can do
|
|
15
|
+
* the intuitive thing: stop what the agent is doing right now, not stop
|
|
16
|
+
* the container.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* True iff the message is a bare operator "stop" — the exact word `stop`
|
|
21
|
+
* (case-insensitive) on its own, optionally followed by a single `.` or
|
|
22
|
+
* `!`. Anything more ("stop the build", "stop it") is normal speech and
|
|
23
|
+
* must flow to the agent as an ordinary turn.
|
|
24
|
+
*/
|
|
25
|
+
export function parseStopKeyword(text: string): boolean {
|
|
26
|
+
return /^stop[.!]?$/i.test(text.trim())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StopReply {
|
|
30
|
+
text: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the operator-facing reply for a stop request.
|
|
35
|
+
*
|
|
36
|
+
* - `turnInFlight === false` → honest "nothing running" (no C-c was sent).
|
|
37
|
+
* - `turnInFlight === true` → confirmation the in-flight turn was
|
|
38
|
+
* cancelled. If pending-session-command slots (#3017/#3018) hold queued
|
|
39
|
+
* commands, say so — a queued `/model`/`/effort` survives the halt and
|
|
40
|
+
* still applies at idle, and silence here would read as "my /model got
|
|
41
|
+
* killed too".
|
|
42
|
+
*
|
|
43
|
+
* Plain text (no HTML) — queued labels carry arbitrary model names.
|
|
44
|
+
*/
|
|
45
|
+
export function buildStopReply(turnInFlight: boolean, queuedSessionCmds: string[]): StopReply {
|
|
46
|
+
if (!turnInFlight) {
|
|
47
|
+
return { text: 'Nothing running — no in-flight turn to cancel.' }
|
|
48
|
+
}
|
|
49
|
+
const lines = ['⏹ Turn cancelled.']
|
|
50
|
+
if (queuedSessionCmds.length > 0) {
|
|
51
|
+
lines.push(
|
|
52
|
+
`Queued command${queuedSessionCmds.length > 1 ? 's' : ''} (${queuedSessionCmds.join(', ')}) survive${queuedSessionCmds.length > 1 ? '' : 's'} and will apply once the session is idle.`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
return { text: lines.join('\n') }
|
|
56
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* photo-precheck.ts — pre-send validation for images attached via the
|
|
3
|
+
* reply tool's `files` param (#3033, layer 2 of the media-dimension
|
|
4
|
+
* defense-in-depth).
|
|
5
|
+
*
|
|
6
|
+
* Telegram's photo path (sendPhoto / sendMediaGroup type:photo) rejects
|
|
7
|
+
* images it can't store as photos with a 400 PHOTO_INVALID_DIMENSIONS /
|
|
8
|
+
* PHOTO_SAVE_FILE_INVALID. One bad photo fails a WHOLE sendMediaGroup
|
|
9
|
+
* album. The reactive fallback (#3022) re-sends as documents after the
|
|
10
|
+
* 400 bounces back — this module catches the offenders BEFORE the wire
|
|
11
|
+
* so good photos still ship as an album and the bad one goes out as a
|
|
12
|
+
* document on the first attempt.
|
|
13
|
+
*
|
|
14
|
+
* Bounds (deterministic, documented):
|
|
15
|
+
* - width + height ≤ 10000 px (Bot API documented cap)
|
|
16
|
+
* - aspect ratio ≤ 10 : 1 (docs say 20:1, but the 2026-07-11
|
|
17
|
+
* clerk incident had 600x8717 — ratio 14.5:1, sum 9317 — rejected
|
|
18
|
+
* with PHOTO_INVALID_DIMENSIONS, so the documented limit is not what
|
|
19
|
+
* the server enforces for uploads. 10:1 is conservative: real phone
|
|
20
|
+
* screenshots are ≤ ~2.5:1; only page-length capture strips exceed
|
|
21
|
+
* 10:1 territory, and those are better as documents anyway.)
|
|
22
|
+
* - file size ≤ 10 MB (photo-path ceiling; documents 50MB)
|
|
23
|
+
*
|
|
24
|
+
* Probe failures (unknown/truncated format) return null and the caller
|
|
25
|
+
* keeps the photo path — the reactive #3022 fallback still backstops.
|
|
26
|
+
* No image decoding, no deps: header-only parsers for PNG / JPEG / GIF /
|
|
27
|
+
* WebP, the formats behind the gateway's PHOTO_EXTS set.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { closeSync, fstatSync, openSync, readSync } from 'node:fs'
|
|
31
|
+
|
|
32
|
+
export interface ImageProbe {
|
|
33
|
+
width: number
|
|
34
|
+
height: number
|
|
35
|
+
bytes: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Photo-path bounds — see module header for provenance. */
|
|
39
|
+
export const PHOTO_MAX_DIMENSION_SUM = 10000
|
|
40
|
+
export const PHOTO_MAX_ASPECT_RATIO = 10
|
|
41
|
+
export const PHOTO_MAX_BYTES = 10 * 1024 * 1024
|
|
42
|
+
|
|
43
|
+
/** Parse PNG dimensions: 8-byte signature then IHDR (width/height BE at 16/20). */
|
|
44
|
+
function pngDimensions(buf: Buffer): { width: number; height: number } | null {
|
|
45
|
+
if (buf.length < 24) return null
|
|
46
|
+
if (buf.readUInt32BE(0) !== 0x89504e47 || buf.readUInt32BE(4) !== 0x0d0a1a0a) return null
|
|
47
|
+
if (buf.toString('ascii', 12, 16) !== 'IHDR') return null
|
|
48
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Parse JPEG dimensions: walk markers to the first SOFn frame header. */
|
|
52
|
+
function jpegDimensions(buf: Buffer): { width: number; height: number } | null {
|
|
53
|
+
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null
|
|
54
|
+
let off = 2
|
|
55
|
+
while (off + 9 < buf.length) {
|
|
56
|
+
if (buf[off] !== 0xff) { off++; continue }
|
|
57
|
+
const marker = buf[off + 1]!
|
|
58
|
+
// Standalone markers without a length segment.
|
|
59
|
+
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01 || marker === 0xff) {
|
|
60
|
+
off += 2
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
const len = buf.readUInt16BE(off + 2)
|
|
64
|
+
// SOF0-SOF15 (excluding DHT 0xc4, JPG 0xc8, DAC 0xcc) carry dimensions.
|
|
65
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
66
|
+
return { height: buf.readUInt16BE(off + 5), width: buf.readUInt16BE(off + 7) }
|
|
67
|
+
}
|
|
68
|
+
if (len < 2) return null
|
|
69
|
+
off += 2 + len
|
|
70
|
+
}
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Parse GIF dimensions (logical screen descriptor, LE at offset 6/8). */
|
|
75
|
+
function gifDimensions(buf: Buffer): { width: number; height: number } | null {
|
|
76
|
+
if (buf.length < 10) return null
|
|
77
|
+
const sig = buf.toString('ascii', 0, 6)
|
|
78
|
+
if (sig !== 'GIF87a' && sig !== 'GIF89a') return null
|
|
79
|
+
return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Parse WebP dimensions (VP8 / VP8L / VP8X chunk variants). */
|
|
83
|
+
function webpDimensions(buf: Buffer): { width: number; height: number } | null {
|
|
84
|
+
if (buf.length < 30) return null
|
|
85
|
+
if (buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WEBP') return null
|
|
86
|
+
const chunk = buf.toString('ascii', 12, 16)
|
|
87
|
+
if (chunk === 'VP8X') {
|
|
88
|
+
// 24-bit LE minus-one canvas dimensions at 24/27.
|
|
89
|
+
const width = 1 + (buf[24]! | (buf[25]! << 8) | (buf[26]! << 16))
|
|
90
|
+
const height = 1 + (buf[27]! | (buf[28]! << 8) | (buf[29]! << 16))
|
|
91
|
+
return { width, height }
|
|
92
|
+
}
|
|
93
|
+
if (chunk === 'VP8L') {
|
|
94
|
+
if (buf[20] !== 0x2f) return null
|
|
95
|
+
const b0 = buf[21]!, b1 = buf[22]!, b2 = buf[23]!, b3 = buf[24]!
|
|
96
|
+
const width = 1 + (((b1 & 0x3f) << 8) | b0)
|
|
97
|
+
const height = 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6))
|
|
98
|
+
return { width, height }
|
|
99
|
+
}
|
|
100
|
+
if (chunk === 'VP8 ') {
|
|
101
|
+
// Lossy: frame tag at 20; sync code 9d 01 2a then 14-bit LE dims.
|
|
102
|
+
if (buf[23] !== 0x9d || buf[24] !== 0x01 || buf[25] !== 0x2a) return null
|
|
103
|
+
const width = buf.readUInt16LE(26) & 0x3fff
|
|
104
|
+
const height = buf.readUInt16LE(28) & 0x3fff
|
|
105
|
+
return { width, height }
|
|
106
|
+
}
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Header-only dimension probe. Returns null when the format is unrecognized. */
|
|
111
|
+
export function probeImageDimensions(buf: Buffer): { width: number; height: number } | null {
|
|
112
|
+
return pngDimensions(buf) ?? jpegDimensions(buf) ?? gifDimensions(buf) ?? webpDimensions(buf)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Fixed-size header reads — never readFileSync the whole image (a 10MB
|
|
117
|
+
* screenshot would be a multi-MB sync read on the event loop for a
|
|
118
|
+
* ~30-byte probe). PNG needs 24 bytes, GIF 10, WebP (VP8X/VP8L/VP8 )
|
|
119
|
+
* tops out at offset 30 — so 32 bytes covers every fixed-offset format.
|
|
120
|
+
* JPEG is the exception: SOFn sits past variable-length APPn segments
|
|
121
|
+
* (EXIF/ICC metadata), so when the 32-byte sniff says JPEG we re-read a
|
|
122
|
+
* 64KB window. If SOFn is buried deeper than that, the probe returns
|
|
123
|
+
* null and the caller keeps the photo route (#3022 reactive backstop).
|
|
124
|
+
*/
|
|
125
|
+
const HEADER_PROBE_BYTES = 32
|
|
126
|
+
const JPEG_PROBE_BYTES = 64 * 1024
|
|
127
|
+
|
|
128
|
+
/** Probe an image file on disk. Returns null on read failure or unknown format. */
|
|
129
|
+
export function probeImageFile(path: string): ImageProbe | null {
|
|
130
|
+
let fd: number | undefined
|
|
131
|
+
try {
|
|
132
|
+
fd = openSync(path, 'r')
|
|
133
|
+
const bytes = fstatSync(fd).size
|
|
134
|
+
const headBuf = Buffer.alloc(HEADER_PROBE_BYTES)
|
|
135
|
+
const headRead = readSync(fd, headBuf, 0, HEADER_PROBE_BYTES, 0)
|
|
136
|
+
let probe = headBuf.subarray(0, headRead)
|
|
137
|
+
if (headRead >= 2 && probe[0] === 0xff && probe[1] === 0xd8) {
|
|
138
|
+
// JPEG: extend to a 64KB window so the marker walk can skip
|
|
139
|
+
// APPn metadata segments and reach SOFn.
|
|
140
|
+
const jpegBuf = Buffer.alloc(Math.min(JPEG_PROBE_BYTES, bytes))
|
|
141
|
+
const jpegRead = readSync(fd, jpegBuf, 0, jpegBuf.length, 0)
|
|
142
|
+
probe = jpegBuf.subarray(0, jpegRead)
|
|
143
|
+
}
|
|
144
|
+
const dims = probeImageDimensions(probe)
|
|
145
|
+
if (!dims || dims.width <= 0 || dims.height <= 0) return null
|
|
146
|
+
return { ...dims, bytes }
|
|
147
|
+
} catch {
|
|
148
|
+
return null
|
|
149
|
+
} finally {
|
|
150
|
+
if (fd !== undefined) {
|
|
151
|
+
try { closeSync(fd) } catch { /* best-effort */ }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Why this image cannot go down Telegram's photo path — or null when it
|
|
158
|
+
* fits the bounds. The reason string is operator/agent-legible (it lands
|
|
159
|
+
* in the gateway log and, via the reactive path, in tool errors).
|
|
160
|
+
*/
|
|
161
|
+
export function photoSendViolation(probe: ImageProbe): string | null {
|
|
162
|
+
const { width, height, bytes } = probe
|
|
163
|
+
if (width + height > PHOTO_MAX_DIMENSION_SUM) {
|
|
164
|
+
return `dimensions ${width}x${height} exceed width+height<=${PHOTO_MAX_DIMENSION_SUM}`
|
|
165
|
+
}
|
|
166
|
+
const ratio = Math.max(width, height) / Math.min(width, height)
|
|
167
|
+
if (ratio > PHOTO_MAX_ASPECT_RATIO) {
|
|
168
|
+
return `aspect ratio ${ratio.toFixed(1)}:1 (${width}x${height}) exceeds ${PHOTO_MAX_ASPECT_RATIO}:1 photo bound`
|
|
169
|
+
}
|
|
170
|
+
if (bytes > PHOTO_MAX_BYTES) {
|
|
171
|
+
return `file size ${(bytes / (1024 * 1024)).toFixed(1)}MB exceeds ${PHOTO_MAX_BYTES / (1024 * 1024)}MB photo ceiling`
|
|
172
|
+
}
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Suffix for the reply tool's result text when files were rerouted from
|
|
178
|
+
* the photo path to sendDocument (precheck or reactive fallback). The
|
|
179
|
+
* agent only sees the tool result — without this it may claim an inline
|
|
180
|
+
* image rendered when it actually went out as a file attachment.
|
|
181
|
+
* Returns '' when nothing was rerouted.
|
|
182
|
+
*/
|
|
183
|
+
export function rerouteResultSuffix(reroutes: Array<{ path: string; reason: string }>): string {
|
|
184
|
+
if (reroutes.length === 0) return ''
|
|
185
|
+
const details = reroutes
|
|
186
|
+
.map((r) => `${r.path.split('/').pop() ?? r.path}: ${r.reason}`)
|
|
187
|
+
.join('; ')
|
|
188
|
+
return ` (${reroutes.length} file(s) sent as document, not inline photo: ${details})`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Decide the send route for a photo-extension file. `photo` = safe for
|
|
193
|
+
* sendPhoto/sendMediaGroup; `document` = pre-route as document (reason
|
|
194
|
+
* says why); probe failure = `photo` (reactive #3022 fallback backstops).
|
|
195
|
+
*/
|
|
196
|
+
export function classifyPhotoFile(path: string): { route: 'photo' } | { route: 'document'; reason: string } {
|
|
197
|
+
const probe = probeImageFile(path)
|
|
198
|
+
if (!probe) return { route: 'photo' }
|
|
199
|
+
const violation = photoSendViolation(probe)
|
|
200
|
+
return violation ? { route: 'document', reason: violation } : { route: 'photo' }
|
|
201
|
+
}
|
|
@@ -149,6 +149,111 @@ export function buildQuotaClaimKey(
|
|
|
149
149
|
return `quota-watch:${accountLabel}:${transition}:${chatId}`;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
// ─── Broker-initiated fleet-roll announcement (#3031 PR 3) ───────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Reserved state-map key for the last-announced broker roll. Same reserved-key
|
|
156
|
+
* trick as FLEET_ALL_EXHAUSTED_KEY: not a valid account label, so it never
|
|
157
|
+
* collides with per-account entries, and the per-account loop never sees it.
|
|
158
|
+
* `lastNotifiedAt` stores the roll's `at` timestamp (edge-trigger latch).
|
|
159
|
+
*/
|
|
160
|
+
export const FLEET_ROLL_ANNOUNCE_KEY = "__fleet_roll_announce__";
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Don't announce a roll older than this — a gateway booting hours after a
|
|
164
|
+
* roll would otherwise resurrect stale news (mirrors the late-recovery
|
|
165
|
+
* discipline). CLAMPED to the broker claim window (#3035 review, finding 1):
|
|
166
|
+
* if this ceiling exceeded the claim window, a gateway with an empty
|
|
167
|
+
* quota-watch.json running its boot tick after the claim expired but before
|
|
168
|
+
* this ceiling (e.g. T+40m under the old 60m/30m split) would pass both the
|
|
169
|
+
* latch AND the expired claim → duplicate roll card. Equal horizons close
|
|
170
|
+
* that gap by construction: any roll still young enough to announce is
|
|
171
|
+
* still inside the claim window that deduped the first announcement.
|
|
172
|
+
*/
|
|
173
|
+
export const FLEET_ROLL_MAX_AGE_MS = QUOTA_WATCH_CLAIM_WINDOW_MS;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Suppress the per-account 🟡 throttling push when a roll announcement for
|
|
177
|
+
* the SAME account fired this recently — the roll card already told the
|
|
178
|
+
* operator that account walled and the fleet moved off it; a trailing
|
|
179
|
+
* "approaching limit" push for it is noise. Matches the broker claim window
|
|
180
|
+
* so the two dedup horizons agree.
|
|
181
|
+
*/
|
|
182
|
+
export const QUOTA_WATCH_ROLL_DEDUP_MS = QUOTA_WATCH_CLAIM_WINDOW_MS;
|
|
183
|
+
|
|
184
|
+
/** Shape of `listState().last_fleet_roll` as the watch consumes it. */
|
|
185
|
+
export interface FleetRollInfo {
|
|
186
|
+
from: string;
|
|
187
|
+
to: string;
|
|
188
|
+
at: number;
|
|
189
|
+
exhausted_until?: number;
|
|
190
|
+
window?: "5h" | "7d";
|
|
191
|
+
pct?: number;
|
|
192
|
+
/**
|
|
193
|
+
* Trigger attribution (#3031 PR 2): "soft-avoid" = proactive
|
|
194
|
+
* serving-preference roll off an account APPROACHING its limits;
|
|
195
|
+
* "hard-exhaustion" = the probe saw a genuine quota wall. Absent on
|
|
196
|
+
* pre-PR-2 brokers — rendered as hard exhaustion.
|
|
197
|
+
*/
|
|
198
|
+
reason?: "soft-avoid" | "hard-exhaustion";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export type FleetRollAnnounceDecision =
|
|
202
|
+
| { kind: "notify"; message: string; newState: QuotaWatchAccountState }
|
|
203
|
+
| { kind: "skip"; reason: string };
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Edge-triggered decision for the broker-roll announcement. Fires at most
|
|
207
|
+
* once per roll event (latched on the roll's `at` timestamp), never for
|
|
208
|
+
* rolls older than `maxAgeMs`, and never re-fires in steady state.
|
|
209
|
+
*/
|
|
210
|
+
export function evaluateFleetRollAnnounce(args: {
|
|
211
|
+
roll: FleetRollInfo | null | undefined;
|
|
212
|
+
prev: QuotaWatchAccountState;
|
|
213
|
+
now: number;
|
|
214
|
+
maxAgeMs?: number;
|
|
215
|
+
}): FleetRollAnnounceDecision {
|
|
216
|
+
const { roll, prev, now } = args;
|
|
217
|
+
const maxAgeMs = args.maxAgeMs ?? FLEET_ROLL_MAX_AGE_MS;
|
|
218
|
+
if (!roll) return { kind: "skip", reason: "no-roll" };
|
|
219
|
+
if (prev.lastNotifiedAt >= roll.at) return { kind: "skip", reason: "already-announced" };
|
|
220
|
+
if (now - roll.at > maxAgeMs) return { kind: "skip", reason: "stale-roll" };
|
|
221
|
+
return {
|
|
222
|
+
kind: "notify",
|
|
223
|
+
message: buildFleetRollMessage(roll, now),
|
|
224
|
+
newState: { lastNotifiedHealth: "healthy", lastNotifiedAt: roll.at },
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Causal + reassuring announcement for a broker-initiated proactive roll.
|
|
230
|
+
* Hard exhaustion: "Switched fleet to <new> — <window> at <pct>% on <old>
|
|
231
|
+
* (resets <time>). Work continues uninterrupted."
|
|
232
|
+
* Soft-avoid (#3031 PR 2 `reason`): reads as PROACTIVE — the old account is
|
|
233
|
+
* approaching its limits, not walled; no mark/reset framing.
|
|
234
|
+
*/
|
|
235
|
+
export function buildFleetRollMessage(roll: FleetRollInfo, now: number): string {
|
|
236
|
+
const winLabel =
|
|
237
|
+
roll.window === "5h" ? "5-hour window" : roll.window === "7d" ? "7-day window" : "quota window";
|
|
238
|
+
const pctPart = typeof roll.pct === "number" ? ` at ${fmtPct(roll.pct)}` : "";
|
|
239
|
+
const resetPart =
|
|
240
|
+
typeof roll.exhausted_until === "number" && roll.exhausted_until > now
|
|
241
|
+
? ` (resets ${formatRelative(new Date(roll.exhausted_until), new Date(now))})`
|
|
242
|
+
: "";
|
|
243
|
+
const softAvoid = roll.reason === "soft-avoid";
|
|
244
|
+
const causeLine = softAvoid
|
|
245
|
+
? `Proactive switch — \`${codeSpanSafe(roll.from)}\` is approaching its limits (${winLabel}${pctPart})${resetPart}, so the fleet moved early instead of hitting the wall.`
|
|
246
|
+
: `${winLabel}${pctPart} on \`${codeSpanSafe(roll.from)}\`${resetPart}.`;
|
|
247
|
+
return [
|
|
248
|
+
`🔁 **Switched fleet to \`${codeSpanSafe(roll.to)}\`**`,
|
|
249
|
+
``,
|
|
250
|
+
causeLine,
|
|
251
|
+
`Work continues uninterrupted — agents and scheduled jobs now serve from \`${codeSpanSafe(roll.to)}\`.`,
|
|
252
|
+
``,
|
|
253
|
+
`_Automatic broker failover${softAvoid ? " (proactive, before exhaustion)" : ""}. Run /auth for fleet status; \`/auth use ${codeSpanSafe(roll.from)}\` to switch back once it ${softAvoid ? "has headroom again" : "refills"}._`,
|
|
254
|
+
].join("\n");
|
|
255
|
+
}
|
|
256
|
+
|
|
152
257
|
// ─── Decision logic ───────────────────────────────────────────────────────────
|
|
153
258
|
|
|
154
259
|
export type QuotaWatchTransition =
|
|
@@ -177,7 +282,7 @@ export type QuotaWatchDecision =
|
|
|
177
282
|
accountLabel: string;
|
|
178
283
|
newAccountState: QuotaWatchAccountState;
|
|
179
284
|
transition: QuotaWatchTransition;
|
|
180
|
-
reason: "boot-tick-recovery" | "late-recovery";
|
|
285
|
+
reason: "boot-tick-recovery" | "late-recovery" | "roll-announced";
|
|
181
286
|
}
|
|
182
287
|
| { kind: "skip"; accountLabel: string; reason: string };
|
|
183
288
|
|
|
@@ -257,6 +362,14 @@ export function evaluateQuotaWatchAccount(args: {
|
|
|
257
362
|
bootTick?: boolean;
|
|
258
363
|
/** Staleness / late-recovery thresholds; 0 disables each. */
|
|
259
364
|
tuning?: Pick<QuotaWatchTuning, "maxStaleMs" | "lateRecoveryMs">;
|
|
365
|
+
/**
|
|
366
|
+
* Most recent broker-initiated fleet roll, if any (#3031 PR 3). A fresh
|
|
367
|
+
* roll OFF this account suppresses the 🟡 entered-throttling push — the
|
|
368
|
+
* roll announcement already told the operator this account walled, so a
|
|
369
|
+
* trailing "approaching limit" for it is redundant. Latched silently
|
|
370
|
+
* (reconcile) so edge-trigger semantics hold: no steady-state re-notify.
|
|
371
|
+
*/
|
|
372
|
+
lastRoll?: Pick<FleetRollInfo, "from" | "at"> | null;
|
|
260
373
|
}): QuotaWatchDecision {
|
|
261
374
|
const { agentName, snap, prev, now } = args;
|
|
262
375
|
const bootTick = args.bootTick ?? false;
|
|
@@ -300,6 +413,28 @@ export function evaluateQuotaWatchAccount(args: {
|
|
|
300
413
|
lastNotifiedHealth: "throttling",
|
|
301
414
|
lastNotifiedAt: now,
|
|
302
415
|
};
|
|
416
|
+
// Roll-dedupe (#3031 PR 3): a fresh broker-roll announcement for this
|
|
417
|
+
// same account already covered the news — latch silently. FIRST
|
|
418
|
+
// post-roll episode only (#3035 review, finding 3): if this account's
|
|
419
|
+
// watch state has already advanced SINCE the roll (prev.lastNotifiedAt
|
|
420
|
+
// >= roll.at — e.g. it recovered to healthy post-roll and is now
|
|
421
|
+
// re-entering throttling), that is a genuinely NEW episode the roll
|
|
422
|
+
// card said nothing about, so it must notify normally.
|
|
423
|
+
const lastRoll = args.lastRoll;
|
|
424
|
+
if (
|
|
425
|
+
lastRoll &&
|
|
426
|
+
lastRoll.from === label &&
|
|
427
|
+
now - lastRoll.at <= QUOTA_WATCH_ROLL_DEDUP_MS &&
|
|
428
|
+
prev.lastNotifiedAt < lastRoll.at
|
|
429
|
+
) {
|
|
430
|
+
return {
|
|
431
|
+
kind: "reconcile",
|
|
432
|
+
accountLabel: label,
|
|
433
|
+
newAccountState: newState,
|
|
434
|
+
transition: "entered-throttling",
|
|
435
|
+
reason: "roll-announced",
|
|
436
|
+
};
|
|
437
|
+
}
|
|
303
438
|
return {
|
|
304
439
|
kind: "notify",
|
|
305
440
|
accountLabel: label,
|
|
@@ -536,8 +671,12 @@ export function buildThrottlingMessage(agentName: string, snap: AccountSnapshot)
|
|
|
536
671
|
? ""
|
|
537
672
|
: `\nThis is a non-active account. Consider \`/auth use ${codeSpanSafe(snap.label)}\` to switch, or keep it as a fallback reserve.`;
|
|
538
673
|
|
|
674
|
+
// Early warning, not an incident (#3031 PR 3): say what happens NEXT so
|
|
675
|
+
// the operator knows no action is required — if utilization keeps climbing
|
|
676
|
+
// to the failover threshold, the broker rolls the fleet to another account
|
|
677
|
+
// automatically and announces the switch.
|
|
539
678
|
const altNote = snap.isActive
|
|
540
|
-
? `\
|
|
679
|
+
? `\nNo action needed: if usage keeps climbing, the fleet will prefer another account once this reaches the failover threshold (you'll get a switch announcement). Or switch early with \`/auth use <other-account>\`, or wait for the ${winLabel} window to refill${resetStr}.`
|
|
541
680
|
: "";
|
|
542
681
|
|
|
543
682
|
return [
|