switchroom 0.18.14 → 0.18.17

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 (49) hide show
  1. package/dist/agent-scheduler/index.js +3 -0
  2. package/dist/auth-broker/index.js +473 -49
  3. package/dist/cli/notion-write-pretool.mjs +3 -0
  4. package/dist/cli/switchroom.js +1200 -1067
  5. package/dist/host-control/main.js +56 -51
  6. package/dist/vault/approvals/kernel-server.js +19 -12
  7. package/dist/vault/broker/server.js +675 -668
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +81 -139
  10. package/telegram-plugin/dist/bridge/bridge.js +21 -0
  11. package/telegram-plugin/dist/gateway/gateway.js +531 -259
  12. package/telegram-plugin/dist/server.js +22 -1
  13. package/telegram-plugin/draft-stream.ts +78 -3
  14. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +3 -4
  15. package/telegram-plugin/gateway/effort-command.ts +9 -7
  16. package/telegram-plugin/gateway/gateway.ts +310 -219
  17. package/telegram-plugin/gateway/litellm-local-notice-wiring.ts +200 -0
  18. package/telegram-plugin/gateway/model-command.ts +96 -18
  19. package/telegram-plugin/gateway/pending-session-command.ts +10 -8
  20. package/telegram-plugin/gateway/session-model-file.ts +38 -172
  21. package/telegram-plugin/litellm-local-notice.ts +189 -0
  22. package/telegram-plugin/model-unavailable.ts +214 -0
  23. package/telegram-plugin/quota-watch.ts +16 -4
  24. package/telegram-plugin/runtime-metrics.ts +47 -0
  25. package/telegram-plugin/send-gate-degraded.test.ts +9 -7
  26. package/telegram-plugin/send-gate.ts +34 -4
  27. package/telegram-plugin/session-tail.ts +14 -2
  28. package/telegram-plugin/stream-controller.ts +143 -20
  29. package/telegram-plugin/stream-reply-handler.ts +12 -2
  30. package/telegram-plugin/tests/bot-api.harness.ts +7 -2
  31. package/telegram-plugin/tests/draft-stream.test.ts +110 -1
  32. package/telegram-plugin/tests/effort-command.test.ts +4 -4
  33. package/telegram-plugin/tests/flood-windows-persistence.test.ts +2 -2
  34. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +33 -19
  35. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +47 -127
  36. package/telegram-plugin/tests/litellm-local-notice.test.ts +417 -0
  37. package/telegram-plugin/tests/model-command.test.ts +84 -1
  38. package/telegram-plugin/tests/model-unavailable.test.ts +187 -0
  39. package/telegram-plugin/tests/operator-events-session-tail.test.ts +55 -0
  40. package/telegram-plugin/tests/quota-watch.test.ts +21 -0
  41. package/telegram-plugin/tests/reaction-gate-routing.test.ts +2 -2
  42. package/telegram-plugin/tests/runtime-metrics.test.ts +24 -0
  43. package/telegram-plugin/tests/session-model-file.test.ts +7 -155
  44. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +521 -0
  45. package/telegram-plugin/tests/stream-reply-handler.test.ts +44 -0
  46. package/telegram-plugin/tests/throttle-tier.test.ts +176 -0
  47. package/telegram-plugin/tests/worker-activity-feed.test.ts +207 -0
  48. package/telegram-plugin/throttle-tier.ts +98 -1
  49. package/telegram-plugin/worker-activity-feed.ts +83 -8
@@ -17088,7 +17088,16 @@ function isTransientUpstreamSignal(text) {
17088
17088
  const lower = sample.toLowerCase();
17089
17089
  return transientUpstreamSignals.some((s) => lower.includes(s));
17090
17090
  }
