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,206 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { execFileSync } from 'node:child_process'
6
+ import {
7
+ writeSilentEndState,
8
+ clearSilentEndState,
9
+ readSilentEndState,
10
+ } from '../silent-end.js'
11
+
12
+ let stateDir: string
13
+ const ORIG_ENV = process.env.TELEGRAM_STATE_DIR
14
+
15
+ beforeEach(() => {
16
+ stateDir = mkdtempSync(join(tmpdir(), 'silent-end-test-'))
17
+ process.env.TELEGRAM_STATE_DIR = stateDir
18
+ })
19
+
20
+ afterEach(() => {
21
+ rmSync(stateDir, { recursive: true, force: true })
22
+ if (ORIG_ENV != null) process.env.TELEGRAM_STATE_DIR = ORIG_ENV
23
+ else delete process.env.TELEGRAM_STATE_DIR
24
+ })
25
+
26
+ describe('silent-end.ts — gateway state writer', () => {
27
+ it('writeSilentEndState creates the file with retryCount=0 on first write', () => {
28
+ writeSilentEndState({ chatId: '123', threadId: null, turnKey: '123:_' })
29
+ const state = readSilentEndState()
30
+ expect(state).not.toBeNull()
31
+ expect(state!.chatId).toBe('123')
32
+ expect(state!.threadId).toBeNull()
33
+ expect(state!.turnKey).toBe('123:_')
34
+ expect(state!.retryCount).toBe(0)
35
+ expect(typeof state!.timestamp).toBe('number')
36
+ })
37
+
38
+ it('writeSilentEndState inherits retryCount IFF the prior file matches the same turnKey', () => {
39
+ // Prior file at retryCount=1 for the same turn (Stop hook had already
40
+ // blocked once and re-incremented).
41
+ const path = join(stateDir, 'silent-end-pending.json')
42
+ writeFileSync(path, JSON.stringify({
43
+ chatId: '123', threadId: null, turnKey: '123:_', retryCount: 1, timestamp: 0,
44
+ }))
45
+ writeSilentEndState({ chatId: '123', threadId: null, turnKey: '123:_' })
46
+ expect(readSilentEndState()!.retryCount).toBe(1)
47
+ })
48
+
49
+ it('writeSilentEndState resets retryCount to 0 when turnKey differs', () => {
50
+ const path = join(stateDir, 'silent-end-pending.json')
51
+ writeFileSync(path, JSON.stringify({
52
+ chatId: '123', threadId: null, turnKey: '123:_', retryCount: 1, timestamp: 0,
53
+ }))
54
+ // Different turn — new silent-end, fresh counter.
55
+ writeSilentEndState({ chatId: '999', threadId: 42, turnKey: '999:42' })
56
+ const state = readSilentEndState()
57
+ expect(state!.turnKey).toBe('999:42')
58
+ expect(state!.retryCount).toBe(0)
59
+ })
60
+
61
+ it('writeSilentEndState falls back to ~/.claude/channels/telegram when TELEGRAM_STATE_DIR is unset', () => {
62
+ // Updated 2026-05-13 UAT overnight: discovered the writer used to
63
+ // silently no-op when the env var was unset, while the Stop hook
64
+ // (silent-end-interrupt-stop.mjs) and the gateway both fall back
65
+ // to `~/.claude/channels/telegram`. Mismatch meant the hook
66
+ // always read a missing file → silent-end recovery never engaged.
67
+ // The writer now applies the same fallback.
68
+ delete process.env.TELEGRAM_STATE_DIR
69
+ const fakeHome = mkdtempSync(join(tmpdir(), 'silent-end-fallback-home-'))
70
+ const origHome = process.env.HOME
71
+ process.env.HOME = fakeHome
72
+ try {
73
+ writeSilentEndState({ chatId: '123', threadId: null, turnKey: '123:_' })
74
+ const expected = join(fakeHome, '.claude', 'channels', 'telegram', 'silent-end-pending.json')
75
+ expect(existsSync(expected)).toBe(true)
76
+ } finally {
77
+ if (origHome != null) process.env.HOME = origHome
78
+ else delete process.env.HOME
79
+ rmSync(fakeHome, { recursive: true, force: true })
80
+ }
81
+ })
82
+
83
+ it('clearSilentEndState removes the file when turnKey matches', () => {
84
+ writeSilentEndState({ chatId: '123', threadId: null, turnKey: '123:_' })
85
+ expect(existsSync(join(stateDir, 'silent-end-pending.json'))).toBe(true)
86
+ clearSilentEndState('123:_')
87
+ expect(existsSync(join(stateDir, 'silent-end-pending.json'))).toBe(false)
88
+ })
89
+
90
+ it('clearSilentEndState leaves the file alone when turnKey does NOT match', () => {
91
+ writeSilentEndState({ chatId: '123', threadId: null, turnKey: '123:_' })
92
+ clearSilentEndState('different-turn')
93
+ expect(existsSync(join(stateDir, 'silent-end-pending.json'))).toBe(true)
94
+ })
95
+
96
+ it('clearSilentEndState is a no-op when no file exists', () => {
97
+ expect(() => clearSilentEndState('123:_')).not.toThrow()
98
+ })
99
+
100
+ it('clearSilentEndState is a no-op when TELEGRAM_STATE_DIR is unset', () => {
101
+ delete process.env.TELEGRAM_STATE_DIR
102
+ expect(() => clearSilentEndState('123:_')).not.toThrow()
103
+ })
104
+
105
+ it('writeSilentEndState handles corrupt prior file by resetting retryCount', () => {
106
+ const path = join(stateDir, 'silent-end-pending.json')
107
+ writeFileSync(path, 'not valid json {{{')
108
+ writeSilentEndState({ chatId: '123', threadId: null, turnKey: '123:_' })
109
+ expect(readSilentEndState()!.retryCount).toBe(0)
110
+ })
111
+
112
+ it('round-trip: write → read → clear', () => {
113
+ writeSilentEndState({ chatId: 'c', threadId: 7, turnKey: 'c:7' })
114
+ const state = readSilentEndState()
115
+ expect(state).toMatchObject({ chatId: 'c', threadId: 7, turnKey: 'c:7', retryCount: 0 })
116
+ clearSilentEndState('c:7')
117
+ expect(readSilentEndState()).toBeNull()
118
+ })
119
+ })
120
+
121
+ describe('silent-end-interrupt-stop hook — integration', () => {
122
+ const hookPath = join(__dirname, '..', 'hooks', 'silent-end-interrupt-stop.mjs')
123
+
124
+ function runHook(input: object): { exit: number; stdout: string; stderr: string } {
125
+ const { spawnSync } = require('node:child_process') as typeof import('node:child_process')
126
+ const r = spawnSync('node', [hookPath], {
127
+ input: JSON.stringify(input),
128
+ env: { ...process.env, TELEGRAM_STATE_DIR: stateDir },
129
+ encoding: 'utf8',
130
+ timeout: 5_000,
131
+ })
132
+ return { exit: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
133
+ }
134
+
135
+ it('allows the stop when no state file exists (normal completion)', () => {
136
+ const r = runHook({
137
+ session_id: 's',
138
+ transcript_path: '/tmp/x.jsonl',
139
+ hook_event_name: 'Stop',
140
+ })
141
+ expect(r.exit).toBe(0)
142
+ expect(r.stdout.trim()).toBe('')
143
+ })
144
+
145
+ it('blocks the stop with decision:block when silent-end state exists at retryCount=0', () => {
146
+ writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
147
+ const r = runHook({
148
+ session_id: 's',
149
+ transcript_path: '/tmp/x.jsonl',
150
+ hook_event_name: 'Stop',
151
+ })
152
+ expect(r.exit).toBe(0)
153
+ const out = JSON.parse(r.stdout.trim())
154
+ expect(out.decision).toBe('block')
155
+ expect(out.reason).toContain('reply')
156
+ // retryCount must have been incremented to 1
157
+ expect(readSilentEndState()!.retryCount).toBe(1)
158
+ })
159
+
160
+ it('allows the stop when retryCount >= MAX_RETRIES (1)', () => {
161
+ const path = join(stateDir, 'silent-end-pending.json')
162
+ writeFileSync(path, JSON.stringify({
163
+ chatId: 'c', threadId: null, turnKey: 'c:_', retryCount: 1, timestamp: 0,
164
+ }))
165
+ const r = runHook({
166
+ session_id: 's',
167
+ transcript_path: '/tmp/x.jsonl',
168
+ hook_event_name: 'Stop',
169
+ })
170
+ expect(r.exit).toBe(0)
171
+ expect(r.stdout.trim()).toBe('')
172
+ expect(r.stderr).toContain('retry exhausted')
173
+ })
174
+
175
+ it('end-to-end: write silent-end → hook blocks → simulate reply → next stop allows', () => {
176
+ // 1. Turn ends silently — gateway writes state
177
+ writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
178
+
179
+ // 2. Stop hook fires, blocks, increments retryCount
180
+ const r1 = runHook({ session_id: 's', transcript_path: '/tmp/x.jsonl', hook_event_name: 'Stop' })
181
+ expect(JSON.parse(r1.stdout).decision).toBe('block')
182
+ expect(readSilentEndState()!.retryCount).toBe(1)
183
+
184
+ // 3. Re-prompted agent calls reply — gateway clears the file
185
+ clearSilentEndState('c:_')
186
+ expect(readSilentEndState()).toBeNull()
187
+
188
+ // 4. Next Stop allows cleanly (no state file)
189
+ const r2 = runHook({ session_id: 's', transcript_path: '/tmp/x.jsonl', hook_event_name: 'Stop' })
190
+ expect(r2.stdout.trim()).toBe('')
191
+ })
192
+
193
+ it('fails open on a corrupt state file', () => {
194
+ const path = join(stateDir, 'silent-end-pending.json')
195
+ mkdirSync(stateDir, { recursive: true })
196
+ writeFileSync(path, 'corrupt {{{', 'utf8')
197
+ const r = runHook({ session_id: 's', transcript_path: '/tmp/x.jsonl', hook_event_name: 'Stop' })
198
+ expect(r.exit).toBe(0)
199
+ expect(r.stdout.trim()).toBe('')
200
+ })
201
+
202
+ it('fails open on empty stdin', () => {
203
+ const r = runHook({}) // serialised as `{}` — but the hook also tolerates empty
204
+ expect(r.exit).toBe(0)
205
+ })
206
+ })
@@ -211,3 +211,110 @@ describe('subagent-tracker-posttool', () => {
211
211
  expect(row?.status).toBe('failed')
212
212
  })
213
213
  })
