@gajae-code/coding-agent 0.11.0 → 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 +63 -1
- package/dist/types/async/job-manager.d.ts +46 -7
- 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-receipt-freshness.d.ts +11 -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/command-controller.d.ts +2 -2
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -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 +21 -19
- 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 +21 -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 +22 -15
- 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/ensure.d.ts +9 -0
- 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/discord-daemon.d.ts +5 -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 +69 -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/tools/subagent-render.d.ts +1 -0
- package/dist/types/tools/subagent.d.ts +6 -1
- 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 +39 -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 +455 -69
- 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 +83 -12
- package/src/gjc-runtime/ultragoal-pipeline.ts +306 -0
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +102 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +156 -1319
- 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/command-controller.ts +9 -15
- package/src/modes/controllers/event-controller.ts +3 -2
- package/src/modes/controllers/extension-ui-controller.ts +72 -25
- 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 +302 -1151
- 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 +78 -19
- 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 +47 -20
- 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 +21 -2
- package/src/sdk/broker/ensure.ts +85 -25
- 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 +14 -4
- 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/discord-daemon.ts +25 -8
- package/src/sdk/bus/html-format.ts +20 -3
- package/src/sdk/bus/index.ts +834 -113
- package/src/sdk/bus/lifecycle-control-runtime.ts +91 -33
- package/src/sdk/bus/native-runtime-compatibility.ts +1 -1
- 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 +1491 -247
- 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 +1901 -637
- 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 +62 -37
- 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/tools/subagent-render.ts +19 -8
- package/src/tools/subagent.ts +84 -32
- 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 ?? "",
|
|
@@ -941,23 +1117,96 @@ type PairedChatPrivacy = "private" | "non-private" | "indeterminate";
|
|
|
941
1117
|
|
|
942
1118
|
export type TelegramUpdateOutcome = "consumed" | "retry";
|
|
943
1119
|
|
|
1120
|
+
export type TelegramPollResult =
|
|
1121
|
+
| { kind: "success"; updateCount: number }
|
|
1122
|
+
| { kind: "aborted" }
|
|
1123
|
+
| { kind: "getUpdates_failed"; error: string }
|
|
1124
|
+
| { kind: "api_failure"; errorCode?: number; description: string }
|
|
1125
|
+
| { kind: "conflict"; description: string; backoffMs: number };
|
|
1126
|
+
|
|
944
1127
|
export interface TelegramUpdatePollerOptions {
|
|
945
1128
|
botApi: BotApi;
|
|
946
1129
|
runtime: NotificationOperatorRuntime;
|
|
947
1130
|
backoff: OperatorBackoffPolicy;
|
|
948
1131
|
processUpdate: (update: unknown) => Promise<TelegramUpdateOutcome>;
|
|
1132
|
+
health?: TelegramPollHealth;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
type TelegramPollHealthStatus = "healthy" | "getUpdates_failed" | "api_failure" | "conflict";
|
|
1136
|
+
|
|
1137
|
+
export class TelegramPollHealth {
|
|
1138
|
+
#status: TelegramPollHealthStatus = "healthy";
|
|
1139
|
+
#suppressedCount = 0;
|
|
1140
|
+
|
|
1141
|
+
record(result: TelegramPollResult): void {
|
|
1142
|
+
if (result.kind === "aborted") return;
|
|
1143
|
+
if (result.kind === "success") {
|
|
1144
|
+
if (this.#status !== "healthy") {
|
|
1145
|
+
logger.info("notifications daemon: Telegram getUpdates recovered", {
|
|
1146
|
+
from: this.#status,
|
|
1147
|
+
suppressedCount: this.#suppressedCount,
|
|
1148
|
+
updateCount: result.updateCount,
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
this.#status = "healthy";
|
|
1152
|
+
this.#suppressedCount = 0;
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const previousStatus = this.#status;
|
|
1157
|
+
if (previousStatus === result.kind) {
|
|
1158
|
+
this.#suppressedCount += 1;
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const suppressedCount = this.#suppressedCount;
|
|
1163
|
+
this.#status = result.kind;
|
|
1164
|
+
this.#suppressedCount = 0;
|
|
1165
|
+
if (result.kind === "conflict") {
|
|
1166
|
+
logger.error("notifications daemon: Telegram getUpdates 409 conflict", {
|
|
1167
|
+
description: result.description,
|
|
1168
|
+
backoffMs: result.backoffMs,
|
|
1169
|
+
previousStatus,
|
|
1170
|
+
suppressedCount,
|
|
1171
|
+
});
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
if (result.kind === "api_failure") {
|
|
1175
|
+
logger.error("notifications daemon: Telegram getUpdates API failed", {
|
|
1176
|
+
errorCode: result.errorCode,
|
|
1177
|
+
description: result.description,
|
|
1178
|
+
previousStatus,
|
|
1179
|
+
suppressedCount,
|
|
1180
|
+
});
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
logger.error("notifications daemon: getUpdates failed", {
|
|
1185
|
+
error: result.error,
|
|
1186
|
+
previousStatus,
|
|
1187
|
+
suppressedCount,
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
949
1190
|
}
|
|
950
1191
|
|
|
951
1192
|
/** Owns getUpdates offset, conflict backoff, and per-update error isolation. */
|
|
952
1193
|
export class TelegramUpdatePoller {
|
|
953
1194
|
#offset = 0;
|
|
954
1195
|
#opts: TelegramUpdatePollerOptions;
|
|
1196
|
+
#health: TelegramPollHealth;
|
|
955
1197
|
|
|
956
1198
|
constructor(opts: TelegramUpdatePollerOptions) {
|
|
957
1199
|
this.#opts = opts;
|
|
1200
|
+
this.#health = opts.health ?? new TelegramPollHealth();
|
|
958
1201
|
}
|
|
959
1202
|
|
|
960
1203
|
async pollOnce(signal?: AbortSignal): Promise<number> {
|
|
1204
|
+
const result = await this.pollOnceResult(signal);
|
|
1205
|
+
this.#health.record(result);
|
|
1206
|
+
return result.kind === "success" ? result.updateCount : 0;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
async pollOnceResult(signal?: AbortSignal): Promise<TelegramPollResult> {
|
|
961
1210
|
let body: {
|
|
962
1211
|
ok?: boolean;
|
|
963
1212
|
error_code?: number;
|
|
@@ -972,24 +1221,37 @@ export class TelegramUpdatePoller {
|
|
|
972
1221
|
)) as typeof body;
|
|
973
1222
|
} catch (err) {
|
|
974
1223
|
// A cooperative stop aborts the in-flight long poll; treat as a clean wake.
|
|
975
|
-
if (isAbortError(err)) return
|
|
1224
|
+
if (isAbortError(err)) return { kind: "aborted" };
|
|
976
1225
|
// A transient Telegram API failure must never crash the daemon.
|
|
977
|
-
logger.error("notifications daemon: getUpdates failed", { error: sanitizeDiagnostic(String(err)) });
|
|
978
1226
|
await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
|
|
979
|
-
return
|
|
1227
|
+
return { kind: "getUpdates_failed", error: sanitizeDiagnostic(String(err)) };
|
|
980
1228
|
}
|
|
981
1229
|
// Telegram allows only one active getUpdates poller per bot. A 409 means
|
|
982
1230
|
// another poller is live; back off boundedly instead of hot-looping.
|
|
983
1231
|
if (body && body.ok === false && (body.error_code === 409 || /409|conflict/i.test(body.description ?? ""))) {
|
|
984
1232
|
const backoffMs = this.#opts.backoff.next();
|
|
985
|
-
logger.error(
|
|
986
|
-
`notifications daemon: Telegram getUpdates 409 conflict (${body.description ?? "no description"}); backing off ${backoffMs}ms`,
|
|
987
|
-
);
|
|
988
1233
|
await this.#opts.runtime.sleep(backoffMs, signal);
|
|
989
|
-
return
|
|
1234
|
+
return { kind: "conflict", description: sanitizeDiagnostic(body.description ?? "no description"), backoffMs };
|
|
1235
|
+
}
|
|
1236
|
+
if (body?.ok !== true || !Array.isArray(body.result)) {
|
|
1237
|
+
await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
|
|
1238
|
+
return {
|
|
1239
|
+
kind: "api_failure",
|
|
1240
|
+
errorCode: typeof body?.error_code === "number" ? body.error_code : undefined,
|
|
1241
|
+
description: sanitizeDiagnostic(body?.description ?? "Malformed getUpdates response"),
|
|
1242
|
+
};
|
|
990
1243
|
}
|
|
991
1244
|
this.#opts.backoff.reset();
|
|
992
|
-
|
|
1245
|
+
let malformedSeen = false;
|
|
1246
|
+
for (const update of body.result) {
|
|
1247
|
+
// A single malformed update_id must not wedge the poller. Skip the
|
|
1248
|
+
// bad entry (surfaced below as an api_failure health signal) while
|
|
1249
|
+
// still processing valid updates and advancing the offset past them,
|
|
1250
|
+
// so one poisoned item cannot stall an otherwise-valid stream.
|
|
1251
|
+
if (!Number.isSafeInteger(update?.update_id)) {
|
|
1252
|
+
malformedSeen = true;
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
993
1255
|
try {
|
|
994
1256
|
const outcome = await this.#opts.processUpdate(update);
|
|
995
1257
|
if (outcome === "retry") {
|
|
@@ -998,11 +1260,17 @@ export class TelegramUpdatePoller {
|
|
|
998
1260
|
}
|
|
999
1261
|
this.#offset = update.update_id + 1;
|
|
1000
1262
|
} catch (err) {
|
|
1001
|
-
logger.error("notifications daemon: handleTelegramUpdate failed", {
|
|
1263
|
+
logger.error("notifications daemon: handleTelegramUpdate failed", {
|
|
1264
|
+
error: sanitizeDiagnostic(String(err)),
|
|
1265
|
+
});
|
|
1002
1266
|
this.#offset = update.update_id + 1;
|
|
1003
1267
|
}
|
|
1004
1268
|
}
|
|
1005
|
-
|
|
1269
|
+
if (malformedSeen) {
|
|
1270
|
+
await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
|
|
1271
|
+
return { kind: "api_failure", description: "Malformed getUpdates response" };
|
|
1272
|
+
}
|
|
1273
|
+
return { kind: "success", updateCount: body.result.length };
|
|
1006
1274
|
}
|
|
1007
1275
|
}
|
|
1008
1276
|
|
|
@@ -1039,6 +1307,7 @@ export interface TelegramDaemonOptions {
|
|
|
1039
1307
|
clearTimeoutImpl?: typeof clearTimeout;
|
|
1040
1308
|
setIntervalImpl?: typeof setInterval;
|
|
1041
1309
|
clearIntervalImpl?: typeof clearInterval;
|
|
1310
|
+
btw?: { enabled: boolean };
|
|
1042
1311
|
idleTimeoutMs?: number;
|
|
1043
1312
|
scanIntervalMs?: number;
|
|
1044
1313
|
pid?: number;
|
|
@@ -1082,10 +1351,13 @@ interface SessionSocket {
|
|
|
1082
1351
|
logicalSessionId: string;
|
|
1083
1352
|
token: string;
|
|
1084
1353
|
endpointKey: string;
|
|
1354
|
+
endpointDigest: string;
|
|
1355
|
+
hostGeneration: number;
|
|
1085
1356
|
ws: WebSocket;
|
|
1086
1357
|
pending: Map<string, { sessionId: string; actionId: string }>;
|
|
1087
1358
|
/** True once the server advertised the `client_ping_pong` capability. */
|
|
1088
1359
|
capable: boolean;
|
|
1360
|
+
ephemeralCapable: boolean;
|
|
1089
1361
|
/** Timestamp (via opts.now) of the last received pong; seeds the TTL window. */
|
|
1090
1362
|
lastPongAt: number;
|
|
1091
1363
|
/** Nonce of the most recent in-flight ping, if any. */
|
|
@@ -1113,6 +1385,12 @@ interface RenderedModelChoice {
|
|
|
1113
1385
|
label: string;
|
|
1114
1386
|
}
|
|
1115
1387
|
|
|
1388
|
+
interface TopicAuthorityLease {
|
|
1389
|
+
sessionId: string;
|
|
1390
|
+
topicId: string;
|
|
1391
|
+
authorityEpoch: number;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1116
1394
|
interface PendingThreadedFrame {
|
|
1117
1395
|
send: ThreadedSend;
|
|
1118
1396
|
msg: Record<string, unknown>;
|
|
@@ -1122,6 +1400,15 @@ type SelectedAckOutcome =
|
|
|
1122
1400
|
| { status: "delivered"; messageId: number }
|
|
1123
1401
|
| { status: "failed"; reason: "route_missing" | "expired" | "cancelled" | "telegram_rejected" }
|
|
1124
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
|
+
}
|
|
1125
1412
|
|
|
1126
1413
|
interface SelectedAckQueueItem {
|
|
1127
1414
|
pendingKey: string;
|
|
@@ -1137,8 +1424,92 @@ interface SelectedAckQueueItem {
|
|
|
1137
1424
|
|
|
1138
1425
|
interface TelegramQueuePayload {
|
|
1139
1426
|
send: ThreadedSend;
|
|
1140
|
-
|
|
1427
|
+
topicLease?: TopicAuthorityLease;
|
|
1141
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
|
+
}
|
|
1142
1513
|
}
|
|
1143
1514
|
|
|
1144
1515
|
export class TelegramNotificationDaemon {
|
|
@@ -1157,12 +1528,22 @@ export class TelegramNotificationDaemon {
|
|
|
1157
1528
|
private running = false;
|
|
1158
1529
|
private readonly fsImpl: TelegramDaemonFs;
|
|
1159
1530
|
private readonly botApi: BotApi;
|
|
1531
|
+
private readonly effects = new TelegramEffectSupervisor();
|
|
1160
1532
|
private readonly topics = new TopicRegistry();
|
|
1161
1533
|
/** Serializes registry snapshots so an older atomic write cannot overwrite newer rename state. */
|
|
1162
1534
|
private topicsPersistQueue: Promise<void> = Promise.resolve();
|
|
1163
1535
|
/** Daemon edit attempts that can race an accepted user service message. */
|
|
1164
1536
|
private readonly daemonRenameAttempts = new Map<string, number>();
|
|
1165
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();
|
|
1166
1547
|
private readonly pool: RateLimitPool<TelegramQueuePayload>;
|
|
1167
1548
|
private readonly poller: TelegramUpdatePoller;
|
|
1168
1549
|
private readonly dispatchState = new TelegramEventDispatchState();
|
|
@@ -1245,11 +1626,18 @@ export class TelegramNotificationDaemon {
|
|
|
1245
1626
|
* ~25s getUpdates timeout. Safe to call from a signal handler.
|
|
1246
1627
|
*/
|
|
1247
1628
|
requestStop(_reason?: "reload" | "stop" | "signal"): void {
|
|
1629
|
+
this.effects.beginShutdown();
|
|
1248
1630
|
for (const item of new Set(this.selectedAckPending.values())) {
|
|
1249
1631
|
if (item.state === "queued") this.pool.removeById(item.itemId);
|
|
1250
1632
|
else item.controller?.abort();
|
|
1251
1633
|
this.finishSelectedAck(item, { status: "unknown", reason: "shutdown" });
|
|
1252
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();
|
|
1253
1641
|
this.runtime.requestStop();
|
|
1254
1642
|
this.running = false;
|
|
1255
1643
|
}
|
|
@@ -1276,8 +1664,14 @@ export class TelegramNotificationDaemon {
|
|
|
1276
1664
|
const deps = (this.opts.createLifecycleOrchestratorDeps ?? buildOrchestratorDeps)({
|
|
1277
1665
|
pairedChatId: this.opts.chatId,
|
|
1278
1666
|
agentNotificationsDir: daemonPaths(agentDir).dir,
|
|
1667
|
+
agentDir,
|
|
1279
1668
|
sessionsRoot: path.join(agentDir, "sessions"),
|
|
1280
|
-
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
|
+
),
|
|
1281
1675
|
});
|
|
1282
1676
|
// Register the lifecycle-request handler BEFORE start(): the native
|
|
1283
1677
|
// control server captures the callback at start time, so wiring must
|
|
@@ -1518,15 +1912,22 @@ export class TelegramNotificationDaemon {
|
|
|
1518
1912
|
return true;
|
|
1519
1913
|
}
|
|
1520
1914
|
if (parsed.kind === "recent") {
|
|
1521
|
-
const recent = listRecentSessions({
|
|
1522
|
-
|
|
1915
|
+
const recent = await listRecentSessions({
|
|
1916
|
+
cwd: process.cwd(),
|
|
1917
|
+
agentDir: this.opts.settings.getAgentDir(),
|
|
1523
1918
|
limit: 10,
|
|
1524
1919
|
includeInternal: false,
|
|
1920
|
+
allWorkspaces: true,
|
|
1525
1921
|
});
|
|
1526
|
-
const body =
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
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
|
+
);
|
|
1530
1931
|
return true;
|
|
1531
1932
|
}
|
|
1532
1933
|
|
|
@@ -1565,7 +1966,7 @@ export class TelegramNotificationDaemon {
|
|
|
1565
1966
|
this.fsImpl = opts.fs ?? nodeFs;
|
|
1566
1967
|
this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
|
|
1567
1968
|
this.aliasTable = createAliasTable();
|
|
1568
|
-
|
|
1969
|
+
const rawBotApi =
|
|
1569
1970
|
opts.botApi ??
|
|
1570
1971
|
new TelegramBotTransport({
|
|
1571
1972
|
botToken: opts.botToken,
|
|
@@ -1573,6 +1974,9 @@ export class TelegramNotificationDaemon {
|
|
|
1573
1974
|
fetchImpl: opts.fetchImpl,
|
|
1574
1975
|
setTimeoutImpl: opts.setTimeoutImpl,
|
|
1575
1976
|
});
|
|
1977
|
+
this.botApi = {
|
|
1978
|
+
call: (method, body, callOpts) => this.effects.call(rawBotApi, method, body, callOpts),
|
|
1979
|
+
};
|
|
1576
1980
|
this.runtime = new NotificationOperatorRuntime({
|
|
1577
1981
|
now: opts.now,
|
|
1578
1982
|
setTimeoutImpl: opts.setTimeoutImpl,
|
|
@@ -1581,7 +1985,7 @@ export class TelegramNotificationDaemon {
|
|
|
1581
1985
|
clearIntervalImpl: opts.clearIntervalImpl,
|
|
1582
1986
|
});
|
|
1583
1987
|
this.sessionRouter = this.createSessionRouter();
|
|
1584
|
-
this.pool = new RateLimitPool<
|
|
1988
|
+
this.pool = new RateLimitPool<TelegramQueuePayload>({ now: opts.now });
|
|
1585
1989
|
this.poller = new TelegramUpdatePoller({
|
|
1586
1990
|
botApi: this.botApi,
|
|
1587
1991
|
runtime: this.runtime,
|
|
@@ -1597,6 +2001,7 @@ export class TelegramNotificationDaemon {
|
|
|
1597
2001
|
matches: msg => msg.type === "hello",
|
|
1598
2002
|
handle: (session, msg) => {
|
|
1599
2003
|
const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
|
|
2004
|
+
if (caps.includes("ephemeral_turn_v1")) session.ephemeralCapable = true;
|
|
1600
2005
|
if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
|
|
1601
2006
|
session.capable = true;
|
|
1602
2007
|
this.startLiveness(session);
|
|
@@ -1636,8 +2041,11 @@ export class TelegramNotificationDaemon {
|
|
|
1636
2041
|
finishImmediately({ status: "failed", reason: "route_missing" });
|
|
1637
2042
|
return;
|
|
1638
2043
|
}
|
|
1639
|
-
const
|
|
1640
|
-
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
|
+
) {
|
|
1641
2049
|
finishImmediately({ status: "failed", reason: "route_missing" });
|
|
1642
2050
|
return;
|
|
1643
2051
|
}
|
|
@@ -1666,14 +2074,14 @@ export class TelegramNotificationDaemon {
|
|
|
1666
2074
|
followers: [],
|
|
1667
2075
|
};
|
|
1668
2076
|
this.selectedAckPending.set(pendingKey, item);
|
|
1669
|
-
this.
|
|
2077
|
+
this.submitPool({
|
|
1670
2078
|
sessionId: session.sessionId,
|
|
1671
2079
|
lane: "ask",
|
|
1672
2080
|
itemId: item.itemId,
|
|
1673
2081
|
deadlineAt,
|
|
1674
2082
|
payload: {
|
|
1675
2083
|
send: { method: "sendMessage", lane: "ask", text: "Selected!" },
|
|
1676
|
-
|
|
2084
|
+
topicLease,
|
|
1677
2085
|
selectedAck: item,
|
|
1678
2086
|
},
|
|
1679
2087
|
});
|
|
@@ -1770,6 +2178,7 @@ export class TelegramNotificationDaemon {
|
|
|
1770
2178
|
handle: async session => {
|
|
1771
2179
|
this.busy.delete(session.sessionId);
|
|
1772
2180
|
this.closedEndpointKeys.set(session.sessionId, session.endpointKey);
|
|
2181
|
+
await this.#terminalizeBtwTurnsForSession(session, true);
|
|
1773
2182
|
await this.deleteTopic(session.sessionId);
|
|
1774
2183
|
this.dropSession(session, "session_closed");
|
|
1775
2184
|
},
|
|
@@ -1831,7 +2240,7 @@ export class TelegramNotificationDaemon {
|
|
|
1831
2240
|
logger.warn(`notifications: failed to persist Telegram update id ${updateId}: ${String(err)}`);
|
|
1832
2241
|
}
|
|
1833
2242
|
}
|
|
1834
|
-
private async
|
|
2243
|
+
private async reserveSeenUpdateId(updateId: number): Promise<boolean> {
|
|
1835
2244
|
if (!Number.isSafeInteger(updateId) || updateId < 0) return false;
|
|
1836
2245
|
const candidate = new Set(this.dispatchState.seenUpdateIds);
|
|
1837
2246
|
candidate.add(updateId);
|
|
@@ -1844,7 +2253,7 @@ export class TelegramNotificationDaemon {
|
|
|
1844
2253
|
updateIds: [...candidate],
|
|
1845
2254
|
});
|
|
1846
2255
|
} catch {
|
|
1847
|
-
logger.warn("notifications:
|
|
2256
|
+
logger.warn("notifications: Telegram update state publication failed");
|
|
1848
2257
|
return false;
|
|
1849
2258
|
}
|
|
1850
2259
|
this.dispatchState.seenUpdateIds.clear();
|
|
@@ -1869,20 +2278,28 @@ export class TelegramNotificationDaemon {
|
|
|
1869
2278
|
for (const file of files.filter(item => item.endsWith(".json"))) {
|
|
1870
2279
|
const sessionId = path.basename(file, ".json");
|
|
1871
2280
|
endpointSessionIds.add(sessionId);
|
|
1872
|
-
if (this.sessions.has(sessionId)) continue;
|
|
1873
2281
|
try {
|
|
1874
2282
|
const endpoint = readEndpoint(path.join(dir, file));
|
|
1875
|
-
//
|
|
1876
|
-
//
|
|
1877
|
-
//
|
|
1878
|
-
//
|
|
1879
|
-
// 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.
|
|
1880
2287
|
const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
|
|
1881
2288
|
if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) {
|
|
1882
|
-
|
|
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);
|
|
1883
2293
|
continue;
|
|
1884
2294
|
}
|
|
2295
|
+
if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
|
|
1885
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
|
+
}
|
|
1886
2303
|
if (this.closedEndpointKeys.get(sessionId) === endpointKey) continue;
|
|
1887
2304
|
this.closedEndpointKeys.delete(sessionId);
|
|
1888
2305
|
this.connectSession(sessionId, endpoint.url, endpoint.token);
|
|
@@ -1891,8 +2308,10 @@ export class TelegramNotificationDaemon {
|
|
|
1891
2308
|
}
|
|
1892
2309
|
if (allRootsReadable) {
|
|
1893
2310
|
for (const sessionId of this.topics.sessionIds()) {
|
|
1894
|
-
if (!this.sessions.has(sessionId) && !endpointSessionIds.has(sessionId))
|
|
1895
|
-
|
|
2311
|
+
if (!this.sessions.has(sessionId) && !endpointSessionIds.has(sessionId)) {
|
|
2312
|
+
this.#terminalizeBtwTurnsForTransportSession(sessionId);
|
|
2313
|
+
await this.observeOrphanedTopic(sessionId);
|
|
2314
|
+
}
|
|
1896
2315
|
}
|
|
1897
2316
|
}
|
|
1898
2317
|
}
|
|
@@ -1901,9 +2320,17 @@ export class TelegramNotificationDaemon {
|
|
|
1901
2320
|
const WS = this.opts.WebSocketImpl ?? WebSocket;
|
|
1902
2321
|
const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
|
|
1903
2322
|
const endpointKey = endpointGenerationKey(url, token);
|
|
2323
|
+
const endpointDigest = endpointAuthorityDigest(url, token);
|
|
1904
2324
|
this.closedEndpointKeys.delete(sessionId);
|
|
1905
2325
|
const existing = this.sessions.get(sessionId);
|
|
1906
|
-
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
|
+
}
|
|
1907
2334
|
this.#clearModelChoiceAliases(sessionId);
|
|
1908
2335
|
|
|
1909
2336
|
const session: SessionSocket = {
|
|
@@ -1912,9 +2339,12 @@ export class TelegramNotificationDaemon {
|
|
|
1912
2339
|
|
|
1913
2340
|
token,
|
|
1914
2341
|
endpointKey,
|
|
2342
|
+
endpointDigest,
|
|
2343
|
+
hostGeneration: 0,
|
|
1915
2344
|
ws,
|
|
1916
2345
|
pending: new Map(),
|
|
1917
2346
|
capable: false,
|
|
2347
|
+
ephemeralCapable: false,
|
|
1918
2348
|
lastPongAt: 0,
|
|
1919
2349
|
awaitingNonce: undefined,
|
|
1920
2350
|
pingTimer: undefined,
|
|
@@ -1936,7 +2366,13 @@ export class TelegramNotificationDaemon {
|
|
|
1936
2366
|
JSON.stringify({
|
|
1937
2367
|
type: "hello",
|
|
1938
2368
|
protocolVersion: NOTIFICATION_PROTOCOL_VERSION,
|
|
1939
|
-
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
|
+
],
|
|
1940
2376
|
}),
|
|
1941
2377
|
);
|
|
1942
2378
|
} catch {}
|
|
@@ -2014,6 +2450,11 @@ export class TelegramNotificationDaemon {
|
|
|
2014
2450
|
clearIntervalImpl(session.pingTimer);
|
|
2015
2451
|
session.pingTimer = undefined;
|
|
2016
2452
|
}
|
|
2453
|
+
if (reason === "socket_closed" || reason === "same_authority_replaced") {
|
|
2454
|
+
this.#invalidateBtwDeliveriesForSession(session);
|
|
2455
|
+
} else {
|
|
2456
|
+
void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
|
|
2457
|
+
}
|
|
2017
2458
|
const isCurrentSession = this.sessions.get(session.sessionId) === session;
|
|
2018
2459
|
if (isCurrentSession || reason === "session_closed") {
|
|
2019
2460
|
this.deleteMessageRoutes(session.sessionId);
|
|
@@ -2036,6 +2477,280 @@ export class TelegramNotificationDaemon {
|
|
|
2036
2477
|
}
|
|
2037
2478
|
}
|
|
2038
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
|
+
|
|
2039
2754
|
private deleteMessageRoutes(sessionId: string, actionId?: string): void {
|
|
2040
2755
|
for (const [messageId, route] of this.messageRoutes.entries()) {
|
|
2041
2756
|
if (route.sessionId === sessionId && (actionId === undefined || route.actionId === actionId)) {
|
|
@@ -2077,6 +2792,14 @@ export class TelegramNotificationDaemon {
|
|
|
2077
2792
|
return alias;
|
|
2078
2793
|
}
|
|
2079
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
|
+
|
|
2080
2803
|
private static readonly THREADED_FRAMES = new Set([
|
|
2081
2804
|
"identity_header",
|
|
2082
2805
|
"context_update",
|
|
@@ -2085,9 +2808,11 @@ export class TelegramNotificationDaemon {
|
|
|
2085
2808
|
"file_attachment",
|
|
2086
2809
|
"config_update",
|
|
2087
2810
|
"control_command_result",
|
|
2811
|
+
"tool_activity",
|
|
2812
|
+
"reasoning_summary",
|
|
2088
2813
|
]);
|
|
2089
2814
|
|
|
2090
|
-
/** 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. */
|
|
2091
2816
|
#updateLogicalSessionForThreadedFrame(session: SessionSocket, msg: Record<string, unknown>): void {
|
|
2092
2817
|
if (
|
|
2093
2818
|
typeof msg.type !== "string" ||
|
|
@@ -2098,6 +2823,7 @@ export class TelegramNotificationDaemon {
|
|
|
2098
2823
|
(msg.type !== "config_update" && !renderThreadedFrame(msg))
|
|
2099
2824
|
)
|
|
2100
2825
|
return;
|
|
2826
|
+
void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
|
|
2101
2827
|
this.#clearModelChoiceAliasesForSocket(session);
|
|
2102
2828
|
this.#clearModelChoiceAliases(msg.sessionId);
|
|
2103
2829
|
session.logicalSessionId = msg.sessionId;
|
|
@@ -2165,11 +2891,13 @@ export class TelegramNotificationDaemon {
|
|
|
2165
2891
|
private topicOwnerForIdentity(msg: { repo?: unknown; branch?: unknown }): string | undefined {
|
|
2166
2892
|
const identityKey = this.topicIdentityKey(msg);
|
|
2167
2893
|
const remembered = identityKey ? this.topicOwnerByIdentity.get(identityKey) : undefined;
|
|
2168
|
-
|
|
2894
|
+
const rememberedTopic = remembered ? this.topics.get(remembered) : undefined;
|
|
2895
|
+
if (remembered && rememberedTopic && rememberedTopic.authorityState !== "delete_pending") return remembered;
|
|
2169
2896
|
if (!identityKey) return undefined;
|
|
2170
2897
|
const base = this.topicIdentityBase(msg);
|
|
2171
2898
|
for (const sessionId of this.topics.sessionIds()) {
|
|
2172
2899
|
const topic = this.topics.get(sessionId);
|
|
2900
|
+
if (topic?.authorityState === "delete_pending") continue;
|
|
2173
2901
|
const nameMatchesLegacyIdentity =
|
|
2174
2902
|
base !== undefined && (topic?.name === base || topic?.name?.startsWith(`${base} - `));
|
|
2175
2903
|
if (topic?.identityKey === identityKey || nameMatchesLegacyIdentity) {
|
|
@@ -2187,30 +2915,55 @@ export class TelegramNotificationDaemon {
|
|
|
2187
2915
|
return !ownerId || ownerId === session.sessionId;
|
|
2188
2916
|
}
|
|
2189
2917
|
|
|
2190
|
-
private async submitThreadedFrame(
|
|
2191
|
-
|
|
2918
|
+
private async submitThreadedFrame(
|
|
2919
|
+
sessionId: string,
|
|
2920
|
+
send: ThreadedSend,
|
|
2921
|
+
topicLease: TopicAuthorityLease,
|
|
2922
|
+
): Promise<void> {
|
|
2923
|
+
this.submitPool({
|
|
2192
2924
|
sessionId,
|
|
2193
2925
|
lane: send.lane,
|
|
2194
2926
|
coalesceKey: send.coalesceKey,
|
|
2195
|
-
payload: { send,
|
|
2927
|
+
payload: { send, topicLease },
|
|
2196
2928
|
});
|
|
2197
2929
|
await this.flushPool();
|
|
2198
2930
|
}
|
|
2199
2931
|
|
|
2200
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> {
|
|
2201
2937
|
if (!(await this.pairedChatIsPrivate())) return undefined;
|
|
2202
|
-
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
|
+
);
|
|
2203
2954
|
}
|
|
2204
2955
|
|
|
2205
2956
|
/** Best-effort re-assertion for a durable user-owned topic name. */
|
|
2206
|
-
private async reconcileUserTopicName(
|
|
2957
|
+
private async reconcileUserTopicName(topicLease: TopicAuthorityLease): Promise<void> {
|
|
2958
|
+
const { sessionId } = topicLease;
|
|
2207
2959
|
if ((this.daemonRenameAttempts.get(sessionId) ?? 0) > 0) return;
|
|
2208
2960
|
let userName = this.topics.userNameToReconcile(sessionId);
|
|
2209
2961
|
while (userName) {
|
|
2210
2962
|
try {
|
|
2963
|
+
if (!this.topicLeaseIsCurrent(topicLease)) return;
|
|
2211
2964
|
const response = await this.botApi.call("editForumTopic", {
|
|
2212
2965
|
chat_id: this.opts.chatId,
|
|
2213
|
-
message_thread_id: Number(topicId),
|
|
2966
|
+
message_thread_id: Number(topicLease.topicId),
|
|
2214
2967
|
name: userName,
|
|
2215
2968
|
});
|
|
2216
2969
|
if (!topicRenameApplied(response)) return;
|
|
@@ -2240,11 +2993,11 @@ export class TelegramNotificationDaemon {
|
|
|
2240
2993
|
this.pendingThreadedFrames.set(sessionId, frames);
|
|
2241
2994
|
}
|
|
2242
2995
|
|
|
2243
|
-
private async flushPendingThreadedFrames(sessionId: string,
|
|
2996
|
+
private async flushPendingThreadedFrames(sessionId: string, topicLease: TopicAuthorityLease): Promise<void> {
|
|
2244
2997
|
const frames = this.pendingThreadedFrames.get(sessionId);
|
|
2245
2998
|
if (!frames || frames.length === 0) return;
|
|
2246
2999
|
this.pendingThreadedFrames.delete(sessionId);
|
|
2247
|
-
for (const frame of frames) await this.submitThreadedFrame(sessionId, frame.send,
|
|
3000
|
+
for (const frame of frames) await this.submitThreadedFrame(sessionId, frame.send, topicLease);
|
|
2248
3001
|
}
|
|
2249
3002
|
|
|
2250
3003
|
/**
|
|
@@ -2256,17 +3009,20 @@ export class TelegramNotificationDaemon {
|
|
|
2256
3009
|
private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
|
|
2257
3010
|
if (!(await this.pairedChatIsPrivate())) return undefined;
|
|
2258
3011
|
const existing = this.topics.get(sessionId);
|
|
3012
|
+
if (existing?.authorityState === "delete_pending") return undefined;
|
|
2259
3013
|
if (existing) return existing.topicId;
|
|
2260
3014
|
try {
|
|
2261
3015
|
const rec = await this.topics.getOrCreateTopic(
|
|
2262
3016
|
sessionId,
|
|
2263
3017
|
async () => {
|
|
2264
|
-
const res =
|
|
3018
|
+
const res = await this.botApi.call("createForumTopic", {
|
|
2265
3019
|
chat_id: this.opts.chatId,
|
|
2266
3020
|
name,
|
|
2267
|
-
})
|
|
2268
|
-
|
|
2269
|
-
|
|
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");
|
|
2270
3026
|
return String(tid);
|
|
2271
3027
|
},
|
|
2272
3028
|
this.opts.now,
|
|
@@ -2278,50 +3034,62 @@ export class TelegramNotificationDaemon {
|
|
|
2278
3034
|
);
|
|
2279
3035
|
await this.persistTopics();
|
|
2280
3036
|
return rec.topicId;
|
|
2281
|
-
} catch {
|
|
2282
|
-
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;
|
|
2283
3043
|
}
|
|
2284
3044
|
}
|
|
2285
3045
|
|
|
2286
3046
|
private topicPastOrphanGrace(sessionId: string): boolean {
|
|
2287
|
-
const
|
|
2288
|
-
return
|
|
3047
|
+
const orphanedAt = this.topics.get(sessionId)?.orphanedAt;
|
|
3048
|
+
return orphanedAt !== undefined && this.runtime.now() - orphanedAt >= ORPHAN_TOPIC_GRACE_MS;
|
|
2289
3049
|
}
|
|
2290
3050
|
|
|
2291
|
-
private async
|
|
3051
|
+
private async observeOrphanedTopic(sessionId: string): Promise<void> {
|
|
3052
|
+
if (this.topics.markOrphaned(sessionId, this.runtime.now())) await this.persistTopics();
|
|
2292
3053
|
if (!this.topicPastOrphanGrace(sessionId)) return;
|
|
2293
3054
|
await this.deleteTopic(sessionId);
|
|
2294
3055
|
}
|
|
2295
3056
|
|
|
2296
3057
|
/** Best-effort delete of a session topic once its local notification endpoint shuts down. */
|
|
2297
3058
|
private async deleteTopic(sessionId: string): Promise<void> {
|
|
2298
|
-
|
|
2299
|
-
|
|
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
|
+
}
|
|
2300
3077
|
try {
|
|
2301
|
-
// Drop queued sends for this session before deleting the topic; otherwise
|
|
2302
|
-
// rate-limited frames can flush later into a deleted topic or across resume.
|
|
2303
|
-
const removed = this.pool.removeWhere(item => item.sessionId === sessionId);
|
|
2304
|
-
for (const item of removed) {
|
|
2305
|
-
if (item.payload.selectedAck)
|
|
2306
|
-
this.finishSelectedAck(item.payload.selectedAck, { status: "failed", reason: "cancelled" });
|
|
2307
|
-
}
|
|
2308
3078
|
await this.flushPool();
|
|
2309
3079
|
const res = (await this.botApi.call("deleteForumTopic", {
|
|
2310
3080
|
chat_id: this.opts.chatId,
|
|
2311
3081
|
message_thread_id: Number(record.topicId),
|
|
2312
3082
|
})) as { ok?: boolean };
|
|
2313
|
-
if (res
|
|
2314
|
-
this.topics.
|
|
2315
|
-
for (const k of [...this.liveMessages.keys()]) {
|
|
2316
|
-
if (k.startsWith(`${sessionId}:`)) this.liveMessages.delete(k);
|
|
2317
|
-
}
|
|
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);
|
|
2318
3086
|
this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
|
|
2319
3087
|
if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
|
|
2320
3088
|
});
|
|
2321
3089
|
this.pendingThreadedFrames.delete(sessionId);
|
|
2322
3090
|
await this.persistTopics();
|
|
2323
3091
|
} catch {
|
|
2324
|
-
//
|
|
3092
|
+
// Retain the persisted delete fence on transport ambiguity.
|
|
2325
3093
|
}
|
|
2326
3094
|
}
|
|
2327
3095
|
|
|
@@ -2462,12 +3230,19 @@ export class TelegramNotificationDaemon {
|
|
|
2462
3230
|
}
|
|
2463
3231
|
|
|
2464
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
|
+
|
|
2465
3239
|
private async flushPoolInner(): Promise<void> {
|
|
2466
3240
|
const { granted: batch, expired } = this.pool.drainWithExpired();
|
|
2467
3241
|
for (const expiredItem of expired) {
|
|
2468
3242
|
if (expiredItem.payload.selectedAck) {
|
|
2469
3243
|
this.finishSelectedAck(expiredItem.payload.selectedAck, { status: "failed", reason: "expired" });
|
|
2470
3244
|
}
|
|
3245
|
+
expiredItem.payload.btwDelivery?.finish("not_delivered");
|
|
2471
3246
|
}
|
|
2472
3247
|
// Within a batch a finalized frame supersedes any still-queued live frame for
|
|
2473
3248
|
// the same streamed message (finalized outranks live), so drop the stale live
|
|
@@ -2491,20 +3266,53 @@ export class TelegramNotificationDaemon {
|
|
|
2491
3266
|
);
|
|
2492
3267
|
}
|
|
2493
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
|
+
}
|
|
2494
3296
|
const selectedAck = item.payload.selectedAck;
|
|
2495
3297
|
if (selectedAck) {
|
|
2496
|
-
const {
|
|
3298
|
+
const { topicLease } = item.payload;
|
|
2497
3299
|
selectedAck.state = "dispatching";
|
|
2498
3300
|
const controller = new AbortController();
|
|
2499
3301
|
selectedAck.controller = controller;
|
|
2500
|
-
const routeAvailable =
|
|
2501
|
-
|
|
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
|
+
}
|
|
2502
3308
|
if (!routeAvailable) {
|
|
2503
3309
|
this.finishSelectedAck(selectedAck, { status: "failed", reason: "route_missing" });
|
|
3310
|
+
this.pool.settle(item.itemId!, "rejected");
|
|
2504
3311
|
continue;
|
|
2505
3312
|
}
|
|
2506
3313
|
if (item.deadlineAt !== undefined && item.deadlineAt <= this.runtime.now()) {
|
|
2507
3314
|
this.finishSelectedAck(selectedAck, { status: "failed", reason: "expired" });
|
|
3315
|
+
this.pool.settle(item.itemId!, "expired");
|
|
2508
3316
|
continue;
|
|
2509
3317
|
}
|
|
2510
3318
|
selectedAck.state = "sending";
|
|
@@ -2518,31 +3326,49 @@ export class TelegramNotificationDaemon {
|
|
|
2518
3326
|
"sendMessage",
|
|
2519
3327
|
{
|
|
2520
3328
|
chat_id: this.opts.chatId,
|
|
2521
|
-
...(
|
|
3329
|
+
...(topicLease ? { message_thread_id: Number(topicLease.topicId) } : {}),
|
|
2522
3330
|
text: "Selected!",
|
|
2523
3331
|
},
|
|
2524
3332
|
{ signal: controller.signal, noRetry: true },
|
|
2525
3333
|
)) as { ok?: unknown; result?: { message_id?: unknown } };
|
|
3334
|
+
const messageId = response.result?.message_id;
|
|
3335
|
+
const delivered = response.ok === true && typeof messageId === "number";
|
|
2526
3336
|
this.finishSelectedAck(
|
|
2527
3337
|
selectedAck,
|
|
2528
|
-
|
|
2529
|
-
? { status: "delivered", messageId: response.result.message_id }
|
|
2530
|
-
: { status: "failed", reason: "telegram_rejected" },
|
|
3338
|
+
delivered ? { status: "delivered", messageId } : { status: "failed", reason: "telegram_rejected" },
|
|
2531
3339
|
);
|
|
3340
|
+
this.pool.settle(item.itemId!, delivered ? "accepted" : "rejected");
|
|
2532
3341
|
} catch {
|
|
2533
3342
|
this.finishSelectedAck(selectedAck, { status: "unknown", reason: "transport_ambiguous" });
|
|
3343
|
+
this.pool.settle(item.itemId!, "ambiguous");
|
|
2534
3344
|
} finally {
|
|
2535
3345
|
(this.opts.clearTimeoutImpl ?? clearTimeout)(timer);
|
|
2536
3346
|
}
|
|
2537
3347
|
continue;
|
|
2538
3348
|
}
|
|
2539
|
-
const { send,
|
|
2540
|
-
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
|
+
}
|
|
2541
3363
|
// Threaded topic when available; otherwise deliver flat to the paired chat.
|
|
2542
3364
|
const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
|
|
2543
3365
|
const ckey = send.editable ? item.coalesceKey : undefined;
|
|
2544
3366
|
const editKey = ckey !== undefined ? `${item.sessionId}:${ckey}` : undefined;
|
|
2545
|
-
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";
|
|
2546
3372
|
try {
|
|
2547
3373
|
// Draft streaming (opt-in, off by default): stream a live turn frame as a
|
|
2548
3374
|
// best-effort rich-draft preview, debounced to >=1.5s per session through
|
|
@@ -2571,6 +3397,10 @@ export class TelegramNotificationDaemon {
|
|
|
2571
3397
|
}
|
|
2572
3398
|
}
|
|
2573
3399
|
}
|
|
3400
|
+
if (topicLease && !this.topicLeaseIsCurrent(topicLease)) {
|
|
3401
|
+
disposition = "rejected";
|
|
3402
|
+
continue;
|
|
3403
|
+
}
|
|
2574
3404
|
if (send.method === "sendPhoto" && send.photoBase64) {
|
|
2575
3405
|
// Real photo upload (the default botApi multiparts base64 -> file).
|
|
2576
3406
|
await this.botApi.call("sendPhoto", {
|
|
@@ -2607,6 +3437,7 @@ export class TelegramNotificationDaemon {
|
|
|
2607
3437
|
// non-editable, HTML-only pool items (rich markers stripped) — same
|
|
2608
3438
|
// per-token discipline as the non-rich split path.
|
|
2609
3439
|
const chunks = splitTelegramHtml(send.text!);
|
|
3440
|
+
if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
|
|
2610
3441
|
await this.botApi.call("sendMessage", {
|
|
2611
3442
|
chat_id: this.opts.chatId,
|
|
2612
3443
|
...threadField,
|
|
@@ -2614,7 +3445,7 @@ export class TelegramNotificationDaemon {
|
|
|
2614
3445
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2615
3446
|
});
|
|
2616
3447
|
for (let i = 1; i < chunks.length; i++) {
|
|
2617
|
-
this.
|
|
3448
|
+
this.submitPool({
|
|
2618
3449
|
sessionId: item.sessionId,
|
|
2619
3450
|
lane: item.lane,
|
|
2620
3451
|
payload: {
|
|
@@ -2630,7 +3461,7 @@ export class TelegramNotificationDaemon {
|
|
|
2630
3461
|
richDraftMarkdown: undefined,
|
|
2631
3462
|
richClass: undefined,
|
|
2632
3463
|
},
|
|
2633
|
-
|
|
3464
|
+
topicLease,
|
|
2634
3465
|
},
|
|
2635
3466
|
});
|
|
2636
3467
|
}
|
|
@@ -2639,6 +3470,7 @@ export class TelegramNotificationDaemon {
|
|
|
2639
3470
|
this.botApi,
|
|
2640
3471
|
{ chat_id: this.opts.chatId, ...threadField },
|
|
2641
3472
|
send,
|
|
3473
|
+
AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
|
|
2642
3474
|
sendHtmlFallback,
|
|
2643
3475
|
logger,
|
|
2644
3476
|
);
|
|
@@ -2676,7 +3508,7 @@ export class TelegramNotificationDaemon {
|
|
|
2676
3508
|
}
|
|
2677
3509
|
if (edited) {
|
|
2678
3510
|
firstMessageId = existingId;
|
|
2679
|
-
} else {
|
|
3511
|
+
} else if (!topicLease || this.topicLeaseIsCurrent(topicLease)) {
|
|
2680
3512
|
const res = (await this.botApi.call("sendMessage", {
|
|
2681
3513
|
chat_id: this.opts.chatId,
|
|
2682
3514
|
...threadField,
|
|
@@ -2704,7 +3536,7 @@ export class TelegramNotificationDaemon {
|
|
|
2704
3536
|
// can never be re-promoted to a duplicate sendRichMessage.
|
|
2705
3537
|
if (item.lane !== "live") {
|
|
2706
3538
|
for (let i = 1; i < chunks.length; i++) {
|
|
2707
|
-
this.
|
|
3539
|
+
this.submitPool({
|
|
2708
3540
|
sessionId: item.sessionId,
|
|
2709
3541
|
lane: item.lane,
|
|
2710
3542
|
payload: {
|
|
@@ -2720,18 +3552,25 @@ export class TelegramNotificationDaemon {
|
|
|
2720
3552
|
richDraftMarkdown: undefined,
|
|
2721
3553
|
richClass: undefined,
|
|
2722
3554
|
},
|
|
2723
|
-
|
|
3555
|
+
topicLease,
|
|
2724
3556
|
},
|
|
2725
3557
|
});
|
|
2726
3558
|
}
|
|
2727
3559
|
}
|
|
2728
|
-
if (editKey && ckey !== undefined && firstMessageId !== undefined) {
|
|
3560
|
+
if (editKey && ckey !== undefined && firstMessageId !== undefined && !send.terminal) {
|
|
2729
3561
|
this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
|
|
2730
3562
|
}
|
|
2731
3563
|
}
|
|
2732
3564
|
}
|
|
2733
3565
|
} catch {
|
|
2734
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);
|
|
2735
3574
|
}
|
|
2736
3575
|
}
|
|
2737
3576
|
}
|
|
@@ -2744,9 +3583,17 @@ export class TelegramNotificationDaemon {
|
|
|
2744
3583
|
private recordLiveMessage(sessionId: string, coalesceKey: string, messageId: number): void {
|
|
2745
3584
|
const mapKey = `${sessionId}:${coalesceKey}`;
|
|
2746
3585
|
const category = coalesceKey.split(":")[0] ?? "";
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
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
|
+
}
|
|
2750
3597
|
}
|
|
2751
3598
|
this.liveMessages.set(mapKey, messageId);
|
|
2752
3599
|
}
|
|
@@ -2764,7 +3611,7 @@ export class TelegramNotificationDaemon {
|
|
|
2764
3611
|
if (!(await this.pairedChatIsPrivate())) return;
|
|
2765
3612
|
await this.notifyThreadedFallback();
|
|
2766
3613
|
if (send.identity && this.flatIdentitySent.has(sessionId)) return;
|
|
2767
|
-
this.
|
|
3614
|
+
this.submitPool({ sessionId, lane: send.lane, coalesceKey: send.coalesceKey, payload: { send } });
|
|
2768
3615
|
await this.flushPool();
|
|
2769
3616
|
if (send.identity) this.flatIdentitySent.add(sessionId);
|
|
2770
3617
|
}
|
|
@@ -2832,6 +3679,38 @@ export class TelegramNotificationDaemon {
|
|
|
2832
3679
|
private stopFlushTimer(): void {
|
|
2833
3680
|
this.runtime.stopInterval("telegram-flush");
|
|
2834
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
|
+
}
|
|
2835
3714
|
|
|
2836
3715
|
/** Run a root scan, guarding against overlapping scans from the timer + loop. */
|
|
2837
3716
|
private async runScan(): Promise<void> {
|
|
@@ -2853,8 +3732,9 @@ export class TelegramNotificationDaemon {
|
|
|
2853
3732
|
|
|
2854
3733
|
/** Send a single `typing` chat action into a busy session's topic (best-effort). */
|
|
2855
3734
|
private async sendTyping(sessionId: string): Promise<void> {
|
|
2856
|
-
const
|
|
2857
|
-
if (!
|
|
3735
|
+
const topicLease = await this.topicAuthorityLease(sessionId);
|
|
3736
|
+
if (!topicLease || !this.topicLeaseIsCurrent(topicLease)) return;
|
|
3737
|
+
const topicId = topicLease.topicId;
|
|
2858
3738
|
try {
|
|
2859
3739
|
await this.botApi.call("sendChatAction", {
|
|
2860
3740
|
chat_id: this.opts.chatId,
|
|
@@ -2918,7 +3798,8 @@ export class TelegramNotificationDaemon {
|
|
|
2918
3798
|
const topicId =
|
|
2919
3799
|
(await this.existingTopicForPrivateChat(session.sessionId)) ??
|
|
2920
3800
|
(await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
|
|
2921
|
-
|
|
3801
|
+
const topicLease = await this.topicAuthorityLease(session.sessionId);
|
|
3802
|
+
if (!topicId || !topicLease || topicLease.topicId !== topicId) return false;
|
|
2922
3803
|
|
|
2923
3804
|
// Each logical session owns only its most recently rendered menu.
|
|
2924
3805
|
this.#clearModelChoiceAliases(logicalSessionId);
|
|
@@ -2929,6 +3810,7 @@ export class TelegramNotificationDaemon {
|
|
|
2929
3810
|
choices.map(choice => choice.label),
|
|
2930
3811
|
index => aliases[index]!,
|
|
2931
3812
|
);
|
|
3813
|
+
if (!this.topicLeaseIsCurrent(topicLease)) return false;
|
|
2932
3814
|
try {
|
|
2933
3815
|
const response = await this.botApi.call("sendMessage", {
|
|
2934
3816
|
chat_id: this.opts.chatId,
|
|
@@ -2964,6 +3846,9 @@ export class TelegramNotificationDaemon {
|
|
|
2964
3846
|
Number.isSafeInteger(msg.lastSeq) &&
|
|
2965
3847
|
msg.lastSeq >= 0 &&
|
|
2966
3848
|
Array.isArray(msg.events);
|
|
3849
|
+
if (replayValid) {
|
|
3850
|
+
session.hostGeneration = msg.generation;
|
|
3851
|
+
}
|
|
2967
3852
|
const replayed: Record<string, unknown>[] = replayValid
|
|
2968
3853
|
? (msg.events as unknown[]).flatMap((event: unknown): Record<string, unknown>[] => {
|
|
2969
3854
|
if (!event || typeof event !== "object" || Array.isArray(event)) return [];
|
|
@@ -3005,8 +3890,12 @@ export class TelegramNotificationDaemon {
|
|
|
3005
3890
|
}
|
|
3006
3891
|
await this.handleSessionMessage(session, frame);
|
|
3007
3892
|
}
|
|
3008
|
-
if (replayValid
|
|
3009
|
-
|
|
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
|
+
}
|
|
3010
3899
|
return;
|
|
3011
3900
|
}
|
|
3012
3901
|
if (msg?.type === "event_replay_result") return;
|
|
@@ -3014,25 +3903,213 @@ export class TelegramNotificationDaemon {
|
|
|
3014
3903
|
if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
|
|
3015
3904
|
if (await this.#renderModelChoices(session, msg as Record<string, unknown>)) return;
|
|
3016
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
|
+
}
|
|
3017
4091
|
if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
|
|
3018
4092
|
const send = renderThreadedFrame(msg);
|
|
3019
4093
|
if (!send) return;
|
|
3020
4094
|
const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
|
|
4095
|
+
if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
|
|
3021
4096
|
if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
|
|
3022
4097
|
this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
|
|
3023
4098
|
return;
|
|
3024
4099
|
}
|
|
3025
4100
|
if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
|
|
3026
4101
|
const ownerId = this.topicOwnerForIdentity(msg);
|
|
3027
|
-
const
|
|
3028
|
-
if (ownerId && ownerId !== session.sessionId &&
|
|
3029
|
-
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);
|
|
3030
4105
|
return;
|
|
3031
4106
|
}
|
|
3032
4107
|
}
|
|
3033
4108
|
const topicId =
|
|
3034
4109
|
existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
|
|
3035
|
-
|
|
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;
|
|
3036
4113
|
await this.deliverFlatFallback(session.sessionId, send);
|
|
3037
4114
|
return;
|
|
3038
4115
|
}
|
|
@@ -3045,7 +4122,7 @@ export class TelegramNotificationDaemon {
|
|
|
3045
4122
|
// Explicit Telegram-side user renames own the topic title. Pending user
|
|
3046
4123
|
// reconciliation runs before daemon identity naming, so retries and daemon
|
|
3047
4124
|
// restarts cannot silently replace the preserved name.
|
|
3048
|
-
await this.reconcileUserTopicName(
|
|
4125
|
+
await this.reconcileUserTopicName(topicLease);
|
|
3049
4126
|
const name = this.topicNameFor(session.sessionId, msg);
|
|
3050
4127
|
if (this.topics.needsRename(session.sessionId, name)) {
|
|
3051
4128
|
this.daemonRenameAttempts.set(
|
|
@@ -3053,6 +4130,7 @@ export class TelegramNotificationDaemon {
|
|
|
3053
4130
|
(this.daemonRenameAttempts.get(session.sessionId) ?? 0) + 1,
|
|
3054
4131
|
);
|
|
3055
4132
|
try {
|
|
4133
|
+
if (!this.topicLeaseIsCurrent(topicLease)) return;
|
|
3056
4134
|
const response = await this.botApi.call("editForumTopic", {
|
|
3057
4135
|
chat_id: this.opts.chatId,
|
|
3058
4136
|
message_thread_id: Number(topicId),
|
|
@@ -3067,31 +4145,34 @@ export class TelegramNotificationDaemon {
|
|
|
3067
4145
|
if (remaining > 0) this.daemonRenameAttempts.set(session.sessionId, remaining);
|
|
3068
4146
|
else {
|
|
3069
4147
|
this.daemonRenameAttempts.delete(session.sessionId);
|
|
3070
|
-
await this.reconcileUserTopicName(
|
|
4148
|
+
await this.reconcileUserTopicName(topicLease);
|
|
3071
4149
|
}
|
|
3072
4150
|
}
|
|
3073
4151
|
}
|
|
3074
4152
|
// Send the full bulleted identity header EXACTLY ONCE per topic.
|
|
3075
4153
|
if (this.topics.needsIdentity(session.sessionId)) {
|
|
3076
|
-
await this.submitThreadedFrame(session.sessionId, send,
|
|
4154
|
+
await this.submitThreadedFrame(session.sessionId, send, topicLease);
|
|
3077
4155
|
this.topics.markIdentitySent(session.sessionId);
|
|
3078
4156
|
}
|
|
3079
|
-
await this.flushPendingThreadedFrames(session.sessionId,
|
|
4157
|
+
await this.flushPendingThreadedFrames(session.sessionId, topicLease);
|
|
3080
4158
|
await this.persistTopics();
|
|
3081
4159
|
return;
|
|
3082
4160
|
}
|
|
3083
|
-
await this.submitThreadedFrame(session.sessionId, send,
|
|
4161
|
+
await this.submitThreadedFrame(session.sessionId, send, topicLease);
|
|
3084
4162
|
return;
|
|
3085
4163
|
}
|
|
3086
4164
|
if (msg.type === "action_needed" && msg.id) {
|
|
3087
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;
|
|
3088
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;
|
|
3089
4170
|
if (!topicId) {
|
|
3090
4171
|
// Fail closed for non-private chats; only nudge + flat-deliver in a private DM.
|
|
3091
4172
|
if (!(await this.pairedChatIsPrivate())) return;
|
|
3092
4173
|
await this.notifyThreadedFallback();
|
|
3093
4174
|
}
|
|
3094
|
-
const threadField =
|
|
4175
|
+
const threadField = topicLease ? { message_thread_id: Number(topicLease.topicId) } : {};
|
|
3095
4176
|
const controls: Array<{
|
|
3096
4177
|
id: "navigation_forward";
|
|
3097
4178
|
kind: "navigation";
|
|
@@ -3146,6 +4227,7 @@ export class TelegramNotificationDaemon {
|
|
|
3146
4227
|
const chunks = splitTelegramHtml(rendered.text);
|
|
3147
4228
|
let result: { result?: { message_id?: number } } = {};
|
|
3148
4229
|
for (let i = 0; i < chunks.length; i++) {
|
|
4230
|
+
if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return undefined;
|
|
3149
4231
|
result = (await this.botApi.call("sendMessage", {
|
|
3150
4232
|
chat_id: this.opts.chatId,
|
|
3151
4233
|
...threadField,
|
|
@@ -3161,6 +4243,7 @@ export class TelegramNotificationDaemon {
|
|
|
3161
4243
|
// Rich (default on): promote to sendRichMessage with a top-level
|
|
3162
4244
|
// reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
|
|
3163
4245
|
|
|
4246
|
+
if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
|
|
3164
4247
|
const outcome = await deliverRichActionWithFallback(
|
|
3165
4248
|
this.botApi,
|
|
3166
4249
|
{ chat_id: this.opts.chatId, ...threadField },
|
|
@@ -3172,8 +4255,8 @@ export class TelegramNotificationDaemon {
|
|
|
3172
4255
|
summary: msg.summary,
|
|
3173
4256
|
}),
|
|
3174
4257
|
replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
|
|
3175
|
-
requireMessageId: kind === "ask",
|
|
3176
4258
|
},
|
|
4259
|
+
AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
|
|
3177
4260
|
sendHtmlChunks,
|
|
3178
4261
|
logger,
|
|
3179
4262
|
);
|
|
@@ -3341,7 +4424,8 @@ export class TelegramNotificationDaemon {
|
|
|
3341
4424
|
} catch {
|
|
3342
4425
|
return "retry";
|
|
3343
4426
|
}
|
|
3344
|
-
|
|
4427
|
+
const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
|
|
4428
|
+
if (topicLease) await this.reconcileUserTopicName(topicLease);
|
|
3345
4429
|
await this.rememberSeenUpdateId(updateId);
|
|
3346
4430
|
return "consumed";
|
|
3347
4431
|
}
|
|
@@ -3350,7 +4434,8 @@ export class TelegramNotificationDaemon {
|
|
|
3350
4434
|
} catch {
|
|
3351
4435
|
return "retry";
|
|
3352
4436
|
}
|
|
3353
|
-
|
|
4437
|
+
const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
|
|
4438
|
+
if (topicLease) await this.reconcileUserTopicName(topicLease);
|
|
3354
4439
|
await this.rememberSeenUpdateId(updateId);
|
|
3355
4440
|
return "consumed";
|
|
3356
4441
|
}
|
|
@@ -3468,6 +4553,8 @@ export class TelegramNotificationDaemon {
|
|
|
3468
4553
|
// message in a topic (plain text, or a reply to a non-ask message) is a
|
|
3469
4554
|
// free-text injection. Previously replies bypassed injection entirely.
|
|
3470
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;
|
|
3471
4558
|
const isAskReply =
|
|
3472
4559
|
replyTo !== undefined && (this.messageRoutes.has(String(replyTo)) || this.messageRoutes.has(Number(replyTo)));
|
|
3473
4560
|
const directControl =
|
|
@@ -3475,7 +4562,7 @@ export class TelegramNotificationDaemon {
|
|
|
3475
4562
|
? parseTelegramControlCommand(raw.message.text, this.botUsername)
|
|
3476
4563
|
: ({ kind: "none" } as const);
|
|
3477
4564
|
const interceptAskControl = isAskReply && directControl.kind !== "none";
|
|
3478
|
-
if (!raw.callback_query && (!isAskReply || interceptAskControl)) {
|
|
4565
|
+
if (!raw.callback_query && (!isAskReply || interceptAskControl || reservedBtw !== undefined)) {
|
|
3479
4566
|
const inbound = decideThreadedInbound(update as never, {
|
|
3480
4567
|
pairedChatId: this.opts.chatId,
|
|
3481
4568
|
topicToSession: t => this.topics.sessionForTopic(t),
|
|
@@ -3491,7 +4578,7 @@ export class TelegramNotificationDaemon {
|
|
|
3491
4578
|
const session = this.sessions.get(inbound.sessionId);
|
|
3492
4579
|
if (preliminaryControl.kind === "invalid" && session?.ws.readyState !== WebSocket.OPEN) return;
|
|
3493
4580
|
if (preliminaryControl.kind === "command" && session?.ws.readyState !== WebSocket.OPEN) {
|
|
3494
|
-
if (await this.
|
|
4581
|
+
if (await this.reserveSeenUpdateId(inbound.updateId)) {
|
|
3495
4582
|
try {
|
|
3496
4583
|
await this.botApi.call("sendMessage", {
|
|
3497
4584
|
chat_id: this.opts.chatId,
|
|
@@ -3504,13 +4591,45 @@ export class TelegramNotificationDaemon {
|
|
|
3504
4591
|
}
|
|
3505
4592
|
return;
|
|
3506
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
|
+
}
|
|
3507
4626
|
if (session?.ws.readyState === WebSocket.OPEN) {
|
|
3508
4627
|
const attachmentResult = inbound.attachment
|
|
3509
4628
|
? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
|
|
3510
4629
|
: undefined;
|
|
3511
4630
|
const images = attachmentResult?.images ?? [];
|
|
3512
4631
|
const fileNotes = attachmentResult?.fileNotes ?? [];
|
|
3513
|
-
const hasMedia = images.length > 0 || fileNotes.length > 0;
|
|
4632
|
+
const hasMedia = inbound.attachment !== undefined || images.length > 0 || fileNotes.length > 0;
|
|
3514
4633
|
const baseInjectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
|
|
3515
4634
|
// A reply to a rich message we sent (not an ask route) loses its original
|
|
3516
4635
|
// text: Telegram does not echo it in reply_to_message. Restore it from the
|
|
@@ -3522,6 +4641,100 @@ export class TelegramNotificationDaemon {
|
|
|
3522
4641
|
const injectedText = repliedOriginal
|
|
3523
4642
|
? `> replied-to message:\n${repliedOriginal}\n\n${baseInjectedText}`
|
|
3524
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
|
+
}
|
|
3525
4738
|
const control = hasMedia ? { kind: "none" as const } : preliminaryControl;
|
|
3526
4739
|
if (control.kind !== "none") {
|
|
3527
4740
|
await this.rememberSeenUpdateId(inbound.updateId);
|
|
@@ -3635,7 +4848,10 @@ export class TelegramNotificationDaemon {
|
|
|
3635
4848
|
try {
|
|
3636
4849
|
await this.botApi.call("setMyCommands", {
|
|
3637
4850
|
commands: [
|
|
3638
|
-
{
|
|
4851
|
+
{
|
|
4852
|
+
command: "verbose",
|
|
4853
|
+
description: "Mirror bounded tool-owned summaries + provider-displayable reasoning summaries",
|
|
4854
|
+
},
|
|
3639
4855
|
{ command: "lean", description: "Mirror assistant text + tool names only (default)" },
|
|
3640
4856
|
{ command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
|
|
3641
4857
|
{ command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
|
|
@@ -3644,6 +4860,7 @@ export class TelegramNotificationDaemon {
|
|
|
3644
4860
|
{ command: "model", description: "Select a model for this session" },
|
|
3645
4861
|
{ command: "context", description: "Show current context usage for this session" },
|
|
3646
4862
|
{ command: "compact", description: "Compact this session: /compact [instructions]" },
|
|
4863
|
+
{ command: "btw", description: "Ask an ephemeral side question in this session" },
|
|
3647
4864
|
{ command: "session_create", description: "Create a GJC session: path, worktree, or dir [--mpreset]" },
|
|
3648
4865
|
{ command: "session_recent", description: "List recent GJC sessions" },
|
|
3649
4866
|
{ command: "session_close", description: "Close a GJC-managed session" },
|
|
@@ -3656,15 +4873,25 @@ export class TelegramNotificationDaemon {
|
|
|
3656
4873
|
}
|
|
3657
4874
|
|
|
3658
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
|
+
}
|
|
3659
4883
|
this.running = await renewDaemonHeartbeat({
|
|
3660
4884
|
settings: this.opts.settings,
|
|
3661
4885
|
ownerId: this.opts.ownerId,
|
|
4886
|
+
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
4887
|
+
chatId: this.opts.chatId,
|
|
3662
4888
|
fs: this.fsImpl,
|
|
3663
4889
|
now: this.opts.now,
|
|
3664
4890
|
pid: this.opts.pid ?? process.pid,
|
|
3665
4891
|
});
|
|
3666
4892
|
if (!this.running) return;
|
|
3667
4893
|
this.runtime.start();
|
|
4894
|
+
this.startOwnershipHeartbeatTimer();
|
|
3668
4895
|
this.startFlushTimer();
|
|
3669
4896
|
this.startScanTimer();
|
|
3670
4897
|
this.startTypingTimer();
|
|
@@ -3682,16 +4909,7 @@ export class TelegramNotificationDaemon {
|
|
|
3682
4909
|
let idleSince = this.runtime.now();
|
|
3683
4910
|
while (this.running) {
|
|
3684
4911
|
if (await this.controlStopRequested()) break;
|
|
3685
|
-
if (
|
|
3686
|
-
!(await renewDaemonHeartbeat({
|
|
3687
|
-
settings: this.opts.settings,
|
|
3688
|
-
ownerId: this.opts.ownerId,
|
|
3689
|
-
fs: this.fsImpl,
|
|
3690
|
-
now: this.opts.now,
|
|
3691
|
-
pid: this.opts.pid ?? process.pid,
|
|
3692
|
-
}))
|
|
3693
|
-
)
|
|
3694
|
-
break;
|
|
4912
|
+
if (!(await this.renewOwnershipHeartbeat())) break;
|
|
3695
4913
|
await this.runScan();
|
|
3696
4914
|
if (await this.controlStopRequested()) break;
|
|
3697
4915
|
const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
|
|
@@ -3726,24 +4944,50 @@ export class TelegramNotificationDaemon {
|
|
|
3726
4944
|
await this.runtime.sleep(10);
|
|
3727
4945
|
}
|
|
3728
4946
|
} finally {
|
|
3729
|
-
this.
|
|
3730
|
-
|
|
3731
|
-
this.
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
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;
|
|
3746
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
|
+
}
|
|
3747
4991
|
}
|
|
3748
4992
|
}
|
|
3749
4993
|
|