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
@@ -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
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Self-improvement proposal IPC handlers, lifted out of gateway.ts.
3
+ *
4
+ * Both `post_skill_proposal` (#2670 one-tap self-improvement) and
5
+ * `post_eval_case_proposal` (RFC amendment §"corrections as eval cases")
6
+ * arrive over the per-agent gateway socket, are chat-fenced via
7
+ * `assertAllowedChat`, persisted to their respective store, and rendered as
8
+ * an Approve/Dismiss card. The store transition + apply on Approve are owned
9
+ * by the callback handlers (so a gateway restart between post and tap still
10
+ * resolves), NOT here.
11
+ *
12
+ * These live in a module (not inline in gateway.ts) so the gateway anti-
13
+ * inflation line ratchet stays flat and the handler bodies are unit-testable
14
+ * against a stubbed `bot` / `swallowingApiCall`. gateway.ts keeps only a thin
15
+ * delegate per handler.
16
+ *
17
+ * The `bot.api.sendMessage` calls are wrapped in the injected
18
+ * `swallowingApiCall` exactly as they were in gateway.ts — the bot-api-wrapping
19
+ * lint recognises the wrapper by name in the surrounding context.
20
+ */
21
+
22
+ import type { Bot, Context } from 'grammy'
23
+ import type { RetryCallOpts } from '../retry-api-call.js'
24
+ import type { PostSkillProposalMessage, PostEvalCaseProposalMessage } from './ipc-protocol.js'
25
+ import { renderSkillProposalCard, skillProposalKeyboard } from './skill-proposal-card.js'
26
+ import { renderEvalCaseProposalCard, evalCaseProposalKeyboard } from './eval-case-proposal-card.js'
27
+ import {
28
+ enqueueProposal as enqueueSkillProposal,
29
+ isSuppressed as isSkillProposalSuppressed,
30
+ } from '../../src/self-improve/skill-proposals.js'
31
+ import { enqueueEvalCaseProposal } from '../../src/self-improve/eval-case-proposals.js'
32
+
33
+ /** Collaborators the gateway injects into each handler. */
34
+ export interface ProposalWiringDeps {
35
+ bot: Bot<Context>
36
+ assertAllowedChat: (chatId: string) => void
37
+ swallowingApiCall: <T>(fn: () => Promise<T>, opts?: RetryCallOpts) => Promise<T | undefined>
38
+ }
39
+
40
+ /**
41
+ * #2670 one-tap self-improvement — persist a skill-improvement proposal and
42
+ * post its Approve/Dismiss card. Dedups against still-live rejection
43
+ * fingerprints so a dismissed proposal never re-surfaces.
44
+ */
45
+ export function handlePostSkillProposal(
46
+ msg: PostSkillProposalMessage,
47
+ deps: ProposalWiringDeps,
48
+ ): void {
49
+ const { bot, assertAllowedChat, swallowingApiCall } = deps
50
+ const self = process.env.SWITCHROOM_AGENT_NAME
51
+ if (self && msg.agentName !== self) {
52
+ process.stderr.write(
53
+ `telegram gateway: post_skill_proposal rejected — agent mismatch (${msg.agentName} != ${self})\n`,
54
+ )
55
+ return
56
+ }
57
+ try {
58
+ assertAllowedChat(msg.chatId)
59
+ } catch (err) {
60
+ process.stderr.write(
61
+ `telegram gateway: post_skill_proposal rejected — ${(err as Error).message}\n`,
62
+ )
63
+ return
64
+ }
65
+ const stateDir = process.env.TELEGRAM_STATE_DIR
66
+ if (stateDir == null || stateDir.length === 0) {
67
+ process.stderr.write(`telegram gateway: post_skill_proposal: TELEGRAM_STATE_DIR unset, skipping\n`)
68
+ return
69
+ }
70
+ // Dedup against still-live rejection fingerprints — never re-surface a
71
+ // proposal the operator already dismissed.
72
+ if (isSkillProposalSuppressed(stateDir, {
73
+ lesson: msg.lesson,
74
+ draft: msg.draft,
75
+ skill_slug: msg.skillSlug,
76
+ })) {
77
+ process.stderr.write(
78
+ `telegram gateway: post_skill_proposal suppressed (rejected before) slug=${msg.skillSlug}\n`,
79
+ )
80
+ return
81
+ }
82
+ const proposal = enqueueSkillProposal(stateDir, {
83
+ skill_slug: msg.skillSlug,
84
+ is_new: msg.isNew,
85
+ lesson: msg.lesson,
86
+ draft: msg.draft,
87
+ evidence: msg.evidence,
88
+ chat_id: Number(msg.chatId),
89
+ // Provenance — absent ⇒ the store's back-compat default (skill-synthesis).
90
+ ...(msg.origin != null ? { origin: msg.origin } : {}),
91
+ })
92
+ const cardText = renderSkillProposalCard({
93
+ id: proposal.id,
94
+ skill_slug: proposal.skill_slug,
95
+ is_new: proposal.is_new,
96
+ lesson: proposal.lesson,
97
+ evidence: proposal.evidence,
98
+ skill_md: proposal.draft['SKILL.md'],
99
+ })
100
+ const threadId = msg.threadId
101
+ void swallowingApiCall(
102
+ () =>
103
+ bot.api.sendMessage(msg.chatId, cardText, {
104
+ parse_mode: 'HTML',
105
+ reply_markup: skillProposalKeyboard(proposal.id),
106
+ ...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
107
+ }),
108
+ { chat_id: msg.chatId, verb: 'skill-proposal-card', ...(threadId != null ? { threadId } : {}) },
109
+ )
110
+ process.stderr.write(
111
+ `telegram gateway: post_skill_proposal agent=${msg.agentName} chat=${msg.chatId} ` +
112
+ `proposal=${proposal.id} slug=${proposal.skill_slug} new=${proposal.is_new}\n`,
113
+ )
114
+ }
115
+
116
+ /**
117
+ * RFC amendment §"corrections as eval cases" — persist an eval-case proposal
118
+ * and post its Approve/Dismiss card. On Approve the callback runs the
119
+ * DETERMINISTIC applier (handleEvalCaseProposalCallback), NOT a model turn, so
120
+ * the case lands byte-exact. Same per-agent-socket / chat-fenced trust model
121
+ * as handlePostSkillProposal.
122
+ */
123
+ export function handlePostEvalCaseProposal(
124
+ msg: PostEvalCaseProposalMessage,
125
+ deps: ProposalWiringDeps,
126
+ ): void {
127
+ const { bot, assertAllowedChat, swallowingApiCall } = deps
128
+ const self = process.env.SWITCHROOM_AGENT_NAME
129
+ if (self && msg.agentName !== self) {
130
+ process.stderr.write(
131
+ `telegram gateway: post_eval_case_proposal rejected — agent mismatch (${msg.agentName} != ${self})\n`,
132
+ )
133
+ return
134
+ }
135
+ try {
136
+ assertAllowedChat(msg.chatId)
137
+ } catch (err) {
138
+ process.stderr.write(
139
+ `telegram gateway: post_eval_case_proposal rejected — ${(err as Error).message}\n`,
140
+ )
141
+ return
142
+ }
143
+ const stateDir = process.env.TELEGRAM_STATE_DIR
144
+ if (stateDir == null || stateDir.length === 0) {
145
+ process.stderr.write(`telegram gateway: post_eval_case_proposal: TELEGRAM_STATE_DIR unset, skipping\n`)
146
+ return
147
+ }
148
+ const proposal = enqueueEvalCaseProposal(stateDir, {
149
+ skill_slug: msg.skillSlug,
150
+ skill_dir: msg.skillDir,
151
+ case: msg.case,
152
+ fingerprint: msg.fingerprint,
153
+ held_out: msg.heldOut === true,
154
+ chat_id: Number(msg.chatId),
155
+ })
156
+ const cardText = renderEvalCaseProposalCard({
157
+ id: proposal.id,
158
+ skill_slug: proposal.skill_slug,
159
+ held_out: proposal.held_out,
160
+ case: proposal.case,
161
+ })
162
+ const threadId = msg.threadId
163
+ void swallowingApiCall(
164
+ () =>
165
+ bot.api.sendMessage(msg.chatId, cardText, {
166
+ parse_mode: 'HTML',
167
+ reply_markup: evalCaseProposalKeyboard(proposal.id),
168
+ ...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
169
+ }),
170
+ { chat_id: msg.chatId, verb: 'eval-case-proposal-card', ...(threadId != null ? { threadId } : {}) },
171
+ )
172
+ process.stderr.write(
173
+ `telegram gateway: post_eval_case_proposal agent=${msg.agentName} chat=${msg.chatId} ` +
174
+ `proposal=${proposal.id} slug=${proposal.skill_slug} held_out=${proposal.held_out}\n`,
175
+ )
176
+ }
@@ -51,9 +51,7 @@ export interface StalePinSweepBotSeam {
51
51
  ) => Promise<unknown>
52
52
  unpinChatMessage: (chatId: string, messageId: number) => Promise<unknown>
53
53
  unpinAllForumTopicMessages: (chatId: string, threadId: number) => Promise<unknown>
54
- getChatMember: (chatId: string, userId: number) => Promise<unknown>
55
54
  }
