switchroom 0.18.32 → 0.19.0

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 (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -68,6 +68,7 @@ import {
68
68
  type ConfigDiff,
69
69
  } from './config-snapshot.js'
70
70
  import { join } from 'path'
71
+ import { readFileSync, statSync } from 'fs'
71
72
  import { bootCardChatKey, loadBootCardMsgId, saveBootCardMsgId } from './boot-card-msgid.js'
72
73
  import { nonEssentialSendSuppression } from '../flood-circuit-breaker.js'
73
74
  import { loadConfig as _loadSwitchroomConfig } from '../../src/config/loader.js'
@@ -278,6 +279,131 @@ const REASON_LABEL: Record<RestartReason, string> = {
278
279
  fresh: 'fresh start',
279
280
  }
280
281
 
282
+ // ─── Routing-mode row (.routing-mode observability) ─────────────────────────
283
+
284
+ /**
285
+ * Parsed `.routing-mode` state file — one overwrite-per-boot line written by
286
+ * start.sh immediately before `exec claude`:
287
+ *
288
+ * mode=<router-root|passthrough|direct-oauth> base=<url> model=<effective>
289
+ * litellm_ok=<0|1> declared=<mode> ts=<iso>
290
+ *
291
+ * `prevBoot` marks a RENDER-TIMING hazard, not file corruption: the gateway
292
+ * (outer pass) can render the boot card BEFORE the inner pass writes this
293
+ * boot's line (the inner LiteLLM probe can hold the write back for minutes on
294
+ * a degraded proxy). When the file's mtime predates this gateway process
295
+ * start, the value belongs to the PREVIOUS boot and must be labeled as such —
296
+ * never shown as current.
297
+ */
298
+ export interface RoutingModeInfo {
299
+ /** Landed routing mode this boot: router-root | passthrough | direct-oauth. */
300
+ mode: string
301
+ /** Declared (apply-time) routing intent, when present in the file. */
302
+ declared?: string
303
+ /** Effective launched model recorded alongside the mode. */
304
+ model?: string
305
+ /** LiteLLM liveliness at boot ("1" ok / "0" not confirmed). */
306
+ litellmOk?: string
307
+ /** True when the file predates this gateway boot (stale — prev boot's value). */
308
+ prevBoot: boolean
309
+ }
310
+
311
+ /**
312
+ * Epoch-ms this CONTAINER booted, derived from PID 1's start time
313
+ * (`/proc/stat` btime + `/proc/1/stat` field 22 in USER_HZ ticks). This is the
314
+ * correct staleness threshold for `.routing-mode`: start.sh writes that file
315
+ * ONCE per container boot, before exec-ing claude/the gateway. Keying staleness
316
+ * on the gateway PROCESS start (`Date.now() - process.uptime()*1000`) mislabels
317
+ * a gateway-only respawn — supervisor restarts the gateway process while the
318
+ * container (and its still-current `.routing-mode`) stays up — as "(prev boot)".
319
+ * Container boot time is stable across such respawns (LOW-1). Returns null when
320
+ * /proc is unavailable or unparseable (non-Linux, tests), so callers fall back
321
+ * to the process-start estimate.
322
+ */
323
+ export function containerBootStartMs(
324
+ fsImpl: { readFileSync: typeof readFileSync } = { readFileSync },
325
+ ): number | null {
326
+ try {
327
+ const btimeLine = fsImpl
328
+ .readFileSync('/proc/stat', 'utf-8')
329
+ .split('\n')
330
+ .find((l) => l.startsWith('btime '))
331
+ if (!btimeLine) return null
332
+ const btimeSec = Number(btimeLine.split(/\s+/)[1])
333
+ if (!Number.isFinite(btimeSec)) return null
334
+ // /proc/1/stat field 22 (starttime) is in clock ticks since system boot.
335
+ // Fields 2 (comm) may contain spaces/parens, so split on the last ')'.
336
+ const stat = fsImpl.readFileSync('/proc/1/stat', 'utf-8')
337
+ const afterComm = stat.slice(stat.lastIndexOf(')') + 2)
338
+ const fields = afterComm.split(/\s+/)
339
+ // starttime is field 22 overall → index 19 of the post-comm slice
340
+ // (field 1 pid + field 2 comm consumed; field 3 becomes index 0).
341
+ const startTicks = Number(fields[19])
342
+ if (!Number.isFinite(startTicks)) return null
343
+ const HZ = 100 // USER_HZ on effectively all Linux; sysconf unavailable in node
344
+ return (btimeSec + startTicks / HZ) * 1000
345
+ } catch {
346
+ return null
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Read + parse `<agentDir>/.routing-mode`. Returns null when the file is
352
+ * absent, unreadable, or carries no `mode=` field (tolerant by design — the
353
+ * boot card must render fine on fleets that pre-date the state file).
354
+ *
355
+ * `bootStartMs` is the epoch-ms this CONTAINER booted (see
356
+ * `containerBootStartMs`); a file mtime strictly older than it belongs to a
357
+ * previous container boot and is flagged `prevBoot` so the renderer can label
358
+ * the row instead of presenting stale data as current. Keyed on container boot
359
+ * (not gateway process start) so a gateway-only respawn does not mislabel the
360
+ * still-current file.
361
+ */
362
+ export function readRoutingMode(
363
+ agentDir: string,
364
+ bootStartMs: number,
365
+ fsImpl: { readFileSync: typeof readFileSync; statSync: typeof statSync } = { readFileSync, statSync },
366
+ ): RoutingModeInfo | null {
367
+ const path = join(agentDir, '.routing-mode')
368
+ try {
369
+ const raw = fsImpl.readFileSync(path, 'utf-8')
370
+ const line = raw.split('\n')[0] ?? ''
371
+ const field = (key: string): string | undefined => {
372
+ const m = line.match(new RegExp(`(?:^|\\s)${key}=([^\\s]+)`))
373
+ return m ? m[1] : undefined
374
+ }
375
+ const mode = field('mode')
376
+ if (!mode) return null
377
+ const mtimeMs = fsImpl.statSync(path).mtimeMs
378
+ return {
379
+ mode,
380
+ declared: field('declared'),
381
+ model: field('model'),
382
+ litellmOk: field('litellm_ok'),
383
+ prevBoot: mtimeMs < bootStartMs,
384
+ }
385
+ } catch {
386
+ return null
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Render the routing row for the boot card. Divergence (landed ≠ declared)
392
+ * gets the warning dot; a converged mode renders as a dim informational row —
393
+ * deliberately always visible (2026-07-17 incident: both divergence cases
394
+ * were SILENT; the row is the standing observability surface).
395
+ */
396
+ export function renderRoutingRow(r: RoutingModeInfo): string {
397
+ const diverged = r.declared != null && r.declared !== '' && r.mode !== r.declared
398
+ const dot = diverged ? DOT.degraded : '🔀'
399
+ const parts: string[] = [escapeMarkdown(r.mode)]
400
+ if (diverged) parts.push(`≠ declared ${escapeMarkdown(r.declared as string)}`)
401
+ if (r.model) parts.push(`\`${r.model}\``)
402
+ if (r.litellmOk != null) parts.push(r.litellmOk === '1' ? 'litellm ok' : 'litellm unconfirmed')
403
+ const suffix = r.prevBoot ? ' _(prev boot)_' : ''
404
+ return `${dot} **Routing** ${parts.join(' · ')}${suffix}`
405
+ }
406
+
281
407
  export interface RenderBootCardOpts {
282
408
  agentName: string
283
409
  /** Lowercase slug used for systemd unit names. Falls back to
@@ -338,6 +464,14 @@ export interface RenderBootCardOpts {
338
464
  * persisted snapshot from the prior boot. See `config-snapshot.ts`.
339
465
  */
340
466
  configChanges?: ConfigDiff
467
+ /**
468
+ * Parsed `.routing-mode` state (see `readRoutingMode`). When present, a
469
+ * "Routing" row is rendered — always visible, warning-dotted on
470
+ * landed≠declared divergence, suffixed "(prev boot)" when the file
471
+ * predates this gateway boot. Absent/null = row omitted entirely
472
+ * (fleets pre-dating the state file, unreadable file).
473
+ */
474
+ routing?: RoutingModeInfo | null
341
475
  }
342
476
 
343
477
  /**
@@ -456,8 +590,13 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
456
590
  }
457
591
  }
458
592
 
593
+ // Routing row (.routing-mode observability) — rendered whenever the state
594
+ // file was readable. Sits with the probe rows so divergence reads like any
595
+ // other degraded surface.
596
+ const routingRows: string[] = opts.routing ? [renderRoutingRow(opts.routing)] : []
597
+
459
598
  const sections: string[] = [ack]
460
- if (degradedRows.length > 0) sections.push('', ...degradedRows)
599
+ if (degradedRows.length > 0 || routingRows.length > 0) sections.push('', ...degradedRows, ...routingRows)
461
600
  if (accountRows.length > 0) sections.push('', ...accountRows)
462
601
  if (opts.updateOutcomeLine) {
463
602
  // PR C: each line of the update-outcome blob is its own row in the
@@ -616,6 +755,15 @@ export interface RunProbesOpts {
616
755
  floodStatePath?: string
617
756
  /** Injectable clock for the flood-wait check (tests). Defaults to Date.now. */
618
757
  nowMs?: () => number
758
+ /**
759
+ * Epoch-ms this gateway boot started — the staleness threshold for the
760
+ * `.routing-mode` row (a file mtime older than this is the PREVIOUS boot's
761
+ * value and is labeled, never shown as current). Defaults to the CONTAINER
762
+ * boot time (`containerBootStartMs`, stable across gateway-only respawns),
763
+ * falling back to the gateway process start when /proc is unavailable. Test
764
+ * override.
765
+ */
766
+ routingBootStartMs?: number
619
767
  }
620
768
 
621
769
  /** Run all six probes concurrently with their own per-probe timeouts.
@@ -879,6 +1027,23 @@ export async function startBootCard(
879
1027
  }
880
1028
  }
881
1029
 
1030
+ // Routing-mode row (.routing-mode observability). Read at settle
1031
+ // time — by then a healthy inner pass has usually written this
1032
+ // boot's line; a degraded LiteLLM probe can still delay the write,
1033
+ // in which case the file mtime predates this gateway boot and the
1034
+ // reader flags it prevBoot (rendered "(prev boot)", never as
1035
+ // current). Absent/unreadable file → row omitted.
1036
+ const routingBootStartMs =
1037
+ opts.routingBootStartMs ??
1038
+ containerBootStartMs() ??
1039
+ Date.now() - process.uptime() * 1000
1040
+ let routing: RoutingModeInfo | null = null
1041
+ try {
1042
+ routing = readRoutingMode(opts.agentDir, routingBootStartMs)
1043
+ } catch {
1044
+ routing = null
1045
+ }
1046
+
882
1047
  // Render with current probe state and edit if anything changed.
883
1048
  let currentText = renderBootCard({
884
1049
  agentName: opts.agentName,
@@ -892,6 +1057,7 @@ export async function startBootCard(
892
1057
  ...(snoozeRows.length > 0 ? { snoozeRows } : {}),
893
1058
  ...(opts.updateOutcomeLine ? { updateOutcomeLine: opts.updateOutcomeLine } : {}),
894
1059
  ...(configChanges.length > 0 ? { configChanges } : {}),
1060
+ ...(routing ? { routing } : {}),
895
1061
  })
896
1062
 
897
1063
  if (currentText !== ackText) {
@@ -947,6 +1113,8 @@ export async function startBootCard(
947
1113
  // (computed once in Phase 1). Pass them through unchanged so
948
1114
  // the live-agent-status edits keep the config-diff rows.
949
1115
  ...(configChanges.length > 0 ? { configChanges } : {}),
1116
+ // Routing row is stable for the card lifetime (read once above).
1117
+ ...(routing ? { routing } : {}),
950
1118
  })
951
1119
 
952
1120
  if (updatedText === currentText) continue
@@ -0,0 +1,209 @@
1
+ /**
2
+ * `/model` and `/effort` slash commands extracted verbatim from gateway.ts
3
+ * (switchroom#2996 P6 bot.command drain).
4
+ *
5
+ * Pure-logic helpers (parse/plan/menu/handle from model-command.ts,
6
+ * effort-command.ts, pending-session-command.ts) are imported directly;
7
+ * every gateway-local surface (busy resolver, deps factories, reply/queue
8
+ * plumbing, history recording) is injected via `deps` (repo factory-deps
9
+ * precedent, same shape as registerOpsInfoCommands). `bot` is injected so
10
+ * grammy registration order is preserved at the original call site.
11
+ */
12
+
13
+ import type { Bot, Context, InlineKeyboard } from 'grammy'
14
+ import { richMessage } from '../rich-send.js'
15
+ import { hardenCardBreaks } from '../format.js'
16
+ import { escapeHtmlForTg } from '../shared/bot-runtime.js'
17
+ import {
18
+ parseModelCommand,
19
+ planModelCommand,
20
+ buildModelMenu,
21
+ handleModelCommand,
22
+ modelCommandReceiptLine,
23
+ type ModelMenuDeps,
24
+ type ModelCommandDeps,
25
+ type ModelMenuReply,
26
+ type ParsedModelCommand,
27
+ } from './model-command.js'
28
+ import {
29
+ parseEffortCommand,
30
+ handleEffortCommand,
31
+ buildEffortMenu,
32
+ type EffortCommandDeps,
33
+ type EffortMenuReply,
34
+ } from './effort-command.js'
35
+ import {
36
+ ackText as pendingCmdAckText,
37
+ type PendingSessionCommand,
38
+ } from './pending-session-command.js'
39
+
40
+ export interface ModelEffortCommandDeps {
41
+ isAuthorizedSender: (ctx: Context) => boolean
42
+ getMyAgentName: () => string
43
+ resolveThreadId: (chat_id: string, explicit?: string | number | null) => number | undefined
44
+ /** Stale-aware busy resolver (#3262) shared by /model and /effort. */
45
+ resolveModelEffortBusy: () => { currentTurnActive: boolean; turnInFlight: boolean }
46
+ historyEnabled: boolean
47
+ recordInbound: (args: {
48
+ chat_id: string
49
+ thread_id: number | null
50
+ message_id: number
51
+ user: string | null
52
+ user_id: string | null
53
+ ts: number
54
+ text: string
55
+ }) => void
56
+ buildModelDeps: (restartCtx?: { chatId: string; threadId: number | undefined }) => ModelMenuDeps & ModelCommandDeps
57
+ buildEffortDeps: () => EffortCommandDeps
58
+ modelMenuReplyMarkup: (reply: ModelMenuReply) => InlineKeyboard | undefined
59
+ effortMenuReplyMarkup: (reply: EffortMenuReply) => InlineKeyboard | undefined
60
+ enqueueSessionCommand: (cmd: PendingSessionCommand) => void
61
+ switchroomReply: (
62
+ ctx: Context,
63
+ text: string,
64
+ options?: { html?: boolean; reply_markup?: InlineKeyboard },
65
+ ) => Promise<void>
66
+ modelMenuEnabled: () => boolean
67
+ /** stderr log sink. */
68
+ log: (line: string) => void
69
+ }
70
+
71
+ export function registerModelEffortCommands(bot: Bot, deps: ModelEffortCommandDeps): void {
72
+ const {
73
+ isAuthorizedSender,
74
+ getMyAgentName,
75
+ resolveThreadId,
76
+ resolveModelEffortBusy,
77
+ historyEnabled,
78
+ recordInbound,
79
+ buildModelDeps,
80
+ buildEffortDeps,
81
+ modelMenuReplyMarkup,
82
+ effortMenuReplyMarkup,
83
+ enqueueSessionCommand,
84
+ switchroomReply,
85
+ modelMenuEnabled,
86
+ log,
87
+ } = deps
88
+
89
+ bot.command('model', async ctx => {
90
+ if (!isAuthorizedSender(ctx)) return
91
+ const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
92
+ const parsed: ParsedModelCommand = parseModelCommand(text) ?? { kind: 'show' as const }
93
+ const chatId = String(ctx.chat!.id)
94
+ const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
95
+ // #3177 — durable receipt FIRST. A typed /model must NEVER be invisible: even
96
+ // if every downstream reply is shed/dropped, or the session is in a
97
+ // phantom-idle window (turn atom cleared while claude is still busy), the
98
+ // command leaves a greppable log line + a history row before any branch. This
99
+ // is the fix for the finn 2026-07-12 zero-trace swallow (no log, no reply, no
100
+ // ack, no deferred apply). `busyNow` folds BOTH busy signals (turn atom AND
101
+ // the authoritative delivery-machine/approval gate) — but a STALE atom or a
102
+ // wedged approval older than the hard TTL is discounted as idle (#3262), so a
103
+ // phantom "active turn" on an idle session no longer blocks the switch. Resolve
104
+ // once (it may clear a dangling atom) and reuse for both the receipt log and
105
+ // the routing disposition.
106
+ const modelBusy = resolveModelEffortBusy()
107
+ const busyNow = modelBusy.currentTurnActive || modelBusy.turnInFlight
108
+ log(modelCommandReceiptLine(getMyAgentName(), parsed, busyNow) + '\n')
109
+ if (historyEnabled && ctx.message?.message_id != null) {
110
+ try {
111
+ recordInbound({
112
+ chat_id: chatId,
113
+ thread_id: threadId ?? null,
114
+ message_id: ctx.message.message_id,
115
+ user: ctx.from?.username ?? (ctx.from?.id != null ? String(ctx.from.id) : null),
116
+ user_id: ctx.from?.id != null ? String(ctx.from.id) : null,
117
+ ts: ctx.message.date ?? Math.floor(Date.now() / 1000),
118
+ text,
119
+ })
120
+ } catch (err) {
121
+ log(`telegram gateway: /model recordInbound failed: ${(err as Error)?.message ?? String(err)}\n`)
122
+ }
123
+ }
124
+ const cmdDeps = buildModelDeps({ chatId, threadId })
125
+ // Route on a pure disposition (#3177) that folds BOTH busy signals so a
126
+ // session busy by EITHER measure ack+queues instead of silently injecting
127
+ // into a busy pane. Every branch below produces a visible action.
128
+ const disposition = planModelCommand(parsed, {
129
+ currentTurnActive: modelBusy.currentTurnActive,
130
+ turnInFlight: modelBusy.turnInFlight,
131
+ menuEnabled: modelMenuEnabled(),
132
+ })
133
+ if (disposition.kind === 'menu') {
134
+ const menu = await buildModelMenu(cmdDeps)
135
+ await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) })
136
+ return
137
+ }
138
+ // Mid-turn (by either busy signal): instead of dead-ending ("Try again in a
139
+ // moment") or silently injecting into a busy pane, ACK + QUEUE + apply-on-idle
140
+ // + confirm (#3017/#3177). The typed set path either injects into claude's
141
+ // input box or triggers a carrier restart — both unsafe while busy.
142
+ if (disposition.kind === 'queue') {
143
+ const target = disposition.target
144
+ const sent = await ctx.replyWithRichMessage(
145
+ richMessage(hardenCardBreaks(pendingCmdAckText('model', target, escapeHtmlForTg))),
146
+ threadId != null ? { message_thread_id: threadId } : {},
147
+ )
148
+ enqueueSessionCommand({
149
+ kind: 'model',
150
+ origin: 'typed',
151
+ arg: target,
152
+ targetLabel: target,
153
+ chatId,
154
+ threadId,
155
+ ackChatId: chatId,
156
+ ackMessageId: (sent as { message_id: number }).message_id,
157
+ requestedAt: Date.now(),
158
+ })
159
+ return
160
+ }
161
+ // Rev 5: the handler relaunches through the carrier and owns every side effect
162
+ // (override write, carrier, premium-recovery clear). There is no post-hoc
163
+ // recording — /status is reconciled at boot from `.active-session-model`, so
164
+ // an unapplied switch can never be optimistically recorded here.
165
+ const reply = await handleModelCommand(parsed, cmdDeps)
166
+ await switchroomReply(ctx, reply.text, { html: reply.html })
167
+ })
168
+ bot.command('effort', async ctx => {
169
+ if (!isAuthorizedSender(ctx)) return
170
+ const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
171
+ const parsed = parseEffortCommand(text) ?? { kind: 'show' as const }
172
+ const cmdDeps = buildEffortDeps()
173
+ if (parsed.kind === 'show') {
174
+ const menu = buildEffortMenu(cmdDeps)
175
+ await switchroomReply(ctx, menu.text, { html: true, reply_markup: effortMenuReplyMarkup(menu) })
176
+ return
177
+ }
178
+ // Mid-turn: ACK + QUEUE + apply-on-idle + confirm (#3017) — parity with
179
+ // /model. `applyEffort` mid-turn silently maybe-failed ("couldn't confirm it
180
+ // applied") before this gate existed. Use the SAME stale-aware busy resolver
181
+ // as /model (#3262) so a dangling turn atom older than the hard TTL is
182
+ // discounted as idle and the switch applies instead of queuing forever on an
183
+ // idle session.
184
+ const effortBusy = resolveModelEffortBusy()
185
+ if ((parsed.kind === 'set' || parsed.kind === 'default') && effortBusy.currentTurnActive) {
186
+ const requestedLevel = parsed.kind === 'set' ? parsed.level : 'default'
187
+ const chatId = String(ctx.chat!.id)
188
+ const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
189
+ const sent = await ctx.replyWithRichMessage(
190
+ richMessage(hardenCardBreaks(pendingCmdAckText('effort', requestedLevel, escapeHtmlForTg))),
191
+ threadId != null ? { message_thread_id: threadId } : {},
192
+ )
193
+ enqueueSessionCommand({
194
+ kind: 'effort',
195
+ origin: 'typed',
196
+ arg: requestedLevel,
197
+ targetLabel: requestedLevel,
198
+ chatId,
199
+ threadId,
200
+ ackChatId: chatId,
201
+ ackMessageId: (sent as { message_id: number }).message_id,
202
+ requestedAt: Date.now(),
203
+ })
204
+ return
205
+ }
206
+ const reply = await handleEffortCommand(parsed, cmdDeps)
207
+ await switchroomReply(ctx, reply.text, { html: reply.html })
208
+ })
209
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Basic info slash commands extracted verbatim from gateway.ts
3
+ * (switchroom#2996 P6 bot.command drain): `/start`, `/help`, `/status`,
4
+ * `/agents`.
5
+ *
6
+ * Pure read-only info surfaces (welcome/help text, pairing status, agent
7
+ * list) with no send-path or turn-lifecycle coupling. Every gateway-local
8
+ * helper they close over is passed in via `deps` (repo factory-deps
9
+ * precedent, same shape as registerOpsInfoCommands) so the module never
10
+ * back-references the gateway singleton. `bot` is injected so grammy
11
+ * registration order is preserved at the original call site.
12
+ */
13
+
14
+ import type { Bot, Context } from 'grammy'
15
+ import { richMessage } from '../rich-send.js'
16
+ import {
17
+ startText as buildStartText,
18
+ helpText as buildHelpText,
19
+ statusPairedText as buildStatusPairedText,
20
+ statusPendingText as buildStatusPendingText,
21
+ statusUnpairedText as buildStatusUnpairedText,
22
+ type AgentMetadata,
23
+ } from '../welcome-text.js'
24
+ import { escapeHtmlForTg } from '../shared/bot-runtime.js'
25
+ import type { Access } from './gateway.js'
26
+
27
+ export interface StartInfoCommandDeps {
28
+ /** #894 backport gate: silent drop on disabled / non-allowlisted senders. */
29
+ dmCommandGate: (ctx: Context) => { access: Access; senderId: string } | null
30
+ isAuthorizedSender: (ctx: Context) => boolean
31
+ getMyAgentName: () => string
32
+ getCommandArgs: (ctx: Context) => string
33
+ hasDemoFlag: (args: string) => boolean
34
+ buildAgentMetadata: (agentName: string) => Promise<AgentMetadata>
35
+ runSwitchroomCommandFormatted: (
36
+ ctx: Context,
37
+ args: string[],
38
+ label: string,
39
+ formatter: () => string | null,
40
+ ) => Promise<void>
41
+ switchroomExecJson: <T = unknown>(args: string[]) => T | null
42
+ statusIcon: (status: string) => string
43
+ }
44
+
45
+ export function registerStartInfoCommands(bot: Bot, deps: StartInfoCommandDeps): void {
46
+ const {
47
+ dmCommandGate,
48
+ isAuthorizedSender,
49
+ getMyAgentName,
50
+ getCommandArgs,
51
+ hasDemoFlag,
52
+ buildAgentMetadata,
53
+ runSwitchroomCommandFormatted,
54
+ switchroomExecJson,
55
+ statusIcon,
56
+ } = deps
57
+
58
+ bot.command('start', async ctx => {
59
+ // dmCommandGate (#894 backport): silent drop on disabled or
60
+ // non-allowlisted senders so the bot doesn't leak its existence.
61
+ const gated = dmCommandGate(ctx)
62
+ if (!gated) return
63
+ const disabled = gated.access.dmPolicy === 'disabled'
64
+ await ctx.replyWithRichMessage(richMessage(buildStartText(getMyAgentName(), disabled)))
65
+ })
66
+ bot.command('help', async ctx => {
67
+ if (!dmCommandGate(ctx)) return
68
+ await ctx.replyWithRichMessage(richMessage(buildHelpText(getMyAgentName())))
69
+ })
70
+ bot.command('status', async ctx => {
71
+ // dmCommandGate (#894 backport) drops disabled / non-allowlisted in
72
+ // allowlist mode. Pairing-mode users still fall through to either
73
+ // the pending-code branch or the unpaired branch.
74
+ const gated = dmCommandGate(ctx)
75
+ if (!gated) return
76
+ const { access, senderId } = gated
77
+ const from = ctx.from!
78
+ if (access.allowFrom.includes(senderId)) {
79
+ const demo = hasDemoFlag(getCommandArgs(ctx))
80
+ const userTag = from.username ? `@${from.username}` : senderId
81
+ const meta = await buildAgentMetadata(getMyAgentName())
82
+ await ctx.replyWithRichMessage(richMessage(buildStatusPairedText({ user: userTag, meta, demo })))
83
+ return
84
+ }
85
+ for (const [code, p] of Object.entries(access.pending)) {
86
+ if (p.senderId === senderId) {
87
+ await ctx.replyWithRichMessage(richMessage(buildStatusPendingText(code)))
88
+ return
89
+ }
90
+ }
91
+ await ctx.reply(buildStatusUnpairedText())
92
+ })
93
+ bot.command('agents', async ctx => {
94
+ if (!isAuthorizedSender(ctx)) return
95
+ await runSwitchroomCommandFormatted(ctx, ['agent', 'list'], 'agent list', () => {
96
+ type AgentListResp = { agents: Array<{ name: string; status: string; uptime: string; template: string; topic_name: string; topic_emoji?: string }> }
97
+ const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
98
+ if (!data) return null
99
+ if (data.agents.length === 0) return '_No agents defined_'
100
+ const lines = ['**Agents**']
101
+ for (const a of data.agents) {
102
+ lines.push(`${statusIcon(a.status)} **${escapeHtmlForTg(a.name)}** · ${escapeHtmlForTg(a.status)} · ${escapeHtmlForTg(a.uptime)}`)
103
+ lines.push(` _${escapeHtmlForTg(a.template)} → ${escapeHtmlForTg(a.topic_name)}${a.topic_emoji ? ' ' + a.topic_emoji : ''}_`)
104
+ }
105
+ return lines.join('\n')
106
+ })
107
+ })
108
+ }