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
@@ -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
 
@@ -400,17 +406,6 @@ export function isPeerFloodError(err: unknown): boolean {
400
406
  return description(err).includes('peer_flood')
401
407
  }
402
408
 
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
409
  /**
415
410
  * `unpinChatMessage` with NO `message_id` on an EMPTY stack answers `400
416
411
  * "message to unpin not found"`. That is a real, positive termination signal —
@@ -566,10 +561,18 @@ export interface StalePinSweepDeps {
566
561
  /** `unpinAllForumTopicMessages(chat, thread)` — the one topic-scoped verb. */
567
562
  unpinAllForumTopicMessages: (chatId: string, threadId: number) => Promise<unknown>
568
563
  /**
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".
564
+ * The SHARED per-process pin-rights negative cache (status-pin.ts
565
+ * `PinRightsCache`), so the sweep and the live status-pin path agree on which
566
+ * chats are rights-less. `rightsBlocked` is TRUE only when a REAL Telegram
567
+ * `400 "not enough rights"` was already observed (by the live pin path or an
568
+ * earlier sweep) — never a proactive network probe — so consulting it cannot
569
+ * resurrect the deleted `getChatMember` precheck. When it returns true the
570
+ * sweep skips the doomed group drain; `recordRightsBlock` feeds the cache from
571
+ * the sweep's OWN reactive rights discoveries. Both optional: undefined ⇒ the
572
+ * sweep relies purely on its reactive classifier.
571
573
  */
572
- canPinInChat: (chatId: string) => Promise<boolean>
574
+ rightsBlocked?: (chatId: string) => boolean
575
+ recordRightsBlock?: (chatId: string) => void
573
576
  /**
574
577
  * Message ids in this chat that must be RE-PINNED once the drain finishes:
575
578
  * live in-memory claims plus the deliberately-retained store rows (unexpired
@@ -1126,24 +1129,32 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
1126
1129
  }
1127
1130
  }
1128
1131
 
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
- }
1132
+ // NO proactive rights precheck. A `getChatMember`-based precheck was
1133
+ // DELETED (see below): the sweep runs against the chat-lock-wrapped bot,
1134
+ // which carries only `.api` and no `.botInfo`, so `self` was always null,
1135
+ // the precheck always returned false, and every group target forfeited at
1136
+ // SWEEP_MAX_ATTEMPTS without a single unpin ever going out
1137
+ // (`getChatMember` appeared 0× in a 35MB live gateway log). Rights are now
1138
+ // classified REACTIVELY and only from a real Telegram `400 "not enough
1139
+ // rights"`: each drain primitive attempts the unpin and folds that error to
1140
+ // `{ kind: 'rights' }` via `classify`, returning `skipped-no-rights`. The
1141
+ // attempt is counted below BEFORE the drain, so a genuinely rights-less
1142
+ // chat still increments `attempts` on every boot and forfeits at
1143
+ // SWEEP_MAX_ATTEMPTS — no infinite retry — while a chat the bot CAN pin in
1144
+ // is no longer wrongly skipped.
1145
+ //
1146
+ // Same-process negative cache (D3): if the LIVE status-pin path (or an
1147
+ // earlier sweep) already observed a REAL `400 not enough rights` for this
1148
+ // chat, skip the doomed group drain rather than re-attempting it. This is
1149
+ // NOT a network probe — it is fed only by observed rights failures — so it
1150
+ // does not resurrect the deleted precheck. DMs are never rights-gated. The
1151
+ // attempt is still counted so the cursor forfeits at SWEEP_MAX_ATTEMPTS.
1152
+ if (kind !== 'dm' && deps.rightsBlocked?.(target.chatId) === true) {
1153
+ cursor.attempts++
1154
+ cursor.lastStatus = 'skipped-no-rights'
1155
+ cursor.updatedAt = deps.now()
1156
+ commit(cursor)
1157
+ return { status: 'skipped-no-rights', popped: 0, issued: 0 }
1147
1158
  }
1148
1159
 
1149
1160
  cursor.attempts++
@@ -1196,6 +1207,11 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
1196
1207
  cursor.updatedAt = deps.now()
1197
1208
  commit(cursor)
1198
1209
 
1210
+ // Feed the shared negative cache (D3): a reactive `skipped-no-rights` is a
1211
+ // real observed `400 not enough rights`, so record it so the live pin path
1212
+ // and later sweeps of this chat skip the doomed attempt too.
1213
+ if (result.status === 'skipped-no-rights') deps.recordRightsBlock?.(target.chatId)
1214
+
1199
1215
  // Restore the deliberately-retained pins the blind DM drain cleared.
1200
1216
  // Rate-gated like any other write; a failure is logged and never fatal.
1201
1217
  //
@@ -73,6 +73,10 @@ export interface ThrottleBrokerClient {
73
73
  throttled_until: number
74
74
  escalated: boolean
75
75
  rolledTo?: string | null
76
+ /** True when the caller has a strict pin (`auth.strict`) — its null
77
+ * `rolledTo` means "riding out the wall", not fleet all-blocked.
78
+ * Absent on pre-flag brokers. */
79
+ caller_pinned_strict?: boolean
76
80
  }>
