switchroom 0.17.6 → 0.17.10

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 (155) hide show
  1. package/dist/agent-scheduler/index.js +38 -4
  2. package/dist/auth-broker/index.js +302 -203
  3. package/dist/cli/notion-write-pretool.mjs +35 -2
  4. package/dist/cli/switchroom.js +1178 -576
  5. package/dist/host-control/main.js +148 -14
  6. package/dist/vault/approvals/kernel-server.js +140 -55
  7. package/dist/vault/broker/server.js +142 -57
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +50 -6
  10. package/profiles/default/CLAUDE.md +116 -0
  11. package/skills/mental-model-curator/SKILL.md +162 -0
  12. package/telegram-plugin/bridge/bridge.ts +80 -1
  13. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  14. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  15. package/telegram-plugin/consolidation-legibility.ts +279 -0
  16. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  17. package/telegram-plugin/dist/gateway/gateway.js +2565 -610
  18. package/telegram-plugin/dist/server.js +86 -2
  19. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  20. package/telegram-plugin/gateway/activity-card-store.ts +293 -0
  21. package/telegram-plugin/gateway/gateway.ts +1376 -82
  22. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  23. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  24. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  25. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  26. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  27. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  28. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  29. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  30. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  31. package/telegram-plugin/memory-legibility.ts +217 -0
  32. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  33. package/telegram-plugin/scoped-approval.ts +59 -0
  34. package/telegram-plugin/silent-end.ts +78 -0
  35. package/telegram-plugin/subagent-watcher.ts +60 -6
  36. package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
  37. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  38. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  39. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  40. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
  41. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  42. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  43. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  44. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  45. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  46. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  47. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  48. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  49. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  50. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  51. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  52. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  53. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  54. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  55. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  56. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  57. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  58. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  59. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  60. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
  61. package/telegram-plugin/uat/assertions.ts +88 -4
  62. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  63. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  64. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  65. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  66. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  67. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  68. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  69. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  70. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  71. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  72. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  73. package/telegram-plugin/worktree-watch-cwds.ts +60 -0
  74. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  75. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  76. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  77. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  78. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  79. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  80. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  81. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  82. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  84. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
  94. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  95. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  96. package/vendor/hindsight-memory/scripts/recall.py +153 -4
  97. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  98. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  99. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  126. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  127. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  128. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  129. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  130. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  131. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
@@ -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'
@@ -392,6 +415,13 @@ import {
392
415
  type StatusPinPersistOp,
393
416
  type TrackedStatusPin,
394
417
  } from './status-pin-store.js'
418
+ import {
419
+ writeActivityCardRecord,
420
+ clearActivityCardRecord,
421
+ runActivityCardBootReaper,
422
+ restartOrphanCardFinalizeText,
423
+ type ActivityCardStoreFsSeam,
424
+ } from './activity-card-store.js'
395
425
  import { driveEscalation } from './escalation-drive.js'
396
426
  import { shouldSuppressRepresent } from './represent-guard.js'
397
427
  import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
@@ -448,6 +478,12 @@ import {
448
478
  buildVaultSaveFailedInbound,
449
479
  buildVaultSaveDiscardedInbound,
450
480
  } from './vault-grant-inbound-builders.js'
481
+ import { renderMentalModelProposeCard } from './mental-model-propose-card.js'
482
+ import {
483
+ resolveMentalModelProposal,
484
+ type MentalModelPendingProposal,
485
+ } from './mental-model-propose-resolve.js'
486
+ import { readDeclaredMentalModelNames } from './mental-model-propose-diff.js'
451
487
  import {
452
488
  parseSkillProposalCallback,
453
489
  buildSkillProposalApplyInbound,
@@ -531,6 +567,8 @@ import {
531
567
  startSubagentWatcher,
532
568
  type SubagentWatcherHandle,
533
569
  } from '../subagent-watcher.js'
570
+ import { listRecords as listWorktreeRecords } from '../../src/worktree/registry.js'
571
+ import { ownedWorktreeCwds } from '../worktree-watch-cwds.js'
534
572
  import {
535
573
  startBootCard,
536
574
  resolvePersonaName,
@@ -551,7 +589,9 @@ import {
551
589
  recordScopedGrant,
552
590
  lookupScopedGrant,
553
591
  sweepScopedGrants,
592
+ countScopedGrants,
554
593
  } from '../scoped-approval.js'
594
+ import { createScopedGrantStore } from './scoped-grant-store.js'
555
595
  import { grantRestartDecision, type GrantRestartDecision } from './grant-restart.js'
556
596
  import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
557
597
  import {
@@ -666,6 +706,12 @@ process.on('beforeExit', () => {
666
706
  // ─── Env + state dir ──────────────────────────────────────────────────────
667
707
  const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
668
708
  const permCardStore = createPermissionCardStore(STATE_DIR)
709
+ // #2862 — missed-approvals re-offer. Persisted list of approvals that
710
+ // TTL-expired while the operator was away; a digest card is posted on the
711
+ // operator's next activity. Kill switch: SWITCHROOM_MISSED_APPROVAL_REOFFER=0.
712
+ const missedApprovalsStore = createMissedApprovalsStore(STATE_DIR)
713
+ const MISSED_APPROVAL_REOFFER_ENABLED =
714
+ process.env.SWITCHROOM_MISSED_APPROVAL_REOFFER !== '0'
669
715
  const ACCESS_FILE = join(STATE_DIR, 'access.json')
670
716
  const APPROVED_DIR = join(STATE_DIR, 'approved')
671
717
  const ENV_FILE = join(STATE_DIR, '.env')
@@ -4579,6 +4625,131 @@ function clearPermissionTimeoutSuppression(reason: string): void {
4579
4625
  `telegram gateway: permission no-repeat suppression cleared (${n} sig(s)) — ${reason}\n`,
4580
4626
  )
4581
4627
  }
4628
+
4629
+ // #2862 — missed-approvals re-offer digest. Called from every operator-activity
4630
+ // path that clears no-repeat suppression (inbound, /approve|/deny, card verdict):
4631
+ // on the FIRST activity after ≄1 approval TTL-expired while the operator was
4632
+ // away, post ONE compact digest card per origin surface listing the misses,
4633
+ // then promote those pending entries into a delivered-digest record (the posted
4634
+ // card is now the durable record in chat). Best-effort + fire-and-forget: never
4635
+ // blocks or throws into the hot inbound/callback path. Kill switch (=0) makes
4636
+ // this a no-op AND the auto-deny append below a no-op, so the whole feature is off.
4637
+ function maybePostMissedApprovalDigest(reason: string): void {
4638
+ if (!MISSED_APPROVAL_REOFFER_ENABLED) return
4639
+ const pending = missedApprovalsStore.listPending()
4640
+ if (pending.length === 0) return
4641
+ // Group by origin surface so each card lands where its cards were posted.
4642
+ const groups = new Map<string, MissedApproval[]>()
4643
+ for (const e of pending) {
4644
+ const key = `${e.chatId}::${e.threadId ?? ''}`
4645
+ const g = groups.get(key)
4646
+ if (g) g.push(e)
4647
+ else groups.set(key, [e])
4648
+ }
4649
+ for (const entries of groups.values()) {
4650
+ const first = entries[0]
4651
+ const chatId = first.chatId
4652
+ const threadId = first.threadId ?? undefined
4653
+ const digestId = randomBytes(4).toString('hex')
4654
+ const text = renderMissedApprovalsDigest(entries, {
4655
+ agentName: process.env.SWITCHROOM_AGENT_NAME ?? null,
4656
+ })
4657
+ void swallowingApiCall(
4658
+ () =>
4659
+ // allow-raw-bot-api: routed through swallowingApiCall retry policy; thread-aware digest card
4660
+ bot.api.sendMessage(chatId, text, {
4661
+ parse_mode: 'HTML',
4662
+ reply_markup: missedApprovalsKeyboard(digestId),
4663
+ ...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
4664
+ }),
4665
+ { chat_id: chatId, verb: 'missed-approval-digest', ...(threadId != null ? { threadId } : {}) },
4666
+ )
4667
+ // Promote synchronously (before the async send resolves) so a second
4668
+ // operator-activity event racing in sees an empty pending list and can't
4669
+ // double-post — "one card per accumulated batch" holds.
4670
+ missedApprovalsStore.promoteToDigest({
4671
+ digestId,
4672
+ chatId,
4673
+ threadId: first.threadId ?? null,
4674
+ entries,
4675
+ deliveredAt: Date.now(),
4676
+ })
4677
+ }
4678
+ process.stderr.write(
4679
+ `telegram gateway: missed-approval digest posted (${pending.length} entr(y/ies), ` +
4680
+ `${groups.size} surface(s)) — ${reason}\n`,
4681
+ )
4682
+ }
4683
+
4684
+ // #2862 — handle a Retry / Dismiss tap on a missed-approvals digest card.
4685
+ // missre:retry:<id> — inject a synthetic inbound asking the agent to
4686
+ // re-attempt the actions (claude-native; re-raises a
4687
+ // fresh approval card). NEVER a verdict / re-execution.
4688
+ // missre:dismiss:<id> — clear the record + edit the card closed.
4689
+ async function handleMissedApprovalCallback(ctx: Context, data: string): Promise<void> {
4690
+ const senderId = String(ctx.from?.id ?? '')
4691
+ const access = loadAccess()
4692
+ if (!access.allowFrom.includes(senderId)) {
4693
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
4694
+ return
4695
+ }
4696
+ const parsed = parseMissedApprovalCallback(data)
4697
+ if (parsed == null) {
4698
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
4699
+ return
4700
+ }
4701
+ const digest = missedApprovalsStore.getDigest(parsed.digestId)
4702
+ if (digest == null) {
4703
+ await ctx.answerCallbackQuery({ text: 'This digest already actioned or expired.' }).catch(() => {})
4704
+ if (ctx.callbackQuery?.message) {
4705
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
4706
+ }
4707
+ return
4708
+ }
4709
+
4710
+ if (parsed.action === 'dismiss') {
4711
+ missedApprovalsStore.removeDigest(parsed.digestId)
4712
+ await ctx.answerCallbackQuery({ text: '🚫 Dismissed — cleared.' }).catch(() => {})
4713
+ if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
4714
+ await ctx
4715
+ .editMessageText(
4716
+ `${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n🚫 <i>Dismissed.</i>`,
4717
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
4718
+ )
4719
+ .catch(() => {})
4720
+ }
4721
+ return
4722
+ }
4723
+
4724
+ // Retry — consume the record, then inject a synthetic inbound to the ORIGIN
4725
+ // surface asking the agent to re-attempt. This rides the same synthesized-
4726
+ // inbound path cron uses; the agent naturally re-raises a fresh approval card.
4727
+ missedApprovalsStore.removeDigest(parsed.digestId)
4728
+ const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
4729
+ const synthetic = buildMissedApprovalRetryInbound({
4730
+ ctx: {
4731
+ agent,
4732
+ chat_id: digest.chatId,
4733
+ ...(digest.threadId != null ? { threadId: digest.threadId } : {}),
4734
+ },
4735
+ actions: digest.entries.map(e => e.action),
4736
+ operatorId: senderId,
4737
+ })
4738
+ const delivered = deliverResumeSyntheticOrBuffer(agent, synthetic)
4739
+ await ctx.answerCallbackQuery({ text: 'šŸ” Asking the agent to retry…' }).catch(() => {})
4740
+ if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
4741
+ await ctx
4742
+ .editMessageText(
4743
+ `${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\nšŸ” <i>Retrying — asked the agent.</i>`,
4744
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
4745
+ )
4746
+ .catch(() => {})
4747
+ }
4748
+ process.stderr.write(
4749
+ `telegram gateway: missed-approval retry injection agent=${agent} ` +
4750
+ `digest=${parsed.digestId} actions=${digest.entries.length} delivered=${delivered}\n`,
4751
+ )
4752
+ }
4582
4753
  // Permission/approval-card origin recovery (marko Rentals-budget, 2026-06-17).
4583
4754
  // When `currentTurn` was force-closed by the orphaned-reply backstop but the
4584
4755
  // claude session kept running into a permission-gated tool, recover the card's
@@ -4612,13 +4783,54 @@ function sweepStaleAlwaysAllowCorrelations(now = Date.now()): void {
4612
4783
  }
4613
4784
  }
4614
4785
 
4786
+ // Sibling of pendingAlwaysAllowCorrelations for the agent-proposes →
4787
+ // human-approves MENTAL MODEL flow (hindsight Phase 5). When the operator
4788
+ // taps Approve on a mental-model PROPOSAL card, the gateway dispatches a
4789
+ // `config_propose_edit` appending the model to memory.mental_models[]; hostd
4790
+ // then calls back for operator approval. We pre-register the exact diff here
4791
+ // so that callback auto-approves WITHOUT a second card (the operator already
4792
+ // approved on the proposal card). Forge-resistance: the auto-resolve match is
4793
+ // an EXACT byte-match of the inbound diff against the diff the gateway itself
4794
+ // synthesized and queued — an agent-forged edit finds no entry and falls
4795
+ // through to a real operator card. Single-shot + a dedicated TTL sweep.
4796
+ //
4797
+ // TTL: this correlation must outlive the WHOLE config-edit approval budget,
4798
+ // NOT the 30s "always allow" window. The mental-model resolve dispatches
4799
+ // `config_propose_edit` to hostd with a 720s timeout (see the 720_000 dispatch
4800
+ // budgets below); hostd's approval callback can legitimately arrive any time
4801
+ // within that window if the operator taps slowly. Reusing the 30s
4802
+ // ALWAYS_ALLOW_CORRELATION_TTL_MS would sweep the correlation out from under a
4803
+ // slow-but-valid tap, dropping the auto-approve and surfacing a SECOND card
4804
+ // for an edit the operator already approved. Size it to the hostd budget.
4805
+ const MENTAL_MODEL_CORRELATION_TTL_MS = 720_000
4806
+ const pendingMentalModelCorrelations = new Map<string, { agentName: string; unifiedDiff: string; createdAt: number }>()
4807
+ function mentalModelCorrelationKey(agentName: string, unifiedDiff: string): string {
4808
+ return `${agentName}::${createHash('sha256').update(unifiedDiff).digest('hex')}`
4809
+ }
4810
+ function sweepStaleMentalModelCorrelations(now = Date.now()): void {
4811
+ for (const [key, entry] of pendingMentalModelCorrelations) {
4812
+ if (now - entry.createdAt > MENTAL_MODEL_CORRELATION_TTL_MS) {
4813
+ pendingMentalModelCorrelations.delete(key)
4814
+ }
4815
+ }
4816
+ }
4817
+
4615
4818
  // Scoped-approval store: the 30-min window that backs the "āœ… Allow" tap for
4616
4819
  // narrow non-destructive scopes (not a separate button — it IS what Allow
4617
4820
  // means for those). Operator-tapped, gateway-side ONLY (never pushed to the
4618
4821
  // bridge's untimed sessionAllowRules), fixed-window, fail-closed. Keyed by
4619
4822
  // agent name for per-agent isolation. All policy lives in
4620
4823
  // ../scoped-approval.ts (pure + unit-tested); this gateway only wires it.
4621
- const scopedGrants: ScopedGrantStore = new Map()
4824
+ //
4825
+ // Persisted across gateway restarts (#2863): a bounce (fleet roll / config
4826
+ // apply / failover probe) used to wipe every window, re-carding an action the
4827
+ // operator allowed minutes ago. The store mirrors to a tiny STATE_DIR JSON
4828
+ // file; reload drops entries already past their ABSOLUTE expiry (a restart
4829
+ // never extends a window). Kill switch SWITCHROOM_SCOPED_GRANT_PERSIST=0 boots
4830
+ // empty and never writes. Fail-closed lookup semantics are unchanged — a
4831
+ // reloaded grant runs the same lookupScopedGrant gate as an in-memory one.
4832
+ const scopedGrantStore = createScopedGrantStore(STATE_DIR)
4833
+ const scopedGrants: ScopedGrantStore = scopedGrantStore.load(Date.now())
4622
4834
  const selfAgentName = (): string => process.env.SWITCHROOM_AGENT_NAME ?? ''
4623
4835
 
4624
4836
  // `ask_user` MCP tool — open prompts awaiting a user button-tap.
@@ -4944,6 +5156,81 @@ function sweepPendingVaultRequestAccesses(): void {
4944
5156
  }
4945
5157
  }
