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,223 @@
1
+ /**
2
+ * Tests for the persistence of the 30-min scoped-approval windows across
3
+ * gateway restarts (#2863). The store (scopedGrants) is in-memory and used
4
+ * to evaporate on any gateway bounce, re-carding an action the operator
5
+ * allowed minutes ago. These pin:
6
+ *
7
+ * - round-trip: record → persist → reload → lookup still hits in-window;
8
+ * - entries already past their ABSOLUTE expiry are dropped at reload;
9
+ * - a restart NEVER extends a window (grant at T, reload at T+29min →
10
+ * ~1 min left, not a fresh 30);
11
+ * - fail-closed survives a reload (a destructive Bash command still
12
+ * re-cards even when a reloaded family grant matches; an expired grant
13
+ * never matches);
14
+ * - kill switch (SWITCHROOM_SCOPED_GRANT_PERSIST=0): no writes, boot
15
+ * ignores any pre-existing file.
16
+ *
17
+ * The persistence I/O (createScopedGrantStore) is thin; the invariants live
18
+ * in the pure serialize/deserialize + lookup, so we drive both together with
19
+ * an isolated tmpdir (never ~/.switchroom or a shared STATE_DIR).
20
+ */
21
+
22
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
23
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, statSync } from 'node:fs'
24
+ import { join } from 'node:path'
25
+ import { tmpdir } from 'node:os'
26
+ import {
27
+ SCOPED_APPROVAL_DEFAULT_TTL_MS,
28
+ recordScopedGrant,
29
+ lookupScopedGrant,
30
+ sweepScopedGrants,
31
+ serializeScopedGrants,
32
+ deserializeScopedGrants,
33
+ countScopedGrants,
34
+ type ScopedGrantStore,
35
+ } from '../scoped-approval.js'
36
+ import { createScopedGrantStore, scopedGrantPersistEnabled } from '../gateway/scoped-grant-store.js'
37
+
38
+ const T0 = 1_000_000
39
+ const TTL = SCOPED_APPROVAL_DEFAULT_TTL_MS
40
+ const AGENT = 'marko'
41
+
42
+ const editInput = (path: string) => JSON.stringify({ file_path: path })
43
+ const bashInput = (command: string) => JSON.stringify({ command })
44
+
45
+ function makeTmpDir() {
46
+ return mkdtempSync(join(tmpdir(), 'scoped-grant-persist-test-'))
47
+ }
48
+
49
+ function filePath(dir: string) {
50
+ return join(dir, 'scoped-grants.json')
51
+ }
52
+
53
+ describe('scoped-grant persistence', () => {
54
+ let dir: string
55
+
56
+ beforeEach(() => {
57
+ dir = makeTmpDir()
58
+ })
59
+ afterEach(() => {
60
+ rmSync(dir, { recursive: true, force: true })
61
+ })
62
+
63
+ it('round-trips: record → persist → reload → lookup hits within the window', () => {
64
+ const store = createScopedGrantStore(dir, {})
65
+ expect(store.enabled).toBe(true)
66
+
67
+ // Operator taps Allow on a narrow file edit at T0.
68
+ const live: ScopedGrantStore = new Map()
69
+ recordScopedGrant(live, AGENT, 'Edit(/state/x.ts)', T0, TTL)
70
+ store.save(live)
71
+
72
+ expect(existsSync(filePath(dir))).toBe(true)
73
+
74
+ // Gateway restarts 5 minutes later; reload the store.
75
+ const reloadAt = T0 + 5 * 60_000
76
+ const reloaded = store.load(reloadAt)
77
+
78
+ // The window is still live → an identical action auto-allows (no re-card).
79
+ const hit = lookupScopedGrant(reloaded, AGENT, 'Edit', editInput('/state/x.ts'), reloadAt)
80
+ expect(hit).toBe('Edit(/state/x.ts)')
81
+ })
82
+
83
+ it('drops entries already past their absolute expiry at reload', () => {
84
+ const store = createScopedGrantStore(dir, {})
85
+ const live: ScopedGrantStore = new Map()
86
+ recordScopedGrant(live, AGENT, 'Edit(/state/x.ts)', T0, TTL)
87
+ store.save(live)
88
+
89
+ // Reload well after the 30-min window has elapsed.
90
+ const reloadAt = T0 + TTL + 60_000
91
+ const reloaded = store.load(reloadAt)
92
+
93
+ expect(countScopedGrants(reloaded)).toBe(0)
94
+ const hit = lookupScopedGrant(reloaded, AGENT, 'Edit', editInput('/state/x.ts'), reloadAt)
95
+ expect(hit).toBeNull()
96
+ })
97
+
98
+ it('a restart does NOT extend the window (absolute expiry preserved)', () => {
99
+ const store = createScopedGrantStore(dir, {})
100
+ const live: ScopedGrantStore = new Map()
101
+ recordScopedGrant(live, AGENT, 'Edit(/state/x.ts)', T0, TTL) // expiresAt = T0 + 30min
102
+ store.save(live)
103
+
104
+ // Reload at T0 + 29 min: only ~1 min should remain, not a fresh 30.
105
+ const reloadAt = T0 + 29 * 60_000
106
+ const reloaded = store.load(reloadAt)
107
+
108
+ // Still live one minute before expiry...
109
+ expect(lookupScopedGrant(reloaded, AGENT, 'Edit', editInput('/state/x.ts'), reloadAt)).toBe(
110
+ 'Edit(/state/x.ts)',
111
+ )
112
+ // ...but dead ~90s later — the original absolute expiry is honoured, the
113
+ // reload gave it no new lease.
114
+ const afterOriginalExpiry = T0 + TTL + 1_000
115
+ expect(
116
+ lookupScopedGrant(reloaded, AGENT, 'Edit', editInput('/state/x.ts'), afterOriginalExpiry),
117
+ ).toBeNull()
118
+ })
119
+
120
+ it('fail-closed survives reload: destructive Bash never auto-allows', () => {
121
+ const store = createScopedGrantStore(dir, {})
122
+ const live: ScopedGrantStore = new Map()
123
+ // A benign `git status` earned a Bash(git:*) family window.
124
+ recordScopedGrant(live, AGENT, 'Bash(git:*)', T0, TTL)
125
+ store.save(live)
126
+
127
+ const reloadAt = T0 + 60_000
128
+ const reloaded = store.load(reloadAt)
129
+
130
+ // Benign member still auto-allows.
131
+ expect(lookupScopedGrant(reloaded, AGENT, 'Bash', bashInput('git status'), reloadAt)).toBe(
132
+ 'Bash(git:*)',
133
+ )
134
+ // Destructive member of the SAME family fails closed after reload.
135
+ expect(
136
+ lookupScopedGrant(reloaded, AGENT, 'Bash', bashInput('git push --force'), reloadAt),
137
+ ).toBeNull()
138
+ expect(
139
+ lookupScopedGrant(reloaded, AGENT, 'Bash', bashInput('git reset --hard'), reloadAt),
140
+ ).toBeNull()
141
+ })
142
+
143
+ it('sweep-expiry removal is written through and a reload cannot resurrect it', () => {
144
+ const store = createScopedGrantStore(dir, {})
145
+ const live: ScopedGrantStore = new Map()
146
+ recordScopedGrant(live, AGENT, 'Edit(/state/x.ts)', T0, TTL)
147
+ store.save(live)
148
+
149
+ // Periodic sweep runs past expiry and removes the entry; write it through.
150
+ const sweepAt = T0 + TTL + 1_000
151
+ sweepScopedGrants(live, sweepAt)
152
+ store.save(live)
153
+
154
+ // A restart immediately after must see an empty store.
155
+ const reloaded = store.load(sweepAt + 1)
156
+ expect(countScopedGrants(reloaded)).toBe(0)
157
+ })
158
+
159
+ it('kill switch: no writes, and boot ignores a pre-existing file', () => {
160
+ // Seed a live grant on disk as if a prior (persist-enabled) boot wrote it.
161
+ const seeded = createScopedGrantStore(dir, {})
162
+ const live: ScopedGrantStore = new Map()
163
+ recordScopedGrant(live, AGENT, 'Edit(/state/x.ts)', T0, TTL)
164
+ seeded.save(live)
165
+ const onDiskBefore = readFileSync(filePath(dir), 'utf-8')
166
+
167
+ // Now boot with the kill switch on.
168
+ const killed = createScopedGrantStore(dir, { SWITCHROOM_SCOPED_GRANT_PERSIST: '0' })
169
+ expect(killed.enabled).toBe(false)
170
+ expect(scopedGrantPersistEnabled({ SWITCHROOM_SCOPED_GRANT_PERSIST: '0' })).toBe(false)
171
+
172
+ // Boot ignores the pre-existing file → empty store.
173
+ const reloaded = killed.load(T0 + 60_000)
174
+ expect(countScopedGrants(reloaded)).toBe(0)
175
+
176
+ // save() is a no-op — the file is not touched.
177
+ const grantsToPersist: ScopedGrantStore = new Map()
178
+ recordScopedGrant(grantsToPersist, AGENT, 'Edit(/state/y.ts)', T0, TTL)
179
+ killed.save(grantsToPersist)
180
+ expect(readFileSync(filePath(dir), 'utf-8')).toBe(onDiskBefore)
181
+ })
182
+
183
+ it('pure serialize/deserialize drops malformed and expired rows (fail-closed)', () => {
184
+ const now = T0
185
+ const raw = [
186
+ { agent: AGENT, rule: 'Edit(/state/live.ts)', expiresAt: now + TTL }, // keep
187
+ { agent: AGENT, rule: 'Edit(/state/gone.ts)', expiresAt: now - 1 }, // expired → drop
188
+ { agent: AGENT, rule: 'Edit(/state/x.ts)' }, // no expiresAt → drop
189
+ { rule: 'Edit(/state/x.ts)', expiresAt: now + TTL }, // no agent → drop
190
+ { agent: AGENT, rule: '', expiresAt: now + TTL }, // empty rule → drop
191
+ 'garbage', // not an object → drop
192
+ null, // → drop
193
+ ]
194
+ const store = deserializeScopedGrants(raw, now)
195
+ expect(countScopedGrants(store)).toBe(1)
196
+ expect(lookupScopedGrant(store, AGENT, 'Edit', editInput('/state/live.ts'), now)).toBe(
197
+ 'Edit(/state/live.ts)',
198
+ )
199
+ })
200
+
201
+ it('serialize captures absolute expiry, not a relative TTL', () => {
202
+ const live: ScopedGrantStore = new Map()
203
+ recordScopedGrant(live, AGENT, 'Edit(/state/x.ts)', T0, TTL)
204
+ const flat = serializeScopedGrants(live)
205
+ expect(flat).toEqual([{ agent: AGENT, rule: 'Edit(/state/x.ts)', expiresAt: T0 + TTL }])
206
+ })
207
+
208
+ it('corrupt on-disk JSON reloads as an empty store (fail-closed, no throw)', () => {
209
+ writeFileSync(filePath(dir), '{ this is not json', 'utf-8')
210
+ const store = createScopedGrantStore(dir, {})
211
+ const reloaded = store.load(T0)
212
+ expect(countScopedGrants(reloaded)).toBe(0)
213
+ })
214
+
215
+ it('persisted file is written with mode 0o600', () => {
216
+ const store = createScopedGrantStore(dir, {})
217
+ const live: ScopedGrantStore = new Map()
218
+ recordScopedGrant(live, AGENT, 'Edit(/state/x.ts)', T0, TTL)
219
+ store.save(live)
220
+ const mode = statSync(filePath(dir)).mode & 0o777
221
+ expect(mode).toBe(0o600)
222
+ })
223
+ })
@@ -0,0 +1,290 @@
1
+ /**
2
+ * silent-end-transport.test.ts — PR 2 of
3
+ * reference/rfcs/deterministic-turn-liveness.md (Phase 2: audit-and-harden
4
+ * the shipped dark-turn guard, per reference/jobs/know-what-my-agent-is-doing.md).
5
+ *
6
+ * Prior coverage (`silent-end.test.ts`, `silent-end-integration.test.ts`)
7
+ * proves the *decision* — state-file lifecycle, retryCount bookkeeping,
8
+ * the `{exhausted}` contract. It never asserts a byte reaches the
9
+ * transport, which is exactly the decision-vs-delivery gap the RFC calls
10
+ * out for the muted 45s floor (#2667) and asks Phase 2 to wall off for
11
+ * the dark-turn guard instead of re-creating it.
12
+ *
13
+ * This file reproduces the EXACT call sequence at the gateway's turn_end
14
+ * handler (gateway.ts ~14486-14535: `if (turn.finalAnswerDelivered ===
15
+ * false) { const silentEnd = recordUndeliveredTurnEnd(...); if
16
+ * (silentEnd.exhausted) { <send> } }`) against a spy transport standing in
17
+ * for `bot.api.sendMessage`, using the REAL exported
18
+ * `recordUndeliveredTurnEnd` / `silentEndFallbackText` /
19
+ * `isFinalAnswerReply` — same pattern `silent-end-integration.test.ts`
20
+ * already established for testing gateway call-sites that aren't
21
+ * themselves exported (gateway.ts is a multi-thousand-line module with
22
+ * process-level side effects on import; see that file's "KNOWN GAP" note).
23
+ * The spy asserts the SEND CALL is made (outcome, not a stubbed decision
24
+ * callback); it does not drive grammY itself — that last hop stays covered
25
+ * by the UAT harness.
26
+ */
27
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
28
+ import { mkdtempSync, rmSync, writeFileSync as writeFileSyncState } from 'node:fs'
29
+ import { tmpdir } from 'node:os'
30
+ import { join } from 'node:path'
31
+ import {
32
+ writeSilentEndState,
33
+ readSilentEndState,
34
+ recordUndeliveredTurnEnd,
35
+ silentEndFallbackText,
36
+ SILENT_END_MAX_RETRIES,
37
+ type SilentEndDeps,
38
+ } from '../silent-end.js'
39
+ import { isFinalAnswerReply } from '../final-answer-detect.js'
40
+
41
+ let stateDir: string
42
+ const ORIG_ENV = process.env.TELEGRAM_STATE_DIR
43
+
44
+ beforeEach(() => {
45
+ stateDir = mkdtempSync(join(tmpdir(), 'silent-end-transport-test-'))
46
+ process.env.TELEGRAM_STATE_DIR = stateDir
47
+ })
48
+
49
+ afterEach(() => {
50
+ rmSync(stateDir, { recursive: true, force: true })
51
+ if (ORIG_ENV != null) process.env.TELEGRAM_STATE_DIR = ORIG_ENV
52
+ else delete process.env.TELEGRAM_STATE_DIR
53
+ })
54
+
55
+ /** Spy transport — records every "send" with a wall-clock timestamp, the
56
+ * same shape a `bot.api.sendMessage` spy would capture at the real
57
+ * transport boundary. */
58
+ interface SentMessage {
59
+ chatId: string
60
+ text: string
61
+ ts: number
62
+ }
63
+
64
+ function makeTransport() {
65
+ const sent: SentMessage[] = []
66
+ return {
67
+ sent,
68
+ send: (chatId: string, text: string) => {
69
+ sent.push({ chatId, text, ts: Date.now() })
70
+ },
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Faithful reproduction of the gateway's turn_end branch
76
+ * (gateway.ts ~14486-14535). Real functions in, spy transport out —
77
+ * including the REAL `silentEndFallbackText` (exported from
78
+ * silent-end.ts so the assertions below validate the shipped copy,
79
+ * not a hand-maintained duplicate).
80
+ */
81
+ function simulateTurnEnd(
82
+ args: {
83
+ chatId: string
84
+ threadId: number | null
85
+ turnKey: string
86
+ finalAnswerDelivered: boolean
87
+ turnDurationMs: number
88
+ },
89
+ transport: ReturnType<typeof makeTransport>,
90
+ deps?: SilentEndDeps,
91
+ ): { exhausted: boolean } | null {
92
+ if (args.finalAnswerDelivered === false) {
93
+ const silentEnd = recordUndeliveredTurnEnd(
94
+ { chatId: args.chatId, threadId: args.threadId, turnKey: args.turnKey },
95
+ deps,
96
+ )
97
+ if (silentEnd.exhausted) {
98
+ transport.send(args.chatId, silentEndFallbackText(args.turnDurationMs))
99
+ }
100
+ return silentEnd
101
+ }
102
+ return null
103
+ }
104
+
105
+ /** Drives the Stop-hook re-prompt ladder up to (but not past) MAX_RETRIES,
106
+ * exactly as `silent-end-interrupt-stop.mjs` does on each block. */
107
+ function driveRetryLadderToMax(turnKey: string) {
108
+ const path = join(stateDir, 'silent-end-pending.json')
109
+ const s = readSilentEndState()!
110
+ writeFileSyncState(path, JSON.stringify({ ...s, retryCount: SILENT_END_MAX_RETRIES, turnKey }), 'utf8')
111
+ }
112
+
113
+ describe('silent-end dark-turn guard — transport-boundary outcome tests (RFC Phase 2)', () => {
114
+ it('(a) full dark turn: exactly one fallback message lands on the real surface', () => {
115
+ const transport = makeTransport()
116
+ const turnKey = 'c:_'
117
+
118
+ // First dark turn-end — writes state, does NOT deliver a fallback
119
+ // (the Stop-hook re-prompt ladder hasn't been exhausted yet).
120
+ simulateTurnEnd(
121
+ { chatId: 'c', threadId: null, turnKey, finalAnswerDelivered: false, turnDurationMs: 12_000 },
122
+ transport,
123
+ )
124
+ expect(transport.sent).toHaveLength(0)
125
+
126
+ // Stop hook re-prompts twice (SILENT_END_MAX_RETRIES), agent stays
127
+ // silent both times — ladder exhausted.
128
+ driveRetryLadderToMax(turnKey)
129
+
130
+ // Re-prompted turn ends dark again — budget spent, deliver fallback.
131
+ simulateTurnEnd(
132
+ { chatId: 'c', threadId: null, turnKey, finalAnswerDelivered: false, turnDurationMs: 47_000 },
133
+ transport,
134
+ )
135
+
136
+ expect(transport.sent).toHaveLength(1)
137
+ expect(transport.sent[0]!.chatId).toBe('c')
138
+ expect(transport.sent[0]!.text).toContain('waited 47s')
139
+ expect(transport.sent[0]!.text).toContain('didn’t send a reply')
140
+ // Once delivered, the state must not linger to double-fire on a
141
+ // subsequent, unrelated dark turn on the same chat/thread.
142
+ expect(readSilentEndState()).toBeNull()
143
+ })
144
+
145
+ it('(b) deliberate silent end (NO_REPLY / HEARTBEAT_OK) never fires the guard', () => {
146
+ // The gateway sets `turn.finalAnswerDelivered = true` for NO_REPLY /
147
+ // HEARTBEAT_OK silent markers BEFORE reaching this branch
148
+ // (gateway.ts ~14192, ~13880, ~10762 — the sentinel-suppression
149
+ // sites) — so the branch is never entered at all for a legitimately
150
+ // silent turn. Reproduce that gate here.
151
+ const transport = makeTransport()
152
+ simulateTurnEnd(
153
+ { chatId: 'c', threadId: null, turnKey: 'c:_', finalAnswerDelivered: true, turnDurationMs: 5_000 },
154
+ transport,
155
+ )
156
+ expect(transport.sent).toHaveLength(0)
157
+ expect(readSilentEndState()).toBeNull()
158
+ })
159
+
160
+ it('(c) normal replied turn: zero fallback messages, regardless of prior stale state', () => {
161
+ const transport = makeTransport()
162
+ // A prior turn on this chat/thread already reached exhaustion and
163
+ // was cleared (see test (a)) — simulate a completely normal turn
164
+ // afterwards; the branch must not even be entered.
165
+ simulateTurnEnd(
166
+ { chatId: 'c', threadId: null, turnKey: 'c:_', finalAnswerDelivered: true, turnDurationMs: 3_000 },
167
+ transport,
168
+ )
169
+ expect(transport.sent).toHaveLength(0)
170
+ })
171
+
172
+ it('(d) the guard never double-fires: two calls to the exhausted branch on the same spent record yield exactly one send', () => {
173
+ const transport = makeTransport()
174
+ const turnKey = 'c:dup'
175
+ writeSilentEndState({ chatId: 'c', threadId: null, turnKey })
176
+ driveRetryLadderToMax(turnKey)
177
+
178
+ simulateTurnEnd(
179
+ { chatId: 'c', threadId: null, turnKey, finalAnswerDelivered: false, turnDurationMs: 10_000 },
180
+ transport,
181
+ )
182
+ expect(transport.sent).toHaveLength(1)
183
+ // State is cleared by the first exhausted call — a defensive SECOND
184
+ // call for the exact same still-dark turn (e.g. a duplicate turn_end
185
+ // event) finds no prior state and starts a FRESH retry cycle rather
186
+ // than re-firing the fallback a second time.
187
+ const second = simulateTurnEnd(
188
+ { chatId: 'c', threadId: null, turnKey, finalAnswerDelivered: false, turnDurationMs: 10_050 },
189
+ transport,
190
+ )
191
+ expect(second?.exhausted).toBe(false)
192
+ expect(transport.sent).toHaveLength(1)
193
+ })
194
+
195
+ it('(d2) staleness hardening: an exhausted-looking record left by an ALREADY-ANSWERED turn on the same chat/thread must not fire the fallback for a brand-new dark turn', () => {
196
+ // silent-end's turnKey is `statusKey(chatId, threadId)` — stable
197
+ // across every turn on a chat/thread, not a per-turn nonce. If a
198
+ // reply's `clearSilentEndState` call is ever missed (it is
199
+ // fail-silent by design), a spent retryCount from an OLD,
200
+ // genuinely-answered turn could otherwise be misread as THIS
201
+ // brand-new dark turn's already-exhausted budget — firing the
202
+ // fallback without ever running the Stop-hook re-prompt ladder.
203
+ //
204
+ // This is the same class of bug #2472 fixed in the represent guard
205
+ // (gateway/represent-guard.ts shouldSuppressRepresent): a
206
+ // satisfied-but-misdetected obligation must be verified against real
207
+ // delivery history, not trusted from key equality + a counter alone.
208
+ const transport = makeTransport()
209
+ const turnKey = 'c:_'
210
+ const staleTimestamp = 1_000_000
211
+
212
+ // Simulate the missed-clear: an old, already-answered turn left the
213
+ // ladder pinned at MAX_RETRIES and (because the clear was missed) the
214
+ // file was never removed.
215
+ writeSilentEndState({ chatId: 'c', threadId: null, turnKey })
216
+ driveRetryLadderToMax(turnKey)
217
+ // Force the stale timestamp to predate the "reply" the deps stub
218
+ // reports below.
219
+ const path = join(stateDir, 'silent-end-pending.json')
220
+ const s = readSilentEndState()!
221
+ writeFileSyncState(path, JSON.stringify({ ...s, timestamp: staleTimestamp }), 'utf8')
222
+
223
+ // A reply WAS in fact delivered on this chat (the missed-clear case)
224
+ // at some point after the stale record's timestamp.
225
+ const deps: SilentEndDeps = {
226
+ hasOutboundDeliveredSince: (chatId, sinceMs) =>
227
+ chatId === 'c' && sinceMs <= staleTimestamp + 500,
228
+ }
229
+
230
+ // A genuinely NEW, unrelated dark turn now ends on the same
231
+ // chat/thread (same turnKey by construction).
232
+ const result = simulateTurnEnd(
233
+ { chatId: 'c', threadId: null, turnKey, finalAnswerDelivered: false, turnDurationMs: 8_000 },
234
+ transport,
235
+ deps,
236
+ )
237
+
238
+ expect(result?.exhausted).toBe(false)
239
+ expect(transport.sent).toHaveLength(0)
240
+ // CRITICAL (adversarial review of #2892): the "fresh retry cycle" must
241
+ // actually be fresh. writeSilentEndState re-inherits retryCount when
242
+ // the on-disk turnKey matches (which it ALWAYS does here — turnKey is
243
+ // statusKey). If the staleness branch writes without clearing first,
244
+ // the new record starts at retryCount=MAX: the Stop hook
245
+ // (silent-end-interrupt-stop.mjs) sees retryCount >= MAX_RETRIES and
246
+ // never re-prompts, while the gateway just got exhausted:false and
247
+ // sent no fallback either — pure silence, worse than no fix at all.
248
+ expect(readSilentEndState()!.retryCount).toBe(0)
249
+ })
250
+
251
+ it('(d3) staleness hardening does NOT suppress a genuine, fresh exhaustion (no reply since the record)', () => {
252
+ const transport = makeTransport()
253
+ const turnKey = 'c:_'
254
+ writeSilentEndState({ chatId: 'c', threadId: null, turnKey })
255
+ driveRetryLadderToMax(turnKey)
256
+
257
+ const deps: SilentEndDeps = {
258
+ // No reply has landed since the record — the exhaustion is real.
259
+ hasOutboundDeliveredSince: () => false,
260
+ }
261
+
262
+ const result = simulateTurnEnd(
263
+ { chatId: 'c', threadId: null, turnKey, finalAnswerDelivered: false, turnDurationMs: 9_000 },
264
+ transport,
265
+ deps,
266
+ )
267
+
268
+ expect(result?.exhausted).toBe(true)
269
+ expect(transport.sent).toHaveLength(1)
270
+ })
271
+
272
+ it('cross-check: isFinalAnswerReply gating means an ack-only turn still reaches the dark-turn branch', () => {
273
+ // Sanity cross-check against the real predicate used at the reply
274
+ // send-sites (executeReply/executeStreamReply) — an interim ack does
275
+ // NOT satisfy finalAnswerDelivered, so the branch is correctly
276
+ // entered (this is the #1664 case, not a legitimate silent end).
277
+ const ack = { text: 'On it', disableNotification: true }
278
+ expect(isFinalAnswerReply(ack)).toBe(false)
279
+
280
+ const transport = makeTransport()
281
+ const turnKey = 'c:ack-only'
282
+ writeSilentEndState({ chatId: 'c', threadId: null, turnKey })
283
+ driveRetryLadderToMax(turnKey)
284
+ simulateTurnEnd(
285
+ { chatId: 'c', threadId: null, turnKey, finalAnswerDelivered: isFinalAnswerReply(ack), turnDurationMs: 15_000 },
286
+ transport,
287
+ )
288
+ expect(transport.sent).toHaveLength(1)
289
+ })
290
+ })