switchroom 0.17.10 → 0.18.6

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 (149) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +29 -2
  3. package/dist/auth-broker/index.js +6163 -152
  4. package/dist/cli/notion-write-pretool.mjs +31 -3
  5. package/dist/cli/switchroom.js +695 -526
  6. package/dist/host-control/main.js +6184 -173
  7. package/dist/vault/approvals/kernel-server.js +5893 -165
  8. package/dist/vault/broker/server.js +6666 -921
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +170 -21
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md.hbs +2 -2
  14. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  15. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  16. package/skills/switchroom-release/SKILL.md +78 -0
  17. package/telegram-plugin/auth-snapshot-format.ts +37 -25
  18. package/telegram-plugin/context-exhaustion.ts +124 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +25025 -9203
  20. package/telegram-plugin/gateway/activity-card-store.ts +76 -0
  21. package/telegram-plugin/gateway/gateway.ts +740 -106
  22. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  23. package/telegram-plugin/gateway/model-command.ts +70 -10
  24. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  25. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  26. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  27. package/telegram-plugin/operator-events.ts +21 -0
  28. package/telegram-plugin/package.json +6 -0
  29. package/telegram-plugin/pending-work-progress.ts +42 -7
  30. package/telegram-plugin/quota-bar-format.ts +360 -0
  31. package/telegram-plugin/quota-watch.ts +4 -6
  32. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  33. package/telegram-plugin/registry/turns-schema.ts +78 -0
  34. package/telegram-plugin/render/ir.ts +209 -0
  35. package/telegram-plugin/render/parse.ts +363 -0
  36. package/telegram-plugin/render/render.ts +440 -0
  37. package/telegram-plugin/render/rich-render.ts +72 -0
  38. package/telegram-plugin/stream-controller.ts +14 -3
  39. package/telegram-plugin/subagent-watcher.ts +27 -9
  40. package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
  41. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  42. package/telegram-plugin/tests/auth-snapshot-format.test.ts +51 -16
  43. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  44. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
  45. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  46. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  47. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  48. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  49. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  50. package/telegram-plugin/tests/model-command.test.ts +193 -16
  51. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  52. package/telegram-plugin/tests/operator-events.test.ts +16 -0
  53. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  54. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  55. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  56. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  57. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  58. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  59. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  60. package/telegram-plugin/tests/render/render.test.ts +436 -0
  61. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  62. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  63. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  64. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  65. package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
  66. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  67. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  68. package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
  69. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
  70. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  71. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  72. package/telegram-plugin/worker-activity-feed.ts +220 -15
  73. package/telegram-plugin/worktree-watch-cwds.ts +92 -17
  74. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  75. package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
  76. package/vendor/hindsight-memory/scripts/recall.py +64 -6
  77. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  78. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  79. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
  80. package/profiles/default/CLAUDE.md +0 -116
  81. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  82. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  84. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  97. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  99. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  125. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  126. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  127. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  130. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -116,3 +116,29 @@ export function decideInboundDelivery(
116
116
  if (input.turnInFlight) return 'buffer-until-idle'
117
117
  return 'deliver'
118
118
  }