4946
5158
 
5159
+ /**
5160
+ * Staged agent-initiated MENTAL MODEL proposal (hindsight Phase 5). The agent
5161
+ * calls `mental_model_propose`; the operator taps Approve/Deny on the card.
5162
+ * Mirrors PendingVaultRequestAccess — no memory content is staged here, only
5163
+ * the proposed DECLARATION (name + source_query + optional knobs). On Approve
5164
+ * the model becomes a first-class declared model in memory.mental_models[] via
5165
+ * the operator-approved config-edit path; on Deny nothing is written.
5166
+ */
5167
+ interface PendingMentalModelPropose {
5168
+ agent: string
5169
+ chat_id: string
5170
+ card_message_id?: number
5171
+ threadId?: number
5172
+ /** Proposed declaration, snake_case (matches memory.mental_models[] schema). */
5173
+ spec: {
5174
+ name: string
5175
+ source_query: string
5176
+ refresh_after_consolidation?: boolean
5177
+ max_tokens?: number
5178
+ }
5179
+ reason?: string
5180
+ staged_at: number
5181
+ }
5182
+ const pendingMentalModelProposes = new Map<string, PendingMentalModelPropose>()
5183
+ const MENTAL_MODEL_PROPOSE_TTL_MS = approvalTtlMs()
5184
+ // Sweep expired pending proposals. For any entry past its TTL we ALSO edit the
5185
+ // posted card's keyboard away, so a stale card left in the chat can't be tapped
5186
+ // into a "Card expired" answer — the operator sees the āŒ› expiry inline instead.
5187
+ // Best-effort: card edits are fire-and-forget (the entry is removed regardless).
5188
+ function sweepPendingMentalModelProposes(): void {
5189
+ const cutoff = Date.now() - MENTAL_MODEL_PROPOSE_TTL_MS
5190
+ for (const [k, v] of pendingMentalModelProposes) {
5191
+ if (v.staged_at < cutoff) {
5192
+ pendingMentalModelProposes.delete(k)
5193
+ if (v.card_message_id != null) {
5194
+ void lockedBot.api
5195
+ .editMessageText(
5196
+ v.chat_id,
5197
+ v.card_message_id,
5198
+ richMessage('āŒ› _This mental-model proposal card expired. Ask the agent to re-propose if it still stands._'),
5199
+ { reply_markup: { inline_keyboard: [] } },
5200
+ )
5201
+ .catch(() => {})
5202
+ }
5203
+ }
5204
+ }
5205
+ }
5206
+
5207
+ // Sliding-window rate limit for mental-model proposals: at most
5208
+ // MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW cards per MENTAL_MODEL_PROPOSE_WINDOW_MS.
5209
+ // The window is per-gateway-process, and since each agent runs its own gateway
5210
+ // process (keyed by $SWITCHROOM_AGENT_NAME), one process == one agent — so this
5211
+ // throttle is effectively per-agent. A candidate model is a deliberate, rare
5212
+ // curation act — an agent that re-proposes in a loop should be throttled so the
5213
+ // operator is never spammed.
5214
+ const mentalModelProposeTimes: number[] = []
5215
+ const MENTAL_MODEL_PROPOSE_WINDOW_MS = 60 * 60 * 1000
5216
+ const MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW = 5
5217
+ // Mirror the memory.mental_models[] schema caps (src/config/schema.ts): a
5218
+ // source_query capped at 2000 chars and max_tokens capped at 8192. Enforced
5219
+ // up-front in executeMentalModelPropose so a proposal that would fail hostd
5220
+ // config validation never reaches an approval card.
5221
+ const MENTAL_MODEL_SOURCE_QUERY_MAX = 2000
5222
+ const MENTAL_MODEL_MAX_TOKENS_CAP = 8192
5223
+ function checkMentalModelProposeRate(now = Date.now()): { ok: true } | { ok: false; retryAtMs: number } {
5224
+ const cutoff = now - MENTAL_MODEL_PROPOSE_WINDOW_MS
5225
+ while (mentalModelProposeTimes.length > 0 && mentalModelProposeTimes[0]! < cutoff) {
5226
+ mentalModelProposeTimes.shift()
5227
+ }
5228
+ if (mentalModelProposeTimes.length >= MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW) {
5229
+ return { ok: false, retryAtMs: mentalModelProposeTimes[0]! + MENTAL_MODEL_PROPOSE_WINDOW_MS }
5230
+ }
5231
+ return { ok: true }
5232
+ }
5233
+
4947
5234
  /**
4948
5235
  * Mint an approval-kernel decision row for a deferred-secret card
4949
5236
  * (MIGRATION.md §1). Best-effort: if the kernel/broker is unreachable, we
@@ -5238,6 +5525,23 @@ const pendingStateReaper = setInterval(() => {
5238
5525
  now,
5239
5526
  )
5240
5527
  }
5528
+ // #2862 — record the miss so it can be re-offered when the operator
5529
+ // returns. Anchor to the card's own origin surface (where the operator
5530
+ // would have tapped), so the digest lands in the same topic — not a
5531
+ // fanned-out DM. Skip if the card was never posted anywhere.
5532
+ if (MISSED_APPROVAL_REOFFER_ENABLED) {
5533
+ const origin = v.cards[0]
5534
+ if (origin != null) {
5535
+ missedApprovalsStore.add({
5536
+ requestId: k,
5537
+ toolName: v.tool_name,
5538
+ action: naturalAction(v.tool_name, v.input_preview),
5539
+ chatId: origin.chatId,
5540
+ threadId: origin.threadId ?? null,
5541
+ timedOutAt: now,
5542
+ })
5543
+ }
5544
+ }
5241
5545
  process.stderr.write(
5242
5546
  `telegram gateway: permission TTL expired — auto-deny request=${k} ` +
5243
5547
  `tool=${v.tool_name} (no operator response in ` +
@@ -5256,8 +5560,14 @@ const pendingStateReaper = setInterval(() => {
5256
5560
  if (now > v.expiresAt) vaultPassphraseCache.delete(k)
5257
5561
  }
5258
5562
  // Drop expired "ā± 30 min" scoped grants. (Lookup already fails closed on
5259
- // expiry; this just keeps the map from accumulating dead entries.)
5563
+ // expiry; this just keeps the map from accumulating dead entries.) Persist
5564
+ // the removal so a restart between sweeps can't resurrect a swept grant —
5565
+ // only write when the sweep actually changed something (sweeps only remove).
5566
+ const scopedGrantsBefore = countScopedGrants(scopedGrants)
5260
5567
  sweepScopedGrants(scopedGrants, now)
5568
+ if (countScopedGrants(scopedGrants) !== scopedGrantsBefore) {
5569
+ scopedGrantStore.save(scopedGrants)
5570
+ }
5261
5571
  for (const [k, v] of deferredSecrets) {
5262
5572
  if (now - v.staged_at > DEFERRED_SECRET_TTL_MS) deferredSecrets.delete(k)
5263
5573
  }
@@ -5754,6 +6064,22 @@ const statusPinStoreFs = {
5754
6064
  }
5755
6065
  const statusPinPersistEnabled = !STATIC && PIN_STATUS_WHILE_WORKING
5756
6066
 
6067
+ // Durable card-handle snapshot for the mid-turn activity card (Known Gap 1,
6068
+ // `reference/rfcs/deterministic-turn-liveness.md`). Persisted on card OPEN,
6069
+ // cleared on normal close/finalize; a boot-time reaper (wired alongside
6070
+ // `statusPinBootCleanup`, same startup-mutex ordering constraint) finalizes
6071
+ // any leftover record with one honest edit so a gateway restart mid-turn can
6072
+ // never leave the card frozen forever. STATIC mode skips disk (dry-run, no
6073
+ // durable volume) — same gate as the status-pin store.
6074
+ const ACTIVITY_CARD_STORE_PATH = join(STATE_DIR, 'activity-cards-pending.json')
6075
+ const activityCardStoreFs: ActivityCardStoreFsSeam = {
6076
+ readFileSync: (p: string) => readFileSync(p, 'utf8'),
6077
+ writeFileSync: (p: string, d: string) => writeFileSync(p, d),
6078
+ renameSync: (a: string, b: string) => renameSync(a, b),
6079
+ existsSync: (p: string) => existsSync(p),
6080
+ }
6081
+ const activityCardPersistEnabled = !STATIC
6082
+
5757
6083
  // Slot-banner pin persistence (#421 crash-recovery). The slot banner is pinned
5758
6084
  // in the owner chat when the agent is on a non-default OAuth slot. Rather than a
5759
6085
  // parallel store + second boot hook, its pin is persisted in the SAME
@@ -5835,6 +6161,75 @@ async function statusPinBootCleanup(): Promise<void> {
5835
6161
  )
5836
6162
  }
5837
6163
  }
6164
+ /**
6165
+ * Boot-time orphan-card reaper (Known Gap 1,
6166
+ * `reference/rfcs/deterministic-turn-liveness.md`). Thin gateway wrapper over
6167
+ * the pure `runActivityCardBootReaper` — binds the live fs seam, a real
6168
+ * Telegram edit, and the gateway logger. Finalizes ANY card left open by a
6169
+ * prior (crashed/restarted) session with ONE honest edit — never a new
6170
+ * message, so no ping. Guarantee is AT-MOST-ONCE: the pure routine deletes each
6171
+ * record before attempting its edit and does not retry, so a failed edit
6172
+ * forfeits that orphan rather than risk a double-finalize on a later boot (see
6173
+ * the module doc in activity-card-store.ts for the full tradeoff). A benign-400
6174
+ * (card already gone) is reported as `vanished`, not `finalized`.
6175
+ *
6176
+ * MUST run ONLY after this gateway wins the startup mutex (the store is a
6177
+ * shared per-agent file; a losing double-boot would finalize a card the
6178
+ * still-alive holder's in-flight turn still legitimately owns) — identical
6179
+ * ordering constraint to `statusPinBootCleanup`.
6180
+ *
6181
+ * Deliberately does NOT attempt to resume climbing the old card: the resumed
6182
+ * turn (if any) opens its own fresh card. Honest finalization, not
6183
+ * resumption, is the goal (see the module doc in activity-card-store.ts).
6184
+ *
6185
+ * After the finalizing edit, also unpins the card, but ONLY for a record whose
6186
+ * persisted `pinned` flag is true — i.e. the card was actually pin-eligible on
6187
+ * open (`PIN_STATUS_WHILE_WORKING`). This unpin is DEFENSE-IN-DEPTH, not the
6188
+ * primary unpin path: `statusPinBootCleanup` already unpins orphaned status
6189
+ * pins from its own durable store on boot. The reaper's unpin is belt-and-
6190
+ * braces for the case where the two stores disagree; the record's `pinned`
6191
+ * flag must therefore reflect the ACTUAL pin outcome (see the open path), not
6192
+ * an unconditional `true`, or the reaper would attempt an unpin on a card that
6193
+ * was never pinned.
6194
+ */
6195
+ async function activityCardBootReaper(): Promise<void> {
6196
+ if (!activityCardPersistEnabled) return
6197
+ const { finalized, vanished, unpinned, total } = await runActivityCardBootReaper({
6198
+ path: ACTIVITY_CARD_STORE_PATH,
6199
+ fs: activityCardStoreFs,
6200
+ finalizeCard: (record) =>
6201
+ robustApiCall(
6202
+ () =>
6203
+ lockedBot.api.editMessageText(
6204
+ record.chatId,
6205
+ record.activityMessageId,
6206
+ richMessage(restartOrphanCardFinalizeText(record.startedAt)),
6207
+ {},
6208
+ ),
6209
+ {
6210
+ chat_id: record.chatId,
6211
+ ...(record.threadId != null ? { threadId: record.threadId } : {}),
6212
+ verb: 'activity-card.boot-reap-finalize',
6213
+ },
6214
+ ),
6215
+ unpinCard: (record) =>
6216
+ robustApiCall(
6217
+ () => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
6218
+ {
6219
+ chat_id: record.chatId,
6220
+ ...(record.threadId != null ? { threadId: record.threadId } : {}),
6221
+ verb: 'activity-card.boot-reap-unpin',
6222
+ },
6223
+ ),
6224
+ })
6225
+ if (total > 0) {
6226
+ process.stderr.write(
6227
+ `telegram gateway: activity-card: finalized ${finalized}/${total} ` +
6228
+ `(vanished ${vanished}/${total}), unpinned ${unpinned}/${total} ` +
6229
+ `orphaned card(s) from a prior session (at-most-once)\n`,
6230
+ )
6231
+ }
6232
+ }
5838
6233
  // NOTE: statusPinBootCleanup() is deliberately NOT invoked here at import time.
