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.
Files changed (62) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +198 -13
  3. package/dist/cli/notion-write-pretool.mjs +1 -0
  4. package/dist/cli/switchroom.js +28 -4
  5. package/dist/host-control/main.js +3 -2
  6. package/dist/vault/approvals/kernel-server.js +2 -1
  7. package/dist/vault/broker/server.js +2 -1
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +119 -37
  10. package/profiles/_shared/dev-protocol.md.hbs +42 -0
  11. package/skills/dev-protocol/SKILL.md +131 -0
  12. package/telegram-plugin/README.md +2 -1
  13. package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
  14. package/telegram-plugin/admin-commands/index.ts +6 -1
  15. package/telegram-plugin/bridge/bridge.ts +23 -1
  16. package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
  17. package/telegram-plugin/chat-lock.ts +13 -0
  18. package/telegram-plugin/dist/bridge/bridge.js +24 -1
  19. package/telegram-plugin/dist/gateway/gateway.js +1831 -263
  20. package/telegram-plugin/dist/server.js +29 -2
  21. package/telegram-plugin/fallback-card-collapse.ts +131 -0
  22. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
  23. package/telegram-plugin/gateway/effort-command.ts +47 -3
  24. package/telegram-plugin/gateway/gateway.ts +1435 -211
  25. package/telegram-plugin/gateway/model-command.ts +94 -8
  26. package/telegram-plugin/gateway/pending-session-command.ts +365 -0
  27. package/telegram-plugin/gateway/permission-timeout.ts +25 -0
  28. package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
  29. package/telegram-plugin/gateway/session-model-file.ts +166 -23
  30. package/telegram-plugin/gateway/stop-command.ts +56 -0
  31. package/telegram-plugin/photo-precheck.ts +201 -0
  32. package/telegram-plugin/quota-watch.ts +141 -2
  33. package/telegram-plugin/registry/subagents-schema.ts +26 -3
  34. package/telegram-plugin/registry/subagents.test.ts +67 -0
  35. package/telegram-plugin/retry-api-call.ts +31 -0
  36. package/telegram-plugin/subagent-watcher.ts +392 -1
  37. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
  38. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
  39. package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
  40. package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
  41. package/telegram-plugin/tests/effort-command.test.ts +59 -2
  42. package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
  43. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
  44. package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
  45. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
  46. package/telegram-plugin/tests/model-command.test.ts +46 -3
  47. package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
  48. package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
  49. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
  50. package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
  51. package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
  52. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
  53. package/telegram-plugin/tests/quota-watch.test.ts +225 -0
  54. package/telegram-plugin/tests/session-model-file.test.ts +101 -2
  55. package/telegram-plugin/tests/stop-command.test.ts +234 -0
  56. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
  57. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
  58. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
  59. package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
  60. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
  61. package/telegram-plugin/welcome-text.ts +4 -3
  62. 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; start.sh deletes it on revert / invalidation /
11
- * corruption / 7-day staleness, and the gateway deletes it on
12
- * `/model default`.
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 REVERT (operator decision:
17
- * a raw `docker restart` / host reboot / crash must revert to the yaml
18
- * model), so every switchroom-managed KEEP path must stamp keep-intent
19
- * BEFORE the bounce. start.sh consumes it (rm -f) every boot; a stale
20
- * (>10 min by the embedded ts) or corrupt intent counts as no intent.
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
- * Restart reasons whose semantics are "revert the session model to the
45
- * configured default". Everything else `triggerSelfRestart` fires with is a
46
- * switchroom-managed relaunch (watchdog recovery, drain-cap bounce,
47
- * turn-complete deferred restart, fleet-fallback resume, sr-to-claude model
48
- * switch, grant restarts) and KEEPS the override the whole point of the
49
- * stickiness contract. Enumerated in the RFC §3; default-keep here is safe
50
- * because only gateway code calls triggerSelfRestart, and a bounce nobody
51
- * stamped (crash, raw docker restart, deploy) reverts by boot default anyway.
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
- const REVERT_RESTART_REASONS: ReadonlySet<string> = new Set(['inline-button-restart'])
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 (revert), which
158
- * is the safe side.
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
- ? `\nConsider \`/auth use <other-account>\` if you have a healthier account, or wait for the ${winLabel} window to refill${resetStr}.`
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 [