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
@@ -34,7 +34,12 @@ import {
34
34
  } from 'fs'
35
35
  import { homedir } from 'os'
36
36
  import { basename, join } from 'path'
37
- import { isMultiAgentEnabled } from './progress-card.js'
37
+ // #1122 PR3: inlined from the deleted progress-card.ts. Kill switch
38
+ // for the sub-agent transcript watcher (PROGRESS_CARD_MULTI_AGENT=0
39
+ // disables it). Name retained for back-compat with operator configs.
40
+ function isMultiAgentEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
41
+ return env.PROGRESS_CARD_MULTI_AGENT !== '0'
42
+ }
38
43
  import { classifyClaudeError, type OperatorEventKind } from './operator-events.js'
39
44
  import { createToolLabelSidecar, type ToolLabelSidecar } from './tool-label-sidecar.js'
40
45
 
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Shared bot runtime helpers — extracted from gateway.ts so both the
3
- * per-agent gateway and the foreman bot can share the same core plumbing
4
- * without duplicating code.
2
+ * Shared bot runtime helpers — extracted from gateway.ts as a reusable
3
+ * core that callers can build on without duplicating the boilerplate.
4
+ * Used today by the per-agent gateway; historically also by the
5
+ * standalone foreman bot before its retirement.
5
6
  *
6
7
  * What lives here:
7
8
  * - `createRobustApiCall` — thin re-export of createRetryApiCall pre-wired
