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
@@ -0,0 +1,493 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import {
3
+ startTurn,
4
+ noteOutbound,
5
+ noteSubagentDispatch,
6
+ noteThinking,
7
+ consumeArmedPoke,
8
+ endTurn,
9
+ silencePokeEnabled,
10
+ formatPokeText,
11
+ formatFrameworkFallbackText,
12
+ __tickForTests,
13
+ __setDepsForTests,
14
+ __getStateForTests,
15
+ __resetAllForTests,
16
+ DEFAULT_THRESHOLDS,
17
+ type SilencePokeMetric,
18
+ type FrameworkFallbackContext,
19
+ } from '../silence-poke.js'
20
+
21
+ const ORIGINAL_KILL_SWITCH = process.env.SWITCHROOM_DISABLE_SILENCE_POKE
22
+
23
+ interface TestFixtures {
24
+ emitted: SilencePokeMetric[]
25
+ fallbacks: FrameworkFallbackContext[]
26
+ }
27
+
28
+ function setupDeps(opts?: { thresholds?: Partial<typeof DEFAULT_THRESHOLDS> }): TestFixtures {
29
+ const fixtures: TestFixtures = { emitted: [], fallbacks: [] }
30
+ __setDepsForTests({
31
+ emitMetric: (e) => fixtures.emitted.push(e),
32
+ onFrameworkFallback: (ctx) => { fixtures.fallbacks.push(ctx) },
33
+ thresholdsMs: { ...DEFAULT_THRESHOLDS, ...(opts?.thresholds ?? {}) },
34
+ })
35
+ return fixtures
36
+ }
37
+
38
+ beforeEach(() => {
39
+ __resetAllForTests()
40
+ delete process.env.SWITCHROOM_DISABLE_SILENCE_POKE
41
+ })
42
+
43
+ afterEach(() => {
44
+ __resetAllForTests()
45
+ if (ORIGINAL_KILL_SWITCH != null) process.env.SWITCHROOM_DISABLE_SILENCE_POKE = ORIGINAL_KILL_SWITCH
46
+ else delete process.env.SWITCHROOM_DISABLE_SILENCE_POKE
47
+ })
48
+
49
+ describe('silence-poke — kill switch', () => {
50
+ it('startTurn is a no-op when SWITCHROOM_DISABLE_SILENCE_POKE=1', () => {
51
+ process.env.SWITCHROOM_DISABLE_SILENCE_POKE = '1'
52
+ expect(silencePokeEnabled()).toBe(false)
53
+ startTurn('k', 1000)
54
+ expect(__getStateForTests('k')).toBeUndefined()
55
+ })
56
+
57
+ it('startTurn is a no-op when SWITCHROOM_DISABLE_SILENCE_POKE=true', () => {
58
+ process.env.SWITCHROOM_DISABLE_SILENCE_POKE = 'true'
59
+ startTurn('k', 1000)
60
+ expect(__getStateForTests('k')).toBeUndefined()
61
+ })
62
+
63
+ it('is enabled when kill switch is unset', () => {
64
+ expect(silencePokeEnabled()).toBe(true)
65
+ startTurn('k', 1000)
66
+ expect(__getStateForTests('k')).toBeDefined()
67
+ })
68
+ })
69
+
70
+ describe('silence-poke — escalation ladder', () => {
71
+ it('soft poke fires at 75s', () => {
72
+ const fx = setupDeps()
73
+ startTurn('chat:0', 0)
74
+
75
+ __tickForTests(70_000) // before threshold
76
+ expect(consumeArmedPoke()).toBeNull()
77
+ expect(fx.emitted).toHaveLength(0)
78
+
79
+ __tickForTests(75_000) // at threshold
80
+ expect(fx.emitted).toEqual([
81
+ expect.objectContaining({ kind: 'silence_poke_fired', level: 'soft', subagent_wait: false }),
82
+ ])
83
+ const text = consumeArmedPoke()
84
+ expect(text).toContain('[silence-poke]')
85
+ expect(text).toContain('75s')
86
+ })
87
+
88
+ it('firm poke fires at 180s after soft', () => {
89
+ const fx = setupDeps()
90
+ startTurn('chat:0', 0)
91
+ __tickForTests(75_000)
92
+ consumeArmedPoke() // drain the soft
93
+ __tickForTests(180_000)
94
+ expect(fx.emitted.map((e) => e.kind)).toEqual([
95
+ 'silence_poke_fired',
96
+ 'silence_poke_fired',
97
+ ])
98
+ expect(fx.emitted[1]).toMatchObject({ level: 'firm' })
99
+ const firm = consumeArmedPoke()
100
+ expect(firm).toContain('3 minutes silent')
101
+ })
102
+
103
+ it('framework fallback fires at 300s with kind=working when no thinking signal', () => {
104
+ const fx = setupDeps()
105
+ startTurn('chatX:42', 0)
106
+ __tickForTests(75_000)
107
+ __tickForTests(180_000)
108
+ __tickForTests(300_000)
109
+ expect(fx.fallbacks).toEqual([
110
+ expect.objectContaining({ chatId: 'chatX', threadId: 42, fallbackKind: 'working' }),
111
+ ])
112
+ expect(fx.emitted.at(-1)).toMatchObject({ kind: 'silence_fallback_sent', fallback_kind: 'working' })
113
+ })
114
+
115
+ it('framework fallback fires with kind=thinking if a thinking event landed within 30s', () => {
116
+ const fx = setupDeps()
117
+ startTurn('c:0', 0)
118
+ noteThinking('c:0', 280_000)
119
+ __tickForTests(75_000)
120
+ __tickForTests(180_000)
121
+ __tickForTests(300_000)
122
+ expect(fx.fallbacks).toEqual([
123
+ expect.objectContaining({ fallbackKind: 'thinking' }),
124
+ ])
125
+ })
126
+
127
+ it('framework fallback fires at most once per turn', () => {
128
+ const fx = setupDeps()
129
+ startTurn('c:0', 0)
130
+ __tickForTests(75_000)
131
+ __tickForTests(180_000)
132
+ __tickForTests(300_000)
133
+ __tickForTests(450_000) // continued silence
134
+ __tickForTests(600_000)
135
+ expect(fx.fallbacks).toHaveLength(1)
136
+ })
137
+ })
138
+
139
+ describe('silence-poke — outbound resets clock + success measurement', () => {
140
+ it('noteOutbound resets the silence clock', () => {
141
+ setupDeps()
142
+ startTurn('k', 0)
143
+ noteOutbound('k', 50_000)
144
+ __tickForTests(120_000) // 70s after outbound — under 75s soft threshold
145
+ expect(consumeArmedPoke()).toBeNull()
146
+ })
147
+
148
+ it('emits silence_poke_succeeded when outbound lands within success window after a poke', () => {
149
+ const fx = setupDeps()
150
+ startTurn('k', 0)
151
+ __tickForTests(75_000) // soft poke armed
152
+ noteOutbound('k', 80_000) // 5s later — within 15s success window
153
+ expect(fx.emitted.map((e) => e.kind)).toContain('silence_poke_succeeded')
154
+ const success = fx.emitted.find((e) => e.kind === 'silence_poke_succeeded')!
155
+ expect(success).toMatchObject({ level: 'soft', latency_ms: 5_000 })
156
+ })
157
+
158
+ it('does NOT emit silence_poke_succeeded if outbound is later than the success window', () => {
159
+ const fx = setupDeps()
160
+ startTurn('k', 0)
161
+ __tickForTests(75_000)
162
+ noteOutbound('k', 95_000) // 20s later — outside 15s window
163
+ expect(fx.emitted.filter((e) => e.kind === 'silence_poke_succeeded')).toHaveLength(0)
164
+ })
165
+
166
+ it('outbound resets pokesFired so the next 75s silence can re-arm', () => {
167
+ const fx = setupDeps()
168
+ startTurn('k', 0)
169
+ __tickForTests(75_000) // soft fires
170
+ noteOutbound('k', 100_000) // reset
171
+ __tickForTests(180_000) // 80s since outbound — under threshold
172
+ __tickForTests(180_000 + 50_000) // would be 130s if not reset; still no fire because clock zero = 100_000, so silence = 130s
173
+ // Actually 230 - 100 = 130s past outbound, > 75s soft threshold:
174
+ expect(fx.emitted.filter((e) => e.kind === 'silence_poke_fired')).toHaveLength(2)
175
+ expect(fx.emitted.filter((e) => e.kind === 'silence_poke_fired').at(-1)).toMatchObject({ level: 'soft' })
176
+ })
177
+ })
178
+
179
+ describe('silence-poke — subagent dispatch extension', () => {
180
+ it('extends soft threshold to 300s when noteSubagentDispatch was called', () => {
181
+ const fx = setupDeps()
182
+ startTurn('k', 0)
183
+ noteSubagentDispatch('k')
184
+ __tickForTests(120_000) // past 75s but under 300s subagent threshold
185
+ expect(fx.emitted).toHaveLength(0)
186
+ __tickForTests(300_000)
187
+ expect(fx.emitted).toHaveLength(1)
188
+ expect(fx.emitted[0]).toMatchObject({ level: 'soft', subagent_wait: true })
189
+ })
190
+
191
+ it('subagent flag PERSISTS through narrating outbound (PR4 fix)', () => {
192
+ // Reviewer note from PR2 #1125 — the parent's "spinning up @reviewer"
193
+ // narration is the outbound that opens the wait. Clearing the
194
+ // subagent flag at that moment would defeat the extended-threshold
195
+ // guarantee for the wait that follows. The flag must persist until
196
+ // endTurn().
197
+ const fx = setupDeps()
198
+ startTurn('k', 0)
199
+ noteSubagentDispatch('k')
200
+ noteOutbound('k', 60_000) // parent narrates "spinning up @reviewer"
201
+ // Subagent wait continues. With the flag persistent, soft threshold
202
+ // is still 300s, so a 90s gap should NOT fire.
203
+ __tickForTests(60_000 + 90_000)
204
+ expect(fx.emitted.filter((e) => e.kind === 'silence_poke_fired')).toHaveLength(0)
205
+ // At 300s past the outbound, the soft poke fires (subagent wait
206
+ // is genuinely long).
207
+ __tickForTests(60_000 + 300_000)
208
+ expect(fx.emitted.filter((e) => e.kind === 'silence_poke_fired')).toHaveLength(1)
209
+ expect(fx.emitted[0]).toMatchObject({ level: 'soft', subagent_wait: true })
210
+ })
211
+
212
+ it('subagent flag clears on endTurn', () => {
213
+ setupDeps()
214
+ startTurn('k', 0)
215
+ noteSubagentDispatch('k')
216
+ // Take snapshot
217
+ const before = __getStateForTests('k')
218
+ expect(before?.subagentDispatchActive).toBe(true)
219
+ endTurn('k')
220
+ expect(__getStateForTests('k')).toBeUndefined()
221
+ })
222
+
223
+ // CC-5 defensive invariant (`docs/status-ask-cause-classes.md`):
224
+ // the original catalog claim was that `subagentDispatchActive` can
225
+ // leak across turns if `endTurn` is skipped (turn dies abnormally,
226
+ // gateway crashes between turn_end signal and cleanup). Investigation
227
+ // shows the claim doesn't hold — `startTurn` calls `state.set(key, ...)`
228
+ // unconditionally with `subagentDispatchActive: false`, so the next
229
+ // turn's startTurn wipes any stale flag.
230
+ //
231
+ // We're pinning that invariant here as a regression guard. If a future
232
+ // refactor changes `startTurn` to a read-modify-write (merge instead
233
+ // of overwrite), this test breaks immediately. Keeps the catalog's
234
+ // worry productive: even though it's not currently a bug, the
235
+ // invariant that makes it not-a-bug is now load-bearing.
236
+ it('startTurn overwrites stale subagentDispatchActive when endTurn was skipped (CC-5 invariant)', () => {
237
+ const fx = setupDeps()
238
+ // Turn 1: dispatch a subagent, then SKIP endTurn (simulating an
239
+ // abnormal abort path — context-exhaustion, gateway crash mid-turn,
240
+ // etc).
241
+ startTurn('k', 0)
242
+ noteSubagentDispatch('k')
243
+ expect(__getStateForTests('k')?.subagentDispatchActive).toBe(true)
244
+
245
+ // Turn 2 in the same key: startTurn MUST clear the flag.
246
+ startTurn('k', 1_000_000)
247
+ expect(__getStateForTests('k')?.subagentDispatchActive).toBe(false)
248
+
249
+ // Verify the soft poke fires at the normal 75s threshold, not at
250
+ // the extended 300s subagentSoft threshold. If the flag had leaked,
251
+ // ticking at 75s after the new turn start would find subagentSoft
252
+ // active and skip the fire.
253
+ __tickForTests(1_000_000 + 75_000)
254
+ const fired = fx.emitted.filter((e) => e.kind === 'silence_poke_fired')
255
+ expect(fired).toHaveLength(1)
256
+ expect(fired[0]).toMatchObject({ level: 'soft', subagent_wait: false })
257
+ })
258
+ })
259
+
260
+ // Pin the contract the gateway must uphold for ABNORMAL turn-ends:
261
+ // every code path that abandons a turn before turn_end (context-
262
+ // exhaust bail, gateway-side wedge timeout, silent-end recovery)
263
+ // MUST call `endTurn(key)`. If it doesn't, the silence-poke state
264
+ // lingers in the Map and the 300s framework fallback fires later
265
+ // for a turn the gateway already considers dead — sending the user
266
+ // a "still working… (no update from agent in 5 min)" message that
267
+ // contradicts the gateway's earlier "⚠️ Context window full" / etc.
268
+ //
269
+ // Surfaced during CC-5 investigation (`docs/status-ask-cause-classes.md`).
270
+ // The fix lives in the gateway (context-exhaust path adds the
271
+ // endTurn call); these tests pin the invariant at the silence-poke
272
+ // level so the contract is verifiable in isolation of the gateway.
273
+ describe('silence-poke — abnormal turn-end invariants (CC-5 follow-up)', () => {
274
+ it('endTurn before the 300s fallback threshold prevents the fallback from firing', () => {
275
+ const fx = setupDeps()
276
+ startTurn('k', 0)
277
+ // Soft + firm pokes arm; turn is alive and the model could still
278
+ // recover.
279
+ __tickForTests(75_000)
280
+ __tickForTests(180_000)
281
+ // Gateway aborts the turn at t=250s (context exhaust, wedge,
282
+ // crash teardown — any abnormal bail). The contract: endTurn
283
+ // gets called BEFORE the 300s threshold.
284
+ endTurn('k')
285
+ // Five minutes total elapse from the original turn start. If
286
+ // endTurn left the state in the Map, the framework fallback
287
+ // would fire here. The contract is: it MUST NOT.
288
+ __tickForTests(300_000)
289
+ expect(fx.fallbacks).toHaveLength(0)
290
+ expect(
291
+ fx.emitted.filter((e) => e.kind === 'silence_fallback_sent'),
292
+ ).toHaveLength(0)
293
+ })
294
+
295
+ it('endTurn after a soft poke fired does not later emit a stale fallback', () => {
296
+ const fx = setupDeps()
297
+ startTurn('k', 0)
298
+ __tickForTests(75_000) // soft fires
299
+ expect(
300
+ fx.emitted.filter((e) => e.kind === 'silence_poke_fired'),
301
+ ).toHaveLength(1)
302
+ // Turn aborts well before firm/fallback thresholds.
303
+ endTurn('k')
304
+ __tickForTests(180_000)
305
+ __tickForTests(300_000)
306
+ // No firm, no fallback after the turn-abort.
307
+ expect(
308
+ fx.emitted.filter((e) => e.kind === 'silence_poke_fired'),
309
+ ).toHaveLength(1) // unchanged: only the original soft
310
+ expect(fx.fallbacks).toHaveLength(0)
311
+ })
312
+ })
313
+
314
+ describe('silence-poke — consumeArmedPoke draining', () => {
315
+ it('drains the armed flag so the next call returns null', () => {
316
+ setupDeps()
317
+ startTurn('k', 0)
318
+ __tickForTests(75_000)
319
+ expect(consumeArmedPoke()).not.toBeNull()
320
+ expect(consumeArmedPoke()).toBeNull()
321
+ })
322
+
323
+ it('returns null when nothing is armed', () => {
324
+ setupDeps()
325
+ startTurn('k', 0)
326
+ expect(consumeArmedPoke()).toBeNull()
327
+ })
328
+
329
+ it('returns the matching level text', () => {
330
+ setupDeps()
331
+ startTurn('k', 0)
332
+ __tickForTests(75_000)
333
+ expect(consumeArmedPoke()).toContain('75s')
334
+ __tickForTests(180_000)
335
+ expect(consumeArmedPoke()).toContain('3 minutes')
336
+ })
337
+ })
338
+
339
+ describe('silence-poke — endTurn cleanup', () => {
340
+ it('endTurn drops state', () => {
341
+ setupDeps()
342
+ startTurn('k', 0)
343
+ expect(__getStateForTests('k')).toBeDefined()
344
+ endTurn('k')
345
+ expect(__getStateForTests('k')).toBeUndefined()
346
+ })
347
+
348
+ it('endTurn on an unknown key is a no-op', () => {
349
+ setupDeps()
350
+ expect(() => endTurn('never-tracked')).not.toThrow()
351
+ })
352
+ })
353
+
354
+ describe('silence-poke — independence across turns', () => {
355
+ it('two turns in different chats fire independently', () => {
356
+ const fx = setupDeps()
357
+ startTurn('a:0', 0)
358
+ startTurn('b:0', 0)
359
+ noteOutbound('a:0', 50_000)
360
+ __tickForTests(75_000)
361
+ // a's clock was reset to 50_000, silence=25s — no fire.
362
+ // b's clock is still at 0, silence=75s — soft fires.
363
+ expect(fx.emitted).toHaveLength(1)
364
+ expect(fx.emitted[0]).toMatchObject({ key: 'b:0', level: 'soft' })
365
+ })
366
+ })
367
+
368
+ describe('silence-poke — fallback handler errors do not break timer', () => {
369
+ it('continues to function if onFrameworkFallback throws', () => {
370
+ const fx: TestFixtures = { emitted: [], fallbacks: [] }
371
+ __setDepsForTests({
372
+ emitMetric: (e) => fx.emitted.push(e),
373
+ onFrameworkFallback: () => { throw new Error('oh no') },
374
+ thresholdsMs: DEFAULT_THRESHOLDS,
375
+ })
376
+ startTurn('k', 0)
377
+ expect(() => {
378
+ __tickForTests(75_000)
379
+ __tickForTests(180_000)
380
+ __tickForTests(300_000)
381
+ }).not.toThrow()
382
+ // Telemetry still emitted
383
+ expect(fx.emitted.some((e) => e.kind === 'silence_fallback_sent')).toBe(true)
384
+ })
385
+
386
+ it('continues to function if onFrameworkFallback returns a rejected promise', async () => {
387
+ const fx: TestFixtures = { emitted: [], fallbacks: [] }
388
+ __setDepsForTests({
389
+ emitMetric: (e) => fx.emitted.push(e),
390
+ onFrameworkFallback: () => Promise.reject(new Error('async fail')),
391
+ thresholdsMs: DEFAULT_THRESHOLDS,
392
+ })
393
+ startTurn('k', 0)
394
+ __tickForTests(75_000)
395
+ __tickForTests(180_000)
396
+ __tickForTests(300_000)
397
+ // Allow microtasks for the rejection-catch to fire
398
+ await new Promise((r) => setTimeout(r, 0))
399
+ expect(fx.emitted.some((e) => e.kind === 'silence_fallback_sent')).toBe(true)
400
+ })
401
+ })
402
+
403
+ describe('silence-poke — system reminder text', () => {
404
+ it('soft poke text references the 75s threshold and contains the system-reminder marker', () => {
405
+ setupDeps()
406
+ startTurn('k', 0)
407
+ __tickForTests(75_000)
408
+ const text = consumeArmedPoke()
409
+ expect(text).toContain('[silence-poke]')
410
+ expect(text).toContain('75s')
411
+ expect(text).toContain('about to finish')
412
+ })
413
+
414
+ it('firm poke text references the 3-minute threshold', () => {
415
+ setupDeps()
416
+ startTurn('k', 0)
417
+ __tickForTests(75_000)
418
+ consumeArmedPoke()
419
+ __tickForTests(180_000)
420
+ const text = consumeArmedPoke()
421
+ expect(text).toContain('3 minutes')
422
+ expect(text).toContain('stuck')
423
+ })
424
+ })
425
+
426
+ // CC-4 from `docs/status-ask-cause-classes.md`: wording is load-bearing
427
+ // (`reference/conversational-pacing.md` § Silence-poke ladder). Snapshot
428
+ // the exact strings here so a refactor that drops a key phrase fails
429
+ // loud at test time. If you genuinely need to change the wording,
430
+ // update the snapshot AND the design doc together.
431
+ describe('silence-poke — wording snapshots (CC-4)', () => {
432
+ it('soft poke text is unchanged', () => {
433
+ expect(formatPokeText('soft')).toMatchInlineSnapshot(
434
+ `"[silence-poke] You've been silent to the user for 75s. If you're still working on this, send one short conversational reply — e.g. "still going, working through X" — so they know you're alive. Keep it brief; don't restate the task. If you're about to finish within the next few seconds, skip the update."`,
435
+ )
436
+ })
437
+
438
+ it('firm poke text is unchanged', () => {
439
+ expect(formatPokeText('firm')).toMatchInlineSnapshot(
440
+ `"[silence-poke] 3 minutes silent. Please send an update now — what you're working on, or whether you're stuck. If something is taking unusually long (slow tool, network, waiting on a sub-agent), say so explicitly."`,
441
+ )
442
+ })
443
+
444
+ it('framework fallback — working at 300s', () => {
445
+ expect(formatFrameworkFallbackText('working', 300_000)).toMatchInlineSnapshot(
446
+ `"still working… (no update from agent in 5 min)"`,
447
+ )
448
+ })
449
+
450
+ it('framework fallback — thinking at 300s', () => {
451
+ expect(formatFrameworkFallbackText('thinking', 300_000)).toMatchInlineSnapshot(
452
+ `"still thinking… (no update from agent in 5 min)"`,
453
+ )
454
+ })
455
+
456
+ it('framework fallback — minutes derived from silenceMs, not hard-coded', () => {
457
+ // The "N min" suffix MUST track ctx.silenceMs so the wording stays
458
+ // honest if the 300s threshold is tuned. If a refactor accidentally
459
+ // hard-codes "5 min", these cases break.
460
+ expect(formatFrameworkFallbackText('working', 360_000)).toBe(
461
+ 'still working… (no update from agent in 6 min)',
462
+ )
463
+ expect(formatFrameworkFallbackText('working', 600_000)).toBe(
464
+ 'still working… (no update from agent in 10 min)',
465
+ )
466
+ })
467
+
468
+ it('framework fallback — minutes floor at 1 even when silenceMs is small', () => {
469
+ // Defensive: a future caller might invoke with sub-minute silenceMs.
470
+ // Rendering "0 min" reads as nonsense; floor at 1.
471
+ expect(formatFrameworkFallbackText('working', 30_000)).toBe(
472
+ 'still working… (no update from agent in 1 min)',
473
+ )
474
+ expect(formatFrameworkFallbackText('working', 0)).toBe(
475
+ 'still working… (no update from agent in 1 min)',
476
+ )
477
+ })
478
+ })
479
+
480
+ describe('silence-poke — performance', () => {
481
+ it('tick over many active turns stays fast', () => {
482
+ setupDeps()
483
+ for (let i = 0; i < 1000; i++) {
484
+ startTurn(`chat${i}:0`, 0)
485
+ }
486
+ const start = performance.now()
487
+ __tickForTests(75_000)
488
+ const elapsed = performance.now() - start
489
+ // 1000 turns should tick in well under 50ms — guards against an
490
+ // accidentally-quadratic implementation.
491
+ expect(elapsed).toBeLessThan(50)
492
+ })
493
+ })