switchroom 0.18.15 → 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 (40) hide show
  1. package/dist/agent-scheduler/index.js +3 -0
  2. package/dist/auth-broker/index.js +432 -10
  3. package/dist/cli/notion-write-pretool.mjs +3 -0
  4. package/dist/cli/switchroom.js +50 -1
  5. package/dist/host-control/main.js +4 -1
  6. package/dist/vault/approvals/kernel-server.js +3 -0
  7. package/dist/vault/broker/server.js +3 -0
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +81 -139
  10. package/telegram-plugin/dist/gateway/gateway.js +386 -259
  11. package/telegram-plugin/draft-stream.ts +78 -3
  12. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +3 -4
  13. package/telegram-plugin/gateway/effort-command.ts +9 -7
  14. package/telegram-plugin/gateway/gateway.ts +265 -220
  15. package/telegram-plugin/gateway/litellm-local-notice-wiring.ts +200 -0
  16. package/telegram-plugin/gateway/model-command.ts +96 -18
  17. package/telegram-plugin/gateway/pending-session-command.ts +10 -8
  18. package/telegram-plugin/gateway/session-model-file.ts +38 -172
  19. package/telegram-plugin/litellm-local-notice.ts +189 -0
  20. package/telegram-plugin/quota-watch.ts +16 -4
  21. package/telegram-plugin/runtime-metrics.ts +16 -0
  22. package/telegram-plugin/send-gate-degraded.test.ts +9 -7
  23. package/telegram-plugin/send-gate.ts +34 -4
  24. package/telegram-plugin/stream-controller.ts +143 -20
  25. package/telegram-plugin/stream-reply-handler.ts +12 -2
  26. package/telegram-plugin/tests/bot-api.harness.ts +7 -2
  27. package/telegram-plugin/tests/draft-stream.test.ts +110 -1
  28. package/telegram-plugin/tests/effort-command.test.ts +4 -4
  29. package/telegram-plugin/tests/flood-windows-persistence.test.ts +2 -2
  30. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +33 -19
  31. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +47 -127
  32. package/telegram-plugin/tests/litellm-local-notice.test.ts +417 -0
  33. package/telegram-plugin/tests/model-command.test.ts +84 -1
  34. package/telegram-plugin/tests/quota-watch.test.ts +21 -0
  35. package/telegram-plugin/tests/reaction-gate-routing.test.ts +2 -2
  36. package/telegram-plugin/tests/session-model-file.test.ts +7 -155
  37. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +521 -0
  38. package/telegram-plugin/tests/stream-reply-handler.test.ts +44 -0
  39. package/telegram-plugin/tests/worker-activity-feed.test.ts +207 -0
  40. package/telegram-plugin/worker-activity-feed.ts +83 -8
@@ -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
- '_Stickypersists across restarts and deploys until \`/effort default\` clears it. To change the configured default, set \`thinking_effort:\` in switchroom.yaml._'
115
+ '_Session-onlythis 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