119
+
120
+ /**
121
+ * #2917 — atomic check-and-reserve for per-chat outbound FIFO.
122
+ *
123
+ * `decideInboundDelivery` decides deliver-vs-buffer, but on the concurrent
124
+ * `handleInbound` path that decision and the busy-mark that records "a turn is
125
+ * now in flight for this chat" are separated by an `await` (attachment
126
+ * download, composer-clear). Two same-chat inbounds can therefore each read
127
+ * "idle" during the other's async lead-in and both deliver — the replies then
128
+ * come back reordered. This helper couples the decision with a `reserve` flag:
129
+ * a FRESH-TURN deliver must reserve the chat's busy key SYNCHRONOUSLY (before
130
+ * any await) so the next same-chat inbound sees it and buffers behind it.
131
+ *
132
+ * `reserve` is true ONLY for a fresh-turn deliver. Steering / interrupt
133
+ * inbounds deliver mid-turn WITHOUT starting a turn, so they must not reserve
134
+ * (reserving would wedge the running turn's key). A buffered decision never
135
+ * reserves.
136
+ */
137
+ export function reserveInboundDelivery(
138
+ input: InboundDeliveryGateInput,
139
+ ): { decision: InboundDeliveryDecision; reserve: boolean } {
140
+ const decision = decideInboundDelivery(input)
141
+ const reserve =
142
+ decision === 'deliver' && !input.isSteering && input.isInterrupt !== true
143
+ return { decision, reserve }
144
+ }
@@ -52,11 +52,14 @@ export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'fable', 'default'] as
52
52
  * Shape gate for the model argument. This string is typed literally
53
53
  * into the agent's tmux pane, so the gate is strict by construction:
54
54
  * one token, alphanumeric start, then alphanumerics plus the chars
55
- * that appear in real model ids (`.` `_` `-` and the `[1m]`-style
56
- * variant brackets). No whitespace means no second token can ride
55
+ * that appear in real model ids (`.` `_` `-` `/` and the `[1m]`-style
56
+ * variant brackets). `/` is allowed for OpenRouter-style
57
+ * `sr-vendor/model` ids; it is not a shell metachar inside the
58
+ * double-quoted `claude --model "$_EFFECTIVE_MODEL"` usage, so it can't
59
+ * break the launch. No whitespace means no second token can ride
57
60
  * along; no control characters means no newline/Enter smuggling.
58
61
  */
59
- const MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._\[\]-]{0,99}$/
62
+ const MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._/\[\]-]{0,99}$/
60
63
 
61
64
  export function isValidModelArg(arg: string): boolean {
62
65
  return MODEL_ARG_RE.test(arg)
@@ -133,6 +136,18 @@ export interface ModelCommandDeps {
133
136
  * mechanism as the `/restart` command (hostd-first, SIGTERM fallback).
134
137
  */
135
138
  scheduleRestart: (reason: string) => Promise<void>
139
+ /**
140
+ * Schedule a session-only switch TO a non-Claude (`sr-*` LiteLLM/OpenRouter)
141
+ * model. claude's in-REPL `/model` picker rejects unknown `sr-*` ids, so an
142
+ * inject can't set them. Instead the gateway writes the chosen token to the
143
+ * `.session-model-override` carrier file and gracefully restarts the agent;
144
+ * the next boot launches `claude --model <token>` directly (LiteLLM routes
145
+ * it, no picker validation). Session-only: reverts to the configured default
146
+ * on the following restart. Wired to the same restart dispatch as
147
+ * `scheduleRestart`, plus the carrier write. `model` is the full `sr-*` id
148
+ * (already alias-expanded); `reason` is stamped as the restart reason.
149
+ */
150
+ scheduleModelRelaunch: (model: string, reason: string) => Promise<void>
136
151
  }
137
152
 
138
153
  export interface ModelCommandReply {
@@ -152,6 +167,7 @@ function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
152
167
  '\`/model\` — show the configured model',
153
168
  `\`/model <name>\` — switch the live session (${MODEL_ALIASES.map(a => `\`${a}\``).join(' · ')} or a full model id)`,
154
169
  `_OpenRouter shortcuts:_ ${srAliasExamples}`,
170
+ '_OpenRouter (sr-\\*) switches restart the session (~30s); Claude switches apply instantly._',
155
171
  PERSIST_NOTE,
156
172
  )
157
173
  return { text: lines.join('\n'), html: true }
@@ -214,6 +230,30 @@ export async function handleModelCommand(
214
230
  }
215
231
  }
216
232
 
233
+ // Claude → sr-*: an in-place inject can't set a non-Anthropic model — claude's
234
+ // native `/model` picker rejects the unknown `sr-*` id ("Model not found").
235
+ // Carry the token across a graceful restart and relaunch `claude --model
236
+ // sr-*` directly (LiteLLM routes it). Session-only: reverts to the configured
237
+ // default on the next restart. The sr-* → Claude direction is handled above.
238
+ if (isSrModel(model)) {
239
+ try {
240
+ await deps.scheduleModelRelaunch(model, `user: /model ${model} (session-only relaunch)`)
241
+ } catch (err) {
242
+ const msg = err instanceof Error ? err.message : String(err)
243
+ return {
244
+ text: `❌ Could not schedule model switch: ${deps.escapeHtml(msg)}`,
245
+ html: true,
246
+ }
247
+ }
248
+ return {
249
+ text: [
250
+ `Switching to \`${deps.escapeHtml(model)}\` — restarting session (~30s).`,
251
+ '_Session-only — reverts to the configured default on the next restart._',
252
+ ].join('\n'),
253
+ html: true,
254
+ }
255
+ }
256
+
217
257
  const verbHtml = `\`/model ${deps.escapeHtml(model)}\``
