switchroom 0.17.6 → 0.18.3

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 (197) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +65 -5
  3. package/dist/auth-broker/index.js +6623 -514
  4. package/dist/cli/notion-write-pretool.mjs +64 -4
  5. package/dist/cli/switchroom.js +1888 -1162
  6. package/dist/host-control/main.js +6306 -162
  7. package/dist/vault/approvals/kernel-server.js +6014 -202
  8. package/dist/vault/broker/server.js +6741 -940
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +218 -25
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +116 -0
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/skills/mental-model-curator/SKILL.md +162 -0
  18. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  19. package/telegram-plugin/bridge/bridge.ts +80 -1
  20. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  21. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  22. package/telegram-plugin/consolidation-legibility.ts +279 -0
  23. package/telegram-plugin/context-exhaustion.ts +124 -0
  24. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  25. package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
  26. package/telegram-plugin/dist/server.js +86 -2
  27. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  28. package/telegram-plugin/gateway/activity-card-store.ts +369 -0
  29. package/telegram-plugin/gateway/gateway.ts +1861 -172
  30. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  31. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  32. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  33. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  34. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  35. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  36. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  37. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  38. package/telegram-plugin/gateway/model-command.ts +70 -10
  39. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  40. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  41. package/telegram-plugin/memory-legibility.ts +217 -0
  42. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  43. package/telegram-plugin/package.json +6 -0
  44. package/telegram-plugin/quota-watch.ts +4 -6
  45. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  46. package/telegram-plugin/registry/turns-schema.ts +78 -0
  47. package/telegram-plugin/render/ir.ts +209 -0
  48. package/telegram-plugin/render/parse.ts +363 -0
  49. package/telegram-plugin/render/render.ts +440 -0
  50. package/telegram-plugin/render/rich-render.ts +72 -0
  51. package/telegram-plugin/scoped-approval.ts +59 -0
  52. package/telegram-plugin/silent-end.ts +78 -0
  53. package/telegram-plugin/stream-controller.ts +14 -3
  54. package/telegram-plugin/subagent-watcher.ts +60 -6
  55. package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
  56. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  57. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  58. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  59. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  60. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  61. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  62. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
  63. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  64. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  65. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  66. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  67. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  68. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  69. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  70. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  71. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  72. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  73. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  74. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  75. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  76. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  77. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  78. package/telegram-plugin/tests/model-command.test.ts +193 -16
  79. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  80. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  81. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  82. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  83. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  84. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  85. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  86. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  87. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  88. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  89. package/telegram-plugin/tests/render/render.test.ts +436 -0
  90. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  91. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  92. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  93. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  94. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  95. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  96. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
  97. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  98. package/telegram-plugin/uat/assertions.ts +88 -4
  99. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  100. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  101. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  102. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  103. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  104. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  105. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  106. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  107. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  109. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  110. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  111. package/telegram-plugin/worktree-watch-cwds.ts +135 -0
  112. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  113. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  119. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  132. package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
  133. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  134. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  135. package/vendor/hindsight-memory/scripts/recall.py +217 -10
  136. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  137. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  138. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  157. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  158. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  159. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  160. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  161. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  162. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  163. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  164. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  165. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  166. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  167. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  168. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  169. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  170. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  171. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  172. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  173. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  174. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  175. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  176. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  177. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  178. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  179. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  180. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  181. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  182. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  183. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  184. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  185. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  186. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  187. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  188. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  189. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  190. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  191. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  192. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  193. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  194. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  195. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  196. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
  197. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -12,7 +12,7 @@
12
12
  import { Bot, GrammyError, InlineKeyboard, InputFile, type Context, type Api } from 'grammy'
13
13
  import { run, type RunnerHandle } from '@grammyjs/runner'
14
14
  import type { ReactionTypeEmoji } from 'grammy/types'
15
- import { randomBytes } from 'crypto'
15
+ import { randomBytes, createHash } from 'crypto'
16
16
  import { execFileSync, execSync, spawn } from 'child_process'
17
17
  import {
18
18
  readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync,
@@ -93,6 +93,20 @@ import {
93
93
  import { StatusReactionController } from '../status-reactions.js'
94
94
  import { DeferredDoneReactions } from '../reaction-defer.js'
95
95
  import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
96
+ import {
97
+ detectMemoryLegibilityEvent,
98
+ isMemoryLegibilityEnabled,
99
+ renderMemoryLegibilityLine,
100
+ MemoryLegibilityStager,
101
+ } from '../memory-legibility.js'
102
+ import {
103
+ ConsolidationRateLimiter,
104
+ consolidationSignature,
105
+ detectConsolidationEvent,
106
+ isConsolidationLegibilityEnabled,
107
+ renderConsolidationLine,
108
+ } from '../consolidation-legibility.js'
109
+ import type { WebhookGatewayRecord } from '../../src/web/webhook-gateway-record.js'
96
110
  import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
97
111
  import type { PinState, DesiredPin } from '../status-pin.js'
98
112
  import { decidePinAction } from '../status-pin.js'
@@ -111,10 +125,25 @@ import {
111
125
  type PermissionCardRef,
112
126
  } from './permission-timeout.js'
113
127
  import { renderVaultRequestAccessCard } from './vault-request-access-card.js'
114
- import { createPermissionCardStore } from './permission-card-store.js'
128
+ import { createPermissionCardStore, type PersistedPermCard } from './permission-card-store.js'
129
+ import {
130
+ isPermissionRearmEnabled,
131
+ permissionRearmGraceMs,
132
+ classifyPermissionRequest,
133
+ computeBootSweepStripTargets,
134
+ distinctRequestIds,
135
+ } from './permission-rearm.js'
136
+ import { createMissedApprovalsStore, type MissedApproval } from './missed-approvals-store.js'
137
+ import {
138
+ renderMissedApprovalsDigest,
139
+ missedApprovalsKeyboard,
140
+ parseMissedApprovalCallback,
141
+ buildMissedApprovalRetryInbound,
142
+ } from './missed-approvals-card.js'
115
143
  import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
116
144
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
117
145
  import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, formatStepSuffix, type SessionActivityHeader } from '../tool-activity-summary.js'
146
+ import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
118
147
  import { REPLY_TOOLS, isDraftOfReply } from '../narrative-dedup.js'
119
148
  import { toolLabel } from '../tool-labels.js'
120
149
  import { createTypingWrapper } from '../typing-wrap.js'
@@ -144,7 +173,7 @@ import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
144
173
  import { classifyInbound } from '../inbound-classifier.js'
145
174
  import * as silencePoke from '../silence-poke.js'
146
175
  import * as pendingProgress from '../pending-work-progress.js'
147
- import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd } from '../silent-end.js'
176
+ import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd, silentEndFallbackText, type SilentEndDeps } from '../silent-end.js'
148
177
  import { isFinalAnswerReply, isSubstantiveFinalReply, FINAL_ANSWER_MIN_CHARS } from '../final-answer-detect.js'
149
178
  import { deriveTurnRole, decideTerminalReason, parsePostAnswerLivenessMs, evaluatePostAnswerLiveness, type LoopRole } from '../turn-liveness-floor.js'
150
179
  import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
@@ -224,15 +253,9 @@ import { validateStringArray } from './access-validator.js'
224
253
  */
225
254
  const REPLY_TO_TEXT_MAX = 200
226
255
 
227
- /**
228
- * #1161 user-facing fallback delivered when a user-message turn ends
229
- * with zero outbound messages AND the deterministic Stop-hook re-prompt
230
- * has already been exhausted. Without this the user only sees the
231
- * progress card vanish; silence must never be the failure mode.
232
- */
233
- const SILENT_END_FALLBACK_TEXT =
234
- '⚠️ The agent finished working but didn’t send a reply — your last ' +
235
- 'message may not have been answered. Please try asking again.'
256
+ // #1161 silent-end fallback text now lives in ../silent-end.ts
257
+ // (`silentEndFallbackText`, imported above) so the transport-boundary
258
+ // tests exercise the real string see PR #2892.
236
259
  import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
237
260
  import { richMessage } from '../rich-send.js'
238
261
  import { scrubVoice } from '../text-voice-scrub.js'
@@ -271,6 +294,7 @@ import {
271
294
  shouldArmOrphanedReplyTimeout,
272
295
  ORPHANED_REPLY_TIMEOUT_MS,
273
296
  ORPHANED_REPLY_MAX_REARMS,
297
+ LivenessTracker,
274
298
  } from '../context-exhaustion.js'
