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
@@ -185,6 +185,12 @@ import {
185
185
  import { clearStaleTelegramPollingState } from '../startup-reset.js'
186
186
  import { gatewayStartupRetry } from './startup-network-retry.js'
187
187
  import { writeQuarantineMarker } from './quarantine.js'
188
+ import {
189
+ runPersonDirectoryBootCheck,
190
+ safeResolvePersonName,
191
+ type PersonDirectory,
192
+ type RawPersonEntry,
193
+ } from './resolve-person.js'
188
194
  // RFC H §7.3: auth-dashboard + auth-slot-parser deleted. Three chat
189
195
  // verbs (/auth show | use | rotate) talk to switchroom-auth-broker
190
196
  // via the thin client in src/auth/broker/client.ts.
@@ -294,6 +300,7 @@ import {
294
300
  shouldArmOrphanedReplyTimeout,
295
301
  ORPHANED_REPLY_TIMEOUT_MS,
296
302
  ORPHANED_REPLY_MAX_REARMS,
303
+ LivenessTracker,
297
304
  } from '../context-exhaustion.js'
298
305
  import {
299
306
  decideTurnFlush,
@@ -346,6 +353,7 @@ import {
346
353
  buildModelMenu,
347
354
  handleModelMenuCallback,
348
355
  isSrToClaudeTransition,
356
+ isValidModelArg,
349
357
  MODEL_CALLBACK_PREFIX,
350
358
  MODEL_CALLBACK_HEADER,
351
359
  MODEL_CALLBACK_SR,
@@ -357,6 +365,7 @@ import {
357
365
  type ModelMenuReply,
358
366
  } from './model-command.js'
359
367
  import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
368
+ import { resolveMainModel } from '../../src/agents/scaffold.js'
360
369
  import {
361
370
  parseEffortCommand,
362
371
  handleEffortCommand,
@@ -419,6 +428,7 @@ import {
419
428
  writeActivityCardRecord,
420
429
  clearActivityCardRecord,
421
430
  runActivityCardBootReaper,
431
+ runActivityCardMidSessionReaper,
422
432
  restartOrphanCardFinalizeText,
423
433
  type ActivityCardStoreFsSeam,
424
434
  } from './activity-card-store.js'
@@ -427,7 +437,7 @@ import { shouldSuppressRepresent } from './represent-guard.js'
427
437
  import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
428
438
  import { createInboundSpool } from './inbound-spool.js'
429
439
  import { purgeStaleTurnsForChat } from './turn-state-purge.js'
430
- import { decideInboundDelivery } from './inbound-delivery-gate.js'
440
+ import { decideInboundDelivery, reserveInboundDelivery } from './inbound-delivery-gate.js'
431
441
  import { mayDrainBufferedInbound, shouldArmNoReplyDrain } from './serialize-drain-gate.js'
432
442
  import { decideFeedReopen } from './feed-reopen-gate.js'
433
443
  import {
@@ -662,6 +672,7 @@ import {
662
672
  findRecentTurnsForChat,
663
673
  getTurnByKey,
664
674
  markTurnResumed,
675
+ reapStaleOpenTurns,
665
676
  } from '../registry/turns-schema.js'
666
677
  import {
667
678
  buildResumeInterruptedInbound,
@@ -716,6 +727,12 @@ const ACCESS_FILE = join(STATE_DIR, 'access.json')
716
727
  const APPROVED_DIR = join(STATE_DIR, 'approved')
717
728
  const ENV_FILE = join(STATE_DIR, '.env')
718
729
  const INBOX_DIR = join(STATE_DIR, 'inbox')
730
+ // person_id name resolution (docs/configuration.md). Sibling to
731
+ // access.json but a COMPLETELY SEPARATE, purely config-derived file —
732
+ // scaffold regenerates it on every reconcile via writeFileSyncIfChanged,
733
+ // the gateway never mutates it, and (unlike access.json) it is read
734
+ // exactly ONCE at boot into PERSON_DIRECTORY below — no hot-reload.
735
+ const PEOPLE_FILE = join(STATE_DIR, 'people.json')
719
736
 
720
737
  /**
721
738
  * Trigger a restart of the agent + gateway pair.
@@ -1219,6 +1236,33 @@ function loadAccess(): Access {
1219
1236
  return BOOT_ACCESS ?? readAccessFile()
1220
1237
  }
1221
1238
 
1239
+ /**
1240
+ * Read `people.json` (the scaffold's plain projection of `users:` entries
1241
+ * that carry a `person_id`). Fail-open: ENOENT or corrupt/malformed JSON
1242
+ * returns an empty array rather than throwing — this feature must never
1243
+ * block startup. Unlike `access.json` this file is never gateway-mutated,
1244
+ * so there's no "move corrupt file aside" concern; the scaffold owns and
1245
+ * regenerates it on every reconcile.
1246
+ */
1247
+ function readPeopleFile(): RawPersonEntry[] {
1248
+ try {
1249
+ const raw = readFileSync(PEOPLE_FILE, 'utf8')
1250
+ const parsed = JSON.parse(raw) as { entries?: unknown }
1251
+ if (!Array.isArray(parsed.entries)) return []
1252
+ return parsed.entries as RawPersonEntry[]
1253
+ } catch {
1254
+ return []
1255
+ }
1256
+ }
1257
+
1258
+ /**
1259
+ * Boot-time-only person-name directory (see resolve-person.ts module doc).
1260
+ * Populated exactly once by the boot-validation block in the startup IIFE
1261
+ * below, then never reassigned or re-read from disk again for the life of
1262
+ * this process — a config change requires an agent restart.
1263
+ */
1264
+ let PERSON_DIRECTORY: PersonDirectory = { byTelegramKey: {} }
1265
+
1222
1266
  function assertAllowedChat(chat_id: string | number): void {
1223
1267
  const id = String(chat_id)
1224
1268
  const access = loadAccess()
@@ -1483,6 +1527,64 @@ function resolveSubagentOriginChat(
1483
1527
  }
1484
1528
  }
1485
1529
 
1530
+ /**
1531
+ * Tracks worker-feed agents whose owner-DM fallback has already been
1532
+ * logged, so `resolveWorkerFeedChat` emits the routing-decision line once
1533
+ * per agent instead of every watcher tick (~1/s). Capped FIFO at 256 — a
1534
+ * late duplicate log is harmless (one extra stderr line), and the cap keeps
1535
+ * the set bounded across a long gateway lifetime even if origin resolution
1536
+ * is persistently broken fleet-wide (history disabled → every nested worker
1537
+ * hits the fallback). A gateway restart clears it.
1538
+ */
1539
+ const WORKER_FEED_FALLBACK_LOG_CAP = 256
1540
+ const workerFeedOwnerDmFallbackLogged = new Set<string>()
1541
+
1542
+ /**
1543
+ * Resolve a worker-feed destination chat with a guaranteed last resort.
1544
+ *
1545
+ * The universal-liveness contract is "any active work has a card, at any
1546
+ * nesting depth, with or without a parent turn open." Origin resolution
1547
+ * (`resolveSubagentOriginChat`) only succeeds when the ancestor turn row is
1548
+ * in `turnsDb` and history is enabled; a depth-2+ worker whose chain can't
1549
+ * be walked (history disabled, row reaped, ancestor never stamped) would
1550
+ * otherwise fall through `fleetChatId || allowFrom[0]` and, if those are
1551
+ * empty too, hit the `chatId.length === 0` skip in `workerActivityFeed.update`
1552
+ * — painting NOTHING, silently. That is the one path most likely to violate
1553
+ * "any and all active work has a card."
1554
+ *
1555
+ * This never returns `''`: the precedence is origin chat → fleet chat →
1556
+ * first allowed chat (the owner DM). The owner DM is the durable floor —
1557
+ * "wrong chat" beats "no card." A one-line routing-decision log flags the
1558
+ * fallback so an operator can see the misroute without it reading as an
1559
+ * error (it isn't one — the card surfaced).
1560
+ */
1561
+ function resolveWorkerFeedChat(
1562
+ agentId: string,
1563
+ fleetChatId: string,
1564
+ ): { chatId: string; threadId?: number } {
1565
+ const origin = resolveSubagentOriginChat(agentId)
1566
+ if (origin != null && origin.chatId.length > 0) return origin
1567
+ if (fleetChatId.length > 0) return { chatId: fleetChatId }
1568
+ const ownerDm = loadAccess().allowFrom[0] ?? ''
1569
+ if (origin == null && fleetChatId.length === 0 && ownerDm.length > 0) {
1570
+ // Routing decision, not a warning: origin resolution failed and no
1571
+ // fleet chat is configured, so the nested worker's card lands in the
1572
+ // owner DM. Logged ONCE per agent so the misroute is auditable
1573
+ // without spamming every tick (the watcher drives onProgress ~1/s).
1574
+ if (!workerFeedOwnerDmFallbackLogged.has(agentId)) {
1575
+ workerFeedOwnerDmFallbackLogged.add(agentId)
1576
+ if (workerFeedOwnerDmFallbackLogged.size > WORKER_FEED_FALLBACK_LOG_CAP) {
1577
+ const oldest = workerFeedOwnerDmFallbackLogged.values().next().value
1578
+ if (oldest != null) workerFeedOwnerDmFallbackLogged.delete(oldest)
1579
+ }
1580
+ process.stderr.write(
1581
+ `telegram gateway: worker-feed origin unresolved agent=${agentId} — routing card to owner DM\n`,
1582
+ )
1583
+ }
1584
+ }
1585
+ return { chatId: ownerDm, threadId: origin?.threadId }
1586
+ }
1587
+
1486
1588
  // ─── Periodic history reaper (#1073) ──────────────────────────────────────
1487
1589
  // The init-time prune in history.ts only touched the `messages` table.
1488
1590
  // `subagents` and `turns` in registry.db grew unbounded — every Agent()
@@ -1949,6 +2051,25 @@ const obligationEscalateInFlight = new Set<string>()
1949
2051
  // drain on the bare turn-end signal.
1950
2052
  const SERIALIZE_UNTIL_REPLIED_ENABLED =
1951
2053
  process.env.SWITCHROOM_SERIALIZE_UNTIL_REPLIED !== '0'
2054
+ // #2917 — rapid-fire per-chat FIFO. The #1556 delivery gate documents its
2055
+ // `turnInFlight` input as a LIVE read ("evaluated at delivery time — not a
2056
+ // receipt-time snapshot"), but handleInbound passes a receipt-time snapshot
2057
+ // (`turnInFlightAtReceipt`) taken at function entry. Under rapid-fire two
2058
+ // same-chat inbounds each snapshot "idle" during the other's async lead-in
2059
+ // (attachment download, composer-clear), both pass the gate, and both are
2060
+ // delivered — so replies come back reordered (observed 1,2,4,5,3,7,8,6). The
2061
+ // fix reads the gate LIVE off `claudeBusyKeys` (which does NOT yet contain
2062
+ // THIS inbound's own key — that is only marked at delivery — so it can't
2063
+ // self-block) AND reserves the key synchronously BEFORE the composer-clear
2064
+ // await, so a concurrent same-chat inbound observes the reservation and
2065
+ // buffers behind it, preserving FIFO. Nothing is dropped: a buffered inbound
2066
+ // drains on turn-complete / idle exactly as before. Kill switch (=0) restores
2067
+ // the receipt-snapshot behaviour. Only the non-cutover path is affected — the
2068
+ // delivery-machine cutover keeps its own at-receipt machine snapshot (reading
2069
+ // the machine live WOULD self-block, since its inbound event already advanced
2070
+ // it for this key).
2071
+ const SERIALIZE_INBOUND_DELIVERY_ENABLED =
2072
+ process.env.SWITCHROOM_SERIALIZE_INBOUND_DELIVERY !== '0'
1952
2073
  // Component 2 (bounded no-reply escape hatch). A turn that legitimately
1953
2074
  // ends with NO reply (handback ack, NO_REPLY marker, silent-end) sets
1954
2075
  // finalAnswerDelivered=false and would block the serialize gate forever.
@@ -2348,13 +2469,15 @@ type CurrentTurn = {
2348
2469
  silentAnchorText: string
2349
2470
  capturedText: string[]
2350
2471
  orphanedReplyTimeoutId: ReturnType<typeof setTimeout> | null
2351
- // How many times the orphaned-reply backstop timer has been re-armed
2352
- // mid-tool-call instead of firing a synthetic turn_end. Bounded so a
2353
- // genuinely wedged single long-running tool still surfaces: the cap is
2354
- // ORPHANED_REPLY_MAX_REARMS (20 × 30 s = 10 min of genuine tool activity).
2355
- // Reset to 0 on a fresh enqueue; NOT reset on text/tool_label re-arms —
2356
- // only a new turn resets the budget.
2357
- orphanedReplyRearmCount: number
2472
+ // Per-turn liveness tracker for the orphaned-reply backstop. Owns
2473
+ // `lastStreamEventAt` (stamped on ANY genuine stream event so a model
2474
+ // reasoning pause keeps the turn "recently streaming" and re-arms the fuse
2475
+ // instead of firing) and the rearm counter (bounded by
2476
+ // ORPHANED_REPLY_MAX_REARMS so a genuinely wedged single tool that never
2477
+ // streams still surfaces after the cap). The counter is zeroed on every
2478
+ // genuine stream event, so the cap only bites CONSECUTIVE silent expiries.
2479
+ // Fresh instance per enqueue (below). See context-exhaustion.ts.
2480
+ liveness: LivenessTracker
2358
2481
  // Component 3 (turn-origin reply routing). A stable per-turn identity,
2359
2482
  // `${registryKey-or-chatKey}#${startedAt}`, assigned when the turn
2360
2483
  // starts and stamped into the inbound meta (`origin_turn_id`) so a reply
@@ -4044,9 +4167,26 @@ async function resolveCompactCard(
4044
4167
  { chat_id: card.chatId, verb: `proactiveCompact.${kind}` },
4045
4168
  );
4046
4169
  } catch (err) {
4170
+ // Best-effort status-card edit — a transport hiccup (429 / "not
4171
+ // modified" / message gone) is not a liveness-logic error and must
4172
+ // not log a "card edit failed" warning (the warning-as-excuse-for-a-
4173
+ // stale-card anti-pattern). Only a genuine unexpected error warrants
4174
+ // a line; transport classes are silent.
4175
+ const desc = err instanceof Error ? err.message : String(err);
4176
+ const low = desc.toLowerCase();
4177
+ if (
4178
+ low.includes('not modified') ||
4179
+ low.includes('not found') ||
4180
+ low.includes("can't be edited") ||
4181
+ low.includes('cannot be edited') ||
4182
+ low.includes('not enough rights') ||
4183
+ low.includes('429') ||
4184
+ low.includes('retry after')
4185
+ ) {
4186
+ return;
4187
+ }
4047
4188
  process.stderr.write(
4048
- `telegram gateway: proactive-compact ${kind} card edit failed: ` +
4049
- `${err instanceof Error ? err.message : String(err)}\n`,
4189
+ `telegram gateway: proactive-compact ${kind} card edit failed: ${desc}\n`,
4050
4190
  );
4051
4191
  }
4052
4192
  }
@@ -6230,6 +6370,127 @@ async function activityCardBootReaper(): Promise<void> {
6230
6370
  )
6231
6371
  }
6232
6372
  }
6373
+ // ─── Mid-session stale-card reaper (#2918) ──────────────────────────────────
6374
+ // The boot reapers (markOrphanedWithTimeoutClassification + activityCardBoot-
6375
+ // Reaper) run ONCE at startup. A turn whose owning SDK subprocess dies
6376
+ // mid-session (SIGKILL / OOM / crash) without a clean end leaves its turns-DB
6377
+ // row `ended_at IS NULL` and its activity card frozen on "working…" until the
6378
+ // NEXT gateway boot — often many hours. This periodic sweep, running inside the
6379
+ // live gateway, finalizes those orphans without waiting for a restart.
6380
+ //
6381
+ // LIVENESS (the critical correctness guard): a turn/card is reaped ONLY when no
6382
+ // live in-flight turn owns it. The live set is derived from `currentTurnMap`
6383
+ // (per-topic byKey) PLUS the singleton `currentTurn` mirror, so it is correct
6384
+ // whether or not the per-topic-isolation flag is on. A healthy long-running
6385
+ // turn is always in that set and is never swept. A TTL gate is a secondary
6386
+ // guard against the just-started-not-yet-tracked race.
6387
+ //
6388
+ // Kill switch: set SWITCHROOM_MID_SESSION_CARD_REAPER=0 to disable (clean
6389
+ // revert, mirrors #2919). TTL/interval overridable via env for tuning.
6390
+ const MID_SESSION_CARD_REAPER_ENABLED =
6391
+ process.env.SWITCHROOM_MID_SESSION_CARD_REAPER !== '0'
6392
+ const MID_SESSION_CARD_REAPER_TTL_MS = (() => {
6393
+ const v = Number(process.env.SWITCHROOM_MID_SESSION_CARD_REAPER_TTL_MS)
6394
+ return Number.isFinite(v) && v > 0 ? v : 15 * 60_000 // 15 min
6395
+ })()
6396
+ const MID_SESSION_CARD_REAPER_INTERVAL_MS = (() => {
6397
+ const v = Number(process.env.SWITCHROOM_MID_SESSION_CARD_REAPER_INTERVAL_MS)
6398
+ return Number.isFinite(v) && v > 0 ? v : 5 * 60_000 // 5 min
6399
+ })()
6400
+
6401
+ // Snapshot the turn_keys / topic keys owned by a live in-flight turn right now.
6402
+ function liveTurnKeySets(): { registryKeys: Set<string>; topicKeys: Set<string> } {
6403
+ const registryKeys = new Set<string>()
6404
+ const topicKeys = new Set<string>()
6405
+ const add = (t: CurrentTurn | null | undefined): void => {
6406
+ if (t == null) return
6407
+ if (t.registryKey != null && t.registryKey.length > 0) registryKeys.add(t.registryKey)
6408
+ topicKeys.add(statusKey(t.sessionChatId, t.sessionThreadId))
6409
+ }
6410
+ for (const t of currentTurnMap.byKey.values()) add(t)
6411
+ add(currentTurn) // flag-OFF store + most-recent mirror
6412
+ return { registryKeys, topicKeys }
6413
+ }
6414
+
6415
+ async function runMidSessionCardReaper(): Promise<void> {
6416
+ if (!MID_SESSION_CARD_REAPER_ENABLED) return
6417
+ const now = Date.now()
6418
+ const { registryKeys, topicKeys } = liveTurnKeySets()
6419
+
6420
+ // 1) Stamp ownerless open turns-DB rows (the durable spinner signal).
6421
+ if (turnsDb != null) {
6422
+ try {
6423
+ const { reaped, reapedTurnKeys } = reapStaleOpenTurns(turnsDb, {
6424
+ activeTurnKeys: registryKeys,
6425
+ ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
6426
+ now,
6427
+ })
6428
+ if (reaped > 0) {
6429
+ process.stderr.write(
6430
+ `telegram gateway: mid-session reaper stamped ${reaped} orphaned turn(s) ` +
6431
+ `as 'restart' (${reapedTurnKeys.join(',')})\n`,
6432
+ )
6433
+ }
6434
+ } catch (err) {
6435
+ process.stderr.write(
6436
+ `telegram gateway: mid-session turn reaper error: ${(err as Error).message}\n`,
6437
+ )
6438
+ }
6439
+ }
6440
+
6441
+ // 2) Finalize the leftover visible activity card(s) for those dead turns.
6442
+ if (activityCardPersistEnabled) {
6443
+ try {
6444
+ const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
6445
+ path: ACTIVITY_CARD_STORE_PATH,
6446
+ fs: activityCardStoreFs,
6447
+ isLive: (record) => topicKeys.has(record.turnKey),
6448
+ ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
6449
+ now,
6450
+ finalizeCard: (record) =>
6451
+ robustApiCall(
6452
+ () =>
6453
+ lockedBot.api.editMessageText(
6454
+ record.chatId,
6455
+ record.activityMessageId,
6456
+ richMessage(restartOrphanCardFinalizeText(record.startedAt)),
6457
+ {},
6458
+ ),
6459
+ {
6460
+ chat_id: record.chatId,
6461
+ ...(record.threadId != null ? { threadId: record.threadId } : {}),
6462
+ verb: 'activity-card.mid-session-reap-finalize',
6463
+ },
6464
+ ),
6465
+ unpinCard: (record) =>
6466
+ robustApiCall(
6467
+ () => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
6468
+ {
6469
+ chat_id: record.chatId,
6470
+ ...(record.threadId != null ? { threadId: record.threadId } : {}),
6471
+ verb: 'activity-card.mid-session-reap-unpin',
6472
+ },
6473
+ ),
6474
+ })
6475
+ if (total > 0) {
6476
+ process.stderr.write(
6477
+ `telegram gateway: activity-card: mid-session finalized ${finalized}/${total} ` +
6478
+ `(vanished ${vanished}/${total}) orphaned card(s) (at-most-once)\n`,
6479
+ )
6480
+ }
6481
+ } catch (err) {
6482
+ process.stderr.write(
6483
+ `telegram gateway: mid-session card reaper error: ${(err as Error).message}\n`,
6484
+ )
6485
+ }
6486
+ }
6487
+ }
6488
+
6489
+ const midSessionCardReaper = setInterval(() => {
6490
+ void runMidSessionCardReaper()
6491
+ }, MID_SESSION_CARD_REAPER_INTERVAL_MS)
6492
+ midSessionCardReaper.unref()
6493
+
6233
6494
  // NOTE: statusPinBootCleanup() is deliberately NOT invoked here at import time.
6234
6495
  // The status-pin store is a SHARED per-agent file, and cleanup issues real
6235
6496
  // unpinChatMessage calls. On a double-boot the losing gateway must NOT touch
@@ -6596,6 +6857,11 @@ function parsePositiveMsEnv(name: string, fallbackMs: number): number {
6596
6857
  const n = Number(raw)
6597
6858
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallbackMs
6598
6859
  }
6860
+ // Orphaned-reply "recently streaming" window (thinking-pause fix). If a genuine
6861
+ // stream event landed within this window, the fuse re-arms instead of firing —
6862
+ // so a long model reasoning pause (which emits no text/tool events) is
6863
+ // survivable while a genuine multi-minute hang still surfaces. Default 120 s.
6864
+ const ORPHANED_REPLY_STREAM_WINDOW_MS = parsePositiveMsEnv('SWITCHROOM_ORPHANED_REPLY_STREAM_WINDOW_MS', 120_000)
6599
6865
  const SILENCE_FALLBACK_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_MS', 300_000)
6600
6866
  const SILENCE_FALLBACK_HARD_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_HARD_MS', 900_000)
6601
6867
  // #2527 — mid-turn liveness floor threshold (default 45s). Still gates the
@@ -12411,20 +12677,32 @@ function resetOrphanedReplyTimeout(): void {
12411
12677
  }
12412
12678
  return false
12413
12679
  })()
