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.
Files changed (76) hide show
  1. package/dist/agent-scheduler/index.js +68 -3
  2. package/dist/auth-broker/index.js +211 -46
  3. package/dist/cli/notion-write-pretool.mjs +68 -3
  4. package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
  5. package/dist/cli/self-improve-stop.mjs +889 -7
  6. package/dist/cli/skill-validate-pretool.mjs +82 -3
  7. package/dist/cli/switchroom.js +5063 -2961
  8. package/dist/host-control/main.js +93 -27
  9. package/dist/vault/approvals/kernel-server.js +92 -26
  10. package/dist/vault/broker/server.js +92 -26
  11. package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
  12. package/package.json +1 -1
  13. package/profiles/_shared/agent-self-service.md.hbs +15 -22
  14. package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
  15. package/profiles/_shared/dev-protocol.md.hbs +1 -1
  16. package/profiles/_shared/execution-discipline.md.hbs +4 -4
  17. package/profiles/_shared/vault-protocol.md.hbs +2 -18
  18. package/profiles/default/CLAUDE.md.hbs +3 -5
  19. package/skills/switchroom-architecture/telegram.md +0 -1
  20. package/skills/switchroom-cli/SKILL.md +0 -1
  21. package/telegram-plugin/README.md +2 -11
  22. package/telegram-plugin/auto-fallback-fleet.ts +37 -2
  23. package/telegram-plugin/bridge/bridge.ts +0 -12
  24. package/telegram-plugin/chat-lock.ts +1 -1
  25. package/telegram-plugin/dist/bridge/bridge.js +0 -12
  26. package/telegram-plugin/dist/gateway/gateway.js +1454 -976
  27. package/telegram-plugin/dist/server.js +0 -12
  28. package/telegram-plugin/fallback-card-collapse.ts +1 -0
  29. package/telegram-plugin/gateway/auth-command.ts +11 -1
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
  31. package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
  32. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
  33. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
  34. package/telegram-plugin/gateway/gateway.ts +109 -149
  35. package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
  36. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  37. package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
  38. package/telegram-plugin/gateway/narrative-lane.ts +33 -2
  39. package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
  40. package/telegram-plugin/gateway/privacy-reset.ts +87 -0
  41. package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
  42. package/telegram-plugin/gateway/privacy-state.ts +206 -0
  43. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
  44. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
  45. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
  46. package/telegram-plugin/gateway/stale-pin-sweep.ts +52 -35
  47. package/telegram-plugin/gateway/status-pin-store.ts +10 -9
  48. package/telegram-plugin/gateway/stream-render.ts +4 -4
  49. package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
  50. package/telegram-plugin/gateway/turn-record-status.ts +32 -1
  51. package/telegram-plugin/hooks/hooks.json +13 -12
  52. package/telegram-plugin/hooks/narration-classify.mjs +1 -2
  53. package/telegram-plugin/hooks/silent-end-scan.mjs +1 -1
  54. package/telegram-plugin/slot-banner-driver.ts +42 -5
  55. package/telegram-plugin/status-pin.ts +2 -5
  56. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
  57. package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
  58. package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
  59. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
  60. package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
  61. package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
  62. package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
  63. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
  64. package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
  65. package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
  66. package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
  67. package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
  68. package/telegram-plugin/throttle-tier.ts +12 -3
  69. package/telegram-plugin/turn-flush-safety.ts +97 -0
  70. package/telegram-plugin/worker-activity-feed.ts +1 -1
  71. package/vendor/hindsight-memory/scripts/recall.py +140 -0
  72. package/vendor/hindsight-memory/scripts/retain.py +306 -0
  73. package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
  74. package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
  75. package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
