switchroom 0.18.11 → 0.18.13

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -0,0 +1,268 @@
1
+ /**
2
+ * FD/timer-leak regression test for the session-tail PreToolUse sidecars
3
+ * (review finding M1).
4
+ *
5
+ * `ToolLabelSidecar`s (each holding a stat-poll timer + file handle) were only
6
+ * reaped in the global `stop()`. Every session rotation (`/clear`, compaction →
7
+ * new sessionId) and every finished sub-agent minted a fresh sidecar that then
8
+ * lived until process exit — hundreds of leaked timers/handles on a long-lived
9
+ * agent.
10
+ *
11
+ * We mock the sidecar factory so each instance is a cheap fake with a `stop`
12
+ * spy, then drive a real parent-session rotation and a real sub-agent reap and
13
+ * assert the ended session's sidecar was stopped. Both assertions FAIL on
14
+ * pre-fix code (stop only in `stop()`).
15
+ */
16
+
17
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
18
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from 'fs'
19
+ import { tmpdir } from 'os'
20
+ import { join } from 'path'
21
+ import { startSessionTail, getProjectsDirForCwd } from '../session-tail.js'
22
+ import type { SidecarOptions, ToolLabelSidecar } from '../tool-label-sidecar.js'
23
+
24
+ // ─── Sidecar factory injection: record every fake instance keyed by sessionId ─
25
+ // We do NOT `vi.mock('../tool-label-sidecar.js')`. Under bun's vitest-compat
26
+ // layer `vi.mock` is PROCESS-GLOBAL (not file-scoped like vitest), and CI's
27
+ // bun-test-run shard runs this whole `tests/` dir in ONE process — so a module
28
+ // mock here would leak into the real `tool-label-sidecar.test.ts` suite and
29
+ // replace the module under test, failing its 7 assertions. Instead we inject a
30
+ // fake sidecar factory through `startSessionTail`'s `createSidecar` seam (the
31
+ // repo's bun-safe DI precedent — see `vault-write-posture.test.ts`), so the
32
+ // shared module cache is never touched. Sibling `subagent-watcher-fd-leak.test.ts`
33
+ // uses the same injected-fake style.
34
+ interface FakeSidecar extends ToolLabelSidecar {
35
+ sessionId: string
36
+ stop: ReturnType<typeof vi.fn>
37
+ }
38
+ const created = { instances: [] as FakeSidecar[] }
39
+ const fakeSidecarFactory = (opts: SidecarOptions): ToolLabelSidecar => {
40
+ const inst: FakeSidecar = {
41
+ sessionId: opts.sessionId,
42
+ stop: vi.fn(),
43
+ getLabel: () => undefined,
44
+ onLabel: () => () => {},
45
+ poll: () => {},
46
+ }
47
+ created.instances.push(inst)
48
+ return inst
49
+ }
50
+
51
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
52
+ const tempDirs: string[] = []
53
+ let prevStateDir: string | undefined
54
+
55
+ beforeEach(() => {
56
+ created.instances.length = 0
57
+ prevStateDir = process.env.TELEGRAM_STATE_DIR
58
+ const stateDir = mkdtempSync(join(tmpdir(), 'sidecar-state-'))
59
+ tempDirs.push(stateDir)
60
+ process.env.TELEGRAM_STATE_DIR = stateDir
61
+ })
62
+
63
+ afterEach(() => {
64
+ if (prevStateDir === undefined) delete process.env.TELEGRAM_STATE_DIR
65
+ else process.env.TELEGRAM_STATE_DIR = prevStateDir
66
+ for (const d of tempDirs) {
67
+ try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ }
68
+ }
69
+ tempDirs.length = 0
70
+ })
71
+
72
+ function mkProjectsDir(): { claudeHome: string; cwd: string; projectsDir: string } {
73
+ const base = mkdtempSync(join(tmpdir(), 'session-tail-sidecar-'))
74
+ tempDirs.push(base)
75
+ const cwd = join(base, 'agent')
76
+ const claudeHome = join(base, 'claude-home')
77
+ const projectsDir = getProjectsDirForCwd(cwd, claudeHome)
78
+ mkdirSync(projectsDir, { recursive: true })
79
+ return { claudeHome, cwd, projectsDir }
80
+ }
81
+
82
+ const setMtime = (path: string, seconds: number): void => utimesSync(path, seconds, seconds)
83
+ const wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
84
+ const assistantText = (text: string): string =>
85
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text }] } }) + '\n'
86
+ const toolUseLine = (name: string, id: string): string =>
87
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', name, id, input: {} }] } }) + '\n'
88
+
89
+ const findSidecar = (sessionId: string): FakeSidecar | undefined =>
90
+ created.instances.find((i) => i.sessionId === sessionId)
91
+
92
+ describe('session-tail sidecar reap (M1) — parent session rotation', () => {
93
+ it('stops the prior session sidecar when the active file rotates', async () => {
94
+ const { claudeHome, cwd, projectsDir } = mkProjectsDir()
95
+ const first = join(projectsDir, 'session-one.jsonl')
96
+ const second = join(projectsDir, 'session-two.jsonl')
97
+
98
+ writeFileSync(first, assistantText('parent one'))
99
+ setMtime(first, 1_000_000)
100
+
101
+ const handle = startSessionTail({ cwd, claudeHome, rescanIntervalMs: 30, onEvent: () => {}, createSidecar: fakeSidecarFactory })
102
+ try {
103
+ await wait(120) // initial attach → sidecar 'session-one' created
104
+ expect(findSidecar('session-one')).toBeDefined()
105
+ expect(findSidecar('session-one')!.stop).not.toHaveBeenCalled()
106
+
107
+ // A newer session file appears (compaction / clear rotation).
108
+ const nowSec = Math.floor(Date.now() / 1000)
109
+ writeFileSync(second, assistantText('parent two'))
110
+ setMtime(second, nowSec + 20)
111
+ setMtime(first, nowSec + 5)
112
+ await wait(200) // rescan flips to session-two → session-one must be reaped
113
+
114
+ expect(findSidecar('session-two')).toBeDefined()
115
+ // The rotated-away session's sidecar is stopped at runtime, not left for stop().
116
+ expect(findSidecar('session-one')!.stop).toHaveBeenCalledTimes(1)
117
+ // The now-active session's sidecar stays alive.
118
+ expect(findSidecar('session-two')!.stop).not.toHaveBeenCalled()
119
+ } finally {
120
+ handle.stop()
121
+ }
122
+ })
123
+ })
124
+
125
+ describe('session-tail sidecar reap (M1) — idle sub-agent', () => {
126
+ it('stops a sub-agent sidecar when its idle sub-tail is reaped', async () => {
127
+ const { claudeHome, cwd, projectsDir } = mkProjectsDir()
128
+ const parent = join(projectsDir, 'parent.jsonl')
129
+ writeFileSync(parent, assistantText('parent'))
130
+ setMtime(parent, Math.floor(Date.now() / 1000))
131
+
132
+ // Sub-agents live under <parentSessionId>/subagents/agent-<id>.jsonl.
133
+ const subDir = join(projectsDir, 'parent', 'subagents')
134
+ mkdirSync(subDir, { recursive: true })
135
+ const subFile = join(subDir, 'agent-sub123.jsonl')
136
+ // A tool_use line makes the sub-tail lazily create its sidecar (keyed by
137
+ // the file stem 'agent-sub123').
138
+ writeFileSync(subFile, toolUseLine('Bash', 'toolu_1'))
139
+
140
+ const handle = startSessionTail({
141
+ cwd,
142
+ claudeHome,
143
+ rescanIntervalMs: 30,
144
+ // Reap sub-tails idle for >1000ms so the test doesn't wait 5 minutes but
145
+ // still respects the L1 floor clamp (Math.max(1000, …) — a smaller value
146
+ // is silently lifted to 1s) and leaves a window to observe the
147
+ // not-yet-reaped state.
148
+ subTailIdleReapMs: 1000,
149
+ onEvent: () => {},
150
+ createSidecar: fakeSidecarFactory,
151
+ })
152
+ try {
153
+ await wait(100) // attach parent + sub, read tool_use → sidecar created
154
+ const sub = findSidecar('agent-sub123')
155
+ expect(sub).toBeDefined()
156
+ expect(sub!.stop).not.toHaveBeenCalled()
157
+
158
+ // Let the sub-tail go idle past the reap window; a rescan tick reaps it.
159
+ await wait(1300)
160
+ expect(sub!.stop).toHaveBeenCalledTimes(1)
161
+ } finally {
162
+ handle.stop()
163
+ }
164
+ })
165
+ })
166
+
167
+ describe('session-tail sidecar reap (L1) — reap window is floor-clamped', () => {
168
+ it('does NOT instant-reap a live sub-tail under a zero (or negative) idle window', async () => {
169
+ // A `subTailIdleReapMs` of 0 (or negative) would, without the
170
+ // Math.max(1000, …) clamp, make `reapIdleSubTails` compute
171
+ // `cutoff = Date.now() - 0`, which is ALWAYS ≥ a live sub-tail's slightly-
172
+ // earlier `lastActivityAt` — so every live sub-agent sidecar would be
173
+ // reaped on the very first rescan tick. The clamp lifts the effective
174
+ // window to the 1s floor, so a sub-tail active moments ago survives.
175
+ const { claudeHome, cwd, projectsDir } = mkProjectsDir()
176
+ const parent = join(projectsDir, 'parent.jsonl')
177
+ writeFileSync(parent, assistantText('parent'))
178
+ setMtime(parent, Math.floor(Date.now() / 1000))
179
+
180
+ const subDir = join(projectsDir, 'parent', 'subagents')
181
+ mkdirSync(subDir, { recursive: true })
182
+ const subFile = join(subDir, 'agent-subzero.jsonl')
183
+ writeFileSync(subFile, toolUseLine('Bash', 'toolu_z'))
184
+
185
+ const handle = startSessionTail({
186
+ cwd,
187
+ claudeHome,
188
+ rescanIntervalMs: 30,
189
+ subTailIdleReapMs: 0, // pathological: pre-clamp this instant-reaps
190
+ onEvent: () => {},
191
+ createSidecar: fakeSidecarFactory,
192
+ })
193
+ try {
194
+ await wait(100) // attach parent + sub → sidecar 'agent-subzero' created
195
+ const sub = findSidecar('agent-subzero')
196
+ expect(sub).toBeDefined()
197
+ expect(sub!.stop).not.toHaveBeenCalled()
198
+
199
+ // Several rescan ticks pass (well under the 1s clamp floor). A live
200
+ // sub-tail whose activity is <1s old must NOT be reaped — so its sidecar
201
+ // stays alive. Pre-clamp, the first tick reaps it (sub.stop called once).
202
+ await wait(200)
203
+ expect(sub!.stop).not.toHaveBeenCalled()
204
+ } finally {
205
+ handle.stop()
206
+ }
207
+ })
208
+ })
209
+
210
+ describe('session-tail sidecar reap (M1) — namespace safety', () => {
211
+ it('a parent-session rotation does NOT stop a concurrently-live sub-agent sidecar', async () => {
212
+ // The rotation reap in attachToFile stops the sidecar keyed by the
213
+ // rotated-away parent's JSONL stem (a UUID). A live sub-agent's sidecar is
214
+ // keyed by its OWN file stem ('agent-<id>'). Because the two key namespaces
215
+ // never collide, rotating the parent must leave the sub-agent's sidecar
216
+ // untouched — otherwise a `/clear` or compaction mid-worker would silently
217
+ // kill the live worker's label sidecar. Reviewer verified by code-reading
218
+ // only; this locks it in.
219
+ const { claudeHome, cwd, projectsDir } = mkProjectsDir()
220
+ const first = join(projectsDir, 'session-one.jsonl')
221
+ const second = join(projectsDir, 'session-two.jsonl')
222
+
223
+ writeFileSync(first, assistantText('parent one'))
224
+ setMtime(first, 1_000_000)
225
+
226
+ // A live sub-agent under the CURRENT (session-one) parent. Its tool_use
227
+ // line makes the sub-tail create a sidecar keyed 'agent-subns'.
228
+ const subDir = join(projectsDir, 'session-one', 'subagents')
229
+ mkdirSync(subDir, { recursive: true })
230
+ const subFile = join(subDir, 'agent-subns.jsonl')
231
+ writeFileSync(subFile, toolUseLine('Bash', 'toolu_ns'))
232
+
233
+ const handle = startSessionTail({
234
+ cwd,
235
+ claudeHome,
236
+ rescanIntervalMs: 30,
237
+ // Large window so the sub-tail is never idle-reaped during the test —
238
+ // we're isolating the rotation path, not the idle path.
239
+ subTailIdleReapMs: 60_000,
240
+ onEvent: () => {},
241
+ createSidecar: fakeSidecarFactory,
242
+ })
243
+ try {
244
+ await wait(120) // attach parent + sub → both sidecars created
245
+ const parentSidecar = findSidecar('session-one')
246
+ const subSidecar = findSidecar('agent-subns')
247
+ expect(parentSidecar).toBeDefined()
248
+ expect(subSidecar).toBeDefined()
249
+ expect(parentSidecar!.stop).not.toHaveBeenCalled()
250
+ expect(subSidecar!.stop).not.toHaveBeenCalled()
251
+
252
+ // Parent session rotates (compaction / clear → new sessionId).
253
+ const nowSec = Math.floor(Date.now() / 1000)
254
+ writeFileSync(second, assistantText('parent two'))
255
+ setMtime(second, nowSec + 20)
256
+ setMtime(first, nowSec + 5)
257
+ await wait(200) // rescan flips active file to session-two
258
+
259
+ // The rotated-away PARENT sidecar is reaped …
260
+ expect(parentSidecar!.stop).toHaveBeenCalledTimes(1)
261
+ // … but the concurrently-live SUB-agent sidecar is NOT — different stem
262
+ // namespace, so the rotation reap never targets it.
263
+ expect(subSidecar!.stop).not.toHaveBeenCalled()
264
+ } finally {
265
+ handle.stop()
266
+ }
267
+ })
268
+ })
@@ -232,18 +232,43 @@ describe("status-pin boot cleanup is mutex-gated (structural)", () => {
232
232
  );
233
233
  });
