switchroom 0.5.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 (718) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +447 -0
  3. package/bin/autoaccept.exp +81 -0
  4. package/bin/boot-self-test.sh +149 -0
  5. package/bin/bridge-watchdog.sh +967 -0
  6. package/bin/handoff-briefing.sh +206 -0
  7. package/bin/run-hook.sh +228 -0
  8. package/bin/switchroom.ts +4 -0
  9. package/bin/timezone-hook.sh +67 -0
  10. package/bin/user-profile-refresh-hook.sh +38 -0
  11. package/bin/workspace-dynamic-hook.sh +142 -0
  12. package/bin/workspace-stable-hook.sh +57 -0
  13. package/dist/cli/autoaccept-poll.js +118 -0
  14. package/dist/cli/switchroom.js +48557 -0
  15. package/package.json +95 -0
  16. package/profiles/_base/settings.json.hbs +15 -0
  17. package/profiles/_base/start.sh.hbs +383 -0
  18. package/profiles/_shared/telegram-style.md.hbs +140 -0
  19. package/profiles/coding/CLAUDE.md.hbs +57 -0
  20. package/profiles/coding/skills/architecture/SKILL.md +70 -0
  21. package/profiles/coding/skills/code-review/SKILL.md +58 -0
  22. package/profiles/coding/workspace/SOUL.md.hbs +25 -0
  23. package/profiles/default/CLAUDE.md +238 -0
  24. package/profiles/default/CLAUDE.md.hbs +113 -0
  25. package/profiles/default/workspace/CLAUDE.md.hbs +126 -0
  26. package/profiles/default/workspace/HEARTBEAT.md.hbs +40 -0
  27. package/profiles/default/workspace/IDENTITY.md.hbs +32 -0
  28. package/profiles/default/workspace/MEMORY.md.hbs +29 -0
  29. package/profiles/default/workspace/SOUL.md.hbs +61 -0
  30. package/profiles/default/workspace/TOOLS.md.hbs +29 -0
  31. package/profiles/default/workspace/USER.md.hbs +52 -0
  32. package/profiles/default/workspace/memory/.gitkeep +0 -0
  33. package/profiles/executive-assistant/CLAUDE.md.hbs +51 -0
  34. package/profiles/executive-assistant/skills/daily-briefing/SKILL.md +55 -0
  35. package/profiles/executive-assistant/skills/meeting-prep/SKILL.md +58 -0
  36. package/profiles/executive-assistant/workspace/SOUL.md.hbs +25 -0
  37. package/profiles/health-coach/CLAUDE.md.hbs +45 -0
  38. package/profiles/health-coach/skills/check-in/SKILL.md +41 -0
  39. package/profiles/health-coach/skills/weekly-review/SKILL.md +53 -0
  40. package/profiles/health-coach/workspace/SOUL.md.hbs +25 -0
  41. package/skills/buildkite-agent-infrastructure/SKILL.md +302 -0
  42. package/skills/buildkite-agent-infrastructure/agents/openai.yaml +6 -0
  43. package/skills/buildkite-agent-infrastructure/assets/buildkite-icon-large.png +0 -0
  44. package/skills/buildkite-agent-infrastructure/assets/buildkite-icon-small.png +0 -0
  45. package/skills/buildkite-agent-infrastructure/references/audit-logging.md +87 -0
  46. package/skills/buildkite-agent-infrastructure/references/graphql-mutations.md +690 -0
  47. package/skills/buildkite-agent-infrastructure/references/instance-shapes.md +38 -0
  48. package/skills/buildkite-agent-infrastructure/references/pipeline-templates.md +73 -0
  49. package/skills/buildkite-agent-infrastructure/references/self-hosted-agents.md +137 -0
  50. package/skills/buildkite-agent-infrastructure/references/sso-saml.md +92 -0
  51. package/skills/buildkite-agent-runtime/SKILL.md +476 -0
  52. package/skills/buildkite-agent-runtime/agents/openai.yaml +6 -0
  53. package/skills/buildkite-agent-runtime/assets/buildkite-icon-large.png +0 -0
  54. package/skills/buildkite-agent-runtime/assets/buildkite-icon-small.png +0 -0
  55. package/skills/buildkite-agent-runtime/references/flag-reference.md +417 -0
  56. package/skills/buildkite-agent-runtime/references/patterns-and-recipes.md +555 -0
  57. package/skills/buildkite-api/SKILL.md +285 -0
  58. package/skills/buildkite-api/agents/openai.yaml +6 -0
  59. package/skills/buildkite-api/assets/buildkite-icon-large.png +0 -0
  60. package/skills/buildkite-api/assets/buildkite-icon-small.png +0 -0
  61. package/skills/buildkite-api/references/graphql-reference.md +195 -0
  62. package/skills/buildkite-api/references/patterns.md +44 -0
  63. package/skills/buildkite-api/references/webhooks.md +161 -0
  64. package/skills/buildkite-cli/SKILL.md +379 -0
  65. package/skills/buildkite-cli/agents/openai.yaml +6 -0
  66. package/skills/buildkite-cli/assets/buildkite-icon-large.png +0 -0
  67. package/skills/buildkite-cli/assets/buildkite-icon-small.png +0 -0
  68. package/skills/buildkite-cli/references/command-reference.md +181 -0
  69. package/skills/buildkite-migration/SKILL.md +182 -0
  70. package/skills/buildkite-pipelines/SKILL.md +464 -0
  71. package/skills/buildkite-pipelines/agents/openai.yaml +6 -0
  72. package/skills/buildkite-pipelines/assets/buildkite-icon-large.png +0 -0
  73. package/skills/buildkite-pipelines/assets/buildkite-icon-small.png +0 -0
  74. package/skills/buildkite-pipelines/examples/basic-pipeline.yml +24 -0
  75. package/skills/buildkite-pipelines/examples/optimized-pipeline.yml +100 -0
  76. package/skills/buildkite-pipelines/references/advanced-patterns.md +286 -0
  77. package/skills/buildkite-pipelines/references/retry-and-error-codes.md +131 -0
  78. package/skills/buildkite-pipelines/references/step-types-reference.md +225 -0
  79. package/skills/buildkite-secure-delivery/SKILL.md +168 -0
  80. package/skills/buildkite-secure-delivery/agents/openai.yaml +6 -0
  81. package/skills/buildkite-secure-delivery/assets/buildkite-icon-large.png +0 -0
  82. package/skills/buildkite-secure-delivery/assets/buildkite-icon-small.png +0 -0
  83. package/skills/buildkite-secure-delivery/references/oidc-cloud-providers.md +83 -0
  84. package/skills/buildkite-secure-delivery/references/package-publishing.md +100 -0
  85. package/skills/buildkite-test-engine/SKILL.md +239 -0
  86. package/skills/buildkite-test-engine/agents/openai.yaml +6 -0
  87. package/skills/buildkite-test-engine/assets/buildkite-icon-large.png +0 -0
  88. package/skills/buildkite-test-engine/assets/buildkite-icon-small.png +0 -0
  89. package/skills/buildkite-test-engine/examples/bktec-splitting.yml +16 -0
  90. package/skills/buildkite-test-engine/examples/collector-pipeline.yml +11 -0
  91. package/skills/buildkite-test-engine/references/collectors.md +198 -0
  92. package/skills/buildkite-test-engine/references/splitting-examples.md +93 -0
  93. package/skills/docx/LICENSE.txt +30 -0
  94. package/skills/docx/SKILL.md +590 -0
  95. package/skills/docx/VENDORED.md +32 -0
  96. package/skills/docx/scripts/__init__.py +1 -0
  97. package/skills/docx/scripts/accept_changes.py +135 -0
  98. package/skills/docx/scripts/comment.py +318 -0
  99. package/skills/docx/scripts/office/helpers/__init__.py +0 -0
  100. package/skills/docx/scripts/office/helpers/merge_runs.py +199 -0
  101. package/skills/docx/scripts/office/helpers/simplify_redlines.py +197 -0
  102. package/skills/docx/scripts/office/pack.py +159 -0
  103. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  104. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  105. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  106. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  107. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  108. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  109. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  110. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  111. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  112. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  113. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  114. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  115. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  116. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  117. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  118. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  119. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  120. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  121. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  122. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  123. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  124. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  125. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  126. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  127. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  128. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  129. package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  130. package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
  131. package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
  132. package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
  133. package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
  134. package/skills/docx/scripts/office/schemas/mce/mc.xsd +75 -0
  135. package/skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  136. package/skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  137. package/skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  138. package/skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  139. package/skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  140. package/skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  141. package/skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  142. package/skills/docx/scripts/office/soffice.py +183 -0
  143. package/skills/docx/scripts/office/unpack.py +132 -0
  144. package/skills/docx/scripts/office/validate.py +111 -0
  145. package/skills/docx/scripts/office/validators/__init__.py +15 -0
  146. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
  147. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
  148. package/skills/docx/scripts/office/validators/base.py +847 -0
  149. package/skills/docx/scripts/office/validators/docx.py +446 -0
  150. package/skills/docx/scripts/office/validators/pptx.py +275 -0
  151. package/skills/docx/scripts/office/validators/redlining.py +247 -0
  152. package/skills/docx/scripts/templates/comments.xml +3 -0
  153. package/skills/docx/scripts/templates/commentsExtended.xml +3 -0
  154. package/skills/docx/scripts/templates/commentsExtensible.xml +3 -0
  155. package/skills/docx/scripts/templates/commentsIds.xml +3 -0
  156. package/skills/docx/scripts/templates/people.xml +3 -0
  157. package/skills/file-bug/SKILL.md +129 -0
  158. package/skills/humanizer/LICENSE +21 -0
  159. package/skills/humanizer/SKILL.md +559 -0
  160. package/skills/humanizer/VENDORED.md +38 -0
  161. package/skills/humanizer-calibrate/SKILL.md +144 -0
  162. package/skills/mcp-builder/LICENSE.txt +202 -0
  163. package/skills/mcp-builder/SKILL.md +236 -0
  164. package/skills/mcp-builder/VENDORED.md +32 -0
  165. package/skills/mcp-builder/reference/evaluation.md +602 -0
  166. package/skills/mcp-builder/reference/mcp_best_practices.md +249 -0
  167. package/skills/mcp-builder/reference/node_mcp_server.md +970 -0
  168. package/skills/mcp-builder/reference/python_mcp_server.md +719 -0
  169. package/skills/mcp-builder/scripts/connections.py +151 -0
  170. package/skills/mcp-builder/scripts/evaluation.py +373 -0
  171. package/skills/mcp-builder/scripts/example_evaluation.xml +22 -0
  172. package/skills/mcp-builder/scripts/requirements.txt +2 -0
  173. package/skills/pdf/LICENSE.txt +30 -0
  174. package/skills/pdf/SKILL.md +314 -0
  175. package/skills/pdf/VENDORED.md +32 -0
  176. package/skills/pdf/forms.md +294 -0
  177. package/skills/pdf/reference.md +612 -0
  178. package/skills/pdf/scripts/check_bounding_boxes.py +65 -0
  179. package/skills/pdf/scripts/check_fillable_fields.py +11 -0
  180. package/skills/pdf/scripts/convert_pdf_to_images.py +33 -0
  181. package/skills/pdf/scripts/create_validation_image.py +37 -0
  182. package/skills/pdf/scripts/extract_form_field_info.py +122 -0
  183. package/skills/pdf/scripts/extract_form_structure.py +115 -0
  184. package/skills/pdf/scripts/fill_fillable_fields.py +98 -0
  185. package/skills/pdf/scripts/fill_pdf_form_with_annotations.py +107 -0
  186. package/skills/pptx/LICENSE.txt +30 -0
  187. package/skills/pptx/SKILL.md +232 -0
  188. package/skills/pptx/VENDORED.md +32 -0
  189. package/skills/pptx/editing.md +205 -0
  190. package/skills/pptx/pptxgenjs.md +420 -0
  191. package/skills/pptx/scripts/__init__.py +0 -0
  192. package/skills/pptx/scripts/add_slide.py +195 -0
  193. package/skills/pptx/scripts/clean.py +286 -0
  194. package/skills/pptx/scripts/office/helpers/__init__.py +0 -0
  195. package/skills/pptx/scripts/office/helpers/merge_runs.py +199 -0
  196. package/skills/pptx/scripts/office/helpers/simplify_redlines.py +197 -0
  197. package/skills/pptx/scripts/office/pack.py +159 -0
  198. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  199. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  200. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  201. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  202. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  203. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  204. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  205. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  206. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  207. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  208. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  209. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  210. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  211. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  212. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  213. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  214. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  215. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  216. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  217. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  218. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  219. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  220. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  221. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  222. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  223. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  224. package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  225. package/skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
  226. package/skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
  227. package/skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
  228. package/skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
  229. package/skills/pptx/scripts/office/schemas/mce/mc.xsd +75 -0
  230. package/skills/pptx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  231. package/skills/pptx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  232. package/skills/pptx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  233. package/skills/pptx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  234. package/skills/pptx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  235. package/skills/pptx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  236. package/skills/pptx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  237. package/skills/pptx/scripts/office/soffice.py +183 -0
  238. package/skills/pptx/scripts/office/unpack.py +132 -0
  239. package/skills/pptx/scripts/office/validate.py +111 -0
  240. package/skills/pptx/scripts/office/validators/__init__.py +15 -0
  241. package/skills/pptx/scripts/office/validators/base.py +847 -0
  242. package/skills/pptx/scripts/office/validators/docx.py +446 -0
  243. package/skills/pptx/scripts/office/validators/pptx.py +275 -0
  244. package/skills/pptx/scripts/office/validators/redlining.py +247 -0
  245. package/skills/pptx/scripts/thumbnail.py +289 -0
  246. package/skills/skill-creator/LICENSE.txt +202 -0
  247. package/skills/skill-creator/SKILL.md +485 -0
  248. package/skills/skill-creator/VENDORED.md +32 -0
  249. package/skills/skill-creator/agents/analyzer.md +274 -0
  250. package/skills/skill-creator/agents/comparator.md +202 -0
  251. package/skills/skill-creator/agents/grader.md +223 -0
  252. package/skills/skill-creator/assets/eval_review.html +146 -0
  253. package/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  254. package/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  255. package/skills/skill-creator/references/schemas.md +430 -0
  256. package/skills/skill-creator/scripts/__init__.py +0 -0
  257. package/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  258. package/skills/skill-creator/scripts/generate_report.py +326 -0
  259. package/skills/skill-creator/scripts/improve_description.py +247 -0
  260. package/skills/skill-creator/scripts/package_skill.py +136 -0
  261. package/skills/skill-creator/scripts/quick_validate.py +103 -0
  262. package/skills/skill-creator/scripts/run_eval.py +310 -0
  263. package/skills/skill-creator/scripts/run_loop.py +328 -0
  264. package/skills/skill-creator/scripts/utils.py +47 -0
  265. package/skills/switchroom-architecture/SKILL.md +60 -0
  266. package/skills/switchroom-architecture/cascade.md +112 -0
  267. package/skills/switchroom-architecture/sub-agents.md +87 -0
  268. package/skills/switchroom-architecture/telegram.md +94 -0
  269. package/skills/switchroom-cli/SKILL.md +274 -0
  270. package/skills/switchroom-health/SKILL.md +101 -0
  271. package/skills/switchroom-install/SKILL.md +116 -0
  272. package/skills/switchroom-manage/SKILL.md +90 -0
  273. package/skills/switchroom-status/SKILL.md +69 -0
  274. package/skills/switchroom-status/scripts/status.sh +69 -0
  275. package/skills/telegram-test-harness/SKILL.md +191 -0
  276. package/skills/token-helpers/SKILL.md +73 -0
  277. package/skills/token-helpers/scripts/google-cal-token.sh +62 -0
  278. package/skills/token-helpers/scripts/ms-graph-token.sh +70 -0
  279. package/skills/webapp-testing/LICENSE.txt +202 -0
  280. package/skills/webapp-testing/SKILL.md +96 -0
  281. package/skills/webapp-testing/VENDORED.md +32 -0
  282. package/skills/webapp-testing/examples/console_logging.py +35 -0
  283. package/skills/webapp-testing/examples/element_discovery.py +40 -0
  284. package/skills/webapp-testing/examples/static_html_automation.py +33 -0
  285. package/skills/webapp-testing/scripts/with_server.py +106 -0
  286. package/skills/xlsx/LICENSE.txt +30 -0
  287. package/skills/xlsx/SKILL.md +292 -0
  288. package/skills/xlsx/VENDORED.md +32 -0
  289. package/skills/xlsx/scripts/office/helpers/__init__.py +0 -0
  290. package/skills/xlsx/scripts/office/helpers/merge_runs.py +199 -0
  291. package/skills/xlsx/scripts/office/helpers/simplify_redlines.py +197 -0
  292. package/skills/xlsx/scripts/office/pack.py +159 -0
  293. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  294. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  295. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  296. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  297. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  298. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  299. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  300. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  301. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  302. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  303. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  304. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  305. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  306. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  307. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  308. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  309. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  310. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  311. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  312. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  313. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  314. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  315. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  316. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  317. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  318. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  319. package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  320. package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
  321. package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
  322. package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
  323. package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
  324. package/skills/xlsx/scripts/office/schemas/mce/mc.xsd +75 -0
  325. package/skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  326. package/skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  327. package/skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  328. package/skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  329. package/skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  330. package/skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  331. package/skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  332. package/skills/xlsx/scripts/office/soffice.py +183 -0
  333. package/skills/xlsx/scripts/office/unpack.py +132 -0
  334. package/skills/xlsx/scripts/office/validate.py +111 -0
  335. package/skills/xlsx/scripts/office/validators/__init__.py +15 -0
  336. package/skills/xlsx/scripts/office/validators/base.py +847 -0
  337. package/skills/xlsx/scripts/office/validators/docx.py +446 -0
  338. package/skills/xlsx/scripts/office/validators/pptx.py +275 -0
  339. package/skills/xlsx/scripts/office/validators/redlining.py +247 -0
  340. package/skills/xlsx/scripts/recalc.py +184 -0
  341. package/telegram-plugin/.claude-plugin/plugin.json +20 -0
  342. package/telegram-plugin/.mcp.json +14 -0
  343. package/telegram-plugin/LICENSE +21 -0
  344. package/telegram-plugin/README.md +352 -0
  345. package/telegram-plugin/active-pins-sweep.ts +204 -0
  346. package/telegram-plugin/active-pins.ts +146 -0
  347. package/telegram-plugin/active-reactions-sweep.ts +79 -0
  348. package/telegram-plugin/active-reactions.ts +134 -0
  349. package/telegram-plugin/admin-commands/dispatch.test.ts +149 -0
  350. package/telegram-plugin/admin-commands/index.ts +106 -0
  351. package/telegram-plugin/answer-stream.ts +565 -0
  352. package/telegram-plugin/ask-user.ts +179 -0
  353. package/telegram-plugin/attachment-path.ts +80 -0
  354. package/telegram-plugin/auth-code-redact.ts +83 -0
  355. package/telegram-plugin/auth-dashboard.ts +1104 -0
  356. package/telegram-plugin/auth-slot-parser.ts +497 -0
  357. package/telegram-plugin/auto-fallback-dispatcher.ts +68 -0
  358. package/telegram-plugin/auto-fallback.ts +348 -0
  359. package/telegram-plugin/bridge/bridge.ts +687 -0
  360. package/telegram-plugin/bridge/ipc-client.ts +326 -0
  361. package/telegram-plugin/bun.lock +218 -0
  362. package/telegram-plugin/card-format.ts +62 -0
  363. package/telegram-plugin/channel-envelope-safety.test.ts +56 -0
  364. package/telegram-plugin/channel-envelope-safety.ts +56 -0
  365. package/telegram-plugin/chat-lock.ts +65 -0
  366. package/telegram-plugin/context-exhaustion.ts +38 -0
  367. package/telegram-plugin/credits-watch.ts +220 -0
  368. package/telegram-plugin/dist/bridge/bridge.js +24758 -0
  369. package/telegram-plugin/dist/foreman/foreman.js +30723 -0
  370. package/telegram-plugin/dist/gateway/gateway.js +46497 -0
  371. package/telegram-plugin/dist/server.js +24551 -0
  372. package/telegram-plugin/dm-command-gate.ts +56 -0
  373. package/telegram-plugin/docs/gateway-server-split.md +133 -0
  374. package/telegram-plugin/docs/multi-agent-card-design.md +847 -0
  375. package/telegram-plugin/docs/pinned-progress-card-reliability.md +144 -0
  376. package/telegram-plugin/docs/stream-json-daemon-mode.md +477 -0
  377. package/telegram-plugin/docs/waiting-ux-spec.md +233 -0
  378. package/telegram-plugin/draft-stream.ts +442 -0
  379. package/telegram-plugin/draft-transport.ts +72 -0
  380. package/telegram-plugin/first-paint.ts +246 -0
  381. package/telegram-plugin/fleet-state.ts +246 -0
  382. package/telegram-plugin/foreman/foreman-create-flow.ts +202 -0
  383. package/telegram-plugin/foreman/foreman-handlers.ts +493 -0
  384. package/telegram-plugin/foreman/foreman.ts +1130 -0
  385. package/telegram-plugin/foreman/setup-flow.ts +345 -0
  386. package/telegram-plugin/foreman/setup-state.ts +239 -0
  387. package/telegram-plugin/foreman/state.ts +203 -0
  388. package/telegram-plugin/format.ts +685 -0
  389. package/telegram-plugin/gateway/access-validator.test.ts +95 -0
  390. package/telegram-plugin/gateway/access-validator.ts +37 -0
  391. package/telegram-plugin/gateway/boot-card.ts +582 -0
  392. package/telegram-plugin/gateway/boot-probes.ts +863 -0
  393. package/telegram-plugin/gateway/boot-reason.ts +51 -0
  394. package/telegram-plugin/gateway/boot-sweep-filter.test.ts +54 -0
  395. package/telegram-plugin/gateway/boot-sweep-filter.ts +32 -0
  396. package/telegram-plugin/gateway/clean-shutdown-marker.ts +183 -0
  397. package/telegram-plugin/gateway/disconnect-flush.ts +109 -0
  398. package/telegram-plugin/gateway/gateway.ts +10202 -0
  399. package/telegram-plugin/gateway/inbound-coalesce.ts +147 -0
  400. package/telegram-plugin/gateway/inject-handler.test.ts +221 -0
  401. package/telegram-plugin/gateway/inject-handler.ts +190 -0
  402. package/telegram-plugin/gateway/ipc-protocol.ts +151 -0
  403. package/telegram-plugin/gateway/ipc-server.ts +494 -0
  404. package/telegram-plugin/gateway/pid-file.ts +103 -0
  405. package/telegram-plugin/gateway/poll-health.ts +156 -0
  406. package/telegram-plugin/gateway/preamble-suppressor.ts +154 -0
  407. package/telegram-plugin/gateway/quota-cache.ts +125 -0
  408. package/telegram-plugin/gateway/resolve-calling-subagent.ts +78 -0
  409. package/telegram-plugin/gateway/restart-watchdog.ts +200 -0
  410. package/telegram-plugin/gateway/session-marker.ts +83 -0
  411. package/telegram-plugin/gateway/shutdown-drain.ts +162 -0
  412. package/telegram-plugin/gateway/startup-mutex.ts +285 -0
  413. package/telegram-plugin/gateway/startup-network-retry.ts +142 -0
  414. package/telegram-plugin/gateway/turn-active-marker.ts +176 -0
  415. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +78 -0
  416. package/telegram-plugin/handoff-continuity.ts +200 -0
  417. package/telegram-plugin/history.ts +468 -0
  418. package/telegram-plugin/hooks/hooks.json +58 -0
  419. package/telegram-plugin/hooks/secret-guard-pretool.mjs +208 -0
  420. package/telegram-plugin/hooks/secret-scrub-stop.mjs +98 -0
  421. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +111 -0
  422. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +296 -0
  423. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +261 -0
  424. package/telegram-plugin/html-sanitize.ts +244 -0
  425. package/telegram-plugin/idle-footer.ts +65 -0
  426. package/telegram-plugin/inline-keyboard-callbacks.ts +166 -0
  427. package/telegram-plugin/interrupt-marker.ts +66 -0
  428. package/telegram-plugin/issues-card.ts +371 -0
  429. package/telegram-plugin/issues-watcher.ts +125 -0
  430. package/telegram-plugin/model-unavailable.ts +325 -0
  431. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  432. package/telegram-plugin/operator-events-history.ts +94 -0
  433. package/telegram-plugin/operator-events.fixtures.json +161 -0
  434. package/telegram-plugin/operator-events.ts +421 -0
  435. package/telegram-plugin/package.json +55 -0
  436. package/telegram-plugin/permission-rule.ts +133 -0
  437. package/telegram-plugin/permission-title.ts +117 -0
  438. package/telegram-plugin/pin-event-log.ts +76 -0
  439. package/telegram-plugin/plugin-logger.ts +136 -0
  440. package/telegram-plugin/progress-card-driver.ts +2697 -0
  441. package/telegram-plugin/progress-card-pin-manager.ts +589 -0
  442. package/telegram-plugin/progress-card-pin-watchdog.ts +98 -0
  443. package/telegram-plugin/progress-card.ts +1409 -0
  444. package/telegram-plugin/pty-partial-handler.ts +247 -0
  445. package/telegram-plugin/pty-tail.ts +730 -0
  446. package/telegram-plugin/quota-check.ts +474 -0
  447. package/telegram-plugin/recent-outbound-dedup.ts +169 -0
  448. package/telegram-plugin/registry/api-registry.test.ts +201 -0
  449. package/telegram-plugin/registry/subagents-bugs.test.ts +454 -0
  450. package/telegram-plugin/registry/subagents-schema.ts +509 -0
  451. package/telegram-plugin/registry/subagents.test.ts +476 -0
  452. package/telegram-plugin/registry/turns-schema.test.ts +101 -0
  453. package/telegram-plugin/registry/turns-schema.ts +417 -0
  454. package/telegram-plugin/retry-api-call.ts +172 -0
  455. package/telegram-plugin/scripts/build.mjs +78 -0
  456. package/telegram-plugin/secret-detect/audit.ts +66 -0
  457. package/telegram-plugin/secret-detect/chunker.ts +37 -0
  458. package/telegram-plugin/secret-detect/entropy.ts +20 -0
  459. package/telegram-plugin/secret-detect/gitleaks-loader.ts +74 -0
  460. package/telegram-plugin/secret-detect/gitleaks.toml +27 -0
  461. package/telegram-plugin/secret-detect/index.ts +218 -0
  462. package/telegram-plugin/secret-detect/kv-scanner.ts +60 -0
  463. package/telegram-plugin/secret-detect/mask.ts +13 -0
  464. package/telegram-plugin/secret-detect/patterns.ts +115 -0
  465. package/telegram-plugin/secret-detect/pipeline.ts +144 -0
  466. package/telegram-plugin/secret-detect/rewrite.ts +26 -0
  467. package/telegram-plugin/secret-detect/secretlint-source.ts +95 -0
  468. package/telegram-plugin/secret-detect/slug.ts +44 -0
  469. package/telegram-plugin/secret-detect/staging.ts +85 -0
  470. package/telegram-plugin/secret-detect/suppressor.ts +34 -0
  471. package/telegram-plugin/secret-detect/url-redact.ts +60 -0
  472. package/telegram-plugin/secret-detect/vault-write.ts +56 -0
  473. package/telegram-plugin/server.js +41795 -0
  474. package/telegram-plugin/server.ts +171 -0
  475. package/telegram-plugin/session-tail.ts +884 -0
  476. package/telegram-plugin/shared/bot-runtime.ts +324 -0
  477. package/telegram-plugin/silent-reply.ts +58 -0
  478. package/telegram-plugin/slot-banner-driver.ts +147 -0
  479. package/telegram-plugin/slot-banner.ts +86 -0
  480. package/telegram-plugin/start.js +26 -0
  481. package/telegram-plugin/startup-reset.ts +45 -0
  482. package/telegram-plugin/status-reactions.ts +332 -0
  483. package/telegram-plugin/steering.ts +155 -0
  484. package/telegram-plugin/sticker-aliases.ts +249 -0
  485. package/telegram-plugin/stream-controller.ts +311 -0
  486. package/telegram-plugin/stream-reply-handler.ts +664 -0
  487. package/telegram-plugin/streaming-metrics.ts +134 -0
  488. package/telegram-plugin/streaming-report.ts +204 -0
  489. package/telegram-plugin/subagent-watcher.ts +880 -0
  490. package/telegram-plugin/telegram-button-constraints.ts +191 -0
  491. package/telegram-plugin/telegraph.ts +381 -0
  492. package/telegram-plugin/tests/HARNESS.md +340 -0
  493. package/telegram-plugin/tests/_progress-card-harness.ts +105 -0
  494. package/telegram-plugin/tests/active-pins-boot-reaper.test.ts +211 -0
  495. package/telegram-plugin/tests/active-pins-sweep.test.ts +309 -0
  496. package/telegram-plugin/tests/active-pins.test.ts +187 -0
  497. package/telegram-plugin/tests/active-reactions-sweep.test.ts +116 -0
  498. package/telegram-plugin/tests/active-reactions.test.ts +198 -0
  499. package/telegram-plugin/tests/answer-stream-dedup.test.ts +352 -0
  500. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +236 -0
  501. package/telegram-plugin/tests/answer-stream.test.ts +878 -0
  502. package/telegram-plugin/tests/ask-user.test.ts +203 -0
  503. package/telegram-plugin/tests/attachment-path.test.ts +199 -0
  504. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +118 -0
  505. package/telegram-plugin/tests/auth-code-auto-capture.test.ts +144 -0
  506. package/telegram-plugin/tests/auth-code-redact.test.ts +248 -0
  507. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +260 -0
  508. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +140 -0
  509. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +559 -0
  510. package/telegram-plugin/tests/auth-dashboard.test.ts +1045 -0
  511. package/telegram-plugin/tests/auth-login-url-button.test.ts +122 -0
  512. package/telegram-plugin/tests/auth-slot-commands.test.ts +640 -0
  513. package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +183 -0
  514. package/telegram-plugin/tests/auto-fallback.test.ts +381 -0
  515. package/telegram-plugin/tests/boot-card-account-quota.test.ts +137 -0
  516. package/telegram-plugin/tests/boot-card-dedupe.test.ts +154 -0
  517. package/telegram-plugin/tests/boot-card-probe-target.test.ts +194 -0
  518. package/telegram-plugin/tests/boot-card-reason.test.ts +103 -0
  519. package/telegram-plugin/tests/boot-card-render.test.ts +219 -0
  520. package/telegram-plugin/tests/boot-probes.test.ts +451 -0
  521. package/telegram-plugin/tests/bot-api.harness.ts +116 -0
  522. package/telegram-plugin/tests/bot-runtime.test.ts +190 -0
  523. package/telegram-plugin/tests/bridge-anonymous-refuse.test.ts +60 -0
  524. package/telegram-plugin/tests/context-exhaustion.test.ts +114 -0
  525. package/telegram-plugin/tests/credits-watch.test.ts +221 -0
  526. package/telegram-plugin/tests/dm-command-gate.test.ts +176 -0
  527. package/telegram-plugin/tests/draft-stream.test.ts +752 -0
  528. package/telegram-plugin/tests/draft-transport.test.ts +141 -0
  529. package/telegram-plugin/tests/e2e.test.ts +436 -0
  530. package/telegram-plugin/tests/fake-bot-api.test.ts +213 -0
  531. package/telegram-plugin/tests/fake-bot-api.ts +617 -0
  532. package/telegram-plugin/tests/false-restart-banner.test.ts +253 -0
  533. package/telegram-plugin/tests/first-paint.test.ts +257 -0
  534. package/telegram-plugin/tests/fixtures/pty-tail-tmux-fragment.bin +6 -0
  535. package/telegram-plugin/tests/fixtures/service-log-current-claude-code.bin +3624 -0
  536. package/telegram-plugin/tests/fleet-state-watcher.test.ts +101 -0
  537. package/telegram-plugin/tests/fleet-state.test.ts +185 -0
  538. package/telegram-plugin/tests/foreman-create-flow.test.ts +359 -0
  539. package/telegram-plugin/tests/foreman-handlers.test.ts +347 -0
  540. package/telegram-plugin/tests/foreman-state.test.ts +164 -0
  541. package/telegram-plugin/tests/foreman-write-ops.test.ts +214 -0
  542. package/telegram-plugin/tests/gateway-409-retry-banner.test.ts +173 -0
  543. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +72 -0
  544. package/telegram-plugin/tests/gateway-bridge.test.ts +811 -0
  545. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +414 -0
  546. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +144 -0
  547. package/telegram-plugin/tests/gateway-message-validator.test.ts +133 -0
  548. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +103 -0
  549. package/telegram-plugin/tests/gateway-secret-detect.test.ts +127 -0
  550. package/telegram-plugin/tests/gateway-startup-mutex.test.ts +284 -0
  551. package/telegram-plugin/tests/gateway-startup-network-retry.test.ts +185 -0
  552. package/telegram-plugin/tests/gateway-startup-reset.test.ts +72 -0
  553. package/telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts +125 -0
  554. package/telegram-plugin/tests/handoff-continuity.test.ts +249 -0
  555. package/telegram-plugin/tests/harness-ordering-invariants.test.ts +243 -0
  556. package/telegram-plugin/tests/harness-parse-mode-validation.test.ts +114 -0
  557. package/telegram-plugin/tests/history.test.ts +364 -0
  558. package/telegram-plugin/tests/html-balanced.ts +63 -0
  559. package/telegram-plugin/tests/html-sanitize.test.ts +146 -0
  560. package/telegram-plugin/tests/idle-footer-wiring.test.ts +88 -0
  561. package/telegram-plugin/tests/idle-footer.test.ts +66 -0
  562. package/telegram-plugin/tests/inbound-coalesce.test.ts +127 -0
  563. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +150 -0
  564. package/telegram-plugin/tests/interrupt-marker.test.ts +126 -0
  565. package/telegram-plugin/tests/ipc-protocol.test.ts +218 -0
  566. package/telegram-plugin/tests/ipc-server-anonymous-refuse.test.ts +82 -0
  567. package/telegram-plugin/tests/ipc-server-client.test.ts +323 -0
  568. package/telegram-plugin/tests/ipc-server-race.test.ts +183 -0
  569. package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +91 -0
  570. package/telegram-plugin/tests/ipc-server-validate-pty-partial.test.ts +64 -0
  571. package/telegram-plugin/tests/ipc-server-validate-update-placeholder.test.ts +77 -0
  572. package/telegram-plugin/tests/ipc-validator.test.ts +274 -0
  573. package/telegram-plugin/tests/issues-card.test.ts +495 -0
  574. package/telegram-plugin/tests/issues-watcher.test.ts +165 -0
  575. package/telegram-plugin/tests/model-unavailable.test.ts +303 -0
  576. package/telegram-plugin/tests/multi-turn-continuity.test.ts +159 -0
  577. package/telegram-plugin/tests/operator-events-history.test.ts +125 -0
  578. package/telegram-plugin/tests/operator-events-session-tail.test.ts +192 -0
  579. package/telegram-plugin/tests/operator-events.test.ts +331 -0
  580. package/telegram-plugin/tests/outbound-ordering.test.ts +293 -0
  581. package/telegram-plugin/tests/parse-mode-rotation.test.ts +164 -0
  582. package/telegram-plugin/tests/permission-rule.test.ts +121 -0
  583. package/telegram-plugin/tests/permission-title.test.ts +106 -0
  584. package/telegram-plugin/tests/pin-event-log.test.ts +124 -0
  585. package/telegram-plugin/tests/plugin-logger.test.ts +97 -0
  586. package/telegram-plugin/tests/poll-health.test.ts +86 -0
  587. package/telegram-plugin/tests/preamble-suppressor.test.ts +285 -0
  588. package/telegram-plugin/tests/progress-card-api-failure-during-deferred.test.ts +73 -0
  589. package/telegram-plugin/tests/progress-card-close-paths-converge.test.ts +272 -0
  590. package/telegram-plugin/tests/progress-card-cross-turn.test.ts +258 -0
  591. package/telegram-plugin/tests/progress-card-dispose-preservepending.test.ts +81 -0
  592. package/telegram-plugin/tests/progress-card-draft-flag.test.ts +80 -0
  593. package/telegram-plugin/tests/progress-card-driver-eviction.test.ts +215 -0
  594. package/telegram-plugin/tests/progress-card-driver-fleet-shadow.test.ts +123 -0
  595. package/telegram-plugin/tests/progress-card-driver-force-complete-parent-done.test.ts +76 -0
  596. package/telegram-plugin/tests/progress-card-edit-timestamps-budget.test.ts +62 -0
  597. package/telegram-plugin/tests/progress-card-memory-bounds.test.ts +84 -0
  598. package/telegram-plugin/tests/progress-card-pin-failure-paths.test.ts +139 -0
  599. package/telegram-plugin/tests/progress-card-pin-manager.test.ts +773 -0
  600. package/telegram-plugin/tests/progress-card-pin-race-fast-turn.test.ts +66 -0
  601. package/telegram-plugin/tests/progress-card-pin-sidecar-partial-write.test.ts +64 -0
  602. package/telegram-plugin/tests/progress-card-pin-watchdog.test.ts +190 -0
  603. package/telegram-plugin/tests/progress-card-sigterm-pin-flush.test.ts +146 -0
  604. package/telegram-plugin/tests/progress-update.test.ts +236 -0
  605. package/telegram-plugin/tests/protocol-fixtures.test.ts +59 -0
  606. package/telegram-plugin/tests/protocol-fixtures.ts +198 -0
  607. package/telegram-plugin/tests/pty-partial-handler.test.ts +326 -0
  608. package/telegram-plugin/tests/pty-tail-real-fixture.test.ts +114 -0
  609. package/telegram-plugin/tests/pty-tail-tmux-fragment.test.ts +71 -0
  610. package/telegram-plugin/tests/pty-tail.test.ts +525 -0
  611. package/telegram-plugin/tests/quota-cache.test.ts +187 -0
  612. package/telegram-plugin/tests/quota-check.test.ts +622 -0
  613. package/telegram-plugin/tests/races.test.ts +842 -0
  614. package/telegram-plugin/tests/real-gateway-f1-ladder-integrity.test.ts +123 -0
  615. package/telegram-plugin/tests/real-gateway-f2-instant-draft.test.ts +82 -0
  616. package/telegram-plugin/tests/real-gateway-f3-late-card.test.ts +114 -0
  617. package/telegram-plugin/tests/real-gateway-harness.ts +699 -0
  618. package/telegram-plugin/tests/real-gateway-i6-turn-flush-replay-dedup.test.ts +313 -0
  619. package/telegram-plugin/tests/real-gateway-ipc-lifecycle.test.ts +299 -0
  620. package/telegram-plugin/tests/real-gateway-spec.test.ts +487 -0
  621. package/telegram-plugin/tests/real-gateway.smoke.test.ts +101 -0
  622. package/telegram-plugin/tests/recent-outbound-dedup.test.ts +192 -0
  623. package/telegram-plugin/tests/registry-turns.test.ts +432 -0
  624. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +149 -0
  625. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +269 -0
  626. package/telegram-plugin/tests/restart-watchdog.test.ts +224 -0
  627. package/telegram-plugin/tests/retry-api-call.test.ts +287 -0
  628. package/telegram-plugin/tests/secret-detect-audit.test.ts +58 -0
  629. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +83 -0
  630. package/telegram-plugin/tests/secret-detect-gitleaks.test.ts +32 -0
  631. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +308 -0
  632. package/telegram-plugin/tests/secret-detect-pipeline.test.ts +123 -0
  633. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +101 -0
  634. package/telegram-plugin/tests/secret-detect-staging.test.ts +45 -0
  635. package/telegram-plugin/tests/secret-detect-suppressor-no-silent-allow.test.ts +67 -0
  636. package/telegram-plugin/tests/secret-detect.test.ts +223 -0
  637. package/telegram-plugin/tests/secret-guard-pretool.test.ts +194 -0
  638. package/telegram-plugin/tests/send-typing-action-validation.test.ts +61 -0
  639. package/telegram-plugin/tests/session-tail-capped.test.ts +285 -0
  640. package/telegram-plugin/tests/session-tail.test.ts +524 -0
  641. package/telegram-plugin/tests/setup-flow.test.ts +510 -0
  642. package/telegram-plugin/tests/setup-state.test.ts +146 -0
  643. package/telegram-plugin/tests/silent-reply-guard.test.ts +122 -0
  644. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +350 -0
  645. package/telegram-plugin/tests/slot-banner.test.ts +74 -0
  646. package/telegram-plugin/tests/snapshot-serializer.ts +79 -0
  647. package/telegram-plugin/tests/spawn-detached-cgroup-escape.test.ts +51 -0
  648. package/telegram-plugin/tests/status-accent.test.ts +186 -0
  649. package/telegram-plugin/tests/status-reactions-allowed-filter.test.ts +132 -0
  650. package/telegram-plugin/tests/status-reactions.test.ts +314 -0
  651. package/telegram-plugin/tests/steering.test.ts +282 -0
  652. package/telegram-plugin/tests/sticker-aliases.test.ts +232 -0
  653. package/telegram-plugin/tests/stream-controller-html-fallback.test.ts +127 -0
  654. package/telegram-plugin/tests/stream-controller.test.ts +262 -0
  655. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +208 -0
  656. package/telegram-plugin/tests/stream-reply-handler.test.ts +1292 -0
  657. package/telegram-plugin/tests/streaming-e2e.test.ts +389 -0
  658. package/telegram-plugin/tests/streaming-metrics.test.ts +201 -0
  659. package/telegram-plugin/tests/streaming-orchestration.test.ts +756 -0
  660. package/telegram-plugin/tests/subagent-registry-bugs.test.ts +725 -0
  661. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +213 -0
  662. package/telegram-plugin/tests/subagent-watcher-parent-marker.test.ts +274 -0
  663. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +243 -0
  664. package/telegram-plugin/tests/subagent-watcher.test.ts +877 -0
  665. package/telegram-plugin/tests/subagents-schema-init-order.test.ts +109 -0
  666. package/telegram-plugin/tests/sync-chat-running-subagents.test.ts +116 -0
  667. package/telegram-plugin/tests/telegram-button-constraints.test.ts +194 -0
  668. package/telegram-plugin/tests/telegram-format.test.ts +1093 -0
  669. package/telegram-plugin/tests/telegraph.test.ts +246 -0
  670. package/telegram-plugin/tests/tool-labels.test.ts +383 -0
  671. package/telegram-plugin/tests/turn-active-marker.test.ts +195 -0
  672. package/telegram-plugin/tests/turn-end-regressions.test.ts +489 -0
  673. package/telegram-plugin/tests/turn-flush-card-takeover.test.ts +218 -0
  674. package/telegram-plugin/tests/turn-flush-dedup-controller.test.ts +144 -0
  675. package/telegram-plugin/tests/turn-flush-prose-recovery.test.ts +78 -0
  676. package/telegram-plugin/tests/turn-flush-safety.test.ts +189 -0
  677. package/telegram-plugin/tests/turn-signal-tracker.test.ts +107 -0
  678. package/telegram-plugin/tests/turns-writer.test.ts +323 -0
  679. package/telegram-plugin/tests/two-zone-bg-carry-full-lifecycle.test.ts +131 -0
  680. package/telegram-plugin/tests/two-zone-bg-detection.test.ts +120 -0
  681. package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +114 -0
  682. package/telegram-plugin/tests/two-zone-bg-early-turn-end.test.ts +87 -0
  683. package/telegram-plugin/tests/two-zone-bg-survives-next-turn.test.ts +211 -0
  684. package/telegram-plugin/tests/two-zone-card-cap.test.ts +62 -0
  685. package/telegram-plugin/tests/two-zone-card-fleet-row.test.ts +101 -0
  686. package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +68 -0
  687. package/telegram-plugin/tests/two-zone-card-html-balance.test.ts +110 -0
  688. package/telegram-plugin/tests/two-zone-card-lifecycle.test.ts +128 -0
  689. package/telegram-plugin/tests/two-zone-card-sanitise.test.ts +58 -0
  690. package/telegram-plugin/tests/two-zone-card-snapshot.test.ts +133 -0
  691. package/telegram-plugin/tests/two-zone-concurrent-turns-isolation.test.ts +155 -0
  692. package/telegram-plugin/tests/two-zone-phasefor-precedence.test.ts +117 -0
  693. package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +143 -0
  694. package/telegram-plugin/tests/two-zone-stuck-edit-throttle.test.ts +149 -0
  695. package/telegram-plugin/tests/two-zone-stuck-header-escalation.test.ts +101 -0
  696. package/telegram-plugin/tests/two-zone-stuck-per-member.test.ts +114 -0
  697. package/telegram-plugin/tests/two-zone-stuck-recovery.test.ts +105 -0
  698. package/telegram-plugin/tests/typing-wrap.test.ts +141 -0
  699. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +147 -0
  700. package/telegram-plugin/tests/update-factory-edited-and-reactions.test.ts +108 -0
  701. package/telegram-plugin/tests/update-factory.ts +305 -0
  702. package/telegram-plugin/tests/vault-grant-wizard.test.ts +84 -0
  703. package/telegram-plugin/tests/vault-grants-revoke.test.ts +265 -0
  704. package/telegram-plugin/tests/vault-subcommands.test.ts +234 -0
  705. package/telegram-plugin/tests/voice-transcribe.test.ts +196 -0
  706. package/telegram-plugin/tests/waiting-ux-harness.ts +381 -0
  707. package/telegram-plugin/tests/waiting-ux.e2e.test.ts +233 -0
  708. package/telegram-plugin/tests/welcome-text.test.ts +407 -0
  709. package/telegram-plugin/tool-error-filter.ts +89 -0
  710. package/telegram-plugin/tool-labels.ts +330 -0
  711. package/telegram-plugin/tool-names.ts +53 -0
  712. package/telegram-plugin/turn-flush-prose-recovery.ts +40 -0
  713. package/telegram-plugin/turn-flush-safety.ts +109 -0
  714. package/telegram-plugin/turn-signal-tracker.ts +79 -0
  715. package/telegram-plugin/two-zone-card.ts +249 -0
  716. package/telegram-plugin/typing-wrap.ts +92 -0
  717. package/telegram-plugin/voice-transcribe.ts +166 -0
  718. package/telegram-plugin/welcome-text.ts +359 -0
