switchroom 0.20.10 → 0.20.12
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.
- package/dist/agent-scheduler/index.js +68 -3
- package/dist/auth-broker/index.js +211 -46
- package/dist/cli/notion-write-pretool.mjs +68 -3
- package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
- package/dist/cli/self-improve-stop.mjs +889 -7
- package/dist/cli/skill-validate-pretool.mjs +82 -3
- package/dist/cli/switchroom.js +5063 -2961
- package/dist/host-control/main.js +93 -27
- package/dist/vault/approvals/kernel-server.js +92 -26
- package/dist/vault/broker/server.js +92 -26
- package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
- package/package.json +1 -1
- package/profiles/_shared/agent-self-service.md.hbs +15 -22
- package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
- package/profiles/_shared/dev-protocol.md.hbs +1 -1
- package/profiles/_shared/execution-discipline.md.hbs +4 -4
- package/profiles/_shared/vault-protocol.md.hbs +2 -18
- package/profiles/default/CLAUDE.md.hbs +3 -5
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/skills/switchroom-cli/SKILL.md +0 -1
- package/telegram-plugin/README.md +2 -11
- package/telegram-plugin/auto-fallback-fleet.ts +37 -2
- package/telegram-plugin/bridge/bridge.ts +0 -12
- package/telegram-plugin/chat-lock.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +0 -12
- package/telegram-plugin/dist/gateway/gateway.js +1454 -976
- package/telegram-plugin/dist/server.js +0 -12
- package/telegram-plugin/fallback-card-collapse.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +11 -1
- package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
- package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
- package/telegram-plugin/gateway/gateway.ts +109 -149
- package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
- package/telegram-plugin/gateway/ipc-server.ts +28 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
- package/telegram-plugin/gateway/narrative-lane.ts +33 -2
- package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
- package/telegram-plugin/gateway/privacy-reset.ts +87 -0
- package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
- package/telegram-plugin/gateway/privacy-state.ts +206 -0
- package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
- package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
- package/telegram-plugin/gateway/stale-pin-sweep.ts +52 -35
- package/telegram-plugin/gateway/status-pin-store.ts +10 -9
- package/telegram-plugin/gateway/stream-render.ts +4 -4
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
- package/telegram-plugin/gateway/turn-record-status.ts +32 -1
- package/telegram-plugin/hooks/hooks.json +13 -12
- package/telegram-plugin/hooks/narration-classify.mjs +1 -2
- package/telegram-plugin/hooks/silent-end-scan.mjs +1 -1
- package/telegram-plugin/slot-banner-driver.ts +42 -5
- package/telegram-plugin/status-pin.ts +2 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
- package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
- package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
- package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
- package/telegram-plugin/throttle-tier.ts +12 -3
- package/telegram-plugin/turn-flush-safety.ts +97 -0
- package/telegram-plugin/worker-activity-feed.ts +1 -1
- package/vendor/hindsight-memory/scripts/recall.py +140 -0
- package/vendor/hindsight-memory/scripts/retain.py +306 -0
- package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
- package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
- package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the session-start privacy reset (PR3 of `/private` `/public`).
|
|
3
|
+
*
|
|
4
|
+
* Covers the two collaborating pieces:
|
|
5
|
+
* - `resetPrivacyOnGenuineSessionStart` (privacy-state.ts): always truncates
|
|
6
|
+
* to public; fires `onOpenIntervalReset` ONLY on a private→public
|
|
7
|
+
* transition (a leftover OPEN interval).
|
|
8
|
+
* - `makePrivacyResetForNewSession` (privacy-reset.ts): binds the loud-send
|
|
9
|
+
* primitive so the alert is emitted exactly when — and only when — that
|
|
10
|
+
* transition happened.
|
|
11
|
+
*
|
|
12
|
+
* The three spec scenarios:
|
|
13
|
+
* 1. leftover open interval → file reset to empty AND loud alert emitted
|
|
14
|
+
* 2. already public → file reset, NO alert
|
|
15
|
+
* 3. compact / no-reset path → state PRESERVED unchanged, NO alert
|
|
16
|
+
*
|
|
17
|
+
* Run with: npx vitest run telegram-plugin/gateway/privacy-reset.test.ts
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
21
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
|
|
22
|
+
import { tmpdir } from 'node:os'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import {
|
|
25
|
+
openPrivateInterval,
|
|
26
|
+
readPrivacyState,
|
|
27
|
+
resetPrivacyOnGenuineSessionStart,
|
|
28
|
+
emptyPrivacyState,
|
|
29
|
+
privacyStatePath,
|
|
30
|
+
SESSION_RESET_ALERT,
|
|
31
|
+
} from './privacy-state.js'
|
|
32
|
+
import {
|
|
33
|
+
makePrivacyResetForNewSession,
|
|
34
|
+
bootRestoresTranscript,
|
|
35
|
+
isContinueRestoreBoot,
|
|
36
|
+
} from './privacy-reset.js'
|
|
37
|
+
|
|
38
|
+
describe('privacy session-start reset', () => {
|
|
39
|
+
let dir: string
|
|
40
|
+
let stderrSpy: ReturnType<typeof vi.spyOn>
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
dir = mkdtempSync(join(tmpdir(), 'privacy-reset-'))
|
|
44
|
+
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
stderrSpy.mockRestore()
|
|
49
|
+
rmSync(dir, { recursive: true, force: true })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const onDisk = () => JSON.parse(readFileSync(privacyStatePath(dir), 'utf8'))
|
|
53
|
+
|
|
54
|
+
describe('resetPrivacyOnGenuineSessionStart', () => {
|
|
55
|
+
it('scenario 1: a leftover OPEN interval → file reset to empty AND callback fired', () => {
|
|
56
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'), dir)
|
|
57
|
+
const onReset = vi.fn()
|
|
58
|
+
const result = resetPrivacyOnGenuineSessionStart({ stateDir: dir, onOpenIntervalReset: onReset })
|
|
59
|
+
expect(result.hadOpenInterval).toBe(true)
|
|
60
|
+
expect(onReset).toHaveBeenCalledTimes(1)
|
|
61
|
+
expect(onDisk()).toEqual(emptyPrivacyState())
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('scenario 2: already public → file reset (idempotent), NO callback', () => {
|
|
65
|
+
const onReset = vi.fn()
|
|
66
|
+
const result = resetPrivacyOnGenuineSessionStart({ stateDir: dir, onOpenIntervalReset: onReset })
|
|
67
|
+
expect(result.hadOpenInterval).toBe(false)
|
|
68
|
+
expect(onReset).not.toHaveBeenCalled()
|
|
69
|
+
expect(onDisk()).toEqual(emptyPrivacyState())
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('scenario 2b: a CLOSED-only history is treated as public → NO callback, reset to empty', () => {
|
|
73
|
+
writeFileSync(
|
|
74
|
+
privacyStatePath(dir),
|
|
75
|
+
JSON.stringify({
|
|
76
|
+
version: 1,
|
|
77
|
+
intervals: [{ start: '2026-08-06T02:00:00.000Z', end: '2026-08-06T02:05:00.000Z' }],
|
|
78
|
+
}),
|
|
79
|
+
'utf8',
|
|
80
|
+
)
|
|
81
|
+
const onReset = vi.fn()
|
|
82
|
+
const result = resetPrivacyOnGenuineSessionStart({ stateDir: dir, onOpenIntervalReset: onReset })
|
|
83
|
+
expect(result.hadOpenInterval).toBe(false)
|
|
84
|
+
expect(onReset).not.toHaveBeenCalled()
|
|
85
|
+
expect(onDisk().intervals).toEqual([])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('scenario 3: a NON-reset path (compact / resume) preserves state — no reset is called', () => {
|
|
89
|
+
// The compact/resume paths are EXEMPT by construction: the gateway simply
|
|
90
|
+
// never calls the reset there. This asserts the invariant that, absent a
|
|
91
|
+
// reset, an open private interval survives untouched.
|
|
92
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'), dir)
|
|
93
|
+
const before = readPrivacyState(dir)
|
|
94
|
+
// ...compaction happens here in the real gateway; privacy is not touched...
|
|
95
|
+
const after = readPrivacyState(dir)
|
|
96
|
+
expect(after).toEqual(before)
|
|
97
|
+
expect(after.intervals).toEqual([{ start: '2026-08-06T02:00:00.000Z', end: null }])
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('makePrivacyResetForNewSession (loud-send binding)', () => {
|
|
102
|
+
// The factory uses the DEFAULT state-dir resolver (no stateDir param), so
|
|
103
|
+
// point TELEGRAM_STATE_DIR at the temp dir for the duration of this test.
|
|
104
|
+
let prevEnv: string | undefined
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
prevEnv = process.env.TELEGRAM_STATE_DIR
|
|
107
|
+
process.env.TELEGRAM_STATE_DIR = dir
|
|
108
|
+
})
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
if (prevEnv === undefined) delete process.env.TELEGRAM_STATE_DIR
|
|
111
|
+
else process.env.TELEGRAM_STATE_DIR = prevEnv
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('emits the loud SESSION_RESET_ALERT to the chat only on a private→public transition', () => {
|
|
115
|
+
const send = vi.fn<(chatId: string, threadId: number | undefined, text: string) => void>()
|
|
116
|
+
const reset = makePrivacyResetForNewSession(send)
|
|
117
|
+
|
|
118
|
+
// Already public: no alert.
|
|
119
|
+
reset('chat-1', undefined)
|
|
120
|
+
expect(send).not.toHaveBeenCalled()
|
|
121
|
+
expect(onDisk()).toEqual(emptyPrivacyState())
|
|
122
|
+
|
|
123
|
+
// Now go private, then reset: exactly one loud alert with the pinned text.
|
|
124
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'))
|
|
125
|
+
reset('chat-1', 77)
|
|
126
|
+
expect(send).toHaveBeenCalledTimes(1)
|
|
127
|
+
expect(send).toHaveBeenCalledWith('chat-1', 77, SESSION_RESET_ALERT)
|
|
128
|
+
expect(onDisk()).toEqual(emptyPrivacyState())
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('boot reset gating on --continue transcript-restore (MAJOR fix)', () => {
|
|
133
|
+
it('bootRestoresTranscript: continue/auto restore; handoff/none/undefined do not; force-fresh overrides', () => {
|
|
134
|
+
expect(bootRestoresTranscript({ resumeMode: 'continue', forceFresh: false })).toBe(true)
|
|
135
|
+
expect(bootRestoresTranscript({ resumeMode: 'auto', forceFresh: false })).toBe(true)
|
|
136
|
+
expect(bootRestoresTranscript({ resumeMode: 'handoff', forceFresh: false })).toBe(false)
|
|
137
|
+
expect(bootRestoresTranscript({ resumeMode: 'none', forceFresh: false })).toBe(false)
|
|
138
|
+
expect(bootRestoresTranscript({ resumeMode: undefined, forceFresh: false })).toBe(false)
|
|
139
|
+
// A /new /reset force-fresh boot is genuinely fresh even under continue/auto.
|
|
140
|
+
expect(bootRestoresTranscript({ resumeMode: 'continue', forceFresh: true })).toBe(false)
|
|
141
|
+
expect(bootRestoresTranscript({ resumeMode: 'auto', forceFresh: true })).toBe(false)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('isContinueRestoreBoot reads SWITCHROOM_RESUME_MODE / SWITCHROOM_FORCE_FRESH', () => {
|
|
145
|
+
const saved = { ...process.env }
|
|
146
|
+
try {
|
|
147
|
+
delete process.env.SWITCHROOM_FORCE_FRESH
|
|
148
|
+
process.env.SWITCHROOM_RESUME_MODE = 'continue'
|
|
149
|
+
expect(isContinueRestoreBoot(null)).toBe(true)
|
|
150
|
+
process.env.SWITCHROOM_RESUME_MODE = 'handoff'
|
|
151
|
+
expect(isContinueRestoreBoot(null)).toBe(false)
|
|
152
|
+
// force-fresh env override wins over continue.
|
|
153
|
+
process.env.SWITCHROOM_RESUME_MODE = 'continue'
|
|
154
|
+
process.env.SWITCHROOM_FORCE_FRESH = '1'
|
|
155
|
+
expect(isContinueRestoreBoot(null)).toBe(false)
|
|
156
|
+
} finally {
|
|
157
|
+
process.env = saved
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// Emulates the guarded boot call site: `if (target && !isContinueRestoreBoot(dir)) reset(...)`.
|
|
162
|
+
// This is the exact failure scenario the reviewer flagged.
|
|
163
|
+
function guardedBootReset(reset: (c: string, t: number | undefined) => void): void {
|
|
164
|
+
if (!isContinueRestoreBoot(null)) reset('boot-chat', undefined)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
it('continue-restore boot: NO reset, NO alert, open interval PRESERVED', () => {
|
|
168
|
+
const saved = { ...process.env }
|
|
169
|
+
try {
|
|
170
|
+
process.env.TELEGRAM_STATE_DIR = dir
|
|
171
|
+
delete process.env.SWITCHROOM_FORCE_FRESH
|
|
172
|
+
process.env.SWITCHROOM_RESUME_MODE = 'continue'
|
|
173
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'))
|
|
174
|
+
const send = vi.fn<(chatId: string, threadId: number | undefined, text: string) => void>()
|
|
175
|
+
guardedBootReset(makePrivacyResetForNewSession(send))
|
|
176
|
+
expect(send).not.toHaveBeenCalled()
|
|
177
|
+
expect(onDisk().intervals).toEqual([{ start: '2026-08-06T02:00:00.000Z', end: null }])
|
|
178
|
+
} finally {
|
|
179
|
+
process.env = saved
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('fresh (handoff) boot: reset FIRES, loud alert emitted, interval cleared', () => {
|
|
184
|
+
const saved = { ...process.env }
|
|
185
|
+
try {
|
|
186
|
+
process.env.TELEGRAM_STATE_DIR = dir
|
|
187
|
+
delete process.env.SWITCHROOM_FORCE_FRESH
|
|
188
|
+
process.env.SWITCHROOM_RESUME_MODE = 'handoff'
|
|
189
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'))
|
|
190
|
+
const send = vi.fn<(chatId: string, threadId: number | undefined, text: string) => void>()
|
|
191
|
+
guardedBootReset(makePrivacyResetForNewSession(send))
|
|
192
|
+
expect(send).toHaveBeenCalledTimes(1)
|
|
193
|
+
expect(send).toHaveBeenCalledWith('boot-chat', undefined, SESSION_RESET_ALERT)
|
|
194
|
+
expect(onDisk()).toEqual(emptyPrivacyState())
|
|
195
|
+
} finally {
|
|
196
|
+
process.env = saved
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('continue-mode /new force-fresh boot: reset FIRES (genuinely fresh)', () => {
|
|
201
|
+
const saved = { ...process.env }
|
|
202
|
+
try {
|
|
203
|
+
process.env.TELEGRAM_STATE_DIR = dir
|
|
204
|
+
process.env.SWITCHROOM_RESUME_MODE = 'continue'
|
|
205
|
+
process.env.SWITCHROOM_FORCE_FRESH = '1'
|
|
206
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'))
|
|
207
|
+
const send = vi.fn<(chatId: string, threadId: number | undefined, text: string) => void>()
|
|
208
|
+
guardedBootReset(makePrivacyResetForNewSession(send))
|
|
209
|
+
expect(send).toHaveBeenCalledTimes(1)
|
|
210
|
+
expect(onDisk()).toEqual(emptyPrivacyState())
|
|
211
|
+
} finally {
|
|
212
|
+
process.env = saved
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway-owned session-start privacy reset (PR3 of the `/private` `/public`
|
|
3
|
+
* feature).
|
|
4
|
+
*
|
|
5
|
+
* A genuine new session — cold boot / crash / planned restart, and a `/clear`
|
|
6
|
+
* (which starts a new logical session) — must return memory writing to its
|
|
7
|
+
* PUBLIC default. If the previous session ended while still private (an OPEN
|
|
8
|
+
* interval was left behind), the operator is told loudly, so they are never
|
|
9
|
+
* silently surprised that a stretch they thought was private is now being
|
|
10
|
+
* recorded again.
|
|
11
|
+
*
|
|
12
|
+
* The gateway is the SINGLE owner of this reset+announce (the Python
|
|
13
|
+
* `session_start.py` is deliberately NOT wired to it), which eliminates the
|
|
14
|
+
* two-owner clear/announce race. Reconnect paths (`bridge-reconnect`) and the
|
|
15
|
+
* compaction / resume / continue paths are EXEMPT by construction: they
|
|
16
|
+
* reattach to a PERSISTING session, so this is simply never called from them —
|
|
17
|
+
* there is no `source`-string branch to get wrong.
|
|
18
|
+
*
|
|
19
|
+
* This thin factory exists so gateway.ts holds only the loud-send primitive
|
|
20
|
+
* (which needs the bot handle) and the call sites stay one-liners; the
|
|
21
|
+
* reset/announce decision lives in `privacy-state.ts` and is unit-tested there.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync } from 'node:fs'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
|
|
27
|
+
import { resetPrivacyOnGenuineSessionStart, SESSION_RESET_ALERT } from './privacy-state.js'
|
|
28
|
+
|
|
29
|
+
/** Posts the loud (notification-ON) reset alert. Provided by the gateway. */
|
|
30
|
+
export type LoudResetSender = (
|
|
31
|
+
chatId: string,
|
|
32
|
+
threadId: number | undefined,
|
|
33
|
+
text: string,
|
|
34
|
+
) => void
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Bind the loud-send primitive into a `(chatId, threadId)` reset function.
|
|
38
|
+
* Calling the returned function always resets privacy to public; it invokes
|
|
39
|
+
* `send` (the loud alert) only when a private→public transition actually
|
|
40
|
+
* happened.
|
|
41
|
+
*/
|
|
42
|
+
export function makePrivacyResetForNewSession(
|
|
43
|
+
send: LoudResetSender,
|
|
44
|
+
): (chatId: string, threadId: number | undefined) => void {
|
|
45
|
+
return (chatId, threadId) => {
|
|
46
|
+
resetPrivacyOnGenuineSessionStart({
|
|
47
|
+
onOpenIntervalReset: () => send(chatId, threadId, SESSION_RESET_ALERT),
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Does this boot RESTORE the previous Claude transcript (via `--continue`)?
|
|
54
|
+
*
|
|
55
|
+
* The boot reset must NOT fire when the transcript persists: a `continue`/`auto`
|
|
56
|
+
* agent whose container restarts mid-`/private` task replays the SAME transcript,
|
|
57
|
+
* so flipping privacy to public would resume storing that very task (the FIX-3
|
|
58
|
+
* mid-task-reset shape). Mirrors `decideBootBriefing` (boot-briefing-builder.ts):
|
|
59
|
+
* `continue` always replays; `auto` MAY replay (only when the JSONL is under
|
|
60
|
+
* `resume_max_bytes`) — the gateway can't see the inner CONTINUE_FLAG, so `auto`
|
|
61
|
+
* is treated conservatively as a restore (privacy persists; safe direction — an
|
|
62
|
+
* over-retained private interval merely excludes more, never leaks). A `/new`
|
|
63
|
+
* `/reset` force-fresh boot is genuinely fresh even under continue/auto.
|
|
64
|
+
*/
|
|
65
|
+
export function bootRestoresTranscript(opts: {
|
|
66
|
+
resumeMode: string | undefined
|
|
67
|
+
forceFresh: boolean
|
|
68
|
+
}): boolean {
|
|
69
|
+
if (opts.forceFresh) return false
|
|
70
|
+
return opts.resumeMode === 'continue' || opts.resumeMode === 'auto'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Env/marker-reading convenience over `bootRestoresTranscript` for the gateway
|
|
75
|
+
* boot site. Reads `SWITCHROOM_RESUME_MODE` and the force-fresh signal
|
|
76
|
+
* (`SWITCHROOM_FORCE_FRESH`, hoisted by start.sh, with the `.force-fresh-session`
|
|
77
|
+
* marker as the non-docker fallback — same pair `boot-briefing-wiring.ts` uses).
|
|
78
|
+
*/
|
|
79
|
+
export function isContinueRestoreBoot(agentDir: string | null): boolean {
|
|
80
|
+
const forceFresh =
|
|
81
|
+
process.env.SWITCHROOM_FORCE_FRESH === '1' ||
|
|
82
|
+
(agentDir != null && existsSync(join(agentDir, '.force-fresh-session')))
|
|
83
|
+
return bootRestoresTranscript({
|
|
84
|
+
resumeMode: process.env.SWITCHROOM_RESUME_MODE,
|
|
85
|
+
forceFresh,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for privacy-state.ts — the gateway side of the `/private`
|
|
3
|
+
* `/public` feature (PR2: open/close/read/reset + the state-file contract).
|
|
4
|
+
*
|
|
5
|
+
* These lock in the on-disk contract shared with the Python retain side
|
|
6
|
+
* (`vendor/hindsight-memory`): the exact schema, `end: null` = open interval,
|
|
7
|
+
* idempotent open/close, atomic writes (no torn file), and best-effort,
|
|
8
|
+
* corrupt-tolerant reads.
|
|
9
|
+
*
|
|
10
|
+
* Run with: npx vitest run telegram-plugin/gateway/privacy-state.test.ts
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
14
|
+
import { mkdtempSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import {
|
|
18
|
+
readPrivacyState,
|
|
19
|
+
openPrivateInterval,
|
|
20
|
+
closePrivateInterval,
|
|
21
|
+
resetToPublic,
|
|
22
|
+
isPrivate,
|
|
23
|
+
privacyStatePath,
|
|
24
|
+
emptyPrivacyState,
|
|
25
|
+
type PrivacyState,
|
|
26
|
+
} from './privacy-state.js'
|
|
27
|
+
|
|
28
|
+
describe('privacy-state', () => {
|
|
29
|
+
let dir: string
|
|
30
|
+
let stderrSpy: ReturnType<typeof vi.spyOn>
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
dir = mkdtempSync(join(tmpdir(), 'privacy-state-'))
|
|
34
|
+
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
stderrSpy.mockRestore()
|
|
39
|
+
rmSync(dir, { recursive: true, force: true })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const path = () => privacyStatePath(dir)
|
|
43
|
+
const onDisk = (): PrivacyState => JSON.parse(readFileSync(path(), 'utf8'))
|
|
44
|
+
|
|
45
|
+
describe('readPrivacyState — best-effort, corrupt-tolerant', () => {
|
|
46
|
+
it('returns the public default when the file is missing', () => {
|
|
47
|
+
expect(readPrivacyState(dir)).toEqual(emptyPrivacyState())
|
|
48
|
+
expect(isPrivate(readPrivacyState(dir))).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('does not throw and returns the default on corrupt JSON', () => {
|
|
52
|
+
writeFileSync(path(), '{ this is not json', 'utf8')
|
|
53
|
+
expect(() => readPrivacyState(dir)).not.toThrow()
|
|
54
|
+
expect(readPrivacyState(dir)).toEqual(emptyPrivacyState())
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns the default when intervals is not an array', () => {
|
|
58
|
+
writeFileSync(path(), JSON.stringify({ version: 1, intervals: 'nope' }), 'utf8')
|
|
59
|
+
expect(readPrivacyState(dir).intervals).toEqual([])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('filters out malformed intervals but keeps valid ones', () => {
|
|
63
|
+
writeFileSync(
|
|
64
|
+
path(),
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
version: 1,
|
|
67
|
+
intervals: [
|
|
68
|
+
{ start: '2026-08-06T02:00:00.000Z', end: '2026-08-06T02:05:00.000Z' },
|
|
69
|
+
{ start: 42, end: null }, // bad start
|
|
70
|
+
{ end: null }, // missing start
|
|
71
|
+
{ start: '2026-08-06T02:10:00.000Z', end: null }, // valid open
|
|
72
|
+
],
|
|
73
|
+
}),
|
|
74
|
+
'utf8',
|
|
75
|
+
)
|
|
76
|
+
const state = readPrivacyState(dir)
|
|
77
|
+
expect(state.intervals).toHaveLength(2)
|
|
78
|
+
expect(state.intervals[1]).toEqual({ start: '2026-08-06T02:10:00.000Z', end: null })
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('openPrivateInterval', () => {
|
|
83
|
+
it('appends a single open interval and writes the exact contract schema', () => {
|
|
84
|
+
const now = new Date('2026-08-06T02:00:25.558Z')
|
|
85
|
+
openPrivateInterval(now, dir)
|
|
86
|
+
expect(onDisk()).toEqual({
|
|
87
|
+
version: 1,
|
|
88
|
+
intervals: [{ start: '2026-08-06T02:00:25.558Z', end: null }],
|
|
89
|
+
})
|
|
90
|
+
expect(isPrivate(readPrivacyState(dir))).toBe(true)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('is idempotent — a second /private while open does not stack', () => {
|
|
94
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'), dir)
|
|
95
|
+
openPrivateInterval(new Date('2026-08-06T02:03:00.000Z'), dir)
|
|
96
|
+
const state = readPrivacyState(dir)
|
|
97
|
+
expect(state.intervals).toHaveLength(1)
|
|
98
|
+
expect(state.intervals[0]).toEqual({ start: '2026-08-06T02:00:00.000Z', end: null })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('opens a fresh interval after a prior one was closed', () => {
|
|
102
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'), dir)
|
|
103
|
+
closePrivateInterval(new Date('2026-08-06T02:05:00.000Z'), dir)
|
|
104
|
+
openPrivateInterval(new Date('2026-08-06T02:10:00.000Z'), dir)
|
|
105
|
+
const state = readPrivacyState(dir)
|
|
106
|
+
expect(state.intervals).toHaveLength(2)
|
|
107
|
+
expect(state.intervals[0].end).toBe('2026-08-06T02:05:00.000Z')
|
|
108
|
+
expect(state.intervals[1]).toEqual({ start: '2026-08-06T02:10:00.000Z', end: null })
|
|
109
|
+
expect(isPrivate(state)).toBe(true)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('closePrivateInterval', () => {
|
|
114
|
+
it('sets end on the open interval', () => {
|
|
115
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'), dir)
|
|
116
|
+
closePrivateInterval(new Date('2026-08-06T02:05:10.100Z'), dir)
|
|
117
|
+
expect(onDisk().intervals[0]).toEqual({
|
|
118
|
+
start: '2026-08-06T02:00:00.000Z',
|
|
119
|
+
end: '2026-08-06T02:05:10.100Z',
|
|
120
|
+
})
|
|
121
|
+
expect(isPrivate(readPrivacyState(dir))).toBe(false)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('is idempotent — /public while already public is a no-op (no throw, no file)', () => {
|
|
125
|
+
expect(() => closePrivateInterval(new Date(), dir)).not.toThrow()
|
|
126
|
+
// No open interval existed → nothing was written.
|
|
127
|
+
expect(existsSync(path())).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('does not reopen or touch an already-closed interval', () => {
|
|
131
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'), dir)
|
|
132
|
+
closePrivateInterval(new Date('2026-08-06T02:05:00.000Z'), dir)
|
|
133
|
+
closePrivateInterval(new Date('2026-08-06T02:09:00.000Z'), dir)
|
|
134
|
+
expect(onDisk().intervals[0].end).toBe('2026-08-06T02:05:00.000Z')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('resetToPublic', () => {
|
|
139
|
+
it('truncates to the empty public default', () => {
|
|
140
|
+
openPrivateInterval(new Date(), dir)
|
|
141
|
+
resetToPublic(dir)
|
|
142
|
+
expect(onDisk()).toEqual({ version: 1, intervals: [] })
|
|
143
|
+
expect(isPrivate(readPrivacyState(dir))).toBe(false)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('atomic writes', () => {
|
|
148
|
+
it('leaves no temp file behind after a write', () => {
|
|
149
|
+
openPrivateInterval(new Date(), dir)
|
|
150
|
+
closePrivateInterval(new Date(), dir)
|
|
151
|
+
// Exactly one file — the state file — no lingering `.tmp`/`.new` sibling.
|
|
152
|
+
const entries = readdirSync(dir)
|
|
153
|
+
expect(entries).toEqual(['privacy-state.json'])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('a concurrent reader only ever sees a complete document', () => {
|
|
157
|
+
// Because the write is rename-based, the file at the destination path is
|
|
158
|
+
// always a fully-formed JSON doc — parsing it after any op never throws.
|
|
159
|
+
openPrivateInterval(new Date('2026-08-06T02:00:00.000Z'), dir)
|
|
160
|
+
expect(() => JSON.parse(readFileSync(path(), 'utf8'))).not.toThrow()
|
|
161
|
+
closePrivateInterval(new Date('2026-08-06T02:05:00.000Z'), dir)
|
|
162
|
+
expect(() => JSON.parse(readFileSync(path(), 'utf8'))).not.toThrow()
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
})
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session privacy state — the gateway side of the `/private` `/public`
|
|
3
|
+
* feature (switchroom private-mode).
|
|
4
|
+
*
|
|
5
|
+
* The operator can pause Hindsight auto-retain for a stretch of a session with
|
|
6
|
+
* `/private`, then resume it with `/public`. The pause is recorded here as a
|
|
7
|
+
* list of half-open time intervals in a small JSON state file that the Python
|
|
8
|
+
* retain side (`vendor/hindsight-memory`) reads to EXCLUDE any turn whose
|
|
9
|
+
* timestamp falls inside an interval from memory.
|
|
10
|
+
*
|
|
11
|
+
* ── The state-file contract (MUST match the Python reader exactly) ──────────
|
|
12
|
+
* Path: `${TELEGRAM_STATE_DIR}/privacy-state.json` (dir resolved the same way
|
|
13
|
+
* `src/cli/self-improve-stop.ts:resolveStateDir()` does). Schema:
|
|
14
|
+
*
|
|
15
|
+
* { "version": 1,
|
|
16
|
+
* "intervals": [
|
|
17
|
+
* { "start": "2026-08-06T02:00:25.558Z", "end": "2026-08-06T02:05:10.100Z" },
|
|
18
|
+
* { "start": "2026-08-06T02:10:00.000Z", "end": null }
|
|
19
|
+
* ] }
|
|
20
|
+
*
|
|
21
|
+
* `end: null` = an OPEN interval = "private right now". At most one is open at
|
|
22
|
+
* a time. A missing file (or `{"intervals":[]}`) means public — the default.
|
|
23
|
+
* Timestamps are ISO-8601 via `new Date().toISOString()`.
|
|
24
|
+
*
|
|
25
|
+
* ── Invariants ──────────────────────────────────────────────────────────────
|
|
26
|
+
* - All writes are ATOMIC (tmp + fsync + rename via `atomicWriteFileSync`),
|
|
27
|
+
* so a crash mid-write can never leave the Python reader a torn file.
|
|
28
|
+
* - All reads are BEST-EFFORT and never throw: a missing, unreadable, or
|
|
29
|
+
* corrupt file resolves to the public default. Losing this state is
|
|
30
|
+
* fail-safe (memory records rather than drops), so — unlike the security
|
|
31
|
+
* stores — a corrupt file here is tolerated silently rather than
|
|
32
|
+
* quarantined.
|
|
33
|
+
* - `openPrivateInterval` / `closePrivateInterval` are IDEMPOTENT: a second
|
|
34
|
+
* `/private` while already private is a no-op, and `/public` while already
|
|
35
|
+
* public is a no-op.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { readFileSync, mkdirSync } from 'node:fs'
|
|
39
|
+
import { homedir } from 'node:os'
|
|
40
|
+
import { join } from 'node:path'
|
|
41
|
+
|
|
42
|
+
import { atomicWriteFileSync } from '../../src/util/atomic.js'
|
|
43
|
+
|
|
44
|
+
/** One half-open privacy interval. `end: null` = still open ("private now"). */
|
|
45
|
+
export interface PrivacyInterval {
|
|
46
|
+
start: string
|
|
47
|
+
end: string | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The on-disk shape of `privacy-state.json`. */
|
|
51
|
+
export interface PrivacyState {
|
|
52
|
+
version: 1
|
|
53
|
+
intervals: PrivacyInterval[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The public (default) state — no private intervals. */
|
|
57
|
+
export function emptyPrivacyState(): PrivacyState {
|
|
58
|
+
return { version: 1, intervals: [] }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Loud, verbatim operator-facing strings ──────────────────────────────────
|
|
62
|
+
// Exported so the gateway command handlers and the boot alert use the exact
|
|
63
|
+
// wording the spec pins (and so tests assert against a single source).
|
|
64
|
+
|
|
65
|
+
/** Reply to `/private`. */
|
|
66
|
+
export const PRIVATE_ON_REPLY =
|
|
67
|
+
'🔒 Private mode ON — memory writing paused. Nothing said until /public is stored.'
|
|
68
|
+
|
|
69
|
+
/** Reply to `/public`. */
|
|
70
|
+
export const PUBLIC_REPLY =
|
|
71
|
+
'🔓 Public mode — memory writing resumed. The private stretch was excluded from memory.'
|
|
72
|
+
|
|
73
|
+
/** Loud alert posted when a genuine session start reset a leftover open interval. */
|
|
74
|
+
export const SESSION_RESET_ALERT =
|
|
75
|
+
'🔓 New session — memory writing is ON by default. Private mode from the previous session was reset.'
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Agent state dir — set by start.sh; resolved identically to
|
|
79
|
+
* `self-improve-stop.ts:resolveStateDir()` so the gateway writer and the
|
|
80
|
+
* Python reader agree on where `privacy-state.json` lives.
|
|
81
|
+
*/
|
|
82
|
+
export function resolvePrivacyStateDir(): string {
|
|
83
|
+
return (
|
|
84
|
+
process.env.TELEGRAM_STATE_DIR ??
|
|
85
|
+
join(homedir(), '.claude', 'channels', 'telegram')
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Absolute path to the shared state file. */
|
|
90
|
+
export function privacyStatePath(stateDir: string = resolvePrivacyStateDir()): string {
|
|
91
|
+
return join(stateDir, 'privacy-state.json')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True iff `v` is a well-formed interval object. */
|
|
95
|
+
function isInterval(v: unknown): v is PrivacyInterval {
|
|
96
|
+
if (v === null || typeof v !== 'object') return false
|
|
97
|
+
const o = v as Record<string, unknown>
|
|
98
|
+
if (typeof o.start !== 'string') return false
|
|
99
|
+
return o.end === null || typeof o.end === 'string'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read the current privacy state. BEST-EFFORT: a missing / unreadable /
|
|
104
|
+
* corrupt file resolves to the public default and NEVER throws. Only
|
|
105
|
+
* well-formed intervals survive; a partially-corrupt array is filtered down to
|
|
106
|
+
* its valid members rather than discarded wholesale.
|
|
107
|
+
*/
|
|
108
|
+
export function readPrivacyState(stateDir: string = resolvePrivacyStateDir()): PrivacyState {
|
|
109
|
+
try {
|
|
110
|
+
const raw = readFileSync(privacyStatePath(stateDir), 'utf8')
|
|
111
|
+
const parsed: unknown = JSON.parse(raw)
|
|
112
|
+
if (parsed === null || typeof parsed !== 'object') return emptyPrivacyState()
|
|
113
|
+
const rawIntervals = (parsed as Record<string, unknown>).intervals
|
|
114
|
+
if (!Array.isArray(rawIntervals)) return emptyPrivacyState()
|
|
115
|
+
return { version: 1, intervals: rawIntervals.filter(isInterval) }
|
|
116
|
+
} catch {
|
|
117
|
+
return emptyPrivacyState()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Atomically persist `state`. BEST-EFFORT: a write failure is logged to stderr
|
|
123
|
+
* and swallowed so a transient fs error can never crash a command handler or
|
|
124
|
+
* the boot path. (Losing the write is fail-safe — memory records the turn.)
|
|
125
|
+
*/
|
|
126
|
+
function writePrivacyState(state: PrivacyState, stateDir: string): void {
|
|
127
|
+
try {
|
|
128
|
+
mkdirSync(stateDir, { recursive: true })
|
|
129
|
+
} catch {
|
|
130
|
+
/* dir may already exist / be unwritable — the write below reports */
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
atomicWriteFileSync(privacyStatePath(stateDir), JSON.stringify(state), 0o600)
|
|
134
|
+
} catch (err) {
|
|
135
|
+
process.stderr.write(
|
|
136
|
+
`telegram gateway: privacy-state write failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** True iff an interval is currently open ("private right now"). */
|
|
142
|
+
export function isPrivate(state: PrivacyState): boolean {
|
|
143
|
+
return state.intervals.some(i => i.end === null)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Start a private stretch. IDEMPOTENT: if an interval is already open this is a
|
|
148
|
+
* no-op (a second `/private` doesn't stack).
|
|
149
|
+
*/
|
|
150
|
+
export function openPrivateInterval(
|
|
151
|
+
now: Date = new Date(),
|
|
152
|
+
stateDir: string = resolvePrivacyStateDir(),
|
|
153
|
+
): void {
|
|
154
|
+
const state = readPrivacyState(stateDir)
|
|
155
|
+
if (isPrivate(state)) return
|
|
156
|
+
state.intervals.push({ start: now.toISOString(), end: null })
|
|
157
|
+
writePrivacyState(state, stateDir)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* End the current private stretch. IDEMPOTENT: if no interval is open (already
|
|
162
|
+
* public) this is a no-op.
|
|
163
|
+
*/
|
|
164
|
+
export function closePrivateInterval(
|
|
165
|
+
now: Date = new Date(),
|
|
166
|
+
stateDir: string = resolvePrivacyStateDir(),
|
|
167
|
+
): void {
|
|
168
|
+
const state = readPrivacyState(stateDir)
|
|
169
|
+
const open = state.intervals.find(i => i.end === null)
|
|
170
|
+
if (!open) return
|
|
171
|
+
open.end = now.toISOString()
|
|
172
|
+
writePrivacyState(state, stateDir)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Truncate the state file back to the public default. */
|
|
176
|
+
export function resetToPublic(stateDir: string = resolvePrivacyStateDir()): void {
|
|
177
|
+
writePrivacyState(emptyPrivacyState(), stateDir)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Outcome of a session-start reset. */
|
|
181
|
+
export interface SessionResetResult {
|
|
182
|
+
/** True iff an OPEN interval existed and was reset (a private→public transition). */
|
|
183
|
+
hadOpenInterval: boolean
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Reset privacy to public at a GENUINE session start (cold boot / crash /
|
|
188
|
+
* planned restart / `/clear`). Always truncates the state file. If — and only
|
|
189
|
+
* if — an OPEN interval existed (the previous session ended still private),
|
|
190
|
+
* `onOpenIntervalReset` is invoked so the caller can post the loud alert. When
|
|
191
|
+
* the previous session was already public there is no transition, so no alert
|
|
192
|
+
* fires (silent reset).
|
|
193
|
+
*
|
|
194
|
+
* The alert is delegated to a callback rather than sent here so this module
|
|
195
|
+
* stays free of gateway/bot dependencies and unit-testable in isolation.
|
|
196
|
+
*/
|
|
197
|
+
export function resetPrivacyOnGenuineSessionStart(opts: {
|
|
198
|
+
stateDir?: string
|
|
199
|
+
onOpenIntervalReset?: () => void
|
|
200
|
+
} = {}): SessionResetResult {
|
|
201
|
+
const stateDir = opts.stateDir ?? resolvePrivacyStateDir()
|
|
202
|
+
const hadOpenInterval = isPrivate(readPrivacyState(stateDir))
|
|
203
|
+
resetToPublic(stateDir)
|
|
204
|
+
if (hadOpenInterval) opts.onOpenIntervalReset?.()
|
|
205
|
+
return { hadOpenInterval }
|
|
206
|
+
}
|