12414
- if (working || humanWaiting) {
12415
- const underCap = t.orphanedReplyRearmCount < ORPHANED_REPLY_MAX_REARMS
12416
- if (humanWaiting || underCap) {
12417
- t.orphanedReplyRearmCount++
12418
- process.stderr.write(
12419
- `telegram gateway: orphaned-reply fuse expired — re-arming` +
12420
- ` (rearm ${t.orphanedReplyRearmCount}/${ORPHANED_REPLY_MAX_REARMS},` +
12421
- ` in_flight=${toolFlightTracker.inFlightCount()},` +
12422
- ` human_wait=${humanWaiting},` +
12423
- ` bg_work=${pendingProgress.hasPendingAsyncDispatch(turnKey)})\n`,
12424
- )
12425
- resetOrphanedReplyTimeout()
12426
- return
12427
- }
12680
+ // Route the rearm decision through the per-turn LivenessTracker. It
12681
+ // rearms when working OR recently-streaming (thinking-pause survival)
12682
+ // OR human-waiting; working/recently-streaming rearms count against
12683
+ // ORPHANED_REPLY_MAX_REARMS while human-wait rearms stay uncapped.
12684
+ const now = Date.now()
12685
+ const recentlyStreaming = t.liveness.recentlyStreaming(now, ORPHANED_REPLY_STREAM_WINDOW_MS)
12686
+ const decision = t.liveness.decideOnExpiry({
12687
+ working,
12688
+ humanWaiting,
12689
+ now,
12690
+ windowMs: ORPHANED_REPLY_STREAM_WINDOW_MS,
12691
+ maxRearms: ORPHANED_REPLY_MAX_REARMS,
12692
+ })
12693
+ if (decision.rearm) {
12694
+ process.stderr.write(
12695
+ `telegram gateway: orphaned-reply fuse expired — re-arming` +
12696
+ ` (rearm ${t.liveness.orphanedReplyRearmCount}/${ORPHANED_REPLY_MAX_REARMS},` +
12697
+ ` in_flight=${toolFlightTracker.inFlightCount()},` +
12698
+ ` human_wait=${humanWaiting},` +
12699
+ ` recently_streaming=${recentlyStreaming},` +
12700
+ ` bg_work=${pendingProgress.hasPendingAsyncDispatch(turnKey)})\n`,
12701
+ )
12702
+ resetOrphanedReplyTimeout()
12703
+ return
12704
+ }
12705
+ if (decision.countsAgainstCap) {
12428
12706
  process.stderr.write(
12429
12707
  `telegram gateway: orphaned-reply rearm cap reached (${ORPHANED_REPLY_MAX_REARMS}) — forcing backstop despite working state\n`,
12430
12708
  )
@@ -12732,7 +13010,22 @@ async function drainActivitySummary(
12732
13010
  turn.activityLastSentRender = target
12733
13011
  } catch (err) {
12734
13012
  const msg = err instanceof Error ? err.message : String(err)
12735
- if (!msg.includes('message is not modified')) {
13013
+ const low = msg.toLowerCase()
13014
+ // Transport-class failures (429, message gone, "not modified") must
13015
+ // NOT inflate `activityDrainFailures` — that counter flags a turn as
13016
+ // DEGRADED on turn-end, and a transient rate-limit or an
13017
+ // already-deleted message is not a logic defect. Counting them would
13018
+ // false-flag a healthy turn. "not modified" is success; gone/429 are
13019
+ // retried or harmless. Only a genuine send/open failure counts.
13020
+ const isTransport =
13021
+ low.includes('not modified') ||
13022
+ low.includes('not found') ||
13023
+ low.includes("can't be edited") ||
13024
+ low.includes('cannot be edited') ||
13025
+ low.includes('not enough rights') ||
13026
+ low.includes('429') ||
13027
+ low.includes('retry after')
13028
+ if (!isTransport) {
12736
13029
  turn.activityDrainFailures += 1
12737
13030
  // Surface the failing anchor + topic: the resume-400 bug fed a
12738
13031
  // fabricated 13-digit message_id as the reply anchor here, so every
@@ -12900,11 +13193,19 @@ function feedHeartbeatTick(): void {
12900
13193
  // durable record once it completes.
12901
13194
  // - 'emit' → genuine in-flight post-answer activity; render the card below.
12902
13195
  const subagentAt = turn.subagentActivityAt
13196
+ // Fix 3 (sub-agent-delegation freeze): a foreground `Task`/`Agent` still
13197
+ // tracked in `turn.foregroundSubAgents` is POSITIVE evidence the worker
13198
+ // has not reported finished — see the doc comment on
13199
+ // `PostAnswerLivenessInput.stillDispatched` in turn-liveness-floor.ts for
13200
+ // why this must bypass the staleness cap rather than let a single long
13201
+ // silent step freeze the card mid-delegation.
13202
+ const stillDispatched = turn.foregroundSubAgents.size > 0
12903
13203
  const livenessVerdict = evaluatePostAnswerLiveness({
12904
13204
  subagentActivityAt: subagentAt,
12905
13205
  finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
12906
13206
  now: Date.now(),
12907
13207
  staleCapMs: POST_ANSWER_LIVENESS_STALE_MS,
13208
+ stillDispatched,
12908
13209
  })
12909
13210
  if (livenessVerdict !== 'emit' || subagentAt == null) return // idle gap or stale worker → stay silent (the `== null` also narrows subagentAt for the elapsed below)
12910
13211
  // A background worker is genuinely active after the answer. Open or maintain
@@ -13074,7 +13375,23 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
13074
13375
  { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.delete' },
13075
13376
  )
13076
13377
  } catch (err) {
13077
- process.stderr.write(`telegram gateway: activity-summary delete failed: ${err}\n`)
13378
+ // Best-effort teardown of a status card. A transport-class failure
13379
+ // (message already deleted, chat gone, 429) is not a liveness-logic
13380
+ // error — "message to delete not found" is the desired end state
13381
+ // here, and a 429 on a teardown delete is retried by robustApiCall.
13382
+ // Stay silent on those; warn only on a genuinely unexpected error.
13383
+ const msg = err instanceof Error ? err.message : String(err)
13384
+ const low = msg.toLowerCase()
13385
+ if (
13386
+ low.includes('not modified') ||
13387
+ low.includes('not found') ||
13388
+ low.includes('not enough rights') ||
13389
+ low.includes('429') ||
13390
+ low.includes('retry after')
13391
+ ) {
13392
+ return
13393
+ }
13394
+ process.stderr.write(`telegram gateway: activity-summary delete failed: ${msg}\n`)
13078
13395
  }
13079
13396
  return
13080
13397
  }
@@ -13100,10 +13417,26 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
13100
13417
  { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.finalize' },
13101
13418
  )
13102
13419
  } catch (err) {
13420
+ // Same transport-class discipline as the delete path: the card
13421
+ // finalize is a best-effort liveness edit. "not modified" = the card
13422
+ // already shows the finalized body (success); "not found" / "not
13423
+ // enough rights" = the message is gone (nothing to finalize); 429 is
13424
+ // retried by robustApiCall. None of those are liveness-logic errors
13425
+ // and none warrant a stderr warning. Warn only on the unexpected.
13103
13426
  const msg = err instanceof Error ? err.message : String(err)
13104
- if (!msg.includes('message is not modified')) {
13105
- process.stderr.write(`telegram gateway: activity-summary finalize failed: ${msg}\n`)
13427
+ const low = msg.toLowerCase()
13428
+ if (
13429
+ low.includes('not modified') ||
13430
+ low.includes('not found') ||
13431
+ low.includes("can't be edited") ||
13432
+ low.includes('cannot be edited') ||
13433
+ low.includes('not enough rights') ||
13434
+ low.includes('429') ||
13435
+ low.includes('retry after')
13436
+ ) {
13437
+ return
13106
13438
  }
13439
+ process.stderr.write(`telegram gateway: activity-summary finalize failed: ${msg}\n`)
13107
13440
  }
13108
13441
  })
13109
13442
  }
@@ -13278,6 +13611,22 @@ function surfaceConsolidationLegibility(
13278
13611
  }
13279
13612
 
13280
13613
  function handleSessionEvent(ev: SessionEvent): void {
13614
+ // Per-turn liveness stamp (orphaned-reply thinking-pause fix). Stamp
13615
+ // lastStreamEventAt AND reset the rearm counter on ANY genuine stream event,
13616
+ // under ONE shared predicate: a live turn is present and this is not the
13617
+ // synthetic durationMs===-1 turn_end (the fire callback's own re-dispatch).
13618
+ // The counter reset MUST live here at the dispatcher — NOT inside
13619
+ // resetOrphanedReplyTimeout() (which is called from the fire callback one
13620
+ // line after the counter increments) and NOT tied to a single case (e.g.
13621
+ // tool_result does not call resetOrphanedReplyTimeout). onStreamEvent applies
13622
+ // the `!(turn_end && -1)` half of the predicate internally.
13623
+ {
13624
+ const liveTurn = currentTurn
13625
+ if (liveTurn != null) {
13626
+ const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
13627
+ liveTurn.liveness.onStreamEvent(ev.kind, durationMs, Date.now())
13628
+ }
13629
+ }
13281
13630
  switch (ev.kind) {
13282
13631
  case 'enqueue': {
13283
13632
  // Drain any orphaned typing-wrap entries left over from a crashed
@@ -13314,6 +13663,14 @@ function handleSessionEvent(ev: SessionEvent): void {
13314
13663
  prior.answerStream.stop()
13315
13664
  prior.answerStream = null
13316
13665
  }
13666
+ // Bounded-leak hardening (A5): clear the prior turn's orphaned-reply
13667
+ // fuse before it is superseded. The fire callback re-reads currentTurn
13668
+ // and no-ops on a stale turn, but proactively clearing the timer avoids
13669
+ // a bounded pile-up of dangling timers across rapid steer/queue turns.
13670
+ if (prior?.orphanedReplyTimeoutId != null) {
13671
+ clearTimeout(prior.orphanedReplyTimeoutId)
13672
+ prior.orphanedReplyTimeoutId = null
13673
+ }
13317
13674
  // #1067: swap the entire turn atom in one assignment. Every
13318
13675
  // handler captures `const turn = currentTurn` at entry, so a
13319
13676
  // captured-then-awaited read can't reattribute to the new turn.
@@ -13373,7 +13730,9 @@ function handleSessionEvent(ev: SessionEvent): void {
13373
13730
  silentAnchorText: '',
13374
13731
  capturedText: [],
13375
13732
  orphanedReplyTimeoutId: null,
13376
- orphanedReplyRearmCount: 0,
13733
+ // Fresh liveness tracker: lastStreamEventAt seeded to the turn start
13734
+ // so a turn that never streams still trips the fuse after windowMs.
13735
+ liveness: new LivenessTracker(startedAt),
13377
13736
  turnId,
13378
13737
  registryKey: null,
13379
13738
  noReplyDrainTimer: null,
@@ -14043,10 +14402,25 @@ function handleSessionEvent(ev: SessionEvent): void {
14043
14402
  if (ev.durationMs === -1) {
14044
14403
  const turn = currentTurn
14045
14404
  const key = turn != null ? statusKey(turn.sessionChatId, turn.sessionThreadId) : ''
14046
- if (isLegitimatelyWorking(key)) {
14405
+ // Widened to also suppress while the turn is RECENTLY STREAMING — a
14406
+ // model reasoning pause emits no tool/text events (so
14407
+ // isLegitimatelyWorking is false) but a genuine stream landed within
14408
+ // the window, so the turn is alive and must not be torn down.
14409
+ // ACCEPTED TRADE-OFF (F3): the context-exhaustion recovery latency via
14410
+ // THIS backstop path grows from ~30 s to ~120-150 s, because the
14411
+ // "Prompt is too long" marker is itself a genuine `text` event that
14412
+ // stamps recentlyStreaming. This is acceptable — the primary
14413
+ // context-exhaustion teardown is the immediate `endCurrentTurnAtomic`
14414
+ // in `case 'text'` (isContextExhaustionText) above; this backstop only
14415
+ // matters if that path is missed, and it still COMPLETES once the
14416
+ // window lapses. Recovery is delayed, never suppressed forever.
14417
+ const recentlyStreaming =
14418
+ turn != null && turn.liveness.recentlyStreaming(Date.now(), ORPHANED_REPLY_STREAM_WINDOW_MS)
14419
+ if (isLegitimatelyWorking(key) || recentlyStreaming) {
14047
14420
  process.stderr.write(
14048
14421
  `telegram gateway: synthetic turn_end suppressed — legitimately working` +
14049
14422
  ` (in_flight=${toolFlightTracker.inFlightCount()},` +
14423
+ ` recently_streaming=${recentlyStreaming},` +
14050
14424
  ` bg_work=${turn != null ? pendingProgress.hasPendingAsyncDispatch(key) : false})\n`,
14051
14425
  )
14052
14426
  return
@@ -15340,30 +15714,32 @@ async function handleInbound(
15340
15714
  // network RTT) but not a user-perceived end-to-end measurement.
15341
15715
  const inboundReceivedAt = Date.now()
15342
15716
 
15343
- // Phase 2b shadow: inbound arrival. Emit BEFORE the snapshot/gate
15344
- // logic so the machine sees the event at the same point in time the
15345
- // imperative code would. The machine internally handles fresh-turn
15346
- // vs mid-turn — its decision will be visible in the gw-trace shadow
15347
- // line emitted to stderr.
15348
- const _shadowKey = statusKey(ctx.chat?.id != null ? String(ctx.chat.id) : '0', ctx.message?.message_thread_id) as _ChatKey
15349
- // PR3b-cutover: snapshot the machine's in-turn state BEFORE the
15350
- // inbound event advances it. A fresh-turn inbound transitions the
15351
- // machine idle→in_turn; reading after the emit would see THIS
15352
- // message's own just-started turn and self-block it (the same
15353
- // self-block hazard the claudeBusyKeys snapshot below guards). When
15354
- // the kill-switch is off this is null and the gate uses the legacy
15717
+ // PR3b-cutover: snapshot the machine's in-turn state AT RECEIPT, before
15718
+ // this handler emits the `inbound` event (that emit is DEFERRED below to
15719
+ // the delivery-commit point — search DEFERRED_INBOUND_EMIT). A fresh-turn
15720
+ // inbound transitions the machine idle→in_turn; reading after the emit
15721
+ // would see THIS message's own just-started turn and self-block it (the
15722
+ // same self-block hazard the claudeBusyKeys snapshot below guards). Null
15723
+ // when the kill-switch is off, in which case the gate uses the legacy
15355
15724
  // claudeBusyKeys read.
15725
+ //
15726
+ // WHY THE INBOUND EMIT IS DEFERRED (overlord /usage wedge, 2026-07-08):
15727
+ // the `inbound` event drives the now-AUTHORITATIVE turn-in-flight gate
15728
+ // (turnInFlightForGate → isMachineInTurn). Emitting it HERE — at handler
15729
+ // entry, before the intercept gauntlet below (permission-reply, /auth
15730
+ // paste-back, interrupt-empty, secret-detect drop, …) — drove the machine
15731
+ // into `bridge_alive_in_turn` for messages that then EARLY-RETURN as an
15732
+ // intercept and never become a turn. No delivery, no claudeBusyKeys mark,
15733
+ // and critically no `turnEnd` ever fires, so the machine held the gate
15734
+ // closed until the 5-min TTL tick force-cleared it — buffering every
15735
+ // subsequent inbound (including /usage) the whole time. That is exactly
15736
+ // the dangerous `machine_over_holds` divergence gate-parity-probe.ts
15737
+ // flags. The imperative claudeBusyKeys tracker got it right (never marked
15738
+ // busy for the intercepted message); the machine was mis-fed. Fix: emit
15739
+ // `inbound` only once the message clears every intercept and reaches the
15740
+ // deliver-or-buffer decision, keeping the machine in lockstep with the
15741
+ // imperative delivery lifecycle it models.
15356
15742
  const machineInTurnAtReceipt = isDeliveryCutoverEnabled() ? isMachineInTurn() : null
15357
- shadowEmit({
15358
- kind: 'inbound',
15359
- key: _shadowKey,
15360
- msg: {
15361
- msgId: ctx.message?.message_id ?? 0,
15362
- isSteering: false, // refined in PR 3 — for now shadow conservatively classifies as non-steering
15363
- payload: null,
15364
- },
15365
- at: Date.now(),
15366
- })
15367
15743
 
15368
15744
  // #1556 self-blocking fix (v0.12.22): snapshot the live turn-state
15369
15745
  // BEFORE the fresh-turn branch (line ~7357) sets activeTurnStartedAt
@@ -16435,12 +16811,28 @@ async function handleInbound(
16435
16811
  TOPIC_FRAMING_ENABLED && messageThreadId != null
16436
16812
  ? 'This message belongs to the current topic only — answer ONLY this question, in this topic. Do not also answer a pending message from another topic.'
16437
16813
  : undefined
16814
+ // person_id name resolution (docs/configuration.md, resolve-person.ts):
16815
+ // chat-scoped, boot-time-static lookup — falls back to today's raw
16816
+ // id/username behavior (fail-open) whenever unresolved or when group
16817
+ // membership can't be positively confirmed from access.json's allowFrom.
16818
+ // Never touches access.json itself and never blocks/denies anything.
16819
+ const rawUser = from.username ?? String(from.id)
16820
+ const displayUser = safeResolvePersonName(
16821
+ PERSON_DIRECTORY,
16822
+ {
16823
+ telegramId: String(from.id),
16824
+ username: from.username,
16825
+ isDm: isDmChatId(chat_id),
16826
+ groupAllowFrom: access.groups[chat_id]?.allowFrom,
16827
+ },
16828
+ rawUser,
16829
+ )
16438
16830
  const inboundMsg: InboundMessage = {
16439
16831
  type: 'inbound',
16440
16832
  chatId: chat_id,
16441
16833
  ...(messageThreadId != null ? { threadId: messageThreadId } : {}),
16442
16834
  messageId: msgId ?? 0,
16443
- user: from.username ?? String(from.id),
16835
+ user: displayUser,
16444
16836
  userId: from.id,
16445
16837
  ts: ctx.message?.date ?? Math.floor(Date.now() / 1000),
16446
16838
  text: effectiveText,
@@ -16455,7 +16847,7 @@ async function handleInbound(
16455
16847
  meta: {
16456
16848
  chat_id,
16457
16849
  ...(msgId != null ? { message_id: String(msgId) } : {}),
16458
- user: from.username ?? String(from.id),
16850
+ user: displayUser,
16459
16851
  user_id: String(from.id),
16460
16852
  ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(),
16461
16853
  ...(messageThreadId != null ? { message_thread_id: String(messageThreadId) } : {}),
@@ -16560,6 +16952,29 @@ async function handleInbound(
16560
16952
  return
16561
16953
  }
16562
16954
 
16955
+ // DEFERRED_INBOUND_EMIT — drive the delivery state machine's `inbound`
16956
+ // event HERE, not at handler entry. Every intercept/early-return above
16957
+ // (permission-reply, /auth paste-back, interrupt-empty, secret-detect
16958
+ // drop, drop/pair) has been passed, so any message reaching this point is
16959
+ // a genuine turn the imperative code is about to deliver or buffer. That
16960
+ // keeps the machine's authoritative turn-in-flight state in lockstep with
16961
+ // the imperative delivery it models and can never be advanced into
16962
+ // `bridge_alive_in_turn` by a message that never becomes a turn — the
16963
+ // overlord /usage wedge of 2026-07-08 (see machineInTurnAtReceipt above).
16964
+ // isSteering is now the real classification (computed at ~line 16289), so
16965
+ // the machine correctly distinguishes a mid-turn steer (delivered, no new
16966
+ // turn) from a fresh turn.
16967
+ shadowEmit({
16968
+ kind: 'inbound',
16969
+ key: statusKey(chat_id, messageThreadId) as _ChatKey,
16970
+ msg: {
16971
+ msgId: msgId ?? 0,
16972
+ isSteering,
16973
+ payload: null,
16974
+ },
16975
+ at: Date.now(),
16976
+ })
16977
+
16563
16978
  // PR2 obligation-ledger OPEN — BEFORE the buffer-until-idle / deliver split so
16564
16979
  // a mid-turn cross-topic inbound (the 715 case) is tracked whether it is
16565
16980
  // buffered or delivered now. Idempotent + gated; no-op when the flag is off.
@@ -16569,21 +16984,32 @@ async function handleInbound(
16569
16984
  effectiveText,
16570
16985
  })
16571
16986
 
16572
- if (
16573
- decideInboundDelivery({
16574
- turnInFlight: turnInFlightAtReceipt,
16575
- isSteering,
16576
- // Interrupt-marker carve-out (2026-05-24): the `!`-prefixed body
16577
- // must bypass the "buffer-until-turn-complete" gate because the
16578
- // SIGINT'd turn often doesn't emit turn_complete, leaving the
16579
- // body stranded in pendingInboundBuffer indefinitely. The
16580
- // `interrupt` const is computed at the start of handleInbound
16581
- // (line ~7606) and remains in scope here. When the user fires
16582
- // `!`-with-body, this delivers the body as a fresh inbound to
16583
- // the freshly-killed bridge.
16584
- isInterrupt: interrupt.isInterrupt,
16585
- }) === 'buffer-until-idle'
16586
- ) {
16987
+ // #2917: read the gate LIVE (not the receipt snapshot) on the default path
16988
+ // so a sibling same-chat inbound delivered during THIS handler's async
16989
+ // lead-in is observed here. Reading `claudeBusyKeys` live is self-block-safe:
16990
+ // THIS inbound's own key is only added at delivery (below), never by the
16991
+ // fresh-turn init bundle — so the live read never sees its own key. The
16992
+ // delivery-machine cutover keeps its at-receipt machine snapshot (a live
16993
+ // machine read WOULD self-block, since the inbound event already advanced
16994
+ // the machine for this key). Kill switch (=0) restores the pure snapshot.
16995
+ const gateTurnInFlight =
16996
+ SERIALIZE_INBOUND_DELIVERY_ENABLED && machineInTurnAtReceipt == null
16997
+ ? claudeBusyKeys.size > 0
16998
+ : turnInFlightAtReceipt
16999
+ const deliveryGate = reserveInboundDelivery({
17000
+ turnInFlight: gateTurnInFlight,
17001
+ isSteering,
17002
+ // Interrupt-marker carve-out (2026-05-24): the `!`-prefixed body
17003
+ // must bypass the "buffer-until-turn-complete" gate because the
17004
+ // SIGINT'd turn often doesn't emit turn_complete, leaving the
17005
+ // body stranded in pendingInboundBuffer indefinitely. The
17006
+ // `interrupt` const is computed at the start of handleInbound
17007
+ // (line ~7606) and remains in scope here. When the user fires
17008
+ // `!`-with-body, this delivers the body as a fresh inbound to
17009
+ // the freshly-killed bridge.
17010
+ isInterrupt: interrupt.isInterrupt,
17011
+ })
17012
+ if (deliveryGate.decision === 'buffer-until-idle') {
16587
17013
  pendingInboundBuffer.push(selfAgent, inboundMsg)
16588
17014
  process.stderr.write(
16589
17015
  `telegram gateway: inbound held mid-turn agent=${selfAgent} ` +
@@ -16607,6 +17033,18 @@ async function handleInbound(
16607
17033
  return
16608
17034
  }
16609
17035
 
17036
+ // #2917: reserve the chat's busy key SYNCHRONOUSLY here — before the
17037
+ // composer-clear await below — so a concurrent same-chat inbound reaching
17038
+ // the LIVE gate above observes this in-flight delivery and buffers behind it
17039
+ // (per-chat FIFO). Without this, both handlers pass the gate during each
17040
+ // other's async lead-in and race to the bridge, reordering the replies.
17041
+ // Only fresh-turn deliveries reserve (steering/interrupt amend a running
17042
+ // turn and must not). Released below if the send misses (bridge offline).
17043
+ let reservedBusyKey: string | null = null
17044
+ if (deliveryGate.reserve && SERIALIZE_INBOUND_DELIVERY_ENABLED && machineInTurnAtReceipt == null) {
17045
+ reservedBusyKey = markClaudeBusyForInbound(inboundMsg)
17046
+ }
17047
+
16610
17048
  // Pre-send composer clear (the marko wedge). The inbound is about to be
16611
17049
  // delivered as an MCP `notifications/claude/channel` notification, which
16612
17050
  // the unmodified CLI appends into its composer and auto-submits ONLY when
@@ -16636,7 +17074,10 @@ async function handleInbound(
16636
17074
 
16637
17075
  const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
16638
17076
  if (delivered) {
16639
- const busyKey = markClaudeBusyForInbound(inboundMsg)
17077
+ // Reuse the key reserved synchronously above (#2917) when present, else
17078
+ // mark now — markClaudeBusyForInbound is idempotent (lockstep re-stamp),
17079
+ // so a re-mark is safe and returns the same chat key.
17080
+ const busyKey = reservedBusyKey ?? markClaudeBusyForInbound(inboundMsg)
16640
17081
  // Track until claude acks via `enqueue` (the marko drop-wedge): if no ack
16641
17082
  // lands, the message stranded in the composer and the sweep re-delivers
16642
17083
  // it. Track ONLY messages that produce an `enqueue` to ack against —
@@ -16658,6 +17099,15 @@ async function handleInbound(
16658
17099
  }
16659
17100
  }
16660
17101
  if (!delivered) {
17102
+ // #2917: the synchronous reservation assumed delivery; the send missed
17103
+ // (bridge offline), so release it in lockstep — otherwise the orphaned
17104
+ // busy key would gate every subsequent inbound into the buffer until the
17105
+ // orphan reaper clears it. The message itself is buffered below, so FIFO
17106
+ // is still preserved (it drains in order on the next bridge register).
17107
+ if (reservedBusyKey != null) {
17108
+ claudeBusyKeys.delete(reservedBusyKey)
17109
+ claudeBusyKeySince.delete(reservedBusyKey)
17110
+ }
16661
17111
  // Only persist fresh user turns to the durable spool. Steering / `!`
16662
17112
  // interrupt / empty bodies are mid-turn amendments or no-ops that would
16663
17113
  // arrive orphaned if replayed as a fresh turn after a restart — drop them
@@ -18223,7 +18673,7 @@ interface ModelDepsRestartContext {
18223
18673
  }
18224
18674
 
18225
18675
  function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & ModelCommandDeps {
18226
- return {
18676
+ const deps: ModelMenuDeps & ModelCommandDeps = {
18227
18677
  discover: (a) => discoverModels(a),
18228
18678
  discoverSrModels: async () => {
18229
18679
  // /model/info lives on the ROOT proxy (model-mapped surface), NOT the
@@ -18329,7 +18779,25 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
18329
18779
  )
18330
18780
  }
18331
18781
  },
18782
+ /**
18783
+ * Session-only switch TO an sr-* (LiteLLM/OpenRouter) model. claude's
18784
+ * native `/model` picker rejects unknown sr-* ids, so we can't inject.
18785
+ * Write the token to the `.session-model-override` carrier file (start.sh
18786
+ * consumes it on the next boot and launches `claude --model <token>`), set
18787
+ * the in-memory session-model so /status stays honest across the restart
18788
+ * window, then run the SAME restart dispatch as scheduleRestart above.
18789
+ */
18790
+ scheduleModelRelaunch: async (model: string, reason: string) => {
18791
+ const agentDir = resolveAgentDirFromEnv()
18792
+ if (!agentDir) throw new Error('agent dir unresolvable — cannot write session-model carrier')
18793
+ // Carrier: single line, token + newline, no quoting (start.sh strips
18794
+ // whitespace and shape-gates). One-shot — consumed on the next boot.
18795
+ writeFileSync(join(agentDir, '.session-model-override'), `${model}\n`, 'utf8')
18796
+ activeSessionModelOverride = model
18797
+ await deps.scheduleRestart(reason)
18798
+ },
18332
18799
  }
18800
+ return deps
18333
18801
  }
18334
18802
 
18335
18803
  function modelMenuReplyMarkup(reply: ModelMenuReply): InlineKeyboard | undefined {
@@ -19804,25 +20272,21 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
19804
20272
  }
19805
20273
  } catch (err) {
19806
20274
  process.stderr.write(`telegram gateway: quota-watch: probe for crossing accounts failed: ${err}\n`)
19807
- if (!tuning.sendOnProbeFail) {
19808
- // A quota notification must never carry numbers we could not verify
19809
- // live. Leave the crossing accounts' state untouched — the
19810
- // transition re-evaluates (and re-probes) on the next 15-min tick.
19811
- // Persist any reconciles already applied, then bail.
19812
- if (reconciledCount > 0) {
19813
- try {
19814
- saveQuotaWatchState(stateDir, mutatedState)
19815
- } catch (saveErr) {
19816
- process.stderr.write(`telegram gateway: quota-watch state persist failed: ${saveErr}\n`)
19817
- }
20275
+ // A quota notification must never carry numbers we could not verify
20276
+ // live. Leave the crossing accounts' state untouched — the
20277
+ // transition re-evaluates (and re-probes) on the next 15-min tick.
20278
+ // Persist any reconciles already applied, then bail.
20279
+ if (reconciledCount > 0) {
20280
+ try {
20281
+ saveQuotaWatchState(stateDir, mutatedState)
20282
+ } catch (saveErr) {
20283
+ process.stderr.write(`telegram gateway: quota-watch state persist failed: ${saveErr}\n`)
19818
20284
  }
19819
- process.stderr.write(
19820
- `telegram gateway: quota-watch: deferring ${pendingTransitions.length} notification(s) until probe succeeds\n`,
19821
- )
19822
- return
19823
20285
  }
19824
- // Legacy (SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL=1): fall through
19825
- // and send from cached data.
20286
+ process.stderr.write(
20287
+ `telegram gateway: quota-watch: deferring ${pendingTransitions.length} notification(s) until probe succeeds\n`,
20288
+ )
20289
+ return
19826
20290
  }
19827
20291
 
19828
20292
  // Build final notifications, enriching the snapshot with fresh probe
@@ -19872,7 +20336,7 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
19872
20336
  // State normalised by the time of the probe — don't notify.
19873
20337
  continue
19874
20338
  }
19875
- } else if (!tuning.sendOnProbeFail) {
20339
+ } else {
19876
20340
  // No verified fresh data for this account (per-account probe failure
19877
20341
  // or label missing from the batch result). Same rule as the batch
19878
20342
  // throw above: never send unverified numbers. State untouched —
@@ -22554,7 +23018,15 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
22554
23018
  tz,
22555
23019
  now: renderNow,
22556
23020
  demo: refreshDemo,
22557
- ...(staleCachedAtMs != null ? { staleCachedAtMs } : { liveProbedAtMs: renderNow.getTime() }),
23021
+ // Honesty backstop (same as /usage): a TOTAL probe failure (zero
23022
+ // result rows, nothing served from cache) renders an explicit
23023
+ // "probe failed" marker instead of a false "Live" footer next to
23024
+ // no-data rows.
23025
+ ...(staleCachedAtMs != null
23026
+ ? { staleCachedAtMs }
23027
+ : probeResp.results.length > 0
23028
+ ? { liveProbedAtMs: renderNow.getTime() }
23029
+ : { probeFailed: true }),
22558
23030
  })
22559
23031
  const kbRows = buildSnapshotKeyboard(snapshots, { now: renderNow, demo: refreshDemo })
22560
23032
  const inline_keyboard = kbRows.map((row) =>
@@ -23020,21 +23492,49 @@ bot.command('usage', async ctx => {
23020
23492
  // flight; a TTL-hit or failed-probe fallback is tagged served:"cache",
23021
23493
  // which we surface as a "⚠ cached Nm ago" footer instead of a false
23022
23494
  // live stamp.
23495
+ const renderNow = new Date()
23023
23496
  const probeResp = await client.probeQuota(state.accounts.map((a) => a.label)).catch(() => ({ results: [] }))
23024
23497
  const { quotas, staleCachedAtMs } = zipProbeResults(
23025
23498
  state.accounts.map((a) => a.label),
23026
23499
  probeResp.results,
23027
23500
  )
23028
- const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
23501
+ const { buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
23029
23502
  '../auth-snapshot-format.js'
23030
23503
  )
23031
- const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
23504
+ const { renderUsageCard } = await import('../quota-bar-format.js')
23032
23505
  const snapshots = buildSnapshotsFromState(state, quotas)
23033
- const text = renderAuthSnapshotFormat2(snapshots, {
23034
- tz,
23035
- now: new Date(),
23506
+ // /usage renders the compact quota-bar block (at-a-glance headroom +
23507
+ // pace tick per window) plus a two-line footer — the actionable
23508
+ // cross-account recommendation verdict and the freshness marker (`⚠
23509
+ // cached Nm ago` when the probe was served stale from cache, else
23510
+ // `Live · refreshed Nm ago`). The old Format 2 table
23511
+ // (renderAuthSnapshotFormat2) is no longer appended here (operator
23512
+ // call, 2026-07-10): the two views were redundant and the table
23513
+ // doubled the message length. Every per-window reset the table
23514
+ // carried has an equivalent in the bar rows' `pct% / <time-left>`
23515
+ // segment, just relative instead of absolute — no info lost; the
23516
+ // recommendation + cached/live footer the table used to carry are
23517
+ // preserved by renderUsageCard.
23518
+ const exhaustedByLabel = new Map<string, boolean>(
23519
+ state.accounts.map((a) => [a.label, a.exhausted]),
23520
+ )
23521
+ const text = renderUsageCard(snapshots, exhaustedByLabel, {
23522
+ now: renderNow,
23036
23523
  demo,
23037
- ...(staleCachedAtMs != null ? { staleCachedAtMs } : { liveProbedAtMs: Date.now() }),
23524
+ // #2495 Change 2 — a TTL-hit / failed-probe fallback is tagged
23525
+ // served:"cache"; surface it as `⚠ cached Nm ago` instead of a
23526
+ // false live stamp. Otherwise stamp the live refresh time.
23527
+ // Honesty backstop: a TOTAL probe failure (the .catch above
23528
+ // returned `{results: []}` and nothing was served from cache)
23529
+ // must render an explicit "probe failed" marker, NOT a false
23530
+ // "Live" stamp next to "⚠️ no data" rows. Without this the
23531
+ // footer claimed "Live · refreshed 0s ago" while every account
23532
+ // row said "no data — probe failed" (#2959 review finding).
23533
+ ...(staleCachedAtMs != null
23534
+ ? { staleCachedAtMs }
23535
+ : probeResp.results.length > 0
23536
+ ? { liveProbedAtMs: renderNow.getTime() }
23537
+ : { probeFailed: true }),
23038
23538
  })
23039
23539
  // Preserve the Switch/Refresh/usage/Add inline keyboard on the
23040
23540
  // rich-message render — the table card carries the same actions the
@@ -23385,14 +23885,41 @@ bot.on('callback_query:data', async ctx => {
23385
23885
  // We track whether we applied the interim edit so we can skip the
23386
23886
  // toastOnly short-circuit if we did — a toastOnly return after the interim
23387
23887
  // edit would leave the menu stuck button-less.
23388
- let didInterimSrEdit = false
23888
+ // sr-* TARGET tap: switch TO a non-Claude (LiteLLM/OpenRouter) model.
23889
+ // Parity with the text `/model sr-*` path — claude's native picker rejects
23890
+ // unknown sr-* ids, so an in-place inject can't set them. Carry the token
23891
+ // across a graceful restart (the `.session-model-override` carrier) and
23892
+ // relaunch `claude --model sr-*`. Session-only; reverts to the configured
23893
+ // default on the next restart. The sr-* → Claude direction is handled below
23894
+ // via the SELECT/alias outcome + isSrToClaudeTransition.
23389
23895
  if (data.startsWith(MODEL_CALLBACK_SR)) {
23390
- const srLabel = escapeHtmlForTg(srFriendlyLabel(data.slice(MODEL_CALLBACK_SR.length)))
23896
+ const srName = data.slice(MODEL_CALLBACK_SR.length)
23897
+ const srLabel = escapeHtmlForTg(srFriendlyLabel(srName))
23898
+ if (!isValidModelArg(srName)) {
23899
+ await ctx
23900
+ .editMessageText(richMessage('❌ Invalid model name'), { reply_markup: { inline_keyboard: [] } })
23901
+ .catch(() => {})
23902
+ return
23903
+ }
23391
23904
  await ctx
23392
- .editMessageText(richMessage(`⏳ Switching session to **${srLabel}**…`), { reply_markup: { inline_keyboard: [] } })
23905
+ .editMessageText(
23906
+ richMessage(`🔄 Switching session to **${srLabel}** — restarting (~30s). _Session-only; reverts to the configured default on the next restart._`),
23907
+ { reply_markup: { inline_keyboard: [] } },
23908
+ )
23393
23909
  .catch(() => {})
23394
- didInterimSrEdit = true
23910
+ try {
23911
+ await modelDeps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`)
23912
+ } catch (err) {
23913
+ await ctx
23914
+ .editMessageText(
23915
+ richMessage(`❌ Could not switch to **${srLabel}**: ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`),
23916
+ { reply_markup: { inline_keyboard: [] } },
23917
+ )
23918
+ .catch(() => {})
23919
+ }
23920
+ return
23395
23921
  }
23922
+ const didInterimSrEdit = false
23396
23923
  try {
23397
23924
  const prevSessionModel = activeSessionModelOverride
23398
23925
  const outcome = await handleModelMenuCallback(data, modelDeps)
@@ -26066,6 +26593,32 @@ void (async () => {
26066
26593
 
26067
26594
  if (!didOneTimeSetup) {
26068
26595
  didOneTimeSetup = true
26596
+
26597
+ // person_id name resolution (docs/configuration.md, resolve-person.ts):
26598
+ // ONE-TIME boot validation, NOT periodic. `PERSON_DIRECTORY` is
26599
+ // built exactly once, here, from the static in-memory `people.json`
26600
+ // the scaffold projected from `switchroom.yaml`'s `users:` block —
26601
+ // a config change requires an agent restart, this never re-reads.
26602
+ // The orchestration (per-entry validation + dead-man's-switch) is
26603
+ // in the pure, unit-tested `runPersonDirectoryBootCheck` — this
26604
+ // block only performs the resulting I/O (stderr log + the EXISTING
26605
+ // fleet-alert path at low/config severity, kind: 'config-warning',
26606
+ // which must never page like a real outage).
26607
+ {
26608
+ const bootCheck = runPersonDirectoryBootCheck(readPeopleFile)
26609
+ PERSON_DIRECTORY = bootCheck.directory
26610
+ process.stderr.write(bootCheck.logLine + '\n')
26611
+ if (bootCheck.alertDetail != null) {
26612
+ emitGatewayOperatorEvent({
26613
+ kind: 'config-warning',
26614
+ agent: process.env.SWITCHROOM_AGENT_NAME ?? '-',
26615
+ detail: bootCheck.alertDetail,
26616
+ suggestedActions: [],
26617
+ firstSeenAt: new Date(),
26618
+ })
26619
+ }
26620
+ }
26621
+
26069
26622
  void registerSwitchroomBotCommands().catch(() => {})
26070
26623
 
26071
26624
  // #613 fix: pre-warm the chatAvailableReactions cache for every
@@ -26376,6 +26929,73 @@ void (async () => {
26376
26929
  }
26377
26930
  } catch {}
26378
26931
 
26932
+ // ─── Session-model re-hydration + LiteLLM-down alert (session relaunch) ───
26933
+ //
26934
+ // start.sh writes the EFFECTIVE launched model to `.active-session-model`
26935
+ // on every boot (the model actually passed to `claude --model`). Re-hydrate
26936
+ // the in-memory session-model override from it so `/status` and the welcome
26937
+ // card stay honest after a session-relaunch restart. Only treat it as an
26938
+ // override when it differs from the configured/default model — a plain boot
26939
+ // on the configured model leaves the override null.
26940
+ //
26941
+ // Also consume the `.session-model-alert` sentinel: start.sh drops it when
26942
+ // it had to DROP an sr-* override because LiteLLM was unreachable at boot
26943
+ // (booting on the configured default instead of 4xx-ing against Anthropic).
26944
+ // We turn it into a loud Telegram message to the operator, then delete it.
26945
+ try {
26946
+ const smAgentDir = resolveAgentDirFromEnv()
26947
+ if (smAgentDir) {
26948
+ const activePath = join(smAgentDir, '.active-session-model')
26949
+ if (existsSync(activePath)) {
26950
+ try {
26951
+ const launched = readFileSync(activePath, 'utf8').trim()
26952
+ const configured = (() => {
26953
+ type AgentListResp = { agents: Array<{ name: string; model?: string | null }> }
26954
+ const d = switchroomExecJson<AgentListResp>(['agent', 'list'])
26955
+ const raw = d?.agents?.find(a => a.name === getMyAgentName())?.model ?? null
26956
+ // Resolve through the SAME resolver start.sh's scaffold uses, so an
26957
+ // unset (`null`) or `model: "default"` config value maps to the
26958
+ // switchroom default model id — matching the EFFECTIVE model start.sh
26959
+ // wrote to `.active-session-model`. Comparing the raw (unresolved)
26960
+ // value would flag every ordinary restart of a default-model agent as
26961
+ // a phantom session override.
26962
+ return resolveMainModel(raw ?? undefined)
26963
+ })()
26964
+ activeSessionModelOverride =
26965
+ launched.length > 0 && launched !== configured ? launched : null
26966
+ } catch { /* leave override as-is on a bad read */ }
26967
+ }
26968
+
26969
+ const alertPath = join(smAgentDir, '.session-model-alert')
26970
+ if (existsSync(alertPath)) {
26971
+ let alertText: string | null = null
26972
+ try {
26973
+ alertText = readFileSync(alertPath, 'utf8').trim()
26974
+ } catch { alertText = null }
26975
+ try { unlinkSync(alertPath) } catch { /* best-effort */ }
26976
+ if (alertText && alertText.length > 0) {
26977
+ // Notify EVERY operator, not just allowFrom[0]. Each send is wrapped
26978
+ // in its own catch so one operator's failure (blocked bot, bad chat
26979
+ // id) never stops the rest, and the outer boot flow never crashes.
26980
+ const operators = loadAccess().allowFrom
26981
+ for (const operator of operators) {
26982
+ if (!operator) continue
26983
+ void lockedBot.api
26984
+ .sendMessage(operator, `⚠️ ${alertText}`)
26985
+ .catch((err: unknown) =>
26986
+ process.stderr.write(
26987
+ `telegram gateway: session-model alert send failed for ${operator}: ${(err as Error)?.message ?? String(err)}\n`,
26988
+ ),
26989
+ )
26990
+ }
26991
+ process.stderr.write(`telegram gateway: session-model: LiteLLM-down override drop — ${alertText}\n`)
26992
+ }
26993
+ }
26994
+ }
26995
+ } catch (err) {
26996
+ process.stderr.write(`telegram gateway: session-model re-hydration failed: ${(err as Error)?.message ?? String(err)}\n`)
26997
+ }
26998
+
26379
26999
  // Credit-exhaustion watcher (#348). Reads `<agentDir>/.claude/.claude.json`
26380
27000
  // for `cachedExtraUsageDisabledReason`. Fires a Telegram notification
26381
27001
  // on transition into / out of fatal billing states (out_of_credits,
@@ -26593,6 +27213,20 @@ void (async () => {
26593
27213
  ownedWorktreeCwds({
26594
27214
  self: process.env.SWITCHROOM_AGENT_NAME,
26595
27215
  listRecords: listWorktreeRecords,
27216
+ // Durable, non-env identity fallback (#1116 / #2893): when
27217
+ // SWITCHROOM_AGENT_NAME is somehow unset, derive this
27218
+ // agent's own identity from its own directory so worktree
27219
+ // ownership still resolves (env is only the fast path).
27220
+ // `watcherAgentDir` is guaranteed non-null in this branch
27221
+ // (the whole watcher is gated on it above). Kill-switch
27222
+ // SWITCHROOM_WORKTREE_IDENTITY_FALLBACK=0 restores the
27223
+ // pre-fix env-only behaviour.
27224
+ agentDir:
27225
+ process.env.SWITCHROOM_WORKTREE_IDENTITY_FALLBACK === '0'
27226
+ ? undefined
27227
+ : watcherAgentDir,
27228
+ log: (msg) =>
27229
+ process.stderr.write(`telegram gateway: ${msg}\n`),
26596
27230
  }),
26597
27231
  // Bug 0 fix: previously omitted, leaving the watcher unable to
26598
27232
  // write liveness/stall/turn_end updates to the registry DB.
@@ -26939,8 +27573,8 @@ void (async () => {
26939
27573
  orphanStatusEnabled,
26940
27574
  })
26941
27575
  if (surface === 'worker-feed') {
26942
- const origin = resolveSubagentOriginChat(agentId)
26943
- const wkChat = origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? '')
27576
+ const wk = resolveWorkerFeedChat(agentId, fleetChatId)
27577
+ const wkChat = wk.chatId
26944
27578
  void workerActivityFeed?.update(
26945
27579
  agentId,
26946
27580
  wkChat,
@@ -26952,7 +27586,7 @@ void (async () => {
26952
27586
  elapsedMs,
26953
27587
  state: 'running',
26954
27588
  },
26955
- origin?.threadId,
27589
+ wk.threadId,
26956
27590
  )?.then(() => reconcileWorkerPin(agentId, wkChat, true))
26957
27591
  return
26958
27592
  }
@@ -27077,8 +27711,8 @@ void (async () => {
27077
27711
  // resolved (the pinned-card fleet that used to carry the chat
27078
27712
  // is gone — see resolveSubagentOriginChat).
27079
27713
  if (workerFeedEnabled) {
27080
- const origin = resolveSubagentOriginChat(agentId)
27081
- const wkChat = origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? '')
27714
+ const wk = resolveWorkerFeedChat(agentId, fleetChatId)
27715
+ const wkChat = wk.chatId
27082
27716
  void workerActivityFeed?.update(
27083
27717
  agentId,
27084
27718
  wkChat,
@@ -27093,7 +27727,7 @@ void (async () => {
27093
27727
  elapsedMs,
27094
27728
  state: 'running',
27095
27729
  },
27096
- origin?.threadId,
27730
+ wk.threadId,
27097
27731
  )?.then(() => reconcileWorkerPin(agentId, wkChat, true))
27098
27732
  return
27099
27733
  }