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,123 @@
1
+ /**
2
+ * Boot-card auth row formatter (RFC H §7.3).
3
+ *
4
+ * The old auth-dashboard exported `formatAccountQuotaLine` + an
5
+ * `AccountSummary` shape that the boot card consumed for its
6
+ * "Accounts (N)" section. Both source-of-truth and shape moved to
7
+ * the auth-broker's `list-state` response. This module reformats that
8
+ * response into the same one-line-per-account block the boot card
9
+ * used to render — visual output unchanged, data source is now the
10
+ * broker.
11
+ *
12
+ * Inputs: a `list-state` data shape (see
13
+ * `src/auth/broker/protocol.ts` → `ListStateDataSchema`) plus the
14
+ * caller agent's name.
15
+ *
16
+ * Output: an array of HTML-safe lines. Empty array when there's
17
+ * nothing to show — preserves the boot-card's silent-when-healthy
18
+ * default.
19
+ */
20
+
21
+ import type { ListStateData, AccountState } from '../../src/auth/broker/client.js'
22
+
23
+ export type { ListStateData, AccountState }
24
+
25
+ // Local HTML-escape (mirrors the helper formerly co-located in
26
+ // auth-dashboard.ts so we keep the same escaping discipline without
27
+ // pulling in a heavier util).
28
+ function escapeHtml(s: string): string {
29
+ return s
30
+ .replace(/&/g, '&')
31
+ .replace(/</g, '&lt;')
32
+ .replace(/>/g, '&gt;')
33
+ }
34
+
35
+ /** Format a duration in ms as a short relative string ("1h 22m", "12s"). */
36
+ function formatRelativeMs(ms: number): string {
37
+ if (ms <= 0) return '0s'
38
+ const totalSec = Math.floor(ms / 1000)
39
+ const days = Math.floor(totalSec / 86400)
40
+ const hours = Math.floor((totalSec % 86400) / 3600)
41
+ const mins = Math.floor((totalSec % 3600) / 60)
42
+ const secs = totalSec % 60
43
+ if (days > 0) return `${days}d ${hours}h`
44
+ if (hours > 0) return `${hours}h ${mins}m`
45
+ if (mins > 0) return `${mins}m ${secs}s`
46
+ return `${secs}s`
47
+ }
48
+
49
+ /**
50
+ * Render the per-account quota inline for one account row. Returns
51
+ * null when there's nothing quota-shaped to say (account is healthy
52
+ * and we have no reset countdown to surface).
53
+ */
54
+ export function formatAuthQuotaLine(acc: AccountState, now: number = Date.now()): string | null {
55
+ if (acc.exhausted) {
56
+ const until = acc.exhausted_until
57
+ if (until != null && until > now) {
58
+ return `<i>exhausted · resets in ${formatRelativeMs(until - now)}</i>`
59
+ }
60
+ return `<i>exhausted</i>`
61
+ }
62
+ return null
63
+ }
64
+
65
+ /**
66
+ * Boot-card auth-row block.
67
+ *
68
+ * Strategy:
69
+ * 1. Determine *which* account is active for `agentName` (per-agent
70
+ * override wins over fleet-active).
71
+ * 2. Emit one row for that account marked with `▶` plus a
72
+ * best-effort quota suffix.
73
+ * 3. Emit one row per other account in `fallback_order` marked with
74
+ * `↳` so the operator sees the rollover plan at a glance.
75
+ *
76
+ * Returns an empty array when `state` is empty (no accounts) — the
77
+ * boot card's silent-when-healthy contract.
78
+ */
79
+ export function renderAuthLine(
80
+ state: ListStateData,
81
+ agentName: string,
82
+ now: number = Date.now(),
83
+ ): string[] {
84
+ if (!state || state.accounts.length === 0) return []
85
+
86
+ const agentEntry = state.agents.find((a) => a.name === agentName)
87
+ const activeLabel = agentEntry?.override ?? agentEntry?.account ?? state.active
88
+
89
+ // Stable display order: active first, then `fallback_order` minus
90
+ // the active label, then any remaining accounts (defensive — should
91
+ // be empty in steady state) in account-list order.
92
+ const seen = new Set<string>()
93
+ const order: string[] = []
94
+ if (activeLabel) {
95
+ order.push(activeLabel)
96
+ seen.add(activeLabel)
97
+ }
98
+ for (const label of state.fallback_order) {
99
+ if (!seen.has(label)) {
100
+ order.push(label)
101
+ seen.add(label)
102
+ }
103
+ }
104
+ for (const acc of state.accounts) {
105
+ if (!seen.has(acc.label)) {
106
+ order.push(acc.label)
107
+ seen.add(acc.label)
108
+ }
109
+ }
110
+
111
+ const byLabel = new Map(state.accounts.map((a) => [a.label, a]))
112
+ const rows: string[] = []
113
+ rows.push(`<b>Accounts (${state.accounts.length})</b>`)
114
+ for (const label of order) {
115
+ const acc = byLabel.get(label)
116
+ if (!acc) continue
117
+ const marker = label === activeLabel ? '▶' : '↳'
118
+ const labelHtml = `<code>${escapeHtml(acc.label)}</code>`
119
+ const quotaLine = formatAuthQuotaLine(acc, now)
120
+ rows.push(quotaLine ? `${marker} ${labelHtml} ${quotaLine}` : `${marker} ${labelHtml}`)
121
+ }
122
+ return rows
123
+ }
@@ -33,8 +33,8 @@
33
33
  */
