@gajae-code/coding-agent 0.13.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +215 -8
- package/README.md +2 -0
- package/bin/gajaessi.js +4 -0
- package/dist/types/async/job-manager.d.ts +55 -2
- package/dist/types/async/support.d.ts +14 -0
- package/dist/types/capability/mcp.d.ts +6 -0
- package/dist/types/capability/types.d.ts +6 -0
- package/dist/types/cli/accounts-cli.d.ts +19 -0
- package/dist/types/cli/args.d.ts +3 -0
- package/dist/types/cli/config-cli.d.ts +6 -0
- package/dist/types/cli/crash-cli.d.ts +10 -0
- package/dist/types/cli/customize-doctor.d.ts +145 -0
- package/dist/types/cli/daemon-cli.d.ts +3 -1
- package/dist/types/cli/mcp-cli.d.ts +11 -0
- package/dist/types/cli/notify-cli.d.ts +1 -1
- package/dist/types/cli/root-flags.d.ts +12 -0
- package/dist/types/cli/setup-cli.d.ts +3 -1
- package/dist/types/cli/skills-cli.d.ts +2 -1
- package/dist/types/cli/update-cli.d.ts +19 -1
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/commands/accounts.d.ts +40 -0
- package/dist/types/commands/crash.d.ts +24 -0
- package/dist/types/commands/customize.d.ts +22 -0
- package/dist/types/commands/daemon.d.ts +1 -1
- package/dist/types/commands/launch.d.ts +6 -0
- package/dist/types/commands/setup.d.ts +6 -0
- package/dist/types/commands/skills.d.ts +6 -1
- package/dist/types/commands/update.d.ts +7 -0
- package/dist/types/config/atomic-yaml-patch-worker.d.ts +25 -0
- package/dist/types/config/atomic-yaml-patch.d.ts +62 -2
- package/dist/types/config/config-schema-version.d.ts +10 -0
- package/dist/types/config/file-lock.d.ts +18 -0
- package/dist/types/config/model-profile-activation.d.ts +3 -1
- package/dist/types/config/model-registry.d.ts +10 -1
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +73 -9
- package/dist/types/config/settings.d.ts +63 -0
- package/dist/types/config/skill-settings-defaults.d.ts +26 -0
- package/dist/types/config/task-delegation.d.ts +39 -0
- package/dist/types/config/update-channel.d.ts +10 -0
- package/dist/types/coordinator-mcp/server.d.ts +5 -1
- package/dist/types/crash/index-store.d.ts +87 -0
- package/dist/types/crash/nudge.d.ts +50 -0
- package/dist/types/crash/record-loader.d.ts +17 -0
- package/dist/types/crash/report.d.ts +134 -0
- package/dist/types/crash/sanitize.d.ts +25 -0
- package/dist/types/cursor.d.ts +13 -2
- package/dist/types/customization/import.d.ts +37 -0
- package/dist/types/customization/index.d.ts +4 -0
- package/dist/types/customization/inventory.d.ts +25 -0
- package/dist/types/customization/mutations.d.ts +32 -0
- package/dist/types/customization/types.d.ts +115 -0
- package/dist/types/daemon/control-types.d.ts +8 -1
- package/dist/types/daemon/operator-contract.d.ts +4 -4
- package/dist/types/discovery/claude.d.ts +39 -1
- package/dist/types/discovery/codex.d.ts +39 -1
- package/dist/types/discovery/index.d.ts +2 -0
- package/dist/types/discovery/mcp-compat.d.ts +30 -0
- package/dist/types/edit/renderer.d.ts +23 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -0
- package/dist/types/extensibility/extensions/types.d.ts +21 -4
- package/dist/types/extensibility/gjc-plugins/observability.d.ts +6 -1
- package/dist/types/extensibility/gjc-plugins/registry.d.ts +8 -1
- package/dist/types/extensibility/hooks/loader.d.ts +19 -5
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +22 -1
- package/dist/types/extensibility/skill-management.d.ts +107 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +3 -1
- package/dist/types/gjc-runtime/launch-worktree.d.ts +8 -0
- package/dist/types/gjc-runtime/linux-proc.d.ts +3 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +10 -6
- package/dist/types/gjc-runtime/session-state-lock.d.ts +104 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +120 -2
- package/dist/types/gjc-runtime/state-writer.d.ts +10 -1
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +4 -2
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -6
- package/dist/types/gjc-runtime/workflow-settings.d.ts +109 -0
- package/dist/types/hooks/events.d.ts +60 -0
- package/dist/types/hooks/normalize.d.ts +83 -0
- package/dist/types/hooks/skill-state.d.ts +9 -2
- package/dist/types/main.d.ts +2 -1
- package/dist/types/modes/acp/abort-scope.d.ts +13 -0
- package/dist/types/modes/acp/acp-agent.d.ts +7 -4
- package/dist/types/modes/components/customization/customization-dashboard.d.ts +44 -0
- package/dist/types/modes/components/customization/import-wizard.d.ts +21 -0
- package/dist/types/modes/components/customization/index.d.ts +1 -0
- package/dist/types/modes/components/dynamic-theme-text.d.ts +9 -0
- package/dist/types/modes/components/gajae-pet-widget.d.ts +6 -28
- package/dist/types/modes/components/irc-sidebar.d.ts +1 -0
- package/dist/types/modes/components/iterm-pet-transport.d.ts +122 -0
- package/dist/types/modes/components/oauth-selector.d.ts +10 -4
- package/dist/types/modes/components/pet-capability.d.ts +7 -21
- package/dist/types/modes/components/settings-selector.d.ts +5 -0
- package/dist/types/modes/components/status-line/gh.d.ts +6 -2
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +7 -8
- package/dist/types/modes/controllers/stt-controller.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/theme/defaults/index.d.ts +118 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/modes/utils/injected-user-submission.d.ts +10 -0
- package/dist/types/runtime-mcp/config.d.ts +9 -0
- package/dist/types/runtime-mcp/manager.d.ts +26 -1
- package/dist/types/runtime-mcp/oauth-discovery.d.ts +4 -0
- package/dist/types/runtime-mcp/oauth-flow.d.ts +24 -1
- package/dist/types/runtime-mcp/pool-key.d.ts +2 -0
- package/dist/types/runtime-mcp/protocol.d.ts +278 -0
- package/dist/types/runtime-mcp/scope-config.d.ts +29 -0
- package/dist/types/runtime-mcp/tool-bridge.d.ts +15 -13
- package/dist/types/runtime-mcp/transports/http.d.ts +9 -0
- package/dist/types/runtime-mcp/types.d.ts +102 -10
- package/dist/types/sdk/acp/adapter.d.ts +26 -5
- package/dist/types/sdk/broker/broker.d.ts +3 -55
- package/dist/types/sdk/broker/lifecycle.d.ts +18 -12
- package/dist/types/sdk/broker/session-index.d.ts +14 -5
- package/dist/types/sdk/broker/startup-failure.d.ts +16 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +51 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -41
- package/dist/types/sdk/bus/config.d.ts +11 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +0 -1
- package/dist/types/sdk/bus/daemon-paths.d.ts +6 -0
- package/dist/types/sdk/bus/discord-conversation.d.ts +2 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +17 -12
- package/dist/types/sdk/bus/existing-thread-readiness.d.ts +4 -34
- package/dist/types/sdk/bus/inbound-reaction-ordering.d.ts +61 -0
- package/dist/types/sdk/bus/index.d.ts +85 -99
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +29 -7
- package/dist/types/sdk/bus/managed-daemon.d.ts +2 -32
- package/dist/types/sdk/bus/notification-service.d.ts +52 -0
- package/dist/types/sdk/bus/operator-runtime.d.ts +1 -1
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +16 -5
- package/dist/types/sdk/bus/reconciliation-store.d.ts +113 -5
- package/dist/types/sdk/bus/session-control.d.ts +25 -1
- package/dist/types/sdk/bus/slack-conversation.d.ts +4 -0
- package/dist/types/sdk/bus/slack-daemon.d.ts +9 -17
- package/dist/types/sdk/bus/slack-live-provider.d.ts +3 -1
- package/dist/types/sdk/bus/slack-provider.d.ts +4 -0
- package/dist/types/sdk/bus/slack-thread-binding.d.ts +6 -40
- package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +30 -104
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +10 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +262 -19
- package/dist/types/sdk/bus/telegram-daemon-orphan-reap.d.ts +125 -0
- package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +22 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -310
- package/dist/types/sdk/bus/telegram-reference.d.ts +1 -47
- package/dist/types/sdk/bus/threaded-render.d.ts +1 -0
- package/dist/types/sdk/bus/topic-registry.d.ts +33 -17
- package/dist/types/sdk/cli/rows.d.ts +2 -11
- package/dist/types/sdk/cli/session-cli.d.ts +15 -25
- package/dist/types/sdk/client/client.d.ts +97 -1
- package/dist/types/sdk/host/control/dispatch.d.ts +0 -7
- package/dist/types/sdk/host/control/index.d.ts +1 -1
- package/dist/types/sdk/host/control/operations.d.ts +18 -5
- package/dist/types/sdk/host/control/runtime-gate.d.ts +19 -0
- package/dist/types/sdk/host/host.d.ts +15 -1
- package/dist/types/sdk/host/query/handlers.d.ts +5 -0
- package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
- package/dist/types/sdk/host/session-runtime.d.ts +127 -7
- package/dist/types/sdk/host/types.d.ts +1 -1
- package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
- package/dist/types/sdk/index.d.ts +2 -1
- package/dist/types/sdk/lifecycle/broker-client.d.ts +8 -0
- package/dist/types/sdk/lifecycle/client.d.ts +59 -0
- package/dist/types/sdk/lifecycle/index.d.ts +3 -0
- package/dist/types/sdk/lifecycle/service.d.ts +232 -0
- package/dist/types/sdk/mcp/server.d.ts +9 -6
- package/dist/types/sdk/prompt-deadline-lease.d.ts +43 -0
- package/dist/types/sdk/prompt-deadline-manager.d.ts +28 -0
- package/dist/types/sdk/prompt-status.d.ts +10 -3
- package/dist/types/sdk/protocol/operation-registry.d.ts +5 -0
- package/dist/types/sdk/receipt-state.d.ts +17 -0
- package/dist/types/sdk/reconciliation-extensions.d.ts +2 -0
- package/dist/types/sdk/router/index.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +152 -0
- package/dist/types/sdk/session-list.d.ts +22 -0
- package/dist/types/sdk/session-reconnect.d.ts +43 -1
- package/dist/types/sdk/session.d.ts +34 -1
- package/dist/types/sdk/transport/serve-cli.d.ts +1 -4
- package/dist/types/session/account-inventory.d.ts +86 -0
- package/dist/types/session/agent-session.d.ts +145 -6
- package/dist/types/session/agent-storage.d.ts +10 -0
- package/dist/types/session/coordinator-tool-label.d.ts +28 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +23 -4
- package/dist/types/session/messages.d.ts +1 -1
- package/dist/types/session/secure-token-file.d.ts +3 -0
- package/dist/types/session/session-manager.d.ts +47 -1
- package/dist/types/session/startup-auth-config.d.ts +44 -0
- package/dist/types/session/terminal-abort.d.ts +378 -0
- package/dist/types/session/yield-queue.d.ts +7 -0
- package/dist/types/session-import/claude.d.ts +26 -0
- package/dist/types/session-import/codex.d.ts +88 -0
- package/dist/types/session-import/command.d.ts +35 -0
- package/dist/types/session-import/detect.d.ts +22 -0
- package/dist/types/session-import/index.d.ts +7 -0
- package/dist/types/session-import/provider-codex.d.ts +22 -0
- package/dist/types/session-import/provider-service.d.ts +38 -0
- package/dist/types/session-import/redact.d.ts +23 -0
- package/dist/types/session-import/service.d.ts +47 -0
- package/dist/types/session-import/types.d.ts +120 -0
- package/dist/types/setup/paseo/check.d.ts +16 -0
- package/dist/types/setup/paseo/install-saga.d.ts +115 -0
- package/dist/types/setup/paseo/json-publisher.d.ts +76 -0
- package/dist/types/setup/paseo/orchestration-preferences.d.ts +24 -0
- package/dist/types/setup/paseo/paseo-ownership.d.ts +87 -0
- package/dist/types/setup/paseo/paseo-setup.d.ts +29 -0
- package/dist/types/setup/paseo/provider-config.d.ts +35 -0
- package/dist/types/setup/paseo/remove.d.ts +14 -0
- package/dist/types/setup/paseo/result-types.d.ts +59 -0
- package/dist/types/setup/paseo/setup-deps.d.ts +73 -0
- package/dist/types/setup/paseo/skills-bridge.d.ts +44 -0
- package/dist/types/skill-state/active-state.d.ts +5 -1
- package/dist/types/slash-commands/acp-builtins.d.ts +5 -1
- package/dist/types/slash-commands/builtin-registry.d.ts +2 -0
- package/dist/types/slash-commands/helpers/credential-switch.d.ts +12 -0
- package/dist/types/slash-commands/helpers/usage-report.d.ts +5 -6
- package/dist/types/slash-commands/types.d.ts +7 -1
- package/dist/types/task/executor.d.ts +10 -0
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/tools/ask-answer-registry.d.ts +6 -3
- package/dist/types/tools/bash-sleep-advisory.d.ts +14 -0
- package/dist/types/tools/bash.d.ts +2 -6
- package/dist/types/tools/browser/attach.d.ts +11 -0
- package/dist/types/tools/browser/launch.d.ts +20 -0
- package/dist/types/tools/browser/profile-discovery.d.ts +6 -0
- package/dist/types/tools/browser/render.d.ts +1 -0
- package/dist/types/tools/browser.d.ts +3 -1
- package/dist/types/tools/image-gen.d.ts +10 -2
- package/dist/types/tools/index.d.ts +8 -0
- package/dist/types/tools/monitor.d.ts +1 -1
- package/dist/types/tools/skill-discovery.d.ts +8 -1
- package/dist/types/tools/subagent.d.ts +2 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +2 -2
- package/dist/types/utils/herdr-pane.d.ts +76 -0
- package/package.json +19 -9
- package/scripts/build-binary.ts +2 -1
- package/scripts/build-sdk-package-smoke.ts +2 -13
- package/scripts/capture-sticky-viewport-showcase.ts +0 -1
- package/scripts/compile-args.ts +2 -0
- package/scripts/generate-sdk-adapter-parity-manifest.ts +31 -16
- package/scripts/generate-sdk-operation-inventory.ts +28 -0
- package/scripts/generate-telegram-baseline-manifest.ts +1 -0
- package/scripts/generate-tool-catalog.ts +6 -4
- package/scripts/verify-gjc-sdk-canonicalization.ts +5 -39
- package/src/async/job-manager.ts +352 -17
- package/src/async/support.ts +25 -0
- package/src/autoresearch/dashboard.ts +28 -13
- package/src/capability/index.ts +3 -1
- package/src/capability/mcp.ts +6 -0
- package/src/capability/types.ts +6 -0
- package/src/cli/accounts-cli.ts +395 -0
- package/src/cli/args.ts +23 -0
- package/src/cli/auth-broker-cli.ts +136 -66
- package/src/cli/auth-gateway-cli.ts +94 -70
- package/src/cli/config-cli.ts +27 -2
- package/src/cli/crash-cli.ts +78 -0
- package/src/cli/customize-doctor.ts +1605 -0
- package/src/cli/daemon-cli.ts +12 -3
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/mcp-cli.ts +128 -60
- package/src/cli/notify-cli.ts +8 -14
- package/src/cli/root-flags.ts +8 -0
- package/src/cli/setup-cli.ts +145 -0
- package/src/cli/skills-cli.ts +38 -2
- package/src/cli/update-cli.ts +167 -12
- package/src/cli.ts +4 -0
- package/src/commands/accounts.ts +71 -0
- package/src/commands/crash.ts +35 -0
- package/src/commands/customize.ts +84 -0
- package/src/commands/daemon.ts +2 -2
- package/src/commands/deep-interview.ts +2 -0
- package/src/commands/launch.ts +1 -0
- package/src/commands/mcp.ts +12 -7
- package/src/commands/ralplan.ts +2 -0
- package/src/commands/sdk.ts +65 -32
- package/src/commands/setup.ts +5 -0
- package/src/commands/skills.ts +14 -4
- package/src/commands/ultragoal.ts +10 -1
- package/src/commands/update.ts +26 -15
- package/src/config/atomic-yaml-patch-worker.ts +57 -0
- package/src/config/atomic-yaml-patch.ts +769 -29
- package/src/config/config-schema-version.ts +10 -0
- package/src/config/file-lock.ts +70 -1
- package/src/config/mcp-schema.json +52 -14
- package/src/config/model-profile-activation.ts +23 -2
- package/src/config/model-profiles.ts +88 -21
- package/src/config/model-registry.ts +147 -21
- package/src/config/models-config-schema.ts +1 -1
- package/src/config/settings-schema.ts +77 -7
- package/src/config/settings.ts +4038 -199
- package/src/config/skill-settings-defaults.ts +41 -3
- package/src/config/task-delegation.ts +87 -0
- package/src/config/update-channel.ts +23 -0
- package/src/coordinator-mcp/server.ts +377 -308
- package/src/crash/index-store.ts +723 -0
- package/src/crash/nudge.ts +89 -0
- package/src/crash/record-loader.ts +149 -0
- package/src/crash/report.ts +551 -0
- package/src/crash/sanitize.ts +129 -0
- package/src/cursor.ts +36 -10
- package/src/customization/import.ts +946 -0
- package/src/customization/index.ts +4 -0
- package/src/customization/inventory.ts +335 -0
- package/src/customization/mutations.ts +141 -0
- package/src/customization/types.ts +223 -0
- package/src/daemon/control-types.ts +8 -1
- package/src/daemon/operator-contract.ts +6 -6
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +31 -6
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -22
- package/src/defaults/gjc/skills/ralplan/SKILL.md +36 -23
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
- package/src/discovery/claude-hooks.ts +38 -0
- package/src/discovery/claude.ts +142 -209
- package/src/discovery/codex-hooks.ts +38 -0
- package/src/discovery/codex.ts +132 -198
- package/src/discovery/helpers.ts +8 -1
- package/src/discovery/index.ts +2 -0
- package/src/discovery/mcp-compat.ts +234 -0
- package/src/discovery/mcp-json.ts +6 -0
- package/src/edit/renderer.ts +40 -0
- package/src/edit/streaming.ts +4 -2
- package/src/exec/bash-executor.ts +24 -20
- package/src/export/html/index.ts +51 -2
- package/src/extensibility/extensions/loader.ts +9 -1
- package/src/extensibility/extensions/runner.ts +17 -4
- package/src/extensibility/extensions/types.ts +31 -6
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +33 -0
- package/src/extensibility/gjc-plugins/installer.ts +9 -5
- package/src/extensibility/gjc-plugins/observability.ts +8 -2
- package/src/extensibility/gjc-plugins/registry.ts +13 -2
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +3 -1
- package/src/extensibility/gjc-plugins/schema.ts +222 -39
- package/src/extensibility/hooks/loader.ts +154 -10
- package/src/extensibility/runtime-skill-discovery.ts +257 -60
- package/src/extensibility/skill-management.ts +368 -0
- package/src/extensibility/skills.ts +52 -16
- package/src/gjc-runtime/deep-interview-runtime.ts +45 -61
- package/src/gjc-runtime/launch-worktree.ts +56 -2
- package/src/gjc-runtime/linux-proc.ts +7 -1
- package/src/gjc-runtime/ralplan-runtime.ts +100 -164
- package/src/gjc-runtime/session-state-lock.ts +992 -0
- package/src/gjc-runtime/session-state-sidecar.ts +644 -142
- package/src/gjc-runtime/state-writer.ts +39 -14
- package/src/gjc-runtime/tmux-owner-isolation.ts +5 -4
- package/src/gjc-runtime/ultragoal-guard.ts +11 -4
- package/src/gjc-runtime/ultragoal-runtime.ts +44 -41
- package/src/gjc-runtime/workflow-settings.ts +596 -0
- package/src/goals/tools/goal-tool.ts +6 -1
- package/src/hooks/events.ts +291 -0
- package/src/hooks/native-skill-hook.ts +4 -0
- package/src/hooks/normalize.ts +369 -0
- package/src/hooks/skill-state.ts +165 -46
- package/src/internal-urls/docs-index.generated.ts +41 -32
- package/src/main.ts +36 -5
- package/src/migrate/mcp-mapper.ts +25 -2
- package/src/modes/acp/abort-scope.ts +29 -0
- package/src/modes/acp/acp-agent.ts +423 -98
- package/src/modes/components/assistant-message.ts +6 -1
- package/src/modes/components/chrome.ts +4 -3
- package/src/modes/components/customization/customization-dashboard.ts +443 -0
- package/src/modes/components/customization/import-wizard.ts +372 -0
- package/src/modes/components/customization/index.ts +1 -0
- package/src/modes/components/dynamic-theme-text.ts +22 -0
- package/src/modes/components/gajae-pet-widget.ts +607 -193
- package/src/modes/components/irc-sidebar.ts +11 -0
- package/src/modes/components/iterm-pet-transport.ts +585 -0
- package/src/modes/components/model-selector.ts +1 -6
- package/src/modes/components/oauth-selector.ts +277 -86
- package/src/modes/components/pet-capability.ts +35 -31
- package/src/modes/components/settings-selector.ts +29 -5
- package/src/modes/components/status-line/gh.ts +45 -1
- package/src/modes/components/status-line/segments.ts +19 -4
- package/src/modes/components/tool-status-header.ts +13 -3
- package/src/modes/controllers/command-controller.ts +20 -8
- package/src/modes/controllers/extension-ui-controller.ts +56 -257
- package/src/modes/controllers/input-controller.ts +10 -0
- package/src/modes/controllers/runtime-mcp-command-controller.ts +16 -3
- package/src/modes/controllers/selector-controller.ts +238 -77
- package/src/modes/controllers/stt-controller.ts +3 -3
- package/src/modes/interactive-mode.ts +88 -16
- package/src/modes/print-mode.ts +35 -6
- package/src/modes/runtime-init.ts +21 -68
- package/src/modes/theme/defaults/index.ts +2 -0
- package/src/modes/theme/defaults/ouroboros.json +118 -0
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/injected-user-submission.ts +25 -0
- package/src/modes/utils/terminal-bell.ts +15 -1
- package/src/prompts/agent-fragments/ralplan-persistence.md +1 -1
- package/src/prompts/agent-fragments/restricted-bash.md +1 -1
- package/src/prompts/agents/critic.md +1 -1
- package/src/prompts/system/rlm-research.md +1 -1
- package/src/prompts/system/system-prompt.md +0 -2
- package/src/prompts/tools/bash.md +7 -1
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/cron.md +6 -6
- package/src/prompts/tools/read.md +0 -2
- package/src/prompts/tools/skill-discovery.md +4 -2
- package/src/runtime-mcp/client.ts +427 -23
- package/src/runtime-mcp/config.ts +37 -5
- package/src/runtime-mcp/manager-pool.test.ts +56 -0
- package/src/runtime-mcp/manager.ts +52 -2
- package/src/runtime-mcp/oauth-discovery.ts +13 -0
- package/src/runtime-mcp/oauth-flow.ts +46 -1
- package/src/runtime-mcp/pool-key.ts +5 -1
- package/src/runtime-mcp/pool.test.ts +9 -0
- package/src/runtime-mcp/protocol.test.ts +353 -0
- package/src/runtime-mcp/protocol.ts +624 -0
- package/src/runtime-mcp/scope-config.ts +78 -0
- package/src/runtime-mcp/tool-bridge.ts +44 -10
- package/src/runtime-mcp/transports/http.ts +84 -13
- package/src/runtime-mcp/types.ts +123 -12
- package/src/sdk/acp/adapter.ts +328 -97
- package/src/sdk/broker/broker.ts +160 -741
- package/src/sdk/broker/ensure.ts +9 -1
- package/src/sdk/broker/lifecycle.ts +510 -116
- package/src/sdk/broker/operation-receipt-ledger.ts +11 -2
- package/src/sdk/broker/process-incarnation.ts +19 -0
- package/src/sdk/broker/session-index.ts +516 -117
- package/src/sdk/broker/startup-failure.ts +28 -5
- package/src/sdk/broker/transport.ts +1 -13
- package/src/sdk/bus/chat-daemon-cli.ts +4 -2
- package/src/sdk/bus/chat-daemon-control.ts +67 -6
- package/src/sdk/bus/chat-daemon-runtime.ts +183 -859
- package/src/sdk/bus/config.ts +13 -0
- package/src/sdk/bus/conversation-store.ts +59 -171
- package/src/sdk/bus/daemon-paths.ts +20 -0
- package/src/sdk/bus/discord-conversation.ts +2 -0
- package/src/sdk/bus/discord-daemon.ts +524 -126
- package/src/sdk/bus/discord-live-provider.ts +206 -79
- package/src/sdk/bus/existing-thread-readiness.ts +4 -97
- package/src/sdk/bus/inbound-reaction-ordering.ts +122 -0
- package/src/sdk/bus/index.ts +2480 -456
- package/src/sdk/bus/kind-aware-reconciliation.ts +204 -34
- package/src/sdk/bus/lifecycle-commands.ts +2 -2
- package/src/sdk/bus/managed-daemon.ts +2 -145
- package/src/sdk/bus/notification-service.ts +196 -0
- package/src/sdk/bus/operator-runtime.ts +1 -1
- package/src/sdk/bus/prompt-reconciliation.ts +24 -15
- package/src/sdk/bus/reconciliation-store.ts +553 -24
- package/src/sdk/bus/session-control.ts +49 -3
- package/src/sdk/bus/slack-conversation.ts +4 -0
- package/src/sdk/bus/slack-daemon.ts +733 -134
- package/src/sdk/bus/slack-live-provider.ts +65 -15
- package/src/sdk/bus/slack-provider.ts +4 -0
- package/src/sdk/bus/slack-thread-binding.ts +9 -104
- package/src/sdk/bus/telegram-adoption-intent.ts +299 -209
- package/src/sdk/bus/telegram-daemon-cli.ts +194 -7
- package/src/sdk/bus/telegram-daemon-contract.ts +265 -19
- package/src/sdk/bus/telegram-daemon-control.ts +3 -13
- package/src/sdk/bus/telegram-daemon-orphan-reap.ts +498 -0
- package/src/sdk/bus/telegram-daemon-owner-registry.ts +176 -0
- package/src/sdk/bus/telegram-daemon.ts +2552 -2280
- package/src/sdk/bus/telegram-reference.ts +1 -226
- package/src/sdk/bus/telegram-setup.ts +2 -2
- package/src/sdk/bus/threaded-render.ts +1 -0
- package/src/sdk/bus/topic-registry.ts +279 -146
- package/src/sdk/cli/rows.ts +2 -35
- package/src/sdk/cli/session-cli.ts +776 -700
- package/src/sdk/client/client.ts +892 -1
- package/src/sdk/host/control/dispatch.ts +93 -58
- package/src/sdk/host/control/index.ts +1 -1
- package/src/sdk/host/control/operations.ts +18 -10
- package/src/sdk/host/control/runtime-gate.ts +40 -0
- package/src/sdk/host/host.ts +63 -12
- package/src/sdk/host/query/handlers.ts +66 -7
- package/src/sdk/host/reverse-leases.ts +2 -2
- package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
- package/src/sdk/host/session-runtime.test.ts +2787 -34
- package/src/sdk/host/session-runtime.ts +1489 -101
- package/src/sdk/host/surface-policy.ts +12 -8
- package/src/sdk/host/types.ts +1 -1
- package/src/sdk/host/websocket-transport.lifecycle.test.ts +31 -59
- package/src/sdk/host/websocket-transport.ts +85 -50
- package/src/sdk/index.ts +2 -1
- package/src/sdk/lifecycle/broker-client.ts +53 -0
- package/src/sdk/lifecycle/client.ts +234 -0
- package/src/sdk/lifecycle/index.ts +3 -0
- package/src/sdk/{bus/recent-activity.ts → lifecycle/recent-sessions.ts} +5 -2
- package/src/sdk/lifecycle/service.ts +720 -0
- package/src/sdk/mcp/server.ts +226 -188
- package/src/sdk/prompt-deadline-lease.ts +55 -0
- package/src/sdk/prompt-deadline-manager.ts +148 -0
- package/src/sdk/prompt-status.ts +11 -3
- package/src/sdk/protocol/operation-inventory.generated.json +272 -129
- package/src/sdk/protocol/operation-registry.ts +71 -8
- package/src/sdk/receipt-state.ts +43 -0
- package/src/sdk/reconciliation-extensions.ts +14 -0
- package/src/sdk/router/index.ts +1 -0
- package/src/sdk/router/session-router.ts +1658 -0
- package/src/sdk/session-list.ts +93 -0
- package/src/sdk/session-reconnect.ts +45 -1
- package/src/sdk/session.ts +716 -120
- package/src/sdk/transport/serve-cli.ts +18 -34
- package/src/session/account-inventory.ts +603 -0
- package/src/session/agent-session.ts +2873 -559
- package/src/session/agent-storage.ts +35 -0
- package/src/session/blob-store.ts +32 -1
- package/src/session/coordinator-tool-label.ts +40 -0
- package/src/session/internal/managed-session-scope.ts +4 -0
- package/src/session/internal/managed-session-storage.ts +199 -31
- package/src/session/messages.ts +1 -1
- package/src/session/secure-token-file.ts +80 -0
- package/src/session/session-manager.ts +757 -57
- package/src/session/session-storage.ts +25 -16
- package/src/session/startup-auth-config.ts +297 -0
- package/src/session/terminal-abort.ts +1253 -0
- package/src/session/yield-queue.ts +57 -15
- package/src/session-import/claude.ts +334 -0
- package/src/session-import/codex.ts +1033 -0
- package/src/session-import/command.ts +77 -0
- package/src/session-import/detect.ts +133 -0
- package/src/session-import/index.ts +7 -0
- package/src/session-import/provider-codex.ts +327 -0
- package/src/session-import/provider-service.ts +457 -0
- package/src/session-import/redact.ts +128 -0
- package/src/session-import/service.ts +1264 -0
- package/src/session-import/types.ts +148 -0
- package/src/setup/paseo/check.ts +213 -0
- package/src/setup/paseo/install-saga.ts +293 -0
- package/src/setup/paseo/json-publisher.ts +263 -0
- package/src/setup/paseo/orchestration-preferences.ts +86 -0
- package/src/setup/paseo/paseo-ownership.ts +224 -0
- package/src/setup/paseo/paseo-setup.ts +253 -0
- package/src/setup/paseo/provider-config.ts +139 -0
- package/src/setup/paseo/remove.ts +183 -0
- package/src/setup/paseo/result-types.ts +79 -0
- package/src/setup/paseo/setup-deps.ts +175 -0
- package/src/setup/paseo/skills-bridge.ts +260 -0
- package/src/skill-state/active-state.ts +21 -11
- package/src/slash-commands/acp-builtins.ts +9 -1
- package/src/slash-commands/builtin-registry.ts +117 -19
- package/src/slash-commands/helpers/credential-switch.ts +143 -0
- package/src/slash-commands/helpers/mcp.ts +9 -3
- package/src/slash-commands/helpers/usage-report.ts +61 -56
- package/src/slash-commands/types.ts +7 -2
- package/src/task/executor.ts +30 -11
- package/src/task/index.ts +66 -7
- package/src/task/render.ts +23 -9
- package/src/tools/ask-answer-registry.ts +23 -7
- package/src/tools/ask.ts +8 -4
- package/src/tools/bash-sleep-advisory.ts +115 -0
- package/src/tools/bash.ts +98 -18
- package/src/tools/browser/attach.ts +71 -3
- package/src/tools/browser/launch.ts +86 -13
- package/src/tools/browser/profile-discovery.ts +57 -7
- package/src/tools/browser/render.ts +8 -1
- package/src/tools/browser.ts +130 -21
- package/src/tools/computer.enforcement.test.ts +1 -1
- package/src/tools/computer.ts +3 -2
- package/src/tools/cron.ts +12 -1
- package/src/tools/image-gen.ts +349 -142
- package/src/tools/index.ts +8 -0
- package/src/tools/job.ts +33 -6
- package/src/tools/monitor.ts +41 -2
- package/src/tools/skill-discovery.ts +21 -29
- package/src/tools/subagent.ts +142 -24
- package/src/tools/todo-contract.ts +55 -3
- package/src/tools/todo-write.ts +4 -3
- package/src/tools/tool-catalog.generated.ts +6 -6
- package/src/tools/ultragoal-ask-guard.ts +10 -3
- package/src/utils/herdr-pane.ts +345 -0
- package/src/utils/title-generator.ts +2 -0
- package/src/web/scrapers/index.ts +6 -4
- package/dist/types/cli/malloc-env-guard.test.d.ts +0 -1
- package/dist/types/defaults/gjc-defaults.test.d.ts +0 -1
- package/dist/types/memory-backend/lazy-loading.test.d.ts +0 -1
- package/dist/types/runtime/boot-ordering.test.d.ts +0 -1
- package/dist/types/runtime/lazy-service.test.d.ts +0 -1
- package/dist/types/runtime/network-prewarm-service.test.d.ts +0 -1
- package/dist/types/runtime/workspace-tree-service.test.d.ts +0 -1
- package/dist/types/runtime-mcp/config.test.d.ts +0 -1
- package/dist/types/runtime-mcp/http.test.d.ts +0 -1
- package/dist/types/runtime-mcp/manager-pool.test.d.ts +0 -1
- package/dist/types/runtime-mcp/pool-key.test.d.ts +0 -1
- package/dist/types/runtime-mcp/pool.test.d.ts +0 -1
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +0 -117
- package/dist/types/sdk/bus/lifecycle-orchestrator.d.ts +0 -141
- package/dist/types/sdk/bus/telegram-cli.d.ts +0 -31
- package/dist/types/sdk/elevation/allowlist.d.ts +0 -20
- package/dist/types/sdk/elevation/audit.d.ts +0 -93
- package/dist/types/sdk/elevation/capability.d.ts +0 -3
- package/dist/types/sdk/elevation/digest.d.ts +0 -72
- package/dist/types/sdk/elevation/dispatch-receipt.d.ts +0 -85
- package/dist/types/sdk/elevation/grant-ledger.d.ts +0 -163
- package/dist/types/sdk/elevation/owner.d.ts +0 -48
- package/dist/types/sdk/host/import-graph.test.d.ts +0 -1
- package/dist/types/sdk/host/sdk-surface-parity.test.d.ts +0 -1
- package/dist/types/sdk/host/session-runtime.test.d.ts +0 -1
- package/dist/types/sdk/host/websocket-transport.lifecycle.test.d.ts +0 -1
- package/dist/types/session/auth-broker-config.d.ts +0 -13
- package/dist/types/session/heap-eviction-artifacts.test.d.ts +0 -1
- package/dist/types/skills/skills.test.d.ts +0 -1
- package/dist/types/slash-commands/notify-command.test.d.ts +0 -1
- package/dist/types/task/token-log.test.d.ts +0 -1
- package/dist/types/tools/computer.enforcement.test.d.ts +0 -1
- package/dist/types/tools/descriptors.test.d.ts +0 -1
- package/scripts/g005-daemon-smoke.ts +0 -180
- package/scripts/g011-daemon-path-smoke.ts +0 -153
- package/src/sdk/bus/lifecycle-control-runtime.ts +0 -1733
- package/src/sdk/bus/lifecycle-orchestrator.ts +0 -489
- package/src/sdk/bus/telegram-cli.ts +0 -212
- package/src/sdk/elevation/allowlist.ts +0 -54
- package/src/sdk/elevation/audit.ts +0 -208
- package/src/sdk/elevation/capability.ts +0 -41
- package/src/sdk/elevation/digest.ts +0 -129
- package/src/sdk/elevation/dispatch-receipt.ts +0 -184
- package/src/sdk/elevation/grant-ledger.ts +0 -1142
- package/src/sdk/elevation/owner.ts +0 -109
- package/src/session/auth-broker-config.ts +0 -109
- /package/dist/types/{ai-core-import-gate.test.d.ts → discovery/claude-hooks.d.ts} +0 -0
- /package/dist/types/{cli/fixture-report.test.d.ts → discovery/codex-hooks.d.ts} +0 -0
- /package/dist/types/sdk/{bus/recent-activity.d.ts → lifecycle/recent-sessions.d.ts} +0 -0
|
@@ -0,0 +1,1658 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { logger } from "@gajae-code/utils";
|
|
5
|
+
import {
|
|
6
|
+
SessionIndex as DefaultSessionIndex,
|
|
7
|
+
type IndexedSession,
|
|
8
|
+
isSessionAuthorityEligible,
|
|
9
|
+
type SessionIndex,
|
|
10
|
+
} from "../broker/session-index";
|
|
11
|
+
import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
|
|
12
|
+
import { SdkClient } from "../client/client";
|
|
13
|
+
import { readSdkBrokerDiscovery, readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
|
|
14
|
+
import {
|
|
15
|
+
type ActivatedPreparedSession,
|
|
16
|
+
type PreparedSessionActivationClient,
|
|
17
|
+
requestPreparedSessionActivation,
|
|
18
|
+
SessionActivationError,
|
|
19
|
+
} from "../session-activation";
|
|
20
|
+
import { ACP_SESSION_RECONNECT, SESSION_REQUEST_TIMEOUT_MS } from "../session-reconnect";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Exact identity of one attached SDK session endpoint. Providers persist it next to
|
|
24
|
+
* their conversation state and re-prove it before every resume, so it must be derived
|
|
25
|
+
* in exactly one place: a caller that recomputes the digest by hand silently stops
|
|
26
|
+
* matching the moment the bound fields change.
|
|
27
|
+
*/
|
|
28
|
+
export function sessionAttachmentAuthorityId(input: {
|
|
29
|
+
sessionId: string;
|
|
30
|
+
generation: number;
|
|
31
|
+
pid: number;
|
|
32
|
+
endpointMtimeMs: number | undefined;
|
|
33
|
+
url: string;
|
|
34
|
+
token: string;
|
|
35
|
+
}): string {
|
|
36
|
+
const endpointAuthorityDigest = crypto
|
|
37
|
+
.createHash("sha256")
|
|
38
|
+
.update(JSON.stringify({ url: input.url, token: input.token }))
|
|
39
|
+
.digest("hex");
|
|
40
|
+
return crypto
|
|
41
|
+
.createHash("sha256")
|
|
42
|
+
.update(
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
sessionId: input.sessionId,
|
|
45
|
+
generation: input.generation,
|
|
46
|
+
pid: input.pid,
|
|
47
|
+
endpointMtimeMs: input.endpointMtimeMs,
|
|
48
|
+
endpointAuthorityDigest,
|
|
49
|
+
}),
|
|
50
|
+
)
|
|
51
|
+
.digest("hex");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The only capability a provider may retain for an attached SDK session. */
|
|
55
|
+
export interface SessionAttachment {
|
|
56
|
+
readonly sessionId: string;
|
|
57
|
+
readonly authorityId?: string;
|
|
58
|
+
/** Current Router-owned transport identity for this exact attachment's reverse leases. */
|
|
59
|
+
readonly connectionId?: string;
|
|
60
|
+
readonly generation: number;
|
|
61
|
+
isCurrent(): boolean;
|
|
62
|
+
send(frame: Record<string, unknown>): unknown;
|
|
63
|
+
/** Revoke this exact capability after provider admission or replay fails closed. */
|
|
64
|
+
retire?(): Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Provider-local notification capability. This is deliberately not an
|
|
69
|
+
* attachment lease: it carries no endpoint, connection, generation, or
|
|
70
|
+
* authority identity and its cancellation can only stop this subscription.
|
|
71
|
+
*/
|
|
72
|
+
export interface NotificationSubscription {
|
|
73
|
+
readonly sessionId: string;
|
|
74
|
+
readonly subscriptionId: string;
|
|
75
|
+
readonly cursor: { readonly generation: number; readonly seq: number };
|
|
76
|
+
readonly isActive: () => boolean;
|
|
77
|
+
readonly send: (frame: Record<string, unknown>) => unknown;
|
|
78
|
+
readonly advanceCursor: (generation: number, seq: number) => void;
|
|
79
|
+
readonly cancel: (reason?: string) => void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type NotificationCleanupState = "pending" | "failed" | "completed";
|
|
83
|
+
|
|
84
|
+
export interface NotificationCleanupReceipt {
|
|
85
|
+
readonly subscriptionId: string;
|
|
86
|
+
readonly sessionId: string;
|
|
87
|
+
readonly state: NotificationCleanupState;
|
|
88
|
+
readonly reason?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The transport surface Router keeps private behind its attachment capabilities. */
|
|
92
|
+
export interface SessionRouterClient {
|
|
93
|
+
onFrame(handler: (frame: Record<string, unknown>) => void): () => void;
|
|
94
|
+
onReconnect?(handler: () => void): () => void;
|
|
95
|
+
connect?(): Promise<void>;
|
|
96
|
+
request(frame: Record<string, unknown>, options?: { timeoutMs?: number }): Promise<Record<string, unknown>>;
|
|
97
|
+
/** Current private transport connection identity, surfaced only through its exact attachment. */
|
|
98
|
+
readonly connectionId?: string;
|
|
99
|
+
|
|
100
|
+
close(): Promise<void>;
|
|
101
|
+
send(frame: Record<string, unknown>): void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One frame after the caller's envelope/payload identity correlation. */
|
|
105
|
+
export interface SessionRouterFrame {
|
|
106
|
+
readonly body: Record<string, unknown>;
|
|
107
|
+
readonly name: string | undefined;
|
|
108
|
+
readonly sessionId: string | undefined;
|
|
109
|
+
readonly generation: number | undefined;
|
|
110
|
+
readonly commandId?: string;
|
|
111
|
+
readonly turnId?: string;
|
|
112
|
+
readonly publicationId?: string;
|
|
113
|
+
readonly seq?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type SessionRouterFrameCorrelator = (frame: Record<string, unknown>) => SessionRouterFrame | undefined;
|
|
117
|
+
|
|
118
|
+
export interface SessionRouterDeps {
|
|
119
|
+
createClient?: (authority: {
|
|
120
|
+
readonly sessionId: string;
|
|
121
|
+
readonly generation: number;
|
|
122
|
+
readonly pid: number;
|
|
123
|
+
readonly endpointMtimeMs: number;
|
|
124
|
+
}) => Promise<SessionRouterClient>;
|
|
125
|
+
createIndex?: (agentDir: string) => SessionIndex;
|
|
126
|
+
createBrokerClient?: () => Promise<SessionRouterClient>;
|
|
127
|
+
/** Receives only an opaque capability and correlated provider-neutral frames. */
|
|
128
|
+
onFrame?: (attachment: SessionAttachment, frame: SessionRouterFrame) => Promise<void> | void;
|
|
129
|
+
onAttachment?: (attachment: SessionAttachment) => Promise<void> | void;
|
|
130
|
+
/** Called only after the opaque capability becomes externally current. */
|
|
131
|
+
onAttachmentReady?: (attachment: SessionAttachment) => Promise<void> | void;
|
|
132
|
+
/** Called when the Broker index no longer reports an attached session as live. */
|
|
133
|
+
onSessionRemoved?: (
|
|
134
|
+
attachment: SessionAttachment,
|
|
135
|
+
reason?: "removed" | "replaced" | "replaced_same_generation",
|
|
136
|
+
) => Promise<void> | void;
|
|
137
|
+
/** Narrow provider surface for notification consumers such as Telegram. */
|
|
138
|
+
onNotificationSubscription?: (subscription: NotificationSubscription) => Promise<void> | void;
|
|
139
|
+
onNotificationSubscriptionReady?: (subscription: NotificationSubscription) => Promise<void> | void;
|
|
140
|
+
onNotificationFrame?: (subscription: NotificationSubscription, frame: SessionRouterFrame) => Promise<void> | void;
|
|
141
|
+
onNotificationSubscriptionRemoved?: (
|
|
142
|
+
subscription: NotificationSubscription,
|
|
143
|
+
reason?: "removed" | "replaced" | "replaced_same_generation" | "cancelled",
|
|
144
|
+
) => Promise<void> | void;
|
|
145
|
+
onReconciled?: () => void;
|
|
146
|
+
setInterval?: typeof setInterval;
|
|
147
|
+
clearInterval?: typeof clearInterval;
|
|
148
|
+
setTimeout?: typeof setTimeout;
|
|
149
|
+
clearTimeout?: typeof clearTimeout;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type SessionRouterProviderDeps = Pick<
|
|
153
|
+
SessionRouterDeps,
|
|
154
|
+
| "createClient"
|
|
155
|
+
| "createIndex"
|
|
156
|
+
| "createBrokerClient"
|
|
157
|
+
| "setInterval"
|
|
158
|
+
| "clearInterval"
|
|
159
|
+
| "setTimeout"
|
|
160
|
+
| "clearTimeout"
|
|
161
|
+
| "onReconciled"
|
|
162
|
+
>;
|
|
163
|
+
|
|
164
|
+
export interface SessionRouterOptions {
|
|
165
|
+
agentDir: string;
|
|
166
|
+
deps?: SessionRouterDeps;
|
|
167
|
+
/** Runtime-specific identity validation; Router supplies a conservative fallback. */
|
|
168
|
+
correlateFrame?: SessionRouterFrameCorrelator;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type SessionRouterErrorPhase = "pre_send" | "ambiguous";
|
|
172
|
+
|
|
173
|
+
export class SessionRouterError extends Error {
|
|
174
|
+
constructor(
|
|
175
|
+
readonly phase: SessionRouterErrorPhase,
|
|
176
|
+
message = "SDK session attachment is unavailable.",
|
|
177
|
+
) {
|
|
178
|
+
super(message);
|
|
179
|
+
this.name = "SessionRouterError";
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
type HeldFrame = Readonly<{ seq: number; frame: Record<string, unknown> }>;
|
|
184
|
+
type FrameOrigin = "live" | "ordered";
|
|
185
|
+
type ReplayBarrier = {
|
|
186
|
+
held: HeldFrame[] | undefined;
|
|
187
|
+
detached: boolean;
|
|
188
|
+
failed: boolean;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
type AttachedSession = {
|
|
192
|
+
readonly id: string;
|
|
193
|
+
readonly sessionId: string;
|
|
194
|
+
readonly endpoint: SdkSessionEndpoint;
|
|
195
|
+
readonly generation: number;
|
|
196
|
+
readonly pid: number;
|
|
197
|
+
readonly endpointMtimeMs: number;
|
|
198
|
+
readonly runEpoch: number;
|
|
199
|
+
readonly client: SessionRouterClient;
|
|
200
|
+
readonly indexed: IndexedSession;
|
|
201
|
+
readonly cursor: { seq: number };
|
|
202
|
+
readonly barrier: ReplayBarrier;
|
|
203
|
+
readonly capability: SessionAttachment;
|
|
204
|
+
readonly notificationSubscription: NotificationSubscription;
|
|
205
|
+
notificationCancelled: boolean;
|
|
206
|
+
readonly notificationCursor: { generation: number; seq: number };
|
|
207
|
+
published: boolean;
|
|
208
|
+
initializingPublication: boolean;
|
|
209
|
+
readyTail: Promise<void>;
|
|
210
|
+
readonly publication: { promise: Promise<void>; resolve: () => void; reject: (reason?: unknown) => void };
|
|
211
|
+
dispose: () => void;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const REPLAY_BARRIER_LIMIT = 1_024;
|
|
215
|
+
const REPLAY_RETRY_ATTEMPTS = 3;
|
|
216
|
+
const REPLAY_RETRY_BACKOFF_MS = 100;
|
|
217
|
+
const DELIVERY_ATTEMPT_LIMIT = 3;
|
|
218
|
+
const ATTACH_CONCURRENCY = 4;
|
|
219
|
+
const ATTACH_CONNECT_TIMEOUT_MS = 10_000;
|
|
220
|
+
const NOTIFICATION_WORK_TIMEOUT_MS = 5_000;
|
|
221
|
+
const NOTIFICATION_WORK_TIMEOUT = Symbol("notification_work_timeout");
|
|
222
|
+
/**
|
|
223
|
+
* Client-message types the native session server authorizes with the
|
|
224
|
+
* per-session endpoint token (`tokens_match` in crates/gjc-sdk server.rs).
|
|
225
|
+
* Frames of these types without a matching `token` are dropped silently.
|
|
226
|
+
*/
|
|
227
|
+
const TOKEN_AUTHORIZED_FRAME_TYPES = new Set([
|
|
228
|
+
"user_message",
|
|
229
|
+
"reply",
|
|
230
|
+
"ephemeral_turn",
|
|
231
|
+
"ephemeral_turn_cancel",
|
|
232
|
+
"config_command",
|
|
233
|
+
"control_command",
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
function readGeneration(value: unknown): number | undefined {
|
|
237
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function readPositiveInteger(value: unknown): number | undefined {
|
|
241
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function readEndpointMtime(value: unknown): number | undefined {
|
|
245
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function readSequence(value: unknown): number | undefined {
|
|
249
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 ? value : undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function fallbackCorrelation(frame: Record<string, unknown>): SessionRouterFrame | undefined {
|
|
253
|
+
const payload =
|
|
254
|
+
frame.type === "event" && frame.payload && typeof frame.payload === "object" && !Array.isArray(frame.payload)
|
|
255
|
+
? (frame.payload as Record<string, unknown>)
|
|
256
|
+
: undefined;
|
|
257
|
+
const readSession = (value: unknown): string | undefined =>
|
|
258
|
+
typeof value === "string" && value.length > 0 ? value : undefined;
|
|
259
|
+
const readName = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
|
|
260
|
+
const readCorrelation = (value: unknown): string | undefined =>
|
|
261
|
+
typeof value === "string" && value.length > 0 ? value : undefined;
|
|
262
|
+
const outerSession = frame.sessionId;
|
|
263
|
+
const innerSession = payload?.sessionId;
|
|
264
|
+
const outerGeneration = frame.generation;
|
|
265
|
+
const innerGeneration = payload?.generation;
|
|
266
|
+
if (outerSession !== undefined && innerSession !== undefined && outerSession !== innerSession) return undefined;
|
|
267
|
+
if (outerGeneration !== undefined && innerGeneration !== undefined && outerGeneration !== innerGeneration)
|
|
268
|
+
return undefined;
|
|
269
|
+
const sessionClaim = outerSession !== undefined ? outerSession : innerSession;
|
|
270
|
+
const generationClaim = outerGeneration !== undefined ? outerGeneration : innerGeneration;
|
|
271
|
+
const sessionId = readSession(sessionClaim);
|
|
272
|
+
const generation = readGeneration(generationClaim);
|
|
273
|
+
if (sessionClaim !== undefined && sessionId === undefined) return undefined;
|
|
274
|
+
if (generationClaim !== undefined && generation === undefined) return undefined;
|
|
275
|
+
const body = payload ?? frame;
|
|
276
|
+
const nestedEvent = payload
|
|
277
|
+
? payload.event && typeof payload.event === "object" && !Array.isArray(payload.event)
|
|
278
|
+
? (payload.event as Record<string, unknown>)
|
|
279
|
+
: undefined
|
|
280
|
+
: undefined;
|
|
281
|
+
const commandId =
|
|
282
|
+
readCorrelation(frame.commandId) ??
|
|
283
|
+
readCorrelation(payload?.commandId) ??
|
|
284
|
+
readCorrelation(nestedEvent?.commandId);
|
|
285
|
+
const turnId =
|
|
286
|
+
readCorrelation(frame.turnId) ?? readCorrelation(payload?.turnId) ?? readCorrelation(nestedEvent?.turnId);
|
|
287
|
+
return {
|
|
288
|
+
body,
|
|
289
|
+
name: readName(frame.name) ?? readName(frame.kind) ?? readName(body.type),
|
|
290
|
+
sessionId,
|
|
291
|
+
generation,
|
|
292
|
+
commandId,
|
|
293
|
+
turnId,
|
|
294
|
+
seq: readSequence(frame.seq) ?? readSequence(payload?.seq),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function readReplayGap(
|
|
299
|
+
value: unknown,
|
|
300
|
+
):
|
|
301
|
+
| Readonly<{ kind: "generation_reset"; toGeneration: number }>
|
|
302
|
+
| Readonly<{ kind: "sequence_gap"; fromSeq: number; toSeq: number }>
|
|
303
|
+
| undefined {
|
|
304
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
305
|
+
const gap = value as Record<string, unknown>;
|
|
306
|
+
if (gap.kind === "generation_reset") {
|
|
307
|
+
const toGeneration = readGeneration(gap.toGeneration);
|
|
308
|
+
return toGeneration === undefined ? undefined : { kind: "generation_reset", toGeneration };
|
|
309
|
+
}
|
|
310
|
+
if (gap.kind !== "sequence_gap") return undefined;
|
|
311
|
+
const fromSeq = readSequence(gap.fromSeq);
|
|
312
|
+
const toSeq = readSequence(gap.toSeq);
|
|
313
|
+
if (fromSeq === undefined || toSeq === undefined || toSeq < fromSeq) return undefined;
|
|
314
|
+
return { kind: "sequence_gap", fromSeq, toSeq };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function sameIndexedAuthority(expected: IndexedSession, current: IndexedSession): boolean {
|
|
318
|
+
return (
|
|
319
|
+
current.sessionId === expected.sessionId &&
|
|
320
|
+
current.live &&
|
|
321
|
+
isSessionAuthorityEligible(current) &&
|
|
322
|
+
!current.terminalUncertain &&
|
|
323
|
+
current.endpointGeneration === expected.endpointGeneration &&
|
|
324
|
+
current.pid === expected.pid &&
|
|
325
|
+
current.endpointMtimeMs === expected.endpointMtimeMs
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
type AdoptedSession = {
|
|
330
|
+
readonly generation: number;
|
|
331
|
+
readonly pid: number;
|
|
332
|
+
readonly endpointMtimeMs: number;
|
|
333
|
+
readonly attachment: SessionAttachment;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Broker-index-backed SDK attachment authority. Providers receive only opaque
|
|
338
|
+
* attachment capabilities; endpoint records and SDK clients remain here.
|
|
339
|
+
*/
|
|
340
|
+
export class SessionRouter {
|
|
341
|
+
readonly #agentDir: string;
|
|
342
|
+
readonly #deps: SessionRouterDeps;
|
|
343
|
+
readonly #correlateFrame: SessionRouterFrameCorrelator;
|
|
344
|
+
readonly #index: SessionIndex;
|
|
345
|
+
readonly #sessions = new Map<string, AttachedSession>();
|
|
346
|
+
readonly #adopted = new Map<string, AdoptedSession>();
|
|
347
|
+
readonly #retirements = new Map<string, Promise<void>>();
|
|
348
|
+
readonly #retirementVersions = new Map<string, number>();
|
|
349
|
+
readonly #pending = new Set<Promise<void>>();
|
|
350
|
+
readonly #frameTails = new Map<string, Promise<void>>();
|
|
351
|
+
readonly #undelivered = new Map<string, { generation: number; seq: number; attempts: number }>();
|
|
352
|
+
readonly #recoveredFrames = new Map<
|
|
353
|
+
string,
|
|
354
|
+
{ generation: number; frames: Array<{ seq: number; frame: Record<string, unknown> }> }
|
|
355
|
+
>();
|
|
356
|
+
readonly #reviving = new Set<string>();
|
|
357
|
+
readonly #notificationReceipts = new Map<string, NotificationCleanupReceipt>();
|
|
358
|
+
#stopTimer: (() => void) | undefined;
|
|
359
|
+
#reconcileTail: Promise<void> = Promise.resolve();
|
|
360
|
+
#reconcilePending: { readonly runEpoch: number } | undefined;
|
|
361
|
+
#ready = false;
|
|
362
|
+
#started = false;
|
|
363
|
+
#stopController = new AbortController();
|
|
364
|
+
#runEpoch = 0;
|
|
365
|
+
|
|
366
|
+
constructor(options: SessionRouterOptions) {
|
|
367
|
+
this.#agentDir = options.agentDir;
|
|
368
|
+
this.#deps = options.deps ?? {};
|
|
369
|
+
this.#correlateFrame = options.correlateFrame ?? fallbackCorrelation;
|
|
370
|
+
this.#index = this.#deps.createIndex?.(options.agentDir) ?? new DefaultSessionIndex(options.agentDir);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Provider-local cleanup outcomes; core authority never depends on these. */
|
|
374
|
+
notificationCleanupReceipts(): NotificationCleanupReceipt[] {
|
|
375
|
+
return [...this.#notificationReceipts.values()].map(receipt => ({ ...receipt }));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
isReady(): boolean {
|
|
379
|
+
return this.#ready;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Starts reconciliation and the index watcher. */
|
|
383
|
+
async start(): Promise<void> {
|
|
384
|
+
if (this.#started) return;
|
|
385
|
+
this.#started = true;
|
|
386
|
+
const runEpoch = ++this.#runEpoch;
|
|
387
|
+
if (this.#stopController.signal.aborted) {
|
|
388
|
+
this.#stopController = new AbortController();
|
|
389
|
+
this.#reconcileTail = Promise.resolve();
|
|
390
|
+
this.#reconcilePending = undefined;
|
|
391
|
+
this.#frameTails.clear();
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
await this.#serialReconcile(runEpoch);
|
|
395
|
+
if (!this.#running(runEpoch)) return;
|
|
396
|
+
const timer = (this.#deps.setInterval ?? setInterval)(
|
|
397
|
+
() => this.#schedule(this.#serialReconcile(runEpoch, true)),
|
|
398
|
+
2_000,
|
|
399
|
+
);
|
|
400
|
+
this.#stopTimer = () => (this.#deps.clearInterval ?? clearInterval)(timer);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if (this.#running(runEpoch)) await this.stop();
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Exposed for deterministic callers and reconciliation tests. */
|
|
408
|
+
async reconcile(): Promise<void> {
|
|
409
|
+
await this.#serialReconcile(this.#runEpoch, true);
|
|
410
|
+
// Periodic reconciliation may have published an attachment while its
|
|
411
|
+
// initial replay continues on that attachment's isolated ready tail.
|
|
412
|
+
// Explicit callers retain the historical synchronous contract without
|
|
413
|
+
// putting any ready tail back onto the fleet-wide reconcile tail.
|
|
414
|
+
await Promise.all([...this.#sessions.values()].map(attached => attached.readyTail));
|
|
415
|
+
}
|
|
416
|
+
/** Ingests a credential-bearing Broker lifecycle result directly into Router custody. */
|
|
417
|
+
async adoptLifecycleResult(
|
|
418
|
+
value: unknown,
|
|
419
|
+
fallback: { sessionId: string; cwd: string },
|
|
420
|
+
): Promise<SessionAttachment> {
|
|
421
|
+
const outer =
|
|
422
|
+
value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
423
|
+
const result =
|
|
424
|
+
outer.result !== null && typeof outer.result === "object" && !Array.isArray(outer.result)
|
|
425
|
+
? (outer.result as Record<string, unknown>)
|
|
426
|
+
: outer;
|
|
427
|
+
const endpointValue = result.endpoint;
|
|
428
|
+
const endpointRecord =
|
|
429
|
+
endpointValue !== null && typeof endpointValue === "object" && !Array.isArray(endpointValue)
|
|
430
|
+
? (endpointValue as Record<string, unknown>)
|
|
431
|
+
: result;
|
|
432
|
+
const sessionId = typeof result.sessionId === "string" ? result.sessionId : undefined;
|
|
433
|
+
const endpointGeneration = readPositiveInteger(result.endpointGeneration);
|
|
434
|
+
const pid = readPositiveInteger(result.pid);
|
|
435
|
+
const endpointMtimeMs = readEndpointMtime(result.endpointMtimeMs);
|
|
436
|
+
if (
|
|
437
|
+
sessionId !== fallback.sessionId ||
|
|
438
|
+
endpointGeneration === undefined ||
|
|
439
|
+
pid === undefined ||
|
|
440
|
+
endpointMtimeMs === undefined ||
|
|
441
|
+
endpointRecord.sessionId !== sessionId ||
|
|
442
|
+
endpointRecord.pid !== pid ||
|
|
443
|
+
typeof endpointRecord.url !== "string" ||
|
|
444
|
+
typeof endpointRecord.token !== "string"
|
|
445
|
+
)
|
|
446
|
+
throw new SessionRouterError(
|
|
447
|
+
"pre_send",
|
|
448
|
+
"Broker lifecycle result omitted an exact session endpoint authority.",
|
|
449
|
+
);
|
|
450
|
+
const repo = path.resolve(fallback.cwd);
|
|
451
|
+
const stateRoot = path.join(repo, ".gjc", "state");
|
|
452
|
+
const indexed: IndexedSession = {
|
|
453
|
+
sessionId,
|
|
454
|
+
locator: { repo, stateRoot },
|
|
455
|
+
endpointGeneration,
|
|
456
|
+
pid,
|
|
457
|
+
endpointMtimeMs,
|
|
458
|
+
live: true,
|
|
459
|
+
indexSeq: 0,
|
|
460
|
+
identityProvenance: "legacy",
|
|
461
|
+
ambiguous: false,
|
|
462
|
+
terminal: false,
|
|
463
|
+
};
|
|
464
|
+
const endpoint: SdkSessionEndpoint = {
|
|
465
|
+
sessionId,
|
|
466
|
+
url: endpointRecord.url,
|
|
467
|
+
token: endpointRecord.token,
|
|
468
|
+
pid,
|
|
469
|
+
path: path.join(stateRoot, "sdk", `${sessionId}.json`),
|
|
470
|
+
};
|
|
471
|
+
const attached = await this.#attach(indexed, this.#runEpoch, endpoint, true, true);
|
|
472
|
+
const current = this.#sessions.get(sessionId);
|
|
473
|
+
const capability = current?.capability;
|
|
474
|
+
if (!attached || !current || !capability)
|
|
475
|
+
throw new SessionRouterError("pre_send", "Broker session endpoint could not be attached.");
|
|
476
|
+
const listing = this.#index.listSessions();
|
|
477
|
+
const indexedCurrent =
|
|
478
|
+
listing.warnings.length === 0 ? listing.sessions.find(item => item.sessionId === sessionId) : undefined;
|
|
479
|
+
if (indexedCurrent && sameIndexedAuthority(indexed, indexedCurrent))
|
|
480
|
+
await this.#serialReconcile(this.#runEpoch, true);
|
|
481
|
+
return capability;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async stop(): Promise<void> {
|
|
485
|
+
if (this.#stopTimer) this.#stopTimer();
|
|
486
|
+
this.#stopTimer = undefined;
|
|
487
|
+
this.#started = false;
|
|
488
|
+
this.#runEpoch += 1;
|
|
489
|
+
this.#stopController.abort();
|
|
490
|
+
this.#ready = false;
|
|
491
|
+
const shutdownTasks: Promise<void>[] = [];
|
|
492
|
+
for (const [sessionId, attached] of this.#sessions) {
|
|
493
|
+
this.#sessions.delete(sessionId);
|
|
494
|
+
attached.dispose();
|
|
495
|
+
this.#detachNotification(attached, "removed");
|
|
496
|
+
shutdownTasks.push((async () => await attached.client.close())());
|
|
497
|
+
void Promise.resolve(this.#deps.onSessionRemoved?.(attached.capability)).catch(error =>
|
|
498
|
+
logger.warn(`SDK provider cleanup failed during router stop: ${String(error)}`),
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
this.#adopted.clear();
|
|
502
|
+
// Provider notification work is detached and bounded independently; core
|
|
503
|
+
// shutdown waits only for Router reconciliation and client close.
|
|
504
|
+
const pending = Promise.allSettled([this.#reconcileTail, ...shutdownTasks]);
|
|
505
|
+
const outcome = await Promise.race([
|
|
506
|
+
pending.then(results => ({ kind: "settled" as const, results })),
|
|
507
|
+
Bun.sleep(5_000).then(() => ({ kind: "timeout" as const })),
|
|
508
|
+
]);
|
|
509
|
+
if (outcome.kind === "timeout") {
|
|
510
|
+
logger.warn(
|
|
511
|
+
"SessionRouter shutdown exceeded 5000ms; authority is revoked and cleanup continues in background.",
|
|
512
|
+
);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const errors = outcome.results
|
|
516
|
+
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
|
|
517
|
+
.map(result => result.reason);
|
|
518
|
+
if (errors.length > 0) throw new AggregateError(errors, "SessionRouter shutdown failed.");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Returns an opaque lease only while the exact attachment generation is live. */
|
|
522
|
+
attachment(sessionId: string, expectedGeneration?: number): SessionAttachment | null {
|
|
523
|
+
const attached = this.#sessions.get(sessionId);
|
|
524
|
+
if (!attached || !this.#attachmentPublished(attached)) return null;
|
|
525
|
+
if (expectedGeneration !== undefined && expectedGeneration !== attached.generation) return null;
|
|
526
|
+
return attached.capability;
|
|
527
|
+
}
|
|
528
|
+
#prepareFrame(attached: AttachedSession, frame: Record<string, unknown>): Record<string, unknown> {
|
|
529
|
+
// The native session server authorizes these client-message types with
|
|
530
|
+
// the per-session endpoint token and silently drops frames whose token
|
|
531
|
+
// is missing or wrong. Providers only hold opaque capabilities (the
|
|
532
|
+
// endpoint record lives here), so the router must stamp the token —
|
|
533
|
+
// omitting it made every daemon-origin injection (Telegram → session)
|
|
534
|
+
// vanish after the daemon had already ACKed the user's message.
|
|
535
|
+
const withToken =
|
|
536
|
+
typeof frame.type === "string" && TOKEN_AUTHORIZED_FRAME_TYPES.has(frame.type) && frame.token === undefined
|
|
537
|
+
? { ...frame, token: attached.endpoint.token }
|
|
538
|
+
: frame;
|
|
539
|
+
const connectionId = attached.client.connectionId;
|
|
540
|
+
if (connectionId === undefined) return withToken;
|
|
541
|
+
if (withToken.connectionId !== undefined && withToken.connectionId !== connectionId)
|
|
542
|
+
throw new SessionRouterError("pre_send", "SDK session transport identity changed before command dispatch.");
|
|
543
|
+
return { ...withToken, connectionId };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Sends an SDK command through the current attachment without exposing its client. */
|
|
547
|
+
async request(
|
|
548
|
+
sessionId: string,
|
|
549
|
+
frame: Record<string, unknown>,
|
|
550
|
+
expectedGeneration?: number,
|
|
551
|
+
expectedAttachment?: SessionAttachment,
|
|
552
|
+
options?: { timeoutMs?: number },
|
|
553
|
+
): Promise<Record<string, unknown>> {
|
|
554
|
+
const publishing = this.#sessions.get(sessionId);
|
|
555
|
+
if (!expectedAttachment || publishing?.capability !== expectedAttachment || !publishing.initializingPublication)
|
|
556
|
+
await this.#serialReconcile(this.#runEpoch, true);
|
|
557
|
+
const attached = this.#sessions.get(sessionId);
|
|
558
|
+
if (!attached || !this.#attachmentPublished(attached))
|
|
559
|
+
throw new SessionRouterError("pre_send", "SDK session attachment is unavailable: session not published.");
|
|
560
|
+
if (expectedGeneration !== undefined && expectedGeneration !== attached.generation)
|
|
561
|
+
throw new SessionRouterError("pre_send", "SDK session endpoint changed before command dispatch.");
|
|
562
|
+
if (expectedAttachment !== undefined && attached.capability !== expectedAttachment)
|
|
563
|
+
throw new SessionRouterError("pre_send", "SDK session attachment changed before command dispatch.");
|
|
564
|
+
if (attached.initializingPublication) {
|
|
565
|
+
const endpoint = await this.#readEndpoint(attached.indexed);
|
|
566
|
+
if (
|
|
567
|
+
!endpoint ||
|
|
568
|
+
endpoint.url !== attached.endpoint.url ||
|
|
569
|
+
endpoint.token !== attached.endpoint.token ||
|
|
570
|
+
endpoint.pid !== attached.pid
|
|
571
|
+
) {
|
|
572
|
+
await this.#retireAttachment(attached, endpoint ? "replaced_same_generation" : undefined);
|
|
573
|
+
throw new SessionRouterError("pre_send", "SDK session attachment changed during publication.");
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// A caller that sized its own budget keeps it; everything else gets the
|
|
577
|
+
// long-lived session budget instead of the transport's one-shot default,
|
|
578
|
+
// which a cold host's first credential-collecting query outruns (#4258).
|
|
579
|
+
const response = await attached.client.request(this.#prepareFrame(attached, frame), {
|
|
580
|
+
...options,
|
|
581
|
+
timeoutMs: options?.timeoutMs ?? SESSION_REQUEST_TIMEOUT_MS,
|
|
582
|
+
});
|
|
583
|
+
if (
|
|
584
|
+
!this.#attachmentPublished(attached) ||
|
|
585
|
+
(expectedGeneration !== undefined && attached.generation !== expectedGeneration) ||
|
|
586
|
+
(expectedAttachment !== undefined && attached.capability !== expectedAttachment)
|
|
587
|
+
)
|
|
588
|
+
throw new SessionRouterError("ambiguous", "SDK session attachment changed while awaiting command response.");
|
|
589
|
+
return response;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Resolves the exact provider-neutral binding authority for operator adoption. */
|
|
593
|
+
async bindingAuthority(sessionId: string): Promise<{ sessionId: string; endpointGeneration: number } | undefined> {
|
|
594
|
+
const attached = this.#sessions.get(sessionId);
|
|
595
|
+
if (!attached || !this.#attachmentPublished(attached)) return undefined;
|
|
596
|
+
let indexed: IndexedSession | undefined;
|
|
597
|
+
try {
|
|
598
|
+
await this.#index.refresh();
|
|
599
|
+
const listing = this.#index.listSessions();
|
|
600
|
+
if (listing.warnings.length > 0) return undefined;
|
|
601
|
+
indexed = listing.sessions.find(candidate => candidate.sessionId === sessionId);
|
|
602
|
+
} catch {
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
if (!indexed?.live || !isSessionAuthorityEligible(indexed) || indexed.terminalUncertain) return undefined;
|
|
606
|
+
if (
|
|
607
|
+
!Number.isSafeInteger(indexed.endpointGeneration) ||
|
|
608
|
+
indexed.endpointGeneration <= 0 ||
|
|
609
|
+
indexed.endpointGeneration !== attached.generation ||
|
|
610
|
+
indexed.endpointMtimeMs === undefined
|
|
611
|
+
)
|
|
612
|
+
return undefined;
|
|
613
|
+
if (!Number.isSafeInteger(indexed.pid) || indexed.pid <= 0) return undefined;
|
|
614
|
+
const endpoint = await this.#readEndpoint(indexed).catch(() => null);
|
|
615
|
+
if (!endpoint || endpoint.stale === true || endpoint.pid !== indexed.pid || !endpoint.token) return undefined;
|
|
616
|
+
if (this.#sessions.get(sessionId) !== attached || !this.#attachmentPublished(attached)) return undefined;
|
|
617
|
+
return { sessionId, endpointGeneration: attached.generation };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Activates a prepared session through one Router-owned, one-shot SDK client. */
|
|
621
|
+
async activatePreparedSession(sessionId: string): Promise<ActivatedPreparedSession> {
|
|
622
|
+
let indexed: IndexedSession | undefined;
|
|
623
|
+
try {
|
|
624
|
+
await this.#index.open();
|
|
625
|
+
await this.#index.refresh();
|
|
626
|
+
const listing = this.#index.listSessions();
|
|
627
|
+
if (listing.warnings.length > 0)
|
|
628
|
+
throw new SessionActivationError(
|
|
629
|
+
"session_not_live",
|
|
630
|
+
"Session activation requires an intact session index.",
|
|
631
|
+
);
|
|
632
|
+
indexed = listing.sessions.find(candidate => candidate.sessionId === sessionId);
|
|
633
|
+
} catch (error) {
|
|
634
|
+
if (error instanceof SessionActivationError) throw error;
|
|
635
|
+
throw new SessionActivationError(
|
|
636
|
+
"session_not_live",
|
|
637
|
+
"Session activation requires an exact live session endpoint.",
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
if (
|
|
641
|
+
!indexed?.live ||
|
|
642
|
+
!isSessionAuthorityEligible(indexed) ||
|
|
643
|
+
indexed.terminalUncertain ||
|
|
644
|
+
!Number.isSafeInteger(indexed.endpointGeneration) ||
|
|
645
|
+
indexed.endpointGeneration <= 0 ||
|
|
646
|
+
!Number.isSafeInteger(indexed.pid) ||
|
|
647
|
+
indexed.pid <= 0 ||
|
|
648
|
+
typeof indexed.endpointMtimeMs !== "number" ||
|
|
649
|
+
!Number.isFinite(indexed.endpointMtimeMs) ||
|
|
650
|
+
indexed.endpointMtimeMs <= 0
|
|
651
|
+
)
|
|
652
|
+
throw new SessionActivationError(
|
|
653
|
+
"session_not_live",
|
|
654
|
+
"Session activation requires an exact live session endpoint.",
|
|
655
|
+
);
|
|
656
|
+
const endpoint = await this.#readEndpoint(indexed).catch(() => null);
|
|
657
|
+
if (!endpoint || endpoint.stale === true || !endpoint.url || !endpoint.token || endpoint.pid !== indexed.pid)
|
|
658
|
+
throw new SessionActivationError(
|
|
659
|
+
"session_not_live",
|
|
660
|
+
"Session activation requires a readable session discovery endpoint.",
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
let client: PreparedSessionActivationClient;
|
|
664
|
+
try {
|
|
665
|
+
client = await (this.#deps.createClient
|
|
666
|
+
? this.#deps.createClient({
|
|
667
|
+
sessionId: indexed.sessionId,
|
|
668
|
+
generation: indexed.endpointGeneration,
|
|
669
|
+
pid: indexed.pid,
|
|
670
|
+
endpointMtimeMs: indexed.endpointMtimeMs,
|
|
671
|
+
})
|
|
672
|
+
: connectPreparedSession(endpoint));
|
|
673
|
+
} catch {
|
|
674
|
+
throw new SessionActivationError("activation_unavailable", "The session endpoint could not be reached.");
|
|
675
|
+
}
|
|
676
|
+
try {
|
|
677
|
+
const currentEndpoint = await this.#readEndpoint(indexed).catch(() => null);
|
|
678
|
+
if (
|
|
679
|
+
!currentEndpoint ||
|
|
680
|
+
currentEndpoint.url !== endpoint.url ||
|
|
681
|
+
currentEndpoint.token !== endpoint.token ||
|
|
682
|
+
currentEndpoint.pid !== endpoint.pid
|
|
683
|
+
)
|
|
684
|
+
throw new SessionActivationError(
|
|
685
|
+
"session_not_live",
|
|
686
|
+
"The session endpoint changed before activation could be dispatched.",
|
|
687
|
+
);
|
|
688
|
+
return await requestPreparedSessionActivation(client, sessionId, indexed.endpointGeneration);
|
|
689
|
+
} finally {
|
|
690
|
+
await client.close().catch(() => undefined);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Lists saved sessions through Router-owned Broker discovery without exposing credentials or mutation authority. */
|
|
695
|
+
async listBrokerSessions(input: Record<string, unknown>, idempotencyKey: string): Promise<Record<string, unknown>> {
|
|
696
|
+
const operation = "session.list";
|
|
697
|
+
const discovery = await readSdkBrokerDiscovery(this.#agentDir);
|
|
698
|
+
if (!discovery) throw new SessionRouterError("pre_send", "SDK broker discovery is unavailable.");
|
|
699
|
+
let client: SessionRouterClient;
|
|
700
|
+
try {
|
|
701
|
+
client = this.#deps.createBrokerClient
|
|
702
|
+
? await this.#deps.createBrokerClient()
|
|
703
|
+
: await SdkClient.connect(discovery.url, discovery.token);
|
|
704
|
+
} catch {
|
|
705
|
+
throw new SessionRouterError("pre_send", "SDK broker connection failed.");
|
|
706
|
+
}
|
|
707
|
+
try {
|
|
708
|
+
const timeoutMs = lifecycleRequestTimeoutMs(operation, input);
|
|
709
|
+
return await client.request(
|
|
710
|
+
{ type: "broker_request", operation, input, idempotencyKey },
|
|
711
|
+
timeoutMs === undefined ? undefined : { timeoutMs },
|
|
712
|
+
);
|
|
713
|
+
} finally {
|
|
714
|
+
await client.close().catch(error => {
|
|
715
|
+
logger.warn(`SDK Broker session.list transport cleanup failed (${String(error)}).`);
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
#serialReconcile(runEpoch: number, deferReplay = false): Promise<void> {
|
|
721
|
+
if (!this.#running(runEpoch)) return Promise.resolve();
|
|
722
|
+
const pending = this.#reconcilePending;
|
|
723
|
+
if (pending?.runEpoch === runEpoch) return this.#reconcileTail;
|
|
724
|
+
const queued = { runEpoch };
|
|
725
|
+
this.#reconcilePending = queued;
|
|
726
|
+
const task = this.#reconcileTail
|
|
727
|
+
.catch(() => undefined)
|
|
728
|
+
.then(async () => {
|
|
729
|
+
if (this.#reconcilePending === queued) this.#reconcilePending = undefined;
|
|
730
|
+
try {
|
|
731
|
+
await this.#reconcile(runEpoch, deferReplay);
|
|
732
|
+
if (!this.#running(runEpoch)) return;
|
|
733
|
+
this.#ready = true;
|
|
734
|
+
this.#deps.onReconciled?.();
|
|
735
|
+
} catch (error) {
|
|
736
|
+
if (this.#running(runEpoch)) this.#ready = false;
|
|
737
|
+
throw error;
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
this.#reconcileTail = task;
|
|
741
|
+
return task;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async #reconcile(runEpoch: number, deferReplay = false): Promise<void> {
|
|
745
|
+
if (!this.#running(runEpoch)) return;
|
|
746
|
+
await this.#index.open();
|
|
747
|
+
if (!this.#running(runEpoch)) return;
|
|
748
|
+
await this.#index.refresh();
|
|
749
|
+
if (!this.#running(runEpoch)) return;
|
|
750
|
+
const indexed = this.#index.listSessions();
|
|
751
|
+
const live =
|
|
752
|
+
indexed.warnings.length === 0
|
|
753
|
+
? indexed.sessions.filter(
|
|
754
|
+
session => session.live && isSessionAuthorityEligible(session) && !session.terminalUncertain,
|
|
755
|
+
)
|
|
756
|
+
: [];
|
|
757
|
+
const liveIds = new Set(live.map(session => session.sessionId));
|
|
758
|
+
const attachedIds = new Set<string>();
|
|
759
|
+
if (indexed.warnings.length === 0) {
|
|
760
|
+
for (const [sessionId, adopted] of [...this.#adopted]) {
|
|
761
|
+
const attached = this.#sessions.get(sessionId);
|
|
762
|
+
const indexedSession = indexed.sessions.find(session => session.sessionId === sessionId);
|
|
763
|
+
if (!attached || attached.capability !== adopted.attachment) {
|
|
764
|
+
this.#adopted.delete(sessionId);
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
const exactIndex =
|
|
768
|
+
indexedSession?.live === true &&
|
|
769
|
+
isSessionAuthorityEligible(indexedSession) &&
|
|
770
|
+
!indexedSession.terminalUncertain &&
|
|
771
|
+
indexedSession.endpointGeneration === adopted.generation &&
|
|
772
|
+
indexedSession.pid === adopted.pid &&
|
|
773
|
+
indexedSession.endpointMtimeMs === adopted.endpointMtimeMs;
|
|
774
|
+
const endpoint = exactIndex ? await this.#readEndpoint(indexedSession).catch(() => null) : null;
|
|
775
|
+
if (
|
|
776
|
+
!exactIndex ||
|
|
777
|
+
!endpoint ||
|
|
778
|
+
endpoint.pid !== adopted.pid ||
|
|
779
|
+
endpoint.url !== attached.endpoint.url ||
|
|
780
|
+
endpoint.token !== attached.endpoint.token
|
|
781
|
+
) {
|
|
782
|
+
this.#adopted.delete(sessionId);
|
|
783
|
+
await this.#retireAttachment(attached);
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
try {
|
|
787
|
+
if (await this.#publishAttachment(attached, true)) {
|
|
788
|
+
this.#adopted.delete(sessionId);
|
|
789
|
+
attachedIds.add(sessionId);
|
|
790
|
+
}
|
|
791
|
+
} catch {
|
|
792
|
+
this.#adopted.delete(sessionId);
|
|
793
|
+
await this.#retireAttachment(attached);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
let nextAttachment = 0;
|
|
798
|
+
const attachWorkers = Array.from({ length: Math.min(ATTACH_CONCURRENCY, live.length) }, async () => {
|
|
799
|
+
for (;;) {
|
|
800
|
+
if (!this.#running(runEpoch)) return;
|
|
801
|
+
const session = live[nextAttachment++];
|
|
802
|
+
if (!session) return;
|
|
803
|
+
try {
|
|
804
|
+
if (await this.#attach(session, runEpoch, undefined, false, false, deferReplay))
|
|
805
|
+
attachedIds.add(session.sessionId);
|
|
806
|
+
} catch {
|
|
807
|
+
const failed = this.#sessions.get(session.sessionId);
|
|
808
|
+
if (failed?.runEpoch === runEpoch)
|
|
809
|
+
await this.#retireAttachment(failed, liveIds.has(session.sessionId) ? "replaced" : "removed");
|
|
810
|
+
if (this.#running(runEpoch))
|
|
811
|
+
logger.warn(
|
|
812
|
+
`SDK session attachment failed for indexed session ${session.sessionId} at generation ${session.endpointGeneration}; the endpoint remains unauthorized.`,
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
await Promise.all(attachWorkers);
|
|
818
|
+
if (!this.#running(runEpoch)) return;
|
|
819
|
+
const cleanupErrors: unknown[] = [];
|
|
820
|
+
for (const [sessionId, attached] of [...this.#sessions]) {
|
|
821
|
+
if (attachedIds.has(sessionId)) continue;
|
|
822
|
+
if (!this.#running(runEpoch) || this.#sessions.get(sessionId) !== attached) continue;
|
|
823
|
+
if (!liveIds.has(sessionId)) {
|
|
824
|
+
this.#undelivered.delete(sessionId);
|
|
825
|
+
this.#recoveredFrames.delete(sessionId);
|
|
826
|
+
}
|
|
827
|
+
try {
|
|
828
|
+
await this.#retireAttachment(attached, liveIds.has(sessionId) ? "replaced" : "removed");
|
|
829
|
+
} catch (error) {
|
|
830
|
+
cleanupErrors.push(error);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, "SessionRouter stale cleanup failed.");
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async #readEndpoint(indexed: IndexedSession): Promise<SdkSessionEndpoint | null> {
|
|
837
|
+
if (!isSessionAuthorityEligible(indexed)) return null;
|
|
838
|
+
const repo = path.resolve(indexed.locator.repo);
|
|
839
|
+
const defaultStateRoot = path.join(repo, ".gjc", "state");
|
|
840
|
+
const indexedStateRoot = path.resolve(indexed.locator.stateRoot);
|
|
841
|
+
const scope =
|
|
842
|
+
indexedStateRoot === defaultStateRoot
|
|
843
|
+
? "default"
|
|
844
|
+
: indexedStateRoot === path.join(defaultStateRoot, "chat")
|
|
845
|
+
? "chat"
|
|
846
|
+
: undefined;
|
|
847
|
+
if (!scope || indexed.endpointMtimeMs === undefined || !Number.isFinite(indexed.endpointMtimeMs)) return null;
|
|
848
|
+
const endpoint = await readSdkSessionEndpoint(repo, indexed.sessionId, scope);
|
|
849
|
+
if (!endpoint || endpoint.stale || endpoint.pid !== indexed.pid) return null;
|
|
850
|
+
const endpointStat = await fs.stat(endpoint.path).catch(() => undefined);
|
|
851
|
+
if (!endpointStat || endpointStat.mtimeMs !== indexed.endpointMtimeMs) return null;
|
|
852
|
+
let raw: Record<string, unknown>;
|
|
853
|
+
try {
|
|
854
|
+
const parsed = JSON.parse(await Bun.file(endpoint.path).text());
|
|
855
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
856
|
+
raw = parsed as Record<string, unknown>;
|
|
857
|
+
} catch {
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
if (
|
|
861
|
+
raw.sessionId !== indexed.sessionId ||
|
|
862
|
+
raw.pid !== indexed.pid ||
|
|
863
|
+
raw.stale === true ||
|
|
864
|
+
raw.url !== endpoint.url ||
|
|
865
|
+
raw.token !== endpoint.token
|
|
866
|
+
)
|
|
867
|
+
return null;
|
|
868
|
+
const endpointStatAfterRead = await fs.stat(endpoint.path).catch(() => undefined);
|
|
869
|
+
if (!endpointStatAfterRead || endpointStatAfterRead.mtimeMs !== indexed.endpointMtimeMs) return null;
|
|
870
|
+
await this.#index.refresh();
|
|
871
|
+
const listing = this.#index.listSessions();
|
|
872
|
+
if (listing.warnings.length > 0) return null;
|
|
873
|
+
const current = listing.sessions.find(session => session.sessionId === indexed.sessionId);
|
|
874
|
+
if (!current || !sameIndexedAuthority(indexed, current)) return null;
|
|
875
|
+
return endpoint;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async #createAttachedClient(
|
|
879
|
+
indexed: IndexedSession,
|
|
880
|
+
endpoint: SdkSessionEndpoint,
|
|
881
|
+
runEpoch: number,
|
|
882
|
+
): Promise<SessionRouterClient | undefined> {
|
|
883
|
+
const endpointMtimeMs = indexed.endpointMtimeMs;
|
|
884
|
+
if (endpointMtimeMs === undefined) return undefined;
|
|
885
|
+
const createClient = this.#deps.createClient;
|
|
886
|
+
let transport: SdkClient | undefined;
|
|
887
|
+
let connection: Promise<SessionRouterClient>;
|
|
888
|
+
if (createClient) {
|
|
889
|
+
connection = createClient({
|
|
890
|
+
sessionId: indexed.sessionId,
|
|
891
|
+
generation: indexed.endpointGeneration,
|
|
892
|
+
pid: indexed.pid,
|
|
893
|
+
endpointMtimeMs,
|
|
894
|
+
});
|
|
895
|
+
} else {
|
|
896
|
+
const defaultClient = new SdkClient(endpoint.url, endpoint.token, { ...ACP_SESSION_RECONNECT });
|
|
897
|
+
transport = defaultClient;
|
|
898
|
+
connection = defaultClient.connect().then(() => defaultClient);
|
|
899
|
+
}
|
|
900
|
+
const stopped = Promise.withResolvers<void>();
|
|
901
|
+
const timeout = Promise.withResolvers<void>();
|
|
902
|
+
const signal = this.#stopController.signal;
|
|
903
|
+
const onStop = (): void => stopped.resolve();
|
|
904
|
+
if (!this.#running(runEpoch) || signal.aborted) onStop();
|
|
905
|
+
else signal.addEventListener("abort", onStop, { once: true });
|
|
906
|
+
const timer = (this.#deps.setTimeout ?? setTimeout)(() => timeout.resolve(), ATTACH_CONNECT_TIMEOUT_MS);
|
|
907
|
+
timer.unref?.();
|
|
908
|
+
try {
|
|
909
|
+
const outcome = await Promise.race([
|
|
910
|
+
connection.then(client => ({ kind: "client" as const, client })),
|
|
911
|
+
stopped.promise.then(() => ({ kind: "stopped" as const })),
|
|
912
|
+
timeout.promise.then(() => ({ kind: "timeout" as const })),
|
|
913
|
+
]);
|
|
914
|
+
if (outcome.kind === "client") return outcome.client;
|
|
915
|
+
if (transport) void transport.close().catch(() => undefined);
|
|
916
|
+
void connection.then(client => client.close().catch(() => undefined)).catch(() => undefined);
|
|
917
|
+
if (outcome.kind === "stopped") return undefined;
|
|
918
|
+
throw new SessionRouterError("pre_send", "SDK session attachment connection timed out.");
|
|
919
|
+
} finally {
|
|
920
|
+
(this.#deps.clearTimeout ?? clearTimeout)(timer);
|
|
921
|
+
signal.removeEventListener("abort", onStop);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async #attach(
|
|
926
|
+
indexed: IndexedSession,
|
|
927
|
+
runEpoch: number,
|
|
928
|
+
resolvedEndpoint?: SdkSessionEndpoint,
|
|
929
|
+
skipReplay = false,
|
|
930
|
+
deferPublication = false,
|
|
931
|
+
deferReplay = false,
|
|
932
|
+
): Promise<boolean> {
|
|
933
|
+
const retirementVersion = this.#retirementVersions.get(indexed.sessionId) ?? 0;
|
|
934
|
+
const retirement = this.#retirements.get(indexed.sessionId);
|
|
935
|
+
if (retirement) await retirement;
|
|
936
|
+
if (!this.#running(runEpoch)) return false;
|
|
937
|
+
if (indexed.endpointMtimeMs === undefined) return false;
|
|
938
|
+
const endpoint = resolvedEndpoint ?? (await this.#readEndpoint(indexed));
|
|
939
|
+
const retirementAfterValidation = this.#retirements.get(indexed.sessionId);
|
|
940
|
+
if (retirementAfterValidation) await retirementAfterValidation;
|
|
941
|
+
if ((this.#retirementVersions.get(indexed.sessionId) ?? 0) !== retirementVersion)
|
|
942
|
+
return await this.#attach(indexed, runEpoch, undefined, skipReplay, deferPublication, deferReplay);
|
|
943
|
+
if (!this.#running(runEpoch)) return false;
|
|
944
|
+
if (!endpoint) return false;
|
|
945
|
+
const existing = this.#sessions.get(indexed.sessionId);
|
|
946
|
+
const resumable =
|
|
947
|
+
existing !== undefined &&
|
|
948
|
+
existing.endpoint.url === endpoint.url &&
|
|
949
|
+
existing.endpoint.token === endpoint.token &&
|
|
950
|
+
existing.generation === indexed.endpointGeneration &&
|
|
951
|
+
existing.pid === indexed.pid &&
|
|
952
|
+
existing.endpointMtimeMs === indexed.endpointMtimeMs;
|
|
953
|
+
if (existing && resumable && !existing.barrier.failed) {
|
|
954
|
+
this.#reviveTransport(existing);
|
|
955
|
+
return true;
|
|
956
|
+
}
|
|
957
|
+
const resumeSeq = existing && resumable ? existing.cursor.seq : 0;
|
|
958
|
+
if (existing) {
|
|
959
|
+
this.#adopted.delete(indexed.sessionId);
|
|
960
|
+
|
|
961
|
+
this.#sessions.delete(indexed.sessionId);
|
|
962
|
+
existing.dispose();
|
|
963
|
+
this.#detachNotification(
|
|
964
|
+
existing,
|
|
965
|
+
existing.generation === indexed.endpointGeneration ? "replaced_same_generation" : "replaced",
|
|
966
|
+
);
|
|
967
|
+
try {
|
|
968
|
+
await existing.client.close();
|
|
969
|
+
} catch (error) {
|
|
970
|
+
logger.warn(
|
|
971
|
+
`SDK session replacement transport cleanup failed for ${indexed.sessionId}; authority remains revoked (${String(error)}).`,
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
void Promise.resolve(
|
|
975
|
+
this.#deps.onSessionRemoved?.(
|
|
976
|
+
existing.capability,
|
|
977
|
+
existing.generation === indexed.endpointGeneration &&
|
|
978
|
+
(existing.endpoint.url !== endpoint.url ||
|
|
979
|
+
existing.endpoint.token !== endpoint.token ||
|
|
980
|
+
existing.pid !== indexed.pid ||
|
|
981
|
+
existing.endpointMtimeMs !== indexed.endpointMtimeMs)
|
|
982
|
+
? "replaced_same_generation"
|
|
983
|
+
: "replaced",
|
|
984
|
+
),
|
|
985
|
+
).catch(error => logger.warn(`SDK provider cleanup failed after replacement: ${String(error)}`));
|
|
986
|
+
if (!resumable) {
|
|
987
|
+
this.#undelivered.delete(indexed.sessionId);
|
|
988
|
+
this.#recoveredFrames.delete(indexed.sessionId);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
const client = await this.#createAttachedClient(indexed, endpoint, runEpoch);
|
|
992
|
+
if (!client) return false;
|
|
993
|
+
|
|
994
|
+
if (!this.#running(runEpoch)) {
|
|
995
|
+
await client.close().catch(() => undefined);
|
|
996
|
+
return false;
|
|
997
|
+
}
|
|
998
|
+
if ((this.#retirementVersions.get(indexed.sessionId) ?? 0) !== retirementVersion) {
|
|
999
|
+
await client.close().catch(() => undefined);
|
|
1000
|
+
const currentRetirement = this.#retirements.get(indexed.sessionId);
|
|
1001
|
+
if (currentRetirement) await currentRetirement;
|
|
1002
|
+
return await this.#attach(indexed, runEpoch, undefined, skipReplay, deferPublication, deferReplay);
|
|
1003
|
+
}
|
|
1004
|
+
let attached: AttachedSession | undefined;
|
|
1005
|
+
const barrier: ReplayBarrier = { held: undefined, detached: false, failed: false };
|
|
1006
|
+
const publication = Promise.withResolvers<void>();
|
|
1007
|
+
void publication.promise.catch(() => undefined);
|
|
1008
|
+
const capability: SessionAttachment = Object.freeze({
|
|
1009
|
+
authorityId: sessionAttachmentAuthorityId({
|
|
1010
|
+
sessionId: indexed.sessionId,
|
|
1011
|
+
generation: indexed.endpointGeneration,
|
|
1012
|
+
pid: indexed.pid,
|
|
1013
|
+
endpointMtimeMs: indexed.endpointMtimeMs,
|
|
1014
|
+
url: endpoint.url,
|
|
1015
|
+
token: endpoint.token,
|
|
1016
|
+
}),
|
|
1017
|
+
sessionId: indexed.sessionId,
|
|
1018
|
+
generation: indexed.endpointGeneration,
|
|
1019
|
+
get connectionId(): string | undefined {
|
|
1020
|
+
return attached?.client.connectionId;
|
|
1021
|
+
},
|
|
1022
|
+
isCurrent: () => attached !== undefined && this.#attachmentPublished(attached),
|
|
1023
|
+
send: async (frame: Record<string, unknown>) => {
|
|
1024
|
+
if (!attached || !this.#attachmentPublished(attached))
|
|
1025
|
+
throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
|
|
1026
|
+
if (attached.initializingPublication) {
|
|
1027
|
+
const endpoint = await this.#readEndpoint(attached.indexed);
|
|
1028
|
+
if (
|
|
1029
|
+
!endpoint ||
|
|
1030
|
+
endpoint.url !== attached.endpoint.url ||
|
|
1031
|
+
endpoint.token !== attached.endpoint.token ||
|
|
1032
|
+
endpoint.pid !== attached.pid
|
|
1033
|
+
) {
|
|
1034
|
+
await this.#retireAttachment(attached, endpoint ? "replaced_same_generation" : undefined);
|
|
1035
|
+
throw new SessionRouterError("pre_send", "SDK session attachment changed during publication.");
|
|
1036
|
+
}
|
|
1037
|
+
} else await this.#serialReconcile(runEpoch, true);
|
|
1038
|
+
if (!attached || !this.#attachmentPublished(attached))
|
|
1039
|
+
throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
|
|
1040
|
+
attached.client.send(this.#prepareFrame(attached, frame));
|
|
1041
|
+
},
|
|
1042
|
+
retire: async () => {
|
|
1043
|
+
if (attached) await this.#retireAttachment(attached);
|
|
1044
|
+
},
|
|
1045
|
+
});
|
|
1046
|
+
const notificationCursor = { generation: indexed.endpointGeneration, seq: resumeSeq };
|
|
1047
|
+
const notificationSubscription: NotificationSubscription = Object.freeze({
|
|
1048
|
+
sessionId: indexed.sessionId,
|
|
1049
|
+
subscriptionId: `notification:${indexed.sessionId}:${crypto.randomUUID()}`,
|
|
1050
|
+
cursor: notificationCursor,
|
|
1051
|
+
isActive: () => attached !== undefined && !attached.notificationCancelled && this.#attachmentLive(attached),
|
|
1052
|
+
send: (frame: Record<string, unknown>) => {
|
|
1053
|
+
if (!attached || attached.notificationCancelled || !this.#attachmentLive(attached))
|
|
1054
|
+
throw new SessionRouterError("pre_send", "Notification subscription is cancelled.");
|
|
1055
|
+
attached.client.send(this.#prepareFrame(attached, frame));
|
|
1056
|
+
},
|
|
1057
|
+
advanceCursor: (generation: number, seq: number) => {
|
|
1058
|
+
if (!Number.isSafeInteger(generation) || generation < 0 || !Number.isSafeInteger(seq) || seq < 0) return;
|
|
1059
|
+
if (
|
|
1060
|
+
generation > notificationCursor.generation ||
|
|
1061
|
+
(generation === notificationCursor.generation && seq > notificationCursor.seq)
|
|
1062
|
+
) {
|
|
1063
|
+
notificationCursor.generation = generation;
|
|
1064
|
+
notificationCursor.seq = seq;
|
|
1065
|
+
}
|
|
1066
|
+
},
|
|
1067
|
+
cancel: (reason?: string) => {
|
|
1068
|
+
if (attached) this.#detachNotification(attached, "cancelled");
|
|
1069
|
+
if (reason) this.#recordNotificationReceipt(notificationSubscription, "pending", reason);
|
|
1070
|
+
},
|
|
1071
|
+
});
|
|
1072
|
+
const disposeFrames = client.onFrame(frame => {
|
|
1073
|
+
if (!attached) return;
|
|
1074
|
+
const task =
|
|
1075
|
+
frame.type === "event_replay_result" && frame.seq === undefined
|
|
1076
|
+
? this.#deliverOutOfBandFrame(attached, frame)
|
|
1077
|
+
: this.#enqueueFrame(attached, frame, "live");
|
|
1078
|
+
this.#schedule(task);
|
|
1079
|
+
});
|
|
1080
|
+
const disposeReconnect = client.onReconnect?.(() => {
|
|
1081
|
+
if (attached) {
|
|
1082
|
+
attached.barrier.held ??= [];
|
|
1083
|
+
this.#schedule(this.#reinitializeAttachment(attached));
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
attached = {
|
|
1087
|
+
initializingPublication: false,
|
|
1088
|
+
id: crypto.randomUUID(),
|
|
1089
|
+
sessionId: indexed.sessionId,
|
|
1090
|
+
endpoint,
|
|
1091
|
+
pid: indexed.pid,
|
|
1092
|
+
endpointMtimeMs: indexed.endpointMtimeMs,
|
|
1093
|
+
generation: indexed.endpointGeneration,
|
|
1094
|
+
runEpoch,
|
|
1095
|
+
client,
|
|
1096
|
+
indexed,
|
|
1097
|
+
readyTail: Promise.resolve(),
|
|
1098
|
+
cursor: { seq: resumeSeq },
|
|
1099
|
+
barrier,
|
|
1100
|
+
capability,
|
|
1101
|
+
notificationSubscription,
|
|
1102
|
+
notificationCancelled: false,
|
|
1103
|
+
notificationCursor,
|
|
1104
|
+
published: false,
|
|
1105
|
+
publication,
|
|
1106
|
+
dispose: () => {
|
|
1107
|
+
disposeFrames();
|
|
1108
|
+
disposeReconnect?.();
|
|
1109
|
+
barrier.detached = true;
|
|
1110
|
+
if (!attached?.published)
|
|
1111
|
+
publication.reject(
|
|
1112
|
+
new SessionRouterError("pre_send", "SDK session publication was detached before completion."),
|
|
1113
|
+
);
|
|
1114
|
+
barrier.held = undefined;
|
|
1115
|
+
},
|
|
1116
|
+
};
|
|
1117
|
+
this.#sessions.set(indexed.sessionId, attached);
|
|
1118
|
+
if (deferPublication)
|
|
1119
|
+
this.#adopted.set(indexed.sessionId, {
|
|
1120
|
+
generation: indexed.endpointGeneration,
|
|
1121
|
+
pid: indexed.pid,
|
|
1122
|
+
endpointMtimeMs: indexed.endpointMtimeMs,
|
|
1123
|
+
attachment: capability,
|
|
1124
|
+
});
|
|
1125
|
+
try {
|
|
1126
|
+
await this.#deps.onAttachment?.(capability);
|
|
1127
|
+
this.#recordNotificationReceipt(notificationSubscription, "pending");
|
|
1128
|
+
void Promise.resolve()
|
|
1129
|
+
.then(() => this.#deps.onNotificationSubscription?.(notificationSubscription))
|
|
1130
|
+
.catch(error => {
|
|
1131
|
+
this.#detachNotification(attached!, "cancelled");
|
|
1132
|
+
logger.warn(`SDK notification subscription admission failed: ${String(error)}`);
|
|
1133
|
+
});
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
const failedStillCurrent = this.#sessions.get(indexed.sessionId) === attached;
|
|
1136
|
+
this.#adopted.delete(indexed.sessionId);
|
|
1137
|
+
if (failedStillCurrent) this.#sessions.delete(indexed.sessionId);
|
|
1138
|
+
attached.dispose();
|
|
1139
|
+
await attached.client.close().catch(() => undefined);
|
|
1140
|
+
if (failedStillCurrent)
|
|
1141
|
+
try {
|
|
1142
|
+
await this.#deps.onSessionRemoved?.(capability);
|
|
1143
|
+
} catch {
|
|
1144
|
+
// Attachment publication failed closed; provider cleanup remains best effort.
|
|
1145
|
+
}
|
|
1146
|
+
throw error;
|
|
1147
|
+
}
|
|
1148
|
+
if (deferPublication) return true;
|
|
1149
|
+
return await this.#publishAttachment(attached, skipReplay, deferReplay);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async #publishAttachment(attached: AttachedSession, skipReplay: boolean, deferReplay = false): Promise<boolean> {
|
|
1153
|
+
if (attached.published) return this.#attachmentPublished(attached);
|
|
1154
|
+
if (!this.#attachmentLive(attached)) return false;
|
|
1155
|
+
const endpoint = await this.#readEndpoint(attached.indexed).catch(() => null);
|
|
1156
|
+
if (!this.#attachmentLive(attached)) return false;
|
|
1157
|
+
if (
|
|
1158
|
+
!endpoint ||
|
|
1159
|
+
endpoint.url !== attached.endpoint.url ||
|
|
1160
|
+
endpoint.token !== attached.endpoint.token ||
|
|
1161
|
+
endpoint.pid !== attached.pid
|
|
1162
|
+
) {
|
|
1163
|
+
await this.#retireAttachment(attached, endpoint ? "replaced_same_generation" : undefined);
|
|
1164
|
+
return false;
|
|
1165
|
+
}
|
|
1166
|
+
if (!skipReplay) attached.barrier.held ??= [];
|
|
1167
|
+
attached.published = true;
|
|
1168
|
+
attached.publication.resolve();
|
|
1169
|
+
attached.initializingPublication = true;
|
|
1170
|
+
try {
|
|
1171
|
+
void Promise.resolve()
|
|
1172
|
+
.then(() => this.#deps.onNotificationSubscriptionReady?.(attached.notificationSubscription))
|
|
1173
|
+
.catch(error => {
|
|
1174
|
+
this.#detachNotification(attached, "cancelled");
|
|
1175
|
+
logger.warn(`SDK notification subscription ready hook failed locally: ${String(error)}`);
|
|
1176
|
+
});
|
|
1177
|
+
await this.#deps.onAttachmentReady?.(attached.capability);
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
const stillCurrent = this.#sessions.get(attached.sessionId) === attached;
|
|
1180
|
+
attached.published = false;
|
|
1181
|
+
this.#adopted.delete(attached.sessionId);
|
|
1182
|
+
if (stillCurrent) this.#sessions.delete(attached.sessionId);
|
|
1183
|
+
attached.dispose();
|
|
1184
|
+
await attached.client.close().catch(() => undefined);
|
|
1185
|
+
if (stillCurrent)
|
|
1186
|
+
try {
|
|
1187
|
+
await this.#deps.onSessionRemoved?.(attached.capability);
|
|
1188
|
+
} catch {
|
|
1189
|
+
// Ready publication failed closed; provider cleanup remains best effort.
|
|
1190
|
+
}
|
|
1191
|
+
throw error;
|
|
1192
|
+
} finally {
|
|
1193
|
+
attached.initializingPublication = false;
|
|
1194
|
+
}
|
|
1195
|
+
if (skipReplay) return true;
|
|
1196
|
+
// When the caller drives the serialized reconcile tail (periodic
|
|
1197
|
+
// re-attachment after a rehost), initial replay must not hold it: each
|
|
1198
|
+
// replay owns its own retry budget, so awaiting it here wedges all later
|
|
1199
|
+
// reconciles (and the sends that funnel through them) until the budget
|
|
1200
|
+
// expires. The barrier still holds live frames, so ordering and
|
|
1201
|
+
// generation fences are unchanged; replay just runs on the attachment's
|
|
1202
|
+
// ready tail like the reconnect path (#4527).
|
|
1203
|
+
if (deferReplay) {
|
|
1204
|
+
attached.readyTail = attached.readyTail
|
|
1205
|
+
.catch(() => undefined)
|
|
1206
|
+
.then(async () => {
|
|
1207
|
+
if (!this.#attachmentLive(attached)) return;
|
|
1208
|
+
if (!(await this.#deliverRecoveredFrames(attached))) return;
|
|
1209
|
+
await this.#replayAttachment(attached, attached.cursor.seq);
|
|
1210
|
+
});
|
|
1211
|
+
return true;
|
|
1212
|
+
}
|
|
1213
|
+
if (!(await this.#deliverRecoveredFrames(attached))) return false;
|
|
1214
|
+
await this.#replayAttachment(attached, attached.cursor.seq);
|
|
1215
|
+
return true;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
async #reinitializeAttachment(attached: AttachedSession): Promise<void> {
|
|
1219
|
+
const previous = attached.readyTail;
|
|
1220
|
+
const current = previous
|
|
1221
|
+
.catch(() => undefined)
|
|
1222
|
+
.then(async () => {
|
|
1223
|
+
if (!this.#attachmentLive(attached)) return;
|
|
1224
|
+
const endpoint = await this.#readEndpoint(attached.indexed);
|
|
1225
|
+
if (
|
|
1226
|
+
!endpoint ||
|
|
1227
|
+
endpoint.url !== attached.endpoint.url ||
|
|
1228
|
+
endpoint.token !== attached.endpoint.token ||
|
|
1229
|
+
endpoint.pid !== attached.pid
|
|
1230
|
+
) {
|
|
1231
|
+
await this.#retireAttachment(attached, endpoint ? "replaced_same_generation" : undefined);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
attached.initializingPublication = true;
|
|
1235
|
+
try {
|
|
1236
|
+
void Promise.resolve()
|
|
1237
|
+
.then(() => this.#deps.onNotificationSubscriptionReady?.(attached.notificationSubscription))
|
|
1238
|
+
.catch(error => {
|
|
1239
|
+
this.#detachNotification(attached, "cancelled");
|
|
1240
|
+
logger.warn(`SDK notification subscription reconnect hook failed locally: ${String(error)}`);
|
|
1241
|
+
});
|
|
1242
|
+
await this.#deps.onAttachmentReady?.(attached.capability);
|
|
1243
|
+
} catch {
|
|
1244
|
+
await this.#retireAttachment(attached);
|
|
1245
|
+
return;
|
|
1246
|
+
} finally {
|
|
1247
|
+
attached.initializingPublication = false;
|
|
1248
|
+
}
|
|
1249
|
+
if (this.#attachmentLive(attached)) await this.#replayAttachment(attached, attached.cursor.seq);
|
|
1250
|
+
});
|
|
1251
|
+
attached.readyTail = current;
|
|
1252
|
+
await current;
|
|
1253
|
+
}
|
|
1254
|
+
#reviveTransport(attached: AttachedSession): void {
|
|
1255
|
+
const connect = attached.client.connect?.bind(attached.client);
|
|
1256
|
+
if (!connect || this.#reviving.has(attached.id)) return;
|
|
1257
|
+
this.#reviving.add(attached.id);
|
|
1258
|
+
void connect()
|
|
1259
|
+
.catch(() => undefined)
|
|
1260
|
+
.finally(() => this.#reviving.delete(attached.id));
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
#running(runEpoch: number): boolean {
|
|
1264
|
+
return this.#started && runEpoch === this.#runEpoch;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
#attachmentLive(attached: AttachedSession): boolean {
|
|
1268
|
+
return (
|
|
1269
|
+
this.#running(attached.runEpoch) &&
|
|
1270
|
+
!attached.barrier.detached &&
|
|
1271
|
+
!attached.barrier.failed &&
|
|
1272
|
+
this.#sessions.get(attached.sessionId) === attached
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
#attachmentPublished(attached: AttachedSession): boolean {
|
|
1277
|
+
return attached.published && this.#attachmentLive(attached);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
#recordNotificationReceipt(
|
|
1281
|
+
subscription: NotificationSubscription,
|
|
1282
|
+
state: NotificationCleanupState,
|
|
1283
|
+
reason?: string,
|
|
1284
|
+
): void {
|
|
1285
|
+
this.#notificationReceipts.set(subscription.subscriptionId, {
|
|
1286
|
+
subscriptionId: subscription.subscriptionId,
|
|
1287
|
+
sessionId: subscription.sessionId,
|
|
1288
|
+
state,
|
|
1289
|
+
...(reason ? { reason: reason.slice(0, 256) } : {}),
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
async #boundedNotificationWork<T>(work: () => Promise<T> | T): Promise<T | typeof NOTIFICATION_WORK_TIMEOUT> {
|
|
1294
|
+
const timeout: Promise<typeof NOTIFICATION_WORK_TIMEOUT> = Bun.sleep(NOTIFICATION_WORK_TIMEOUT_MS).then(
|
|
1295
|
+
() => NOTIFICATION_WORK_TIMEOUT,
|
|
1296
|
+
);
|
|
1297
|
+
return await Promise.race([Promise.resolve().then(work), timeout]);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
#detachNotification(
|
|
1301
|
+
attached: AttachedSession,
|
|
1302
|
+
reason: "removed" | "replaced" | "replaced_same_generation" | "cancelled",
|
|
1303
|
+
): void {
|
|
1304
|
+
if (attached.notificationCancelled) return;
|
|
1305
|
+
attached.notificationCancelled = true;
|
|
1306
|
+
this.#recordNotificationReceipt(attached.notificationSubscription, "pending", reason);
|
|
1307
|
+
const work = this.#boundedNotificationWork(() =>
|
|
1308
|
+
this.#deps.onNotificationSubscriptionRemoved?.(attached.notificationSubscription, reason),
|
|
1309
|
+
)
|
|
1310
|
+
.then(
|
|
1311
|
+
result =>
|
|
1312
|
+
this.#recordNotificationReceipt(
|
|
1313
|
+
attached.notificationSubscription,
|
|
1314
|
+
result === NOTIFICATION_WORK_TIMEOUT ? "failed" : "completed",
|
|
1315
|
+
reason,
|
|
1316
|
+
),
|
|
1317
|
+
(error: unknown) =>
|
|
1318
|
+
this.#recordNotificationReceipt(
|
|
1319
|
+
attached.notificationSubscription,
|
|
1320
|
+
"failed",
|
|
1321
|
+
error instanceof Error ? error.message : String(error),
|
|
1322
|
+
),
|
|
1323
|
+
)
|
|
1324
|
+
.catch(() => undefined);
|
|
1325
|
+
void work;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
#dispatchNotificationFrame(attached: AttachedSession, frame: SessionRouterFrame): void {
|
|
1329
|
+
if (attached.notificationCancelled || !this.#attachmentLive(attached)) return;
|
|
1330
|
+
const callback = this.#deps.onNotificationFrame;
|
|
1331
|
+
if (!callback) return;
|
|
1332
|
+
const work = this.#boundedNotificationWork(async () => {
|
|
1333
|
+
await callback(attached.notificationSubscription, frame);
|
|
1334
|
+
return true;
|
|
1335
|
+
}).then(
|
|
1336
|
+
result => {
|
|
1337
|
+
if (result === NOTIFICATION_WORK_TIMEOUT) {
|
|
1338
|
+
this.#detachNotification(attached, "cancelled");
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
if (frame.seq !== undefined)
|
|
1342
|
+
attached.notificationSubscription.advanceCursor(frame.generation ?? attached.generation, frame.seq);
|
|
1343
|
+
},
|
|
1344
|
+
(error: unknown) => {
|
|
1345
|
+
this.#detachNotification(attached, "cancelled");
|
|
1346
|
+
logger.warn(
|
|
1347
|
+
`SDK notification subscription ${attached.notificationSubscription.subscriptionId} failed locally: ${
|
|
1348
|
+
error instanceof Error ? error.message : String(error)
|
|
1349
|
+
}`,
|
|
1350
|
+
);
|
|
1351
|
+
},
|
|
1352
|
+
);
|
|
1353
|
+
void work;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
async #retireAttachment(
|
|
1357
|
+
attached: AttachedSession,
|
|
1358
|
+
explicitReason?: "removed" | "replaced" | "replaced_same_generation",
|
|
1359
|
+
): Promise<void> {
|
|
1360
|
+
this.#adopted.delete(attached.sessionId);
|
|
1361
|
+
if (this.#sessions.get(attached.sessionId) !== attached) return;
|
|
1362
|
+
this.#retirementVersions.set(attached.sessionId, (this.#retirementVersions.get(attached.sessionId) ?? 0) + 1);
|
|
1363
|
+
this.#sessions.delete(attached.sessionId);
|
|
1364
|
+
attached.dispose();
|
|
1365
|
+
const gate = Promise.withResolvers<void>();
|
|
1366
|
+
this.#retirements.set(attached.sessionId, gate.promise);
|
|
1367
|
+
try {
|
|
1368
|
+
let reason = explicitReason;
|
|
1369
|
+
if (reason === undefined) {
|
|
1370
|
+
try {
|
|
1371
|
+
await this.#index.refresh();
|
|
1372
|
+
const current = this.#index
|
|
1373
|
+
.listSessions()
|
|
1374
|
+
.sessions.find(session => session.sessionId === attached.sessionId);
|
|
1375
|
+
if (
|
|
1376
|
+
current?.live &&
|
|
1377
|
+
(current.endpointGeneration !== attached.generation ||
|
|
1378
|
+
current.pid !== attached.pid ||
|
|
1379
|
+
current.endpointMtimeMs !== attached.endpointMtimeMs)
|
|
1380
|
+
)
|
|
1381
|
+
reason = current.endpointGeneration === attached.generation ? "replaced_same_generation" : "replaced";
|
|
1382
|
+
} catch {
|
|
1383
|
+
// Revocation remains terminal when current Broker authority cannot be proven.
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
reason ??= "removed";
|
|
1387
|
+
void Promise.resolve(this.#deps.onSessionRemoved?.(attached.capability, reason)).catch(error =>
|
|
1388
|
+
logger.warn(`SDK provider cleanup failed after authority revocation: ${String(error)}`),
|
|
1389
|
+
);
|
|
1390
|
+
this.#detachNotification(attached, reason);
|
|
1391
|
+
await attached.client.close().catch(() => undefined);
|
|
1392
|
+
} finally {
|
|
1393
|
+
gate.resolve();
|
|
1394
|
+
if (this.#retirements.get(attached.sessionId) === gate.promise) this.#retirements.delete(attached.sessionId);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
#failBarrier(attached: AttachedSession, reason: string): void {
|
|
1399
|
+
if (attached.barrier.detached || attached.barrier.failed) return;
|
|
1400
|
+
attached.barrier.failed = true;
|
|
1401
|
+
attached.barrier.held = undefined;
|
|
1402
|
+
logger.warn(
|
|
1403
|
+
`chat daemon replay barrier failed (${reason}); rebuilding session ${attached.sessionId} at generation ${attached.generation} from seq ${attached.cursor.seq}.`,
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
#failDelivery(attached: AttachedSession, seq: number, error: unknown): void {
|
|
1407
|
+
const previous = this.#undelivered.get(attached.sessionId);
|
|
1408
|
+
const attempts = previous?.generation === attached.generation && previous.seq === seq ? previous.attempts + 1 : 1;
|
|
1409
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1410
|
+
if (attempts >= DELIVERY_ATTEMPT_LIMIT) {
|
|
1411
|
+
this.#undelivered.delete(attached.sessionId);
|
|
1412
|
+
this.#removeRecoveredFrame(attached.sessionId, attached.generation, seq);
|
|
1413
|
+
attached.cursor.seq = seq;
|
|
1414
|
+
logger.warn(
|
|
1415
|
+
`chat daemon conceded seq ${seq} of session ${attached.sessionId} at generation ${attached.generation} after ${attempts} refused publications (${reason}); delivery resumes above it.`,
|
|
1416
|
+
);
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
this.#undelivered.set(attached.sessionId, { generation: attached.generation, seq, attempts });
|
|
1420
|
+
this.#failBarrier(attached, `publication failed at seq ${seq} (${reason})`);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
#rememberRecoveredFrame(attached: AttachedSession, seq: number, frame: Record<string, unknown>): void {
|
|
1424
|
+
let pending = this.#recoveredFrames.get(attached.sessionId);
|
|
1425
|
+
if (!pending || pending.generation !== attached.generation) {
|
|
1426
|
+
pending = { generation: attached.generation, frames: [] };
|
|
1427
|
+
this.#recoveredFrames.set(attached.sessionId, pending);
|
|
1428
|
+
}
|
|
1429
|
+
const existing = pending.frames.find(item => item.seq === seq);
|
|
1430
|
+
if (existing) existing.frame = frame;
|
|
1431
|
+
else {
|
|
1432
|
+
pending.frames.push({ seq, frame });
|
|
1433
|
+
pending.frames.sort((left, right) => left.seq - right.seq);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
#removeRecoveredFrame(sessionId: string, generation: number, seq: number): void {
|
|
1438
|
+
const pending = this.#recoveredFrames.get(sessionId);
|
|
1439
|
+
if (!pending || pending.generation !== generation) return;
|
|
1440
|
+
pending.frames = pending.frames.filter(item => item.seq !== seq);
|
|
1441
|
+
if (pending.frames.length === 0) this.#recoveredFrames.delete(sessionId);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
async #deliverRecoveredFrames(attached: AttachedSession): Promise<boolean> {
|
|
1445
|
+
const pending = this.#recoveredFrames.get(attached.sessionId);
|
|
1446
|
+
if (!pending || pending.generation !== attached.generation) return true;
|
|
1447
|
+
for (const item of [...pending.frames]) {
|
|
1448
|
+
if (item.seq <= attached.cursor.seq) {
|
|
1449
|
+
this.#removeRecoveredFrame(attached.sessionId, attached.generation, item.seq);
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
await this.#enqueueFrame(attached, item.frame, "ordered");
|
|
1453
|
+
if (attached.barrier.detached || attached.barrier.failed) return false;
|
|
1454
|
+
}
|
|
1455
|
+
return true;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
#schedule(task: Promise<void>): void {
|
|
1459
|
+
this.#pending.add(task);
|
|
1460
|
+
void task.then(
|
|
1461
|
+
() => this.#pending.delete(task),
|
|
1462
|
+
() => this.#pending.delete(task),
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
async #deliverOutOfBandFrame(attached: AttachedSession, frame: Record<string, unknown>): Promise<void> {
|
|
1467
|
+
if (!this.#attachmentLive(attached)) return;
|
|
1468
|
+
if (!attached.published) {
|
|
1469
|
+
await attached.publication.promise.catch(() => undefined);
|
|
1470
|
+
if (!this.#attachmentPublished(attached)) return;
|
|
1471
|
+
}
|
|
1472
|
+
const correlated = this.#correlateFrame(frame);
|
|
1473
|
+
if (!correlated) return;
|
|
1474
|
+
if (correlated.sessionId !== undefined && correlated.sessionId !== attached.sessionId) return;
|
|
1475
|
+
if (correlated.generation !== undefined && correlated.generation !== attached.generation) return;
|
|
1476
|
+
this.#dispatchNotificationFrame(attached, correlated);
|
|
1477
|
+
void Promise.resolve(this.#deps.onFrame?.(attached.capability, correlated)).catch(error =>
|
|
1478
|
+
logger.warn(`SDK provider frame hook failed: ${String(error)}`),
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
#enqueueFrame(attached: AttachedSession, frame: Record<string, unknown>, origin: FrameOrigin): Promise<void> {
|
|
1482
|
+
const previous = this.#frameTails.get(attached.id) ?? Promise.resolve();
|
|
1483
|
+
const current = previous
|
|
1484
|
+
.catch(() => undefined)
|
|
1485
|
+
.then(async () => {
|
|
1486
|
+
if (!this.#attachmentLive(attached)) return;
|
|
1487
|
+
if (!attached.published) {
|
|
1488
|
+
await attached.publication.promise.catch(() => undefined);
|
|
1489
|
+
if (!this.#attachmentPublished(attached)) return;
|
|
1490
|
+
}
|
|
1491
|
+
const correlated = this.#correlateFrame(frame);
|
|
1492
|
+
if (!correlated) return;
|
|
1493
|
+
const seq = typeof frame.seq === "number" && Number.isSafeInteger(frame.seq) ? frame.seq : undefined;
|
|
1494
|
+
if (correlated.sessionId !== undefined && correlated.sessionId !== attached.sessionId) return;
|
|
1495
|
+
if (correlated.generation !== undefined && correlated.generation !== attached.generation) return;
|
|
1496
|
+
if (seq !== undefined && correlated.generation === undefined) return;
|
|
1497
|
+
const ownsSequence =
|
|
1498
|
+
correlated.generation === attached.generation &&
|
|
1499
|
+
(correlated.sessionId === undefined || correlated.sessionId === attached.sessionId);
|
|
1500
|
+
if (seq !== undefined && ownsSequence) {
|
|
1501
|
+
if (seq <= attached.cursor.seq) return;
|
|
1502
|
+
const held = attached.barrier.held;
|
|
1503
|
+
if (held && origin === "live") {
|
|
1504
|
+
if (held.length >= REPLAY_BARRIER_LIMIT) {
|
|
1505
|
+
this.#failBarrier(attached, `hold buffer overflowed at ${REPLAY_BARRIER_LIMIT} frames`);
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
held.push({ seq, frame });
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
const publicationId =
|
|
1513
|
+
seq !== undefined && ownsSequence ? `${attached.sessionId}:${attached.generation}:${seq}` : undefined;
|
|
1514
|
+
try {
|
|
1515
|
+
const notificationFrame = publicationId === undefined ? correlated : { ...correlated, publicationId };
|
|
1516
|
+
this.#dispatchNotificationFrame(attached, notificationFrame);
|
|
1517
|
+
await this.#deps.onFrame?.(attached.capability, notificationFrame);
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
if (!this.#attachmentLive(attached)) return;
|
|
1520
|
+
if (seq === undefined || !ownsSequence) throw error;
|
|
1521
|
+
this.#failDelivery(attached, seq, error);
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
if (!this.#attachmentLive(attached)) return;
|
|
1525
|
+
if (seq !== undefined && ownsSequence) {
|
|
1526
|
+
this.#undelivered.delete(attached.sessionId);
|
|
1527
|
+
this.#removeRecoveredFrame(attached.sessionId, attached.generation, seq);
|
|
1528
|
+
if (seq > attached.cursor.seq) attached.cursor.seq = seq;
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1531
|
+
this.#frameTails.set(attached.id, current);
|
|
1532
|
+
void current.then(
|
|
1533
|
+
() => {
|
|
1534
|
+
if (this.#frameTails.get(attached.id) === current) this.#frameTails.delete(attached.id);
|
|
1535
|
+
},
|
|
1536
|
+
() => {
|
|
1537
|
+
if (this.#frameTails.get(attached.id) === current) this.#frameTails.delete(attached.id);
|
|
1538
|
+
},
|
|
1539
|
+
);
|
|
1540
|
+
return current;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
async #drainHeldFrames(attached: AttachedSession, held: HeldFrame[]): Promise<void> {
|
|
1544
|
+
for (;;) {
|
|
1545
|
+
if (attached.barrier.held !== held || !this.#attachmentLive(attached)) return;
|
|
1546
|
+
if (held.length === 0) {
|
|
1547
|
+
attached.barrier.held = undefined;
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
const batch = held.splice(0, held.length).sort((left, right) => left.seq - right.seq);
|
|
1551
|
+
for (const entry of batch) await this.#enqueueFrame(attached, entry.frame, "ordered");
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
async #replayAttachment(attached: AttachedSession, sinceSeq: number): Promise<void> {
|
|
1556
|
+
if (!this.#attachmentLive(attached)) return;
|
|
1557
|
+
const held: HeldFrame[] = attached.barrier.held ?? [];
|
|
1558
|
+
attached.barrier.held = held;
|
|
1559
|
+
try {
|
|
1560
|
+
let replay: Record<string, unknown>;
|
|
1561
|
+
for (let attempt = 0; ; attempt++) {
|
|
1562
|
+
try {
|
|
1563
|
+
const stopped = Promise.withResolvers<void>();
|
|
1564
|
+
const onStop = (): void => stopped.resolve();
|
|
1565
|
+
if (this.#stopController.signal.aborted) stopped.resolve();
|
|
1566
|
+
else this.#stopController.signal.addEventListener("abort", onStop, { once: true });
|
|
1567
|
+
const replayRequest = attached.client.request({
|
|
1568
|
+
type: "event_replay",
|
|
1569
|
+
sinceGeneration: attached.generation,
|
|
1570
|
+
sinceSeq,
|
|
1571
|
+
});
|
|
1572
|
+
let outcome: { kind: "response"; value: Record<string, unknown> } | { kind: "stopped" };
|
|
1573
|
+
try {
|
|
1574
|
+
outcome = await Promise.race([
|
|
1575
|
+
replayRequest.then(value => ({ kind: "response" as const, value })),
|
|
1576
|
+
stopped.promise.then(() => ({ kind: "stopped" as const })),
|
|
1577
|
+
]);
|
|
1578
|
+
} finally {
|
|
1579
|
+
this.#stopController.signal.removeEventListener("abort", onStop);
|
|
1580
|
+
}
|
|
1581
|
+
if (outcome.kind === "stopped") return;
|
|
1582
|
+
replay = outcome.value;
|
|
1583
|
+
break;
|
|
1584
|
+
} catch {
|
|
1585
|
+
if (attempt >= REPLAY_RETRY_ATTEMPTS) {
|
|
1586
|
+
this.#failBarrier(attached, "replay went unanswered");
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
await Bun.sleep(REPLAY_RETRY_BACKOFF_MS * 2 ** attempt);
|
|
1590
|
+
if (attached.barrier.held !== held || !this.#attachmentLive(attached)) return;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
if (attached.barrier.held !== held || !this.#attachmentLive(attached)) return;
|
|
1594
|
+
await this.#frameTails.get(attached.id)?.catch(() => undefined);
|
|
1595
|
+
if (attached.barrier.held !== held || !this.#attachmentLive(attached)) return;
|
|
1596
|
+
const events = Array.isArray(replay.events)
|
|
1597
|
+
? replay.events.filter(
|
|
1598
|
+
(event): event is Record<string, unknown> =>
|
|
1599
|
+
!!event && typeof event === "object" && !Array.isArray(event),
|
|
1600
|
+
)
|
|
1601
|
+
: [];
|
|
1602
|
+
if (replay.gap !== undefined) {
|
|
1603
|
+
const gap = readReplayGap(replay.gap);
|
|
1604
|
+
if (!gap) {
|
|
1605
|
+
this.#failBarrier(attached, "replay reported a gap it did not state");
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
if (gap.kind === "generation_reset") {
|
|
1609
|
+
this.#failBarrier(attached, `replay reported a generation reset to ${gap.toGeneration}`);
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
if (gap.fromSeq !== sinceSeq + 1) {
|
|
1613
|
+
this.#failBarrier(
|
|
1614
|
+
attached,
|
|
1615
|
+
`replay conceded sequences ${gap.fromSeq}-${gap.toSeq} for a request that resumed from seq ${sinceSeq}`,
|
|
1616
|
+
);
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
const retained = events
|
|
1620
|
+
.map(event => readSequence(event.seq))
|
|
1621
|
+
.find(seq => seq !== undefined && seq <= gap.toSeq);
|
|
1622
|
+
if (retained !== undefined) {
|
|
1623
|
+
this.#failBarrier(
|
|
1624
|
+
attached,
|
|
1625
|
+
`replay conceded sequences ${gap.fromSeq}-${gap.toSeq} while returning seq ${retained}`,
|
|
1626
|
+
);
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
const recovered = held.filter(entry => entry.seq <= gap.toSeq).sort((left, right) => left.seq - right.seq);
|
|
1630
|
+
const carried = held.filter(entry => entry.seq > gap.toSeq);
|
|
1631
|
+
held.splice(0, held.length, ...carried);
|
|
1632
|
+
const recoveredNote =
|
|
1633
|
+
recovered.length > 0 ? `, ${recovered.length} of them recovered from live delivery` : "";
|
|
1634
|
+
logger.warn(
|
|
1635
|
+
`chat daemon replay conceded a retention gap (sequences ${gap.fromSeq}-${gap.toSeq} are gone from the host${recoveredNote}); session ${attached.sessionId} generation ${attached.generation} resumes at seq ${gap.toSeq + 1}.`,
|
|
1636
|
+
);
|
|
1637
|
+
for (const entry of recovered) this.#rememberRecoveredFrame(attached, entry.seq, entry.frame);
|
|
1638
|
+
if (!(await this.#deliverRecoveredFrames(attached))) return;
|
|
1639
|
+
if (gap.toSeq > attached.cursor.seq) attached.cursor.seq = gap.toSeq;
|
|
1640
|
+
}
|
|
1641
|
+
for (const event of events) await this.#enqueueFrame(attached, event, "ordered");
|
|
1642
|
+
await this.#drainHeldFrames(attached, held);
|
|
1643
|
+
} finally {
|
|
1644
|
+
if (attached.barrier.held === held) attached.barrier.held = undefined;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
async function connectPreparedSession(endpoint: {
|
|
1650
|
+
url: string;
|
|
1651
|
+
token: string;
|
|
1652
|
+
}): Promise<PreparedSessionActivationClient> {
|
|
1653
|
+
const client = await SdkClient.connect(endpoint.url, endpoint.token, { reconnectAttempts: 0 });
|
|
1654
|
+
return {
|
|
1655
|
+
request: async frame => (await client.request(frame)) as Record<string, unknown>,
|
|
1656
|
+
close: async () => await client.close(),
|
|
1657
|
+
};
|
|
1658
|
+
}
|