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,158 @@
1
+ /**
2
+ * End-to-end UAT scenario for the agent-initiated vault_request_access
3
+ * flow — closes the test-coverage gap that allowed #1053 to ship.
4
+ *
5
+ * #1053: an agent called `vault_request_access`, operator approved,
6
+ * passphrase was entered, broker minted a grant token and wrote it
7
+ * to `.vault-token` — BUT the agent's subsequent `vault get` still
8
+ * returned VAULT-BROKER-DENIED because the CLI's get path didn't
9
+ * forward the token. Every unit test passed (gateway, broker, CLI
10
+ * each looked right in isolation) but the integration was broken.
11
+ *
12
+ * The lesson the operator drew: "test and prevent these kinds of
13
+ * things using the full telegram test bot." This scenario is that
14
+ * test — it round-trips through real Telegram, real broker, real
15
+ * agent, asserting the final state (vault get succeeds) rather
16
+ * than any single component's contract.
17
+ *
18
+ * Sibling: `vault-audit-allow-dm.test.ts` exercises the OPERATOR-
19
+ * initiated path (operator opens /vault audit, taps Allow on a
20
+ * recent denial). This scenario exercises the AGENT-initiated path
21
+ * (agent calls vault_request_access, operator approves the card
22
+ * the agent's tool emitted) — a different gateway handler
23
+ * (handleVaultRequestAccessCallback vs handleVaultRecentDenialCallback)
24
+ * but the same broker token-writing + grant-validation backend.
25
+ *
26
+ * **Skipped by default.** To unskip:
27
+ *
28
+ * 1. Standard UAT preflight (`uat/SETUP.md` §5-6) — test-harness
29
+ * agent live, driver session auth'd, env vars set.
30
+ *
31
+ * 2. **Operator passphrase visibility.** The scenario must enter
32
+ * the operator's vault passphrase in chat as part of the
33
+ * approve flow. Set `TELEGRAM_UAT_VAULT_PASSPHRASE` in the
34
+ * env so the scenario can send it. The gateway deletes the
35
+ * passphrase message from chat history immediately after
36
+ * caching it (see `deleteSensitiveMessage` in gateway.ts) so
37
+ * no plaintext lingers.
38
+ *
39
+ * 3. **Sacrificial vault key.** Same convention as the
40
+ * `/vault audit` scenario — pre-create a key the harness can
41
+ * request. Suggested:
42
+ *
43
+ * ```bash
44
+ * TMPF=$(mktemp) && printf '%s' 'sentinel-1053-value' > "$TMPF" && \
45
+ * switchroom vault set uat/req-access-target --file "$TMPF" \
46
+ * --format string ; shred -u "$TMPF"
47
+ * ```
48
+ *
49
+ * Slash-namespaced shape on purpose — also exercises #1047
50
+ * (vault-key regex allowing '/').
51
+ *
52
+ * 4. Remove `describe.skip` below.
53
+ *
54
+ * Why skipped: mutates host vault state (mints a 30-day grant on
55
+ * test-harness) — opt-in only. Cleanup is operator-side
56
+ * (`switchroom vault revoke <grant-id>` after the run).
57
+ */
58
+
59
+ import { describe, expect, it } from "vitest";
60
+ import { spinUp } from "../harness.js";
61
+
62
+ const SENTINEL_VALUE = "sentinel-1053-value";
63
+ const TARGET_KEY = "uat/req-access-target";
64
+
65
+ describe.skip("uat: vault_request_access end-to-end (#1053 regression)", () => {
66
+ it(
67
+ "agent calls tool → operator approves + enters passphrase → agent reads the value",
68
+ async () => {
69
+ const operatorPassphrase = process.env.TELEGRAM_UAT_VAULT_PASSPHRASE;
70
+ if (!operatorPassphrase) {
71
+ throw new Error(
72
+ "TELEGRAM_UAT_VAULT_PASSPHRASE must be set in env for this scenario " +
73
+ "(see SETUP.md). The scenario sends it via DM as part of the approve " +
74
+ "flow; the gateway deletes the message immediately after caching.",
75
+ );
76
+ }
77
+ const sc = await spinUp({ agent: "test-harness" });
78
+ try {
79
+ // 1. Tell the agent to call the MCP tool. The agent's reply
80
+ // is what fires the approval card — we don't fire it
81
+ // from the driver side because the WHOLE POINT is to
82
+ // cover the agent → gateway → broker → token-file
83
+ // → agent path.
84
+ await sc.sendDM(
85
+ `Please call your vault_request_access MCP tool with ` +
86
+ `key="${TARGET_KEY}", scope="read", reason="UAT regression for #1053". ` +
87
+ `Then attempt to read the key once the operator confirms.`,
88
+ );
89
+
90
+ // 2. Wait for the bot's approval card. Anchor on the
91
+ // headline emoji + tool-specific copy.
92
+ const card = await sc.expectMessage(/🔐.*wants vault access/, {
93
+ from: "bot",
94
+ timeout: 60_000,
95
+ });
96
+
97
+ // 3. Confirm the card carries the right inline keyboard.
98
+ // Locate the [✅ Approve] button.
99
+ const kb = await sc.driver.getKeyboard(sc.botUserId, card.messageId);
100
+ expect(kb).not.toBeNull();
101
+ const approveButton = kb!
102
+ .flat()
103
+ .find((b) => b.callbackData !== undefined && /approve/i.test(b.text));
104
+ expect(approveButton, "card should have an [✅ Approve] button").toBeDefined();
105
+
106
+ // 4. Tap Approve. With no cached passphrase yet, the gateway
107
+ // edits the card to prompt for the passphrase as the
108
+ // next message (vault_request_access tap-to-unlock flow
109
+ // from #1012 Phase 2 / #1034).
110
+ await sc.driver.pressButton(
111
+ sc.botUserId,
112
+ card.messageId,
113
+ approveButton!.callbackData!,
114
+ );
115
+ await sc.expectMessage(/Vault is locked.*Reply with your passphrase/, {
116
+ from: "bot",
117
+ timeout: 15_000,
118
+ });
119
+
120
+ // 5. Send the passphrase. Gateway caches it, deletes the
121
+ // chat message via deleteSensitiveMessage, then auto-
122
+ // resumes the mint flow. Card edits to the "Granted"
123
+ // state when the broker accepts the attestation.
124
+ await sc.sendDM(operatorPassphrase);
125
+ await sc.expectMessage(/Granted.*read access/, {
126
+ from: "bot",
127
+ timeout: 30_000,
128
+ });
129
+
130
+ // 6. THE LOAD-BEARING ASSERTION FOR #1053: ask the agent
131
+ // to fetch the key. The agent's `switchroom vault get`
132
+ // MUST forward the freshly-minted token. If it doesn't
133
+ // (the pre-#1053-fix state), the broker denies on the
134
+ // peercred ACL and the agent reports VAULT-BROKER-DENIED.
135
+ // Post-fix: the get succeeds and returns the sentinel
136
+ // value the operator pre-staged.
137
+ await sc.sendDM(
138
+ `Now run: switchroom vault get ${TARGET_KEY} — and tell me ` +
139
+ `exactly what the command printed (including any error markers).`,
140
+ );
141
+ const replyAfterGet = await sc.expectMessage(
142
+ new RegExp(SENTINEL_VALUE),
143
+ { from: "bot", timeout: 60_000 },
144
+ );
145
+ expect(replyAfterGet.text).toContain(SENTINEL_VALUE);
146
+ // The bot's reply MUST NOT contain the denial marker. This
147
+ // is the regression guard: a future bug that reintroduces
148
+ // the silent token-drop would surface VAULT-BROKER-DENIED
149
+ // alongside the value (or instead of it).
150
+ expect(replyAfterGet.text).not.toMatch(/VAULT-BROKER-DENIED/);
151
+ } finally {
152
+ await sc.tearDown();
153
+ }
154
+ },
155
+ 300_000, // 5 min — covers card render + Approve tap + passphrase
156
+ // round-trip + grant mint + agent's next turn + vault get.
157
+ );
158
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Voice-inbound scenario — driver sends a voice note (OGG/Opus)
3
+ * to the test bot, gateway's `voice_in` skill transcribes it,
4
+ * bot replies with text.
5
+ *
6
+ * Part of: https://github.com/switchroom/switchroom/issues/866
7
+ *
8
+ * **Gated by fixture + bot config.** To unskip:
9
+ *
10
+ * 1. Generate a 1-second silent OGG/Opus fixture:
11
+ *
12
+ * ```bash
13
+ * mkdir -p telegram-plugin/uat/fixtures/voice
14
+ * ffmpeg -f lavfi -i anullsrc=r=48000:cl=mono -t 1 \
15
+ * -c:a libopus -b:a 32k \
16
+ * telegram-plugin/uat/fixtures/voice/silence-1s.opus
17
+ * ```
18
+ *
19
+ * (Fixture is intentionally NOT committed to git — keep the repo
20
+ * light. Generate locally before unskipping.)
21
+ *
22
+ * 2. Verify the test-harness agent has `voice_in` configured. The
23
+ * default profile may not enable it; check `switchroom config
24
+ * show test-harness` for `channels.telegram.voice_in.enabled`.
25
+ *
26
+ * 3. Remove the `describe.skip` below.
27
+ *
28
+ * Why skipped by default: voice transcription costs money per call
29
+ * (OpenAI/Whisper) and slow turns are expected — keeping this off
30
+ * the default UAT path until someone explicitly tests voice.
31
+ */
32
+
33
+ import path from "node:path";
34
+ import { existsSync } from "node:fs";
35
+ import { describe, expect, it } from "vitest";
36
+ import { spinUp } from "../harness.js";
37
+
38
+ const FIXTURE = path.resolve(
39
+ __dirname,
40
+ "..",
41
+ "fixtures",
42
+ "voice",
43
+ "silence-1s.opus",
44
+ );
45
+
46
+ describe.skip("uat: voice-inbound DM round-trip", () => {
47
+ it("driver sends a voice note, bot transcribes + replies within 60s", async () => {
48
+ if (!existsSync(FIXTURE)) {
49
+ throw new Error(
50
+ `voice fixture not found at ${FIXTURE} — see scenario header to generate one`,
51
+ );
52
+ }
53
+ const sc = await spinUp({ agent: "test-harness" });
54
+ try {
55
+ await sc.driver.sendVoice(sc.botUserId, FIXTURE);
56
+ const reply = await sc.expectMessage(/.+/, {
57
+ from: "bot",
58
+ timeout: 60_000,
59
+ });
60
+ expect(reply.text.length).toBeGreaterThan(0);
61
+ } finally {
62
+ await sc.tearDown();
63
+ }
64
+ });
65
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Resolve the vault grant-card approval posture from switchroom config.
3
+ *
4
+ * Pre-#1115-follow-up this module also loaded the auto-unlock blob
5
+ * (file-based on legacy installs, broker-IPC `get_unlock_passphrase`
6
+ * on Docker) and surfaced the plaintext passphrase to the gateway so
7
+ * it could attest mint_grant calls. The reviewer flagged that as a
8
+ * bypass surface (claude in the same agent container could exfiltrate
9
+ * the passphrase via /proc or broker socket). Pivoted to broker-
10
+ * mediated attestation: the passphrase NEVER leaves the broker
11
+ * process; the gateway just signals operator-tap intent via
12
+ * `attest_via_posture: true` on mint_grant / list_grants.
13
+ *
14
+ * What this module does NOW: read `vault.broker.approvalAuth` from
15
+ * the operator's switchroom.yaml and tell the gateway whether to
16
+ * branch into the silent-mint code path. Nothing else.
17
+ *
18
+ * Behaviour:
19
+ * - `approvalAuth` absent / `passphrase` → passphrase posture.
20
+ * - `approvalAuth: telegram-id` → telegram-id posture (gateway will
21
+ * use attest_via_posture on broker calls).
22
+ * - `approvalAuth` set to anything else → passphrase posture (the
23
+ * schema rejects unknown values at startup; this is defence in
24
+ * depth in case the schema is bypassed).
25
+ */
26
+
27
+ export interface VaultBrokerPostureConfig {
28
+ approvalAuth?: string
29
+ }
30
+
31
+ export interface ResolvedPosture {
32
+ mode: 'passphrase' | 'telegram-id'
33
+ }
34
+
35
+ export function resolveVaultApprovalPosture(
36
+ broker: VaultBrokerPostureConfig | undefined,
37
+ ): ResolvedPosture {
38
+ if (broker?.approvalAuth === 'telegram-id') {
39
+ return { mode: 'telegram-id' }
40
+ }
41
+ return { mode: 'passphrase' }
42
+ }
@@ -159,6 +159,7 @@ export function helpText(agentName: string): string {
159
159
  ``,
160
160
  `<code>/start</code> — pairing instructions`,
161
161
  `<code>/status</code> — agent, model, auth`,
162
+ `<code>/vault audit &lt;agent&gt;</code> — admin: review agent's vault access + one-tap [🔓 Allow] on recent denials`,
162
163
  `<code>/commands</code> — full command list`,
163
164
  ].join("\n");
164
165
  }
@@ -1,204 +0,0 @@
1
- /**
2
- * Sweep logic for the active-pins sidecar.
3
- *
4
- * The sidecar (see `active-pins.ts`) records every progress-card
5
- * message the bot has pinned but not yet unpinned. Two lifecycle
6
- * events consume it:
7
- *
8
- * 1. Startup — when a new bot process boots, it sweeps any entries
9
- * left over from a prior session that crashed or was killed
10
- * mid-turn. Without this, the pins stay on Telegram forever
11
- * because the in-memory map that tracks them died with the old
12
- * process.
13
- *
14
- * 2. Pre-restart — when the /restart, /reconcile --restart, or
15
- * /update commands fire a self-restart, the bot proactively
16
- * unpins any still-pinned cards before it gets SIGTERM'd. This
17
- * avoids a ~1s window where the restart ack is visible in chat
18
- * but the previous turn's progress card is still pinned.
19
- *
20
- * Both consumers call `sweepActivePins`, which is shaped as a pure
21
- * function that takes the unpin callback as an argument. That keeps
22
- * it testable in isolation — the tests pass a fake unpin and assert
23
- * which pins were visited and whether the sidecar was cleared.
24
- */
25
-
26
- import { readActivePins, clearActivePins, type ActivePin } from "./active-pins.js";
27
-
28
- export type UnpinFn = (chatId: string, messageId: number) => Promise<unknown>;
29
- /**
30
- * Optional pre-unpin hook. Called once per sidecar entry before the
31
- * unpin fires. Used by the boot-time orphan-pin reaper (#689) to edit
32
- * the message body to a "Restart interrupted this work" banner, so the
33
- * user sees WHY the card stopped updating rather than silently losing
34
- * the pin.
35
- *
36
- * Hook errors are logged and swallowed: a banner edit failing must
37
- * never block the unpin (frozen card is worse than no card).
38
- */
39
- export type EditBeforeUnpinFn = (pin: ActivePin) => Promise<unknown>;
40
-
41
- export interface SweepOptions {
42
- /** Upper bound on how long to wait for all unpin calls before returning. */
43
- timeoutMs?: number;
44
- /** Optional log hook — called with human-readable progress/error lines. */
45
- log?: (msg: string) => void;
46
- /**
47
- * Optional per-pin edit hook fired BEFORE the unpin. Failures are
48
- * caught and logged; the unpin still runs. See {@link EditBeforeUnpinFn}.
49
- */
50
- editBeforeUnpin?: EditBeforeUnpinFn;
51
- }
52
-
53
- export interface SweepResult {
54
- swept: ActivePin[];
55
- timedOut: boolean;
56
- }
57
-
58
- /**
59
- * Unpin every entry in the sidecar, then clear it. Bounded by
60
- * `timeoutMs` (default 2s) so a slow Telegram API can't block a
61
- * restart indefinitely. Unpin failures are logged and swallowed —
62
- * the sidecar is cleared regardless so stale entries don't pile up
63
- * on subsequent boots.
64
- */
65
- export async function sweepActivePins(
66
- agentDir: string,
67
- unpin: UnpinFn,
68
- options: SweepOptions = {},
69
- ): Promise<SweepResult> {
70
- const log = options.log ?? (() => {});
71
- const timeoutMs = options.timeoutMs ?? 2000;
72
- const pins = readActivePins(agentDir);
73
- if (pins.length === 0) return { swept: [], timedOut: false };
74
-
75
- log(`sweeping ${pins.length} active pin(s)`);
76
- const editBeforeUnpin = options.editBeforeUnpin;
77
- const attempts = pins.map((pin) =>
78
- Promise.resolve()
79
- .then(async () => {
80
- if (editBeforeUnpin != null) {
81
- try {
82
- await editBeforeUnpin(pin);
83
- } catch (err) {
84
- // Banner edits are best-effort — message may already be gone
85
- // or the bot may have lost edit rights. Don't block unpin.
86
- const msg = err instanceof Error ? err.message : String(err);
87
- log(`banner edit failed for ${pin.chatId}/${pin.messageId}: ${msg}`);
88
- }
89
- }
90
- return unpin(pin.chatId, pin.messageId);
91
- })
92
- .catch((err: unknown) => {
93
- const msg = err instanceof Error ? err.message : String(err);
94
- log(`unpin failed for ${pin.chatId}/${pin.messageId}: ${msg}`);
95
- }),
96
- );
97
-
98
- let timedOut = false;
99
- await Promise.race([
100
- Promise.allSettled(attempts),
101
- new Promise<void>((resolve) =>
102
- setTimeout(() => {
103
- timedOut = true;
104
- resolve();
105
- }, timeoutMs),
106
- ),
107
- ]);
108
-
109
- // By design: clear the sidecar on timeout even though in-flight unpins
110
- // may not have landed. Telegram's unpin is idempotent, so a retried unpin
111
- // on the next boot is a cheap no-op, whereas keeping the sidecar entries
112
- // around would have the sweep re-fire forever whenever Telegram is slow.
113
- clearActivePins(agentDir);
114
- return { swept: pins, timedOut };
115
- }
116
-
117
- /**
118
- * A single pinned message returned from Telegram's `getChat` API,
119
- * narrowed to the fields this sweep needs. `fromId` is null when the
120
- * pinned message has no `from` (e.g., anonymous channel posts) — in
121
- * that case the sweep treats the pin as foreign and stops, since we
122
- * can only confidently unpin messages we authored ourselves.
123
- */
124
- export interface PinnedMessageInfo {
125
- messageId: number;
126
- fromId: number | null;
127
- }
128
-
129
- export type GetTopPinFn = (chatId: string) => Promise<PinnedMessageInfo | null>;
130
-
131
- export interface BotAuthoredSweepResult {
132
- /** One entry per chat — how many bot-authored pins were unpinned there. */
133
- perChat: Record<string, number>;
134
- /** Total across all chats. */
135
- total: number;
136
- }
137
-
138
- /**
139
- * Sweep bot-authored pinned messages from the given chats. Telegram's
140
- * Bot API doesn't expose a "list all pinned messages" endpoint, only
141
- * `getChat().pinned_message` which returns the topmost pin. This
142
- * iterates that endpoint: if the top pin is authored by our bot, we
143
- * unpin it and re-check — the next most recent pin bubbles up. We
144
- * stop when the top pin is either missing or authored by someone
145
- * else, which is the safe behavior: a user-pinned message acts as a
146
- * barrier so we never interfere with pins the user made themselves.
147
- *
148
- * The per-chat loop is bounded by `maxPerChat` (default 32) so a
149
- * chat with an unexpected pile of bot pins can't spin forever.
150
- * Failures from `getChat` or `unpin` are logged and tolerated — the
151
- * sweep advances to the next chat rather than aborting the boot
152
- * sequence.
153
- *
154
- * This complements `sweepActivePins`, which only touches entries
155
- * previously recorded in the sidecar. Some stale pins never land in
156
- * the sidecar (e.g., if a pin write raced a crash before `addActivePin`
157
- * ran, or if the sidecar file itself was lost). This function is the
158
- * belt-and-suspenders backstop that picks those up on the next boot.
159
- */
160
- export async function sweepBotAuthoredPins(
161
- chatIds: ReadonlyArray<string>,
162
- botUserId: number,
163
- getTopPin: GetTopPinFn,
164
- unpin: UnpinFn,
165
- options: SweepOptions & { maxPerChat?: number } = {},
166
- ): Promise<BotAuthoredSweepResult> {
167
- const log = options.log ?? (() => {});
168
- const maxPerChat = options.maxPerChat ?? 32;
169
- const perChat: Record<string, number> = {};
170
- let total = 0;
171
-
172
- for (const chatId of chatIds) {
173
- let unpinnedHere = 0;
174
- for (let i = 0; i < maxPerChat; i++) {
175
- let top: PinnedMessageInfo | null;
176
- try {
177
- top = await getTopPin(chatId);
178
- } catch (err) {
179
- const msg = err instanceof Error ? err.message : String(err);
180
- log(`getChat failed for ${chatId}: ${msg}`);
181
- break;
182
- }
183
- if (top == null) break;
184
- if (top.fromId !== botUserId) break;
185
- try {
186
- await unpin(chatId, top.messageId);
187
- unpinnedHere++;
188
- total++;
189
- } catch (err) {
190
- const msg = err instanceof Error ? err.message : String(err);
191
- log(`unpin failed for ${chatId}/${top.messageId}: ${msg}`);
192
- // If unpin fails, the top pin stays — another loop iteration
193
- // would fetch the same one and loop forever. Break out.
194
- break;
195
- }
196
- }
197
- if (unpinnedHere > 0) {
198
- perChat[chatId] = unpinnedHere;
199
- log(`unpinned ${unpinnedHere} bot-authored pin(s) in ${chatId}`);
200
- }
201
- }
202
-
203
- return { perChat, total };
204
- }
@@ -1,146 +0,0 @@
1
- /**
2
- * Pure helpers for tracking pinned progress-card message IDs across
3
- * restarts.
4
- *
5
- * The progress-card driver pins a per-turn card on first emit and
6
- * unpins on turn complete. That lifecycle is in-memory only, so a
7
- * crash or kill mid-turn leaves a pinned message with no cleanup
8
- * path — on restart, the in-memory map is empty and Telegram still
9
- * shows the stale pin.
10
- *
11
- * This module persists the set of currently-pinned cards to a
12
- * `.active-pins.json` sidecar under `$AGENT_DIR`. The server adds an
13
- * entry right after `pinChatMessage` succeeds, removes the entry
14
- * after `unpinChatMessage`, and on startup reads the file to sweep
15
- * any stale entries (best-effort unpin) before the driver starts.
16
- *
17
- * All helpers are filesystem-only — no Telegram side effects — so
18
- * they're unit-testable in isolation, mirroring `handoff-continuity.ts`.
19
- */
20
-
21
- import { readFileSync, writeFileSync, renameSync, existsSync, unlinkSync } from "node:fs";
22
- import { join } from "node:path";
23
-
24
- export const ACTIVE_PINS_FILENAME = ".active-pins.json";
25
-
26
- export interface ActivePin {
27
- chatId: string;
28
- messageId: number;
29
- turnKey: string;
30
- pinnedAt: number;
31
- /**
32
- * Per-agent identity for the pin. Optional in the on-disk shape so
33
- * sidecars written before per-agent cards (#per-agent-cards) still
34
- * parse cleanly — readers should treat a missing field as the parent
35
- * sentinel (`__parent__`).
36
- */
37
- agentId?: string;
38
- }
39
-
40
- function pinsPath(agentDir: string): string {
41
- return join(agentDir, ACTIVE_PINS_FILENAME);
42
- }
43
-
44
- /**
45
- * Read the active-pins sidecar. Missing, empty, or malformed files
46
- * return an empty array — callers never have to handle parse errors.
47
- * Entries that fail shape validation are dropped silently so a
48
- * corrupted file can't brick the startup sweep.
49
- */
50
- export function readActivePins(agentDir: string): ActivePin[] {
51
- const p = pinsPath(agentDir);
52
- if (!existsSync(p)) return [];
53
- let raw: string;
54
- try {
55
- raw = readFileSync(p, "utf-8");
56
- } catch {
57
- return [];
58
- }
59
- if (raw.trim().length === 0) return [];
60
- let parsed: unknown;
61
- try {
62
- parsed = JSON.parse(raw);
63
- } catch {
64
- return [];
65
- }
66
- if (!Array.isArray(parsed)) return [];
67
- const out: ActivePin[] = [];
68
- for (const item of parsed) {
69
- if (
70
- item != null &&
71
- typeof item === "object" &&
72
- typeof (item as ActivePin).chatId === "string" &&
73
- typeof (item as ActivePin).messageId === "number" &&
74
- typeof (item as ActivePin).turnKey === "string" &&
75
- typeof (item as ActivePin).pinnedAt === "number"
76
- ) {
77
- const aid = (item as ActivePin).agentId;
78
- const entry: ActivePin = aid != null && typeof aid === "string"
79
- ? (item as ActivePin)
80
- : { ...(item as ActivePin), agentId: undefined };
81
- out.push(entry);
82
- }
83
- }
84
- return out;
85
- }
86
-
87
- /**
88
- * Atomically overwrite the sidecar with the given list. Writing an
89
- * empty list deletes the file so a fresh restart sees no state.
90
- */
91
- export function writeActivePins(agentDir: string, pins: ActivePin[]): void {
92
- const p = pinsPath(agentDir);
93
- if (pins.length === 0) {
94
- try {
95
- unlinkSync(p);
96
- } catch {
97
- /* already gone */
98
- }
99
- return;
100
- }
101
- const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
102
- try {
103
- writeFileSync(tmp, JSON.stringify(pins) + "\n", "utf-8");
104
- renameSync(tmp, p);
105
- } catch {
106
- /* best-effort — failsafe cleanup is cosmetic, not safety-critical */
107
- }
108
- }
109
-
110
- /**
111
- * Append a new pin to the sidecar. Idempotent on `(chatId, messageId)`
112
- * — a duplicate add replaces the existing entry so `pinnedAt` reflects
113
- * the most recent pin.
114
- */
115
- export function addActivePin(agentDir: string, pin: ActivePin): void {
116
- const existing = readActivePins(agentDir).filter(
117
- (p) => !(p.chatId === pin.chatId && p.messageId === pin.messageId),
118
- );
119
- existing.push(pin);
120
- writeActivePins(agentDir, existing);
121
- }
122
-
123
- /**
124
- * Remove the pin matching `(chatId, messageId)`. No-op when the
125
- * sidecar or entry is absent.
126
- */
127
- export function removeActivePin(agentDir: string, chatId: string, messageId: number): void {
128
- const existing = readActivePins(agentDir);
129
- const next = existing.filter(
130
- (p) => !(p.chatId === chatId && p.messageId === messageId),
131
- );
132
- if (next.length === existing.length) return;
133
- writeActivePins(agentDir, next);
134
- }
135
-
136
- /**
137
- * Delete the sidecar outright. Called after the startup sweep so the
138
- * next run starts clean regardless of unpin success.
139
- */
140
- export function clearActivePins(agentDir: string): void {
141
- try {
142
- unlinkSync(pinsPath(agentDir));
143
- } catch {
144
- /* already gone */
145
- }
146
- }