275
299
  import {
276
300
  decideTurnFlush,
@@ -323,6 +347,7 @@ import {
323
347
  buildModelMenu,
324
348
  handleModelMenuCallback,
325
349
  isSrToClaudeTransition,
350
+ isValidModelArg,
326
351
  MODEL_CALLBACK_PREFIX,
327
352
  MODEL_CALLBACK_HEADER,
328
353
  MODEL_CALLBACK_SR,
@@ -334,6 +359,7 @@ import {
334
359
  type ModelMenuReply,
335
360
  } from './model-command.js'
336
361
  import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
362
+ import { resolveMainModel } from '../../src/agents/scaffold.js'
337
363
  import {
338
364
  parseEffortCommand,
339
365
  handleEffortCommand,
@@ -392,12 +418,20 @@ import {
392
418
  type StatusPinPersistOp,
393
419
  type TrackedStatusPin,
394
420
  } from './status-pin-store.js'
421
+ import {
422
+ writeActivityCardRecord,
423
+ clearActivityCardRecord,
424
+ runActivityCardBootReaper,
425
+ runActivityCardMidSessionReaper,
426
+ restartOrphanCardFinalizeText,
427
+ type ActivityCardStoreFsSeam,
428
+ } from './activity-card-store.js'
395
429
  import { driveEscalation } from './escalation-drive.js'
396
430
  import { shouldSuppressRepresent } from './represent-guard.js'
397
431
  import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
398
432
  import { createInboundSpool } from './inbound-spool.js'
399
433
  import { purgeStaleTurnsForChat } from './turn-state-purge.js'
400
- import { decideInboundDelivery } from './inbound-delivery-gate.js'
434
+ import { decideInboundDelivery, reserveInboundDelivery } from './inbound-delivery-gate.js'
401
435
  import { mayDrainBufferedInbound, shouldArmNoReplyDrain } from './serialize-drain-gate.js'
402
436
  import { decideFeedReopen } from './feed-reopen-gate.js'
403
437
  import {
@@ -448,6 +482,12 @@ import {
448
482
  buildVaultSaveFailedInbound,
449
483
  buildVaultSaveDiscardedInbound,
450
484
  } from './vault-grant-inbound-builders.js'
485
+ import { renderMentalModelProposeCard } from './mental-model-propose-card.js'
486
+ import {
487
+ resolveMentalModelProposal,
488
+ type MentalModelPendingProposal,
489
+ } from './mental-model-propose-resolve.js'
490
+ import { readDeclaredMentalModelNames } from './mental-model-propose-diff.js'
451
491
  import {
452
492
  parseSkillProposalCallback,
453
493
  buildSkillProposalApplyInbound,
@@ -531,6 +571,8 @@ import {
531
571
  startSubagentWatcher,
532
572
  type SubagentWatcherHandle,
533
573
  } from '../subagent-watcher.js'
574
+ import { listRecords as listWorktreeRecords } from '../../src/worktree/registry.js'
575
+ import { ownedWorktreeCwds } from '../worktree-watch-cwds.js'
534
576
  import {
535
577
  startBootCard,
536
578
  resolvePersonaName,
@@ -551,7 +593,9 @@ import {
551
593
  recordScopedGrant,
552
594
  lookupScopedGrant,
553
595
  sweepScopedGrants,
596
+ countScopedGrants,
554
597
  } from '../scoped-approval.js'
598
+ import { createScopedGrantStore } from './scoped-grant-store.js'
555
599
  import { grantRestartDecision, type GrantRestartDecision } from './grant-restart.js'
556
600
  import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
557
601
  import {
@@ -622,6 +666,7 @@ import {
622
666
  findRecentTurnsForChat,
623
667
  getTurnByKey,
624
668
  markTurnResumed,
669
+ reapStaleOpenTurns,
625
670
  } from '../registry/turns-schema.js'
626
671
  import {
627
672
  buildResumeInterruptedInbound,
@@ -666,6 +711,12 @@ process.on('beforeExit', () => {
666
711
  // ─── Env + state dir ──────────────────────────────────────────────────────
667
712
  const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
668
713
  const permCardStore = createPermissionCardStore(STATE_DIR)
714
+ // #2862 — missed-approvals re-offer. Persisted list of approvals that
715
+ // TTL-expired while the operator was away; a digest card is posted on the
716
+ // operator's next activity. Kill switch: SWITCHROOM_MISSED_APPROVAL_REOFFER=0.
717
+ const missedApprovalsStore = createMissedApprovalsStore(STATE_DIR)
718
+ const MISSED_APPROVAL_REOFFER_ENABLED =
719
+ process.env.SWITCHROOM_MISSED_APPROVAL_REOFFER !== '0'
669
720
  const ACCESS_FILE = join(STATE_DIR, 'access.json')
670
721
  const APPROVED_DIR = join(STATE_DIR, 'approved')
671
722
  const ENV_FILE = join(STATE_DIR, '.env')
@@ -1903,6 +1954,25 @@ const obligationEscalateInFlight = new Set<string>()
1903
1954
  // drain on the bare turn-end signal.
1904
1955
  const SERIALIZE_UNTIL_REPLIED_ENABLED =
1905
1956
  process.env.SWITCHROOM_SERIALIZE_UNTIL_REPLIED !== '0'
1957
+ // #2917 — rapid-fire per-chat FIFO. The #1556 delivery gate documents its
1958
+ // `turnInFlight` input as a LIVE read ("evaluated at delivery time — not a
1959
+ // receipt-time snapshot"), but handleInbound passes a receipt-time snapshot
1960
+ // (`turnInFlightAtReceipt`) taken at function entry. Under rapid-fire two
1961
+ // same-chat inbounds each snapshot "idle" during the other's async lead-in
1962
+ // (attachment download, composer-clear), both pass the gate, and both are
1963
+ // delivered — so replies come back reordered (observed 1,2,4,5,3,7,8,6). The
1964
+ // fix reads the gate LIVE off `claudeBusyKeys` (which does NOT yet contain
1965
+ // THIS inbound's own key — that is only marked at delivery — so it can't
1966
+ // self-block) AND reserves the key synchronously BEFORE the composer-clear
1967
+ // await, so a concurrent same-chat inbound observes the reservation and
1968
+ // buffers behind it, preserving FIFO. Nothing is dropped: a buffered inbound
1969
+ // drains on turn-complete / idle exactly as before. Kill switch (=0) restores
1970
+ // the receipt-snapshot behaviour. Only the non-cutover path is affected — the
1971
+ // delivery-machine cutover keeps its own at-receipt machine snapshot (reading
1972
+ // the machine live WOULD self-block, since its inbound event already advanced
1973
+ // it for this key).
1974
+ const SERIALIZE_INBOUND_DELIVERY_ENABLED =
1975
+ process.env.SWITCHROOM_SERIALIZE_INBOUND_DELIVERY !== '0'
1906
1976
  // Component 2 (bounded no-reply escape hatch). A turn that legitimately
1907
1977
  // ends with NO reply (handback ack, NO_REPLY marker, silent-end) sets
1908
1978
  // finalAnswerDelivered=false and would block the serialize gate forever.
@@ -2302,13 +2372,15 @@ type CurrentTurn = {
2302
2372
  silentAnchorText: string
2303
2373
  capturedText: string[]
2304
2374
  orphanedReplyTimeoutId: ReturnType<typeof setTimeout> | null
2305
- // How many times the orphaned-reply backstop timer has been re-armed
2306
- // mid-tool-call instead of firing a synthetic turn_end. Bounded so a
2307
- // genuinely wedged single long-running tool still surfaces: the cap is
2308
- // ORPHANED_REPLY_MAX_REARMS (20 × 30 s = 10 min of genuine tool activity).
2309
- // Reset to 0 on a fresh enqueue; NOT reset on text/tool_label re-arms —
2310
- // only a new turn resets the budget.
2311
- orphanedReplyRearmCount: number
2375
+ // Per-turn liveness tracker for the orphaned-reply backstop. Owns
2376
+ // `lastStreamEventAt` (stamped on ANY genuine stream event so a model
2377
+ // reasoning pause keeps the turn "recently streaming" and re-arms the fuse
2378
+ // instead of firing) and the rearm counter (bounded by
2379
+ // ORPHANED_REPLY_MAX_REARMS so a genuinely wedged single tool that never
2380
+ // streams still surfaces after the cap). The counter is zeroed on every
2381
+ // genuine stream event, so the cap only bites CONSECUTIVE silent expiries.
2382
+ // Fresh instance per enqueue (below). See context-exhaustion.ts.
2383
+ liveness: LivenessTracker
2312
2384
  // Component 3 (turn-origin reply routing). A stable per-turn identity,
2313
2385
  // `${registryKey-or-chatKey}#${startedAt}`, assigned when the turn
2314
2386
  // starts and stamped into the inbound meta (`origin_turn_id`) so a reply
@@ -4579,6 +4651,131 @@ function clearPermissionTimeoutSuppression(reason: string): void {
4579
4651
  `telegram gateway: permission no-repeat suppression cleared (${n} sig(s)) — ${reason}\n`,
4580
4652
  )
4581
4653
  }
4654
+
4655
+ // #2862 — missed-approvals re-offer digest. Called from every operator-activity
4656
+ // path that clears no-repeat suppression (inbound, /approve|/deny, card verdict):
4657
+ // on the FIRST activity after ≥1 approval TTL-expired while the operator was
4658
+ // away, post ONE compact digest card per origin surface listing the misses,
4659
+ // then promote those pending entries into a delivered-digest record (the posted
4660
+ // card is now the durable record in chat). Best-effort + fire-and-forget: never
4661
+ // blocks or throws into the hot inbound/callback path. Kill switch (=0) makes
4662
+ // this a no-op AND the auto-deny append below a no-op, so the whole feature is off.
4663
+ function maybePostMissedApprovalDigest(reason: string): void {
4664
+ if (!MISSED_APPROVAL_REOFFER_ENABLED) return
4665
+ const pending = missedApprovalsStore.listPending()
4666
+ if (pending.length === 0) return
4667
+ // Group by origin surface so each card lands where its cards were posted.
4668
+ const groups = new Map<string, MissedApproval[]>()
4669
+ for (const e of pending) {
4670
+ const key = `${e.chatId}::${e.threadId ?? ''}`
4671
+ const g = groups.get(key)
4672
+ if (g) g.push(e)
4673
+ else groups.set(key, [e])
4674
+ }
4675
+ for (const entries of groups.values()) {
4676
+ const first = entries[0]
4677
+ const chatId = first.chatId
4678
+ const threadId = first.threadId ?? undefined
4679
+ const digestId = randomBytes(4).toString('hex')
4680
+ const text = renderMissedApprovalsDigest(entries, {
4681
+ agentName: process.env.SWITCHROOM_AGENT_NAME ?? null,
4682
+ })
4683
+ void swallowingApiCall(
4684
+ () =>
4685
+ // allow-raw-bot-api: routed through swallowingApiCall retry policy; thread-aware digest card
4686
+ bot.api.sendMessage(chatId, text, {
4687
+ parse_mode: 'HTML',
4688
+ reply_markup: missedApprovalsKeyboard(digestId),
4689
+ ...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
4690
+ }),
4691
+ { chat_id: chatId, verb: 'missed-approval-digest', ...(threadId != null ? { threadId } : {}) },
4692
+ )
4693
+ // Promote synchronously (before the async send resolves) so a second
4694
+ // operator-activity event racing in sees an empty pending list and can't
4695
+ // double-post — "one card per accumulated batch" holds.
4696
+ missedApprovalsStore.promoteToDigest({
4697
+ digestId,
4698
+ chatId,
4699
+ threadId: first.threadId ?? null,
4700
+ entries,
4701
+ deliveredAt: Date.now(),
4702
+ })
4703
+ }
4704
+ process.stderr.write(
4705
+ `telegram gateway: missed-approval digest posted (${pending.length} entr(y/ies), ` +
4706
+ `${groups.size} surface(s)) — ${reason}\n`,
4707
+ )
4708
+ }
4709
+
4710
+ // #2862 — handle a Retry / Dismiss tap on a missed-approvals digest card.
4711
+ // missre:retry:<id> — inject a synthetic inbound asking the agent to
4712
+ // re-attempt the actions (claude-native; re-raises a
4713
+ // fresh approval card). NEVER a verdict / re-execution.
4714
+ // missre:dismiss:<id> — clear the record + edit the card closed.
4715
+ async function handleMissedApprovalCallback(ctx: Context, data: string): Promise<void> {
4716
+ const senderId = String(ctx.from?.id ?? '')
4717
+ const access = loadAccess()
4718
+ if (!access.allowFrom.includes(senderId)) {
4719
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
4720
+ return
4721
+ }
4722
+ const parsed = parseMissedApprovalCallback(data)
4723
+ if (parsed == null) {
4724
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
4725
+ return
4726
+ }
4727
+ const digest = missedApprovalsStore.getDigest(parsed.digestId)
4728
+ if (digest == null) {
4729
+ await ctx.answerCallbackQuery({ text: 'This digest already actioned or expired.' }).catch(() => {})
4730
+ if (ctx.callbackQuery?.message) {
4731
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
4732
+ }
4733
+ return
4734
+ }
4735
+
4736
+ if (parsed.action === 'dismiss') {
4737
+ missedApprovalsStore.removeDigest(parsed.digestId)
4738
+ await ctx.answerCallbackQuery({ text: '🚫 Dismissed — cleared.' }).catch(() => {})
4739
+ if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
4740
+ await ctx
4741
+ .editMessageText(
4742
+ `${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n🚫 <i>Dismissed.</i>`,
4743
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
4744
+ )
4745
+ .catch(() => {})
4746
+ }
4747
+ return
4748
+ }
4749
+
4750
+ // Retry — consume the record, then inject a synthetic inbound to the ORIGIN
4751
+ // surface asking the agent to re-attempt. This rides the same synthesized-
4752
+ // inbound path cron uses; the agent naturally re-raises a fresh approval card.
4753
+ missedApprovalsStore.removeDigest(parsed.digestId)
4754
+ const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
4755
+ const synthetic = buildMissedApprovalRetryInbound({
4756
+ ctx: {
4757
+ agent,
4758
+ chat_id: digest.chatId,
4759
+ ...(digest.threadId != null ? { threadId: digest.threadId } : {}),
4760
+ },
4761
+ actions: digest.entries.map(e => e.action),
4762
+ operatorId: senderId,
4763
+ })
4764
+ const delivered = deliverResumeSyntheticOrBuffer(agent, synthetic)
4765
+ await ctx.answerCallbackQuery({ text: '🔁 Asking the agent to retry…' }).catch(() => {})
4766
+ if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
4767
+ await ctx
4768
+ .editMessageText(
4769
+ `${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n🔁 <i>Retrying — asked the agent.</i>`,
4770
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
4771
+ )
4772
+ .catch(() => {})
4773
+ }
4774
+ process.stderr.write(
4775
+ `telegram gateway: missed-approval retry injection agent=${agent} ` +
4776
+ `digest=${parsed.digestId} actions=${digest.entries.length} delivered=${delivered}\n`,
4777
+ )
4778
+ }
4582
4779
  // Permission/approval-card origin recovery (marko Rentals-budget, 2026-06-17).
4583
4780
  // When `currentTurn` was force-closed by the orphaned-reply backstop but the
4584
4781
  // claude session kept running into a permission-gated tool, recover the card's
@@ -4612,13 +4809,54 @@ function sweepStaleAlwaysAllowCorrelations(now = Date.now()): void {
4612
4809
  }
4613
4810
  }
4614
4811
 
4812
+ // Sibling of pendingAlwaysAllowCorrelations for the agent-proposes →
4813
+ // human-approves MENTAL MODEL flow (hindsight Phase 5). When the operator
4814
+ // taps Approve on a mental-model PROPOSAL card, the gateway dispatches a
4815
+ // `config_propose_edit` appending the model to memory.mental_models[]; hostd
4816
+ // then calls back for operator approval. We pre-register the exact diff here
4817
+ // so that callback auto-approves WITHOUT a second card (the operator already
4818
+ // approved on the proposal card). Forge-resistance: the auto-resolve match is
4819
+ // an EXACT byte-match of the inbound diff against the diff the gateway itself
4820
+ // synthesized and queued — an agent-forged edit finds no entry and falls
4821
+ // through to a real operator card. Single-shot + a dedicated TTL sweep.
4822
+ //
4823
+ // TTL: this correlation must outlive the WHOLE config-edit approval budget,
4824
+ // NOT the 30s "always allow" window. The mental-model resolve dispatches
4825
+ // `config_propose_edit` to hostd with a 720s timeout (see the 720_000 dispatch
4826
+ // budgets below); hostd's approval callback can legitimately arrive any time
4827
+ // within that window if the operator taps slowly. Reusing the 30s
4828
+ // ALWAYS_ALLOW_CORRELATION_TTL_MS would sweep the correlation out from under a
4829
+ // slow-but-valid tap, dropping the auto-approve and surfacing a SECOND card
4830
+ // for an edit the operator already approved. Size it to the hostd budget.
4831
+ const MENTAL_MODEL_CORRELATION_TTL_MS = 720_000
4832
+ const pendingMentalModelCorrelations = new Map<string, { agentName: string; unifiedDiff: string; createdAt: number }>()
4833
+ function mentalModelCorrelationKey(agentName: string, unifiedDiff: string): string {
4834
+ return `${agentName}::${createHash('sha256').update(unifiedDiff).digest('hex')}`
4835
+ }
4836
+ function sweepStaleMentalModelCorrelations(now = Date.now()): void {
4837
+ for (const [key, entry] of pendingMentalModelCorrelations) {
4838
+ if (now - entry.createdAt > MENTAL_MODEL_CORRELATION_TTL_MS) {
4839
+ pendingMentalModelCorrelations.delete(key)
4840
+ }
4841
+ }
4842
+ }
4843
+
4615
4844
  // Scoped-approval store: the 30-min window that backs the "✅ Allow" tap for
4616
4845
  // narrow non-destructive scopes (not a separate button — it IS what Allow
4617
4846
  // means for those). Operator-tapped, gateway-side ONLY (never pushed to the
4618
4847
  // bridge's untimed sessionAllowRules), fixed-window, fail-closed. Keyed by
4619
4848
  // agent name for per-agent isolation. All policy lives in
4620
4849
  // ../scoped-approval.ts (pure + unit-tested); this gateway only wires it.
4621
- const scopedGrants: ScopedGrantStore = new Map()
4850
+ //
4851
+ // Persisted across gateway restarts (#2863): a bounce (fleet roll / config
4852
+ // apply / failover probe) used to wipe every window, re-carding an action the
4853
+ // operator allowed minutes ago. The store mirrors to a tiny STATE_DIR JSON
4854
+ // file; reload drops entries already past their ABSOLUTE expiry (a restart
4855
+ // never extends a window). Kill switch SWITCHROOM_SCOPED_GRANT_PERSIST=0 boots
4856
+ // empty and never writes. Fail-closed lookup semantics are unchanged — a
4857
+ // reloaded grant runs the same lookupScopedGrant gate as an in-memory one.
4858
+ const scopedGrantStore = createScopedGrantStore(STATE_DIR)
4859
+ const scopedGrants: ScopedGrantStore = scopedGrantStore.load(Date.now())
4622
4860
  const selfAgentName = (): string => process.env.SWITCHROOM_AGENT_NAME ?? ''
4623
4861
 
4624
4862
  // `ask_user` MCP tool — open prompts awaiting a user button-tap.
@@ -4944,6 +5182,81 @@ function sweepPendingVaultRequestAccesses(): void {
4944
5182
  }
4945
5183
  }
4946
5184
 
5185
+ /**
5186
+ * Staged agent-initiated MENTAL MODEL proposal (hindsight Phase 5). The agent
5187
+ * calls `mental_model_propose`; the operator taps Approve/Deny on the card.
5188
+ * Mirrors PendingVaultRequestAccess — no memory content is staged here, only
5189
+ * the proposed DECLARATION (name + source_query + optional knobs). On Approve
5190
+ * the model becomes a first-class declared model in memory.mental_models[] via
5191
+ * the operator-approved config-edit path; on Deny nothing is written.
5192
+ */
5193
+ interface PendingMentalModelPropose {
5194
+ agent: string
5195
+ chat_id: string
5196
+ card_message_id?: number
5197
+ threadId?: number
5198
+ /** Proposed declaration, snake_case (matches memory.mental_models[] schema). */
5199
+ spec: {
5200
+ name: string
5201
+ source_query: string
5202
+ refresh_after_consolidation?: boolean
5203
+ max_tokens?: number
5204
+ }
5205
+ reason?: string
5206
+ staged_at: number
5207
+ }
5208
+ const pendingMentalModelProposes = new Map<string, PendingMentalModelPropose>()
5209
+ const MENTAL_MODEL_PROPOSE_TTL_MS = approvalTtlMs()
5210
+ // Sweep expired pending proposals. For any entry past its TTL we ALSO edit the
5211
+ // posted card's keyboard away, so a stale card left in the chat can't be tapped
5212
+ // into a "Card expired" answer — the operator sees the ⌛ expiry inline instead.
5213
+ // Best-effort: card edits are fire-and-forget (the entry is removed regardless).
5214
+ function sweepPendingMentalModelProposes(): void {
5215
+ const cutoff = Date.now() - MENTAL_MODEL_PROPOSE_TTL_MS
5216
+ for (const [k, v] of pendingMentalModelProposes) {
5217
+ if (v.staged_at < cutoff) {
5218
+ pendingMentalModelProposes.delete(k)
5219
+ if (v.card_message_id != null) {
5220
+ void lockedBot.api
5221
+ .editMessageText(
5222
+ v.chat_id,
5223
+ v.card_message_id,
5224
+ richMessage('⌛ _This mental-model proposal card expired. Ask the agent to re-propose if it still stands._'),
5225
+ { reply_markup: { inline_keyboard: [] } },
5226
+ )
5227
+ .catch(() => {})
5228
+ }
5229
+ }
5230
+ }
5231
+ }
5232
+
5233
+ // Sliding-window rate limit for mental-model proposals: at most
5234
+ // MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW cards per MENTAL_MODEL_PROPOSE_WINDOW_MS.
5235
+ // The window is per-gateway-process, and since each agent runs its own gateway
5236
+ // process (keyed by $SWITCHROOM_AGENT_NAME), one process == one agent — so this
5237
+ // throttle is effectively per-agent. A candidate model is a deliberate, rare
5238
+ // curation act — an agent that re-proposes in a loop should be throttled so the
5239
+ // operator is never spammed.
5240
+ const mentalModelProposeTimes: number[] = []
5241
+ const MENTAL_MODEL_PROPOSE_WINDOW_MS = 60 * 60 * 1000
5242
+ const MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW = 5
5243
+ // Mirror the memory.mental_models[] schema caps (src/config/schema.ts): a
5244
+ // source_query capped at 2000 chars and max_tokens capped at 8192. Enforced
5245
+ // up-front in executeMentalModelPropose so a proposal that would fail hostd
5246
+ // config validation never reaches an approval card.
5247
+ const MENTAL_MODEL_SOURCE_QUERY_MAX = 2000
5248
+ const MENTAL_MODEL_MAX_TOKENS_CAP = 8192
5249
+ function checkMentalModelProposeRate(now = Date.now()): { ok: true } | { ok: false; retryAtMs: number } {
5250
+ const cutoff = now - MENTAL_MODEL_PROPOSE_WINDOW_MS
5251
+ while (mentalModelProposeTimes.length > 0 && mentalModelProposeTimes[0]! < cutoff) {
5252
+ mentalModelProposeTimes.shift()
5253
+ }
5254
+ if (mentalModelProposeTimes.length >= MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW) {
5255
+ return { ok: false, retryAtMs: mentalModelProposeTimes[0]! + MENTAL_MODEL_PROPOSE_WINDOW_MS }
5256
+ }
5257
+ return { ok: true }
5258
+ }
5259
+
4947
5260
  /**
4948
5261
  * Mint an approval-kernel decision row for a deferred-secret card
4949
5262
  * (MIGRATION.md §1). Best-effort: if the kernel/broker is unreachable, we
@@ -5238,6 +5551,23 @@ const pendingStateReaper = setInterval(() => {
5238
5551
  now,
5239
5552
  )
5240
5553
  }
5554
+ // #2862 — record the miss so it can be re-offered when the operator
5555
+ // returns. Anchor to the card's own origin surface (where the operator
5556
+ // would have tapped), so the digest lands in the same topic — not a
5557
+ // fanned-out DM. Skip if the card was never posted anywhere.
5558
+ if (MISSED_APPROVAL_REOFFER_ENABLED) {
5559
+ const origin = v.cards[0]
5560
+ if (origin != null) {
5561
+ missedApprovalsStore.add({
5562
+ requestId: k,
5563
+ toolName: v.tool_name,
5564
+ action: naturalAction(v.tool_name, v.input_preview),
5565
+ chatId: origin.chatId,
5566
+ threadId: origin.threadId ?? null,
5567
+ timedOutAt: now,
5568
+ })
5569
+ }
5570
+ }
5241
5571
  process.stderr.write(
5242
5572
  `telegram gateway: permission TTL expired — auto-deny request=${k} ` +
5243
5573
  `tool=${v.tool_name} (no operator response in ` +
@@ -5256,8 +5586,14 @@ const pendingStateReaper = setInterval(() => {
5256
5586
  if (now > v.expiresAt) vaultPassphraseCache.delete(k)
5257
5587
  }
5258
5588
  // Drop expired "⏱ 30 min" scoped grants. (Lookup already fails closed on
5259
- // expiry; this just keeps the map from accumulating dead entries.)
5589
+ // expiry; this just keeps the map from accumulating dead entries.) Persist
5590
+ // the removal so a restart between sweeps can't resurrect a swept grant —
5591
+ // only write when the sweep actually changed something (sweeps only remove).
5592
+ const scopedGrantsBefore = countScopedGrants(scopedGrants)
5260
5593
  sweepScopedGrants(scopedGrants, now)
5594
+ if (countScopedGrants(scopedGrants) !== scopedGrantsBefore) {
5595
+ scopedGrantStore.save(scopedGrants)
5596
+ }
5261
5597
  for (const [k, v] of deferredSecrets) {
5262
5598
  if (now - v.staged_at > DEFERRED_SECRET_TTL_MS) deferredSecrets.delete(k)
5263
5599
  }
@@ -5754,6 +6090,22 @@ const statusPinStoreFs = {
5754
6090
  }
5755
6091
  const statusPinPersistEnabled = !STATIC && PIN_STATUS_WHILE_WORKING
5756
6092
 
6093
+ // Durable card-handle snapshot for the mid-turn activity card (Known Gap 1,
6094
+ // `reference/rfcs/deterministic-turn-liveness.md`). Persisted on card OPEN,
6095
+ // cleared on normal close/finalize; a boot-time reaper (wired alongside
6096
+ // `statusPinBootCleanup`, same startup-mutex ordering constraint) finalizes
6097
+ // any leftover record with one honest edit so a gateway restart mid-turn can
6098
+ // never leave the card frozen forever. STATIC mode skips disk (dry-run, no
6099
+ // durable volume) — same gate as the status-pin store.
6100
+ const ACTIVITY_CARD_STORE_PATH = join(STATE_DIR, 'activity-cards-pending.json')
6101
+ const activityCardStoreFs: ActivityCardStoreFsSeam = {
6102
+ readFileSync: (p: string) => readFileSync(p, 'utf8'),
6103
+ writeFileSync: (p: string, d: string) => writeFileSync(p, d),
6104
+ renameSync: (a: string, b: string) => renameSync(a, b),
6105
+ existsSync: (p: string) => existsSync(p),
6106
+ }
6107
+ const activityCardPersistEnabled = !STATIC
6108
+
5757
6109
  // Slot-banner pin persistence (#421 crash-recovery). The slot banner is pinned
5758
6110
  // in the owner chat when the agent is on a non-default OAuth slot. Rather than a
5759
6111
  // parallel store + second boot hook, its pin is persisted in the SAME
@@ -5835,6 +6187,196 @@ async function statusPinBootCleanup(): Promise<void> {
5835
6187
  )
5836
6188
  }
5837
6189
  }
6190
+ /**
6191
+ * Boot-time orphan-card reaper (Known Gap 1,
6192
+ * `reference/rfcs/deterministic-turn-liveness.md`). Thin gateway wrapper over
6193
+ * the pure `runActivityCardBootReaper` — binds the live fs seam, a real
6194
+ * Telegram edit, and the gateway logger. Finalizes ANY card left open by a
6195
+ * prior (crashed/restarted) session with ONE honest edit — never a new
6196
+ * message, so no ping. Guarantee is AT-MOST-ONCE: the pure routine deletes each
6197
+ * record before attempting its edit and does not retry, so a failed edit
6198
+ * forfeits that orphan rather than risk a double-finalize on a later boot (see
6199
+ * the module doc in activity-card-store.ts for the full tradeoff). A benign-400
6200
+ * (card already gone) is reported as `vanished`, not `finalized`.
6201
+ *
6202
+ * MUST run ONLY after this gateway wins the startup mutex (the store is a
6203
+ * shared per-agent file; a losing double-boot would finalize a card the
6204
+ * still-alive holder's in-flight turn still legitimately owns) — identical
6205
+ * ordering constraint to `statusPinBootCleanup`.
6206
+ *
6207
+ * Deliberately does NOT attempt to resume climbing the old card: the resumed
6208
+ * turn (if any) opens its own fresh card. Honest finalization, not
6209
+ * resumption, is the goal (see the module doc in activity-card-store.ts).
6210
+ *
6211
+ * After the finalizing edit, also unpins the card, but ONLY for a record whose
6212
+ * persisted `pinned` flag is true — i.e. the card was actually pin-eligible on
6213
+ * open (`PIN_STATUS_WHILE_WORKING`). This unpin is DEFENSE-IN-DEPTH, not the
6214
+ * primary unpin path: `statusPinBootCleanup` already unpins orphaned status
6215
+ * pins from its own durable store on boot. The reaper's unpin is belt-and-
6216
+ * braces for the case where the two stores disagree; the record's `pinned`
6217
+ * flag must therefore reflect the ACTUAL pin outcome (see the open path), not
6218
+ * an unconditional `true`, or the reaper would attempt an unpin on a card that
6219
+ * was never pinned.
6220
+ */
6221
+ async function activityCardBootReaper(): Promise<void> {
6222
+ if (!activityCardPersistEnabled) return
6223
+ const { finalized, vanished, unpinned, total } = await runActivityCardBootReaper({
6224
+ path: ACTIVITY_CARD_STORE_PATH,
6225
+ fs: activityCardStoreFs,
6226
+ finalizeCard: (record) =>
6227
+ robustApiCall(
6228
+ () =>
6229
+ lockedBot.api.editMessageText(
6230
+ record.chatId,
6231
+ record.activityMessageId,
6232
+ richMessage(restartOrphanCardFinalizeText(record.startedAt)),
6233
+ {},
6234
+ ),
6235
+ {
6236
+ chat_id: record.chatId,
6237
+ ...(record.threadId != null ? { threadId: record.threadId } : {}),
6238
+ verb: 'activity-card.boot-reap-finalize',
6239
+ },
6240
+ ),
6241
+ unpinCard: (record) =>
6242
+ robustApiCall(
6243
+ () => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
6244
+ {
6245
+ chat_id: record.chatId,
6246
+ ...(record.threadId != null ? { threadId: record.threadId } : {}),
6247
+ verb: 'activity-card.boot-reap-unpin',
6248
+ },
6249
+ ),
6250
+ })
6251
+ if (total > 0) {
6252
+ process.stderr.write(
6253
+ `telegram gateway: activity-card: finalized ${finalized}/${total} ` +
6254
+ `(vanished ${vanished}/${total}), unpinned ${unpinned}/${total} ` +
6255
+ `orphaned card(s) from a prior session (at-most-once)\n`,
6256
+ )
6257
+ }
6258
+ }
6259
+ // ─── Mid-session stale-card reaper (#2918) ──────────────────────────────────
6260
+ // The boot reapers (markOrphanedWithTimeoutClassification + activityCardBoot-
6261
+ // Reaper) run ONCE at startup. A turn whose owning SDK subprocess dies
6262
+ // mid-session (SIGKILL / OOM / crash) without a clean end leaves its turns-DB
6263
+ // row `ended_at IS NULL` and its activity card frozen on "working…" until the
6264
+ // NEXT gateway boot — often many hours. This periodic sweep, running inside the
6265
+ // live gateway, finalizes those orphans without waiting for a restart.
6266
+ //
6267
+ // LIVENESS (the critical correctness guard): a turn/card is reaped ONLY when no
6268
+ // live in-flight turn owns it. The live set is derived from `currentTurnMap`
6269
+ // (per-topic byKey) PLUS the singleton `currentTurn` mirror, so it is correct
6270
+ // whether or not the per-topic-isolation flag is on. A healthy long-running
6271
+ // turn is always in that set and is never swept. A TTL gate is a secondary
6272
+ // guard against the just-started-not-yet-tracked race.
6273
+ //
6274
+ // Kill switch: set SWITCHROOM_MID_SESSION_CARD_REAPER=0 to disable (clean
6275
+ // revert, mirrors #2919). TTL/interval overridable via env for tuning.
6276
+ const MID_SESSION_CARD_REAPER_ENABLED =
6277
+ process.env.SWITCHROOM_MID_SESSION_CARD_REAPER !== '0'
6278
+ const MID_SESSION_CARD_REAPER_TTL_MS = (() => {
6279
+ const v = Number(process.env.SWITCHROOM_MID_SESSION_CARD_REAPER_TTL_MS)
6280
+ return Number.isFinite(v) && v > 0 ? v : 15 * 60_000 // 15 min
6281
+ })()
6282
+ const MID_SESSION_CARD_REAPER_INTERVAL_MS = (() => {
6283
+ const v = Number(process.env.SWITCHROOM_MID_SESSION_CARD_REAPER_INTERVAL_MS)
6284
+ return Number.isFinite(v) && v > 0 ? v : 5 * 60_000 // 5 min
6285
+ })()
6286
+
6287
+ // Snapshot the turn_keys / topic keys owned by a live in-flight turn right now.
6288
+ function liveTurnKeySets(): { registryKeys: Set<string>; topicKeys: Set<string> } {
6289
+ const registryKeys = new Set<string>()
6290
+ const topicKeys = new Set<string>()
6291
+ const add = (t: CurrentTurn | null | undefined): void => {
6292
+ if (t == null) return
6293
+ if (t.registryKey != null && t.registryKey.length > 0) registryKeys.add(t.registryKey)
6294
+ topicKeys.add(statusKey(t.sessionChatId, t.sessionThreadId))
6295
+ }
6296
+ for (const t of currentTurnMap.byKey.values()) add(t)
6297
+ add(currentTurn) // flag-OFF store + most-recent mirror
6298
+ return { registryKeys, topicKeys }
6299
+ }
6300
+
6301
+ async function runMidSessionCardReaper(): Promise<void> {
6302
+ if (!MID_SESSION_CARD_REAPER_ENABLED) return
6303
+ const now = Date.now()
6304
+ const { registryKeys, topicKeys } = liveTurnKeySets()
6305
+
6306
+ // 1) Stamp ownerless open turns-DB rows (the durable spinner signal).
6307
+ if (turnsDb != null) {
6308
+ try {
6309
+ const { reaped, reapedTurnKeys } = reapStaleOpenTurns(turnsDb, {
6310
+ activeTurnKeys: registryKeys,
6311
+ ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
6312
+ now,
6313
+ })
6314
+ if (reaped > 0) {
6315
+ process.stderr.write(
6316
+ `telegram gateway: mid-session reaper stamped ${reaped} orphaned turn(s) ` +
6317
+ `as 'restart' (${reapedTurnKeys.join(',')})\n`,
6318
+ )
6319
+ }
6320
+ } catch (err) {
6321
+ process.stderr.write(
6322
+ `telegram gateway: mid-session turn reaper error: ${(err as Error).message}\n`,
6323
+ )
6324
+ }
6325
+ }
6326
+
6327
+ // 2) Finalize the leftover visible activity card(s) for those dead turns.
6328
+ if (activityCardPersistEnabled) {
6329
+ try {
6330
+ const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
6331
+ path: ACTIVITY_CARD_STORE_PATH,
6332
+ fs: activityCardStoreFs,
6333
+ isLive: (record) => topicKeys.has(record.turnKey),
6334
+ ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
6335
+ now,
6336
+ finalizeCard: (record) =>
6337
+ robustApiCall(
6338
+ () =>
6339
+ lockedBot.api.editMessageText(
6340
+ record.chatId,
6341
+ record.activityMessageId,
6342
+ richMessage(restartOrphanCardFinalizeText(record.startedAt)),
6343
+ {},
6344
+ ),
6345
+ {
6346
+ chat_id: record.chatId,
6347
+ ...(record.threadId != null ? { threadId: record.threadId } : {}),
6348
+ verb: 'activity-card.mid-session-reap-finalize',
6349
+ },
6350
+ ),
6351
+ unpinCard: (record) =>
6352
+ robustApiCall(
6353
+ () => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
6354
+ {
6355
+ chat_id: record.chatId,
6356
+ ...(record.threadId != null ? { threadId: record.threadId } : {}),
6357
+ verb: 'activity-card.mid-session-reap-unpin',
6358
+ },
6359
+ ),
6360
+ })
6361
+ if (total > 0) {
6362
+ process.stderr.write(
6363
+ `telegram gateway: activity-card: mid-session finalized ${finalized}/${total} ` +
6364
+ `(vanished ${vanished}/${total}) orphaned card(s) (at-most-once)\n`,
6365
+ )
6366
+ }
6367
+ } catch (err) {
6368
+ process.stderr.write(
6369
+ `telegram gateway: mid-session card reaper error: ${(err as Error).message}\n`,
6370
+ )
6371
+ }
6372
+ }
6373
+ }
6374
+
6375
+ const midSessionCardReaper = setInterval(() => {
6376
+ void runMidSessionCardReaper()
6377
+ }, MID_SESSION_CARD_REAPER_INTERVAL_MS)
6378
+ midSessionCardReaper.unref()
6379
+
5838
6380
  // NOTE: statusPinBootCleanup() is deliberately NOT invoked here at import time.
5839
6381
  // The status-pin store is a SHARED per-agent file, and cleanup issues real
5840
6382
  // unpinChatMessage calls. On a double-boot the losing gateway must NOT touch
@@ -6140,6 +6682,7 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
6140
6682
  // LOSING double-boot never unpins the live holder's legitimate pins.
6141
6683
  // Fire-and-forget: cleanup is best-effort and must not block boot.
6142
6684
  void statusPinBootCleanup()
6685
+ void activityCardBootReaper()
6143
6686
  } catch (err) {
6144
6687
  process.stderr.write(
6145
6688
  `telegram gateway: boot.lock_acquire_failed err=${(err as Error).message} agent=${SWITCHROOM_AGENT_NAME}\n`,
@@ -6156,6 +6699,7 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
6156
6699
  // successful writePidFile here means no live holder was detected, so
6157
6700
  // running orphan cleanup is consistent with the pre-mutex behaviour.
6158
6701
  void statusPinBootCleanup()
6702
+ void activityCardBootReaper()
6159
6703
  } catch (writeErr) {
6160
6704
  process.stderr.write(`telegram gateway: writePidFile failed: ${writeErr}\n`)
6161
6705
  }
@@ -6199,13 +6743,22 @@ function parsePositiveMsEnv(name: string, fallbackMs: number): number {
6199
6743
  const n = Number(raw)
6200
6744
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallbackMs
6201
6745
  }
6746
+ // Orphaned-reply "recently streaming" window (thinking-pause fix). If a genuine
6747
+ // stream event landed within this window, the fuse re-arms instead of firing —
6748
+ // so a long model reasoning pause (which emits no text/tool events) is
6749
+ // survivable while a genuine multi-minute hang still surfaces. Default 120 s.
6750
+ const ORPHANED_REPLY_STREAM_WINDOW_MS = parsePositiveMsEnv('SWITCHROOM_ORPHANED_REPLY_STREAM_WINDOW_MS', 120_000)
6202
6751
  const SILENCE_FALLBACK_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_MS', 300_000)
6203
6752
  const SILENCE_FALLBACK_HARD_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_HARD_MS', 900_000)
6204
- // #2527 — mid-turn liveness floor threshold (default 45s). The early, quiet
6205
- // beat: a `user` turn working silently this long without a substantive answer
6206
- // gets ONE honest "still on it" interim, so the ambient 👀 never masquerades
6207
- // as "done". Strictly below SILENCE_FALLBACK_MS (the loud 300s unwedge).
6208
- // Whole floor is kill-switchable via SWITCHROOM_TG_LIVENESS_FLOOR=0.
6753
+ // #2527 — mid-turn liveness floor threshold (default 45s). Still gates the
6754
+ // decision (`decideMidTurnFloor`, role + delivery + fire-once + timing) for a
6755
+ // `user` turn working silently this long without a substantive answer.
6756
+ // Phase 3 (deterministic-turn-liveness.md): the busy-but-silent case no
6757
+ // longer sends TEXT at this threshold — the climbing card (Phase 1) covers
6758
+ // it. The ONE surviving delivery gated by this threshold is the
6759
+ // approval-blocked re-ping (see `onMidTurnFloor` below). Strictly below
6760
+ // SILENCE_FALLBACK_MS (the loud 300s unwedge). Whole floor is
6761
+ // kill-switchable via SWITCHROOM_TG_LIVENESS_FLOOR=0.
6209
6762
  const SILENCE_FLOOR_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FLOOR_MS', 45_000)
6210
6763
  // #2527 — role-aware terminal reaction honesty (the "thumbs-up false done"
6211
6764
  // fix). Default ON; SWITCHROOM_TG_TERMINAL_HONESTY=0 reverts to always-👍.
@@ -6222,6 +6775,25 @@ const SILENCE_DEFER_INFLIGHT_TOOLS = process.env.SWITCHROOM_SILENCE_DEFER_INFLIG
6222
6775
  // and null currentTurn mid-work. Default ON; SWITCHROOM_SILENCE_LIVENESS_PRODUCTION=0
6223
6776
  // restores the legacy "only a real reply resets the clock" behaviour.
6224
6777
  const SILENCE_LIVENESS_PRODUCTION = process.env.SWITCHROOM_SILENCE_LIVENESS_PRODUCTION !== '0'
6778
+ // Sparse chat-legible memory (#2849, hindsight Phase 4). Surface ONE terse
6779
+ // line in the originating chat/topic when the interactive session materially
6780
+ // changes what it remembers (create_directive / invalidate / demote) — never
6781
+ // on ordinary recall or routine consolidation. Default ON;
6782
+ // SWITCHROOM_MEMORY_LEGIBILITY=0 disables it.
6783
+ const MEMORY_LEGIBILITY_ENABLED = isMemoryLegibilityEnabled(process.env.SWITCHROOM_MEMORY_LEGIBILITY)
6784
+ // Consolidation-driven legibility (#2849 follow-up, hindsight Phase 4). The
6785
+ // "updated what I know about Y" side, driven by the background
6786
+ // `consolidation.completed` webhook rather than a foreground tool call.
6787
+ // OPT-IN (default OFF), unlike MEMORY_LEGIBILITY_ENABLED above: this path is
6788
+ // fed by an unbounded background engine + a webhook the pinned hindsight image
6789
+ // does not yet emit, so the RFC keeps it "sparse … clearly gated".
6790
+ const CONSOLIDATION_LEGIBILITY_ENABLED = isConsolidationLegibilityEnabled(
6791
+ process.env.SWITCHROOM_CONSOLIDATION_LEGIBILITY,
6792
+ )
6793
+ // One long-lived limiter across all consolidation events — collapses a burst
6794
+ // of material consolidations to at most one line per interval, and suppresses
6795
+ // an identical "updated about X" line inside the dedup window.
6796
+ const consolidationRateLimiter = new ConsolidationRateLimiter()
6225
6797
 
6226
6798
  /**
6227
6799
  * Feed-survival predicate — the single source of truth for "is this turn
@@ -6290,10 +6862,19 @@ silencePoke.startTimer({
6290
6862
  if (statusKey(turn.sessionChatId, turn.sessionThreadId) !== key) return null
6291
6863
  return { role: turn.role, finalAnswerDelivered: turn.finalAnswerDelivered }
6292
6864
  },
6293
- // #2527 the early, quiet liveness beat. Honest text from the longest
6294
- // in-flight tool (model-free, claude-native), routed through the SAME send
6295
- // path as the 300s fallback; pings OFF (this is the gentle beat, not the
6296
- // loud unwedge) and the turn is NOT torn down it keeps working.
6865
+ // Phase 3 (reference/rfcs/deterministic-turn-liveness.md): the busy-but-
6866
+ // silent TEXT floor is retired. The climbing card (Phase 1 the 0-label
6867
+ // branch of `feedHeartbeatTick` / `runSilentTurnHeartbeatTick` above) IS
6868
+ // the mid-turn floor now: it edits the already-open activity card's
6869
+ // elapsed clock every `FEED_HEARTBEAT_MIN_STALE_MS`, model-independent, no
6870
+ // ping. This handler still decides/fires via `decideMidTurnFloor` (the
6871
+ // timing + role + fire-once machinery in `silence-poke.ts` /
6872
+ // `turn-liveness-floor.ts` is unchanged and still load-bearing — see
6873
+ // below), but its ONLY surviving delivery is the approval-blocked re-ping.
6874
+ // Do not re-add a generic "still working…" text send here believing this
6875
+ // path covers the silent-tool case; it does not, and re-adding one would
6876
+ // reintroduce the exact banned cadence ping `conversational-pacing.md`
6877
+ // retired in #2667.
6297
6878
  onMidTurnFloor: async (ctx) => {
6298
6879
  // Late-fire guard, mirroring the fallback: a clean turn-end can race the
6299
6880
  // tick. If the turn is gone, stay silent.
@@ -6301,19 +6882,19 @@ silencePoke.startTimer({
6301
6882
  const blockedOnApproval = activeStatusReactions
6302
6883
  .get(statusKey(ctx.chatId, ctx.threadId))
6303
6884
  ?.isAwaiting() ?? false
6885
+ // The one surviving text case: the turn is parked on an approval card
6886
+ // waiting for YOUR tap. That's not a stall — it names the real blocker —
6887
+ // so it keeps this quiet re-ping (`conversational-pacing.md` § Silence-
6888
+ // poke fallback names this one of the two surviving honest cases). The
6889
+ // busy-but-silent (non-approval) case sends nothing here; the card climb
6890
+ // (Phase 1) is its only signal now.
6891
+ if (!blockedOnApproval) return
6304
6892
  const text = silencePoke.formatFrameworkFallbackText(
6305
6893
  'working',
6306
6894
  ctx.silenceMs,
6307
6895
  ctx.inFlightTools,
6308
6896
  blockedOnApproval,
6309
6897
  )
6310
- // The stall-notice stop-gap is retired: `formatFrameworkFallbackText`
6311
- // returns null for the pure-liveness "still working / running <Tool>"
6312
- // beat (the only string it still emits is the approval-blocked re-ping).
6313
- // The early quiet floor beat existed ONLY to surface that stall notice, so
6314
- // with the notice gone there is nothing to send here unless the turn is
6315
- // parked on an approval card. Skip silently otherwise; the live draft +
6316
- // the model's own pacing beats carry progress.
6317
6898
  if (text == null) return
6318
6899
  try {
6319
6900
  await robustApiCall(
@@ -7283,6 +7864,81 @@ function dispatchPermissionVerdict(ev: PermissionEvent): void {
7283
7864
  }
7284
7865
  }
7285
7866
 
7867
+ /**
7868
+ * #2861 re-arm: a `permission_request` re-sent by the bridge after a gateway
7869
+ * restart, for a request_id that's NO LONGER in `pendingPermissions` but is
7870
+ * still in the persisted card store. Restore the in-memory pending entry with
7871
+ * the ORIGINAL `startedAt` (so the existing TTL clock keeps running from the
7872
+ * original ask — an already-expired re-arm drops straight into the TTL
7873
+ * auto-deny sweep, which correctly unwedges claude) and re-attach the inline
7874
+ * keyboard on the EXISTING message(s) via editMessageText — never a new card.
7875
+ *
7876
+ * NEVER answers the card: only the question is restored (no-self-escalation).
7877
+ */
7878
+ function rearmPermissionFromStore(
7879
+ msg: PermissionRequestForward,
7880
+ persisted: PersistedPermCard[],
7881
+ ): void {
7882
+ const { requestId, toolName, description, inputPreview } = msg
7883
+ // Original clock: take the earliest persisted startedAt (all entries for one
7884
+ // request share it, but be defensive).
7885
+ const startedAt = persisted.reduce(
7886
+ (min, c) => (c.startedAt < min ? c.startedAt : min),
7887
+ persisted[0].startedAt,
7888
+ )
7889
+ const cardText = persisted[0].cardText
7890
+ const cards = persisted.map(c => ({ chatId: c.chatId, messageId: c.messageId }))
7891
+ pendingPermissions.set(requestId, {
7892
+ tool_name: toolName,
7893
+ description,
7894
+ input_preview: inputPreview,
7895
+ startedAt,
7896
+ card_text: cardText,
7897
+ cards,
7898
+ })
7899
+ process.stderr.write(
7900
+ `telegram gateway: re-arming permission card(s) for request=${requestId} ` +
7901
+ `tool=${toolName} (${cards.length} card(s), original startedAt preserved)\n`,
7902
+ )
7903
+ // Re-attach the keyboard on each surviving card message. Message-id-targeted
7904
+ // edit — no thread needed, so THREAD_NOT_FOUND is not in the blast radius.
7905
+ const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null
7906
+ const keyboard = buildPermissionActionRow(requestId, showAlways)
7907
+ for (const c of cards) {
7908
+ void swallowingApiCall(
7909
+ // allow-raw-bot-api: routed through swallowingApiCall (retry policy); message-id-targeted edit (no thread to lose). Re-attaches the Allow/Deny keyboard on the persisted card after a gateway restart.
7910
+ () => bot.api.editMessageText(c.chatId, c.messageId, richMessage(cardText), { reply_markup: keyboard }),
7911
+ { chat_id: c.chatId, verb: 'permission_request.rearm' },
7912
+ )
7913
+ }
7914
+ }
7915
+
7916
+ /**
7917
+ * Strip a single stale permission card: drop its keyboard and show the
7918
+ * "gateway restarted" notice. Shared by the boot-sweep (immediate legacy path
7919
+ * and the #2861 grace-period path). Best-effort — a deleted/inaccessible card
7920
+ * is swallowed, never fatal.
7921
+ */
7922
+ async function stripStalePermissionCard(card: PersistedPermCard): Promise<void> {
7923
+ const toolLabel = card.toolName ?? 'unknown tool'
7924
+ const notice = `🔒 **${toolLabel}**\n\n⚠️ *Gateway restarted — this request is no longer active. Ask your agent to try again if needed.*`
7925
+ try {
7926
+ // allow-raw-bot-api: targeted by message_id; no thread needed; fire-and-forget boot sweep
7927
+ await bot.api.editMessageText(
7928
+ card.chatId,
7929
+ card.messageId,
7930
+ richMessage(notice),
7931
+ { reply_markup: { inline_keyboard: [] } },
7932
+ )
7933
+ } catch (err) {
7934
+ // Card may already be deleted, edited, or in an inaccessible chat — benign
7935
+ process.stderr.write(
7936
+ `telegram gateway: boot-sweep: stale-card strip failed ` +
7937
+ `${card.chatId}:${card.messageId}: ${(err as Error).message}\n`,
7938
+ )
7939
+ }
7940
+ }
7941
+
7286
7942
  const ipcServer: IpcServer = createIpcServer({
7287
7943
  socketPath: SOCKET_PATH,
7288
7944
 
@@ -7652,6 +8308,38 @@ const ipcServer: IpcServer = createIpcServer({
7652
8308
 
7653
8309
  onPermissionRequest(_client: IpcClient, msg: PermissionRequestForward) {
7654
8310
  const { requestId, toolName, description, inputPreview } = msg
8311
+ // #2861 idempotent re-send handling. The bridge re-sends every outstanding
8312
+ // permission_request on each IPC (re)connect, so the same request_id can
8313
+ // arrive more than once. Disposition:
8314
+ // duplicate → already live in pendingPermissions; ignore (no 2nd card).
8315
+ // rearm → gone from memory (gateway restarted) but the persisted card
8316
+ // store still has it; restore + re-attach keyboard in place.
8317
+ // fresh → new ask; fall through to the normal card-posting path.
8318
+ // Either re-claim marks the id so the grace-period boot-sweep won't strip
8319
+ // it. Kill switch SWITCHROOM_PERMISSION_REARM=0 skips this entirely (legacy
8320
+ // behavior: a re-send would post a duplicate card, as before this fix).
8321
+ if (isPermissionRearmEnabled()) {
8322
+ const persisted = pendingPermissions.has(requestId)
8323
+ ? []
8324
+ : permCardStore.loadAll().filter(e => e.requestId === requestId)
8325
+ const disposition = classifyPermissionRequest({
8326
+ hasPending: pendingPermissions.has(requestId),
8327
+ persistedCount: persisted.length,
8328
+ })
8329
+ if (disposition === 'duplicate') {
8330
+ process.stderr.write(
8331
+ `telegram gateway: permission_request duplicate re-send ignored ` +
8332
+ `request=${requestId} (already pending)\n`,
8333
+ )
8334
+ return
8335
+ }
8336
+ if (disposition === 'rearm') {
8337
+ // Re-arm restores the pending entry; the grace-period boot-sweep
8338
+ // preserves anything live in pendingPermissions, so it won't strip it.
8339
+ rearmPermissionFromStore(msg, persisted)
8340
+ return
8341
+ }
8342
+ }
7655
8343
  // "⏱ 30 min" short-circuit: if the operator tapped a live scoped grant
7656
8344
  // covering this exact request, auto-allow without posting a card. CRITICAL:
7657
8345
  // dispatch WITHOUT a `rule` so the bridge does NOT cache it untimed
@@ -8180,11 +8868,24 @@ const ipcServer: IpcServer = createIpcServer({
8180
8868
  // field, or any other byte difference) won't match → falls
8181
8869
  // through to a real operator approval card.
8182
8870
  const added = extractAddedAllowRule(msg.unifiedDiff)
8183
- if (!added) return null
8184
- const key = `${msg.agentName}::${added}`
8185
- const entry = pendingAlwaysAllowCorrelations.get(key)
8186
- if (entry && entry.unifiedDiff === msg.unifiedDiff) {
8187
- pendingAlwaysAllowCorrelations.delete(key)
8871
+ if (added) {
8872
+ const key = `${msg.agentName}::${added}`
8873
+ const entry = pendingAlwaysAllowCorrelations.get(key)
8874
+ if (entry && entry.unifiedDiff === msg.unifiedDiff) {
8875
+ pendingAlwaysAllowCorrelations.delete(key)
8876
+ return 'approve'
8877
+ }
8878
+ }
8879
+ // hindsight Phase 5: a mental-model PROPOSAL the operator already
8880
+ // approved on the proposal card. The gateway pre-registered the EXACT
8881
+ // diff; auto-approve on a byte-exact match (the security gate), so no
8882
+ // second card is posted. Any forged/other edit finds no entry and
8883
+ // falls through to a real operator card.
8884
+ sweepStaleMentalModelCorrelations()
8885
+ const mmKey = mentalModelCorrelationKey(msg.agentName, msg.unifiedDiff)
8886
+ const mmEntry = pendingMentalModelCorrelations.get(mmKey)
8887
+ if (mmEntry && mmEntry.unifiedDiff === msg.unifiedDiff) {
8888
+ pendingMentalModelCorrelations.delete(mmKey)
8188
8889
  return 'approve'
8189
8890
  }
8190
8891
  return null
@@ -8317,11 +9018,45 @@ const ipcServer: IpcServer = createIpcServer({
8317
9018
  // frequent cron, or a crashed cron session) falls back to the MAIN agent
8318
9019
  // bridge so the fire lands now; it routes cheap again once the session is
8319
9020
  // up. See deliverInjectWithFallback.
9021
+ // #2793 part B — durable cron boot-replay. A fire the scheduler is
9022
+ // REPLAYING across a restart carries `meta.replay_fire_ms`; route it
9023
+ // through the durable inbound spool so accept and consume are ledgered
9024
+ // separately (mirrors the real-inbound path). The scheduler's own
9025
+ // scheduler.jsonl records the fire at socket-accept, which stops
9026
+ // findMissedFires from re-firing it — but a socket accept is NOT proof
9027
+ // the session consumed it. Spooling here means: if the session isn't up
9028
+ // (accepted-but-not-consumed), the entry stays un-acked and the spool's
9029
+ // boot-replay re-delivers it on the next gateway boot (closes the
9030
+ // silent-loss window); and the stable `spoolId` (keyed on the replayed
9031
+ // fire) dedups a re-replay so it lands at most once (closes the
9032
+ // double-fire window). Live cron ticks never set replay_fire_ms, so they
9033
+ // keep the fire-and-forget path unchanged. STATIC mode has no spool —
9034
+ // fall through to the legacy path.
9035
+ const isDurableReplay =
9036
+ inboundSpool != null &&
9037
+ typeof msg.inbound.meta?.replay_fire_ms === 'string' &&
9038
+ msg.inbound.meta.replay_fire_ms.length > 0
9039
+ if (isDurableReplay && inboundSpool != null) {
9040
+ // Durable ACCEPT: put before delivery so a crash between accept and
9041
+ // consume leaves the entry recoverable (boot-replay re-delivers).
9042
+ // Idempotent by stable spoolId — a re-replay of the same missed fire
9043
+ // is a no-op while the prior entry is still un-acked.
9044
+ inboundSpool.put(msg.agentName, msg.inbound as unknown as InboundMessage)
9045
+ }
8320
9046
  const { target, delivered, fellBackToMain } = deliverInjectWithFallback(
8321
9047
  msg.agentName,
8322
9048
  msg.inbound.meta,
8323
9049
  (t) => ipcServer.sendToAgent(t, msg.inbound),
8324
9050
  )
9051
+ if (isDurableReplay && inboundSpool != null && delivered) {
9052
+ // Durable CONSUME: the fire reached a live bridge, so tombstone the
9053
+ // spool entry (same "delivered to a live registered bridge" ack
9054
+ // semantics the inbound spool uses — see its v1 scope note). On a
9055
+ // miss we leave it un-acked: the pendingInboundBuffer.push below
9056
+ // buffers it for the reconnect drain, and the durable spool copy is
9057
+ // re-delivered on the next boot if this process dies first.
9058
+ inboundSpool.ack(msg.inbound as unknown as InboundMessage)
9059
+ }
8325
9060
  if (fellBackToMain) {
8326
9061
  process.stderr.write(
8327
9062
  `telegram gateway: cron fire fell back to main session (no cron bridge) agent=${msg.agentName} prompt_key=${promptKey}\n`,
@@ -8560,6 +9295,7 @@ const ipcServer: IpcServer = createIpcServer({
8560
9295
  {
8561
9296
  inject: webhookInject,
8562
9297
  log: (s) => process.stderr.write(`telegram gateway: ${s}`),
9298
+ onConsolidation: surfaceConsolidationLegibility,
8563
9299
  },
8564
9300
  ),
8565
9301
  })
@@ -8681,6 +9417,7 @@ const ALLOWED_TOOLS = new Set([
8681
9417
  'vault_request_save',
8682
9418
  'vault_request_access',
8683
9419
  'request_secret',
9420
+ 'mental_model_propose',
8684
9421
  'linear_agent_activity',
8685
9422
  'linear_create_issue',
8686
9423
  'linear_agent_setup',
@@ -8727,6 +9464,8 @@ async function executeToolCall(tool: string, args: Record<string, unknown>): Pro
8727
9464
  return executeVaultRequestAccess(args)
8728
9465
  case 'request_secret':
8729
9466
  return executeRequestSecret(args)
9467
+ case 'mental_model_propose':
9468
+ return executeMentalModelPropose(args)
8730
9469
  case 'linear_agent_activity':
8731
9470
  return executeLinearAgentActivity(args)
8732
9471
  case 'linear_create_issue':
@@ -11355,6 +12094,182 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
11355
12094
  }
11356
12095
  }
11357
12096
 
12097
+ /** Read the live switchroom.yaml bytes for diff/dup-check. Mirrors the
12098
+ * always-allow persistence path's config read. */
12099
+ function readLiveSwitchroomConfigText(): string {
12100
+ const cfgPath = process.env.SWITCHROOM_CONFIG ?? findSwitchroomConfigFile()
12101
+ return readFileSync(cfgPath, 'utf8')
12102
+ }
12103
+
12104
+ const MENTAL_MODEL_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/
12105
+
12106
+ function buildMentalModelProposeKeyboard(stageId: string): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
12107
+ return {
12108
+ inline_keyboard: [
12109
+ [
12110
+ { text: '✅ Approve', callback_data: `mmp:approve:${stageId}` },
12111
+ { text: '🚫 Deny', callback_data: `mmp:deny:${stageId}` },
12112
+ ],
12113
+ ],
12114
+ }
12115
+ }
12116
+
12117
+ /**
12118
+ * `mental_model_propose` tool (hindsight Phase 5) — the agent surfaces a
12119
+ * candidate mental model for the operator to approve. Mirrors the
12120
+ * `vault_request_access` shape: the agent can only PROPOSE; the [Approve]/[Deny]
12121
+ * tap is operator-gated (handleMentalModelProposeCallback), so an agent can
12122
+ * never self-approve. On Approve the proposal is DECLARED — appended to the
12123
+ * agent's memory.mental_models[] via the operator-approved config-edit path
12124
+ * (reusing config_propose_edit apply+reconcile) — and ensured in the bank. On
12125
+ * Deny nothing is written. Guardrails enforced here BEFORE any card:
12126
+ * duplicate-name rejection against the agent's already-declared models, and a
12127
+ * per-agent rate limit so proposals stay non-spammy.
12128
+ */
12129
+ async function executeMentalModelPropose(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
12130
+ const chat_id = String(args.chat_id ?? '')
12131
+ if (!chat_id) throw new Error('mental_model_propose: chat_id is required')
12132
+ const name = typeof args.name === 'string' ? args.name.trim() : ''
12133
+ if (!name) throw new Error('mental_model_propose: name is required')
12134
+ if (!MENTAL_MODEL_NAME_REGEX.test(name)) {
12135
+ throw new Error('mental_model_propose: name must be a slug (letters/digits/_/-, ≤64 chars, e.g. `training-plan-state`)')
12136
+ }
12137
+ const source_query = typeof args.source_query === 'string' ? args.source_query.trim() : ''
12138
+ if (!source_query) throw new Error('mental_model_propose: source_query is required')
12139
+ // Enforce the memory.mental_models[] schema cap (src/config/schema.ts) up-front
12140
+ // so the operator never approves a card that then fails hostd config validation.
12141
+ // The 2000-char ceiling also keeps the rendered card under Telegram's 4096-char
12142
+ // message limit.
12143
+ if (source_query.length > MENTAL_MODEL_SOURCE_QUERY_MAX) {
12144
+ throw new Error(
12145
+ `mental_model_propose: source_query is ${source_query.length} chars; the schema caps it at ${MENTAL_MODEL_SOURCE_QUERY_MAX} (a standing reflection query, not a document). Shorten it.`,
12146
+ )
12147
+ }
12148
+ // Accept `why` as an alias for `reason` (mirrors the vault tools).
12149
+ const reason =
12150
+ typeof args.reason === 'string' ? args.reason : typeof args.why === 'string' ? args.why : undefined
12151
+ let refresh_after_consolidation: boolean | undefined
12152
+ if (args.refresh_after_consolidation !== undefined) {
12153
+ if (typeof args.refresh_after_consolidation !== 'boolean') {
12154
+ throw new Error('mental_model_propose: refresh_after_consolidation must be a boolean')
12155
+ }
12156
+ refresh_after_consolidation = args.refresh_after_consolidation
12157
+ }
12158
+ let max_tokens: number | undefined
12159
+ if (args.max_tokens !== undefined) {
12160
+ const n = Number(args.max_tokens)
12161
+ if (!Number.isInteger(n) || n <= 0) {
12162
+ throw new Error('mental_model_propose: max_tokens must be a positive integer')
12163
+ }
12164
+ // Enforce the schema ceiling (src/config/schema.ts) here so the card can't be
12165
+ // approved into a config-validation failure downstream.
12166
+ if (n > MENTAL_MODEL_MAX_TOKENS_CAP) {
12167
+ throw new Error(
12168
+ `mental_model_propose: max_tokens ${n} exceeds the schema cap of ${MENTAL_MODEL_MAX_TOKENS_CAP} (a mental model is a standing summary, not a corpus).`,
12169
+ )
12170
+ }
12171
+ max_tokens = n
12172
+ }
12173
+ assertAllowedChat(chat_id)
12174
+
12175
+ const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
12176
+
12177
+ // Rate limit: a proposal is a rare, deliberate curation act — throttle so a
12178
+ // looping agent can never spam the operator with cards.
12179
+ const rate = checkMentalModelProposeRate()
12180
+ if (!rate.ok) {
12181
+ const retryAtIso = new Date(rate.retryAtMs).toISOString()
12182
+ return {
12183
+ content: [
12184
+ {
12185
+ type: 'text',
12186
+ text:
12187
+ `mental_model_propose: RATE-LIMITED (max ${MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW} proposals/hour). ` +
12188
+ `No card was posted. Next slot opens at ${retryAtIso}. Proposing mental models is meant to be ` +
12189
+ `rare — batch or wait rather than re-firing.`,
12190
+ },
12191
+ ],
12192
+ }
12193
+ }
12194
+
12195
+ // Duplicate-name guard: reject a proposal for a model already DECLARED for
12196
+ // this agent, BEFORE posting a card (the name is the idempotent-ensure key).
12197
+ try {
12198
+ const configText = readLiveSwitchroomConfigText()
12199
+ const declared = readDeclaredMentalModelNames(configText, agentSlug)
12200
+ if (declared.includes(name)) {
12201
+ return {
12202
+ content: [
12203
+ {
12204
+ type: 'text',
12205
+ text:
12206
+ `mental_model_propose: '${name}' is ALREADY a declared mental model for ${agentSlug} ` +
12207
+ `(memory.mental_models[]). No card was posted — it already exists and is ensured in your ` +
12208
+ `bank. Pick a different name if you meant a NEW model, or just use the existing one.`,
12209
+ },
12210
+ ],
12211
+ }
12212
+ }
12213
+ } catch (err) {
12214
+ // Config read failed (transient) — fall through to the card. The approve
12215
+ // path re-reads and re-checks (dupe guard is defense-in-depth), so a
12216
+ // redundant card is harmless; suppressing a needed card is not.
12217
+ process.stderr.write(`telegram gateway: mental_model_propose dup pre-check read failed: ${(err as Error).message}\n`)
12218
+ }
12219
+
12220
+ const stageId = randomBytes(4).toString('hex')
12221
+ const pending: PendingMentalModelPropose = {
12222
+ agent: agentSlug,
12223
+ chat_id,
12224
+ spec: {
12225
+ name,
12226
+ source_query,
12227
+ ...(refresh_after_consolidation !== undefined ? { refresh_after_consolidation } : {}),
12228
+ ...(max_tokens !== undefined ? { max_tokens } : {}),
12229
+ },
12230
+ ...(reason ? { reason } : {}),
12231
+ staged_at: Date.now(),
12232
+ }
12233
+ pendingMentalModelProposes.set(stageId, pending)
12234
+ sweepPendingMentalModelProposes()
12235
+
12236
+ const text = renderMentalModelProposeCard({
12237
+ agent: agentSlug,
12238
+ name,
12239
+ source_query,
12240
+ ...(reason ? { reason } : {}),
12241
+ ...(refresh_after_consolidation !== undefined ? { refresh_after_consolidation } : {}),
12242
+ })
12243
+ const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
12244
+ if (threadId != null) pending.threadId = threadId
12245
+ const sent = await retryWithThreadFallback<{ message_id: number }>(
12246
+ robustApiCall,
12247
+ (tid) =>
12248
+ lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
12249
+ reply_markup: buildMentalModelProposeKeyboard(stageId),
12250
+ ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
12251
+ }),
12252
+ { threadId, chat_id, verb: 'mental_model_propose.card' },
12253
+ )
12254
+ pending.card_message_id = sent.message_id
12255
+ // Only count a proposal against the rate budget once its card actually
12256
+ // posted (validation errors / dupes don't consume the budget).
12257
+ mentalModelProposeTimes.push(Date.now())
12258
+
12259
+ return {
12260
+ content: [
12261
+ {
12262
+ type: 'text',
12263
+ text:
12264
+ `mental_model_propose: card sent (stage_id=${stageId}, name=${name}). Wait for the operator to tap ` +
12265
+ `Approve or Deny — END YOUR TURN cleanly. A fresh inbound arrives with the outcome ` +
12266
+ `(source=mental_model_proposal_applied / mental_model_proposal_denied). Do NOT re-propose this ` +
12267
+ `model while the card is open.`,
12268
+ },
12269
+ ],
12270
+ }
12271
+ }
12272
+
11358
12273
  async function executeReact(args: Record<string, unknown>): Promise<unknown> {
11359
12274
  if (!args.chat_id) throw new Error('react: chat_id is required')
11360
12275
  if (!args.message_id) throw new Error('react: message_id is required')
@@ -11648,20 +12563,32 @@ function resetOrphanedReplyTimeout(): void {
11648
12563
  }
11649
12564
  return false
11650
12565
  })()
11651
- if (working || humanWaiting) {
11652
- const underCap = t.orphanedReplyRearmCount < ORPHANED_REPLY_MAX_REARMS
11653
- if (humanWaiting || underCap) {
11654
- t.orphanedReplyRearmCount++
11655
- process.stderr.write(
11656
- `telegram gateway: orphaned-reply fuse expired — re-arming` +
11657
- ` (rearm ${t.orphanedReplyRearmCount}/${ORPHANED_REPLY_MAX_REARMS},` +
11658
- ` in_flight=${toolFlightTracker.inFlightCount()},` +
11659
- ` human_wait=${humanWaiting},` +
11660
- ` bg_work=${pendingProgress.hasPendingAsyncDispatch(turnKey)})\n`,
11661
- )
11662
- resetOrphanedReplyTimeout()
11663
- return
11664
- }
12566
+ // Route the rearm decision through the per-turn LivenessTracker. It
12567
+ // rearms when working OR recently-streaming (thinking-pause survival)
12568
+ // OR human-waiting; working/recently-streaming rearms count against
12569
+ // ORPHANED_REPLY_MAX_REARMS while human-wait rearms stay uncapped.
12570
+ const now = Date.now()
12571
+ const recentlyStreaming = t.liveness.recentlyStreaming(now, ORPHANED_REPLY_STREAM_WINDOW_MS)
12572
+ const decision = t.liveness.decideOnExpiry({
12573
+ working,
12574
+ humanWaiting,
12575
+ now,
12576
+ windowMs: ORPHANED_REPLY_STREAM_WINDOW_MS,
12577
+ maxRearms: ORPHANED_REPLY_MAX_REARMS,
12578
+ })
12579
+ if (decision.rearm) {
12580
+ process.stderr.write(
12581
+ `telegram gateway: orphaned-reply fuse expired — re-arming` +
12582
+ ` (rearm ${t.liveness.orphanedReplyRearmCount}/${ORPHANED_REPLY_MAX_REARMS},` +
12583
+ ` in_flight=${toolFlightTracker.inFlightCount()},` +
12584
+ ` human_wait=${humanWaiting},` +
12585
+ ` recently_streaming=${recentlyStreaming},` +
12586
+ ` bg_work=${pendingProgress.hasPendingAsyncDispatch(turnKey)})\n`,
12587
+ )
12588
+ resetOrphanedReplyTimeout()
12589
+ return
12590
+ }
12591
+ if (decision.countsAgainstCap) {
11665
12592
  process.stderr.write(
11666
12593
  `telegram gateway: orphaned-reply rearm cap reached (${ORPHANED_REPLY_MAX_REARMS}) — forcing backstop despite working state\n`,
11667
12594
  )
@@ -11924,6 +12851,31 @@ async function drainActivitySummary(
11924
12851
  )
11925
12852
  turn.activityMessageId = sent.message_id
11926
12853
  turn.activityEverOpened = true
12854
+ // Known Gap 1 (deterministic-turn-liveness.md) — persist the
12855
+ // minimal card handle the moment it opens, so a gateway restart
12856
+ // mid-turn has something to finalize on next boot instead of
12857
+ // leaving this card frozen forever. Fire-and-forget/best-effort:
12858
+ // a failed persist degrades to the pre-fix (in-memory-only)
12859
+ // behaviour, never blocks the card opening.
12860
+ if (activityCardPersistEnabled) {
12861
+ writeActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, {
12862
+ turnKey: statusKey(chat, thread),
12863
+ chatId: chat,
12864
+ threadId: thread ?? null,
12865
+ activityMessageId: sent.message_id,
12866
+ startedAt: turn.startedAt,
12867
+ // Mirror the ACTUAL pin decision, not an unconditional `true`:
12868
+ // the OPEN below silently-pins the fresh card only when
12869
+ // `PIN_STATUS_WHILE_WORKING` is on (`reconcileStatusPin` no-ops
12870
+ // when it's off, and can also fail on missing supergroup
12871
+ // rights). Persisting `pinned: true` regardless would make the
12872
+ // boot reaper attempt an unpin on a card that was never pinned.
12873
+ // The reaper's unpin is defense-in-depth anyway
12874
+ // (`statusPinBootCleanup` owns the primary unpin), so tracking
12875
+ // the flag honestly is what matters here.
12876
+ pinned: PIN_STATUS_WHILE_WORKING,
12877
+ })
12878
+ }
11927
12879
  // Status-pin: the per-turn status message just opened — it's the
11928
12880
  // in-flight "what it's doing" surface. Silently pin it so the turn
11929
12881
  // stays in view when the feed scrolls past. Keyed to the same
@@ -11968,11 +12920,15 @@ async function drainActivitySummary(
11968
12920
  }
11969
12921
 
11970
12922
  /**
11971
- * Open (or climb) the minimal "Working…" liveness card for a 0-label turn once
11972
- * it has been alive >= FEED_LIVENESS_OPEN_MS. The ONE place the liveness card
11973
- * may OPEN — both the enqueue-time early-open timer
11974
- * (`scheduleEarlyLivenessOpen`) and the 6 s heartbeat call through here, so a
11975
- * card opened by one caller is a clean no-op for the other:
12923
+ * OPEN the minimal "Working…" liveness card for a 0-label turn once it has been
12924
+ * alive >= FEED_LIVENESS_OPEN_MS. The ONE place the liveness card may OPEN — both
12925
+ * the enqueue-time early-open timer (`scheduleEarlyLivenessOpen`) and the 6 s
12926
+ * heartbeat call through here, so a card opened by one caller is a clean no-op
12927
+ * for the other. This function OPENS only; it does NOT climb an already-open card
12928
+ * (its WHEN-gate `shouldEarlyOpenLiveness` returns false once `activityMessageId`
12929
+ * is set). The 0-label CLIMB of an already-open card lives at the heartbeat call
12930
+ * site via `silentTurnClimbRender` (deterministic-turn-liveness.md Phase 1) — so
12931
+ * BOTH the labelled and the 0-label branches now keep the card visibly climbing.
11976
12932
  * - `drainActivitySummary` OPENs when `activityMessageId == null` and EDITs
11977
12933
  * once it is set, so a second call after an open just maintains the card;
11978
12934
  * - the `mirrorLines.length === 0` guard at the heartbeat call site (and the
@@ -12108,11 +13064,19 @@ function feedHeartbeatTick(): void {
12108
13064
  // durable record once it completes.
12109
13065
  // - 'emit' → genuine in-flight post-answer activity; render the card below.
12110
13066
  const subagentAt = turn.subagentActivityAt
13067
+ // Fix 3 (sub-agent-delegation freeze): a foreground `Task`/`Agent` still
13068
+ // tracked in `turn.foregroundSubAgents` is POSITIVE evidence the worker
13069
+ // has not reported finished — see the doc comment on
13070
+ // `PostAnswerLivenessInput.stillDispatched` in turn-liveness-floor.ts for
13071
+ // why this must bypass the staleness cap rather than let a single long
13072
+ // silent step freeze the card mid-delegation.
13073
+ const stillDispatched = turn.foregroundSubAgents.size > 0
12111
13074
  const livenessVerdict = evaluatePostAnswerLiveness({
12112
13075
  subagentActivityAt: subagentAt,
12113
13076
  finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
12114
13077
  now: Date.now(),
12115
13078
  staleCapMs: POST_ANSWER_LIVENESS_STALE_MS,
13079
+ stillDispatched,
12116
13080
  })
12117
13081
  if (livenessVerdict !== 'emit' || subagentAt == null) return // idle gap or stale worker → stay silent (the `== null` also narrows subagentAt for the elapsed below)
12118
13082
  // A background worker is genuinely active after the answer. Open or maintain
@@ -12157,13 +13121,45 @@ function feedHeartbeatTick(): void {
12157
13121
  // sends (opens) when activityMessageId is null and edits (maintains) once set
12158
13122
  // — so this one branch handles both the open and the climb.
12159
13123
  //
12160
- // The open/climb logic lives in ONE place (`openLivenessFeedIfDue`) so the
13124
+ // The OPEN logic lives in ONE place (`openLivenessFeedIfDue`) so the
12161
13125
  // enqueue-time early-open timer (`scheduleEarlyLivenessOpen`) and this 6 s
12162
13126
  // heartbeat both reach the same drain — there is exactly one path that can
12163
13127
  // OPEN the liveness card, so the two callers can never double-open or race.
12164
- if (turn.mirrorLines.length === 0) {
12165
- openLivenessFeedIfDue(turn)
12166
- return
13128
+ //
13129
+ // Phase 1 climb (deterministic-turn-liveness.md): once the card IS open, the
13130
+ // OPEN path no-ops (its WHEN-gate `shouldEarlyOpenLiveness` returns false for
13131
+ // an already-open card) — which is exactly how the 0-label card used to FREEZE
13132
+ // during a long silent tool. So split the two cases: OPEN when no card exists,
13133
+ // and otherwise re-render the "Working…" card with a fresh wall-clock elapsed
13134
+ // through the SAME cardDrainGate / mayDrain / liveness EDIT path the labelled
13135
+ // branch below uses. Model-independent (reads only `now - startedAt`), so a
13136
+ // blocked tool call can't starve it; edit-only, so it never push-notifies.
13137
+ //
13138
+ // The tick BODY lives in feed-heartbeat-climb.ts (`runSilentTurnHeartbeatTick`)
13139
+ // so the shipped decision logic is directly under the outcome-based regression
13140
+ // test (tests/silent-turn-climb-transport.test.ts) — this gateway IIFE cannot
13141
+ // be imported in-process. This call site only wires the REAL deps; the wiring
13142
+ // shape is pinned structurally by tests/feed-heartbeat-liveness-open.test.ts.
13143
+ {
13144
+ const ea = emissionAuthorityFor(turn)
13145
+ const handled = runSilentTurnHeartbeatTick(
13146
+ {
13147
+ mirrorLineCount: turn.mirrorLines.length,
13148
+ activityMessageId: turn.activityMessageId,
13149
+ labeledToolCount: turn.labeledToolCount,
13150
+ ageMs: Date.now() - turn.startedAt,
13151
+ minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
13152
+ },
13153
+ {
13154
+ openLivenessFeedIfDue: () => openLivenessFeedIfDue(turn),
13155
+ setPendingRender: (rendered) => { turn.activityPendingRender = rendered },
13156
+ cardDrainGate: (run) => cardDrainGate(turn, ea, run),
13157
+ mayDrain: () => ea.mayDrain(turn),
13158
+ openOrEditCard: (apply) => ea.openOrEditCard('liveness', apply),
13159
+ drain: () => { turn.activityInFlight = drainActivitySummary(turn, 'liveness') },
13160
+ },
13161
+ )
13162
+ if (handled) return
12167
13163
  }
12168
13164
 
12169
13165
  // Labelled-feed heartbeat: keep a stale in-progress step visibly advancing.
@@ -12228,6 +13224,21 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
12228
13224
  if (turn.activityMessageId == null) return
12229
13225
  const id = turn.activityMessageId
12230
13226
  turn.activityMessageId = null
13227
+ // Known Gap 1 (deterministic-turn-liveness.md) — the card is closing
13228
+ // normally (about to be deleted or finalized below), so drop its durable
13229
+ // handle too: a normal close must never leave a stale record for the
13230
+ // boot reaper to "finalize" a message that's already been handled.
13231
+ if (activityCardPersistEnabled) {
13232
+ clearActivityCardRecord(
13233
+ ACTIVITY_CARD_STORE_PATH,
13234
+ activityCardStoreFs,
13235
+ statusKey(chat, thread),
13236
+ // Scope to this card's exact id (reap-race guard): only drop the row
13237
+ // for the card THIS turn is closing, never a fresher card another
13238
+ // turn may have already upserted under the same topic key.
13239
+ id,
13240
+ )
13241
+ }
12231
13242
  if (CLEAR_STATUS_ON_COMPLETION) {
12232
13243
  try {
12233
13244
  await robustApiCall(
@@ -12260,16 +13271,201 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
12260
13271
  () => bot.api.editMessageText(chat, id, richMessage(finalHtml), {}),
12261
13272
  { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.finalize' },
12262
13273
  )
12263
- } catch (err) {
12264
- const msg = err instanceof Error ? err.message : String(err)
12265
- if (!msg.includes('message is not modified')) {
12266
- process.stderr.write(`telegram gateway: activity-summary finalize failed: ${msg}\n`)
12267
- }
13274
+ } catch (err) {
13275
+ const msg = err instanceof Error ? err.message : String(err)
13276
+ if (!msg.includes('message is not modified')) {
13277
+ process.stderr.write(`telegram gateway: activity-summary finalize failed: ${msg}\n`)
13278
+ }
13279
+ }
13280
+ })
13281
+ }
13282
+
13283
+ /**
13284
+ * #2849 hindsight Phase 4 — sparse chat-legible memory.
13285
+ *
13286
+ * Fire-and-forget: if this main-agent tool call is a material memory
13287
+ * operation (create_directive → 📌 remembered; invalidate/demote → ✂️
13288
+ * forgot), send ONE terse real message into the turn's originating
13289
+ * chat/topic. Routes on `turn.sessionChatId` / `turn.sessionThreadId`
13290
+ * (the originating topic, never the operator DM), through
13291
+ * `retryWithThreadFallback` so a deleted forum topic can't crash the
13292
+ * gateway. Non-material tool calls return silently — no line on ordinary
13293
+ * recall or routine consolidation. Kill-switch: SWITCHROOM_MEMORY_LEGIBILITY=0.
13294
+ */
13295
+ /**
13296
+ * Fix 1.3 (#2903): a memory-legibility line must render only AFTER the write
13297
+ * is confirmed. We stage the detected event on `tool_use` (keyed by
13298
+ * `toolUseId`) and flush the 📌/✂️ line on the matching SUCCESSFUL
13299
+ * `tool_result` — a failed write (engine down / isError envelope) drops the
13300
+ * staged line, so chat never claims "remembered" for a write that errored.
13301
+ *
13302
+ * A hindsight `tools/call` returns HTTP 200 + `is_error:true` on failure, and
13303
+ * Claude Code surfaces that on the transcript `tool_result` block, which
13304
+ * `session-tail` projects onto `ev.isError`. That is the confirmed-success
13305
+ * signal we gate on.
13306
+ */
13307
+ interface MemoryLegibilityRoute {
13308
+ chatId: string
13309
+ threadId: number | undefined
13310
+ toolName: string
13311
+ }
13312
+ const memoryLegibilityStager = new MemoryLegibilityStager<MemoryLegibilityRoute>()
13313
+
13314
+ function sendMemoryLegibilityLine(
13315
+ event: NonNullable<ReturnType<typeof detectMemoryLegibilityEvent>>,
13316
+ chatId: string,
13317
+ threadId: number | undefined,
13318
+ ): void {
13319
+ const line = renderMemoryLegibilityLine(event)
13320
+ void retryWithThreadFallback(
13321
+ robustApiCall,
13322
+ (tid) =>
13323
+ bot.api.sendMessage(chatId, line, {
13324
+ parse_mode: 'HTML',
13325
+ // Status surface, not the user's answer — never ping the device.
13326
+ disable_notification: true,
13327
+ ...(tid != null ? { message_thread_id: tid } : {}),
13328
+ }),
13329
+ { threadId, chat_id: chatId, verb: 'memory-legibility.sendMessage' },
13330
+ ).catch((err) => {
13331
+ process.stderr.write(
13332
+ `telegram gateway: memory-legibility send failed: ${
13333
+ err instanceof Error ? err.message : String(err)
13334
+ }\n`,
13335
+ )
13336
+ })
13337
+ }
13338
+
13339
+ /** Stage a material memory event observed on `tool_use`. Nothing is sent yet;
13340
+ * the line is flushed by `confirmMemoryLegibility` on a successful result. */
13341
+ function surfaceMemoryLegibility(
13342
+ turn: CurrentTurn,
13343
+ toolName: string,
13344
+ toolUseId: string | null | undefined,
13345
+ input: Record<string, unknown> | undefined,
13346
+ ): void {
13347
+ if (!MEMORY_LEGIBILITY_ENABLED) return
13348
+ const event = detectMemoryLegibilityEvent(toolName, input)
13349
+ if (event == null) return
13350
+ const chatId = turn.sessionChatId
13351
+ const threadId = turn.sessionThreadId
13352
+ // Without a toolUseId we cannot correlate a result to confirm success. This
13353
+ // effectively never happens (Claude Code always stamps a tool_use id), so
13354
+ // rather than claim an unconfirmed "remembered", we skip and log.
13355
+ if (toolUseId == null || toolUseId.length === 0) {
13356
+ process.stderr.write(
13357
+ `telegram gateway: memory-legibility ${event.kind} SKIPPED (no toolUseId) ` +
13358
+ `chat=${chatId} tool=${toolName}\n`,
13359
+ )
13360
+ return
13361
+ }
13362
+ memoryLegibilityStager.stage(toolUseId, event, { chatId, threadId, toolName })
13363
+ process.stderr.write(
13364
+ `telegram gateway: memory-legibility ${event.kind} STAGED chat=${chatId} ` +
13365
+ `thread=${threadId ?? '-'} tool=${toolName} id=${toolUseId}\n`,
13366
+ )
13367
+ }
13368
+
13369
+ /** Flush (or drop) a staged memory-legibility line on its matching
13370
+ * `tool_result`. Sends only on confirmed success; a failed write is dropped
13371
+ * so chat never claims "remembered" for a write that errored. */
13372
+ function confirmMemoryLegibility(
13373
+ toolUseId: string | null | undefined,
13374
+ isError: boolean | undefined,
13375
+ ): void {
13376
+ const resolved = memoryLegibilityStager.confirm(toolUseId, isError)
13377
+ if (resolved == null) {
13378
+ if (isError === true) {
13379
+ process.stderr.write(
13380
+ `telegram gateway: memory-legibility DROPPED (write errored) id=${toolUseId}\n`,
13381
+ )
12268
13382
  }
13383
+ return
13384
+ }
13385
+ const { event, meta } = resolved
13386
+ process.stderr.write(
13387
+ `telegram gateway: memory-legibility ${event.kind} CONFIRMED chat=${meta.chatId} ` +
13388
+ `thread=${meta.threadId ?? '-'} tool=${meta.toolName} id=${toolUseId}\n`,
13389
+ )
13390
+ sendMemoryLegibilityLine(event, meta.chatId, meta.threadId)
13391
+ }
13392
+
13393
+ /**
13394
+ * #2849 hindsight Phase 4 (follow-up) — consolidation-driven legibility.
13395
+ *
13396
+ * The gateway-side sink for a verified `hindsight` / `consolidation.completed`
13397
+ * webhook. `recordWebhookEvent` has already logged the event for audit and
13398
+ * resolved the agent's channel target; this decides whether it is MATERIAL
13399
+ * (a genuine store/correct — else nothing), rate-limits it hard, and — only
13400
+ * if it clears both gates — sends ONE terse "🧠 updated what I know about Y"
13401
+ * real message with notifications suppressed. Never injects a model turn.
13402
+ * OPT-IN via SWITCHROOM_CONSOLIDATION_LEGIBILITY.
13403
+ */
13404
+ function surfaceConsolidationLegibility(
13405
+ rec: WebhookGatewayRecord,
13406
+ target: { chatId: string; threadId?: number },
13407
+ ): void {
13408
+ if (!CONSOLIDATION_LEGIBILITY_ENABLED) return
13409
+ const event = detectConsolidationEvent(rec.payload)
13410
+ if (event == null) return // routine no-op consolidation — surface nothing
13411
+ const agent = rec.agent
13412
+ // Rate-limiter clock MUST be wall-clock (Date.now()), never rec.ts: rec.ts
13413
+ // can be derived from the (attacker-influenceable) webhook payload, and a
13414
+ // spoofed far-past / far-future timestamp would poison the per-agent
13415
+ // min-interval gate — either permanently opening it (stale ts always older
13416
+ // than the window) or jamming it shut. The limiter only cares about "how
13417
+ // long since the last surface on THIS gateway", which is a local wall-clock
13418
+ // question.
13419
+ if (!consolidationRateLimiter.allow(agent, consolidationSignature(event), Date.now())) {
13420
+ process.stderr.write(
13421
+ `telegram gateway: consolidation-legibility ${event.kind} rate-limited ` +
13422
+ `agent=${agent} topic='${event.topic}'\n`,
13423
+ )
13424
+ return
13425
+ }
13426
+ const chatId = target.chatId
13427
+ const threadId = target.threadId
13428
+ const line = renderConsolidationLine(event)
13429
+ process.stderr.write(
13430
+ `telegram gateway: consolidation-legibility ${event.kind} chat=${chatId} ` +
13431
+ `thread=${threadId ?? '-'} agent=${agent}\n`,
13432
+ )
13433
+ void retryWithThreadFallback(
13434
+ robustApiCall,
13435
+ (tid) =>
13436
+ bot.api.sendMessage(chatId, line, {
13437
+ parse_mode: 'HTML',
13438
+ // Status surface, not the user's answer — never ping the device.
13439
+ disable_notification: true,
13440
+ ...(tid != null ? { message_thread_id: tid } : {}),
13441
+ }),
13442
+ { threadId, chat_id: chatId, verb: 'consolidation-legibility.sendMessage' },
13443
+ ).catch((err) => {
13444
+ process.stderr.write(
13445
+ `telegram gateway: consolidation-legibility send failed: ${
13446
+ err instanceof Error ? err.message : String(err)
13447
+ }\n`,
13448
+ )
12269
13449
  })
12270
13450
  }
12271
13451
 
12272
13452
  function handleSessionEvent(ev: SessionEvent): void {
13453
+ // Per-turn liveness stamp (orphaned-reply thinking-pause fix). Stamp
13454
+ // lastStreamEventAt AND reset the rearm counter on ANY genuine stream event,
13455
+ // under ONE shared predicate: a live turn is present and this is not the
13456
+ // synthetic durationMs===-1 turn_end (the fire callback's own re-dispatch).
13457
+ // The counter reset MUST live here at the dispatcher — NOT inside
13458
+ // resetOrphanedReplyTimeout() (which is called from the fire callback one
13459
+ // line after the counter increments) and NOT tied to a single case (e.g.
13460
+ // tool_result does not call resetOrphanedReplyTimeout). onStreamEvent applies
13461
+ // the `!(turn_end && -1)` half of the predicate internally.
13462
+ {
13463
+ const liveTurn = currentTurn
13464
+ if (liveTurn != null) {
13465
+ const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
13466
+ liveTurn.liveness.onStreamEvent(ev.kind, durationMs, Date.now())
13467
+ }
13468
+ }
12273
13469
  switch (ev.kind) {
12274
13470
  case 'enqueue': {
12275
13471
  // Drain any orphaned typing-wrap entries left over from a crashed
@@ -12306,6 +13502,14 @@ function handleSessionEvent(ev: SessionEvent): void {
12306
13502
  prior.answerStream.stop()
12307
13503
  prior.answerStream = null
12308
13504
  }
13505
+ // Bounded-leak hardening (A5): clear the prior turn's orphaned-reply
13506
+ // fuse before it is superseded. The fire callback re-reads currentTurn
13507
+ // and no-ops on a stale turn, but proactively clearing the timer avoids
13508
+ // a bounded pile-up of dangling timers across rapid steer/queue turns.
13509
+ if (prior?.orphanedReplyTimeoutId != null) {
13510
+ clearTimeout(prior.orphanedReplyTimeoutId)
13511
+ prior.orphanedReplyTimeoutId = null
13512
+ }
12309
13513
  // #1067: swap the entire turn atom in one assignment. Every
12310
13514
  // handler captures `const turn = currentTurn` at entry, so a
12311
13515
  // captured-then-awaited read can't reattribute to the new turn.
@@ -12365,7 +13569,9 @@ function handleSessionEvent(ev: SessionEvent): void {
12365
13569
  silentAnchorText: '',
12366
13570
  capturedText: [],
12367
13571
  orphanedReplyTimeoutId: null,
12368
- orphanedReplyRearmCount: 0,
13572
+ // Fresh liveness tracker: lastStreamEventAt seeded to the turn start
13573
+ // so a turn that never streams still trips the fuse after windowMs.
13574
+ liveness: new LivenessTracker(startedAt),
12369
13575
  turnId,
12370
13576
  registryKey: null,
12371
13577
  noReplyDrainTimer: null,
@@ -12567,6 +13773,14 @@ function handleSessionEvent(ev: SessionEvent): void {
12567
13773
  // of dropping. The answer-stream's own dedup handles overlap
12568
13774
  // with the reply tool's payload.
12569
13775
  preambleSuppressor.onTool({ isReplyTool: isTelegramSurfaceTool(ev.toolName) })
13776
+ // #2849 Phase 4 — sparse chat-legible memory. Surface ONE terse line in
13777
+ // the originating chat/topic when this tool call materially changes what
13778
+ // the agent remembers (create_directive / invalidate / demote). Fires
13779
+ // BEFORE the `if (!ctrl) return` status-reaction gate below so it works
13780
+ // on turns with no active status-reaction controller. Deterministic
13781
+ // tool-call observation — no model call, no polling; ordinary recall and
13782
+ // routine consolidation never reach here (they aren't material tools).
13783
+ surfaceMemoryLegibility(turn, ev.toolName, ev.toolUseId, ev.input)
12570
13784
  const ctrl = activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))
12571
13785
  const name = ev.toolName
12572
13786
  // Phase tracking removed in #553 PR 5 — phases only fed the
@@ -12997,6 +14211,9 @@ function handleSessionEvent(ev: SessionEvent): void {
12997
14211
  }
12998
14212
  case 'tool_result': {
12999
14213
  if (ev.toolUseId) typingWrapper.onToolResult(ev.toolUseId)
14214
+ // Fix 1.3 (#2903): flush a staged 📌/✂️ memory-legibility line only on a
14215
+ // CONFIRMED-successful write; a failed write (ev.isError) drops it.
14216
+ confirmMemoryLegibility(ev.toolUseId, ev.isError)
13000
14217
  return
13001
14218
  }
13002
14219
  case 'sub_agent_tool_use': {
@@ -13024,10 +14241,25 @@ function handleSessionEvent(ev: SessionEvent): void {
13024
14241
  if (ev.durationMs === -1) {
13025
14242
  const turn = currentTurn
13026
14243
  const key = turn != null ? statusKey(turn.sessionChatId, turn.sessionThreadId) : ''
13027
- if (isLegitimatelyWorking(key)) {
14244
+ // Widened to also suppress while the turn is RECENTLY STREAMING — a
14245
+ // model reasoning pause emits no tool/text events (so
14246
+ // isLegitimatelyWorking is false) but a genuine stream landed within
14247
+ // the window, so the turn is alive and must not be torn down.
14248
+ // ACCEPTED TRADE-OFF (F3): the context-exhaustion recovery latency via
14249
+ // THIS backstop path grows from ~30 s to ~120-150 s, because the
14250
+ // "Prompt is too long" marker is itself a genuine `text` event that
14251
+ // stamps recentlyStreaming. This is acceptable — the primary
14252
+ // context-exhaustion teardown is the immediate `endCurrentTurnAtomic`
14253
+ // in `case 'text'` (isContextExhaustionText) above; this backstop only
14254
+ // matters if that path is missed, and it still COMPLETES once the
14255
+ // window lapses. Recovery is delayed, never suppressed forever.
14256
+ const recentlyStreaming =
14257
+ turn != null && turn.liveness.recentlyStreaming(Date.now(), ORPHANED_REPLY_STREAM_WINDOW_MS)
14258
+ if (isLegitimatelyWorking(key) || recentlyStreaming) {
13028
14259
  process.stderr.write(
13029
14260
  `telegram gateway: synthetic turn_end suppressed — legitimately working` +
13030
14261
  ` (in_flight=${toolFlightTracker.inFlightCount()},` +
14262
+ ` recently_streaming=${recentlyStreaming},` +
13031
14263
  ` bg_work=${turn != null ? pendingProgress.hasPendingAsyncDispatch(key) : false})\n`,
13032
14264
  )
13033
14265
  return
@@ -13753,11 +14985,28 @@ function handleSessionEvent(ev: SessionEvent): void {
13753
14985
  // this path. The turn-flush 'flush' branch also returns earlier
13754
14986
  // (and sets finalAnswerDelivered=true defensively).
13755
14987
  if (turn.finalAnswerDelivered === false) {
13756
- const silentEnd = recordUndeliveredTurnEnd({
13757
- chatId,
13758
- threadId: threadId ?? null,
13759
- turnKey: tKey,
13760
- })
14988
+ // PR #2892 (deterministic-turn-liveness RFC Phase 2) hardening:
14989
+ // wire the represent-guard-style staleness
14990
+ // check (`recordSilentTurnEnd`'s `hasOutboundDeliveredSince` dep) so
14991
+ // an exhausted-looking record left over from a PRIOR, already-
14992
+ // answered turn on this same chat/thread (statusKey is not a
14993
+ // per-turn nonce) can never be misread as this turn's spent
14994
+ // re-prompt budget. Falls back to the pre-existing turnKey/
14995
+ // retryCount-only check when history is unavailable.
14996
+ const silentEndDeps: SilentEndDeps | undefined = HISTORY_ENABLED
14997
+ ? {
14998
+ hasOutboundDeliveredSince: (cid, sinceMs, tid) =>
14999
+ hasOutboundDeliveredSince(cid, sinceMs, tid, 1),
15000
+ }
15001
+ : undefined
15002
+ const silentEnd = recordUndeliveredTurnEnd(
15003
+ {
15004
+ chatId,
15005
+ threadId: threadId ?? null,
15006
+ turnKey: tKey,
15007
+ },
15008
+ silentEndDeps,
15009
+ )
13761
15010
  if (silentEnd.exhausted) {
13762
15011
  process.stderr.write(
13763
15012
  `telegram gateway: WARN silent-end fallback — agent stayed ` +
@@ -13769,7 +15018,7 @@ function handleSessionEvent(ev: SessionEvent): void {
13769
15018
  (tid) =>
13770
15019
  bot.api.sendMessage(
13771
15020
  chatId,
13772
- SILENT_END_FALLBACK_TEXT,
15021
+ silentEndFallbackText(turnDurationMs),
13773
15022
  tid != null ? { message_thread_id: tid } : {},
13774
15023
  ),
13775
15024
  { threadId, chat_id: chatId, verb: 'silent-end-fallback.sendMessage' },
@@ -14289,6 +15538,8 @@ async function handleInbound(
14289
15538
  // present, so reset any no-repeat suppression: the next time the agent asks
14290
15539
  // for something that timed out earlier, they should see a fresh card.
14291
15540
  clearPermissionTimeoutSuppression('operator inbound')
15541
+ // #2862 — operator is back; re-offer any approvals that timed out meanwhile.
15542
+ maybePostMissedApprovalDigest('operator inbound')
14292
15543
 
14293
15544
  // Capture wall-clock receive time for inbound_ack metric (#203).
14294
15545
  // Must be after gate() so early-exit paths (drop/pair) don't skew the delta.
@@ -14302,30 +15553,32 @@ async function handleInbound(
14302
15553
  // network RTT) but not a user-perceived end-to-end measurement.
14303
15554
  const inboundReceivedAt = Date.now()
14304
15555
 
14305
- // Phase 2b shadow: inbound arrival. Emit BEFORE the snapshot/gate
14306
- // logic so the machine sees the event at the same point in time the
14307
- // imperative code would. The machine internally handles fresh-turn
14308
- // vs mid-turn its decision will be visible in the gw-trace shadow
14309
- // line emitted to stderr.
14310
- const _shadowKey = statusKey(ctx.chat?.id != null ? String(ctx.chat.id) : '0', ctx.message?.message_thread_id) as _ChatKey
14311
- // PR3b-cutover: snapshot the machine's in-turn state BEFORE the
14312
- // inbound event advances it. A fresh-turn inbound transitions the
14313
- // machine idle→in_turn; reading after the emit would see THIS
14314
- // message's own just-started turn and self-block it (the same
14315
- // self-block hazard the claudeBusyKeys snapshot below guards). When
14316
- // the kill-switch is off this is null and the gate uses the legacy
15556
+ // PR3b-cutover: snapshot the machine's in-turn state AT RECEIPT, before
15557
+ // this handler emits the `inbound` event (that emit is DEFERRED below to
15558
+ // the delivery-commit point search DEFERRED_INBOUND_EMIT). A fresh-turn
15559
+ // inbound transitions the machine idle→in_turn; reading after the emit
15560
+ // would see THIS message's own just-started turn and self-block it (the
15561
+ // same self-block hazard the claudeBusyKeys snapshot below guards). Null
15562
+ // when the kill-switch is off, in which case the gate uses the legacy
14317
15563
  // claudeBusyKeys read.
15564
+ //
15565
+ // WHY THE INBOUND EMIT IS DEFERRED (overlord /usage wedge, 2026-07-08):
15566
+ // the `inbound` event drives the now-AUTHORITATIVE turn-in-flight gate
15567
+ // (turnInFlightForGate → isMachineInTurn). Emitting it HERE — at handler
15568
+ // entry, before the intercept gauntlet below (permission-reply, /auth
15569
+ // paste-back, interrupt-empty, secret-detect drop, …) — drove the machine
15570
+ // into `bridge_alive_in_turn` for messages that then EARLY-RETURN as an
15571
+ // intercept and never become a turn. No delivery, no claudeBusyKeys mark,
15572
+ // and critically no `turnEnd` ever fires, so the machine held the gate
15573
+ // closed until the 5-min TTL tick force-cleared it — buffering every
15574
+ // subsequent inbound (including /usage) the whole time. That is exactly
15575
+ // the dangerous `machine_over_holds` divergence gate-parity-probe.ts
15576
+ // flags. The imperative claudeBusyKeys tracker got it right (never marked
15577
+ // busy for the intercepted message); the machine was mis-fed. Fix: emit
15578
+ // `inbound` only once the message clears every intercept and reaches the
15579
+ // deliver-or-buffer decision, keeping the machine in lockstep with the
15580
+ // imperative delivery lifecycle it models.
14318
15581
  const machineInTurnAtReceipt = isDeliveryCutoverEnabled() ? isMachineInTurn() : null
14319
- shadowEmit({
14320
- kind: 'inbound',
14321
- key: _shadowKey,
14322
- msg: {
14323
- msgId: ctx.message?.message_id ?? 0,
14324
- isSteering: false, // refined in PR 3 — for now shadow conservatively classifies as non-steering
14325
- payload: null,
14326
- },
14327
- at: Date.now(),
14328
- })
14329
15582
 
14330
15583
  // #1556 self-blocking fix (v0.12.22): snapshot the live turn-state
14331
15584
  // BEFORE the fresh-turn branch (line ~7357) sets activeTurnStartedAt
@@ -15522,6 +16775,29 @@ async function handleInbound(
15522
16775
  return
15523
16776
  }
15524
16777
 
16778
+ // DEFERRED_INBOUND_EMIT — drive the delivery state machine's `inbound`
16779
+ // event HERE, not at handler entry. Every intercept/early-return above
16780
+ // (permission-reply, /auth paste-back, interrupt-empty, secret-detect
16781
+ // drop, drop/pair) has been passed, so any message reaching this point is
16782
+ // a genuine turn the imperative code is about to deliver or buffer. That
16783
+ // keeps the machine's authoritative turn-in-flight state in lockstep with
16784
+ // the imperative delivery it models and can never be advanced into
16785
+ // `bridge_alive_in_turn` by a message that never becomes a turn — the
16786
+ // overlord /usage wedge of 2026-07-08 (see machineInTurnAtReceipt above).
16787
+ // isSteering is now the real classification (computed at ~line 16289), so
16788
+ // the machine correctly distinguishes a mid-turn steer (delivered, no new
16789
+ // turn) from a fresh turn.
16790
+ shadowEmit({
16791
+ kind: 'inbound',
16792
+ key: statusKey(chat_id, messageThreadId) as _ChatKey,
16793
+ msg: {
16794
+ msgId: msgId ?? 0,
16795
+ isSteering,
16796
+ payload: null,
16797
+ },
16798
+ at: Date.now(),
16799
+ })
16800
+
15525
16801
  // PR2 obligation-ledger OPEN — BEFORE the buffer-until-idle / deliver split so
15526
16802
  // a mid-turn cross-topic inbound (the 715 case) is tracked whether it is
15527
16803
  // buffered or delivered now. Idempotent + gated; no-op when the flag is off.
@@ -15531,21 +16807,32 @@ async function handleInbound(
15531
16807
  effectiveText,
15532
16808
  })
15533
16809
 
15534
- if (
15535
- decideInboundDelivery({
15536
- turnInFlight: turnInFlightAtReceipt,
15537
- isSteering,
15538
- // Interrupt-marker carve-out (2026-05-24): the `!`-prefixed body
15539
- // must bypass the "buffer-until-turn-complete" gate because the
15540
- // SIGINT'd turn often doesn't emit turn_complete, leaving the
15541
- // body stranded in pendingInboundBuffer indefinitely. The
15542
- // `interrupt` const is computed at the start of handleInbound
15543
- // (line ~7606) and remains in scope here. When the user fires
15544
- // `!`-with-body, this delivers the body as a fresh inbound to
15545
- // the freshly-killed bridge.
15546
- isInterrupt: interrupt.isInterrupt,
15547
- }) === 'buffer-until-idle'
15548
- ) {
16810
+ // #2917: read the gate LIVE (not the receipt snapshot) on the default path
16811
+ // so a sibling same-chat inbound delivered during THIS handler's async
16812
+ // lead-in is observed here. Reading `claudeBusyKeys` live is self-block-safe:
16813
+ // THIS inbound's own key is only added at delivery (below), never by the
16814
+ // fresh-turn init bundle — so the live read never sees its own key. The
16815
+ // delivery-machine cutover keeps its at-receipt machine snapshot (a live
16816
+ // machine read WOULD self-block, since the inbound event already advanced
16817
+ // the machine for this key). Kill switch (=0) restores the pure snapshot.
16818
+ const gateTurnInFlight =
16819
+ SERIALIZE_INBOUND_DELIVERY_ENABLED && machineInTurnAtReceipt == null
16820
+ ? claudeBusyKeys.size > 0
16821
+ : turnInFlightAtReceipt
16822
+ const deliveryGate = reserveInboundDelivery({
16823
+ turnInFlight: gateTurnInFlight,
16824
+ isSteering,
16825
+ // Interrupt-marker carve-out (2026-05-24): the `!`-prefixed body
16826
+ // must bypass the "buffer-until-turn-complete" gate because the
16827
+ // SIGINT'd turn often doesn't emit turn_complete, leaving the
16828
+ // body stranded in pendingInboundBuffer indefinitely. The
16829
+ // `interrupt` const is computed at the start of handleInbound
16830
+ // (line ~7606) and remains in scope here. When the user fires
16831
+ // `!`-with-body, this delivers the body as a fresh inbound to
16832
+ // the freshly-killed bridge.
16833
+ isInterrupt: interrupt.isInterrupt,
16834
+ })
16835
+ if (deliveryGate.decision === 'buffer-until-idle') {
15549
16836
  pendingInboundBuffer.push(selfAgent, inboundMsg)
15550
16837
  process.stderr.write(
15551
16838
  `telegram gateway: inbound held mid-turn agent=${selfAgent} ` +
@@ -15569,6 +16856,18 @@ async function handleInbound(
15569
16856
  return
15570
16857
  }
15571
16858
 
16859
+ // #2917: reserve the chat's busy key SYNCHRONOUSLY here — before the
16860
+ // composer-clear await below — so a concurrent same-chat inbound reaching
16861
+ // the LIVE gate above observes this in-flight delivery and buffers behind it
16862
+ // (per-chat FIFO). Without this, both handlers pass the gate during each
16863
+ // other's async lead-in and race to the bridge, reordering the replies.
16864
+ // Only fresh-turn deliveries reserve (steering/interrupt amend a running
16865
+ // turn and must not). Released below if the send misses (bridge offline).
16866
+ let reservedBusyKey: string | null = null
16867
+ if (deliveryGate.reserve && SERIALIZE_INBOUND_DELIVERY_ENABLED && machineInTurnAtReceipt == null) {
16868
+ reservedBusyKey = markClaudeBusyForInbound(inboundMsg)
16869
+ }
16870
+
15572
16871
  // Pre-send composer clear (the marko wedge). The inbound is about to be
15573
16872
  // delivered as an MCP `notifications/claude/channel` notification, which
15574
16873
  // the unmodified CLI appends into its composer and auto-submits ONLY when
@@ -15598,7 +16897,10 @@ async function handleInbound(
15598
16897
 
15599
16898
  const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
15600
16899
  if (delivered) {
15601
- const busyKey = markClaudeBusyForInbound(inboundMsg)
16900
+ // Reuse the key reserved synchronously above (#2917) when present, else
16901
+ // mark now — markClaudeBusyForInbound is idempotent (lockstep re-stamp),
16902
+ // so a re-mark is safe and returns the same chat key.
16903
+ const busyKey = reservedBusyKey ?? markClaudeBusyForInbound(inboundMsg)
15602
16904
  // Track until claude acks via `enqueue` (the marko drop-wedge): if no ack
15603
16905
  // lands, the message stranded in the composer and the sweep re-delivers
15604
16906
  // it. Track ONLY messages that produce an `enqueue` to ack against —
@@ -15620,6 +16922,15 @@ async function handleInbound(
15620
16922
  }
15621
16923
  }
15622
16924
  if (!delivered) {
16925
+ // #2917: the synchronous reservation assumed delivery; the send missed
16926
+ // (bridge offline), so release it in lockstep — otherwise the orphaned
16927
+ // busy key would gate every subsequent inbound into the buffer until the
16928
+ // orphan reaper clears it. The message itself is buffered below, so FIFO
16929
+ // is still preserved (it drains in order on the next bridge register).
16930
+ if (reservedBusyKey != null) {
16931
+ claudeBusyKeys.delete(reservedBusyKey)
16932
+ claudeBusyKeySince.delete(reservedBusyKey)
16933
+ }
15623
16934
  // Only persist fresh user turns to the durable spool. Steering / `!`
15624
16935
  // interrupt / empty bodies are mid-turn amendments or no-ops that would
15625
16936
  // arrive orphaned if replayed as a fresh turn after a restart — drop them
@@ -17185,7 +18496,7 @@ interface ModelDepsRestartContext {
17185
18496
  }
17186
18497
 
17187
18498
  function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & ModelCommandDeps {
17188
- return {
18499
+ const deps: ModelMenuDeps & ModelCommandDeps = {
17189
18500
  discover: (a) => discoverModels(a),
17190
18501
  discoverSrModels: async () => {
17191
18502
  // /model/info lives on the ROOT proxy (model-mapped surface), NOT the
@@ -17291,7 +18602,25 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
17291
18602
  )
17292
18603
  }
17293
18604
  },
18605
+ /**
18606
+ * Session-only switch TO an sr-* (LiteLLM/OpenRouter) model. claude's
18607
+ * native `/model` picker rejects unknown sr-* ids, so we can't inject.
18608
+ * Write the token to the `.session-model-override` carrier file (start.sh
18609
+ * consumes it on the next boot and launches `claude --model <token>`), set
18610
+ * the in-memory session-model so /status stays honest across the restart
18611
+ * window, then run the SAME restart dispatch as scheduleRestart above.
18612
+ */
18613
+ scheduleModelRelaunch: async (model: string, reason: string) => {
18614
+ const agentDir = resolveAgentDirFromEnv()
18615
+ if (!agentDir) throw new Error('agent dir unresolvable — cannot write session-model carrier')
18616
+ // Carrier: single line, token + newline, no quoting (start.sh strips
18617
+ // whitespace and shape-gates). One-shot — consumed on the next boot.
18618
+ writeFileSync(join(agentDir, '.session-model-override'), `${model}\n`, 'utf8')
18619
+ activeSessionModelOverride = model
18620
+ await deps.scheduleRestart(reason)
18621
+ },
17294
18622
  }
18623
+ return deps
17295
18624
  }
17296
18625
 
17297
18626
  function modelMenuReplyMarkup(reply: ModelMenuReply): InlineKeyboard | undefined {
@@ -18041,6 +19370,8 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
18041
19370
  }
18042
19371
  // Operator answered via slash ⇒ present; reset no-repeat suppression.
18043
19372
  clearPermissionTimeoutSuppression('operator answered via /approve|/deny')
19373
+ // #2862 — operator is present; re-offer any approvals that timed out meanwhile.
19374
+ maybePostMissedApprovalDigest('operator answered via /approve|/deny')
18044
19375
  // Forward to connected bridges — same IPC the button handler uses.
18045
19376
  dispatchPermissionVerdict({ type: 'permission', requestId: request_id, behavior })
18046
19377
  resumeReactionAfterVerdict()
@@ -18764,25 +20095,21 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
18764
20095
  }
18765
20096
  } catch (err) {
18766
20097
  process.stderr.write(`telegram gateway: quota-watch: probe for crossing accounts failed: ${err}\n`)
18767
- if (!tuning.sendOnProbeFail) {
18768
- // A quota notification must never carry numbers we could not verify
18769
- // live. Leave the crossing accounts' state untouched the
18770
- // transition re-evaluates (and re-probes) on the next 15-min tick.
18771
- // Persist any reconciles already applied, then bail.
18772
- if (reconciledCount > 0) {
18773
- try {
18774
- saveQuotaWatchState(stateDir, mutatedState)
18775
- } catch (saveErr) {
18776
- process.stderr.write(`telegram gateway: quota-watch state persist failed: ${saveErr}\n`)
18777
- }
20098
+ // A quota notification must never carry numbers we could not verify
20099
+ // live. Leave the crossing accounts' state untouched the
20100
+ // transition re-evaluates (and re-probes) on the next 15-min tick.
20101
+ // Persist any reconciles already applied, then bail.
20102
+ if (reconciledCount > 0) {
20103
+ try {
20104
+ saveQuotaWatchState(stateDir, mutatedState)
20105
+ } catch (saveErr) {
20106
+ process.stderr.write(`telegram gateway: quota-watch state persist failed: ${saveErr}\n`)
18778
20107
  }
18779
- process.stderr.write(
18780
- `telegram gateway: quota-watch: deferring ${pendingTransitions.length} notification(s) until probe succeeds\n`,
18781
- )
18782
- return
18783
20108
  }
18784
- // Legacy (SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL=1): fall through
18785
- // and send from cached data.
20109
+ process.stderr.write(
20110
+ `telegram gateway: quota-watch: deferring ${pendingTransitions.length} notification(s) until probe succeeds\n`,
20111
+ )
20112
+ return
18786
20113
  }
18787
20114
 
18788
20115
  // Build final notifications, enriching the snapshot with fresh probe
@@ -18832,7 +20159,7 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
18832
20159
  // State normalised by the time of the probe — don't notify.
18833
20160
  continue
18834
20161
  }
18835
- } else if (!tuning.sendOnProbeFail) {
20162
+ } else {
18836
20163
  // No verified fresh data for this account (per-account probe failure
18837
20164
  // or label missing from the batch result). Same rule as the batch
18838
20165
  // throw above: never send unverified numbers. State untouched —
@@ -19898,6 +21225,185 @@ async function handleSkillProposalCallback(ctx: Context, data: string): Promise<
19898
21225
  )
19899
21226
  }
19900
21227
 
21228
+ /**
21229
+ * hindsight Phase 5 — handle a tap on the mental-model PROPOSAL card.
21230
+ * mmp:approve:<stageId> — declare the model: append it to the agent's
21231
+ * memory.mental_models[] via the operator-approved
21232
+ * config-edit path (reused config_propose_edit
21233
+ * apply+reconcile; reconcile ensures it), then wake
21234
+ * the agent with an "applied" inbound.
21235
+ * mmp:deny:<stageId> — drop the proposal; NOTHING is written; wake the
21236
+ * agent with a "denied" inbound.
21237
+ *
21238
+ * Authorization: the tapper MUST be on the gateway's allowFrom list — an agent
21239
+ * can PROPOSE but can never self-approve (identical gate to the vault flow).
21240
+ */
21241
+ async function handleMentalModelProposeCallback(ctx: Context, data: string): Promise<void> {
21242
+ const senderId = String(ctx.from?.id ?? '')
21243
+ const access = loadAccess()
21244
+ if (!access.allowFrom.includes(senderId)) {
21245
+ // Self-approve is impossible: only an allow-listed operator can resolve
21246
+ // the card. A tap from anyone else (incl. a compromised agent identity) is
21247
+ // refused here.
21248
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
21249
+ return
21250
+ }
21251
+ const parts = data.split(':')
21252
+ if (parts.length < 3) {
21253
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
21254
+ return
21255
+ }
21256
+ const action = parts[1]
21257
+ const stageId = parts.slice(2).join(':')
21258
+ const pending = pendingMentalModelProposes.get(stageId)
21259
+ if (!pending) {
21260
+ await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-propose.' }).catch(() => {})
21261
+ if (ctx.callbackQuery?.message) {
21262
+ await ctx.api
21263
+ .editMessageText(
21264
+ ctx.callbackQuery.message.chat.id,
21265
+ ctx.callbackQuery.message.message_id,
21266
+ richMessage('⌛ _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
21267
+ { reply_markup: { inline_keyboard: [] } },
21268
+ )
21269
+ .catch(() => {})
21270
+ }
21271
+ return
21272
+ }
21273
+ if (action !== 'approve' && action !== 'deny') {
21274
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
21275
+ return
21276
+ }
21277
+ // Enforce the TTL at TAP time, not just on the next propose's sweep. Without
21278
+ // this, a card left untapped past its TTL is still resolvable if no fresh
21279
+ // proposal has run the sweep — an operator could approve a stale proposal.
21280
+ if (Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS) {
21281
+ pendingMentalModelProposes.delete(stageId)
21282
+ await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-propose.' }).catch(() => {})
21283
+ if (pending.card_message_id != null) {
21284
+ await ctx.api
21285
+ .editMessageText(
21286
+ pending.chat_id,
21287
+ pending.card_message_id,
21288
+ richMessage('⌛ _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
21289
+ { reply_markup: { inline_keyboard: [] } },
21290
+ )
21291
+ .catch(() => {})
21292
+ }
21293
+ return
21294
+ }
21295
+ // Single-shot: remove the pending entry immediately so a double-tap can't
21296
+ // resolve twice.
21297
+ pendingMentalModelProposes.delete(stageId)
21298
+
21299
+ const proposal: MentalModelPendingProposal = {
21300
+ agent: pending.agent,
21301
+ chat_id: pending.chat_id,
21302
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
21303
+ spec: pending.spec,
21304
+ ...(pending.reason ? { reason: pending.reason } : {}),
21305
+ }
21306
+
21307
+ const resolveDeps = {
21308
+ readConfigText: () => readLiveSwitchroomConfigText(),
21309
+ registerPreApproval: (agent: string, diff: string) => {
21310
+ pendingMentalModelCorrelations.set(mentalModelCorrelationKey(agent, diff), {
21311
+ agentName: agent,
21312
+ unifiedDiff: diff,
21313
+ createdAt: Date.now(),
21314
+ })
21315
+ },
21316
+ clearPreApproval: (agent: string, diff: string) => {
21317
+ pendingMentalModelCorrelations.delete(mentalModelCorrelationKey(agent, diff))
21318
+ },
21319
+ dispatchConfigEdit: async (a: { agent: string; diff: string; reason: string }) => {
21320
+ const req: HostdRequest = {
21321
+ v: 1,
21322
+ op: 'config_propose_edit',
21323
+ request_id: hostdRequestId('gw-mental-model'),
21324
+ args: {
21325
+ unified_diff: a.diff,
21326
+ reason: a.reason,
21327
+ target_path: '/state/config/switchroom.yaml',
21328
+ },
21329
+ }
21330
+ // config_propose_edit blocks on validate→approve→apply→reconcile
21331
+ // (5-10 min on a busy host) — allow 12 min. The operator already
21332
+ // approved on the proposal card, so hostd's config-approval callback
21333
+ // auto-resolves via the pre-registered correlation (no second card).
21334
+ const resp = await tryHostdDispatch(a.agent, req, 720_000)
21335
+ if (resp === 'not-configured') {
21336
+ return { state: 'error' as const, reason: 'hostd config-edit is not configured (host_control disabled or socket absent)' }
21337
+ }
21338
+ if (resp.result === 'completed') return { state: 'applied' as const }
21339
+ if (resp.result === 'denied') return { state: 'denied' as const, reason: resp.error ?? 'operator/host denied the edit' }
21340
+ return { state: 'error' as const, reason: resp.error ?? `hostd returned '${resp.result}'` }
21341
+ },
21342
+ // Ensure is delegated to reconcile: config_propose_edit's apply triggers a
21343
+ // reconcile which runs ensureDeclaredMentalModels (#2874) for the newly
21344
+ // declared model — the authoritative, correctly-scoped ensure. We
21345
+ // deliberately do NOT add a redundant gateway-side ensure (it would need
21346
+ // the agent's bank id + a reachable Hindsight endpoint from the gateway).
21347
+ injectInbound: (inbound: InboundMessage) => {
21348
+ deliverResumeSyntheticOrBuffer(pending.agent, inbound)
21349
+ },
21350
+ log: (m: string) => process.stderr.write(`telegram gateway: ${m}\n`),
21351
+ }
21352
+
21353
+ if (action === 'deny') {
21354
+ await ctx.answerCallbackQuery({ text: '🚫 Denied' }).catch(() => {})
21355
+ await resolveMentalModelProposal('deny', proposal, stageId, senderId, resolveDeps)
21356
+ if (pending.card_message_id != null) {
21357
+ await ctx.api
21358
+ .editMessageText(
21359
+ pending.chat_id,
21360
+ pending.card_message_id,
21361
+ richMessage(`🚫 _Denied. **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` was not declared._`),
21362
+ { reply_markup: { inline_keyboard: [] } },
21363
+ )
21364
+ .catch(() => {})
21365
+ }
21366
+ return
21367
+ }
21368
+
21369
+ // Approve. Ack immediately + show an interim state, then persist in the
21370
+ // background (config_propose_edit can take minutes), then edit the card with
21371
+ // the real outcome. The turn resumes via the synthetic inbound injected by
21372
+ // resolveMentalModelProposal — not by this card edit.
21373
+ await ctx.answerCallbackQuery({ text: '✅ Declaring the model…' }).catch(() => {})
21374
+ if (pending.card_message_id != null) {
21375
+ await ctx.api
21376
+ .editMessageText(
21377
+ pending.chat_id,
21378
+ pending.card_message_id,
21379
+ richMessage(`⏳ _Declaring **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` — appending to config + ensuring…_`),
21380
+ { reply_markup: { inline_keyboard: [] } },
21381
+ )
21382
+ .catch(() => {})
21383
+ }
21384
+ void (async () => {
21385
+ let result
21386
+ try {
21387
+ result = await resolveMentalModelProposal('approve', proposal, stageId, senderId, resolveDeps)
21388
+ } catch (err) {
21389
+ process.stderr.write(`telegram gateway: mental_model_propose approve threw: ${(err as Error).message}\n`)
21390
+ result = { outcome: 'failed' as const, reason: (err as Error).message }
21391
+ }
21392
+ if (pending.card_message_id != null) {
21393
+ const label =
21394
+ result.outcome === 'applied'
21395
+ ? `✅ **Declared** ${escapeHtmlForTg(pending.agent)}'s mental model \`${pending.spec.name}\` — appended to \`memory.mental_models[]\` and ensured. Restart the agent to load it if it isn't picked up automatically.`
21396
+ : `⚠️ **Did NOT declare** \`${pending.spec.name}\`${'reason' in result && result.reason ? ` — ${escapeHtmlForTg(result.reason)}` : ''}. Nothing was written.`
21397
+ await ctx.api
21398
+ .editMessageText(pending.chat_id, pending.card_message_id, richMessage(label), {
21399
+ reply_markup: { inline_keyboard: [] },
21400
+ link_preview_options: { is_disabled: true },
21401
+ })
21402
+ .catch(() => {})
21403
+ }
21404
+ })()
21405
+ }
21406
+
19901
21407
  async function handleVaultRequestAccessCallback(ctx: Context, data: string): Promise<void> {
19902
21408
  const senderId = String(ctx.from?.id ?? '')
19903
21409
  const access = loadAccess()
@@ -22166,14 +23672,41 @@ bot.on('callback_query:data', async ctx => {
22166
23672
  // We track whether we applied the interim edit so we can skip the
22167
23673
  // toastOnly short-circuit if we did — a toastOnly return after the interim
22168
23674
  // edit would leave the menu stuck button-less.
22169
- let didInterimSrEdit = false
23675
+ // sr-* TARGET tap: switch TO a non-Claude (LiteLLM/OpenRouter) model.
23676
+ // Parity with the text `/model sr-*` path — claude's native picker rejects
23677
+ // unknown sr-* ids, so an in-place inject can't set them. Carry the token
23678
+ // across a graceful restart (the `.session-model-override` carrier) and
23679
+ // relaunch `claude --model sr-*`. Session-only; reverts to the configured
23680
+ // default on the next restart. The sr-* → Claude direction is handled below
23681
+ // via the SELECT/alias outcome + isSrToClaudeTransition.
22170
23682
  if (data.startsWith(MODEL_CALLBACK_SR)) {
22171
- const srLabel = escapeHtmlForTg(srFriendlyLabel(data.slice(MODEL_CALLBACK_SR.length)))
23683
+ const srName = data.slice(MODEL_CALLBACK_SR.length)
23684
+ const srLabel = escapeHtmlForTg(srFriendlyLabel(srName))
23685
+ if (!isValidModelArg(srName)) {
23686
+ await ctx
23687
+ .editMessageText(richMessage('❌ Invalid model name'), { reply_markup: { inline_keyboard: [] } })
23688
+ .catch(() => {})
23689
+ return
23690
+ }
22172
23691
  await ctx
22173
- .editMessageText(richMessage(`⏳ Switching session to **${srLabel}**…`), { reply_markup: { inline_keyboard: [] } })
23692
+ .editMessageText(
23693
+ richMessage(`🔄 Switching session to **${srLabel}** — restarting (~30s). _Session-only; reverts to the configured default on the next restart._`),
23694
+ { reply_markup: { inline_keyboard: [] } },
23695
+ )
22174
23696
  .catch(() => {})
22175
- didInterimSrEdit = true
23697
+ try {
23698
+ await modelDeps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`)
23699
+ } catch (err) {
23700
+ await ctx
23701
+ .editMessageText(
23702
+ richMessage(`❌ Could not switch to **${srLabel}**: ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`),
23703
+ { reply_markup: { inline_keyboard: [] } },
23704
+ )
23705
+ .catch(() => {})
23706
+ }
23707
+ return
22176
23708
  }
23709
+ const didInterimSrEdit = false
22177
23710
  try {
22178
23711
  const prevSessionModel = activeSessionModelOverride
22179
23712
  const outcome = await handleModelMenuCallback(data, modelDeps)
@@ -22439,6 +23972,15 @@ bot.on('callback_query:data', async ctx => {
22439
23972
  return
22440
23973
  }
22441
23974
 
23975
+ // hindsight Phase 5: agent-proposes → human-approves mental-model card.
23976
+ // mmp:approve:<stageId> — declare the model (append memory.mental_models[]
23977
+ // via operator-approved config edit) + ensure it
23978
+ // mmp:deny:<stageId> — drop the proposal; NOTHING is written
23979
+ if (data.startsWith('mmp:')) {
23980
+ await handleMentalModelProposeCallback(ctx, data)
23981
+ return
23982
+ }
23983
+
22442
23984
  // #2670: one-tap skill-improvement proposal card.
22443
23985
  // skprop:approve:<id> — apply the stored draft via the personal-skill
22444
23986
  // write pipeline (inject a turn; agent writes it)
@@ -22449,6 +23991,15 @@ bot.on('callback_query:data', async ctx => {
22449
23991
  return
22450
23992
  }
22451
23993
 
23994
+ // #2862: missed-approvals re-offer digest.
23995
+ // missre:retry:<id> — inject a synthetic inbound asking the agent to
23996
+ // re-attempt (re-raises a fresh approval card)
23997
+ // missre:dismiss:<id> — clear the record + edit the card closed
23998
+ if (data.startsWith('missre:')) {
23999
+ await handleMissedApprovalCallback(ctx, data)
24000
+ return
24001
+ }
24002
+
22452
24003
  // Issue #969 P2b: vault recent-denial one-tap approval.
22453
24004
  // vrd:<agent>:<key> — mint a 30-day read-grant for the agent + key
22454
24005
  // Posted by /vault audit <agent> in the "Recent denials" section.
@@ -23095,6 +24646,8 @@ bot.on('callback_query:data', async ctx => {
23095
24646
  // Operator tapped a verdict ⇒ they are present; reset no-repeat suppression
23096
24647
  // so a later identical ask is shown fresh rather than silently short-circuited.
23097
24648
  clearPermissionTimeoutSuppression('operator answered a permission card')
24649
+ // #2862 — operator is present; re-offer any approvals that timed out meanwhile.
24650
+ maybePostMissedApprovalDigest('operator answered a permission card')
23098
24651
  const pd = pendingPermissions.get(request_id)
23099
24652
  const resumeAction = pd ? naturalAction(pd.tool_name, pd.input_preview) : ''
23100
24653
  const scopedTtl = scopedApprovalTtlMs()
@@ -23106,6 +24659,9 @@ bot.on('callback_query:data', async ctx => {
23106
24659
  permCardStore.remove(request_id)
23107
24660
  if (timeBox && grantAgent) {
23108
24661
  recordScopedGrant(scopedGrants, grantAgent, timeBox.rule, Date.now(), scopedTtl)
24662
+ // Write-through so the window survives a gateway restart (#2863). Absolute
24663
+ // expiry is baked into the entry, so a reload can't extend it.
24664
+ scopedGrantStore.save(scopedGrants)
23109
24665
  process.stderr.write(
23110
24666
  `telegram gateway: scoped-approval granted via Allow rule="${timeBox.rule}" ` +
23111
24667
  `agent=${grantAgent} ttl_ms=${scopedTtl} (request_id=${request_id})\n`,
@@ -24874,39 +26430,68 @@ void (async () => {
24874
26430
  // tracks the live turn from there.
24875
26431
  try { removeTurnActiveMarker(STATE_DIR) } catch { /* best-effort */ }
24876
26432
 
24877
- // Strip stale permission cards from prior gateway session. Any entry
24878
- // still in the store was never resolved (gateway died before the
24879
- // operator tapped or the reaper ran). The operator might have seen
24880
- // those cards and tapped them — if so, they got STALE_TAP_NOTICE
24881
- // ("already resolved") which is misleading. Edit the messages to
24882
- // remove the keyboard and show a clear "restarted" notice instead.
24883
- void (async () => {
24884
- const stale = permCardStore.loadAll()
24885
- if (stale.length === 0) return
24886
- process.stderr.write(
24887
- `telegram gateway: boot-sweep: stripping ${stale.length} stale permission card(s) from prior gateway session\n`,
24888
- )
24889
- for (const card of stale) {
24890
- const toolLabel = card.toolName ?? 'unknown tool'
24891
- const notice = `🔒 **${toolLabel}**\n\n⚠️ *Gateway restarted — this request is no longer active. Ask your agent to try again if needed.*`
24892
- try {
24893
- // allow-raw-bot-api: targeted by message_id; no thread needed; fire-and-forget boot sweep
24894
- await bot.api.editMessageText(
24895
- card.chatId,
24896
- card.messageId,
24897
- richMessage(notice),
24898
- { reply_markup: { inline_keyboard: [] } },
24899
- )
24900
- } catch (err) {
24901
- // Card may already be deleted, edited, or in an inaccessible chat — benign
24902
- process.stderr.write(
24903
- `telegram gateway: boot-sweep: stale-card strip failed ` +
24904
- `${card.chatId}:${card.messageId}: ${(err as Error).message}\n`,
24905
- )
24906
- }
26433
+ // Boot-sweep for permission cards from a prior gateway session. Any
26434
+ // entry still in the store was never resolved (gateway died before the
26435
+ // operator tapped or the reaper ran).
26436
+ //
26437
+ // #2861: DON'T strip immediately. A still-live claude session's bridge
26438
+ // re-sends its outstanding permission_requests on IPC reconnect within
26439
+ // a few seconds — re-arming the persisted card instead of losing the
26440
+ // suspended turn. So we wait a grace window (~90 s) and only strip
26441
+ // entries that were NOT re-claimed by a bridge re-send in the meantime.
26442
+ // A genuinely dead session (container recreate → new bridge, empty
26443
+ // ledger) never re-sends, so those still get the "restarted" notice.
26444
+ //
26445
+ // Kill switch SWITCHROOM_PERMISSION_REARM=0 reverts to the legacy
26446
+ // immediate strip-everything sweep.
26447
+ if (!isPermissionRearmEnabled()) {
26448
+ void (async () => {
26449
+ const stale = permCardStore.loadAll()
26450
+ if (stale.length === 0) return
26451
+ process.stderr.write(
26452
+ `telegram gateway: boot-sweep: stripping ${stale.length} stale permission card(s) from prior gateway session\n`,
26453
+ )
26454
+ for (const card of stale) await stripStalePermissionCard(card)
26455
+ permCardStore.clear()
26456
+ })()
26457
+ } else {
26458
+ const bootStale = permCardStore.loadAll()
26459
+ if (bootStale.length > 0) {
26460
+ const graceMs = permissionRearmGraceMs()
26461
+ process.stderr.write(
26462
+ `telegram gateway: boot-sweep: ${bootStale.length} pending permission card(s) from prior ` +
26463
+ `session; deferring strip ${graceMs}ms to allow bridge re-arm (#2861)\n`,
26464
+ )
26465
+ setTimeout(() => {
26466
+ void (async () => {
26467
+ // Reload — some entries may have been resolved (tap/TTL) during
26468
+ // the grace window, which removes them from the store.
26469
+ const remaining = permCardStore.loadAll()
26470
+ // Preserve anything currently LIVE in pendingPermissions: that
26471
+ // covers both re-armed cards (a bridge re-send restored them)
26472
+ // AND fresh cards born during the grace window (a new approval
26473
+ // posted after boot). Only cards with no live pending entry —
26474
+ // a genuinely dead session that never reconnected — are stripped.
26475
+ const liveRequestIds = new Set(pendingPermissions.keys())
26476
+ const toStrip = computeBootSweepStripTargets(remaining, liveRequestIds)
26477
+ if (toStrip.length === 0) {
26478
+ process.stderr.write(
26479
+ `telegram gateway: boot-sweep: all pending permission cards re-armed or still live — nothing to strip\n`,
26480
+ )
26481
+ return
26482
+ }
26483
+ process.stderr.write(
26484
+ `telegram gateway: boot-sweep: stripping ${toStrip.length} dead-session permission card(s) ` +
26485
+ `after ${graceMs}ms grace (of ${remaining.length} still persisted, ${liveRequestIds.size} live)\n`,
26486
+ )
26487
+ for (const card of toStrip) await stripStalePermissionCard(card)
26488
+ // Remove ONLY the stripped request_ids — re-armed entries stay
26489
+ // persisted so a subsequent restart re-arms them again.
26490
+ for (const id of distinctRequestIds(toStrip)) permCardStore.remove(id)
26491
+ })()
26492
+ }, graceMs).unref?.()
24907
26493
  }
24908
- permCardStore.clear()
24909
- })()
26494
+ }
24910
26495
 
24911
26496
  // Boot-time pin sweep
24912
26497
  try {
@@ -25105,6 +26690,73 @@ void (async () => {
25105
26690
  }
25106
26691
  } catch {}
25107
26692
 
26693
+ // ─── Session-model re-hydration + LiteLLM-down alert (session relaunch) ───
26694
+ //
26695
+ // start.sh writes the EFFECTIVE launched model to `.active-session-model`
26696
+ // on every boot (the model actually passed to `claude --model`). Re-hydrate
26697
+ // the in-memory session-model override from it so `/status` and the welcome
26698
+ // card stay honest after a session-relaunch restart. Only treat it as an
26699
+ // override when it differs from the configured/default model — a plain boot
26700
+ // on the configured model leaves the override null.
26701
+ //
26702
+ // Also consume the `.session-model-alert` sentinel: start.sh drops it when
26703
+ // it had to DROP an sr-* override because LiteLLM was unreachable at boot
26704
+ // (booting on the configured default instead of 4xx-ing against Anthropic).
26705
+ // We turn it into a loud Telegram message to the operator, then delete it.
26706
+ try {
26707
+ const smAgentDir = resolveAgentDirFromEnv()
26708
+ if (smAgentDir) {
26709
+ const activePath = join(smAgentDir, '.active-session-model')
26710
+ if (existsSync(activePath)) {
26711
+ try {
26712
+ const launched = readFileSync(activePath, 'utf8').trim()
26713
+ const configured = (() => {
26714
+ type AgentListResp = { agents: Array<{ name: string; model?: string | null }> }
26715
+ const d = switchroomExecJson<AgentListResp>(['agent', 'list'])
26716
+ const raw = d?.agents?.find(a => a.name === getMyAgentName())?.model ?? null
26717
+ // Resolve through the SAME resolver start.sh's scaffold uses, so an
26718
+ // unset (`null`) or `model: "default"` config value maps to the
26719
+ // switchroom default model id — matching the EFFECTIVE model start.sh
26720
+ // wrote to `.active-session-model`. Comparing the raw (unresolved)
26721
+ // value would flag every ordinary restart of a default-model agent as
26722
+ // a phantom session override.
26723
+ return resolveMainModel(raw ?? undefined)
26724
+ })()
26725
+ activeSessionModelOverride =
26726
+ launched.length > 0 && launched !== configured ? launched : null
26727
+ } catch { /* leave override as-is on a bad read */ }
26728
+ }
26729
+
26730
+ const alertPath = join(smAgentDir, '.session-model-alert')
26731
+ if (existsSync(alertPath)) {
26732
+ let alertText: string | null = null
26733
+ try {
26734
+ alertText = readFileSync(alertPath, 'utf8').trim()
26735
+ } catch { alertText = null }
26736
+ try { unlinkSync(alertPath) } catch { /* best-effort */ }
26737
+ if (alertText && alertText.length > 0) {
26738
+ // Notify EVERY operator, not just allowFrom[0]. Each send is wrapped
26739
+ // in its own catch so one operator's failure (blocked bot, bad chat
26740
+ // id) never stops the rest, and the outer boot flow never crashes.
26741
+ const operators = loadAccess().allowFrom
26742
+ for (const operator of operators) {
26743
+ if (!operator) continue
26744
+ void lockedBot.api
26745
+ .sendMessage(operator, `⚠️ ${alertText}`)
26746
+ .catch((err: unknown) =>
26747
+ process.stderr.write(
26748
+ `telegram gateway: session-model alert send failed for ${operator}: ${(err as Error)?.message ?? String(err)}\n`,
26749
+ ),
26750
+ )
26751
+ }
26752
+ process.stderr.write(`telegram gateway: session-model: LiteLLM-down override drop — ${alertText}\n`)
26753
+ }
26754
+ }
26755
+ }
26756
+ } catch (err) {
26757
+ process.stderr.write(`telegram gateway: session-model re-hydration failed: ${(err as Error)?.message ?? String(err)}\n`)
26758
+ }
26759
+
25108
26760
  // Credit-exhaustion watcher (#348). Reads `<agentDir>/.claude/.claude.json`
25109
26761
  // for `cachedExtraUsageDisabledReason`. Fires a Telegram notification
25110
26762
  // on transition into / out of fatal billing states (out_of_credits,
@@ -25300,6 +26952,43 @@ void (async () => {
25300
26952
  // accident no longer pollute the watcher with phantom
25301
26953
  // registrations + ENOENT log spam + false stalls.
25302
26954
  agentCwd: watcherAgentDir,
26955
+ // Gap 2 (deterministic-turn-liveness.md "Known gaps"): a
26956
+ // sub-agent dispatched into a `switchroom worktree claim`
26957
+ // cwd runs under a different project-dir slug than
26958
+ // `agentCwd` above, so the #1116 foreign-slug filter would
26959
+ // otherwise skip it forever — no activity stamp, no `🛠
26960
+ // Worker` feed for the entire run. Re-derive, fresh on every
26961
+ // rescan tick, the set of worktree paths this agent itself
26962
+ // currently owns (registry records are the deterministic
26963
+ // source of truth for "cwds this agent's sub-agents may run
26964
+ // in") and let the watcher also watch those slugs.
26965
+ // Best-effort: a registry read failure (e.g. no worktree dir
26966
+ // on an agent that never claims one) must not affect the
26967
+ // primary agentCwd watch.
26968
+ extraWatchCwdsProvider: () =>
26969
+ // Fail-CLOSED ownership filter (unset identity ⇒ nothing;
26970
+ // ownerless records excluded; registry throw ⇒ []). Extracted
26971
+ // to telegram-plugin/worktree-watch-cwds.ts so the #1116 /
26972
+ // Gap-2 ownership predicate is under direct unit test — see
26973
+ // telegram-plugin/tests/worktree-watch-cwds.test.ts.
26974
+ ownedWorktreeCwds({
26975
+ self: process.env.SWITCHROOM_AGENT_NAME,
26976
+ listRecords: listWorktreeRecords,
26977
+ // Durable, non-env identity fallback (#1116 / #2893): when
26978
+ // SWITCHROOM_AGENT_NAME is somehow unset, derive this
26979
+ // agent's own identity from its own directory so worktree
26980
+ // ownership still resolves (env is only the fast path).
26981
+ // `watcherAgentDir` is guaranteed non-null in this branch
26982
+ // (the whole watcher is gated on it above). Kill-switch
26983
+ // SWITCHROOM_WORKTREE_IDENTITY_FALLBACK=0 restores the
26984
+ // pre-fix env-only behaviour.
26985
+ agentDir:
26986
+ process.env.SWITCHROOM_WORKTREE_IDENTITY_FALLBACK === '0'
26987
+ ? undefined
26988
+ : watcherAgentDir,
26989
+ log: (msg) =>
26990
+ process.stderr.write(`telegram gateway: ${msg}\n`),
26991
+ }),
25303
26992
  // Bug 0 fix: previously omitted, leaving the watcher unable to
25304
26993
  // write liveness/stall/turn_end updates to the registry DB.
25305
26994
  // Liveness writes are now persisted across the gateway lifetime.