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
@@ -103,6 +103,7 @@ export type FallbackDeliveryOutcomeKind =
103
103
  | 'all-blocked'
104
104
  | 'no-old-active'
105
105
  | 'no-eligible-target'
106
+ | 'strict-pinned'
106
107
  | 'error';
107
108
 
108
109
  /**
@@ -328,7 +328,15 @@ export interface AuthBrokerClient {
328
328
  * `setActive` this needs no admin — the account is derived from the caller's
329
329
  * identity — so auto-fallback works from any agent.
330
330
  */
331
- markExhausted(until?: number): Promise<{ account: string; rolled: string[]; rolledTo?: string | null }>
331
+ markExhausted(until?: number): Promise<{
332
+ account: string
333
+ rolled: string[]
334
+ rolledTo?: string | null
335
+ /** True when the caller is a strict-pinned agent (`auth.strict`): its
336
+ * null `rolledTo` means "riding out the wall", not fleet all-blocked.
337
+ * Absent on pre-flag brokers. */
338
+ caller_pinned_strict?: boolean
339
+ }>
332
340
  /**
333
341
  * 429 throttle tier (broker `mark-throttled`). Records a transient
334
342
  * per-account rate limit on the CALLER's own account — `throttled_until`
@@ -344,6 +352,8 @@ export interface AuthBrokerClient {
344
352
  throttled_until: number
345
353
  escalated: boolean
346
354
  rolledTo?: string | null
355
+ /** See markExhausted.caller_pinned_strict. */
356
+ caller_pinned_strict?: boolean
347
357
  }>
348
358
  rmAccount(label: string): Promise<{ label: string }>
349
359
  refreshAccount(label: string): Promise<{ account: string; expiresAt?: number }>
@@ -70,6 +70,11 @@ import {
70
70
  getProposal as getSkillProposal,
71
71
  setProposalStatus as setSkillProposalStatus,
72
72
  } from '../../src/self-improve/skill-proposals.js'
73
+ import { parseEvalCaseProposalCallback } from './eval-case-proposal-card.js'
74
+ import {
75
+ getEvalCaseProposal,
76
+ setEvalCaseProposalStatus,
77
+ } from '../../src/self-improve/eval-case-proposals.js'
73
78
  import { maskToken } from '../secret-detect/mask.js'
74
79
  import {
75
80
  defaultVaultWrite,
@@ -1328,6 +1333,100 @@ async function handleSkillProposalCallback(ctx: Context, data: string): Promise<
1328
1333
  )
1329
1334
  }
1330
1335
 
