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,316 @@
1
+ /**
2
+ * Tests for SubagentWatcher's post-stall terminal-synthesis path
3
+ * (RFC §Bug 6 — background `Agent` dispatches in some Claude Code
4
+ * versions write a JSONL that never ends with `system + turn_duration`,
5
+ * so the canonical `sub_agent_turn_end` event never fires). Locks the
6
+ * contract that:
7
+ *
8
+ * 1. After `silentStallTerminalMs` past the stall notification, the
9
+ * watcher synthesises terminal: flips `entry.state = 'done'`, fires
10
+ * `onStallTerminal`, fires the existing `onFinish` audit surface.
11
+ * 2. Synthesis is idempotent: each entry triggers it at most once per
12
+ * lifetime (no repeat fires on every poll tick once the window
13
+ * elapses).
14
+ * 3. A pre-window unstall (JSONL activity resumes) clears `stalledAt`
15
+ * and prevents synthesis. A subsequent re-stall starts the clock
16
+ * from scratch.
17
+ * 4. Synthesis is suppressed for entries whose state is already `done`
18
+ * or `failed` — only `running + stallNotified` entries qualify.
19
+ */
20
+
21
+ import { describe, it, expect, vi } from 'vitest'
22
+ import { startSubagentWatcher } from '../subagent-watcher.js'
23
+ import * as fs from 'fs'
24
+
25
+ function buildJSONL(...lines: object[]): string {
26
+ return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
27
+ }
28
+ function subAgentUserMsg(promptText: string) {
29
+ return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
30
+ }
31
+ function subAgentTurnEnd() {
32
+ return { type: 'system', subtype: 'turn_duration', duration_ms: 1234 }
33
+ }
34
+
35
+ interface Harness {
36
+ stallCalls: Array<{ agentId: string; idleMs: number }>
37
+ stallTerminalCalls: Array<{ agentId: string; description: string }>
38
+ finishCalls: Array<{ agentId: string; outcome: string }>
39
+ unstallCalls: Array<{ agentId: string }>
40
+ logs: string[]
41
+ advance: (ms: number) => void
42
+ watcher: ReturnType<typeof startSubagentWatcher>
43
+ fileContents: Map<string, Buffer>
44
+ jsonlPath: string
45
+ appendActivity: () => void
46
+ }
47
+
48
+ function makeHarness(opts: {
49
+ agentId?: string
50
+ stallThresholdMs?: number
51
+ silentStallTerminalMs?: number
52
+ rescanMs?: number
53
+ } = {}): Harness {
54
+ const {
55
+ agentId = 'bug6-agent',
56
+ stallThresholdMs = 60_000,
57
+ silentStallTerminalMs = 300_000,
58
+ rescanMs = 500,
59
+ } = opts
60
+
61
+ let currentTime = 1000
62
+ const stallCalls: Array<{ agentId: string; idleMs: number }> = []
63
+ const stallTerminalCalls: Array<{ agentId: string; description: string }> = []
64
+ const finishCalls: Array<{ agentId: string; outcome: string }> = []
65
+ const unstallCalls: Array<{ agentId: string }> = []
66
+ const logs: string[] = []
67
+
68
+ const agentDir = '/home/user/.switchroom/agents/myagent'
69
+ const sessionId = 'mock-session'
70
+ const projectsRoot = `${agentDir}/.claude/projects`
71
+ const projectDir = `${projectsRoot}/mock-cwd`
72
+ const sessionDir = `${projectDir}/${sessionId}`
73
+ const subagentsDir = `${sessionDir}/subagents`
74
+ const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
75
+
76
+ const fileContents = new Map<string, Buffer>()
77
+ fileContents.set(
78
+ jsonlPath,
79
+ Buffer.from(buildJSONL(subAgentUserMsg('bg task')), 'utf-8'),
80
+ )
81
+
82
+ let lastOpenedPath: string | null = null
83
+ const mockFs = {
84
+ existsSync: ((p: fs.PathLike) => {
85
+ const ps = String(p)
86
+ if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
87
+ if (fileContents.has(ps)) return true
88
+ return false
89
+ }) as typeof fs.existsSync,
90
+ readdirSync: ((p: fs.PathLike) => {
91
+ const ps = String(p)
92
+ if (ps === projectsRoot) return ['mock-cwd']
93
+ if (ps === projectDir) return [sessionId]
94
+ if (ps === sessionDir) return ['subagents']
95
+ if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
96
+ return []
97
+ }) as unknown as typeof fs.readdirSync,
98
+ statSync: ((p: fs.PathLike) => ({ size: fileContents.get(String(p))?.length ?? 0 }) as fs.Stats) as typeof fs.statSync,
99
+ openSync: ((p: fs.PathLike) => {
100
+ lastOpenedPath = String(p)
101
+ return 42
102
+ }) as unknown as typeof fs.openSync,
103
+ closeSync: (() => { lastOpenedPath = null }) as typeof fs.closeSync,
104
+ readSync: ((
105
+ _fd: number,
106
+ buf: NodeJS.ArrayBufferView,
107
+ offset: number,
108
+ length: number,
109
+ position: number | null,
110
+ ): number => {
111
+ const content = lastOpenedPath != null ? fileContents.get(lastOpenedPath) : undefined
112
+ if (!content) return 0
113
+ const pos = position ?? 0
114
+ const src = content.slice(pos, pos + length)
115
+ ;(src as Buffer).copy(buf as Buffer, offset)
116
+ return src.length
117
+ }) as unknown as typeof fs.readSync,
118
+ watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
119
+ }
120
+
121
+ const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
122
+ let nextRef = 1
123
+
124
+ const watcher = startSubagentWatcher({
125
+ agentDir,
126
+ stallThresholdMs,
127
+ silentSynthesisStallThresholdMs: stallThresholdMs,
128
+ silentStallTerminalMs,
129
+ rescanMs,
130
+ sendNotification: () => {},
131
+ onStall: (id, idleMs) => stallCalls.push({ agentId: id, idleMs }),
132
+ onUnstall: (id) => unstallCalls.push({ agentId: id }),
133
+ onStallTerminal: (id, desc) => stallTerminalCalls.push({ agentId: id, description: desc }),
134
+ onFinish: ({ agentId: id, outcome }) => finishCalls.push({ agentId: id, outcome }),
135
+ now: () => currentTime,
136
+ setInterval: (fn, ms) => {
137
+ const ref = nextRef++
138
+ intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
139
+ return { ref }
140
+ },
141
+ clearInterval: (handle) => {
142
+ const { ref } = handle as { ref: number }
143
+ const idx = intervals.findIndex((i) => i.ref === ref)
144
+ if (idx !== -1) intervals.splice(idx, 1)
145
+ },
146
+ fs: mockFs,
147
+ log: (msg) => logs.push(msg),
148
+ })
149
+
150
+ const advance = (ms: number): void => {
151
+ currentTime += ms
152
+ for (;;) {
153
+ intervals.sort((a, b) => a.fireAt - b.fireAt)
154
+ const next = intervals[0]
155
+ if (!next || next.fireAt > currentTime) break
156
+ next.fireAt += next.ms
157
+ next.fn()
158
+ }
159
+ }
160
+
161
+ const appendActivity = (): void => {
162
+ // Append a `text` line so the watcher sees the JSONL grow and
163
+ // flips the entry out of "stalled" via the unstall path.
164
+ const cur = fileContents.get(jsonlPath) ?? Buffer.alloc(0)
165
+ const more = buildJSONL({
166
+ type: 'assistant',
167
+ message: { content: [{ type: 'text', text: 'still working' }] },
168
+ })
169
+ fileContents.set(jsonlPath, Buffer.concat([cur, Buffer.from(more, 'utf-8')]))
170
+ }
171
+
172
+ return {
173
+ stallCalls,
174
+ stallTerminalCalls,
175
+ finishCalls,
176
+ unstallCalls,
177
+ logs,
178
+ advance,
179
+ watcher,
180
+ fileContents,
181
+ jsonlPath,
182
+ appendActivity,
183
+ }
184
+ }
185
+
186
+ // Files present at boot are flagged historical=true and stall+synth
187
+ // detection is suppressed for those (don't flood chat on restart).
188
+ // Tests flip the flag to simulate a post-boot discovery — same pattern
189
+ // as the existing stall-notification tests.
190
+ function unmarkHistorical(harness: Harness, agentId: string): void {
191
+ const entry = harness.watcher.getRegistry().get(agentId)
192
+ if (entry) entry.historical = false
193
+ }
194
+
195
+ describe('subagent-watcher post-stall terminal synthesis (RFC §Bug 6)', () => {
196
+ it('synthesises terminal after silentStallTerminalMs past stall notification', () => {
197
+ const agentId = 'bug6-synth-1'
198
+ const h = makeHarness({
199
+ agentId,
200
+ stallThresholdMs: 60_000,
201
+ silentStallTerminalMs: 300_000,
202
+ rescanMs: 500,
203
+ })
204
+
205
+ h.advance(500) // register
206
+ unmarkHistorical(h, agentId)
207
+ h.advance(62_000) // stall fires
208
+ expect(h.stallCalls).toHaveLength(1)
209
+ expect(h.stallTerminalCalls).toHaveLength(0)
210
+ expect(h.finishCalls).toHaveLength(0)
211
+
212
+ // Advance to JUST BEFORE the post-stall window closes — synth must
213
+ // not fire yet.
214
+ h.advance(299_000)
215
+ expect(h.stallTerminalCalls).toHaveLength(0)
216
+ expect(h.finishCalls).toHaveLength(0)
217
+
218
+ // Cross the threshold — synthesis fires exactly once.
219
+ h.advance(2_000)
220
+ expect(h.stallTerminalCalls).toHaveLength(1)
221
+ expect(h.stallTerminalCalls[0].agentId).toBe(agentId)
222
+ expect(h.finishCalls).toHaveLength(1)
223
+ expect(h.finishCalls[0].agentId).toBe(agentId)
224
+ // The synthesised path uses outcome 'completed' so downstream
225
+ // consumers treat it the same as a real `sub_agent_turn_end` —
226
+ // the audit log entry distinguishes via the `synthesis` log line.
227
+ expect(h.finishCalls[0].outcome).toBe('completed')
228
+ })
229
+
230
+ it('is idempotent — does not re-fire on subsequent poll ticks', () => {
231
+ const agentId = 'bug6-synth-idempotent'
232
+ const h = makeHarness({
233
+ agentId,
234
+ stallThresholdMs: 60_000,
235
+ silentStallTerminalMs: 60_000,
236
+ rescanMs: 500,
237
+ })
238
+
239
+ h.advance(500)
240
+ unmarkHistorical(h, agentId)
241
+ h.advance(62_000)
242
+ h.advance(62_000) // synth fires
243
+ expect(h.stallTerminalCalls).toHaveLength(1)
244
+
245
+ // Many more polls past the window — synth must not re-fire.
246
+ h.advance(60_000)
247
+ h.advance(60_000)
248
+ h.advance(60_000)
249
+ expect(h.stallTerminalCalls).toHaveLength(1)
250
+ expect(h.finishCalls).toHaveLength(1)
251
+ })
252
+
253
+ it('a pre-window unstall resets the synthesis clock', () => {
254
+ const agentId = 'bug6-synth-unstall'
255
+ const h = makeHarness({
256
+ agentId,
257
+ stallThresholdMs: 60_000,
258
+ silentStallTerminalMs: 60_000,
259
+ rescanMs: 500,
260
+ })
261
+
262
+ h.advance(500)
263
+ unmarkHistorical(h, agentId)
264
+ h.advance(62_000) // stall fires
265
+ expect(h.stallCalls).toHaveLength(1)
266
+
267
+ // 30s into the post-stall window — append activity so the watcher
268
+ // sees JSONL growth and fires onUnstall.
269
+ h.advance(30_000)
270
+ h.appendActivity()
271
+ h.advance(1_000) // next poll reads the new bytes, fires onUnstall
272
+ expect(h.unstallCalls).toHaveLength(1)
273
+ expect(h.stallTerminalCalls).toHaveLength(0)
274
+
275
+ // Advance another 60s past unstall — would have synthesised if the
276
+ // clock hadn't reset. It should not.
277
+ h.advance(60_000)
278
+ expect(h.stallTerminalCalls).toHaveLength(0)
279
+ expect(h.finishCalls).toHaveLength(0)
280
+ })
281
+
282
+ it('does NOT synthesise when an explicit sub_agent_turn_end lands inside the window', () => {
283
+ const agentId = 'bug6-synth-explicit-end'
284
+ const h = makeHarness({
285
+ agentId,
286
+ stallThresholdMs: 60_000,
287
+ silentStallTerminalMs: 300_000,
288
+ rescanMs: 500,
289
+ })
290
+
291
+ h.advance(500)
292
+ unmarkHistorical(h, agentId)
293
+ h.advance(62_000) // stall fires
294
+ expect(h.stallCalls).toHaveLength(1)
295
+
296
+ // 100s into the post-stall window the worker writes its terminal
297
+ // JSONL line — the watcher's existing turn_end path flips state to
298
+ // 'done' and fires onFinish with outcome='completed'. The synth
299
+ // path then sees state !== 'running' and skips.
300
+ h.advance(100_000)
301
+ const cur = h.fileContents.get(h.jsonlPath) ?? Buffer.alloc(0)
302
+ h.fileContents.set(
303
+ h.jsonlPath,
304
+ Buffer.concat([cur, Buffer.from(buildJSONL(subAgentTurnEnd()), 'utf-8')]),
305
+ )
306
+ h.advance(1_000)
307
+ expect(h.finishCalls).toHaveLength(1)
308
+ expect(h.finishCalls[0].outcome).toBe('completed')
309
+
310
+ // Cross the synth threshold — synth must NOT fire (the explicit
311
+ // path already terminated the entry).
312
+ h.advance(300_000)
313
+ expect(h.stallTerminalCalls).toHaveLength(0)
314
+ expect(h.finishCalls).toHaveLength(1) // unchanged
315
+ })
316
+ })
@@ -879,4 +879,267 @@ describe('startSubagentWatcher', () => {
879
879
  h.watcher.stop()
880
880
  })
