switchroom 0.17.6 → 0.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +65 -5
  3. package/dist/auth-broker/index.js +6623 -514
  4. package/dist/cli/notion-write-pretool.mjs +64 -4
  5. package/dist/cli/switchroom.js +1888 -1162
  6. package/dist/host-control/main.js +6306 -162
  7. package/dist/vault/approvals/kernel-server.js +6014 -202
  8. package/dist/vault/broker/server.js +6741 -940
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +218 -25
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +116 -0
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/skills/mental-model-curator/SKILL.md +162 -0
  18. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  19. package/telegram-plugin/bridge/bridge.ts +80 -1
  20. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  21. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  22. package/telegram-plugin/consolidation-legibility.ts +279 -0
  23. package/telegram-plugin/context-exhaustion.ts +124 -0
  24. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  25. package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
  26. package/telegram-plugin/dist/server.js +86 -2
  27. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  28. package/telegram-plugin/gateway/activity-card-store.ts +369 -0
  29. package/telegram-plugin/gateway/gateway.ts +1861 -172
  30. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  31. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  32. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  33. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  34. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  35. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  36. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  37. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  38. package/telegram-plugin/gateway/model-command.ts +70 -10
  39. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  40. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  41. package/telegram-plugin/memory-legibility.ts +217 -0
  42. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  43. package/telegram-plugin/package.json +6 -0
  44. package/telegram-plugin/quota-watch.ts +4 -6
  45. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  46. package/telegram-plugin/registry/turns-schema.ts +78 -0
  47. package/telegram-plugin/render/ir.ts +209 -0
  48. package/telegram-plugin/render/parse.ts +363 -0
  49. package/telegram-plugin/render/render.ts +440 -0
  50. package/telegram-plugin/render/rich-render.ts +72 -0
  51. package/telegram-plugin/scoped-approval.ts +59 -0
  52. package/telegram-plugin/silent-end.ts +78 -0
  53. package/telegram-plugin/stream-controller.ts +14 -3
  54. package/telegram-plugin/subagent-watcher.ts +60 -6
  55. package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
  56. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  57. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  58. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  59. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  60. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  61. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  62. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
  63. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  64. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  65. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  66. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  67. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  68. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  69. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  70. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  71. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  72. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  73. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  74. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  75. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  76. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  77. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  78. package/telegram-plugin/tests/model-command.test.ts +193 -16
  79. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  80. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  81. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  82. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  83. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  84. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  85. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  86. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  87. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  88. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  89. package/telegram-plugin/tests/render/render.test.ts +436 -0
  90. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  91. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  92. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  93. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  94. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  95. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  96. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
  97. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  98. package/telegram-plugin/uat/assertions.ts +88 -4
  99. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  100. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  101. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  102. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  103. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  104. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  105. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  106. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  107. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  109. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  110. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  111. package/telegram-plugin/worktree-watch-cwds.ts +135 -0
  112. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  113. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  119. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  132. package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
  133. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  134. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  135. package/vendor/hindsight-memory/scripts/recall.py +217 -10
  136. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  137. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  138. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  157. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  158. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  159. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  160. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  161. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  162. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  163. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  164. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  165. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  166. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  167. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  168. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  169. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  170. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  171. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  172. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  173. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  174. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  175. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  176. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  177. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  178. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  179. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  180. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  181. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  182. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  183. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  184. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  185. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  186. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  187. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  188. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  189. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  190. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  191. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  192. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  193. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  194. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  195. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  196. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
  197. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Missed-approvals re-offer digest card (#2862).
3
+ *
4
+ * Pure builders — card text, inline keyboard, callback shape, and the
5
+ * synthetic inbound the gateway injects on Retry — kept out of gateway.ts so
6
+ * they're pinned by tests independent of the bot plumbing (mirrors
7
+ * `skill-proposal-card.ts`).
8
+ *
9
+ * The digest lists the approvals that TTL-expired while the operator was away
10
+ * and offers two taps:
11
+ * • Ask agent to retry → inject a synthetic inbound asking the live agent to
12
+ * re-attempt the actions. This rides the SAME `inject_inbound` /
13
+ * synthesized-inbound path cron uses (claude-native): the agent starts a
14
+ * fresh turn and naturally re-raises a new approval card. It is a QUESTION
15
+ * to the agent — never a re-execution, never a synthesized verdict
16
+ * (no-self-escalation intact).
17
+ * • Dismiss → clear the list and edit the card closed.
18
+ *
19
+ * callback_data shape (fits Telegram's 64-byte limit):
20
+ * missre:retry:<digestId>
21
+ * missre:dismiss:<digestId>
22
+ */
23
+
24
+ import type { InboundMessage } from './ipc-protocol.js'
25
+ import type { MissedApproval } from './missed-approvals-store.js'
26
+
27
+ export const MISSED_APPROVAL_CALLBACK_PREFIX = 'missre:'
28
+ /** How many entries to list in the card before collapsing to "…and N more". */
29
+ export const MISSED_APPROVAL_RENDER_CAP = 10
30
+
31
+ function escapeHtml(s: string): string {
32
+ return s
33
+ .replace(/&/g, '&amp;')
34
+ .replace(/</g, '&lt;')
35
+ .replace(/>/g, '&gt;')
36
+ }
37
+
38
+ /** Compact HH:MM (UTC) stamp for a miss. */
39
+ function fmtTime(ms: number): string {
40
+ const d = new Date(ms)
41
+ const hh = String(d.getUTCHours()).padStart(2, '0')
42
+ const mm = String(d.getUTCMinutes()).padStart(2, '0')
43
+ return `${hh}:${mm}`
44
+ }
45
+
46
+ /**
47
+ * Render the digest card text (HTML parse mode). Lists up to
48
+ * `cap` entries, then a "…and N more" overflow line.
49
+ */
50
+ export function renderMissedApprovalsDigest(
51
+ entries: MissedApproval[],
52
+ opts?: { cap?: number; agentName?: string | null },
53
+ ): string {
54
+ const cap = opts?.cap ?? MISSED_APPROVAL_RENDER_CAP
55
+ const who = opts?.agentName ? escapeHtml(opts.agentName) : 'the agent'
56
+ const shown = entries.slice(0, cap)
57
+ const overflow = entries.length - shown.length
58
+
59
+ const lines: string[] = []
60
+ lines.push('⏳ <b>Missed approvals while you were away</b>')
61
+ lines.push('')
62
+ lines.push('These approval requests timed out before you could respond:')
63
+ for (const e of shown) {
64
+ lines.push(`• ${escapeHtml(e.action)} <i>(${fmtTime(e.timedOutAt)} UTC)</i>`)
65
+ }
66
+ if (overflow > 0) {
67
+ lines.push(`…and ${overflow} more`)
68
+ }
69
+ lines.push('')
70
+ lines.push(
71
+ `<i>Tap “Ask agent to retry” to have ${who} re-attempt them — you’ll be ` +
72
+ 'asked to approve again as normal. Dismiss to clear.</i>',
73
+ )
74
+ return lines.join('\n')
75
+ }
76
+
77
+ /** Inline keyboard for the digest card. */
78
+ export function missedApprovalsKeyboard(digestId: string): {
79
+ inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
80
+ } {
81
+ return {
82
+ inline_keyboard: [
83
+ [
84
+ {
85
+ text: '🔁 Ask agent to retry',
86
+ callback_data: `${MISSED_APPROVAL_CALLBACK_PREFIX}retry:${digestId}`,
87
+ },
88
+ {
89
+ text: '🚫 Dismiss',
90
+ callback_data: `${MISSED_APPROVAL_CALLBACK_PREFIX}dismiss:${digestId}`,
91
+ },
92
+ ],
93
+ ],
94
+ }
95
+ }
96
+
97
+ /** Parse a `missre:` callback into { action, digestId }, or null if not ours. */
98
+ export function parseMissedApprovalCallback(
99
+ data: string,
100
+ ): { action: 'retry' | 'dismiss'; digestId: string } | null {
101
+ if (!data.startsWith(MISSED_APPROVAL_CALLBACK_PREFIX)) return null
102
+ const rest = data.slice(MISSED_APPROVAL_CALLBACK_PREFIX.length)
103
+ const idx = rest.indexOf(':')
104
+ if (idx < 0) return null
105
+ const action = rest.slice(0, idx)
106
+ const digestId = rest.slice(idx + 1)
107
+ if ((action !== 'retry' && action !== 'dismiss') || digestId.length === 0) return null
108
+ return { action, digestId }
109
+ }
110
+
111
+ export interface MissedApprovalInboundContext {
112
+ agent: string
113
+ chat_id: string
114
+ threadId?: number
115
+ }
116
+
117
+ /**
118
+ * Build the synthetic inbound injected when the operator taps "Ask agent to
119
+ * retry". It ASKS the agent to re-attempt the listed actions — it never
120
+ * re-executes them and never carries a verdict (no-self-escalation). The
121
+ * agent starts a fresh turn and naturally re-raises approval cards.
122
+ *
123
+ * `meta.source = "missed_approval_retry"` so the bridge renders it as
124
+ * `<channel source="missed_approval_retry">`.
125
+ */
126
+ export function buildMissedApprovalRetryInbound(opts: {
127
+ ctx: MissedApprovalInboundContext
128
+ actions: string[]
129
+ operatorId: string
130
+ nowMs?: number
131
+ }): InboundMessage {
132
+ const ts = opts.nowMs ?? Date.now()
133
+ const list =
134
+ opts.actions.length === 1
135
+ ? opts.actions[0]
136
+ : opts.actions.map(a => `\n• ${a}`).join('')
137
+ const body =
138
+ opts.actions.length === 1
139
+ ? `Please re-attempt it now: ${list}.`
140
+ : `Please re-attempt them now:${list}`
141
+ return {
142
+ type: 'inbound',
143
+ chatId: opts.ctx.chat_id,
144
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
145
+ messageId: ts,
146
+ user: 'missed-approvals',
147
+ userId: 0,
148
+ ts,
149
+ text:
150
+ `🔁 Operator asked you to retry ${opts.actions.length === 1 ? 'an action' : 'some actions'} ` +
151
+ `that timed out earlier while they were away. ${body} You'll be asked to ` +
152
+ `approve again as normal — do not assume the earlier timeout was a denial.`,
153
+ meta: {
154
+ source: 'missed_approval_retry',
155
+ agent: opts.ctx.agent,
156
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
157
+ operator_id: opts.operatorId,
158
+ action_count: String(opts.actions.length),
159
+ },
160
+ }
161
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Persistence store for approvals that TTL-expired while the operator was
3
+ * away (#2862, "approval cards don't stick — re-offer digest").
4
+ *
5
+ * Problem: when a permission card auto-denies on TTL (the #2411 timeout path)
6
+ * with the operator absent, the gated work is silently dropped and NOTHING
7
+ * re-offers it when the operator returns. For cron-fired work (marko's brevo
8
+ * nurture-drip batches) the batch just doesn't go out and the operator finds
9
+ * out a day later.
10
+ *
11
+ * Fix: at TTL auto-deny the gateway appends the missed request to this store
12
+ * (persisted so a gateway restart — which happens ~14× on a fleet-roll day —
13
+ * doesn't lose the digest). On the FIRST operator-activity event afterwards,
14
+ * the gateway posts ONE compact digest card ("While you were away these
15
+ * approvals timed out: … [Ask agent to retry] [Dismiss]") to the origin
16
+ * surface, then promotes the pending entries into a delivered-digest record
17
+ * (the posted card is now the durable record in chat). Retry synthesizes an
18
+ * inbound asking the agent to re-attempt the actions; Dismiss clears the
19
+ * record and edits the card closed.
20
+ *
21
+ * File format: a single JSON object `{ pending, delivered }`, written
22
+ * synchronously to avoid interleaving on concurrent auto-denies, mode 0o600.
23
+ * Mirrors the `permission-card-store.ts` pattern. Both lists are hard-capped
24
+ * so the file stays tiny under a runaway loop.
25
+ */
26
+
27
+ import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
28
+ import { join } from 'node:path'
29
+
30
+ export interface MissedApproval {
31
+ /** The permission request_id that timed out (dedup key). */
32
+ requestId: string
33
+ /** Raw claude tool name, e.g. `mcp__brevo__post`. */
34
+ toolName: string
35
+ /** Natural-language action (from `naturalAction()`), e.g. "post to Brevo". */
36
+ action: string
37
+ /** Origin surface the card was posted to. */
38
+ chatId: string
39
+ threadId?: number | null
40
+ /** When the TTL auto-deny fired (epoch ms). */
41
+ timedOutAt: number
42
+ }
43
+
44
+ export interface DeliveredDigest {
45
+ /** Short id embedded in the card's callback_data (retry/dismiss routing). */
46
+ digestId: string
47
+ /** Origin surface the digest card was posted to. */
48
+ chatId: string
49
+ threadId?: number | null
50
+ /** The missed entries this digest covers (for the Retry synthesis). */
51
+ entries: MissedApproval[]
52
+ deliveredAt: number
53
+ }
54
+
55
+ interface FileShape {
56
+ pending: MissedApproval[]
57
+ delivered: DeliveredDigest[]
58
+ }
59
+
60
+ export interface MissedApprovalsStore {
61
+ /** Record a newly-missed approval. Idempotent on requestId. */
62
+ add(entry: MissedApproval): void
63
+ /** Undelivered misses, oldest first. */
64
+ listPending(): MissedApproval[]
65
+ pendingCount(): number
66
+ /**
67
+ * Move a set of pending entries into a delivered-digest record. The entries
68
+ * carried on `digest` are removed from `pending` (matched by requestId).
69
+ */
70
+ promoteToDigest(digest: DeliveredDigest): void
71
+ /** Look up a delivered digest by its id (for a retry/dismiss tap). */
72
+ getDigest(digestId: string): DeliveredDigest | undefined
73
+ /** Drop a delivered digest (retry consumed it, or dismiss cleared it). */
74
+ removeDigest(digestId: string): void
75
+ /** Delete the backing file entirely. */
76
+ clear(): void
77
+ }
78
+
79
+ /** Bound the on-disk file even under a runaway auto-deny loop. */
80
+ export const MAX_PENDING = 50
81
+ export const MAX_DELIVERED = 20
82
+
83
+ export function createMissedApprovalsStore(stateDir: string): MissedApprovalsStore {
84
+ const filePath = join(stateDir, 'missed-approvals.json')
85
+
86
+ function read(): FileShape {
87
+ try {
88
+ const raw = readFileSync(filePath, 'utf-8')
89
+ const parsed = JSON.parse(raw) as Partial<FileShape>
90
+ return {
91
+ pending: Array.isArray(parsed?.pending) ? parsed.pending : [],
92
+ delivered: Array.isArray(parsed?.delivered) ? parsed.delivered : [],
93
+ }
94
+ } catch {
95
+ return { pending: [], delivered: [] }
96
+ }
97
+ }
98
+
99
+ function write(f: FileShape): void {
100
+ try {
101
+ writeFileSync(filePath, JSON.stringify(f), { encoding: 'utf-8', mode: 0o600 })
102
+ } catch (err) {
103
+ process.stderr.write(
104
+ `telegram gateway: missed-approvals-store write failed: ${(err as Error).message}\n`,
105
+ )
106
+ }
107
+ }
108
+
109
+ return {
110
+ add(entry) {
111
+ const f = read()
112
+ const idx = f.pending.findIndex(e => e.requestId === entry.requestId)
113
+ if (idx >= 0) {
114
+ f.pending[idx] = entry
115
+ } else {
116
+ f.pending.push(entry)
117
+ }
118
+ // Keep only the most-recent MAX_PENDING (drop oldest by insertion order).
119
+ if (f.pending.length > MAX_PENDING) {
120
+ f.pending = f.pending.slice(f.pending.length - MAX_PENDING)
121
+ }
122
+ write(f)
123
+ },
124
+
125
+ listPending() {
126
+ return read().pending
127
+ },
128
+
129
+ pendingCount() {
130
+ return read().pending.length
131
+ },
132
+
133
+ promoteToDigest(digest) {
134
+ const f = read()
135
+ const ids = new Set(digest.entries.map(e => e.requestId))
136
+ f.pending = f.pending.filter(e => !ids.has(e.requestId))
137
+ // Replace any existing record with the same id, then append.
138
+ f.delivered = f.delivered.filter(d => d.digestId !== digest.digestId)
139
+ f.delivered.push(digest)
140
+ if (f.delivered.length > MAX_DELIVERED) {
141
+ f.delivered = f.delivered.slice(f.delivered.length - MAX_DELIVERED)
142
+ }
143
+ write(f)
144
+ },
145
+
146
+ getDigest(digestId) {
147
+ return read().delivered.find(d => d.digestId === digestId)
148
+ },
149
+
150
+ removeDigest(digestId) {
151
+ const f = read()
152
+ const filtered = f.delivered.filter(d => d.digestId !== digestId)
153
+ if (filtered.length !== f.delivered.length) {
154
+ f.delivered = filtered
155
+ write(f)
156
+ }
157
+ },
158
+
159
+ clear() {
160
+ try {
161
+ unlinkSync(filePath)
162
+ } catch {
163
+ // File may not exist — fine.
164
+ }
165
+ },
166
+ }
167
+ }
@@ -52,11 +52,14 @@ export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'fable', 'default'] as
52
52
  * Shape gate for the model argument. This string is typed literally
53
53
  * into the agent's tmux pane, so the gate is strict by construction:
54
54
  * one token, alphanumeric start, then alphanumerics plus the chars
55
- * that appear in real model ids (`.` `_` `-` and the `[1m]`-style
56
- * variant brackets). No whitespace means no second token can ride
55
+ * that appear in real model ids (`.` `_` `-` `/` and the `[1m]`-style
56
+ * variant brackets). `/` is allowed for OpenRouter-style
57
+ * `sr-vendor/model` ids; it is not a shell metachar inside the
58
+ * double-quoted `claude --model "$_EFFECTIVE_MODEL"` usage, so it can't
59
+ * break the launch. No whitespace means no second token can ride
57
60
  * along; no control characters means no newline/Enter smuggling.
58
61
  */
59
- const MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._\[\]-]{0,99}$/
62
+ const MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._/\[\]-]{0,99}$/
60
63
 
61
64
  export function isValidModelArg(arg: string): boolean {
62
65
  return MODEL_ARG_RE.test(arg)
@@ -133,6 +136,18 @@ export interface ModelCommandDeps {
133
136
  * mechanism as the `/restart` command (hostd-first, SIGTERM fallback).
134
137
  */
135
138
  scheduleRestart: (reason: string) => Promise<void>
139
+ /**
140
+ * Schedule a session-only switch TO a non-Claude (`sr-*` LiteLLM/OpenRouter)
141
+ * model. claude's in-REPL `/model` picker rejects unknown `sr-*` ids, so an
142
+ * inject can't set them. Instead the gateway writes the chosen token to the
143
+ * `.session-model-override` carrier file and gracefully restarts the agent;
144
+ * the next boot launches `claude --model <token>` directly (LiteLLM routes
145
+ * it, no picker validation). Session-only: reverts to the configured default
146
+ * on the following restart. Wired to the same restart dispatch as
147
+ * `scheduleRestart`, plus the carrier write. `model` is the full `sr-*` id
148
+ * (already alias-expanded); `reason` is stamped as the restart reason.
149
+ */
150
+ scheduleModelRelaunch: (model: string, reason: string) => Promise<void>
136
151
  }
137
152
 
138
153
  export interface ModelCommandReply {
@@ -152,6 +167,7 @@ function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
152
167
  '\`/model\` — show the configured model',
153
168
  `\`/model <name>\` — switch the live session (${MODEL_ALIASES.map(a => `\`${a}\``).join(' · ')} or a full model id)`,
154
169
  `_OpenRouter shortcuts:_ ${srAliasExamples}`,
170
+ '_OpenRouter (sr-\\*) switches restart the session (~30s); Claude switches apply instantly._',
155
171
  PERSIST_NOTE,
156
172
  )
157
173
  return { text: lines.join('\n'), html: true }
@@ -214,6 +230,30 @@ export async function handleModelCommand(
214
230
  }
215
231
  }
216
232
 
233
+ // Claude → sr-*: an in-place inject can't set a non-Anthropic model — claude's
234
+ // native `/model` picker rejects the unknown `sr-*` id ("Model not found").
235
+ // Carry the token across a graceful restart and relaunch `claude --model
236
+ // sr-*` directly (LiteLLM routes it). Session-only: reverts to the configured
237
+ // default on the next restart. The sr-* → Claude direction is handled above.
238
+ if (isSrModel(model)) {
239
+ try {
240
+ await deps.scheduleModelRelaunch(model, `user: /model ${model} (session-only relaunch)`)
241
+ } catch (err) {
242
+ const msg = err instanceof Error ? err.message : String(err)
243
+ return {
244
+ text: `❌ Could not schedule model switch: ${deps.escapeHtml(msg)}`,
245
+ html: true,
246
+ }
247
+ }
248
+ return {
249
+ text: [
250
+ `Switching to \`${deps.escapeHtml(model)}\` — restarting session (~30s).`,
251
+ '_Session-only — reverts to the configured default on the next restart._',
252
+ ].join('\n'),
253
+ html: true,
254
+ }
255
+ }
256
+
217
257
  const verbHtml = `\`/model ${deps.escapeHtml(model)}\``
218
258
  let result: InjectResult
219
259
  try {
@@ -358,16 +398,31 @@ export const EXTRA_CLAUDE_ALIASES: ReadonlyArray<{ alias: string; label: string
358
398
  * Friendly display names for sr-* synthetic model names. An sr-* model in
359
399
  * LiteLLM has no entry in `model_group_settings.*.forward_client_headers_to_llm_api`
360
400
  * so the Anthropic OAuth credential is NEVER forwarded — safe to route to
361
- * OpenRouter. Names here are display-only; the raw `sr-*` id is what gets
362
- * injected into the agent's session. See reference/rfcs/litellm-max-subscription-invariants.md § I6.
401
+ * OpenRouter. Names here are display-only (used by srFriendlyLabel for /status
402
+ * and switch confirmations); the raw `sr-*` id is what gets injected into the
403
+ * agent's session. This table is a SUPERSET of the menu-reachable set
404
+ * (SR_MODEL_ALIASES): it also labels models that are only reachable by typing
405
+ * the full `/model sr-<name>` (manual passthrough), so those still get a
406
+ * friendly name without appearing as a keyboard button.
407
+ * See reference/rfcs/litellm-max-subscription-invariants.md § I6.
363
408
  */
364
409
  export const SR_MODEL_LABELS: Record<string, string> = {
365
410
  'sr-gemini-2.5-pro': 'Gemini 2.5 Pro',
366
411
  'sr-gemini-2.5-flash': 'Gemini 2.5 Flash',
367
412
  'sr-deepseek-r1': 'DeepSeek R1',
368
413
  'sr-deepseek-v3': 'DeepSeek V3',
369
- 'sr-glm-5': 'GLM-5',
414
+ // sr-glm-5 now targets glm-5.2 in the live litellm config — label bumped to match.
415
+ 'sr-glm-5': 'GLM-5.2',
370
416
  'sr-codex-5.5': 'Codex 5.5',
417
+ // OpenRouter coverage (pairs with the live litellm sr-* model_name additions).
418
+ 'sr-gpt-oss-20b': 'GPT-OSS 20B',
419
+ 'sr-gpt-oss-120b': 'GPT-OSS 120B',
420
+ 'sr-gpt-5.5': 'GPT-5.5',
421
+ 'sr-gpt-5-codex': 'GPT-5 Codex',
422
+ 'sr-gpt-5.2-codex': 'GPT-5.2 Codex',
423
+ 'sr-gemini-flash-lite': 'Gemini 3.1 Flash Lite',
424
+ 'sr-minimax-m3': 'MiniMax M3',
425
+ 'sr-deepseek-v4-flash': 'DeepSeek V4 Flash',
371
426
  }
372
427
 
373
428
  /**
@@ -448,10 +503,15 @@ function headerRow(label: string): ModelMenuKeyboardButton[] {
448
503
  * requires ANTHROPIC_CUSTOM_HEADERS (a litellm key) to be set on the gateway
449
504
  * process. switchroom never sets that env on the gateway, so in production
450
505
  * discoverSrModels() always returns [] and the external group was silently
451
- * empty. The six SR_MODEL_ALIASES targets are the sr-* names the litellm
452
- * config actually exposes, so seeding from them makes the group reliable
453
- * without the missing env — while still merging any live results on hosts
454
- * that do configure discovery.
506
+ * empty. The six SR_MODEL_ALIASES targets are the CURATED main sr-* set, so
507
+ * seeding from them makes the group reliable without the missing env — while
508
+ * still merging any live results on hosts that do configure discovery.
509
+ *
510
+ * Deliberately a SUBSET: the litellm config exposes more sr-* models than this
511
+ * (the full OpenRouter catalogue). Those extras are display-labelled in
512
+ * SR_MODEL_LABELS and remain typeable via `/model <full-sr-name>` (manual
513
+ * passthrough — the set path is a shape gate, no whitelist), but they are kept
514
+ * OUT of SR_MODEL_ALIASES on purpose so the keyboard stays small and curated.
455
515
  *
456
516
  * Subscription-honest: ONLY the curated sr-* aliases surface as buttons. Raw
457
517
  * gpt-4o / openrouter/* dupes / voyage-* embeddings never do.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Pure decision helpers for permission-card re-arm across gateway restarts
3
+ * (#2861 / umbrella #2859).
4
+ *
5
+ * gateway.ts has top-level side effects and can't be unit-imported, so the
6
+ * discriminable logic lives here where it can be tested in isolation. The
7
+ * gateway wires these into `onPermissionRequest` and the boot-sweep.
8
+ *
9
+ * The mechanism, end to end:
10
+ *
11
+ * 1. The bridge keeps an outstanding-request ledger and re-sends every
12
+ * unresolved `permission_request` to the gateway on each IPC (re)connect
13
+ * (see `bridge/permission-ledger.ts`). So the gateway MUST treat repeat
14
+ * `permission_request`s for the same request_id as idempotent.
15
+ *
16
+ * 2. `classifyPermissionRequest` decides what a `permission_request` is:
17
+ * - `duplicate` — already live in `pendingPermissions` → ignore (no
18
+ * second card). The bridge re-sends on every reconnect.
19
+ * - `rearm` — not in memory, but the persisted card store still has
20
+ * entries for it → a gateway restart orphaned it. Restore the
21
+ * pending entry with the ORIGINAL `startedAt` (the TTL clock keeps
22
+ * running from the original ask; an already-expired re-arm falls
23
+ * straight into the existing TTL auto-deny sweep, which correctly
24
+ * unwedges claude) and re-attach the keyboard on the EXISTING
25
+ * message(s) via editMessageText — never a new card.
26
+ * - `fresh` — a brand-new ask; post a card as usual.
27
+ *
28
+ * 3. The boot-sweep becomes a grace-period strip. At boot, instead of
29
+ * stripping persisted cards immediately, wait ~90 s, then strip only the
30
+ * cards whose request_id is NOT currently live in `pendingPermissions`.
31
+ * A live entry means either a bridge re-send re-armed the card, OR a fresh
32
+ * approval was posted during the window — both are legitimately-pending
33
+ * asks. Only a genuinely dead session's card (container recreate → new
34
+ * bridge, empty ledger, never re-sends → no live entry) gets today's
35
+ * "Gateway restarted" strip.
36
+ *
37
+ * NEVER auto-allows anything: re-arm restores only the QUESTION, never a
38
+ * verdict (no-self-escalation invariant). No model callsite is involved —
39
+ * verdicts still travel only over the existing IPC/MCP channel.
40
+ */
41
+
42
+ import type { PersistedPermCard } from './permission-card-store.js'
43
+
44
+ /** Default grace window before the boot-sweep strips an un-re-claimed card. */
45
+ export const PERMISSION_REARM_GRACE_MS = 90_000
46
+
47
+ /**
48
+ * Kill switch. `SWITCHROOM_PERMISSION_REARM=0` reverts to the legacy
49
+ * immediate-strip (gateway) / drop-when-disconnected (bridge) behavior.
50
+ */
51
+ export function isPermissionRearmEnabled(
52
+ env: NodeJS.ProcessEnv = process.env,
53
+ ): boolean {
54
+ return env.SWITCHROOM_PERMISSION_REARM !== '0'
55
+ }
56
+
57
+ /**
58
+ * Resolve the boot-sweep grace window (ms). `SWITCHROOM_PERMISSION_REARM_GRACE_MS`
59
+ * overrides the default (used by tests to collapse the wait). A non-finite or
60
+ * negative override falls back to the default.
61
+ */
62
+ export function permissionRearmGraceMs(
63
+ env: NodeJS.ProcessEnv = process.env,
64
+ ): number {
65
+ const raw = env.SWITCHROOM_PERMISSION_REARM_GRACE_MS
66
+ if (raw == null || raw === '') return PERMISSION_REARM_GRACE_MS
67
+ const n = Number(raw)
68
+ return Number.isFinite(n) && n >= 0 ? n : PERMISSION_REARM_GRACE_MS
69
+ }
70
+
71
+ export type PermissionRequestDisposition = 'duplicate' | 'rearm' | 'fresh'
72
+
73
+ /**
74
+ * Classify an incoming `permission_request` (pure).
75
+ *
76
+ * @param hasPending is the request_id already live in `pendingPermissions`?
77
+ * @param persistedCount how many persisted card entries exist for this request_id?
78
+ */
79
+ export function classifyPermissionRequest(opts: {
80
+ hasPending: boolean
81
+ persistedCount: number
82
+ }): PermissionRequestDisposition {
83
+ if (opts.hasPending) return 'duplicate'
84
+ if (opts.persistedCount > 0) return 'rearm'
85
+ return 'fresh'
86
+ }
87
+
88
+ /**
89
+ * Which persisted cards should the boot-sweep strip at the grace deadline?
90
+ * Everything whose request_id is NOT currently live.
91
+ *
92
+ * `liveRequestIds` is the set of request_ids present in `pendingPermissions`
93
+ * at the deadline — which covers BOTH:
94
+ * - re-armed cards (a bridge re-send restored the pending entry), and
95
+ * - fresh cards born during the grace window (a new approval posted after
96
+ * boot sets a pending entry the normal way).
97
+ * Both are legitimately-pending asks a claude session is still suspended on;
98
+ * stripping either would re-wedge the turn. Only a genuinely dead session's
99
+ * card — persisted but with no live pending entry (the bridge never
100
+ * reconnected to re-arm it) — is stripped. Keying on liveness rather than a
101
+ * "was re-claimed" flag is what makes fresh-during-grace cards safe.
102
+ */
103
+ export function computeBootSweepStripTargets(
104
+ stale: readonly PersistedPermCard[],
105
+ liveRequestIds: ReadonlySet<string>,
106
+ ): PersistedPermCard[] {
107
+ return stale.filter(card => !liveRequestIds.has(card.requestId))
108
+ }
109
+
110
+ /** Distinct request_ids in a persisted-card list (for per-request store removal). */
111
+ export function distinctRequestIds(
112
+ cards: readonly PersistedPermCard[],
113
+ ): string[] {
114
+ return [...new Set(cards.map(c => c.requestId))]
115
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Persistence for the gateway's 30-min scoped-approval windows
3
+ * (`scopedGrants: ScopedGrantStore`, see ../scoped-approval.ts).
4
+ *
5
+ * Problem: the store is in-memory. Any gateway restart (fleet roll, config
6
+ * apply, failover probe — marko saw ~14 boots on 2026-07-05) wipes it, so an
7
+ * action the operator allowed with a 30-min scope minutes ago re-cards
8
+ * immediately. It reads as "my approval didn't stick."
9
+ *
10
+ * Fix: mirror the store to a tiny JSON file in STATE_DIR (same shape as
11
+ * permission-card-store.ts — synchronous writes, mode 0o600, one small file).
12
+ * Write-through on every grant and on sweep-expiry removal; reload at boot,
13
+ * dropping entries already past their ABSOLUTE expiry.
14
+ *
15
+ * This only widens WHEN a still-valid window survives a restart — never WHAT
16
+ * qualifies. Expiry is absolute wall-clock, captured at the original tap, so a
17
+ * restart can never extend a window. All fail-closed semantics
18
+ * (destructive-Bash never scoped, expiry lookup fails closed) live in
19
+ * scoped-approval.ts and are untouched — a reloaded grant runs the identical
20
+ * `lookupScopedGrant` gate as an in-memory one.
21
+ *
22
+ * Kill switch: SWITCHROOM_SCOPED_GRANT_PERSIST=0 → boot with an empty store
23
+ * and never write (any pre-existing file is ignored).
24
+ */
25
+
26
+ import { readFileSync, writeFileSync } from 'node:fs'
27
+ import { join } from 'node:path'
28
+ import {
29
+ serializeScopedGrants,
30
+ deserializeScopedGrants,
31
+ type ScopedGrantStore,
32
+ } from '../scoped-approval.js'
33
+
34
+ export interface ScopedGrantPersistence {
35
+ /** True unless the kill switch disabled persistence. */
36
+ readonly enabled: boolean
37
+ /** Load surviving grants (absolute expiry > now). Empty when disabled. */
38
+ load(now: number): ScopedGrantStore
39
+ /** Write-through the current store. No-op when disabled. */
40
+ save(store: ScopedGrantStore): void
41
+ }
42
+
43
+ /** Persistence is on unless SWITCHROOM_SCOPED_GRANT_PERSIST is exactly "0". */
44
+ export function scopedGrantPersistEnabled(
45
+ env: Record<string, string | undefined> = process.env,
46
+ ): boolean {
47
+ return env.SWITCHROOM_SCOPED_GRANT_PERSIST !== '0'
48
+ }
49
+
50
+ export function createScopedGrantStore(
51
+ stateDir: string,
52
+ env: Record<string, string | undefined> = process.env,
53
+ ): ScopedGrantPersistence {
54
+ const filePath = join(stateDir, 'scoped-grants.json')
55
+ const enabled = scopedGrantPersistEnabled(env)
56
+
57
+ function read(): unknown[] {
58
+ try {
59
+ const raw = readFileSync(filePath, 'utf-8')
60
+ const parsed = JSON.parse(raw)
61
+ return Array.isArray(parsed) ? parsed : []
62
+ } catch {
63
+ return []
64
+ }
65
+ }
66
+
67
+ return {
68
+ enabled,
69
+
70
+ load(now) {
71
+ if (!enabled) return new Map()
72
+ return deserializeScopedGrants(read(), now)
73
+ },
74
+
75
+ save(store) {
76
+ if (!enabled) return
77
+ try {
78
+ writeFileSync(filePath, JSON.stringify(serializeScopedGrants(store)), {
79
+ encoding: 'utf-8',
80
+ mode: 0o600,
81
+ })
82
+ } catch (err) {
83
+ process.stderr.write(
84
+ `telegram gateway: scoped-grant-store write failed: ${(err as Error).message}\n`,
85
+ )
86
+ }
87
+ },
88
+ }
89
+ }