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.
- package/LICENSE +21 -0
- package/README.md +447 -0
- package/bin/autoaccept.exp +81 -0
- package/bin/boot-self-test.sh +149 -0
- package/bin/bridge-watchdog.sh +967 -0
- package/bin/handoff-briefing.sh +206 -0
- package/bin/run-hook.sh +228 -0
- package/bin/switchroom.ts +4 -0
- package/bin/timezone-hook.sh +67 -0
- package/bin/user-profile-refresh-hook.sh +38 -0
- package/bin/workspace-dynamic-hook.sh +142 -0
- package/bin/workspace-stable-hook.sh +57 -0
- package/dist/cli/autoaccept-poll.js +118 -0
- package/dist/cli/switchroom.js +48557 -0
- package/package.json +95 -0
- package/profiles/_base/settings.json.hbs +15 -0
- package/profiles/_base/start.sh.hbs +383 -0
- package/profiles/_shared/telegram-style.md.hbs +140 -0
- package/profiles/coding/CLAUDE.md.hbs +57 -0
- package/profiles/coding/skills/architecture/SKILL.md +70 -0
- package/profiles/coding/skills/code-review/SKILL.md +58 -0
- package/profiles/coding/workspace/SOUL.md.hbs +25 -0
- package/profiles/default/CLAUDE.md +238 -0
- package/profiles/default/CLAUDE.md.hbs +113 -0
- package/profiles/default/workspace/CLAUDE.md.hbs +126 -0
- package/profiles/default/workspace/HEARTBEAT.md.hbs +40 -0
- package/profiles/default/workspace/IDENTITY.md.hbs +32 -0
- package/profiles/default/workspace/MEMORY.md.hbs +29 -0
- package/profiles/default/workspace/SOUL.md.hbs +61 -0
- package/profiles/default/workspace/TOOLS.md.hbs +29 -0
- package/profiles/default/workspace/USER.md.hbs +52 -0
- package/profiles/default/workspace/memory/.gitkeep +0 -0
- package/profiles/executive-assistant/CLAUDE.md.hbs +51 -0
- package/profiles/executive-assistant/skills/daily-briefing/SKILL.md +55 -0
- package/profiles/executive-assistant/skills/meeting-prep/SKILL.md +58 -0
- package/profiles/executive-assistant/workspace/SOUL.md.hbs +25 -0
- package/profiles/health-coach/CLAUDE.md.hbs +45 -0
- package/profiles/health-coach/skills/check-in/SKILL.md +41 -0
- package/profiles/health-coach/skills/weekly-review/SKILL.md +53 -0
- package/profiles/health-coach/workspace/SOUL.md.hbs +25 -0
- package/skills/buildkite-agent-infrastructure/SKILL.md +302 -0
- package/skills/buildkite-agent-infrastructure/agents/openai.yaml +6 -0
- package/skills/buildkite-agent-infrastructure/assets/buildkite-icon-large.png +0 -0
- package/skills/buildkite-agent-infrastructure/assets/buildkite-icon-small.png +0 -0
- package/skills/buildkite-agent-infrastructure/references/audit-logging.md +87 -0
- package/skills/buildkite-agent-infrastructure/references/graphql-mutations.md +690 -0
- package/skills/buildkite-agent-infrastructure/references/instance-shapes.md +38 -0
- package/skills/buildkite-agent-infrastructure/references/pipeline-templates.md +73 -0
- package/skills/buildkite-agent-infrastructure/references/self-hosted-agents.md +137 -0
- package/skills/buildkite-agent-infrastructure/references/sso-saml.md +92 -0
- package/skills/buildkite-agent-runtime/SKILL.md +476 -0
- package/skills/buildkite-agent-runtime/agents/openai.yaml +6 -0
- package/skills/buildkite-agent-runtime/assets/buildkite-icon-large.png +0 -0
- package/skills/buildkite-agent-runtime/assets/buildkite-icon-small.png +0 -0
- package/skills/buildkite-agent-runtime/references/flag-reference.md +417 -0
- package/skills/buildkite-agent-runtime/references/patterns-and-recipes.md +555 -0
- package/skills/buildkite-api/SKILL.md +285 -0
- package/skills/buildkite-api/agents/openai.yaml +6 -0
- package/skills/buildkite-api/assets/buildkite-icon-large.png +0 -0
- package/skills/buildkite-api/assets/buildkite-icon-small.png +0 -0
- package/skills/buildkite-api/references/graphql-reference.md +195 -0
- package/skills/buildkite-api/references/patterns.md +44 -0
- package/skills/buildkite-api/references/webhooks.md +161 -0
- package/skills/buildkite-cli/SKILL.md +379 -0
- package/skills/buildkite-cli/agents/openai.yaml +6 -0
- package/skills/buildkite-cli/assets/buildkite-icon-large.png +0 -0
- package/skills/buildkite-cli/assets/buildkite-icon-small.png +0 -0
- package/skills/buildkite-cli/references/command-reference.md +181 -0
- package/skills/buildkite-migration/SKILL.md +182 -0
- package/skills/buildkite-pipelines/SKILL.md +464 -0
- package/skills/buildkite-pipelines/agents/openai.yaml +6 -0
- package/skills/buildkite-pipelines/assets/buildkite-icon-large.png +0 -0
- package/skills/buildkite-pipelines/assets/buildkite-icon-small.png +0 -0
- package/skills/buildkite-pipelines/examples/basic-pipeline.yml +24 -0
- package/skills/buildkite-pipelines/examples/optimized-pipeline.yml +100 -0
- package/skills/buildkite-pipelines/references/advanced-patterns.md +286 -0
- package/skills/buildkite-pipelines/references/retry-and-error-codes.md +131 -0
- package/skills/buildkite-pipelines/references/step-types-reference.md +225 -0
- package/skills/buildkite-secure-delivery/SKILL.md +168 -0
- package/skills/buildkite-secure-delivery/agents/openai.yaml +6 -0
- package/skills/buildkite-secure-delivery/assets/buildkite-icon-large.png +0 -0
- package/skills/buildkite-secure-delivery/assets/buildkite-icon-small.png +0 -0
- package/skills/buildkite-secure-delivery/references/oidc-cloud-providers.md +83 -0
- package/skills/buildkite-secure-delivery/references/package-publishing.md +100 -0
- package/skills/buildkite-test-engine/SKILL.md +239 -0
- package/skills/buildkite-test-engine/agents/openai.yaml +6 -0
- package/skills/buildkite-test-engine/assets/buildkite-icon-large.png +0 -0
- package/skills/buildkite-test-engine/assets/buildkite-icon-small.png +0 -0
- package/skills/buildkite-test-engine/examples/bktec-splitting.yml +16 -0
- package/skills/buildkite-test-engine/examples/collector-pipeline.yml +11 -0
- package/skills/buildkite-test-engine/references/collectors.md +198 -0
- package/skills/buildkite-test-engine/references/splitting-examples.md +93 -0
- package/skills/docx/LICENSE.txt +30 -0
- package/skills/docx/SKILL.md +590 -0
- package/skills/docx/VENDORED.md +32 -0
- package/skills/docx/scripts/__init__.py +1 -0
- package/skills/docx/scripts/accept_changes.py +135 -0
- package/skills/docx/scripts/comment.py +318 -0
- package/skills/docx/scripts/office/helpers/__init__.py +0 -0
- package/skills/docx/scripts/office/helpers/merge_runs.py +199 -0
- package/skills/docx/scripts/office/helpers/simplify_redlines.py +197 -0
- package/skills/docx/scripts/office/pack.py +159 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
- package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
- package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
- package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
- package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
- package/skills/docx/scripts/office/schemas/mce/mc.xsd +75 -0
- package/skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
- package/skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
- package/skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
- package/skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
- package/skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
- package/skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
- package/skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
- package/skills/docx/scripts/office/soffice.py +183 -0
- package/skills/docx/scripts/office/unpack.py +132 -0
- package/skills/docx/scripts/office/validate.py +111 -0
- package/skills/docx/scripts/office/validators/__init__.py +15 -0
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/base.py +847 -0
- package/skills/docx/scripts/office/validators/docx.py +446 -0
- package/skills/docx/scripts/office/validators/pptx.py +275 -0
- package/skills/docx/scripts/office/validators/redlining.py +247 -0
- package/skills/docx/scripts/templates/comments.xml +3 -0
- package/skills/docx/scripts/templates/commentsExtended.xml +3 -0
- package/skills/docx/scripts/templates/commentsExtensible.xml +3 -0
- package/skills/docx/scripts/templates/commentsIds.xml +3 -0
- package/skills/docx/scripts/templates/people.xml +3 -0
- package/skills/file-bug/SKILL.md +129 -0
- package/skills/humanizer/LICENSE +21 -0
- package/skills/humanizer/SKILL.md +559 -0
- package/skills/humanizer/VENDORED.md +38 -0
- package/skills/humanizer-calibrate/SKILL.md +144 -0
- package/skills/mcp-builder/LICENSE.txt +202 -0
- package/skills/mcp-builder/SKILL.md +236 -0
- package/skills/mcp-builder/VENDORED.md +32 -0
- package/skills/mcp-builder/reference/evaluation.md +602 -0
- package/skills/mcp-builder/reference/mcp_best_practices.md +249 -0
- package/skills/mcp-builder/reference/node_mcp_server.md +970 -0
- package/skills/mcp-builder/reference/python_mcp_server.md +719 -0
- package/skills/mcp-builder/scripts/connections.py +151 -0
- package/skills/mcp-builder/scripts/evaluation.py +373 -0
- package/skills/mcp-builder/scripts/example_evaluation.xml +22 -0
- package/skills/mcp-builder/scripts/requirements.txt +2 -0
- package/skills/pdf/LICENSE.txt +30 -0
- package/skills/pdf/SKILL.md +314 -0
- package/skills/pdf/VENDORED.md +32 -0
- package/skills/pdf/forms.md +294 -0
- package/skills/pdf/reference.md +612 -0
- package/skills/pdf/scripts/check_bounding_boxes.py +65 -0
- package/skills/pdf/scripts/check_fillable_fields.py +11 -0
- package/skills/pdf/scripts/convert_pdf_to_images.py +33 -0
- package/skills/pdf/scripts/create_validation_image.py +37 -0
- package/skills/pdf/scripts/extract_form_field_info.py +122 -0
- package/skills/pdf/scripts/extract_form_structure.py +115 -0
- package/skills/pdf/scripts/fill_fillable_fields.py +98 -0
- package/skills/pdf/scripts/fill_pdf_form_with_annotations.py +107 -0
- package/skills/pptx/LICENSE.txt +30 -0
- package/skills/pptx/SKILL.md +232 -0
- package/skills/pptx/VENDORED.md +32 -0
- package/skills/pptx/editing.md +205 -0
- package/skills/pptx/pptxgenjs.md +420 -0
- package/skills/pptx/scripts/__init__.py +0 -0
- package/skills/pptx/scripts/add_slide.py +195 -0
- package/skills/pptx/scripts/clean.py +286 -0
- package/skills/pptx/scripts/office/helpers/__init__.py +0 -0
- package/skills/pptx/scripts/office/helpers/merge_runs.py +199 -0
- package/skills/pptx/scripts/office/helpers/simplify_redlines.py +197 -0
- package/skills/pptx/scripts/office/pack.py +159 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
- package/skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
- package/skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
- package/skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
- package/skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
- package/skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
- package/skills/pptx/scripts/office/schemas/mce/mc.xsd +75 -0
- package/skills/pptx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
- package/skills/pptx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
- package/skills/pptx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
- package/skills/pptx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
- package/skills/pptx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
- package/skills/pptx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
- package/skills/pptx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
- package/skills/pptx/scripts/office/soffice.py +183 -0
- package/skills/pptx/scripts/office/unpack.py +132 -0
- package/skills/pptx/scripts/office/validate.py +111 -0
- package/skills/pptx/scripts/office/validators/__init__.py +15 -0
- package/skills/pptx/scripts/office/validators/base.py +847 -0
- package/skills/pptx/scripts/office/validators/docx.py +446 -0
- package/skills/pptx/scripts/office/validators/pptx.py +275 -0
- package/skills/pptx/scripts/office/validators/redlining.py +247 -0
- package/skills/pptx/scripts/thumbnail.py +289 -0
- package/skills/skill-creator/LICENSE.txt +202 -0
- package/skills/skill-creator/SKILL.md +485 -0
- package/skills/skill-creator/VENDORED.md +32 -0
- package/skills/skill-creator/agents/analyzer.md +274 -0
- package/skills/skill-creator/agents/comparator.md +202 -0
- package/skills/skill-creator/agents/grader.md +223 -0
- package/skills/skill-creator/assets/eval_review.html +146 -0
- package/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- package/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- package/skills/skill-creator/references/schemas.md +430 -0
- package/skills/skill-creator/scripts/__init__.py +0 -0
- package/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/skills/skill-creator/scripts/generate_report.py +326 -0
- package/skills/skill-creator/scripts/improve_description.py +247 -0
- package/skills/skill-creator/scripts/package_skill.py +136 -0
- package/skills/skill-creator/scripts/quick_validate.py +103 -0
- package/skills/skill-creator/scripts/run_eval.py +310 -0
- package/skills/skill-creator/scripts/run_loop.py +328 -0
- package/skills/skill-creator/scripts/utils.py +47 -0
- package/skills/switchroom-architecture/SKILL.md +60 -0
- package/skills/switchroom-architecture/cascade.md +112 -0
- package/skills/switchroom-architecture/sub-agents.md +87 -0
- package/skills/switchroom-architecture/telegram.md +94 -0
- package/skills/switchroom-cli/SKILL.md +274 -0
- package/skills/switchroom-health/SKILL.md +101 -0
- package/skills/switchroom-install/SKILL.md +116 -0
- package/skills/switchroom-manage/SKILL.md +90 -0
- package/skills/switchroom-status/SKILL.md +69 -0
- package/skills/switchroom-status/scripts/status.sh +69 -0
- package/skills/telegram-test-harness/SKILL.md +191 -0
- package/skills/token-helpers/SKILL.md +73 -0
- package/skills/token-helpers/scripts/google-cal-token.sh +62 -0
- package/skills/token-helpers/scripts/ms-graph-token.sh +70 -0
- package/skills/webapp-testing/LICENSE.txt +202 -0
- package/skills/webapp-testing/SKILL.md +96 -0
- package/skills/webapp-testing/VENDORED.md +32 -0
- package/skills/webapp-testing/examples/console_logging.py +35 -0
- package/skills/webapp-testing/examples/element_discovery.py +40 -0
- package/skills/webapp-testing/examples/static_html_automation.py +33 -0
- package/skills/webapp-testing/scripts/with_server.py +106 -0
- package/skills/xlsx/LICENSE.txt +30 -0
- package/skills/xlsx/SKILL.md +292 -0
- package/skills/xlsx/VENDORED.md +32 -0
- package/skills/xlsx/scripts/office/helpers/__init__.py +0 -0
- package/skills/xlsx/scripts/office/helpers/merge_runs.py +199 -0
- package/skills/xlsx/scripts/office/helpers/simplify_redlines.py +197 -0
- package/skills/xlsx/scripts/office/pack.py +159 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
- package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
- package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
- package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
- package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
- package/skills/xlsx/scripts/office/schemas/mce/mc.xsd +75 -0
- package/skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
- package/skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
- package/skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
- package/skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
- package/skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
- package/skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
- package/skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
- package/skills/xlsx/scripts/office/soffice.py +183 -0
- package/skills/xlsx/scripts/office/unpack.py +132 -0
- package/skills/xlsx/scripts/office/validate.py +111 -0
- package/skills/xlsx/scripts/office/validators/__init__.py +15 -0
- package/skills/xlsx/scripts/office/validators/base.py +847 -0
- package/skills/xlsx/scripts/office/validators/docx.py +446 -0
- package/skills/xlsx/scripts/office/validators/pptx.py +275 -0
- package/skills/xlsx/scripts/office/validators/redlining.py +247 -0
- package/skills/xlsx/scripts/recalc.py +184 -0
- package/telegram-plugin/.claude-plugin/plugin.json +20 -0
- package/telegram-plugin/.mcp.json +14 -0
- package/telegram-plugin/LICENSE +21 -0
- package/telegram-plugin/README.md +352 -0
- package/telegram-plugin/active-pins-sweep.ts +204 -0
- package/telegram-plugin/active-pins.ts +146 -0
- package/telegram-plugin/active-reactions-sweep.ts +79 -0
- package/telegram-plugin/active-reactions.ts +134 -0
- package/telegram-plugin/admin-commands/dispatch.test.ts +149 -0
- package/telegram-plugin/admin-commands/index.ts +106 -0
- package/telegram-plugin/answer-stream.ts +565 -0
- package/telegram-plugin/ask-user.ts +179 -0
- package/telegram-plugin/attachment-path.ts +80 -0
- package/telegram-plugin/auth-code-redact.ts +83 -0
- package/telegram-plugin/auth-dashboard.ts +1104 -0
- package/telegram-plugin/auth-slot-parser.ts +497 -0
- package/telegram-plugin/auto-fallback-dispatcher.ts +68 -0
- package/telegram-plugin/auto-fallback.ts +348 -0
- package/telegram-plugin/bridge/bridge.ts +687 -0
- package/telegram-plugin/bridge/ipc-client.ts +326 -0
- package/telegram-plugin/bun.lock +218 -0
- package/telegram-plugin/card-format.ts +62 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +56 -0
- package/telegram-plugin/channel-envelope-safety.ts +56 -0
- package/telegram-plugin/chat-lock.ts +65 -0
- package/telegram-plugin/context-exhaustion.ts +38 -0
- package/telegram-plugin/credits-watch.ts +220 -0
- package/telegram-plugin/dist/bridge/bridge.js +24758 -0
- package/telegram-plugin/dist/foreman/foreman.js +30723 -0
- package/telegram-plugin/dist/gateway/gateway.js +46497 -0
- package/telegram-plugin/dist/server.js +24551 -0
- package/telegram-plugin/dm-command-gate.ts +56 -0
- package/telegram-plugin/docs/gateway-server-split.md +133 -0
- package/telegram-plugin/docs/multi-agent-card-design.md +847 -0
- package/telegram-plugin/docs/pinned-progress-card-reliability.md +144 -0
- package/telegram-plugin/docs/stream-json-daemon-mode.md +477 -0
- package/telegram-plugin/docs/waiting-ux-spec.md +233 -0
- package/telegram-plugin/draft-stream.ts +442 -0
- package/telegram-plugin/draft-transport.ts +72 -0
- package/telegram-plugin/first-paint.ts +246 -0
- package/telegram-plugin/fleet-state.ts +246 -0
- package/telegram-plugin/foreman/foreman-create-flow.ts +202 -0
- package/telegram-plugin/foreman/foreman-handlers.ts +493 -0
- package/telegram-plugin/foreman/foreman.ts +1130 -0
- package/telegram-plugin/foreman/setup-flow.ts +345 -0
- package/telegram-plugin/foreman/setup-state.ts +239 -0
- package/telegram-plugin/foreman/state.ts +203 -0
- package/telegram-plugin/format.ts +685 -0
- package/telegram-plugin/gateway/access-validator.test.ts +95 -0
- package/telegram-plugin/gateway/access-validator.ts +37 -0
- package/telegram-plugin/gateway/boot-card.ts +582 -0
- package/telegram-plugin/gateway/boot-probes.ts +863 -0
- package/telegram-plugin/gateway/boot-reason.ts +51 -0
- package/telegram-plugin/gateway/boot-sweep-filter.test.ts +54 -0
- package/telegram-plugin/gateway/boot-sweep-filter.ts +32 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +183 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +109 -0
- package/telegram-plugin/gateway/gateway.ts +10202 -0
- package/telegram-plugin/gateway/inbound-coalesce.ts +147 -0
- package/telegram-plugin/gateway/inject-handler.test.ts +221 -0
- package/telegram-plugin/gateway/inject-handler.ts +190 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +151 -0
- package/telegram-plugin/gateway/ipc-server.ts +494 -0
- package/telegram-plugin/gateway/pid-file.ts +103 -0
- package/telegram-plugin/gateway/poll-health.ts +156 -0
- package/telegram-plugin/gateway/preamble-suppressor.ts +154 -0
- package/telegram-plugin/gateway/quota-cache.ts +125 -0
- package/telegram-plugin/gateway/resolve-calling-subagent.ts +78 -0
- package/telegram-plugin/gateway/restart-watchdog.ts +200 -0
- package/telegram-plugin/gateway/session-marker.ts +83 -0
- package/telegram-plugin/gateway/shutdown-drain.ts +162 -0
- package/telegram-plugin/gateway/startup-mutex.ts +285 -0
- package/telegram-plugin/gateway/startup-network-retry.ts +142 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +176 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +78 -0
- package/telegram-plugin/handoff-continuity.ts +200 -0
- package/telegram-plugin/history.ts +468 -0
- package/telegram-plugin/hooks/hooks.json +58 -0
- package/telegram-plugin/hooks/secret-guard-pretool.mjs +208 -0
- package/telegram-plugin/hooks/secret-scrub-stop.mjs +98 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +111 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +296 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +261 -0
- package/telegram-plugin/html-sanitize.ts +244 -0
- package/telegram-plugin/idle-footer.ts +65 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +166 -0
- package/telegram-plugin/interrupt-marker.ts +66 -0
- package/telegram-plugin/issues-card.ts +371 -0
- package/telegram-plugin/issues-watcher.ts +125 -0
- package/telegram-plugin/model-unavailable.ts +325 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/operator-events-history.ts +94 -0
- package/telegram-plugin/operator-events.fixtures.json +161 -0
- package/telegram-plugin/operator-events.ts +421 -0
- package/telegram-plugin/package.json +55 -0
- package/telegram-plugin/permission-rule.ts +133 -0
- package/telegram-plugin/permission-title.ts +117 -0
- package/telegram-plugin/pin-event-log.ts +76 -0
- package/telegram-plugin/plugin-logger.ts +136 -0
- package/telegram-plugin/progress-card-driver.ts +2697 -0
- package/telegram-plugin/progress-card-pin-manager.ts +589 -0
- package/telegram-plugin/progress-card-pin-watchdog.ts +98 -0
- package/telegram-plugin/progress-card.ts +1409 -0
- package/telegram-plugin/pty-partial-handler.ts +247 -0
- package/telegram-plugin/pty-tail.ts +730 -0
- package/telegram-plugin/quota-check.ts +474 -0
- package/telegram-plugin/recent-outbound-dedup.ts +169 -0
- package/telegram-plugin/registry/api-registry.test.ts +201 -0
- package/telegram-plugin/registry/subagents-bugs.test.ts +454 -0
- package/telegram-plugin/registry/subagents-schema.ts +509 -0
- package/telegram-plugin/registry/subagents.test.ts +476 -0
- package/telegram-plugin/registry/turns-schema.test.ts +101 -0
- package/telegram-plugin/registry/turns-schema.ts +417 -0
- package/telegram-plugin/retry-api-call.ts +172 -0
- package/telegram-plugin/scripts/build.mjs +78 -0
- package/telegram-plugin/secret-detect/audit.ts +66 -0
- package/telegram-plugin/secret-detect/chunker.ts +37 -0
- package/telegram-plugin/secret-detect/entropy.ts +20 -0
- package/telegram-plugin/secret-detect/gitleaks-loader.ts +74 -0
- package/telegram-plugin/secret-detect/gitleaks.toml +27 -0
- package/telegram-plugin/secret-detect/index.ts +218 -0
- package/telegram-plugin/secret-detect/kv-scanner.ts +60 -0
- package/telegram-plugin/secret-detect/mask.ts +13 -0
- package/telegram-plugin/secret-detect/patterns.ts +115 -0
- package/telegram-plugin/secret-detect/pipeline.ts +144 -0
- package/telegram-plugin/secret-detect/rewrite.ts +26 -0
- package/telegram-plugin/secret-detect/secretlint-source.ts +95 -0
- package/telegram-plugin/secret-detect/slug.ts +44 -0
- package/telegram-plugin/secret-detect/staging.ts +85 -0
- package/telegram-plugin/secret-detect/suppressor.ts +34 -0
- package/telegram-plugin/secret-detect/url-redact.ts +60 -0
- package/telegram-plugin/secret-detect/vault-write.ts +56 -0
- package/telegram-plugin/server.js +41795 -0
- package/telegram-plugin/server.ts +171 -0
- package/telegram-plugin/session-tail.ts +884 -0
- package/telegram-plugin/shared/bot-runtime.ts +324 -0
- package/telegram-plugin/silent-reply.ts +58 -0
- package/telegram-plugin/slot-banner-driver.ts +147 -0
- package/telegram-plugin/slot-banner.ts +86 -0
- package/telegram-plugin/start.js +26 -0
- package/telegram-plugin/startup-reset.ts +45 -0
- package/telegram-plugin/status-reactions.ts +332 -0
- package/telegram-plugin/steering.ts +155 -0
- package/telegram-plugin/sticker-aliases.ts +249 -0
- package/telegram-plugin/stream-controller.ts +311 -0
- package/telegram-plugin/stream-reply-handler.ts +664 -0
- package/telegram-plugin/streaming-metrics.ts +134 -0
- package/telegram-plugin/streaming-report.ts +204 -0
- package/telegram-plugin/subagent-watcher.ts +880 -0
- package/telegram-plugin/telegram-button-constraints.ts +191 -0
- package/telegram-plugin/telegraph.ts +381 -0
- package/telegram-plugin/tests/HARNESS.md +340 -0
- package/telegram-plugin/tests/_progress-card-harness.ts +105 -0
- package/telegram-plugin/tests/active-pins-boot-reaper.test.ts +211 -0
- package/telegram-plugin/tests/active-pins-sweep.test.ts +309 -0
- package/telegram-plugin/tests/active-pins.test.ts +187 -0
- package/telegram-plugin/tests/active-reactions-sweep.test.ts +116 -0
- package/telegram-plugin/tests/active-reactions.test.ts +198 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +352 -0
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +236 -0
- package/telegram-plugin/tests/answer-stream.test.ts +878 -0
- package/telegram-plugin/tests/ask-user.test.ts +203 -0
- package/telegram-plugin/tests/attachment-path.test.ts +199 -0
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +118 -0
- package/telegram-plugin/tests/auth-code-auto-capture.test.ts +144 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +248 -0
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +260 -0
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +140 -0
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +559 -0
- package/telegram-plugin/tests/auth-dashboard.test.ts +1045 -0
- package/telegram-plugin/tests/auth-login-url-button.test.ts +122 -0
- package/telegram-plugin/tests/auth-slot-commands.test.ts +640 -0
- package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +183 -0
- package/telegram-plugin/tests/auto-fallback.test.ts +381 -0
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +137 -0
- package/telegram-plugin/tests/boot-card-dedupe.test.ts +154 -0
- package/telegram-plugin/tests/boot-card-probe-target.test.ts +194 -0
- package/telegram-plugin/tests/boot-card-reason.test.ts +103 -0
- package/telegram-plugin/tests/boot-card-render.test.ts +219 -0
- package/telegram-plugin/tests/boot-probes.test.ts +451 -0
- package/telegram-plugin/tests/bot-api.harness.ts +116 -0
- package/telegram-plugin/tests/bot-runtime.test.ts +190 -0
- package/telegram-plugin/tests/bridge-anonymous-refuse.test.ts +60 -0
- package/telegram-plugin/tests/context-exhaustion.test.ts +114 -0
- package/telegram-plugin/tests/credits-watch.test.ts +221 -0
- package/telegram-plugin/tests/dm-command-gate.test.ts +176 -0
- package/telegram-plugin/tests/draft-stream.test.ts +752 -0
- package/telegram-plugin/tests/draft-transport.test.ts +141 -0
- package/telegram-plugin/tests/e2e.test.ts +436 -0
- package/telegram-plugin/tests/fake-bot-api.test.ts +213 -0
- package/telegram-plugin/tests/fake-bot-api.ts +617 -0
- package/telegram-plugin/tests/false-restart-banner.test.ts +253 -0
- package/telegram-plugin/tests/first-paint.test.ts +257 -0
- package/telegram-plugin/tests/fixtures/pty-tail-tmux-fragment.bin +6 -0
- package/telegram-plugin/tests/fixtures/service-log-current-claude-code.bin +3624 -0
- package/telegram-plugin/tests/fleet-state-watcher.test.ts +101 -0
- package/telegram-plugin/tests/fleet-state.test.ts +185 -0
- package/telegram-plugin/tests/foreman-create-flow.test.ts +359 -0
- package/telegram-plugin/tests/foreman-handlers.test.ts +347 -0
- package/telegram-plugin/tests/foreman-state.test.ts +164 -0
- package/telegram-plugin/tests/foreman-write-ops.test.ts +214 -0
- package/telegram-plugin/tests/gateway-409-retry-banner.test.ts +173 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +72 -0
- package/telegram-plugin/tests/gateway-bridge.test.ts +811 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +414 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +144 -0
- package/telegram-plugin/tests/gateway-message-validator.test.ts +133 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +103 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +127 -0
- package/telegram-plugin/tests/gateway-startup-mutex.test.ts +284 -0
- package/telegram-plugin/tests/gateway-startup-network-retry.test.ts +185 -0
- package/telegram-plugin/tests/gateway-startup-reset.test.ts +72 -0
- package/telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts +125 -0
- package/telegram-plugin/tests/handoff-continuity.test.ts +249 -0
- package/telegram-plugin/tests/harness-ordering-invariants.test.ts +243 -0
- package/telegram-plugin/tests/harness-parse-mode-validation.test.ts +114 -0
- package/telegram-plugin/tests/history.test.ts +364 -0
- package/telegram-plugin/tests/html-balanced.ts +63 -0
- package/telegram-plugin/tests/html-sanitize.test.ts +146 -0
- package/telegram-plugin/tests/idle-footer-wiring.test.ts +88 -0
- package/telegram-plugin/tests/idle-footer.test.ts +66 -0
- package/telegram-plugin/tests/inbound-coalesce.test.ts +127 -0
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +150 -0
- package/telegram-plugin/tests/interrupt-marker.test.ts +126 -0
- package/telegram-plugin/tests/ipc-protocol.test.ts +218 -0
- package/telegram-plugin/tests/ipc-server-anonymous-refuse.test.ts +82 -0
- package/telegram-plugin/tests/ipc-server-client.test.ts +323 -0
- package/telegram-plugin/tests/ipc-server-race.test.ts +183 -0
- package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +91 -0
- package/telegram-plugin/tests/ipc-server-validate-pty-partial.test.ts +64 -0
- package/telegram-plugin/tests/ipc-server-validate-update-placeholder.test.ts +77 -0
- package/telegram-plugin/tests/ipc-validator.test.ts +274 -0
- package/telegram-plugin/tests/issues-card.test.ts +495 -0
- package/telegram-plugin/tests/issues-watcher.test.ts +165 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +303 -0
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +159 -0
- package/telegram-plugin/tests/operator-events-history.test.ts +125 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +192 -0
- package/telegram-plugin/tests/operator-events.test.ts +331 -0
- package/telegram-plugin/tests/outbound-ordering.test.ts +293 -0
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +164 -0
- package/telegram-plugin/tests/permission-rule.test.ts +121 -0
- package/telegram-plugin/tests/permission-title.test.ts +106 -0
- package/telegram-plugin/tests/pin-event-log.test.ts +124 -0
- package/telegram-plugin/tests/plugin-logger.test.ts +97 -0
- package/telegram-plugin/tests/poll-health.test.ts +86 -0
- package/telegram-plugin/tests/preamble-suppressor.test.ts +285 -0
- package/telegram-plugin/tests/progress-card-api-failure-during-deferred.test.ts +73 -0
- package/telegram-plugin/tests/progress-card-close-paths-converge.test.ts +272 -0
- package/telegram-plugin/tests/progress-card-cross-turn.test.ts +258 -0
- package/telegram-plugin/tests/progress-card-dispose-preservepending.test.ts +81 -0
- package/telegram-plugin/tests/progress-card-draft-flag.test.ts +80 -0
- package/telegram-plugin/tests/progress-card-driver-eviction.test.ts +215 -0
- package/telegram-plugin/tests/progress-card-driver-fleet-shadow.test.ts +123 -0
- package/telegram-plugin/tests/progress-card-driver-force-complete-parent-done.test.ts +76 -0
- package/telegram-plugin/tests/progress-card-edit-timestamps-budget.test.ts +62 -0
- package/telegram-plugin/tests/progress-card-memory-bounds.test.ts +84 -0
- package/telegram-plugin/tests/progress-card-pin-failure-paths.test.ts +139 -0
- package/telegram-plugin/tests/progress-card-pin-manager.test.ts +773 -0
- package/telegram-plugin/tests/progress-card-pin-race-fast-turn.test.ts +66 -0
- package/telegram-plugin/tests/progress-card-pin-sidecar-partial-write.test.ts +64 -0
- package/telegram-plugin/tests/progress-card-pin-watchdog.test.ts +190 -0
- package/telegram-plugin/tests/progress-card-sigterm-pin-flush.test.ts +146 -0
- package/telegram-plugin/tests/progress-update.test.ts +236 -0
- package/telegram-plugin/tests/protocol-fixtures.test.ts +59 -0
- package/telegram-plugin/tests/protocol-fixtures.ts +198 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +326 -0
- package/telegram-plugin/tests/pty-tail-real-fixture.test.ts +114 -0
- package/telegram-plugin/tests/pty-tail-tmux-fragment.test.ts +71 -0
- package/telegram-plugin/tests/pty-tail.test.ts +525 -0
- package/telegram-plugin/tests/quota-cache.test.ts +187 -0
- package/telegram-plugin/tests/quota-check.test.ts +622 -0
- package/telegram-plugin/tests/races.test.ts +842 -0
- package/telegram-plugin/tests/real-gateway-f1-ladder-integrity.test.ts +123 -0
- package/telegram-plugin/tests/real-gateway-f2-instant-draft.test.ts +82 -0
- package/telegram-plugin/tests/real-gateway-f3-late-card.test.ts +114 -0
- package/telegram-plugin/tests/real-gateway-harness.ts +699 -0
- package/telegram-plugin/tests/real-gateway-i6-turn-flush-replay-dedup.test.ts +313 -0
- package/telegram-plugin/tests/real-gateway-ipc-lifecycle.test.ts +299 -0
- package/telegram-plugin/tests/real-gateway-spec.test.ts +487 -0
- package/telegram-plugin/tests/real-gateway.smoke.test.ts +101 -0
- package/telegram-plugin/tests/recent-outbound-dedup.test.ts +192 -0
- package/telegram-plugin/tests/registry-turns.test.ts +432 -0
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +149 -0
- package/telegram-plugin/tests/resolve-calling-subagent.test.ts +269 -0
- package/telegram-plugin/tests/restart-watchdog.test.ts +224 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +287 -0
- package/telegram-plugin/tests/secret-detect-audit.test.ts +58 -0
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +83 -0
- package/telegram-plugin/tests/secret-detect-gitleaks.test.ts +32 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +308 -0
- package/telegram-plugin/tests/secret-detect-pipeline.test.ts +123 -0
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +101 -0
- package/telegram-plugin/tests/secret-detect-staging.test.ts +45 -0
- package/telegram-plugin/tests/secret-detect-suppressor-no-silent-allow.test.ts +67 -0
- package/telegram-plugin/tests/secret-detect.test.ts +223 -0
- package/telegram-plugin/tests/secret-guard-pretool.test.ts +194 -0
- package/telegram-plugin/tests/send-typing-action-validation.test.ts +61 -0
- package/telegram-plugin/tests/session-tail-capped.test.ts +285 -0
- package/telegram-plugin/tests/session-tail.test.ts +524 -0
- package/telegram-plugin/tests/setup-flow.test.ts +510 -0
- package/telegram-plugin/tests/setup-state.test.ts +146 -0
- package/telegram-plugin/tests/silent-reply-guard.test.ts +122 -0
- package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +350 -0
- package/telegram-plugin/tests/slot-banner.test.ts +74 -0
- package/telegram-plugin/tests/snapshot-serializer.ts +79 -0
- package/telegram-plugin/tests/spawn-detached-cgroup-escape.test.ts +51 -0
- package/telegram-plugin/tests/status-accent.test.ts +186 -0
- package/telegram-plugin/tests/status-reactions-allowed-filter.test.ts +132 -0
- package/telegram-plugin/tests/status-reactions.test.ts +314 -0
- package/telegram-plugin/tests/steering.test.ts +282 -0
- package/telegram-plugin/tests/sticker-aliases.test.ts +232 -0
- package/telegram-plugin/tests/stream-controller-html-fallback.test.ts +127 -0
- package/telegram-plugin/tests/stream-controller.test.ts +262 -0
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +208 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +1292 -0
- package/telegram-plugin/tests/streaming-e2e.test.ts +389 -0
- package/telegram-plugin/tests/streaming-metrics.test.ts +201 -0
- package/telegram-plugin/tests/streaming-orchestration.test.ts +756 -0
- package/telegram-plugin/tests/subagent-registry-bugs.test.ts +725 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +213 -0
- package/telegram-plugin/tests/subagent-watcher-parent-marker.test.ts +274 -0
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +243 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +877 -0
- package/telegram-plugin/tests/subagents-schema-init-order.test.ts +109 -0
- package/telegram-plugin/tests/sync-chat-running-subagents.test.ts +116 -0
- package/telegram-plugin/tests/telegram-button-constraints.test.ts +194 -0
- package/telegram-plugin/tests/telegram-format.test.ts +1093 -0
- package/telegram-plugin/tests/telegraph.test.ts +246 -0
- package/telegram-plugin/tests/tool-labels.test.ts +383 -0
- package/telegram-plugin/tests/turn-active-marker.test.ts +195 -0
- package/telegram-plugin/tests/turn-end-regressions.test.ts +489 -0
- package/telegram-plugin/tests/turn-flush-card-takeover.test.ts +218 -0
- package/telegram-plugin/tests/turn-flush-dedup-controller.test.ts +144 -0
- package/telegram-plugin/tests/turn-flush-prose-recovery.test.ts +78 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +189 -0
- package/telegram-plugin/tests/turn-signal-tracker.test.ts +107 -0
- package/telegram-plugin/tests/turns-writer.test.ts +323 -0
- package/telegram-plugin/tests/two-zone-bg-carry-full-lifecycle.test.ts +131 -0
- package/telegram-plugin/tests/two-zone-bg-detection.test.ts +120 -0
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +114 -0
- package/telegram-plugin/tests/two-zone-bg-early-turn-end.test.ts +87 -0
- package/telegram-plugin/tests/two-zone-bg-survives-next-turn.test.ts +211 -0
- package/telegram-plugin/tests/two-zone-card-cap.test.ts +62 -0
- package/telegram-plugin/tests/two-zone-card-fleet-row.test.ts +101 -0
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +68 -0
- package/telegram-plugin/tests/two-zone-card-html-balance.test.ts +110 -0
- package/telegram-plugin/tests/two-zone-card-lifecycle.test.ts +128 -0
- package/telegram-plugin/tests/two-zone-card-sanitise.test.ts +58 -0
- package/telegram-plugin/tests/two-zone-card-snapshot.test.ts +133 -0
- package/telegram-plugin/tests/two-zone-concurrent-turns-isolation.test.ts +155 -0
- package/telegram-plugin/tests/two-zone-phasefor-precedence.test.ts +117 -0
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +143 -0
- package/telegram-plugin/tests/two-zone-stuck-edit-throttle.test.ts +149 -0
- package/telegram-plugin/tests/two-zone-stuck-header-escalation.test.ts +101 -0
- package/telegram-plugin/tests/two-zone-stuck-per-member.test.ts +114 -0
- package/telegram-plugin/tests/two-zone-stuck-recovery.test.ts +105 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +141 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +147 -0
- package/telegram-plugin/tests/update-factory-edited-and-reactions.test.ts +108 -0
- package/telegram-plugin/tests/update-factory.ts +305 -0
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +84 -0
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +265 -0
- package/telegram-plugin/tests/vault-subcommands.test.ts +234 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +196 -0
- package/telegram-plugin/tests/waiting-ux-harness.ts +381 -0
- package/telegram-plugin/tests/waiting-ux.e2e.test.ts +233 -0
- package/telegram-plugin/tests/welcome-text.test.ts +407 -0
- package/telegram-plugin/tool-error-filter.ts +89 -0
- package/telegram-plugin/tool-labels.ts +330 -0
- package/telegram-plugin/tool-names.ts +53 -0
- package/telegram-plugin/turn-flush-prose-recovery.ts +40 -0
- package/telegram-plugin/turn-flush-safety.ts +109 -0
- package/telegram-plugin/turn-signal-tracker.ts +79 -0
- package/telegram-plugin/two-zone-card.ts +249 -0
- package/telegram-plugin/typing-wrap.ts +92 -0
- package/telegram-plugin/voice-transcribe.ts +166 -0
- 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
|
+
}
|