@gajae-code/coding-agent 0.11.1 → 0.11.2
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/CHANGELOG.md +54 -0
- package/dist/types/cli/config-cli.d.ts +11 -1
- package/dist/types/cli/notify-cli.d.ts +1 -0
- package/dist/types/cli/session-picker.d.ts +2 -1
- package/dist/types/cli/setup-cli.d.ts +1 -1
- package/dist/types/commands/mcp-serve.d.ts +33 -0
- package/dist/types/commands/mcp.d.ts +1 -1
- package/dist/types/commands/notify.d.ts +0 -2
- package/dist/types/commands/sdk.d.ts +1 -1
- package/dist/types/config/atomic-yaml-patch.d.ts +18 -0
- package/dist/types/config/file-lock.d.ts +14 -4
- package/dist/types/config/keybindings.d.ts +42 -1
- package/dist/types/config/model-auth.d.ts +3 -0
- package/dist/types/config/model-bindings-applier.d.ts +13 -0
- package/dist/types/config/model-discovery-manager.d.ts +49 -0
- package/dist/types/config/model-equivalence.d.ts +20 -0
- package/dist/types/config/model-registry.d.ts +16 -15
- package/dist/types/config/model-resolver.d.ts +28 -18
- package/dist/types/config/models-config-schema.d.ts +11 -0
- package/dist/types/config/settings-schema.d.ts +56 -1
- package/dist/types/config/settings.d.ts +14 -11
- package/dist/types/coordinator-mcp/policy.d.ts +5 -0
- package/dist/types/coordinator-mcp/question-gate-codec.d.ts +129 -0
- package/dist/types/coordinator-mcp/question-state.d.ts +356 -0
- package/dist/types/coordinator-mcp/server.d.ts +4 -0
- package/dist/types/discovery/helpers.d.ts +0 -6
- package/dist/types/edit/index.d.ts +3 -0
- package/dist/types/eval/py/env.d.ts +9 -0
- package/dist/types/extensibility/extensions/loader.d.ts +2 -1
- package/dist/types/extensibility/extensions/runner.d.ts +2 -2
- package/dist/types/extensibility/extensions/types.d.ts +34 -3
- package/dist/types/extensibility/hooks/runner.d.ts +1 -1
- package/dist/types/extensibility/shared-events.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +10 -1
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +44 -0
- package/dist/types/gjc-runtime/goal-mode-request.d.ts +11 -1
- package/dist/types/gjc-runtime/launch-worktree.d.ts +4 -2
- package/dist/types/gjc-runtime/linux-proc.d.ts +29 -0
- package/dist/types/gjc-runtime/session-resolution.d.ts +2 -2
- package/dist/types/gjc-runtime/state-migrations.d.ts +1 -1
- package/dist/types/gjc-runtime/state-writer.d.ts +2 -1
- package/dist/types/gjc-runtime/team-launch.d.ts +59 -0
- package/dist/types/gjc-runtime/team-notify.d.ts +45 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +29 -121
- package/dist/types/gjc-runtime/team-store.d.ts +160 -0
- package/dist/types/gjc-runtime/team-workers.d.ts +78 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +8 -0
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +13 -0
- package/dist/types/gjc-runtime/ultragoal-evidence.d.ts +38 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +7 -0
- package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +23 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +55 -33
- package/dist/types/gjc-runtime/workflow-cli-common.d.ts +8 -0
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -0
- package/dist/types/goals/continuation-timeout-guard.d.ts +22 -0
- package/dist/types/goals/runtime.d.ts +1 -0
- package/dist/types/goals/state.d.ts +8 -0
- package/dist/types/harness-control-plane/lifecycle-reconciliation.d.ts +17 -0
- package/dist/types/harness-control-plane/phase-rollup.d.ts +4 -7
- package/dist/types/harness-control-plane/receipt-ingest.d.ts +5 -0
- package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
- package/dist/types/hindsight/state.d.ts +11 -4
- package/dist/types/lsp/lspmux.d.ts +5 -3
- package/dist/types/lsp/path-trust.d.ts +1 -0
- package/dist/types/main.d.ts +3 -2
- package/dist/types/memories/storage.d.ts +1 -0
- package/dist/types/modes/action-registry.d.ts +29 -0
- package/dist/types/modes/components/chrome.d.ts +14 -0
- package/dist/types/modes/components/command-palette.d.ts +27 -0
- package/dist/types/modes/components/custom-provider-wizard.d.ts +1 -0
- package/dist/types/modes/components/index.d.ts +2 -1
- package/dist/types/modes/components/pet-selector.d.ts +1 -1
- package/dist/types/modes/components/plan-preview-overlay.d.ts +43 -0
- package/dist/types/modes/components/queue-mode-selector.d.ts +1 -1
- package/dist/types/modes/components/queue-pane.d.ts +12 -0
- package/dist/types/modes/components/read-tool-group.d.ts +6 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +3 -4
- package/dist/types/modes/components/sessions-dashboard.d.ts +32 -0
- package/dist/types/modes/components/settings-selector.d.ts +1 -1
- package/dist/types/modes/components/show-images-selector.d.ts +1 -1
- package/dist/types/modes/components/status-line/types.d.ts +1 -1
- package/dist/types/modes/components/status-line.d.ts +1 -94
- package/dist/types/modes/components/tasks-pane.d.ts +15 -0
- package/dist/types/modes/components/theme-selector.d.ts +1 -1
- package/dist/types/modes/components/tool-execution.d.ts +15 -0
- package/dist/types/modes/components/tool-status-header.d.ts +111 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +42 -0
- package/dist/types/modes/controllers/goal-mode-controller.d.ts +62 -0
- package/dist/types/modes/controllers/input-controller.d.ts +8 -1
- package/dist/types/modes/controllers/mode-gate.d.ts +8 -0
- package/dist/types/modes/controllers/plan-mode-controller.d.ts +65 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +15 -3
- package/dist/types/modes/controllers/stt-controller.d.ts +26 -0
- package/dist/types/modes/interactive-mode.d.ts +19 -17
- package/dist/types/modes/jobs-observer.d.ts +2 -0
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -11
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +20 -2
- package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +31 -0
- package/dist/types/modes/tasks-aggregator.d.ts +39 -0
- package/dist/types/modes/transcript-item-registry.d.ts +60 -0
- package/dist/types/modes/types.d.ts +20 -13
- package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
- package/dist/types/runtime-mcp/manager.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +78 -12
- package/dist/types/sdk/broker/lifecycle-codec.d.ts +4 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +15 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -2
- package/dist/types/sdk/broker/process-incarnation.d.ts +2 -0
- package/dist/types/sdk/broker/session-index.d.ts +0 -1
- package/dist/types/sdk/broker/state-version.d.ts +2 -0
- package/dist/types/sdk/bus/chat-effect-journal.d.ts +1 -0
- package/dist/types/sdk/bus/config-commands.d.ts +1 -1
- package/dist/types/sdk/bus/config.d.ts +11 -0
- package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
- package/dist/types/sdk/bus/html-format.d.ts +3 -2
- package/dist/types/sdk/bus/index.d.ts +27 -0
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
- package/dist/types/sdk/bus/notification-service.d.ts +20 -0
- package/dist/types/sdk/bus/rate-limit-pool.d.ts +13 -2
- package/dist/types/sdk/bus/recent-activity.d.ts +21 -12
- package/dist/types/sdk/bus/rich-document.d.ts +7 -0
- package/dist/types/sdk/bus/rich-render.d.ts +18 -41
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +46 -3
- package/dist/types/sdk/bus/threaded-inbound.d.ts +1 -1
- package/dist/types/sdk/bus/threaded-render.d.ts +13 -0
- package/dist/types/sdk/bus/topic-registry.d.ts +24 -2
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +2 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/session-directory.d.ts +56 -0
- package/dist/types/session/agent-session.d.ts +82 -47
- package/dist/types/session/agent-storage.d.ts +7 -0
- package/dist/types/session/fallback-chain-controller.d.ts +10 -2
- package/dist/types/session/internal/managed-session-scope.d.ts +102 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +38 -0
- package/dist/types/session/messages.d.ts +16 -0
- package/dist/types/session/session-manager-internal.d.ts +14 -0
- package/dist/types/session/session-manager.d.ts +82 -12
- package/dist/types/session/session-storage.d.ts +43 -3
- package/dist/types/session/volatile-context-pruning.d.ts +15 -0
- package/dist/types/skill-state/{deep-interview-mutation-guard.d.ts → workflow-mutation-guard.d.ts} +24 -8
- package/dist/types/skill-state/workflow-state-contract.d.ts +1 -3
- package/dist/types/slash-commands/builtin-registry.d.ts +4 -1
- package/dist/types/system-prompt.d.ts +2 -11
- package/dist/types/task/executor.d.ts +3 -1
- package/dist/types/thinking.d.ts +7 -0
- package/dist/types/tool-discovery/tool-index.d.ts +1 -50
- package/dist/types/tools/ask.d.ts +64 -2
- package/dist/types/tools/bash.d.ts +3 -0
- package/dist/types/tools/browser/dead-tab-recovery.d.ts +24 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +21 -0
- package/dist/types/tools/cron.d.ts +2 -0
- package/dist/types/tools/fetch.d.ts +1 -0
- package/dist/types/tools/index.d.ts +30 -15
- package/dist/types/tools/output-meta.d.ts +1 -0
- package/dist/types/tools/read.d.ts +3 -0
- package/dist/types/tools/search-tool-bm25.d.ts +3 -4
- package/dist/types/utils/event-bus.d.ts +1 -1
- package/dist/types/web/search/index.d.ts +3 -1
- package/package.json +21 -9
- package/scripts/build-sdk-package-smoke.ts +34 -1
- package/scripts/generate-hotkeys-docs.ts +74 -0
- package/scripts/generate-sdk-operation-inventory.ts +64 -20
- package/scripts/verify-gjc-sdk-canonicalization.ts +182 -2
- package/src/async/job-manager.ts +49 -6
- package/src/cli/config-cli.ts +75 -2
- package/src/cli/notify-cli.ts +92 -26
- package/src/cli/session-picker.ts +20 -3
- package/src/cli/setup-cli.ts +1 -1
- package/src/cli.ts +1 -1
- package/src/commands/config.ts +1 -1
- package/src/commands/harness.ts +34 -106
- package/src/commands/launch.ts +6 -4
- package/src/commands/mcp-serve.ts +85 -8
- package/src/commands/mcp.ts +2 -2
- package/src/commands/notify.ts +8 -4
- package/src/commands/sdk.ts +61 -40
- package/src/commands/ultragoal.ts +4 -1
- package/src/config/atomic-yaml-patch.ts +80 -13
- package/src/config/file-lock.ts +105 -43
- package/src/config/keybindings.ts +64 -20
- package/src/config/model-auth.ts +9 -0
- package/src/config/model-bindings-applier.ts +139 -0
- package/src/config/model-discovery-manager.ts +224 -0
- package/src/config/model-equivalence.ts +66 -0
- package/src/config/model-registry.ts +169 -302
- package/src/config/model-resolver.ts +361 -369
- package/src/config/models-config-schema.ts +5 -4
- package/src/config/settings-schema.ts +159 -2
- package/src/config/settings.ts +458 -482
- package/src/coordinator-mcp/policy.ts +76 -11
- package/src/coordinator-mcp/question-gate-codec.ts +274 -0
- package/src/coordinator-mcp/question-state.ts +778 -0
- package/src/coordinator-mcp/server.ts +1702 -238
- package/src/debug/index.ts +1 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +13 -1
- package/src/defaults/gjc/skills/team/SKILL.md +16 -0
- package/src/discovery/helpers.ts +1 -50
- package/src/edit/index.ts +64 -0
- package/src/eval/py/env.ts +26 -0
- package/src/eval/py/kernel.ts +5 -3
- package/src/extensibility/extensions/loader.ts +9 -1
- package/src/extensibility/extensions/runner.ts +12 -3
- package/src/extensibility/extensions/types.ts +39 -1
- package/src/extensibility/gjc-plugins/installer.ts +16 -16
- package/src/extensibility/hooks/runner.ts +2 -2
- package/src/extensibility/runtime-skill-discovery.ts +33 -13
- package/src/extensibility/shared-events.ts +2 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +63 -3
- package/src/gjc-runtime/deep-interview-runtime.ts +76 -27
- package/src/gjc-runtime/deep-interview-state.ts +317 -5
- package/src/gjc-runtime/goal-mode-request.ts +55 -8
- package/src/gjc-runtime/launch-tmux.ts +2 -5
- package/src/gjc-runtime/launch-worktree.ts +5 -5
- package/src/gjc-runtime/linux-proc.ts +71 -0
- package/src/gjc-runtime/ralplan-runtime.ts +74 -90
- package/src/gjc-runtime/session-resolution.ts +18 -3
- package/src/gjc-runtime/session-state-sidecar.ts +6 -107
- package/src/gjc-runtime/state-migrations.ts +1 -1
- package/src/gjc-runtime/state-runtime.ts +320 -249
- package/src/gjc-runtime/state-writer.ts +36 -40
- package/src/gjc-runtime/team-launch.ts +319 -0
- package/src/gjc-runtime/team-notify.ts +326 -0
- package/src/gjc-runtime/team-runtime.ts +1099 -1482
- package/src/gjc-runtime/team-store.ts +828 -0
- package/src/gjc-runtime/team-workers.ts +962 -0
- package/src/gjc-runtime/tmux-owner-isolation-cli.ts +2 -11
- package/src/gjc-runtime/tmux-owner-isolation.ts +14 -9
- package/src/gjc-runtime/tmux-sessions.ts +4 -21
- package/src/gjc-runtime/ultragoal-change-set.ts +172 -0
- package/src/gjc-runtime/ultragoal-evidence.ts +874 -0
- package/src/gjc-runtime/ultragoal-guard.ts +29 -5
- package/src/gjc-runtime/ultragoal-pipeline.ts +306 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +84 -1311
- package/src/gjc-runtime/workflow-cli-common.ts +30 -0
- package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
- package/src/gjc-runtime/workflow-manifest.ts +18 -0
- package/src/goals/continuation-timeout-guard.ts +79 -0
- package/src/goals/runtime.ts +7 -5
- package/src/goals/state.ts +18 -0
- package/src/harness-control-plane/lifecycle-reconciliation.ts +64 -0
- package/src/harness-control-plane/phase-rollup.ts +4 -7
- package/src/harness-control-plane/receipt-ingest.ts +5 -0
- package/src/harness-control-plane/receipt-spool.ts +51 -7
- package/src/harness-control-plane/session-lease.ts +12 -130
- package/src/hindsight/backend.ts +26 -27
- package/src/hindsight/state.ts +101 -32
- package/src/hooks/skill-state.ts +17 -11
- package/src/internal-urls/docs-index.generated.ts +21 -18
- package/src/lsp/client.ts +1 -1
- package/src/lsp/config.ts +145 -26
- package/src/lsp/index.ts +1 -1
- package/src/lsp/lspmux.ts +39 -10
- package/src/lsp/path-trust.ts +81 -0
- package/src/main.ts +28 -8
- package/src/memories/index.ts +1 -0
- package/src/memories/storage.ts +6 -2
- package/src/modes/action-registry.ts +165 -0
- package/src/modes/components/chrome.ts +40 -0
- package/src/modes/components/command-palette.ts +168 -0
- package/src/modes/components/custom-editor.ts +1 -1
- package/src/modes/components/custom-provider-wizard.ts +51 -14
- package/src/modes/components/index.ts +2 -1
- package/src/modes/components/pet-selector.ts +11 -17
- package/src/modes/components/plan-preview-overlay.ts +295 -0
- package/src/modes/components/queue-mode-selector.ts +10 -27
- package/src/modes/components/queue-pane.ts +79 -0
- package/src/modes/components/read-tool-group.ts +13 -0
- package/src/modes/components/session-observer-overlay.ts +207 -772
- package/src/modes/components/sessions-dashboard.ts +181 -0
- package/src/modes/components/settings-selector.ts +1 -1
- package/src/modes/components/show-images-selector.ts +10 -24
- package/src/modes/components/status-line/types.ts +1 -1
- package/src/modes/components/status-line.ts +1 -969
- package/src/modes/components/tasks-pane.ts +73 -0
- package/src/modes/components/theme-selector.ts +11 -31
- package/src/modes/components/tool-execution.ts +22 -0
- package/src/modes/components/tool-status-header.ts +1056 -0
- package/src/modes/components/transcript-viewer-overlay.ts +384 -0
- package/src/modes/controllers/btw-controller.ts +1 -0
- package/src/modes/controllers/event-controller.ts +3 -2
- package/src/modes/controllers/extension-ui-controller.ts +24 -1
- package/src/modes/controllers/goal-mode-controller.ts +474 -0
- package/src/modes/controllers/input-controller.ts +764 -229
- package/src/modes/controllers/mode-gate.ts +24 -0
- package/src/modes/controllers/plan-mode-controller.ts +517 -0
- package/src/modes/controllers/selector-controller.ts +225 -10
- package/src/modes/controllers/stt-controller.ts +125 -0
- package/src/modes/interactive-mode.ts +289 -1150
- package/src/modes/jobs-observer.ts +3 -0
- package/src/modes/runtime-init.ts +8 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -106
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +71 -16
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +177 -0
- package/src/modes/tasks-aggregator.ts +199 -0
- package/src/modes/transcript-item-registry.ts +229 -0
- package/src/modes/types.ts +45 -18
- package/src/modes/utils/ui-helpers.ts +17 -9
- package/src/prompts/agent-fragments/ralplan-persistence.md +6 -0
- package/src/prompts/agent-fragments/restricted-bash.md +1 -0
- package/src/prompts/agents/architect.md +2 -8
- package/src/prompts/agents/critic.md +2 -8
- package/src/prompts/agents/executor.md +3 -0
- package/src/prompts/agents/planner.md +4 -12
- package/src/prompts/system/custom-system-prompt.md +1 -23
- package/src/prompts/system/plan-mode-approved.md +8 -0
- package/src/prompts/system/system-prompt.md +17 -96
- package/src/prompts/tools/bash.md +4 -4
- package/src/prompts/tools/browser.md +15 -52
- package/src/prompts/tools/find.md +2 -2
- package/src/prompts/tools/hashline.md +13 -55
- package/src/prompts/tools/read.md +3 -6
- package/src/prompts/tools/search-tool-bm25.md +0 -2
- package/src/prompts/tools/search.md +2 -4
- package/src/prompts/tools/skill-discovery.md +1 -2
- package/src/prompts/tools/task.md +1 -1
- package/src/runtime-mcp/manager.ts +1 -1
- package/src/sdk/broker/broker.ts +206 -78
- package/src/sdk/broker/discovery.ts +1 -1
- package/src/sdk/broker/ensure.ts +3 -1
- package/src/sdk/broker/lifecycle-codec.ts +11 -0
- package/src/sdk/broker/lifecycle-ledger.ts +371 -74
- package/src/sdk/broker/lifecycle.ts +1399 -142
- package/src/sdk/broker/process-incarnation.ts +5 -13
- package/src/sdk/broker/session-index.ts +145 -56
- package/src/sdk/broker/state-version.ts +21 -0
- package/src/sdk/broker/transport.ts +1 -1
- package/src/sdk/bus/chat-daemon-cli.ts +2 -0
- package/src/sdk/bus/chat-daemon-control.ts +8 -28
- package/src/sdk/bus/chat-effect-journal.ts +10 -2
- package/src/sdk/bus/config-commands.ts +5 -4
- package/src/sdk/bus/config.ts +98 -0
- package/src/sdk/bus/conversation-store.ts +5 -29
- package/src/sdk/bus/daemon-paths.ts +2 -0
- package/src/sdk/bus/html-format.ts +20 -3
- package/src/sdk/bus/index.ts +778 -93
- package/src/sdk/bus/lifecycle-control-runtime.ts +91 -33
- package/src/sdk/bus/natives-augment.d.ts +26 -1
- package/src/sdk/bus/notification-service.ts +168 -20
- package/src/sdk/bus/rate-limit-pool.ts +58 -14
- package/src/sdk/bus/recent-activity.ts +188 -74
- package/src/sdk/bus/rich-document.ts +258 -0
- package/src/sdk/bus/rich-render.ts +62 -70
- package/src/sdk/bus/telegram-daemon-cli.ts +15 -59
- package/src/sdk/bus/telegram-daemon-contract.ts +9 -4
- package/src/sdk/bus/telegram-daemon.ts +1389 -237
- package/src/sdk/bus/threaded-inbound.ts +8 -3
- package/src/sdk/bus/threaded-render.ts +57 -1
- package/src/sdk/bus/topic-registry.ts +120 -13
- package/src/sdk/host/control/dispatch.ts +14 -2
- package/src/sdk/host/control/operations.ts +6 -1
- package/src/sdk/host/host.ts +31 -1
- package/src/sdk/host/query/handlers.ts +20 -22
- package/src/sdk/host/query/revision-store.ts +80 -57
- package/src/sdk/index.ts +1 -0
- package/src/sdk/protocol/operation-inventory.generated.json +943 -1907
- package/src/sdk/session-directory.ts +141 -0
- package/src/sdk/session.ts +458 -479
- package/src/session/agent-session.ts +1700 -583
- package/src/session/agent-storage.ts +18 -0
- package/src/session/cache-economics.ts +25 -8
- package/src/session/fallback-chain-controller.ts +53 -3
- package/src/session/internal/managed-session-scope.ts +2226 -0
- package/src/session/internal/managed-session-storage.ts +453 -0
- package/src/session/messages.ts +91 -2
- package/src/session/session-manager-internal.ts +33 -0
- package/src/session/session-manager.ts +1112 -333
- package/src/session/session-storage.ts +444 -39
- package/src/session/volatile-context-pruning.ts +78 -0
- package/src/setup/credential-auto-import.ts +2 -1
- package/src/setup/hermes/templates/operator-instructions.v1.md +1 -1
- package/src/setup/provider-onboarding.ts +36 -61
- package/src/setup/provider-presets.json +5 -0
- package/src/skill-state/active-state.ts +3 -13
- package/src/skill-state/{deep-interview-mutation-guard.ts → workflow-mutation-guard.ts} +47 -29
- package/src/skill-state/workflow-state-contract.ts +6 -41
- package/src/slash-commands/builtin-registry.ts +46 -11
- package/src/system-prompt.ts +37 -44
- package/src/task/agents.ts +19 -1
- package/src/task/executor.ts +90 -6
- package/src/task/index.ts +36 -21
- package/src/thinking.ts +21 -6
- package/src/tool-discovery/tool-index.ts +2 -134
- package/src/tools/ask.ts +157 -21
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/bash.ts +38 -0
- package/src/tools/browser/dead-tab-recovery.ts +78 -0
- package/src/tools/browser/tab-supervisor.ts +282 -36
- package/src/tools/computer.ts +10 -3
- package/src/tools/cron.ts +30 -16
- package/src/tools/eval.ts +1 -2
- package/src/tools/fetch.ts +11 -4
- package/src/tools/index.ts +76 -24
- package/src/tools/output-meta.ts +13 -5
- package/src/tools/read.ts +37 -1
- package/src/tools/resource-gc.ts +2 -2
- package/src/tools/search-tool-bm25.ts +11 -74
- package/src/utils/event-bus.ts +9 -4
- package/src/web/search/index.ts +3 -3
- package/dist/types/discovery/plugin-dir-roots.d.ts +0 -15
- package/dist/types/runtime-mcp/discoverable-tool-metadata.d.ts +0 -7
- package/src/discovery/plugin-dir-roots.ts +0 -28
- package/src/runtime-mcp/discoverable-tool-metadata.ts +0 -25
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { spawn as childProcessSpawn } from "node:child_process";
|
|
2
3
|
import * as crypto from "node:crypto";
|
|
3
4
|
import * as fs from "node:fs";
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
buildButtonGrid,
|
|
22
23
|
buildCompactChoiceGrid,
|
|
23
24
|
code,
|
|
25
|
+
markdownToTelegramHtml,
|
|
24
26
|
splitTelegramHtml,
|
|
25
27
|
TELEGRAM_MESSAGE_LIMIT,
|
|
26
28
|
TELEGRAM_PARSE_MODE,
|
|
@@ -54,7 +56,12 @@ import { RateLimitPool } from "./rate-limit-pool";
|
|
|
54
56
|
import { listRecentSessions } from "./recent-activity";
|
|
55
57
|
import { ReplySentStore } from "./reply-sent-store";
|
|
56
58
|
import { DraftStreamState, deliverDraft, shouldStreamDraft } from "./rich-draft";
|
|
57
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
deliverRichActionWithFallback,
|
|
61
|
+
deliverRichWithFallback,
|
|
62
|
+
isBtwRichEligible,
|
|
63
|
+
shouldPromoteRich,
|
|
64
|
+
} from "./rich-render";
|
|
58
65
|
import {
|
|
59
66
|
type AliasTable,
|
|
60
67
|
buildActionMarkdown,
|
|
@@ -80,6 +87,11 @@ export interface DaemonState {
|
|
|
80
87
|
startedAt: number;
|
|
81
88
|
heartbeatAt: number;
|
|
82
89
|
roots: string[];
|
|
90
|
+
/**
|
|
91
|
+
* Present only for the Windows source-launch handoff. `pid` starts as this
|
|
92
|
+
* short-lived launcher PID and may be rebound exactly once to the daemon PID.
|
|
93
|
+
*/
|
|
94
|
+
launcherPid?: number;
|
|
83
95
|
version: 1;
|
|
84
96
|
/**
|
|
85
97
|
* Operational daemon generation of the process that owns the lock, distinct
|
|
@@ -117,6 +129,8 @@ export interface TelegramDaemonDeps {
|
|
|
117
129
|
opts: { detached: boolean; stdio: "ignore"; logPath?: string },
|
|
118
130
|
) => SpawnResult;
|
|
119
131
|
execPath?: string;
|
|
132
|
+
/** Injectable platform seam for source-linked Windows daemon spawning. */
|
|
133
|
+
platform?: NodeJS.Platform;
|
|
120
134
|
randomId?: () => string;
|
|
121
135
|
/**
|
|
122
136
|
* Signal delivery + poll timing for the stale-generation reload handoff in
|
|
@@ -136,6 +150,8 @@ export const CLIENT_PING_PONG_CAPABILITY = "client_ping_pong";
|
|
|
136
150
|
/** Capability required for typed controls and semantic Selected acknowledgement frames. */
|
|
137
151
|
export const ASK_SELECTED_ACK_CAPABILITY = "ask_selected_ack_v1";
|
|
138
152
|
export const ASK_CONTROLS_CAPABILITY = "ask_controls_v1";
|
|
153
|
+
/** Capability required for tool lifecycle and reasoning-summary frames. */
|
|
154
|
+
export const TOOL_ACTIVITY_CAPABILITY = "tool_activity_v1";
|
|
139
155
|
|
|
140
156
|
const nodeFs: TelegramDaemonFs = fs.promises as unknown as TelegramDaemonFs;
|
|
141
157
|
|
|
@@ -177,7 +193,50 @@ const ORPHAN_TOPIC_GRACE_MS = 60_000;
|
|
|
177
193
|
const CONSUMED_REACTION = "✅";
|
|
178
194
|
const MODEL_CALLBACK_PREFIX = "m:";
|
|
179
195
|
const MODEL_CHOICE_TTL_MS = 10 * 60 * 1_000;
|
|
196
|
+
const BTW_PENDING_TTL_MS = 300_000;
|
|
197
|
+
const BTW_MAX_PENDING = 256;
|
|
198
|
+
const BTW_SHUTDOWN_JOIN_MS = 1_000;
|
|
199
|
+
const BTW_USAGE_TEXT = "Usage: /btw <question>";
|
|
200
|
+
const BTW_CAPACITY_TEXT = "Too many /btw questions are pending. Wait for one to finish and try again.";
|
|
201
|
+
export const BTW_QUESTION_MAX_UNICODE_SCALARS = 4_096;
|
|
202
|
+
export const BTW_QUESTION_MAX_UTF8_BYTES = 16_384;
|
|
203
|
+
const BTW_QUESTION_LIMIT_TEXT = "Question must be at most 4096 Unicode scalar values and 16384 UTF-8 bytes.";
|
|
204
|
+
type ParsedBtwCommand = { kind: "question"; question: string } | { kind: "ignored" };
|
|
205
|
+
class ThreadedModeCapabilityRefusal extends Error {}
|
|
206
|
+
|
|
207
|
+
/** Only an explicit Bot API capability refusal may enable flat private-chat delivery. */
|
|
208
|
+
function isThreadedModeCapabilityRefusal(response: unknown): boolean {
|
|
209
|
+
if (!response || typeof response !== "object") return false;
|
|
210
|
+
const { ok, description } = response as { ok?: unknown; description?: unknown };
|
|
211
|
+
return (
|
|
212
|
+
ok === false &&
|
|
213
|
+
typeof description === "string" &&
|
|
214
|
+
/(?:threaded mode|forum topics? (?:is|are) (?:disabled|not enabled)|(?:not allowed|not permitted|cannot|can't) create forum topics?)/i.test(
|
|
215
|
+
description,
|
|
216
|
+
)
|
|
217
|
+
);
|
|
218
|
+
}
|
|
180
219
|
|
|
220
|
+
function parseBtwCommand(text: string, botUsername?: string): ParsedBtwCommand | undefined {
|
|
221
|
+
const trimmed = text.trim();
|
|
222
|
+
const [rawCommand] = trimmed.split(/\s+/, 1);
|
|
223
|
+
if (!rawCommand) return undefined;
|
|
224
|
+
const command = rawCommand.toLowerCase();
|
|
225
|
+
if (command === "/btw") return { kind: "question", question: trimmed.slice(rawCommand.length).trim() };
|
|
226
|
+
const match = /^\/btw@([^@]+)$/.exec(command);
|
|
227
|
+
if (!match) return command.startsWith("/btw") ? { kind: "question", question: "" } : undefined;
|
|
228
|
+
if (!botUsername || match[1] !== botUsername.toLowerCase()) return { kind: "ignored" };
|
|
229
|
+
return { kind: "question", question: trimmed.slice(rawCommand.length).trim() };
|
|
230
|
+
}
|
|
231
|
+
function isValidBtwQuestion(question: string): boolean {
|
|
232
|
+
if (Buffer.byteLength(question, "utf8") > BTW_QUESTION_MAX_UTF8_BYTES) return false;
|
|
233
|
+
let scalarCount = 0;
|
|
234
|
+
for (const _scalar of question) {
|
|
235
|
+
scalarCount++;
|
|
236
|
+
if (scalarCount > BTW_QUESTION_MAX_UNICODE_SCALARS) return false;
|
|
237
|
+
}
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
181
240
|
const MODEL_BUTTON_LABEL_MAX_BYTES = 48;
|
|
182
241
|
const SENSITIVE_MODEL_LABEL =
|
|
183
242
|
/(?:\b(?:https?|wss?):\/\/|\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b|\b(?:api[-_ ]?key|access[-_ ]?token|bearer|secret|password|account(?:\s*id)?|email|exception|stack trace)\b|\b(?:sk|pk|rk)-[A-Za-z0-9_-]{12,}\b)/i;
|
|
@@ -220,13 +279,38 @@ function splitTelegramPlainText(text: string, max = TELEGRAM_MESSAGE_LIMIT): str
|
|
|
220
279
|
if (out) chunks.push(out);
|
|
221
280
|
return chunks;
|
|
222
281
|
}
|
|
282
|
+
export function endpointAuthorityDigest(url: string, token: string, connectionIdentity?: string): string {
|
|
283
|
+
// Discovery supplies the authenticated endpoint; normalize presentation-only
|
|
284
|
+
// URL differences before deriving authority, but never trust a client frame.
|
|
285
|
+
const parsed = new URL(url);
|
|
286
|
+
parsed.hash = "";
|
|
287
|
+
parsed.search = "";
|
|
288
|
+
parsed.hostname = parsed.hostname.toLowerCase();
|
|
289
|
+
if (
|
|
290
|
+
((parsed.protocol === "http:" || parsed.protocol === "ws:") && parsed.port === "80") ||
|
|
291
|
+
((parsed.protocol === "https:" || parsed.protocol === "wss:") && parsed.port === "443")
|
|
292
|
+
)
|
|
293
|
+
parsed.port = "";
|
|
294
|
+
return crypto
|
|
295
|
+
.createHash("sha256")
|
|
296
|
+
.update(`${parsed.toString()}\0${token}\0${connectionIdentity ?? ""}`, "utf8")
|
|
297
|
+
.digest("hex");
|
|
298
|
+
}
|
|
299
|
+
|
|
223
300
|
function endpointGenerationKey(url: string, token: string): string {
|
|
224
|
-
return
|
|
301
|
+
return endpointAuthorityDigest(url, token);
|
|
225
302
|
}
|
|
226
303
|
|
|
227
304
|
function topicRenameApplied(response: unknown): boolean {
|
|
228
305
|
return !!response && typeof response === "object" && (response as { ok?: unknown }).ok === true;
|
|
229
306
|
}
|
|
307
|
+
function topicDeleteSettled(response: unknown): boolean {
|
|
308
|
+
if (!response || typeof response !== "object") return false;
|
|
309
|
+
const result = response as { ok?: unknown; description?: unknown };
|
|
310
|
+
if (result.ok === true) return true;
|
|
311
|
+
if (typeof result.description !== "string") return false;
|
|
312
|
+
return /(?:TOPIC_ID_INVALID|message thread not found)/i.test(result.description);
|
|
313
|
+
}
|
|
230
314
|
|
|
231
315
|
/**
|
|
232
316
|
* Whether `err` is a transient network failure worth retrying. Telegram API
|
|
@@ -307,6 +391,7 @@ export function startDaemonLifecycleControl(input: {
|
|
|
307
391
|
const deps = buildOrchestratorDeps({
|
|
308
392
|
pairedChatId: input.pairedChatId,
|
|
309
393
|
agentNotificationsDir: daemonPaths(input.agentDir).dir,
|
|
394
|
+
agentDir: input.agentDir,
|
|
310
395
|
sessionsRoot: path.join(input.agentDir, "sessions"),
|
|
311
396
|
auditRedactionKey: input.auditRedactionKey,
|
|
312
397
|
env: input.env,
|
|
@@ -346,6 +431,24 @@ async function tryOpenWx(fsImpl: TelegramDaemonFs, file: string): Promise<boolea
|
|
|
346
431
|
throw error;
|
|
347
432
|
}
|
|
348
433
|
}
|
|
434
|
+
function validDaemonPid(pid: unknown): pid is number {
|
|
435
|
+
return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function tryCreateOwnershipLock(
|
|
439
|
+
fsImpl: TelegramDaemonFs,
|
|
440
|
+
file: string,
|
|
441
|
+
initialization: { pid: number; startedAt: number },
|
|
442
|
+
): Promise<boolean> {
|
|
443
|
+
try {
|
|
444
|
+
await fsImpl.writeFile(file, `${JSON.stringify(initialization)}\n`, { mode: 0o600, flag: "wx" });
|
|
445
|
+
await fsImpl.chmod(file, 0o600).catch(() => undefined);
|
|
446
|
+
return true;
|
|
447
|
+
} catch (error) {
|
|
448
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
349
452
|
|
|
350
453
|
export async function registerNotificationRoot(input: {
|
|
351
454
|
settings: Settings;
|
|
@@ -417,6 +520,10 @@ function notificationRootForCwd(cwd: string): string {
|
|
|
417
520
|
return path.join(cwd, ".gjc", "state");
|
|
418
521
|
}
|
|
419
522
|
|
|
523
|
+
function validBotToken(token: unknown): token is string {
|
|
524
|
+
return typeof token === "string" && token.length > 0;
|
|
525
|
+
}
|
|
526
|
+
|
|
420
527
|
function ownerIdentityMatches(state: DaemonState, tokenFingerprint: string, chatId: string): boolean {
|
|
421
528
|
return state.tokenFingerprint === tokenFingerprint && state.chatId === chatId;
|
|
422
529
|
}
|
|
@@ -431,7 +538,9 @@ function liveOwnerUsesDifferentIdentity(input: {
|
|
|
431
538
|
return Boolean(
|
|
432
539
|
state &&
|
|
433
540
|
state.version === DAEMON_VERSION &&
|
|
541
|
+
state.stoppedAt === undefined &&
|
|
434
542
|
!ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
|
|
543
|
+
validDaemonPid(state.pid) &&
|
|
435
544
|
input.pidAlive(state.pid),
|
|
436
545
|
);
|
|
437
546
|
}
|
|
@@ -447,8 +556,10 @@ export function isFreshLiveOwner(input: {
|
|
|
447
556
|
return Boolean(
|
|
448
557
|
state &&
|
|
449
558
|
state.version === DAEMON_VERSION &&
|
|
559
|
+
state.stoppedAt === undefined &&
|
|
450
560
|
ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
|
|
451
561
|
input.now - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
|
|
562
|
+
validDaemonPid(state.pid) &&
|
|
452
563
|
input.pidAlive(state.pid),
|
|
453
564
|
);
|
|
454
565
|
}
|
|
@@ -463,6 +574,10 @@ export async function acquireDaemonOwnership(input: {
|
|
|
463
574
|
pid?: number;
|
|
464
575
|
pidAlive?: (pid: number) => boolean;
|
|
465
576
|
randomId?: () => string;
|
|
577
|
+
/** Permit one Windows source launcher PID to daemon PID handoff. */
|
|
578
|
+
allowPidRebind?: boolean;
|
|
579
|
+
/** A caller-supplied opaque owner identity, used when the launcher PID is not durable. */
|
|
580
|
+
ownerId?: string;
|
|
466
581
|
}): Promise<{
|
|
467
582
|
acquired: boolean;
|
|
468
583
|
ownerId?: string;
|
|
@@ -474,142 +589,192 @@ export async function acquireDaemonOwnership(input: {
|
|
|
474
589
|
const fsImpl = input.fs ?? nodeFs;
|
|
475
590
|
const now = input.now ?? Date.now;
|
|
476
591
|
const pid = input.pid ?? process.pid;
|
|
592
|
+
if (!validDaemonPid(pid)) return { acquired: false, attached: true };
|
|
477
593
|
const pidAlive = input.pidAlive ?? defaultPidAlive;
|
|
478
594
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
479
595
|
await ensureDir(fsImpl, paths.dir);
|
|
480
|
-
const ownerId =
|
|
596
|
+
const ownerId =
|
|
597
|
+
input.ownerId ?? input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
481
598
|
const roots = input.roots ?? (await readJson<{ roots?: string[] }>(fsImpl, paths.roots))?.roots ?? [];
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
599
|
+
return withFileLock(paths.ownership, async () => {
|
|
600
|
+
const lockIsPositivelyStale = async (): Promise<boolean> => {
|
|
601
|
+
let initialization: { pid?: unknown; startedAt?: unknown } | undefined;
|
|
602
|
+
try {
|
|
603
|
+
initialization = await readJson<{ pid?: unknown; startedAt?: unknown }>(fsImpl, paths.lock);
|
|
604
|
+
} catch {
|
|
605
|
+
// Legacy daemons wrote an empty lock file. With no live state to
|
|
606
|
+
// identify an owner, malformed metadata must not wedge startup.
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
if (!initialization) return true;
|
|
610
|
+
if (
|
|
611
|
+
!validDaemonPid(initialization.pid) ||
|
|
612
|
+
typeof initialization.startedAt !== "number" ||
|
|
613
|
+
!Number.isFinite(initialization.startedAt)
|
|
614
|
+
)
|
|
615
|
+
return true;
|
|
616
|
+
return now() - initialization.startedAt > HEARTBEAT_TTL_MS && !pidAlive(initialization.pid);
|
|
617
|
+
};
|
|
618
|
+
const ownershipIsPositivelyStale = async (state: DaemonState | undefined): Promise<boolean> => {
|
|
619
|
+
if (state?.stoppedAt !== undefined) return true;
|
|
620
|
+
if (state && validDaemonPid(state.pid)) return !pidAlive(state.pid);
|
|
621
|
+
return await lockIsPositivelyStale();
|
|
622
|
+
};
|
|
623
|
+
const writeState = async (): Promise<void> => {
|
|
624
|
+
const timestamp = now();
|
|
625
|
+
await writeJsonAtomic(fsImpl, paths.state, {
|
|
626
|
+
pid,
|
|
627
|
+
ownerId,
|
|
493
628
|
tokenFingerprint: input.tokenFingerprint,
|
|
494
629
|
chatId: input.chatId,
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
chatId: input.chatId,
|
|
523
|
-
startedAt: now(),
|
|
524
|
-
heartbeatAt: now(),
|
|
525
|
-
roots,
|
|
526
|
-
version: DAEMON_VERSION,
|
|
527
|
-
generation: DAEMON_GENERATION,
|
|
528
|
-
} satisfies DaemonState);
|
|
529
|
-
return { acquired: true, ownerId };
|
|
530
|
-
}
|
|
531
|
-
const afterLock = await readJson<DaemonState>(fsImpl, paths.state);
|
|
532
|
-
if (
|
|
533
|
-
liveOwnerUsesDifferentIdentity({
|
|
534
|
-
state: afterLock,
|
|
535
|
-
tokenFingerprint: input.tokenFingerprint,
|
|
536
|
-
chatId: input.chatId,
|
|
537
|
-
pidAlive,
|
|
538
|
-
})
|
|
539
|
-
) {
|
|
540
|
-
return { acquired: false, blocked: true, reason: "identity_mismatch" };
|
|
541
|
-
}
|
|
542
|
-
const afterLockDecision = attachDecision(afterLock);
|
|
543
|
-
if (afterLockDecision) return afterLockDecision;
|
|
544
|
-
if (!afterLock) return { acquired: false, attached: true };
|
|
545
|
-
if (!(await tryOpenWx(fsImpl, paths.steal))) return { acquired: false, attached: true };
|
|
546
|
-
try {
|
|
547
|
-
const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
|
|
548
|
-
const recheckedDecision = attachDecision(rechecked);
|
|
549
|
-
if (recheckedDecision) return recheckedDecision;
|
|
550
|
-
if (
|
|
630
|
+
startedAt: timestamp,
|
|
631
|
+
heartbeatAt: timestamp,
|
|
632
|
+
roots,
|
|
633
|
+
version: DAEMON_VERSION,
|
|
634
|
+
generation: DAEMON_GENERATION,
|
|
635
|
+
...(input.allowPidRebind ? { launcherPid: pid } : {}),
|
|
636
|
+
} satisfies DaemonState);
|
|
637
|
+
};
|
|
638
|
+
const attachDecision = (
|
|
639
|
+
state: DaemonState | undefined,
|
|
640
|
+
): { acquired: false; attached: boolean; reloadRequired?: boolean } | undefined => {
|
|
641
|
+
if (
|
|
642
|
+
!isFreshLiveOwner({
|
|
643
|
+
state,
|
|
644
|
+
now: now(),
|
|
645
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
646
|
+
chatId: input.chatId,
|
|
647
|
+
pidAlive,
|
|
648
|
+
})
|
|
649
|
+
) {
|
|
650
|
+
return undefined;
|
|
651
|
+
}
|
|
652
|
+
return (state?.generation ?? 0) < DAEMON_GENERATION
|
|
653
|
+
? { acquired: false, attached: false, reloadRequired: true }
|
|
654
|
+
: { acquired: false, attached: true };
|
|
655
|
+
};
|
|
656
|
+
const rejectLiveDifferentIdentity = (state: DaemonState | undefined) =>
|
|
551
657
|
liveOwnerUsesDifferentIdentity({
|
|
552
|
-
state
|
|
658
|
+
state,
|
|
553
659
|
tokenFingerprint: input.tokenFingerprint,
|
|
554
660
|
chatId: input.chatId,
|
|
555
661
|
pidAlive,
|
|
556
|
-
})
|
|
557
|
-
)
|
|
558
|
-
|
|
662
|
+
});
|
|
663
|
+
const existing = await readJson<DaemonState>(fsImpl, paths.state);
|
|
664
|
+
if (rejectLiveDifferentIdentity(existing)) {
|
|
665
|
+
return { acquired: false, blocked: true, reason: "identity_mismatch" } as const;
|
|
559
666
|
}
|
|
560
|
-
|
|
561
|
-
|
|
667
|
+
const existingDecision = attachDecision(existing);
|
|
668
|
+
if (existingDecision) return existingDecision;
|
|
669
|
+
const createdAt = now();
|
|
670
|
+
if (await tryCreateOwnershipLock(fsImpl, paths.lock, { pid, startedAt: createdAt })) {
|
|
671
|
+
await writeState();
|
|
672
|
+
return { acquired: true, ownerId };
|
|
562
673
|
}
|
|
563
|
-
await fsImpl
|
|
564
|
-
if (
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
674
|
+
const afterLock = await readJson<DaemonState>(fsImpl, paths.state);
|
|
675
|
+
if (rejectLiveDifferentIdentity(afterLock)) {
|
|
676
|
+
return { acquired: false, blocked: true, reason: "identity_mismatch" } as const;
|
|
677
|
+
}
|
|
678
|
+
const afterLockDecision = attachDecision(afterLock);
|
|
679
|
+
if (afterLockDecision) return afterLockDecision;
|
|
680
|
+
if (!afterLock && !(await lockIsPositivelyStale())) return { acquired: false, attached: true };
|
|
681
|
+
if (!(await tryOpenWx(fsImpl, paths.steal))) return { acquired: false, attached: true };
|
|
682
|
+
try {
|
|
683
|
+
const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
|
|
684
|
+
const recheckedDecision = attachDecision(rechecked);
|
|
685
|
+
if (recheckedDecision) return recheckedDecision;
|
|
686
|
+
if (rejectLiveDifferentIdentity(rechecked)) {
|
|
687
|
+
return { acquired: false, blocked: true, reason: "identity_mismatch" } as const;
|
|
688
|
+
}
|
|
689
|
+
if (
|
|
690
|
+
rechecked &&
|
|
691
|
+
rechecked.stoppedAt === undefined &&
|
|
692
|
+
validDaemonPid(rechecked.pid) &&
|
|
693
|
+
pidAlive(rechecked.pid)
|
|
694
|
+
) {
|
|
695
|
+
return { acquired: false, attached: true };
|
|
696
|
+
}
|
|
697
|
+
if (!rechecked && !(await lockIsPositivelyStale())) {
|
|
698
|
+
return { acquired: false, attached: true };
|
|
699
|
+
}
|
|
700
|
+
if (!(await ownershipIsPositivelyStale(rechecked))) {
|
|
701
|
+
return { acquired: false, attached: true };
|
|
702
|
+
}
|
|
703
|
+
await fsImpl.unlink(paths.lock).catch(() => undefined);
|
|
704
|
+
if (!(await tryCreateOwnershipLock(fsImpl, paths.lock, { pid, startedAt: createdAt }))) {
|
|
705
|
+
return { acquired: false, attached: true };
|
|
706
|
+
}
|
|
707
|
+
await writeState();
|
|
708
|
+
return { acquired: true, ownerId };
|
|
709
|
+
} finally {
|
|
710
|
+
await fsImpl.unlink(paths.steal).catch(() => undefined);
|
|
711
|
+
}
|
|
712
|
+
});
|
|
580
713
|
}
|
|
581
714
|
|
|
582
715
|
export async function renewDaemonHeartbeat(input: {
|
|
583
716
|
settings: Settings;
|
|
584
717
|
ownerId: string;
|
|
718
|
+
/** Bind a daemon heartbeat to the Telegram identity that acquired the lock. */
|
|
719
|
+
tokenFingerprint: string;
|
|
720
|
+
chatId: string;
|
|
721
|
+
/** Require the current daemon PID after the launcher-to-daemon PID rebind. */
|
|
722
|
+
pid: number;
|
|
585
723
|
fs?: TelegramDaemonFs;
|
|
586
724
|
now?: () => number;
|
|
587
|
-
pid?: number;
|
|
588
725
|
}): Promise<boolean> {
|
|
589
726
|
const fsImpl = input.fs ?? nodeFs;
|
|
590
727
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
728
|
+
return withFileLock(paths.ownership, async () => {
|
|
729
|
+
const state = await readJson<DaemonState>(fsImpl, paths.state);
|
|
730
|
+
if (
|
|
731
|
+
!state ||
|
|
732
|
+
state.stoppedAt !== undefined ||
|
|
733
|
+
typeof input.tokenFingerprint !== "string" ||
|
|
734
|
+
typeof input.chatId !== "string" ||
|
|
735
|
+
!Number.isSafeInteger(input.pid) ||
|
|
736
|
+
input.pid <= 0 ||
|
|
737
|
+
state.ownerId !== input.ownerId ||
|
|
738
|
+
!ownerIdentityMatches(state, input.tokenFingerprint, input.chatId)
|
|
739
|
+
)
|
|
740
|
+
return false;
|
|
741
|
+
if (!validDaemonPid(state.pid)) return false;
|
|
742
|
+
if (state.pid !== input.pid && (!validDaemonPid(state.launcherPid) || state.pid !== state.launcherPid))
|
|
743
|
+
return false;
|
|
744
|
+
await writeJsonAtomic(fsImpl, paths.state, {
|
|
745
|
+
...state,
|
|
746
|
+
pid: input.pid,
|
|
747
|
+
heartbeatAt: (input.now ?? Date.now)(),
|
|
748
|
+
});
|
|
749
|
+
return true;
|
|
597
750
|
});
|
|
598
|
-
return true;
|
|
599
751
|
}
|
|
600
752
|
|
|
601
753
|
export async function releaseDaemonOwnership(input: {
|
|
602
754
|
settings: Settings;
|
|
603
755
|
ownerId: string;
|
|
756
|
+
/** Bind release to the Telegram identity that acquired the lock. */
|
|
757
|
+
tokenFingerprint: string;
|
|
758
|
+
chatId: string;
|
|
759
|
+
/** Require the current daemon PID after the launcher-to-daemon PID rebind. */
|
|
760
|
+
pid: number;
|
|
604
761
|
fs?: TelegramDaemonFs;
|
|
605
762
|
now?: () => number;
|
|
606
763
|
}): Promise<void> {
|
|
607
764
|
const fsImpl = input.fs ?? nodeFs;
|
|
608
765
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
766
|
+
await withFileLock(paths.ownership, async () => {
|
|
767
|
+
const state = await readJson<DaemonState>(fsImpl, paths.state);
|
|
768
|
+
if (
|
|
769
|
+
!state ||
|
|
770
|
+
state.ownerId !== input.ownerId ||
|
|
771
|
+
!ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) ||
|
|
772
|
+
state.pid !== input.pid
|
|
773
|
+
)
|
|
774
|
+
return;
|
|
775
|
+
await writeJsonAtomic(fsImpl, paths.state, { ...state, stoppedAt: (input.now ?? Date.now)() });
|
|
776
|
+
await fsImpl.unlink(paths.lock).catch(() => undefined);
|
|
777
|
+
});
|
|
613
778
|
}
|
|
614
779
|
|
|
615
780
|
/** Read the persisted daemon ownership state (or undefined when absent). */
|
|
@@ -727,6 +892,15 @@ export async function spawnTelegramDaemonOwner(
|
|
|
727
892
|
): Promise<TelegramSpawnOwnerResult> {
|
|
728
893
|
const agentDir = input.settings.getAgentDir();
|
|
729
894
|
const execPath = deps.execPath ?? process.execPath;
|
|
895
|
+
const runtimeInfo = resolveGjcRuntimeSpawnInfo(execPath);
|
|
896
|
+
// On Windows, a source-linked Bun/Node detached child can begin after its
|
|
897
|
+
// short-lived CLI parent has exited. Keep the owner id opaque so the
|
|
898
|
+
// daemon-internal launcher does not mistake that parent PID for its owner;
|
|
899
|
+
// the daemon rebinds state.pid and validates token/chat below.
|
|
900
|
+
const ownerId =
|
|
901
|
+
runtimeInfo.mode === "source" && (deps.platform ?? process.platform) === "win32"
|
|
902
|
+
? `daemon-${deps.randomId?.() ?? crypto.randomUUID()}`
|
|
903
|
+
: undefined;
|
|
730
904
|
const ownership = await acquireDaemonOwnership({
|
|
731
905
|
settings: input.settings,
|
|
732
906
|
roots: input.roots,
|
|
@@ -736,9 +910,11 @@ export async function spawnTelegramDaemonOwner(
|
|
|
736
910
|
now: deps.now,
|
|
737
911
|
pid: deps.pid,
|
|
738
912
|
pidAlive: deps.pidAlive,
|
|
739
|
-
randomId: deps.randomId,
|
|
913
|
+
randomId: ownerId ? undefined : deps.randomId,
|
|
914
|
+
ownerId,
|
|
915
|
+
allowPidRebind: true,
|
|
740
916
|
});
|
|
741
|
-
//
|
|
917
|
+
// Build args from the shared runtime resolver.
|
|
742
918
|
const { command, args, runtime } = buildTelegramDaemonSpawnArgs({
|
|
743
919
|
execPath,
|
|
744
920
|
ownerId: ownership.ownerId ?? "",
|
|
@@ -1131,6 +1307,7 @@ export interface TelegramDaemonOptions {
|
|
|
1131
1307
|
clearTimeoutImpl?: typeof clearTimeout;
|
|
1132
1308
|
setIntervalImpl?: typeof setInterval;
|
|
1133
1309
|
clearIntervalImpl?: typeof clearInterval;
|
|
1310
|
+
btw?: { enabled: boolean };
|
|
1134
1311
|
idleTimeoutMs?: number;
|
|
1135
1312
|
scanIntervalMs?: number;
|
|
1136
1313
|
pid?: number;
|
|
@@ -1174,10 +1351,13 @@ interface SessionSocket {
|
|
|
1174
1351
|
logicalSessionId: string;
|
|
1175
1352
|
token: string;
|
|
1176
1353
|
endpointKey: string;
|
|
1354
|
+
endpointDigest: string;
|
|
1355
|
+
hostGeneration: number;
|
|
1177
1356
|
ws: WebSocket;
|
|
1178
1357
|
pending: Map<string, { sessionId: string; actionId: string }>;
|
|
1179
1358
|
/** True once the server advertised the `client_ping_pong` capability. */
|
|
1180
1359
|
capable: boolean;
|
|
1360
|
+
ephemeralCapable: boolean;
|
|
1181
1361
|
/** Timestamp (via opts.now) of the last received pong; seeds the TTL window. */
|
|
1182
1362
|
lastPongAt: number;
|
|
1183
1363
|
/** Nonce of the most recent in-flight ping, if any. */
|
|
@@ -1205,6 +1385,12 @@ interface RenderedModelChoice {
|
|
|
1205
1385
|
label: string;
|
|
1206
1386
|
}
|
|
1207
1387
|
|
|
1388
|
+
interface TopicAuthorityLease {
|
|
1389
|
+
sessionId: string;
|
|
1390
|
+
topicId: string;
|
|
1391
|
+
authorityEpoch: number;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1208
1394
|
interface PendingThreadedFrame {
|
|
1209
1395
|
send: ThreadedSend;
|
|
1210
1396
|
msg: Record<string, unknown>;
|
|
@@ -1214,6 +1400,15 @@ type SelectedAckOutcome =
|
|
|
1214
1400
|
| { status: "delivered"; messageId: number }
|
|
1215
1401
|
| { status: "failed"; reason: "route_missing" | "expired" | "cancelled" | "telegram_rejected" }
|
|
1216
1402
|
| { status: "unknown"; reason: "transport_ambiguous" | "shutdown" };
|
|
1403
|
+
type BtwQueuedDeliveryOutcome = "accepted" | "not_delivered" | "uncertain" | "stale" | "partial_accepted";
|
|
1404
|
+
|
|
1405
|
+
interface BtwQueuedDelivery {
|
|
1406
|
+
pending: PendingBtwTurn;
|
|
1407
|
+
body: Record<string, unknown>;
|
|
1408
|
+
signal: AbortSignal;
|
|
1409
|
+
isAuthoritative: () => boolean;
|
|
1410
|
+
finish: (outcome: BtwQueuedDeliveryOutcome) => void;
|
|
1411
|
+
}
|
|
1217
1412
|
|
|
1218
1413
|
interface SelectedAckQueueItem {
|
|
1219
1414
|
pendingKey: string;
|
|
@@ -1229,8 +1424,92 @@ interface SelectedAckQueueItem {
|
|
|
1229
1424
|
|
|
1230
1425
|
interface TelegramQueuePayload {
|
|
1231
1426
|
send: ThreadedSend;
|
|
1232
|
-
|
|
1427
|
+
topicLease?: TopicAuthorityLease;
|
|
1233
1428
|
selectedAck?: SelectedAckQueueItem;
|
|
1429
|
+
btwDelivery?: BtwQueuedDelivery;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
interface PendingBtwTurn {
|
|
1433
|
+
/** Immutable transport/topic ownership key. */
|
|
1434
|
+
transportSessionId: string;
|
|
1435
|
+
/** Logical session id supplied to the session endpoint. */
|
|
1436
|
+
logicalSessionId: string;
|
|
1437
|
+
endpointDigest: string;
|
|
1438
|
+
generation: number;
|
|
1439
|
+
question: string;
|
|
1440
|
+
messageId: number;
|
|
1441
|
+
threadId: string;
|
|
1442
|
+
updateId: number;
|
|
1443
|
+
expiresAt: number;
|
|
1444
|
+
}
|
|
1445
|
+
interface PendingBtwDelivery {
|
|
1446
|
+
pending: PendingBtwTurn;
|
|
1447
|
+
/** The owning session changed while this terminal Bot API call was in flight. */
|
|
1448
|
+
invalidated: boolean;
|
|
1449
|
+
/** Whether a definitive authority loss should emit the session-unavailable reply. */
|
|
1450
|
+
terminalizeOnInvalidation: boolean;
|
|
1451
|
+
controller: AbortController;
|
|
1452
|
+
finished: Promise<void>;
|
|
1453
|
+
finish: () => void;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
class TelegramEffectSupervisor {
|
|
1457
|
+
#stopping = false;
|
|
1458
|
+
readonly #abort = new AbortController();
|
|
1459
|
+
readonly #terminalContext = new AsyncLocalStorage<boolean>();
|
|
1460
|
+
readonly #pending = new Set<Promise<unknown>>();
|
|
1461
|
+
|
|
1462
|
+
call(
|
|
1463
|
+
api: BotApi,
|
|
1464
|
+
method: string,
|
|
1465
|
+
body: unknown,
|
|
1466
|
+
opts?: { signal?: AbortSignal; noRetry?: boolean },
|
|
1467
|
+
): Promise<unknown> {
|
|
1468
|
+
const terminal = this.#terminalContext.getStore() === true;
|
|
1469
|
+
if (this.#stopping && !terminal)
|
|
1470
|
+
return Promise.reject(Object.assign(new Error("Daemon is stopping"), { name: "AbortError" }));
|
|
1471
|
+
const signal = terminal
|
|
1472
|
+
? opts?.signal
|
|
1473
|
+
: opts?.signal
|
|
1474
|
+
? AbortSignal.any([this.#abort.signal, opts.signal])
|
|
1475
|
+
: this.#abort.signal;
|
|
1476
|
+
return this.track(api.call(method, body, { ...opts, signal }));
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
track<T>(effect: Promise<T>): Promise<T> {
|
|
1480
|
+
this.#pending.add(effect);
|
|
1481
|
+
void effect.then(
|
|
1482
|
+
() => this.#pending.delete(effect),
|
|
1483
|
+
() => this.#pending.delete(effect),
|
|
1484
|
+
);
|
|
1485
|
+
return effect;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
get stopping(): boolean {
|
|
1489
|
+
return this.#stopping;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
beginShutdown(): void {
|
|
1493
|
+
this.#stopping = true;
|
|
1494
|
+
this.#abort.abort("daemon_shutdown");
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
allowTerminal<T>(effect: () => Promise<T>): Promise<T> {
|
|
1498
|
+
return this.#terminalContext.run(true, () => this.track(effect()));
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
async join(deadlineMs: number): Promise<boolean> {
|
|
1502
|
+
const expiresAt = Date.now() + deadlineMs;
|
|
1503
|
+
while (this.#pending.size > 0) {
|
|
1504
|
+
const remaining = expiresAt - Date.now();
|
|
1505
|
+
if (remaining <= 0) return false;
|
|
1506
|
+
await Promise.race([
|
|
1507
|
+
Promise.allSettled([...this.#pending]),
|
|
1508
|
+
new Promise<void>(resolve => setTimeout(resolve, remaining)),
|
|
1509
|
+
]);
|
|
1510
|
+
}
|
|
1511
|
+
return true;
|
|
1512
|
+
}
|
|
1234
1513
|
}
|
|
1235
1514
|
|
|
1236
1515
|
export class TelegramNotificationDaemon {
|
|
@@ -1249,12 +1528,22 @@ export class TelegramNotificationDaemon {
|
|
|
1249
1528
|
private running = false;
|
|
1250
1529
|
private readonly fsImpl: TelegramDaemonFs;
|
|
1251
1530
|
private readonly botApi: BotApi;
|
|
1531
|
+
private readonly effects = new TelegramEffectSupervisor();
|
|
1252
1532
|
private readonly topics = new TopicRegistry();
|
|
1253
1533
|
/** Serializes registry snapshots so an older atomic write cannot overwrite newer rename state. */
|
|
1254
1534
|
private topicsPersistQueue: Promise<void> = Promise.resolve();
|
|
1255
1535
|
/** Daemon edit attempts that can race an accepted user service message. */
|
|
1256
1536
|
private readonly daemonRenameAttempts = new Map<string, number>();
|
|
1257
1537
|
private readonly selectedAckPending = new Map<string, SelectedAckQueueItem>();
|
|
1538
|
+
#pendingBtwTurns = new Map<string, PendingBtwTurn>();
|
|
1539
|
+
#btwTerminalDeliveries = new Map<string, PendingBtwDelivery>();
|
|
1540
|
+
#btwTerminalTombstones = new Map<
|
|
1541
|
+
string,
|
|
1542
|
+
{ sessionId: string; updateId: number; messageId: number; threadId: string; expiresAt: number }
|
|
1543
|
+
>();
|
|
1544
|
+
#stoppingBtw = false;
|
|
1545
|
+
readonly #btwDeliveryAbort = new AbortController();
|
|
1546
|
+
readonly #deliveryAbort = new AbortController();
|
|
1258
1547
|
private readonly pool: RateLimitPool<TelegramQueuePayload>;
|
|
1259
1548
|
private readonly poller: TelegramUpdatePoller;
|
|
1260
1549
|
private readonly dispatchState = new TelegramEventDispatchState();
|
|
@@ -1337,11 +1626,18 @@ export class TelegramNotificationDaemon {
|
|
|
1337
1626
|
* ~25s getUpdates timeout. Safe to call from a signal handler.
|
|
1338
1627
|
*/
|
|
1339
1628
|
requestStop(_reason?: "reload" | "stop" | "signal"): void {
|
|
1629
|
+
this.effects.beginShutdown();
|
|
1340
1630
|
for (const item of new Set(this.selectedAckPending.values())) {
|
|
1341
1631
|
if (item.state === "queued") this.pool.removeById(item.itemId);
|
|
1342
1632
|
else item.controller?.abort();
|
|
1343
1633
|
this.finishSelectedAck(item, { status: "unknown", reason: "shutdown" });
|
|
1344
1634
|
}
|
|
1635
|
+
this.#stoppingBtw = true;
|
|
1636
|
+
for (const delivery of this.#btwTerminalDeliveries.values()) {
|
|
1637
|
+
delivery.invalidated = true;
|
|
1638
|
+
delivery.controller.abort("daemon_shutdown");
|
|
1639
|
+
}
|
|
1640
|
+
this.#deliveryAbort.abort();
|
|
1345
1641
|
this.runtime.requestStop();
|
|
1346
1642
|
this.running = false;
|
|
1347
1643
|
}
|
|
@@ -1368,8 +1664,14 @@ export class TelegramNotificationDaemon {
|
|
|
1368
1664
|
const deps = (this.opts.createLifecycleOrchestratorDeps ?? buildOrchestratorDeps)({
|
|
1369
1665
|
pairedChatId: this.opts.chatId,
|
|
1370
1666
|
agentNotificationsDir: daemonPaths(agentDir).dir,
|
|
1667
|
+
agentDir,
|
|
1371
1668
|
sessionsRoot: path.join(agentDir, "sessions"),
|
|
1372
|
-
auditRedactionKey: deriveLifecycleAuditRedactionKey(
|
|
1669
|
+
auditRedactionKey: deriveLifecycleAuditRedactionKey(
|
|
1670
|
+
(() => {
|
|
1671
|
+
if (!validBotToken(this.opts.botToken)) throw new Error("invalid Telegram bot token");
|
|
1672
|
+
return this.opts.botToken;
|
|
1673
|
+
})(),
|
|
1674
|
+
),
|
|
1373
1675
|
});
|
|
1374
1676
|
// Register the lifecycle-request handler BEFORE start(): the native
|
|
1375
1677
|
// control server captures the callback at start time, so wiring must
|
|
@@ -1610,15 +1912,22 @@ export class TelegramNotificationDaemon {
|
|
|
1610
1912
|
return true;
|
|
1611
1913
|
}
|
|
1612
1914
|
if (parsed.kind === "recent") {
|
|
1613
|
-
const recent = listRecentSessions({
|
|
1614
|
-
|
|
1915
|
+
const recent = await listRecentSessions({
|
|
1916
|
+
cwd: process.cwd(),
|
|
1917
|
+
agentDir: this.opts.settings.getAgentDir(),
|
|
1615
1918
|
limit: 10,
|
|
1616
1919
|
includeInternal: false,
|
|
1920
|
+
allWorkspaces: true,
|
|
1617
1921
|
});
|
|
1618
|
-
const body =
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1922
|
+
const body =
|
|
1923
|
+
recent.kind === "error"
|
|
1924
|
+
? `Recent sessions could not be verified: ${recent.message}`
|
|
1925
|
+
: recent.entries.length
|
|
1926
|
+
? recent.entries.map(e => `• ${code(e.sessionId)}${e.path ? ` (${code(e.path)})` : ""}`).join("\n")
|
|
1927
|
+
: "No recent sessions.";
|
|
1928
|
+
await replyHtml(
|
|
1929
|
+
recent.kind === "complete" && recent.warnings.length ? `${body}\n\n${recent.warnings.join("\n")}` : body,
|
|
1930
|
+
);
|
|
1622
1931
|
return true;
|
|
1623
1932
|
}
|
|
1624
1933
|
|
|
@@ -1657,7 +1966,7 @@ export class TelegramNotificationDaemon {
|
|
|
1657
1966
|
this.fsImpl = opts.fs ?? nodeFs;
|
|
1658
1967
|
this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
|
|
1659
1968
|
this.aliasTable = createAliasTable();
|
|
1660
|
-
|
|
1969
|
+
const rawBotApi =
|
|
1661
1970
|
opts.botApi ??
|
|
1662
1971
|
new TelegramBotTransport({
|
|
1663
1972
|
botToken: opts.botToken,
|
|
@@ -1665,6 +1974,9 @@ export class TelegramNotificationDaemon {
|
|
|
1665
1974
|
fetchImpl: opts.fetchImpl,
|
|
1666
1975
|
setTimeoutImpl: opts.setTimeoutImpl,
|
|
1667
1976
|
});
|
|
1977
|
+
this.botApi = {
|
|
1978
|
+
call: (method, body, callOpts) => this.effects.call(rawBotApi, method, body, callOpts),
|
|
1979
|
+
};
|
|
1668
1980
|
this.runtime = new NotificationOperatorRuntime({
|
|
1669
1981
|
now: opts.now,
|
|
1670
1982
|
setTimeoutImpl: opts.setTimeoutImpl,
|
|
@@ -1673,7 +1985,7 @@ export class TelegramNotificationDaemon {
|
|
|
1673
1985
|
clearIntervalImpl: opts.clearIntervalImpl,
|
|
1674
1986
|
});
|
|
1675
1987
|
this.sessionRouter = this.createSessionRouter();
|
|
1676
|
-
this.pool = new RateLimitPool<
|
|
1988
|
+
this.pool = new RateLimitPool<TelegramQueuePayload>({ now: opts.now });
|
|
1677
1989
|
this.poller = new TelegramUpdatePoller({
|
|
1678
1990
|
botApi: this.botApi,
|
|
1679
1991
|
runtime: this.runtime,
|
|
@@ -1689,6 +2001,7 @@ export class TelegramNotificationDaemon {
|
|
|
1689
2001
|
matches: msg => msg.type === "hello",
|
|
1690
2002
|
handle: (session, msg) => {
|
|
1691
2003
|
const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
|
|
2004
|
+
if (caps.includes("ephemeral_turn_v1")) session.ephemeralCapable = true;
|
|
1692
2005
|
if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
|
|
1693
2006
|
session.capable = true;
|
|
1694
2007
|
this.startLiveness(session);
|
|
@@ -1728,8 +2041,11 @@ export class TelegramNotificationDaemon {
|
|
|
1728
2041
|
finishImmediately({ status: "failed", reason: "route_missing" });
|
|
1729
2042
|
return;
|
|
1730
2043
|
}
|
|
1731
|
-
const
|
|
1732
|
-
if (
|
|
2044
|
+
const topicLease = this.topicAuthorityLeaseFromRegistry(session.sessionId);
|
|
2045
|
+
if (
|
|
2046
|
+
this.topics.get(session.sessionId)?.authorityState === "delete_pending" ||
|
|
2047
|
+
(mode === "recovery" && (!topicLease || msg.sessionId !== session.sessionId))
|
|
2048
|
+
) {
|
|
1733
2049
|
finishImmediately({ status: "failed", reason: "route_missing" });
|
|
1734
2050
|
return;
|
|
1735
2051
|
}
|
|
@@ -1758,14 +2074,14 @@ export class TelegramNotificationDaemon {
|
|
|
1758
2074
|
followers: [],
|
|
1759
2075
|
};
|
|
1760
2076
|
this.selectedAckPending.set(pendingKey, item);
|
|
1761
|
-
this.
|
|
2077
|
+
this.submitPool({
|
|
1762
2078
|
sessionId: session.sessionId,
|
|
1763
2079
|
lane: "ask",
|
|
1764
2080
|
itemId: item.itemId,
|
|
1765
2081
|
deadlineAt,
|
|
1766
2082
|
payload: {
|
|
1767
2083
|
send: { method: "sendMessage", lane: "ask", text: "Selected!" },
|
|
1768
|
-
|
|
2084
|
+
topicLease,
|
|
1769
2085
|
selectedAck: item,
|
|
1770
2086
|
},
|
|
1771
2087
|
});
|
|
@@ -1862,6 +2178,7 @@ export class TelegramNotificationDaemon {
|
|
|
1862
2178
|
handle: async session => {
|
|
1863
2179
|
this.busy.delete(session.sessionId);
|
|
1864
2180
|
this.closedEndpointKeys.set(session.sessionId, session.endpointKey);
|
|
2181
|
+
await this.#terminalizeBtwTurnsForSession(session, true);
|
|
1865
2182
|
await this.deleteTopic(session.sessionId);
|
|
1866
2183
|
this.dropSession(session, "session_closed");
|
|
1867
2184
|
},
|
|
@@ -1923,7 +2240,7 @@ export class TelegramNotificationDaemon {
|
|
|
1923
2240
|
logger.warn(`notifications: failed to persist Telegram update id ${updateId}: ${String(err)}`);
|
|
1924
2241
|
}
|
|
1925
2242
|
}
|
|
1926
|
-
private async
|
|
2243
|
+
private async reserveSeenUpdateId(updateId: number): Promise<boolean> {
|
|
1927
2244
|
if (!Number.isSafeInteger(updateId) || updateId < 0) return false;
|
|
1928
2245
|
const candidate = new Set(this.dispatchState.seenUpdateIds);
|
|
1929
2246
|
candidate.add(updateId);
|
|
@@ -1936,7 +2253,7 @@ export class TelegramNotificationDaemon {
|
|
|
1936
2253
|
updateIds: [...candidate],
|
|
1937
2254
|
});
|
|
1938
2255
|
} catch {
|
|
1939
|
-
logger.warn("notifications:
|
|
2256
|
+
logger.warn("notifications: Telegram update state publication failed");
|
|
1940
2257
|
return false;
|
|
1941
2258
|
}
|
|
1942
2259
|
this.dispatchState.seenUpdateIds.clear();
|
|
@@ -1961,20 +2278,28 @@ export class TelegramNotificationDaemon {
|
|
|
1961
2278
|
for (const file of files.filter(item => item.endsWith(".json"))) {
|
|
1962
2279
|
const sessionId = path.basename(file, ".json");
|
|
1963
2280
|
endpointSessionIds.add(sessionId);
|
|
1964
|
-
if (this.sessions.has(sessionId)) continue;
|
|
1965
2281
|
try {
|
|
1966
2282
|
const endpoint = readEndpoint(path.join(dir, file));
|
|
1967
|
-
//
|
|
1968
|
-
//
|
|
1969
|
-
//
|
|
1970
|
-
//
|
|
1971
|
-
// same best-effort delete path as graceful session shutdown.
|
|
2283
|
+
// Validate endpoint ownership even for an already-connected socket.
|
|
2284
|
+
// A hard-killed owner can leave both its endpoint file and socket map
|
|
2285
|
+
// entry behind; skipping the read in that case permanently preserves
|
|
2286
|
+
// the stale Telegram topic.
|
|
1972
2287
|
const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
|
|
1973
2288
|
if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) {
|
|
1974
|
-
|
|
2289
|
+
const connected = this.sessions.get(sessionId);
|
|
2290
|
+
if (connected) this.dropSession(connected, "endpoint_owner_dead");
|
|
2291
|
+
else this.#terminalizeBtwTurnsForTransportSession(sessionId);
|
|
2292
|
+
await this.observeOrphanedTopic(sessionId);
|
|
1975
2293
|
continue;
|
|
1976
2294
|
}
|
|
2295
|
+
if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
|
|
1977
2296
|
const endpointKey = endpointGenerationKey(endpoint.url, endpoint.token);
|
|
2297
|
+
const connected = this.sessions.get(sessionId);
|
|
2298
|
+
if (connected) {
|
|
2299
|
+
if (connected.endpointKey !== endpointKey)
|
|
2300
|
+
this.connectSession(sessionId, endpoint.url, endpoint.token);
|
|
2301
|
+
continue;
|
|
2302
|
+
}
|
|
1978
2303
|
if (this.closedEndpointKeys.get(sessionId) === endpointKey) continue;
|
|
1979
2304
|
this.closedEndpointKeys.delete(sessionId);
|
|
1980
2305
|
this.connectSession(sessionId, endpoint.url, endpoint.token);
|
|
@@ -1983,8 +2308,10 @@ export class TelegramNotificationDaemon {
|
|
|
1983
2308
|
}
|
|
1984
2309
|
if (allRootsReadable) {
|
|
1985
2310
|
for (const sessionId of this.topics.sessionIds()) {
|
|
1986
|
-
if (!this.sessions.has(sessionId) && !endpointSessionIds.has(sessionId))
|
|
1987
|
-
|
|
2311
|
+
if (!this.sessions.has(sessionId) && !endpointSessionIds.has(sessionId)) {
|
|
2312
|
+
this.#terminalizeBtwTurnsForTransportSession(sessionId);
|
|
2313
|
+
await this.observeOrphanedTopic(sessionId);
|
|
2314
|
+
}
|
|
1988
2315
|
}
|
|
1989
2316
|
}
|
|
1990
2317
|
}
|
|
@@ -1993,9 +2320,17 @@ export class TelegramNotificationDaemon {
|
|
|
1993
2320
|
const WS = this.opts.WebSocketImpl ?? WebSocket;
|
|
1994
2321
|
const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
|
|
1995
2322
|
const endpointKey = endpointGenerationKey(url, token);
|
|
2323
|
+
const endpointDigest = endpointAuthorityDigest(url, token);
|
|
1996
2324
|
this.closedEndpointKeys.delete(sessionId);
|
|
1997
2325
|
const existing = this.sessions.get(sessionId);
|
|
1998
|
-
if (existing)
|
|
2326
|
+
if (existing) {
|
|
2327
|
+
this.dropSession(
|
|
2328
|
+
existing,
|
|
2329
|
+
existing.endpointDigest === endpointDigest ? "same_authority_replaced" : "authority_replaced",
|
|
2330
|
+
);
|
|
2331
|
+
} else {
|
|
2332
|
+
this.#terminalizeBtwTurnsForEndpointReplacement(sessionId, endpointDigest);
|
|
2333
|
+
}
|
|
1999
2334
|
this.#clearModelChoiceAliases(sessionId);
|
|
2000
2335
|
|
|
2001
2336
|
const session: SessionSocket = {
|
|
@@ -2004,9 +2339,12 @@ export class TelegramNotificationDaemon {
|
|
|
2004
2339
|
|
|
2005
2340
|
token,
|
|
2006
2341
|
endpointKey,
|
|
2342
|
+
endpointDigest,
|
|
2343
|
+
hostGeneration: 0,
|
|
2007
2344
|
ws,
|
|
2008
2345
|
pending: new Map(),
|
|
2009
2346
|
capable: false,
|
|
2347
|
+
ephemeralCapable: false,
|
|
2010
2348
|
lastPongAt: 0,
|
|
2011
2349
|
awaitingNonce: undefined,
|
|
2012
2350
|
pingTimer: undefined,
|
|
@@ -2028,7 +2366,13 @@ export class TelegramNotificationDaemon {
|
|
|
2028
2366
|
JSON.stringify({
|
|
2029
2367
|
type: "hello",
|
|
2030
2368
|
protocolVersion: NOTIFICATION_PROTOCOL_VERSION,
|
|
2031
|
-
capabilities: [
|
|
2369
|
+
capabilities: [
|
|
2370
|
+
CLIENT_PING_PONG_CAPABILITY,
|
|
2371
|
+
ASK_CONTROLS_CAPABILITY,
|
|
2372
|
+
ASK_SELECTED_ACK_CAPABILITY,
|
|
2373
|
+
TOOL_ACTIVITY_CAPABILITY,
|
|
2374
|
+
"ephemeral_turn_v1",
|
|
2375
|
+
],
|
|
2032
2376
|
}),
|
|
2033
2377
|
);
|
|
2034
2378
|
} catch {}
|
|
@@ -2106,6 +2450,11 @@ export class TelegramNotificationDaemon {
|
|
|
2106
2450
|
clearIntervalImpl(session.pingTimer);
|
|
2107
2451
|
session.pingTimer = undefined;
|
|
2108
2452
|
}
|
|
2453
|
+
if (reason === "socket_closed" || reason === "same_authority_replaced") {
|
|
2454
|
+
this.#invalidateBtwDeliveriesForSession(session);
|
|
2455
|
+
} else {
|
|
2456
|
+
void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
|
|
2457
|
+
}
|
|
2109
2458
|
const isCurrentSession = this.sessions.get(session.sessionId) === session;
|
|
2110
2459
|
if (isCurrentSession || reason === "session_closed") {
|
|
2111
2460
|
this.deleteMessageRoutes(session.sessionId);
|
|
@@ -2128,6 +2477,280 @@ export class TelegramNotificationDaemon {
|
|
|
2128
2477
|
}
|
|
2129
2478
|
}
|
|
2130
2479
|
|
|
2480
|
+
#purgeBtwTombstones(): void {
|
|
2481
|
+
const now = this.opts.now?.() ?? Date.now();
|
|
2482
|
+
for (const [requestId, tombstone] of this.#btwTerminalTombstones)
|
|
2483
|
+
if (tombstone.expiresAt <= now) this.#btwTerminalTombstones.delete(requestId);
|
|
2484
|
+
}
|
|
2485
|
+
#finishQueuedBtwDeliveries(pending: PendingBtwTurn, outcome: BtwQueuedDeliveryOutcome): void {
|
|
2486
|
+
for (const item of this.pool.removeWhere(item => item.payload.btwDelivery?.pending === pending)) {
|
|
2487
|
+
item.payload.btwDelivery?.finish(outcome);
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
#takeBtwTurn(requestId: string, pending: PendingBtwTurn): boolean {
|
|
2491
|
+
if (this.#pendingBtwTurns.get(requestId) !== pending) return false;
|
|
2492
|
+
this.#pendingBtwTurns.delete(requestId);
|
|
2493
|
+
this.#btwTerminalTombstones.set(requestId, {
|
|
2494
|
+
sessionId: pending.logicalSessionId,
|
|
2495
|
+
updateId: pending.updateId,
|
|
2496
|
+
messageId: pending.messageId,
|
|
2497
|
+
threadId: pending.threadId,
|
|
2498
|
+
expiresAt: (this.opts.now?.() ?? Date.now()) + BTW_PENDING_TTL_MS,
|
|
2499
|
+
});
|
|
2500
|
+
while (this.#pendingBtwTurns.size + this.#btwTerminalTombstones.size > BTW_MAX_PENDING)
|
|
2501
|
+
this.#btwTerminalTombstones.delete(this.#btwTerminalTombstones.keys().next().value!);
|
|
2502
|
+
return true;
|
|
2503
|
+
}
|
|
2504
|
+
#invalidateBtwDeliveriesForSession(session: SessionSocket): void {
|
|
2505
|
+
for (const delivery of this.#btwTerminalDeliveries.values()) {
|
|
2506
|
+
const pending = delivery.pending;
|
|
2507
|
+
if (
|
|
2508
|
+
pending.transportSessionId !== session.sessionId ||
|
|
2509
|
+
pending.logicalSessionId !== this.#logicalSessionId(session) ||
|
|
2510
|
+
pending.endpointDigest !== session.endpointDigest ||
|
|
2511
|
+
pending.generation !== session.hostGeneration
|
|
2512
|
+
)
|
|
2513
|
+
continue;
|
|
2514
|
+
delivery.invalidated = true;
|
|
2515
|
+
this.#finishQueuedBtwDeliveries(pending, "stale");
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
#terminalizeBtwTurnsForEndpointReplacement(sessionId: string, endpointDigest: string): void {
|
|
2519
|
+
for (const [requestId, pending] of this.#pendingBtwTurns) {
|
|
2520
|
+
if (pending.transportSessionId !== sessionId || pending.endpointDigest === endpointDigest) continue;
|
|
2521
|
+
void this.#terminalizeBtwTurn(requestId, pending).catch(() => undefined);
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
#terminalizeBtwTurnsForTransportSession(sessionId: string): void {
|
|
2525
|
+
for (const [requestId, pending] of this.#pendingBtwTurns) {
|
|
2526
|
+
if (pending.transportSessionId !== sessionId) continue;
|
|
2527
|
+
void this.#terminalizeBtwTurn(requestId, pending).catch(() => undefined);
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
#terminalizeBtwTurnsForGenerationChange(session: SessionSocket): void {
|
|
2531
|
+
for (const [requestId, pending] of this.#pendingBtwTurns) {
|
|
2532
|
+
if (
|
|
2533
|
+
pending.transportSessionId !== session.sessionId ||
|
|
2534
|
+
pending.endpointDigest !== session.endpointDigest ||
|
|
2535
|
+
pending.logicalSessionId !== this.#logicalSessionId(session) ||
|
|
2536
|
+
pending.generation === session.hostGeneration
|
|
2537
|
+
)
|
|
2538
|
+
continue;
|
|
2539
|
+
void this.#terminalizeBtwTurn(requestId, pending).catch(() => undefined);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
#resumeBtwTurnsForSession(session: SessionSocket): void {
|
|
2543
|
+
if (!session.ephemeralCapable || session.hostGeneration < 1 || session.ws.readyState !== WebSocket.OPEN) return;
|
|
2544
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
2545
|
+
const now = this.opts.now?.() ?? Date.now();
|
|
2546
|
+
for (const [requestId, pending] of this.#pendingBtwTurns) {
|
|
2547
|
+
if (pending.expiresAt <= now) {
|
|
2548
|
+
this.#pendingBtwTurns.delete(requestId);
|
|
2549
|
+
continue;
|
|
2550
|
+
}
|
|
2551
|
+
if (
|
|
2552
|
+
pending.transportSessionId !== session.sessionId ||
|
|
2553
|
+
pending.logicalSessionId !== logicalSessionId ||
|
|
2554
|
+
pending.endpointDigest !== session.endpointDigest ||
|
|
2555
|
+
pending.generation !== session.hostGeneration
|
|
2556
|
+
)
|
|
2557
|
+
continue;
|
|
2558
|
+
this.#sendPendingBtwTurn(session, requestId, pending);
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
#sendPendingBtwTurn(session: SessionSocket, requestId: string, pending: PendingBtwTurn): boolean {
|
|
2562
|
+
try {
|
|
2563
|
+
session.ws.send(
|
|
2564
|
+
JSON.stringify({
|
|
2565
|
+
type: "ephemeral_turn",
|
|
2566
|
+
sessionId: pending.logicalSessionId,
|
|
2567
|
+
question: pending.question,
|
|
2568
|
+
token: session.token,
|
|
2569
|
+
requestId,
|
|
2570
|
+
updateId: pending.updateId,
|
|
2571
|
+
threadId: pending.threadId,
|
|
2572
|
+
messageId: pending.messageId,
|
|
2573
|
+
}),
|
|
2574
|
+
);
|
|
2575
|
+
return true;
|
|
2576
|
+
} catch {
|
|
2577
|
+
return false;
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
async #terminalizeBtwTurnsForSession(session: SessionSocket, waitForInFlight = false): Promise<void> {
|
|
2581
|
+
const terminalizations: Promise<void>[] = [];
|
|
2582
|
+
for (const [requestId, pending] of this.#pendingBtwTurns) {
|
|
2583
|
+
if (
|
|
2584
|
+
pending.transportSessionId !== session.sessionId ||
|
|
2585
|
+
pending.logicalSessionId !== this.#logicalSessionId(session) ||
|
|
2586
|
+
pending.endpointDigest !== session.endpointDigest ||
|
|
2587
|
+
pending.generation !== session.hostGeneration
|
|
2588
|
+
)
|
|
2589
|
+
continue;
|
|
2590
|
+
const terminalization = this.#terminalizeBtwTurn(requestId, pending, false, waitForInFlight);
|
|
2591
|
+
if (waitForInFlight) terminalizations.push(terminalization);
|
|
2592
|
+
else void terminalization.catch(() => undefined);
|
|
2593
|
+
}
|
|
2594
|
+
if (waitForInFlight) await Promise.all(terminalizations);
|
|
2595
|
+
}
|
|
2596
|
+
async #terminalizeBtwTurn(
|
|
2597
|
+
requestId: string,
|
|
2598
|
+
pending: PendingBtwTurn,
|
|
2599
|
+
allowWhileStopping = false,
|
|
2600
|
+
waitForInFlight = false,
|
|
2601
|
+
signal?: AbortSignal,
|
|
2602
|
+
): Promise<void> {
|
|
2603
|
+
const delivery = this.#btwTerminalDeliveries.get(requestId);
|
|
2604
|
+
if (delivery?.pending === pending) {
|
|
2605
|
+
delivery.invalidated = true;
|
|
2606
|
+
delivery.terminalizeOnInvalidation = true;
|
|
2607
|
+
this.#finishQueuedBtwDeliveries(pending, "stale");
|
|
2608
|
+
if (allowWhileStopping || waitForInFlight) await delivery.finished;
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
if (this.#stoppingBtw && !allowWhileStopping) return;
|
|
2612
|
+
if (!this.#takeBtwTurn(requestId, pending)) return;
|
|
2613
|
+
try {
|
|
2614
|
+
await this.#sendBtwMessage({
|
|
2615
|
+
threadId: pending.threadId,
|
|
2616
|
+
messageId: pending.messageId,
|
|
2617
|
+
text: "This /btw question stopped because the GJC session closed or changed. Reopen it and try again.",
|
|
2618
|
+
allowWhileStopping,
|
|
2619
|
+
signal,
|
|
2620
|
+
});
|
|
2621
|
+
} catch {
|
|
2622
|
+
logger.warn("notifications: /btw session-unavailable delivery failed");
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
async #sendBtwMessage(input: {
|
|
2626
|
+
threadId: string;
|
|
2627
|
+
messageId: number;
|
|
2628
|
+
text: string;
|
|
2629
|
+
parseMode?: typeof TELEGRAM_PARSE_MODE;
|
|
2630
|
+
allowWhileStopping?: boolean;
|
|
2631
|
+
signal?: AbortSignal;
|
|
2632
|
+
}): Promise<unknown> {
|
|
2633
|
+
if ((!input.allowWhileStopping && this.#stoppingBtw) || this.#btwDeliveryAbort.signal.aborted) return undefined;
|
|
2634
|
+
const signals = [this.#btwDeliveryAbort.signal, AbortSignal.timeout(30_000)];
|
|
2635
|
+
if (input.signal) signals.unshift(input.signal);
|
|
2636
|
+
return this.botApi.call(
|
|
2637
|
+
"sendMessage",
|
|
2638
|
+
{
|
|
2639
|
+
chat_id: this.opts.chatId,
|
|
2640
|
+
message_thread_id: Number(input.threadId),
|
|
2641
|
+
reply_parameters: { message_id: input.messageId },
|
|
2642
|
+
text: input.text,
|
|
2643
|
+
...(input.parseMode ? { parse_mode: input.parseMode } : {}),
|
|
2644
|
+
},
|
|
2645
|
+
{
|
|
2646
|
+
noRetry: true,
|
|
2647
|
+
signal: AbortSignal.any(signals),
|
|
2648
|
+
},
|
|
2649
|
+
);
|
|
2650
|
+
}
|
|
2651
|
+
async #queueBtwFallbackChunk(input: {
|
|
2652
|
+
requestId: string;
|
|
2653
|
+
chunkIndex: number;
|
|
2654
|
+
pending: PendingBtwTurn;
|
|
2655
|
+
body: Record<string, unknown>;
|
|
2656
|
+
signal: AbortSignal;
|
|
2657
|
+
isAuthoritative: () => boolean;
|
|
2658
|
+
}): Promise<BtwQueuedDeliveryOutcome> {
|
|
2659
|
+
if (input.signal.aborted) return "uncertain";
|
|
2660
|
+
const result = Promise.withResolvers<BtwQueuedDeliveryOutcome>();
|
|
2661
|
+
let settled = false;
|
|
2662
|
+
const finish = (outcome: BtwQueuedDeliveryOutcome): void => {
|
|
2663
|
+
if (settled) return;
|
|
2664
|
+
settled = true;
|
|
2665
|
+
result.resolve(outcome);
|
|
2666
|
+
};
|
|
2667
|
+
const itemId = `btw-delivery:${input.requestId}:${input.chunkIndex}`;
|
|
2668
|
+
const delivery: BtwQueuedDelivery = {
|
|
2669
|
+
pending: input.pending,
|
|
2670
|
+
body: input.body,
|
|
2671
|
+
signal: input.signal,
|
|
2672
|
+
isAuthoritative: input.isAuthoritative,
|
|
2673
|
+
finish,
|
|
2674
|
+
};
|
|
2675
|
+
const abort = (): void => {
|
|
2676
|
+
const removed = this.pool.removeById(itemId);
|
|
2677
|
+
if (removed?.payload.btwDelivery === delivery) finish("uncertain");
|
|
2678
|
+
};
|
|
2679
|
+
input.signal.addEventListener("abort", abort, { once: true });
|
|
2680
|
+
try {
|
|
2681
|
+
const handle = this.pool.submit({
|
|
2682
|
+
sessionId: input.pending.transportSessionId,
|
|
2683
|
+
lane: "finalized",
|
|
2684
|
+
itemId,
|
|
2685
|
+
payload: {
|
|
2686
|
+
send: { method: "sendMessage", lane: "finalized", text: String(input.body.text ?? "") },
|
|
2687
|
+
btwDelivery: delivery,
|
|
2688
|
+
},
|
|
2689
|
+
});
|
|
2690
|
+
if (input.signal.aborted) abort();
|
|
2691
|
+
else await this.flushPool();
|
|
2692
|
+
await handle.settled;
|
|
2693
|
+
return await result.promise;
|
|
2694
|
+
} catch {
|
|
2695
|
+
const removed = this.pool.removeById(itemId);
|
|
2696
|
+
if (removed?.payload.btwDelivery === delivery) finish("uncertain");
|
|
2697
|
+
return "uncertain";
|
|
2698
|
+
} finally {
|
|
2699
|
+
input.signal.removeEventListener("abort", abort);
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
async #drainBtwTurns(): Promise<void> {
|
|
2704
|
+
this.#stoppingBtw = true;
|
|
2705
|
+
const shutdownController = new AbortController();
|
|
2706
|
+
const shutdownDeadline = Promise.withResolvers<void>();
|
|
2707
|
+
const shutdownTimer = setTimeout(() => {
|
|
2708
|
+
shutdownController.abort("daemon_shutdown_timeout");
|
|
2709
|
+
shutdownDeadline.resolve();
|
|
2710
|
+
}, BTW_SHUTDOWN_JOIN_MS);
|
|
2711
|
+
for (const delivery of this.#btwTerminalDeliveries.values()) {
|
|
2712
|
+
delivery.invalidated = true;
|
|
2713
|
+
delivery.controller.abort("daemon_shutdown");
|
|
2714
|
+
}
|
|
2715
|
+
const deliveryJoins = [...this.#btwTerminalDeliveries.values()].map(delivery => delivery.finished);
|
|
2716
|
+
|
|
2717
|
+
const terminalizations: Promise<void>[] = [];
|
|
2718
|
+
for (const [requestId, pending] of this.#pendingBtwTurns) {
|
|
2719
|
+
if (shutdownController.signal.aborted) break;
|
|
2720
|
+
if (this.#btwTerminalDeliveries.has(requestId)) continue;
|
|
2721
|
+
const session = this.sessions.get(pending.transportSessionId);
|
|
2722
|
+
if (
|
|
2723
|
+
session &&
|
|
2724
|
+
session.ws.readyState === WebSocket.OPEN &&
|
|
2725
|
+
session.endpointDigest === pending.endpointDigest &&
|
|
2726
|
+
session.hostGeneration === pending.generation &&
|
|
2727
|
+
this.#logicalSessionId(session) === pending.logicalSessionId
|
|
2728
|
+
) {
|
|
2729
|
+
try {
|
|
2730
|
+
session.ws.send(
|
|
2731
|
+
JSON.stringify({
|
|
2732
|
+
type: "ephemeral_turn_cancel",
|
|
2733
|
+
sessionId: pending.logicalSessionId,
|
|
2734
|
+
token: session.token,
|
|
2735
|
+
requestId,
|
|
2736
|
+
updateId: pending.updateId,
|
|
2737
|
+
messageId: pending.messageId,
|
|
2738
|
+
threadId: pending.threadId,
|
|
2739
|
+
reason: "daemon_shutdown",
|
|
2740
|
+
}),
|
|
2741
|
+
);
|
|
2742
|
+
} catch {}
|
|
2743
|
+
}
|
|
2744
|
+
terminalizations.push(this.#terminalizeBtwTurn(requestId, pending, true, false, shutdownController.signal));
|
|
2745
|
+
}
|
|
2746
|
+
await Promise.race([Promise.allSettled([...deliveryJoins, ...terminalizations]), shutdownDeadline.promise]);
|
|
2747
|
+
clearTimeout(shutdownTimer);
|
|
2748
|
+
shutdownController.abort("daemon_shutdown");
|
|
2749
|
+
this.#btwDeliveryAbort.abort();
|
|
2750
|
+
this.#btwTerminalTombstones.clear();
|
|
2751
|
+
for (const item of this.pool.removeWhere(() => true)) item.payload.btwDelivery?.finish("uncertain");
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2131
2754
|
private deleteMessageRoutes(sessionId: string, actionId?: string): void {
|
|
2132
2755
|
for (const [messageId, route] of this.messageRoutes.entries()) {
|
|
2133
2756
|
if (route.sessionId === sessionId && (actionId === undefined || route.actionId === actionId)) {
|
|
@@ -2169,6 +2792,14 @@ export class TelegramNotificationDaemon {
|
|
|
2169
2792
|
return alias;
|
|
2170
2793
|
}
|
|
2171
2794
|
|
|
2795
|
+
/**
|
|
2796
|
+
* Coalesce-key categories whose entries are concurrent and independently keyed
|
|
2797
|
+
* (one live message per key), so {@link recordLiveMessage} must NOT evict
|
|
2798
|
+
* same-category siblings. `tool:<toolCallId>` bubbles from parallel tools each
|
|
2799
|
+
* own their own message and finalize independently.
|
|
2800
|
+
*/
|
|
2801
|
+
static readonly #CONCURRENT_LIVE_CATEGORIES = new Set(["tool"]);
|
|
2802
|
+
|
|
2172
2803
|
private static readonly THREADED_FRAMES = new Set([
|
|
2173
2804
|
"identity_header",
|
|
2174
2805
|
"context_update",
|
|
@@ -2177,9 +2808,11 @@ export class TelegramNotificationDaemon {
|
|
|
2177
2808
|
"file_attachment",
|
|
2178
2809
|
"config_update",
|
|
2179
2810
|
"control_command_result",
|
|
2811
|
+
"tool_activity",
|
|
2812
|
+
"reasoning_summary",
|
|
2180
2813
|
]);
|
|
2181
2814
|
|
|
2182
|
-
/** Rekey model controls before a valid threaded frame can render or
|
|
2815
|
+
/** Rekey model controls before a valid server-threaded frame can render or route output. */
|
|
2183
2816
|
#updateLogicalSessionForThreadedFrame(session: SessionSocket, msg: Record<string, unknown>): void {
|
|
2184
2817
|
if (
|
|
2185
2818
|
typeof msg.type !== "string" ||
|
|
@@ -2190,6 +2823,7 @@ export class TelegramNotificationDaemon {
|
|
|
2190
2823
|
(msg.type !== "config_update" && !renderThreadedFrame(msg))
|
|
2191
2824
|
)
|
|
2192
2825
|
return;
|
|
2826
|
+
void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
|
|
2193
2827
|
this.#clearModelChoiceAliasesForSocket(session);
|
|
2194
2828
|
this.#clearModelChoiceAliases(msg.sessionId);
|
|
2195
2829
|
session.logicalSessionId = msg.sessionId;
|
|
@@ -2257,11 +2891,13 @@ export class TelegramNotificationDaemon {
|
|
|
2257
2891
|
private topicOwnerForIdentity(msg: { repo?: unknown; branch?: unknown }): string | undefined {
|
|
2258
2892
|
const identityKey = this.topicIdentityKey(msg);
|
|
2259
2893
|
const remembered = identityKey ? this.topicOwnerByIdentity.get(identityKey) : undefined;
|
|
2260
|
-
|
|
2894
|
+
const rememberedTopic = remembered ? this.topics.get(remembered) : undefined;
|
|
2895
|
+
if (remembered && rememberedTopic && rememberedTopic.authorityState !== "delete_pending") return remembered;
|
|
2261
2896
|
if (!identityKey) return undefined;
|
|
2262
2897
|
const base = this.topicIdentityBase(msg);
|
|
2263
2898
|
for (const sessionId of this.topics.sessionIds()) {
|
|
2264
2899
|
const topic = this.topics.get(sessionId);
|
|
2900
|
+
if (topic?.authorityState === "delete_pending") continue;
|
|
2265
2901
|
const nameMatchesLegacyIdentity =
|
|
2266
2902
|
base !== undefined && (topic?.name === base || topic?.name?.startsWith(`${base} - `));
|
|
2267
2903
|
if (topic?.identityKey === identityKey || nameMatchesLegacyIdentity) {
|
|
@@ -2279,30 +2915,55 @@ export class TelegramNotificationDaemon {
|
|
|
2279
2915
|
return !ownerId || ownerId === session.sessionId;
|
|
2280
2916
|
}
|
|
2281
2917
|
|
|
2282
|
-
private async submitThreadedFrame(
|
|
2283
|
-
|
|
2918
|
+
private async submitThreadedFrame(
|
|
2919
|
+
sessionId: string,
|
|
2920
|
+
send: ThreadedSend,
|
|
2921
|
+
topicLease: TopicAuthorityLease,
|
|
2922
|
+
): Promise<void> {
|
|
2923
|
+
this.submitPool({
|
|
2284
2924
|
sessionId,
|
|
2285
2925
|
lane: send.lane,
|
|
2286
2926
|
coalesceKey: send.coalesceKey,
|
|
2287
|
-
payload: { send,
|
|
2927
|
+
payload: { send, topicLease },
|
|
2288
2928
|
});
|
|
2289
2929
|
await this.flushPool();
|
|
2290
2930
|
}
|
|
2291
2931
|
|
|
2292
2932
|
private async existingTopicForPrivateChat(sessionId: string): Promise<string | undefined> {
|
|
2933
|
+
return (await this.topicAuthorityLease(sessionId))?.topicId;
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
private async topicAuthorityLease(sessionId: string): Promise<TopicAuthorityLease | undefined> {
|
|
2293
2937
|
if (!(await this.pairedChatIsPrivate())) return undefined;
|
|
2294
|
-
return this.
|
|
2938
|
+
return this.topicAuthorityLeaseFromRegistry(sessionId);
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
private topicAuthorityLeaseFromRegistry(sessionId: string): TopicAuthorityLease | undefined {
|
|
2942
|
+
const topic = this.topics.get(sessionId);
|
|
2943
|
+
if (!topic || topic.authorityState === "delete_pending") return undefined;
|
|
2944
|
+
return { sessionId, topicId: topic.topicId, authorityEpoch: topic.authorityEpoch ?? 0 };
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
private topicLeaseIsCurrent(lease: TopicAuthorityLease): boolean {
|
|
2948
|
+
const topic = this.topics.get(lease.sessionId);
|
|
2949
|
+
return (
|
|
2950
|
+
topic?.authorityState !== "delete_pending" &&
|
|
2951
|
+
topic?.topicId === lease.topicId &&
|
|
2952
|
+
(topic.authorityEpoch ?? 0) === lease.authorityEpoch
|
|
2953
|
+
);
|
|
2295
2954
|
}
|
|
2296
2955
|
|
|
2297
2956
|
/** Best-effort re-assertion for a durable user-owned topic name. */
|
|
2298
|
-
private async reconcileUserTopicName(
|
|
2957
|
+
private async reconcileUserTopicName(topicLease: TopicAuthorityLease): Promise<void> {
|
|
2958
|
+
const { sessionId } = topicLease;
|
|
2299
2959
|
if ((this.daemonRenameAttempts.get(sessionId) ?? 0) > 0) return;
|
|
2300
2960
|
let userName = this.topics.userNameToReconcile(sessionId);
|
|
2301
2961
|
while (userName) {
|
|
2302
2962
|
try {
|
|
2963
|
+
if (!this.topicLeaseIsCurrent(topicLease)) return;
|
|
2303
2964
|
const response = await this.botApi.call("editForumTopic", {
|
|
2304
2965
|
chat_id: this.opts.chatId,
|
|
2305
|
-
message_thread_id: Number(topicId),
|
|
2966
|
+
message_thread_id: Number(topicLease.topicId),
|
|
2306
2967
|
name: userName,
|
|
2307
2968
|
});
|
|
2308
2969
|
if (!topicRenameApplied(response)) return;
|
|
@@ -2332,11 +2993,11 @@ export class TelegramNotificationDaemon {
|
|
|
2332
2993
|
this.pendingThreadedFrames.set(sessionId, frames);
|
|
2333
2994
|
}
|
|
2334
2995
|
|
|
2335
|
-
private async flushPendingThreadedFrames(sessionId: string,
|
|
2996
|
+
private async flushPendingThreadedFrames(sessionId: string, topicLease: TopicAuthorityLease): Promise<void> {
|
|
2336
2997
|
const frames = this.pendingThreadedFrames.get(sessionId);
|
|
2337
2998
|
if (!frames || frames.length === 0) return;
|
|
2338
2999
|
this.pendingThreadedFrames.delete(sessionId);
|
|
2339
|
-
for (const frame of frames) await this.submitThreadedFrame(sessionId, frame.send,
|
|
3000
|
+
for (const frame of frames) await this.submitThreadedFrame(sessionId, frame.send, topicLease);
|
|
2340
3001
|
}
|
|
2341
3002
|
|
|
2342
3003
|
/**
|
|
@@ -2348,17 +3009,20 @@ export class TelegramNotificationDaemon {
|
|
|
2348
3009
|
private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
|
|
2349
3010
|
if (!(await this.pairedChatIsPrivate())) return undefined;
|
|
2350
3011
|
const existing = this.topics.get(sessionId);
|
|
3012
|
+
if (existing?.authorityState === "delete_pending") return undefined;
|
|
2351
3013
|
if (existing) return existing.topicId;
|
|
2352
3014
|
try {
|
|
2353
3015
|
const rec = await this.topics.getOrCreateTopic(
|
|
2354
3016
|
sessionId,
|
|
2355
3017
|
async () => {
|
|
2356
|
-
const res =
|
|
3018
|
+
const res = await this.botApi.call("createForumTopic", {
|
|
2357
3019
|
chat_id: this.opts.chatId,
|
|
2358
3020
|
name,
|
|
2359
|
-
})
|
|
2360
|
-
|
|
2361
|
-
|
|
3021
|
+
});
|
|
3022
|
+
if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
|
|
3023
|
+
const tid = (res as { result?: { message_thread_id?: unknown } }).result?.message_thread_id;
|
|
3024
|
+
if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0)
|
|
3025
|
+
throw new Error("createForumTopic: invalid message_thread_id");
|
|
2362
3026
|
return String(tid);
|
|
2363
3027
|
},
|
|
2364
3028
|
this.opts.now,
|
|
@@ -2370,50 +3034,62 @@ export class TelegramNotificationDaemon {
|
|
|
2370
3034
|
);
|
|
2371
3035
|
await this.persistTopics();
|
|
2372
3036
|
return rec.topicId;
|
|
2373
|
-
} catch {
|
|
2374
|
-
return undefined;
|
|
3037
|
+
} catch (err) {
|
|
3038
|
+
if (err instanceof ThreadedModeCapabilityRefusal) return undefined;
|
|
3039
|
+
logger.warn(
|
|
3040
|
+
`notifications: Telegram topic creation failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
|
|
3041
|
+
);
|
|
3042
|
+
throw err;
|
|
2375
3043
|
}
|
|
2376
3044
|
}
|
|
2377
3045
|
|
|
2378
3046
|
private topicPastOrphanGrace(sessionId: string): boolean {
|
|
2379
|
-
const
|
|
2380
|
-
return
|
|
3047
|
+
const orphanedAt = this.topics.get(sessionId)?.orphanedAt;
|
|
3048
|
+
return orphanedAt !== undefined && this.runtime.now() - orphanedAt >= ORPHAN_TOPIC_GRACE_MS;
|
|
2381
3049
|
}
|
|
2382
3050
|
|
|
2383
|
-
private async
|
|
3051
|
+
private async observeOrphanedTopic(sessionId: string): Promise<void> {
|
|
3052
|
+
if (this.topics.markOrphaned(sessionId, this.runtime.now())) await this.persistTopics();
|
|
2384
3053
|
if (!this.topicPastOrphanGrace(sessionId)) return;
|
|
2385
3054
|
await this.deleteTopic(sessionId);
|
|
2386
3055
|
}
|
|
2387
3056
|
|
|
2388
3057
|
/** Best-effort delete of a session topic once its local notification endpoint shuts down. */
|
|
2389
3058
|
private async deleteTopic(sessionId: string): Promise<void> {
|
|
2390
|
-
|
|
2391
|
-
|
|
3059
|
+
let record = this.topics.beginDelete(sessionId);
|
|
3060
|
+
// Persist even an absent-record fence: it revokes a create that was admitted
|
|
3061
|
+
// before close but has not yet committed its topic record.
|
|
3062
|
+
await this.persistTopics();
|
|
3063
|
+
if (!record) {
|
|
3064
|
+
await this.topics.awaitInflight(sessionId);
|
|
3065
|
+
record = this.topics.get(sessionId);
|
|
3066
|
+
// A remotely accepted revoked create installs a delete_pending record while
|
|
3067
|
+
// its caller rejects; durably retain it before compensating remotely.
|
|
3068
|
+
await this.persistTopics();
|
|
3069
|
+
if (!record) return;
|
|
3070
|
+
}
|
|
3071
|
+
const removed = this.pool.removeWhere(item => item.sessionId === sessionId);
|
|
3072
|
+
for (const item of removed) {
|
|
3073
|
+
if (item.payload.selectedAck)
|
|
3074
|
+
this.finishSelectedAck(item.payload.selectedAck, { status: "failed", reason: "cancelled" });
|
|
3075
|
+
item.payload.btwDelivery?.finish("stale");
|
|
3076
|
+
}
|
|
2392
3077
|
try {
|
|
2393
|
-
// Drop queued sends for this session before deleting the topic; otherwise
|
|
2394
|
-
// rate-limited frames can flush later into a deleted topic or across resume.
|
|
2395
|
-
const removed = this.pool.removeWhere(item => item.sessionId === sessionId);
|
|
2396
|
-
for (const item of removed) {
|
|
2397
|
-
if (item.payload.selectedAck)
|
|
2398
|
-
this.finishSelectedAck(item.payload.selectedAck, { status: "failed", reason: "cancelled" });
|
|
2399
|
-
}
|
|
2400
3078
|
await this.flushPool();
|
|
2401
3079
|
const res = (await this.botApi.call("deleteForumTopic", {
|
|
2402
3080
|
chat_id: this.opts.chatId,
|
|
2403
3081
|
message_thread_id: Number(record.topicId),
|
|
2404
3082
|
})) as { ok?: boolean };
|
|
2405
|
-
if (res
|
|
2406
|
-
this.topics.
|
|
2407
|
-
for (const k of [...this.liveMessages.keys()]) {
|
|
2408
|
-
if (k.startsWith(`${sessionId}:`)) this.liveMessages.delete(k);
|
|
2409
|
-
}
|
|
3083
|
+
if (!topicDeleteSettled(res)) return;
|
|
3084
|
+
this.topics.settleDelete(sessionId, record.topicId);
|
|
3085
|
+
for (const k of [...this.liveMessages.keys()]) if (k.startsWith(`${sessionId}:`)) this.liveMessages.delete(k);
|
|
2410
3086
|
this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
|
|
2411
3087
|
if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
|
|
2412
3088
|
});
|
|
2413
3089
|
this.pendingThreadedFrames.delete(sessionId);
|
|
2414
3090
|
await this.persistTopics();
|
|
2415
3091
|
} catch {
|
|
2416
|
-
//
|
|
3092
|
+
// Retain the persisted delete fence on transport ambiguity.
|
|
2417
3093
|
}
|
|
2418
3094
|
}
|
|
2419
3095
|
|
|
@@ -2554,12 +3230,19 @@ export class TelegramNotificationDaemon {
|
|
|
2554
3230
|
}
|
|
2555
3231
|
|
|
2556
3232
|
/** Drain the shared rate-limit pool and deliver each granted send to its topic. */
|
|
3233
|
+
private submitPool(item: Parameters<RateLimitPool<TelegramQueuePayload>["submit"]>[0]): boolean {
|
|
3234
|
+
if (this.effects.stopping) return false;
|
|
3235
|
+
this.pool.submit(item);
|
|
3236
|
+
return true;
|
|
3237
|
+
}
|
|
3238
|
+
|
|
2557
3239
|
private async flushPoolInner(): Promise<void> {
|
|
2558
3240
|
const { granted: batch, expired } = this.pool.drainWithExpired();
|
|
2559
3241
|
for (const expiredItem of expired) {
|
|
2560
3242
|
if (expiredItem.payload.selectedAck) {
|
|
2561
3243
|
this.finishSelectedAck(expiredItem.payload.selectedAck, { status: "failed", reason: "expired" });
|
|
2562
3244
|
}
|
|
3245
|
+
expiredItem.payload.btwDelivery?.finish("not_delivered");
|
|
2563
3246
|
}
|
|
2564
3247
|
// Within a batch a finalized frame supersedes any still-queued live frame for
|
|
2565
3248
|
// the same streamed message (finalized outranks live), so drop the stale live
|
|
@@ -2583,20 +3266,53 @@ export class TelegramNotificationDaemon {
|
|
|
2583
3266
|
);
|
|
2584
3267
|
}
|
|
2585
3268
|
for (const item of batch) {
|
|
3269
|
+
const btwDelivery = item.payload.btwDelivery;
|
|
3270
|
+
if (btwDelivery) {
|
|
3271
|
+
if (!btwDelivery.isAuthoritative()) {
|
|
3272
|
+
btwDelivery.finish("stale");
|
|
3273
|
+
this.pool.settle(item.itemId!, "removed");
|
|
3274
|
+
continue;
|
|
3275
|
+
}
|
|
3276
|
+
if (btwDelivery.signal.aborted) {
|
|
3277
|
+
btwDelivery.finish("uncertain");
|
|
3278
|
+
this.pool.settle(item.itemId!, "ambiguous");
|
|
3279
|
+
continue;
|
|
3280
|
+
}
|
|
3281
|
+
try {
|
|
3282
|
+
const response = await this.botApi.call("sendMessage", btwDelivery.body, {
|
|
3283
|
+
noRetry: true,
|
|
3284
|
+
signal: btwDelivery.signal,
|
|
3285
|
+
});
|
|
3286
|
+
const accepted = response && typeof response === "object" && (response as { ok?: unknown }).ok === true;
|
|
3287
|
+
const rejected = response && typeof response === "object" && (response as { ok?: unknown }).ok === false;
|
|
3288
|
+
btwDelivery.finish(accepted ? "accepted" : rejected ? "not_delivered" : "uncertain");
|
|
3289
|
+
this.pool.settle(item.itemId!, accepted ? "accepted" : rejected ? "rejected" : "ambiguous");
|
|
3290
|
+
} catch {
|
|
3291
|
+
btwDelivery.finish("uncertain");
|
|
3292
|
+
this.pool.settle(item.itemId!, "ambiguous");
|
|
3293
|
+
}
|
|
3294
|
+
continue;
|
|
3295
|
+
}
|
|
2586
3296
|
const selectedAck = item.payload.selectedAck;
|
|
2587
3297
|
if (selectedAck) {
|
|
2588
|
-
const {
|
|
3298
|
+
const { topicLease } = item.payload;
|
|
2589
3299
|
selectedAck.state = "dispatching";
|
|
2590
3300
|
const controller = new AbortController();
|
|
2591
3301
|
selectedAck.controller = controller;
|
|
2592
|
-
const routeAvailable =
|
|
2593
|
-
|
|
3302
|
+
const routeAvailable =
|
|
3303
|
+
(await this.pairedChatIsPrivate()) && (!topicLease || this.topicLeaseIsCurrent(topicLease));
|
|
3304
|
+
if (this.selectedAckPending.get(selectedAck.pendingKey) !== selectedAck) {
|
|
3305
|
+
this.pool.settle(item.itemId!, "removed");
|
|
3306
|
+
continue;
|
|
3307
|
+
}
|
|
2594
3308
|
if (!routeAvailable) {
|
|
2595
3309
|
this.finishSelectedAck(selectedAck, { status: "failed", reason: "route_missing" });
|
|
3310
|
+
this.pool.settle(item.itemId!, "rejected");
|
|
2596
3311
|
continue;
|
|
2597
3312
|
}
|
|
2598
3313
|
if (item.deadlineAt !== undefined && item.deadlineAt <= this.runtime.now()) {
|
|
2599
3314
|
this.finishSelectedAck(selectedAck, { status: "failed", reason: "expired" });
|
|
3315
|
+
this.pool.settle(item.itemId!, "expired");
|
|
2600
3316
|
continue;
|
|
2601
3317
|
}
|
|
2602
3318
|
selectedAck.state = "sending";
|
|
@@ -2610,31 +3326,49 @@ export class TelegramNotificationDaemon {
|
|
|
2610
3326
|
"sendMessage",
|
|
2611
3327
|
{
|
|
2612
3328
|
chat_id: this.opts.chatId,
|
|
2613
|
-
...(
|
|
3329
|
+
...(topicLease ? { message_thread_id: Number(topicLease.topicId) } : {}),
|
|
2614
3330
|
text: "Selected!",
|
|
2615
3331
|
},
|
|
2616
3332
|
{ signal: controller.signal, noRetry: true },
|
|
2617
3333
|
)) as { ok?: unknown; result?: { message_id?: unknown } };
|
|
3334
|
+
const messageId = response.result?.message_id;
|
|
3335
|
+
const delivered = response.ok === true && typeof messageId === "number";
|
|
2618
3336
|
this.finishSelectedAck(
|
|
2619
3337
|
selectedAck,
|
|
2620
|
-
|
|
2621
|
-
? { status: "delivered", messageId: response.result.message_id }
|
|
2622
|
-
: { status: "failed", reason: "telegram_rejected" },
|
|
3338
|
+
delivered ? { status: "delivered", messageId } : { status: "failed", reason: "telegram_rejected" },
|
|
2623
3339
|
);
|
|
3340
|
+
this.pool.settle(item.itemId!, delivered ? "accepted" : "rejected");
|
|
2624
3341
|
} catch {
|
|
2625
3342
|
this.finishSelectedAck(selectedAck, { status: "unknown", reason: "transport_ambiguous" });
|
|
3343
|
+
this.pool.settle(item.itemId!, "ambiguous");
|
|
2626
3344
|
} finally {
|
|
2627
3345
|
(this.opts.clearTimeoutImpl ?? clearTimeout)(timer);
|
|
2628
3346
|
}
|
|
2629
3347
|
continue;
|
|
2630
3348
|
}
|
|
2631
|
-
const { send,
|
|
2632
|
-
if (
|
|
3349
|
+
const { send, topicLease } = item.payload;
|
|
3350
|
+
if (topicLease && !this.topicLeaseIsCurrent(topicLease)) {
|
|
3351
|
+
this.pool.settle(item.itemId!, "rejected");
|
|
3352
|
+
continue;
|
|
3353
|
+
}
|
|
3354
|
+
const topicId = topicLease?.topicId;
|
|
3355
|
+
if (topicId && !(await this.pairedChatIsPrivate())) {
|
|
3356
|
+
this.pool.settle(item.itemId!, "rejected");
|
|
3357
|
+
continue;
|
|
3358
|
+
}
|
|
3359
|
+
if (topicLease && !this.topicLeaseIsCurrent(topicLease)) {
|
|
3360
|
+
this.pool.settle(item.itemId!, "rejected");
|
|
3361
|
+
continue;
|
|
3362
|
+
}
|
|
2633
3363
|
// Threaded topic when available; otherwise deliver flat to the paired chat.
|
|
2634
3364
|
const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
|
|
2635
3365
|
const ckey = send.editable ? item.coalesceKey : undefined;
|
|
2636
3366
|
const editKey = ckey !== undefined ? `${item.sessionId}:${ckey}` : undefined;
|
|
2637
|
-
if (item.lane === "live" && editKey && finalizedKeys.has(editKey))
|
|
3367
|
+
if (item.lane === "live" && editKey && finalizedKeys.has(editKey)) {
|
|
3368
|
+
this.pool.settle(item.itemId!, "removed");
|
|
3369
|
+
continue;
|
|
3370
|
+
}
|
|
3371
|
+
let disposition: "accepted" | "ambiguous" | "rejected" = "accepted";
|
|
2638
3372
|
try {
|
|
2639
3373
|
// Draft streaming (opt-in, off by default): stream a live turn frame as a
|
|
2640
3374
|
// best-effort rich-draft preview, debounced to >=1.5s per session through
|
|
@@ -2663,6 +3397,10 @@ export class TelegramNotificationDaemon {
|
|
|
2663
3397
|
}
|
|
2664
3398
|
}
|
|
2665
3399
|
}
|
|
3400
|
+
if (topicLease && !this.topicLeaseIsCurrent(topicLease)) {
|
|
3401
|
+
disposition = "rejected";
|
|
3402
|
+
continue;
|
|
3403
|
+
}
|
|
2666
3404
|
if (send.method === "sendPhoto" && send.photoBase64) {
|
|
2667
3405
|
// Real photo upload (the default botApi multiparts base64 -> file).
|
|
2668
3406
|
await this.botApi.call("sendPhoto", {
|
|
@@ -2699,6 +3437,7 @@ export class TelegramNotificationDaemon {
|
|
|
2699
3437
|
// non-editable, HTML-only pool items (rich markers stripped) — same
|
|
2700
3438
|
// per-token discipline as the non-rich split path.
|
|
2701
3439
|
const chunks = splitTelegramHtml(send.text!);
|
|
3440
|
+
if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
|
|
2702
3441
|
await this.botApi.call("sendMessage", {
|
|
2703
3442
|
chat_id: this.opts.chatId,
|
|
2704
3443
|
...threadField,
|
|
@@ -2706,7 +3445,7 @@ export class TelegramNotificationDaemon {
|
|
|
2706
3445
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2707
3446
|
});
|
|
2708
3447
|
for (let i = 1; i < chunks.length; i++) {
|
|
2709
|
-
this.
|
|
3448
|
+
this.submitPool({
|
|
2710
3449
|
sessionId: item.sessionId,
|
|
2711
3450
|
lane: item.lane,
|
|
2712
3451
|
payload: {
|
|
@@ -2722,7 +3461,7 @@ export class TelegramNotificationDaemon {
|
|
|
2722
3461
|
richDraftMarkdown: undefined,
|
|
2723
3462
|
richClass: undefined,
|
|
2724
3463
|
},
|
|
2725
|
-
|
|
3464
|
+
topicLease,
|
|
2726
3465
|
},
|
|
2727
3466
|
});
|
|
2728
3467
|
}
|
|
@@ -2731,6 +3470,7 @@ export class TelegramNotificationDaemon {
|
|
|
2731
3470
|
this.botApi,
|
|
2732
3471
|
{ chat_id: this.opts.chatId, ...threadField },
|
|
2733
3472
|
send,
|
|
3473
|
+
AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
|
|
2734
3474
|
sendHtmlFallback,
|
|
2735
3475
|
logger,
|
|
2736
3476
|
);
|
|
@@ -2768,7 +3508,7 @@ export class TelegramNotificationDaemon {
|
|
|
2768
3508
|
}
|
|
2769
3509
|
if (edited) {
|
|
2770
3510
|
firstMessageId = existingId;
|
|
2771
|
-
} else {
|
|
3511
|
+
} else if (!topicLease || this.topicLeaseIsCurrent(topicLease)) {
|
|
2772
3512
|
const res = (await this.botApi.call("sendMessage", {
|
|
2773
3513
|
chat_id: this.opts.chatId,
|
|
2774
3514
|
...threadField,
|
|
@@ -2796,7 +3536,7 @@ export class TelegramNotificationDaemon {
|
|
|
2796
3536
|
// can never be re-promoted to a duplicate sendRichMessage.
|
|
2797
3537
|
if (item.lane !== "live") {
|
|
2798
3538
|
for (let i = 1; i < chunks.length; i++) {
|
|
2799
|
-
this.
|
|
3539
|
+
this.submitPool({
|
|
2800
3540
|
sessionId: item.sessionId,
|
|
2801
3541
|
lane: item.lane,
|
|
2802
3542
|
payload: {
|
|
@@ -2812,18 +3552,25 @@ export class TelegramNotificationDaemon {
|
|
|
2812
3552
|
richDraftMarkdown: undefined,
|
|
2813
3553
|
richClass: undefined,
|
|
2814
3554
|
},
|
|
2815
|
-
|
|
3555
|
+
topicLease,
|
|
2816
3556
|
},
|
|
2817
3557
|
});
|
|
2818
3558
|
}
|
|
2819
3559
|
}
|
|
2820
|
-
if (editKey && ckey !== undefined && firstMessageId !== undefined) {
|
|
3560
|
+
if (editKey && ckey !== undefined && firstMessageId !== undefined && !send.terminal) {
|
|
2821
3561
|
this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
|
|
2822
3562
|
}
|
|
2823
3563
|
}
|
|
2824
3564
|
}
|
|
2825
3565
|
} catch {
|
|
2826
3566
|
// Best-effort: a failed send/edit must never stop the daemon.
|
|
3567
|
+
disposition = "ambiguous";
|
|
3568
|
+
} finally {
|
|
3569
|
+
this.pool.settle(item.itemId!, disposition);
|
|
3570
|
+
// A terminal tool frame owns the end of this coalescing key even when both
|
|
3571
|
+
// edit and fallback delivery fail. Retaining the old message id would leak
|
|
3572
|
+
// one entry per failure and let a later reused key edit stale Telegram state.
|
|
3573
|
+
if (send.terminal && editKey) this.liveMessages.delete(editKey);
|
|
2827
3574
|
}
|
|
2828
3575
|
}
|
|
2829
3576
|
}
|
|
@@ -2836,9 +3583,17 @@ export class TelegramNotificationDaemon {
|
|
|
2836
3583
|
private recordLiveMessage(sessionId: string, coalesceKey: string, messageId: number): void {
|
|
2837
3584
|
const mapKey = `${sessionId}:${coalesceKey}`;
|
|
2838
3585
|
const category = coalesceKey.split(":")[0] ?? "";
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
3586
|
+
// Single-slot categories (rolling turn/context/reasoning previews) evict prior
|
|
3587
|
+
// same-category entries to stay bounded. Concurrent categories such as
|
|
3588
|
+
// `tool:<toolCallId>` key each in-flight item independently: parallel tools each
|
|
3589
|
+
// own a distinct bubble, so evicting same-category siblings would orphan a
|
|
3590
|
+
// still-open tool's message id and leave a stale "started" bubble that its
|
|
3591
|
+
// terminal frame can no longer edit in place.
|
|
3592
|
+
if (!TelegramNotificationDaemon.#CONCURRENT_LIVE_CATEGORIES.has(category)) {
|
|
3593
|
+
const prefix = `${sessionId}:${category}:`;
|
|
3594
|
+
for (const k of [...this.liveMessages.keys()]) {
|
|
3595
|
+
if (k !== mapKey && k.startsWith(prefix)) this.liveMessages.delete(k);
|
|
3596
|
+
}
|
|
2842
3597
|
}
|
|
2843
3598
|
this.liveMessages.set(mapKey, messageId);
|
|
2844
3599
|
}
|
|
@@ -2856,7 +3611,7 @@ export class TelegramNotificationDaemon {
|
|
|
2856
3611
|
if (!(await this.pairedChatIsPrivate())) return;
|
|
2857
3612
|
await this.notifyThreadedFallback();
|
|
2858
3613
|
if (send.identity && this.flatIdentitySent.has(sessionId)) return;
|
|
2859
|
-
this.
|
|
3614
|
+
this.submitPool({ sessionId, lane: send.lane, coalesceKey: send.coalesceKey, payload: { send } });
|
|
2860
3615
|
await this.flushPool();
|
|
2861
3616
|
if (send.identity) this.flatIdentitySent.add(sessionId);
|
|
2862
3617
|
}
|
|
@@ -2924,6 +3679,38 @@ export class TelegramNotificationDaemon {
|
|
|
2924
3679
|
private stopFlushTimer(): void {
|
|
2925
3680
|
this.runtime.stopInterval("telegram-flush");
|
|
2926
3681
|
}
|
|
3682
|
+
private async renewOwnershipHeartbeat(): Promise<boolean> {
|
|
3683
|
+
return renewDaemonHeartbeat({
|
|
3684
|
+
settings: this.opts.settings,
|
|
3685
|
+
ownerId: this.opts.ownerId,
|
|
3686
|
+
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
3687
|
+
chatId: this.opts.chatId,
|
|
3688
|
+
fs: this.fsImpl,
|
|
3689
|
+
now: this.opts.now,
|
|
3690
|
+
pid: this.opts.pid ?? process.pid,
|
|
3691
|
+
});
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
/**
|
|
3695
|
+
* Ownership must be renewed independently of Telegram's 25-second long poll:
|
|
3696
|
+
* the ownership TTL is shorter than a single poll request.
|
|
3697
|
+
*/
|
|
3698
|
+
private startOwnershipHeartbeatTimer(): void {
|
|
3699
|
+
this.runtime.startInterval("telegram-owner-heartbeat", HEARTBEAT_INTERVAL_MS, () => {
|
|
3700
|
+
if (!this.running) return;
|
|
3701
|
+
void this.runtime
|
|
3702
|
+
.runExclusive("telegram-owner-heartbeat", async () => {
|
|
3703
|
+
if (!(await this.renewOwnershipHeartbeat())) this.runtime.requestStop();
|
|
3704
|
+
})
|
|
3705
|
+
.catch(err => {
|
|
3706
|
+
logger.warn(`notifications: ownership heartbeat failed: ${sanitizeDiagnostic(String(err))}`);
|
|
3707
|
+
});
|
|
3708
|
+
});
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
private stopOwnershipHeartbeatTimer(): void {
|
|
3712
|
+
this.runtime.stopInterval("telegram-owner-heartbeat");
|
|
3713
|
+
}
|
|
2927
3714
|
|
|
2928
3715
|
/** Run a root scan, guarding against overlapping scans from the timer + loop. */
|
|
2929
3716
|
private async runScan(): Promise<void> {
|
|
@@ -2945,8 +3732,9 @@ export class TelegramNotificationDaemon {
|
|
|
2945
3732
|
|
|
2946
3733
|
/** Send a single `typing` chat action into a busy session's topic (best-effort). */
|
|
2947
3734
|
private async sendTyping(sessionId: string): Promise<void> {
|
|
2948
|
-
const
|
|
2949
|
-
if (!
|
|
3735
|
+
const topicLease = await this.topicAuthorityLease(sessionId);
|
|
3736
|
+
if (!topicLease || !this.topicLeaseIsCurrent(topicLease)) return;
|
|
3737
|
+
const topicId = topicLease.topicId;
|
|
2950
3738
|
try {
|
|
2951
3739
|
await this.botApi.call("sendChatAction", {
|
|
2952
3740
|
chat_id: this.opts.chatId,
|
|
@@ -3010,7 +3798,8 @@ export class TelegramNotificationDaemon {
|
|
|
3010
3798
|
const topicId =
|
|
3011
3799
|
(await this.existingTopicForPrivateChat(session.sessionId)) ??
|
|
3012
3800
|
(await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
|
|
3013
|
-
|
|
3801
|
+
const topicLease = await this.topicAuthorityLease(session.sessionId);
|
|
3802
|
+
if (!topicId || !topicLease || topicLease.topicId !== topicId) return false;
|
|
3014
3803
|
|
|
3015
3804
|
// Each logical session owns only its most recently rendered menu.
|
|
3016
3805
|
this.#clearModelChoiceAliases(logicalSessionId);
|
|
@@ -3021,6 +3810,7 @@ export class TelegramNotificationDaemon {
|
|
|
3021
3810
|
choices.map(choice => choice.label),
|
|
3022
3811
|
index => aliases[index]!,
|
|
3023
3812
|
);
|
|
3813
|
+
if (!this.topicLeaseIsCurrent(topicLease)) return false;
|
|
3024
3814
|
try {
|
|
3025
3815
|
const response = await this.botApi.call("sendMessage", {
|
|
3026
3816
|
chat_id: this.opts.chatId,
|
|
@@ -3056,6 +3846,9 @@ export class TelegramNotificationDaemon {
|
|
|
3056
3846
|
Number.isSafeInteger(msg.lastSeq) &&
|
|
3057
3847
|
msg.lastSeq >= 0 &&
|
|
3058
3848
|
Array.isArray(msg.events);
|
|
3849
|
+
if (replayValid) {
|
|
3850
|
+
session.hostGeneration = msg.generation;
|
|
3851
|
+
}
|
|
3059
3852
|
const replayed: Record<string, unknown>[] = replayValid
|
|
3060
3853
|
? (msg.events as unknown[]).flatMap((event: unknown): Record<string, unknown>[] => {
|
|
3061
3854
|
if (!event || typeof event !== "object" || Array.isArray(event)) return [];
|
|
@@ -3097,8 +3890,12 @@ export class TelegramNotificationDaemon {
|
|
|
3097
3890
|
}
|
|
3098
3891
|
await this.handleSessionMessage(session, frame);
|
|
3099
3892
|
}
|
|
3100
|
-
if (replayValid
|
|
3101
|
-
|
|
3893
|
+
if (replayValid) {
|
|
3894
|
+
this.#terminalizeBtwTurnsForGenerationChange(session);
|
|
3895
|
+
this.#resumeBtwTurnsForSession(session);
|
|
3896
|
+
if (this.topics.markReplayCursor(session.sessionId, msg.generation, msg.lastSeq))
|
|
3897
|
+
await this.persistTopics();
|
|
3898
|
+
}
|
|
3102
3899
|
return;
|
|
3103
3900
|
}
|
|
3104
3901
|
if (msg?.type === "event_replay_result") return;
|
|
@@ -3106,25 +3903,213 @@ export class TelegramNotificationDaemon {
|
|
|
3106
3903
|
if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
|
|
3107
3904
|
if (await this.#renderModelChoices(session, msg as Record<string, unknown>)) return;
|
|
3108
3905
|
|
|
3906
|
+
if (msg?.type === "ephemeral_turn_result") {
|
|
3907
|
+
const requestId = typeof msg.requestId === "string" ? msg.requestId : undefined;
|
|
3908
|
+
if (!requestId) return;
|
|
3909
|
+
this.#purgeBtwTombstones();
|
|
3910
|
+
const tombstone = this.#btwTerminalTombstones.get(requestId);
|
|
3911
|
+
if (
|
|
3912
|
+
tombstone &&
|
|
3913
|
+
tombstone.sessionId === msg.sessionId &&
|
|
3914
|
+
tombstone.updateId === msg.updateId &&
|
|
3915
|
+
tombstone.messageId === msg.messageId &&
|
|
3916
|
+
tombstone.threadId === msg.threadId
|
|
3917
|
+
)
|
|
3918
|
+
return;
|
|
3919
|
+
if (tombstone) return;
|
|
3920
|
+
const pending = this.#pendingBtwTurns.get(requestId);
|
|
3921
|
+
if (!pending || pending.expiresAt <= (this.opts.now?.() ?? Date.now())) {
|
|
3922
|
+
this.#pendingBtwTurns.delete(requestId);
|
|
3923
|
+
return;
|
|
3924
|
+
}
|
|
3925
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
3926
|
+
if (
|
|
3927
|
+
msg.sessionId !== logicalSessionId ||
|
|
3928
|
+
pending.logicalSessionId !== logicalSessionId ||
|
|
3929
|
+
pending.transportSessionId !== session.sessionId ||
|
|
3930
|
+
this.sessions.get(session.sessionId) !== session ||
|
|
3931
|
+
pending.endpointDigest !== session.endpointDigest ||
|
|
3932
|
+
pending.generation !== session.hostGeneration ||
|
|
3933
|
+
msg.threadId !== pending.threadId ||
|
|
3934
|
+
msg.updateId !== pending.updateId ||
|
|
3935
|
+
msg.messageId !== pending.messageId ||
|
|
3936
|
+
this.topics.sessionForTopic(pending.threadId) !== pending.transportSessionId
|
|
3937
|
+
)
|
|
3938
|
+
return;
|
|
3939
|
+
if (
|
|
3940
|
+
msg.status !== "ok" &&
|
|
3941
|
+
msg.status !== "busy" &&
|
|
3942
|
+
msg.status !== "timeout" &&
|
|
3943
|
+
msg.status !== "cancelled" &&
|
|
3944
|
+
msg.status !== "session_unavailable" &&
|
|
3945
|
+
msg.status !== "failed"
|
|
3946
|
+
)
|
|
3947
|
+
return;
|
|
3948
|
+
if (msg.status === "ok" && typeof msg.text !== "string") return;
|
|
3949
|
+
if (this.#stoppingBtw || this.#btwTerminalDeliveries.has(requestId)) return;
|
|
3950
|
+
const finished = Promise.withResolvers<void>();
|
|
3951
|
+
const terminalDelivery: PendingBtwDelivery = {
|
|
3952
|
+
pending,
|
|
3953
|
+
invalidated: false,
|
|
3954
|
+
terminalizeOnInvalidation: false,
|
|
3955
|
+
controller: new AbortController(),
|
|
3956
|
+
finished: finished.promise,
|
|
3957
|
+
finish: finished.resolve,
|
|
3958
|
+
};
|
|
3959
|
+
this.#btwTerminalDeliveries.set(requestId, terminalDelivery);
|
|
3960
|
+
let deliveryOutcome: "accepted" | "not_delivered" | "uncertain" | "partial_accepted" = "not_delivered";
|
|
3961
|
+
try {
|
|
3962
|
+
if (msg.status !== "ok") {
|
|
3963
|
+
const text =
|
|
3964
|
+
msg.status === "busy"
|
|
3965
|
+
? "Two /btw questions are already running. Wait for one to finish."
|
|
3966
|
+
: msg.status === "timeout"
|
|
3967
|
+
? "This /btw question timed out after 120 seconds. Send it again to retry."
|
|
3968
|
+
: msg.status === "cancelled" || msg.status === "session_unavailable"
|
|
3969
|
+
? "This /btw question stopped because the GJC session closed or changed. Reopen it and try again."
|
|
3970
|
+
: "This /btw question failed. Send it again to retry.";
|
|
3971
|
+
try {
|
|
3972
|
+
const response = await this.#sendBtwMessage({
|
|
3973
|
+
threadId: pending.threadId,
|
|
3974
|
+
messageId: pending.messageId,
|
|
3975
|
+
text,
|
|
3976
|
+
signal: terminalDelivery.controller.signal,
|
|
3977
|
+
});
|
|
3978
|
+
deliveryOutcome =
|
|
3979
|
+
response && typeof response === "object" && (response as { ok?: unknown }).ok === true
|
|
3980
|
+
? "accepted"
|
|
3981
|
+
: response && typeof response === "object" && (response as { ok?: unknown }).ok === false
|
|
3982
|
+
? "not_delivered"
|
|
3983
|
+
: "uncertain";
|
|
3984
|
+
} catch {
|
|
3985
|
+
deliveryOutcome = "uncertain";
|
|
3986
|
+
logger.warn("notifications: /btw status delivery failed");
|
|
3987
|
+
}
|
|
3988
|
+
return;
|
|
3989
|
+
}
|
|
3990
|
+
const markdown = msg.text;
|
|
3991
|
+
const isAuthoritative = (): boolean =>
|
|
3992
|
+
!this.#stoppingBtw &&
|
|
3993
|
+
this.sessions.get(session.sessionId) === session &&
|
|
3994
|
+
session.ws.readyState === WebSocket.OPEN &&
|
|
3995
|
+
pending.endpointDigest === session.endpointDigest &&
|
|
3996
|
+
pending.generation === session.hostGeneration &&
|
|
3997
|
+
pending.logicalSessionId === this.#logicalSessionId(session) &&
|
|
3998
|
+
pending.transportSessionId === session.sessionId &&
|
|
3999
|
+
requestId === msg.requestId &&
|
|
4000
|
+
pending.logicalSessionId === msg.sessionId &&
|
|
4001
|
+
pending.messageId === msg.messageId &&
|
|
4002
|
+
pending.threadId === msg.threadId &&
|
|
4003
|
+
pending.updateId === msg.updateId &&
|
|
4004
|
+
this.topics.sessionForTopic(pending.threadId) === pending.transportSessionId;
|
|
4005
|
+
const signal = AbortSignal.any([
|
|
4006
|
+
this.#btwDeliveryAbort.signal,
|
|
4007
|
+
terminalDelivery.controller.signal,
|
|
4008
|
+
AbortSignal.timeout(30_000),
|
|
4009
|
+
]);
|
|
4010
|
+
const deliver = async (
|
|
4011
|
+
method: "sendMessage" | "sendRichMessage",
|
|
4012
|
+
body: unknown,
|
|
4013
|
+
): Promise<"accepted" | "rejected" | "uncertain" | "stale"> => {
|
|
4014
|
+
if (!isAuthoritative()) return "stale";
|
|
4015
|
+
try {
|
|
4016
|
+
const response = await this.botApi.call(method, body, { noRetry: true, signal });
|
|
4017
|
+
if (!response || typeof response !== "object") return "uncertain";
|
|
4018
|
+
if ((response as { ok?: unknown }).ok === true) return "accepted";
|
|
4019
|
+
if ((response as { ok?: unknown }).ok === false) return "rejected";
|
|
4020
|
+
return "uncertain";
|
|
4021
|
+
} catch {
|
|
4022
|
+
return "uncertain";
|
|
4023
|
+
}
|
|
4024
|
+
};
|
|
4025
|
+
const html = markdownToTelegramHtml(markdown);
|
|
4026
|
+
const fallback = async (): Promise<typeof deliveryOutcome> => {
|
|
4027
|
+
let acceptedChunks = 0;
|
|
4028
|
+
for (const [index, text] of splitTelegramHtml(html).entries()) {
|
|
4029
|
+
const outcome = await this.#queueBtwFallbackChunk({
|
|
4030
|
+
requestId,
|
|
4031
|
+
chunkIndex: index,
|
|
4032
|
+
pending,
|
|
4033
|
+
signal,
|
|
4034
|
+
isAuthoritative,
|
|
4035
|
+
body: {
|
|
4036
|
+
chat_id: this.opts.chatId,
|
|
4037
|
+
message_thread_id: Number(pending.threadId),
|
|
4038
|
+
...(index === 0 ? { reply_parameters: { message_id: pending.messageId } } : {}),
|
|
4039
|
+
text,
|
|
4040
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
4041
|
+
},
|
|
4042
|
+
});
|
|
4043
|
+
if (outcome === "accepted") {
|
|
4044
|
+
acceptedChunks++;
|
|
4045
|
+
continue;
|
|
4046
|
+
}
|
|
4047
|
+
if (outcome === "partial_accepted" || acceptedChunks > 0) return "partial_accepted";
|
|
4048
|
+
return outcome === "uncertain" ? "uncertain" : "not_delivered";
|
|
4049
|
+
}
|
|
4050
|
+
return "accepted";
|
|
4051
|
+
};
|
|
4052
|
+
if (this.opts.rich?.enabled !== false && isBtwRichEligible(markdown)) {
|
|
4053
|
+
const outcome = await deliver("sendRichMessage", {
|
|
4054
|
+
chat_id: this.opts.chatId,
|
|
4055
|
+
message_thread_id: Number(pending.threadId),
|
|
4056
|
+
reply_parameters: { message_id: pending.messageId },
|
|
4057
|
+
rich_message: { markdown, skip_entity_detection: true },
|
|
4058
|
+
});
|
|
4059
|
+
deliveryOutcome =
|
|
4060
|
+
outcome === "accepted"
|
|
4061
|
+
? "accepted"
|
|
4062
|
+
: outcome === "rejected"
|
|
4063
|
+
? await fallback()
|
|
4064
|
+
: outcome === "uncertain"
|
|
4065
|
+
? "uncertain"
|
|
4066
|
+
: "not_delivered";
|
|
4067
|
+
} else {
|
|
4068
|
+
deliveryOutcome = await fallback();
|
|
4069
|
+
}
|
|
4070
|
+
} finally {
|
|
4071
|
+
if (this.#btwTerminalDeliveries.get(requestId) === terminalDelivery) {
|
|
4072
|
+
this.#btwTerminalDeliveries.delete(requestId);
|
|
4073
|
+
}
|
|
4074
|
+
try {
|
|
4075
|
+
// Unknown transport outcomes stay single-attempt to avoid duplicate Telegram replies.
|
|
4076
|
+
if (terminalDelivery.invalidated && deliveryOutcome === "not_delivered") {
|
|
4077
|
+
if (terminalDelivery.terminalizeOnInvalidation)
|
|
4078
|
+
await this.#terminalizeBtwTurn(requestId, pending, this.#stoppingBtw);
|
|
4079
|
+
} else {
|
|
4080
|
+
// Any accepted chunk is already a user-visible terminal sequence. Tombstone
|
|
4081
|
+
// it even when a later chunk fails so invalidation cannot append a second
|
|
4082
|
+
// session-unavailable reply.
|
|
4083
|
+
this.#takeBtwTurn(requestId, pending);
|
|
4084
|
+
}
|
|
4085
|
+
} finally {
|
|
4086
|
+
terminalDelivery.finish();
|
|
4087
|
+
}
|
|
4088
|
+
}
|
|
4089
|
+
return;
|
|
4090
|
+
}
|
|
3109
4091
|
if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
|
|
3110
4092
|
const send = renderThreadedFrame(msg);
|
|
3111
4093
|
if (!send) return;
|
|
3112
4094
|
const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
|
|
4095
|
+
if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
|
|
3113
4096
|
if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
|
|
3114
4097
|
this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
|
|
3115
4098
|
return;
|
|
3116
4099
|
}
|
|
3117
4100
|
if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
|
|
3118
4101
|
const ownerId = this.topicOwnerForIdentity(msg);
|
|
3119
|
-
const
|
|
3120
|
-
if (ownerId && ownerId !== session.sessionId &&
|
|
3121
|
-
await this.flushPendingThreadedFrames(session.sessionId,
|
|
4102
|
+
const ownerLease = ownerId ? await this.topicAuthorityLease(ownerId) : undefined;
|
|
4103
|
+
if (ownerId && ownerId !== session.sessionId && ownerLease) {
|
|
4104
|
+
await this.flushPendingThreadedFrames(session.sessionId, ownerLease);
|
|
3122
4105
|
return;
|
|
3123
4106
|
}
|
|
3124
4107
|
}
|
|
3125
4108
|
const topicId =
|
|
3126
4109
|
existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
|
|
3127
|
-
|
|
4110
|
+
const topicLease = await this.topicAuthorityLease(session.sessionId);
|
|
4111
|
+
if (!topicId || !topicLease || topicLease.topicId !== topicId) {
|
|
4112
|
+
if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
|
|
3128
4113
|
await this.deliverFlatFallback(session.sessionId, send);
|
|
3129
4114
|
return;
|
|
3130
4115
|
}
|
|
@@ -3137,7 +4122,7 @@ export class TelegramNotificationDaemon {
|
|
|
3137
4122
|
// Explicit Telegram-side user renames own the topic title. Pending user
|
|
3138
4123
|
// reconciliation runs before daemon identity naming, so retries and daemon
|
|
3139
4124
|
// restarts cannot silently replace the preserved name.
|
|
3140
|
-
await this.reconcileUserTopicName(
|
|
4125
|
+
await this.reconcileUserTopicName(topicLease);
|
|
3141
4126
|
const name = this.topicNameFor(session.sessionId, msg);
|
|
3142
4127
|
if (this.topics.needsRename(session.sessionId, name)) {
|
|
3143
4128
|
this.daemonRenameAttempts.set(
|
|
@@ -3145,6 +4130,7 @@ export class TelegramNotificationDaemon {
|
|
|
3145
4130
|
(this.daemonRenameAttempts.get(session.sessionId) ?? 0) + 1,
|
|
3146
4131
|
);
|
|
3147
4132
|
try {
|
|
4133
|
+
if (!this.topicLeaseIsCurrent(topicLease)) return;
|
|
3148
4134
|
const response = await this.botApi.call("editForumTopic", {
|
|
3149
4135
|
chat_id: this.opts.chatId,
|
|
3150
4136
|
message_thread_id: Number(topicId),
|
|
@@ -3159,31 +4145,34 @@ export class TelegramNotificationDaemon {
|
|
|
3159
4145
|
if (remaining > 0) this.daemonRenameAttempts.set(session.sessionId, remaining);
|
|
3160
4146
|
else {
|
|
3161
4147
|
this.daemonRenameAttempts.delete(session.sessionId);
|
|
3162
|
-
await this.reconcileUserTopicName(
|
|
4148
|
+
await this.reconcileUserTopicName(topicLease);
|
|
3163
4149
|
}
|
|
3164
4150
|
}
|
|
3165
4151
|
}
|
|
3166
4152
|
// Send the full bulleted identity header EXACTLY ONCE per topic.
|
|
3167
4153
|
if (this.topics.needsIdentity(session.sessionId)) {
|
|
3168
|
-
await this.submitThreadedFrame(session.sessionId, send,
|
|
4154
|
+
await this.submitThreadedFrame(session.sessionId, send, topicLease);
|
|
3169
4155
|
this.topics.markIdentitySent(session.sessionId);
|
|
3170
4156
|
}
|
|
3171
|
-
await this.flushPendingThreadedFrames(session.sessionId,
|
|
4157
|
+
await this.flushPendingThreadedFrames(session.sessionId, topicLease);
|
|
3172
4158
|
await this.persistTopics();
|
|
3173
4159
|
return;
|
|
3174
4160
|
}
|
|
3175
|
-
await this.submitThreadedFrame(session.sessionId, send,
|
|
4161
|
+
await this.submitThreadedFrame(session.sessionId, send, topicLease);
|
|
3176
4162
|
return;
|
|
3177
4163
|
}
|
|
3178
4164
|
if (msg.type === "action_needed" && msg.id) {
|
|
3179
4165
|
if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: session.sessionId, actionId: msg.id });
|
|
4166
|
+
if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
|
|
3180
4167
|
const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
|
|
4168
|
+
const topicLease = topicId ? this.topicAuthorityLeaseFromRegistry(session.sessionId) : undefined;
|
|
4169
|
+
if (topicId && (!topicLease || topicLease.topicId !== topicId)) return;
|
|
3181
4170
|
if (!topicId) {
|
|
3182
4171
|
// Fail closed for non-private chats; only nudge + flat-deliver in a private DM.
|
|
3183
4172
|
if (!(await this.pairedChatIsPrivate())) return;
|
|
3184
4173
|
await this.notifyThreadedFallback();
|
|
3185
4174
|
}
|
|
3186
|
-
const threadField =
|
|
4175
|
+
const threadField = topicLease ? { message_thread_id: Number(topicLease.topicId) } : {};
|
|
3187
4176
|
const controls: Array<{
|
|
3188
4177
|
id: "navigation_forward";
|
|
3189
4178
|
kind: "navigation";
|
|
@@ -3238,6 +4227,7 @@ export class TelegramNotificationDaemon {
|
|
|
3238
4227
|
const chunks = splitTelegramHtml(rendered.text);
|
|
3239
4228
|
let result: { result?: { message_id?: number } } = {};
|
|
3240
4229
|
for (let i = 0; i < chunks.length; i++) {
|
|
4230
|
+
if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return undefined;
|
|
3241
4231
|
result = (await this.botApi.call("sendMessage", {
|
|
3242
4232
|
chat_id: this.opts.chatId,
|
|
3243
4233
|
...threadField,
|
|
@@ -3253,6 +4243,7 @@ export class TelegramNotificationDaemon {
|
|
|
3253
4243
|
// Rich (default on): promote to sendRichMessage with a top-level
|
|
3254
4244
|
// reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
|
|
3255
4245
|
|
|
4246
|
+
if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
|
|
3256
4247
|
const outcome = await deliverRichActionWithFallback(
|
|
3257
4248
|
this.botApi,
|
|
3258
4249
|
{ chat_id: this.opts.chatId, ...threadField },
|
|
@@ -3264,8 +4255,8 @@ export class TelegramNotificationDaemon {
|
|
|
3264
4255
|
summary: msg.summary,
|
|
3265
4256
|
}),
|
|
3266
4257
|
replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
|
|
3267
|
-
requireMessageId: kind === "ask",
|
|
3268
4258
|
},
|
|
4259
|
+
AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
|
|
3269
4260
|
sendHtmlChunks,
|
|
3270
4261
|
logger,
|
|
3271
4262
|
);
|
|
@@ -3433,7 +4424,8 @@ export class TelegramNotificationDaemon {
|
|
|
3433
4424
|
} catch {
|
|
3434
4425
|
return "retry";
|
|
3435
4426
|
}
|
|
3436
|
-
|
|
4427
|
+
const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
|
|
4428
|
+
if (topicLease) await this.reconcileUserTopicName(topicLease);
|
|
3437
4429
|
await this.rememberSeenUpdateId(updateId);
|
|
3438
4430
|
return "consumed";
|
|
3439
4431
|
}
|
|
@@ -3442,7 +4434,8 @@ export class TelegramNotificationDaemon {
|
|
|
3442
4434
|
} catch {
|
|
3443
4435
|
return "retry";
|
|
3444
4436
|
}
|
|
3445
|
-
|
|
4437
|
+
const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
|
|
4438
|
+
if (topicLease) await this.reconcileUserTopicName(topicLease);
|
|
3446
4439
|
await this.rememberSeenUpdateId(updateId);
|
|
3447
4440
|
return "consumed";
|
|
3448
4441
|
}
|
|
@@ -3560,6 +4553,8 @@ export class TelegramNotificationDaemon {
|
|
|
3560
4553
|
// message in a topic (plain text, or a reply to a non-ask message) is a
|
|
3561
4554
|
// free-text injection. Previously replies bypassed injection entirely.
|
|
3562
4555
|
const replyTo = raw.message?.reply_to_message?.message_id;
|
|
4556
|
+
const reservedBtw =
|
|
4557
|
+
typeof raw.message?.text === "string" ? parseBtwCommand(raw.message.text, this.botUsername) : undefined;
|
|
3563
4558
|
const isAskReply =
|
|
3564
4559
|
replyTo !== undefined && (this.messageRoutes.has(String(replyTo)) || this.messageRoutes.has(Number(replyTo)));
|
|
3565
4560
|
const directControl =
|
|
@@ -3567,7 +4562,7 @@ export class TelegramNotificationDaemon {
|
|
|
3567
4562
|
? parseTelegramControlCommand(raw.message.text, this.botUsername)
|
|
3568
4563
|
: ({ kind: "none" } as const);
|
|
3569
4564
|
const interceptAskControl = isAskReply && directControl.kind !== "none";
|
|
3570
|
-
if (!raw.callback_query && (!isAskReply || interceptAskControl)) {
|
|
4565
|
+
if (!raw.callback_query && (!isAskReply || interceptAskControl || reservedBtw !== undefined)) {
|
|
3571
4566
|
const inbound = decideThreadedInbound(update as never, {
|
|
3572
4567
|
pairedChatId: this.opts.chatId,
|
|
3573
4568
|
topicToSession: t => this.topics.sessionForTopic(t),
|
|
@@ -3583,7 +4578,7 @@ export class TelegramNotificationDaemon {
|
|
|
3583
4578
|
const session = this.sessions.get(inbound.sessionId);
|
|
3584
4579
|
if (preliminaryControl.kind === "invalid" && session?.ws.readyState !== WebSocket.OPEN) return;
|
|
3585
4580
|
if (preliminaryControl.kind === "command" && session?.ws.readyState !== WebSocket.OPEN) {
|
|
3586
|
-
if (await this.
|
|
4581
|
+
if (await this.reserveSeenUpdateId(inbound.updateId)) {
|
|
3587
4582
|
try {
|
|
3588
4583
|
await this.botApi.call("sendMessage", {
|
|
3589
4584
|
chat_id: this.opts.chatId,
|
|
@@ -3596,13 +4591,45 @@ export class TelegramNotificationDaemon {
|
|
|
3596
4591
|
}
|
|
3597
4592
|
return;
|
|
3598
4593
|
}
|
|
4594
|
+
const reservedBtw = parseBtwCommand(inbound.text, this.botUsername);
|
|
4595
|
+
if (reservedBtw?.kind === "ignored") {
|
|
4596
|
+
await this.rememberSeenUpdateId(inbound.updateId);
|
|
4597
|
+
return;
|
|
4598
|
+
}
|
|
4599
|
+
if (reservedBtw?.kind === "question" && (!reservedBtw.question || inbound.attachment)) {
|
|
4600
|
+
if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
|
|
4601
|
+
await this.#sendBtwMessage({
|
|
4602
|
+
threadId: inbound.threadId,
|
|
4603
|
+
messageId: inbound.messageId,
|
|
4604
|
+
text: BTW_USAGE_TEXT,
|
|
4605
|
+
});
|
|
4606
|
+
return;
|
|
4607
|
+
}
|
|
4608
|
+
if (reservedBtw?.kind === "question" && this.opts.btw?.enabled === false) {
|
|
4609
|
+
if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
|
|
4610
|
+
await this.#sendBtwMessage({
|
|
4611
|
+
threadId: inbound.threadId,
|
|
4612
|
+
messageId: inbound.messageId,
|
|
4613
|
+
text: "Telegram /btw is disabled in local settings.",
|
|
4614
|
+
});
|
|
4615
|
+
return;
|
|
4616
|
+
}
|
|
4617
|
+
if (reservedBtw?.kind === "question" && session?.ws.readyState !== WebSocket.OPEN) {
|
|
4618
|
+
if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
|
|
4619
|
+
await this.#sendBtwMessage({
|
|
4620
|
+
threadId: inbound.threadId,
|
|
4621
|
+
messageId: inbound.messageId,
|
|
4622
|
+
text: "Restart this GJC session to enable /btw.",
|
|
4623
|
+
});
|
|
4624
|
+
return;
|
|
4625
|
+
}
|
|
3599
4626
|
if (session?.ws.readyState === WebSocket.OPEN) {
|
|
3600
4627
|
const attachmentResult = inbound.attachment
|
|
3601
4628
|
? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
|
|
3602
4629
|
: undefined;
|
|
3603
4630
|
const images = attachmentResult?.images ?? [];
|
|
3604
4631
|
const fileNotes = attachmentResult?.fileNotes ?? [];
|
|
3605
|
-
const hasMedia = images.length > 0 || fileNotes.length > 0;
|
|
4632
|
+
const hasMedia = inbound.attachment !== undefined || images.length > 0 || fileNotes.length > 0;
|
|
3606
4633
|
const baseInjectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
|
|
3607
4634
|
// A reply to a rich message we sent (not an ask route) loses its original
|
|
3608
4635
|
// text: Telegram does not echo it in reply_to_message. Restore it from the
|
|
@@ -3614,6 +4641,100 @@ export class TelegramNotificationDaemon {
|
|
|
3614
4641
|
const injectedText = repliedOriginal
|
|
3615
4642
|
? `> replied-to message:\n${repliedOriginal}\n\n${baseInjectedText}`
|
|
3616
4643
|
: baseInjectedText;
|
|
4644
|
+
const btw = parseBtwCommand(inbound.text, this.botUsername);
|
|
4645
|
+
if (btw?.kind === "ignored") {
|
|
4646
|
+
await this.rememberSeenUpdateId(inbound.updateId);
|
|
4647
|
+
return;
|
|
4648
|
+
}
|
|
4649
|
+
if (btw?.kind === "question") {
|
|
4650
|
+
const btwQuestion = btw.question;
|
|
4651
|
+
if (!btwQuestion || hasMedia) {
|
|
4652
|
+
if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
|
|
4653
|
+
await this.#sendBtwMessage({
|
|
4654
|
+
threadId: inbound.threadId,
|
|
4655
|
+
messageId: inbound.messageId,
|
|
4656
|
+
text: BTW_USAGE_TEXT,
|
|
4657
|
+
});
|
|
4658
|
+
return;
|
|
4659
|
+
}
|
|
4660
|
+
if (!isValidBtwQuestion(btwQuestion)) {
|
|
4661
|
+
try {
|
|
4662
|
+
await this.#sendBtwMessage({
|
|
4663
|
+
threadId: inbound.threadId,
|
|
4664
|
+
messageId: inbound.messageId,
|
|
4665
|
+
text: BTW_QUESTION_LIMIT_TEXT,
|
|
4666
|
+
});
|
|
4667
|
+
} catch {
|
|
4668
|
+
logger.warn("notifications: /btw question-limit delivery failed");
|
|
4669
|
+
}
|
|
4670
|
+
await this.reserveSeenUpdateId(inbound.updateId);
|
|
4671
|
+
return;
|
|
4672
|
+
}
|
|
4673
|
+
if (this.opts.btw?.enabled === false) {
|
|
4674
|
+
if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
|
|
4675
|
+
await this.#sendBtwMessage({
|
|
4676
|
+
threadId: inbound.threadId,
|
|
4677
|
+
messageId: inbound.messageId,
|
|
4678
|
+
text: "Telegram /btw is disabled in local settings.",
|
|
4679
|
+
});
|
|
4680
|
+
return;
|
|
4681
|
+
}
|
|
4682
|
+
if (!session.ephemeralCapable || session.hostGeneration < 1) {
|
|
4683
|
+
if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
|
|
4684
|
+
await this.#sendBtwMessage({
|
|
4685
|
+
threadId: inbound.threadId,
|
|
4686
|
+
messageId: inbound.messageId,
|
|
4687
|
+
text: "Restart this GJC session to enable /btw.",
|
|
4688
|
+
});
|
|
4689
|
+
return;
|
|
4690
|
+
}
|
|
4691
|
+
this.#purgeBtwTombstones();
|
|
4692
|
+
for (const [id, pending] of this.#pendingBtwTurns) {
|
|
4693
|
+
if (pending.expiresAt <= (this.opts.now?.() ?? Date.now())) this.#pendingBtwTurns.delete(id);
|
|
4694
|
+
}
|
|
4695
|
+
if (!Number.isSafeInteger(inbound.messageId) || inbound.messageId <= 0) return;
|
|
4696
|
+
while (
|
|
4697
|
+
this.#pendingBtwTurns.size + this.#btwTerminalTombstones.size >= BTW_MAX_PENDING &&
|
|
4698
|
+
this.#btwTerminalTombstones.size > 0
|
|
4699
|
+
) {
|
|
4700
|
+
this.#btwTerminalTombstones.delete(this.#btwTerminalTombstones.keys().next().value!);
|
|
4701
|
+
}
|
|
4702
|
+
if (this.#pendingBtwTurns.size >= BTW_MAX_PENDING) {
|
|
4703
|
+
if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
|
|
4704
|
+
await this.#sendBtwMessage({
|
|
4705
|
+
threadId: inbound.threadId,
|
|
4706
|
+
messageId: inbound.messageId,
|
|
4707
|
+
text: BTW_CAPACITY_TEXT,
|
|
4708
|
+
});
|
|
4709
|
+
return;
|
|
4710
|
+
}
|
|
4711
|
+
const transportSessionId = session.sessionId;
|
|
4712
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
4713
|
+
const requestId = `btw:${crypto.randomUUID()}`;
|
|
4714
|
+
if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
|
|
4715
|
+
const pending: PendingBtwTurn = {
|
|
4716
|
+
transportSessionId,
|
|
4717
|
+
logicalSessionId,
|
|
4718
|
+
endpointDigest: session.endpointDigest,
|
|
4719
|
+
generation: session.hostGeneration,
|
|
4720
|
+
question: btwQuestion,
|
|
4721
|
+
messageId: inbound.messageId,
|
|
4722
|
+
threadId: inbound.threadId,
|
|
4723
|
+
updateId: inbound.updateId,
|
|
4724
|
+
expiresAt: (this.opts.now?.() ?? Date.now()) + BTW_PENDING_TTL_MS,
|
|
4725
|
+
};
|
|
4726
|
+
this.#pendingBtwTurns.set(requestId, pending);
|
|
4727
|
+
if (!this.#sendPendingBtwTurn(session, requestId, pending)) {
|
|
4728
|
+
this.#pendingBtwTurns.delete(requestId);
|
|
4729
|
+
await this.#sendBtwMessage({
|
|
4730
|
+
threadId: inbound.threadId,
|
|
4731
|
+
messageId: inbound.messageId,
|
|
4732
|
+
text: "Unable to start /btw because this GJC session disconnected. Reopen the session and try again.",
|
|
4733
|
+
});
|
|
4734
|
+
return;
|
|
4735
|
+
}
|
|
4736
|
+
return;
|
|
4737
|
+
}
|
|
3617
4738
|
const control = hasMedia ? { kind: "none" as const } : preliminaryControl;
|
|
3618
4739
|
if (control.kind !== "none") {
|
|
3619
4740
|
await this.rememberSeenUpdateId(inbound.updateId);
|
|
@@ -3727,7 +4848,10 @@ export class TelegramNotificationDaemon {
|
|
|
3727
4848
|
try {
|
|
3728
4849
|
await this.botApi.call("setMyCommands", {
|
|
3729
4850
|
commands: [
|
|
3730
|
-
{
|
|
4851
|
+
{
|
|
4852
|
+
command: "verbose",
|
|
4853
|
+
description: "Mirror bounded tool-owned summaries + provider-displayable reasoning summaries",
|
|
4854
|
+
},
|
|
3731
4855
|
{ command: "lean", description: "Mirror assistant text + tool names only (default)" },
|
|
3732
4856
|
{ command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
|
|
3733
4857
|
{ command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
|
|
@@ -3736,6 +4860,7 @@ export class TelegramNotificationDaemon {
|
|
|
3736
4860
|
{ command: "model", description: "Select a model for this session" },
|
|
3737
4861
|
{ command: "context", description: "Show current context usage for this session" },
|
|
3738
4862
|
{ command: "compact", description: "Compact this session: /compact [instructions]" },
|
|
4863
|
+
{ command: "btw", description: "Ask an ephemeral side question in this session" },
|
|
3739
4864
|
{ command: "session_create", description: "Create a GJC session: path, worktree, or dir [--mpreset]" },
|
|
3740
4865
|
{ command: "session_recent", description: "List recent GJC sessions" },
|
|
3741
4866
|
{ command: "session_close", description: "Close a GJC-managed session" },
|
|
@@ -3748,15 +4873,25 @@ export class TelegramNotificationDaemon {
|
|
|
3748
4873
|
}
|
|
3749
4874
|
|
|
3750
4875
|
async run(): Promise<void> {
|
|
4876
|
+
// Runtime callers can bypass TypeScript's option type. Do not derive an
|
|
4877
|
+
// ownership identity from absent credentials; lifecycle startup still owns
|
|
4878
|
+
// cleanup of a factory-created server on this fail-closed path.
|
|
4879
|
+
if (!validBotToken(this.opts.botToken)) {
|
|
4880
|
+
await this.startLifecycleControl();
|
|
4881
|
+
return;
|
|
4882
|
+
}
|
|
3751
4883
|
this.running = await renewDaemonHeartbeat({
|
|
3752
4884
|
settings: this.opts.settings,
|
|
3753
4885
|
ownerId: this.opts.ownerId,
|
|
4886
|
+
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
4887
|
+
chatId: this.opts.chatId,
|
|
3754
4888
|
fs: this.fsImpl,
|
|
3755
4889
|
now: this.opts.now,
|
|
3756
4890
|
pid: this.opts.pid ?? process.pid,
|
|
3757
4891
|
});
|
|
3758
4892
|
if (!this.running) return;
|
|
3759
4893
|
this.runtime.start();
|
|
4894
|
+
this.startOwnershipHeartbeatTimer();
|
|
3760
4895
|
this.startFlushTimer();
|
|
3761
4896
|
this.startScanTimer();
|
|
3762
4897
|
this.startTypingTimer();
|
|
@@ -3774,16 +4909,7 @@ export class TelegramNotificationDaemon {
|
|
|
3774
4909
|
let idleSince = this.runtime.now();
|
|
3775
4910
|
while (this.running) {
|
|
3776
4911
|
if (await this.controlStopRequested()) break;
|
|
3777
|
-
if (
|
|
3778
|
-
!(await renewDaemonHeartbeat({
|
|
3779
|
-
settings: this.opts.settings,
|
|
3780
|
-
ownerId: this.opts.ownerId,
|
|
3781
|
-
fs: this.fsImpl,
|
|
3782
|
-
now: this.opts.now,
|
|
3783
|
-
pid: this.opts.pid ?? process.pid,
|
|
3784
|
-
}))
|
|
3785
|
-
)
|
|
3786
|
-
break;
|
|
4912
|
+
if (!(await this.renewOwnershipHeartbeat())) break;
|
|
3787
4913
|
await this.runScan();
|
|
3788
4914
|
if (await this.controlStopRequested()) break;
|
|
3789
4915
|
const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
|
|
@@ -3818,24 +4944,50 @@ export class TelegramNotificationDaemon {
|
|
|
3818
4944
|
await this.runtime.sleep(10);
|
|
3819
4945
|
}
|
|
3820
4946
|
} finally {
|
|
3821
|
-
this.
|
|
3822
|
-
|
|
3823
|
-
this.
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
now: this.opts.now,
|
|
4947
|
+
this.effects.beginShutdown();
|
|
4948
|
+
let persisted = false;
|
|
4949
|
+
const shutdown = this.effects.allowTerminal(async () => {
|
|
4950
|
+
await this.#drainBtwTurns();
|
|
4951
|
+
this.runtime.stop();
|
|
4952
|
+
this.stopOwnershipHeartbeatTimer();
|
|
4953
|
+
this.stopFlushTimer();
|
|
4954
|
+
this.stopScanTimer();
|
|
4955
|
+
this.stopTypingTimer();
|
|
4956
|
+
this.stopLifecycleControl();
|
|
4957
|
+
await this.cleanupAllAttachmentDirs();
|
|
4958
|
+
await this.persistAliases();
|
|
4959
|
+
await this.persistTopics();
|
|
4960
|
+
await this.persistSeenUpdateIds();
|
|
4961
|
+
await this.opts.control?.clear?.(this.opts.ownerId);
|
|
4962
|
+
persisted = true;
|
|
3838
4963
|
});
|
|
4964
|
+
const deadline = Promise.withResolvers<boolean>();
|
|
4965
|
+
const deadlineTimer = setTimeout(() => deadline.resolve(false), BTW_SHUTDOWN_JOIN_MS);
|
|
4966
|
+
const completed = await Promise.race([
|
|
4967
|
+
shutdown.then(
|
|
4968
|
+
() => true,
|
|
4969
|
+
error => {
|
|
4970
|
+
logger.warn(`notifications: shutdown persistence failed: ${sanitizeDiagnostic(String(error))}`);
|
|
4971
|
+
return false;
|
|
4972
|
+
},
|
|
4973
|
+
),
|
|
4974
|
+
deadline.promise,
|
|
4975
|
+
]);
|
|
4976
|
+
clearTimeout(deadlineTimer);
|
|
4977
|
+
const quiesced = completed && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
|
|
4978
|
+
if (!quiesced || !persisted) {
|
|
4979
|
+
logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
|
|
4980
|
+
} else {
|
|
4981
|
+
await releaseDaemonOwnership({
|
|
4982
|
+
settings: this.opts.settings,
|
|
4983
|
+
ownerId: this.opts.ownerId,
|
|
4984
|
+
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
4985
|
+
chatId: this.opts.chatId,
|
|
4986
|
+
pid: this.opts.pid ?? process.pid,
|
|
4987
|
+
fs: this.fsImpl,
|
|
4988
|
+
now: this.opts.now,
|
|
4989
|
+
});
|
|
4990
|
+
}
|
|
3839
4991
|
}
|
|
3840
4992
|
}
|
|
3841
4993
|
|