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
@@ -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
+ }
@@ -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
+ }