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,247 @@
1
+ /**
2
+ * Unit tests for the boot-issue dedup module (`boot-issue-cache.ts`).
3
+ *
4
+ * Covers the four canonical lifecycles a probe can move through across
5
+ * consecutive boots:
6
+ *
7
+ * 1. novel โ€” first boot a fingerprint is seen โ†’ not snoozed, not resolved
8
+ * 2. repeated โ€” fingerprint matches prior boot โ†’ counter increments
9
+ * 3. snoozed โ€” same fingerprint past snoozeBoots / snoozeMs โ†’ hidden
10
+ * 4. resolved โ€” was degraded/fail last boot, ok this boot โ†’ resolved=true
11
+ *
12
+ * Plus persistence guardrails (corrupt cache, schema mismatch, GC).
13
+ */
14
+
15
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
16
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'fs'
17
+ import { tmpdir } from 'os'
18
+ import { join } from 'path'
19
+
20
+ import {
21
+ fingerprintProbe,
22
+ diffProbes,
23
+ loadCache,
24
+ applyAndSave,
25
+ DEFAULT_SNOOZE_BOOTS,
26
+ type BootIssueCacheFile,
27
+ } from '../gateway/boot-issue-cache.js'
28
+ import type { ProbeMap } from '../gateway/boot-card.js'
29
+
30
+ let tmp: string
31
+ beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'boot-issue-')) })
32
+ afterEach(() => { rmSync(tmp, { recursive: true, force: true }) })
33
+
34
+ describe('fingerprintProbe โ€” per-probe fold policy', () => {
35
+ it('skills folds across dangling count and named entries', () => {
36
+ const a = fingerprintProbe('skills', {
37
+ status: 'degraded', label: 'Skills',
38
+ detail: '3/12 dangling: alpha, beta, gamma',
39
+ })
40
+ const b = fingerprintProbe('skills', {
41
+ status: 'degraded', label: 'Skills',
42
+ detail: '5/12 dangling: alpha, beta, gamma, delta, epsilon',
43
+ })
44
+ expect(a).toBe(b)
45
+ })
46
+
47
+ it('account folds by status_kind (signed-out vs token-expiring vs token-expired)', () => {
48
+ const signedOut1 = fingerprintProbe('account', { status: 'degraded', label: 'Account', detail: 'not signed in' })
49
+ const signedOut2 = fingerprintProbe('account', { status: 'degraded', label: 'Account', detail: 'Not Signed In' })
50
+ expect(signedOut1).toBe(signedOut2)
51
+
52
+ const exp1 = fingerprintProbe('account', { status: 'degraded', label: 'Account', detail: 'a@b ยท Pro ยท token 4d' })
53
+ const exp2 = fingerprintProbe('account', { status: 'degraded', label: 'Account', detail: 'a@b ยท Pro ยท token 6d' })
54
+ expect(exp1).toBe(exp2)
55
+ expect(exp1).not.toBe(signedOut1)
56
+ })
57
+
58
+ it('agent folds by raw systemd state string', () => {
59
+ const a = fingerprintProbe('agent', { status: 'fail', label: 'Agent', detail: 'service failed' })
60
+ const b = fingerprintProbe('agent', { status: 'fail', label: 'Agent', detail: 'service failed' })
61
+ expect(a).toBe(b)
62
+ const c = fingerprintProbe('agent', { status: 'degraded', label: 'Agent', detail: 'service activating' })
63
+ expect(a).not.toBe(c)
64
+ })
65
+
66
+ it('broker / kernel / hindsight use literal detail (normalized)', () => {
67
+ const broker1 = fingerprintProbe('broker', { status: 'fail', label: 'Broker', detail: 'socket missing' })
68
+ const broker2 = fingerprintProbe('broker', { status: 'fail', label: 'Broker', detail: 'socket missing' })
69
+ expect(broker1).toBe(broker2)
70
+ const broker3 = fingerprintProbe('broker', { status: 'fail', label: 'Broker', detail: 'connection refused' })
71
+ expect(broker1).not.toBe(broker3)
72
+ })
73
+
74
+ it('ok results all share a single per-probe fingerprint', () => {
75
+ const a = fingerprintProbe('skills', { status: 'ok', label: 'Skills', detail: '12 resolved' })
76
+ const b = fingerprintProbe('skills', { status: 'ok', label: 'Skills', detail: '8 resolved' })
77
+ expect(a).toBe(b)
78
+ expect(a).toBe('skills:ok')
79
+ })
80
+ })
81
+
82
+ describe('diffProbes โ€” lifecycle: novel โ†’ repeated โ†’ snoozed โ†’ resolved', () => {
83
+ it('novel: first sighting โ†’ not snoozed, not resolved, counter=1', () => {
84
+ const probes: ProbeMap = { broker: { status: 'fail', label: 'Broker', detail: 'socket missing' } }
85
+ const diff = diffProbes(probes, { schema: 1, probes: {} }, { now: () => 1000 })
86
+ expect(diff.broker?.snoozed).toBe(false)
87
+ expect(diff.broker?.resolved).toBe(false)
88
+ expect(diff.broker?.firstSighting).toBe(true)
89
+ expect(diff.broker?.nextEntry?.consecutiveBoots).toBe(1)
90
+ })
91
+
92
+ it('repeated: same fingerprint โ†’ counter increments, still surfaced (not snoozed) below threshold', () => {
93
+ const probes: ProbeMap = { broker: { status: 'fail', label: 'Broker', detail: 'socket missing' } }
94
+ const cache: BootIssueCacheFile = {
95
+ schema: 1,
96
+ probes: {
97
+ broker: {
98
+ fingerprint: 'broker:fail:socket missing',
99
+ consecutiveBoots: 3,
100
+ firstSeenMs: 1000,
101
+ lastSeenMs: 2000,
102
+ },
103
+ },
104
+ }
105
+ const diff = diffProbes(probes, cache, { now: () => 3000, snoozeBoots: 10, snoozeMs: 1_000_000 })
106
+ expect(diff.broker?.snoozed).toBe(false)
107
+ expect(diff.broker?.nextEntry?.consecutiveBoots).toBe(4)
108
+ })
109
+
110
+ it('snoozed: same fingerprint past snoozeBoots โ†’ snoozed=true', () => {
111
+ const probes: ProbeMap = { broker: { status: 'fail', label: 'Broker', detail: 'socket missing' } }
112
+ const cache: BootIssueCacheFile = {
113
+ schema: 1,
114
+ probes: {
115
+ broker: {
116
+ fingerprint: 'broker:fail:socket missing',
117
+ consecutiveBoots: DEFAULT_SNOOZE_BOOTS, // next boot triggers snooze
118
+ firstSeenMs: 1000,
119
+ lastSeenMs: 2000,
120
+ },
121
+ },
122
+ }
123
+ const diff = diffProbes(probes, cache, { now: () => 3000, snoozeMs: 1_000_000_000 })
124
+ expect(diff.broker?.snoozed).toBe(true)
125
+ expect(diff.broker?.nextEntry?.consecutiveBoots).toBe(DEFAULT_SNOOZE_BOOTS + 1)
126
+ })
127
+
128
+ it('snoozed: same fingerprint past snoozeMs โ†’ snoozed=true even if below boot count', () => {
129
+ const probes: ProbeMap = { broker: { status: 'fail', label: 'Broker', detail: 'socket missing' } }
130
+ const cache: BootIssueCacheFile = {
131
+ schema: 1,
132
+ probes: {
133
+ broker: {
134
+ fingerprint: 'broker:fail:socket missing',
135
+ consecutiveBoots: 2,
136
+ firstSeenMs: 1000,
137
+ lastSeenMs: 1500,
138
+ },
139
+ },
140
+ }
141
+ const diff = diffProbes(probes, cache, {
142
+ now: () => 1000 + 4 * 24 * 60 * 60 * 1000, // 4 days later
143
+ snoozeMs: 3 * 24 * 60 * 60 * 1000, // 3-day window
144
+ snoozeBoots: 100,
145
+ })
146
+ expect(diff.broker?.snoozed).toBe(true)
147
+ })
148
+
149
+ it('resolved: was degraded last boot, now ok โ†’ resolved=true, nextEntry=null', () => {
150
+ const probes: ProbeMap = { broker: { status: 'ok', label: 'Broker', detail: 'reachable' } }
151
+ const cache: BootIssueCacheFile = {
152
+ schema: 1,
153
+ probes: {
154
+ broker: {
155
+ fingerprint: 'broker:fail:socket missing',
156
+ consecutiveBoots: 2,
157
+ firstSeenMs: 1000,
158
+ lastSeenMs: 2000,
159
+ },
160
+ },
161
+ }
162
+ const diff = diffProbes(probes, cache, { now: () => 3000 })
163
+ expect(diff.broker?.resolved).toBe(true)
164
+ expect(diff.broker?.snoozed).toBe(false)
165
+ expect(diff.broker?.nextEntry).toBeNull()
166
+ })
167
+
168
+ it('fingerprint change resets counter โ€” new failure mode shows even if old one was snoozed', () => {
169
+ const probes: ProbeMap = { broker: { status: 'fail', label: 'Broker', detail: 'connection refused' } }
170
+ const cache: BootIssueCacheFile = {
171
+ schema: 1,
172
+ probes: {
173
+ broker: {
174
+ fingerprint: 'broker:fail:socket missing',
175
+ consecutiveBoots: 50,
176
+ firstSeenMs: 1000,
177
+ lastSeenMs: 2000,
178
+ },
179
+ },
180
+ }
181
+ const diff = diffProbes(probes, cache, { now: () => 3000 })
182
+ expect(diff.broker?.snoozed).toBe(false)
183
+ expect(diff.broker?.nextEntry?.consecutiveBoots).toBe(1)
184
+ expect(diff.broker?.firstSighting).toBe(true)
185
+ })
186
+ })
187
+
188
+ describe('loadCache / applyAndSave โ€” persistence', () => {
189
+ it('round-trips a diff: save then load yields the same probe entries', () => {
190
+ const path = join(tmp, 'cache.json')
191
+ const probes: ProbeMap = { broker: { status: 'fail', label: 'Broker', detail: 'socket missing' } }
192
+ const empty: BootIssueCacheFile = { schema: 1, probes: {} }
193
+ const diff = diffProbes(probes, empty, { now: () => 1000 })
194
+ applyAndSave(path, empty, diff)
195
+ expect(existsSync(path)).toBe(true)
196
+ const loaded = loadCache(path, () => 1000) // same clock as the diff
197
+ expect(loaded.probes.broker?.fingerprint).toBe(diff.broker?.fingerprint)
198
+ })
199
+
200
+ it('resolved probe removes its entry from the cache (nextEntry=null)', () => {
201
+ const path = join(tmp, 'cache.json')
202
+ const seed: BootIssueCacheFile = {
203
+ schema: 1,
204
+ probes: {
205
+ broker: { fingerprint: 'broker:fail:x', consecutiveBoots: 2, firstSeenMs: 1, lastSeenMs: 2 },
206
+ },
207
+ }
208
+ writeFileSync(path, JSON.stringify(seed))
209
+ const loaded = loadCache(path, () => 1000)
210
+ expect(loaded.probes.broker).toBeDefined()
211
+ const probes: ProbeMap = { broker: { status: 'ok', label: 'Broker', detail: 'reachable' } }
212
+ const diff = diffProbes(probes, loaded, { now: () => 1000 })
213
+ applyAndSave(path, loaded, diff)
214
+ const reloaded = loadCache(path, () => 1000)
215
+ expect(reloaded.probes.broker).toBeUndefined()
216
+ })
217
+
218
+ it('corrupt cache file is renamed aside and an empty cache is returned', () => {
219
+ const path = join(tmp, 'cache.json')
220
+ writeFileSync(path, 'not-json-{{{')
221
+ const loaded = loadCache(path, () => 12345)
222
+ expect(loaded.probes).toEqual({})
223
+ // The corrupt file is preserved for forensics.
224
+ expect(existsSync(`${path}.corrupt-12345`)).toBe(true)
225
+ })
226
+
227
+ it('schema mismatch is treated like empty', () => {
228
+ const path = join(tmp, 'cache.json')
229
+ writeFileSync(path, JSON.stringify({ schema: 99, probes: {} }))
230
+ const loaded = loadCache(path)
231
+ expect(loaded.probes).toEqual({})
232
+ })
233
+
234
+ it('GC drops entries older than 30 days on load', () => {
235
+ const path = join(tmp, 'cache.json')
236
+ const seed: BootIssueCacheFile = {
237
+ schema: 1,
238
+ probes: {
239
+ broker: { fingerprint: 'x', consecutiveBoots: 1, firstSeenMs: 0, lastSeenMs: 0 },
240
+ },
241
+ }
242
+ writeFileSync(path, JSON.stringify(seed))
243
+ // Now = far enough that 0-mtime entry exceeds 30d.
244
+ const loaded = loadCache(path, () => 60 * 24 * 60 * 60 * 1000)
245
+ expect(loaded.probes.broker).toBeUndefined()
246
+ })
247
+ })
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Integration unit test โ€” couples `determineRestartReason` to
3
+ * `renderBootCard` so the user-visible output is pinned across the
4
+ * decision boundary.
5
+ *
6
+ * Cause class CC-8 from `docs/status-ask-cause-classes.md`:
7
+ *
8
+ * Boot card silenced on operator update vs. silent on a real crash.
9
+ *
10
+ * Clean-shutdown marker (#1139/#1141/#1142) silences the boot card
11
+ * on operator-driven restarts. If the marker is stamped erroneously
12
+ * (or the 5-min freshness window is too generous on a slow boot),
13
+ * the card stays silent after a real crash โ†’ user sees the agent
14
+ * come back with no acknowledgement โ†’ asks "did you crash?".
15
+ *
16
+ * `boot-card-reason.test.ts` covers the decision in isolation. This
17
+ * file pins the next link: given the decision, what string lands in
18
+ * the user's chat? Specifically, on `'crash'` the card MUST surface
19
+ * the โš ๏ธ row + journalctl `nextStep` hint; on `'graceful'` /
20
+ * `'planned'` it MUST NOT (no crash row, just the ack).
21
+ *
22
+ * Together: a refactor that subtly drops the crash row, swaps the
23
+ * emoji on `'crash'`, or accidentally renders the crash row on
24
+ * `'graceful'`, fails one of these snapshots at test time.
25
+ */
26
+
27
+ import { describe, it, expect } from 'bun:test'
28
+ import { determineRestartReason } from '../gateway/boot-reason.js'
29
+ import { renderBootCard } from '../gateway/boot-card.js'
30
+
31
+ const NOW = 1_700_000_000_000
32
+ const VERSION = 'v0.8.0+106'
33
+ const AGENT = 'test-harness'
34
+
35
+ function rec(offsetMs: number) {
36
+ return { ts: NOW - offsetMs }
37
+ }
38
+
39
+ function clean(offsetMs: number, reason?: string) {
40
+ return { ts: NOW - offsetMs, signal: 'SIGTERM', reason }
41
+ }
42
+
43
+ const session = { pid: 1234 }
44
+
45
+ describe('boot-card: reason โ†’ user-visible render (CC-8)', () => {
46
+ // โ”€โ”€โ”€ happy path: clean operator restart, recently stamped โ”€โ”€โ”€โ”€โ”€โ”€
47
+ it('clean operator restart within 5min window renders ack only (no crash row)', () => {
48
+ const reason = determineRestartReason({
49
+ marker: null,
50
+ cleanMarker: clean(97_000, 'operator: switchroom update'),
51
+ sessionMarker: session,
52
+ now: NOW,
53
+ })
54
+ expect(reason).toBe('graceful')
55
+ const card = renderBootCard({
56
+ agentName: AGENT,
57
+ version: VERSION,
58
+ restartReason: reason,
59
+ })
60
+ expect(card).toMatchInlineSnapshot(`"โœ… <b>test-harness</b> back up ยท v0.8.0+106"`)
61
+ // Negative assertions on the failure surface CC-8 worries about:
62
+ expect(card).not.toContain('crash recovery')
63
+ expect(card).not.toContain('journalctl')
64
+ expect(card).not.toContain('โš ๏ธ')
65
+ })
66
+
67
+ // โ”€โ”€โ”€ the worry case: marker erroneously stamped, real crash later โ”€โ”€
68
+ it('operator marker stale beyond 5min + session marker โ†’ crash card with hint', () => {
69
+ // 6 min after stamping โ€” operator-extended window has expired,
70
+ // so even a planned-looking marker reads as a crash. This is
71
+ // EXACTLY CC-8's failure shape: if we'd left this case silent,
72
+ // the user would never see the crash recovery row.
73
+ const reason = determineRestartReason({
74
+ marker: null,
75
+ cleanMarker: clean(6 * 60_000, 'operator: switchroom update'),
76
+ sessionMarker: session,
77
+ now: NOW,
78
+ })
79
+ expect(reason).toBe('crash')
80
+ const card = renderBootCard({
81
+ agentName: AGENT,
82
+ version: VERSION,
83
+ restartReason: reason,
84
+ restartAgeMs: 3_400,
85
+ })
86
+ expect(card).toContain('โš ๏ธ <b>test-harness</b> back up')
87
+ expect(card).toContain('โš ๏ธ <b>Restart</b> crash recovery ยท 3.4s ago')
88
+ expect(card).toContain('Tail logs:')
89
+ expect(card).toContain('<code>journalctl --user -u switchroom-test-harness -n 100</code>')
90
+ })
91
+
92
+ // โ”€โ”€โ”€ canonical crash: no marker at all โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
93
+ it('no markers + session marker โ†’ crash card with hint', () => {
94
+ const reason = determineRestartReason({
95
+ marker: null,
96
+ cleanMarker: null,
97
+ sessionMarker: session,
98
+ now: NOW,
99
+ })
100
+ expect(reason).toBe('crash')
101
+ const card = renderBootCard({
102
+ agentName: AGENT,
103
+ version: VERSION,
104
+ restartReason: reason,
105
+ restartAgeMs: 12_000,
106
+ })
107
+ expect(card).toContain('โš ๏ธ <b>test-harness</b> back up')
108
+ expect(card).toContain('crash recovery ยท 12.0s ago')
109
+ expect(card).toContain('Tail logs:')
110
+ })
111
+
112
+ // โ”€โ”€โ”€ user /restart (non-operator): tight 60s window applies โ”€โ”€โ”€โ”€
113
+ it('user: /restart marker stale beyond 60s โ†’ crash card (tight window not extended)', () => {
114
+ // A /restart from chat that takes >60s before its gateway boots
115
+ // is a real crash; the catalog's CC-8 worry includes this path.
116
+ const reason = determineRestartReason({
117
+ marker: null,
118
+ cleanMarker: clean(90_000, 'user: /restart from chat'),
119
+ sessionMarker: session,
120
+ now: NOW,
121
+ })
122
+ expect(reason).toBe('crash')
123
+ const card = renderBootCard({
124
+ agentName: AGENT,
125
+ version: VERSION,
126
+ restartReason: reason,
127
+ restartAgeMs: 1_500,
128
+ })
129
+ expect(card).toContain('crash recovery ยท 1.5s ago')
130
+ })
131
+
132
+ // โ”€โ”€โ”€ planned restart via switchroom: ack only โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
133
+ it('planned restart (marker present, fresh) โ†’ ack only', () => {
134
+ const reason = determineRestartReason({
135
+ marker: rec(10_000),
136
+ cleanMarker: null,
137
+ sessionMarker: session,
138
+ now: NOW,
139
+ })
140
+ expect(reason).toBe('planned')
141
+ const card = renderBootCard({
142
+ agentName: AGENT,
143
+ version: VERSION,
144
+ restartReason: reason,
145
+ })
146
+ expect(card).toMatchInlineSnapshot(`"โœ… <b>test-harness</b> back up ยท v0.8.0+106"`)
147
+ expect(card).not.toContain('crash recovery')
148
+ })
149
+
150
+ // โ”€โ”€โ”€ fresh first start: distinct emoji, ack only โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
151
+ it('fresh first start (no markers, no session) โ†’ ๐Ÿ†• ack', () => {
152
+ const reason = determineRestartReason({
153
+ marker: null,
154
+ cleanMarker: null,
155
+ sessionMarker: null,
156
+ now: NOW,
157
+ })
158
+ expect(reason).toBe('fresh')
159
+ const card = renderBootCard({
160
+ agentName: AGENT,
161
+ version: VERSION,
162
+ restartReason: reason,
163
+ })
164
+ expect(card).toMatchInlineSnapshot(`"๐Ÿ†• <b>test-harness</b> back up ยท v0.8.0+106"`)
165
+ expect(card).not.toContain('crash recovery')
166
+ })
167
+
168
+ // โ”€โ”€โ”€ slug override path: agentSlug used in journalctl, not agentName โ”€โ”€โ”€
169
+ it('crash card uses agentSlug (not agentName) in the journalctl command', () => {
170
+ // The journalctl row is the user's actionable next step โ€” if the
171
+ // slug ever drifts (capitalization, special chars), the copy-paste
172
+ // command stops working. Pin the slug pathway explicitly.
173
+ const card = renderBootCard({
174
+ agentName: 'Test Harness Display',
175
+ agentSlug: 'test-harness',
176
+ version: VERSION,
177
+ restartReason: 'crash',
178
+ restartAgeMs: 800,
179
+ })
180
+ expect(card).toContain('<code>journalctl --user -u switchroom-test-harness -n 100</code>')
181
+ })
182
+ })
@@ -16,8 +16,10 @@ function recentMarker(offsetMs = 0) {
16
16
  return { ts: NOW - offsetMs }
17
17
  }
18
18
 
19
- function recentCleanMarker(offsetMs = 0) {
20
- return { ts: NOW - offsetMs }
19
+ function recentCleanMarker(offsetMs = 0, reason?: string) {
20
+ // `signal` is part of the CleanShutdownMarker shape; required at the
21
+ // type level but not exercised by determineRestartReason itself.
22
+ return { ts: NOW - offsetMs, signal: 'SIGTERM', reason }
21
23
  }
22
24
 
23
25
  function sessionMarker() {
@@ -89,6 +91,67 @@ describe('determineRestartReason', () => {
89
91
  expect(result).toBe('planned')
90
92
  })
91
93
 
94
+ it('returns "graceful" when clean-shutdown marker has operator: reason and is within the extended 5-min window (#1141 follow-up: 9-agent fleet recreate can exceed 60s)', () => {
95
+ const result = determineRestartReason({
96
+ marker: null,
97
+ cleanMarker: recentCleanMarker(97_000, 'operator: switchroom update'),
98
+ sessionMarker: sessionMarker(),
99
+ now: NOW,
100
+ })
101
+ expect(result).toBe('graceful')
102
+ })
103
+
104
+ it('still treats operator: marker as stale beyond 5 min (longer window not "silent forever")', () => {
105
+ const result = determineRestartReason({
106
+ marker: null,
107
+ cleanMarker: recentCleanMarker(6 * 60_000, 'operator: switchroom update'),
108
+ sessionMarker: sessionMarker(),
109
+ now: NOW,
110
+ })
111
+ expect(result).toBe('crash')
112
+ })
113
+
114
+ it('non-operator reasons (user:, cli:) keep the tight 60s window โ€” a /restart that takes >60s before its gateway boots is still a crash', () => {
115
+ const result = determineRestartReason({
116
+ marker: null,
117
+ cleanMarker: recentCleanMarker(90_000, 'user: /restart from chat'),
118
+ sessionMarker: sessionMarker(),
119
+ now: NOW,
120
+ })
121
+ expect(result).toBe('crash')
122
+ })
123
+
124
+ it('cli: reasons also keep the tight 60s window', () => {
125
+ const result = determineRestartReason({
126
+ marker: null,
127
+ cleanMarker: recentCleanMarker(90_000, 'cli: switchroom restart'),
128
+ sessionMarker: sessionMarker(),
129
+ now: NOW,
130
+ })
131
+ expect(result).toBe('crash')
132
+ })
133
+
134
+ it('operator: marker just barely inside the 5-min window still graceful', () => {
135
+ const result = determineRestartReason({
136
+ marker: null,
137
+ cleanMarker: recentCleanMarker(4 * 60_000 + 59_000, 'operator: switchroom update'),
138
+ sessionMarker: sessionMarker(),
139
+ now: NOW,
140
+ })
141
+ expect(result).toBe('graceful')
142
+ })
143
+
144
+ it('respects operatorMaxAgeMs override (tests can tighten the window)', () => {
145
+ const result = determineRestartReason({
146
+ marker: null,
147
+ cleanMarker: recentCleanMarker(120_000, 'operator: switchroom update'),
148
+ sessionMarker: sessionMarker(),
149
+ now: NOW,
150
+ operatorMaxAgeMs: 60_000, // override tightens to 60s
151
+ })
152
+ expect(result).toBe('crash')
153
+ })
154
+
92
155
  it('respects custom markerMaxAgeMs โ€” stale marker does not count as planned', () => {
93
156
  const result = determineRestartReason({
94
157
  marker: recentMarker(10 * 60_000), // 10 min ago
@@ -135,6 +135,95 @@ describe('renderBootCard โ€” degraded conditions', () => {
135
135
  expect(out).toContain('๐ŸŸก <b>Account</b> expiring')
136
136
  })
137
137
 
138
+ it('renders nextStep as an indented continuation line beneath a degraded row', () => {
139
+ // Principle 1 ("If they need the docs, we've failed"): every degraded
140
+ // probe should surface its remediation inline. Plain backticks in the
141
+ // nextStep get translated to <code> spans so the command stays tap-to-
142
+ // copy on mobile.
143
+ const out = renderBootCard({
144
+ agentName: 'lawgpt',
145
+ version: 'v0.7.16',
146
+ probes: {
147
+ skills: {
148
+ status: 'degraded',
149
+ label: 'Skills',
150
+ detail: '10/10 dangling: a, b, c +7 more',
151
+ nextStep: 'Run `switchroom agent reconcile lawgpt` to rebuild symlinks',
152
+ },
153
+ },
154
+ })
155
+ expect(out).toContain('๐ŸŸก <b>Skills</b> 10/10 dangling')
156
+ expect(out).toContain(' โ†ณ Run <code>switchroom agent reconcile lawgpt</code> to rebuild symlinks')
157
+ })
158
+
159
+ it('crash row carries a tail-logs next-step', () => {
160
+ const out = renderBootCard({
161
+ agentName: 'lawgpt',
162
+ version: 'v0.7.16',
163
+ restartReason: 'crash',
164
+ restartAgeMs: 6_100,
165
+ })
166
+ expect(out).toContain('โš ๏ธ <b>Restart</b> crash recovery ยท 6.1s ago')
167
+ expect(out).toContain('โ†ณ Tail logs: <code>journalctl --user -u switchroom-lawgpt -n 100</code>')
168
+ })
169
+
170
+ it('crash row uses agentSlug for the systemd unit when provided', () => {
171
+ const out = renderBootCard({
172
+ agentName: 'LawGPT',
173
+ agentSlug: 'lawgpt',
174
+ version: 'v1',
175
+ restartReason: 'crash',
176
+ })
177
+ expect(out).toContain('switchroom-lawgpt')
178
+ expect(out).not.toContain('switchroom-LawGPT')
179
+ })
180
+
181
+ it('renderNextStep escapes HTML inside backtick-quoted commands', () => {
182
+ const out = renderBootCard({
183
+ agentName: 'a',
184
+ version: 'v',
185
+ probes: {
186
+ account: {
187
+ status: 'fail',
188
+ label: 'Account',
189
+ detail: 'expired',
190
+ nextStep: 'Run `foo <bar> & baz` to fix',
191
+ },
192
+ },
193
+ })
194
+ expect(out).toContain('<code>foo &lt;bar&gt; &amp; baz</code>')
195
+ expect(out).not.toContain('<bar>')
196
+ })
197
+
198
+ it('unpaired backticks in nextStep fall back to plain escaped text', () => {
199
+ const out = renderBootCard({
200
+ agentName: 'a',
201
+ version: 'v',
202
+ probes: {
203
+ account: {
204
+ status: 'fail',
205
+ label: 'Account',
206
+ detail: 'expired',
207
+ nextStep: 'Run `switchroom foo to fix',
208
+ },
209
+ },
210
+ })
211
+ expect(out).toContain('โ†ณ Run `switchroom foo to fix')
212
+ expect(out).not.toContain('<code>')
213
+ })
214
+
215
+ it('degraded rows without nextStep render unchanged (backwards compat)', () => {
216
+ const out = renderBootCard({
217
+ agentName: 'a',
218
+ version: 'v',
219
+ probes: {
220
+ quota: { status: 'fail', label: 'Quota', detail: 'rate limited' },
221
+ },
222
+ })
223
+ expect(out).toContain('๐Ÿ”ด <b>Quota</b> rate limited')
224
+ expect(out).not.toContain('โ†ณ')
225
+ })
226
+
138
227
  it('null probe entries are skipped (defensive against partial probe maps)', () => {
139
228
  const out = renderBootCard({
140
229
  agentName: 'a',
@@ -218,3 +307,60 @@ describe('resolvePersonaName โ€” persona name over slug (#169)', () => {
218
307
  expect(out).not.toContain('<b>finn</b>')
219
308
  })
220
309
  })
310
+
311
+ // โ”€โ”€ Issue dedup rendering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
312
+ // Resolved rows render at the top of the degraded section; snoozed rows
313
+ // suppress the matching probe row entirely.
314
+
315
+ describe('renderBootCard โ€” resolved / snooze rendering', () => {
316
+ it('renders a โœ… "resolved" row for each entry in resolvedRows above the degraded section', () => {
317
+ const out = renderBootCard({
318
+ agentName: 'k',
319
+ version: 'v',
320
+ probes: {
321
+ broker: { status: 'fail', label: 'Broker', detail: 'socket missing' },
322
+ },
323
+ resolvedRows: ['hindsight'],
324
+ })
325
+ expect(out).toContain('โœ… <b>Hindsight</b> resolved')
326
+ expect(out).toContain('๐Ÿ”ด <b>Broker</b> socket missing')
327
+ // Resolved appears BEFORE Broker.
328
+ expect(out.indexOf('Hindsight')).toBeLessThan(out.indexOf('Broker'))
329
+ })
330
+
331
+ it('skips a degraded row when its probe key is in snoozeRows', () => {
332
+ const out = renderBootCard({
333
+ agentName: 'k',
334
+ version: 'v',
335
+ probes: {
336
+ broker: { status: 'fail', label: 'Broker', detail: 'socket missing' },
337
+ kernel: { status: 'fail', label: 'Kernel', detail: 'socket missing' },
338
+ },
339
+ snoozeRows: ['broker'],
340
+ })
341
+ expect(out).not.toContain('Broker')
342
+ expect(out).toContain('Kernel')
343
+ })
344
+
345
+ it('snoozed everything โ†’ output is the bare ack line (silent-when-snoozed)', () => {
346
+ const out = renderBootCard({
347
+ agentName: 'k',
348
+ version: 'v0.1',
349
+ probes: {
350
+ broker: { status: 'fail', label: 'Broker', detail: 'socket missing' },
351
+ },
352
+ snoozeRows: ['broker'],
353
+ })
354
+ expect(out).toBe('โœ… <b>k</b> back up ยท v0.1')
355
+ })
356
+
357
+ it('resolvedRows alone (no probes degraded) renders the resolved row beneath the ack', () => {
358
+ const out = renderBootCard({
359
+ agentName: 'k',
360
+ version: 'v',
361
+ resolvedRows: ['skills', 'broker'],
362
+ })
363
+ expect(out).toContain('โœ… <b>Skills</b> resolved')
364
+ expect(out).toContain('โœ… <b>Broker</b> resolved')
365
+ })
366
+ })