@@ -0,0 +1,2697 @@
1
+ /**
2
+ * Driver that owns per-chat progress-card state and controls when to emit
3
+ * an `update` call to the outer world (typically a handleStreamReply or a
4
+ * test spy).
5
+ *
6
+ * Cadence rules:
7
+ * - Fire IMMEDIATELY on state transitions (tool start, tool end, stage
8
+ * change, enqueue). This is the key anti-flicker property — each event
9
+ * renders exactly once at the moment of semantic change.
10
+ * - Coalesce bursts: if multiple events land within `coalesceMs`, only
11
+ * the last render actually fires (a single setTimeout collapses them).
12
+ * - Hard floor: never emit faster than `minIntervalMs` to respect
13
+ * Telegram's editMessageText rate budget.
14
+ *
15
+ * Pure in-process state. No IO; the outer `emit` callback does the send.
16
+ */
17
+
18
+ import type { SessionEvent } from './session-tail.js'
19
+ import {
20
+ hasAnyRunningSubAgent,
21
+ initialState,
22
+ reduce,
23
+ render,
24
+ type ProgressCardState,
25
+ type TaskNum,
26
+ type SubAgentState,
27
+ } from './progress-card.js'
28
+ import { isTelegramReplyTool } from './tool-names.js'
29
+ import {
30
+ applyCapped as fleetApplyCapped,
31
+ applyToolResult as fleetApplyToolResult,
32
+ applyToolUse as fleetApplyToolUse,
33
+ applyTurnEnd as fleetApplyTurnEnd,
34
+ createFleetMember,
35
+ hasLiveBackground,
36
+ markStuck as fleetMarkStuck,
37
+ roleFromDispatch,
38
+ type FleetMember,
39
+ } from './fleet-state.js'
40
+
41
+ /**
42
+ * Classification of a Telegram API error for failure-escalation purposes.
43
+ *
44
+ * - `permanent_4xx`: 4xx error that won't resolve itself (message deleted,
45
+ * bot blocked, etc.). After K consecutive such failures the card is marked
46
+ * terminal and all further edits are suppressed.
47
+ * - `transient`: network/5xx error — retryable; does NOT count toward the
48
+ * permanent-failure threshold.
49
+ * - `benign`: "message is not modified" — the edit had no effect because the
50
+ * text was already identical. Not a failure at all; counter must not advance.
51
+ */
52
+ export type ApiFailureKind = 'permanent_4xx' | 'transient' | 'benign'
53
+
54
+ /**
55
+ * Reason a per-chat card is being closed. Used by the unified
56
+ * `closePerChat` helper to drive the small set of behavioural deltas
57
+ * between paths (sub-agent force-close, stalled-render flag).
58
+ *
59
+ * - 'turn-end' : normal completion — no in-flight sub-agents.
60
+ * - 'zombie' : abandonment via heartbeat maxIdle ceiling or
61
+ * new-enqueue force-close.
62
+ * - 'stalled' : Gap-8 deferred-completion timeout expired.
63
+ */
64
+ export type CloseReason = 'turn-end' | 'zombie' | 'stalled'
65
+
66
+ /**
67
+ * Failure descriptor reported back to the driver after an async emit fails.
68
+ * The outer layer (server.ts) inspects the raw Telegram error and classifies
69
+ * it before calling `reportApiFailure`.
70
+ */
71
+ export interface ApiFailureInfo {
72
+ /** HTTP-level error code from Telegram (400, 403, 404, 500, …). */
73
+ code: number
74
+ /** Telegram's `description` field, e.g. "Forbidden: bot was blocked by the user". */
75
+ description: string
76
+ kind: ApiFailureKind
77
+ }
78
+
79
+ export interface ProgressDriverConfig {
80
+ /**
81
+ * Emit rendered HTML for the given chat+thread. Caller owns the send.
82
+ *
83
+ * `isFirstEmit` is true exactly once per turn — on the very first flush
84
+ * that creates the Telegram message. The caller can use this signal to
85
+ * pin the new message: after this call resolves, the message_id will be
86
+ * available in the caller's draft-stream handle.
87
+ *
88
+ * `replyToMessageId` is set only on the first emit (when `isFirstEmit`
89
+ * is true) and only when the turn was started with a source message_id
90
+ * (via `startTurn({ replyToMessageId })`). The caller should pass this
91
+ * as `reply_parameters` on the initial `sendMessage` so the progress
92
+ * card is a tappable reply to the user's original message. Edits
93
+ * (subsequent emits) must NOT carry reply_parameters — Telegram rejects
94
+ * it on editMessageText.
95
+ */
96
+ emit: (args: {
97
+ chatId: string
98
+ threadId?: string
99
+ /** Unique key for this turn (chatId:threadId:seq). Use for pin/unpin tracking. */
100
+ turnKey: string
101
+ html: string
102
+ done: boolean
103
+ /** True only on the first flush for this turn (message creation). */
104
+ isFirstEmit: boolean
105
+ /**
106
+ * Set on the first emit only (isFirstEmit=true) when the turn was
107
+ * started via startTurn({ replyToMessageId }). Pass as
108
+ * reply_parameters.message_id on the initial sendMessage.
109
+ */
110
+ replyToMessageId?: number
111
+ /**
112
+ * Per-agent card identity. Absent for parent-card emits (the
113
+ * gateway treats absence as the parent sentinel `__parent__`).
114
+ * Retained for caller compatibility post-P4 cutover; the two-zone
115
+ * renderer no longer emits per-sub-agent cards.
116
+ */
117
+ agentId?: string
118
+ }) => void
119
+ /**
120
+ * Optional callback fired once per turn immediately after the final
121
+ * render on `turn_end`. Receives a compact, one-line plain-text
122
+ * summary suitable for the session-handoff continuity line. The outer
123
+ * layer typically pipes this into `writeLastTurnSummary(agentDir, …)`
124
+ * so that a session restart can show "↩️ Picked up — <summary>"
125
+ * even if the Stop-hook summarizer didn't run.
126
+ */
127
+ onTurnEnd?: (summary: string) => void
128
+ /**
129
+ * Fired once per turn when `turn_end` is processed, with full chat
130
+ * context. Use this for per-chat post-completion work: unpin the card,
131
+ * send a completion summary to the main chat, etc.
132
+ *
133
+ * Fires BEFORE the per-chat state is deleted, so `summary` is still
134
+ * accessible. The caller must NOT re-enter the driver from this callback.
135
+ */
136
+ onTurnComplete?: (args: {
137
+ chatId: string
138
+ threadId?: string
139
+ /** Unique key for this turn (chatId:threadId:seq). Use for pin/unpin tracking. */
140
+ turnKey: string
141
+ summary: string
142
+ taskIndex: number
143
+ taskTotal: number
144
+ }) => void
145
+ /**
146
+ * Fired when a turn ends with no reply sent (silentEnd=true). The outer
147
+ * layer can write a state file so the Stop hook can block the session and
148
+ * re-prompt the agent. The callback returns `{ suppressed: true }` when the
149
+ * retry is allowed (retryCount was 0) — in that case the driver will
150
+ * re-render the final card WITHOUT the "🙊 Ended without reply" warning so
151
+ * the user doesn't see a false-positive before the retry lands.
152
+ *
153
+ * On the second silent-end (retryCount exhausted) the callback returns
154
+ * `{ suppressed: false }` and the warning card renders as normal.
155
+ *
156
+ * Not fired for autonomous turns (wasAutonomous=true) — those intentionally
157
+ * produce no user-visible reply.
158
+ */
159
+ onSilentEnd?: (args: {
160
+ chatId: string
161
+ threadId?: string
162
+ turnKey: string
163
+ }) => { suppressed: boolean } | void
164
+ /** Min ms between edits for a given chat+thread. Default 500. */
165
+ minIntervalMs?: number
166
+ /** Coalesce window — burst events within this land as one render. Default 400. */
167
+ coalesceMs?: number
168
+ /** `Date.now` override for tests. */
169
+ now?: () => number
170
+ /** `setTimeout` override for tests. */
171
+ setTimeout?: (fn: () => void, ms: number) => { ref: unknown }
172
+ clearTimeout?: (ref: unknown) => void
173
+ /** `setInterval` override for tests (used by the heartbeat). */
174
+ setInterval?: (fn: () => void, ms: number) => { ref: unknown }
175
+ clearInterval?: (ref: unknown) => void
176
+ /**
177
+ * Heartbeat cadence for the no-events-flowing re-render. When a turn
178
+ * has settled into a long-running tool call (e.g. a sub-agent that
179
+ * emits no session-JSONL events for minutes), the elapsed-time counter
180
+ * in the card header never visibly ticks because no event fires a
181
+ * re-render. The heartbeat forces a flush every `heartbeatMs` while
182
+ * any chat has a running turn. Default 5000. Set to 0 to disable.
183
+ */
184
+ heartbeatMs?: number
185
+ /**
186
+ * Multi-agent rate-limit guardrail (design §4.4). Telegram caps edits
187
+ * at ~20/min/chat. With N parallel sub-agents emitting bursty events
188
+ * the default 400ms coalesce + 500ms floor can exceed the cap. When
189
+ * we observe more than `editBudgetThreshold` edits in the trailing
190
+ * 60s for a chat, the coalesce window expands to `editBudgetCoalesceMs`
191
+ * until the rate drops back. Heartbeat is also suppressed while the
192
+ * budget is hot.
193
+ *
194
+ * Defaults: threshold=18, coalesce window when hot=3000ms.
195
+ */
196
+ editBudgetThreshold?: number
197
+ editBudgetCoalesceMs?: number
198
+ /**
199
+ * Zombie-card ceiling. If a chat's `lastEventAt` is older than this
200
+ * many ms, the heartbeat loop force-closes the card (flush done,
201
+ * onTurnComplete, delete from chats). This is the backstop for cards
202
+ * orphaned by a missed `turn_end` line or an enqueue echo-drop that
203
+ * routed events to a different card — without it, the heartbeat
204
+ * would re-render a stale card forever (50+ minute ghost cards).
205
+ *
206
+ * Default 30 minutes. Set to 0 to disable entirely (not recommended
207
+ * outside tests).
208
+ */
209
+ maxIdleMs?: number
210
+ /**
211
+ * Suppress the progress card for fast turns. The first emit is
212
+ * deferred by this many ms after startTurn. If `turn_end` arrives
213
+ * before the timer fires (and isFirstEmit is still true), no card
214
+ * is ever shown — the user only sees the final reply.
215
+ *
216
+ * The card can be promoted out of suppression early when a sub-agent
217
+ * starts (see `promoteOnSubAgent`) — long-running tool work and
218
+ * background dispatches stay visible without waiting the full delay.
219
+ *
220
+ * Default 60000 (60 seconds, #553 PR 4). Set to 0 to disable.
221
+ */
222
+ initialDelayMs?: number
223
+ /**
224
+ * Promote the first emit immediately when a sub-agent transitions to
225
+ * running during the suppression window, when the watcher fires
226
+ * `onSubAgentStall`, or when `startTurn` carries over running
227
+ * sub-agents from a prior turn (#334 carry-over). The card jumps
228
+ * straight to visible instead of waiting for `initialDelayMs`.
229
+ *
230
+ * Fast-turn suppression (`turn_end` before the card has emitted) is
231
+ * unchanged — it short-circuits in `flush()` regardless of this flag.
232
+ *
233
+ * Default true. Set to false to disable promotion entirely (the card
234
+ * will only appear after `initialDelayMs` elapses, even when sub-agents
235
+ * are dispatched mid-turn).
236
+ */
237
+ promoteOnSubAgent?: boolean
238
+ /**
239
+ * Promote the card out of initial-delay suppression once the agent has
240
+ * issued this many parent-side tool calls in the suppression window.
241
+ * Closes #478 — the user sees no progress card for the first 30s of a
242
+ * substantial turn that does parent-side work (Read/Grep/Bash/Edit)
243
+ * but never dispatches a sub-agent.
244
+ *
245
+ * Symmetric to `promoteOnSubAgent`. **Default 0 (disabled, #553 PR 4):**
246
+ * under the v2 contract tools alone never trigger the card — only
247
+ * sub-agents or `elapsed >= 60s`. Values of 0 or non-finite (Infinity)
248
+ * are treated as "never promote on tool count". Set to a positive
249
+ * integer (e.g. 3) to opt back in to the pre-v2 behaviour.
250
+ *
251
+ * Fast-turn suppression in `flush()` is unchanged — if the turn
252
+ * ends before promotion, the card still skips the emit.
253
+ */
254
+ promoteOnParentToolCount?: number
255
+ /**
256
+ * Time-based first-emit promotion (#553 F3): if the turn has been
257
+ * running this long with no tool/sub-agent that already triggered
258
+ * promotion, force the card to emit. Without this, single- or two-
259
+ * tool turns that take 5–30s never cross any existing promotion
260
+ * threshold and the card stays suppressed until `initialDelayMs`,
261
+ * at which point fast-turn-suppression cancels it on `turn_end`.
262
+ *
263
+ * Symmetric to `promoteOnParentToolCount`: pure additive promotion,
264
+ * never delays an emit that would otherwise fire. Fast-turn
265
+ * suppression in `flush()` is unchanged — sub-`promoteAfterMs` turns
266
+ * still skip the card.
267
+ *
268
+ * **Default 0 (disabled, #553 PR 4).** The PR #570 5s time-promote was
269
+ * a stop-gap when `initialDelayMs` defaulted to 30s; with the new
270
+ * 60s `initialDelayMs` and the sub-agent promote intact, time-based
271
+ * promotion is no longer needed. `ensureTimePromoteScheduled` no-ops
272
+ * when this is 0 so the timer never schedules. Set to a positive
273
+ * value to opt back in to the pre-v2 behaviour.
274
+ */
275
+ promoteAfterMs?: number
276
+ /**
277
+ * Number of consecutive 4xx Telegram API failures on card edits before
278
+ * the card is marked terminal and all further edits are suppressed for
279
+ * this turn. Transient (5xx/network) errors and "message is not modified"
280
+ * do NOT count toward this threshold. A single success resets the counter.
281
+ *
282
+ * Default 3. Set to 0 to disable the escalation mechanism entirely.
283
+ */
284
+ maxConsecutive4xx?: number
285
+ /**
286
+ * Gap 3 (orphan promotion): how long a `PendingAgentSpawn` must be
287
+ * outstanding before the heartbeat promotes it to a synthesised
288
+ * sub-agent row (state='running'). Gives the sub-agent JSONL watcher a
289
+ * chance to deliver the real `sub_agent_started` event first.
290
+ *
291
+ * Default 5000 (5 seconds). Set to 0 to disable promotion entirely.
292
+ */
293
+ orphanPromotionMs?: number
294
+ /**
295
+ * Gap 4 (cold-JSONL detection): when a running sub-agent's last event
296
+ * is older than this threshold, the heartbeat synthesises a
297
+ * `sub_agent_turn_end` for it so the deferred-completion path can
298
+ * proceed (avoids the card staying pinned forever on a dead watcher).
299
+ *
300
+ * Default 30000 (30 seconds). Set to 0 to disable the synthetic close.
301
+ */
302
+ coldSubAgentThresholdMs?: number
303
+ /**
304
+ * Gap 8 (decoupled render and unpin): after `turn_end` arrives while
305
+ * sub-agents are still running, this is the maximum ms to wait before
306
+ * force-closing the card with a "stalled — forced close" header and
307
+ * calling `onTurnComplete`. This is separate from `maxIdleMs` (which
308
+ * watches for absence of ALL events) — this timeout starts specifically
309
+ * on parent `turn_end` and fires regardless of sub-agent activity.
310
+ *
311
+ * Default 180000 (3 minutes). Set to 0 to disable.
312
+ */
313
+ deferredCompletionTimeoutMs?: number
314
+ /**
315
+ * Fix #314 — elapsed-ticker interval for silent sub-agent gaps.
316
+ *
317
+ * While at least one sub-agent is in `state='running'`, the parent card
318
+ * only re-renders when an event changes the HTML (tool start/end, stage
319
+ * change). During silent stretches between tool calls the elapsed counter
320
+ * freezes — the diff guard suppresses edits when only the timestamp
321
+ * advances. This interval forces a render (bypassing that guard) every N ms
322
+ * so the elapsed counter visibly ticks even when the sub-agent is quietly
323
+ * thinking or waiting for I/O.
324
+ *
325
+ * 10 s was chosen as a balance: short enough that the counter advances
326
+ * at human-perceptible speed (users notice a 15+ second freeze), long
327
+ * enough to stay well under Telegram's ~20 edits/minute budget even when
328
+ * multiple cards are active in parallel.
329
+ *
330
+ * Default 10000. Set to 0 to disable the elapsed-ticker path entirely.
331
+ */
332
+ subAgentTickIntervalMs?: number
333
+ }
334
+
335
+ /**
336
+ * Issue #399: Sync the per-chat running-sub-agent registry after any state
337
+ * transition that may have moved agents to a terminal state.
338
+ *
339
+ * Factored out from the inline block inside `ingest` so it can be called
340
+ * from three paths that can transition agents to done/failed without going
341
+ * through the normal ingest post-reduce step:
342
+ * 1. ingest post-reduce (existing call site, refactored)
343
+ * 2. cold-jsonl-synth path (Gap-4, heartbeat)
344
+ * 3. closeZombie direct mutation path
345
+ * 4. deferred-completion-timeout force-close (Gap-8, heartbeat)
346
+ */
347
+ export function syncChatRunningSubagents(
348
+ prev: ProgressCardState,
349
+ next: ProgressCardState,
350
+ cBaseKey: string,
351
+ chatRunningSubagents: Map<string, Map<string, SubAgentState>>,
352
+ ): { newRunningAppeared: boolean } {
353
+ if (prev.subAgents === next.subAgents) return { newRunningAppeared: false }
354
+ let newRunningAppeared = false
355
+ // Check for new or newly-running entries (sub_agent_started path).
356
+ for (const [agentId, sa] of next.subAgents) {
357
+ if (sa.state === 'running') {
358
+ const prevSa = prev.subAgents.get(agentId)
359
+ if (prevSa == null || prevSa.state !== 'running') {
360
+ // Newly running — register in chat-scoped registry.
361
+ let chatMap = chatRunningSubagents.get(cBaseKey)
362
+ if (chatMap == null) {
363
+ chatMap = new Map<string, SubAgentState>()
364
+ chatRunningSubagents.set(cBaseKey, chatMap)
365
+ }
366
+ chatMap.set(agentId, sa)
367
+ newRunningAppeared = true
368
+ }
369
+ } else if (sa.state === 'done' || sa.state === 'failed') {
370
+ // Terminal state — remove from chat registry if present.
371
+ chatRunningSubagents.get(cBaseKey)?.delete(agentId)
372
+ }
373
+ }
374
+ // Also handle entries that were removed from subAgents entirely
375
+ // (shouldn't happen normally but be defensive).
376
+ for (const agentId of prev.subAgents.keys()) {
377
+ if (!next.subAgents.has(agentId)) {
378
+ chatRunningSubagents.get(cBaseKey)?.delete(agentId)
379
+ }
380
+ }
381
+ return { newRunningAppeared }
382
+ }
383
+
384
+ /**
385
+ * Compact one-line summary of a completed turn for the handoff sidecar.
386
+ * Shape: `"<tool-count> tool[s], <duration> — <user-request>"`.
387
+ * Falls back gracefully when fields are missing (empty items → "no tools";
388
+ * no userRequest → just the stats prefix).
389
+ */
390
+ export function summariseTurn(state: ProgressCardState, now: number): string {
391
+ const toolCount = state.items.length
392
+ const toolLabel = toolCount === 1 ? '1 tool' : `${toolCount} tools`
393
+ const durSec = Math.max(0, Math.floor((now - state.turnStartedAt) / 1000))
394
+ const dur =
395
+ durSec >= 60
396
+ ? `${Math.floor(durSec / 60)}:${(durSec % 60).toString().padStart(2, '0')}`
397
+ : `${durSec}s`
398
+ const stats = toolCount === 0 ? `no tools, ${dur}` : `${toolLabel}, ${dur}`
399
+ const req = state.userRequest?.trim()
400
+ return req ? `${stats} — ${req}` : stats
401
+ }
402
+
403
+ interface PerChatState {
404
+ chatId: string
405
+ threadId?: string
406
+ /** Unique key for this turn: `chatId:threadId:seq`. Used as the chats-map key. */
407
+ turnKey: string
408
+ /** 1-based index of this card among all cards created for this chat:thread in this session. */
409
+ taskIndex: number
410
+ /** Total cards created for this chat:thread so far (snapshot at card creation). */
411
+ taskTotal: number
412
+ state: ProgressCardState
413
+ lastEmittedAt: number
414
+ lastEmittedHtml: string | null
415
+ pendingTimer: unknown
416
+ /** True until the very first flush fires for this turn. Cleared after first emit. */
417
+ isFirstEmit: boolean
418
+ /** Timer for the deferred first emit (initial-delay suppression). */
419
+ deferredFirstEmitTimer: unknown
420
+ /**
421
+ * F3 fix (#553): timer for the time-based first-emit promotion.
422
+ * Scheduled on the first ingest event; fires after `promoteAfterMs`
423
+ * to force-promote turns that don't trip parent-tool-count or
424
+ * sub-agent thresholds (e.g. one long Bash). Cleared on
425
+ * `promoteFirstEmit` or turn end.
426
+ */
427
+ timePromoteTimer: unknown
428
+ /**
429
+ * The Telegram message_id of the user's original inbound message that
430
+ * triggered this turn. Set via startTurn({ replyToMessageId }). Passed
431
+ * as reply_parameters on the FIRST sendMessage only — edits must not
432
+ * carry it (Telegram rejects reply_parameters on editMessageText).
433
+ */
434
+ replyToMessageId?: number
435
+ /**
436
+ * Wall-clock ms of the last real session event routed to this card.
437
+ * Distinct from `lastEmittedAt`: the heartbeat ticks `lastEmittedAt`
438
+ * every cycle, but `lastEventAt` only advances when an actual event
439
+ * (enqueue, tool_use, tool_result, turn_end, sub_agent_*) lands on
440
+ * this chat state. The heartbeat uses it as a zombie ceiling — a
441
+ * card whose `lastEventAt` is older than `maxIdleMs` has been
442
+ * orphaned (turn_end missed by the session-tail, or an enqueue
443
+ * echo-drop routed events to a different card) and is force-closed
444
+ * so it can't tick forever.
445
+ */
446
+ lastEventAt: number
447
+ /**
448
+ * True once the parent turn has ended (via `turn_end` or
449
+ * `forceCompleteTurn`) BUT one or more sub-agents were still running
450
+ * at that moment. The card stays alive and keeps ticking so the
451
+ * running sub-agents remain visible. When the last running sub-agent
452
+ * transitions to done (via `sub_agent_turn_end` or parent's Agent
453
+ * `tool_result`), completion callbacks finally fire and the card is
454
+ * closed. Guards against duplicate completion firing (both turn_end
455
+ * and forceCompleteTurn can legitimately arrive).
456
+ */
457
+ pendingCompletion: boolean
458
+ /**
459
+ * Set to true the moment completion callbacks have fired, whether
460
+ * immediately (no in-flight sub-agents at turn_end) or deferred
461
+ * (after last sub-agent finished). Guards against double-firing if
462
+ * multiple completion signals race.
463
+ */
464
+ completionFired: boolean
465
+ /**
466
+ * Set to true when an external code path has assumed ownership of
467
+ * the pinned card message (e.g. turn-flush rewriting the card with
468
+ * the user-facing answer — see #654). Once true, `flush()`
469
+ * short-circuits at the top so the driver never edits the card
470
+ * again for this turn. The external owner is responsible for
471
+ * issuing the final edit/unpin via pinMgr.
472
+ */
473
+ cardTakenOver: boolean
474
+ /**
475
+ * Tracks consecutive Telegram 4xx failures on card edits. Once
476
+ * `terminal` is true, flush() and the heartbeat tick skip all edits
477
+ * for this card (message deleted / bot blocked / stale message_id).
478
+ *
479
+ * Resets automatically when a fresh turn starts (new PerChatState).
480
+ */
481
+ apiFailures: {
482
+ consecutive4xx: number
483
+ lastError: { code: number; description: string; timestamp: number } | null
484
+ terminal: boolean
485
+ }
486
+ /**
487
+ * Issue #132: did the agent call `reply` or `stream_reply` (under any
488
+ * MCP server-key prefix) at least once during this turn?
489
+ *
490
+ * Set true on the first matching `tool_use` event observed by `ingest()`.
491
+ * When the turn ends with this still false, the card renders the
492
+ * "🙊 Ended without reply" silent-end variant instead of "✅ Done" so the
493
+ * user can tell the difference between "agent acknowledged with text"
494
+ * and "agent ran tools and went mute". Resets implicitly with each new
495
+ * `PerChatState` (one per turn).
496
+ */
497
+ replyToolCalled: boolean
498
+ /**
499
+ * Issue #137: how many outbound replies actually landed in the chat
500
+ * this turn? Bumped by `ProgressDriver.recordOutboundDelivered()` from
501
+ * the gateway's executeReply / executeStreamReply success paths.
502
+ *
503
+ * Combined with `replyToolCalled` at turn-end, this distinguishes:
504
+ * - both false → silent-end (#132, "Ended without reply")
505
+ * - replyToolCalled only → reply attempted but never delivered
506
+ * (#137 — render a degraded variant
507
+ * distinct from silent-end so the user
508
+ * knows the agent TRIED)
509
+ * - delivered>0 → real success
510
+ */
511
+ outboundDeliveredCount: number
512
+ /**
513
+ * Issue #259: true when the turn was started by an autonomous wakeup
514
+ * sentinel (`<<autonomous-loop>>` or `<<autonomous-loop-dynamic>>`).
515
+ * When set, the "🙊 Ended without reply" silent-end warning is
516
+ * suppressed — autonomous turns intentionally produce no user-visible
517
+ * reply and ending without one is entirely expected.
518
+ */
519
+ wasAutonomous: boolean
520
+ /**
521
+ * Set by prepareSilentEndSuppression when onSilentEnd returns
522
+ * { suppressed: true }. Causes flush() to render the final card without
523
+ * the "🙊 Ended without reply" header so no false-positive appears before
524
+ * the retry reply lands.
525
+ */
526
+ silentEndSuppressed: boolean
527
+ /**
528
+ * Idempotent guard for prepareSilentEndSuppression — ensures the
529
+ * onSilentEnd callback (which writes the Stop-hook state file) only
530
+ * fires once per turn even if multiple sites call into the helper.
531
+ */
532
+ silentEndPrepared: boolean
533
+ /**
534
+ * Gap 8 (decoupled render and unpin): set to the timestamp when parent
535
+ * `turn_end` landed while sub-agents were still running. Used by the
536
+ * heartbeat to enforce `deferredCompletionTimeoutMs`. Null until
537
+ * parent turn_end with in-flight sub-agents is observed.
538
+ */
539
+ parentTurnEndAt: number | null
540
+ /**
541
+ * Gap 8: true once the parent-done render (✅ Done header with sub-agents
542
+ * still visible) has been emitted. Prevents re-rendering the ✅ Done
543
+ * frame on every sub-agent event while deferred.
544
+ */
545
+ parentDoneRendered: boolean
546
+ /**
547
+ * Gap 3 (orphan promotion): set of toolUseIds from `pendingAgentSpawns`
548
+ * that have already been promoted to synthetic sub-agent rows. Guards
549
+ * against re-promotion on successive heartbeat ticks and against
550
+ * double-registration if a real `sub_agent_started` arrives later.
551
+ */
552
+ promotedSpawnIds: Set<string>
553
+ /**
554
+ * P0 of #662 — shadow fleet map updated alongside `state.subAgents` at
555
+ * every sub_agent_* event. Coexists with the legacy map; P1/P2/P3 build
556
+ * the v2 two-zone status card on this without disturbing the existing
557
+ * renderer. See fleet-state.ts for the pure transitions.
558
+ */
559
+ fleet: Map<string, FleetMember>
560
+ /**
561
+ * P2 of #662 — set of parent toolUseIds whose Agent/Task tool_use was
562
+ * dispatched with `input.run_in_background === true`. When the
563
+ * matching `sub_agent_started` correlates and writes
564
+ * `parentToolUseId` into the freshly-created subagent state, the
565
+ * fleet reducer flips that member's `status` from `running` to
566
+ * `background`. Entry stays around for the life of the turn so a
567
+ * reverse-race adoption (sub_agent_started arriving before tool_use)
568
+ * still matches.
569
+ */
570
+ backgroundParentToolUseIds: Set<string>
571
+ /**
572
+ * P2 of #662 / fixes #64 — set true when `completeTurnFully` was
573
+ * called but at least one fleet member was still in `status:
574
+ * 'background'` and not terminal. The chats-map entry is preserved
575
+ * (instead of deleted) and the original card stays pinned so updates
576
+ * can continue to land. When the last live background member reaches
577
+ * a terminal status, `finalizeBackgroundCarryIfReady` triggers the
578
+ * deferred completion.
579
+ */
580
+ backgroundCarry: boolean
581
+ }
582
+
583
+ export interface ProgressDriver {
584
+ /** Feed a session-tail event. Fires emit() as the cadence allows. */
585
+ ingest(event: SessionEvent, chatId: string | null, threadId?: string): void
586
+ /**
587
+ * Stop internal timers and clear driver state. Idempotent.
588
+ *
589
+ * When called with `{ preservePending: true }`, chats with
590
+ * `pendingCompletion === true` are preserved so their heartbeat and
591
+ * deferred-completion timeout continue firing after a bridge disconnect.
592
+ * Coalesce timers (`pendingTimer`, `deferredFirstEmitTimer`) on those
593
+ * preserved chats ARE cleared — they cannot safely emit into a finalized
594
+ * draft stream. Chats WITHOUT `pendingCompletion` are fully removed.
595
+ * The heartbeat is only stopped if no `pendingCompletion` chats remain.
596
+ *
597
+ * When called with no args or `{ preservePending: false }`, the existing
598
+ * wipe-everything behavior is retained for back-compat.
599
+ */
600
+ dispose?(opts?: { preservePending?: boolean }): void
601
+ /**
602
+ * Begin a new turn synchronously — called from the inbound-message
603
+ * handler the instant a user's message clears the gate, BEFORE any
604
+ * session-tail event arrives. Creates a fresh progress card state; the
605
+ * first visible render is gated by `initialDelayMs` (default 60s) so
606
+ * turns that finish before the delay produce no card at all and the
607
+ * user only sees the final reply.
608
+ *
609
+ * If a card is already active for this chat, it is force-closed (done=true,
610
+ * onTurnComplete fired) before the new card is created. Each call always
611
+ * produces an independent card with its own pin lifecycle.
612
+ */
613
+ startTurn(args: { chatId: string; threadId?: string; userText: string; replyToMessageId?: number }): void
614
+ /**
615
+ * External completion hook — authoritative turn-finished signal from
616
+ * outside the session-tail path. Intended for `stream_reply(done=true)`
617
+ * so the final-answer arrival acts with equal authority to a session-tail
618
+ * `turn_end` event. Idempotent: first caller wins, subsequent callers
619
+ * on the same chat+thread find no active card and no-op.
620
+ *
621
+ * Closes any active card for (chatId, threadId):
622
+ * - cancels the deferred-first-emit timer (fast-turn suppression)
623
+ * - synthesizes a `turn_end` through the reducer
624
+ * - fires onTurnEnd + onTurnComplete
625
+ * - clears chats map + bookkeeping
626
+ *
627
+ * If the deferred first emit hasn't landed yet (fast turn), `flush` sees
628
+ * `forceDone=true` on a still-`isFirstEmit=true` state and suppresses
629
+ * the emit entirely — no ghost card. If the card already emitted, the
630
+ * normal flush+unpin path runs via onTurnComplete.
631
+ */
632
+ forceCompleteTurn(args: { chatId: string; threadId?: string }): void
633
+ /**
634
+ * #654 deterministic double-message fix. Hand off ownership of the
635
+ * pinned progress card for an active turn so an external code path
636
+ * (specifically the turn-flush backstop in gateway.ts) can rewrite
637
+ * the card message with the user-facing answer instead of issuing a
638
+ * fresh sendMessage that lands as a second Telegram message.
639
+ *
640
+ * Effects:
641
+ * - cancels the deferred-first-emit timer if pending (no late
642
+ * card emission can race the takeover)
643
+ * - sets `cardTakenOver = true` — `flush()` short-circuits at the
644
+ * top, so no further edits go out from the driver for this turn
645
+ * - sets `completionFired = true` — guards against double-firing
646
+ * `completeTurnFully` if a deferred-completion path also runs
647
+ *
648
+ * Returns:
649
+ * - `wasEmitted`: true iff the card has already been published to
650
+ * Telegram (i.e. the deferred-emit timer fired or pinning has
651
+ * occurred). Caller can use this to decide between editMessageText
652
+ * vs sendMessage.
653
+ * - `turnKey`: the active turn's full key (chatId:threadId?:seq)
654
+ * so the caller can look up the pinned messageId via pinMgr.
655
+ * Null only when no active card exists for (chatId, threadId).
656
+ *
657
+ * Idempotent — safe to call multiple times for the same turn; the
658
+ * second call returns the same shape with timer-cancellation already
659
+ * complete.
660
+ */
661
+ takeOverCard(args: { chatId: string; threadId?: string }): {
662
+ wasEmitted: boolean
663
+ turnKey: string | null
664
+ }
665
+ /** Current state for a chat (for tests / inspection). */
666
+ peek(chatId: string, threadId?: string): ProgressCardState | undefined
667
+ /**
668
+ * P0 of #662 — fetch the shadow fleet map for a chat. Used by tests
669
+ * and (eventually) by the v2 renderer. Same lookup semantics as
670
+ * `peek`. Returns undefined when no active card exists.
671
+ */
672
+ peekFleet(chatId: string, threadId?: string): Map<string, FleetMember> | undefined
673
+ /**
674
+ * P2 of #662 — debug/test hook returning every live PerChatState's
675
+ * fleet keyed by turnKey. Used by cross-turn background tests to
676
+ * verify routing landed on the originating turn rather than the
677
+ * currently-active one. Not part of the production driver contract.
678
+ */
679
+ peekAllFleets(): Array<{ turnKey: string; chatId: string | null; fleet: Map<string, FleetMember> }>
680
+ /**
681
+ * True when the driver is still managing an active card for this chat+
682
+ * thread — either a normal turn or a deferred-completion turn waiting on
683
+ * in-flight sub-agents. Used by the gateway's `closeProgressLane`
684
+ * backstop to avoid tearing down the draft stream while the driver is
685
+ * still going to emit into it. Without this guard, parent turn_end
686
+ * closes the stream, sub-agent tool_use events fire fresh emits, and
687
+ * each emit creates a new `sendMessage` on Telegram (= new push
688
+ * notification) instead of editing the pinned card.
689
+ */
690
+ hasActiveCard(chatId: string, threadId?: string): boolean
691
+ /**
692
+ * Issue #305 Option A — push a sub-agent narrative line into the
693
+ * pinned progress card's row body for `agentId` (jsonl_agent_id).
694
+ * Replace-on-each-call. Caller (gateway) is responsible for truncating
695
+ * `text` to the 200-char card cap before invocation.
696
+ *
697
+ * Returns:
698
+ * - `{ ok: true }` when the narrative was applied + flush triggered.
699
+ * - `{ ok: false, reason: 'no_active_card' }` if no card is tracked
700
+ * for (chatId, threadId) or its turn already completionFired.
701
+ * - `{ ok: false, reason: 'unknown_agent' }` if the card is active
702
+ * but does not yet contain a sub-agent for `agentId` (likely a
703
+ * race with sub-agent watcher's jsonl_agent_id backfill — caller
704
+ * should fall through to the message-send path).
705
+ *
706
+ * Never throws.
707
+ */
708
+ recordSubAgentNarrative(args: {
709
+ chatId: string
710
+ threadId?: string
711
+ agentId: string
712
+ text: string
713
+ }): { ok: true } | { ok: false; reason: 'no_active_card' | 'unknown_agent' }
714
+ /**
715
+ * Report a Telegram API failure back to the driver after an async emit
716
+ * fails. The outer layer (server.ts catch handler) classifies the raw
717
+ * error and calls this so the driver can track consecutive 4xx failures
718
+ * and mark the card terminal when the threshold is reached.
719
+ *
720
+ * Rules:
721
+ * - `benign` (message is not modified) — ignored; counter unchanged.
722
+ * - `transient` (5xx, network) — logged at debug; counter unchanged.
723
+ * - `permanent_4xx` — counter incremented; terminal=true after K hits.
724
+ *
725
+ * Idempotent after terminal=true.
726
+ */
727
+ reportApiFailure(turnKey: string, failure: ApiFailureInfo): void
728
+ /**
729
+ * Report a successful Telegram API call for a card. Resets the
730
+ * consecutive-4xx counter so a single success after a transient failure
731
+ * doesn't leave the counter elevated. Call from the `.then()` handler
732
+ * of the async emit in server.ts.
733
+ */
734
+ reportApiSuccess(turnKey: string): void
735
+ /**
736
+ * Issue #137: bump the per-turn outbound-delivered counter for the
737
+ * card matching (chatId, threadId). Called from the gateway's reply
738
+ * success paths (executeReply, executeStreamReply) AFTER the
739
+ * `bot.api.sendMessage` resolved. If no card is active for that
740
+ * chat+thread, the call is a silent no-op (boot banners and other
741
+ * system messages don't tick the counter).
742
+ */
743
+ recordOutboundDelivered(chatId: string, threadId?: string): void
744
+ /**
745
+ * Option C — watcher stall callback. Called by the sub-agent watcher
746
+ * (via config.onStall) when a running sub-agent's JSONL goes silent for
747
+ * longer than `stallThresholdMs`. Updates the sub-agent's `lastEventAt`
748
+ * to trigger the elapsed-ticker so the progress card re-renders with a
749
+ * visible ⚠️ stall indicator, even when the bridge has disconnected.
750
+ *
751
+ * No-op if no card is currently tracking this `agentId`.
752
+ */
753
+ onSubAgentStall(agentId: string, idleMs: number, description: string): void
754
+ /**
755
+ * Test-only accessor exposing the driver's internal Maps so unit tests
756
+ * can assert TTL eviction and outer-base-key cleanup actually drop
757
+ * entries. Not part of the supported runtime API — gated behind the
758
+ * leading-underscore name.
759
+ */
760
+ _debugGetMaps?(): {
761
+ chats: Map<string, unknown>
762
+ seenEnqueueMsgIds: Map<string, number>
763
+ pendingSyncEchoes: Map<string, number>
764
+ chatRunningSubagents: Map<string, Map<string, unknown>>
765
+ baseTurnSeqs: Map<string, number>
766
+ editTimestamps: Map<string, number[]>
767
+ }
768
+ }
769
+
770
+ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriver {
771
+ const minIntervalMs = config.minIntervalMs ?? 500
772
+ const coalesceMs = config.coalesceMs ?? 400
773
+ const now = config.now ?? (() => Date.now())
774
+ const setT =
775
+ config.setTimeout ??
776
+ ((fn, ms) => {
777
+ const h = setTimeout(fn, ms)
778
+ return { ref: h }
779
+ })
780
+ const clearT =
781
+ config.clearTimeout ??
782
+ ((ref) => {
783
+ const handle = (ref as { ref: ReturnType<typeof setTimeout> }).ref
784
+ clearTimeout(handle)
785
+ })
786
+ const setI =
787
+ config.setInterval ??
788
+ ((fn, ms) => {
789
+ const h = setInterval(fn, ms)
790
+ return { ref: h }
791
+ })
792
+ const clearI =
793
+ config.clearInterval ??
794
+ ((ref) => {
795
+ const handle = (ref as { ref: ReturnType<typeof setInterval> }).ref
796
+ clearInterval(handle)
797
+ })
798
+ const heartbeatMs = config.heartbeatMs ?? 5000
799
+ const editBudgetThreshold = config.editBudgetThreshold ?? 18
800
+ const editBudgetCoalesceMs = config.editBudgetCoalesceMs ?? 3000
801
+ const maxIdleMs = config.maxIdleMs ?? 30 * 60_000
802
+ // v2 card-gate (#553 PR 4): card visibility is `(elapsed >= 60s) OR
803
+ // (any sub-agent appeared)`. Tools alone never trigger the card.
804
+ // - initialDelayMs: 60s (was 30s) — pushes the time-based gate to
805
+ // the spec value.
806
+ // - promoteOnParentToolCount: 0 (was 3) — disabled. The check below
807
+ // treats 0 (and Infinity) as "never promote on tool count".
808
+ // - promoteAfterMs: 0 (was 5_000) — disabled. ensureTimePromoteScheduled
809
+ // no-ops when this is 0, so the timer never schedules. The PR #570
810
+ // time-promote was a stop-gap when initialDelayMs was 30s; with
811
+ // initialDelayMs=60s and the sub-agent promote intact, it is no
812
+ // longer needed.
813
+ // - promoteOnSubAgent: true (unchanged) — sub-agents/background workers
814
+ // break the suppression immediately.
815
+ const initialDelayMs = config.initialDelayMs ?? 60_000
816
+ const promoteOnSubAgent = config.promoteOnSubAgent ?? true
817
+ const promoteOnParentToolCount = config.promoteOnParentToolCount ?? 0
818
+ const promoteAfterMs = config.promoteAfterMs ?? 0
819
+ const maxConsecutive4xx = config.maxConsecutive4xx ?? 3
820
+ const orphanPromotionMs = config.orphanPromotionMs ?? 5_000
821
+ const coldSubAgentThresholdMs = config.coldSubAgentThresholdMs ?? 30_000
822
+ const deferredCompletionTimeoutMs = config.deferredCompletionTimeoutMs ?? 3 * 60_000
823
+ const subAgentTickIntervalMs = config.subAgentTickIntervalMs ?? 10_000
824
+ // Per-chat sliding 60s window of recent emit timestamps. When the
825
+ // window holds more than `editBudgetThreshold` entries we're "hot"
826
+ // and coalesce more aggressively.
827
+ const editTimestamps = new Map<string, number[]>()
828
+ function recordEdit(k: string): void {
829
+ const arr = editTimestamps.get(k) ?? []
830
+ arr.push(now())
831
+ // Drop entries older than 60s.
832
+ const cutoff = now() - 60_000
833
+ while (arr.length > 0 && arr[0] < cutoff) arr.shift()
834
+ editTimestamps.set(k, arr)
835
+ }
836
+ function isBudgetHot(k: string): boolean {
837
+ const arr = editTimestamps.get(k)
838
+ if (!arr) return false
839
+ const cutoff = now() - 60_000
840
+ while (arr.length > 0 && arr[0] < cutoff) arr.shift()
841
+ return arr.length >= editBudgetThreshold
842
+ }
843
+
844
+ const chats = new Map<string, PerChatState>()
845
+
846
+ // Issue #334: per-chat registry of sub-agents that are still running.
847
+ // Keyed by baseKey(chatId, threadId) → Map<agentId, SubAgentState>.
848
+ // When a sub-agent starts it's added; when it reaches a terminal state
849
+ // (done/failed) it's removed. On a new turn for the same chat, any
850
+ // entries here are cloned into the new PerChatState's subAgents so the
851
+ // new turn's progress card shows still-running background sub-agents
852
+ // from the prior turn.
853
+ const chatRunningSubagents = new Map<string, Map<string, SubAgentState>>()
854
+
855
+ // Per-chat turn sequence counters. Key = baseKey(chatId, threadId).
856
+ // Each new startTurn increments the counter; the value is the NEXT seq
857
+ // to allocate (so current total = value - 1 once at least one was allocated).
858
+ const baseTurnSeqs = new Map<string, number>()
859
+ // Tracks base keys of turns started via isSync (startTurn). When the
860
+ // corresponding non-sync session-tail echo arrives, it's dropped and
861
+ // the entry is consumed. This prevents orphan cards when a fast turn
862
+ // completes before the session-tail fires its enqueue echo — Guard 1
863
+ // misses it because currentTurnKey is already null, but this guard
864
+ // catches the echo regardless of turn lifecycle state.
865
+ const pendingSyncEchoes = new Map<string, number>()
866
+ // MessageId-based dedup: tracks recently seen enqueue messageIds so
867
+ // that repeated delivery of the same user message (from session
868
+ // restarts, reconnects, or JSONL rotation) is dropped even after
869
+ // Guard 2's one-shot marker has been consumed. Keyed by
870
+ // `base:messageId` → timestamp. Entries expire after 60s.
871
+ const seenEnqueueMsgIds = new Map<string, number>()
872
+
873
+ /** Allocate a new turn slot for chatId:threadId. Returns the unique turnKey and 1-based index. */
874
+ function allocateTurnSlot(chatId: string, threadId?: string): { turnKey: string; index: number; total: number } {
875
+ const base = baseKey(chatId, threadId)
876
+ const seq = (baseTurnSeqs.get(base) ?? 0) + 1
877
+ baseTurnSeqs.set(base, seq)
878
+ return { turnKey: `${base}:${seq}`, index: seq, total: seq }
879
+ }
880
+
881
+ // Track the last enqueued turn key so non-enqueue session events (tool_use,
882
+ // tool_result, turn_end) which arrive with chatIdMaybe=null from the
883
+ // session-tail supervisor still route to the correct card.
884
+ let currentChatId: string | null = null
885
+ let currentThreadId: string | undefined
886
+ /** Full turn key (chatId:threadId:seq) for the currently active turn. */
887
+ let currentTurnKey: string | null = null
888
+ let heartbeatHandle: { ref: unknown } | null = null
889
+ // Throttled inline TTL eviction for `seenEnqueueMsgIds` and
890
+ // `pendingSyncEchoes`. Previously eviction lived inside the heartbeat tick,
891
+ // but the heartbeat stops when `chats.size === 0`, leaving these maps to
892
+ // grow unbounded across idle periods. The inline path runs at the top of
893
+ // every public ingress (ingest / startTurn) but is rate-limited to once
894
+ // every `evictThrottleMs` so it stays effectively free in the hot path.
895
+ let lastEvictedAt = 0
896
+ const evictThrottleMs = 30_000
897
+ // Tracks the last elapsed-seconds bucket we emitted for each chat so
898
+ // the heartbeat can coalesce — if the HTML hasn't changed AND the
899
+ // header elapsed counter (rounded to the heartbeat cadence) would
900
+ // still render identically, skip the edit.
901
+ const lastHeartbeatBucket = new Map<string, number>()
902
+ // Fix #314: tracks the last sub-agent elapsed-tick bucket per turn.
903
+ // Works exactly like `lastHeartbeatBucket` but uses `subAgentTickIntervalMs`
904
+ // as the bucket width. When the bucket advances AND at least one sub-agent
905
+ // is running, the heartbeat forces an emit even when the HTML hash is
906
+ // unchanged. Bucket-based (not timestamp-based) so the comparison is stable
907
+ // even when multiple heartbeat ticks fire at the same `now()` value during
908
+ // a fake-clock advance in tests.
909
+ const lastSubAgentTickBucket = new Map<string, number>()
910
+
911
+ /**
912
+ * Fire completion callbacks + delete chatState + tidy bookkeeping.
913
+ * Idempotent via `completionFired`. Does not touch the reducer or
914
+ * flush — the caller is responsible for putting the state into its
915
+ * final shape before invoking this.
916
+ *
917
+ * Shared by three completion paths:
918
+ * - Normal turn_end with no in-flight sub-agents
919
+ * - Deferred completion (last sub-agent finishes after parent turn_end)
920
+ * - Abandonment (closeZombie for maxIdle / enqueue-force-close)
921
+ */
922
+ /**
923
+ * Prepare silent-end suppression BEFORE the final flush.
924
+ *
925
+ * Must run before the outer `flush(cs, true)` at every site that calls
926
+ * `completeTurnFully`, so the render at that flush already knows whether
927
+ * to suppress the "🙊 Ended without reply" header. If we relied on
928
+ * `completeTurnFully` to set the flag and re-flush, the outer flush would
929
+ * already have queued a warning-card edit/send to Telegram — and in the
930
+ * worst case (the first edit finalizes before the second arrives) the
931
+ * user sees both the warning AND the corrected card as separate messages.
932
+ *
933
+ * Idempotent — `silentEndPrepared` guards against re-firing the
934
+ * `onSilentEnd` callback (which writes a state file the Stop hook reads).
935
+ */
936
+ function prepareSilentEndSuppression(cs: PerChatState): void {
937
+ if (cs.silentEndPrepared) return
938
+ cs.silentEndPrepared = true
939
+ // #371 fix: when stream_reply(done=true) lands as the final tool call,
940
+ // the Stop hook can fire before session-tail observes the matching
941
+ // tool_use event. Pre-fix replyToolCalled stayed false long enough for
942
+ // isSilentEnd to read true → the silent-end retry kicks in → the user
943
+ // sees a duplicate reply.
944
+ //
945
+ // outboundDeliveredCount is bumped synchronously by
946
+ // recordOutboundDelivered() inside the stream_reply MCP handler when
947
+ // the API call returns successfully — it doesn't depend on the
948
+ // session-tail event landing. Consulting it here closes the race.
949
+ const isSilentEnd =
950
+ !cs.replyToolCalled
951
+ && cs.outboundDeliveredCount === 0
952
+ && !cs.wasAutonomous
953
+ if (!isSilentEnd || !config.onSilentEnd) return
954
+ try {
955
+ const result = config.onSilentEnd({ chatId: cs.chatId, threadId: cs.threadId, turnKey: cs.turnKey })
956
+ if (result?.suppressed === true) {
957
+ cs.silentEndSuppressed = true
958
+ }
959
+ } catch {
960
+ /* never let the callback break the completion path */
961
+ }
962
+ }
963
+
964
+ function beginTurnEnd(target: PerChatState, durationMs: number): void {
965
+ target.parentTurnEndAt = now()
966
+ target.state = reduce(target.state, { kind: 'turn_end', durationMs }, now())
967
+ }
968
+
969
+ function completeTurnFully(cs: PerChatState): void {
970
+ if (cs.completionFired) return
971
+ cs.completionFired = true
972
+ // Defensive: if a caller forgot to call prepareSilentEndSuppression
973
+ // before its flush, run it now so the onSilentEnd callback still fires
974
+ // (the Stop hook still gets the state file). The flag is already set
975
+ // for any caller that did call it (idempotent guard).
976
+ prepareSilentEndSuppression(cs)
977
+ const taskNum = taskNumFor(cs)
978
+ const summary = summariseTurn(cs.state, now())
979
+ if (config.onTurnEnd) {
980
+ try {
981
+ config.onTurnEnd(summary)
982
+ } catch {
983
+ /* never let a summary write break the stream */
984
+ }
985
+ }
986
+ if (config.onTurnComplete) {
987
+ process.stderr.write(`telegram gateway: progress-card: onTurnComplete firing turnKey=${cs.turnKey}\n`)
988
+ try {
989
+ config.onTurnComplete({
990
+ chatId: cs.chatId,
991
+ threadId: cs.threadId,
992
+ turnKey: cs.turnKey,
993
+ summary,
994
+ taskIndex: taskNum.index,
995
+ taskTotal: taskNum.total,
996
+ })
997
+ } catch {
998
+ /* never let completion callback break the stream */
999
+ }
1000
+ }
1001
+ if (cs.pendingTimer != null) {
1002
+ clearT(cs.pendingTimer)
1003
+ cs.pendingTimer = null
1004
+ }
1005
+ if (cs.deferredFirstEmitTimer != null) {
1006
+ clearT(cs.deferredFirstEmitTimer)
1007
+ cs.deferredFirstEmitTimer = null
1008
+ }
1009
+ if (cs.timePromoteTimer != null) {
1010
+ clearT(cs.timePromoteTimer)
1011
+ cs.timePromoteTimer = null
1012
+ }
1013
+ chats.delete(cs.turnKey)
1014
+ lastHeartbeatBucket.delete(cs.turnKey)
1015
+ lastSubAgentTickBucket.delete(cs.turnKey)
1016
+ editTimestamps.delete(cs.turnKey)
1017
+ // Drop the outer base-key entries if no other chat shares the same base.
1018
+ // Covers all 3 close paths since they all funnel through here:
1019
+ // completeTurnFully (turn_end), closeZombie (abandonment), and the
1020
+ // stalled-close branch in the heartbeat. Prevents unbounded growth of
1021
+ // `chatRunningSubagents` / `baseTurnSeqs` across idle periods.
1022
+ cleanupBaseKeyIfUnused(baseKey(cs.chatId, cs.threadId), parseTurnSeq(cs.turnKey))
1023
+ if (currentTurnKey === cs.turnKey) {
1024
+ currentChatId = null
1025
+ currentThreadId = undefined
1026
+ currentTurnKey = null
1027
+ }
1028
+ if (chats.size === 0) stopHeartbeat()
1029
+ }
1030
+
1031
+ /**
1032
+ * Post-ingest check: if the turn is in `pendingCompletion` state and
1033
+ * no sub-agents are still in-flight, fire completion. Called after
1034
+ * every reducer dispatch that could transition a sub-agent to done
1035
+ * (sub_agent_turn_end, parent Agent tool_result, etc.).
1036
+ */
1037
+ function maybeCompleteDeferredTurn(cs: PerChatState): void {
1038
+ if (!cs.pendingCompletion) return
1039
+ // Gate on ANY running sub-agent (correlated OR orphan). Orphans from
1040
+ // `Agent({run_in_background:true})` only deregister via their own
1041
+ // `sub_agent_turn_end` — the card must stay pinned until then so the
1042
+ // user sees the background work. Closes #87. Historical ghost-pin
1043
+ // risk (#31/#43) is bounded by `closeZombie` on new enqueue +
1044
+ // `maxIdleMs` heartbeat ceiling.
1045
+ // Also gate on fleet background members: a bg sub-agent that hasn't
1046
+ // yet emitted any events will be absent from state.subAgents but
1047
+ // present in fleet with status:'background'. Without this gate the
1048
+ // deferred completion would fire immediately and close the card.
1049
+ // Fixes #713 and #709.
1050
+ if (hasAnyRunningSubAgent(cs.state)) return
1051
+ if (hasLiveBackground(cs.fleet)) return
1052
+ process.stderr.write(`telegram gateway: progress-card: deferred completion firing turnKey=${cs.turnKey} (last sub-agent finished)\n`)
1053
+ // Route through the unified close path (turn-end reason) so the
1054
+ // prelude (silentEnd suppression, final flush, tail cleanup) matches
1055
+ // every other completion site.
1056
+ closePerChat(cs, 'turn-end')
1057
+ }
1058
+
1059
+ /**
1060
+ * Unified per-chat close path. Called by every site that finalises a
1061
+ * card so the prelude (timer cleanup, sub-agent force-close where the
1062
+ * reason demands it, silentEnd preparation, final flush) is applied
1063
+ * consistently. The cleanup tail (chats.delete, baseKey cleanup,
1064
+ * heartbeat-stop-if-last) lives in `completeTurnFully` and runs at the
1065
+ * end of every reason path.
1066
+ *
1067
+ * Reasons:
1068
+ * - 'turn-end' : normal completion (parent turn_end fired with no
1069
+ * in-flight sub-agents, or the deferred-completion
1070
+ * gate cleared). Sub-agents are NOT force-closed
1071
+ * because by definition none are running.
1072
+ * - 'zombie' : abandonment (heartbeat maxIdle ceiling, or new
1073
+ * enqueue force-closing the previous card). Force-
1074
+ * closes running sub-agents because we are giving
1075
+ * up on them. Preserves `pendingSyncEchoes` because
1076
+ * the echo for the previous turn may still arrive.
1077
+ * - 'stalled' : Gap-8 deferred-completion timeout expired. Force-
1078
+ * closes running sub-agents and passes
1079
+ * `stalledClose=true` to flush so the renderer shows
1080
+ * "⚠️ Stalled — forced close".
1081
+ *
1082
+ * Must not re-enter ingest.
1083
+ */
1084
+ function closePerChat(cs: PerChatState, reason: CloseReason): void {
1085
+ // Clear pending coalesce timer for every reason — we are about to
1086
+ // emit the final render synchronously.
1087
+ if (cs.pendingTimer != null) {
1088
+ clearT(cs.pendingTimer)
1089
+ cs.pendingTimer = null
1090
+ }
1091
+
1092
+ if (reason === 'zombie' || reason === 'stalled') {
1093
+ // Both reasons synthesise a turn_end (zombie) or have already had
1094
+ // one fire (stalled — parentTurnEndAt is set) and then explicitly
1095
+ // close every running sub-agent so the render accounts for all
1096
+ // work. zombie: reduce now; stalled: reducer already saw turn_end.
1097
+ if (reason === 'zombie') {
1098
+ const durationMs = Math.max(0, now() - cs.state.turnStartedAt)
1099
+ cs.state = reduce(cs.state, { kind: 'turn_end', durationMs }, now())
1100
+ }
1101
+ if (hasAnyRunningSubAgent(cs.state)) {
1102
+ const prevStateForSync = cs.state
1103
+ const closed = new Map(cs.state.subAgents)
1104
+ const nowMs = now()
1105
+ for (const [k, sa] of closed) {
1106
+ if (sa.state === 'running') {
1107
+ closed.set(k, { ...sa, state: 'done', finishedAt: nowMs, pendingPreamble: null })
1108
+ }
1109
+ }
1110
+ cs.state = { ...cs.state, subAgents: closed }
1111
+ // Issue #399: sync the chat-scoped running-sub-agent registry so
1112
+ // stale entries don't carry into the next turn's progress card.
1113
+ syncChatRunningSubagents(
1114
+ prevStateForSync,
1115
+ cs.state,
1116
+ baseKey(cs.chatId, cs.threadId),
1117
+ chatRunningSubagents,
1118
+ )
1119
+ }
1120
+ }
1121
+
1122
+ // Set silentEndSuppressed BEFORE the outer flush so the rendered
1123
+ // card already excludes the "🙊 Ended without reply" header when a
1124
+ // retry is queued. Otherwise the outer flush would queue a warning-
1125
+ // card edit and a follow-up correction edit could race.
1126
+ prepareSilentEndSuppression(cs)
1127
+ // zombie passes stalledClose=false — we abandoned the card but did
1128
+ // NOT exceed the deferred-completion timeout. Promoting it to
1129
+ // stalled would mis-render the close header.
1130
+ flush(cs, /*forceDone*/ true, /*stalledClose*/ reason === 'stalled')
1131
+ completeTurnFully(cs)
1132
+ // Note: zombie deliberately preserves `pendingSyncEchoes` because
1133
+ // the echo for the closed turn may still arrive after close. The
1134
+ // dedup map's TTL eviction (maybeEvict) will reap it eventually.
1135
+ }
1136
+
1137
+ /**
1138
+ * Backwards-compatible alias for the zombie close path. Retained as a
1139
+ * thin wrapper so call sites read clearly ("close the zombie") without
1140
+ * needing to know about the reason taxonomy.
1141
+ */
1142
+ function closeZombie(cs: PerChatState): void {
1143
+ closePerChat(cs, 'zombie')
1144
+ }
1145
+
1146
+ /**
1147
+ * TTL-evict stale entries from the messageId-dedup map and the sync-echo
1148
+ * marker map. Same TTLs as the (now-removed) heartbeat eviction:
1149
+ * - `seenEnqueueMsgIds`: 60s (matches the dedup window in `ingest`).
1150
+ * - `pendingSyncEchoes`: 30s (matches the consumer in `ingest`).
1151
+ */
1152
+ function evictStaleDedup(nowMs: number): void {
1153
+ const t60 = nowMs - 60_000
1154
+ for (const [k, ts] of seenEnqueueMsgIds) {
1155
+ if (ts <= t60) seenEnqueueMsgIds.delete(k)
1156
+ }
1157
+ const t30 = nowMs - 30_000
1158
+ for (const [k, ts] of pendingSyncEchoes) {
1159
+ if (ts <= t30) pendingSyncEchoes.delete(k)
1160
+ }
1161
+ }
1162
+
1163
+ /**
1164
+ * Throttled wrapper. Cheap when not due — a single timestamp compare and
1165
+ * branch. Called at the top of every public ingress so eviction runs
1166
+ * regardless of whether any chats are currently live.
1167
+ */
1168
+ function maybeEvict(nowMs: number): void {
1169
+ if (nowMs - lastEvictedAt < evictThrottleMs) return
1170
+ lastEvictedAt = nowMs
1171
+ evictStaleDedup(nowMs)
1172
+ }
1173
+
1174
+ /**
1175
+ * Best-effort outer-base-key cleanup, called after a chat is removed from
1176
+ * the `chats` map. Only drops entries that are *safe* to drop:
1177
+ *
1178
+ * - `chatRunningSubagents[base]`: deleted iff (a) no surviving chat
1179
+ * shares the same base AND (b) the inner map is empty. Background
1180
+ * sub-agents intentionally outlive their parent turn (cross-turn
1181
+ * carry-over for `Agent({run_in_background:true})`), so we never
1182
+ * drop a non-empty inner map — that would erase the next turn's
1183
+ * seed list. The empty-map case is the unbounded-growth path the
1184
+ * caller cares about: a chat that ran but never spawned anything
1185
+ * still got a `Map` allocated (or, more importantly, the entry
1186
+ * remains after natural sub-agent completion).
1187
+ *
1188
+ * - `baseTurnSeqs[base]`: deleted iff no surviving chat shares the
1189
+ * same base AND no in-flight enqueue has just allocated a new turn
1190
+ * for this base via `allocateTurnSlot` (signalled by
1191
+ * `currentTurnKey` whose prefix matches `base`). The latter guard
1192
+ * matters because the new-enqueue path runs
1193
+ * `allocateTurnSlot -> closeZombie(old)` before registering the
1194
+ * new chat in `chats`; a naive delete here would clobber the
1195
+ * just-allocated seq, causing the next allocation to reset to 1
1196
+ * and collide with the still-live new turn.
1197
+ */
1198
+ function cleanupBaseKeyIfUnused(base: string, closingTurnSeq?: number): void {
1199
+ for (const cs of chats.values()) {
1200
+ if (baseKey(cs.chatId, cs.threadId) === base) return
1201
+ }
1202
+ const inner = chatRunningSubagents.get(base)
1203
+ if (inner == null || inner.size === 0) {
1204
+ chatRunningSubagents.delete(base)
1205
+ }
1206
+ // Skip `baseTurnSeqs` cleanup if `allocateTurnSlot` has just bumped the
1207
+ // seq past the turn we are closing. That happens in the new-enqueue
1208
+ // path: `allocateTurnSlot` runs BEFORE `closeZombie(old)` and BEFORE
1209
+ // the new PerChatState is registered in `chats`, so the new turn is
1210
+ // invisible to the iteration above. Detecting that via the seq diff
1211
+ // avoids clobbering the just-allocated counter (would reset numbering
1212
+ // to 1 and cause turnKey collisions with the still-live new turn).
1213
+ const currentSeq = baseTurnSeqs.get(base)
1214
+ if (
1215
+ currentSeq != null
1216
+ && closingTurnSeq != null
1217
+ && currentSeq > closingTurnSeq
1218
+ ) {
1219
+ return
1220
+ }
1221
+ baseTurnSeqs.delete(base)
1222
+ }
1223
+
1224
+ /** Parse the trailing `:N` from a turnKey. Returns undefined if absent. */
1225
+ function parseTurnSeq(turnKey: string): number | undefined {
1226
+ const idx = turnKey.lastIndexOf(':')
1227
+ if (idx < 0) return undefined
1228
+ const n = Number(turnKey.slice(idx + 1))
1229
+ return Number.isFinite(n) ? n : undefined
1230
+ }
1231
+
1232
+ function startHeartbeatIfNeeded(): void {
1233
+ if (heartbeatMs <= 0) return
1234
+ if (heartbeatHandle != null) return
1235
+ if (chats.size === 0) return
1236
+ heartbeatHandle = setI(() => {
1237
+ // Force a re-render for any chat with an open turn so the header
1238
+ // elapsed time and per-item `(dur)` tick visibly — even when no
1239
+ // session-JSONL events have arrived for a while (common while a
1240
+ // sub-agent is running). Coalesce: only actually emit if either
1241
+ // the rendered HTML changed or the elapsed-time bucket
1242
+ // (rounded to the heartbeat period) advanced.
1243
+ //
1244
+ // Zombie ceiling: collect any card whose last real event is
1245
+ // older than maxIdleMs and force-close it after the iteration.
1246
+ // Deferring the close keeps Map iteration safe and lets us batch
1247
+ // the cleanup.
1248
+ const zombies: PerChatState[] = []
1249
+ // Gap 3: pendingAgentSpawns that need orphan promotion this tick.
1250
+ const orphanPromotions: PerChatState[] = []
1251
+ // Gap 4: running sub-agents whose JSONL watcher appears cold.
1252
+ const coldSubAgents: Array<{ cs: PerChatState; agentId: string }> = []
1253
+ // Gap 8: cards where the deferred-completion timeout has expired.
1254
+ const stalledCards: PerChatState[] = []
1255
+ for (const [, cs] of chats) {
1256
+ // P3 of #662 — per-member stuck escalation runs FIRST, before any
1257
+ // skip gate. This is pure data plumbing on the fleet shadow map;
1258
+ // it must happen even when the chat is in the initial-delay window
1259
+ // or budget-hot (the renderer's job is gated by those conditions
1260
+ // separately). markStuck is idempotent and a no-op for non-running
1261
+ // members, so running it every tick is cheap.
1262
+ {
1263
+ const fleet = cs.fleet
1264
+ if (fleet.size > 0) {
1265
+ const tNow = now()
1266
+ for (const [agentId, m] of fleet) {
1267
+ const next = fleetMarkStuck(m, tNow, 60_000)
1268
+ if (next !== m) fleet.set(agentId, next)
1269
+ }
1270
+ }
1271
+ }
1272
+
1273
+ // Skip only when TRULY done. During the deferred-completion
1274
+ // window (parent turn_end fired but sub-agents — correlated or
1275
+ // orphan — are still running), reducer stage is 'done' but the
1276
+ // card is still alive. Keeping the heartbeat ticking lets per-row
1277
+ // elapsed times advance visibly; otherwise the card looks frozen
1278
+ // ("card went dead" bug). Same gate as the defer paths so the
1279
+ // heartbeat lifetime tracks the pin lifetime exactly.
1280
+ if (cs.state.stage === 'done' && !hasAnyRunningSubAgent(cs.state)) continue
1281
+ // Skip heartbeat for terminal cards — the Telegram message is gone
1282
+ // (deleted / bot blocked). No edits should be attempted.
1283
+ if (cs.apiFailures.terminal) continue
1284
+ // Don't heartbeat a card that's still in the initial delay window.
1285
+ if (cs.isFirstEmit && cs.deferredFirstEmitTimer !== DELAY_ELAPSED) continue
1286
+ if (maxIdleMs > 0 && now() - cs.lastEventAt > maxIdleMs) {
1287
+ zombies.push(cs)
1288
+ continue
1289
+ }
1290
+
1291
+ // Gap 3 — orphan promotion: if any PendingAgentSpawn has been
1292
+ // waiting longer than orphanPromotionMs without a matching
1293
+ // sub_agent_started, promote it to a synthesised sub-agent row so
1294
+ // the work is at least visible on the card.
1295
+ if (orphanPromotionMs > 0 && cs.state.pendingAgentSpawns.size > 0) {
1296
+ for (const [toolUseId, pending] of cs.state.pendingAgentSpawns) {
1297
+ if (!cs.promotedSpawnIds.has(toolUseId) && now() - pending.startedAt >= orphanPromotionMs) {
1298
+ orphanPromotions.push(cs)
1299
+ break
1300
+ }
1301
+ }
1302
+ }
1303
+
1304
+ // Gap 4 — cold-JSONL detection: if a running sub-agent hasn't
1305
+ // emitted an event for coldSubAgentThresholdMs, synthesise a
1306
+ // sub_agent_turn_end so the deferred-completion path can proceed.
1307
+ if (coldSubAgentThresholdMs > 0 && cs.pendingCompletion) {
1308
+ for (const [agentId, sa] of cs.state.subAgents) {
1309
+ if (sa.state === 'running' && sa.lastEventAt != null && now() - sa.lastEventAt >= coldSubAgentThresholdMs) {
1310
+ coldSubAgents.push({ cs, agentId })
1311
+ }
1312
+ }
1313
+ }
1314
+
1315
+ // Gap 8 — deferred-completion timeout: if the parent turn_end fired
1316
+ // but sub-agents never finished within deferredCompletionTimeoutMs,
1317
+ // force-close with a "stalled" header.
1318
+ if (
1319
+ deferredCompletionTimeoutMs > 0
1320
+ && cs.parentTurnEndAt != null
1321
+ && now() - cs.parentTurnEndAt >= deferredCompletionTimeoutMs
1322
+ ) {
1323
+ stalledCards.push(cs)
1324
+ continue
1325
+ }
1326
+
1327
+ // Fix #314 — elapsed-ticker bucket: compute BEFORE the budget-hot
1328
+ // skip so the ticker can override the skip when the elapsed counter
1329
+ // would otherwise freeze. A bursty sub-agent (many tool calls) makes
1330
+ // the chat hot, which suppresses the heartbeat — but the user still
1331
+ // expects elapsed time to advance visibly. The ticker provides a hard
1332
+ // floor every `subAgentTickIntervalMs` so the UI never looks dead for
1333
+ // longer than that, even when a sub-agent is grinding through tools.
1334
+ const subAgentRunning = subAgentTickIntervalMs > 0 && hasAnyRunningSubAgent(cs.state)
1335
+ const subAgentBucket = subAgentTickIntervalMs > 0 ? Math.floor(now() / subAgentTickIntervalMs) : 0
1336
+ const prevSubAgentBucket = lastSubAgentTickBucket.get(cs.turnKey)
1337
+ const elapsedTickDue = subAgentRunning && subAgentBucket !== prevSubAgentBucket
1338
+
1339
+ // Skip heartbeat while the chat is hot — sub-agent bursts are
1340
+ // already producing edits, the elapsed counter is ticking from
1341
+ // those, and an extra heartbeat edit just spends budget. (Design
1342
+ // §4.4: "heartbeat respects budget too".) EXCEPTION: when the
1343
+ // elapsed-ticker is due, push one render through to keep elapsed
1344
+ // visibly advancing — this is the floor that fixes #314.
1345
+ if (isBudgetHot(cs.turnKey) && !elapsedTickDue) continue
1346
+ if (elapsedTickDue) {
1347
+ lastSubAgentTickBucket.set(cs.turnKey, subAgentBucket)
1348
+ }
1349
+ const stuckMs = Math.max(0, now() - cs.lastEventAt)
1350
+ // Issue #132: silentEnd only matters once the parent turn is in
1351
+ // `stage='done'` AND no sub-agents are still running. While work
1352
+ // is in flight, "no reply yet" is normal; the card stays in
1353
+ // "Working…". The renderer applies the same gate, so passing the
1354
+ // unconditional flag here is safe.
1355
+ // Issue #259: suppress for autonomous wakeup turns (no reply is expected).
1356
+ // silentEndSuppressed: set when a retry is queued (first silent-end) so
1357
+ // the heartbeat renders "✅ Done" instead of "🙊 Ended without reply".
1358
+ const silentEnd = !cs.replyToolCalled && !cs.wasAutonomous && !cs.silentEndSuppressed
1359
+ // Issue #137: agent called reply/stream_reply (replyToolCalled=true)
1360
+ // but the actual outbound never landed (recordOutboundDelivered was
1361
+ // never called for this card). Distinct from silentEnd because the
1362
+ // agent TRIED — the failure is in the delivery layer, not the model.
1363
+ const replyNotDelivered = cs.replyToolCalled && cs.outboundDeliveredCount === 0
1364
+ // Gap 8: pass parentDone to renderer during the deferred-unpin window.
1365
+ const parentDone = cs.parentTurnEndAt != null && hasAnyRunningSubAgent(cs.state)
1366
+ const html = render(cs.state, now(), undefined, { stuckMs, silentEnd, replyNotDelivered, parentDone }, undefined, cs.fleet)
1367
+ const bucket = Math.floor(now() / heartbeatMs)
1368
+ const prevBucket = lastHeartbeatBucket.get(cs.turnKey)
1369
+
1370
+ // Fix #314 — elapsed-ticker bypass for the html-unchanged guard. When
1371
+ // the elapsed-ticker is due, push the emit through even if html and
1372
+ // heartbeat-bucket are both unchanged. Combined with the budget-hot
1373
+ // bypass above, this guarantees the elapsed counter advances at most
1374
+ // `subAgentTickIntervalMs` apart while a sub-agent is running.
1375
+ if (html === cs.lastEmittedHtml && bucket === prevBucket && !elapsedTickDue) continue
1376
+
1377
+ lastHeartbeatBucket.set(cs.turnKey, bucket)
1378
+ cs.lastEmittedHtml = html
1379
+ cs.lastEmittedAt = now()
1380
+ recordEdit(cs.turnKey)
1381
+ config.emit({
1382
+ chatId: cs.chatId,
1383
+ threadId: cs.threadId,
1384
+ turnKey: cs.turnKey,
1385
+ html,
1386
+ done: false,
1387
+ isFirstEmit: false,
1388
+ })
1389
+ }
1390
+ for (const cs of zombies) closeZombie(cs)
1391
+
1392
+ // Gap 3: promote stale PendingAgentSpawns to synthetic sub-agent rows.
1393
+ for (const cs of orphanPromotions) {
1394
+ for (const [toolUseId, pending] of cs.state.pendingAgentSpawns) {
1395
+ if (cs.promotedSpawnIds.has(toolUseId)) continue
1396
+ if (now() - pending.startedAt < orphanPromotionMs) continue
1397
+ cs.promotedSpawnIds.add(toolUseId)
1398
+ const syntheticId = `orphan-${toolUseId}`
1399
+ process.stderr.write(
1400
+ `telegram gateway: progress-card: orphan-promotion toolUseId=${toolUseId} syntheticId=${syntheticId} description="${pending.description}" (Gap 3 #313)\n`,
1401
+ )
1402
+ // Synthesise a sub_agent_started event — drives the reducer's
1403
+ // existing sub_agent_started path (adds to subAgents, removes
1404
+ // from pendingAgentSpawns, links checklist item via spawnedAgentId).
1405
+ cs.state = reduce(cs.state, {
1406
+ kind: 'sub_agent_started',
1407
+ agentId: syntheticId,
1408
+ firstPromptText: pending.promptText,
1409
+ }, now())
1410
+ cs.lastEventAt = now()
1411
+ flush(cs, false)
1412
+ }
1413
+ }
1414
+
1415
+ // Gap 4: synthesise sub_agent_turn_end for cold-JSONL sub-agents.
1416
+ for (const { cs, agentId } of coldSubAgents) {
1417
+ process.stderr.write(
1418
+ `telegram gateway: progress-card: cold-jsonl-synth-turn-end agentId=${agentId} turnKey=${cs.turnKey} (Gap 4 #313)\n`,
1419
+ )
1420
+ const prevStateGap4 = cs.state
1421
+ cs.state = reduce(cs.state, { kind: 'sub_agent_turn_end', agentId }, now())
1422
+ // Issue #399: sync the chat-scoped running-sub-agent registry so the
1423
+ // cold-synth terminal transition doesn't leave a stale entry that would
1424
+ // carry over into the next turn's progress card.
1425
+ syncChatRunningSubagents(
1426
+ prevStateGap4,
1427
+ cs.state,
1428
+ baseKey(cs.chatId, cs.threadId),
1429
+ chatRunningSubagents,
1430
+ )
1431
+ cs.lastEventAt = now()
1432
+ maybeCompleteDeferredTurn(cs)
1433
+ if (!cs.completionFired) flush(cs, false)
1434
+ }
1435
+
1436
+ // Gap 8: force-close cards whose deferred-completion timeout has expired.
1437
+ // The unified `closePerChat('stalled')` path applies the same prelude
1438
+ // (sub-agent sync, prepareSilentEndSuppression) and renders the
1439
+ // "⚠️ Stalled — forced close" header via stalledClose=true.
1440
+ for (const cs of stalledCards) {
1441
+ process.stderr.write(
1442
+ `telegram gateway: progress-card: deferred-completion-timeout-expired turnKey=${cs.turnKey} deferredCompletionTimeoutMs=${deferredCompletionTimeoutMs} (Gap 8 #313)\n`,
1443
+ )
1444
+ closePerChat(cs, 'stalled')
1445
+ }
1446
+ // Dedup-map TTL eviction has moved to `maybeEvict` (called from
1447
+ // every public ingress). Keeping it here was unsafe because the
1448
+ // heartbeat stops when `chats.size === 0`, which let
1449
+ // `seenEnqueueMsgIds` / `pendingSyncEchoes` grow unbounded across
1450
+ // idle periods.
1451
+ //
1452
+ // If every chat has ended, stop the heartbeat to avoid an
1453
+ // always-on timer.
1454
+ if (chats.size === 0) stopHeartbeat()
1455
+ }, heartbeatMs)
1456
+ }
1457
+
1458
+ function stopHeartbeat(): void {
1459
+ if (heartbeatHandle == null) return
1460
+ clearI(heartbeatHandle)
1461
+ heartbeatHandle = null
1462
+ }
1463
+
1464
+ /** Base key for a chat:thread (no turn seq). Used as prefix for turn keys. */
1465
+ function baseKey(chatId: string, threadId?: string): string {
1466
+ return threadId != null ? `${chatId}:${threadId}` : chatId
1467
+ }
1468
+
1469
+ /**
1470
+ * Return the N/M task counter for a card. Index and total are derived
1471
+ * from the currently ACTIVE cards for this chat:thread — NOT the
1472
+ * session-cumulative baseTurnSeqs counter. Using the cumulative counter
1473
+ * causes "(11/11)" to appear after 11 sequential turns, which reads as
1474
+ * "task 11 of 11" (confusingly final-looking) rather than conveying
1475
+ * parallel concurrency. The N/M suffix is only meaningful when 2+ cards
1476
+ * are simultaneously active; for sequential turns it should be absent.
1477
+ */
1478
+ function taskNumFor(chatState: PerChatState): TaskNum {
1479
+ const base = baseKey(chatState.chatId, chatState.threadId)
1480
+ // Count only currently active cards for this chat:thread so that
1481
+ // sequential turns always return total=1 (counter hidden) and only
1482
+ // parallel active turns (2+ simultaneous cards) show "(N/M)".
1483
+ let activeCount = 0
1484
+ let activeIndex = 1
1485
+ for (const [, cs] of chats) {
1486
+ if (baseKey(cs.chatId, cs.threadId) === base) {
1487
+ activeCount++
1488
+ if (cs.turnKey === chatState.turnKey) activeIndex = activeCount
1489
+ }
1490
+ }
1491
+ return { index: activeIndex, total: activeCount }
1492
+ }
1493
+
1494
+ const DELAY_ELAPSED = 'elapsed'
1495
+ function flush(chatState: PerChatState, forceDone: boolean, stalledClose = false): void {
1496
+ // If this card has hit the permanent-failure threshold, don't attempt
1497
+ // any more edits. Avoids log spam and pointless retries for deleted
1498
+ // messages / blocked bots.
1499
+ if (chatState.apiFailures.terminal) return
1500
+ // External takeover (e.g. turn-flush rewriting the card with the
1501
+ // user-facing answer text — see #654). Once handed off, the driver
1502
+ // must never issue another edit for this card; the new owner has
1503
+ // full control of the message until they call pinMgr.completeTurn.
1504
+ if (chatState.cardTakenOver) return
1505
+ // Suppress the card entirely if the turn ends before the initial
1506
+ // delay has elapsed — no point flashing a "Working…" card for a
1507
+ // turn that completed in under initialDelayMs.
1508
+ if (chatState.isFirstEmit && initialDelayMs > 0 && chatState.deferredFirstEmitTimer !== DELAY_ELAPSED) {
1509
+ if (forceDone || chatState.state.stage === 'done') {
1510
+ // Turn ended before the card was ever shown — suppress it.
1511
+ if (chatState.deferredFirstEmitTimer != null) {
1512
+ clearT(chatState.deferredFirstEmitTimer)
1513
+ chatState.deferredFirstEmitTimer = null
1514
+ }
1515
+ process.stderr.write(`telegram gateway: progress-card: fast-turn suppression turnKey=${chatState.turnKey} (turn ended before initialDelayMs=${initialDelayMs}ms)\n`)
1516
+ return
1517
+ }
1518
+ // Defer the first emit — schedule it for initialDelayMs from now
1519
+ // if not already scheduled.
1520
+ if (chatState.deferredFirstEmitTimer == null) {
1521
+ const capturedTurnKey = chatState.turnKey
1522
+ process.stderr.write(`telegram gateway: progress-card: scheduled initial-delay timer turnKey=${capturedTurnKey} delay=${initialDelayMs}ms\n`)
1523
+ chatState.deferredFirstEmitTimer = setT(() => {
1524
+ if (!chats.has(capturedTurnKey)) return
1525
+ chatState.deferredFirstEmitTimer = DELAY_ELAPSED
1526
+ process.stderr.write(`telegram gateway: progress-card: initial-delay timer fired turnKey=${capturedTurnKey}\n`)
1527
+ flush(chatState, false)
1528
+ }, initialDelayMs)
1529
+ }
1530
+ return
1531
+ }
1532
+ const taskNum = taskNumFor(chatState)
1533
+ const stuckMs = Math.max(0, now() - chatState.lastEventAt)
1534
+ // Issue #259: autonomous wakeup turns never produce a reply by design —
1535
+ // suppress the silent-end warning so the card renders "✅ Done" instead
1536
+ // of "🙊 Ended without reply" when ScheduleWakeup / CronCreate fires.
1537
+ // silentEndSuppressed is set by completeTurnFully when onSilentEnd returns
1538
+ // { suppressed: true } — used to re-render the final card without the
1539
+ // warning after a retry is queued, preventing a false-positive flash.
1540
+ const silentEnd =
1541
+ !chatState.replyToolCalled && !chatState.wasAutonomous && !chatState.silentEndSuppressed
1542
+ const replyNotDelivered =
1543
+ chatState.replyToolCalled && chatState.outboundDeliveredCount === 0
1544
+ // Gap 8: during the deferred-unpin window (parent turn_end fired but
1545
+ // sub-agents still running), show ✅ Done in the parent header immediately.
1546
+ const parentDone = chatState.parentTurnEndAt != null && hasAnyRunningSubAgent(chatState.state)
1547
+ const html = render(
1548
+ chatState.state,
1549
+ now(),
1550
+ taskNum.total > 1 ? taskNum : undefined,
1551
+ { stuckMs, silentEnd, replyNotDelivered, parentDone, stalledClose },
1552
+ undefined,
1553
+ chatState.fleet,
1554
+ )
1555
+ // Issue #81 diagnostic: which checklist branch is the renderer taking?
1556
+ // The card prefers `narratives` (human preambles) over `items` (raw
1557
+ // tool counts). When prose lands without narratives we want to know
1558
+ // why — log the available state at the decision boundary.
1559
+ //
1560
+ // Fires on the first emit AND on any forced-done flush (terminal
1561
+ // state via completeTurnFully / closeZombie / maybeCompleteDeferredTurn)
1562
+ // — both are useful inflection points for understanding what the card
1563
+ // looked like when it transitioned.
1564
+ if (forceDone || chatState.lastEmittedHtml == null /* first emit or terminal flush */) {
1565
+ const s = chatState.state
1566
+ const branch = s.narratives.length > 0
1567
+ ? 'narratives'
1568
+ : s.items.length > 0
1569
+ ? 'tool-count-fallback'
1570
+ : 'empty'
1571
+ process.stderr.write(
1572
+ `progress-card.diag: render branch=${branch} chatId=${chatState.chatId} turnKey=${chatState.turnKey} ` +
1573
+ `narratives=${s.narratives.length} items=${s.items.length} latestText_len=${s.latestText?.length ?? 0} ` +
1574
+ `subagents=${s.subAgents.size} pendingPreamble=${s.pendingPreamble ? 'yes' : 'no'} forceDone=${forceDone}\n`,
1575
+ )
1576
+ }
1577
+ if (html === chatState.lastEmittedHtml && !forceDone) return
1578
+ chatState.lastEmittedHtml = html
1579
+ chatState.lastEmittedAt = now()
1580
+ recordEdit(chatState.turnKey)
1581
+ const isFirst = chatState.isFirstEmit
1582
+ chatState.isFirstEmit = false
1583
+ // Notification-spam fix (2026-04-23): never emit done=true while the
1584
+ // card is still waiting on in-flight sub-agents. The reducer sets
1585
+ // `stage='done'` the moment parent turn_end lands, so a naive
1586
+ // `done: stage==='done'` passes done=true on every subsequent sub-
1587
+ // agent event. handleStreamReply finalizes + deletes the draft
1588
+ // stream after every done=true call, so the NEXT emit creates a
1589
+ // fresh sendMessage — which Telegram delivers as a new push
1590
+ // notification. Ken observed ~13 identical "✅ Done" notifications
1591
+ // while two parallel review sub-agents were grinding.
1592
+ //
1593
+ // Safe to gate on `hasAnyRunningSubAgent`: the completion paths
1594
+ // (`completeTurnFully` / `closeZombie` / `maybeCompleteDeferredTurn`)
1595
+ // either (a) ran when no sub-agents are running or (b) explicitly
1596
+ // marked every running sub-agent as done in the reducer state BEFORE
1597
+ // the final flush. Including orphans here keeps `done=true` suppressed
1598
+ // while a background dispatch is still active (closes #87).
1599
+ const terminal =
1600
+ (forceDone || chatState.state.stage === 'done')
1601
+ && !hasAnyRunningSubAgent(chatState.state)
1602
+ config.emit({
1603
+ chatId: chatState.chatId,
1604
+ threadId: chatState.threadId,
1605
+ turnKey: chatState.turnKey,
1606
+ html,
1607
+ done: terminal,
1608
+ isFirstEmit: isFirst,
1609
+ // Thread the source message_id through on the first emit only so
1610
+ // the caller can pass it as reply_parameters on the initial
1611
+ // sendMessage. Edits (isFirstEmit=false) must NOT carry it.
1612
+ ...(isFirst && chatState.replyToMessageId != null
1613
+ ? { replyToMessageId: chatState.replyToMessageId }
1614
+ : {}),
1615
+ })
1616
+ }
1617
+
1618
+ /**
1619
+ * Promote a card out of the initial-delay suppression window early.
1620
+ * Idempotent — short-circuits if the card has already emitted, the
1621
+ * delay has already elapsed, or the card is terminal.
1622
+ *
1623
+ * Sets `deferredFirstEmitTimer = DELAY_ELAPSED` so the very next
1624
+ * `flush()` call bypasses the suppression branch and emits a real
1625
+ * card render. Cancels any in-flight deferred timer to prevent a
1626
+ * second emit when the original `initialDelayMs` clock would have
1627
+ * fired. Calls `flush()` directly so the card surfaces immediately.
1628
+ *
1629
+ * Used by:
1630
+ * - sub-agent state diff in `ingest()` when a sub-agent transitions
1631
+ * to running during the suppression window
1632
+ * - the enqueue branch when carriedOver running sub-agents seed the
1633
+ * fresh PerChatState (#334 cross-turn carry-over)
1634
+ * - `onSubAgentStall()` when a watcher reports a stalled sub-agent
1635
+ * before the card has emitted
1636
+ */
1637
+ function promoteFirstEmit(cs: PerChatState, reason: string): void {
1638
+ if (!cs.isFirstEmit) return
1639
+ if (cs.deferredFirstEmitTimer === DELAY_ELAPSED) return
1640
+ if (cs.apiFailures.terminal) return
1641
+ if (cs.deferredFirstEmitTimer != null) {
1642
+ clearT(cs.deferredFirstEmitTimer)
1643
+ }
1644
+ if (cs.timePromoteTimer != null) {
1645
+ clearT(cs.timePromoteTimer)
1646
+ cs.timePromoteTimer = null
1647
+ }
1648
+ cs.deferredFirstEmitTimer = DELAY_ELAPSED
1649
+ process.stderr.write(
1650
+ `telegram gateway: progress-card: promoteFirstEmit turnKey=${cs.turnKey} reason=${reason}\n`,
1651
+ )
1652
+ flush(cs, /*forceDone*/ false)
1653
+ }
1654
+
1655
+ /**
1656
+ * F3 fix (#553): schedule a one-shot timer that force-promotes the
1657
+ * card after `promoteAfterMs` if no other promotion path has fired
1658
+ * by then. Idempotent — safe to call repeatedly. The timer is
1659
+ * cleared by `promoteFirstEmit` (so the existing promotion paths
1660
+ * still win when they fire first) and at turn end.
1661
+ *
1662
+ * Without this proactive timer, a long single-tool turn (e.g. one
1663
+ * 10s Bash) never crosses any existing promotion threshold and
1664
+ * the card stays suppressed until `initialDelayMs` (30s by
1665
+ * default). Fast-turn-suppression then cancels it on `turn_end`.
1666
+ */
1667
+ function ensureTimePromoteScheduled(cs: PerChatState): void {
1668
+ if (!cs.isFirstEmit) return
1669
+ if (cs.deferredFirstEmitTimer === DELAY_ELAPSED) return
1670
+ if (cs.apiFailures.terminal) return
1671
+ if (cs.timePromoteTimer != null) return
1672
+ if (promoteAfterMs <= 0) return
1673
+ const elapsed = now() - cs.state.turnStartedAt
1674
+ const remaining = Math.max(0, promoteAfterMs - elapsed)
1675
+ const capturedTurnKey = cs.turnKey
1676
+ cs.timePromoteTimer = setT(() => {
1677
+ if (!chats.has(capturedTurnKey)) return
1678
+ const cs2 = chats.get(capturedTurnKey)!
1679
+ cs2.timePromoteTimer = null
1680
+ // Idempotency belt-and-braces: promoteFirstEmit no-ops if already
1681
+ // promoted by another path between scheduling and firing.
1682
+ promoteFirstEmit(cs2, `time_${promoteAfterMs}ms`)
1683
+ }, remaining)
1684
+ }
1685
+
1686
+ /**
1687
+ * True if `a` and `b` differ in any field that actually appears in the
1688
+ * rendered card (items, stage, userRequest, latestText). Internal
1689
+ * bookkeeping fields like `thinking` that don't reach render() don't
1690
+ * count — we don't want to waste a Telegram edit on them.
1691
+ */
1692
+ function visibleDiff(a: ProgressCardState, b: ProgressCardState): boolean {
1693
+ if (a.stage !== b.stage) return true
1694
+ if (a.userRequest !== b.userRequest) return true
1695
+ if (a.latestText !== b.latestText) return true
1696
+ if (a.items.length !== b.items.length) return true
1697
+ for (let i = 0; i < a.items.length; i++) {
1698
+ if (a.items[i].state !== b.items[i].state) return true
1699
+ if (a.items[i].tool !== b.items[i].tool) return true
1700
+ // Multi-agent: spawnedAgentId attached on correlation matters for
1701
+ // the [Main] line's 🤖 vs ✅ glyph (PR 3 renderer).
1702
+ if (a.items[i].spawnedAgentId !== b.items[i].spawnedAgentId) return true
1703
+ }
1704
+ // Multi-agent: any change in sub-agent shape or per-sub-agent state
1705
+ // is user-visible. Cheap O(N) scan; N is the sub-agent count, which
1706
+ // is bounded by how many parallel Agent calls one turn makes (~4–12
1707
+ // in practice).
1708
+ if (a.subAgents.size !== b.subAgents.size) return true
1709
+ for (const [k, sa] of a.subAgents) {
1710
+ const sb = b.subAgents.get(k)
1711
+ if (!sb) return true
1712
+ if (sa.state !== sb.state) return true
1713
+ if (sa.toolCount !== sb.toolCount) return true
1714
+ if (sa.description !== sb.description) return true
1715
+ if (sa.parentToolUseId !== sb.parentToolUseId) return true
1716
+ if (sa.nestedSpawnCount !== sb.nestedSpawnCount) return true
1717
+ if ((sa.currentTool?.toolUseId ?? null) !== (sb.currentTool?.toolUseId ?? null)) return true
1718
+ if (sa.currentNarrative !== sb.currentNarrative) return true
1719
+ }
1720
+ return false
1721
+ }
1722
+
1723
+ // P0 of #662 — shadow fleet maintenance. Mutates cs.fleet in place
1724
+ // by replacing entries with new immutable FleetMember objects from the
1725
+ // pure transition functions in fleet-state.ts.
1726
+ function updateFleetForEvent(cs: PerChatState, event: SessionEvent): void {
1727
+ switch (event.kind) {
1728
+ case 'tool_use': {
1729
+ // P2 of #662 — capture the run_in_background flag from parent
1730
+ // Agent/Task dispatches. The flag is keyed by parentToolUseId
1731
+ // so the matching sub_agent_started (which gets the same id
1732
+ // wired in via the reducer's pendingAgentSpawns adoption) can
1733
+ // look it up when creating the fleet member.
1734
+ if (
1735
+ (event.toolName === 'Agent' || event.toolName === 'Task') &&
1736
+ event.toolUseId &&
1737
+ event.input?.run_in_background === true
1738
+ ) {
1739
+ cs.backgroundParentToolUseIds.add(event.toolUseId)
1740
+ }
1741
+ return
1742
+ }
1743
+ case 'sub_agent_started': {
1744
+ // Idempotent — late duplicates of the same agentId keep the
1745
+ // original startedAt + originatingTurnKey snapshot.
1746
+ if (cs.fleet.has(event.agentId)) return
1747
+ const role = roleFromDispatch(undefined, event.subagentType, event.firstPromptText)
1748
+ // P2: derive background status from the parent dispatch flag.
1749
+ // The reducer at progress-card.ts:706 already correlated the
1750
+ // matching pendingAgentSpawn and wrote parentToolUseId into the
1751
+ // fresh subagent state — read it back here so the fleet reflects
1752
+ // the dispatch's run_in_background flag.
1753
+ const parentToolUseId = cs.state.subAgents.get(event.agentId)?.parentToolUseId ?? null
1754
+ const isBackground =
1755
+ parentToolUseId != null && cs.backgroundParentToolUseIds.has(parentToolUseId)
1756
+ const member = createFleetMember({
1757
+ agentId: event.agentId,
1758
+ role,
1759
+ startedAt: now(),
1760
+ originatingTurnKey: currentTurnKey ?? cs.turnKey,
1761
+ })
1762
+ cs.fleet.set(event.agentId, isBackground ? { ...member, status: 'background' } : member)
1763
+ return
1764
+ }
1765
+ case 'sub_agent_tool_use': {
1766
+ const m = cs.fleet.get(event.agentId)
1767
+ if (m == null) return
1768
+ cs.fleet.set(event.agentId, fleetApplyToolUse(m, event.toolName, event.input, now()))
1769
+ return
1770
+ }
1771
+ case 'sub_agent_tool_result': {
1772
+ const m = cs.fleet.get(event.agentId)
1773
+ if (m == null) return
1774
+ cs.fleet.set(event.agentId, fleetApplyToolResult(m, event.isError))
1775
+ return
1776
+ }
1777
+ case 'sub_agent_turn_end': {
1778
+ const m = cs.fleet.get(event.agentId)
1779
+ if (m == null) return
1780
+ cs.fleet.set(event.agentId, fleetApplyTurnEnd(m, now()))
1781
+ return
1782
+ }
1783
+ case 'sub_agent_capped': {
1784
+ // The sub-agent transcript was truncated mid-flight: >= threshold
1785
+ // tool_uses with no terminal record. Transition the fleet member to
1786
+ // `capped` so the progress card shows a terminal "capped" row instead
1787
+ // of hanging "running" indefinitely. Also drive the legacy reducer via
1788
+ // sub_agent_turn_end so the subAgents map stays consistent.
1789
+ const m = cs.fleet.get(event.agentId)
1790
+ if (m != null) {
1791
+ cs.fleet.set(event.agentId, fleetApplyCapped(m, now()))
1792
+ }
1793
+ // Mirror into the legacy reducer so render() sees the agent as done.
1794
+ cs.state = reduce(cs.state, { kind: 'sub_agent_turn_end', agentId: event.agentId }, now())
1795
+ return
1796
+ }
1797
+ default:
1798
+ return
1799
+ }
1800
+ }
1801
+
1802
+ // Cardinality reconciler: the legacy state.subAgents map can grow
1803
+ // through paths the fleet shadow doesn't know about (parent Agent
1804
+ // tool_use synthesised correlations, heartbeat orphan promotions,
1805
+ // cross-turn carry-over). Mirror those into fleet so the invariant
1806
+ // that `fleet` is a superset-or-equal of `subAgents` (by key) holds.
1807
+ function reconcileFleetWithSubAgents(cs: PerChatState): void {
1808
+ for (const [agentId, sa] of cs.state.subAgents) {
1809
+ if (!cs.fleet.has(agentId)) {
1810
+ // P0 follow-up (#662 reviewer items 1+2): preserve `startedAt`
1811
+ // from the legacy SubAgentState when present so the synthesised
1812
+ // carry-over entry doesn't reset the clock and immediately mask
1813
+ // a stuck condition. `originatingTurnKey` has no legacy
1814
+ // counterpart — fall back to the current/active turn.
1815
+ const startedAt = sa.startedAt > 0 ? sa.startedAt : now()
1816
+ cs.fleet.set(
1817
+ agentId,
1818
+ createFleetMember({
1819
+ agentId,
1820
+ role: sa.description ?? 'agent',
1821
+ startedAt,
1822
+ originatingTurnKey: currentTurnKey ?? cs.turnKey,
1823
+ }),
1824
+ )
1825
+ }
1826
+ }
1827
+ // Drop fleet entries the legacy map no longer tracks (rare — only
1828
+ // when a parent tool_result correlation prunes a sub-agent before
1829
+ // any sub_agent_turn_end arrived).
1830
+ for (const agentId of [...cs.fleet.keys()]) {
1831
+ if (!cs.state.subAgents.has(agentId)) {
1832
+ cs.fleet.delete(agentId)
1833
+ }
1834
+ }
1835
+ }
1836
+
1837
+ return {
1838
+ ingest(event, chatIdMaybe, threadId) {
1839
+ // Throttled inline TTL sweep — see `maybeEvict` for rationale.
1840
+ maybeEvict(now())
1841
+ // An `enqueue` event carries its own chatId (extracted from the XML
1842
+ // channel wrapper). Everything else falls back to the caller-provided
1843
+ // chatIdMaybe, which the session-tail supervisor tracks.
1844
+ let chatId = chatIdMaybe
1845
+ if (event.kind === 'enqueue') {
1846
+ chatId = event.chatId
1847
+ threadId = event.threadId ?? undefined
1848
+
1849
+ // Skip enqueue events with no chatId. These come from non-channel
1850
+ // turns (e.g. terminal input) forwarded by the bridge's session-tail.
1851
+ // Creating a card with chatId=null spams "chat null is not allowlisted"
1852
+ // on every emit attempt and produces a ghost card that occupies
1853
+ // currentTurnKey, potentially interfering with real card routing.
1854
+ if (chatId == null || chatId === '') return
1855
+
1856
+ // A session-tail enqueue (isSync not set) arriving while a card is
1857
+ // already live for the same chat+thread is an echo of a sync
1858
+ // startTurn() call — drop it. startTurn owns the turn lifecycle for
1859
+ // non-steering messages; if we fell through we'd orphan the pinned
1860
+ // card and spawn a second "Working…" message that takes over all
1861
+ // the updates while the original stays stuck at 0ms.
1862
+ if (!event.isSync) {
1863
+ // Guard 0 (messageId dedup): if we've already seen an enqueue
1864
+ // with this messageId for this chat+thread, drop it. Session
1865
+ // restarts can produce multiple echoes of the same user message
1866
+ // (each restart re-processes the queue, writing a fresh enqueue
1867
+ // to a new JSONL). Guard 2 only catches the first; this guard
1868
+ // catches all subsequent duplicates by messageId.
1869
+ if (event.messageId != null) {
1870
+ const base = baseKey(chatId, threadId ?? undefined)
1871
+ const dedupKey = `${base}:${event.messageId}`
1872
+ const seenAt = seenEnqueueMsgIds.get(dedupKey)
1873
+ if (seenAt != null && now() - seenAt < 60_000) {
1874
+ return
1875
+ }
1876
+ seenEnqueueMsgIds.set(dedupKey, now())
1877
+ }
1878
+
1879
+ // Guard 1: active card exists for this chat+thread.
1880
+ // P2 of #662 / fixes #64 — except when the active card is a
1881
+ // background-carry state (turn ended, fleet still has live bg
1882
+ // members). The new enqueue is a real follow-up turn that must
1883
+ // create a fresh PerChatState; the bg carry stays alive in
1884
+ // parallel under its own turnKey.
1885
+ if (currentTurnKey != null) {
1886
+ const existing = chats.get(currentTurnKey)
1887
+ if (
1888
+ existing != null &&
1889
+ existing.chatId === chatId &&
1890
+ existing.threadId === threadId &&
1891
+ !hasLiveBackground(existing.fleet)
1892
+ ) {
1893
+ return
1894
+ }
1895
+ }
1896
+ // Guard 2: this enqueue is the session-tail echo of a sync
1897
+ // startTurn() call. Drop it and consume the marker. Without
1898
+ // this, fast turns that complete before the echo arrives would
1899
+ // pass Guard 1 (currentTurnKey already null) and spawn an
1900
+ // orphan card.
1901
+ const base = baseKey(chatId, threadId ?? undefined)
1902
+ const syncStart = pendingSyncEchoes.get(base)
1903
+ if (syncStart != null && now() - syncStart < 30_000) {
1904
+ pendingSyncEchoes.delete(base)
1905
+ return
1906
+ }
1907
+ }
1908
+
1909
+ // Allocate a new turn slot FIRST — this increments baseTurnSeqs so
1910
+ // that taskNumFor() on the old card will see the correct total (N+1)
1911
+ // when we render its final "done" frame below.
1912
+ const slot = allocateTurnSlot(chatId, threadId)
1913
+
1914
+ // If an existing card is still active for this chat, force-close it
1915
+ // so it gets properly done/unpinned before the new card takes over.
1916
+ // Also close ghost cards (chatId is null/empty) — these come from
1917
+ // non-channel session-tail events that slipped through before the
1918
+ // null guard above was added, or from a race.
1919
+ //
1920
+ // Route through closeZombie so any still-running sub-agents on
1921
+ // the old card are explicitly marked done (abandoned) and the
1922
+ // shared completion sequence fires exactly once. This is the
1923
+ // correct path for "new turn replacing old" even when the old
1924
+ // turn was in pendingCompletion state (background sub-agent
1925
+ // hadn't reported done yet).
1926
+ // P2 of #662 / fixes #64 — if the in-flight turn has live
1927
+ // background fleet members, do NOT closeZombie it. Detach it
1928
+ // from currentTurnKey instead so the new turn takes over the
1929
+ // active slot while turn A's PerChatState stays alive in `chats`
1930
+ // to receive cross-turn sub_agent_* events. Mark it with
1931
+ // backgroundCarry so completion fires once the last live bg
1932
+ // member reaches terminal status.
1933
+ let bgCarryActive = false
1934
+ if (currentTurnKey != null) {
1935
+ const existing = chats.get(currentTurnKey)
1936
+ if (existing != null && (existing.chatId === chatId || !existing.chatId)) {
1937
+ if (hasLiveBackground(existing.fleet)) {
1938
+ existing.backgroundCarry = true
1939
+ bgCarryActive = true
1940
+ process.stderr.write(
1941
+ `telegram gateway: progress-card: bg-carry preserving turnKey=${existing.turnKey} (live background fleet members) on new enqueue\n`,
1942
+ )
1943
+ } else {
1944
+ closeZombie(existing)
1945
+ }
1946
+ }
1947
+ }
1948
+ currentChatId = chatId
1949
+ currentThreadId = threadId
1950
+ currentTurnKey = slot.turnKey
1951
+
1952
+ // Issue #334: seed the new turn's subAgents from any still-running
1953
+ // background sub-agents dispatched in a prior turn for this chat.
1954
+ const initialTurnState = reduce(initialState(), event, now())
1955
+ const cBaseKey = baseKey(chatId, threadId)
1956
+ // P2 of #662 — when bg carry is active, the originating PerChatState
1957
+ // still owns the running sub-agents. Don't re-seed turn B with them
1958
+ // (would duplicate the fleet entries and cause turn B to defer its
1959
+ // own completion waiting for sub-agents that don't belong to it).
1960
+ const carriedOver = bgCarryActive ? undefined : chatRunningSubagents.get(cBaseKey)
1961
+ const seededState: ProgressCardState = (carriedOver != null && carriedOver.size > 0)
1962
+ ? {
1963
+ ...initialTurnState,
1964
+ subAgents: new Map<string, SubAgentState>(
1965
+ [...carriedOver.entries()].map(([id, sa]) => [id, { ...sa }]),
1966
+ ),
1967
+ }
1968
+ : initialTurnState
1969
+
1970
+ const chatState: PerChatState = {
1971
+ chatId,
1972
+ threadId,
1973
+ turnKey: slot.turnKey,
1974
+ taskIndex: slot.index,
1975
+ taskTotal: slot.total,
1976
+ state: seededState,
1977
+ lastEmittedAt: 0,
1978
+ lastEmittedHtml: null,
1979
+ pendingTimer: null,
1980
+ isFirstEmit: true,
1981
+ deferredFirstEmitTimer: null,
1982
+ timePromoteTimer: null,
1983
+ lastEventAt: now(),
1984
+ pendingCompletion: false,
1985
+ completionFired: false,
1986
+ cardTakenOver: false,
1987
+ apiFailures: { consecutive4xx: 0, lastError: null, terminal: false },
1988
+ replyToolCalled: false,
1989
+ outboundDeliveredCount: 0,
1990
+ wasAutonomous: false,
1991
+ silentEndSuppressed: false,
1992
+ silentEndPrepared: false,
1993
+ parentTurnEndAt: null,
1994
+ parentDoneRendered: false,
1995
+ promotedSpawnIds: new Set(),
1996
+ fleet: new Map<string, FleetMember>(),
1997
+ backgroundParentToolUseIds: new Set<string>(),
1998
+ backgroundCarry: false,
1999
+ }
2000
+ chats.set(slot.turnKey, chatState)
2001
+ if (event.isSync) {
2002
+ pendingSyncEchoes.set(baseKey(chatId, threadId), now())
2003
+ }
2004
+ startHeartbeatIfNeeded()
2005
+ // #334 cross-turn carry-over: a fresh PerChatState seeded with
2006
+ // running sub-agents from a prior turn already has visible work
2007
+ // to surface. Skip suppression and emit immediately. The diff-
2008
+ // based promote in the reducer block above misses this case
2009
+ // because the carried-over sub-agents were copied during
2010
+ // `initialState()` reduction — there is no prev→next transition
2011
+ // for it to detect.
2012
+ //
2013
+ // Defensive: post-#401, `closeZombie` syncs the chat-scoped
2014
+ // registry on every parent-replacement enqueue, so carriedOver
2015
+ // is empty in the common path. Keeping the hook means future
2016
+ // regressions in the sync path (or a code path that bypasses
2017
+ // closeZombie) still produce a visible card instead of a
2018
+ // silently-suppressed turn.
2019
+ if (promoteOnSubAgent && carriedOver != null && carriedOver.size > 0) {
2020
+ promoteFirstEmit(chatState, 'carried_over_subagents')
2021
+ } else {
2022
+ flush(chatState, /*forceDone*/ false)
2023
+ }
2024
+ return
2025
+ } else if (chatId == null) {
2026
+ // Non-enqueue event with no explicit chat: fall back to the
2027
+ // most recently enqueued chat for this driver.
2028
+ chatId = currentChatId
2029
+ threadId = threadId ?? currentThreadId
2030
+ }
2031
+ if (chatId == null) return
2032
+
2033
+ // P2 of #662 / fixes #64 — sub_agent_* events for an agentId whose
2034
+ // fleet member lives on a non-current PerChatState (background
2035
+ // carry) must route to the originating turn, not currentTurnKey.
2036
+ // Without this, a background sub-agent that emits tool_use after
2037
+ // its parent turn ended (and a new turn took over) would either
2038
+ // be dropped or update the wrong turn's card.
2039
+ let chatState: PerChatState | undefined
2040
+ if (
2041
+ (event.kind === 'sub_agent_tool_use' ||
2042
+ event.kind === 'sub_agent_tool_result' ||
2043
+ event.kind === 'sub_agent_turn_end' ||
2044
+ event.kind === 'sub_agent_capped' ||
2045
+ event.kind === 'sub_agent_started') &&
2046
+ 'agentId' in event
2047
+ ) {
2048
+ const agentId = (event as { agentId: string }).agentId
2049
+ for (const candidate of chats.values()) {
2050
+ if (candidate.chatId !== chatId) continue
2051
+ if (candidate.fleet.has(agentId)) {
2052
+ chatState = candidate
2053
+ break
2054
+ }
2055
+ }
2056
+ }
2057
+
2058
+ // Route to the current active turn key. Drop late events for a turn
2059
+ // that already ended — without this, a stray tool_result after turn_end
2060
+ // would resurrect the card. currentTurnKey is cleared on turn_end.
2061
+ if (chatState == null) {
2062
+ const k = currentTurnKey
2063
+ if (k == null) {
2064
+ if (event.kind.startsWith('sub_agent_')) {
2065
+ process.stderr.write(
2066
+ `telegram gateway: progress-card: late-sub-agent-event-dropped kind=${event.kind} agentId=${'agentId' in event ? (event as { agentId: string }).agentId : 'n/a'} chatId=${chatId}\n`,
2067
+ )
2068
+ }
2069
+ return
2070
+ }
2071
+ chatState = chats.get(k)
2072
+ if (chatState == null) return
2073
+ }
2074
+
2075
+ const prev = chatState.state
2076
+ chatState.state = reduce(chatState.state, event, now())
2077
+ chatState.lastEventAt = now()
2078
+
2079
+ // P0 of #662 — shadow fleet map. Mirror sub_agent_* events into
2080
+ // the parallel FleetMember map using the pure transitions from
2081
+ // fleet-state.ts. Legacy state.subAgents is unchanged; P1/P2/P3
2082
+ // build on `fleet` without touching the existing renderer.
2083
+ updateFleetForEvent(chatState, event)
2084
+ // Reconcile shadow with legacy map: any sub-agent that appears in
2085
+ // state.subAgents (e.g. via parent-tool-result correlation, the
2086
+ // heartbeat orphan-promotion path, or carry-over) but is missing
2087
+ // from fleet gets a synthetic FleetMember so the cardinality
2088
+ // invariant holds. Conversely, drop fleet entries that legacy
2089
+ // dropped (these are already terminal in the watcher's view).
2090
+ reconcileFleetWithSubAgents(chatState)
2091
+ const stageChanged = chatState.state.stage !== prev.stage
2092
+ const visibleChanged = visibleDiff(prev, chatState.state)
2093
+
2094
+ // Issue #334/#399: mirror sub-agent state changes into the chat-scoped
2095
+ // running-sub-agent registry so new turns can seed from it.
2096
+ // We diff prev.subAgents vs chatState.state.subAgents to catch all
2097
+ // mutation paths: sub_agent_started, sub_agent_turn_end, and parent
2098
+ // tool_result (which can finalize a sub-agent via parentToolUseId).
2099
+ // Factored into syncChatRunningSubagents (issue #399) so closeZombie
2100
+ // and the heartbeat's cold-jsonl-synth path can call the same logic.
2101
+ // Returns `newRunningAppeared` so the caller can promote the card
2102
+ // out of initial-delay suppression on a fresh sub-agent transition.
2103
+ const { newRunningAppeared: newRunningSubAgentAppeared } = syncChatRunningSubagents(
2104
+ prev,
2105
+ chatState.state,
2106
+ baseKey(chatState.chatId, chatState.threadId),
2107
+ chatRunningSubagents,
2108
+ )
2109
+
2110
+ // Promote the card out of initial-delay suppression as soon as a
2111
+ // sub-agent transitions to running. Long-running sub-agent dispatches
2112
+ // are exactly the case where the user wants to see what's happening
2113
+ // — waiting the full `initialDelayMs` before showing the card means
2114
+ // 30s of staring at a frozen draft bubble. Diff-based detection
2115
+ // (rather than gating on a specific event kind) catches every path
2116
+ // that reaches `running`: real `sub_agent_started`, heartbeat orphan
2117
+ // promotion, and parent-tool-result correlation.
2118
+ if (
2119
+ newRunningSubAgentAppeared
2120
+ && promoteOnSubAgent
2121
+ && chatState.isFirstEmit
2122
+ && chatState.deferredFirstEmitTimer !== DELAY_ELAPSED
2123
+ && !chatState.apiFailures.terminal
2124
+ ) {
2125
+ promoteFirstEmit(chatState, 'sub_agent_started')
2126
+ }
2127
+
2128
+ // #478 / #553 PR 4: promote the card when the agent has issued
2129
+ // enough parent-side tool calls during the suppression window.
2130
+ // Disabled by default in v2 (promoteOnParentToolCount=0 / Infinity)
2131
+ // — under the v2 contract tools alone never trigger the card. The
2132
+ // check is preserved as a config knob for callers that want the
2133
+ // old behaviour, but values of 0 or non-finite (Infinity) are
2134
+ // treated as "never promote on tool count".
2135
+ if (
2136
+ promoteOnParentToolCount > 0
2137
+ && Number.isFinite(promoteOnParentToolCount)
2138
+ && chatState.isFirstEmit
2139
+ && chatState.deferredFirstEmitTimer !== DELAY_ELAPSED
2140
+ && !chatState.apiFailures.terminal
2141
+ && chatState.state.items.length >= promoteOnParentToolCount
2142
+ ) {
2143
+ promoteFirstEmit(chatState, `parent_tool_count_${chatState.state.items.length}`)
2144
+ }
2145
+
2146
+ // F3 fix (#553): schedule the time-based promotion timer on
2147
+ // every ingest event (idempotent — only the first call schedules;
2148
+ // subsequent calls are no-ops). Without this, a long single-tool
2149
+ // turn never crossed parent_tool_count or sub_agent thresholds
2150
+ // and the card stayed suppressed until initialDelayMs (30s).
2151
+ ensureTimePromoteScheduled(chatState)
2152
+
2153
+ // Issue #132: track whether the agent has called `reply` or
2154
+ // `stream_reply` at least once this turn so the renderer can
2155
+ // distinguish "Done with reply" from "Done without reply" at
2156
+ // turn_end. Tool-use intent is the right granularity here — if
2157
+ // the call landed but failed mid-API, the model sees the error
2158
+ // in tool_result and may retry, which still flips this true.
2159
+ // Only false → true; never reset mid-turn.
2160
+ if (
2161
+ !chatState.replyToolCalled
2162
+ && event.kind === 'tool_use'
2163
+ && isTelegramReplyTool(event.toolName)
2164
+ ) {
2165
+ chatState.replyToolCalled = true
2166
+ }
2167
+
2168
+ // Issue #81 diagnostic: when a 'text' event lands, did the reducer
2169
+ // recognize it as a narrative step? If narratives.length didn't grow,
2170
+ // the card's "human-readable preamble" path can't render and the
2171
+ // tool-count fallback wins. The log lets us correlate "user typed
2172
+ // status?" telemetry with the missing narrative path.
2173
+ //
2174
+ // Gated behind PROGRESS_CARD_DIAG=1 because this fires on every
2175
+ // assistant text event — a long verbose turn could produce dozens
2176
+ // of lines per minute. The render-branch and prose-recovery diags
2177
+ // (~2x and ~1x per turn respectively) stay always-on. Flip the env
2178
+ // var on a one-off agent restart to capture data, then turn it off.
2179
+ if (event.kind === 'text' && process.env.PROGRESS_CARD_DIAG === '1') {
2180
+ const before = prev.narratives.length
2181
+ const after = chatState.state.narratives.length
2182
+ const last = chatState.state.narratives[after - 1]
2183
+ const preview = last?.text ? last.text.slice(0, 60).replace(/\n/g, ' ') : ''
2184
+ const took = before === after ? 'discarded' : 'captured'
2185
+ process.stderr.write(
2186
+ `progress-card.diag: text-event ${took} chatId=${chatState.chatId} turnKey=${chatState.turnKey} ` +
2187
+ `narratives_before=${before} narratives_after=${after} text_len=${event.text.length} preview=${JSON.stringify(preview)}\n`,
2188
+ )
2189
+ }
2190
+
2191
+ // Cancel any pending coalesce timer — we'll either fire now or
2192
+ // reschedule.
2193
+ if (chatState.pendingTimer != null) {
2194
+ clearT(chatState.pendingTimer)
2195
+ chatState.pendingTimer = null
2196
+ }
2197
+
2198
+ // Fire immediately on terminal state — no coalesce delay when the
2199
+ // turn finishes. The user sees the final card the instant turn_end
2200
+ // lands. (Note: `enqueue` events are handled upstream by startTurn,
2201
+ // not ingested here, so the prior `event.kind === 'enqueue'` check
2202
+ // was dead code per the SessionEvent union.)
2203
+ if (event.kind === 'turn_end' || stageChanged) {
2204
+ if (event.kind === 'turn_end') {
2205
+ process.stderr.write(`telegram gateway: progress-card: turn_end flush chatId=${chatState.chatId} threadId=${chatState.threadId ?? '-'} turnKey=${chatState.turnKey}\n`)
2206
+ // Only fire silent-end prep when we're actually about to complete —
2207
+ // i.e. no sub-agents still running. The sub-agent defer path
2208
+ // returns below and prep will run later via maybeCompleteDeferredTurn.
2209
+ if (!hasAnyRunningSubAgent(chatState.state)) {
2210
+ prepareSilentEndSuppression(chatState)
2211
+ }
2212
+ }
2213
+ if (event.kind === 'turn_end' && hasAnyRunningSubAgent(chatState.state)) {
2214
+ // Gap 8: parent turn_end with sub-agents still running — render
2215
+ // done=true immediately (card shows ✅ Done) then defer unpin.
2216
+ // Set parentTurnEndAt BEFORE flush so flush()'s parentDone
2217
+ // computation picks it up on this very call.
2218
+ chatState.parentTurnEndAt = now()
2219
+ }
2220
+ flush(chatState, /*forceDone*/ event.kind === 'turn_end')
2221
+ if (event.kind === 'turn_end') {
2222
+ // Gate on BOTH the legacy subAgents map AND the fleet's background
2223
+ // members. Background sub-agents (dispatched with run_in_background:true)
2224
+ // are tagged in cs.fleet with status:'background' by updateFleetForEvent
2225
+ // at sub_agent_started time. If the parent turn_end fires before the
2226
+ // background sub-agent has produced any events, state.subAgents may
2227
+ // still be empty for that agent — hasAnyRunningSubAgent alone would
2228
+ // miss it and close the card prematurely. Fixes #713 and #709.
2229
+ if (hasAnyRunningSubAgent(chatState.state) || hasLiveBackground(chatState.fleet)) {
2230
+ // Parent turn ended but at least one sub-agent is still running.
2231
+ // Keep the card alive so the sub-agent work stays visible; defer
2232
+ // completion until the last running sub-agent reports done via
2233
+ // its own sub_agent_turn_end (or the parent Agent tool_result).
2234
+ // Closes #87: orphans from `Agent({run_in_background:true})` now
2235
+ // gate the defer too, so background dispatches stay visible past
2236
+ // parent turn-end. Safety nets: `closeZombie` on new enqueue +
2237
+ // the `maxIdleMs` heartbeat ceiling bound the bad case (orphan
2238
+ // never reports done).
2239
+ chatState.pendingCompletion = true
2240
+ const correlated: string[] = []
2241
+ const orphans: string[] = []
2242
+ const background: string[] = []
2243
+ for (const [k, sa] of chatState.state.subAgents) {
2244
+ if (sa.state === 'running') {
2245
+ if (sa.parentToolUseId != null) correlated.push(k)
2246
+ else orphans.push(k)
2247
+ }
2248
+ }
2249
+ for (const [k, m] of chatState.fleet) {
2250
+ if (m.status === 'background' && m.terminalAt == null) background.push(k)
2251
+ }
2252
+ process.stderr.write(`telegram gateway: progress-card: turn_end deferred turnKey=${chatState.turnKey} reason=in-flight-sub-agents correlated=${correlated.length} orphans=${orphans.length} background=${background.length} correlatedAgentIds=[${correlated.join(',')}] orphanAgentIds=[${orphans.join(',')}] backgroundAgentIds=[${background.join(',')}]\n`)
2253
+ return
2254
+ }
2255
+ closePerChat(chatState, 'turn-end')
2256
+ }
2257
+ return
2258
+ }
2259
+
2260
+ // Post-reduce deferred-completion check: if this event transitioned
2261
+ // the last in-flight sub-agent to done (sub_agent_turn_end, parent
2262
+ // Agent tool_result), fire completion now.
2263
+ maybeCompleteDeferredTurn(chatState)
2264
+
2265
+ // If this event didn't change anything user-visible (e.g. a
2266
+ // `thinking` flag toggle that isn't rendered), don't schedule a
2267
+ // flush. Prevents emit noise from events that only mutate internal
2268
+ // state, and avoids spurious edits driven by ticking elapsed time
2269
+ // in the header.
2270
+ if (!visibleChanged) return
2271
+
2272
+ // Otherwise: respect the min-interval floor. If we just emitted,
2273
+ // defer to at least minIntervalMs after the last emit. Also always
2274
+ // coalesce bursts — even a burst that runs past minIntervalMs gets
2275
+ // at most one flush per coalesce window.
2276
+ //
2277
+ // Multi-agent rate-limit: if the chat has emitted >threshold edits
2278
+ // in the last 60s, expand the coalesce window to
2279
+ // editBudgetCoalesceMs (default 3s) so the Telegram 20/min cap is
2280
+ // never exceeded by sub-agent bursts.
2281
+ const sinceLast = now() - chatState.lastEmittedAt
2282
+ const effectiveCoalesce = isBudgetHot(chatState.turnKey) ? editBudgetCoalesceMs : coalesceMs
2283
+ const delay = Math.max(effectiveCoalesce, minIntervalMs - sinceLast, 0)
2284
+ const capturedTurnKey = chatState.turnKey
2285
+ chatState.pendingTimer = setT(() => {
2286
+ // Defensive: if the chat was deleted between schedule and fire
2287
+ // (e.g. a turn_end racing with an async boundary added later),
2288
+ // don't resurrect it with a stale flush.
2289
+ if (!chats.has(capturedTurnKey)) return
2290
+ chatState!.pendingTimer = null
2291
+ flush(chatState!, /*forceDone*/ false)
2292
+ }, delay)
2293
+ },
2294
+
2295
+ startTurn({ chatId, threadId, userText, replyToMessageId }) {
2296
+ // Synthesize an enqueue event and run it through the normal ingest
2297
+ // path. This guarantees we share all the flush/cadence/teardown
2298
+ // semantics with session-tail-driven enqueues.
2299
+ //
2300
+ // Each call creates a NEW card — if a card is already active for
2301
+ // this chat it is force-closed first so it gets properly done/unpinned.
2302
+ const raw = `<channel source="switchroom-telegram" chat_id="${chatId}"${threadId != null ? ` message_thread_id="${threadId}"` : ''}>${userText}</channel>`
2303
+ this.ingest(
2304
+ {
2305
+ kind: 'enqueue',
2306
+ chatId,
2307
+ messageId: null,
2308
+ threadId: threadId ?? null,
2309
+ rawContent: raw,
2310
+ isSync: true,
2311
+ },
2312
+ chatId,
2313
+ threadId,
2314
+ )
2315
+ // Stash the source message_id and autonomous flag on the newly-created
2316
+ // PerChatState so flush() can use them. Do this AFTER ingest() so the
2317
+ // new PerChatState entry is in chats.
2318
+ if (currentTurnKey != null) {
2319
+ const cs = chats.get(currentTurnKey)
2320
+ if (cs != null && cs.chatId === chatId) {
2321
+ if (replyToMessageId != null) {
2322
+ cs.replyToMessageId = replyToMessageId
2323
+ }
2324
+ // Issue #259: autonomous wakeup turns (ScheduleWakeup / CronCreate
2325
+ // sentinel) never produce a user-visible reply by design. Suppress
2326
+ // the "🙊 Ended without reply" warning for these turns.
2327
+ if (userText.startsWith('<<autonomous-loop')) {
2328
+ cs.wasAutonomous = true
2329
+ }
2330
+ }
2331
+ }
2332
+ },
2333
+
2334
+ forceCompleteTurn({ chatId, threadId }) {
2335
+ // Find active chatState for this chat:thread. Prefer the one pointed
2336
+ // at by currentTurnKey; fall back to any state matching the chat key.
2337
+ let target: PerChatState | undefined
2338
+ if (currentTurnKey != null) {
2339
+ const cs = chats.get(currentTurnKey)
2340
+ if (cs != null && cs.chatId === chatId && cs.threadId === threadId) {
2341
+ target = cs
2342
+ }
2343
+ }
2344
+ if (target == null) {
2345
+ for (const cs of chats.values()) {
2346
+ if (cs.chatId === chatId && cs.threadId === threadId) {
2347
+ target = cs
2348
+ break
2349
+ }
2350
+ }
2351
+ }
2352
+ if (target == null) {
2353
+ // No active card for this chat+thread — either the turn already
2354
+ // completed via another path, or no turn is in flight. Idempotent
2355
+ // no-op.
2356
+ return
2357
+ }
2358
+ // Simulate the normal turn_end path so in-flight sub-agents keep
2359
+ // their card surface. If sub-agents are running, this sets
2360
+ // pendingCompletion and defers; if not, it closes immediately.
2361
+ // stream_reply(done=true) signals "user's answer landed", not
2362
+ // "all background work finished" — we must not abandon still-
2363
+ // running sub-agents just because the final reply was sent.
2364
+ if (target.completionFired) return
2365
+ process.stderr.write(`telegram gateway: progress-card: forceCompleteTurn turnKey=${target.turnKey} (external completion signal, e.g. stream_reply done=true)\n`)
2366
+ const durationMs = Math.max(0, now() - target.state.turnStartedAt)
2367
+ beginTurnEnd(target, durationMs)
2368
+ target.lastEventAt = now()
2369
+ flush(target, /*forceDone*/ true)
2370
+ if (hasAnyRunningSubAgent(target.state)) {
2371
+ target.pendingCompletion = true
2372
+ const correlated: string[] = []
2373
+ const orphans: string[] = []
2374
+ for (const [k, sa] of target.state.subAgents) {
2375
+ if (sa.state === 'running') {
2376
+ if (sa.parentToolUseId != null) correlated.push(k)
2377
+ else orphans.push(k)
2378
+ }
2379
+ }
2380
+ process.stderr.write(`telegram gateway: progress-card: forceCompleteTurn deferred turnKey=${target.turnKey} reason=in-flight-sub-agents correlated=${correlated.length} orphans=${orphans.length} correlatedAgentIds=[${correlated.join(',')}] orphanAgentIds=[${orphans.join(',')}]\n`)
2381
+ return
2382
+ }
2383
+ closePerChat(target, 'turn-end')
2384
+ },
2385
+
2386
+ takeOverCard({ chatId, threadId }) {
2387
+ // Mirror the (chatId, threadId) lookup used by forceCompleteTurn
2388
+ // — prefer the currentTurnKey-pinned target so concurrent fresh
2389
+ // turns can't get clobbered.
2390
+ let target: PerChatState | undefined
2391
+ if (currentTurnKey != null) {
2392
+ const cs = chats.get(currentTurnKey)
2393
+ if (cs != null && cs.chatId === chatId && cs.threadId === threadId) {
2394
+ target = cs
2395
+ }
2396
+ }
2397
+ if (target == null) {
2398
+ for (const cs of chats.values()) {
2399
+ if (cs.chatId === chatId && cs.threadId === threadId) {
2400
+ target = cs
2401
+ break
2402
+ }
2403
+ }
2404
+ }
2405
+ if (target == null) return { wasEmitted: false, turnKey: null }
2406
+
2407
+ // Cancel any pending deferred-first-emit timer so no card emits
2408
+ // late, AFTER the external owner takes over. If the timer has
2409
+ // already fired (DELAY_ELAPSED sentinel), nothing to clear.
2410
+ if (target.deferredFirstEmitTimer != null && target.deferredFirstEmitTimer !== DELAY_ELAPSED) {
2411
+ clearT(target.deferredFirstEmitTimer)
2412
+ target.deferredFirstEmitTimer = null
2413
+ }
2414
+ // The card has been emitted iff the deferred-emit timer fired
2415
+ // (driver's own indicator) or `isFirstEmit === false` (an emit
2416
+ // path other than the deferred one already ran).
2417
+ const wasEmitted =
2418
+ target.deferredFirstEmitTimer === DELAY_ELAPSED || !target.isFirstEmit
2419
+
2420
+ target.cardTakenOver = true
2421
+ target.completionFired = true
2422
+
2423
+ process.stderr.write(
2424
+ `telegram gateway: progress-card: takeOverCard turnKey=${target.turnKey} wasEmitted=${wasEmitted}\n`,
2425
+ )
2426
+ return { wasEmitted, turnKey: target.turnKey }
2427
+ },
2428
+
2429
+ /**
2430
+ * P2 of #662 — debug/test hook returning every live PerChatState's
2431
+ * fleet keyed by turnKey. Used by cross-turn background tests to
2432
+ * verify routing landed on the originating turn rather than the
2433
+ * currently-active one. Not part of the production driver contract.
2434
+ */
2435
+ peekAllFleets() {
2436
+ const out: Array<{ turnKey: string; chatId: string | null; fleet: Map<string, FleetMember> }> = []
2437
+ for (const cs of chats.values()) {
2438
+ out.push({ turnKey: cs.turnKey, chatId: cs.chatId, fleet: cs.fleet })
2439
+ }
2440
+ return out
2441
+ },
2442
+
2443
+ peekFleet(chatId, threadId) {
2444
+ if (currentTurnKey != null) {
2445
+ const cs = chats.get(currentTurnKey)
2446
+ if (cs != null && cs.chatId === chatId && cs.threadId === threadId) {
2447
+ return cs.fleet
2448
+ }
2449
+ }
2450
+ for (const cs of chats.values()) {
2451
+ if (cs.chatId === chatId && cs.threadId === threadId) return cs.fleet
2452
+ }
2453
+ return undefined
2454
+ },
2455
+
2456
+ peek(chatId, threadId) {
2457
+ // Return the current active turn state for this chat:thread.
2458
+ if (currentTurnKey != null) {
2459
+ const cs = chats.get(currentTurnKey)
2460
+ if (cs != null && cs.chatId === chatId && cs.threadId === threadId) {
2461
+ return cs.state
2462
+ }
2463
+ }
2464
+ // Fallback: find any active card for this chatId (threadId match optional).
2465
+ for (const cs of chats.values()) {
2466
+ if (cs.chatId === chatId && cs.threadId === threadId) return cs.state
2467
+ }
2468
+ return undefined
2469
+ },
2470
+
2471
+ hasActiveCard(chatId, threadId) {
2472
+ for (const cs of chats.values()) {
2473
+ if (
2474
+ cs.chatId === chatId
2475
+ && cs.threadId === threadId
2476
+ && !cs.completionFired
2477
+ ) {
2478
+ return true
2479
+ }
2480
+ }
2481
+ return false
2482
+ },
2483
+
2484
+ recordSubAgentNarrative({ chatId, threadId, agentId, text }) {
2485
+ // Locate the active card for (chatId, threadId). Mirrors
2486
+ // hasActiveCard's iteration since `chats` is keyed by turnKey.
2487
+ let cs: PerChatState | null = null
2488
+ for (const candidate of chats.values()) {
2489
+ if (
2490
+ candidate.chatId === chatId
2491
+ && candidate.threadId === threadId
2492
+ && !candidate.completionFired
2493
+ ) {
2494
+ cs = candidate
2495
+ break
2496
+ }
2497
+ }
2498
+ if (cs == null) {
2499
+ return { ok: false, reason: 'no_active_card' }
2500
+ }
2501
+ // Sub-agents are keyed by jsonl_agent_id in the reducer state.
2502
+ if (!cs.state.subAgents.has(agentId)) {
2503
+ return { ok: false, reason: 'unknown_agent' }
2504
+ }
2505
+ // Dispatch through the same reduce path used by ingest().
2506
+ cs.state = reduce(
2507
+ cs.state,
2508
+ { kind: 'sub_agent_narrative', agentId, text },
2509
+ now(),
2510
+ )
2511
+ // Force re-render even though milestoneVersion didn't bump.
2512
+ flush(cs, false)
2513
+ return { ok: true }
2514
+ },
2515
+
2516
+ reportApiFailure(turnKey, failure) {
2517
+ const cs = chats.get(turnKey)
2518
+ if (cs == null) return // turn already completed — ignore
2519
+ if (cs.apiFailures.terminal) return // already terminal — no-op
2520
+
2521
+ if (failure.kind === 'benign') {
2522
+ // "message is not modified" — not a real failure; don't touch counter.
2523
+ return
2524
+ }
2525
+ if (failure.kind === 'transient') {
2526
+ // Network/5xx — retryable by the outer layer; don't escalate.
2527
+ process.stderr.write(
2528
+ `telegram gateway: progress-card: transient API error turnKey=${turnKey} code=${failure.code} (${failure.description}) — will retry\n`,
2529
+ )
2530
+ return
2531
+ }
2532
+
2533
+ // permanent_4xx
2534
+ cs.apiFailures.consecutive4xx++
2535
+ cs.apiFailures.lastError = {
2536
+ code: failure.code,
2537
+ description: failure.description,
2538
+ timestamp: now(),
2539
+ }
2540
+
2541
+ if (maxConsecutive4xx > 0 && cs.apiFailures.consecutive4xx >= maxConsecutive4xx) {
2542
+ cs.apiFailures.terminal = true
2543
+ process.stderr.write(
2544
+ `telegram gateway: progress-card: card edit giving 4xx, abandoning locally` +
2545
+ ` (chat=${cs.chatId}, turnKey=${turnKey}, code=${failure.code}, desc="${failure.description}")\n`,
2546
+ )
2547
+ } else {
2548
+ process.stderr.write(
2549
+ `telegram gateway: progress-card: card edit 4xx (${cs.apiFailures.consecutive4xx}/${maxConsecutive4xx})` +
2550
+ ` turnKey=${turnKey} code=${failure.code} (${failure.description})\n`,
2551
+ )
2552
+ }
2553
+ },
2554
+
2555
+ reportApiSuccess(turnKey) {
2556
+ const cs = chats.get(turnKey)
2557
+ if (cs == null) return
2558
+ if (cs.apiFailures.consecutive4xx > 0) {
2559
+ cs.apiFailures.consecutive4xx = 0
2560
+ }
2561
+ },
2562
+
2563
+ recordOutboundDelivered(chatId, threadId) {
2564
+ // Issue #137: walk the active chats and find the entry matching the
2565
+ // outbound destination. We can't index by chatId alone — multiple
2566
+ // turns may queue against the same chat — so iterate. The map is
2567
+ // small (one entry per active turn) so the linear scan is fine.
2568
+ for (const cs of chats.values()) {
2569
+ if (cs.chatId === chatId && cs.threadId === threadId) {
2570
+ cs.outboundDeliveredCount += 1
2571
+ return
2572
+ }
2573
+ }
2574
+ // No active card → outbound was likely a system message (boot
2575
+ // banner, restart ack, etc.) and isn't part of any agent turn.
2576
+ // Silent no-op.
2577
+ },
2578
+
2579
+ dispose(opts?: { preservePending?: boolean }) {
2580
+ if (opts?.preservePending === true) {
2581
+ // Selective dispose: preserve chats with pendingCompletion=true so
2582
+ // their heartbeat and deferred-completion timeout continue firing
2583
+ // after a bridge disconnect. This is the fix for the regression
2584
+ // introduced in commit 4c0186d where dispose() wiped all in-flight
2585
+ // card state on every bridge disconnect (stdio-MCP per-call lifecycle).
2586
+ let hasPending = false
2587
+ for (const [turnKey, cs] of chats) {
2588
+ // Always clear coalesce timers — they could emit into a finalized
2589
+ // draft stream and spawn duplicate messages.
2590
+ if (cs.pendingTimer != null) {
2591
+ clearT(cs.pendingTimer)
2592
+ cs.pendingTimer = null
2593
+ }
2594
+ if (cs.deferredFirstEmitTimer != null) {
2595
+ clearT(cs.deferredFirstEmitTimer)
2596
+ cs.deferredFirstEmitTimer = null
2597
+ }
2598
+ if (cs.pendingCompletion) {
2599
+ // Keep this chat alive — it has running background sub-agents
2600
+ // that will continue emitting events and need the heartbeat.
2601
+ hasPending = true
2602
+ } else {
2603
+ // No pending completion — clear this chat (existing behavior).
2604
+ chats.delete(turnKey)
2605
+ }
2606
+ }
2607
+ // Only stop the heartbeat if nothing is pending; if any chat is still
2608
+ // alive, the heartbeat is exactly what drives future re-renders.
2609
+ if (!hasPending) {
2610
+ stopHeartbeat()
2611
+ }
2612
+ // Reset currentChatId/currentTurnKey only if they no longer map to
2613
+ // a surviving pendingCompletion chat.
2614
+ if (currentTurnKey != null && !chats.has(currentTurnKey)) {
2615
+ currentChatId = null
2616
+ currentThreadId = undefined
2617
+ currentTurnKey = null
2618
+ }
2619
+ pendingSyncEchoes.clear()
2620
+ seenEnqueueMsgIds.clear()
2621
+ } else {
2622
+ // Back-compat: wipe everything (original behavior).
2623
+ stopHeartbeat()
2624
+ for (const cs of chats.values()) {
2625
+ if (cs.pendingTimer != null) {
2626
+ clearT(cs.pendingTimer)
2627
+ cs.pendingTimer = null
2628
+ }
2629
+ if (cs.deferredFirstEmitTimer != null) {
2630
+ clearT(cs.deferredFirstEmitTimer)
2631
+ cs.deferredFirstEmitTimer = null
2632
+ }
2633
+ }
2634
+ chats.clear()
2635
+ currentChatId = null
2636
+ currentThreadId = undefined
2637
+ currentTurnKey = null
2638
+ pendingSyncEchoes.clear()
2639
+ seenEnqueueMsgIds.clear()
2640
+ }
2641
+ },
2642
+
2643
+ onSubAgentStall(agentId: string, _idleMs: number, _description: string) {
2644
+ // Option C: watcher detected a stall for this sub-agent. Find which
2645
+ // chat state is tracking it and force an elapsed-tick re-render so the
2646
+ // ⚠️ stall indicator becomes visible even when no events are flowing.
2647
+ for (const cs of chats.values()) {
2648
+ if (!cs.state.subAgents.has(agentId)) continue
2649
+ const sa = cs.state.subAgents.get(agentId)!
2650
+ if (sa.state !== 'running') continue
2651
+ // Leave sa.lastEventAt unchanged — the render computes the ⚠️
2652
+ // stall badge from (now - sa.lastEventAt) >= SUBAGENT_STALL_MS,
2653
+ // so the stale value is exactly what makes the badge appear.
2654
+ // All we need to do here is force a re-render so the user sees it.
2655
+ //
2656
+ // If the card is still suppressed (no first emit yet), the user
2657
+ // has nothing on screen — the stall warning needs to be visible
2658
+ // immediately. Promote out of the initial-delay window before
2659
+ // forcing the heartbeat tick.
2660
+ if (
2661
+ promoteOnSubAgent
2662
+ && cs.isFirstEmit
2663
+ && cs.deferredFirstEmitTimer !== DELAY_ELAPSED
2664
+ && !cs.apiFailures.terminal
2665
+ ) {
2666
+ promoteFirstEmit(cs, 'sub_agent_stall')
2667
+ }
2668
+ // Force the next heartbeat tick to emit by clearing the diff-guard
2669
+ // buckets for this turnKey. Note: this clears the chat-level and
2670
+ // sub-agent-tick buckets — distinct from cs.lastEventAt (chat-level,
2671
+ // drives stuckMs) which is left untouched.
2672
+ lastHeartbeatBucket.delete(cs.turnKey)
2673
+ lastSubAgentTickBucket.delete(cs.turnKey)
2674
+ // If the heartbeat isn't running (it would have been kept alive by
2675
+ // preserve-pending, but check defensively), start it.
2676
+ if (chats.size > 0) startHeartbeatIfNeeded()
2677
+ break
2678
+ }
2679
+ },
2680
+
2681
+ /**
2682
+ * Test-only accessor. Returns the live internal Maps so tests can
2683
+ * assert TTL eviction and outer-base-key cleanup actually drop
2684
+ * entries. Not part of the supported API — naming reflects that.
2685
+ */
2686
+ _debugGetMaps() {
2687
+ return {
2688
+ chats,
2689
+ seenEnqueueMsgIds,
2690
+ pendingSyncEchoes,
2691
+ chatRunningSubagents,
2692
+ baseTurnSeqs,
2693
+ editTimestamps,
2694
+ }
2695
+ },
2696
+ }
2697
+ }