switchroom 0.20.9 → 0.20.11

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 (59) hide show
  1. package/bin/handoff-briefing.sh +57 -5
  2. package/bin/working-state-reload-hook.sh +262 -0
  3. package/dist/agent-scheduler/index.js +65 -2
  4. package/dist/auth-broker/index.js +204 -24
  5. package/dist/cli/notion-write-pretool.mjs +65 -2
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
  7. package/dist/cli/self-improve-stop.mjs +889 -7
  8. package/dist/cli/skill-validate-pretool.mjs +82 -3
  9. package/dist/cli/switchroom.js +3699 -2110
  10. package/dist/host-control/main.js +67 -4
  11. package/dist/vault/approvals/kernel-server.js +66 -3
  12. package/dist/vault/broker/server.js +66 -3
  13. package/package.json +1 -1
  14. package/profiles/_base/start.sh.hbs +49 -0
  15. package/profiles/_shared/agent-self-service.md.hbs +15 -22
  16. package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
  17. package/profiles/_shared/dev-protocol.md.hbs +1 -1
  18. package/profiles/_shared/execution-discipline.md.hbs +4 -4
  19. package/profiles/_shared/vault-protocol.md.hbs +2 -18
  20. package/profiles/default/CLAUDE.md.hbs +3 -5
  21. package/telegram-plugin/auto-fallback-fleet.ts +37 -2
  22. package/telegram-plugin/dist/gateway/gateway.js +1414 -918
  23. package/telegram-plugin/fallback-card-collapse.ts +1 -0
  24. package/telegram-plugin/gateway/auth-command.ts +11 -1
  25. package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
  26. package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
  27. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
  28. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
  29. package/telegram-plugin/gateway/gateway.ts +85 -90
  30. package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
  31. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  32. package/telegram-plugin/gateway/narrative-lane.ts +33 -2
  33. package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
  34. package/telegram-plugin/gateway/privacy-reset.ts +87 -0
  35. package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
  36. package/telegram-plugin/gateway/privacy-state.ts +206 -0
  37. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
  38. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
  39. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
  40. package/telegram-plugin/gateway/stale-pin-sweep.ts +48 -32
  41. package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
  42. package/telegram-plugin/slot-banner-driver.ts +42 -5
  43. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
  44. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
  45. package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
  46. package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
  47. package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
  48. package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
  49. package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
  50. package/telegram-plugin/throttle-tier.ts +12 -3
  51. package/telegram-plugin/turn-flush-safety.ts +97 -0
  52. package/vendor/hindsight-memory/CHANGELOG.md +31 -0
  53. package/vendor/hindsight-memory/hooks/hooks.json +2 -1
  54. package/vendor/hindsight-memory/scripts/retain.py +306 -0
  55. package/vendor/hindsight-memory/scripts/session_start.py +35 -8
  56. package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
  57. package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
  59. package/vendor/hindsight-memory/scripts/tests/test_session_start_durability.py +107 -0
@@ -573,6 +573,48 @@ export interface PostSkillProposalMessage {
573
573
  evidence: string;
574
574
  /** Full drafted skill bundle (SKILL.md + optional files). */
575
575
  draft: Record<string, string>;
576
+ /**
577
+ * Provenance of the proposal — `"skill-synthesis"` (the weekly cron) or
578
+ * `"failure-synthesis"` (a skill drafted from an observed failure). Absent
579
+ * ⇒ the store defaults it to `"skill-synthesis"` (back-compat). Provenance
580
+ * is orthogonal to tier routing.
581
+ */
582
+ origin?: "skill-synthesis" | "failure-synthesis";
583
+ }
584
+
585
+ /**
586
+ * `switchroom self-improve add-eval-case` asks the caller agent's gateway to
587
+ * persist an eval-case proposal and post a one-tap Approve/Dismiss card (RFC
588
+ * amendment §"corrections as eval cases"). Unlike a skill proposal, the
589
+ * Approve tap does NOT inject a model turn — the gateway runs the
590
+ * DETERMINISTIC `apply-eval-case` applier so the case lands byte-exact.
591
+ *
592
+ * Trust model identical to post_skill_proposal: per-agent socket, agentName
593
+ * validated, chat fenced to the agent's own chat.
594
+ */
595
+ export interface PostEvalCaseProposalMessage {
596
+ type: "post_eval_case_proposal";
597
+ agentName: string;
598
+ /** Agent's own chat id (fenced server-side). */
599
+ chatId: string;
600
+ threadId?: number;
601
+ /** Target skill slug. */
602
+ skillSlug: string;
603
+ /** Absolute path to the owned skill bundle dir (resolved by the CLI). */
604
+ skillDir: string;
605
+ /** The eval case to append (prompt + optional expected/expectations/etc). */
606
+ case: {
607
+ prompt: string;
608
+ expected_output?: string;
609
+ files?: string[];
610
+ expectations?: string[];
611
+ source?: string;
612
+ id?: string;
613
+ };
614
+ /** Prompt fingerprint (dedup + provenance). */
615
+ fingerprint: string;
616
+ /** Route the case to the held-out sink instead of evals.json. */
617
+ heldOut: boolean;
576
618
  }