1336
+ /**
1337
+ * Eval-case proposal callback (RFC amendment §"corrections as eval cases").
1338
+ *
1339
+ * On Approve this runs the DETERMINISTIC `apply-eval-case` applier via
1340
+ * execFileSync — NOT a model turn — so the case lands byte-exact as approved
1341
+ * (precedent: the `switchroom vault set` on-tap execFileSync in this file).
1342
+ * The operator's tap IS the authorization; the gateway sets the proposal
1343
+ * `approved` BEFORE invoking the applier (whose own status check is
1344
+ * defense-in-depth, MJ2, since the store is agent-writable).
1345
+ */
1346
+ async function handleEvalCaseProposalCallback(ctx: Context, data: string): Promise<void> {
1347
+ const senderId = String(ctx.from?.id ?? '')
1348
+ const access = loadAccess()
1349
+ if (!access.allowFrom.includes(senderId)) {
1350
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
1351
+ return
1352
+ }
1353
+ const parsed = parseEvalCaseProposalCallback(data)
1354
+ if (parsed == null) {
1355
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
1356
+ return
1357
+ }
1358
+ const stateDir = process.env.TELEGRAM_STATE_DIR
1359
+ const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
1360
+ if (stateDir == null || stateDir.length === 0) {
1361
+ await ctx.answerCallbackQuery({ text: 'State dir unset — cannot apply.' }).catch(() => {})
1362
+ return
1363
+ }
1364
+ const proposal = getEvalCaseProposal(stateDir, parsed.id)
1365
+ if (proposal == null) {
1366
+ await ctx.answerCallbackQuery({ text: 'Proposal expired or already actioned.' }).catch(() => {})
1367
+ if (ctx.callbackQuery?.message) {
1368
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
1369
+ }
1370
+ return
1371
+ }
1372
+ if (proposal.status !== 'pending') {
1373
+ await ctx.answerCallbackQuery({ text: `Already ${proposal.status}.` }).catch(() => {})
1374
+ if (ctx.callbackQuery?.message) {
1375
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
1376
+ }
1377
+ return
1378
+ }
1379
+
1380
+ if (parsed.action === 'deny') {
1381
+ setEvalCaseProposalStatus(stateDir, parsed.id, 'rejected')
1382
+ await ctx.answerCallbackQuery({ text: '🚫 Dismissed.' }).catch(() => {})
1383
+ if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
1384
+ await ctx
1385
+ .editMessageText(
1386
+ `${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n🚫 <i>Dismissed.</i>`,
1387
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
1388
+ )
1389
+ .catch(() => {})
1390
+ }
1391
+ return
1392
+ }
1393
+
1394
+ // Approve — authorize, then run the deterministic applier.
1395
+ setEvalCaseProposalStatus(stateDir, parsed.id, 'approved')
1396
+ await ctx.answerCallbackQuery({ text: '✅ Adding the eval case…' }).catch(() => {})
1397
+
1398
+ const cli = process.env.SWITCHROOM_CLI_PATH ?? 'switchroom'
1399
+ let applyOk = true
1400
+ let applyOut = ''
1401
+ try {
1402
+ applyOut = execFileSync(
1403
+ cli,
1404
+ ['self-improve', 'apply-eval-case', '--id', parsed.id],
1405
+ { encoding: 'utf8', timeout: 15000, env: process.env },
1406
+ ).trim()
1407
+ } catch (err) {
1408
+ applyOk = false
1409
+ const e = err as { stdout?: string; stderr?: string; message?: string }
1410
+ applyOut = [e.stdout, e.stderr, e.message].filter(Boolean).join('\n').trim()
1411
+ }
1412
+
1413
+ const footer = applyOk
1414
+ ? '✅ <i>Added as a regression test.</i>'
1415
+ : `⚠️ <i>Apply failed:</i> ${escapeHtmlForTg(applyOut.slice(0, 200))}`
1416
+ if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
1417
+ await ctx
1418
+ .editMessageText(
1419
+ `${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n${footer}`,
1420
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
1421
+ )
1422
+ .catch(() => {})
1423
+ }
1424
+ process.stderr.write(
1425
+ `telegram gateway: eval_case_apply agent=${agent} proposal=${proposal.id} ` +
1426
+ `slug=${proposal.skill_slug} ok=${applyOk}\n`,
1427
+ )
1428
+ }
1429
+
1331
1430
  /**
1332
1431
  * hindsight Phase 5 — handle a tap on the mental-model PROPOSAL card.
1333
1432
  * mmp:approve:<stageId> — declare the model: append it to the agent's
@@ -3233,6 +3332,7 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
3233
3332
  performVaultAccessApproval,
3234
3333
  resolveAccessApprovalPassphraseMismatch,
3235
3334
  handleSkillProposalCallback,
3335
+ handleEvalCaseProposalCallback,
3236
3336
  handleMentalModelProposeCallback,
3237
3337
  handleVaultRequestAccessCallback,
3238
3338
  handleVaultRequestSaveCallback,
@@ -0,0 +1,86 @@
1
+ /**
2
+ * One-tap eval-case proposal card (RFC amendment §"corrections as eval
3
+ * cases").
4
+ *
5
+ * `switchroom self-improve add-eval-case` sends a `post_eval_case_proposal`
6
+ * IPC; the gateway persists it (eval-case-proposals store) and renders THIS
7
+ * card. Unlike the skill-proposal card, tapping Approve does NOT inject a
8
+ * model turn — the gateway callback runs the DETERMINISTIC `apply-eval-case`
9
+ * applier via execFileSync, so the case lands byte-exact as approved.
10
+ *
11
+ * Pure builders, kept out of gateway.ts so the card text + callback shape are
12
+ * pinned by tests independent of the bot plumbing.
13
+ *
14
+ * callback_data shape (must fit Telegram's 64-byte limit):
15
+ * evcase:approve:<id>
16
+ * evcase:deny:<id>
17
+ */
18
+
19
+ export const EVAL_CASE_PROPOSAL_CALLBACK_PREFIX = 'evcase:'
20
+
21
+ /** Narrow view of a stored eval-case proposal the card needs. */
22
+ export interface EvalCaseProposalView {
23
+ id: string
24
+ skill_slug: string
25
+ held_out: boolean
26
+ case: { prompt: string; expectations?: string[] }
27
+ }
28
+
29
+ function escapeHtml(s: string): string {
30
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
31
+ }
32
+
33
+ /** Truncate a single-line preview of the prompt for the card. */
34
+ function previewPrompt(prompt: string, max = 240): string {
35
+ const oneLine = prompt.replace(/\s+/g, ' ').trim()
36
+ return oneLine.length > max ? oneLine.slice(0, max - 1) + '…' : oneLine
37
+ }
38
+
39
+ /** Render the eval-case proposal card text (HTML parse mode). */
40
+ export function renderEvalCaseProposalCard(p: EvalCaseProposalView): string {
41
+ const lines: string[] = []
42
+ lines.push(`🧪 <b>Eval case proposed</b>`)
43
+ lines.push('')
44
+ lines.push(`<b>Skill:</b> <code>${escapeHtml(p.skill_slug)}</code>`)
45
+ if (p.held_out) lines.push(`<i>held-out (won't be added to evals.json)</i>`)
46
+ lines.push(`<b>Test prompt:</b> ${escapeHtml(previewPrompt(p.case.prompt))}`)
47
+ const exps = p.case.expectations ?? []
48
+ if (exps.length > 0) {
49
+ lines.push('<b>Checks:</b>')
50
+ for (const e of exps.slice(0, 5)) lines.push(`• ${escapeHtml(e.slice(0, 120))}`)
51
+ }
52
+ lines.push('')
53
+ lines.push(
54
+ '<i>Tap Add to append it as a regression test (re-scanned for ' +
55
+ 'secrets/PII, written byte-exact — no model turn). Tap Dismiss to drop it.</i>',
56
+ )
57
+ return lines.join('\n')
58
+ }
59
+
60
+ /** Inline keyboard for the card. */
61
+ export function evalCaseProposalKeyboard(id: string): {
62
+ inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
63
+ } {
64
+ return {
65
+ inline_keyboard: [
66
+ [
67
+ { text: '✅ Add case', callback_data: `${EVAL_CASE_PROPOSAL_CALLBACK_PREFIX}approve:${id}` },
68
+ { text: '🚫 Dismiss', callback_data: `${EVAL_CASE_PROPOSAL_CALLBACK_PREFIX}deny:${id}` },
69
+ ],
70
+ ],
71
+ }
72
+ }
73
+
74
+ /** Parse an `evcase:` callback into { action, id }, or null if not ours. */
75
+ export function parseEvalCaseProposalCallback(
76
+ data: string,
77
+ ): { action: 'approve' | 'deny'; id: string } | null {
78
+ if (!data.startsWith(EVAL_CASE_PROPOSAL_CALLBACK_PREFIX)) return null
79
+ const rest = data.slice(EVAL_CASE_PROPOSAL_CALLBACK_PREFIX.length)
80
+ const idx = rest.indexOf(':')
81
+ if (idx < 0) return null
82
+ const action = rest.slice(0, idx)
83
+ const id = rest.slice(idx + 1)
84
+ if ((action !== 'approve' && action !== 'deny') || id.length === 0) return null
85
+ return { action, id }
86
+ }
@@ -0,0 +1,74 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ resetFleetFallbackNoticeCooldowns,
4
+ shouldSendAllBlockedNotice,
5
+ shouldSendStrictPinnedNotice,
6
+ } from './fleet-fallback-notice-cooldown.js'
7
+
8
+ const COOLDOWN_MS = 30 * 60_000
9
+ // Realistic wall-clock base: the initial state is { lastSentAtMs: 0 }, so the
10
+ // first call only "sends" when `now` is at least COOLDOWN_MS past the epoch —
11
+ // always true for a real Date.now(), so anchor the fakes there too.
12
+ const BASE = 1_700_000_000_000
13
+
14
+ function captureStderr(): { lines: string[]; restore: () => void } {
15
+ const lines: string[] = []
16
+ const spy = vi
17
+ .spyOn(process.stderr, 'write')
18
+ .mockImplementation((chunk: string | Uint8Array): boolean => {
19
+ lines.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
20
+ return true
21
+ })
22
+ return { lines, restore: () => spy.mockRestore() }
23
+ }
24
+
25
+ afterEach(() => {
26
+ // Reset the module-scope windows so each test starts from a clean gate.
27
+ resetFleetFallbackNoticeCooldowns()
28
+ vi.restoreAllMocks()
29
+ })
30
+
31
+ describe('fleet-fallback notice cooldown gates', () => {
32
+ it('sends the first all-blocked card and suppresses a repeat inside the window', () => {
33
+ const { lines, restore } = captureStderr()
34
+ const t0 = BASE
35
+ expect(shouldSendAllBlockedNotice('agentA', t0)).toBe(true)
36
+ // Re-fire well inside the 30-min window: suppressed, with the exact log text.
37
+ expect(shouldSendAllBlockedNotice('agentA', t0 + 60_000)).toBe(false)
38
+ expect(lines).toEqual([
39
+ 'telegram gateway: [fleet-fallback] all-blocked card suppressed (cooldown) agent=agentA\n',
40
+ ])
41
+ // After the window elapses, it sends again.
42
+ expect(shouldSendAllBlockedNotice('agentA', t0 + COOLDOWN_MS)).toBe(true)
43
+ restore()
44
+ })
45
+
46
+ it('sends the first strict-pinned card and suppresses a repeat inside the window', () => {
47
+ const { lines, restore } = captureStderr()
48
+ const t0 = BASE + 100_000_000
49
+ expect(shouldSendStrictPinnedNotice('agentB', t0)).toBe(true)
50
+ expect(shouldSendStrictPinnedNotice('agentB', t0 + 60_000)).toBe(false)
51
+ expect(lines).toEqual([
52
+ 'telegram gateway: [fleet-fallback] strict-pinned card suppressed (cooldown) agent=agentB\n',
53
+ ])
54
+ restore()
55
+ })
56
+
57
+ it('keeps the two windows independent — a strict card never suppresses an all-blocked', () => {
58
+ const t0 = BASE + 200_000_000
59
+ expect(shouldSendStrictPinnedNotice('agentC', t0)).toBe(true)
60
+ // All-blocked has its own window and is unaffected by the strict send above.
61
+ expect(shouldSendAllBlockedNotice('agentC', t0 + 1000)).toBe(true)
62
+ })
63
+
64
+ it('reset re-arms both windows so a post-recovery transition emits promptly', () => {
65
+ const t0 = BASE + 300_000_000
66
+ expect(shouldSendAllBlockedNotice('agentD', t0)).toBe(true)
67
+ expect(shouldSendStrictPinnedNotice('agentD', t0)).toBe(true)
68
+ // A successful swap resets both windows.
69
+ resetFleetFallbackNoticeCooldowns()
70
+ // Immediately after reset, both send again despite being inside the window.
71
+ expect(shouldSendAllBlockedNotice('agentD', t0 + 1000)).toBe(true)
72
+ expect(shouldSendStrictPinnedNotice('agentD', t0 + 1000)).toBe(true)
73
+ })
74
+ })
@@ -0,0 +1,71 @@
1
+ /**
2
+ * fleet-fallback-notice-cooldown.ts — per-gateway cooldown gates for the two
3
+ * NO-OP fleet-fallback outcomes: "all accounts blocked" and "strict-pinned".
4
+ *
5
+ * Both outcomes make `doFireFleetAutoFallback` return false WITHOUT swapping the
6
+ * active account, so the `fleetFallbackGate` dedup window never arms for them and
7
+ * the ~60s `quota_wall_detected` re-trigger would otherwise re-broadcast the
8
+ * identical card every minute for the life of the wall. Each outcome gets its own
9
+ * per-gateway cooldown window (separate state so a strict-pinned card never
10
+ * suppresses a genuine later all-blocked, or vice versa). A successful swap resets
11
+ * both windows so a fresh transition after recovery is not stale-suppressed.
12
+ *
13
+ * Extracted verbatim from gateway.ts (switchroom#4442) to keep the inline notice
14
+ * logic out of the file the line-ratchet guards. Behaviour is byte-identical to
15
+ * the previous inline gates: same 30-min cooldown window (via
16
+ * `evaluateAllBlockedNotice`), same stderr suppress-log text, same
17
+ * return-false-on-suppress contract.
18
+ */
19
+
20
+ import {
21
+ evaluateAllBlockedNotice,
22
+ type FallbackAllBlockedNoticeState,
23
+ } from '../auto-fallback-fleet.js'
24
+
25
+ let allBlockedState: FallbackAllBlockedNoticeState = { lastSentAtMs: 0 }
26
+ let strictPinnedState: FallbackAllBlockedNoticeState = { lastSentAtMs: 0 }
27
+
28
+ /** Reset both cooldown windows — called on a successful account swap. */
29
+ export function resetFleetFallbackNoticeCooldowns(): void {
30
+ allBlockedState = { lastSentAtMs: 0 }
31
+ strictPinnedState = { lastSentAtMs: 0 }
32
+ }
33
+
34
+ /**
35
+ * Gate the "all accounts blocked" card. Returns true if it should be sent now
36
+ * (advancing the cooldown window); false if suppressed by the window, writing the
37
+ * same stderr suppress log the gateway used inline.
38
+ */
39
+ export function shouldSendAllBlockedNotice(
40
+ triggerAgent: string,
41
+ now: number = Date.now(),
42
+ ): boolean {
43
+ const verdict = evaluateAllBlockedNotice(allBlockedState, now)
44
+ if (!verdict.send) {
45
+ process.stderr.write(
46
+ `telegram gateway: [fleet-fallback] all-blocked card suppressed (cooldown) agent=${triggerAgent}\n`,
47
+ )
48
+ return false
49
+ }
50
+ allBlockedState = verdict.next
51
+ return true
52
+ }
53
+
54
+ /**
55
+ * Gate the "strict-pinned" card. Same contract as `shouldSendAllBlockedNotice`,
56
+ * against the separate strict-pinned cooldown window.
57
+ */
58
+ export function shouldSendStrictPinnedNotice(
59
+ triggerAgent: string,
60
+ now: number = Date.now(),
61
+ ): boolean {
62
+ const verdict = evaluateAllBlockedNotice(strictPinnedState, now)
63
+ if (!verdict.send) {
64
+ process.stderr.write(
65
+ `telegram gateway: [fleet-fallback] strict-pinned card suppressed (cooldown) agent=${triggerAgent}\n`,
66
+ )
67
+ return false
68
+ }
69
+ strictPinnedState = verdict.next
70
+ return true
71
+ }
@@ -433,7 +433,8 @@ import {
433
433
  import { createThrottleTierRunner } from './throttle-tier-wiring.js'
434
434
  import { parseLitellmNoticeWindowMs } from '../litellm-local-notice.js'
435
435
  import { createLitellmLocalNoticeRunner, decideRateLimitedSurface } from './litellm-local-notice-wiring.js'
436
- import { runFleetAutoFallback, renderFallbackFailureNotice, evaluateFallbackFailureNotice, evaluateAllBlockedNotice, type FallbackFailureNoticeState, type FallbackAllBlockedNoticeState } from '../auto-fallback-fleet.js'
436
+ import { runFleetAutoFallback, renderFallbackFailureNotice, evaluateFallbackFailureNotice, type FallbackFailureNoticeState } from '../auto-fallback-fleet.js'
437
+ import { resetFleetFallbackNoticeCooldowns, shouldSendAllBlockedNotice, shouldSendStrictPinnedNotice } from './fleet-fallback-notice-cooldown.js'
437
438
  import { startRestartWatchdog } from './restart-watchdog.js'
438
439
  import { createAccessStore } from './access-store.js'
439
440
 
@@ -626,6 +627,13 @@ import { readTurnUsages } from '../../src/agents/perf.js'
626
627
  import { buildContextOccupancy, writeContextOccupancySnapshot } from './context-occupancy.js'
627
628
  import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
628
629
  import { IdleTracker, idleDurationToMs, DEFAULT_IDLE_CLEAR_MS } from './idle-clear.js'
630
+ import {
631
+ openPrivateInterval,
632
+ closePrivateInterval,
633
+ PRIVATE_ON_REPLY,
634
+ PUBLIC_REPLY,
635
+ } from './privacy-state.js'
636
+ import { makePrivacyResetForNewSession, isContinueRestoreBoot } from './privacy-reset.js'
629
637
  import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
630
638
  import {
631
639
  tryHostdDispatch,
@@ -769,13 +777,9 @@ import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
769
777
  import { dispatchEffects } from './inbound-delivery-machine-dispatch.js'
770
778
  import { maybeFireWarmup } from './prefix-warmup.js'
771
779
  import {
772
- renderSkillProposalCard,
773
- skillProposalKeyboard,
774
- } from './skill-proposal-card.js'
775
- import {
776
- enqueueProposal as enqueueSkillProposal,
777
- isSuppressed as isSkillProposalSuppressed,
778
- } from '../../src/self-improve/skill-proposals.js'
780
+ handlePostSkillProposal,
781
+ handlePostEvalCaseProposal,
782
+ } from './self-improve-proposal-wiring.js'
779
783
  import { decideSubagentHandback } from './subagent-handback-inbound-builder.js'
780
784
  import {
781
785
  decideSubagentProgress,
@@ -803,6 +807,7 @@ import type {
803
807
  QueryPendingPermissionMessage,
804
808
  CheckPreApprovedMessage,
805
809
  PostSkillProposalMessage,
810
+ PostEvalCaseProposalMessage,
806
811
  PermissionEvent,
807
812
  RolloutStatusPostMessage,
808
813
  RolloutStatusEditMessage,
@@ -5097,6 +5102,10 @@ function maybeIdleClear(): void {
5097
5102
  `telegram gateway: idle /clear suppressed for ${agentName} ` +
5098
5103
  `(activity in check-to-send gap)\n`,
5099
5104
  );
5105
+ } else {
5106
+ // The idle /clear fired → new logical session; reset privacy to public.
5107
+ const idleChat = loadAccess().allowFrom[0];
5108
+ if (idleChat) resetPrivacyForNewSession(String(idleChat), undefined);
5100
5109
  }
5101
5110
  })