881
881
  })
882
+
883
+ // ─── Issue #1116 regressions ─────────────────────────────────────────────
884
+
885
+ describe('issue #1116 — project-dir slug filter (Bug A)', () => {
886
+ /**
887
+ * Claude Code keys its `.claude/projects/<slug>/` dirs off the cwd it
888
+ * was launched in. Over time an agent's home can accumulate stale
889
+ * project dirs from prior boots (or from a sibling agent that briefly
890
+ * shared the home via a wayward `CLAUDE_PROJECT_DIR`). Pre-#1116 the
891
+ * watcher enumerated every project dir under `<agentDir>/.claude/projects/`
892
+ * and registered any `agent-*.jsonl` it found, which produced phantom
893
+ * registry entries whose backing files vanish on the next session-cleanup
894
+ * tick (ENOENT spam + false stall notifications, per the issue's
895
+ * smoking-gun log).
896
+ *
897
+ * The fix: when `agentCwd` is supplied, the watcher restricts
898
+ * enumeration to the project dir whose slug matches
899
+ * `sanitizeCwdToProjectName(agentCwd)`. Foreign-slug shadow dirs are
900
+ * skipped entirely.
901
+ */
902
+
903
+ let tmpRoot = ''
904
+ const startedWatchers: Array<{ stop(): void }> = []
905
+
906
+ beforeEach(() => {
907
+ tmpRoot = mkdtempSync(join(tmpdir(), 'switchroom-watcher-1116-slug-'))
908
+ })
909
+
910
+ afterEach(() => {
911
+ while (startedWatchers.length) {
912
+ try { startedWatchers.pop()?.stop() } catch { /* ignore */ }
913
+ }
914
+ try { rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* ignore */ }
915
+ })
916
+
917
+ function startWatcherSync(opts: { agentDir: string; agentCwd?: string }): {
918
+ notifications: string[]
919
+ logs: string[]
920
+ poll: () => void
921
+ watcher: ReturnType<typeof startSubagentWatcher>
922
+ } {
923
+ const notifications: string[] = []
924
+ const logs: string[] = []
925
+ const intervals: Array<{ fn: () => void; ref: number }> = []
926
+ const timeouts: Array<{ fn: () => void; ref: number }> = []
927
+ let nextRef = 1
928
+ const watcher = startSubagentWatcher({
929
+ agentDir: opts.agentDir,
930
+ ...(opts.agentCwd !== undefined ? { agentCwd: opts.agentCwd } : {}),
931
+ sendNotification: (text) => notifications.push(text),
932
+ stallThresholdMs: 60_000,
933
+ rescanMs: 500,
934
+ now: () => Date.now(),
935
+ setInterval: (fn) => {
936
+ const ref = nextRef++
937
+ intervals.push({ fn, ref })
938
+ return { ref }
939
+ },
940
+ clearInterval: (handle) => {
941
+ const { ref } = handle as { ref: number }
942
+ const idx = intervals.findIndex((i) => i.ref === ref)
943
+ if (idx !== -1) intervals.splice(idx, 1)
944
+ },
945
+ setTimeout: (fn) => {
946
+ const ref = nextRef++
947
+ timeouts.push({ fn, ref })
948
+ return { ref }
949
+ },
950
+ clearTimeout: (handle) => {
951
+ const { ref } = handle as { ref: number }
952
+ const idx = timeouts.findIndex((t) => t.ref === ref)
953
+ if (idx !== -1) timeouts.splice(idx, 1)
954
+ },
955
+ log: (msg) => logs.push(msg),
956
+ })
957
+ startedWatchers.push(watcher)
958
+ return {
959
+ notifications,
960
+ logs,
961
+ poll: () => intervals[0]?.fn(),
962
+ watcher,
963
+ }
964
+ }
965
+
966
+ it('skips foreign-slug project dirs when agentCwd is provided', () => {
967
+ // Layout: an agent whose real cwd is /home/test/agent-own. Its
968
+ // home contains BOTH its own project dir (-home-test-agent-own)
969
+ // AND a stale foreign one (-home-test-agent-foreign) left over
970
+ // from a prior boot. Each contains a sub-agent JSONL with a
971
+ // distinct agentId.
972
+ const agentDir = join(tmpRoot, 'agent')
973
+ const agentCwd = '/home/test/agent-own'
974
+ const ownSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-own', 'sess-A', 'subagents')
975
+ const foreignSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-foreign', 'sess-B', 'subagents')
976
+ mkdirSync(ownSubagents, { recursive: true })
977
+ mkdirSync(foreignSubagents, { recursive: true })
978
+ writeFileSync(
979
+ join(ownSubagents, 'agent-ownworker.jsonl'),
980
+ buildJSONL(subAgentUserMsg('legit work')),
981
+ )
982
+ writeFileSync(
983
+ join(foreignSubagents, 'agent-foreignworker.jsonl'),
984
+ buildJSONL(subAgentUserMsg('stale shadow')),
985
+ )
986
+
987
+ const h = startWatcherSync({ agentDir, agentCwd })
988
+ h.poll()
989
+
990
+ const reg = h.watcher.getRegistry()
991
+ expect(reg.has('ownworker')).toBe(true)
992
+ expect(reg.has('foreignworker')).toBe(false)
993
+ })
994
+
995
+ it('does NOT emit "read error" ENOENT for foreign-slug files that vanish mid-scan', () => {
996
+ // Simulates the smoking-gun log line from the issue. Foreign-slug
997
+ // JSONL is deleted from disk before the watcher's first readSubTail.
998
+ // With the slug filter in place the foreign agent is never even
999
+ // registered, so no ENOENT log line is emitted.
1000
+ const agentDir = join(tmpRoot, 'agent')
1001
+ const agentCwd = '/home/test/agent-own'
1002
+ const ownSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-own', 'sess-A', 'subagents')
1003
+ const foreignSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-foreign', 'sess-B', 'subagents')
1004
+ mkdirSync(ownSubagents, { recursive: true })
1005
+ mkdirSync(foreignSubagents, { recursive: true })
1006
+ writeFileSync(
1007
+ join(ownSubagents, 'agent-ownworker.jsonl'),
1008
+ buildJSONL(subAgentUserMsg('legit work')),
1009
+ )
1010
+ const foreignJsonl = join(foreignSubagents, 'agent-foreignworker.jsonl')
1011
+ writeFileSync(foreignJsonl, buildJSONL(subAgentUserMsg('about to vanish')))
1012
+
1013
+ const h = startWatcherSync({ agentDir, agentCwd })
1014
+ // Reap the foreign file the way Claude Code's session cleanup
1015
+ // would, *before* the first poll runs.
1016
+ rmSync(foreignJsonl)
1017
+ h.poll()
1018
+
1019
+ // Polls should not produce a "read error … ENOENT" log line.
1020
+ const enoentLogs = h.logs.filter((l) => l.includes('read error') && l.includes('ENOENT'))
1021
+ expect(enoentLogs).toHaveLength(0)
1022
+ // And the foreign agent must not be registered.
1023
+ expect(h.watcher.getRegistry().has('foreignworker')).toBe(false)
1024
+ })
1025
+ })
1026
+
1027
+ describe('issue #1116 — terminal cleanup must not re-fire completion (Bug B)', () => {
1028
+ /**
1029
+ * Pre-#1116: after `TERMINAL_CLEANUP_GRACE_MS` (30s) elapsed,
1030
+ * `cleanupTerminalAgent` deleted the agentId from `registry` and
1031
+ * the JSONL path from `knownFiles`. The JSONL itself stayed on disk
1032
+ * (Claude Code keeps the file). The next `rescanSubagentDirs` poll
1033
+ * found the JSONL absent from `knownFiles`, re-added it, called
1034
+ * `registerAgent` with a fresh `completionNotified=false` entry,
1035
+ * read the terminal `turn_duration` line again, and fired a SECOND
1036
+ * `✓ Worker done` notification. This loops every grace-window
1037
+ * (~30s); for the operator it manifests as the same handful of
1038
+ * sub-agents announcing completion every ~6 min indefinitely.
1039
+ */
1040
+
1041
+ let tmpRoot = ''
1042
+ const startedWatchers: Array<{ stop(): void }> = []
1043
+
1044
+ beforeEach(() => {
1045
+ tmpRoot = mkdtempSync(join(tmpdir(), 'switchroom-watcher-1116-rerun-'))
1046
+ })
1047
+
1048
+ afterEach(() => {
1049
+ while (startedWatchers.length) {
1050
+ try { startedWatchers.pop()?.stop() } catch { /* ignore */ }
1051
+ }
1052
+ try { rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* ignore */ }
1053
+ })
1054
+
1055
+ it('does NOT re-fire "Worker done" after terminal cleanup grace expires and rescan rediscovers the JSONL', () => {
1056
+ const agentDir = join(tmpRoot, 'agent')
1057
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'sess-A', 'subagents')
1058
+ mkdirSync(subagentsDir, { recursive: true })
1059
+ const jsonlPath = join(subagentsDir, 'agent-loopme.jsonl')
1060
+
1061
+ // Boot with an empty subagents dir, then write a post-boot JSONL so
1062
+ // the agent is registered non-historical (otherwise the historical
1063
+ // shortcut suppresses the completion notification entirely).
1064
+ const notifications: string[] = []
1065
+ const intervals: Array<{ fn: () => void; ref: number }> = []
1066
+ const timeouts: Array<{ fn: () => void; ref: number }> = []
1067
+ let nextRef = 1
1068
+ const watcher = startSubagentWatcher({
1069
+ agentDir,
1070
+ sendNotification: (text) => notifications.push(text),
1071
+ stallThresholdMs: 60_000,
1072
+ rescanMs: 500,
1073
+ now: () => Date.now(),
1074
+ setInterval: (fn) => {
1075
+ const ref = nextRef++
1076
+ intervals.push({ fn, ref })
1077
+ return { ref }
1078
+ },
1079
+ clearInterval: (handle) => {
1080
+ const { ref } = handle as { ref: number }
1081
+ const idx = intervals.findIndex((i) => i.ref === ref)
1082
+ if (idx !== -1) intervals.splice(idx, 1)
1083
+ },
1084
+ setTimeout: (fn) => {
1085
+ const ref = nextRef++
1086
+ timeouts.push({ fn, ref })
1087
+ return { ref }
1088
+ },
1089
+ clearTimeout: (handle) => {
1090
+ const { ref } = handle as { ref: number }
1091
+ const idx = timeouts.findIndex((t) => t.ref === ref)
1092
+ if (idx !== -1) timeouts.splice(idx, 1)
1093
+ },
1094
+ log: () => {},
1095
+ })
1096
+ startedWatchers.push(watcher)
1097
+
1098
+ const poll = () => intervals[0]?.fn()
1099
+ const fireScheduledCleanups = () => {
1100
+ let fired = 0
1101
+ while (timeouts.length) {
1102
+ const next = timeouts.shift()!
1103
+ next.fn()
1104
+ fired++
1105
+ }
1106
+ return fired
1107
+ }
1108
+
1109
+ // Step 1: post-boot, write an in-flight JSONL → poll registers it.
1110
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
1111
+ poll()
1112
+ expect(watcher.getRegistry().has('loopme')).toBe(true)
1113
+ expect(watcher.getRegistry().get('loopme')?.historical).toBe(false)
1114
+
1115
+ // Step 2: append turn_duration → poll → state=done → "Worker done" fires.
1116
+ appendFileSync(jsonlPath, buildJSONL(subAgentTurnDuration()))
1117
+ poll()
1118
+ const firstRoundNotifs = notifications.filter((n) => n.includes('Worker done'))
1119
+ expect(firstRoundNotifs).toHaveLength(1)
1120
+
1121
+ // Step 3: fire the terminal-cleanup grace timer (drains the
1122
+ // scheduled cleanup). Registry entry should be gone.
1123
+ const fired = fireScheduledCleanups()
1124
+ expect(fired).toBeGreaterThan(0)
1125
+ expect(watcher.getRegistry().has('loopme')).toBe(false)
1126
+
1127
+ // Step 4: the JSONL is STILL on disk — Claude Code doesn't delete
1128
+ // it after the sub-agent finishes. The next poll re-scans the dir
1129
+ // and finds the file. Pre-fix: re-registers the agent and fires a
1130
+ // SECOND "Worker done". Post-fix: re-discovery is a no-op for
1131
+ // an agentId we've already announced as terminal.
1132
+ poll()
1133
+ const afterRescanNotifs = notifications.filter((n) => n.includes('Worker done'))
1134
+ expect(
1135
+ afterRescanNotifs.length,
1136
+ `Expected exactly 1 "Worker done" notification, got ${afterRescanNotifs.length}: ${JSON.stringify(afterRescanNotifs)}`,
1137
+ ).toBe(1)
1138
+
1139
+ // And another poll for good measure — still exactly one.
1140
+ poll()
1141
+ poll()
1142
+ expect(notifications.filter((n) => n.includes('Worker done'))).toHaveLength(1)
1143
+ })
1144
+ })
882
1145
  })