@@ -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,
@@ -22,7 +22,6 @@ import {
22
22
  createStalePinSweeper,
23
23
  isNothingToUnpinError,
24
24
  isPeerFloodError,
25
- isPinRightsError,
26
25
  mayUnpinAllForumTopic,
27
26
  retryAfterSeconds,
28
27
  unexpiredStoreRepinIds,
@@ -38,6 +37,10 @@ import {
38
37
  type SweepCursor,
39
38
  type SweepStoreFsSeam,
40
39
  } from './stale-pin-sweep-store.js'
40
+ // The sweep now imports its rights detector from the single source of truth
41
+ // (status-pin.ts) — no second copy. The negative cache it shares with the live
42
+ // pin path is that module's `PinRightsCache` (D3).
43
+ import { isPinRightsError, PinRightsCache } from '../status-pin.js'
41
44
 
42
45
  // Synthetic ids (check-no-pii-secrets): DMs positive, groups negative.
43
46
  const DM = '900000001'
@@ -84,7 +87,10 @@ interface FakeOpts {
84
87
  * call-counted `popped` and an observation-counted `popped` disagree.
85
88
  */
86
89
  unpinAllTopicRemoves?: number[]
87
- canPin?: boolean
90
+ /** Errors to throw from `unpinChatMessage`, consumed in order (null = normal).
91
+ * The sweep classifies pin rights REACTIVELY from a real Telegram 400 here —
92
+ * there is no proactive precheck to model. */
93
+ unpinErrors?: (unknown | null)[]
88
94
  /** Throws from `getChat`, consumed in order (null = normal read). */
89
95
  getChatErrors?: (unknown | null)[]
90
96
  }
@@ -93,6 +99,7 @@ function fakeChat(o: FakeOpts) {
93
99
  const stack = [...o.stack]
94
100
  const calls: string[] = []
95
101
  const pinErrors = [...(o.pinErrors ?? [])]
102
+ const unpinErrors = [...(o.unpinErrors ?? [])]
96
103
  const unpinAllErrors = [...(o.unpinAllErrors ?? [])]
97
104
  const getChatErrors = [...(o.getChatErrors ?? [])]
98
105
  return {
@@ -115,6 +122,10 @@ function fakeChat(o: FakeOpts) {
115
122
  },
116
123
  unpin: async (_chatId: string, messageId: number) => {
117
124
  calls.push(`unpin:${messageId}`)
125
+ // A real Telegram rejection (e.g. `400 not enough rights`) is injected
126
+ // here so the sweep's REACTIVE rights classifier is what gets exercised.
127
+ const err = unpinErrors.shift()
128
+ if (err != null) throw err
118
129
  // ALWAYS resolves ok:true — the whole point.
119
130
  if (o.unpinIsSilentNoop === true) return { ok: true }
120
131
  const at = stack.indexOf(messageId)
@@ -135,10 +146,6 @@ function fakeChat(o: FakeOpts) {
135
146
  }
136
147
  return { ok: true }
137
148
  },
138
- canPinInChat: async () => {
139
- calls.push('getChatMember')
140
- return o.canPin !== false
141
- },
142
149
  }
143
150
  }
144
151
 
@@ -165,6 +172,9 @@ function harness(
165
172
  allowUnpinAllForumTopic?: boolean
166
173
  /** Ids the gateway is on record as having pinned (the group drain's list). */
167
174
  recordedPinIds?: number[]
175
+ /** D3: shared per-process pin-rights negative cache seam. */
176
+ rightsBlocked?: (chatId: string) => boolean
177
+ recordRightsBlock?: (chatId: string) => void
168
178
  },
169
179
  ): Harness {
170
180
  const fake = fakeChat(o)
@@ -183,10 +193,11 @@ function harness(
183
193
  pinSilent: costed(fake.pinSilent),
184
194
  unpin: costed(fake.unpin),
185
195
  unpinAllForumTopicMessages: costed(fake.unpinAllForumTopicMessages),
186
- canPinInChat: costed(fake.canPinInChat),
187
196
  protectedMessageIds: () => o.protectedMessageIds ?? [],
188
197
  recordedPinIds: () => o.recordedPinIds ?? [],
189
198
  eligible: () => o.eligible !== false,
199
+ rightsBlocked: o.rightsBlocked,
200
+ recordRightsBlock: o.recordRightsBlock,
190
201
  sleep: async (ms) => {
191
202
  sleeps.push(ms)
192
203
  if (o.clockAdvances !== false) clock += ms
@@ -552,8 +563,22 @@ describe('stale-pin sweep — forum topics', () => {
552
563
  // ─── group safety: rights + service-message spam ─────────────────────────────
553
564
 
554
565
  describe('stale-pin sweep — group safety', () => {
555
- it('skips a group without pin rights WITHOUT writing anything', async () => {
556
- const h = harness({ stack: [151, 152], canPin: false })
566
+ // The honest Telegram rejection a rights-less bot gets from EVERY pin verb.
567
+ const RIGHTS_400 = {
568
+ error_code: 400,
569
+ description: 'Bad Request: not enough rights to manage pinned messages in the chat',
570
+ }
571
+
572
+ it('ATTEMPTS the drain (no proactive precheck) and classifies a real 400 as rights', async () => {
573
+ // The pre-fix bug: a proactive getChatMember precheck against the wrapped
574
+ // (botInfo-less) bot always returned false, so a group forfeited WITHOUT a
575
+ // single unpin ever going out. The sweep must instead ATTEMPT the unpin and
576
+ // read Telegram's honest 400 — so `unpin:151` MUST appear in the call log.
577
+ const h = harness({
578
+ stack: [151, 152],
579
+ recordedPinIds: [151],
580
+ unpinErrors: [RIGHTS_400],
581
+ })
557
582
  const res = await createStalePinSweeper(h.deps).sweepTarget({
558
583
  chatId: GROUP,
559
584
  threadId: 5,
@@ -561,22 +586,31 @@ describe('stale-pin sweep — group safety', () => {
561
586
  })
562
587
 
563
588
  expect(res.status).toBe('skipped-no-rights')
564
- expect(h.fake.calls).toEqual(['getChatMember']) // the precheck and nothing else
565
- expect(h.fake.stack).toEqual([151, 152])
589
+ expect(h.fake.calls).toContain('unpin:151') // the drain was ATTEMPTED
590
+ expect(h.fake.stack).toEqual([151, 152]) // the rejected unpin popped nothing
566
591
  })
567
592
 
568
- it('treats a throwing rights check as "no rights"', async () => {
569
- const h = harness({ stack: [161] })
570
- h.deps.canPinInChat = async () => {
571
- throw new Error('Bad Request: chat not found')
593
+ it('increments attempts and eventually FORFEITS a rights-less group via the reactive path (no infinite retry)', async () => {
594
+ const fs = memFs()
595
+ const path = '/state/stale-pin-sweep.json'
596
+ // Every boot: a fresh process attempts the recorded unpin, Telegram answers
597
+ // 400 not enough rights, the sweep records skipped-no-rights and bumps
598
+ // attempts. After SWEEP_MAX_ATTEMPTS boots the cursor forfeits and stops
599
+ // re-burning the pin-op budget — the bounded reactive path, no precheck.
600
+ let last = ''
601
+ for (let boot = 0; boot < SWEEP_MAX_ATTEMPTS + 1; boot++) {
602
+ const chat = fakeChat({ stack: [161], unpinErrors: [RIGHTS_400] })
603
+ const res = await sweeperOver(fs, path, chat, { recordedPinIds: [161] }).sweepTarget({
604
+ chatId: GROUP,
605
+ })
606
+ last = res.status
572
607
  }
573
- const res = await createStalePinSweeper(h.deps).sweepTarget({
574
- chatId: GROUP,
575
- threadId: 5,
576
- isForum: true,
577
- })
578
- expect(res.status).toBe('skipped-no-rights')
579
- expect(h.fake.calls).toEqual([])
608
+ // The final boot is past the attempt budget: it forfeits instead of
609
+ // attempting yet another doomed unpin.
610
+ expect(last).toBe('forfeited')
611
+ const cursor = loadSweepCursors(path, fs).find((c) => c.chatId === GROUP)
612
+ expect(cursor?.attempts).toBe(SWEEP_MAX_ATTEMPTS)
613
+ expect(cursor?.done).toBe(false)
580
614
  })
581
615
 
582
616
  it('NEVER pins in a group — a pin is the only op that emits a service message', async () => {
@@ -623,10 +657,74 @@ describe('stale-pin sweep — group safety', () => {
623
657
  expect(h.cursors().find((c) => c.chatId === GROUP)?.done).toBe(false)
624
658
  })
625
659
 
626
- it('runs a DM sweep with NO rights precheck (getChatMember is meaningless there)', async () => {
660
+ it('runs a DM sweep straight into a drain (no rights gating whatsoever)', async () => {
627
661
  const h = harness({ stack: [181] })
628
- await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
662
+ const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
663
+ // No proactive precheck anywhere: the DM goes straight to the repin+unpin
664
+ // drain and clears the orphan.
629
665
  expect(h.fake.calls).not.toContain('getChatMember')
666
+ expect(res.status).toBe('drained')
667
+ expect(h.fake.stack).toEqual([])
668
+ })
669
+ })
670
+
671
+ // ─── D3: shared per-process pin-rights negative cache ─────────────────────────
672
+
673
+ describe('stale-pin sweep — shared PinRightsCache (D3)', () => {
674
+ const RIGHTS_400 = {
675
+ error_code: 400,
676
+ description: 'Bad Request: not enough rights to manage pinned messages in the chat',
677
+ }
678
+
679
+ it('SKIPS a group the live path already blocked, without issuing any unpin', async () => {
680
+ const cache = new PinRightsCache()
681
+ cache.block(GROUP) // the live status-pin path already proved this chat rights-less
682
+ const h = harness({
683
+ stack: [201],
684
+ recordedPinIds: [201],
685
+ rightsBlocked: (c) => cache.isBlocked(c),
686
+ recordRightsBlock: (c) => {
687
+ cache.block(c)
688
+ },
689
+ })
690
+ const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: GROUP })
691
+
692
+ expect(res.status).toBe('skipped-no-rights')
693
+ // No doomed traffic: the shared cache short-circuited BEFORE any unpin.
694
+ expect(h.fake.calls.filter((c) => c.startsWith('unpin'))).toEqual([])
695
+ })
696
+
697
+ it('FEEDS the cache from its own reactive rights discovery so the live path skips too', async () => {
698
+ const cache = new PinRightsCache()
699
+ const h = harness({
700
+ stack: [202],
701
+ recordedPinIds: [202],
702
+ unpinErrors: [RIGHTS_400],
703
+ rightsBlocked: (c) => cache.isBlocked(c),
704
+ recordRightsBlock: (c) => {
705
+ cache.block(c)
706
+ },
707
+ })
708
+ const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: GROUP })
709
+
710
+ expect(res.status).toBe('skipped-no-rights')
711
+ expect(h.fake.calls).toContain('unpin:202') // it ATTEMPTED before classifying
712
+ expect(cache.isBlocked(GROUP)).toBe(true) // and recorded the block for the live path
713
+ })
714
+
715
+ it('does NOT skip a DM even when its chat id is in the cache (DMs are never rights-gated)', async () => {
716
+ const cache = new PinRightsCache()
717
+ cache.block(DM)
718
+ const h = harness({
719
+ stack: [203],
720
+ rightsBlocked: (c) => cache.isBlocked(c),
721
+ recordRightsBlock: (c) => {
722
+ cache.block(c)
723
+ },
724
+ })
725
+ const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
726
+ expect(res.status).toBe('drained')
727
+ expect(h.fake.stack).toEqual([])
630
728
  })
631
729
  })
632
730
 
@@ -945,7 +1043,6 @@ function sweeperOver(
945
1043
  pinSilent: fake.pinSilent,
946
1044
  unpin: fake.unpin,
947
1045
  unpinAllForumTopicMessages: fake.unpinAllForumTopicMessages,
948
- canPinInChat: fake.canPinInChat,
949
1046
  protectedMessageIds: () => [],
950
1047
  recordedPinIds: () => opts.recordedPinIds ?? [],
951
1048
  eligible: () => true,
@@ -1045,7 +1142,7 @@ describe('stale-pin sweep — re-drain after the boot-seed prune (#3953)', () =>
1045
1142
  const path = '/state/stale-pin-sweep.json'
1046
1143
 
1047
1144
  // Session 1: the one recorded orphan is reaped, obligation discharged.
1048
- const s1 = await sweeperOver(fs, path, fakeChat({ stack: [77], canPin: true }), {
1145
+ const s1 = await sweeperOver(fs, path, fakeChat({ stack: [77] }), {
1049
1146
  recordedPinIds: [77],
1050
1147
  }).sweepTarget({ chatId: GROUP })
1051
1148
  expect(s1.status).toBe('drained')
@@ -132,6 +132,12 @@ import {
132
132
  upsertSweepCursor,
133
133
  SWEEP_MAX_ATTEMPTS,
134
134
  } from './stale-pin-sweep-store.js'
135
+ // The single source of truth for the pin-rights concept. The sweep classifies
136
+ // a rights failure REACTIVELY — it attempts the unpin and reads Telegram's
137
+ // honest `400 "not enough rights"` — using the same detector the live status-pin
138
+ // path uses, so the two paths can never disagree on what "no pin rights" means
139
+ // (this file must stay Telegram-import-free; status-pin.ts is dependency-free).
140
+ import { isPinRightsError } from '../status-pin.js'
135
141
 
136
142
  // ─── Rate gates (operator-mandated; do not inline these numbers) ─────────────
137
143
 
@@ -346,9 +352,10 @@ export function collectSweepTargets(input: {
346
352
 
347
353
  /**
348
354
  * The messageIds in `chatId` that must SURVIVE a drain because they belong to
349
- * deliberately-retained store rows: unexpired time-scoped `tool:` pins (the
350
- * `pin_message` MCP tool, #3001), which `runStatusPinBootCleanup` intentionally
351
- * KEEPS across restarts. Work-scoped rows (no `expiresAt`) are stale by
355
+ * deliberately-retained store rows: unexpired time-scoped `tool:` pins (legacy
356
+ * rows from the retired `pin_message` MCP tool, #3001 / #4452 — no new ones are
357
+ * written), which `runStatusPinBootCleanup` intentionally KEEPS across restarts
358
+ * until they expire. Work-scoped rows (no `expiresAt`) are stale by
352
359
  * definition after a restart and are NOT re-pin candidates; expired
353
360
  * time-scoped rows are due for sweeping.
354
361
  */
@@ -400,17 +407,6 @@ export function isPeerFloodError(err: unknown): boolean {
400
407
  return description(err).includes('peer_flood')
401
408
  }
402
409
 
403
- /**
404
- * The bot is not allowed to manage pins here. Verified live: a bot without
405
- * `can_pin_messages` gets `400 "not enough rights to manage pinned messages in
406
- * the chat"` from EVERY pin method — an honest, stable rejection, unlike the
407
- * silent-success unpin. So a rights failure is terminal for the chat, not a
408
- * retry.
409
- */
410
- export function isPinRightsError(err: unknown): boolean {
411
- return description(err).includes('not enough rights')
412
- }
413
-
414
410
  /**
415
411
  * `unpinChatMessage` with NO `message_id` on an EMPTY stack answers `400
416
412
  * "message to unpin not found"`. That is a real, positive termination signal —
@@ -566,10 +562,18 @@ export interface StalePinSweepDeps {
566
562
  /** `unpinAllForumTopicMessages(chat, thread)` — the one topic-scoped verb. */
567
563
  unpinAllForumTopicMessages: (chatId: string, threadId: number) => Promise<unknown>
568
564
  /**
569
- * `getChatMember(chat, self)` folded to "is administrator AND can_pin_messages".
570
- * Consulted BEFORE any write in a group. A throw is treated as "no rights".
565
+ * The SHARED per-process pin-rights negative cache (status-pin.ts
566
+ * `PinRightsCache`), so the sweep and the live status-pin path agree on which
567
+ * chats are rights-less. `rightsBlocked` is TRUE only when a REAL Telegram
568
+ * `400 "not enough rights"` was already observed (by the live pin path or an
569
+ * earlier sweep) — never a proactive network probe — so consulting it cannot
570
+ * resurrect the deleted `getChatMember` precheck. When it returns true the
571
+ * sweep skips the doomed group drain; `recordRightsBlock` feeds the cache from
572
+ * the sweep's OWN reactive rights discoveries. Both optional: undefined ⇒ the
573
+ * sweep relies purely on its reactive classifier.
571
574
  */
572
- canPinInChat: (chatId: string) => Promise<boolean>
575
+ rightsBlocked?: (chatId: string) => boolean
576
+ recordRightsBlock?: (chatId: string) => void
573
577
  /**
574
578
  * Message ids in this chat that must be RE-PINNED once the drain finishes:
575
579
  * live in-memory claims plus the deliberately-retained store rows (unexpired
@@ -1126,24 +1130,32 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
1126
1130
  }
1127
1131
  }
1128
1132
 
1129
- // RIGHTS PRECHECK, before ANY write, in every group. A bot without
1130
- // can_pin_messages is honestly rejected by Telegram, but burning a rejected
1131
- // write per orphan against the flood ledger is pointless. DMs need no
1132
- // precheck — getChatMember is not meaningful there.
1133
- if (kind !== 'dm') {
1134
- let allowed = false
1135
- try {
1136
- allowed = await deps.canPinInChat(target.chatId)
1137
- } catch {
1138
- allowed = false
1139
- }
1140
- if (!allowed) {
1141
- cursor.attempts++
1142
- cursor.lastStatus = 'skipped-no-rights'
1143
- cursor.updatedAt = deps.now()
1144
- commit(cursor)
1145
- return { status: 'skipped-no-rights', popped: 0, issued: 0 }
1146
- }
1133
+ // NO proactive rights precheck. A `getChatMember`-based precheck was
1134
+ // DELETED (see below): the sweep runs against the chat-lock-wrapped bot,
1135
+ // which carries only `.api` and no `.botInfo`, so `self` was always null,
1136
+ // the precheck always returned false, and every group target forfeited at
1137
+ // SWEEP_MAX_ATTEMPTS without a single unpin ever going out
1138
+ // (`getChatMember` appeared 0× in a 35MB live gateway log). Rights are now
1139
+ // classified REACTIVELY and only from a real Telegram `400 "not enough
1140
+ // rights"`: each drain primitive attempts the unpin and folds that error to
1141
+ // `{ kind: 'rights' }` via `classify`, returning `skipped-no-rights`. The
1142
+ // attempt is counted below BEFORE the drain, so a genuinely rights-less
1143
+ // chat still increments `attempts` on every boot and forfeits at
1144
+ // SWEEP_MAX_ATTEMPTS — no infinite retry — while a chat the bot CAN pin in
1145
+ // is no longer wrongly skipped.
1146
+ //
1147
+ // Same-process negative cache (D3): if the LIVE status-pin path (or an
1148
+ // earlier sweep) already observed a REAL `400 not enough rights` for this
1149
+ // chat, skip the doomed group drain rather than re-attempting it. This is
1150
+ // NOT a network probe — it is fed only by observed rights failures — so it
1151
+ // does not resurrect the deleted precheck. DMs are never rights-gated. The
1152
+ // attempt is still counted so the cursor forfeits at SWEEP_MAX_ATTEMPTS.
1153
+ if (kind !== 'dm' && deps.rightsBlocked?.(target.chatId) === true) {
1154
+ cursor.attempts++
1155
+ cursor.lastStatus = 'skipped-no-rights'
1156
+ cursor.updatedAt = deps.now()
1157
+ commit(cursor)
1158
+ return { status: 'skipped-no-rights', popped: 0, issued: 0 }
1147
1159
  }
1148
1160
 
1149
1161
  cursor.attempts++
@@ -1196,6 +1208,11 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
1196
1208
  cursor.updatedAt = deps.now()
1197
1209
  commit(cursor)
1198
1210
 
1211
+ // Feed the shared negative cache (D3): a reactive `skipped-no-rights` is a
1212
+ // real observed `400 not enough rights`, so record it so the live pin path
1213
+ // and later sweeps of this chat skip the doomed attempt too.
1214
+ if (result.status === 'skipped-no-rights') deps.recordRightsBlock?.(target.chatId)
1215
+
1199
1216
  // Restore the deliberately-retained pins the blind DM drain cleared.
1200
1217
  // Rate-gated like any other write; a failure is logged and never fatal.
1201
1218
  //
@@ -17,7 +17,8 @@
17
17
  * — the turn it represented is over or crashed), dropping rows only after a
18
18
  * successful unpin (failed ones are retained with an attempt counter for a
19
19
  * next-boot retry — see runStatusPinBootCleanup). Time-scoped `tool:` rows
20
- * (the `pin_message` MCP tool, #3001) survive boots until their `expiresAt`.
20
+ * (legacy pins from the retired `pin_message` MCP tool, #3001 / #4452 — no new
21
+ * ones are written) survive boots until their `expiresAt`.
21
22
  * It does NOT re-adopt or re-pin — it only cleans up.
22
23
  *
23
24
  * Shape choice — SNAPSHOT, not append-log, mirroring obligation-store.ts. The
@@ -78,10 +79,10 @@ export interface PersistedStatusPin {
78
79
  /** Wall-clock ms after which this pin is stale and boot cleanup unpins it.
79
80
  * Rows WITHOUT this field are work-scoped (fg:/wk:/banner:) — stale the
80
81
  * moment their owning session dies, so boot cleanup unpins them
81
- * unconditionally. Rows WITH it (the `tool:` pins written by the
82
- * `pin_message` MCP tool, #3001) represent deliberate agent pins that have
83
- * no "work finished" event: they SURVIVE restarts and are only swept once
84
- * expired. */
82
+ * unconditionally. Rows WITH it (legacy `tool:` pins from the retired
83
+ * `pin_message` MCP tool, #3001 / #4452 — no new ones are written)
84
+ * represented deliberate agent pins with no "work finished" event: they
85
+ * SURVIVE restarts and are only swept once expired. */
85
86
  expiresAt?: number
86
87
  /** Boot-cleanup unpin retry counter (#3001). Incremented each boot the
87
88
  * unpin fails (flood-wait exhausted / transient 5xx); the row is retained
@@ -282,10 +283,10 @@ export function pinnedMessageIsOurs(
282
283
  * record whose pin MAY have landed in Telegram, so we must treat it exactly
283
284
  * like a confirmed one and unpin it.
284
285
  *
285
- * TIME-SCOPED rows (`tool:` pins from the `pin_message` MCP tool, carrying
286
- * `expiresAt`) have no "work finished" event, so a restart does NOT reset
287
- * them: an unexpired row is RETAINED untouched across boots and only unpinned
288
- * once `now >= expiresAt`.
286
+ * TIME-SCOPED rows (legacy `tool:` pins from the retired `pin_message` MCP
287
+ * tool, #4452, carrying `expiresAt`) have no "work finished" event, so a
288
+ * restart does NOT reset them: an unexpired row is RETAINED untouched across
289
+ * boots and only unpinned once `now >= expiresAt`.
289
290
  *
290
291
  * RETRY-SAFETY (#3001): a row is dropped only AFTER its unpin resolves. A
291
292
  * failing unpin (flood-wait exhausted / transient 5xx) retains the row with an