218
258
  let result: InjectResult
219
259
  try {
@@ -358,16 +398,31 @@ export const EXTRA_CLAUDE_ALIASES: ReadonlyArray<{ alias: string; label: string
358
398
  * Friendly display names for sr-* synthetic model names. An sr-* model in
359
399
  * LiteLLM has no entry in `model_group_settings.*.forward_client_headers_to_llm_api`
360
400
  * so the Anthropic OAuth credential is NEVER forwarded — safe to route to
361
- * OpenRouter. Names here are display-only; the raw `sr-*` id is what gets
362
- * injected into the agent's session. See reference/rfcs/litellm-max-subscription-invariants.md § I6.
401
+ * OpenRouter. Names here are display-only (used by srFriendlyLabel for /status
402
+ * and switch confirmations); the raw `sr-*` id is what gets injected into the
403
+ * agent's session. This table is a SUPERSET of the menu-reachable set
404
+ * (SR_MODEL_ALIASES): it also labels models that are only reachable by typing
405
+ * the full `/model sr-<name>` (manual passthrough), so those still get a
406
+ * friendly name without appearing as a keyboard button.
407
+ * See reference/rfcs/litellm-max-subscription-invariants.md § I6.
363
408
  */
364
409
  export const SR_MODEL_LABELS: Record<string, string> = {
365
410
  'sr-gemini-2.5-pro': 'Gemini 2.5 Pro',
366
411
  'sr-gemini-2.5-flash': 'Gemini 2.5 Flash',
367
412
  'sr-deepseek-r1': 'DeepSeek R1',
368
413
  'sr-deepseek-v3': 'DeepSeek V3',
369
- 'sr-glm-5': 'GLM-5',
414
+ // sr-glm-5 now targets glm-5.2 in the live litellm config — label bumped to match.
415
+ 'sr-glm-5': 'GLM-5.2',
370
416
  'sr-codex-5.5': 'Codex 5.5',
417
+ // OpenRouter coverage (pairs with the live litellm sr-* model_name additions).
418
+ 'sr-gpt-oss-20b': 'GPT-OSS 20B',
419
+ 'sr-gpt-oss-120b': 'GPT-OSS 120B',
420
+ 'sr-gpt-5.5': 'GPT-5.5',
421
+ 'sr-gpt-5-codex': 'GPT-5 Codex',
422
+ 'sr-gpt-5.2-codex': 'GPT-5.2 Codex',
423
+ 'sr-gemini-flash-lite': 'Gemini 3.1 Flash Lite',
424
+ 'sr-minimax-m3': 'MiniMax M3',
425
+ 'sr-deepseek-v4-flash': 'DeepSeek V4 Flash',
371
426
  }
372
427
 
373
428
  /**
@@ -448,10 +503,15 @@ function headerRow(label: string): ModelMenuKeyboardButton[] {
448
503
  * requires ANTHROPIC_CUSTOM_HEADERS (a litellm key) to be set on the gateway
449
504
  * process. switchroom never sets that env on the gateway, so in production
450
505
  * discoverSrModels() always returns [] and the external group was silently
451
- * empty. The six SR_MODEL_ALIASES targets are the sr-* names the litellm
452
- * config actually exposes, so seeding from them makes the group reliable
453
- * without the missing env — while still merging any live results on hosts
454
- * that do configure discovery.
506
+ * empty. The six SR_MODEL_ALIASES targets are the CURATED main sr-* set, so
507
+ * seeding from them makes the group reliable without the missing env — while
508
+ * still merging any live results on hosts that do configure discovery.
509
+ *
510
+ * Deliberately a SUBSET: the litellm config exposes more sr-* models than this
511
+ * (the full OpenRouter catalogue). Those extras are display-labelled in
512
+ * SR_MODEL_LABELS and remain typeable via `/model <full-sr-name>` (manual
513
+ * passthrough — the set path is a shape gate, no whitelist), but they are kept
514
+ * OUT of SR_MODEL_ALIASES on purpose so the keyboard stays small and curated.
455
515
  *
456
516
  * Subscription-honest: ONLY the curated sr-* aliases surface as buttons. Raw
457
517
  * gpt-4o / openrouter/* dupes / voyage-* embeddings never do.
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Boot-time-only, chat-scoped resolution of a raw Telegram id/username into
3
+ * a human `person_id` (e.g. "Lisa") for display in the `<channel>` tag's
4
+ * `user` attribute.
5
+ *
6
+ * Design (converged after adversarial review — see the PR description for
7
+ * the tradeoffs, do not re-litigate here):
8
+ *
9
+ * - No MCP tool. Not callable by agents — display-only, gateway-internal.
10
+ * - No hot-reload. `PersonDirectory` is built ONCE at gateway boot from
11
+ * the static in-memory `people.json` the scaffold projected from
12
+ * `switchroom.yaml`'s `users:` block. A config change requires an agent
13
+ * restart to take effect — this file never re-reads anything.
14
+ * - `access.json` (the fail-CLOSED allow-list) is a completely separate
15
+ * concern and is never touched here. This feature is fail-OPEN: an
16
+ * unresolved id/username falls back to today's behavior (the caller
17
+ * keeps using the raw id/username) — it never blocks or denies
18
+ * anything.
19
+ * - Chat-scoped: a resolved name is only ever returned for a chat/group
20
+ * the person is actually a member of. In a DM the chat IS the sender,
21
+ * so resolution always applies. In a group, resolution only applies if
22
+ * the sender's id or username is explicitly present in that group's
23
+ * `allowFrom` (read from the EXISTING `access.json`/`loadAccess()`
24
+ * data — no new membership source). An empty/unset group `allowFrom`
25
+ * means membership can't be positively confirmed, so we conservatively
26
+ * do NOT resolve (the raw id/username is shown instead) — a name safe
27
+ * in a 1:1 DM could be a bigger leak in a shared group.
28
+ * - Per-entry validation: a malformed `users:` entry (duplicate
29
+ * `person_id` claimed by two different keys, empty/invalid
30
+ * `person_id`, no `telegram_ids`) drops ONLY that one entry — it never
31
+ * blanks resolution for the whole fleet. `buildPersonDirectory` never
32
+ * throws; every failure mode is reported via its `dropped` return
33
+ * value instead, so the caller can route it through the existing
34
+ * fleet-alert path (`emitGatewayOperatorEvent` in gateway.ts) at
35
+ * low/config severity — this must never page like a real outage.
36
+ *
37
+ * Fix note (accepted soft mitigation — see docs/configuration.md): there is
38
+ * no automated enforcement that a configured `person_id` stays safe to show
39
+ * if a group's membership changes AFTER the entry is written. This is an
40
+ * operator-discipline convention, not a closed gap. Low severity today —
41
+ * only two `person_id`s are configured fleet-wide.
42
+ */
43
+
44
+ export interface RawPersonEntry {
45
+ /** The `users:` map key (e.g. "lisa") — carried through for alert text. */
46
+ key: string
47
+ person_id: string
48
+ telegram_ids: string[]
49
+ }
50
+
51
+ export interface PersonDirectoryEntry {
52
+ key: string
53
+ personId: string
54
+ /** Normalized (lowercased, no leading "@") telegram ids/usernames. */
55
+ telegramKeys: string[]
56
+ }
57
+
58
+ export interface PersonDirectory {
59
+ /** normalized telegram id/username -> directory entry */
60
+ byTelegramKey: Record<string, PersonDirectoryEntry>
61
+ }
62
+
63
+ export interface DroppedPersonEntry {
64
+ key: string
65
+ reason: string
66
+ /**
67
+ * Name-scrubbed reason CLASS — the same rejection without the embedded
68
+ * `person_id` human-name value or the colliding telegram id. Used to
69
+ * build the broadcast `alertDetail` so a config-warning card (which
70
+ * `emitGatewayOperatorEvent` sends to EVERY `allowFrom` chat, including
71
+ * group chats where the named person may NOT be a member) never surfaces
72
+ * a human name that the display path (`resolvePersonName`) deliberately
73
+ * chat-scopes. The verbose `reason` is still carried for the operator's
74
+ * own stderr `logLine` (private, not broadcast).
75
+ */
76
+ reasonClass: string
77
+ }
78
+
79
+ export interface BuildPersonDirectoryResult {
80
+ directory: PersonDirectory
81
+ dropped: DroppedPersonEntry[]
82
+ }
83
+
84
+ /** Normalize a telegram id or @username for keying: trim, strip a leading
85
+ * "@", lowercase (Telegram usernames are case-insensitive). */
86
+ function normalizeTelegramKey(raw: string): string {
87
+ return raw.trim().replace(/^@/, '').toLowerCase()
88
+ }
89
+
90
+ /**
91
+ * Validate and dedupe raw `users:` `person_id` entries into a lookup
92
+ * directory. Never throws — every rejection is reported via `dropped`
93
+ * instead, per the fail-open / drop-only-that-entry design.
94
+ *
95
+ * Drop reasons:
96
+ * - missing/blank `key` or `person_id`
97
+ * - `telegram_ids` missing or empty (nothing to key the entry by)
98
+ * - `person_id` already claimed by an earlier (different) entry key —
99
+ * first-declared entry wins, the later duplicate is dropped whole
100
+ * (not just the colliding id) so the alert is unambiguous about which
101
+ * entry lost
102
+ * - a `telegram_id`/username already claimed by an earlier (different)
103
+ * entry key — same first-declared-wins convention: the later duplicate
104
+ * is dropped whole so the alert is unambiguous about which entry lost
105
+ * and we never silently last-write-wins a numeric id into the wrong
106
+ * person's name
107
+ */
108
+ export function buildPersonDirectory(entries: readonly RawPersonEntry[]): BuildPersonDirectoryResult {
109
+ const byTelegramKey: Record<string, PersonDirectoryEntry> = {}
110
+ const dropped: DroppedPersonEntry[] = []
111
+ const personIdOwner = new Map<string, string>() // person_id (lowercased) -> owning entry key
112
+ const telegramKeyOwner = new Map<string, string>() // normalized telegram id/username -> owning entry key
113
+
114
+ for (const raw of entries) {
115
+ const key = typeof raw.key === 'string' ? raw.key.trim() : ''
116
+ const personId = typeof raw.person_id === 'string' ? raw.person_id.trim() : ''
117
+
118
+ if (key.length === 0) {
119
+ dropped.push({ key: raw.key || '(unknown)', reason: 'missing users: map key', reasonClass: 'missing users: map key' })
120
+ continue
121
+ }
122
+ if (personId.length === 0) {
123
+ dropped.push({ key, reason: 'empty or missing person_id', reasonClass: 'empty or missing person_id' })
124
+ continue
125
+ }
126
+ if (!Array.isArray(raw.telegram_ids) || raw.telegram_ids.length === 0) {
127
+ dropped.push({ key, reason: 'no telegram_ids to resolve against', reasonClass: 'no telegram_ids to resolve against' })
128
+ continue
129
+ }
130
+
131
+ const personIdLower = personId.toLowerCase()
132
+ const existingOwner = personIdOwner.get(personIdLower)
133
+ if (existingOwner != null && existingOwner !== key) {
134
+ dropped.push({
135
+ key,
136
+ // Verbose reason (stderr logLine only — private to the operator):
137
+ // names the person_id value + the owning config key.
138
+ reason: `duplicate person_id "${personId}" already claimed by users.${existingOwner}`,
139
+ // reasonClass (broadcast alertDetail): scrubs the human-name
140
+ // person_id value; keeps the entry key + collision class. A
141
+ // config-warning card fans out to every allowFrom chat, so the
142
+ // name must not travel further than the display path allows.
143
+ reasonClass: `duplicate person_id already claimed by users.${existingOwner}`,
144
+ })
145
+ continue
146
+ }
147
+ personIdOwner.set(personIdLower, key)
148
+
149
+ const telegramKeys = [...new Set(raw.telegram_ids.map(normalizeTelegramKey).filter((k) => k.length > 0))]
150
+ if (telegramKeys.length === 0) {
151
+ dropped.push({ key, reason: 'telegram_ids contained no usable id/username', reasonClass: 'telegram_ids contained no usable id/username' })
152
+ personIdOwner.delete(personIdLower)
153
+ continue
154
+ }
155
+
156
+ const collidingTelegramKey = telegramKeys.find((tk) => {
157
+ const existingTkOwner = telegramKeyOwner.get(tk)
158
+ return existingTkOwner != null && existingTkOwner !== key
159
+ })
160
+ if (collidingTelegramKey != null) {
161
+ const existingTkOwner = telegramKeyOwner.get(collidingTelegramKey)
162
+ dropped.push({
163
+ key,
164
+ reason: `duplicate telegram_id "${collidingTelegramKey}" already claimed by users.${existingTkOwner}`,
165
+ // Scrub the colliding telegram id/username from the broadcast
166
+ // reason; keep the owning config key (operator-chosen slug).
167
+ reasonClass: `duplicate telegram_id already claimed by users.${existingTkOwner}`,
168
+ })
169
+ personIdOwner.delete(personIdLower)
170
+ continue
171
+ }
172
+
173
+ const entry: PersonDirectoryEntry = { key, personId, telegramKeys }
174
+ for (const tk of telegramKeys) {
175
+ telegramKeyOwner.set(tk, key)
176
+ byTelegramKey[tk] = entry
177
+ }
178
+ }
179
+
180
+ return { directory: { byTelegramKey }, dropped }
181
+ }
182
+
183
+ export interface PersonDirectoryBootResult {
184
+ directory: PersonDirectory
185
+ /** Non-null when the caller should route an alert through the fleet
186
+ * alert path (`emitGatewayOperatorEvent`, kind: 'config-warning'). */
187
+ alertDetail: string | null
188
+ /** Always present — one human-readable line for stderr. */
189
+ logLine: string
190
+ }
191
+
192
+ /**
193
+ * Orchestrate the ONE-TIME boot-time check (requirement: boot-time-only,
194
+ * NOT periodic — call this exactly once, at gateway boot, never on a
195
+ * timer/interval). Extracted as a pure(ish) function — taking the file
196
+ * read as an injected dependency and RETURNING what to log/alert rather
197
+ * than performing the I/O itself — so it's unit-testable without booting
198
+ * the real gateway process.
199
+ *
200
+ * Dead-man's-switch: wraps the whole check in try/catch. If anything
201
+ * throws before completing (including `readEntries` itself), that failure
202
+ * is ALSO surfaced via `alertDetail` — mirrors a past incident where
203
+ * `access.json` validation failed silently in the wrong (fail-open)
204
+ * direction, undetected for hours. `PERSON_DIRECTORY` falls back to an
205
+ * empty directory on crash (fail-open: raw ids/usernames keep showing).
206
+ */
207
+ export function runPersonDirectoryBootCheck(readEntries: () => RawPersonEntry[]): PersonDirectoryBootResult {
208
+ try {
209
+ const rawEntries = readEntries()
210
+ const { directory, dropped } = buildPersonDirectory(rawEntries)
211
+
212
+ if (dropped.length > 0) {
213
+ // Broadcast-safe summary for `alertDetail` (fans out to EVERY
214
+ // allowFrom chat via emitGatewayOperatorEvent, including group chats
215
+ // where the named person may not be a member): built from
216
+ // `reasonClass`, which scrubs the embedded person_id human-name
217
+ // value and the colliding telegram id. The verbose `reason`
218
+ // (names the values) is kept for the operator's own stderr
219
+ // `logLine` — private, never broadcast.
220
+ const alertSummary = dropped.map((d) => `${d.key} (${d.reasonClass})`).join('; ')
221
+ const logSummary = dropped.map((d) => `${d.key} (${d.reason})`).join('; ')
222
+ const plural = dropped.length === 1 ? 'y' : 'ies'
223
+ return {
224
+ directory,
225
+ alertDetail: `person_id: dropped ${dropped.length} malformed users: entr${plural} at boot — ${alertSummary}`,
226
+ logLine: `telegram gateway: person_id boot validation dropped ${dropped.length} entr${plural}: ${logSummary}`,
227
+ }
228
+ }
229
+
230
+ return {
231
+ directory,
232
+ alertDetail: null,
233
+ logLine: rawEntries.length > 0
234
+ ? `telegram gateway: person_id boot validation ok — ${Object.keys(directory.byTelegramKey).length} telegram id/username(s) resolved`
235
+ : `telegram gateway: person_id boot validation ok — no person_id entries configured`,
236
+ }
237
+ } catch (err) {
238
+ const msg = err instanceof Error ? err.message : String(err)
239
+ return {
240
+ directory: { byTelegramKey: {} },
241
+ // Crash alertDetail scrubs the raw error message too — it can
242
+ // carry a filesystem path or other host detail that shouldn't fan
243
+ // out to every allowFrom chat. The full message stays in logLine.
244
+ alertDetail: `person_id boot validation crashed — name resolution disabled this boot (fail-open, raw ids/usernames will show); see gateway stderr for detail`,
245
+ logLine: `telegram gateway: person_id boot validation CRASHED (name resolution disabled this boot, raw ids will show — fail-open): ${msg}`,
246
+ }
247
+ }
248
+ }
249
+
250
+ export interface ResolvePersonOptions {
251
+ telegramId: string
252
+ username?: string | undefined
253
+ isDm: boolean
254
+ /** That chat's/group's configured allowFrom, if any (from access.json). */
255
+ groupAllowFrom?: readonly string[] | undefined
256
+ }
257
+
258
+ /**
259
+ * Resolve a sender's display name for THIS chat, chat-scoped per the
260
+ * module doc above. Returns undefined (fall back to raw id/username) when
261
+ * unresolved OR when membership in a group chat can't be positively
262
+ * confirmed.
263
+ */
264
+ export function resolvePersonName(directory: PersonDirectory, opts: ResolvePersonOptions): string | undefined {
265
+ const idKey = normalizeTelegramKey(opts.telegramId)
266
+ const usernameKey = opts.username ? normalizeTelegramKey(opts.username) : undefined
267
+
268
+ const entry = directory.byTelegramKey[idKey] ?? (usernameKey ? directory.byTelegramKey[usernameKey] : undefined)
269
+ if (!entry) return undefined
270
+
271
+ // DM: the chat IS the sender, so resolution always applies.
272
+ if (opts.isDm) return entry.personId
273
+
274
+ // Group: only resolve if the sender is explicitly present in that
275
+ // chat's allowFrom (the existing membership source) — conservative
276
+ // fallback (undefined) otherwise, since we can't positively confirm
277
+ // membership from an empty/unset list.
278
+ const allowFrom = opts.groupAllowFrom ?? []
279
+ const allowFromNormalized = allowFrom.map(normalizeTelegramKey)
280
+ const memberConfirmed =
281
+ allowFromNormalized.includes(idKey) || (usernameKey != null && allowFromNormalized.includes(usernameKey))
282
+ return memberConfirmed ? entry.personId : undefined
283
+ }
284
+
285
+ /**
286
+ * Fail-open call-site wrapper around `resolvePersonName`, for use at the
287
+ * `handleInbound` call site (gateway.ts). This feature's whole design
288
+ * point is "never blocks or denies anything" (module doc above) — a throw
289
+ * from resolution must fall back to the raw id/username, not abort message
290
+ * handling. Mirrors the same defensive pattern already used for
291
+ * `readPeopleFile` (gateway.ts) and `runPersonDirectoryBootCheck` (this
292
+ * file): catch, fall back, never propagate.
293
+ */
294
+ export function safeResolvePersonName(
295
+ directory: PersonDirectory,
296
+ opts: ResolvePersonOptions,
297
+ rawFallback: string,
298
+ ): string {
299
+ try {
300
+ return resolvePersonName(directory, opts) ?? rawFallback
301
+ } catch {
302
+ return rawFallback
303
+ }
304
+ }
@@ -126,7 +126,27 @@ export function classifyRejection(
126
126
  // handler shut the process down. reconcileStatusPin now absorbs its own
127
127
  // errors (primary fix); this entry is defense-in-depth so ANY leaked
128
128
  // pin-rights 400 from any path is log-only, not fatal.
129
- desc.includes('not enough rights')
129
+ desc.includes('not enough rights') ||
130
+ // 'group chat was upgraded to a supergroup chat' fires when a send
131
+ // targets a basic-group chat_id that Telegram has since migrated to
132
+ // a supergroup (new id format -100xxxxxxxxxx). Telegram sometimes
133
+ // surfaces the replacement id via `error.parameters.migrate_to_chat_id`
134
+ // but not reliably for every send method, so this handler can't always
135
+ // auto-repair the stale id — it can only make the failure non-fatal.
136
+ // This crashed marko's gateway on 2026-07-09 (and recurred from the
137
+ // same stale-id root cause on 2026-06-07 and 2026-06-09): a send to an
138
+ // old pre-migration group id crashed the WHOLE gateway process for
139
+ // every agent/chat, not just the one stale chat. A single unmigrated
140
+ // chat_id in cached state must never be fatal — log it so the stale id
141
+ // can be tracked down and fixed, but keep serving every other chat.
142
+ desc.includes('group chat was upgraded to a supergroup chat') ||
143
+ // Broader class: any 400 whose description signals the target
144
+ // chat_id itself is no longer valid (migrated, deactivated, or
145
+ // otherwise unresolvable). These are all "this one destination is
146
+ // broken", never "the gateway is broken" — same log-only posture as
147
+ // 'chat not found' above.
148
+ desc.includes('chat_id is empty') ||
149
+ desc.includes('group chat was deactivated')
130
150
  ) {
131
151
  return 'log_only'
132
152
  }