@@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach } from 'vitest'
2
2
  import {
3
3
  reset,
4
4
  noteSignal,
5
+ noteOutbound,
5
6
  getLongestGap,
6
7
  getLastSignalAt,
8
+ getOutboundMetrics,
7
9
  clear,
8
10
  __resetAllForTests,
9
11
  } from '../turn-signal-tracker.js'
@@ -105,3 +107,82 @@ describe('turn-signal-tracker', () => {
105
107
  expect(getLongestGap(k)).toBe(0)
106
108
  })
107
109
  })
110
+
111
+ describe('turn-signal-tracker — outbound metrics (#1122)', () => {
112
+ it('a turn with zero outbound messages reports ttfoMs=null, count=0, gap=0', () => {
113
+ reset('k', 1000)
114
+ const m = getOutboundMetrics('k')
115
+ expect(m.ttfoMs).toBeNull()
116
+ expect(m.outboundCount).toBe(0)
117
+ expect(m.longestOutboundGapMs).toBe(0)
118
+ })
119
+
120
+ it('first noteOutbound() records TTFO = (now - turnStartedAt)', () => {
121
+ reset('k', 1000)
122
+ noteOutbound('k', 1750)
123
+ const m = getOutboundMetrics('k')
124
+ expect(m.ttfoMs).toBe(750)
125
+ expect(m.outboundCount).toBe(1)
126
+ expect(m.longestOutboundGapMs).toBe(0) // single message — no gap yet
127
+ })
128
+
129
+ it('multiple outbound messages compute the longest gap between them', () => {
130
+ reset('k', 0)
131
+ noteOutbound('k', 100) // ttfo=100, no gap
132
+ noteOutbound('k', 200) // gap=100
133
+ noteOutbound('k', 1700) // gap=1500 (longest)
134
+ noteOutbound('k', 1800) // gap=100
135
+ const m = getOutboundMetrics('k')
136
+ expect(m.ttfoMs).toBe(100)
137
+ expect(m.outboundCount).toBe(4)
138
+ expect(m.longestOutboundGapMs).toBe(1500)
139
+ })
140
+
141
+ it('noteOutbound() on an unknown key is a no-op', () => {
142
+ noteOutbound('untracked', 1000)
143
+ const m = getOutboundMetrics('untracked')
144
+ expect(m.ttfoMs).toBeNull()
145
+ expect(m.outboundCount).toBe(0)
146
+ })
147
+
148
+ it('reset() clears outbound state from prior turn', () => {
149
+ reset('k', 0)
150
+ noteOutbound('k', 100)
151
+ noteOutbound('k', 5000) // big gap
152
+ reset('k', 10000) // new turn
153
+ noteOutbound('k', 10100)
154
+ const m = getOutboundMetrics('k')
155
+ expect(m.ttfoMs).toBe(100)
156
+ expect(m.outboundCount).toBe(1)
157
+ expect(m.longestOutboundGapMs).toBe(0)
158
+ })
159
+
160
+ it('clear() wipes outbound state too', () => {
161
+ reset('k', 0)
162
+ noteOutbound('k', 100)
163
+ clear('k')
164
+ const m = getOutboundMetrics('k')
165
+ expect(m.ttfoMs).toBeNull()
166
+ expect(m.outboundCount).toBe(0)
167
+ })
168
+
169
+ it('outbound and signal counters track independently', () => {
170
+ reset('k', 0)
171
+ // Many signals (status reaction churn) but only one outbound
172
+ noteSignal('k', 100)
173
+ noteSignal('k', 500)
174
+ noteSignal('k', 2500)
175
+ noteOutbound('k', 3000) // first outbound, ttfo=3000
176
+ const m = getOutboundMetrics('k')
177
+ expect(m.ttfoMs).toBe(3000)
178
+ expect(m.outboundCount).toBe(1)
179
+ // Signal-gap reflects ALL signals (including reactions)
180
+ expect(getLongestGap('k')).toBeGreaterThanOrEqual(1000)
181
+ })
182
+
183
+ it('TTFO = 0 when first outbound fires at exact turn-start tick', () => {
184
+ reset('k', 5000)
185
+ noteOutbound('k', 5000)
186
+ expect(getOutboundMetrics('k').ttfoMs).toBe(0)
187
+ })
188
+ })