17091
- var transientUpstreamSignals;
17091
+ function isLitellmProxyLocal429(text) {
17092
+ if (typeof text !== "string" || text.length === 0)
17093
+ return false;
17094
+ const sample = text.length > 16384 ? text.slice(0, 16384) : text;
17095
+ const lower = sample.toLowerCase();
17096
+ if (litellmProxyLocal429Signals.some((s) => lower.includes(s)))
17097
+ return true;
17098
+ return litellmV3LimiterSignalPair.every((s) => lower.includes(s));
17099
+ }
17100
+ var transientUpstreamSignals, litellmProxyLocal429Signals, litellmV3LimiterSignalPair;
17092
17101
  var init_model_unavailable = __esm(() => {
17093
17102
  init_quota_check();
17094
17103
  init_card_format();
@@ -17102,6 +17111,17 @@ var init_model_unavailable = __esm(() => {
17102
17111
  "would exceed your account\u2019s rate limit",
17103
17112
  "would exceed your account's rate limit"
17104
17113
  ];
17114
+ litellmProxyLocal429Signals = [
17115
+ "deployment over user-defined ratelimit",
17116
+ "model rate limit exceeded. tpm limit",
17117
+ "model rate limit exceeded. rpm limit",
17118
+ "deployment over defined rpm limit",
17119
+ "no deployments available for selected model",
17120
+ "litellm rate limit handler",
17121
+ "crossed tpm / rpm",
17122
+ "max parallel request limit reached"
17123
+ ];
17124
+ litellmV3LimiterSignalPair = ["rate limit exceeded for ", "limit type:"];
17105
17125
  });
17106
17126
 
17107
17127
  // tool-label-sidecar.ts
@@ -17534,6 +17554,7 @@ function detectErrorInTranscriptLine(line) {
17534
17554
  const errStr = typeof obj.error === "string" ? obj.error : "";
17535
17555
  const text = extractAssistantText(obj);
17536
17556
  const kind2 = status === 429 ? isTransientUpstreamSignal(`${text}
17557
+ ${errStr}`) || isLitellmProxyLocal429(`${text}
17537
17558
  ${errStr}`) ? "rate-limited" : "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
17538
17559
  return {
17539
17560
  kind: kind2,
@@ -26,6 +26,40 @@
26
26
 
27
27
  const TELEGRAM_MAX_CHARS = 32768
28
28
 
29
+ /**
30
+ * Error a transport layer (stream-controller.ts) throws from its edit
31
+ * callback when the send gate SHED the edit — it did NOT land, and the
32
+ * stream must not record the snapshot as on-screen (#3110). Marked with a
33
+ * property (not a message pattern) so the classification is exact.
34
+ *
35
+ * `flush()` recognizes this and PRESERVES the snapshot in `shedText` so a
36
+ * later `finalize()` — including the argument-less finalize the gateway's
37
+ * cleanup paths use — re-flushes it as the stream's final state instead of
38
+ * silently losing the content (review F2). It deliberately does NOT restore
39
+ * `pendingText`: `flushLoop` drains while `pendingText != null`, and a
40
+ * restore there would busy-spin the loop against an open flood window.
41
+ */
42
+ export interface DraftEditShedError extends Error {
43
+ draftEditShed: true
44
+ }
45
+
46
+ /** Build the marker error a transport throws for a gate-shed edit. */
47
+ export function makeDraftEditShedError(messageId: number | null): DraftEditShedError {
48
+ return Object.assign(
49
+ new Error(
50
+ `draft edit shed by send gate (id=${messageId ?? 'unknown'}); snapshot preserved for re-flush`,
51
+ ),
52
+ { draftEditShed: true as const },
53
+ )
54
+ }
55
+
56
+ /** True when `err` is the shed marker thrown by a stream transport. */
57
+ export function isDraftEditShedError(err: unknown): err is DraftEditShedError {
58
+ return (
59
+ err instanceof Error && (err as Partial<DraftEditShedError>).draftEditShed === true
60
+ )
61
+ }
62
+
29
63
  // Throttle defaults for the in-place engine.
30
64
  // DM chats: 400 ms — slightly more responsive than groups while staying
31
65
  // well under Telegram's practical ~1 edit/sec/message ceiling. This
@@ -116,8 +150,17 @@ export interface DraftStreamHandle {
116
150
  * Mark the stream as final. Flushes any pending text and rejects all
117
151
  * future update() calls. Returns a promise that resolves once the final
118
152
  * edit has landed (or the initial send if no edits ever fired).
153
+ *
154
+ * When `finalText` is provided, it becomes the pending snapshot for the
155
+ * final flush (superseding any older pending draft — last-write-wins).
156
+ * Callers that know a text is the LAST one (e.g. `stream_reply`
157
+ * `done=true`) MUST pass it here instead of `update(text)` +
158
+ * `finalize()`: the flush then runs with the stream already final, so
159
+ * the transport layer (stream-controller) classifies the edit that
160
+ * renders the completed answer as `critical` for the send gate — never
161
+ * shed as a cosmetic draft under flood pressure (#3110).
119
162
  */
120
- finalize(): Promise<void>
163
+ finalize(finalText?: string): Promise<void>
121
164
 
122
165
  /** Returns the captured Telegram message_id, or null if nothing has sent yet. */
123
166
  getMessageId(): number | null
@@ -163,6 +206,14 @@ export function createDraftStream(
163
206
  let messageId: number | null = config.initialMessageId ?? null
164
207
  let pendingText: string | null = null
165
208
  let lastSentText: string | null = null
209
+ /**
210
+ * Snapshot of the newest text the transport reported as SHED (thrown
211
+ * `DraftEditShedError`) — content that never landed. Cleared on any
212
+ * successful flush (a newer snapshot superseded it) and consumed by
213
+ * `finalize()` so the stream's last state is re-delivered once the
214
+ * pressure clears instead of being lost (review F2).
215
+ */
216
+ let shedText: string | null = null
166
217
  let lastSentAt = 0
167
218
  let inFlight: Promise<void> | null = null
168
219
  // Observability — per-stream fire counters for the stream-end trace.
@@ -216,11 +267,21 @@ export function createDraftStream(
216
267
  await sendViaMessage(textToSend)
217
268
  lastSentText = textToSend
218
269
  lastSentAt = Date.now()
270
+ shedText = null // a newer snapshot landed — drop any older shed one
219
271
  } catch (err) {
220
272
  const msg = (err as Error).message ?? String(err)
221
- if (/\bmessage is not modified\b/i.test(msg)) {
273
+ if (isDraftEditShedError(err)) {
274
+ // #3110 review F2: the send gate shed this edit — it did NOT land.
275
+ // Preserve the snapshot for finalize()'s re-flush (as the stream's
276
+ // final state, sent critical) instead of silently losing it. Do NOT
277
+ // restore pendingText: flushLoop drains while pendingText != null
278
+ // and would busy-spin against an open flood window.
279
+ shedText = textToSend
280
+ log?.(`stream → shed by send gate (id: ${messageId}); snapshot preserved for re-flush`)
281
+ } else if (/\bmessage is not modified\b/i.test(msg)) {
222
282
  lastSentText = textToSend
223
283
  lastSentAt = Date.now()
284
+ shedText = null // on-screen text == this snapshot; nothing to recover
224
285
  log?.(`stream → not modified (id: ${messageId})`)
225
286
  } else if (
226
287
  /\bmessage to edit not found\b/i.test(msg)
@@ -327,9 +388,14 @@ export function createDraftStream(
327
388
  return waitPromise
328
389
  },
329
390
 
330
- async finalize(): Promise<void> {
391
+ async finalize(finalText?: string): Promise<void> {
331
392
  if (final) return
332
393
  final = true
394
+ // A caller-supplied final snapshot supersedes any pending draft
395
+ // (last-write-wins) and is flushed below with `final` already set,
396
+ // so the transport classifies this edit as the answer's final
397
+ // render, not a sheddable draft (#3110).
398
+ if (finalText != null && !stopped) pendingText = finalText
333
399
  // Drain any pending updates
334
400
  if (scheduledTimer != null) {
335
401
  clearTimeout(scheduledTimer)
@@ -338,6 +404,15 @@ export function createDraftStream(
338
404
  if (inFlight) {
339
405
  await inFlight
340
406
  }
407
+ // #3110 review F2: if the newest snapshot was SHED by the send gate
408
+ // (never landed) and nothing newer is pending, re-flush it as the
409
+ // stream's final state. Checked AFTER awaiting inFlight so a flush
410
+ // that sheds mid-finalize is recovered too. A provided finalText and
411
+ // any pending draft both outrank the shed snapshot (they are newer).
412
+ if (pendingText == null && shedText != null && !stopped) {
413
+ pendingText = shedText
414
+ }
415
+ shedText = null
341
416
  if (pendingText != null && !stopped) {
342
417
  await flush()
343
418
  }
@@ -60,10 +60,9 @@ import { readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
60
60
  import { isCronIdentity } from './cron-session.js'
61
61
  import type { InboundMessage } from './ipc-protocol.js'
62
62
 
63
- /** The distinct triggerSelfRestart reason for this escalation. Classified
64
- * as intent 'keep' by intentForRestartReason (recovery bounce — session
65
- * model stickiness preserved), like every other switchroom-managed
66
- * relaunch. */
63
+ /** The distinct triggerSelfRestart reason for this escalation. Like every
64
+ * other switchroom-managed relaunch it reverts any session `/model` override
65
+ * to the configured default (session-scoped, rev 4). */
67
66
  export const BRIDGE_DEAD_RESTART_REASON = 'bridge-dead-resume'
68
67
 
69
68
  /** Default grace window before a missing bridge is treated as dead.
@@ -65,8 +65,9 @@ export function parseEffortCommand(text: string): ParsedEffortCommand | null {
65
65
  }
66
66
  const arg = parts[0]
67
67
  if (arg.toLowerCase() === 'help') return { kind: 'help' }
68
- // `/effort default` — explicit user action that clears the durable
69
- // `.session-effort` override and restores the configured default (#3039).
68
+ // `/effort default` — explicit user action that clears the session
69
+ // override (in-memory + any leftover queued-command carrier) and restores
70
+ // the configured default (#3186, session-scoped).
70
71
  if (arg.toLowerCase() === 'default') return { kind: 'default' }
71
72
  if (!isValidEffortArg(arg)) {
72
73
  return { kind: 'help', reason: `not a valid effort level: ${arg}` }
@@ -91,14 +92,15 @@ export interface EffortCommandDeps {
91
92
  */
92
93
  getConfiguredEffort: () => string | null
93
94
  /**
94
- * Delete the durable `.session-effort` override (#3039). Optional so
95
+ * Clear the session effort override (#3186: the in-memory live level plus
96
+ * any leftover queued-command `.session-effort` carrier). Optional so
95
97
  * gateway-agnostic tests can omit it; the gateway always wires it.
96
98
  */
97
99
  clearSessionEffort?: () => void
98
100
  /**
99
- * The active durable `.session-effort` override level, or null when none
100
- * (#3039). Optional; used to mark the LIVE level in the menu and the show
101
- * text honestly after a restart re-applied the override.
101
+ * The active session effort override level, or null when none (#3186:
102
+ * in-memory, session-scoped — reverts on restart). Optional; used to mark
103
+ * the LIVE level in the menu and the show text honestly.
102
104
  */
103
105
  getSessionEffort?: () => string | null
104
106
  escapeHtml: (s: string) => string
@@ -110,7 +112,7 @@ export interface EffortCommandReply {
110
112
  }
111
113
 
112
114
  const PERSIST_NOTE =
113
- '_Sticky — persists across restarts and deploys until \`/effort default\` clears it. To change the configured default, set \`thinking_effort:\` in switchroom.yaml._'
115
+ '_Session-only — this override lasts until the agent’s next restart, then reverts to the configured \`thinking_effort:\`. \`/effort default\` clears it now. To change the default permanently, set \`thinking_effort:\` in switchroom.yaml._'
114
116
 
115
117
  const LEVELS_INLINE = EFFORT_LEVELS.map(l => `\`${l}\``).join(' · ')
116
118