switchroom 0.7.15 → 0.10.0

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 (301) hide show
  1. package/README.md +51 -59
  2. package/bin/run-hook.sh +27 -11
  3. package/bin/timezone-hook.sh +9 -7
  4. package/dist/agent-scheduler/index.js +410 -133
  5. package/dist/auth-broker/index.js +13932 -0
  6. package/dist/cli/switchroom.js +26937 -5601
  7. package/dist/host-control/main.js +12702 -0
  8. package/dist/vault/approvals/kernel-server.js +467 -184
  9. package/dist/vault/broker/server.js +1430 -724
  10. package/examples/minimal.yaml +63 -0
  11. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  12. package/examples/personal-google-workspace-mcp/README.md +194 -0
  13. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  14. package/examples/switchroom.yaml +220 -0
  15. package/package.json +7 -4
  16. package/profiles/_base/settings.json.hbs +20 -5
  17. package/profiles/_base/start.sh.hbs +16 -3
  18. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  19. package/profiles/_shared/telegram-style.md.hbs +20 -90
  20. package/profiles/_shared/vault-protocol.md.hbs +68 -0
  21. package/profiles/default/CLAUDE.md +50 -96
  22. package/profiles/default/CLAUDE.md.hbs +36 -6
  23. package/profiles/default/workspace/SOUL.md.hbs +12 -5
  24. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  25. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  26. package/skills/buildkite-api/SKILL.md +31 -8
  27. package/skills/buildkite-cli/SKILL.md +27 -9
  28. package/skills/buildkite-migration/SKILL.md +22 -9
  29. package/skills/buildkite-pipelines/SKILL.md +26 -9
  30. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  31. package/skills/buildkite-test-engine/SKILL.md +25 -8
  32. package/skills/docx/SKILL.md +1 -1
  33. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
  34. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
  35. package/skills/file-bug/SKILL.md +34 -6
  36. package/skills/humanizer/SKILL.md +15 -0
  37. package/skills/humanizer-calibrate/SKILL.md +7 -1
  38. package/skills/mcp-builder/SKILL.md +1 -1
  39. package/skills/pdf/SKILL.md +1 -1
  40. package/skills/pptx/SKILL.md +1 -1
  41. package/skills/skill-creator/SKILL.md +21 -1
  42. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  43. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  44. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  45. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  46. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  47. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  48. package/skills/switchroom-cli/SKILL.md +63 -64
  49. package/skills/switchroom-health/SKILL.md +23 -10
  50. package/skills/switchroom-install/SKILL.md +3 -3
  51. package/skills/switchroom-manage/SKILL.md +26 -19
  52. package/skills/switchroom-runtime/SKILL.md +191 -0
  53. package/skills/switchroom-status/SKILL.md +27 -2
  54. package/skills/telegram-test-harness/SKILL.md +3 -0
  55. package/skills/token-helpers/SKILL.md +24 -1
  56. package/skills/webapp-testing/SKILL.md +31 -1
  57. package/skills/xlsx/SKILL.md +1 -1
  58. package/telegram-plugin/admin-commands/index.ts +7 -5
  59. package/telegram-plugin/analytics-posthog.ts +191 -0
  60. package/telegram-plugin/bridge/bridge.ts +69 -0
  61. package/telegram-plugin/bridge/ipc-client.ts +4 -1
  62. package/telegram-plugin/dist/bridge/bridge.js +194 -119
  63. package/telegram-plugin/dist/gateway/gateway.js +23611 -19671
  64. package/telegram-plugin/dist/server.js +245 -189
  65. package/telegram-plugin/first-paint.ts +3 -24
  66. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  67. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  68. package/telegram-plugin/gateway/auth-command.ts +794 -0
  69. package/telegram-plugin/gateway/auth-line.ts +123 -0
  70. package/telegram-plugin/gateway/boot-card.ts +169 -40
  71. package/telegram-plugin/gateway/boot-issue-cache.ts +308 -0
  72. package/telegram-plugin/gateway/boot-probes.ts +166 -123
  73. package/telegram-plugin/gateway/boot-reason.ts +41 -7
  74. package/telegram-plugin/gateway/boot-version.ts +66 -0
  75. package/telegram-plugin/gateway/gateway.ts +3499 -1885
  76. package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
  77. package/telegram-plugin/gateway/ipc-protocol.ts +18 -0
  78. package/telegram-plugin/gateway/pending-inbound-buffer.ts +106 -0
  79. package/telegram-plugin/gateway/quarantine.ts +69 -0
  80. package/telegram-plugin/gateway/quota-cache.ts +9 -4
  81. package/telegram-plugin/gateway/reaction-trigger.ts +401 -0
  82. package/telegram-plugin/gateway/recent-denials.test.ts +103 -0
  83. package/telegram-plugin/gateway/recent-denials.ts +77 -0
  84. package/telegram-plugin/gateway/startup-network-retry.ts +109 -31
  85. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +125 -0
  86. package/telegram-plugin/history.ts +91 -0
  87. package/telegram-plugin/hooks/hooks.json +10 -0
  88. package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +130 -0
  89. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -2
  90. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +22 -2
  91. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  92. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  93. package/telegram-plugin/inbound-classifier.ts +50 -0
  94. package/telegram-plugin/inline-keyboard-callbacks.ts +136 -0
  95. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  96. package/telegram-plugin/package.json +4 -2
  97. package/telegram-plugin/permission-rule.ts +51 -0
  98. package/telegram-plugin/permission-title.ts +56 -0
  99. package/telegram-plugin/quota-check.ts +19 -41
  100. package/telegram-plugin/registry/reaper.ts +223 -0
  101. package/telegram-plugin/retry-api-call.ts +80 -0
  102. package/telegram-plugin/runtime-metrics.ts +177 -0
  103. package/telegram-plugin/scripts/build.mjs +0 -1
  104. package/telegram-plugin/secret-detect/index.ts +24 -0
  105. package/telegram-plugin/secret-detect/vault-error.test.ts +64 -12
  106. package/telegram-plugin/secret-detect/vault-error.ts +78 -11
  107. package/telegram-plugin/secret-detect/vault-write.ts +14 -2
  108. package/telegram-plugin/server.js +41795 -0
  109. package/telegram-plugin/session-tail.ts +6 -1
  110. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  111. package/telegram-plugin/silence-poke.ts +420 -0
  112. package/telegram-plugin/silent-end.ts +174 -0
  113. package/telegram-plugin/stream-controller.ts +13 -0
  114. package/telegram-plugin/stream-reply-handler.ts +7 -0
  115. package/telegram-plugin/subagent-watcher.ts +213 -4
  116. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  117. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  118. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  119. package/telegram-plugin/tests/boot-card-issue-dedup.test.ts +247 -0
  120. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +182 -0
  121. package/telegram-plugin/tests/boot-card-reason.test.ts +65 -2
  122. package/telegram-plugin/tests/boot-card-render.test.ts +146 -0
  123. package/telegram-plugin/tests/boot-card-silent-on-operator.test.ts +103 -0
  124. package/telegram-plugin/tests/boot-probes.test.ts +216 -10
  125. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  126. package/telegram-plugin/tests/finalize-callback.test.ts +190 -0
  127. package/telegram-plugin/tests/gateway-message-validator.test.ts +26 -0
  128. package/telegram-plugin/tests/gateway-secret-detect.test.ts +12 -3
  129. package/telegram-plugin/tests/gateway-startup-network-retry.test.ts +104 -0
  130. package/telegram-plugin/tests/history-reaper.test.ts +378 -0
  131. package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
  132. package/telegram-plugin/tests/inbound-classifier.test.ts +76 -0
  133. package/telegram-plugin/tests/inbound-message-types.test.ts +267 -0
  134. package/telegram-plugin/tests/issues-card.test.ts +49 -0
  135. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +132 -0
  136. package/telegram-plugin/tests/permission-rule.test.ts +80 -1
  137. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  138. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  139. package/telegram-plugin/tests/races.test.ts +179 -0
  140. package/telegram-plugin/tests/reaction-trigger-flow.test.ts +353 -0
  141. package/telegram-plugin/tests/reaction-trigger.test.ts +397 -0
  142. package/telegram-plugin/tests/retry-api-call.test.ts +152 -1
  143. package/telegram-plugin/tests/runtime-metrics.test.ts +145 -0
  144. package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +155 -0
  145. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +133 -0
  146. package/telegram-plugin/tests/secret-detect-false-positives.test.ts +137 -0
  147. package/telegram-plugin/tests/silence-poke.test.ts +493 -0
  148. package/telegram-plugin/tests/silent-end.test.ts +206 -0
  149. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +107 -0
  150. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +224 -0
  151. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +316 -0
  152. package/telegram-plugin/tests/subagent-watcher.test.ts +263 -0
  153. package/telegram-plugin/tests/turn-signal-tracker.test.ts +81 -0
  154. package/telegram-plugin/tests/vault-approval-posture.test.ts +256 -0
  155. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +73 -0
  156. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +226 -0
  157. package/telegram-plugin/tests/vault-grant-union.test.ts +130 -0
  158. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +140 -0
  159. package/telegram-plugin/tests/vault-posture-quarantine.test.ts +104 -0
  160. package/telegram-plugin/tests/vault-request-access-tool.test.ts +114 -0
  161. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +106 -0
  162. package/telegram-plugin/turn-signal-tracker.ts +100 -24
  163. package/telegram-plugin/uat/SETUP.md +210 -35
  164. package/telegram-plugin/uat/assertions.ts +264 -37
  165. package/telegram-plugin/uat/driver-info.ts +57 -0
  166. package/telegram-plugin/uat/driver.ts +590 -51
  167. package/telegram-plugin/uat/harness.ts +140 -94
  168. package/telegram-plugin/uat/load-env.test.ts +72 -0
  169. package/telegram-plugin/uat/load-env.ts +48 -0
  170. package/telegram-plugin/uat/login.ts +96 -53
  171. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  172. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  173. package/telegram-plugin/uat/runners/report.ts +150 -0
  174. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  175. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  176. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  177. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  178. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  179. package/telegram-plugin/uat/scenarios/ask-user-button-tap-dm.test.ts +141 -0
  180. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +191 -0
  181. package/telegram-plugin/uat/scenarios/fuzz-extended-dm.test.ts +255 -0
  182. package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +275 -0
  183. package/telegram-plugin/uat/scenarios/fuzz-random-prompts-dm.test.ts +146 -0
  184. package/telegram-plugin/uat/scenarios/fuzz-status-ask-dm.test.ts +486 -0
  185. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +67 -0
  186. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +100 -0
  187. package/telegram-plugin/uat/scenarios/jtbd-soft-commit-dm.test.ts +67 -0
  188. package/telegram-plugin/uat/scenarios/jtbd-status-query-dm.test.ts +49 -0
  189. package/telegram-plugin/uat/scenarios/location-inbound-dm.test.ts +65 -0
  190. package/telegram-plugin/uat/scenarios/midturn-silent-dm.test.ts +175 -0
  191. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +142 -0
  192. package/telegram-plugin/uat/scenarios/reactions-trigger-turn-dm.test.ts +96 -0
  193. package/telegram-plugin/uat/scenarios/secret-redaction-deletes-original-dm.test.ts +123 -0
  194. package/telegram-plugin/uat/scenarios/secret-redaction-no-false-positive-dm.test.ts +87 -0
  195. package/telegram-plugin/uat/scenarios/silence-poke-soft-dm.test.ts +155 -0
  196. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +95 -0
  197. package/telegram-plugin/uat/scenarios/smoke-dm-reply.test.ts +57 -0
  198. package/telegram-plugin/uat/scenarios/subagent-watcher-no-rerun-dm.test.ts +135 -0
  199. package/telegram-plugin/uat/scenarios/vault-approval-posture-telegram-id-dm.test.ts +191 -0
  200. package/telegram-plugin/uat/scenarios/vault-audit-allow-dm.test.ts +108 -0
  201. package/telegram-plugin/uat/scenarios/vault-grant-auto-resume-dm.test.ts +121 -0
  202. package/telegram-plugin/uat/scenarios/vault-request-access-concurrent-dm.test.ts +161 -0
  203. package/telegram-plugin/uat/scenarios/vault-request-access-end-to-end-dm.test.ts +158 -0
  204. package/telegram-plugin/uat/scenarios/voice-inbound-dm.test.ts +65 -0
  205. package/telegram-plugin/vault-approval-posture.ts +42 -0
  206. package/telegram-plugin/welcome-text.ts +1 -0
  207. package/telegram-plugin/active-pins-sweep.ts +0 -204
  208. package/telegram-plugin/active-pins.ts +0 -146
  209. package/telegram-plugin/auth-dashboard.ts +0 -1104
  210. package/telegram-plugin/auth-slot-parser.ts +0 -497
  211. package/telegram-plugin/card-event-log.ts +0 -138
  212. package/telegram-plugin/dist/foreman/foreman.js +0 -31106
  213. package/telegram-plugin/docs/multi-agent-card-design.md +0 -847
  214. package/telegram-plugin/docs/pinned-progress-card-reliability.md +0 -144
  215. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  216. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  217. package/telegram-plugin/foreman/foreman.ts +0 -1165
  218. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  219. package/telegram-plugin/foreman/setup-state.ts +0 -239
  220. package/telegram-plugin/foreman/state.ts +0 -203
  221. package/telegram-plugin/pin-event-log.ts +0 -76
  222. package/telegram-plugin/progress-card-driver.ts +0 -2886
  223. package/telegram-plugin/progress-card-pin-manager.ts +0 -589
  224. package/telegram-plugin/progress-card-pin-watchdog.ts +0 -98
  225. package/telegram-plugin/progress-card.ts +0 -1409
  226. package/telegram-plugin/tests/HARNESS.md +0 -340
  227. package/telegram-plugin/tests/_progress-card-harness.ts +0 -109
  228. package/telegram-plugin/tests/active-pins-boot-reaper.test.ts +0 -211
  229. package/telegram-plugin/tests/active-pins-sweep.test.ts +0 -309
  230. package/telegram-plugin/tests/active-pins.test.ts +0 -187
  231. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  232. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  233. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  234. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  235. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  236. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  237. package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +0 -201
  238. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  239. package/telegram-plugin/tests/card-event-log.test.ts +0 -145
  240. package/telegram-plugin/tests/first-paint.test.ts +0 -257
  241. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  242. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  243. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  244. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  245. package/telegram-plugin/tests/harness-ordering-invariants.test.ts +0 -243
  246. package/telegram-plugin/tests/pin-event-log.test.ts +0 -124
  247. package/telegram-plugin/tests/progress-card-api-failure-during-deferred.test.ts +0 -73
  248. package/telegram-plugin/tests/progress-card-close-paths-converge.test.ts +0 -272
  249. package/telegram-plugin/tests/progress-card-cross-turn.test.ts +0 -258
  250. package/telegram-plugin/tests/progress-card-delay-842.test.ts +0 -160
  251. package/telegram-plugin/tests/progress-card-dispose-preservepending.test.ts +0 -81
  252. package/telegram-plugin/tests/progress-card-draft-flag.test.ts +0 -80
  253. package/telegram-plugin/tests/progress-card-driver-eviction.test.ts +0 -215
  254. package/telegram-plugin/tests/progress-card-driver-fleet-shadow.test.ts +0 -123
  255. package/telegram-plugin/tests/progress-card-driver-force-complete-parent-done.test.ts +0 -76
  256. package/telegram-plugin/tests/progress-card-edit-timestamps-budget.test.ts +0 -62
  257. package/telegram-plugin/tests/progress-card-memory-bounds.test.ts +0 -84
  258. package/telegram-plugin/tests/progress-card-pin-failure-paths.test.ts +0 -139
  259. package/telegram-plugin/tests/progress-card-pin-manager.test.ts +0 -773
  260. package/telegram-plugin/tests/progress-card-pin-race-fast-turn.test.ts +0 -66
  261. package/telegram-plugin/tests/progress-card-pin-sidecar-partial-write.test.ts +0 -64
  262. package/telegram-plugin/tests/progress-card-pin-watchdog.test.ts +0 -190
  263. package/telegram-plugin/tests/progress-card-sigterm-pin-flush.test.ts +0 -146
  264. package/telegram-plugin/tests/real-gateway-f1-ladder-integrity.test.ts +0 -123
  265. package/telegram-plugin/tests/real-gateway-f2-instant-draft.test.ts +0 -82
  266. package/telegram-plugin/tests/real-gateway-f3-late-card.test.ts +0 -114
  267. package/telegram-plugin/tests/real-gateway-harness.ts +0 -699
  268. package/telegram-plugin/tests/real-gateway-i6-turn-flush-replay-dedup.test.ts +0 -313
  269. package/telegram-plugin/tests/real-gateway-ipc-lifecycle.test.ts +0 -299
  270. package/telegram-plugin/tests/real-gateway-spec.test.ts +0 -487
  271. package/telegram-plugin/tests/real-gateway.smoke.test.ts +0 -101
  272. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  273. package/telegram-plugin/tests/setup-state.test.ts +0 -146
  274. package/telegram-plugin/tests/sync-chat-running-subagents.test.ts +0 -116
  275. package/telegram-plugin/tests/turn-end-regressions.test.ts +0 -489
  276. package/telegram-plugin/tests/turn-flush-card-takeover.test.ts +0 -218
  277. package/telegram-plugin/tests/turn-flush-prose-recovery.test.ts +0 -78
  278. package/telegram-plugin/tests/two-zone-bg-carry-full-lifecycle.test.ts +0 -131
  279. package/telegram-plugin/tests/two-zone-bg-detection.test.ts +0 -120
  280. package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +0 -116
  281. package/telegram-plugin/tests/two-zone-bg-early-turn-end.test.ts +0 -87
  282. package/telegram-plugin/tests/two-zone-bg-survives-next-turn.test.ts +0 -211
  283. package/telegram-plugin/tests/two-zone-card-cap.test.ts +0 -62
  284. package/telegram-plugin/tests/two-zone-card-fleet-row.test.ts +0 -101
  285. package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +0 -78
  286. package/telegram-plugin/tests/two-zone-card-html-balance.test.ts +0 -110
  287. package/telegram-plugin/tests/two-zone-card-lifecycle.test.ts +0 -128
  288. package/telegram-plugin/tests/two-zone-card-sanitise.test.ts +0 -58
  289. package/telegram-plugin/tests/two-zone-card-snapshot.test.ts +0 -133
  290. package/telegram-plugin/tests/two-zone-concurrent-turns-isolation.test.ts +0 -155
  291. package/telegram-plugin/tests/two-zone-phasefor-precedence.test.ts +0 -117
  292. package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +0 -187
  293. package/telegram-plugin/tests/two-zone-stuck-edit-throttle.test.ts +0 -149
  294. package/telegram-plugin/tests/two-zone-stuck-header-escalation.test.ts +0 -101
  295. package/telegram-plugin/tests/two-zone-stuck-per-member.test.ts +0 -114
  296. package/telegram-plugin/tests/two-zone-stuck-recovery.test.ts +0 -105
  297. package/telegram-plugin/tests/waiting-ux-harness.ts +0 -381
  298. package/telegram-plugin/tests/waiting-ux.e2e.test.ts +0 -233
  299. package/telegram-plugin/turn-flush-prose-recovery.ts +0 -40
  300. package/telegram-plugin/two-zone-card.ts +0 -269
  301. package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +0 -61