5839
6234
  // The status-pin store is a SHARED per-agent file, and cleanup issues real
5840
6235
  // unpinChatMessage calls. On a double-boot the losing gateway must NOT touch
@@ -6140,6 +6535,7 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
6140
6535
  // LOSING double-boot never unpins the live holder's legitimate pins.
6141
6536
  // Fire-and-forget: cleanup is best-effort and must not block boot.
6142
6537
  void statusPinBootCleanup()
6538
+ void activityCardBootReaper()
6143
6539
  } catch (err) {
6144
6540
  process.stderr.write(
6145
6541
  `telegram gateway: boot.lock_acquire_failed err=${(err as Error).message} agent=${SWITCHROOM_AGENT_NAME}\n`,
@@ -6156,6 +6552,7 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
6156
6552
  // successful writePidFile here means no live holder was detected, so
6157
6553
  // running orphan cleanup is consistent with the pre-mutex behaviour.
6158
6554
  void statusPinBootCleanup()
6555
+ void activityCardBootReaper()
6159
6556
  } catch (writeErr) {
6160
6557
  process.stderr.write(`telegram gateway: writePidFile failed: ${writeErr}\n`)
6161
6558
  }
@@ -6201,11 +6598,15 @@ function parsePositiveMsEnv(name: string, fallbackMs: number): number {
6201
6598
  }
6202
6599
  const SILENCE_FALLBACK_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_MS', 300_000)
6203
6600
  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.
6601
+ // #2527 — mid-turn liveness floor threshold (default 45s). Still gates the
6602
+ // decision (`decideMidTurnFloor`, role + delivery + fire-once + timing) for a
6603
+ // `user` turn working silently this long without a substantive answer.
6604
+ // Phase 3 (deterministic-turn-liveness.md): the busy-but-silent case no
6605
+ // longer sends TEXT at this threshold — the climbing card (Phase 1) covers
6606
+ // it. The ONE surviving delivery gated by this threshold is the
6607
+ // approval-blocked re-ping (see `onMidTurnFloor` below). Strictly below
6608
+ // SILENCE_FALLBACK_MS (the loud 300s unwedge). Whole floor is
6609
+ // kill-switchable via SWITCHROOM_TG_LIVENESS_FLOOR=0.
6209
6610
  const SILENCE_FLOOR_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FLOOR_MS', 45_000)
6210
6611
  // #2527 — role-aware terminal reaction honesty (the "thumbs-up false done"
6211
6612
  // fix). Default ON; SWITCHROOM_TG_TERMINAL_HONESTY=0 reverts to always-šŸ‘.
@@ -6222,6 +6623,25 @@ const SILENCE_DEFER_INFLIGHT_TOOLS = process.env.SWITCHROOM_SILENCE_DEFER_INFLIG
6222
6623
  // and null currentTurn mid-work. Default ON; SWITCHROOM_SILENCE_LIVENESS_PRODUCTION=0
6223
6624
  // restores the legacy "only a real reply resets the clock" behaviour.
6224
6625
  const SILENCE_LIVENESS_PRODUCTION = process.env.SWITCHROOM_SILENCE_LIVENESS_PRODUCTION !== '0'
6626
+ // Sparse chat-legible memory (#2849, hindsight Phase 4). Surface ONE terse
6627
+ // line in the originating chat/topic when the interactive session materially
6628
+ // changes what it remembers (create_directive / invalidate / demote) — never
6629
+ // on ordinary recall or routine consolidation. Default ON;
6630
+ // SWITCHROOM_MEMORY_LEGIBILITY=0 disables it.
6631
+ const MEMORY_LEGIBILITY_ENABLED = isMemoryLegibilityEnabled(process.env.SWITCHROOM_MEMORY_LEGIBILITY)
6632
+ // Consolidation-driven legibility (#2849 follow-up, hindsight Phase 4). The
6633
+ // "updated what I know about Y" side, driven by the background
6634
+ // `consolidation.completed` webhook rather than a foreground tool call.
6635
+ // OPT-IN (default OFF), unlike MEMORY_LEGIBILITY_ENABLED above: this path is
6636
+ // fed by an unbounded background engine + a webhook the pinned hindsight image
6637
+ // does not yet emit, so the RFC keeps it "sparse … clearly gated".
6638
+ const CONSOLIDATION_LEGIBILITY_ENABLED = isConsolidationLegibilityEnabled(
6639
+ process.env.SWITCHROOM_CONSOLIDATION_LEGIBILITY,
6640
+ )
6641
+ // One long-lived limiter across all consolidation events — collapses a burst
6642
+ // of material consolidations to at most one line per interval, and suppresses
6643
+ // an identical "updated about X" line inside the dedup window.
6644
+ const consolidationRateLimiter = new ConsolidationRateLimiter()
6225
6645
 
6226
6646
  /**
6227
6647
  * Feed-survival predicate — the single source of truth for "is this turn
@@ -6290,10 +6710,19 @@ silencePoke.startTimer({
6290
6710
  if (statusKey(turn.sessionChatId, turn.sessionThreadId) !== key) return null
6291
6711
  return { role: turn.role, finalAnswerDelivered: turn.finalAnswerDelivered }
6292
6712
  },
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.
6713
+ // Phase 3 (reference/rfcs/deterministic-turn-liveness.md): the busy-but-
6714
+ // silent TEXT floor is retired. The climbing card (Phase 1 — the 0-label
6715
+ // branch of `feedHeartbeatTick` / `runSilentTurnHeartbeatTick` above) IS
6716
+ // the mid-turn floor now: it edits the already-open activity card's
6717
+ // elapsed clock every `FEED_HEARTBEAT_MIN_STALE_MS`, model-independent, no
6718
+ // ping. This handler still decides/fires via `decideMidTurnFloor` (the
6719
+ // timing + role + fire-once machinery in `silence-poke.ts` /
6720
+ // `turn-liveness-floor.ts` is unchanged and still load-bearing — see
6721
+ // below), but its ONLY surviving delivery is the approval-blocked re-ping.
6722
+ // Do not re-add a generic "still working…" text send here believing this
6723
+ // path covers the silent-tool case; it does not, and re-adding one would
6724
+ // reintroduce the exact banned cadence ping `conversational-pacing.md`
6725
+ // retired in #2667.
6297
6726
  onMidTurnFloor: async (ctx) => {
6298
6727
  // Late-fire guard, mirroring the fallback: a clean turn-end can race the
6299
6728
  // tick. If the turn is gone, stay silent.
@@ -6301,19 +6730,19 @@ silencePoke.startTimer({
6301
6730
  const blockedOnApproval = activeStatusReactions
6302
6731
  .get(statusKey(ctx.chatId, ctx.threadId))
6303
6732
  ?.isAwaiting() ?? false
6733
+ // The one surviving text case: the turn is parked on an approval card
6734
+ // waiting for YOUR tap. That's not a stall — it names the real blocker —
6735
+ // so it keeps this quiet re-ping (`conversational-pacing.md` § Silence-
6736
+ // poke fallback names this one of the two surviving honest cases). The
6737
+ // busy-but-silent (non-approval) case sends nothing here; the card climb
6738
+ // (Phase 1) is its only signal now.
6739
+ if (!blockedOnApproval) return
6304
6740
  const text = silencePoke.formatFrameworkFallbackText(
6305
6741
  'working',
6306
6742
  ctx.silenceMs,
6307
6743
  ctx.inFlightTools,
6308
6744
  blockedOnApproval,
6309
6745
  )
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
6746
  if (text == null) return
6318
6747
  try {
6319
6748
  await robustApiCall(
@@ -7283,6 +7712,81 @@ function dispatchPermissionVerdict(ev: PermissionEvent): void {
7283
7712
  }
7284
7713
  }
7285
7714
 
7715
+ /**
7716
+ * #2861 re-arm: a `permission_request` re-sent by the bridge after a gateway
7717
+ * restart, for a request_id that's NO LONGER in `pendingPermissions` but is
7718
+ * still in the persisted card store. Restore the in-memory pending entry with
7719
+ * the ORIGINAL `startedAt` (so the existing TTL clock keeps running from the
7720
+ * original ask — an already-expired re-arm drops straight into the TTL
7721
+ * auto-deny sweep, which correctly unwedges claude) and re-attach the inline
7722
+ * keyboard on the EXISTING message(s) via editMessageText — never a new card.
7723
+ *
7724
+ * NEVER answers the card: only the question is restored (no-self-escalation).
7725
+ */
7726
+ function rearmPermissionFromStore(
7727
+ msg: PermissionRequestForward,
7728
+ persisted: PersistedPermCard[],
7729
+ ): void {
7730
+ const { requestId, toolName, description, inputPreview } = msg
7731
+ // Original clock: take the earliest persisted startedAt (all entries for one
7732
+ // request share it, but be defensive).
7733
+ const startedAt = persisted.reduce(
7734
+ (min, c) => (c.startedAt < min ? c.startedAt : min),
7735
+ persisted[0].startedAt,
7736
+ )
7737
+ const cardText = persisted[0].cardText
7738
+ const cards = persisted.map(c => ({ chatId: c.chatId, messageId: c.messageId }))
7739
+ pendingPermissions.set(requestId, {
7740
+ tool_name: toolName,
7741
+ description,
7742
+ input_preview: inputPreview,
7743
+ startedAt,
7744
+ card_text: cardText,
7745
+ cards,
7746
+ })
7747
+ process.stderr.write(
7748
+ `telegram gateway: re-arming permission card(s) for request=${requestId} ` +
7749
+ `tool=${toolName} (${cards.length} card(s), original startedAt preserved)\n`,
7750
+ )
7751
+ // Re-attach the keyboard on each surviving card message. Message-id-targeted
7752
+ // edit — no thread needed, so THREAD_NOT_FOUND is not in the blast radius.
7753
+ const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null
7754
+ const keyboard = buildPermissionActionRow(requestId, showAlways)
7755
+ for (const c of cards) {
7756
+ void swallowingApiCall(
7757
+ // 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.
7758
+ () => bot.api.editMessageText(c.chatId, c.messageId, richMessage(cardText), { reply_markup: keyboard }),
7759
+ { chat_id: c.chatId, verb: 'permission_request.rearm' },
7760
+ )
7761
+ }
7762
+ }
7763
+
7764
+ /**
7765
+ * Strip a single stale permission card: drop its keyboard and show the
7766
+ * "gateway restarted" notice. Shared by the boot-sweep (immediate legacy path
7767
+ * and the #2861 grace-period path). Best-effort — a deleted/inaccessible card
7768
+ * is swallowed, never fatal.
7769
+ */
7770
+ async function stripStalePermissionCard(card: PersistedPermCard): Promise<void> {
7771
+ const toolLabel = card.toolName ?? 'unknown tool'
7772
+ const notice = `šŸ”’ **${toolLabel}**\n\nāš ļø *Gateway restarted — this request is no longer active. Ask your agent to try again if needed.*`
7773
+ try {
7774
+ // allow-raw-bot-api: targeted by message_id; no thread needed; fire-and-forget boot sweep
7775
+ await bot.api.editMessageText(
7776
+ card.chatId,
7777
+ card.messageId,
7778
+ richMessage(notice),
7779
+ { reply_markup: { inline_keyboard: [] } },
7780
+ )
7781
+ } catch (err) {
7782
+ // Card may already be deleted, edited, or in an inaccessible chat — benign
7783
+ process.stderr.write(
7784
+ `telegram gateway: boot-sweep: stale-card strip failed ` +
7785
+ `${card.chatId}:${card.messageId}: ${(err as Error).message}\n`,
7786
+ )
7787
+ }
7788
+ }
7789
+
7286
7790
  const ipcServer: IpcServer = createIpcServer({
7287
7791
  socketPath: SOCKET_PATH,
7288
7792
 
@@ -7652,6 +8156,38 @@ const ipcServer: IpcServer = createIpcServer({
7652
8156
 
7653
8157
  onPermissionRequest(_client: IpcClient, msg: PermissionRequestForward) {
7654
8158
  const { requestId, toolName, description, inputPreview } = msg
8159
+ // #2861 idempotent re-send handling. The bridge re-sends every outstanding
8160
+ // permission_request on each IPC (re)connect, so the same request_id can
8161
+ // arrive more than once. Disposition:
8162
+ // duplicate → already live in pendingPermissions; ignore (no 2nd card).
8163
+ // rearm → gone from memory (gateway restarted) but the persisted card
8164
+ // store still has it; restore + re-attach keyboard in place.
8165
+ // fresh → new ask; fall through to the normal card-posting path.
8166
+ // Either re-claim marks the id so the grace-period boot-sweep won't strip
8167
+ // it. Kill switch SWITCHROOM_PERMISSION_REARM=0 skips this entirely (legacy
8168
+ // behavior: a re-send would post a duplicate card, as before this fix).
8169
+ if (isPermissionRearmEnabled()) {
8170
+ const persisted = pendingPermissions.has(requestId)
8171
+ ? []
8172
+ : permCardStore.loadAll().filter(e => e.requestId === requestId)
8173
+ const disposition = classifyPermissionRequest({
8174
+ hasPending: pendingPermissions.has(requestId),
8175
+ persistedCount: persisted.length,
8176
+ })
8177
+ if (disposition === 'duplicate') {
8178
+ process.stderr.write(
8179
+ `telegram gateway: permission_request duplicate re-send ignored ` +
8180
+ `request=${requestId} (already pending)\n`,
8181
+ )
8182
+ return
8183
+ }
8184
+ if (disposition === 'rearm') {
8185
+ // Re-arm restores the pending entry; the grace-period boot-sweep
8186
+ // preserves anything live in pendingPermissions, so it won't strip it.
8187
+ rearmPermissionFromStore(msg, persisted)
8188
+ return
8189
+ }
8190
+ }
7655
8191
  // "ā± 30 min" short-circuit: if the operator tapped a live scoped grant
7656
8192
  // covering this exact request, auto-allow without posting a card. CRITICAL:
7657
8193
  // dispatch WITHOUT a `rule` so the bridge does NOT cache it untimed
@@ -8180,11 +8716,24 @@ const ipcServer: IpcServer = createIpcServer({
8180
8716
  // field, or any other byte difference) won't match → falls
8181
8717
  // through to a real operator approval card.
8182
8718
  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)
8719
+ if (added) {
8720
+ const key = `${msg.agentName}::${added}`
8721
+ const entry = pendingAlwaysAllowCorrelations.get(key)
8722
+ if (entry && entry.unifiedDiff === msg.unifiedDiff) {
8723
+ pendingAlwaysAllowCorrelations.delete(key)
8724
+ return 'approve'
8725
+ }
8726
+ }
8727
+ // hindsight Phase 5: a mental-model PROPOSAL the operator already
8728
+ // approved on the proposal card. The gateway pre-registered the EXACT
8729
+ // diff; auto-approve on a byte-exact match (the security gate), so no
8730
+ // second card is posted. Any forged/other edit finds no entry and
8731
+ // falls through to a real operator card.
8732
+ sweepStaleMentalModelCorrelations()
8733
+ const mmKey = mentalModelCorrelationKey(msg.agentName, msg.unifiedDiff)
8734
+ const mmEntry = pendingMentalModelCorrelations.get(mmKey)
8735
+ if (mmEntry && mmEntry.unifiedDiff === msg.unifiedDiff) {
8736
+ pendingMentalModelCorrelations.delete(mmKey)
8188
8737
  return 'approve'
8189
8738
  }
8190
8739
  return null
@@ -8317,11 +8866,45 @@ const ipcServer: IpcServer = createIpcServer({
8317
8866
  // frequent cron, or a crashed cron session) falls back to the MAIN agent
8318
8867
  // bridge so the fire lands now; it routes cheap again once the session is
8319
8868
  // up. See deliverInjectWithFallback.
8869
+ // #2793 part B — durable cron boot-replay. A fire the scheduler is
8870
+ // REPLAYING across a restart carries `meta.replay_fire_ms`; route it
8871
+ // through the durable inbound spool so accept and consume are ledgered
8872
+ // separately (mirrors the real-inbound path). The scheduler's own
8873
+ // scheduler.jsonl records the fire at socket-accept, which stops
8874
+ // findMissedFires from re-firing it — but a socket accept is NOT proof
8875
+ // the session consumed it. Spooling here means: if the session isn't up
8876
+ // (accepted-but-not-consumed), the entry stays un-acked and the spool's
8877
+ // boot-replay re-delivers it on the next gateway boot (closes the
8878
+ // silent-loss window); and the stable `spoolId` (keyed on the replayed
8879
+ // fire) dedups a re-replay so it lands at most once (closes the
8880
+ // double-fire window). Live cron ticks never set replay_fire_ms, so they
8881
+ // keep the fire-and-forget path unchanged. STATIC mode has no spool —
8882
+ // fall through to the legacy path.
8883
+ const isDurableReplay =
8884
+ inboundSpool != null &&
8885
+ typeof msg.inbound.meta?.replay_fire_ms === 'string' &&
8886
+ msg.inbound.meta.replay_fire_ms.length > 0
8887
+ if (isDurableReplay && inboundSpool != null) {
8888
+ // Durable ACCEPT: put before delivery so a crash between accept and
8889
+ // consume leaves the entry recoverable (boot-replay re-delivers).
8890
+ // Idempotent by stable spoolId — a re-replay of the same missed fire
8891
+ // is a no-op while the prior entry is still un-acked.
8892
+ inboundSpool.put(msg.agentName, msg.inbound as unknown as InboundMessage)
8893
+ }
8320
8894
  const { target, delivered, fellBackToMain } = deliverInjectWithFallback(
8321
8895
  msg.agentName,
8322
8896
  msg.inbound.meta,
8323
8897
  (t) => ipcServer.sendToAgent(t, msg.inbound),
8324
8898
  )
8899
+ if (isDurableReplay && inboundSpool != null && delivered) {
8900
+ // Durable CONSUME: the fire reached a live bridge, so tombstone the
8901
+ // spool entry (same "delivered to a live registered bridge" ack
8902
+ // semantics the inbound spool uses — see its v1 scope note). On a
8903
+ // miss we leave it un-acked: the pendingInboundBuffer.push below
8904
+ // buffers it for the reconnect drain, and the durable spool copy is
8905
+ // re-delivered on the next boot if this process dies first.
8906
+ inboundSpool.ack(msg.inbound as unknown as InboundMessage)
8907
+ }
8325
8908
  if (fellBackToMain) {
8326
8909
  process.stderr.write(
8327
8910
  `telegram gateway: cron fire fell back to main session (no cron bridge) agent=${msg.agentName} prompt_key=${promptKey}\n`,
@@ -8560,6 +9143,7 @@ const ipcServer: IpcServer = createIpcServer({
8560
9143
  {
8561
9144
  inject: webhookInject,
8562
9145
  log: (s) => process.stderr.write(`telegram gateway: ${s}`),
9146
+ onConsolidation: surfaceConsolidationLegibility,
8563
9147
  },
8564
9148
  ),
8565
9149
  })
@@ -8681,6 +9265,7 @@ const ALLOWED_TOOLS = new Set([
8681
9265
  'vault_request_save',
8682
9266
  'vault_request_access',
8683
9267
  'request_secret',
9268
+ 'mental_model_propose',
8684
9269
  'linear_agent_activity',
8685
9270
  'linear_create_issue',
8686
9271
  'linear_agent_setup',
@@ -8727,6 +9312,8 @@ async function executeToolCall(tool: string, args: Record<string, unknown>): Pro
8727
9312
  return executeVaultRequestAccess(args)
8728
9313
  case 'request_secret':
8729
9314
  return executeRequestSecret(args)
9315
+ case 'mental_model_propose':
9316
+ return executeMentalModelPropose(args)
8730
9317
  case 'linear_agent_activity':
8731
9318
  return executeLinearAgentActivity(args)
8732
9319
  case 'linear_create_issue':
@@ -11355,6 +11942,182 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
11355
11942
  }
11356
11943
  }
11357
11944
 
11945
+ /** Read the live switchroom.yaml bytes for diff/dup-check. Mirrors the
11946
+ * always-allow persistence path's config read. */
11947
+ function readLiveSwitchroomConfigText(): string {
11948
+ const cfgPath = process.env.SWITCHROOM_CONFIG ?? findSwitchroomConfigFile()
11949
+ return readFileSync(cfgPath, 'utf8')
11950
+ }
11951
+
11952
+ const MENTAL_MODEL_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/
11953
+
11954
+ function buildMentalModelProposeKeyboard(stageId: string): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
11955
+ return {
11956
+ inline_keyboard: [
11957
+ [
11958
+ { text: 'āœ… Approve', callback_data: `mmp:approve:${stageId}` },
11959
+ { text: '🚫 Deny', callback_data: `mmp:deny:${stageId}` },
11960
+ ],
11961
+ ],
11962
+ }
11963
+ }
11964
+
11965
+ /**
11966
+ * `mental_model_propose` tool (hindsight Phase 5) — the agent surfaces a
11967
+ * candidate mental model for the operator to approve. Mirrors the
11968
+ * `vault_request_access` shape: the agent can only PROPOSE; the [Approve]/[Deny]
11969
+ * tap is operator-gated (handleMentalModelProposeCallback), so an agent can
11970
+ * never self-approve. On Approve the proposal is DECLARED — appended to the
11971
+ * agent's memory.mental_models[] via the operator-approved config-edit path
11972
+ * (reusing config_propose_edit apply+reconcile) — and ensured in the bank. On
11973
+ * Deny nothing is written. Guardrails enforced here BEFORE any card:
11974
+ * duplicate-name rejection against the agent's already-declared models, and a
11975
+ * per-agent rate limit so proposals stay non-spammy.
11976
+ */
11977
+ async function executeMentalModelPropose(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
11978
+ const chat_id = String(args.chat_id ?? '')
11979
+ if (!chat_id) throw new Error('mental_model_propose: chat_id is required')
11980
+ const name = typeof args.name === 'string' ? args.name.trim() : ''
11981
+ if (!name) throw new Error('mental_model_propose: name is required')
11982
+ if (!MENTAL_MODEL_NAME_REGEX.test(name)) {
11983
+ throw new Error('mental_model_propose: name must be a slug (letters/digits/_/-, ≤64 chars, e.g. `training-plan-state`)')
11984
+ }
11985
+ const source_query = typeof args.source_query === 'string' ? args.source_query.trim() : ''
11986
+ if (!source_query) throw new Error('mental_model_propose: source_query is required')
11987
+ // Enforce the memory.mental_models[] schema cap (src/config/schema.ts) up-front
11988
+ // so the operator never approves a card that then fails hostd config validation.
11989
+ // The 2000-char ceiling also keeps the rendered card under Telegram's 4096-char
11990
+ // message limit.
11991
+ if (source_query.length > MENTAL_MODEL_SOURCE_QUERY_MAX) {
11992
+ throw new Error(
11993
+ `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.`,
11994
+ )
11995
+ }
11996
+ // Accept `why` as an alias for `reason` (mirrors the vault tools).
11997
+ const reason =
11998
+ typeof args.reason === 'string' ? args.reason : typeof args.why === 'string' ? args.why : undefined
11999
+ let refresh_after_consolidation: boolean | undefined
12000
+ if (args.refresh_after_consolidation !== undefined) {
12001
+ if (typeof args.refresh_after_consolidation !== 'boolean') {
12002
+ throw new Error('mental_model_propose: refresh_after_consolidation must be a boolean')
12003
+ }
12004
+ refresh_after_consolidation = args.refresh_after_consolidation
12005
+ }
12006
+ let max_tokens: number | undefined
12007
+ if (args.max_tokens !== undefined) {
12008
+ const n = Number(args.max_tokens)
12009
+ if (!Number.isInteger(n) || n <= 0) {
12010
+ throw new Error('mental_model_propose: max_tokens must be a positive integer')
12011
+ }
12012
+ // Enforce the schema ceiling (src/config/schema.ts) here so the card can't be
12013
+ // approved into a config-validation failure downstream.
12014
+ if (n > MENTAL_MODEL_MAX_TOKENS_CAP) {
12015
+ throw new Error(
12016
+ `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).`,
12017
+ )
12018
+ }
12019
+ max_tokens = n
12020
+ }
12021
+ assertAllowedChat(chat_id)
12022
+
12023
+ const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
12024
+
12025
+ // Rate limit: a proposal is a rare, deliberate curation act — throttle so a
12026
+ // looping agent can never spam the operator with cards.
12027
+ const rate = checkMentalModelProposeRate()
12028
+ if (!rate.ok) {
12029
+ const retryAtIso = new Date(rate.retryAtMs).toISOString()
12030
+ return {
12031
+ content: [
12032
+ {
12033
+ type: 'text',
12034
+ text:
12035
+ `mental_model_propose: RATE-LIMITED (max ${MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW} proposals/hour). ` +
12036
+ `No card was posted. Next slot opens at ${retryAtIso}. Proposing mental models is meant to be ` +
12037
+ `rare — batch or wait rather than re-firing.`,
12038
+ },
12039
+ ],
12040
+ }
12041
+ }
12042
+
12043
+ // Duplicate-name guard: reject a proposal for a model already DECLARED for
12044
+ // this agent, BEFORE posting a card (the name is the idempotent-ensure key).
12045
+ try {
12046
+ const configText = readLiveSwitchroomConfigText()
12047
+ const declared = readDeclaredMentalModelNames(configText, agentSlug)
12048
+ if (declared.includes(name)) {
12049
+ return {
12050
+ content: [
12051
+ {
12052
+ type: 'text',
12053
+ text:
12054
+ `mental_model_propose: '${name}' is ALREADY a declared mental model for ${agentSlug} ` +
12055
+ `(memory.mental_models[]). No card was posted — it already exists and is ensured in your ` +
12056
+ `bank. Pick a different name if you meant a NEW model, or just use the existing one.`,
12057
+ },
12058
+ ],
12059
+ }
12060
+ }
12061
+ } catch (err) {
12062
+ // Config read failed (transient) — fall through to the card. The approve
12063
+ // path re-reads and re-checks (dupe guard is defense-in-depth), so a
12064
+ // redundant card is harmless; suppressing a needed card is not.
12065
+ process.stderr.write(`telegram gateway: mental_model_propose dup pre-check read failed: ${(err as Error).message}\n`)
12066
+ }
12067
+
12068
+ const stageId = randomBytes(4).toString('hex')
12069
+ const pending: PendingMentalModelPropose = {
12070
+ agent: agentSlug,
12071
+ chat_id,
12072
+ spec: {
12073
+ name,
12074
+ source_query,
12075
+ ...(refresh_after_consolidation !== undefined ? { refresh_after_consolidation } : {}),
12076
+ ...(max_tokens !== undefined ? { max_tokens } : {}),
12077
+ },
12078
+ ...(reason ? { reason } : {}),
12079
+ staged_at: Date.now(),
12080
+ }
12081
+ pendingMentalModelProposes.set(stageId, pending)
12082
+ sweepPendingMentalModelProposes()
12083
+
12084
+ const text = renderMentalModelProposeCard({
12085
+ agent: agentSlug,
12086
+ name,
12087
+ source_query,
12088
+ ...(reason ? { reason } : {}),
12089
+ ...(refresh_after_consolidation !== undefined ? { refresh_after_consolidation } : {}),
12090
+ })
12091
+ const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
12092
+ if (threadId != null) pending.threadId = threadId
12093
+ const sent = await retryWithThreadFallback<{ message_id: number }>(
12094
+ robustApiCall,
12095
+ (tid) =>
12096
+ lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
12097
+ reply_markup: buildMentalModelProposeKeyboard(stageId),
12098
+ ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
12099
+ }),
12100
+ { threadId, chat_id, verb: 'mental_model_propose.card' },
12101
+ )
12102
+ pending.card_message_id = sent.message_id
12103
+ // Only count a proposal against the rate budget once its card actually
12104
+ // posted (validation errors / dupes don't consume the budget).
12105
+ mentalModelProposeTimes.push(Date.now())
12106
+
12107
+ return {
12108
+ content: [
12109
+ {
12110
+ type: 'text',
12111
+ text:
12112
+ `mental_model_propose: card sent (stage_id=${stageId}, name=${name}). Wait for the operator to tap ` +
12113
+ `Approve or Deny — END YOUR TURN cleanly. A fresh inbound arrives with the outcome ` +
12114
+ `(source=mental_model_proposal_applied / mental_model_proposal_denied). Do NOT re-propose this ` +
12115
+ `model while the card is open.`,
12116
+ },
12117
+ ],
12118
+ }
12119
+ }
12120
+
11358
12121
  async function executeReact(args: Record<string, unknown>): Promise<unknown> {
11359
12122
  if (!args.chat_id) throw new Error('react: chat_id is required')
11360
12123
  if (!args.message_id) throw new Error('react: message_id is required')
@@ -11924,6 +12687,31 @@ async function drainActivitySummary(
11924
12687
  )
11925
12688
  turn.activityMessageId = sent.message_id
11926
12689
  turn.activityEverOpened = true
12690
+ // Known Gap 1 (deterministic-turn-liveness.md) — persist the
12691
+ // minimal card handle the moment it opens, so a gateway restart
12692
+ // mid-turn has something to finalize on next boot instead of
12693
+ // leaving this card frozen forever. Fire-and-forget/best-effort:
12694
+ // a failed persist degrades to the pre-fix (in-memory-only)
12695
+ // behaviour, never blocks the card opening.
12696
+ if (activityCardPersistEnabled) {
12697
+ writeActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, {
12698
+ turnKey: statusKey(chat, thread),
12699
+ chatId: chat,
12700
+ threadId: thread ?? null,
12701
+ activityMessageId: sent.message_id,
12702
+ startedAt: turn.startedAt,
12703
+ // Mirror the ACTUAL pin decision, not an unconditional `true`:
12704
+ // the OPEN below silently-pins the fresh card only when
12705
+ // `PIN_STATUS_WHILE_WORKING` is on (`reconcileStatusPin` no-ops
12706
+ // when it's off, and can also fail on missing supergroup
12707
+ // rights). Persisting `pinned: true` regardless would make the
12708
+ // boot reaper attempt an unpin on a card that was never pinned.
12709
+ // The reaper's unpin is defense-in-depth anyway
12710
+ // (`statusPinBootCleanup` owns the primary unpin), so tracking
12711
+ // the flag honestly is what matters here.
12712
+ pinned: PIN_STATUS_WHILE_WORKING,
12713
+ })
12714
+ }
11927
12715
  // Status-pin: the per-turn status message just opened — it's the
11928
12716
  // in-flight "what it's doing" surface. Silently pin it so the turn
11929
12717
  // stays in view when the feed scrolls past. Keyed to the same
@@ -11968,11 +12756,15 @@ async function drainActivitySummary(
11968
12756
  }
11969
12757
 
11970
12758
  /**
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:
12759
+ * OPEN the minimal "Working…" liveness card for a 0-label turn once it has been
12760
+ * alive >= FEED_LIVENESS_OPEN_MS. The ONE place the liveness card may OPEN — both
12761
+ * the enqueue-time early-open timer (`scheduleEarlyLivenessOpen`) and the 6 s
12762
+ * heartbeat call through here, so a card opened by one caller is a clean no-op
12763
+ * for the other. This function OPENS only; it does NOT climb an already-open card
12764
+ * (its WHEN-gate `shouldEarlyOpenLiveness` returns false once `activityMessageId`
12765
+ * is set). The 0-label CLIMB of an already-open card lives at the heartbeat call
12766
+ * site via `silentTurnClimbRender` (deterministic-turn-liveness.md Phase 1) — so
12767
+ * BOTH the labelled and the 0-label branches now keep the card visibly climbing.
11976
12768
  * - `drainActivitySummary` OPENs when `activityMessageId == null` and EDITs
11977
12769
  * once it is set, so a second call after an open just maintains the card;
11978
12770
  * - the `mirrorLines.length === 0` guard at the heartbeat call site (and the
@@ -12157,13 +12949,45 @@ function feedHeartbeatTick(): void {
12157
12949
  // sends (opens) when activityMessageId is null and edits (maintains) once set
12158
12950
  // — so this one branch handles both the open and the climb.
12159
12951
  //
12160
- // The open/climb logic lives in ONE place (`openLivenessFeedIfDue`) so the
12952
+ // The OPEN logic lives in ONE place (`openLivenessFeedIfDue`) so the
12161
12953
  // enqueue-time early-open timer (`scheduleEarlyLivenessOpen`) and this 6 s
12162
12954
  // heartbeat both reach the same drain — there is exactly one path that can
12163
12955
  // 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
12956
+ //
12957
+ // Phase 1 climb (deterministic-turn-liveness.md): once the card IS open, the
12958
+ // OPEN path no-ops (its WHEN-gate `shouldEarlyOpenLiveness` returns false for
12959
+ // an already-open card) — which is exactly how the 0-label card used to FREEZE
12960
+ // during a long silent tool. So split the two cases: OPEN when no card exists,
12961
+ // and otherwise re-render the "Working…" card with a fresh wall-clock elapsed
12962
+ // through the SAME cardDrainGate / mayDrain / liveness EDIT path the labelled
12963
+ // branch below uses. Model-independent (reads only `now - startedAt`), so a
12964
+ // blocked tool call can't starve it; edit-only, so it never push-notifies.
12965
+ //
12966
+ // The tick BODY lives in feed-heartbeat-climb.ts (`runSilentTurnHeartbeatTick`)
12967
+ // so the shipped decision logic is directly under the outcome-based regression
12968
+ // test (tests/silent-turn-climb-transport.test.ts) — this gateway IIFE cannot
12969
+ // be imported in-process. This call site only wires the REAL deps; the wiring
12970
+ // shape is pinned structurally by tests/feed-heartbeat-liveness-open.test.ts.
12971
+ {
12972
+ const ea = emissionAuthorityFor(turn)
12973
+ const handled = runSilentTurnHeartbeatTick(
12974
+ {
12975
+ mirrorLineCount: turn.mirrorLines.length,
12976
+ activityMessageId: turn.activityMessageId,
12977
+ labeledToolCount: turn.labeledToolCount,
12978
+ ageMs: Date.now() - turn.startedAt,
12979
+ minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
12980
+ },
12981
+ {
12982
+ openLivenessFeedIfDue: () => openLivenessFeedIfDue(turn),
12983
+ setPendingRender: (rendered) => { turn.activityPendingRender = rendered },
12984
+ cardDrainGate: (run) => cardDrainGate(turn, ea, run),
12985
+ mayDrain: () => ea.mayDrain(turn),
12986
+ openOrEditCard: (apply) => ea.openOrEditCard('liveness', apply),
12987
+ drain: () => { turn.activityInFlight = drainActivitySummary(turn, 'liveness') },
12988
+ },
12989
+ )
12990
+ if (handled) return
12167
12991
  }
12168
12992
 
12169
12993
  // Labelled-feed heartbeat: keep a stale in-progress step visibly advancing.
@@ -12228,6 +13052,21 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
12228
13052
  if (turn.activityMessageId == null) return
12229
13053
  const id = turn.activityMessageId
12230
13054
  turn.activityMessageId = null
13055
+ // Known Gap 1 (deterministic-turn-liveness.md) — the card is closing
13056
+ // normally (about to be deleted or finalized below), so drop its durable
13057
+ // handle too: a normal close must never leave a stale record for the
13058
+ // boot reaper to "finalize" a message that's already been handled.
13059
+ if (activityCardPersistEnabled) {
13060
+ clearActivityCardRecord(
13061
+ ACTIVITY_CARD_STORE_PATH,
13062
+ activityCardStoreFs,
13063
+ statusKey(chat, thread),
13064
+ // Scope to this card's exact id (reap-race guard): only drop the row
13065
+ // for the card THIS turn is closing, never a fresher card another
13066
+ // turn may have already upserted under the same topic key.
13067
+ id,
13068
+ )
13069
+ }
12231
13070
  if (CLEAR_STATUS_ON_COMPLETION) {
12232
13071
  try {
12233
13072
  await robustApiCall(
@@ -12269,6 +13108,175 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
12269
13108
  })
12270
13109
  }
12271
13110
 
13111
+ /**
13112
+ * #2849 hindsight Phase 4 — sparse chat-legible memory.
13113
+ *
13114
+ * Fire-and-forget: if this main-agent tool call is a material memory
13115
+ * operation (create_directive → šŸ“Œ remembered; invalidate/demote → āœ‚ļø
13116
+ * forgot), send ONE terse real message into the turn's originating
13117
+ * chat/topic. Routes on `turn.sessionChatId` / `turn.sessionThreadId`
13118
+ * (the originating topic, never the operator DM), through
13119
+ * `retryWithThreadFallback` so a deleted forum topic can't crash the
13120
+ * gateway. Non-material tool calls return silently — no line on ordinary
13121
+ * recall or routine consolidation. Kill-switch: SWITCHROOM_MEMORY_LEGIBILITY=0.
13122
+ */
13123
+ /**
13124
+ * Fix 1.3 (#2903): a memory-legibility line must render only AFTER the write
13125
+ * is confirmed. We stage the detected event on `tool_use` (keyed by
13126
+ * `toolUseId`) and flush the šŸ“Œ/āœ‚ļø line on the matching SUCCESSFUL
13127
+ * `tool_result` — a failed write (engine down / isError envelope) drops the
13128
+ * staged line, so chat never claims "remembered" for a write that errored.
13129
+ *
13130
+ * A hindsight `tools/call` returns HTTP 200 + `is_error:true` on failure, and
13131
+ * Claude Code surfaces that on the transcript `tool_result` block, which
13132
+ * `session-tail` projects onto `ev.isError`. That is the confirmed-success
13133
+ * signal we gate on.
13134
+ */
13135
+ interface MemoryLegibilityRoute {
13136
+ chatId: string
13137
+ threadId: number | undefined
13138
+ toolName: string
13139
+ }
13140
+ const memoryLegibilityStager = new MemoryLegibilityStager<MemoryLegibilityRoute>()
13141
+
13142
+ function sendMemoryLegibilityLine(
13143
+ event: NonNullable<ReturnType<typeof detectMemoryLegibilityEvent>>,
13144
+ chatId: string,
13145
+ threadId: number | undefined,
13146
+ ): void {
13147
+ const line = renderMemoryLegibilityLine(event)
13148
+ void retryWithThreadFallback(
13149
+ robustApiCall,
13150
+ (tid) =>
13151
+ bot.api.sendMessage(chatId, line, {
13152
+ parse_mode: 'HTML',
13153
+ // Status surface, not the user's answer — never ping the device.
13154
+ disable_notification: true,
13155
+ ...(tid != null ? { message_thread_id: tid } : {}),
13156
+ }),
13157
+ { threadId, chat_id: chatId, verb: 'memory-legibility.sendMessage' },
13158
+ ).catch((err) => {
13159
+ process.stderr.write(
13160
+ `telegram gateway: memory-legibility send failed: ${
13161
+ err instanceof Error ? err.message : String(err)
13162
+ }\n`,
13163
+ )
13164
+ })
13165
+ }
13166
+
13167
+ /** Stage a material memory event observed on `tool_use`. Nothing is sent yet;
13168
+ * the line is flushed by `confirmMemoryLegibility` on a successful result. */
13169
+ function surfaceMemoryLegibility(
13170
+ turn: CurrentTurn,
13171
+ toolName: string,
13172
+ toolUseId: string | null | undefined,
13173
+ input: Record<string, unknown> | undefined,
13174
+ ): void {
13175
+ if (!MEMORY_LEGIBILITY_ENABLED) return
13176
+ const event = detectMemoryLegibilityEvent(toolName, input)
13177
+ if (event == null) return
13178
+ const chatId = turn.sessionChatId
13179
+ const threadId = turn.sessionThreadId
13180
+ // Without a toolUseId we cannot correlate a result to confirm success. This
13181
+ // effectively never happens (Claude Code always stamps a tool_use id), so
13182
+ // rather than claim an unconfirmed "remembered", we skip and log.
13183
+ if (toolUseId == null || toolUseId.length === 0) {
13184
+ process.stderr.write(
13185
+ `telegram gateway: memory-legibility ${event.kind} SKIPPED (no toolUseId) ` +
13186
+ `chat=${chatId} tool=${toolName}\n`,
13187
+ )
13188
+ return
13189
+ }
13190
+ memoryLegibilityStager.stage(toolUseId, event, { chatId, threadId, toolName })
13191
+ process.stderr.write(
13192
+ `telegram gateway: memory-legibility ${event.kind} STAGED chat=${chatId} ` +
13193
+ `thread=${threadId ?? '-'} tool=${toolName} id=${toolUseId}\n`,
13194
+ )
13195
+ }
13196
+
13197
+ /** Flush (or drop) a staged memory-legibility line on its matching
13198
+ * `tool_result`. Sends only on confirmed success; a failed write is dropped
13199
+ * so chat never claims "remembered" for a write that errored. */
13200
+ function confirmMemoryLegibility(
13201
+ toolUseId: string | null | undefined,
13202
+ isError: boolean | undefined,
13203
+ ): void {
13204
+ const resolved = memoryLegibilityStager.confirm(toolUseId, isError)
13205
+ if (resolved == null) {
13206
+ if (isError === true) {
13207
+ process.stderr.write(
13208
+ `telegram gateway: memory-legibility DROPPED (write errored) id=${toolUseId}\n`,
13209
+ )
13210
+ }
13211
+ return
13212
+ }
13213
+ const { event, meta } = resolved
13214
+ process.stderr.write(
13215
+ `telegram gateway: memory-legibility ${event.kind} CONFIRMED chat=${meta.chatId} ` +
13216
+ `thread=${meta.threadId ?? '-'} tool=${meta.toolName} id=${toolUseId}\n`,
13217
+ )
13218
+ sendMemoryLegibilityLine(event, meta.chatId, meta.threadId)
13219
+ }
13220
+
13221
+ /**
13222
+ * #2849 hindsight Phase 4 (follow-up) — consolidation-driven legibility.
13223
+ *
13224
+ * The gateway-side sink for a verified `hindsight` / `consolidation.completed`
13225
+ * webhook. `recordWebhookEvent` has already logged the event for audit and
13226
+ * resolved the agent's channel target; this decides whether it is MATERIAL
13227
+ * (a genuine store/correct — else nothing), rate-limits it hard, and — only
13228
+ * if it clears both gates — sends ONE terse "🧠 updated what I know about Y"
13229
+ * real message with notifications suppressed. Never injects a model turn.
13230
+ * OPT-IN via SWITCHROOM_CONSOLIDATION_LEGIBILITY.
13231
+ */
13232
+ function surfaceConsolidationLegibility(
13233
+ rec: WebhookGatewayRecord,
13234
+ target: { chatId: string; threadId?: number },
13235
+ ): void {
13236
+ if (!CONSOLIDATION_LEGIBILITY_ENABLED) return
13237
+ const event = detectConsolidationEvent(rec.payload)
13238
+ if (event == null) return // routine no-op consolidation — surface nothing
13239
+ const agent = rec.agent
13240
+ // Rate-limiter clock MUST be wall-clock (Date.now()), never rec.ts: rec.ts
13241
+ // can be derived from the (attacker-influenceable) webhook payload, and a
13242
+ // spoofed far-past / far-future timestamp would poison the per-agent
13243
+ // min-interval gate — either permanently opening it (stale ts always older
13244
+ // than the window) or jamming it shut. The limiter only cares about "how
13245
+ // long since the last surface on THIS gateway", which is a local wall-clock
13246
+ // question.
13247
+ if (!consolidationRateLimiter.allow(agent, consolidationSignature(event), Date.now())) {
13248
+ process.stderr.write(
13249
+ `telegram gateway: consolidation-legibility ${event.kind} rate-limited ` +
13250
+ `agent=${agent} topic='${event.topic}'\n`,
13251
+ )
13252
+ return
13253
+ }
13254
+ const chatId = target.chatId
13255
+ const threadId = target.threadId
13256
+ const line = renderConsolidationLine(event)
13257
+ process.stderr.write(
13258
+ `telegram gateway: consolidation-legibility ${event.kind} chat=${chatId} ` +
13259
+ `thread=${threadId ?? '-'} agent=${agent}\n`,
13260
+ )
13261
+ void retryWithThreadFallback(
13262
+ robustApiCall,
13263
+ (tid) =>
13264
+ bot.api.sendMessage(chatId, line, {
13265
+ parse_mode: 'HTML',
13266
+ // Status surface, not the user's answer — never ping the device.
13267
+ disable_notification: true,
13268
+ ...(tid != null ? { message_thread_id: tid } : {}),
13269
+ }),
13270
+ { threadId, chat_id: chatId, verb: 'consolidation-legibility.sendMessage' },
13271
+ ).catch((err) => {
13272
+ process.stderr.write(
13273
+ `telegram gateway: consolidation-legibility send failed: ${
13274
+ err instanceof Error ? err.message : String(err)
13275
+ }\n`,
13276
+ )
13277
+ })
13278
+ }
13279
+
12272
13280
  function handleSessionEvent(ev: SessionEvent): void {
12273
13281
  switch (ev.kind) {
12274
13282
  case 'enqueue': {
@@ -12567,6 +13575,14 @@ function handleSessionEvent(ev: SessionEvent): void {
12567
13575
  // of dropping. The answer-stream's own dedup handles overlap
12568
13576
  // with the reply tool's payload.
12569
13577
  preambleSuppressor.onTool({ isReplyTool: isTelegramSurfaceTool(ev.toolName) })
13578
+ // #2849 Phase 4 — sparse chat-legible memory. Surface ONE terse line in
13579
+ // the originating chat/topic when this tool call materially changes what
13580
+ // the agent remembers (create_directive / invalidate / demote). Fires
13581
+ // BEFORE the `if (!ctrl) return` status-reaction gate below so it works
13582
+ // on turns with no active status-reaction controller. Deterministic
13583
+ // tool-call observation — no model call, no polling; ordinary recall and
13584
+ // routine consolidation never reach here (they aren't material tools).
13585
+ surfaceMemoryLegibility(turn, ev.toolName, ev.toolUseId, ev.input)
12570
13586
  const ctrl = activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))
12571
13587
  const name = ev.toolName
12572
13588
  // Phase tracking removed in #553 PR 5 — phases only fed the
@@ -12997,6 +14013,9 @@ function handleSessionEvent(ev: SessionEvent): void {
12997
14013
  }
12998
14014
  case 'tool_result': {
12999
14015
  if (ev.toolUseId) typingWrapper.onToolResult(ev.toolUseId)
14016
+ // Fix 1.3 (#2903): flush a staged šŸ“Œ/āœ‚ļø memory-legibility line only on a
14017
+ // CONFIRMED-successful write; a failed write (ev.isError) drops it.
14018
+ confirmMemoryLegibility(ev.toolUseId, ev.isError)
13000
14019
  return
13001
14020
  }
13002
14021
  case 'sub_agent_tool_use': {
@@ -13753,11 +14772,28 @@ function handleSessionEvent(ev: SessionEvent): void {
13753
14772
  // this path. The turn-flush 'flush' branch also returns earlier
13754
14773
  // (and sets finalAnswerDelivered=true defensively).
13755
14774
  if (turn.finalAnswerDelivered === false) {
13756
- const silentEnd = recordUndeliveredTurnEnd({
13757
- chatId,
13758
- threadId: threadId ?? null,
13759
- turnKey: tKey,
13760
- })
14775
+ // PR #2892 (deterministic-turn-liveness RFC Phase 2) hardening:
14776
+ // wire the represent-guard-style staleness
14777
+ // check (`recordSilentTurnEnd`'s `hasOutboundDeliveredSince` dep) so
14778
+ // an exhausted-looking record left over from a PRIOR, already-
14779
+ // answered turn on this same chat/thread (statusKey is not a
14780
+ // per-turn nonce) can never be misread as this turn's spent
14781
+ // re-prompt budget. Falls back to the pre-existing turnKey/
14782
+ // retryCount-only check when history is unavailable.
14783
+ const silentEndDeps: SilentEndDeps | undefined = HISTORY_ENABLED
14784
+ ? {
14785
+ hasOutboundDeliveredSince: (cid, sinceMs, tid) =>
14786
+ hasOutboundDeliveredSince(cid, sinceMs, tid, 1),
14787
+ }
14788
+ : undefined
14789
+ const silentEnd = recordUndeliveredTurnEnd(
14790
+ {
14791
+ chatId,
14792
+ threadId: threadId ?? null,
14793
+ turnKey: tKey,
14794
+ },
14795
+ silentEndDeps,
14796
+ )
13761
14797
  if (silentEnd.exhausted) {
13762
14798
  process.stderr.write(
13763
14799
  `telegram gateway: WARN silent-end fallback — agent stayed ` +
@@ -13769,7 +14805,7 @@ function handleSessionEvent(ev: SessionEvent): void {
13769
14805
  (tid) =>
13770
14806
  bot.api.sendMessage(
13771
14807
  chatId,
13772
- SILENT_END_FALLBACK_TEXT,
14808
+ silentEndFallbackText(turnDurationMs),
13773
14809
  tid != null ? { message_thread_id: tid } : {},
13774
14810
  ),
13775
14811
  { threadId, chat_id: chatId, verb: 'silent-end-fallback.sendMessage' },
@@ -14289,6 +15325,8 @@ async function handleInbound(
14289
15325
  // present, so reset any no-repeat suppression: the next time the agent asks
14290
15326
  // for something that timed out earlier, they should see a fresh card.
14291
15327
  clearPermissionTimeoutSuppression('operator inbound')
15328
+ // #2862 — operator is back; re-offer any approvals that timed out meanwhile.
15329
+ maybePostMissedApprovalDigest('operator inbound')
14292
15330
 
14293
15331
  // Capture wall-clock receive time for inbound_ack metric (#203).
14294
15332
  // Must be after gate() so early-exit paths (drop/pair) don't skew the delta.
@@ -18041,6 +19079,8 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
18041
19079
  }
18042
19080
  // Operator answered via slash ⇒ present; reset no-repeat suppression.
18043
19081
  clearPermissionTimeoutSuppression('operator answered via /approve|/deny')
19082
+ // #2862 — operator is present; re-offer any approvals that timed out meanwhile.
19083
+ maybePostMissedApprovalDigest('operator answered via /approve|/deny')
18044
19084
  // Forward to connected bridges — same IPC the button handler uses.
18045
19085
  dispatchPermissionVerdict({ type: 'permission', requestId: request_id, behavior })
18046
19086
  resumeReactionAfterVerdict()
@@ -19898,6 +20938,185 @@ async function handleSkillProposalCallback(ctx: Context, data: string): Promise<
19898
20938
  )
19899
20939
  }
19900
20940
 
20941
+ /**
20942
+ * hindsight Phase 5 — handle a tap on the mental-model PROPOSAL card.
20943
+ * mmp:approve:<stageId> — declare the model: append it to the agent's
20944
+ * memory.mental_models[] via the operator-approved
20945
+ * config-edit path (reused config_propose_edit
20946
+ * apply+reconcile; reconcile ensures it), then wake
20947
+ * the agent with an "applied" inbound.
20948
+ * mmp:deny:<stageId> — drop the proposal; NOTHING is written; wake the
20949
+ * agent with a "denied" inbound.
20950
+ *
20951
+ * Authorization: the tapper MUST be on the gateway's allowFrom list — an agent
20952
+ * can PROPOSE but can never self-approve (identical gate to the vault flow).
20953
+ */
20954
+ async function handleMentalModelProposeCallback(ctx: Context, data: string): Promise<void> {
20955
+ const senderId = String(ctx.from?.id ?? '')
20956
+ const access = loadAccess()
20957
+ if (!access.allowFrom.includes(senderId)) {
20958
+ // Self-approve is impossible: only an allow-listed operator can resolve
20959
+ // the card. A tap from anyone else (incl. a compromised agent identity) is
20960
+ // refused here.
20961
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
20962
+ return
20963
+ }
20964
+ const parts = data.split(':')
20965
+ if (parts.length < 3) {
20966
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
20967
+ return
20968
+ }
20969
+ const action = parts[1]
20970
+ const stageId = parts.slice(2).join(':')
20971
+ const pending = pendingMentalModelProposes.get(stageId)
20972
+ if (!pending) {
20973
+ await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-propose.' }).catch(() => {})
20974
+ if (ctx.callbackQuery?.message) {
20975
+ await ctx.api
20976
+ .editMessageText(
20977
+ ctx.callbackQuery.message.chat.id,
20978
+ ctx.callbackQuery.message.message_id,
20979
+ richMessage('āŒ› _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
20980
+ { reply_markup: { inline_keyboard: [] } },
20981
+ )
20982
+ .catch(() => {})
20983
+ }
20984
+ return
20985
+ }
20986
+ if (action !== 'approve' && action !== 'deny') {
20987
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
20988
+ return
20989
+ }
20990
+ // Enforce the TTL at TAP time, not just on the next propose's sweep. Without
20991
+ // this, a card left untapped past its TTL is still resolvable if no fresh
20992
+ // proposal has run the sweep — an operator could approve a stale proposal.
20993
+ if (Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS) {
20994
+ pendingMentalModelProposes.delete(stageId)
20995
+ await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-propose.' }).catch(() => {})
20996
+ if (pending.card_message_id != null) {
20997
+ await ctx.api
20998
+ .editMessageText(
20999
+ pending.chat_id,
21000
+ pending.card_message_id,
21001
+ richMessage('āŒ› _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
21002
+ { reply_markup: { inline_keyboard: [] } },
21003
+ )
21004
+ .catch(() => {})
21005
+ }
21006
+ return
21007
+ }
21008
+ // Single-shot: remove the pending entry immediately so a double-tap can't
21009
+ // resolve twice.
21010
+ pendingMentalModelProposes.delete(stageId)
21011
+
21012
+ const proposal: MentalModelPendingProposal = {
21013
+ agent: pending.agent,
21014
+ chat_id: pending.chat_id,
21015
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
21016
+ spec: pending.spec,
21017
+ ...(pending.reason ? { reason: pending.reason } : {}),
21018
+ }
21019
+
21020
+ const resolveDeps = {
21021
+ readConfigText: () => readLiveSwitchroomConfigText(),
21022
+ registerPreApproval: (agent: string, diff: string) => {
21023
+ pendingMentalModelCorrelations.set(mentalModelCorrelationKey(agent, diff), {
21024
+ agentName: agent,
21025
+ unifiedDiff: diff,
21026
+ createdAt: Date.now(),
21027
+ })
21028
+ },
21029
+ clearPreApproval: (agent: string, diff: string) => {
21030
+ pendingMentalModelCorrelations.delete(mentalModelCorrelationKey(agent, diff))
21031
+ },
21032
+ dispatchConfigEdit: async (a: { agent: string; diff: string; reason: string }) => {
21033
+ const req: HostdRequest = {
21034
+ v: 1,
21035
+ op: 'config_propose_edit',
21036
+ request_id: hostdRequestId('gw-mental-model'),
21037
+ args: {
21038
+ unified_diff: a.diff,
21039
+ reason: a.reason,
21040
+ target_path: '/state/config/switchroom.yaml',
21041
+ },
21042
+ }
21043
+ // config_propose_edit blocks on validate→approve→apply→reconcile
21044
+ // (5-10 min on a busy host) — allow 12 min. The operator already
21045
+ // approved on the proposal card, so hostd's config-approval callback
21046
+ // auto-resolves via the pre-registered correlation (no second card).
21047
+ const resp = await tryHostdDispatch(a.agent, req, 720_000)
21048
+ if (resp === 'not-configured') {
21049
+ return { state: 'error' as const, reason: 'hostd config-edit is not configured (host_control disabled or socket absent)' }
21050
+ }
21051
+ if (resp.result === 'completed') return { state: 'applied' as const }
21052
+ if (resp.result === 'denied') return { state: 'denied' as const, reason: resp.error ?? 'operator/host denied the edit' }
21053
+ return { state: 'error' as const, reason: resp.error ?? `hostd returned '${resp.result}'` }
21054
+ },
21055
+ // Ensure is delegated to reconcile: config_propose_edit's apply triggers a
21056
+ // reconcile which runs ensureDeclaredMentalModels (#2874) for the newly
21057
+ // declared model — the authoritative, correctly-scoped ensure. We
21058
+ // deliberately do NOT add a redundant gateway-side ensure (it would need
21059
+ // the agent's bank id + a reachable Hindsight endpoint from the gateway).
21060
+ injectInbound: (inbound: InboundMessage) => {
21061
+ deliverResumeSyntheticOrBuffer(pending.agent, inbound)
21062
+ },
21063
+ log: (m: string) => process.stderr.write(`telegram gateway: ${m}\n`),
21064
+ }
21065
+
21066
+ if (action === 'deny') {
21067
+ await ctx.answerCallbackQuery({ text: '🚫 Denied' }).catch(() => {})
21068
+ await resolveMentalModelProposal('deny', proposal, stageId, senderId, resolveDeps)
21069
+ if (pending.card_message_id != null) {
21070
+ await ctx.api
21071
+ .editMessageText(
21072
+ pending.chat_id,
21073
+ pending.card_message_id,
21074
+ richMessage(`🚫 _Denied. **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` was not declared._`),
21075
+ { reply_markup: { inline_keyboard: [] } },
21076
+ )
21077
+ .catch(() => {})
21078
+ }
21079
+ return
21080
+ }
21081
+
21082
+ // Approve. Ack immediately + show an interim state, then persist in the
21083
+ // background (config_propose_edit can take minutes), then edit the card with
21084
+ // the real outcome. The turn resumes via the synthetic inbound injected by
21085
+ // resolveMentalModelProposal — not by this card edit.
21086
+ await ctx.answerCallbackQuery({ text: 'āœ… Declaring the model…' }).catch(() => {})
21087
+ if (pending.card_message_id != null) {
21088
+ await ctx.api
21089
+ .editMessageText(
21090
+ pending.chat_id,
21091
+ pending.card_message_id,
21092
+ richMessage(`ā³ _Declaring **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` — appending to config + ensuring…_`),
21093
+ { reply_markup: { inline_keyboard: [] } },
21094
+ )
21095
+ .catch(() => {})
21096
+ }
21097
+ void (async () => {
21098
+ let result
21099
+ try {
21100
+ result = await resolveMentalModelProposal('approve', proposal, stageId, senderId, resolveDeps)
21101
+ } catch (err) {
21102
+ process.stderr.write(`telegram gateway: mental_model_propose approve threw: ${(err as Error).message}\n`)
21103
+ result = { outcome: 'failed' as const, reason: (err as Error).message }
21104
+ }
21105
+ if (pending.card_message_id != null) {
21106
+ const label =
21107
+ result.outcome === 'applied'
21108
+ ? `āœ… **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.`
21109
+ : `āš ļø **Did NOT declare** \`${pending.spec.name}\`${'reason' in result && result.reason ? ` — ${escapeHtmlForTg(result.reason)}` : ''}. Nothing was written.`
21110
+ await ctx.api
21111
+ .editMessageText(pending.chat_id, pending.card_message_id, richMessage(label), {
21112
+ reply_markup: { inline_keyboard: [] },
21113
+ link_preview_options: { is_disabled: true },
21114
+ })
21115
+ .catch(() => {})
21116
+ }
21117
+ })()
21118
+ }
21119
+
19901
21120
  async function handleVaultRequestAccessCallback(ctx: Context, data: string): Promise<void> {
19902
21121
  const senderId = String(ctx.from?.id ?? '')
19903
21122
  const access = loadAccess()
@@ -22439,6 +23658,15 @@ bot.on('callback_query:data', async ctx => {
22439
23658
  return
22440
23659
  }
22441
23660
 
23661
+ // hindsight Phase 5: agent-proposes → human-approves mental-model card.
23662
+ // mmp:approve:<stageId> — declare the model (append memory.mental_models[]
23663
+ // via operator-approved config edit) + ensure it
23664
+ // mmp:deny:<stageId> — drop the proposal; NOTHING is written
23665
+ if (data.startsWith('mmp:')) {
23666
+ await handleMentalModelProposeCallback(ctx, data)
23667
+ return
23668
+ }
23669
+
22442
23670
  // #2670: one-tap skill-improvement proposal card.
22443
23671
  // skprop:approve:<id> — apply the stored draft via the personal-skill
22444
23672
  // write pipeline (inject a turn; agent writes it)
@@ -22449,6 +23677,15 @@ bot.on('callback_query:data', async ctx => {
22449
23677
  return
22450
23678
  }
22451
23679
 
23680
+ // #2862: missed-approvals re-offer digest.
23681
+ // missre:retry:<id> — inject a synthetic inbound asking the agent to
23682
+ // re-attempt (re-raises a fresh approval card)
23683
+ // missre:dismiss:<id> — clear the record + edit the card closed
23684
+ if (data.startsWith('missre:')) {
23685
+ await handleMissedApprovalCallback(ctx, data)
23686
+ return
23687
+ }
23688
+
22452
23689
  // Issue #969 P2b: vault recent-denial one-tap approval.
22453
23690
  // vrd:<agent>:<key> — mint a 30-day read-grant for the agent + key
22454
23691
  // Posted by /vault audit <agent> in the "Recent denials" section.
@@ -23095,6 +24332,8 @@ bot.on('callback_query:data', async ctx => {
23095
24332
  // Operator tapped a verdict ⇒ they are present; reset no-repeat suppression
23096
24333
  // so a later identical ask is shown fresh rather than silently short-circuited.
23097
24334
  clearPermissionTimeoutSuppression('operator answered a permission card')
24335
+ // #2862 — operator is present; re-offer any approvals that timed out meanwhile.
24336
+ maybePostMissedApprovalDigest('operator answered a permission card')
23098
24337
  const pd = pendingPermissions.get(request_id)
23099
24338
  const resumeAction = pd ? naturalAction(pd.tool_name, pd.input_preview) : ''
23100
24339
  const scopedTtl = scopedApprovalTtlMs()
@@ -23106,6 +24345,9 @@ bot.on('callback_query:data', async ctx => {
23106
24345
  permCardStore.remove(request_id)
23107
24346
  if (timeBox && grantAgent) {
23108
24347
  recordScopedGrant(scopedGrants, grantAgent, timeBox.rule, Date.now(), scopedTtl)
24348
+ // Write-through so the window survives a gateway restart (#2863). Absolute
24349
+ // expiry is baked into the entry, so a reload can't extend it.
24350
+ scopedGrantStore.save(scopedGrants)
23109
24351
  process.stderr.write(
23110
24352
  `telegram gateway: scoped-approval granted via Allow rule="${timeBox.rule}" ` +
23111
24353
  `agent=${grantAgent} ttl_ms=${scopedTtl} (request_id=${request_id})\n`,
@@ -24874,39 +26116,68 @@ void (async () => {
24874
26116
  // tracks the live turn from there.
24875
26117
  try { removeTurnActiveMarker(STATE_DIR) } catch { /* best-effort */ }
24876
26118
 
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
- }
26119
+ // Boot-sweep for permission cards from a prior gateway session. Any
26120
+ // entry still in the store was never resolved (gateway died before the
26121
+ // operator tapped or the reaper ran).
26122
+ //
26123
+ // #2861: DON'T strip immediately. A still-live claude session's bridge
26124
+ // re-sends its outstanding permission_requests on IPC reconnect within
26125
+ // a few seconds — re-arming the persisted card instead of losing the
26126
+ // suspended turn. So we wait a grace window (~90 s) and only strip
26127
+ // entries that were NOT re-claimed by a bridge re-send in the meantime.
26128
+ // A genuinely dead session (container recreate → new bridge, empty
26129
+ // ledger) never re-sends, so those still get the "restarted" notice.
26130
+ //
26131
+ // Kill switch SWITCHROOM_PERMISSION_REARM=0 reverts to the legacy
26132
+ // immediate strip-everything sweep.
26133
+ if (!isPermissionRearmEnabled()) {
26134
+ void (async () => {
26135
+ const stale = permCardStore.loadAll()
26136
+ if (stale.length === 0) return
26137
+ process.stderr.write(
26138
+ `telegram gateway: boot-sweep: stripping ${stale.length} stale permission card(s) from prior gateway session\n`,
26139
+ )
26140
+ for (const card of stale) await stripStalePermissionCard(card)
26141
+ permCardStore.clear()
26142
+ })()
26143
+ } else {
26144
+ const bootStale = permCardStore.loadAll()
26145
+ if (bootStale.length > 0) {
26146
+ const graceMs = permissionRearmGraceMs()
26147
+ process.stderr.write(
26148
+ `telegram gateway: boot-sweep: ${bootStale.length} pending permission card(s) from prior ` +
26149
+ `session; deferring strip ${graceMs}ms to allow bridge re-arm (#2861)\n`,
26150
+ )
26151
+ setTimeout(() => {
26152
+ void (async () => {
26153
+ // Reload — some entries may have been resolved (tap/TTL) during
26154
+ // the grace window, which removes them from the store.
26155
+ const remaining = permCardStore.loadAll()
26156
+ // Preserve anything currently LIVE in pendingPermissions: that
26157
+ // covers both re-armed cards (a bridge re-send restored them)
26158
+ // AND fresh cards born during the grace window (a new approval
26159
+ // posted after boot). Only cards with no live pending entry —
26160
+ // a genuinely dead session that never reconnected — are stripped.
26161
+ const liveRequestIds = new Set(pendingPermissions.keys())
26162
+ const toStrip = computeBootSweepStripTargets(remaining, liveRequestIds)
26163
+ if (toStrip.length === 0) {
26164
+ process.stderr.write(
26165
+ `telegram gateway: boot-sweep: all pending permission cards re-armed or still live — nothing to strip\n`,
26166
+ )
26167
+ return
26168
+ }
26169
+ process.stderr.write(
26170
+ `telegram gateway: boot-sweep: stripping ${toStrip.length} dead-session permission card(s) ` +
26171
+ `after ${graceMs}ms grace (of ${remaining.length} still persisted, ${liveRequestIds.size} live)\n`,
26172
+ )
26173
+ for (const card of toStrip) await stripStalePermissionCard(card)
26174
+ // Remove ONLY the stripped request_ids — re-armed entries stay
26175
+ // persisted so a subsequent restart re-arms them again.
26176
+ for (const id of distinctRequestIds(toStrip)) permCardStore.remove(id)
26177
+ })()
26178
+ }, graceMs).unref?.()
24907
26179
  }
24908
- permCardStore.clear()
24909
- })()
26180
+ }
24910
26181
 
24911
26182
  // Boot-time pin sweep
24912
26183
  try {
@@ -25300,6 +26571,29 @@ void (async () => {
25300
26571
  // accident no longer pollute the watcher with phantom
25301
26572
  // registrations + ENOENT log spam + false stalls.
25302
26573
  agentCwd: watcherAgentDir,
26574
+ // Gap 2 (deterministic-turn-liveness.md "Known gaps"): a
26575
+ // sub-agent dispatched into a `switchroom worktree claim`
26576
+ // cwd runs under a different project-dir slug than
26577
+ // `agentCwd` above, so the #1116 foreign-slug filter would
26578
+ // otherwise skip it forever — no activity stamp, no `šŸ› 
26579
+ // Worker` feed for the entire run. Re-derive, fresh on every
26580
+ // rescan tick, the set of worktree paths this agent itself
26581
+ // currently owns (registry records are the deterministic
26582
+ // source of truth for "cwds this agent's sub-agents may run
26583
+ // in") and let the watcher also watch those slugs.
26584
+ // Best-effort: a registry read failure (e.g. no worktree dir
26585
+ // on an agent that never claims one) must not affect the
26586
+ // primary agentCwd watch.
26587
+ extraWatchCwdsProvider: () =>
26588
+ // Fail-CLOSED ownership filter (unset identity ⇒ nothing;
26589
+ // ownerless records excluded; registry throw ⇒ []). Extracted
26590
+ // to telegram-plugin/worktree-watch-cwds.ts so the #1116 /
26591
+ // Gap-2 ownership predicate is under direct unit test — see
26592
+ // telegram-plugin/tests/worktree-watch-cwds.test.ts.
26593
+ ownedWorktreeCwds({
26594
+ self: process.env.SWITCHROOM_AGENT_NAME,
26595
+ listRecords: listWorktreeRecords,
26596
+ }),
25303
26597
  // Bug 0 fix: previously omitted, leaving the watcher unable to
25304
26598
  // write liveness/stall/turn_end updates to the registry DB.
25305
26599
  // Liveness writes are now persisted across the gateway lifetime.