56
- botInfo?: { id?: number }
57
55
  }
58
56
  /** The gateway's retry/telemetry envelope (`robustApiCall`). */
59
57
  call: <T>(fn: () => Promise<T>, meta: { chat_id: string; verb: string }) => Promise<T>
@@ -68,6 +66,14 @@ export interface StalePinSweepWiring {
68
66
  loadPinRows: () => PersistedStatusPin[]
69
67
  /** Mutex-won AND bot-constructed. False ⇒ the drain must not write. */
70
68
  eligible: () => boolean
69
+ /**
70
+ * The SHARED per-process pin-rights negative cache (status-pin.ts
71
+ * `PinRightsCache`), unifying the sweep and the live status-pin path on which
72
+ * chats are rights-less. Only `isBlocked`/`block` are used here; the live
73
+ * path owns `clear` (on a successful explicit pin) and boot owns the reset.
74
+ * Optional — undefined ⇒ the sweep relies purely on its reactive classifier.
75
+ */
76
+ rightsCache?: { isBlocked: (chatId: string) => boolean; block: (chatId: string) => boolean }
71
77
  store: { path: string; fs: SweepStoreFsSeam }
72
78
  /** Per-deployment override of `UNPIN_ALL_FORUM_TOPIC_ENABLED`; undefined =
73
79
  * take the standing policy (the wholesale topic drain stays OFF). */
@@ -168,18 +174,16 @@ export function createGatewayStalePinSweeper(w: StalePinSweepWiring): StalePinSw
168
174
  chat_id: chatId,
169
175
  verb: 'stale-pin-sweep.unpin-all-topic',
170
176
  }),