@@ -17,6 +17,45 @@ import { basename } from "node:path";
17
17
  const COMMAND_TITLE_MAX = 40;
18
18
  const PATH_TITLE_MAX = 40;
19
19
 
20
+ /**
21
+ * Human-friendly descriptions for switchroom-managed MCP tools. The
22
+ * raw `mcp__<server>__<tool>` name is operator-unfriendly — they shouldn't
23
+ * have to decode the namespace to understand what the agent is asking
24
+ * to do. Use this map to turn the code-level identifier into a verb
25
+ * phrase ("Read its own merged config" instead of
26
+ * "mcp__agent-config__config_get") for the approval card.
27
+ *
28
+ * Note: post-#1215 these tools are pre-allowed in scaffolded
29
+ * settings.permissions.allow, so the card should fire rarely.
30
+ * This map is for the fallback path — agents the operator
31
+ * narrowed the allowlist on, or tools added in future PRs that
32
+ * haven't shipped the allowlist bump yet.
33
+ */
34
+ const MCP_TOOL_DESCRIPTIONS: Record<string, string> = {
35
+ // agent-config — every agent's self-service surface (#1163, #1215)
36
+ "mcp__agent-config__config_get": "Read its own merged config",
37
+ "mcp__agent-config__cron_list": "List its own scheduled tasks",
38
+ "mcp__agent-config__skill_list": "List its own installed skills",
39
+ "mcp__agent-config__audit_tail": "Read its own recent tool-call audit log",
40
+ "mcp__agent-config__peers_list": "List the other agents on this instance",
41
+ "mcp__agent-config__schedule_add": "Add a scheduled task to its own cron",
42
+ "mcp__agent-config__schedule_remove": "Remove one of its own scheduled tasks",
43
+ "mcp__agent-config__skill_install": "Install a bundled skill onto itself",
44
+ "mcp__agent-config__skill_remove": "Remove one of its own installed skills",
45
+ // hostd — admin-flagged agents' fleet-management surface (#1175, #1215)
46
+ "mcp__hostd__agent_restart": "Restart an agent in the fleet",
47
+ "mcp__hostd__agent_start": "Start a stopped agent in the fleet",
48
+ "mcp__hostd__agent_stop": "Stop a running agent in the fleet",
49
+ "mcp__hostd__agent_logs": "Read another agent's container logs",
50
+ "mcp__hostd__agent_exec": "Run a read-only inspection inside another agent",
51
+ "mcp__hostd__update_check": "Check what a fleet-wide update would do",
52
+ "mcp__hostd__update_apply": "Apply a fleet-wide update (pull + recreate)",
53
+ // hindsight — memory
54
+ "mcp__hindsight__recall": "Recall relevant memories",
55
+ "mcp__hindsight__retain": "Retain a memory",
56
+ "mcp__hindsight__reflect": "Reflect across its memory bank",
57
+ };
58
+
20
59
  /**
21
60
  * Build a title fragment for a permission prompt. Returns the toolName
22
61
  * for any tool we don't recognise — the helper is intentionally
@@ -27,6 +66,23 @@ export function summarizeToolForTitle(
27
66
  toolName: string,
28
67
  inputPreview: string | undefined,
29
68
  ): string {
69
+ // MCP tools: `mcp__<server>__<verb>`. Prefer a curated human
70
+ // description (so the card reads "Read its own merged config"
71
+ // instead of "mcp__agent-config__config_get"). Fall through to a
72
+ // generic `<server>: <verb-with-spaces>` shape for unknown MCP
73
+ // tools and finally to the raw name when even that fails.
74
+ if (toolName.startsWith("mcp__")) {
75
+ const curated = MCP_TOOL_DESCRIPTIONS[toolName];
76
+ if (curated) return curated;
77
+ const parts = toolName.split("__");
78
+ if (parts.length >= 3) {
79
+ const server = parts[1]!;
80
+ const verb = parts.slice(2).join("__").replace(/_/g, " ");
81
+ return `${server}: ${verb}`;
82
+ }
83
+ return toolName;
84
+ }
85
+
30
86
  const input = parseInput(inputPreview);
31
87
  if (!input) return toolName;
32
88
 
@@ -17,11 +17,13 @@
17
17
 
18
18
  import { readFileSync, existsSync } from "fs";
19
19
  import { join } from "path";
20
- import {
21
- readAccountQuota,
22
- snapshotFromQuotaUtilization,
23
- writeAccountQuota,
24
- } from "../src/auth/account-quota-store.js";
20
+
21
+ // RFC H: per-account quota state moved to switchroom-auth-broker
22
+ // (state/auth-broker/quota.json). The gateway's in-process cache
23
+ // below is still useful for sub-second formatting, but the disk-
24
+ // persistence layer that account-quota-store provided is gone —
25
+ // the broker owns the canonical store and exposes it via
26
+ // `list-state`. Disk hydrate / disk persist below are no-ops.
25
27
 
26
28
  /**
27
29
  * OAuth beta flag — proves the request is coming from a subscription client.
@@ -350,20 +352,10 @@ export async function fetchAccountQuota(
350
352
  timeoutMs: opts.timeoutMs,
351
353
  });
352
354
  accountQuotaCache.set(label, { fetchedAt: now, result });
353
- // Persist the snapshot to disk so a future gateway restart can
354
- // re-hydrate its in-process cache without an API call. Best-effort
355
- // (write errors swallowed inside writeAccountQuota). Issue #708.
356
- if (result.ok) {
357
- try {
358
- writeAccountQuota(
359
- label,
360
- snapshotFromQuotaUtilization(result.data, new Date(now)),
361
- opts.home,
362
- );
363
- } catch {
364
- /* best-effort */
365
- }
366
- }
355
+ // Note: pre-RFC-H this also persisted to disk via writeAccountQuota
356
+ // (#708) so a gateway restart could re-hydrate without an API call.
357
+ // Post-RFC-H the broker holds canonical quota state and answers
358
+ // via `list-state`, so the gateway's in-process cache is enough.
367
359
  return result;