214
+
215
+ describe('agent-dir resolution (RFC §Bug 2)', () => {
216
+ // The hooks used to look only at SWITCHROOM_AGENT_DIR and then cwd.
217
+ // In production neither matched the path the gateway + watcher used,
218
+ // so rows were written to a registry.db nobody read. The fix adds
219
+ // TELEGRAM_STATE_DIR (the env var start.sh exports for every agent)
220
+ // as a middle lookup. These tests pin the precedence + the legacy
221
+ // fallback so a future refactor can't silently revert.
222
+
223
+ function runWith(scriptPath: string, event: object, env: Record<string, string | undefined>) {
224
+ const finalEnv: Record<string, string> = { ...process.env } as Record<string, string>
225
+ // Clear the inherited overrides; we want a clean baseline.
226
+ delete finalEnv.SWITCHROOM_AGENT_DIR
227
+ delete finalEnv.TELEGRAM_STATE_DIR
228
+ for (const [k, v] of Object.entries(env)) {
229
+ if (v === undefined) delete finalEnv[k]
230
+ else finalEnv[k] = v
231
+ }
232
+ return spawnSync(process.execPath, [scriptPath], {
233
+ input: JSON.stringify(event),
234
+ encoding: 'utf8',
235
+ env: finalEnv,
236
+ timeout: 15_000,
237
+ })
238
+ }
239
+
240
+ const baseEvent = {
241
+ session_id: 's',
242
+ tool_name: 'Agent',
243
+ tool_use_id: 'toolu_envtest1',
244
+ tool_input: { subagent_type: 'w', description: 'd', run_in_background: true },
245
+ }
246
+
247
+ it('pretool prefers SWITCHROOM_AGENT_DIR over TELEGRAM_STATE_DIR', () => {
248
+ const explicit = mkdtempSync(join(tmpdir(), 'agent-dir-explicit-'))
249
+ const stateDirParent = mkdtempSync(join(tmpdir(), 'state-dir-parent-'))
250
+ mkdirSync(join(explicit, 'telegram'), { recursive: true })
251
+ mkdirSync(join(stateDirParent, 'telegram'), { recursive: true })
252
+ try {
253
+ const result = runWith(PRETOOL_SCRIPT, baseEvent, {
254
+ SWITCHROOM_AGENT_DIR: explicit,
255
+ TELEGRAM_STATE_DIR: join(stateDirParent, 'telegram'),
256
+ })
257
+ expect(result.status).toBe(0)
258
+ // Row landed in the EXPLICIT location, not the state-dir-derived one.
259
+ const explicitDb = join(explicit, 'telegram', 'registry.db')
260
+ const stateDirDb = join(stateDirParent, 'telegram', 'registry.db')
261
+ expect(Bun.file(explicitDb).size).toBeGreaterThan(0)
262
+ expect(Bun.file(stateDirDb).size).toBe(0)
263
+ } finally {
264
+ try { rmSync(explicit, { recursive: true }) } catch { /* */ }
265
+ try { rmSync(stateDirParent, { recursive: true }) } catch { /* */ }
266
+ }
267
+ })
268
+
269
+ it('pretool derives agentDir from TELEGRAM_STATE_DIR when SWITCHROOM_AGENT_DIR is unset (the production path)', () => {
270
+ const stateDirParent = mkdtempSync(join(tmpdir(), 'state-dir-only-'))
271
+ mkdirSync(join(stateDirParent, 'telegram'), { recursive: true })
272
+ try {
273
+ const result = runWith(PRETOOL_SCRIPT, baseEvent, {
274
+ TELEGRAM_STATE_DIR: join(stateDirParent, 'telegram'),
275
+ })
276
+ expect(result.status).toBe(0)
277
+ const dbPath = join(stateDirParent, 'telegram', 'registry.db')
278
+ expect(Bun.file(dbPath).size).toBeGreaterThan(0)
279
+ // Row landed in the state-dir-derived location.
280
+ const { Database } = require('bun:sqlite') as { Database: new (p: string) => {
281
+ prepare(sql: string): { get(...params: unknown[]): unknown }
282
+ } }
283
+ const db = new Database(dbPath)
284
+ const row = db.prepare('SELECT id FROM subagents WHERE id = ?').get('toolu_envtest1') as
285
+ | { id: string } | undefined
286
+ expect(row?.id).toBe('toolu_envtest1')
287
+ } finally {
288
+ try { rmSync(stateDirParent, { recursive: true }) } catch { /* */ }
289
+ }
290
+ })
291
+
292
+ it('pretool ignores TELEGRAM_STATE_DIR that does NOT end in /telegram (defensive)', () => {
293
+ // If TELEGRAM_STATE_DIR ever drifts to a non-canonical shape, the
294
+ // hook should NOT silently use it — falling through to cwd is the
295
+ // safer behaviour (you'll notice the wrong location quickly).
296
+ const weirdDir = mkdtempSync(join(tmpdir(), 'state-dir-weird-'))
297
+ const cwdDir = mkdtempSync(join(tmpdir(), 'cwd-dir-'))
298
+ mkdirSync(join(cwdDir, 'telegram'), { recursive: true })
299
+ try {
300
+ const result = spawnSync(process.execPath, [PRETOOL_SCRIPT], {
301
+ input: JSON.stringify(baseEvent),
302
+ encoding: 'utf8',
303
+ cwd: cwdDir,
304
+ env: {
305
+ ...process.env,
306
+ SWITCHROOM_AGENT_DIR: undefined as unknown as string,
307
+ TELEGRAM_STATE_DIR: weirdDir, // does NOT end in /telegram
308
+ } as Record<string, string>,
309
+ timeout: 15_000,
310
+ })
311
+ expect(result.status).toBe(0)
312
+ // Fell through to cwd, NOT the weird TELEGRAM_STATE_DIR.
313
+ const cwdDb = join(cwdDir, 'telegram', 'registry.db')
314
+ expect(Bun.file(cwdDb).size).toBeGreaterThan(0)
315
+ } finally {
316
+ try { rmSync(weirdDir, { recursive: true }) } catch { /* */ }
317
+ try { rmSync(cwdDir, { recursive: true }) } catch { /* */ }
318
+ }
319
+ })
320
+ })
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Env-var overrides for the watcher's threshold knobs
3
+ * (`stallThresholdMs`, `silentSynthesisStallThresholdMs`,
4
+ * `silentStallTerminalMs`). Used by the UAT harness to compress the
5
+ * stall+synth window so `bg-sub-agent-dispatch-dm.test.ts` can
6
+ * validate Bug 6's terminal-synthesis path inside its 120s timeout
7
+ * instead of waiting the production-tuned 6min.
8
+ *
9
+ * Resolution order: explicit config arg → env var → compile-time
10
+ * default. Invalid env values (0, negative, NaN, empty string) fall
11
+ * through to the default — we don't want a stray `=0` to silently
12
+ * disable stall detection in production.
13
+ */
14
+
15
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
16
+ import { startSubagentWatcher } from '../subagent-watcher.js'
17
+ import * as fs from 'fs'
18
+
19
+ function buildJSONL(...lines: object[]): string {
20
+ return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
21
+ }
22
+ function subAgentUserMsg(promptText: string) {
23
+ return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
24
+ }
25
+
26
+ function makeHarness(opts: {
27
+ agentId?: string
28
+ configStallThresholdMs?: number
29
+ configSilentStallTerminalMs?: number
30
+ } = {}) {
31
+ const { agentId = 'env-thresh-agent', configStallThresholdMs, configSilentStallTerminalMs } = opts
32
+
33
+ let currentTime = 1000
34
+ const stallCalls: Array<{ idleMs: number }> = []
35
+ const stallTerminalCalls: Array<{ agentId: string }> = []
36
+ const finishCalls: Array<{ outcome: string }> = []
37
+
38
+ const agentDir = '/home/user/.switchroom/agents/myagent'
39
+ const sessionId = 'mock-session'
40
+ const projectsRoot = `${agentDir}/.claude/projects`
41
+ const projectDir = `${projectsRoot}/mock-cwd`
42
+ const sessionDir = `${projectDir}/${sessionId}`
43
+ const subagentsDir = `${sessionDir}/subagents`
44
+ const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
45
+ const fileContents = new Map<string, Buffer>()
46
+ fileContents.set(jsonlPath, Buffer.from(buildJSONL(subAgentUserMsg('bg task')), 'utf-8'))
47
+
48
+ let lastOpenedPath: string | null = null
49
+ const mockFs = {
50
+ existsSync: ((p: fs.PathLike) => {
51
+ const ps = String(p)
52
+ if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
53
+ return fileContents.has(ps)
54
+ }) as typeof fs.existsSync,
55
+ readdirSync: ((p: fs.PathLike) => {
56
+ const ps = String(p)
57
+ if (ps === projectsRoot) return ['mock-cwd']
58
+ if (ps === projectDir) return [sessionId]
59
+ if (ps === sessionDir) return ['subagents']
60
+ if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
61
+ return []
62
+ }) as unknown as typeof fs.readdirSync,
63
+ statSync: ((p: fs.PathLike) => ({ size: fileContents.get(String(p))?.length ?? 0 }) as fs.Stats) as typeof fs.statSync,
64
+ openSync: ((p: fs.PathLike) => { lastOpenedPath = String(p); return 42 }) as unknown as typeof fs.openSync,
65
+ closeSync: (() => { lastOpenedPath = null }) as typeof fs.closeSync,
66
+ readSync: ((
67
+ _fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
68
+ ): number => {
69
+ const content = lastOpenedPath != null ? fileContents.get(lastOpenedPath) : undefined
70
+ if (!content) return 0
71
+ const pos = position ?? 0
72
+ const src = content.slice(pos, pos + length)
73
+ ;(src as Buffer).copy(buf as Buffer, offset)
74
+ return src.length
75
+ }) as unknown as typeof fs.readSync,
76
+ watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
77
+ }
78
+
79
+ const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
80
+ let nextRef = 1
81
+ const watcher = startSubagentWatcher({
82
+ agentDir,
83
+ stallThresholdMs: configStallThresholdMs,
84
+ silentSynthesisStallThresholdMs: configStallThresholdMs,
85
+ silentStallTerminalMs: configSilentStallTerminalMs,
86
+ rescanMs: 500,
87
+ sendNotification: () => {},
88
+ onStall: (_id, idleMs) => stallCalls.push({ idleMs }),
89
+ onStallTerminal: (id) => stallTerminalCalls.push({ agentId: id }),
90
+ onFinish: ({ outcome }) => finishCalls.push({ outcome }),
91
+ now: () => currentTime,
92
+ setInterval: (fn, ms) => {
93
+ const ref = nextRef++
94
+ intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
95
+ return { ref }
96
+ },
97
+ clearInterval: (h) => {
98
+ const { ref } = h as { ref: number }
99
+ const idx = intervals.findIndex((i) => i.ref === ref)
100
+ if (idx !== -1) intervals.splice(idx, 1)
101
+ },
102
+ fs: mockFs,
103
+ })
104
+
105
+ const advance = (ms: number): void => {
106
+ currentTime += ms
107
+ for (;;) {
108
+ intervals.sort((a, b) => a.fireAt - b.fireAt)
109
+ const next = intervals[0]
110
+ if (!next || next.fireAt > currentTime) break
111
+ next.fireAt += next.ms
112
+ next.fn()
113
+ }
114
+ }
115
+
116
+ const unmarkHistorical = (): void => {
117
+ const e = watcher.getRegistry().get(agentId)
118
+ if (e) e.historical = false
119
+ }
120
+
121
+ return { stallCalls, stallTerminalCalls, finishCalls, advance, unmarkHistorical }
122
+ }
123
+
124
+ const ENV_KEYS = [
125
+ 'SWITCHROOM_SUBAGENT_STALL_MS',
126
+ 'SWITCHROOM_SUBAGENT_SILENT_SYNTH_STALL_MS',
127
+ 'SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS',
128
+ ] as const
129
+
130
+ describe('subagent-watcher env-var threshold overrides', () => {
131
+ const saved: Record<string, string | undefined> = {}
132
+
133
+ beforeEach(() => {
134
+ for (const k of ENV_KEYS) {
135
+ saved[k] = process.env[k]
136
+ delete process.env[k]
137
+ }
138
+ })
139
+
140
+ afterEach(() => {
141
+ for (const k of ENV_KEYS) {
142
+ if (saved[k] === undefined) delete process.env[k]
143
+ else process.env[k] = saved[k]
144
+ }
145
+ })
146
+
147
+ it('honors SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS for the synth window', () => {
148
+ process.env.SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS = '5000'
149
+ const h = makeHarness({
150
+ // Tight stall threshold so the test isn't dominated by the
151
+ // 60s default.
152
+ configStallThresholdMs: 1000,
153
+ })
154
+ h.advance(500) // register
155
+ h.unmarkHistorical()
156
+ h.advance(2_000) // stall fires (idle > 1s)
157
+ expect(h.stallCalls).toHaveLength(1)
158
+ expect(h.stallTerminalCalls).toHaveLength(0)
159
+
160
+ // 4s post-stall — still under 5s env override.
161
+ h.advance(4_000)
162
+ expect(h.stallTerminalCalls).toHaveLength(0)
163
+
164
+ // Cross 5s — synth fires.
165
+ h.advance(2_000)
166
+ expect(h.stallTerminalCalls).toHaveLength(1)
167
+ expect(h.finishCalls).toHaveLength(1)
168
+ })
169
+
170
+ it('explicit config arg overrides env var (config wins)', () => {
171
+ process.env.SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS = '5000'
172
+ const h = makeHarness({
173
+ configStallThresholdMs: 1000,
174
+ configSilentStallTerminalMs: 60_000, // overrides env
175
+ })
176
+ h.advance(500)
177
+ h.unmarkHistorical()
178
+ h.advance(2_000) // stall fires
179
+ expect(h.stallCalls).toHaveLength(1)
180
+
181
+ // 10s past stall — env would have synthesised (5s) but config
182
+ // override pins it at 60s.
183
+ h.advance(10_000)
184
+ expect(h.stallTerminalCalls).toHaveLength(0)
185
+
186
+ // Cross 60s — synth fires.
187
+ h.advance(55_000)
188
+ expect(h.stallTerminalCalls).toHaveLength(1)
189
+ })
190
+
191
+ it('invalid env value falls through to default (does not disable)', () => {
192
+ // Both negative and NaN should be ignored — not coerced to "disable
193
+ // stall detection" or "fire immediately".
194
+ process.env.SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS = '-1'
195
+ const h1 = makeHarness({ configStallThresholdMs: 1000 })
196
+ h1.advance(500)
197
+ h1.unmarkHistorical()
198
+ h1.advance(2_000) // stall fires
199
+ expect(h1.stallCalls).toHaveLength(1)
200
+ // Default is 300_000 — synth must NOT fire after a small advance.
201
+ h1.advance(60_000)
202
+ expect(h1.stallTerminalCalls).toHaveLength(0)
203
+ })
204
+
205
+ it('NaN env value falls through to default', () => {
206
+ process.env.SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS = 'not-a-number'
207
+ const h = makeHarness({ configStallThresholdMs: 1000 })
208
+ h.advance(500)
209
+ h.unmarkHistorical()
210
+ h.advance(2_000)
211
+ h.advance(60_000)
212
+ expect(h.stallTerminalCalls).toHaveLength(0)
213
+ })
214
+
215
+ it('zero env value falls through to default (zero is not "fire immediately")', () => {
216
+ process.env.SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS = '0'
217
+ const h = makeHarness({ configStallThresholdMs: 1000 })
218
+ h.advance(500)
219
+ h.unmarkHistorical()
220
+ h.advance(2_000)
221
+ h.advance(60_000)
222
+ expect(h.stallTerminalCalls).toHaveLength(0)
223
+ })
224
+ })