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
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Bounded exponential-backoff retry for gateway startup network errors.
2
+ * Bounded exponential-backoff retry for gateway startup network errors,
3
+ * with classification of the failure mode so the caller can act.
3
4
  *
4
5
  * On 2026-04-29 all five switchroom gateways silently broke at boot because
5
6
  * `api.telegram.org` was unreachable for ~27 minutes after system boot (the
@@ -9,22 +10,29 @@
9
10
  * process alive but not polling. No crash, so systemd's `Restart=always` never
10
11
  * fired. Telegram → agent delivery was dead until manual restarts.
11
12
  *
12
- * This module provides:
13
- *
14
- * `isBootNetworkError(err)` — recognises network-layer errors thrown by
15
- * grammy's HttpError wrapper and by raw fetch/Node network failures.
13
+ * Then issue #1076: a *revoked or wrong-typed* bot token returns Telegram API
14
+ * 401 `Unauthorized`. Pre-fix `gatewayStartupRetry` rethrew non-network errors
15
+ * immediately, the surrounding gateway catch block exited 1, the in-container
16
+ * `_switchroom_supervise` respawned, the new gateway re-hit 401, repeat. Ten
17
+ * restarts in <60 s tripped the supervisor cap and the gateway went silently
18
+ * dead with no operator-visible signal. This module now distinguishes 401 as
19
+ * a permanent config error, which the gateway handles by writing an issue +
20
+ * quarantine marker + exit-78 (the supervisor's "config error, don't
21
+ * restart" sentinel — see profiles/_base/start.sh.hbs).
16
22
  *
17
- * `STARTUP_RETRY_DELAYS_MS` — the chosen backoff schedule.
23
+ * This module provides:
18
24
  *
19
- * `gatewayStartupRetry(fn, opts)` — drives the retry loop. Calls `fn()` up to
20
- * `maxAttempts` times with delays from `delaysMs`. On success it resolves.
21
- * On exhaustion it calls `opts.onExhausted()` (default: `process.exit(1)`)
22
- * so systemd's `Restart=always` can restart the unit cleanly.
25
+ * `classifyStartupError(err)` — returns `'network' | 'unauthorized' | 'other'`.
26
+ * `isBootNetworkError(err)` back-compat alias for the network arm.
27
+ * `STARTUP_RETRY_DELAYS_MS` the chosen backoff schedule.
28
+ * `gatewayStartupRetry(fn, opts)` drives the retry loop.
23
29
  *
24
30
  * The function is extracted from `gateway.ts`'s top-level IIFE so it can be
25
31
  * unit-tested without spinning up the full bot runtime.
26
32
  */
27
33
 
34
+ export type StartupErrorKind = 'network' | 'unauthorized' | 'other'
35
+
28
36
  export interface StartupRetryOpts {
29
37
  /**
30
38
  * Delay schedule in milliseconds. Each attempt waits the corresponding
@@ -39,11 +47,23 @@ export interface StartupRetryOpts {
39
47
  sleep?: (ms: number) => Promise<void>
40
48
 
41
49
  /**
42
- * Called when all attempts are exhausted. Should NOT return (exit/throw).
43
- * Defaults to `process.exit(1)`.
50
+ * Called when all NETWORK retries are exhausted. Should NOT return
51
+ * (exit/throw). Defaults to `process.exit(1)` so systemd /
52
+ * `_switchroom_supervise` restart-on-failure can recycle the unit.
44
53
  */
45
54
  onExhausted?: (lastError: unknown) => never
46
55
 
56
+ /**
57
+ * Called when a startup API call returns 401 Unauthorized. The bot token
58
+ * is permanently wrong (revoked, wrong type, typo) — retrying just burns
59
+ * the supervisor restart budget. Caller should write an issue + quarantine
60
+ * marker and `process.exit(78)` (EX_CONFIG). Should NOT return.
61
+ *
62
+ * Default: same exit-1 path as `onExhausted` so callers that haven't been
63
+ * updated keep the pre-fix behaviour (rather than silently swallowing 401).
64
+ */
65
+ onUnauthorized?: (err: unknown) => never
66
+
47
67
  /** Log sink for retry progress messages. Defaults to process.stderr.write. */
48
68
  log?: (line: string) => void
49
69
  }
@@ -67,38 +87,83 @@ const DEFAULT_SLEEP = (ms: number): Promise<void> =>
67
87
  new Promise((resolve) => setTimeout(resolve, ms))
68
88
 
69
89
  /**
70
- * Returns true if `err` is a transient network-level failure that the startup
71
- * retry loop should absorb. Covers:
90
+ * Classify a startup-time error into one of:
72
91
  *
73
- * - Grammy's `HttpError` (name === 'HttpError'), which wraps fetch/ECONN errors
74
- * during `deleteWebhook` and `getMe`.
75
- * - Raw Node/fetch errors: ECONNRESET, ETIMEDOUT, ENOTFOUND, ECONNREFUSED,
76
- * fetch failed, etc.
92
+ * - `network`: transient connectivity / DNS / TCP / fetch failure — the
93
+ * retry loop should absorb these with backoff.
94
+ * - `unauthorized`: Telegram API 401 (revoked or wrong-typed bot token).
95
+ * Permanent until the operator rotates the token. Retrying compounds
96
+ * the supervisor restart budget for no gain — see #1076.
97
+ * - `other`: everything else (bad request shape, 5xx, server bug, etc.).
98
+ * Rethrown to the surrounding gateway catch block, which exits non-zero
99
+ * so the supervisor can recycle.
100
+ *
101
+ * Grammy surfaces 401 via `GrammyError` (name === 'GrammyError') with
102
+ * `error_code === 401`. Some test fixtures and node-fetch wrappers surface
103
+ * 401 only in the message string, so we fall through to a substring match
104
+ * for `Unauthorized` as defence in depth.
77
105
  */
78
- export function isBootNetworkError(err: unknown): boolean {
79
- if (!(err instanceof Error)) return false
80
- // Grammy wraps network errors in HttpError (name is set in the constructor)
81
- if (err.name === 'HttpError') return true
106
+ export function classifyStartupError(err: unknown): StartupErrorKind {
107
+ if (!(err instanceof Error)) return 'other'
108
+
109
+ // Unauthorized (#1076). Check BEFORE the network arm so a Grammy-wrapped
110
+ // 401 doesn't accidentally match the "Network request" substring branch
111
+ // through some future change to grammy's error stringification.
112
+ const errAny = err as Error & {
113
+ error_code?: number
114
+ name?: string
115
+ }
116
+ if (
117
+ errAny.name === 'GrammyError' &&
118
+ errAny.error_code === 401
119
+ ) {
120
+ return 'unauthorized'
121
+ }
122
+ // Fall-back string match. Telegram's API returns the literal token
123
+ // 'Unauthorized' for 401 in the description field. We avoid a substring
124
+ // of just '401' here because that can match unrelated error codes /
125
+ // ports / numeric content.
126
+ if (err.message.includes('Unauthorized')) return 'unauthorized'
127
+
128
+ // Network arm — grammy wraps fetch/ECONN errors in HttpError.
129
+ if (err.name === 'HttpError') return 'network'
82
130
  const msg = err.message
83
- return (
131
+ if (
84
132
  msg.includes('ECONNRESET') ||
85
133
  msg.includes('ETIMEDOUT') ||
86
134
  msg.includes('ENOTFOUND') ||
87
135
  msg.includes('ECONNREFUSED') ||
88
136
  msg.includes('fetch failed') ||
89
137
  msg.includes('Network request')
90
- )
138
+ ) {
139
+ return 'network'
140
+ }
141
+
142
+ return 'other'
143
+ }
144
+
145
+ /**
146
+ * Returns true if `err` is a transient network-level failure that the startup
147
+ * retry loop should absorb. Retained as a named export for the existing
148
+ * regression tests and downstream callers that only care about the network
149
+ * arm. Prefer `classifyStartupError` for new code.
150
+ */
151
+ export function isBootNetworkError(err: unknown): boolean {
152
+ return classifyStartupError(err) === 'network'
91
153
  }
92
154
 
93
155
  /**
94
- * Attempt `fn()` and retry on `isBootNetworkError` failures using the
95
- * provided delay schedule.
156
+ * Attempt `fn()` and retry on network failures using the provided delay
157
+ * schedule.
96
158
  *
97
159
  * - On success: returns whatever `fn()` resolved to.
98
- * - On non-network error: re-throws immediately (not a transient boot issue).
99
- * - On exhausted retries: calls `opts.onExhausted(lastError)` which must not
100
- * return (it should exit or throw). The default is `process.exit(1)` so
101
- * systemd's `Restart=always` picks up the dead unit.
160
+ * - On unauthorized (401): calls `opts.onUnauthorized(err)` which must not
161
+ * return. The gateway uses this to write an issue + quarantine marker
162
+ * + `process.exit(78)`. Default is `process.exit(1)` for back-compat.
163
+ * - On other non-network error: re-throws immediately (not a transient
164
+ * boot issue, not a known config error).
165
+ * - On exhausted network retries: calls `opts.onExhausted(lastError)` which
166
+ * must not return. Default is `process.exit(1)`.
102
167
  */
103
168
  export async function gatewayStartupRetry<T>(
104
169
  fn: () => Promise<T>,
@@ -114,6 +179,16 @@ export async function gatewayStartupRetry<T>(
114
179
  )
115
180
  process.exit(1)
116
181
  })
182
+ const onUnauthorized: (err: unknown) => never =
183
+ opts.onUnauthorized ??
184
+ ((err: unknown) => {
185
+ // Back-compat default. Real callers (gateway.ts) override this with
186
+ // an issue-sink writer + quarantine-marker writer + exit-78.
187
+ process.stderr.write(
188
+ `telegram gateway: startup unauthorized (bot token rejected) — exiting: ${(err as Error).message}\n`,
189
+ )
190
+ process.exit(1)
191
+ })
117
192
  const log =
118
193
  opts.log ??
119
194
  ((line: string) => {
@@ -127,7 +202,10 @@ export async function gatewayStartupRetry<T>(
127
202
  try {
128
203
  return await fn()
129
204
  } catch (err) {
130
- if (!isBootNetworkError(err)) throw err
205
+ const kind = classifyStartupError(err)
206
+ if (kind === 'unauthorized') return onUnauthorized(err)
207
+ if (kind === 'other') throw err
208
+ // network
131
209
  lastError = err
132
210
  if (attempt >= maxAttempts) break
133
211
  const delayMs = delays[attempt - 1]
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Pure builders for the synthetic `vault_grant_approved` and
3
+ * `vault_grant_denied` inbounds the gateway injects after the
4
+ * operator taps Approve / Deny on a `vault_request_access` card
5
+ * (#1052 / #1150).
6
+ *
7
+ * Extracted from `gateway.ts` so the InboundMessage shape is pinned
8
+ * by tests separate from the broker/IPC plumbing. The shape is
9
+ * load-bearing — it carries the `meta.source` field the bridge keys
10
+ * on when rendering `<channel source="vault_grant_approved">` /
11
+ * `<channel source="vault_grant_denied">` blocks for the model, and
12
+ * the `meta.{agent,key,scope,stage_id,operator_id}` fields that
13
+ * downstream filters / dashboards may anchor on.
14
+ *
15
+ * A regression that drops a meta field or changes the source string
16
+ * would silently break the agent's wake-up flow — the bridge wouldn't
17
+ * recognize the source and route as a generic channel event, the
18
+ * model wouldn't know it was an approval response, and the
19
+ * conversation would drift. Pinning the builders against fixture
20
+ * tests is cheaper than catching that downstream.
21
+ */
22
+
23
+ import type { InboundMessage } from './ipc-protocol.js'
24
+
25
+ /** Subset of the pending-request state the builders need. Kept narrow
26
+ * so callers don't have to pass the full PendingVaultRequestAccess. */
27
+ export interface VaultGrantInboundContext {
28
+ agent: string
29
+ key: string
30
+ scope: 'read' | 'write'
31
+ /** Telegram chat id where the approval card lived. Used as the
32
+ * inbound's chatId — keeps the synthesized turn associated with
33
+ * the conversation that triggered the request. */
34
+ chat_id: string
35
+ /** Seconds. For approved grants; ignored for deny. */
36
+ ttl_seconds: number
37
+ }
38
+
39
+ /**
40
+ * Build the synthetic InboundMessage for a successful operator
41
+ * approval. Meta fields are pinned by tests.
42
+ *
43
+ * @param ctx Per-request context (agent, key, scope, chat).
44
+ * @param grantId Broker-returned grant id (e.g. "vg_a1b2c3").
45
+ * @param stageId The card's stage id from the approval flow.
46
+ * @param operatorId Telegram user id of the approving operator
47
+ * (string for portability — Telegram ids are
48
+ * numeric but routinely round-trip as strings).
49
+ * @param nowMs Wall-clock ms. Used for both `ts` and
50
+ * `messageId` so the helper is deterministic
51
+ * under fake clock. Defaults to `Date.now()`.
52
+ */
53
+ export function buildVaultGrantApprovedInbound(opts: {
54
+ ctx: VaultGrantInboundContext
55
+ grantId: string
56
+ stageId: string
57
+ operatorId: string
58
+ nowMs?: number
59
+ }): InboundMessage {
60
+ const ts = opts.nowMs ?? Date.now()
61
+ const days = Math.round(opts.ctx.ttl_seconds / 86400)
62
+ return {
63
+ type: 'inbound',
64
+ chatId: opts.ctx.chat_id,
65
+ messageId: ts, // synthetic — no Telegram message id exists
66
+ user: 'vault-broker',
67
+ userId: 0,
68
+ ts,
69
+ text:
70
+ `✅ Operator approved your vault access request for ` +
71
+ `\`${opts.ctx.key}\` (scope=${opts.ctx.scope}, ` +
72
+ `${days}d, grant=${opts.grantId}). ` +
73
+ `The token has been written. Please resume the task that was ` +
74
+ `waiting on this credential — fetch via the usual switchroom vault ` +
75
+ `get path.`,
76
+ meta: {
77
+ source: 'vault_grant_approved',
78
+ agent: opts.ctx.agent,
79
+ key: opts.ctx.key,
80
+ scope: opts.ctx.scope,
81
+ grant_id: opts.grantId,
82
+ stage_id: opts.stageId,
83
+ operator_id: opts.operatorId,
84
+ },
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Build the synthetic InboundMessage for an operator denial.
90
+ *
91
+ * The text steers the model toward a fallback path (apologise, try a
92
+ * different approach, skip the feature) — added in #1156 alongside
93
+ * the buffer-on-disconnect fix because the deny side had the same
94
+ * agent-stays-idle bug as the approve side.
95
+ */
96
+ export function buildVaultGrantDeniedInbound(opts: {
97
+ ctx: VaultGrantInboundContext
98
+ stageId: string
99
+ operatorId: string
100
+ nowMs?: number
101
+ }): InboundMessage {
102
+ const ts = opts.nowMs ?? Date.now()
103
+ return {
104
+ type: 'inbound',
105
+ chatId: opts.ctx.chat_id,
106
+ messageId: ts,
107
+ user: 'vault-broker',
108
+ userId: 0,
109
+ ts,
110
+ text:
111
+ `🚫 Operator denied your vault access request for ` +
112
+ `\`${opts.ctx.key}\` (scope=${opts.ctx.scope}). ` +
113
+ `The credential is unavailable — pick a fallback for the original task ` +
114
+ `(apologise to the user, try a different approach, or skip the feature). ` +
115
+ `Do NOT re-request this key without first asking the user.`,
116
+ meta: {
117
+ source: 'vault_grant_denied',
118
+ agent: opts.ctx.agent,
119
+ key: opts.ctx.key,
120
+ scope: opts.ctx.scope,
121
+ stage_id: opts.stageId,
122
+ operator_id: opts.operatorId,
123
+ },
124
+ }
125
+ }
@@ -195,6 +195,69 @@ export function _resetForTests(): void {
195
195
  }
196
196
  }
197
197
 
198
+ /**
199
+ * Issue a WAL checkpoint on the history DB, releasing `*.db-wal` pages
200
+ * back to the main DB and truncating the WAL file. Called by the
201
+ * gateway's periodic reaper so the WAL doesn't grow unbounded in
202
+ * long-running agent sessions (issue #1073).
203
+ *
204
+ * Wrapped in try/catch — `PRAGMA wal_checkpoint(TRUNCATE)` can return
205
+ * SQLITE_BUSY under reader pressure, which bun:sqlite raises as a thrown
206
+ * error. That's non-fatal; the next reaper tick retries. Returns true
207
+ * on success, false on a swallowed error.
208
+ *
209
+ * No-op (returns false) if `initHistory` was never called.
210
+ */
211
+ export function checkpointWal(): boolean {
212
+ if (db == null) return false
213
+ try {
214
+ db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').run()
215
+ return true
216
+ } catch {
217
+ return false
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Prune `messages` rows older than `retentionDays`. Used by the periodic
223
+ * reaper (#1073) to catch the case where the gateway runs for weeks or
224
+ * months — the init-time prune only fires once at boot.
225
+ *
226
+ * Returns the number of rows deleted (sum across all batches). No-op
227
+ * if `retentionDays <= 0` or if `initHistory` was never called.
228
+ *
229
+ * Batched to keep transactions short; otherwise a years-old DB on first
230
+ * boot after an upgrade would lock the inbound write path for the duration
231
+ * of a single multi-million-row DELETE. Uses the rowid-subselect form
232
+ * because bun:sqlite is built without SQLITE_ENABLE_UPDATE_DELETE_LIMIT
233
+ * (same constraint as reaper.ts).
234
+ */
235
+ export function pruneMessagesOlderThanDays(
236
+ retentionDays: number,
237
+ nowSec?: number,
238
+ batchLimit = 5000,
239
+ ): number {
240
+ if (db == null) return 0
241
+ if (retentionDays <= 0) return 0
242
+ const cutoffSec = (nowSec ?? Math.floor(Date.now() / 1000)) - retentionDays * 86400
243
+ const stmt = db.prepare(`
244
+ DELETE FROM messages
245
+ WHERE rowid IN (
246
+ SELECT rowid FROM messages WHERE ts < ? LIMIT ?
247
+ )
248
+ `)
249
+ let total = 0
250
+ // Same defence-in-depth ceiling as reaper.ts — caps a single call at
251
+ // 5M rows at the default batch size, more than any healthy fleet.
252
+ for (let i = 0; i < 1000; i++) {
253
+ const result = stmt.run(cutoffSec, batchLimit) as { changes: number }
254
+ const n = result.changes ?? 0
255
+ total += n
256
+ if (n === 0) break
257
+ }
258
+ return total
259
+ }
260
+
198
261
  function requireDb(): SqliteDatabase {
199
262
  if (db == null) {
200
263
  throw new Error('history: initHistory() must be called before any record/query operation')
@@ -430,6 +493,34 @@ export function getLatestInboundMessageId(
430
493
  return row?.message_id ?? null
431
494
  }
432
495
 
496
+ /**
497
+ * Look up the role + text of a single message by (chat_id, message_id).
498
+ * Returns `null` if no row exists (the message predates history, the
499
+ * row was reaped, or history is disabled). Used by the reaction-trigger
500
+ * handler (#1074) to decide whether a reacted-to message is bot-authored
501
+ * AND to pull the preview text for the synthesized inbound — both in
502
+ * one DB hit, so the trigger predicate doesn't need a Telegram API call
503
+ * per reaction.
504
+ *
505
+ * Telegram message_ids are unique within a chat regardless of thread,
506
+ * so we match on (chat_id, message_id) and ignore thread_id — same as
507
+ * recordEdit / recordReaction.
508
+ */
509
+ export function lookupMessageRoleAndText(
510
+ chatId: string,
511
+ messageId: number,
512
+ ): { role: 'user' | 'assistant'; text: string } | null {
513
+ const row = requireDb()
514
+ .prepare(
515
+ `SELECT role, text FROM messages WHERE chat_id = ? AND message_id = ? LIMIT 1`,
516
+ )
517
+ .get(chatId, messageId) as
518
+ | { role: 'user' | 'assistant'; text: string | null }
519
+ | undefined
520
+ if (!row) return null
521
+ return { role: row.role, text: row.text ?? '' }
522
+ }
523
+
433
524
  export function getRecentOutboundCount(
434
525
  chatId: string,
435
526
  withinSeconds: number,
@@ -40,6 +40,16 @@
40
40
  "timeout": 10
41
41
  }
42
42
  ]
43
+ },
44
+ {
45
+ "matcher": ".*",
46
+ "hooks": [
47
+ {
48
+ "type": "command",
49
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/sandbox-hint-posttool.mjs\"",
50
+ "timeout": 3
51
+ }
52
+ ]
43
53
  }
44
54
  ],
45
55
  "Stop": [
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PostToolUse hook — detects sandbox-related errors in tool_response and
4
+ * injects a one-line hint via Claude Code's `hookSpecificOutput.
5
+ * additionalContext` channel. The hint reminds the agent that the
6
+ * read-only file system / EROFS error is the switchroom sandbox working
7
+ * as intended, and that it should respond to the user with a concrete
8
+ * "Operator action: ..." line rather than retrying or echoing the raw
9
+ * kernel error.
10
+ *
11
+ * Pairs with the SANDBOX_GUIDANCE primer in --append-system-prompt
12
+ * (src/agents/scaffold.ts). The primer is the always-on context; this
13
+ * hook is the just-in-time nudge that fires only when the agent
14
+ * actually hits the boundary.
15
+ *
16
+ * Claude Code PostToolUse protocol:
17
+ * stdin: JSON { tool_name, tool_use_id, tool_input, tool_response, ... }
18
+ * stdout: optional JSON
19
+ * {"hookSpecificOutput":{"hookEventName":"PostToolUse",
20
+ * "additionalContext":"<text>"}}
21
+ * prepended to the model's next-turn context after the tool
22
+ * result is shown.
23
+ * exit: 0 always. Hook failures must never block the tool flow.
24
+ *
25
+ * Design notes:
26
+ * - Detection is a substring/regex match against the stringified
27
+ * tool_response (covers stdout, stderr, error fields).
28
+ * - No DB writes, no IPC. Pure stdin → stdout, fail-silent.
29
+ * - Idempotent: re-reading the same tool_response yields the same
30
+ * hint. Claude Code dedupes additionalContext naturally because the
31
+ * hook fires once per PostToolUse event.
32
+ */
33
+
34
+ import { readFileSync } from 'node:fs'
35
+
36
+ function readStdin() {
37
+ try {
38
+ return readFileSync(0, 'utf8')
39
+ } catch {
40
+ return ''
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Patterns that indicate a sandbox-boundary hit, in order of specificity.
46
+ * Each entry: [regex, hint-key]. Hint text is composed below from the
47
+ * matched key — keeps the patterns easy to scan.
48
+ */
49
+ const PATTERNS = [
50
+ // The canonical kernel error code + message. Covers most write/mkdir/
51
+ // rename/unlink failures against the read-only rootfs.
52
+ [/\bEROFS\b/, 'erofs'],
53
+ [/read[- ]only file ?system/i, 'erofs'],
54
+ // npm/pip install attempts that hit a read-only prefix. These usually
55
+ // surface as ENOENT or permission errors against /usr/lib/node_modules
56
+ // or /usr/local/lib — listing the explicit paths keeps us from
57
+ // false-matching on user code that legitimately mentions /usr.
58
+ [/EACCES.+\/(usr|opt|etc|bin|lib)\//, 'eacces-rootfs'],
59
+ // apt / dpkg refusing to write to /var/lib/dpkg etc.
60
+ [/dpkg.*permission denied|apt.*permission denied|Unable to acquire the dpkg/i, 'apt'],
61
+ ]
62
+
63
+ function buildHint(key) {
64
+ const common =
65
+ 'Sandbox boundary hit. The agent container has `read_only: true` rootfs ' +
66
+ '(see the SANDBOX primer in the system prompt). Do NOT retry the same ' +
67
+ 'write. Tell the user what you tried, why the sandbox blocked it, and ' +
68
+ 'name an operator action (e.g. "edit on host then `switchroom apply`", ' +
69
+ 'or "add to docker/Dockerfile.agent and rebuild"). Writable paths: ' +
70
+ '$HOME (/state/agent/home), /tmp, /state/agent/**, /var/log/switchroom.'
71
+
72
+ if (key === 'apt') {
73
+ return (
74
+ common +
75
+ ' For package installs specifically: ask the operator to add the ' +
76
+ 'package to docker/Dockerfile.agent and rebuild the agent image — ' +
77
+ 'in-container apt is not the right path.'
78
+ )
79
+ }
80
+ return common
81
+ }
82
+
83
+ function emitContext(text) {
84
+ const payload = {
85
+ hookSpecificOutput: {
86
+ hookEventName: 'PostToolUse',
87
+ additionalContext: text,
88
+ },
89
+ }
90
+ process.stdout.write(JSON.stringify(payload) + '\n')
91
+ }
92
+
93
+ function main() {
94
+ const raw = readStdin()
95
+ if (!raw) return
96
+
97
+ let evt
98
+ try {
99
+ evt = JSON.parse(raw)
100
+ } catch {
101
+ return
102
+ }
103
+
104
+ // tool_response shape varies by tool — string for Bash, object with
105
+ // file/oldString/newString for Edit/Write, etc. Stringify the whole
106
+ // thing so we match against every nested error field at once. Cap the
107
+ // scan window to keep memory bounded if the model just dumped a 10MB
108
+ // log into the tool_response.
109
+ let body
110
+ try {
111
+ body = JSON.stringify(evt.tool_response ?? '')
112
+ } catch {
113
+ return
114
+ }
115
+ if (!body) return
116
+ if (body.length > 64 * 1024) body = body.slice(0, 64 * 1024)
117
+
118
+ for (const [pattern, key] of PATTERNS) {
119
+ if (pattern.test(body)) {
120
+ emitContext(buildHint(key))
121
+ return
122
+ }
123
+ }
124
+ }
125
+
126
+ try {
127
+ main()
128
+ } catch {
129
+ // Fail-silent. The PostToolUse must never block the tool flow.
130
+ }
@@ -11,7 +11,13 @@
11
11
  * block the tool response.
12
12
  *
13
13
  * DB location: <agentDir>/telegram/registry.db
14
- * agentDir = SWITCHROOM_AGENT_DIR env var, falling back to process.cwd()
14
+ * agentDir lookup (first hit wins):
15
+ * 1. SWITCHROOM_AGENT_DIR env var (explicit override, mainly used in tests)
16
+ * 2. TELEGRAM_STATE_DIR with `/telegram` suffix stripped — the canonical
17
+ * env var start.sh exports on every switchroom agent. See the
18
+ * sibling pretool hook docblock for why this lookup matters (without
19
+ * it the hook used to write to a registry.db nobody read).
20
+ * 3. process.cwd() (legacy fallback for ad-hoc invocations).
15
21
  *
16
22
  * Performance: the actual DB write is deferred via setImmediate (Node 22+
17
23
  * node:sqlite path) or non-blocking spawn (CLI fallback) so the hook returns
@@ -268,7 +274,18 @@ function main() {
268
274
  const id = event.tool_use_id ?? null
269
275
  if (!id) process.exit(0)
270
276
 
271
- const agentDir = process.env.SWITCHROOM_AGENT_DIR ?? process.cwd()
277
+ // Same agent-dir resolution as the pretool hook (Bug 2 fix). Without
278
+ // the TELEGRAM_STATE_DIR derivation the posttool would write the
279
+ // `ended_at` row to a registry.db nobody reads, even though the row
280
+ // was originally inserted by the pretool hook that DID write to the
281
+ // correct DB (after this PR). Keep the two hooks in lock-step.
282
+ const stateDir = process.env.TELEGRAM_STATE_DIR
283
+ const derivedFromStateDir = stateDir && stateDir.endsWith('/telegram')
284
+ ? stateDir.slice(0, -'/telegram'.length)
285
+ : null
286
+ const agentDir = process.env.SWITCHROOM_AGENT_DIR
287
+ ?? derivedFromStateDir
288
+ ?? process.cwd()
272
289
  const dbPath = join(agentDir, 'telegram', 'registry.db')
273
290
 
274
291
  // If DB doesn't exist yet, nothing to update
@@ -11,7 +11,17 @@
11
11
  * block the tool call.
12
12
  *
13
13
  * DB location: <agentDir>/telegram/registry.db
14
- * agentDir = SWITCHROOM_AGENT_DIR env var, falling back to process.cwd()
14
+ * agentDir lookup (first hit wins):
15
+ * 1. SWITCHROOM_AGENT_DIR env var (explicit override, mainly used in tests)
16
+ * 2. TELEGRAM_STATE_DIR with `/telegram` suffix stripped — the canonical
17
+ * env var start.sh exports for every switchroom agent (and the same
18
+ * path the gateway + watcher resolve their DB through). Without this
19
+ * the hook used to fall through to process.cwd() in production,
20
+ * writing to a registry.db nobody read, leaving every bg sub-agent
21
+ * invisible to the watcher. Surfaced by
22
+ * bg-sub-agent-dispatch-dm.test.ts; see RFC Phase 2 §Bug 2 in
23
+ * reference/sub-agent-visibility-rfc.md.
24
+ * 3. process.cwd() (legacy fallback for ad-hoc invocations).
15
25
  *
16
26
  * Performance: the actual DB write is deferred via setImmediate (Node 22+
17
27
  * node:sqlite path) or a non-blocking spawn (CLI fallback) so the hook
@@ -223,7 +233,17 @@ function main() {
223
233
  // misroute).
224
234
  if (event.tool_name !== 'Agent' && event.tool_name !== 'Task') process.exit(0)
225
235
 
226
- const agentDir = process.env.SWITCHROOM_AGENT_DIR ?? process.cwd()
236
+ // Resolve agent dir: explicit env override → derive from TELEGRAM_STATE_DIR
237
+ // (start.sh exports this on every agent) → cwd fallback. The middle case
238
+ // is the production path; without it the hook silently wrote to a
239
+ // registry.db nobody read (#709 / #776 / #782 / #788 Bug 2).
240
+ const stateDir = process.env.TELEGRAM_STATE_DIR
241
+ const derivedFromStateDir = stateDir && stateDir.endsWith('/telegram')
242
+ ? stateDir.slice(0, -'/telegram'.length)
243
+ : null
244
+ const agentDir = process.env.SWITCHROOM_AGENT_DIR
245
+ ?? derivedFromStateDir
246
+ ?? process.cwd()
227
247
  const telegramDir = join(agentDir, 'telegram')
228
248
  const dbPath = join(telegramDir, 'registry.db')
229
249
 
@@ -111,6 +111,17 @@ export function computeLabel(toolName, input) {
111
111
  case 'KillBash':
112
112
  case 'KillShell':
113
113
  return 'Stopping background process'
114
+ case 'Skill': {
115
+ // The Skill tool's input is `{ skill: "<slug>", args?: "..." }`.
116
+ // We emit `Running skill <slug>` so downstream observers
117
+ // (notably the skill-coverage UAT runner at
118
+ // telegram-plugin/uat/runners/skill-coverage.ts) can tail the
119
+ // sidecar JSONL and recover which skill fired per turn —
120
+ // the progress card path that used to surface this was retired
121
+ // when `progressDriver` was nulled out in #1122 PR3.
122
+ const slug = clip(String(i.skill ?? ''), 64)
123
+ return slug ? `Running skill ${slug}` : null
124
+ }
114
125
  }
115
126
 
116
127
  // MCP allowlist.