368
360
  }
369
361
 
@@ -381,29 +373,15 @@ export async function fetchAccountQuota(
381
373
  * prefetch will replace it on the next tap.
382
374
  */
383
375
  export function hydrateAccountQuotaCacheFromDisk(
384
- labels: ReadonlyArray<string>,
385
- home?: string,
376
+ _labels: ReadonlyArray<string>,
377
+ _home?: string,
386
378
  ): void {
387
- for (const label of labels) {
388
- if (accountQuotaCache.has(label)) continue;
389
- const snap = readAccountQuota(label, home);
390
- if (!snap) continue;
391
- const fetchedAt = Date.parse(snap.capturedAt);
392
- if (!Number.isFinite(fetchedAt)) continue;
393
- const result: QuotaResult = {
394
- ok: true,
395
- data: {
396
- fiveHourUtilizationPct: snap.fiveHourPct ?? 0,
397
- sevenDayUtilizationPct: snap.sevenDayPct ?? 0,
398
- fiveHourResetAt: snap.fiveHourResetAt ? new Date(snap.fiveHourResetAt) : null,
399
- sevenDayResetAt: snap.sevenDayResetAt ? new Date(snap.sevenDayResetAt) : null,
400
- representativeClaim: null,
401
- overageStatus: null,
402
- overageDisabledReason: null,
403
- },
404
- };
405
- accountQuotaCache.set(label, { fetchedAt, result });
406
- }
379
+ // No-op post-RFC-H. The disk-snapshot store this function used to
380
+ // re-hydrate from (per-account quota.json files under
381
+ // ~/.switchroom/accounts/<label>/) is gone — switchroom-auth-broker
382
+ // now owns canonical quota state. Boot-time hydration is the
383
+ // broker's `list-state` call instead. Signature preserved so
384
+ // existing call sites continue to compile while we phase them out.
407
385
  }
408
386
 
409
387
  /** Test/utility helper — wipe the per-account quota cache. The
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Registry reaper — prunes long-lived `subagents` and `turns` rows.
3
+ *
4
+ * Issue #1073. The init-time prune in `history.ts` only sweeps the `messages`
5
+ * table. `subagents` and `turns` in `registry.db` grew unbounded — a
6
+ * long-running agent accumulates a row per `Agent()` call and per turn
7
+ * forever. The SQLite WAL also grew without bound because no path issued
8
+ * a checkpoint.
9
+ *
10
+ * This module adds:
11
+ *
12
+ * - `pruneSubagentsOlderThan(db, cutoffMs, batchLimit)` — batch DELETE on
13
+ * `subagents` where `COALESCE(ended_at, last_activity_at, started_at)`
14
+ * is older than the cutoff. Batched so a huge backlog can't lock the
15
+ * DB for minutes; stops when a batch deletes 0 rows.
16
+ * - `pruneTurnsOlderThan(db, cutoffMs, batchLimit)` — same shape for
17
+ * `turns`, using `COALESCE(ended_at, started_at)`.
18
+ * - `runRegistryReaper(db, opts)` — one-shot orchestrator that runs both
19
+ * prunes, issues `PRAGMA wal_checkpoint(TRUNCATE)`, and returns counts.
20
+ *
21
+ * Timestamp model
22
+ * `subagents.started_at` / `last_activity_at` / `ended_at` and
23
+ * `turns.started_at` / `ended_at` are all unix MILLISECONDS (see
24
+ * subagents-schema.ts and turns-schema.ts), distinct from
25
+ * `messages.ts` which is unix SECONDS. Callers pass `cutoffMs`
26
+ * directly — no conversion is done here.
27
+ *
28
+ * Retention selection
29
+ * Default retention window is 14 days. Resolved by the gateway from:
30
+ * 1. `process.env.HISTORY_RETENTION_DAYS` (integer days)
31
+ * 2. `access.json:historyRetentionDays` (legacy: shared with the
32
+ * messages-table init prune)
33
+ * 3. fallback constant `DEFAULT_RETENTION_DAYS = 14`
34
+ *
35
+ * Concurrency
36
+ * bun:sqlite holds the DB connection in WAL mode. Reader/writer
37
+ * concurrency is fine. The batch DELETE statements use short
38
+ * transactions so the gateway's record paths (recordSubagentStart,
39
+ * bumpSubagentActivity, recordTurnStart, recordTurnEnd) never block
40
+ * for more than a single batch's worth of work.
41
+ *
42
+ * Hard rules
43
+ * - Never touch the `messages` table from here. That table has its
44
+ * own (separately-configured) retention policy.
45
+ * - Bound the loop: every prune call must have a max-iteration safety
46
+ * so a runaway clock or schema-corruption bug can't spin forever.
47
+ */
48
+
49
+ type SqliteDatabase = {
50
+ exec(sql: string): void
51
+ prepare(sql: string): {
52
+ run(...params: unknown[]): unknown
53
+ all(...params: unknown[]): unknown[]
54
+ get(...params: unknown[]): unknown
55
+ }
56
+ transaction(fn: (...args: unknown[]) => unknown): (...args: unknown[]) => unknown
57
+ close(): void
58
+ }
59
+
60
+ /** Default retention window for subagents + turns. */
61
+ export const DEFAULT_RETENTION_DAYS = 14
62
+
63
+ /** Default batch size — empirically a good ceiling for SQLite write
64
+ * transactions on a busy WAL. Tuneable by callers via `batchLimit`. */
65
+ export const DEFAULT_BATCH_LIMIT = 5000
66
+
67
+ /** Defence-in-depth ceiling on the batch-delete loop. At
68
+ * DEFAULT_BATCH_LIMIT this caps a single prune call at 5 million rows,
69
+ * far more than any healthy agent registry will ever hold. */
70
+ const MAX_BATCH_ITERATIONS = 1000
71
+
72
+ export interface PruneResult {
73
+ /** Total rows deleted across all batches. */
74
+ deleted: number
75
+ /** Number of batch iterations executed. */
76
+ batches: number
77
+ }
78
+
79
+ /**
80
+ * Delete `subagents` rows whose latest-known activity is older than
81
+ * `cutoffMs`. Activity is `COALESCE(ended_at, last_activity_at,
82
+ * started_at)` — a row gets the most generous timestamp available,
83
+ * so a still-running row that simply hasn't pinged liveness in 14d
84
+ * is NOT pruned if its `last_activity_at` is recent.
85
+ *
86
+ * Batched: deletes up to `batchLimit` rows per iteration, looping until
87
+ * a batch returns 0. Bounded by MAX_BATCH_ITERATIONS.
88
+ */
89
+ export function pruneSubagentsOlderThan(
90
+ db: SqliteDatabase,
91
+ cutoffMs: number,
92
+ batchLimit: number = DEFAULT_BATCH_LIMIT,
93
+ ): PruneResult {
94
+ // SQLite's DELETE ... LIMIT requires the SQLITE_ENABLE_UPDATE_DELETE_LIMIT
95
+ // compile flag. bun:sqlite ships with it OFF, so a literal LIMIT clause
96
+ // on DELETE fails parsing. Wrap with a sub-SELECT on rowid — that works
97
+ // on every SQLite build and behaves identically. The same pattern is
98
+ // used by pruneTurnsOlderThan below.
99
+ const stmt = db.prepare(`
100
+ DELETE FROM subagents
101
+ WHERE rowid IN (
102
+ SELECT rowid FROM subagents
103
+ WHERE COALESCE(ended_at, last_activity_at, started_at) < ?
104
+ LIMIT ?
105
+ )
106
+ `)
107
+ let total = 0
108
+ let batches = 0
109
+ for (let i = 0; i < MAX_BATCH_ITERATIONS; i++) {
110
+ const result = stmt.run(cutoffMs, batchLimit) as { changes: number }
111
+ batches += 1
112
+ const n = result.changes ?? 0
113
+ total += n
114
+ if (n === 0) break
115
+ }
116
+ return { deleted: total, batches }
117
+ }
118
+
119
+ /**
120
+ * Delete `turns` rows whose latest-known activity is older than `cutoffMs`.
121
+ * Activity is `COALESCE(ended_at, started_at)` — an open turn (ended_at
122
+ * NULL) is preserved if its `started_at` is recent. Batched like
123
+ * pruneSubagentsOlderThan.
124
+ */
125
+ export function pruneTurnsOlderThan(
126
+ db: SqliteDatabase,
127
+ cutoffMs: number,
128
+ batchLimit: number = DEFAULT_BATCH_LIMIT,
129
+ ): PruneResult {
130
+ const stmt = db.prepare(`
131
+ DELETE FROM turns
132
+ WHERE rowid IN (
133
+ SELECT rowid FROM turns
134
+ WHERE COALESCE(ended_at, started_at) < ?
135
+ LIMIT ?
136
+ )
137
+ `)
138
+ let total = 0
139
+ let batches = 0
140
+ for (let i = 0; i < MAX_BATCH_ITERATIONS; i++) {
141
+ const result = stmt.run(cutoffMs, batchLimit) as { changes: number }
142
+ batches += 1
143
+ const n = result.changes ?? 0
144
+ total += n
145
+ if (n === 0) break
146
+ }
147
+ return { deleted: total, batches }
148
+ }
149
+
150
+ export interface RegistryReaperResult {
151
+ subagents: PruneResult
152
+ turns: PruneResult
153
+ /** True if the WAL checkpoint ran without throwing. The result of the
154
+ * pragma is logged but not propagated (it can legitimately report
155
+ * "busy" under reader pressure — not a failure). */
156
+ walCheckpointed: boolean
157
+ }
158
+
159
+ export interface RegistryReaperOpts {
160
+ /** Retention window in days. */
161
+ retentionDays?: number
162
+ /** Override "now" for tests. Defaults to Date.now(). */
163
+ now?: number
164
+ /** Override batch size (mostly for tests). */
165
+ batchLimit?: number
166
+ }
167
+
168
+ /**
169
+ * Run the full registry reaper: prune subagents, prune turns, checkpoint
170
+ * the WAL. Caller logs the result.
171
+ */
172
+ export function runRegistryReaper(
173
+ db: SqliteDatabase,
174
+ opts: RegistryReaperOpts = {},
175
+ ): RegistryReaperResult {
176
+ const retentionDays = opts.retentionDays ?? DEFAULT_RETENTION_DAYS
177
+ const now = opts.now ?? Date.now()
178
+ const batchLimit = opts.batchLimit ?? DEFAULT_BATCH_LIMIT
179
+ const cutoffMs = now - retentionDays * 86_400_000
180
+
181
+ const subagents = pruneSubagentsOlderThan(db, cutoffMs, batchLimit)
182
+ const turns = pruneTurnsOlderThan(db, cutoffMs, batchLimit)
183
+
184
+ // WAL checkpoint releases the .db-wal file's pages back to the main DB
185
+ // and truncates the WAL to zero bytes. TRUNCATE mode does both;
186
+ // PASSIVE/FULL leave WAL pages behind. Wrap in try/catch — the
187
+ // checkpoint can return SQLITE_BUSY under concurrent reads, which
188
+ // bun:sqlite surfaces as a thrown error. That's a transient,
189
+ // non-fatal condition: the next reaper tick will retry.
190
+ let walCheckpointed = false
191
+ try {
192
+ db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').run()
193
+ walCheckpointed = true
194
+ } catch {
195
+ walCheckpointed = false
196
+ }
197
+
198
+ return { subagents, turns, walCheckpointed }
199
+ }
200
+
201
+ /**
202
+ * Resolve the retention window in days from environment + access-file
203
+ * sources. Order: env `HISTORY_RETENTION_DAYS` → `accessRetentionDays`
204
+ * (caller passes whatever's in access.json) → `DEFAULT_RETENTION_DAYS`.
205
+ *
206
+ * Returns a clamped positive integer. Invalid env values (non-numeric,
207
+ * <= 0, NaN) fall through to the access value, then to the default.
208
+ */
209
+ export function resolveRetentionDays(accessRetentionDays?: number): number {
210
+ const envRaw = process.env.HISTORY_RETENTION_DAYS
211
+ if (envRaw != null && envRaw !== '') {
212
+ const n = Number.parseInt(envRaw, 10)
213
+ if (Number.isFinite(n) && n > 0) return n
214
+ }
215
+ if (
216
+ typeof accessRetentionDays === 'number'
217
+ && Number.isFinite(accessRetentionDays)
218
+ && accessRetentionDays > 0
219
+ ) {
220
+ return accessRetentionDays
221
+ }
222
+ return DEFAULT_RETENTION_DAYS
223
+ }
@@ -170,3 +170,83 @@ export function createRetryApiCall(
170
170
  throw giveUpErr
171
171
  }
172
172
  }
