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
@@ -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).
@@ -8356,8 +8370,7 @@ const statusPinClaims = new Map<string, StatusPinClaim>()
8356
8370
  // Rights-aware negative cache (#3024): chats where an auto status-pin attempt
8357
8371
  // failed with the permanent "not enough rights to manage pinned messages" 400.
8358
8372
  // Per-process only — a restart clears it so a later-granted pin right re-enables
8359
- // auto-pin. The explicit `pin_message` MCP tool deliberately does NOT consult
8360
- // this cache (it always attempts and surfaces the error to the agent).
8373
+ // auto-pin.
8361
8374
  const statusPinRightsCache = new PinRightsCache()
8362
8375
 
8363
8376
  // Durable snapshot of the pin claim set on the persistent per-agent volume
@@ -8452,21 +8465,16 @@ const BANNER_PIN_KEY = 'banner:owner'
8452
8465
  // run → no-op). The boot-cleanup gate below is widened to cover this.
8453
8466
  const bannerPinPersistEnabled = !STATIC
8454
8467
 
8455
- // `pin_message` MCP-tool pin registration (#3001). Tool pins ride the same
8456
- // shared status-pins.json store under `tool:<chatId>:<messageId>` keys, but
8457
- // with a TTL row (`expiresAt`): a tool pin is a deliberate agent action with
8458
- // no "work finished" event, so a restart does NOT reset it — boot cleanup
8459
- // keeps unexpired tool rows and only unpins them once the TTL lapses (the
8460
- // backstop against agent-pinned messages accumulating forever). Independent
8461
- // of PIN_STATUS_WHILE_WORKING (it gates the auto status pin, not the tool).
8468
+ // LEGACY `tool:` pin drain (#3001; the `pin_message` MCP tool was retired in
8469
+ // #4452). No new `tool:<chatId>:<messageId>` rows are ever written now that the
8470
+ // tool is gone, but rows an earlier build persisted may still sit in the shared
8471
+ // status-pins.json with a TTL (`expiresAt`). This flag keeps the boot cleanup /
8472
+ // stale-pin sweep aware of those legacy rows so they are still expired and
8473
+ // drained correctly on upgrade — an unexpired legacy row is preserved across
8474
+ // boots (never reaped as if it were a work-scoped card), then unpinned once its
8475
+ // TTL lapses, exactly as before. Independent of PIN_STATUS_WHILE_WORKING.
8476
+ // Self-clears within one TTL window since nothing writes new tool rows.
8462
8477
  const toolPinPersistEnabled = !STATIC
8463
- // 7 days: generous — an agent-pinned message the operator still cares about
8464
- // after a week has usually been re-pinned or acted on; anything older is the
8465
- // stale-pin long tail this issue exists to clear. Override for tuning.
8466
- const TOOL_PIN_TTL_MS = (() => {
8467
- const v = Number(process.env.SWITCHROOM_TOOL_PIN_TTL_MS)
8468
- return Number.isFinite(v) && v > 0 ? v : 7 * 24 * 60 * 60_000
8469
- })()
8470
8478
 
8471
8479
  // Persist (or drop) the slot-banner's pin row into the shared store. Routes
8472
8480
  // through mutateStatusPinRow: a read-modify-write for ONLY the banner:owner key,