77
81
  claimNotification(key: string, windowMs: number): Promise<{ granted: boolean }>
78
82
  }
@@ -214,19 +218,22 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
214
218
  client: ThrottleBrokerClient | null,
215
219
  account: string | null,
216
220
  rolledTo: string | null,
221
+ callerPinnedStrict: boolean,
217
222
  triggerAgent: string,
218
223
  armedAtMs: number,
219
224
  ): Promise<void> {
220
225
  deps.log(
221
226
  `[throttle-tier] escalated to wall account=${account ?? '?'} ` +
222
- `rolledTo=${rolledTo ?? 'none (all blocked)'}`,
227
+ `rolledTo=${rolledTo ?? (callerPinnedStrict ? 'none (strict pin, riding it out)' : 'none (all blocked)')}`,
223
228
  )
224
229
  await broadcastDeduped(
225
230
  client,
226
231
  'throttle-escalation',
227
232
  account,
228
- renderThrottleEscalationNotice({ account, agent: triggerAgent, rolledTo }),
233
+ renderThrottleEscalationNotice({ account, agent: triggerAgent, rolledTo, callerPinnedStrict }),
229
234
  )
235
+ // No nudge on a null rolledTo — for a strict pin a resume would replay
236
+ // the turn straight back into the wall it just hit.
230
237
  if (rolledTo) nudgeResume('throttle-escalation-resume', armedAtMs)
231
238
  }
232
239
 
@@ -240,6 +247,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
240
247
  let account: string | null = null
241
248
  let escalated = false
242
249
  let rolledTo: string | null = null