234
234
 
235
- it("every statusPinBootCleanup() call site follows the won-lock check", () => {
236
- // Find each *invocation* (with parens), excluding the declaration.
235
+ it("every boot pin-cleanup call site follows the won-lock check", () => {
236
+ // #3026: boot cleanup is now invoked via the mutex-gated orchestrator
237
+ // runBootPinCleanupAndDmSweep(), which awaits statusPinBootCleanup()
238
+ // (+ the other reapers) and then runs the DM stale-pin sweep. The
239
+ // invariant is unchanged: cleanup must only run after the lock is won.
237
240
  const lines = gatewaySrc.split("\n");
238
241
  const lockIdx = lines.findIndex((l) => l.includes("acquireStartupLock({"));
239
242
  expect(lockIdx).toBeGreaterThan(-1);
240
243
 
244
+ // Every `void runBootPinCleanupAndDmSweep()` invocation must come AFTER
245
+ // the acquireStartupLock outcome is available (the boot block the winner
246
+ // — or the link()-unsupported fallback — reaches).
247
+ const callIdxs = lines
248
+ .map((l, i) => ({ l, i }))
249
+ .filter(
250
+ ({ l }) =>
251
+ /void runBootPinCleanupAndDmSweep\(\)/.test(l) &&
252
+ !l.trimStart().startsWith("//"),
253
+ )
254
+ .map(({ i }) => i);
255
+ expect(callIdxs.length).toBeGreaterThan(0);
256
+ for (const idx of callIdxs) {
257
+ expect(idx).toBeGreaterThan(lockIdx);
258
+ }
259
+
260
+ // statusPinBootCleanup() itself must only ever be invoked from INSIDE
261
+ // that orchestrator — never at a bare post-import call site that a
262
+ // losing double-boot could reach.
241
263
  const declIdx = lines.findIndex((l) =>
242
264
  /async function statusPinBootCleanup/.test(l),
243
265
  );
244
266
  expect(declIdx).toBeGreaterThan(-1);
245
-
246
- const callIdxs = lines
267
+ const orchestratorIdx = lines.findIndex((l) =>
268
+ /async function runBootPinCleanupAndDmSweep/.test(l),
269
+ );
270
+ expect(orchestratorIdx).toBeGreaterThan(-1);
271
+ const invocationIdxs = lines
247
272
  .map((l, i) => ({ l, i }))
248
273
  .filter(
249
274
  ({ l, i }) =>
@@ -253,25 +278,21 @@ describe("status-pin boot cleanup is mutex-gated (structural)", () => {
253
278
  !l.includes("statusPinBootCleanup() is deliberately NOT"),
254
279
  )
255
280
  .map(({ i }) => i);
256
-
257
- // There must be at least one real call, and every real call must come
258
- // AFTER the acquireStartupLock outcome is available (i.e. inside the boot
259
- // block that only the winner reaches).
260
- expect(callIdxs.length).toBeGreaterThan(0);
261
- for (const idx of callIdxs) {
262
- expect(idx).toBeGreaterThan(lockIdx);
281
+ expect(invocationIdxs.length).toBeGreaterThan(0);
282
+ for (const idx of invocationIdxs) {
283
+ expect(idx).toBeGreaterThan(orchestratorIdx);
263
284
  }
264
285
  });
265
286
 
266
287
  it("the blocked (losing) boot exits before reaching cleanup", () => {
267
288
  // In the mutex block, `outcome.status === 'blocked'` must lead to
268
- // process.exit(1) BEFORE any statusPinBootCleanup() call in that block —
269
- // so a loser never touches the shared store.
289
+ // process.exit(1) BEFORE the winner's runBootPinCleanupAndDmSweep() call
290
+ // in that block — so a loser never touches the shared store.
270
291
  const blockedIdx = gatewaySrc.indexOf("outcome.status === 'blocked'");
271
292
  expect(blockedIdx).toBeGreaterThan(-1);
272
293
  const afterBlocked = gatewaySrc.slice(blockedIdx);
273
294
  const exitIdx = afterBlocked.indexOf("process.exit(1)");
274
- const cleanupIdx = afterBlocked.indexOf("void statusPinBootCleanup()");
295
+ const cleanupIdx = afterBlocked.indexOf("void runBootPinCleanupAndDmSweep()");
275
296
  expect(exitIdx).toBeGreaterThan(-1);
276
297
  expect(cleanupIdx).toBeGreaterThan(-1);
277
298
  // The exit for the blocked branch appears before the winner's cleanup call.
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { GrammyError } from 'grammy'
3
- import { decidePinAction } from '../status-pin.js'
3
+ import { decidePinAction, isPinRightsError, PinRightsCache } from '../status-pin.js'
4
4
  import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
5
5
  import type { PinState } from '../status-pin.js'
6
6
 
@@ -200,3 +200,277 @@ describe('reconcilePin — pin-rights 400 must never crash (marko 2026-07-01)',
200
200
  }
201
201
  })
202
202
  })
203
+
204
+ // ── #3024: rights-aware negative cache ─────────────────────────────────────
205
+ // marko logged 41 identical `pinChatMessage` "not enough rights" rejections in
206
+ // 48h — one per auto status-pin attempt in a group where the bot isn't a pin
207
+ // admin. The permanent-rights class is now cached per-chat so the SECOND and
208
+ // later attempts skip the API call entirely and stop spamming the log. Synthetic
209
+ // chat IDs only (the real ones in the issue are operator PII).
210
+ describe('isPinRightsError (pure classifier)', () => {
211
+ it('matches the permanent pin-rights 400 (grammy .description)', () => {
212
+ expect(
213
+ isPinRightsError(
214
+ grammyError(400, 'Bad Request: not enough rights to manage pinned messages in the chat'),
215
+ ),
216
+ ).toBe(true)
217
+ })
218
+
219
+ it('matches a plain Error carrying the rights text', () => {
220
+ expect(isPinRightsError(new Error('not enough rights to manage pinned messages'))).toBe(true)
221
+ })
222
+
223
+ it('does NOT match transient classes (429 flood-wait, network)', () => {
224
+ expect(isPinRightsError(grammyError(429, 'Too Many Requests: retry after 5'))).toBe(false)
225
+ expect(isPinRightsError(new Error('fetch failed'))).toBe(false)
226
+ expect(isPinRightsError(grammyError(400, 'Bad Request: message to edit not found'))).toBe(false)
227
+ })
228
+ })
229
+
230
+ describe('PinRightsCache (pure)', () => {
231
+ it('block() returns true only the first time a chat is added (log-once)', () => {
232
+ const cache = new PinRightsCache()
233
+ expect(cache.isBlocked('-1009000000001')).toBe(false)
234
+ expect(cache.block('-1009000000001')).toBe(true)
235
+ expect(cache.block('-1009000000001')).toBe(false)
236
+ expect(cache.isBlocked('-1009000000001')).toBe(true)
237
+ })
238
+
239
+ it('clear() forgets a chat (rights re-granted)', () => {
240
+ const cache = new PinRightsCache()
241
+ cache.block('-1009000000001')
242
+ expect(cache.clear('-1009000000001')).toBe(true)
243
+ expect(cache.isBlocked('-1009000000001')).toBe(false)
244
+ expect(cache.clear('-1009000000001')).toBe(false)
245
+ })
246
+ })
247
+
248
+ /** A fake Bot API counting pin calls, throwing a chosen error on pin. */
249
+ function countingApi(pinError?: unknown) {
250
+ let pinCalls = 0
251
+ let unpinCalls = 0
252
+ const api: PinBotApi = {
253
+ pinChatMessage: async () => {
254
+ pinCalls += 1
255
+ if (pinError) throw pinError
256
+ },
257
+ unpinChatMessage: async () => {
258
+ unpinCalls += 1
259
+ },
260
+ }
261
+ return {
262
+ api,
263
+ get pinCalls() {
264
+ return pinCalls
265
+ },
266
+ get unpinCalls() {
267
+ return unpinCalls
268
+ },
269
+ }
270
+ }
271
+
272
+ describe('reconcilePin — rights-aware negative cache (#3024)', () => {
273
+ const rightsErr = () =>
274
+ grammyError(400, 'Bad Request: not enough rights to manage pinned messages in the chat')
275
+
276
+ it('first rights failure: attempts once, logs once, records the chat', async () => {
277
+ const cache = new PinRightsCache()
278
+ const disabled: string[] = []
279
+ const pinFails: string[] = []
280
+ const t = countingApi(rightsErr())
281
+
282
+ const next = await reconcilePin({
283
+ api: t.api,
284
+ chatId: '-1009000000001',
285
+ prevState: null,
286
+ desired: { pinned: true, messageId: 4 },
287
+ rightsCache: cache,
288
+ onPinRightsDisabled: (chat) => disabled.push(chat),
289
+ onError: (phase) => pinFails.push(phase),
290
+ })
291
+
292
+ expect(t.pinCalls).toBe(1) // it did try
293
+ expect(next).toBeNull() // no claim taken
294
+ expect(disabled).toEqual(['-1009000000001']) // logged once
295
+ expect(pinFails).toEqual([]) // NOT routed through the per-attempt onError
296
+ expect(cache.isBlocked('-1009000000001')).toBe(true)
297
+ })
298
+
299
+ it('second attempt in the same chat makes NO api call and logs nothing', async () => {
300
+ const cache = new PinRightsCache()
301
+ const disabled: string[] = []
302
+ const pinFails: string[] = []
303
+ const t = countingApi(rightsErr())
304
+
305
+ const common = {
306
+ chatId: '-1009000000001',
307
+ prevState: null,
308
+ desired: { pinned: true, messageId: 4 } as const,
309
+ rightsCache: cache,
310
+ onPinRightsDisabled: (chat: string) => disabled.push(chat),
311
+ onError: (phase: 'pin' | 'unpin') => pinFails.push(phase),
312
+ }
313
+
314
+ await reconcilePin({ api: t.api, ...common })
315
+ await reconcilePin({ api: t.api, ...common })
316
+
317
+ expect(t.pinCalls).toBe(1) // second attempt short-circuited before the API
318
+ expect(disabled).toEqual(['-1009000000001']) // still logged exactly once
319
+ expect(pinFails).toEqual([])
320
+ })
321
+
322
+ it('a DIFFERENT chat is unaffected by another chat being blocked', async () => {
323
+ const cache = new PinRightsCache()
324
+ cache.block('-1009000000001')
325
+ const t = countingApi() // pin succeeds here
326
+
327
+ const next = await reconcilePin({
328
+ api: t.api,
329
+ chatId: '-1009000000002',
330
+ prevState: null,
331
+ desired: { pinned: true, messageId: 7 },
332
+ rightsCache: cache,
333
+ onPinRightsDisabled: () => {
334
+ throw new Error('should not disable a healthy chat')
335
+ },
336
+ })
337
+
338
+ expect(t.pinCalls).toBe(1)
339
+ expect(next).toEqual({ messageId: 7 })
340
+ expect(cache.isBlocked('-1009000000002')).toBe(false)
341
+ })
342
+
343
+ it('a 429 (transient) does NOT enter the cache — retry behaviour preserved', async () => {
344
+ const cache = new PinRightsCache()
345
+ const disabled: string[] = []
346
+ const pinFails: string[] = []
347
+ const t = countingApi(grammyError(429, 'Too Many Requests: retry after 5'))
348
+
349
+ await reconcilePin({
350
+ api: t.api,
351
+ chatId: '-1009000000003',
352
+ prevState: null,
353
+ desired: { pinned: true, messageId: 9 },
354
+ rightsCache: cache,
355
+ onPinRightsDisabled: (chat) => disabled.push(chat),
356
+ onError: (phase) => pinFails.push(phase),
357
+ })
358
+
359
+ expect(cache.isBlocked('-1009000000003')).toBe(false) // NOT cached
360
+ expect(disabled).toEqual([]) // no rights-disable log
361
+ expect(pinFails).toEqual(['pin']) // routed through onError → normal retry path
362
+
363
+ // A follow-up attempt still hits the API (no negative cache in the way).
364
+ await reconcilePin({
365
+ api: t.api,
366
+ chatId: '-1009000000003',
367
+ prevState: null,
368
+ desired: { pinned: true, messageId: 9 },
369
+ rightsCache: cache,
370
+ onError: (phase) => pinFails.push(phase),
371
+ })
372
+ expect(t.pinCalls).toBe(2)
373
+ })
374
+
375
+ it('a later successful pin clears a previously-blocked chat', async () => {
376
+ const cache = new PinRightsCache()
377
+ cache.block('-1009000000004')
378
+ const t = countingApi() // succeeds
379
+
380
+ // While blocked, the pin is skipped (no claim, no API call).
381
+ const skipped = await reconcilePin({
382
+ api: t.api,
383
+ chatId: '-1009000000004',
384
+ prevState: null,
385
+ desired: { pinned: true, messageId: 3 },
386
+ rightsCache: cache,
387
+ })
388
+ expect(skipped).toBeNull()
389
+ expect(t.pinCalls).toBe(0)
390
+
391
+ // Simulate rights granted: unblock, then a pin succeeds and stays clear.
392
+ cache.clear('-1009000000004')
393
+ const next = await reconcilePin({
394
+ api: t.api,
395
+ chatId: '-1009000000004',
396
+ prevState: null,
397
+ desired: { pinned: true, messageId: 3 },
398
+ rightsCache: cache,
399
+ })
400
+ expect(next).toEqual({ messageId: 3 })
401
+ expect(cache.isBlocked('-1009000000004')).toBe(false)
402
+ })
403
+
404
+ it('UNPIN rights-failure (rights revoked mid-session) blocks the chat, logs once, second unpin makes no API call', async () => {
405
+ const cache = new PinRightsCache()
406
+ const disabled: string[] = []
407
+ const unpinFails: string[] = []
408
+ let unpinCalls = 0
409
+ const api: PinBotApi = {
410
+ pinChatMessage: async () => {},
411
+ unpinChatMessage: async () => {
412
+ unpinCalls += 1
413
+ throw rightsErr()
414
+ },
415
+ }
416
+ const common = {
417
+ api,
418
+ chatId: '-1009000000007',
419
+ desired: { pinned: false } as const,
420
+ rightsCache: cache,
421
+ onPinRightsDisabled: (chat: string) => disabled.push(chat),
422
+ onError: (phase: 'pin' | 'unpin') => unpinFails.push(phase),
423
+ }
424
+
425
+ const first = await reconcilePin({ ...common, prevState: { messageId: 21 } })
426
+ expect(first).toBeNull() // claim dropped regardless (drop-on-unpin contract)
427
+ expect(unpinCalls).toBe(1) // it did try once
428
+ expect(disabled).toEqual(['-1009000000007']) // logged exactly once
429
+ expect(unpinFails).toEqual([]) // NOT routed through per-attempt onError
430
+ expect(cache.isBlocked('-1009000000007')).toBe(true)
431
+
432
+ const second = await reconcilePin({ ...common, prevState: { messageId: 22 } })
433
+ expect(second).toBeNull() // claim still dropped
434
+ expect(unpinCalls).toBe(1) // second attempt short-circuited before the API
435
+ expect(disabled).toEqual(['-1009000000007']) // no second log
436
+ })
437
+
438
+ it('a blocked chat skips the UNPIN api call too (drops the claim silently)', async () => {
439
+ const cache = new PinRightsCache()
440
+ cache.block('-1009000000005')
441
+ const t = countingApi()
442
+
443
+ const next = await reconcilePin({
444
+ api: t.api,
445
+ chatId: '-1009000000005',
446
+ prevState: { messageId: 12 },
447
+ desired: { pinned: false },
448
+ rightsCache: cache,
449
+ })
450
+
451
+ expect(next).toBeNull() // claim dropped
452
+ expect(t.unpinCalls).toBe(0) // but no wasted API call
453
+ })
454
+
455
+ it('fire-and-forget with the cache leaks NO unhandledRejection', async () => {
456
+ const rejections: unknown[] = []
457
+ const onUnhandled = (err: unknown) => rejections.push(err)
458
+ process.on('unhandledRejection', onUnhandled)
459
+ try {
460
+ const cache = new PinRightsCache()
461
+ const t = countingApi(rightsErr())
462
+ void reconcilePin({
463
+ api: t.api,
464
+ chatId: '-1009000000006',
465
+ prevState: null,
466
+ desired: { pinned: true, messageId: 4 },
467
+ rightsCache: cache,
468
+ onPinRightsDisabled: () => {},
469
+ })
470
+ await new Promise((r) => setTimeout(r, 20))
471
+ expect(rejections).toEqual([])
472
+ } finally {
473
+ process.off('unhandledRejection', onUnhandled)
474
+ }
475
+ })
476
+ })