@@ -8936,8 +8944,7 @@ async function reconcileStatusPin(
8936
8944
  // "not enough rights to manage pinned messages" 400 in a supergroup took the
8937
8945
  // whole gateway down (marko, 2026-07-01). Auto status-pin is cosmetic; it
8938
8946
  // must NEVER be able to crash the gateway. Any throw here is logged and
8939
- // absorbed. (The `pin_message` MCP tool still surfaces failures to the agent
8940
- // as a normal tool-error — that path is `executePinMessage`, not this one.)
8947
+ // absorbed.
8941
8948
  try {
8942
8949
  // Serialize per pinKey (F2): reconcileStatusPinInner reads `prev` from the
8943
8950
  // in-memory claim map at its top, so overlapping same-key reconciles must
@@ -9058,6 +9065,11 @@ const stalePinSweeper: StalePinSweeper = createGatewayStalePinSweeper({
9058
9065
  ? loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs)
9059
9066
  : [],
9060
9067
  eligible: () => stalePinSweepEligible,
9068
+ // Share the live path's per-process pin-rights negative cache so the sweep
9069
+ // and executePinLeg agree on rights-less chats (D3): the sweep skips a chat
9070
+ // the live path already proved rights-less, and feeds its own reactive
9071
+ // discoveries back so the live path skips too.
9072
+ rightsCache: statusPinRightsCache,
9061
9073
  store: { path: STALE_PIN_SWEEP_STORE_PATH, fs: sweepStoreFs },
9062
9074
  // Per-deployment override only. UNSET (the normal case) means "take the
9063
9075
  // standing policy" — UNPIN_ALL_FORUM_TOPIC_ENABLED in stale-pin-sweep.ts,
@@ -11592,69 +11604,19 @@ if (isGatewayMain) ipcServer = createIpcServer({
11592
11604
  // onQuotaWallDetected so it doesn't fall inside the source slice that
11593
11605
  // send-outbound-wiring.test.ts takes between onSendOutbound and
11594
11606
  // onQuotaWallDetected.
11607
+ // Thin delegate — body lives in self-improve-proposal-wiring.ts to keep the
11608
+ // gateway line ratchet flat. Placed AFTER onQuotaWallDetected so it doesn't
11609
+ // fall inside the source slice send-outbound-wiring.test.ts takes between
11610
+ // onSendOutbound and onQuotaWallDetected.
11595
11611
  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
- )
11612
+ handlePostSkillProposal(msg, { bot, assertAllowedChat, swallowingApiCall })
11613
+ },
11614
+
11615
+ // RFC amendment §"corrections as eval cases" — thin delegate; the
11616
+ // DETERMINISTIC applier runs on Approve in handleEvalCaseProposalCallback,
11617
+ // NOT a model turn. Body in self-improve-proposal-wiring.ts.
11618
+ onPostEvalCaseProposal(_client: IpcClient, msg: PostEvalCaseProposalMessage) {
11619
+ handlePostEvalCaseProposal(msg, { bot, assertAllowedChat, swallowingApiCall })
11658
11620
  },
11659
11621
 
11660
11622
  // Buzz Phase 2b: the duplex peer's advisory publish outcome — no-op unless the hub mirror booted.
@@ -11839,7 +11801,7 @@ if (isGatewayMain && !STATIC) {
11839
11801
  * bridge from calling arbitrary functions by name. */
11840
11802
  const ALLOWED_TOOLS = new Set([
11841
11803
  'reply', 'progress_update', 'react', 'download_attachment',
11842
- 'edit_message', 'send_typing', 'pin_message', 'delete_message',
11804
+ 'edit_message', 'send_typing', 'delete_message',
11843
11805
  'forward_message', 'get_recent_messages',
11844
11806
  'send_checklist', 'update_checklist',
11845
11807
  'ask_user',
@@ -11875,8 +11837,6 @@ async function executeToolCall(
11875
11837
  return executeEditMessage(args)
11876
11838
  case 'send_typing':
11877
11839
  return executeSendTyping(args)
11878
- case 'pin_message':
11879
- return executePinMessage(args)
11880
11840
  case 'delete_message':
11881
11841
  return executeDeleteMessage(args)
11882
11842
  case 'forward_message':
@@ -13226,44 +13186,6 @@ async function executeSendTyping(args: Record<string, unknown>): Promise<unknown
13226
13186
  return { content: [{ type: 'text', text: `${action} indicator sent (auto-refreshes every 4s, stops after 30s or next reply)` }] }
13227
13187
  }
13228
13188
 
13229
- async function executePinMessage(args: Record<string, unknown>): Promise<unknown> {
13230
- if (!args.chat_id) throw new Error('pin_message: chat_id is required')
13231
- if (!args.message_id) throw new Error('pin_message: message_id is required')
13232
- const pinChatId = String(args.chat_id ?? '')
13233
- assertAllowedChat(pinChatId)
13234
- // #1075: wrap through robustApiCall so flood-wait / transient network
13235
- // errors are retried. THREAD_NOT_FOUND on a stale topic surfaces to the
13236
- // agent as a tool-error — pinning a vanished message is genuinely a
13237
- // failure the agent should see.
13238
- const pinMsgId = Number(args.message_id)
13239
- await robustApiCall(
13240
- () => lockedBot.api.pinChatMessage(pinChatId, pinMsgId), // allow-raw-pin: MCP `pin_message` tool — an explicit, agent-requested pin of an arbitrary message, not a progress surface with a claim.
13241
- { chat_id: pinChatId, verb: 'pin_message' },
13242
- )
13243
- // An explicit pin succeeded here, so the bot demonstrably HAS pin rights in
13244
- // this chat now — clear any auto-pin negative-cache entry (#3024) so the auto
13245
- // status-pin path resumes immediately rather than waiting for a restart. The
13246
- // explicit tool itself never consults the cache; a failure above still
13247
- // surfaces to the agent as a normal tool error (robustApiCall rethrows).
13248
- statusPinRightsCache.clear(pinChatId)
13249
- // #3001: register the tool pin in the shared status-pin store under a
13250
- // `tool:` key so it is no longer fire-and-forget. Unlike work-scoped
13251
- // fg:/wk: rows a tool pin has no "work finished" event, so a restart does
13252
- // NOT reset it — boot cleanup keeps the row until its TTL, then unpins the
13253
- // (likely long-forgotten) message. Best-effort fire-and-forget: a store
13254
- // failure must never fail the tool call the pin already landed for.
13255
- if (toolPinPersistEnabled) {
13256
- const toolPinKey = `tool:${pinChatId}:${pinMsgId}`
13257
- void mutateStatusPinRow(STATUS_PIN_STORE_PATH, statusPinStoreFs, toolPinKey, { // allow-raw-pin-store: records the TTL-scoped `tool:` row for the explicit pin above so the boot sweep can expire it.
13258
- pinKey: toolPinKey,
13259
- chatId: pinChatId,
13260
- messageId: pinMsgId,
13261
- expiresAt: Date.now() + TOOL_PIN_TTL_MS,
13262
- })
13263
- }
13264
- return { content: [{ type: 'text', text: `pinned message ${args.message_id}` }] }
13265
- }
13266
-
13267
13189
  async function executeDeleteMessage(args: Record<string, unknown>): Promise<unknown> {
13268
13190
  if (!args.chat_id) throw new Error('delete_message: chat_id is required')
13269
13191
  if (!args.message_id) throw new Error('delete_message: message_id is required')
@@ -17683,6 +17605,10 @@ async function refreshPinnedBanner(reason: string): Promise<void> {
17683
17605
  onError: (phase, err) => {
17684
17606
  process.stderr.write(`telegram gateway: banner ${phase} failed (${reason}): ${err}\n`)
17685
17607
  },
17608
+ // Share the one per-process pin-rights negative cache with the status-pin
17609
+ // path and the stale-pin sweep (D5): the banner skips a chat already known
17610
+ // rights-less and records/clears its own pin-verb discoveries there.
17611
+ rightsCache: statusPinRightsCache,
17686
17612
  // Durable pin persistence into the SHARED status-pin store (distinct
17687
17613
  // `banner:` pinKey). persist-BEFORE-pin ordering: pending() lands before
17688
17614
  // the pinChatMessage call so a crash in that window is recoverable by the
@@ -17908,16 +17834,10 @@ const litellmLocalNoticeRunner = createLitellmLocalNoticeRunner({
17908
17834
  */
17909
17835
  let fallbackFailureNoticeState: FallbackFailureNoticeState = { lastSentAtMs: 0 }
17910
17836
 
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 }
17837
+ // Per-gateway cooldown gates for the two NO-OP fleet-fallback outcomes
17838
+ // (all-blocked and strict-pinned) live in ./fleet-fallback-notice-cooldown.ts,
17839
+ // which owns their state and stderr suppress-logs (switchroom#4442, extracted to
17840
+ // keep the inline notice logic out of the line-ratchet-guarded gateway.ts).
17921
17841
 
17922
17842
  function broadcastFleetFallbackFailure(triggerAgent: string, reason: string): void {
17923
17843
  if (process.env.SWITCHROOM_FLEET_FALLBACK_FAILURE_NOTICE === '0') return
@@ -18167,7 +18087,11 @@ async function doFireFleetAutoFallback(
18167
18087
  // weekly-capped account is never re-probed (and re-wedged) within the
18168
18088
  // broker's ~5h default.
18169
18089
  const r = await client.markExhausted(untilMs)
18170
- return { rolledTo: r.rolledTo ?? null, rolled: r.rolled }
18090
+ return {
18091
+ rolledTo: r.rolledTo ?? null,
18092
+ rolled: r.rolled,
18093
+ callerPinnedStrict: r.caller_pinned_strict ?? false,
18094
+ }
18171
18095
  },
18172
18096
  triggerAgent,
18173
18097
  tz,
@@ -18193,7 +18117,11 @@ async function doFireFleetAutoFallback(
18193
18117
  // cooldown; a successful swap resets the window so a later (genuinely new)
18194
18118
  // all-blocked still emits promptly.
18195
18119
  if (outcome.kind === 'switched') {
18196
- fallbackAllBlockedNoticeState = { lastSentAtMs: 0 }
18120
+ resetFleetFallbackNoticeCooldowns()
18121
+ } else if (outcome.kind === 'strict-pinned') {
18122
+ // Same cooldown pattern as all-blocked (below): a no-op outcome the
18123
+ // ~60s wall re-trigger would otherwise re-broadcast every minute.
18124
+ if (!shouldSendStrictPinnedNotice(triggerAgent)) return false
18197
18125
  } else if (outcome.kind === 'all-blocked') {
18198
18126
  // ── Second recovery tier: MODEL-TIER downgrade (precedence A) ──────────
18199
18127
  // Account-swap just came back all-blocked (no account still serves the
@@ -18210,14 +18138,7 @@ async function doFireFleetAutoFallback(
18210
18138
  // card — a restart is coming that replays the interrupted turn.
18211
18139
  return false
18212
18140
  }
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
18141
+ if (!shouldSendAllBlockedNotice(triggerAgent)) return false
18221
18142
  }
18222
18143
  // Post the announcement to every authorized chat. Mirrors the
18223
18144
  // operator-event broadcast pattern (line ~2290) — DM-only opts
@@ -19849,6 +19770,22 @@ bot.command('compact', async ctx => {
19849
19770
  })
19850
19771
  bot.command('clear', async ctx => {
19851
19772
  await handleInjectCommand(ctx, buildInjectDeps({ open: true, fixedVerb: '/clear' }))
19773
+ // A /clear starts a new logical session → reset privacy to public.
19774
+ const clearChatId = String(ctx.chat!.id)
19775
+ resetPrivacyForNewSession(clearChatId, resolveThreadId(clearChatId, ctx.message?.message_thread_id))
19776
+ })
19777
+ // Per-operator session privacy controls. NOT admin verbs (deliberately kept
19778
+ // out of ADMIN_COMMAND_NAMES) — they gate memory writing for THIS session, so
19779
+ // they share the plain per-command isAuthorizedSender gate like inject/clear.
19780
+ bot.command('private', async ctx => {
19781
+ if (!isAuthorizedSender(ctx)) return
19782
+ openPrivateInterval()
19783
+ await switchroomReply(ctx, PRIVATE_ON_REPLY)
19784
+ })
19785
+ bot.command('public', async ctx => {
19786
+ if (!isAuthorizedSender(ctx)) return
19787
+ closePrivateInterval()
19788
+ await switchroomReply(ctx, PUBLIC_REPLY)
19852
19789
  })
19853
19790
  // /model and /effort extracted to bot-commands-model-effort.ts
19854
19791
  // (switchroom#2996 P6 bot.command drain). Helper registration keeps grammy
@@ -21749,6 +21686,15 @@ bot.on('callback_query:data', async ctx => {
21749
21686
  return
21750
21687
  }
21751
21688
 
21689
+ // RFC amendment §"corrections as eval cases": one-tap eval-case card.
21690
+ // evcase:approve:<id> — run the DETERMINISTIC apply-eval-case applier
21691
+ // (byte-exact write; NOT a model turn)
21692
+ // evcase:deny:<id> — dismiss + mark the proposal rejected
21693
+ if (data.startsWith('evcase:')) {
21694
+ await callbackQueryHandlers.handleEvalCaseProposalCallback(ctx, data)
21695
+ return
21696
+ }
21697
+
21752
21698
  // #2862: missed-approvals re-offer digest.
21753
21699
  // missre:retry:<id> — inject a synthetic inbound asking the agent to
21754
21700
  // re-attempt (re-raises a fresh approval card)
@@ -23527,6 +23473,8 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23527
23473
  // Only when we KNOW it was a /model switch (reason) AND we will send the
23528
23474
  // confirmation (marker chat captured); otherwise the boot card fires
23529
23475
  // normally. Version/quota remain available via /status.
23476
+ // 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.
23477
+ if (target && !isContinueRestoreBoot(resolveAgentDirFromEnv())) resetPrivacyForNewSession(target.chatId, target.threadId)
23530
23478
  const suppressBootCardForModelSwitch =
23531
23479
  modelSwitchReason != null && modelSwitchMarkerChat != null
23532
23480
  if (target && suppressBootCardForModelSwitch) {
@@ -23879,6 +23827,14 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23879
23827
  const raw = Number(process.env.SWITCHROOM_TG_WORKER_FEED_MAX_ROWS)
23880
23828
  return Number.isInteger(raw) && raw > 0 ? raw : undefined
23881
23829
  })()
23830
+ // First-paint hold for a prose-silent sub-agent card. Unset /
23831
+ // non-positive → the feed's built-in default (4000ms). Operator
23832
+ // override (SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS) lets first
23833
+ // paint be retuned/reverted (e.g. back to 8000) without a redeploy.
23834
+ const workerFeedFirstPaintMs = (() => {
23835
+ const raw = Number(process.env.SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS)
23836
+ return Number.isInteger(raw) && raw > 0 ? raw : undefined
23837
+ })()
23882
23838
  // Model A — foreground sub-agent nesting in the parent's live
23883
23839
  // activity draft. ON by default; this edits the SAME activity-
23884
23840
  // summary message the tool_label feed already owns (not the
@@ -23952,6 +23908,10 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23952
23908
  // channels.telegram.worker_feed.max_rows via the config cascade
23953
23909
  // (scaffold emits SWITCHROOM_TG_WORKER_FEED_MAX_ROWS); unset → 8.
23954
23910
  maxRows: workerFeedMaxRows,
23911
+ // First-paint hold for a prose-silent worker's initial card.
23912
+ // Unset → the feed's built-in default (4000ms); the operator
23913
+ // override SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS retunes it.
23914
+ firstPaintMinMs: workerFeedFirstPaintMs,
23955
23915
  // Backstop TTL for the feed's stale-row reaper, DERIVED in code
23956
23916
  // from the watcher's effective in-flight terminal cap (same env /
23957
23917
  // default the watcher itself resolves) plus a margin — NOT a
@@ -573,6 +573,48 @@ export interface PostSkillProposalMessage {
573
573
  evidence: string;
574
574
  /** Full drafted skill bundle (SKILL.md + optional files). */
575
575
  draft: Record<string, string>;
576
+ /**
577
+ * Provenance of the proposal — `"skill-synthesis"` (the weekly cron) or
578
+ * `"failure-synthesis"` (a skill drafted from an observed failure). Absent
579
+ * ⇒ the store defaults it to `"skill-synthesis"` (back-compat). Provenance
580
+ * is orthogonal to tier routing.
581
+ */
582
+ origin?: "skill-synthesis" | "failure-synthesis";
583
+ }
584
+
585
+ /**
586
+ * `switchroom self-improve add-eval-case` asks the caller agent's gateway to
587
+ * persist an eval-case proposal and post a one-tap Approve/Dismiss card (RFC
588
+ * amendment §"corrections as eval cases"). Unlike a skill proposal, the
589
+ * Approve tap does NOT inject a model turn — the gateway runs the
590
+ * DETERMINISTIC `apply-eval-case` applier so the case lands byte-exact.
591
+ *
592
+ * Trust model identical to post_skill_proposal: per-agent socket, agentName
593
+ * validated, chat fenced to the agent's own chat.
594
+ */
595
+ export interface PostEvalCaseProposalMessage {
596
+ type: "post_eval_case_proposal";
597
+ agentName: string;
598
+ /** Agent's own chat id (fenced server-side). */
599
+ chatId: string;
600
+ threadId?: number;
601
+ /** Target skill slug. */
602
+ skillSlug: string;
603
+ /** Absolute path to the owned skill bundle dir (resolved by the CLI). */
604
+ skillDir: string;
605
+ /** The eval case to append (prompt + optional expected/expectations/etc). */
606
+ case: {
607
+ prompt: string;
608
+ expected_output?: string;
609
+ files?: string[];
610
+ expectations?: string[];
611
+ source?: string;
612
+ id?: string;
613
+ };
614
+ /** Prompt fingerprint (dedup + provenance). */
615
+ fingerprint: string;
616
+ /** Route the case to the held-out sink instead of evals.json. */
617
+ heldOut: boolean;
576
618
  }
577
619
 
578
620
  /**
@@ -766,6 +808,7 @@ export type ClientToGateway =
766
808
  | QuotaWallDetectedMessage
767
809
  | SendOutboundMessage
768
810
  | PostSkillProposalMessage
811
+ | PostEvalCaseProposalMessage
769
812
  | RolloutStatusPostMessage
770
813
  | RolloutStatusEditMessage
771
814
  | QueryPendingPermissionMessage
@@ -9,6 +9,7 @@ import type {
9
9
  QueryPendingPermissionMessage,
10
10
  CheckPreApprovedMessage,
11
11
  PostSkillProposalMessage,
12
+ PostEvalCaseProposalMessage,
12
13
  OperatorEventForward,
13
14
  PermissionRequestForward,
14
15
  PtyPartialForward,
@@ -101,6 +102,12 @@ export interface IpcServerOptions {
101
102
  * chat. Optional; gateways that don't surface proposals ignore it.
102
103
  */
103
104
  onPostSkillProposal?: (client: IpcClient, msg: PostSkillProposalMessage) => void;
105
+ /**
106
+ * RFC amendment §"corrections as eval cases" — `add-eval-case` asks the
107
+ * gateway to persist an eval-case proposal and post its one-tap card.
108
+ * Optional; gateways that don't surface proposals ignore it.
109
+ */
110
+ onPostEvalCaseProposal?: (client: IpcClient, msg: PostEvalCaseProposalMessage) => void;
104
111
  /**
105
112
  * RFC E §4.2 Cut 2 — Drive-write PreToolUse hook asks the gateway
106
113
  * to register a kernel approval request + post a diff-preview
@@ -379,6 +386,23 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
379
386
  && (typeof m.threadId !== "number" || !Number.isInteger(m.threadId as number))) return false;
380
387
  return true;
381
388
  }
389
+ case "post_eval_case_proposal": {
390
+ // RFC amendment §"corrections as eval cases" — validate the wire shape;
391
+ // the gateway handler fences chatId to the agent's own chat.
392
+ if (typeof m.agentName !== "string"
393
+ || !AGENT_NAME_RE.test(m.agentName as string)) return false;
394
+ if (typeof m.chatId !== "string" || (m.chatId as string).length === 0) return false;
395
+ if (typeof m.skillSlug !== "string" || (m.skillSlug as string).length === 0) return false;
396
+ if (typeof m.skillDir !== "string" || (m.skillDir as string).length === 0) return false;
397
+ if (typeof m.fingerprint !== "string" || (m.fingerprint as string).length === 0) return false;
398
+ if (typeof m.heldOut !== "boolean") return false;
399
+ if (typeof m.case !== "object" || m.case === null || Array.isArray(m.case)) return false;
400
+ if (typeof (m.case as Record<string, unknown>).prompt !== "string"
401
+ || ((m.case as Record<string, unknown>).prompt as string).length === 0) return false;
402
+ if (m.threadId !== undefined
403
+ && (typeof m.threadId !== "number" || !Number.isInteger(m.threadId as number))) return false;
404
+ return true;
405
+ }
382
406
  case "quota_wall_detected": {
383
407
  // wedge-watchdog detected the /rate-limit-options weekly-quota menu.
384
408
  if (typeof m.agentName !== "string"
@@ -565,6 +589,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
565
589
  onQueryPendingPermission,
566
590
  onCheckPreApproved,
567
591
  onPostSkillProposal,
592
+ onPostEvalCaseProposal,
568
593
  onRequestDriveApproval,
569
594
  onRequestMs365Approval,
570
595
  onRequestConfigApproval,
@@ -733,6 +758,9 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
733
758
  case "post_skill_proposal":
734
759
  if (onPostSkillProposal) onPostSkillProposal(client, msg as PostSkillProposalMessage);
735
760
  break;
761
+ case "post_eval_case_proposal":
762
+ if (onPostEvalCaseProposal) onPostEvalCaseProposal(client, msg as PostEvalCaseProposalMessage);
763
+ break;
736
764
  case "quota_wall_detected":
737
765
  if (onQuotaWallDetected) onQuotaWallDetected(client, msg as QuotaWallDetectedMessage);
738
766
  break;
@@ -21,6 +21,7 @@ import * as silencePoke from '../silence-poke.js'
21
21
  import * as signalTracker from '../turn-signal-tracker.js'
22
22
  import * as pendingProgress from '../pending-work-progress.js'
23
23
  import { emitRuntimeMetric } from '../runtime-metrics.js'
24
+ import { computeTurnDurationMs } from './turn-record-status.js'
24
25
  import { logStreamingEvent } from '../streaming-metrics.js'
25
26
  import { clearSilentEndState } from '../silent-end.js'
26
27
  import { purgeStaleTurnsForChat } from './turn-state-purge.js'
@@ -377,7 +378,11 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
377
378
  turnMatchesFallback && wedgedTurn != null && turnLiveForItsTopic(wedgedTurn)
378
379
  const turnStartedAt = activeTurnStartedAt.get(fbKey)
379
380
  if (turnStartedAt != null) {
380
- const turnDurationMs = Date.now() - turnStartedAt
381
+ // Guard shared with buildTurnRecord + the stream-render turn_ended paths:
382
+ // a 0 / bogus start must never emit `Date.now() - 0` (an absolute epoch
383
+ // value) as a duration. This path previously did a bare subtraction and
384
+ // poisoned the turn_ended dataset when `activeTurnStartedAt` held 0.
385
+ const turnDurationMs = computeTurnDurationMs(turnStartedAt, Date.now())
381
386
  const outboundMetrics = signalTracker.getOutboundMetrics(fbKey)
382
387
  emitRuntimeMetric({
383
388
  kind: 'turn_ended',
@@ -56,7 +56,7 @@ import {
56
56
  appendActivityLabel, clipNarrative, formatStepSuffix, renderActivityFeedWithNested,
57
57
  } from '../tool-activity-summary.js'
58
58
  import { evaluatePostAnswerLiveness } from '../turn-liveness-floor.js'
59
- import { isSilentSentinelCardOutcome } from '../turn-flush-safety.js'
59
+ import { isHollowGhostCardOutcome, isSilentSentinelCardOutcome } from '../turn-flush-safety.js'
60
60
  import { clearActivityCardRecord, writeActivityCardRecord } from './activity-card-store.js'
61
61
  import { chatKeyWithSuffix } from './chat-key.js'
62
62
  import {
@@ -455,10 +455,18 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
455
455
  // stays in view when the feed scrolls past. Keyed to the same
456
456
  // status-key the canonical turn-end (purgeReactionTracking) unpins.
457
457
  // Fire-and-forget; the single-owner reconcile keeps state consistent.
458
+ // Pass `thread` as the 4th arg so the persisted row keeps the forum
459
+ // topic (D4): without it a forum-topic foreground card orphaned by a
460
+ // crash lands a threadId-less row, and the sweep cannot aim
461
+ // `unpinAllForumTopicMessages` at the right topic. Mirrors the worker
462
+ // feed's `reconcilePin` (gateway.ts). It does NOT change the pinKey —
463
+ // `statusKey(chat, thread)` already folds the topic into the key; the
464
+ // 4th arg only threads the topic into the claim + durable row.
458
465
  void reconcileStatusPin(
459
466
  `fg:${statusKey(chat, thread)}`,
460
467
  chat,
461
468
  { pinned: true, messageId: sent.message_id },
469
+ thread,
462
470
  )
463
471
  } else {
464
472
  const id = turn.activityMessageId
@@ -900,7 +908,30 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
900
908
  capturedText: turn.capturedText,
901
909
  finalAnswerEverDelivered: turn.finalAnswerEverDelivered,
902
910
  })
903
- if (CLEAR_STATUS_ON_COMPLETION || silentSentinelTurn) {
911
+ // #45 — hollow-ghost suppression. The sibling to the #4348 sentinel gate:
912
+ // a turn that ended having done ZERO surfaced tool work, never called
913
+ // reply, delivered no final answer, surfaced NO narration, and emitted no
914
+ // captured/reply text adopted an activity card at turn start that never
915
+ // got any content — the contentless `🤖 Agent · done · 0 tools · Ns`
916
+ // record Ken reported (a card opened by the liveness timer that stayed
917
+ // empty). The sentinel gate can't catch it (there is no NO_REPLY/
918
+ // HEARTBEAT_OK text to match; the flush classifies it `empty-text`, not
919
+ // `silent-marker`), so DELETE the empty card here instead of finalizing
920
+ // it. Deterministic (pure predicate over already-tracked turn fields) and
921
+ // normal-case-safe: any surfaced tool step, any rendered narrative line
922
+ // (`mirrorLines` — content the user actually saw), any reply, any captured
923
+ // text, or a delivered answer keeps the card. The `finalHtmlOverride`
924
+ // finalize path is only taken by the foreground handoff-clear (which fires
925
+ // on a delivered final answer), so this gate never contends with it.
926
+ const hollowGhostTurn = isHollowGhostCardOutcome({
927
+ replyCalled: turn.replyCalled,
928
+ labeledToolCount: turn.labeledToolCount,
929
+ mirrorLines: turn.mirrorLines,
930
+ capturedText: turn.capturedText,
931
+ lastReplyText: turn.lastReplyText,
932
+ finalAnswerEverDelivered: turn.finalAnswerEverDelivered,
933
+ })
934
+ if (CLEAR_STATUS_ON_COMPLETION || silentSentinelTurn || hollowGhostTurn) {
904
935
  try {
905
936
  await robustApiCall(
906
937
  () => bot.api.deleteMessage(chat, id),