250
+ let callerPinnedStrict = false
243
251
  try {
244
252
  client = await deps.getBrokerClient()
245
253
  if (client) {
@@ -247,6 +255,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
247
255
  account = r.account
248
256
  escalated = r.escalated
249
257
  rolledTo = r.rolledTo ?? null
258
+ callerPinnedStrict = r.caller_pinned_strict ?? false
250
259
  } else {
251
260
  deps.log(
252
261
  `[throttle-tier] broker unreachable — notice only, no ledger record agent=${triggerAgent}`,
@@ -259,7 +268,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
259
268
  }
260
269
 
261
270
  if (escalated) {
262
- await announceEscalation(client, account, rolledTo, triggerAgent, armedAtMs)
271
+ await announceEscalation(client, account, rolledTo, callerPinnedStrict, triggerAgent, armedAtMs)
263
272
  return
264
273
  }
265
274
 
@@ -303,6 +312,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
303
312
  let account: string | null = null
304
313
  let escalated = false
305
314
  let rolledTo: string | null = null
315
+ let callerPinnedStrict = false
306
316
  try {
307
317
  client = await deps.getBrokerClient()
308
318
  if (client) {
@@ -312,6 +322,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
312
322
  account = r.account
313
323
  escalated = r.escalated
314
324
  rolledTo = r.rolledTo ?? null
325
+ callerPinnedStrict = r.caller_pinned_strict ?? false
315
326
  } else {
316
327
  deps.log(
317
328
  `[throttle-tier] broker unreachable — probe-only skipped agent=${triggerAgent}`,
@@ -324,7 +335,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
324
335
  }
325
336
 
326
337
  if (escalated) {
327
- await announceEscalation(client, account, rolledTo, triggerAgent, armedAtMs)
338
+ await announceEscalation(client, account, rolledTo, callerPinnedStrict, triggerAgent, armedAtMs)
328
339
  return
329
340
  }
330
341
 
@@ -22,6 +22,11 @@
22
22
 
23
23
  import type { BannerState } from './slot-banner.js';
24
24
  import { decideBannerAction } from './slot-banner.js';
25
+ // Shared rights model (D5): the banner adopts the status-pin path's rights
26
+ // detector, terminal-unpin classifier and per-process negative cache, so the two
27
+ // pin owners agree on which chats are rights-less instead of the banner's old
28
+ // drop-on-ANY-unpin. status-pin.ts is dependency-free.
29
+ import { isPinRightsError, isUnpinTerminalError, type PinRightsCache } from './status-pin.js';
25
30
 
26
31
  /** Minimal subset of grammy's `bot.api` we depend on. Letting tests
27
32
  * swap in `fake-bot-api.ts` without dragging in the full Bot type. */
@@ -72,6 +77,15 @@ export interface RefreshBannerArgs {
72
77
  /** Optional API-failure observer. Phase identifies which Bot API
73
78
  * call failed so the caller can log meaningfully. Default: silent. */
74
79
  onError?: (phase: 'pin' | 'edit' | 'unpin', err: unknown) => void;
80
+ /** Shared per-process pin-rights negative cache (status-pin.ts
81
+ * `PinRightsCache`), unifying the banner with the status-pin path on
82
+ * rights-less chats (D5). When the chat is already known rights-less the pin
83
+ * action is skipped so we never emit an un-pinnable notice; a real pin-rights
84
+ * 400 from the pin/unpin verb records the block and a confirmed pin clears
85
+ * it. Fed ONLY from the pin/unpin verbs — a `sendMessage` failure is never
86
+ * cached, because a send failure is not a pin-rights signal. Optional;
87
+ * omitted in unit tests. */
88
+ rightsCache?: Pick<PinRightsCache, 'isBlocked' | 'block' | 'clear'>;
75
89
  /** Optional durable-persistence hooks so an orphaned banner pin is
76
90
  * recoverable across a gateway crash. The gateway wires these to the
77
91
  * shared status-pin store (a distinct `banner:` pinKey), mirroring the
@@ -127,20 +141,34 @@ export async function refreshBanner(
127
141
 
128
142
  if (action.kind === 'unpin') {
129
143
  try {
144
+ // allow-raw-pin: the slot banner is a sanctioned separate pin owner (see
145
+ // scripts/check-status-pin-single-path-allowlist.txt); its unpin is not
146
+ // routed through reconcileStatusPin.
130
147
  await args.bot.api.unpinChatMessage(args.ownerChatId, action.messageId);
131
148
  } catch (err) {
132
149
  args.onError?.('unpin', err);
150
+ // Feed the shared negative cache ONLY from a real pin-rights 400 on the
151
+ // unpin verb — never from any other failure class (D5).
152
+ if (isPinRightsError(err)) args.rightsCache?.block(String(args.ownerChatId));
153
+ // Adopt the status-pin terminal-vs-never-confirmed contract (#3664 Defect
154
+ // B) instead of drop-on-ANY-unpin: retain the claim when the unpin did
155
+ // NOT terminally land (transient flood/5xx/network), so the next refresh
156
+ // retries rather than orphaning a still-pinned banner with no record. A
157
+ // terminal 4xx (rights, message gone, bot kicked) — and a success — fall
158
+ // through to the drop below, because re-issuing cannot help.
159
+ if (!isUnpinTerminalError(err)) return args.prevState;
133
160
  }
134
- // Even if unpin failed, drop our claim the message may have been
135
- // unpinned out-of-band (operator did it manually) and re-pinning
136
- // would be more confusing than surfacing it again later. Drop the
137
- // persisted record too (unpin-then-clear mirrors the status-pin store:
138
- // a crash between unpin and clear just re-unpins next boot, idempotent).
161
+ // Terminal outcome: drop our claim and the persisted record. A crash
162
+ // between unpin and clear just re-unpins next boot (idempotent).
139
163
  safePersist(args.persist?.clear);
140
164
  return null;
141
165
  }
142
166
 
143
167
  if (action.kind === 'pin') {
168
+ // The banner is a PINNED notice: if this chat is already known rights-less
169
+ // (proved by the status-pin path or an earlier banner pin), skip the whole
170
+ // send+pin — an un-pinnable send is pure noise (D5).
171
+ if (args.rightsCache?.isBlocked(String(args.ownerChatId))) return args.prevState;
144
172
  let sent: { message_id: number };
145
173
  try {
146
174
  // sendRichMessage doesn't accept link_preview_options — omit it.
@@ -150,6 +178,8 @@ export async function refreshBanner(
150
178
  disable_notification: true,
151
179
  });
152
180
  } catch (err) {
181
+ // SEND failure — deliberately NOT a pin-rights signal, so the cache is
182
+ // NEVER written here even though the phase tag below is 'pin' (D5).
153
183
  args.onError?.('pin', err);
154
184
  return args.prevState;
155
185
  }
@@ -158,17 +188,24 @@ export async function refreshBanner(
158
188
  // unpins next boot — closing the persist-after-pin leak.
159
189
  safePersist(() => args.persist?.pending?.(String(args.ownerChatId), sent.message_id));
160
190
  try {
191
+ // allow-raw-pin: sanctioned banner pin owner (see the allowlist).
161
192
  await args.bot.api.pinChatMessage(args.ownerChatId, sent.message_id, {
162
193
  disable_notification: true,
163
194
  });
164
195
  } catch (err) {
165
196
  args.onError?.('pin', err);
197
+ // The PIN verb failing with a real pin-rights 400 IS the rights signal —
198
+ // record it so later refreshes skip the doomed send+pin (D5).
199
+ if (isPinRightsError(err)) args.rightsCache?.block(String(args.ownerChatId));
166
200
  // sendMessage succeeded but pin failed — don't claim the message, and
167
201
  // drop the pending record so we don't leave a phantom claim for a pin
168
202
  // that never landed.
169
203
  safePersist(args.persist?.clear);
170
204
  return args.prevState;
171
205
  }
206
+ // Pin confirmed — rights are present, so clear any stale block for this chat
207
+ // (mirrors the status-pin path clearing on a successful pin).
208
+ args.rightsCache?.clear(String(args.ownerChatId));
172
209
  // Pin confirmed — rewrite the record without the pending flag.
173
210
  safePersist(() => args.persist?.confirm?.(String(args.ownerChatId), sent.message_id));
174
211
  return { messageId: sent.message_id, slot: action.slot };
@@ -190,6 +190,30 @@ describe('runFleetAutoFallback', () => {
190
190
  expect(failover).toHaveBeenCalledTimes(1);
191
191
  });
192
192
 
193
+ it('strict-pinned caller: null rolledTo yields the strict-pinned outcome, NOT all-blocked', async () => {
194
+ // agents.<name>.auth.strict — the broker marked the account but
195
+ // deliberately did not roll the caller. The all-blocked card here would
196
+ // claim fleet-wide exhaustion while its own snapshots show healthy
197
+ // accounts.
198
+ const failover = vi.fn(async () => ({
199
+ rolledTo: null, rolled: [], callerPinnedStrict: true,
200
+ }));
201
+ const out = await runFleetAutoFallback({
202
+ state: state('work@x', ['work@x']),
203
+ quotas: [qOk({ fiveHourUtilizationPct: 12, sevenDayUtilizationPct: 30 })],
204
+ failover,
205
+ triggerAgent: 'workbot',
206
+ now: NOW,
207
+ tz: 'UTC',
208
+ rateLimitTrigger: true,
209
+ });
210
+ expect(out.kind).toBe('strict-pinned');
211
+ expect(failover).toHaveBeenCalledTimes(1);
212
+ expect(out.announcement).toContain('strictly pinned');
213
+ expect(out.announcement).toContain('fleet is unaffected');
214
+ expect(out.announcement).not.toContain('blocked');
215
+ });
216
+
193
217
  it('idempotency: skips the swap WITHOUT calling failover when active probes healthy', async () => {
194
218
  const failover = vi.fn();
195
219
  const out = await runFleetAutoFallback({
@@ -57,6 +57,8 @@ const GOLDEN_REGISTRATIONS: readonly string[] = [
57
57
  'command:inject',
58
58
  'command:compact',
59
59
  'command:clear',
60
+ 'command:private',
61
+ 'command:public',
60
62
  'helper:registerModelEffortCommands',
61
63
  'command:agentstart',
62
64
  'command:agentstop',