5102
5111
  .catch((err: unknown) => {
@@ -5600,6 +5609,11 @@ const swallowingApiCall = createSwallowingRetryApiCall(
5600
5609
  robustApiCall,
5601
5610
  (line) => process.stderr.write(line),
5602
5611
  )
5612
+ // Privacy (#private-mode): reset to public on a genuine new session (boot / /clear); loud only on a private→public transition. See privacy-reset.ts.
5613
+ const resetPrivacyForNewSession = makePrivacyResetForNewSession((chatId, threadId, text) =>
5614
+ void swallowingApiCall(
5615
+ () => lockedBot.api.sendMessage(chatId, text, threadId != null ? { message_thread_id: threadId, disable_notification: false } : { disable_notification: false }),
5616
+ { chat_id: chatId, verb: 'privacy-reset-alert', priorityClass: 'critical' }))
5603
5617
 
5604
5618
  /**
5605
5619
  * The ONE seam every `setMessageReaction` in this gateway goes through (#3155).
@@ -9058,6 +9072,11 @@ const stalePinSweeper: StalePinSweeper = createGatewayStalePinSweeper({
9058
9072
  ? loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs)
9059
9073
  : [],
9060
9074
  eligible: () => stalePinSweepEligible,
9075
+ // Share the live path's per-process pin-rights negative cache so the sweep
9076
+ // and executePinLeg agree on rights-less chats (D3): the sweep skips a chat
9077
+ // the live path already proved rights-less, and feeds its own reactive
9078
+ // discoveries back so the live path skips too.
9079
+ rightsCache: statusPinRightsCache,
9061
9080
  store: { path: STALE_PIN_SWEEP_STORE_PATH, fs: sweepStoreFs },
9062
9081
  // Per-deployment override only. UNSET (the normal case) means "take the
9063
9082
  // standing policy" — UNPIN_ALL_FORUM_TOPIC_ENABLED in stale-pin-sweep.ts,
@@ -11592,69 +11611,19 @@ if (isGatewayMain) ipcServer = createIpcServer({
11592
11611
  // onQuotaWallDetected so it doesn't fall inside the source slice that
11593
11612
  // send-outbound-wiring.test.ts takes between onSendOutbound and
11594
11613
  // onQuotaWallDetected.
11614
+ // Thin delegate — body lives in self-improve-proposal-wiring.ts to keep the
11615
+ // gateway line ratchet flat. Placed AFTER onQuotaWallDetected so it doesn't
11616
+ // fall inside the source slice send-outbound-wiring.test.ts takes between
11617
+ // onSendOutbound and onQuotaWallDetected.
11595
11618
  onPostSkillProposal(_client: IpcClient, msg: PostSkillProposalMessage) {
11596
- const self = process.env.SWITCHROOM_AGENT_NAME
11597
- if (self && msg.agentName !== self) {
11598
- process.stderr.write(
11599
- `telegram gateway: post_skill_proposal rejected agent mismatch (${msg.agentName} != ${self})\n`,
11600
- )
11601
- return
11602
- }
11603
- try {
11604
- assertAllowedChat(msg.chatId)
11605
- } catch (err) {
11606
- process.stderr.write(
11607
- `telegram gateway: post_skill_proposal rejected — ${(err as Error).message}\n`,
11608
- )
11609
- return
11610
- }
11611
- const stateDir = process.env.TELEGRAM_STATE_DIR
11612
- if (stateDir == null || stateDir.length === 0) {
11613
- process.stderr.write(`telegram gateway: post_skill_proposal: TELEGRAM_STATE_DIR unset, skipping\n`)
11614
- return
11615
- }
11616
- // Dedup against still-live rejection fingerprints — never re-surface a
11617
- // proposal the operator already dismissed.
11618
- if (isSkillProposalSuppressed(stateDir, {
11619
- lesson: msg.lesson,
11620
- draft: msg.draft,
11621
- skill_slug: msg.skillSlug,
11622
- })) {
11623
- process.stderr.write(
11624
- `telegram gateway: post_skill_proposal suppressed (rejected before) slug=${msg.skillSlug}\n`,
11625
- )
11626
- return
11627
- }
11628
- const proposal = enqueueSkillProposal(stateDir, {
11629
- skill_slug: msg.skillSlug,
11630
- is_new: msg.isNew,
11631
- lesson: msg.lesson,
11632
- draft: msg.draft,
11633
- evidence: msg.evidence,
11634
- chat_id: Number(msg.chatId),
11635
- })
11636
- const cardText = renderSkillProposalCard({
11637
- id: proposal.id,
11638
- skill_slug: proposal.skill_slug,
11639
- is_new: proposal.is_new,
11640
- lesson: proposal.lesson,
11641
- evidence: proposal.evidence,
11642
- skill_md: proposal.draft['SKILL.md'],
11643
- })
11644
- const threadId = msg.threadId
11645
- void swallowingApiCall(
11646
- () =>
11647
- bot.api.sendMessage(msg.chatId, cardText, {
11648
- parse_mode: 'HTML',
11649
- reply_markup: skillProposalKeyboard(proposal.id),
11650
- ...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
11651
- }),
11652
- { chat_id: msg.chatId, verb: 'skill-proposal-card', ...(threadId != null ? { threadId } : {}) },
11653
- )
11654
- process.stderr.write(
11655
- `telegram gateway: post_skill_proposal agent=${msg.agentName} chat=${msg.chatId} ` +
11656
- `proposal=${proposal.id} slug=${proposal.skill_slug} new=${proposal.is_new}\n`,
11657
- )
11619
+ handlePostSkillProposal(msg, { bot, assertAllowedChat, swallowingApiCall })
11620
+ },
11621
+
11622
+ // RFC amendment §"corrections as eval cases" thin delegate; the
11623
+ // DETERMINISTIC applier runs on Approve in handleEvalCaseProposalCallback,
11624
+ // NOT a model turn. Body in self-improve-proposal-wiring.ts.
11625
+ onPostEvalCaseProposal(_client: IpcClient, msg: PostEvalCaseProposalMessage) {
11626
+ handlePostEvalCaseProposal(msg, { bot, assertAllowedChat, swallowingApiCall })
11658
11627
  },
11659
11628
 
11660
11629
  // Buzz Phase 2b: the duplex peer's advisory publish outcome — no-op unless the hub mirror booted.
@@ -17683,6 +17652,10 @@ async function refreshPinnedBanner(reason: string): Promise<void> {
17683
17652
  onError: (phase, err) => {
17684
17653
  process.stderr.write(`telegram gateway: banner ${phase} failed (${reason}): ${err}\n`)
17685
17654
  },
17655
+ // Share the one per-process pin-rights negative cache with the status-pin
17656
+ // path and the stale-pin sweep (D5): the banner skips a chat already known
17657
+ // rights-less and records/clears its own pin-verb discoveries there.
17658
+ rightsCache: statusPinRightsCache,
17686
17659
  // Durable pin persistence into the SHARED status-pin store (distinct
17687
17660
  // `banner:` pinKey). persist-BEFORE-pin ordering: pending() lands before
17688
17661
  // the pinChatMessage call so a crash in that window is recoverable by the
@@ -17908,16 +17881,10 @@ const litellmLocalNoticeRunner = createLitellmLocalNoticeRunner({
17908
17881
  */
17909
17882
  let fallbackFailureNoticeState: FallbackFailureNoticeState = { lastSentAtMs: 0 }
17910
17883
 
17911
- /**
17912
- * Bug 2 per-gateway cooldown for the "All accounts blocked" card. The
17913
- * all-blocked outcome is a no-op swap (doFireFleetAutoFallback returns false),
17914
- * so the fleetFallbackGate dedup window never arms for it, and the ~60s
17915
- * quota_wall_detected re-trigger would otherwise re-broadcast the identical card
17916
- * every minute for the life of the wall. This bounds it to one card per window.
17917
- * Reset on a successful swap so a fresh all-blocked after a recovery (a real new
17918
- * transition) is not stale-suppressed.
17919
- */
17920
- let fallbackAllBlockedNoticeState: FallbackAllBlockedNoticeState = { lastSentAtMs: 0 }
17884
+ // Per-gateway cooldown gates for the two NO-OP fleet-fallback outcomes
17885
+ // (all-blocked and strict-pinned) live in ./fleet-fallback-notice-cooldown.ts,
17886
+ // which owns their state and stderr suppress-logs (switchroom#4442, extracted to
17887
+ // keep the inline notice logic out of the line-ratchet-guarded gateway.ts).
17921
17888
 
17922
17889
  function broadcastFleetFallbackFailure(triggerAgent: string, reason: string): void {
17923
17890
  if (process.env.SWITCHROOM_FLEET_FALLBACK_FAILURE_NOTICE === '0') return
@@ -18167,7 +18134,11 @@ async function doFireFleetAutoFallback(
18167
18134
  // weekly-capped account is never re-probed (and re-wedged) within the
18168
18135
  // broker's ~5h default.
18169
18136
  const r = await client.markExhausted(untilMs)
18170
- return { rolledTo: r.rolledTo ?? null, rolled: r.rolled }
18137
+ return {
18138
+ rolledTo: r.rolledTo ?? null,
18139
+ rolled: r.rolled,
18140
+ callerPinnedStrict: r.caller_pinned_strict ?? false,
18141
+ }
18171
18142
  },
18172
18143
  triggerAgent,
18173
18144
  tz,
@@ -18193,7 +18164,11 @@ async function doFireFleetAutoFallback(
18193
18164
  // cooldown; a successful swap resets the window so a later (genuinely new)
18194
18165
  // all-blocked still emits promptly.
18195
18166
  if (outcome.kind === 'switched') {
18196
- fallbackAllBlockedNoticeState = { lastSentAtMs: 0 }
18167
+ resetFleetFallbackNoticeCooldowns()
18168
+ } else if (outcome.kind === 'strict-pinned') {
18169
+ // Same cooldown pattern as all-blocked (below): a no-op outcome the
18170
+ // ~60s wall re-trigger would otherwise re-broadcast every minute.
18171
+ if (!shouldSendStrictPinnedNotice(triggerAgent)) return false
18197
18172
  } else if (outcome.kind === 'all-blocked') {
18198
18173
  // ── Second recovery tier: MODEL-TIER downgrade (precedence A) ──────────
18199
18174
  // Account-swap just came back all-blocked (no account still serves the
@@ -18210,14 +18185,7 @@ async function doFireFleetAutoFallback(
18210
18185
  // card — a restart is coming that replays the interrupted turn.
18211
18186
  return false
18212
18187
  }
18213
- const verdict = evaluateAllBlockedNotice(fallbackAllBlockedNoticeState, Date.now())
18214
- if (!verdict.send) {
18215
- process.stderr.write(
18216
- `telegram gateway: [fleet-fallback] all-blocked card suppressed (cooldown) agent=${triggerAgent}\n`,
18217
- )
18218
- return false
18219
- }
18220
- fallbackAllBlockedNoticeState = verdict.next
18188
+ if (!shouldSendAllBlockedNotice(triggerAgent)) return false
18221
18189
  }
18222
18190
  // Post the announcement to every authorized chat. Mirrors the
18223
18191
  // operator-event broadcast pattern (line ~2290) — DM-only opts
@@ -19849,6 +19817,22 @@ bot.command('compact', async ctx => {
19849
19817
  })
19850
19818
  bot.command('clear', async ctx => {
19851
19819
  await handleInjectCommand(ctx, buildInjectDeps({ open: true, fixedVerb: '/clear' }))
19820
+ // A /clear starts a new logical session → reset privacy to public.
19821
+ const clearChatId = String(ctx.chat!.id)
19822
+ resetPrivacyForNewSession(clearChatId, resolveThreadId(clearChatId, ctx.message?.message_thread_id))
19823
+ })
19824
+ // Per-operator session privacy controls. NOT admin verbs (deliberately kept
19825
+ // out of ADMIN_COMMAND_NAMES) — they gate memory writing for THIS session, so
19826
+ // they share the plain per-command isAuthorizedSender gate like inject/clear.
19827
+ bot.command('private', async ctx => {
19828
+ if (!isAuthorizedSender(ctx)) return
19829
+ openPrivateInterval()
19830
+ await switchroomReply(ctx, PRIVATE_ON_REPLY)
19831
+ })
19832
+ bot.command('public', async ctx => {
19833
+ if (!isAuthorizedSender(ctx)) return
19834
+ closePrivateInterval()
19835
+ await switchroomReply(ctx, PUBLIC_REPLY)
19852
19836
  })
19853
19837
  // /model and /effort extracted to bot-commands-model-effort.ts
19854
19838
  // (switchroom#2996 P6 bot.command drain). Helper registration keeps grammy
@@ -21749,6 +21733,15 @@ bot.on('callback_query:data', async ctx => {
21749
21733
  return
21750
21734
  }
21751
21735
 
21736
+ // RFC amendment §"corrections as eval cases": one-tap eval-case card.
21737
+ // evcase:approve:<id> — run the DETERMINISTIC apply-eval-case applier
21738
+ // (byte-exact write; NOT a model turn)
21739
+ // evcase:deny:<id> — dismiss + mark the proposal rejected
21740
+ if (data.startsWith('evcase:')) {
21741
+ await callbackQueryHandlers.handleEvalCaseProposalCallback(ctx, data)
21742
+ return
21743
+ }
21744
+
21752
21745
  // #2862: missed-approvals re-offer digest.
21753
21746
  // missre:retry:<id> — inject a synthetic inbound asking the agent to
21754
21747
  // re-attempt (re-raises a fresh approval card)
@@ -23527,6 +23520,8 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23527
23520
  // Only when we KNOW it was a /model switch (reason) AND we will send the
23528
23521
  // confirmation (marker chat captured); otherwise the boot card fires
23529
23522
  // normally. Version/quota remain available via /status.
23523
+ // Privacy: genuine FRESH boot (cold/crash/planned/model-switch) → reset to public, reusing boot-card's chat. Suppressed on a --continue/auto transcript-restore (state must persist) and never wired to bridge-reconnect.
23524
+ if (target && !isContinueRestoreBoot(resolveAgentDirFromEnv())) resetPrivacyForNewSession(target.chatId, target.threadId)
23530
23525
  const suppressBootCardForModelSwitch =
23531
23526
  modelSwitchReason != null && modelSwitchMarkerChat != null
23532
23527
  if (target && suppressBootCardForModelSwitch) {