34
34
 
35
35
  import type { ProbeResult, GatewayRuntimeInfo } from './boot-probes.js'
36
- import type { AccountSummary } from '../auth-dashboard.js'
37
- import { formatAccountQuotaLine } from '../auth-dashboard.js'
36
+ import type { ListStateData } from './auth-line.js'
37
+ import { renderAuthLine } from './auth-line.js'
38
38
  import {
39
39
  probeAccount,
40
40
  probeAgentProcess,
@@ -50,6 +50,12 @@ import {
50
50
  AGENT_LIVE_POLL_INTERVAL_MS,
51
51
  } from './boot-probes.js'
52
52
  import { escapeHtml } from '../card-format.js'
53
+ import {
54
+ loadCache as loadBootIssueCache,
55
+ diffProbes as diffBootProbes,
56
+ applyAndSave as saveBootIssueCache,
57
+ type ProbeDiffMap,
58
+ } from './boot-issue-cache.js'
53
59
  import { join } from 'path'
54
60
  import { loadConfig as _loadSwitchroomConfig } from '../../src/config/loader.js'
55
61
 
@@ -243,6 +249,10 @@ const REASON_LABEL: Record<RestartReason, string> = {
243
249
 
244
250
  export interface RenderBootCardOpts {
245
251
  agentName: string
252
+ /** Lowercase slug used for systemd unit names. Falls back to
253
+ * `agentName` when omitted — matches the same fallback used by
254
+ * `runAllProbes` for systemd targets. */
255
+ agentSlug?: string
246
256
  /** Pre-formatted version string, e.g. "v0.3.0+44" or "v0.3.0 · #143 · 2h ago". */
247
257
  version: string
248
258
  /** Probe results (only present after the settle window). When absent or
@@ -264,9 +274,21 @@ export interface RenderBootCardOpts {
264
274
  * silent-when-healthy contract for callers that don't pass account
265
275
  * data (tests, harnesses, gateways without the auth model).
266
276
  *
267
- * Closes #708.
277
+ * Post-RFC H (auth-broker rewire): this carries the broker's
278
+ * `list-state` shape — `renderAuthLine` consumes it to emit the
279
+ * same one-line-per-account rows as before. Callers that pass
280
+ * `null`/`undefined` get no section. Closes #708.
268
281
  */
269
- accounts?: ReadonlyArray<AccountSummary>
282
+ accounts?: ListStateData | null
283
+ /** Probe keys for which the prior boot saw degraded/fail and this boot
284
+ * sees ok. Rendered as a small ✅ line above the degraded section so
285
+ * the user gets positive-feedback that a known issue is gone. */
286
+ resolvedRows?: ReadonlyArray<ProbeKey>
287
+ /** Probe keys whose degraded/fail row is hidden on this boot because
288
+ * the user has seen the same fingerprint for too many consecutive
289
+ * boots (snooze). The renderer skips the corresponding probe row.
290
+ * See `boot-issue-cache.ts`. */
291
+ snoozeRows?: ReadonlyArray<ProbeKey>
270
292
  /** Clock injection point for tests; defaults to `new Date()`. */
271
293
  now?: Date
272
294
  }
@@ -279,12 +301,42 @@ export interface RenderBootCardOpts {
279
301
  * user only needs to know the agent came back up. Anything red catches
280
302
  * the eye; everything else stays out of the way.
281
303
  */
304
+ /**
305
+ * Render a probe's `nextStep` hint as Telegram HTML. The hint is
306
+ * authored as plain text with backtick-quoted commands (one shell idiom
307
+ * across the codebase — search "Run `switchroom"). We translate those
308
+ * to <code> spans and escape everything else, so commands stay tap-to-
309
+ * copy on mobile without bleeding raw HTML through.
310
+ */
311
+ function renderNextStep(text: string): string {
312
+ const parts = text.split('`')
313
+ // Odd-count backticks means an unterminated <code> span — fall back to
314
+ // plain-escaped text rather than rendering the trailing tail inside a
315
+ // code block. Author error, not user-input, but defensive is cheap.
316
+ if (parts.length % 2 === 0) return escapeHtml(text)
317
+ return parts.map((p, i) => (i % 2 === 0 ? escapeHtml(p) : `<code>${escapeHtml(p)}</code>`)).join('')
318
+ }
319
+
282
320
  export function renderBootCard(opts: RenderBootCardOpts): string {
283
321
  const { agentName, version, probes, restartReason, restartAgeMs } = opts
322
+ const agentSlug = opts.agentSlug ?? agentName
284
323
  const ackEmoji = restartReason ? REASON_EMOJI[restartReason] : '✅'
285
324
  const ack = `${ackEmoji} <b>${escapeHtml(agentName)}</b> back up · ${escapeHtml(version)}`
286
325
 
287
326
  const degradedRows: string[] = []
327
+ const snoozeSet = new Set<ProbeKey>(opts.snoozeRows ?? [])
328
+
329
+ // Resolved rows (issue dedup, this PR) — render ✅ entries for probes
330
+ // that were degraded/fail on the previous boot and are now ok. Small
331
+ // positive-feedback signal so the user sees their fix worked instead
332
+ // of guessing from the absence of a row.
333
+ if (opts.resolvedRows && opts.resolvedRows.length > 0) {
334
+ for (const key of opts.resolvedRows) {
335
+ const lbl = PROBE_LABELS[key]
336
+ if (!lbl) continue
337
+ degradedRows.push(`✅ <b>${escapeHtml(lbl)}</b> resolved`)
338
+ }
339
+ }
288
340
 
289
341
  // Crash recovery: surface explicitly so the user can tell whether
290
342
  // their next message will land on a fresh process. The agent-crashed
@@ -295,25 +347,49 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
295
347
  ? ` · ${(restartAgeMs / 1000).toFixed(1)}s ago`
296
348
  : ''
297
349
  degradedRows.push(`⚠️ <b>Restart</b> ${escapeHtml(REASON_LABEL.crash)}${ageStr}`)
350
+ // Principle 1: every failure carries its next step. The crash row
351
+ // tells the user how to inspect why.
352
+ degradedRows.push(` ↳ Tail logs: <code>journalctl --user -u switchroom-${escapeHtml(agentSlug)} -n 100</code>`)
298
353
  }
299
354
 
300
355
  // Probe rows — only those that surfaced as degraded/fail. Healthy
301
- // (`ok`) probes don't render at all.
356
+ // (`ok`) probes don't render at all. When a probe carries a nextStep,
357
+ // it renders as an indented continuation line beneath the row — see
358
+ // `reference/principles.md` principle 1 ("If they need the docs, we've
359
+ // failed"): every failure surface tells the user what to run.
302
360
  if (probes) {
303
361
  for (const key of PROBE_KEYS) {
304
362
  const r = probes[key]
305
363
  if (!r) continue
306
364
  if (r.status === 'ok') continue
365
+ // Snoozed rows (issue dedup, this PR) — the user has seen the
366
+ // same fingerprint enough consecutive boots that we hide the row.
367
+ // The cache still tracks it; if the fingerprint changes (new
368
+ // failure mode) the snooze resets and the row reappears.
369
+ if (snoozeSet.has(key)) continue
307
370
  const dot = DOT[r.status] ?? DOT.fail
371
+ // The "Still: " prefix is reserved for rows the user has seen
372
+ // before (consecutiveBoots > 1) but hasn't been snoozed yet.
373
+ // We can't compute that from probes alone — the caller signals
374
+ // it implicitly: a degraded/fail row that's NOT in snoozeRows
375
+ // and NOT in resolvedRows is either novel or still-being-shown.
376
+ // We surface the existing row format unchanged here; the
377
+ // "Still:" / "New:" distinction is conveyed by which rows the
378
+ // user does or doesn't see across consecutive boots.
308
379
  degradedRows.push(`${dot} <b>${PROBE_LABELS[key]}</b> ${escapeHtml(r.detail)}`)
380
+ if (r.nextStep) {
381
+ degradedRows.push(` ↳ ${renderNextStep(r.nextStep)}`)
382
+ }
309
383
  }
310
384
  }
311
385
 
312
- // Per-account quota section (issue #708) — one line per enabled
313
- // account showing 5h % / 7d % / nearest reset, with the active
314
- // account marked. Renders alongside the ack line so users see
315
- // headroom without running /auth or /usage.
316
- const accountRows = renderAccountRows(opts.accounts, opts.now ?? new Date())
386
+ // Per-account auth section (issue #708, RFC H rewire) — one line
387
+ // per known account with the active account marked. Renders
388
+ // alongside the ack line so users see headroom without running
389
+ // /auth or /usage. Source of truth: auth-broker list-state.
390
+ const accountRows = opts.accounts
391
+ ? renderAuthLine(opts.accounts, agentName, (opts.now ?? new Date()).getTime())
392
+ : []
317
393
 
318
394
  const sections: string[] = [ack]
319
395
  if (degradedRows.length > 0) sections.push('', ...degradedRows)
@@ -323,31 +399,12 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
323
399
  }
324
400
 
325
401
  /**
326
- * Render the per-account quota rows. Returns an empty array when no
327
- * accounts are passed keeping the boot card's silent-when-healthy
328
- * default for callers that don't supply account data.
329
- *
330
- * Reuses the dashboard's `formatAccountQuotaLine` so the two surfaces
331
- * speak with one voice.
402
+ * Re-export the broker-fed auth-row renderer under its historical
403
+ * name so direct callers (tests, harnesses) keep working without
404
+ * importing two modules. New code should import `renderAuthLine`
405
+ * from `./auth-line.js` directly.
332
406
  */
333
- export function renderAccountRows(
334
- accounts: ReadonlyArray<AccountSummary> | undefined,
335
- now: Date,
336
- ): string[] {
337
- if (!accounts || accounts.length === 0) return []
338
- const rows: string[] = []
339
- rows.push(`<b>Accounts (${accounts.length})</b>`)
340
- const nowMs = now.getTime()
341
- for (const a of accounts) {
342
- const marker = a.activeForThisAgent ? '▶' : '↳'
343
- const labelHtml = `<code>${escapeHtml(a.label)}</code>`
344
- // formatAccountQuotaLine returns HTML (with <i> tags) so we don't
345
- // re-escape — pass it through verbatim.
346
- const quotaLine = formatAccountQuotaLine(a, nowMs)
347
- rows.push(quotaLine ? `${marker} ${labelHtml} ${quotaLine}` : `${marker} ${labelHtml}`)
348
- }
349
- return rows
350
- }
407
+ export { renderAuthLine as renderAccountRows } from './auth-line.js'
351
408
 
352
409
  // ─── Probe orchestration ─────────────────────────────────────────────────────
353
410
 
@@ -368,6 +425,15 @@ export interface RunProbesOpts {
368
425
  restartReason?: RestartReason
369
426
  /** Age of the restart marker in ms — shown in the crash row. */
370
427
  restartAgeMs?: number
428
+ /** Free-form reason text from `clean-shutdown.json` (e.g.
429
+ * `"operator: switchroom update"`, `"user: /restart from chat"`).
430
+ * Used to silence the boot-card notification for operator-initiated
431
+ * redeploys: routine fleet updates shouldn't ping every user every
432
+ * time. `user:` reasons (and crash / fresh) still notify normally —
433
+ * the user asked for that restart, so the boot card should
434
+ * announce. The text is also passed through unchanged so future
435
+ * surfaces can render it. */
436
+ restartReasonDetail?: string
371
437
  /** Override fetch for tests. */
372
438
  fetchImpl?: typeof fetch
373
439
  /** Override settle window for tests; production uses SETTLE_WINDOW_MS. */
@@ -400,12 +466,21 @@ export interface RunProbesOpts {
400
466
  * during the post-settle re-render so the first paint stays fast.
401
467
  */
402
468
  loadAccounts?: () =>
403
- | ReadonlyArray<AccountSummary>
469
+ | ListStateData
404
470
  | null
405
- | Promise<ReadonlyArray<AccountSummary> | null>
471
+ | Promise<ListStateData | null>
406
472
  /** When true, resolve the agent PID via cgroup walk instead of MainPID
407
473
  * (which is the tmux server pid under tmux supervisor). */
408
474
  tmuxSupervisor?: boolean
475
+ /** Path to the per-agent boot-issue cache file. When set, the
476
+ * post-settle render applies snooze + resolved-row dedup against
477
+ * prior boots (see `boot-issue-cache.ts`). Omit to disable dedup
478
+ * entirely (legacy behaviour). */
479
+ bootIssueCachePath?: string
480
+ /** Override snoozeBoots threshold for tests. */
481
+ snoozeBoots?: number
482
+ /** Override snoozeMs threshold for tests. */
483
+ snoozeMs?: number
409
484
  /** When true, the gateway is running inside an agent docker container.
410
485
  * Probes that depend on systemctl (Agent, Crons) switch to /proc walks
411
486
  * and externally-managed surface text instead of execing systemctl
@@ -424,7 +499,7 @@ export async function runAllProbes(opts: RunProbesOpts): Promise<ProbeMap> {
424
499
  const slug = opts.agentSlug ?? opts.agentName
425
500
 
426
501
  await Promise.allSettled([
427
- probeAccount(opts.agentDir).then(r => { probes.account = r }),
502
+ probeAccount(opts.agentDir, { agentName: opts.agentSlug ?? opts.agentName }).then(r => { probes.account = r }),
428
503
  probeAgentProcess(slug, { execFileImpl: opts.probeExecFileImpl, tmuxSupervisor: opts.tmuxSupervisor, dockerMode: opts.dockerMode }).then(r => { probes.agent = r }),
429
504
  probeGateway(opts.gatewayInfo).then(r => { probes.gateway = r }),
430
505
  probeQuota(claudeDir, opts.agentDir, opts.fetchImpl).then(r => { probes.quota = r }),
@@ -432,7 +507,7 @@ export async function runAllProbes(opts: RunProbesOpts): Promise<ProbeMap> {
432
507
  probeScheduler(slug, { dockerMode: opts.dockerMode }).then(r => { probes.scheduler = r }),
433
508
  probeBroker(undefined, { dockerMode: opts.dockerMode }).then(r => { probes.broker = r }),
434
509
  probeKernel(undefined, { dockerMode: opts.dockerMode }).then(r => { probes.kernel = r }),
435
- probeSkills(opts.agentDir).then(r => { probes.skills = r }),
510
+ probeSkills(opts.agentDir, { agentName: opts.agentSlug ?? opts.agentName }).then(r => { probes.skills = r }),
436
511
  ])
437
512
 
438
513
  return probes
@@ -461,11 +536,24 @@ export async function startBootCard(
461
536
  // confirmation that the agent is back without waiting on probes.
462
537
  const ackText = renderBootCard({
463
538
  agentName: opts.agentName,
539
+ agentSlug: opts.agentSlug,
464
540
  version: opts.version,
465
541
  restartReason: opts.restartReason,
466
542
  restartAgeMs: opts.restartAgeMs,
467
543
  })
468
544
 
545
+ // Silence the notification for operator-initiated redeploys. A
546
+ // routine `switchroom update` should land in the chat as a record
547
+ // but not buzz every user's phone — every agent posts a card, so
548
+ // a fleet update with N agents produces N notifications otherwise.
549
+ // We key on the reason-text prefix `operator:` (today only
550
+ // `operator: switchroom update` writes this) so user-initiated
551
+ // restarts (`user: /restart from chat`, `cli: switchroom restart`)
552
+ // and unplanned events (crash, fresh, planned-marker) keep their
553
+ // normal notification behaviour — the user explicitly asked for
554
+ // those, or they need to know something went wrong.
555
+ const silentBootCard = opts.restartReasonDetail?.startsWith('operator:') === true
556
+
469
557
  let messageId: number
470
558
  try {
471
559
  const sent = await bot.sendMessage(chatId, ackText, {
@@ -473,9 +561,10 @@ export async function startBootCard(
473
561
  link_preview_options: { is_disabled: true },
474
562
  ...(threadId != null ? { message_thread_id: threadId } : {}),
475
563
  ...(ackMessageId != null ? { reply_parameters: { message_id: ackMessageId } } : {}),
564
+ ...(silentBootCard ? { disable_notification: true } : {}),
476
565
  })
477
566
  messageId = sent.message_id
478
- logger(`telegram gateway: boot-card: posted msgId=${messageId} chatId=${chatId} reason=${opts.restartReason ?? '-'}\n`)
567
+ logger(`telegram gateway: boot-card: posted msgId=${messageId} chatId=${chatId} reason=${opts.restartReason ?? '-'} reason_detail=${opts.restartReasonDetail ?? '-'} silent=${silentBootCard}\n`)
479
568
  } catch (err: unknown) {
480
569
  logger(`telegram gateway: boot-card: failed to post ack: ${(err as Error)?.message ?? String(err)}\n`)
481
570
  return { messageId: -1, complete: () => {} }
@@ -501,7 +590,7 @@ export async function startBootCard(
501
590
  // Per-account rows (issue #708). Loaded best-effort
502
591
  // alongside probes; failures are swallowed so the card still
503
592
  // renders correctly with no accounts section.
504
- let accountRows: ReadonlyArray<AccountSummary> | null = null
593
+ let accountRows: ListStateData | null = null
505
594
  if (opts.loadAccounts) {
506
595
  try {
507
596
  accountRows = await opts.loadAccounts()
@@ -514,14 +603,49 @@ export async function startBootCard(
514
603
  }
515
604
  }
516
605
 
606
+ // Issue-dedup diff: when a cache path is configured, compare this
607
+ // boot's probe outcomes against the cached fingerprints to derive
608
+ // resolvedRows (was bad, now ok) and snoozeRows (same fingerprint
609
+ // shown too many consecutive boots). One disk read up-front, one
610
+ // disk write on the way out — no second write from the live-watch
611
+ // loop below (which reuses the same masks).
612
+ let diff: ProbeDiffMap = {}
613
+ let resolvedRows: import('./boot-card.js').ProbeKey[] = []
614
+ let snoozeRows: import('./boot-card.js').ProbeKey[] = []
615
+ if (opts.bootIssueCachePath) {
616
+ try {
617
+ const cache = loadBootIssueCache(opts.bootIssueCachePath)
618
+ diff = diffBootProbes(probes, cache, {
619
+ snoozeBoots: opts.snoozeBoots,
620
+ snoozeMs: opts.snoozeMs,
621
+ })
622
+ for (const [k, d] of Object.entries(diff) as [import('./boot-card.js').ProbeKey, NonNullable<ProbeDiffMap[import('./boot-card.js').ProbeKey]>][]) {
623
+ if (d.resolved) resolvedRows.push(k)
624
+ if (d.snoozed) snoozeRows.push(k)
625
+ }
626
+ // Persist once. The live-watch loop reuses the same mask in
627
+ // memory; it does NOT re-write the cache.
628
+ saveBootIssueCache(opts.bootIssueCachePath, cache, diff)
629
+ } catch (diffErr: unknown) {
630
+ logger(
631
+ `telegram gateway: boot-card: issue-dedup diff failed: ${
632
+ (diffErr as Error)?.message ?? String(diffErr)
633
+ }\n`,
634
+ )
635
+ }
636
+ }
637
+
517
638
  // Render with current probe state and edit if anything changed.
518
639
  let currentText = renderBootCard({
519
640
  agentName: opts.agentName,
641
+ agentSlug: opts.agentSlug,
520
642
  version: opts.version,
521
643
  probes,
522
644
  restartReason: opts.restartReason,
523
645
  restartAgeMs: opts.restartAgeMs,
524
646
  ...(accountRows ? { accounts: accountRows } : {}),
647
+ ...(resolvedRows.length > 0 ? { resolvedRows } : {}),
648
+ ...(snoozeRows.length > 0 ? { snoozeRows } : {}),
525
649
  })
526
650
 
527
651
  if (currentText !== ackText) {
@@ -563,11 +687,16 @@ export async function startBootCard(
563
687
  const updatedProbes: ProbeMap = { ...probes, agent: agentResult }
564
688
  const updatedText = renderBootCard({
565
689
  agentName: opts.agentName,
690
+ agentSlug: opts.agentSlug,
566
691
  version: opts.version,
567
692
  probes: updatedProbes,
568
693
  restartReason: opts.restartReason,
569
694
  restartAgeMs: opts.restartAgeMs,
570
695
  ...(accountRows ? { accounts: accountRows } : {}),
696
+ // Reuse the same masks computed once above — no second
697
+ // cache write from the live-watch loop.
698
+ ...(resolvedRows.length > 0 ? { resolvedRows } : {}),
699
+ ...(snoozeRows.length > 0 ? { snoozeRows } : {}),
571
700
  })
572
701
 
573
702
  if (updatedText === currentText) continue