577
619
 
578
620
  /**
@@ -766,6 +808,7 @@ export type ClientToGateway =
766
808
  | QuotaWallDetectedMessage
767
809
  | SendOutboundMessage
768
810
  | PostSkillProposalMessage
811
+ | PostEvalCaseProposalMessage
769
812
  | RolloutStatusPostMessage
770
813
  | RolloutStatusEditMessage
771
814
  | QueryPendingPermissionMessage
@@ -9,6 +9,7 @@ import type {
9
9
  QueryPendingPermissionMessage,
10
10
  CheckPreApprovedMessage,
11
11
  PostSkillProposalMessage,
12
+ PostEvalCaseProposalMessage,
12
13
  OperatorEventForward,
13
14
  PermissionRequestForward,
14
15
  PtyPartialForward,
@@ -101,6 +102,12 @@ export interface IpcServerOptions {
101
102
  * chat. Optional; gateways that don't surface proposals ignore it.
102
103
  */
103
104
  onPostSkillProposal?: (client: IpcClient, msg: PostSkillProposalMessage) => void;
105
+ /**
106
+ * RFC amendment §"corrections as eval cases" — `add-eval-case` asks the
107
+ * gateway to persist an eval-case proposal and post its one-tap card.
108
+ * Optional; gateways that don't surface proposals ignore it.
109
+ */
110
+ onPostEvalCaseProposal?: (client: IpcClient, msg: PostEvalCaseProposalMessage) => void;
104
111
  /**
105
112
  * RFC E §4.2 Cut 2 — Drive-write PreToolUse hook asks the gateway
106
113
  * to register a kernel approval request + post a diff-preview
@@ -379,6 +386,23 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
379
386
  && (typeof m.threadId !== "number" || !Number.isInteger(m.threadId as number))) return false;
380
387
  return true;
381
388
  }
389
+ case "post_eval_case_proposal": {
390
+ // RFC amendment §"corrections as eval cases" — validate the wire shape;
391
+ // the gateway handler fences chatId to the agent's own chat.
392
+ if (typeof m.agentName !== "string"
393
+ || !AGENT_NAME_RE.test(m.agentName as string)) return false;
394
+ if (typeof m.chatId !== "string" || (m.chatId as string).length === 0) return false;
395
+ if (typeof m.skillSlug !== "string" || (m.skillSlug as string).length === 0) return false;
396
+ if (typeof m.skillDir !== "string" || (m.skillDir as string).length === 0) return false;
397
+ if (typeof m.fingerprint !== "string" || (m.fingerprint as string).length === 0) return false;
398
+ if (typeof m.heldOut !== "boolean") return false;
399
+ if (typeof m.case !== "object" || m.case === null || Array.isArray(m.case)) return false;
400
+ if (typeof (m.case as Record<string, unknown>).prompt !== "string"
401
+ || ((m.case as Record<string, unknown>).prompt as string).length === 0) return false;
402
+ if (m.threadId !== undefined
403
+ && (typeof m.threadId !== "number" || !Number.isInteger(m.threadId as number))) return false;
404
+ return true;
405
+ }
382
406
  case "quota_wall_detected": {
383
407
  // wedge-watchdog detected the /rate-limit-options weekly-quota menu.
384
408
  if (typeof m.agentName !== "string"
@@ -565,6 +589,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
565
589
  onQueryPendingPermission,
566
590
  onCheckPreApproved,
567
591
  onPostSkillProposal,
592
+ onPostEvalCaseProposal,
568
593
  onRequestDriveApproval,
569
594
  onRequestMs365Approval,
570
595
  onRequestConfigApproval,
@@ -733,6 +758,9 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
733
758
  case "post_skill_proposal":
734
759
  if (onPostSkillProposal) onPostSkillProposal(client, msg as PostSkillProposalMessage);
735
760
  break;
761
+ case "post_eval_case_proposal":
762
+ if (onPostEvalCaseProposal) onPostEvalCaseProposal(client, msg as PostEvalCaseProposalMessage);
763
+ break;
736
764
  case "quota_wall_detected":
737
765
  if (onQuotaWallDetected) onQuotaWallDetected(client, msg as QuotaWallDetectedMessage);
738
766
  break;
@@ -56,7 +56,7 @@ import {
56
56
  appendActivityLabel, clipNarrative, formatStepSuffix, renderActivityFeedWithNested,
57
57
  } from '../tool-activity-summary.js'
58
58
  import { evaluatePostAnswerLiveness } from '../turn-liveness-floor.js'
59
- import { isSilentSentinelCardOutcome } from '../turn-flush-safety.js'
59
+ import { isHollowGhostCardOutcome, isSilentSentinelCardOutcome } from '../turn-flush-safety.js'
60
60
  import { clearActivityCardRecord, writeActivityCardRecord } from './activity-card-store.js'
61
61
  import { chatKeyWithSuffix } from './chat-key.js'
62
62
  import {
@@ -455,10 +455,18 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
455
455
  // stays in view when the feed scrolls past. Keyed to the same
456
456
  // status-key the canonical turn-end (purgeReactionTracking) unpins.
457
457
  // Fire-and-forget; the single-owner reconcile keeps state consistent.
458
+ // Pass `thread` as the 4th arg so the persisted row keeps the forum
459
+ // topic (D4): without it a forum-topic foreground card orphaned by a
460
+ // crash lands a threadId-less row, and the sweep cannot aim
461
+ // `unpinAllForumTopicMessages` at the right topic. Mirrors the worker
462
+ // feed's `reconcilePin` (gateway.ts). It does NOT change the pinKey —
463
+ // `statusKey(chat, thread)` already folds the topic into the key; the
464
+ // 4th arg only threads the topic into the claim + durable row.
458
465
  void reconcileStatusPin(
459
466
  `fg:${statusKey(chat, thread)}`,
460
467
  chat,
461
468
  { pinned: true, messageId: sent.message_id },
469
+ thread,
462
470
  )
463
471
  } else {
464
472
  const id = turn.activityMessageId
@@ -900,7 +908,30 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
900
908
  capturedText: turn.capturedText,
901
909
  finalAnswerEverDelivered: turn.finalAnswerEverDelivered,
902
910
  })
903
- if (CLEAR_STATUS_ON_COMPLETION || silentSentinelTurn) {
911
+ // #45 — hollow-ghost suppression. The sibling to the #4348 sentinel gate:
912
+ // a turn that ended having done ZERO surfaced tool work, never called
913
+ // reply, delivered no final answer, surfaced NO narration, and emitted no
914
+ // captured/reply text adopted an activity card at turn start that never
915
+ // got any content — the contentless `🤖 Agent · done · 0 tools · Ns`
916
+ // record Ken reported (a card opened by the liveness timer that stayed
917
+ // empty). The sentinel gate can't catch it (there is no NO_REPLY/
918
+ // HEARTBEAT_OK text to match; the flush classifies it `empty-text`, not
919
+ // `silent-marker`), so DELETE the empty card here instead of finalizing
920
+ // it. Deterministic (pure predicate over already-tracked turn fields) and
921
+ // normal-case-safe: any surfaced tool step, any rendered narrative line
922
+ // (`mirrorLines` — content the user actually saw), any reply, any captured
923
+ // text, or a delivered answer keeps the card. The `finalHtmlOverride`
924
+ // finalize path is only taken by the foreground handoff-clear (which fires
925
+ // on a delivered final answer), so this gate never contends with it.
926
+ const hollowGhostTurn = isHollowGhostCardOutcome({
927
+ replyCalled: turn.replyCalled,
928
+ labeledToolCount: turn.labeledToolCount,
929
+ mirrorLines: turn.mirrorLines,
930
+ capturedText: turn.capturedText,
931
+ lastReplyText: turn.lastReplyText,
932
+ finalAnswerEverDelivered: turn.finalAnswerEverDelivered,
933
+ })
934
+ if (CLEAR_STATUS_ON_COMPLETION || silentSentinelTurn || hollowGhostTurn) {
904
935
  try {
905
936
  await robustApiCall(
906
937
  () => bot.api.deleteMessage(chat, id),
@@ -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
+ })