171
- // Rights are checked before ANY write in a group. Telegram is HONEST about
172
- // missing pin rights (400 "not enough rights to manage pinned messages"),
173
- // but the precheck keeps a rights-less bot from emitting doomed traffic.
174
- canPinInChat: async (chatId) => {
175
- const self = bot().botInfo?.id
176
- if (self == null) return false
177
- const member = (await call(() => bot().api.getChatMember(chatId, self), {
178
- chat_id: chatId,
179
- verb: 'stale-pin-sweep.get-chat-member',
180
- })) as { status?: string; can_pin_messages?: boolean } | undefined
181
- return member?.status === 'administrator' && member.can_pin_messages === true
182
- },
177
+ // No proactive rights precheck. The old `getChatMember` precheck read
178
+ // `bot().botInfo?.id`, but the sweep runs against the chat-lock-wrapped bot
179
+ // (`wrapBot({ api: bot.api })`), which carries only `.api` — `.botInfo` was
180
+ // always undefined, so `self` was always null and every group precheck
181
+ // returned false, forfeiting every group cursor without a single unpin. The
182
+ // sweep now relies SOLELY on its reactive classifier: it attempts the unpin
183
+ // and classifies Telegram's honest `400 "not enough rights"` via
184
+ // `isPinRightsError`. Telegram is authoritative about pin rights; a precheck
185
+ // could only ever be redundant with (and, wired against the wrong bot,
186
+ // wrong about) that answer.
183
187
  recordedPinIds: (chatId, threadId) => {
184
188
  try {
185
189
  return recordedPinIdsFor(w.loadPinRows(), chatId, threadId)
@@ -200,6 +204,12 @@ export function createGatewayStalePinSweeper(w: StalePinSweepWiring): StalePinSw
200
204
  log,
201
205
  }),
202
206
  eligible: w.eligible,
207
+ rightsBlocked: w.rightsCache ? (chatId) => w.rightsCache!.isBlocked(chatId) : undefined,
208
+ recordRightsBlock: w.rightsCache
209
+ ? (chatId) => {
210
+ w.rightsCache!.block(chatId)
211
+ }
212
+ : undefined,
203
213
  sleep: w.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
204
214
  now,
205
215
  store: w.store,