@@ -361,7 +362,7 @@ export async function runPollingLoop(
361
362
 
362
363
  /**
363
364
  * Returns true if the sender's user ID is in the allowFrom list.
364
- * Used by both gateway and foreman for auth gating.
365
+ * Used by the gateway for sender-allowlist auth gating.
365
366
  */
366
367
  export function isAllowedSender(ctx: Context, allowFrom: string[]): boolean {
367
368
  const from = ctx.from
@@ -0,0 +1,420 @@
1
+ /**
2
+ * silence-poke.ts — framework safety net for "model is silent to the user."
3
+ *
4
+ * Background (issue #1122): we're moving away from a pinned progress card
5
+ * to a conversational shape where the chat itself is the artifact. The
6
+ * progress card was implicitly doing one useful job — covering for a
7
+ * model that doesn't know how to say "still working." This module is the
8
+ * explicit replacement: when the model has been silent past a threshold,
9
+ * we nudge it (or, as a last resort, send a framework message ourselves).
10
+ *
11
+ * Two clocks (this module owns ONE of them; the other is the legacy
12
+ * idle stall in status-reactions.ts and is unrelated):
13
+ *
14
+ * silence clock = now - lastOutboundAt (or turnStartedAt if no outbound yet)
15
+ *
16
+ * Outbound = a fresh `reply` or `stream_reply` first-emit. Reactions,
17
+ * edits, and tool churn DO NOT reset the silence clock — that's the
18
+ * whole point. The model could be ripping through 20 tool calls and
19
+ * still be "silent" to the user.
20
+ *
21
+ * Escalation ladder per turn:
22
+ *
23
+ * t=0 startTurn() — silence clock starts at turnStartedAt
24
+ * t=75s soft poke armed — appended to next tool result as a
25
+ * <system-reminder> nudging the model to send an update
26
+ * t=180s firm poke armed (stronger wording) if no outbound landed
27
+ * t=300s framework fallback: gateway itself sends a user-visible
28
+ * "still working… / still thinking…" message. Fires at most
29
+ * once per turn. Pings the device (user needs to know).
30
+ *
31
+ * Subagent-dispatch override: when the model dispatches a sub-agent
32
+ * (`Task(...)`, `@worker` etc), the soft threshold extends to 300s for
33
+ * that turn — the model is legitimately waiting on a child, no point
34
+ * poking it to narrate the wait. Firm/fallback thresholds unchanged.
35
+ *
36
+ * Wired into the gateway at the central tool-result chokepoint
37
+ * (`gateway.ts:onToolCall`) so the poke text piggybacks the next tool
38
+ * result back to claude. MCP doesn't allow mid-generation injection;
39
+ * tool results are the only synchronous moment we own the wire.
40
+ *
41
+ * Kill switch: SWITCHROOM_DISABLE_SILENCE_POKE=1 disables the whole
42
+ * subsystem (no timers, no injection, no fallback). The conversational
43
+ * pacing prompt still applies; only the framework safety net is off.
44
+ */
45
+
46
+ export type PokeLevel = 'soft' | 'firm'
47
+
48
+ export interface SilencePokeState {
49
+ /** Wall-clock ms of turn start. Silence clock zero-point when no outbound yet. */
50
+ turnStartedAt: number
51
+ /** Wall-clock ms of last outbound message, or null. */
52
+ lastOutboundAt: number | null
53
+ /** 0 = none, 1 = soft fired, 2 = firm fired, 3 = fallback fired. */
54
+ pokesFired: 0 | 1 | 2 | 3
55
+ /** Armed pending drain on the next tool result, or null. */
56
+ pokeArmed: { level: PokeLevel } | null
57
+ /** When true, soft threshold extends to subagentSoft (default 300s). */
58
+ subagentDispatchActive: boolean
59
+ /** Wall-clock ms of last `thinking` session event, or null. */
60
+ lastThinkingAt: number | null
61
+ /** True once the 300s framework fallback has fired this turn. */
62
+ fallbackFired: boolean
63
+ /** Wall-clock ms of last poke fire — used for poke-success latency. */
64
+ lastPokeFiredAt: number | null
65
+ }
66
+
67
+ export interface ThresholdsMs {
68
+ soft: number
69
+ firm: number
70
+ fallback: number
71
+ /** Soft threshold when subagentDispatchActive=true. */
72
+ subagentSoft: number
73
+ /** How long after a poke we still count an outbound as a "success." */
74
+ pokeSuccessWindowMs: number
75
+ }
76
+
77
+ export const DEFAULT_THRESHOLDS: ThresholdsMs = {
78
+ soft: 75_000,
79
+ firm: 180_000,
80
+ fallback: 300_000,
81
+ subagentSoft: 300_000,
82
+ pokeSuccessWindowMs: 15_000,
83
+ }
84
+
85
+ export const DEFAULT_POLL_INTERVAL_MS = 5_000
86
+
87
+ export interface FrameworkFallbackContext {
88
+ key: string
89
+ chatId: string
90
+ threadId: number | null
91
+ /** Picked from lastThinkingAt: 'thinking' if a thinking event landed in
92
+ * the last 30s of silence, else 'working'. */
93
+ fallbackKind: 'working' | 'thinking'
94
+ silenceMs: number
95
+ }
96
+
97
+ export type SilencePokeMetric =
98
+ | { kind: 'silence_poke_fired'; key: string; level: PokeLevel; silence_ms: number; subagent_wait: boolean }
99
+ | { kind: 'silence_poke_succeeded'; key: string; level: PokeLevel; latency_ms: number }
100
+ | { kind: 'silence_fallback_sent'; key: string; fallback_kind: 'working' | 'thinking'; silence_ms: number }
101
+
102
+ export interface SilencePokeDeps {
103
+ /** Called when the 300s fallback fires. Caller sends the user-visible
104
+ * message + ensures it pings the device. Caller must NOT call back
105
+ * into noteOutbound for this — it's a framework-sourced message,
106
+ * not a model-sourced one, and we want pokes to continue (well, no,
107
+ * fallbackFired ensures only one per turn anyway). */
108
+ onFrameworkFallback: (ctx: FrameworkFallbackContext) => Promise<void> | void
109
+ /** Telemetry sink for poke events. */
110
+ emitMetric: (event: SilencePokeMetric) => void
111
+ /** Threshold overrides (tests). */
112
+ thresholdsMs?: ThresholdsMs
113
+ /** Poll interval (tests). */
114
+ pollIntervalMs?: number
115
+ }
116
+
117
+ const state = new Map<string, SilencePokeState>()
118
+ let timer: ReturnType<typeof setInterval> | null = null
119
+ let activeDeps: SilencePokeDeps | null = null
120
+
121
+ /**
122
+ * True iff the kill switch is OFF. Re-read every call so tests can
123
+ * toggle process.env without reloading the module.
124
+ */
125
+ export function silencePokeEnabled(): boolean {
126
+ const v = process.env.SWITCHROOM_DISABLE_SILENCE_POKE
127
+ return !(v === '1' || v === 'true')
128
+ }
129
+
130
+ /**
131
+ * Initialise a fresh turn's silence state. No-op when kill switch is on.
132
+ */
133
+ export function startTurn(key: string, now: number): void {
134
+ if (!silencePokeEnabled()) return
135
+ state.set(key, {
136
+ turnStartedAt: now,
137
+ lastOutboundAt: null,
138
+ pokesFired: 0,
139
+ pokeArmed: null,
140
+ subagentDispatchActive: false,
141
+ lastThinkingAt: null,
142
+ fallbackFired: false,
143
+ lastPokeFiredAt: null,
144
+ })
145
+ }
146
+
147
+ /**
148
+ * Record a fresh user-visible outbound message (reply or stream_reply
149
+ * first-emit). Resets the silence clock + the escalation counter. If a
150
+ * poke fired recently, emit a `silence_poke_succeeded` metric.
151
+ */
152
+ export function noteOutbound(key: string, now: number): void {
153
+ const s = state.get(key)
154
+ if (s == null) return
155
+ // Success measurement: if a poke fired within the success window and
156
+ // an outbound just landed, count it as a successful poke.
157
+ const thresholds = activeDeps?.thresholdsMs ?? DEFAULT_THRESHOLDS
158
+ if (
159
+ s.lastPokeFiredAt != null
160
+ && (now - s.lastPokeFiredAt) <= thresholds.pokeSuccessWindowMs
161
+ && activeDeps != null
162
+ && s.pokesFired >= 1
163
+ && s.pokesFired <= 2
164
+ ) {
165
+ activeDeps.emitMetric({
166
+ kind: 'silence_poke_succeeded',
167
+ key,
168
+ level: s.pokesFired === 1 ? 'soft' : 'firm',
169
+ latency_ms: now - s.lastPokeFiredAt,
170
+ })
171
+ }
172
+ s.lastOutboundAt = now
173
+ s.pokesFired = 0
174
+ s.pokeArmed = null
175
+ // Intentionally DO NOT clear `subagentDispatchActive` here. The
176
+ // model's `reply` narrating the dispatch ("spinning up @reviewer")
177
+ // is itself the outbound that resets the silence clock — clearing
178
+ // the flag would defeat the extended-threshold guarantee for the
179
+ // wait that follows. The flag persists until endTurn(). Fixes the
180
+ // non-blocking note from PR2 review (#1125).
181
+ s.lastPokeFiredAt = null
182
+ s.fallbackFired = false
183
+ }
184
+
185
+ /**
186
+ * Note that the model dispatched a sub-agent (Task tool, @worker, etc).
187
+ * Extends the soft threshold for THIS turn. The flag persists until
188
+ * endTurn() — subsequent outbound messages within the turn keep the
189
+ * extended threshold, which is the correct shape for the dispatch
190
+ * narrate → wait → child-result → summarise sequence.
191
+ */
192
+ export function noteSubagentDispatch(key: string): void {
193
+ const s = state.get(key)
194
+ if (s == null) return
195
+ s.subagentDispatchActive = true
196
+ }
197
+
198
+ /**
199
+ * Record a `thinking` session event. Used to pick "still thinking…" vs
200
+ * "still working…" wording for the 300s framework fallback.
201
+ */
202
+ export function noteThinking(key: string, now: number): void {
203
+ const s = state.get(key)
204
+ if (s == null) return
205
+ s.lastThinkingAt = now
206
+ }
207
+
208
+ /**
209
+ * Drain any armed poke for ANY active turn and return the system-reminder
210
+ * text to append. Returns null if nothing is armed.
211
+ *
212
+ * Called at the gateway's tool-result chokepoint; the appended reminder
213
+ * piggybacks the result back to claude. Drains the flag immediately so
214
+ * the next tool result doesn't double-inject.
215
+ *
216
+ * Iterates all keys because the tool result doesn't carry which turn it
217
+ * belongs to. In practice the gateway has ≤1 active turn at a time, but
218
+ * the code handles multi-turn correctly: each turn's poke text is
219
+ * appended once (and never appears in another turn's tool result, since
220
+ * we drain by mutating the matched state).
221
+ */
222
+ export function consumeArmedPoke(): string | null {
223
+ for (const s of state.values()) {
224
+ if (s.pokeArmed != null) {
225
+ const level = s.pokeArmed.level
226
+ s.pokeArmed = null
227
+ return formatPokeText(level)
228
+ }
229
+ }
230
+ return null
231
+ }
232
+
233
+ /** End a turn — drop state. Idempotent. */
234
+ export function endTurn(key: string): void {
235
+ state.delete(key)
236
+ }
237
+
238
+ /** Verbatim poke text. Wording is load-bearing — see issue #1122 design. */
239
+ export function formatPokeText(level: PokeLevel): string {
240
+ if (level === 'soft') {
241
+ return (
242
+ "[silence-poke] You've been silent to the user for 75s. If you're "
243
+ + "still working on this, send one short conversational reply — e.g. "
244
+ + "\"still going, working through X\" — so they know you're alive. "
245
+ + "Keep it brief; don't restate the task. If you're about to finish "
246
+ + 'within the next few seconds, skip the update.'
247
+ )
248
+ }
249
+ return (
250
+ "[silence-poke] 3 minutes silent. Please send an update now — what "
251
+ + "you're working on, or whether you're stuck. If something is taking "
252
+ + 'unusually long (slow tool, network, waiting on a sub-agent), say so '
253
+ + 'explicitly.'
254
+ )
255
+ }
256
+
257
+ /**
258
+ * Verbatim framework-fallback text — the user-visible "still working / still
259
+ * thinking" message the gateway sends at the 300s threshold when the model
260
+ * hasn't broken its own silence. Wording is load-bearing (see
261
+ * `reference/conversational-pacing.md` § Silence-poke ladder). Two principles:
262
+ *
263
+ * 1. The parenthetical `(no update from agent in N min)` is honest —
264
+ * distinguishes from "the agent said something" so users learn to trust
265
+ * real agent messages. `N` is derived from `silenceMs`, never hard-coded.
266
+ * 2. The verb is `working` by default, `thinking` only when the session
267
+ * stream has emitted a `kind: 'thinking'` event in the last 30s. Picked
268
+ * by the caller via `fallbackKind`; this helper just formats.
269
+ *
270
+ * Extracted from the gateway's `onFrameworkFallback` callback so the wording
271
+ * can be snapshot-tested in isolation. CC-4 in `docs/status-ask-cause-classes.md`.
272
+ */
273
+ export function formatFrameworkFallbackText(
274
+ fallbackKind: 'working' | 'thinking',
275
+ silenceMs: number,
276
+ ): string {
277
+ const minutes = Math.max(1, Math.round(silenceMs / 60_000))
278
+ const suffix = `(no update from agent in ${minutes} min)`
279
+ return fallbackKind === 'thinking'
280
+ ? `still thinking… ${suffix}`
281
+ : `still working… ${suffix}`
282
+ }
283
+
284
+ /**
285
+ * Internal tick — iterates active states, arms pokes or fires fallback.
286
+ * Exported as __tickForTests so suite can step the clock deterministically.
287
+ */
288
+ function tick(now: number): void {
289
+ if (activeDeps == null) return
290
+ const thresholds = activeDeps.thresholdsMs ?? DEFAULT_THRESHOLDS
291
+ for (const [key, s] of state.entries()) {
292
+ const zeroAt = s.lastOutboundAt ?? s.turnStartedAt
293
+ const silence = now - zeroAt
294
+ if (silence < 0) continue
295
+ const softThreshold = s.subagentDispatchActive
296
+ ? thresholds.subagentSoft
297
+ : thresholds.soft
298
+
299
+ if (s.pokesFired === 0 && silence >= softThreshold) {
300
+ s.pokeArmed = { level: 'soft' }
301
+ s.pokesFired = 1
302
+ s.lastPokeFiredAt = now
303
+ activeDeps.emitMetric({
304
+ kind: 'silence_poke_fired',
305
+ key,
306
+ level: 'soft',
307
+ silence_ms: silence,
308
+ subagent_wait: s.subagentDispatchActive,
309
+ })
310
+ continue
311
+ }
312
+
313
+ if (s.pokesFired === 1 && silence >= thresholds.firm) {
314
+ s.pokeArmed = { level: 'firm' }
315
+ s.pokesFired = 2
316
+ s.lastPokeFiredAt = now
317
+ activeDeps.emitMetric({
318
+ kind: 'silence_poke_fired',
319
+ key,
320
+ level: 'firm',
321
+ silence_ms: silence,
322
+ subagent_wait: s.subagentDispatchActive,
323
+ })
324
+ continue
325
+ }
326
+
327
+ if (s.pokesFired === 2 && !s.fallbackFired && silence >= thresholds.fallback) {
328
+ s.fallbackFired = true
329
+ s.pokesFired = 3
330
+ const { chatId, threadId } = parseKey(key)
331
+ const recentThinking = s.lastThinkingAt != null
332
+ && (now - s.lastThinkingAt) < 30_000
333
+ const fallbackKind: 'working' | 'thinking' = recentThinking ? 'thinking' : 'working'
334
+ activeDeps.emitMetric({
335
+ kind: 'silence_fallback_sent',
336
+ key,
337
+ fallback_kind: fallbackKind,
338
+ silence_ms: silence,
339
+ })
340
+ // Caller may throw or fail — guard so a busted fallback doesn't kill the timer.
341
+ try {
342
+ const r = activeDeps.onFrameworkFallback({
343
+ key,
344
+ chatId,
345
+ threadId,
346
+ fallbackKind,
347
+ silenceMs: silence,
348
+ })
349
+ if (r != null && typeof (r as Promise<void>).catch === 'function') {
350
+ ;(r as Promise<void>).catch((err) => {
351
+ process.stderr.write(
352
+ `silence-poke: framework fallback handler rejected: ${err}\n`,
353
+ )
354
+ })
355
+ }
356
+ } catch (err) {
357
+ process.stderr.write(
358
+ `silence-poke: framework fallback handler threw: ${err}\n`,
359
+ )
360
+ }
361
+ }
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Parse `<chatId>:<threadIdOrEmpty>` back into structured fields. Matches
367
+ * the `statusKey` shape used throughout the gateway.
368
+ */
369
+ function parseKey(key: string): { chatId: string; threadId: number | null } {
370
+ const idx = key.indexOf(':')
371
+ if (idx < 0) return { chatId: key, threadId: null }
372
+ const chatId = key.slice(0, idx)
373
+ const tail = key.slice(idx + 1)
374
+ if (tail === '' || tail === 'undefined') return { chatId, threadId: null }
375
+ const n = Number(tail)
376
+ return { chatId, threadId: Number.isFinite(n) ? n : null }
377
+ }
378
+
379
+ /**
380
+ * Start the timer. Idempotent — second call is a no-op. Stash deps so
381
+ * tick() can find them. Honours the kill switch.
382
+ */
383
+ export function startTimer(deps: SilencePokeDeps): void {
384
+ if (!silencePokeEnabled()) return
385
+ if (timer != null) return
386
+ activeDeps = deps
387
+ const poll = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
388
+ timer = setInterval(() => tick(Date.now()), poll)
389
+ if (typeof timer.unref === 'function') timer.unref()
390
+ }
391
+
392
+ /** Stop the timer. Idempotent. */
393
+ export function stopTimer(): void {
394
+ if (timer != null) {
395
+ clearInterval(timer)
396
+ timer = null
397
+ }
398
+ activeDeps = null
399
+ }
400
+
401
+ /** Test-only: drive a single tick at a deterministic clock value. */
402
+ export function __tickForTests(now: number): void {
403
+ tick(now)
404
+ }
405
+
406
+ /** Test-only: install deps without starting the real timer. */
407
+ export function __setDepsForTests(deps: SilencePokeDeps | null): void {
408
+ activeDeps = deps
409
+ }
410
+
411
+ /** Test-only: peek at state. */
412
+ export function __getStateForTests(key: string): SilencePokeState | undefined {
413
+ return state.get(key)
414
+ }
415
+
416
+ /** Test-only: full reset. */
417
+ export function __resetAllForTests(): void {
418
+ state.clear()
419
+ stopTimer()
420
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * silent-end.ts — gateway-side state-file writer for the Stop hook.
3
+ *
4
+ * The Stop hook (`telegram-plugin/hooks/silent-end-interrupt-stop.mjs`)
5
+ * reads `$TELEGRAM_STATE_DIR/silent-end-pending.json` to decide whether
6
+ * to block-and-re-prompt or allow the session to end. Pre-#1122 PR3 the
7
+ * file was written from inside the progress-card driver's `onSilentEnd`
8
+ * callback. PR3 deleted the driver and accidentally removed the writer.
9
+ * The hook still ran on every Stop, but the file never appeared, so the
10
+ * hook always allowed the stop → users could ask a question, see 👀
11
+ * fire, and then get nothing back if the model failed to call `reply`.
12
+ *
13
+ * This module is the deterministic replacement. The gateway calls
14
+ * `writeSilentEndState(...)` when a fresh user-message turn ends with
15
+ * zero outbound messages, and `clearSilentEndState(...)` the moment a
16
+ * reply lands. The Stop hook reads the same file and makes its
17
+ * decision — no prompt dependency, no model behaviour required.
18
+ *
19
+ * Retry semantics: on first silent-end the hook blocks the stop with
20
+ * a re-prompt; on the second silent-end (retryCount >= MAX_RETRIES in
21
+ * the hook) the hook lets the session end. We inherit retryCount from
22
+ * any prior state file IFF the prior file's `turnKey` matches — a new
23
+ * turn always starts at retryCount=0.
24
+ *
25
+ * The state file is per-agent (each agent has its own
26
+ * TELEGRAM_STATE_DIR), so two agents going silent at the same time
27
+ * don't collide.
28
+ */
29
+
30
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'
31
+ import { dirname, join } from 'node:path'
32
+ import { homedir } from 'node:os'
33
+
34
+ export interface SilentEndState {
35
+ /** The chat the silent turn was for — used by operator-facing diagnostics. */
36
+ chatId: string
37
+ /** Optional forum thread id, stringified or null. */
38
+ threadId: number | null
39
+ /** Stable identifier for the in-flight turn (statusKey shape). */
40
+ turnKey: string
41
+ /** Incremented each time the Stop hook blocks for this turn. */
42
+ retryCount: number
43
+ /** Wall-clock ms of last write. */
44
+ timestamp: number
45
+ }
46
+
47
+ export interface SilentEndDeps {
48
+ /** State dir root (defaults to `TELEGRAM_STATE_DIR` env). */
49
+ stateDir?: string
50
+ /** stderr writer (defaults to `process.stderr.write`). */
51
+ log?: (line: string) => void
52
+ }
53
+
54
+ function resolveStateDir(deps?: SilentEndDeps): string {
55
+ if (deps?.stateDir != null) return deps.stateDir
56
+ const env = process.env.TELEGRAM_STATE_DIR
57
+ if (env != null && env !== '') return env
58
+ // Same fallback the gateway (`gateway.ts STATE_DIR`) and the Stop
59
+ // hook (`silent-end-interrupt-stop.mjs getStateDir`) already use.
60
+ // Discovered during UAT overnight 2026-05-13: test-harness ran
61
+ // without `TELEGRAM_STATE_DIR` set, so the writer returned null
62
+ // path → no state file ever appeared → hook always read "no
63
+ // silent-end pending" → silent-end recovery never engaged. The
64
+ // hook + writer have to agree on the path.
65
+ //
66
+ // Prefer `process.env.HOME` over `node:os` `homedir()` so the
67
+ // fallback is overridable in tests. Bun's `os.homedir()` reads
68
+ // the system home once at startup and ignores subsequent
69
+ // `process.env.HOME` mutations, which breaks the bun-test pass
70
+ // of `silent-end.test.ts` even though the vitest pass is fine
71
+ // (Node's `os.homedir()` documents `HOME` as the first source).
72
+ // In production both branches yield the same path — `HOME` is
73
+ // always set under the agent's tini-supervised process tree.
74
+ const home = process.env.HOME ?? homedir()
75
+ return join(home, '.claude', 'channels', 'telegram')
76
+ }
77
+
78
+ function resolveStatePath(deps?: SilentEndDeps): string {
79
+ return join(resolveStateDir(deps), 'silent-end-pending.json')
80
+ }
81
+
82
+ function emitLog(deps: SilentEndDeps | undefined, line: string): void {
83
+ if (deps?.log != null) deps.log(line)
84
+ else process.stderr.write(line)
85
+ }
86
+
87
+ /**
88
+ * Write the silent-end state file for the given turn. Inherits
89
+ * retryCount from a prior write IFF the prior write's turnKey matches.
90
+ * Otherwise resets to 0.
91
+ *
92
+ * State path: `${TELEGRAM_STATE_DIR ?? ~/.claude/channels/telegram}/
93
+ * silent-end-pending.json` — exactly matching the path the Stop hook
94
+ * (silent-end-interrupt-stop.mjs) reads. The parent dir is created
95
+ * with `mkdir -p` if it doesn't exist (fresh-install case).
96
+ */
97
+ export function writeSilentEndState(
98
+ args: { chatId: string; threadId: number | null; turnKey: string },
99
+ deps?: SilentEndDeps,
100
+ ): void {
101
+ const statePath = resolveStatePath(deps)
102
+ let retryCount = 0
103
+ try {
104
+ if (existsSync(statePath)) {
105
+ const prev = JSON.parse(readFileSync(statePath, 'utf8')) as Partial<SilentEndState>
106
+ if (prev.turnKey === args.turnKey && typeof prev.retryCount === 'number') {
107
+ retryCount = prev.retryCount
108
+ }
109
+ }
110
+ } catch {
111
+ retryCount = 0
112
+ }
113
+ const state: SilentEndState = {
114
+ chatId: args.chatId,
115
+ threadId: args.threadId,
116
+ turnKey: args.turnKey,
117
+ retryCount,
118
+ timestamp: Date.now(),
119
+ }
120
+ try {
121
+ // The fallback path may not exist on a fresh install — mkdir-p
122
+ // before writing. Cheap and idempotent. Without this the writer
123
+ // throws ENOENT in environments where the operator hasn't booted
124
+ // claude before (the dir is normally created by claude itself
125
+ // on first run).
126
+ mkdirSync(dirname(statePath), { recursive: true })
127
+ writeFileSync(statePath, JSON.stringify(state), 'utf8')
128
+ emitLog(
129
+ deps,
130
+ `silent-end: wrote state file turnKey=${args.turnKey} retryCount=${retryCount}\n`,
131
+ )
132
+ } catch (err) {
133
+ emitLog(
134
+ deps,
135
+ `silent-end: failed to write state file: ${(err as Error).message}\n`,
136
+ )
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Clear the silent-end state file IFF it belongs to the given turnKey.
142
+ * Called the moment a reply / stream_reply first-emit lands so the
143
+ * Stop hook doesn't fire a stale block on the next stop.
144
+ *
145
+ * Fail-silent: missing file, mismatched turnKey, or read/unlink errors
146
+ * are all benign. The Stop hook itself defends against stale files via
147
+ * the retryCount mechanism.
148
+ */
149
+ export function clearSilentEndState(turnKey: string, deps?: SilentEndDeps): void {
150
+ const statePath = resolveStatePath(deps)
151
+ if (!existsSync(statePath)) return
152
+ try {
153
+ const prev = JSON.parse(readFileSync(statePath, 'utf8')) as Partial<SilentEndState>
154
+ if (prev.turnKey !== turnKey) return
155
+ unlinkSync(statePath)
156
+ emitLog(deps, `silent-end: cleared state file turnKey=${turnKey}\n`)
157
+ } catch {
158
+ // best-effort
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Read the state file (for tests + diagnostics). Returns null when
164
+ * absent or unparsable.
165
+ */
166
+ export function readSilentEndState(deps?: SilentEndDeps): SilentEndState | null {
167
+ const statePath = resolveStatePath(deps)
168
+ if (!existsSync(statePath)) return null
169
+ try {
170
+ return JSON.parse(readFileSync(statePath, 'utf8')) as SilentEndState
171
+ } catch {
172
+ return null
173
+ }
174
+ }
@@ -81,6 +81,13 @@ export interface StreamSendOpts {
81
81
  * accept this parameter, so the controller omits it from edit opts.
82
82
  */
83
83
  protect_content?: boolean
84
+ /**
85
+ * When true, the initial `sendMessage` is silent (no device ping).
86
+ * Has no effect on `editMessageText` — Telegram never pings on edits.
87
+ * Used by mid-turn `stream_reply` calls under the #1122 conversational
88
+ * pacing redesign so only the final answer pings.
89
+ */
90
+ disable_notification?: boolean
84
91
  }
85
92
 
86
93
  export type RetryPolicy = <T>(
@@ -113,6 +120,11 @@ export interface StreamControllerConfig {
113
120
  * accept protect_content.
114
121
  */
115
122
  protectContent?: boolean
123
+ /**
124
+ * When true, the initial `sendMessage` is silent (no device ping).
125
+ * editMessageText never pings regardless. Default false. #1122.
126
+ */
127
+ disableNotification?: boolean
116
128
  /**
117
129
  * Inline keyboard markup attached to every send and edit. Without this,
118
130
  * editMessageText strips any previously attached keyboard. The progress-
@@ -228,6 +240,7 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
228
240
  }
229
241
  : {}),
230
242
  ...(protectContent === true ? { protect_content: true } : {}),
243
+ ...(cfg.disableNotification === true ? { disable_notification: true } : {}),
231
244
  }
232
245
 
233
246
  // Strip parse_mode from a copy of opts — used for the parse-entities
@@ -100,6 +100,12 @@ export interface StreamReplyArgs {
100
100
  * Applied on the initial send only (editMessageText ignores it).
101
101
  */
102
102
  protect_content?: boolean
103
+ /**
104
+ * When true, the INITIAL `sendMessage` is silent (no device ping).
105
+ * Edits never ping regardless. Used by mid-turn stream_reply calls
106
+ * under the #1122 conversational-pacing redesign. Default false.
107
+ */
108
+ disable_notification?: boolean
103
109
  /**
104
110
  * Optional surgical quote text. When set along with `reply_to`, the initial
105
111
  * send includes `reply_parameters: { message_id, quote: { text, position: 0 } }`
@@ -508,6 +514,7 @@ export async function handleStreamReply(
508
514
  ...(replyToMessageId != null ? { replyToMessageId } : {}),
509
515
  ...(args.quote_text != null && replyToMessageId != null ? { quoteText: args.quote_text } : {}),
510
516
  ...(args.protect_content === true ? { protectContent: true } : {}),
517
+ ...(args.disable_notification === true ? { disableNotification: true } : {}),
511
518
  ...(args.reply_markup != null ? { replyMarkup: args.reply_markup } : {}),
512
519
  previewTransport: resolvedTransport,
513
520
  isPrivateChat: deps.isPrivateChat === true,