173
+
174
+ /**
175
+ * Compose a swallowing wrapper around a `retryApiCall` instance.
176
+ *
177
+ * Use this for **fire-and-forget** Telegram API callsites — boot/issues/
178
+ * subagent cards, "agent restarting" notices, reactions on stale targets,
179
+ * anything where the caller previously had `.catch(() => {})`. The wrapper
180
+ * resolves to `undefined` on the cases retryApiCall throws (THREAD_NOT_FOUND,
181
+ * give-up after network retries, GrammyError 403/400-not-chat, …) and logs
182
+ * a one-line note to `log` so the failure is at least visible in stderr.
183
+ *
184
+ * Why not just `.catch(() => {})` at the callsite? Two reasons:
185
+ *
186
+ * 1. We want THREAD_NOT_FOUND specifically to NOT crash but to be
187
+ * *visible* — `.catch(() => {})` silently swallows everything, which
188
+ * hid #1075 for months. The log here surfaces it.
189
+ * 2. Callers shouldn't have to remember to wrap each raw `bot.api.*`
190
+ * with the retry policy AND the swallow — this is one function.
191
+ *
192
+ * For callsites that legitimately need to inspect failure (e.g. drop
193
+ * thread_id and retry on main chat), use `retryApiCall` directly and
194
+ * handle `THREAD_NOT_FOUND` explicitly — see `gateway.ts:2806` for the
195
+ * canonical pattern (the reply chunk loop).
196
+ */
197
+ export function createSwallowingRetryApiCall(
198
+ retry: <T>(fn: () => Promise<T>, opts?: RetryCallOpts) => Promise<T>,
199
+ log?: (line: string) => void,
200
+ ): <T>(fn: () => Promise<T>, opts?: RetryCallOpts) => Promise<T | undefined> {
201
+ return async function swallow<T>(
202
+ fn: () => Promise<T>,
203
+ opts?: RetryCallOpts,
204
+ ): Promise<T | undefined> {
205
+ try {
206
+ return await retry(fn, opts)
207
+ } catch (err) {
208
+ const msg = err instanceof Error ? err.message : String(err)
209
+ const verb = opts?.verb ?? 'api-call'
210
+ log?.(`telegram gateway: ${verb} swallowed: ${msg}\n`)
211
+ return undefined
212
+ }
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Helper for callsites that pass `message_thread_id` and want to fall
218
+ * back to the main chat when the thread is deleted.
219
+ *
220
+ * The caller provides a `send` closure that takes `threadId?: number` and
221
+ * builds its own request. On THREAD_NOT_FOUND, `send(undefined)` is invoked
222
+ * once more — the wrapper drops the thread id and re-tries; everything
223
+ * else falls through to the underlying retry policy.
224
+ *
225
+ * Returns the final API response (typed as `T` — fallback resolved, or
226
+ * threadId-bearing call resolved). On non-thread errors, propagates as
227
+ * `retry` does.
228
+ */
229
+ export async function retryWithThreadFallback<T>(
230
+ retry: <U>(fn: () => Promise<U>, opts?: RetryCallOpts) => Promise<U>,
231
+ send: (threadId: number | undefined) => Promise<T>,
232
+ opts: { threadId: number | undefined; chat_id: string; verb?: string },
233
+ ): Promise<T> {
234
+ try {
235
+ return await retry(() => send(opts.threadId), {
236
+ ...(opts.threadId != null ? { threadId: opts.threadId } : {}),
237
+ chat_id: opts.chat_id,
238
+ ...(opts.verb != null ? { verb: opts.verb } : {}),
239
+ })
240
+ } catch (err) {
241
+ if (err instanceof Error && err.message === 'THREAD_NOT_FOUND') {
242
+ // Drop the thread id and retry once on the main chat. Don't pass
243
+ // threadId in opts so a *second* thread-not-found (shouldn't
244
+ // happen) just propagates as a normal error.
245
+ return await retry(() => send(undefined), {
246
+ chat_id: opts.chat_id,
247
+ ...(opts.verb != null ? { verb: opts.verb } : {}),
248
+ })
249
+ }
250
+ throw err
251
+ }
252
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * runtime-metrics.ts — high-value gateway events fanned out to PostHog
3
+ * AND a local JSONL file.
4
+ *
5
+ * Why both sinks:
6
+ * - PostHog gets the events for dashboards, funnels, error correlation,
7
+ * fleet-wide KPI tracking. This is the source of truth for the
8
+ * conversational-turn-UX redesign KPIs (see docs/posthog.md).
9
+ * - JSONL is preserved as a per-agent debug breadcrumb so the agent's
10
+ * own context (or an operator on the host) can read what happened
11
+ * without round-tripping to PostHog. Same file the silence-poke
12
+ * subsystem (next PR) will append to.
13
+ *
14
+ * Distinct from `streaming-metrics.ts` — that module is the noisy
15
+ * gated-by-env stderr stream used for one-off streaming-perf analysis.
16
+ * Runtime metrics are always-on, narrow, and KPI-shaped.
17
+ */
18
+
19
+ import { existsSync, mkdirSync, appendFileSync } from 'node:fs'
20
+ import { dirname, join } from 'node:path'
21
+ import { captureEvent } from './analytics-posthog.js'
22
+
23
+ export type RuntimeMetricEvent =
24
+ /**
25
+ * A user-sent message that matches a status-query pattern
26
+ * ("status?", "still there?", etc). Primary lagging KPI for the
27
+ * conversational turn UX — every fire is a JTBD failure.
28
+ */
29
+ | {
30
+ kind: 'inbound_status_query'
31
+ chat_id: string
32
+ message_id: number | null
33
+ thread_id: number | null
34
+ text_length: number
35
+ prior_turn_in_flight: boolean
36
+ seconds_since_turn_start: number | null
37
+ }
38
+ /**
39
+ * A fresh turn began (user message arrived, ack reaction fired).
40
+ * Pairs with `turn_ended` for duration / TTFO computation.
41
+ */
42
+ | {
43
+ kind: 'turn_started'
44
+ chat_id: string
45
+ message_id: number | null
46
+ thread_id: number | null
47
+ inbound_classified_as_status_query: boolean
48
+ }
49
+ /**
50
+ * A turn completed (terminal reply or silent close). Carries the
51
+ * gap distribution + TTFO so the dashboard can compute outbound
52
+ * silence p95 without per-event reconstruction.
53
+ */
54
+ | {
55
+ kind: 'turn_ended'
56
+ chat_id: string
57
+ thread_id: number | null
58
+ duration_ms: number
59
+ ttfo_ms: number | null
60
+ outbound_count: number
61
+ longest_silent_gap_ms: number
62
+ ended_via: 'reply' | 'stream_reply_done' | 'silent' | 'forced' | 'framework_fallback'
63
+ }
64
+ /**
65
+ * Framework safety-net: a silence-poke was armed at 75s (soft) or
66
+ * 180s (firm). The system-reminder appended to the next tool result
67
+ * nudges the model to send an update. Doubles as a design-health
68
+ * signal — if these fire frequently, the conversational-pacing
69
+ * prompt isn't doing its job.
70
+ */
71
+ | {
72
+ kind: 'silence_poke_fired'
73
+ key: string
74
+ level: 'soft' | 'firm'
75
+ silence_ms: number
76
+ subagent_wait: boolean
77
+ }
78
+ /**
79
+ * The model sent an outbound message within the success window
80
+ * (default 15s) after a poke fired. Pair with `silence_poke_fired`
81
+ * to compute success rate — the design target is >80%.
82
+ */
83
+ | {
84
+ kind: 'silence_poke_succeeded'
85
+ key: string
86
+ level: 'soft' | 'firm'
87
+ latency_ms: number
88
+ }
89
+ /**
90
+ * Last-resort: 5 minutes silent, the framework itself sent a
91
+ * user-visible "still working… / still thinking…" message. Should
92
+ * be rare (target <5 per 1000 turns); a high rate means the model
93
+ * is genuinely stuck or the soft/firm pokes aren't being honoured.
94
+ */
95
+ | {
96
+ kind: 'silence_fallback_sent'
97
+ key: string
98
+ fallback_kind: 'working' | 'thinking'
99
+ silence_ms: number
100
+ }
101
+
102
+ /**
103
+ * The JSONL sink lives under the runtime state dir so it's per-agent
104
+ * and survives container restarts (the dir is bind-mounted from the
105
+ * host). Path can be overridden for tests via SWITCHROOM_RUNTIME_METRICS_PATH.
106
+ */
107
+ function resolveJsonlPath(): string {
108
+ const override = process.env.SWITCHROOM_RUNTIME_METRICS_PATH
109
+ if (override && override.trim() !== '') return override.trim()
110
+ const base = process.env.SWITCHROOM_RUNTIME_STATE_DIR ?? '/state/agent'
111
+ return join(base, 'runtime-metrics.jsonl')
112
+ }
113
+
114
+ function appendJsonl(line: string): void {
115
+ const path = resolveJsonlPath()
116
+ try {
117
+ mkdirSync(dirname(path), { recursive: true })
118
+ appendFileSync(path, line + '\n', 'utf-8')
119
+ } catch (err) {
120
+ // JSONL is a local debug aid; failing to write must not break
121
+ // the gateway. Surface to stderr so it's at least visible in
122
+ // the plugin log.
123
+ process.stderr.write(`runtime-metrics: jsonl write failed: ${(err as Error).message}\n`)
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Whether to write JSONL at all. Defaults to ON (the user asked for it
129
+ * to stay as a local debugging side-channel). Operator can opt-out with
130
+ * SWITCHROOM_RUNTIME_METRICS_JSONL_DISABLED=1 if disk pressure is a
131
+ * concern.
132
+ */
133
+ function jsonlEnabled(): boolean {
134
+ const v = process.env.SWITCHROOM_RUNTIME_METRICS_JSONL_DISABLED
135
+ return !(v === '1' || v === 'true')
136
+ }
137
+
138
+ /**
139
+ * Emit one runtime metric event. Fans out to:
140
+ * 1. JSONL file (unless disabled)
141
+ * 2. PostHog (unless SWITCHROOM_TELEMETRY_DISABLED=1)
142
+ *
143
+ * Never throws. Each sink fails independently — a broken sink does not
144
+ * block the other.
145
+ */
146
+ export function emitRuntimeMetric(event: RuntimeMetricEvent): void {
147
+ const wrapped = { ts: Date.now(), ...event }
148
+ if (jsonlEnabled()) {
149
+ try {
150
+ appendJsonl(JSON.stringify(wrapped))
151
+ } catch {
152
+ // already guarded inside appendJsonl
153
+ }
154
+ }
155
+ // captureEvent is async + internally guarded; void-fire to avoid blocking
156
+ // the caller. PostHog batches, so this is cheap.
157
+ void captureEvent(event.kind, { ...event, ts: wrapped.ts })
158
+ }
159
+
160
+ /** Exposed for tests — pin the JSONL path to a temp file. */
161
+ export function __setRuntimeMetricsPathForTests(path: string | null): void {
162
+ if (path == null) {
163
+ delete process.env.SWITCHROOM_RUNTIME_METRICS_PATH
164
+ } else {
165
+ process.env.SWITCHROOM_RUNTIME_METRICS_PATH = path
166
+ }
167
+ }
168
+
169
+ /** Exposed for tests — read back the current resolved path. */
170
+ export function __getRuntimeMetricsPathForTests(): string {
171
+ return resolveJsonlPath()
172
+ }
173
+
174
+ /** Exposed for tests — JSONL gate helper. */
175
+ export function __isJsonlEnabledForTests(): boolean {
176
+ return jsonlEnabled()
177
+ }
@@ -24,7 +24,6 @@ const entries = [
24
24
  { src: "server.ts", out: "server.js", label: "server (legacy + dual-mode shim)" },
25
25
  { src: "gateway/gateway.ts", out: "gateway/gateway.js", label: "gateway (persistent service)" },
26
26
  { src: "bridge/bridge.ts", out: "bridge/bridge.js", label: "bridge (MCP proxy)" },
27
- { src: "foreman/foreman.ts", out: "foreman/foreman.js", label: "foreman (admin bot)" },
28
27
  ];
29
28
 
30
29
  for (const { src, out, label } of entries) {
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { ALL_PATTERNS } from './patterns.js'
27
27
  import { scanKeyValue, type RawHit } from './kv-scanner.js'
28
+ import { shannonEntropy } from './entropy.js'
28
29
  import { chunk } from './chunker.js'
29
30
  import { isSuppressed } from './suppressor.js'
30
31
  import { deriveSlug } from './slug.js'
@@ -79,6 +80,29 @@ export function detectSecrets(text: string): Detection[] {
79
80
  const globalEnd = globalStart + cap.length
80
81
  // For env_key_value (captureIndex=3), the LHS is group 1.
81
82
  const keyName = p.rule_id === 'env_key_value' ? m[1] : undefined
83
+ // 2026-05-12: shape gate on env_key_value — the pattern matches
84
+ // any value after an ALLCAPS *_KEY/_TOKEN/_SECRET/_PASSWORD
85
+ // identifier, which previously fired on casual chat like
86
+ // "MY_TOKEN=hello" or "OPENAI_API_KEY=sk-yourkey" (placeholder
87
+ // values, code-shaped human language). Operator UAT reproduced
88
+ // this on 2026-05-12 — the redaction pipeline was deleting the
89
+ // operator's *question* and staging a card asking them to save
90
+ // the literal word "hello" as a vault entry.
91
+ //
92
+ // Mirror the kv_entropy gate from kv-scanner.ts: require
93
+ // BOTH a length floor (cuts short placeholders) AND a Shannon
94
+ // entropy floor (cuts low-randomness words like "hello",
95
+ // "yourkey", "foo"). Threshold is slightly looser than
96
+ // kv_entropy's 4.0 because the LHS structure already gives us
97
+ // higher confidence that this IS an env declaration.
98
+ // See tests/secret-detect-false-positives.test.ts for the
99
+ // pinned cases.
100
+ if (p.rule_id === 'env_key_value') {
101
+ const ENV_KV_MIN_LEN = 12
102
+ const ENV_KV_MIN_ENTROPY = 3.5
103
+ if (cap.length < ENV_KV_MIN_LEN) continue
104
+ if (shannonEntropy(cap) < ENV_KV_MIN_ENTROPY) continue
105
+ }
82
106
  raw.push({
83
107
  rule_id: p.rule_id,
84
108
  start: globalStart,