@selesai/code 0.5.28 → 0.6.1
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 +24 -0
- package/README.md +1 -1
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +9 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/config-selector.d.ts.map +1 -1
- package/dist/cli/config-selector.js +1 -1
- package/dist/cli/config-selector.js.map +1 -1
- package/dist/cli/credential-print.d.ts +23 -0
- package/dist/cli/credential-print.d.ts.map +1 -0
- package/dist/cli/credential-print.js +117 -0
- package/dist/cli/credential-print.js.map +1 -0
- package/dist/cli/startup-ui.d.ts.map +1 -1
- package/dist/cli/startup-ui.js +1 -1
- package/dist/cli/startup-ui.js.map +1 -1
- package/dist/core/agent-session-runtime.d.ts.map +1 -1
- package/dist/core/agent-session-runtime.js +3 -0
- package/dist/core/agent-session-runtime.js.map +1 -1
- package/dist/core/agent-session.d.ts +12 -1
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +20 -14
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/compaction/compaction.d.ts.map +1 -1
- package/dist/core/compaction/compaction.js +11 -3
- package/dist/core/compaction/compaction.js.map +1 -1
- package/dist/core/extensions/runner.d.ts +1 -0
- package/dist/core/extensions/runner.d.ts.map +1 -1
- package/dist/core/extensions/runner.js +11 -0
- package/dist/core/extensions/runner.js.map +1 -1
- package/dist/core/extensions/types.d.ts +14 -1
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/footer-data-provider.d.ts +10 -0
- package/dist/core/footer-data-provider.d.ts.map +1 -1
- package/dist/core/footer-data-provider.js +1 -1
- package/dist/core/footer-data-provider.js.map +1 -1
- package/dist/core/llama/provider.d.ts.map +1 -1
- package/dist/core/llama/provider.js +8 -3
- package/dist/core/llama/provider.js.map +1 -1
- package/dist/core/model-config.d.ts +30 -0
- package/dist/core/model-config.d.ts.map +1 -1
- package/dist/core/model-config.js +6 -0
- package/dist/core/model-config.js.map +1 -1
- package/dist/core/model-registry.d.ts.map +1 -1
- package/dist/core/model-registry.js +2 -2
- package/dist/core/model-registry.js.map +1 -1
- package/dist/core/model-resolver.d.ts +1 -0
- package/dist/core/model-resolver.d.ts.map +1 -1
- package/dist/core/model-resolver.js +20 -3
- package/dist/core/model-resolver.js.map +1 -1
- package/dist/core/model-runtime.d.ts +2 -0
- package/dist/core/model-runtime.d.ts.map +1 -1
- package/dist/core/model-runtime.js +5 -4
- package/dist/core/model-runtime.js.map +1 -1
- package/dist/core/package-manager.d.ts.map +1 -1
- package/dist/core/package-manager.js +13 -6
- package/dist/core/package-manager.js.map +1 -1
- package/dist/core/remote-catalog-provider.d.ts +1 -1
- package/dist/core/remote-catalog-provider.d.ts.map +1 -1
- package/dist/core/remote-catalog-provider.js +24 -11
- package/dist/core/remote-catalog-provider.js.map +1 -1
- package/dist/core/resource-loader.d.ts +15 -0
- package/dist/core/resource-loader.d.ts.map +1 -1
- package/dist/core/resource-loader.js +66 -9
- package/dist/core/resource-loader.js.map +1 -1
- package/dist/core/settings-manager.d.ts +1 -1
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/system-prompt.d.ts.map +1 -1
- package/dist/core/system-prompt.js +19 -1
- package/dist/core/system-prompt.js.map +1 -1
- package/dist/core/system-prompt.test.d.ts +2 -0
- package/dist/core/system-prompt.test.d.ts.map +1 -0
- package/dist/core/system-prompt.test.js +89 -0
- package/dist/core/system-prompt.test.js.map +1 -0
- package/dist/core/tools/bash.d.ts +2 -0
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js +34 -5
- package/dist/core/tools/bash.js.map +1 -1
- package/dist/core/tools/tool-definition-wrapper.d.ts.map +1 -1
- package/dist/core/tools/tool-definition-wrapper.js +3 -1
- package/dist/core/tools/tool-definition-wrapper.js.map +1 -1
- package/dist/defaults/models.json +13 -45
- package/dist/defaults/settings.json +1 -2
- package/dist/extensions/copy-turn.test.ts +131 -0
- package/dist/extensions/copy-turn.ts +6 -1
- package/dist/extensions/package.json +0 -1
- package/dist/extensions/pi-intercom/CHANGELOG.md +249 -0
- package/dist/extensions/pi-intercom/README.md +102 -39
- package/dist/extensions/pi-intercom/broker/broker.ts +1233 -36
- package/dist/extensions/pi-intercom/broker/client.test.ts +83 -0
- package/dist/extensions/pi-intercom/broker/client.ts +315 -12
- package/dist/extensions/pi-intercom/broker/extension-state.ts +186 -0
- package/dist/extensions/pi-intercom/broker/extension.test.ts +387 -0
- package/dist/extensions/pi-intercom/broker/framing.test.ts +114 -0
- package/dist/extensions/pi-intercom/broker/framing.ts +82 -24
- package/dist/extensions/pi-intercom/broker/paths.test.ts +153 -0
- package/dist/extensions/pi-intercom/broker/paths.ts +117 -8
- package/dist/extensions/pi-intercom/broker/runtime-claim.test.ts +34 -0
- package/dist/extensions/pi-intercom/broker/runtime-claim.ts +21 -0
- package/dist/extensions/pi-intercom/broker/spawn.test.ts +160 -23
- package/dist/extensions/pi-intercom/broker/spawn.ts +113 -27
- package/dist/extensions/pi-intercom/config.test.ts +93 -0
- package/dist/extensions/pi-intercom/config.ts +55 -6
- package/dist/extensions/pi-intercom/cwd.test.ts +40 -0
- package/dist/extensions/pi-intercom/cwd.ts +31 -0
- package/dist/extensions/pi-intercom/extension-api.ts +44 -0
- package/dist/extensions/pi-intercom/format-context.test.ts +31 -0
- package/dist/extensions/pi-intercom/format-context.ts +32 -0
- package/dist/extensions/pi-intercom/index.ts +742 -145
- package/dist/extensions/pi-intercom/intercom.integration.test.ts +2646 -0
- package/dist/extensions/pi-intercom/package.json +15 -5
- package/dist/extensions/pi-intercom/reply-tracker.test.ts +134 -0
- package/dist/extensions/pi-intercom/reply-tracker.ts +31 -13
- package/dist/extensions/pi-intercom/skills/pi-intercom/SKILL.md +13 -11
- package/dist/extensions/pi-intercom/test/inline-message.test.ts +184 -0
- package/dist/extensions/pi-intercom/test/overlay-width.test.ts +66 -0
- package/dist/extensions/pi-intercom/types.ts +94 -4
- package/dist/extensions/pi-intercom/ui/compose.ts +8 -4
- package/dist/extensions/pi-intercom/ui/inline-message.ts +61 -25
- package/dist/extensions/pi-intercom/ui/session-list.ts +7 -3
- package/dist/extensions/pi-subagents/CHANGELOG.md +88 -0
- package/dist/extensions/pi-subagents/LICENSE +21 -0
- package/dist/extensions/pi-subagents/README.md +158 -69
- package/dist/extensions/pi-subagents/agents/architect.md +5 -4
- package/dist/extensions/pi-subagents/agents/builder.md +6 -4
- package/dist/extensions/pi-subagents/agents/commentator.md +3 -2
- package/dist/extensions/pi-subagents/agents/explorer.md +3 -2
- package/dist/extensions/pi-subagents/agents/recapper.md +3 -2
- package/dist/extensions/pi-subagents/agents/researcher.md +4 -3
- package/dist/extensions/pi-subagents/package-lock.json +2 -2
- package/dist/extensions/pi-subagents/package.json +3 -3
- package/dist/extensions/pi-subagents/prompts/review-loop.md +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +22 -988
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/constraints-and-recipes.md +257 -0
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +431 -0
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/management-authoring-rpc.md +144 -0
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/prompting-and-roles.md +281 -0
- package/dist/extensions/pi-subagents/src/agents/agent-management.ts +125 -37
- package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +4 -0
- package/dist/extensions/pi-subagents/src/agents/agents.ts +118 -9
- package/dist/extensions/pi-subagents/src/agents/skills.ts +14 -12
- package/dist/extensions/pi-subagents/src/agents/task-aware-routing.ts +125 -0
- package/dist/extensions/pi-subagents/src/api/delegation.ts +3 -0
- package/dist/extensions/pi-subagents/src/api/preflight.ts +17 -13
- package/dist/extensions/pi-subagents/src/extension/chain-validation.ts +17 -1
- package/dist/extensions/pi-subagents/src/extension/index.ts +22 -8
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +248 -6
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +33 -8
- package/dist/extensions/pi-subagents/src/extension/tool-description.ts +32 -14
- package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +9 -4
- package/dist/extensions/pi-subagents/src/intercom/result-intercom.ts +33 -4
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +86 -21
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +24 -13
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -5
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +18 -2
- package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +48 -5
- package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +68 -1
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +56 -5
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +92 -11
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +29 -3
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +388 -29
- package/dist/extensions/pi-subagents/src/runs/foreground/async-stop-action.ts +65 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-clarify.ts +3 -3
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +214 -60
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +546 -252
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +42 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +553 -154
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +19 -13
- package/dist/extensions/pi-subagents/src/runs/shared/capability-ceiling.ts +51 -19
- package/dist/extensions/pi-subagents/src/runs/shared/chain-outputs.ts +3 -1
- package/dist/extensions/pi-subagents/src/runs/shared/dynamic-fanout.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-allowlist.ts +44 -11
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +8 -0
- package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +97 -20
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +43 -12
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +52 -4
- package/dist/extensions/pi-subagents/src/runs/shared/process-signal.ts +19 -0
- package/dist/extensions/pi-subagents/src/runs/shared/run-history.ts +45 -9
- package/dist/extensions/pi-subagents/src/runs/shared/runtime-acknowledged-extensions.ts +71 -0
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +63 -9
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +31 -1
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-startup-retry.ts +101 -0
- package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +22 -1
- package/dist/extensions/pi-subagents/src/runs/shared/usage-budget.ts +65 -0
- package/dist/extensions/pi-subagents/src/runs/shared/workflow-graph.ts +26 -1
- package/dist/extensions/pi-subagents/src/shared/settings.ts +17 -1
- package/dist/extensions/pi-subagents/src/shared/types.ts +196 -12
- package/dist/extensions/pi-subagents/src/shared/utils.ts +102 -5
- package/dist/extensions/pi-subagents/src/slash/delegation-adapters.ts +10 -1
- package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +46 -4
- package/dist/extensions/pi-subagents/src/slash/slash-live-state.ts +5 -3
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +87 -24
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +182 -9
- package/dist/extensions/pi-subagents/src/tui/render.ts +55 -34
- package/dist/extensions/pi-subagents/src/watchdog/register-main.ts +14 -7
- package/dist/extensions/pi-subagents/src/watchdog/review.ts +5 -4
- package/dist/extensions/pi-subagents/src/watchdog/runtime.ts +170 -17
- package/dist/extensions/pi-subagents/src/watchdog/scope.ts +62 -0
- package/dist/extensions/pi-subagents/src/watchdog/settings.ts +41 -1
- package/dist/extensions/pi-subagents/src/watchdog/types.ts +10 -0
- package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +113 -8
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +419 -47
- package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +84 -0
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +4 -2
- package/dist/extensions/pi-subagents/test/integration/chain-clarify.test.ts +51 -2
- package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +62 -22
- package/dist/extensions/pi-subagents/test/integration/detect-error.test.ts +48 -0
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +8 -6
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +231 -19
- package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +48 -7
- package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +44 -0
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +199 -17
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +898 -16
- package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +112 -23
- package/dist/extensions/pi-subagents/test/support/mock-pi-script.mjs +17 -5
- package/dist/extensions/pi-subagents/test/support/mock-pi.ts +2 -0
- package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +18 -2
- package/dist/extensions/pi-subagents/test/support/register-loader.mjs +3 -3
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +3 -1
- package/dist/extensions/pi-subagents/test/unit/agent-disabled.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +198 -6
- package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +161 -1
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +47 -2
- package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +60 -0
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +8 -0
- package/dist/extensions/pi-subagents/test/unit/builtin-agent-documentation.test.ts +63 -0
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling-agent-allowlist.test.ts +136 -0
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +6 -0
- package/dist/extensions/pi-subagents/test/unit/chain-validation.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +48 -48
- package/dist/extensions/pi-subagents/test/unit/config-dir-runtime.test.ts +23 -0
- package/dist/extensions/pi-subagents/test/unit/control-channel.test.ts +27 -0
- package/dist/extensions/pi-subagents/test/unit/delegation-api.test.ts +28 -2
- package/dist/extensions/pi-subagents/test/unit/dynamic-fanout.test.ts +1 -0
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +176 -8
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +164 -5
- package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +41 -1
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +17 -12
- package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +18 -2
- package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +20 -1
- package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +30 -2
- package/dist/extensions/pi-subagents/test/unit/notify.test.ts +39 -2
- package/dist/extensions/pi-subagents/test/unit/parallel-utils.test.ts +22 -0
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +103 -0
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +18 -0
- package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +48 -0
- package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +13 -1
- package/dist/extensions/pi-subagents/test/unit/result-intercom.test.ts +29 -4
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +223 -4
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +30 -0
- package/dist/extensions/pi-subagents/test/unit/runtime-acknowledged-extensions.test.ts +52 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +46 -2
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +91 -1
- package/dist/extensions/pi-subagents/test/unit/skills-fallback.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/streamed-progress-bounds.test.ts +78 -0
- package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +41 -0
- package/dist/extensions/pi-subagents/test/unit/subagent-startup-retry.test.ts +77 -0
- package/dist/extensions/pi-subagents/test/unit/task-aware-routing.test.ts +213 -0
- package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +26 -1
- package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +61 -10
- package/dist/extensions/pi-subagents/test/unit/total-cost.test.ts +1 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-runtime.test.ts +210 -1
- package/dist/extensions/pi-subagents/test/unit/watchdog-scope.test.ts +33 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-settings.test.ts +32 -0
- package/dist/extensions/pi-subagents/test/unit/writer-budget-guidance.test.ts +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +43 -0
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/custom-message.d.ts +3 -1
- package/dist/modes/interactive/components/custom-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/custom-message.js +10 -2
- package/dist/modes/interactive/components/custom-message.js.map +1 -1
- package/dist/modes/interactive/components/extension-editor.d.ts +1 -2
- package/dist/modes/interactive/components/extension-editor.d.ts.map +1 -1
- package/dist/modes/interactive/components/extension-editor.js +16 -46
- package/dist/modes/interactive/components/extension-editor.js.map +1 -1
- package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/model-selector.js +4 -1
- package/dist/modes/interactive/components/model-selector.js.map +1 -1
- package/dist/modes/interactive/components/scoped-models-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/scoped-models-selector.js +22 -13
- package/dist/modes/interactive/components/scoped-models-selector.js.map +1 -1
- package/dist/modes/interactive/external-editor.d.ts +12 -0
- package/dist/modes/interactive/external-editor.d.ts.map +1 -0
- package/dist/modes/interactive/external-editor.js +37 -0
- package/dist/modes/interactive/external-editor.js.map +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +72 -64
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +14 -0
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/skills/ponytail/SKILL.md +1 -3
- package/dist/utils/clipboard.d.ts.map +1 -1
- package/dist/utils/clipboard.js +19 -8
- package/dist/utils/clipboard.js.map +1 -1
- package/dist/utils/version-check.d.ts.map +1 -1
- package/dist/utils/version-check.js +1 -1
- package/dist/utils/version-check.js.map +1 -1
- package/docs/compaction.md +1 -1
- package/docs/custom-provider.md +14 -5
- package/docs/environment-variables.md +86 -0
- package/docs/extensions.md +15 -5
- package/docs/index.md +1 -0
- package/docs/models.md +9 -2
- package/docs/plans/subagent-delegation/phase-0-correctness.md +265 -0
- package/docs/plans/subagent-delegation/phase-1-behavioral-contract.md +486 -0
- package/docs/plans/subagent-delegation/phase-2-context-controls.md +282 -0
- package/docs/plans/subagent-delegation/phase-3-advisory-routing.md +362 -0
- package/docs/plans/subagent-delegation/phase-4-optional-enforcement.md +381 -0
- package/docs/providers.md +21 -2
- package/docs/rpc.md +23 -6
- package/docs/session-format.md +2 -0
- package/docs/settings.md +1 -1
- package/docs/usage.md +0 -13
- package/examples/extensions/custom-compaction.ts +5 -2
- package/examples/extensions/custom-provider-anthropic/index.ts +9 -3
- package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/gondolin/package-lock.json +2 -2
- package/examples/extensions/gondolin/package.json +1 -1
- package/examples/extensions/handoff.ts +11 -3
- package/examples/extensions/message-renderer.ts +3 -3
- package/examples/extensions/sandbox/package-lock.json +2 -2
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/summarize.ts +5 -2
- package/examples/extensions/with-deps/package-lock.json +2 -2
- package/examples/extensions/with-deps/package.json +1 -1
- package/examples/sdk/12-full-control.ts +3 -1
- package/package.json +16 -7
- package/dist/extensions/caveman/caveman-instructions.cjs +0 -11
- package/dist/extensions/caveman/index.js +0 -118
- package/dist/extensions/caveman/package.json +0 -8
- package/dist/extensions/caveman/test/extension.test.js +0 -203
- package/dist/extensions/caveman/test/helpers.test.js +0 -58
- package/dist/skills/caveman/SKILL.md +0 -50
|
@@ -1,18 +1,118 @@
|
|
|
1
1
|
import net from "net";
|
|
2
|
-
import { writeFileSync, unlinkSync
|
|
2
|
+
import { writeFileSync, unlinkSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { randomUUID } from "crypto";
|
|
5
|
-
import { writeMessage, createMessageReader } from "./framing.
|
|
6
|
-
import {
|
|
7
|
-
|
|
5
|
+
import { writeMessage, createMessageReader } from "./framing.ts";
|
|
6
|
+
import {
|
|
7
|
+
ensureIntercomRuntimeDir,
|
|
8
|
+
getBrokerListenTarget,
|
|
9
|
+
getBrokerPortFilePath,
|
|
10
|
+
getIntercomDirPath,
|
|
11
|
+
INTERCOM_PROTOCOL_NAME,
|
|
12
|
+
INTERCOM_PROTOCOL_VERSION,
|
|
13
|
+
INTERCOM_RUNTIME_FILE_MODE,
|
|
14
|
+
restrictIntercomRuntimeFile,
|
|
15
|
+
type BrokerConnectTarget,
|
|
16
|
+
} from "./paths.ts";
|
|
17
|
+
import { getAskTimeoutMs } from "../config.ts";
|
|
18
|
+
import { sameCwd } from "../cwd.ts";
|
|
19
|
+
import { EXTENSION_BUS_FEATURE } from "../types.ts";
|
|
20
|
+
import type { SessionInfo, Message, Attachment, BrokerMessage, SessionRegistration, ExtensionCapability, MessageControl, MessageReceipt, MessageReceiptStatus } from "../types.ts";
|
|
21
|
+
import { ExtensionStateManager } from "./extension-state.ts";
|
|
22
|
+
import { assertNoLiveBroker } from "./runtime-claim.ts";
|
|
8
23
|
|
|
9
|
-
const INTERCOM_DIR =
|
|
10
|
-
const
|
|
24
|
+
const INTERCOM_DIR = getIntercomDirPath();
|
|
25
|
+
const LISTEN_TARGET = getBrokerListenTarget();
|
|
11
26
|
const PID_PATH = join(INTERCOM_DIR, "broker.pid");
|
|
27
|
+
const PORT_PATH = getBrokerPortFilePath(INTERCOM_DIR);
|
|
28
|
+
const BROKER_STATE_ID = randomUUID();
|
|
29
|
+
const MAX_SESSIONS = 128;
|
|
30
|
+
const MAX_UNREGISTERED_CONNECTIONS = 32;
|
|
31
|
+
const REGISTRATION_TIMEOUT_MS = 1000;
|
|
32
|
+
const RATE_LIMIT_CAPACITY = 240;
|
|
33
|
+
const RATE_LIMIT_REFILL_PER_SECOND = 120;
|
|
34
|
+
const PRESENCE_HEARTBEAT_MS = 1000;
|
|
35
|
+
const MAX_EXTENSIONS_PER_SESSION = 32;
|
|
36
|
+
const MAX_EXTENSION_MESSAGE_BYTES = 16 * 1024;
|
|
37
|
+
const MAX_EXTENSION_STATE_BYTES = 64 * 1024;
|
|
38
|
+
const MESSAGE_RECEIPT_ROUTE_RETENTION_MS = 60 * 60 * 1000;
|
|
39
|
+
const DISCONNECTED_SESSION_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
40
|
+
const MAILBOX_MESSAGE_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
41
|
+
const MAX_MAILBOX_MESSAGES = 256;
|
|
42
|
+
|
|
43
|
+
function serializedPayloadSize(payload: unknown): number | null {
|
|
44
|
+
try {
|
|
45
|
+
const json = JSON.stringify(payload);
|
|
46
|
+
return json === undefined ? null : Buffer.byteLength(json, "utf8");
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
12
51
|
|
|
13
52
|
interface ConnectedSession {
|
|
14
53
|
socket: net.Socket;
|
|
15
54
|
info: SessionInfo;
|
|
55
|
+
lastPresenceBroadcastAt: number;
|
|
56
|
+
ownerOrder: number;
|
|
57
|
+
extensions?: ExtensionCapability[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface NamespaceOwner {
|
|
61
|
+
sessionId: string;
|
|
62
|
+
socket: net.Socket;
|
|
63
|
+
epoch: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ConnectionState {
|
|
67
|
+
socket: net.Socket;
|
|
68
|
+
tokens: number;
|
|
69
|
+
lastRefillAt: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface AskEdge {
|
|
73
|
+
from: string;
|
|
74
|
+
to: string;
|
|
75
|
+
createdAt: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface MessageReceiptRoute {
|
|
79
|
+
from: string;
|
|
80
|
+
to: string;
|
|
81
|
+
createdAt: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface DisconnectedSession {
|
|
85
|
+
info: SessionInfo;
|
|
86
|
+
disconnectedAt: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface MailboxMessage {
|
|
90
|
+
from: SessionInfo;
|
|
91
|
+
target: SessionInfo;
|
|
92
|
+
message: Message;
|
|
93
|
+
queuedAt: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isMessageReceiptStatus(value: unknown): value is MessageReceiptStatus {
|
|
97
|
+
return value === "receiver_received"
|
|
98
|
+
|| value === "queued"
|
|
99
|
+
|| value === "injected"
|
|
100
|
+
|| value === "acknowledged"
|
|
101
|
+
|| value === "expired"
|
|
102
|
+
|| value === "cancelled"
|
|
103
|
+
|| value === "superseded"
|
|
104
|
+
|| value === "cancellation_requested";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isMessageReceipt(value: unknown): value is MessageReceipt {
|
|
108
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const receipt = value as Record<string, unknown>;
|
|
112
|
+
if (typeof receipt.messageId !== "string" || !isMessageReceiptStatus(receipt.status) || typeof receipt.timestamp !== "number") {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
return receipt.detail === undefined || typeof receipt.detail === "string";
|
|
16
116
|
}
|
|
17
117
|
|
|
18
118
|
function isAttachment(value: unknown): value is Attachment {
|
|
@@ -48,6 +148,20 @@ function isMessage(value: unknown): value is Message {
|
|
|
48
148
|
return false;
|
|
49
149
|
}
|
|
50
150
|
|
|
151
|
+
for (const key of ["senderSequence", "brokerReceivedAt", "brokerDeliveredAt", "receiverReceivedAt", "injectedAt"] as const) {
|
|
152
|
+
if (message[key] !== undefined && typeof message[key] !== "number") {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (message.supersedes !== undefined && typeof message.supersedes !== "string") {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (message.retryOf !== undefined && typeof message.retryOf !== "string") {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
51
165
|
if (message.replyTo !== undefined && typeof message.replyTo !== "string") {
|
|
52
166
|
return false;
|
|
53
167
|
}
|
|
@@ -69,7 +183,11 @@ function isMessage(value: unknown): value is Message {
|
|
|
69
183
|
|| (Array.isArray(content.attachments) && content.attachments.every(isAttachment));
|
|
70
184
|
}
|
|
71
185
|
|
|
72
|
-
function
|
|
186
|
+
function isSessionId(value: unknown): value is string {
|
|
187
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isSessionRegistration(value: unknown): value is SessionRegistration {
|
|
73
191
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
74
192
|
return false;
|
|
75
193
|
}
|
|
@@ -95,14 +213,26 @@ function isSessionRegistration(value: unknown): value is Omit<SessionInfo, "id">
|
|
|
95
213
|
|
|
96
214
|
class IntercomBroker {
|
|
97
215
|
private sessions = new Map<string, ConnectedSession>();
|
|
216
|
+
private askEdges = new Map<string, AskEdge>();
|
|
217
|
+
private messageReceiptRoutes = new Map<string, MessageReceiptRoute>();
|
|
218
|
+
private disconnectedSessions = new Map<string, DisconnectedSession>();
|
|
219
|
+
private mailboxMessages: MailboxMessage[] = [];
|
|
220
|
+
private connections = new Set<net.Socket>();
|
|
221
|
+
private unregisteredConnections = new Set<net.Socket>();
|
|
98
222
|
private server: net.Server;
|
|
99
223
|
private shutdownTimer: NodeJS.Timeout | null = null;
|
|
224
|
+
private readonly askTimeoutMs = getAskTimeoutMs();
|
|
225
|
+
private namespaceOwners = new Map<string, NamespaceOwner>();
|
|
226
|
+
private nextOwnerOrder = 1;
|
|
227
|
+
private extensionStateManager: ExtensionStateManager;
|
|
100
228
|
|
|
101
229
|
constructor() {
|
|
102
|
-
|
|
103
|
-
|
|
230
|
+
ensureIntercomRuntimeDir(INTERCOM_DIR);
|
|
231
|
+
assertNoLiveBroker(PID_PATH);
|
|
232
|
+
this.extensionStateManager = new ExtensionStateManager(INTERCOM_DIR);
|
|
233
|
+
if (typeof LISTEN_TARGET === "string" && process.platform !== "win32") {
|
|
104
234
|
try {
|
|
105
|
-
unlinkSync(
|
|
235
|
+
unlinkSync(LISTEN_TARGET);
|
|
106
236
|
} catch {
|
|
107
237
|
// A clean startup has no stale socket to remove.
|
|
108
238
|
}
|
|
@@ -111,20 +241,82 @@ class IntercomBroker {
|
|
|
111
241
|
}
|
|
112
242
|
|
|
113
243
|
start(): void {
|
|
114
|
-
|
|
115
|
-
|
|
244
|
+
const onListening = () => {
|
|
245
|
+
if (typeof LISTEN_TARGET === "string") {
|
|
246
|
+
restrictIntercomRuntimeFile(LISTEN_TARGET);
|
|
247
|
+
} else {
|
|
248
|
+
const address = this.server.address();
|
|
249
|
+
if (!address || typeof address === "string") {
|
|
250
|
+
throw new Error("Intercom TCP broker started without a TCP address");
|
|
251
|
+
}
|
|
252
|
+
const endpoint: BrokerConnectTarget = {
|
|
253
|
+
transport: "tcp",
|
|
254
|
+
host: LISTEN_TARGET.host,
|
|
255
|
+
port: address.port,
|
|
256
|
+
stateId: BROKER_STATE_ID,
|
|
257
|
+
};
|
|
258
|
+
writeFileSync(PORT_PATH, `${JSON.stringify(endpoint)}\n`, { mode: INTERCOM_RUNTIME_FILE_MODE });
|
|
259
|
+
restrictIntercomRuntimeFile(PORT_PATH);
|
|
260
|
+
}
|
|
261
|
+
writeFileSync(PID_PATH, String(process.pid), { mode: INTERCOM_RUNTIME_FILE_MODE });
|
|
262
|
+
restrictIntercomRuntimeFile(PID_PATH);
|
|
116
263
|
console.log(`Intercom broker started (pid: ${process.pid})`);
|
|
117
|
-
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
if (typeof LISTEN_TARGET === "string") {
|
|
267
|
+
this.server.listen(LISTEN_TARGET, onListening);
|
|
268
|
+
} else {
|
|
269
|
+
this.server.listen({ host: LISTEN_TARGET.host, port: LISTEN_TARGET.port }, onListening);
|
|
270
|
+
}
|
|
118
271
|
process.on("SIGTERM", () => this.shutdown());
|
|
119
272
|
process.on("SIGINT", () => this.shutdown());
|
|
120
273
|
}
|
|
121
274
|
|
|
122
275
|
private handleConnection(socket: net.Socket): void {
|
|
276
|
+
this.connections.add(socket);
|
|
123
277
|
let sessionId: string | null = null;
|
|
278
|
+
let registrationTimeout: NodeJS.Timeout | null = null;
|
|
279
|
+
const armRegistrationTimeout = () => {
|
|
280
|
+
if (registrationTimeout) {
|
|
281
|
+
clearTimeout(registrationTimeout);
|
|
282
|
+
}
|
|
283
|
+
this.unregisteredConnections.delete(socket);
|
|
284
|
+
this.unregisteredConnections.add(socket);
|
|
285
|
+
this.evictOldestUnregisteredConnections(socket);
|
|
286
|
+
registrationTimeout = setTimeout(() => {
|
|
287
|
+
if (!sessionId) {
|
|
288
|
+
socket.destroy();
|
|
289
|
+
}
|
|
290
|
+
}, REGISTRATION_TIMEOUT_MS);
|
|
291
|
+
registrationTimeout.unref?.();
|
|
292
|
+
};
|
|
293
|
+
const clearRegistrationTimeout = () => {
|
|
294
|
+
if (registrationTimeout) {
|
|
295
|
+
clearTimeout(registrationTimeout);
|
|
296
|
+
registrationTimeout = null;
|
|
297
|
+
}
|
|
298
|
+
this.unregisteredConnections.delete(socket);
|
|
299
|
+
};
|
|
300
|
+
armRegistrationTimeout();
|
|
301
|
+
const connection: ConnectionState = {
|
|
302
|
+
socket,
|
|
303
|
+
tokens: RATE_LIMIT_CAPACITY,
|
|
304
|
+
lastRefillAt: Date.now(),
|
|
305
|
+
};
|
|
124
306
|
|
|
125
307
|
const reader = createMessageReader((msg) => {
|
|
308
|
+
if (!this.consumeToken(connection)) {
|
|
309
|
+
writeMessage(socket, { type: "error", error: "Intercom broker rate limit exceeded" });
|
|
310
|
+
socket.destroy(new Error("Intercom broker rate limit exceeded"));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
126
313
|
this.handleMessage(socket, msg, sessionId, (id) => {
|
|
127
314
|
sessionId = id;
|
|
315
|
+
if (id) {
|
|
316
|
+
clearRegistrationTimeout();
|
|
317
|
+
} else {
|
|
318
|
+
armRegistrationTimeout();
|
|
319
|
+
}
|
|
128
320
|
});
|
|
129
321
|
}, (error) => {
|
|
130
322
|
socket.destroy(error);
|
|
@@ -133,11 +325,18 @@ class IntercomBroker {
|
|
|
133
325
|
socket.on("data", reader);
|
|
134
326
|
|
|
135
327
|
socket.on("close", () => {
|
|
328
|
+
clearRegistrationTimeout();
|
|
329
|
+
this.connections.delete(socket);
|
|
136
330
|
if (sessionId) {
|
|
137
|
-
this.sessions.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
331
|
+
const existing = this.sessions.get(sessionId);
|
|
332
|
+
if (existing?.socket === socket) {
|
|
333
|
+
this.rememberDisconnectedSession(existing.info);
|
|
334
|
+
this.sessions.delete(sessionId);
|
|
335
|
+
this.clearMessageReceiptRoutesForSession(sessionId);
|
|
336
|
+
this.broadcast({ type: "session_left", sessionId }, sessionId);
|
|
337
|
+
this.recomputeNamespaceOwners();
|
|
338
|
+
this.scheduleShutdownCheck();
|
|
339
|
+
}
|
|
141
340
|
}
|
|
142
341
|
});
|
|
143
342
|
|
|
@@ -146,6 +345,36 @@ class IntercomBroker {
|
|
|
146
345
|
});
|
|
147
346
|
}
|
|
148
347
|
|
|
348
|
+
private evictOldestUnregisteredConnections(currentSocket: net.Socket): void {
|
|
349
|
+
while (this.unregisteredConnections.size > MAX_UNREGISTERED_CONNECTIONS) {
|
|
350
|
+
const [oldest] = this.unregisteredConnections;
|
|
351
|
+
if (!oldest) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (oldest === currentSocket && this.unregisteredConnections.size === 1) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
this.unregisteredConnections.delete(oldest);
|
|
358
|
+
oldest.destroy();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private consumeToken(connection: ConnectionState, now = Date.now()): boolean {
|
|
363
|
+
const elapsedMs = now - connection.lastRefillAt;
|
|
364
|
+
if (elapsedMs > 0) {
|
|
365
|
+
connection.tokens = Math.min(
|
|
366
|
+
RATE_LIMIT_CAPACITY,
|
|
367
|
+
connection.tokens + elapsedMs * RATE_LIMIT_REFILL_PER_SECOND / 1000,
|
|
368
|
+
);
|
|
369
|
+
connection.lastRefillAt = now;
|
|
370
|
+
}
|
|
371
|
+
if (connection.tokens < 1) {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
connection.tokens -= 1;
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
|
|
149
378
|
private scheduleShutdownCheck(): void {
|
|
150
379
|
if (this.shutdownTimer) return;
|
|
151
380
|
|
|
@@ -169,6 +398,28 @@ class IntercomBroker {
|
|
|
169
398
|
}
|
|
170
399
|
|
|
171
400
|
const clientMessage = msg as { type: string } & Record<string, unknown>;
|
|
401
|
+
const requiresEndpointAuth = typeof LISTEN_TARGET !== "string";
|
|
402
|
+
const hasEndpointAuth = clientMessage.stateId === BROKER_STATE_ID;
|
|
403
|
+
|
|
404
|
+
if (clientMessage.type === "health") {
|
|
405
|
+
if (typeof clientMessage.requestId !== "string") {
|
|
406
|
+
throw new Error("Invalid health message");
|
|
407
|
+
}
|
|
408
|
+
if (requiresEndpointAuth && !hasEndpointAuth) {
|
|
409
|
+
throw new Error("Invalid intercom TCP endpoint credentials");
|
|
410
|
+
}
|
|
411
|
+
writeMessage(socket, {
|
|
412
|
+
type: "health_ok",
|
|
413
|
+
requestId: clientMessage.requestId,
|
|
414
|
+
protocol: INTERCOM_PROTOCOL_NAME,
|
|
415
|
+
version: INTERCOM_PROTOCOL_VERSION,
|
|
416
|
+
});
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (requiresEndpointAuth && clientMessage.type === "register" && !hasEndpointAuth) {
|
|
421
|
+
throw new Error("Invalid intercom TCP endpoint credentials");
|
|
422
|
+
}
|
|
172
423
|
|
|
173
424
|
if (currentId === null && clientMessage.type !== "register") {
|
|
174
425
|
throw new Error(`Received ${clientMessage.type} before register`);
|
|
@@ -184,26 +435,155 @@ class IntercomBroker {
|
|
|
184
435
|
throw new Error("Received duplicate register message");
|
|
185
436
|
}
|
|
186
437
|
|
|
187
|
-
|
|
438
|
+
let id: string = randomUUID();
|
|
439
|
+
if (clientMessage.sessionId !== undefined) {
|
|
440
|
+
if (!isSessionId(clientMessage.sessionId)) {
|
|
441
|
+
throw new Error("Invalid register sessionId");
|
|
442
|
+
}
|
|
443
|
+
id = clientMessage.sessionId;
|
|
444
|
+
}
|
|
445
|
+
const session = clientMessage.session;
|
|
446
|
+
const extensions = session.extensions;
|
|
447
|
+
if (extensions !== undefined) {
|
|
448
|
+
if (!Array.isArray(extensions) || extensions.length > MAX_EXTENSIONS_PER_SESSION) {
|
|
449
|
+
throw new Error(`Invalid extensions field (maximum ${MAX_EXTENSIONS_PER_SESSION})`);
|
|
450
|
+
}
|
|
451
|
+
for (const extension of extensions) {
|
|
452
|
+
if (!this.validateExtensionCapability(extension)) {
|
|
453
|
+
throw new Error(`Invalid extension capability: ${JSON.stringify(extension)}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
this.pruneDisconnectedSessions();
|
|
459
|
+
this.pruneMailboxMessages();
|
|
460
|
+
const previous = this.sessions.get(id);
|
|
461
|
+
if (!previous && this.sessions.size >= MAX_SESSIONS) {
|
|
462
|
+
writeMessage(socket, { type: "error", error: "Too many registered intercom sessions" });
|
|
463
|
+
socket.destroy();
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
if (previous) {
|
|
467
|
+
this.clearAskEdgesForSession(id);
|
|
468
|
+
this.clearMessageReceiptRoutesForSession(id);
|
|
469
|
+
previous.socket.end();
|
|
470
|
+
}
|
|
188
471
|
setId(id);
|
|
189
|
-
const info: SessionInfo = {
|
|
190
|
-
|
|
472
|
+
const info: SessionInfo = {
|
|
473
|
+
id,
|
|
474
|
+
...(session.name !== undefined ? { name: session.name } : {}),
|
|
475
|
+
cwd: session.cwd,
|
|
476
|
+
model: session.model,
|
|
477
|
+
pid: session.pid,
|
|
478
|
+
startedAt: session.startedAt,
|
|
479
|
+
lastActivity: session.lastActivity,
|
|
480
|
+
...(session.status !== undefined ? { status: session.status } : {}),
|
|
481
|
+
trustedLocal: typeof LISTEN_TARGET === "string" && process.platform !== "win32",
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const connectedSession: ConnectedSession = {
|
|
485
|
+
socket,
|
|
486
|
+
info,
|
|
487
|
+
lastPresenceBroadcastAt: Date.now(),
|
|
488
|
+
ownerOrder: previous?.ownerOrder ?? this.nextOwnerOrder++,
|
|
489
|
+
extensions,
|
|
490
|
+
};
|
|
491
|
+
this.sessions.set(id, connectedSession);
|
|
492
|
+
this.disconnectedSessions.delete(id);
|
|
191
493
|
|
|
192
494
|
if (this.shutdownTimer) {
|
|
193
495
|
clearTimeout(this.shutdownTimer);
|
|
194
496
|
this.shutdownTimer = null;
|
|
195
497
|
}
|
|
196
498
|
|
|
197
|
-
|
|
499
|
+
// This must be the first broker message. Older clients ignore the
|
|
500
|
+
// additive features field; newer clients use it to avoid sending
|
|
501
|
+
// extension operations to an older broker.
|
|
502
|
+
writeMessage(socket, {
|
|
503
|
+
type: "registered",
|
|
504
|
+
sessionId: id,
|
|
505
|
+
features: [EXTENSION_BUS_FEATURE],
|
|
506
|
+
});
|
|
198
507
|
this.broadcast({ type: "session_joined", session: info }, id);
|
|
508
|
+
|
|
509
|
+
this.recomputeNamespaceOwners();
|
|
510
|
+
this.flushMailboxForSession(connectedSession);
|
|
511
|
+
|
|
512
|
+
if (extensions) {
|
|
513
|
+
for (const ext of extensions) {
|
|
514
|
+
const owner = this.namespaceOwners.get(ext.namespace);
|
|
515
|
+
writeMessage(socket, {
|
|
516
|
+
type: "extension_owner",
|
|
517
|
+
namespace: ext.namespace,
|
|
518
|
+
...(owner ? { ownerId: owner.sessionId, ownerEpoch: owner.epoch } : {}),
|
|
519
|
+
});
|
|
520
|
+
const state = this.extensionStateManager.loadState(ext.namespace);
|
|
521
|
+
if (state) {
|
|
522
|
+
writeMessage(socket, {
|
|
523
|
+
type: "extension_state",
|
|
524
|
+
namespace: ext.namespace,
|
|
525
|
+
revision: state.revision,
|
|
526
|
+
payload: state.payload,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
199
531
|
break;
|
|
200
532
|
}
|
|
201
533
|
|
|
202
534
|
case "unregister": {
|
|
203
|
-
|
|
204
|
-
|
|
535
|
+
if (!currentId) {
|
|
536
|
+
throw new Error("Received unregister before register");
|
|
537
|
+
}
|
|
538
|
+
const existing = this.sessions.get(currentId);
|
|
539
|
+
if (existing?.socket === socket) {
|
|
540
|
+
this.rememberDisconnectedSession(existing.info);
|
|
541
|
+
this.sessions.delete(currentId);
|
|
542
|
+
this.clearMessageReceiptRoutesForSession(currentId);
|
|
543
|
+
this.broadcast({ type: "session_left", sessionId: currentId }, currentId);
|
|
544
|
+
this.recomputeNamespaceOwners();
|
|
545
|
+
this.scheduleShutdownCheck();
|
|
546
|
+
}
|
|
205
547
|
setId(null);
|
|
206
|
-
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
case "extension_capabilities_update": {
|
|
552
|
+
if (!currentId) {
|
|
553
|
+
throw new Error("Received extension_capabilities_update before register");
|
|
554
|
+
}
|
|
555
|
+
const session = this.sessions.get(currentId);
|
|
556
|
+
if (!session || session.socket !== socket) {
|
|
557
|
+
throw new Error("Extension capability session not found");
|
|
558
|
+
}
|
|
559
|
+
const extensions = clientMessage.extensions;
|
|
560
|
+
if (!Array.isArray(extensions) || extensions.length > MAX_EXTENSIONS_PER_SESSION) {
|
|
561
|
+
throw new Error(`Invalid extensions field (maximum ${MAX_EXTENSIONS_PER_SESSION})`);
|
|
562
|
+
}
|
|
563
|
+
for (const extension of extensions) {
|
|
564
|
+
if (!this.validateExtensionCapability(extension)) {
|
|
565
|
+
throw new Error(`Invalid extension capability: ${JSON.stringify(extension)}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
session.extensions = extensions;
|
|
569
|
+
this.recomputeNamespaceOwners();
|
|
570
|
+
for (const extension of extensions) {
|
|
571
|
+
const owner = this.namespaceOwners.get(extension.namespace);
|
|
572
|
+
writeMessage(socket, {
|
|
573
|
+
type: "extension_owner",
|
|
574
|
+
namespace: extension.namespace,
|
|
575
|
+
...(owner ? { ownerId: owner.sessionId, ownerEpoch: owner.epoch } : {}),
|
|
576
|
+
});
|
|
577
|
+
const state = this.extensionStateManager.loadState(extension.namespace);
|
|
578
|
+
if (state) {
|
|
579
|
+
writeMessage(socket, {
|
|
580
|
+
type: "extension_state",
|
|
581
|
+
namespace: extension.namespace,
|
|
582
|
+
revision: state.revision,
|
|
583
|
+
payload: state.payload,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
207
587
|
break;
|
|
208
588
|
}
|
|
209
589
|
|
|
@@ -218,6 +598,9 @@ class IntercomBroker {
|
|
|
218
598
|
}
|
|
219
599
|
|
|
220
600
|
case "send": {
|
|
601
|
+
if (!currentId) {
|
|
602
|
+
throw new Error("Received send before register");
|
|
603
|
+
}
|
|
221
604
|
const message = clientMessage.message;
|
|
222
605
|
const messageId = isMessage(message) ? message.id : "unknown";
|
|
223
606
|
|
|
@@ -230,10 +613,23 @@ class IntercomBroker {
|
|
|
230
613
|
break;
|
|
231
614
|
}
|
|
232
615
|
|
|
616
|
+
const brokerReceivedAt = Date.now();
|
|
617
|
+
this.pruneAskEdges();
|
|
618
|
+
this.pruneMessageReceiptRoutes(brokerReceivedAt);
|
|
619
|
+
const replyEdge = message.replyTo ? this.askEdges.get(message.replyTo) : undefined;
|
|
620
|
+
|
|
233
621
|
const targets = this.findSessions(clientMessage.to);
|
|
234
622
|
if (targets.length === 1) {
|
|
623
|
+
if (message.replyTo && !replyEdge) {
|
|
624
|
+
writeMessage(socket, {
|
|
625
|
+
type: "delivery_failed",
|
|
626
|
+
messageId: message.id,
|
|
627
|
+
reason: "Reply target does not match a pending ask",
|
|
628
|
+
});
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
235
631
|
const fromSession = this.sessions.get(currentId);
|
|
236
|
-
if (!fromSession) {
|
|
632
|
+
if (!fromSession || fromSession.socket !== socket) {
|
|
237
633
|
writeMessage(socket, {
|
|
238
634
|
type: "delivery_failed",
|
|
239
635
|
messageId: message.id,
|
|
@@ -241,11 +637,65 @@ class IntercomBroker {
|
|
|
241
637
|
});
|
|
242
638
|
break;
|
|
243
639
|
}
|
|
244
|
-
|
|
640
|
+
const target = targets[0];
|
|
641
|
+
if (message.supersedes) {
|
|
642
|
+
const supersededRoute = this.messageReceiptRoutes.get(message.supersedes);
|
|
643
|
+
if (!supersededRoute || supersededRoute.from !== currentId || supersededRoute.to !== target.info.id) {
|
|
644
|
+
writeMessage(socket, {
|
|
645
|
+
type: "delivery_failed",
|
|
646
|
+
messageId: message.id,
|
|
647
|
+
reason: "Supersede target does not match a previous message from this sender to this receiver",
|
|
648
|
+
});
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (replyEdge && (replyEdge.to !== currentId || replyEdge.from !== target.info.id)) {
|
|
653
|
+
writeMessage(socket, {
|
|
654
|
+
type: "delivery_failed",
|
|
655
|
+
messageId: message.id,
|
|
656
|
+
reason: "Reply target does not match the pending ask",
|
|
657
|
+
});
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
if (message.expectsReply) {
|
|
661
|
+
const reverseEdge = Array.from(this.askEdges.entries()).find(([edgeMessageId, edge]) => edgeMessageId !== message.replyTo && edge.from === target.info.id && edge.to === currentId);
|
|
662
|
+
if (reverseEdge) {
|
|
663
|
+
writeMessage(socket, {
|
|
664
|
+
type: "delivery_failed",
|
|
665
|
+
messageId: message.id,
|
|
666
|
+
reason: "Mutual ask refused: target session is already waiting for a reply from this session.",
|
|
667
|
+
});
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
this.askEdges.set(message.id, { from: currentId, to: target.info.id, createdAt: Date.now() });
|
|
671
|
+
}
|
|
672
|
+
const deliveredMessage: Message = {
|
|
673
|
+
...message,
|
|
674
|
+
brokerReceivedAt,
|
|
675
|
+
brokerDeliveredAt: Date.now(),
|
|
676
|
+
};
|
|
677
|
+
if (message.supersedes) {
|
|
678
|
+
const control: MessageControl = {
|
|
679
|
+
action: "supersede",
|
|
680
|
+
messageId: message.supersedes,
|
|
681
|
+
supersededBy: message.id,
|
|
682
|
+
timestamp: Date.now(),
|
|
683
|
+
};
|
|
684
|
+
writeMessage(target.socket, {
|
|
685
|
+
type: "message_control",
|
|
686
|
+
from: fromSession.info,
|
|
687
|
+
control,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
writeMessage(target.socket, {
|
|
245
691
|
type: "message",
|
|
246
692
|
from: fromSession.info,
|
|
247
|
-
message,
|
|
693
|
+
message: deliveredMessage,
|
|
248
694
|
});
|
|
695
|
+
if (message.replyTo) {
|
|
696
|
+
this.askEdges.delete(message.replyTo);
|
|
697
|
+
}
|
|
698
|
+
this.messageReceiptRoutes.set(message.id, { from: currentId, to: target.info.id, createdAt: brokerReceivedAt });
|
|
249
699
|
writeMessage(socket, { type: "delivered", messageId: message.id });
|
|
250
700
|
break;
|
|
251
701
|
}
|
|
@@ -259,6 +709,87 @@ class IntercomBroker {
|
|
|
259
709
|
break;
|
|
260
710
|
}
|
|
261
711
|
|
|
712
|
+
const disconnectedTargets = this.findDisconnectedSessions(clientMessage.to);
|
|
713
|
+
if (disconnectedTargets.length === 1) {
|
|
714
|
+
if (message.replyTo && !replyEdge) {
|
|
715
|
+
writeMessage(socket, {
|
|
716
|
+
type: "delivery_failed",
|
|
717
|
+
messageId: message.id,
|
|
718
|
+
reason: "Reply target does not match a pending ask",
|
|
719
|
+
});
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
const fromSession = this.sessions.get(currentId);
|
|
723
|
+
if (!fromSession || fromSession.socket !== socket) {
|
|
724
|
+
writeMessage(socket, {
|
|
725
|
+
type: "delivery_failed",
|
|
726
|
+
messageId: message.id,
|
|
727
|
+
reason: "Sender session not found",
|
|
728
|
+
});
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
const target = disconnectedTargets[0]!.info;
|
|
732
|
+
if (message.supersedes) {
|
|
733
|
+
writeMessage(socket, {
|
|
734
|
+
type: "delivery_failed",
|
|
735
|
+
messageId: message.id,
|
|
736
|
+
reason: "Supersede target is not connected",
|
|
737
|
+
});
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
if (replyEdge && (replyEdge.to !== currentId || replyEdge.from !== target.id)) {
|
|
741
|
+
writeMessage(socket, {
|
|
742
|
+
type: "delivery_failed",
|
|
743
|
+
messageId: message.id,
|
|
744
|
+
reason: "Reply target does not match the pending ask",
|
|
745
|
+
});
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
const liveMailboxTarget = this.findUniqueLiveSessionForDisconnectedSession(target);
|
|
749
|
+
const effectiveTargetId = liveMailboxTarget?.info.id ?? target.id;
|
|
750
|
+
if (message.expectsReply) {
|
|
751
|
+
const reverseEdge = Array.from(this.askEdges.entries()).find(([edgeMessageId, edge]) => edgeMessageId !== message.replyTo && edge.from === effectiveTargetId && edge.to === currentId);
|
|
752
|
+
if (reverseEdge) {
|
|
753
|
+
writeMessage(socket, {
|
|
754
|
+
type: "delivery_failed",
|
|
755
|
+
messageId: message.id,
|
|
756
|
+
reason: "Mutual ask refused: target session is already waiting for a reply from this session.",
|
|
757
|
+
});
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
this.askEdges.set(message.id, { from: currentId, to: effectiveTargetId, createdAt: Date.now() });
|
|
761
|
+
}
|
|
762
|
+
if (liveMailboxTarget) {
|
|
763
|
+
const deliveredMessage: Message = {
|
|
764
|
+
...message,
|
|
765
|
+
brokerReceivedAt,
|
|
766
|
+
brokerDeliveredAt: Date.now(),
|
|
767
|
+
};
|
|
768
|
+
writeMessage(liveMailboxTarget.socket, {
|
|
769
|
+
type: "message",
|
|
770
|
+
from: fromSession.info,
|
|
771
|
+
message: deliveredMessage,
|
|
772
|
+
});
|
|
773
|
+
this.messageReceiptRoutes.set(message.id, { from: currentId, to: liveMailboxTarget.info.id, createdAt: brokerReceivedAt });
|
|
774
|
+
} else {
|
|
775
|
+
this.queueMailboxMessage(fromSession.info, target, message, brokerReceivedAt);
|
|
776
|
+
}
|
|
777
|
+
if (message.replyTo) {
|
|
778
|
+
this.askEdges.delete(message.replyTo);
|
|
779
|
+
}
|
|
780
|
+
writeMessage(socket, { type: "delivered", messageId: message.id });
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (disconnectedTargets.length > 1) {
|
|
785
|
+
writeMessage(socket, {
|
|
786
|
+
type: "delivery_failed",
|
|
787
|
+
messageId: message.id,
|
|
788
|
+
reason: `Multiple disconnected sessions named \"${clientMessage.to}\" can receive queued mail. Use the session ID instead.`,
|
|
789
|
+
});
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
|
|
262
793
|
writeMessage(socket, {
|
|
263
794
|
type: "delivery_failed",
|
|
264
795
|
messageId: message.id,
|
|
@@ -267,38 +798,298 @@ class IntercomBroker {
|
|
|
267
798
|
break;
|
|
268
799
|
}
|
|
269
800
|
|
|
801
|
+
case "message_receipt": {
|
|
802
|
+
if (!currentId) {
|
|
803
|
+
throw new Error("Received message_receipt before register");
|
|
804
|
+
}
|
|
805
|
+
if (!isMessageReceipt(clientMessage.receipt)) {
|
|
806
|
+
throw new Error("Invalid message_receipt message");
|
|
807
|
+
}
|
|
808
|
+
this.pruneMessageReceiptRoutes();
|
|
809
|
+
const route = this.messageReceiptRoutes.get(clientMessage.receipt.messageId);
|
|
810
|
+
const receiver = this.sessions.get(currentId);
|
|
811
|
+
const sender = route ? this.sessions.get(route.from) : undefined;
|
|
812
|
+
if (route?.to === currentId && receiver?.socket === socket && sender) {
|
|
813
|
+
writeMessage(sender.socket, {
|
|
814
|
+
type: "message_receipt",
|
|
815
|
+
from: receiver.info,
|
|
816
|
+
receipt: clientMessage.receipt,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
break;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
case "cancel_message": {
|
|
823
|
+
if (!currentId) {
|
|
824
|
+
throw new Error("Received cancel_message before register");
|
|
825
|
+
}
|
|
826
|
+
if (typeof clientMessage.messageId !== "string") {
|
|
827
|
+
throw new Error("Invalid cancel_message message");
|
|
828
|
+
}
|
|
829
|
+
this.pruneMessageReceiptRoutes();
|
|
830
|
+
this.pruneMailboxMessages();
|
|
831
|
+
const sender = this.sessions.get(currentId);
|
|
832
|
+
const queuedIndex = this.mailboxMessages.findIndex(entry => entry.message.id === clientMessage.messageId && entry.from.id === currentId);
|
|
833
|
+
if (queuedIndex >= 0 && sender?.socket === socket) {
|
|
834
|
+
this.mailboxMessages.splice(queuedIndex, 1);
|
|
835
|
+
const edge = this.askEdges.get(clientMessage.messageId);
|
|
836
|
+
if (edge?.from === currentId) {
|
|
837
|
+
this.askEdges.delete(clientMessage.messageId);
|
|
838
|
+
}
|
|
839
|
+
writeMessage(socket, { type: "delivered", messageId: clientMessage.messageId });
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
const route = this.messageReceiptRoutes.get(clientMessage.messageId);
|
|
843
|
+
const receiver = route ? this.sessions.get(route.to) : undefined;
|
|
844
|
+
if (route?.from !== currentId || sender?.socket !== socket || !receiver) {
|
|
845
|
+
writeMessage(socket, {
|
|
846
|
+
type: "delivery_failed",
|
|
847
|
+
messageId: clientMessage.messageId,
|
|
848
|
+
reason: "Message cannot be cancelled by this session",
|
|
849
|
+
});
|
|
850
|
+
break;
|
|
851
|
+
}
|
|
852
|
+
writeMessage(receiver.socket, {
|
|
853
|
+
type: "message_control",
|
|
854
|
+
from: sender.info,
|
|
855
|
+
control: {
|
|
856
|
+
action: "cancel",
|
|
857
|
+
messageId: clientMessage.messageId,
|
|
858
|
+
timestamp: Date.now(),
|
|
859
|
+
},
|
|
860
|
+
});
|
|
861
|
+
const edge = this.askEdges.get(clientMessage.messageId);
|
|
862
|
+
if (edge?.from === currentId) {
|
|
863
|
+
this.askEdges.delete(clientMessage.messageId);
|
|
864
|
+
}
|
|
865
|
+
writeMessage(socket, { type: "delivered", messageId: clientMessage.messageId });
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
case "cancel_ask": {
|
|
870
|
+
if (!currentId) {
|
|
871
|
+
throw new Error("Received cancel_ask before register");
|
|
872
|
+
}
|
|
873
|
+
if (typeof clientMessage.messageId !== "string") {
|
|
874
|
+
throw new Error("Invalid cancel_ask message");
|
|
875
|
+
}
|
|
876
|
+
const session = this.sessions.get(currentId);
|
|
877
|
+
const edge = this.askEdges.get(clientMessage.messageId);
|
|
878
|
+
if (session?.socket === socket && edge?.from === currentId) {
|
|
879
|
+
this.askEdges.delete(clientMessage.messageId);
|
|
880
|
+
}
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
|
|
270
884
|
case "presence": {
|
|
885
|
+
if (!currentId) {
|
|
886
|
+
throw new Error("Received presence before register");
|
|
887
|
+
}
|
|
271
888
|
const session = this.sessions.get(currentId);
|
|
272
|
-
if (session) {
|
|
889
|
+
if (session?.socket === socket) {
|
|
890
|
+
let changed = false;
|
|
273
891
|
if (clientMessage.name !== undefined) {
|
|
274
892
|
if (typeof clientMessage.name !== "string") {
|
|
275
893
|
throw new Error("Invalid presence name");
|
|
276
894
|
}
|
|
277
|
-
session.info.name
|
|
895
|
+
if (session.info.name !== clientMessage.name) {
|
|
896
|
+
session.info.name = clientMessage.name;
|
|
897
|
+
changed = true;
|
|
898
|
+
}
|
|
278
899
|
}
|
|
279
900
|
if (clientMessage.status !== undefined) {
|
|
280
901
|
if (typeof clientMessage.status !== "string") {
|
|
281
902
|
throw new Error("Invalid presence status");
|
|
282
903
|
}
|
|
283
|
-
session.info.status
|
|
904
|
+
if (session.info.status !== clientMessage.status) {
|
|
905
|
+
session.info.status = clientMessage.status;
|
|
906
|
+
changed = true;
|
|
907
|
+
}
|
|
284
908
|
}
|
|
285
909
|
if (clientMessage.model !== undefined) {
|
|
286
910
|
if (typeof clientMessage.model !== "string") {
|
|
287
911
|
throw new Error("Invalid presence model");
|
|
288
912
|
}
|
|
289
|
-
session.info.model
|
|
913
|
+
if (session.info.model !== clientMessage.model) {
|
|
914
|
+
session.info.model = clientMessage.model;
|
|
915
|
+
changed = true;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// Context-usage fields: a number updates, an explicit null CLEARS (the
|
|
919
|
+
// value is unknown right after a compaction — delete rather than carry
|
|
920
|
+
// the stale-high value forward), undefined leaves the field untouched.
|
|
921
|
+
if (clientMessage.contextPct !== undefined) {
|
|
922
|
+
if (clientMessage.contextPct === null) {
|
|
923
|
+
if (session.info.contextPct !== undefined) { delete session.info.contextPct; changed = true; }
|
|
924
|
+
} else if (typeof clientMessage.contextPct !== "number") {
|
|
925
|
+
throw new Error("Invalid presence contextPct");
|
|
926
|
+
} else if (session.info.contextPct !== clientMessage.contextPct) {
|
|
927
|
+
session.info.contextPct = clientMessage.contextPct;
|
|
928
|
+
changed = true;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (clientMessage.contextTokens !== undefined) {
|
|
932
|
+
if (clientMessage.contextTokens === null) {
|
|
933
|
+
if (session.info.contextTokens !== undefined) { delete session.info.contextTokens; changed = true; }
|
|
934
|
+
} else if (typeof clientMessage.contextTokens !== "number") {
|
|
935
|
+
throw new Error("Invalid presence contextTokens");
|
|
936
|
+
} else if (session.info.contextTokens !== clientMessage.contextTokens) {
|
|
937
|
+
session.info.contextTokens = clientMessage.contextTokens;
|
|
938
|
+
changed = true;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
if (clientMessage.contextWindow !== undefined) {
|
|
942
|
+
if (clientMessage.contextWindow === null) {
|
|
943
|
+
if (session.info.contextWindow !== undefined) { delete session.info.contextWindow; changed = true; }
|
|
944
|
+
} else if (typeof clientMessage.contextWindow !== "number") {
|
|
945
|
+
throw new Error("Invalid presence contextWindow");
|
|
946
|
+
} else if (session.info.contextWindow !== clientMessage.contextWindow) {
|
|
947
|
+
session.info.contextWindow = clientMessage.contextWindow;
|
|
948
|
+
changed = true;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const now = Date.now();
|
|
952
|
+
session.info.lastActivity = now;
|
|
953
|
+
if (changed || now - session.lastPresenceBroadcastAt >= PRESENCE_HEARTBEAT_MS) {
|
|
954
|
+
session.lastPresenceBroadcastAt = now;
|
|
955
|
+
this.broadcast({ type: "presence_update", session: session.info }, currentId);
|
|
290
956
|
}
|
|
291
|
-
session.info.lastActivity = Date.now();
|
|
292
|
-
this.broadcast({ type: "presence_update", session: session.info }, currentId);
|
|
293
957
|
}
|
|
294
958
|
break;
|
|
295
959
|
}
|
|
296
960
|
|
|
961
|
+
case "extension_publish": {
|
|
962
|
+
this.handleExtensionPublish(socket, currentId, clientMessage);
|
|
963
|
+
break;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
case "extension_state_commit": {
|
|
967
|
+
this.handleExtensionStateCommit(socket, currentId, clientMessage);
|
|
968
|
+
break;
|
|
969
|
+
}
|
|
970
|
+
|
|
297
971
|
default:
|
|
298
972
|
throw new Error(`Unknown client message type: ${clientMessage.type}`);
|
|
299
973
|
}
|
|
300
974
|
}
|
|
301
975
|
|
|
976
|
+
private rememberDisconnectedSession(info: SessionInfo, now = Date.now()): void {
|
|
977
|
+
this.disconnectedSessions.set(info.id, { info: { ...info }, disconnectedAt: now });
|
|
978
|
+
this.pruneDisconnectedSessions(now);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
private pruneDisconnectedSessions(now = Date.now()): void {
|
|
982
|
+
for (const [sessionId, session] of this.disconnectedSessions) {
|
|
983
|
+
if (now - session.disconnectedAt > DISCONNECTED_SESSION_RETENTION_MS) {
|
|
984
|
+
this.disconnectedSessions.delete(sessionId);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
private pruneMailboxMessages(now = Date.now()): void {
|
|
990
|
+
for (let index = this.mailboxMessages.length - 1; index >= 0; index -= 1) {
|
|
991
|
+
const entry = this.mailboxMessages[index]!;
|
|
992
|
+
if (now - entry.queuedAt > MAILBOX_MESSAGE_RETENTION_MS) {
|
|
993
|
+
if (entry.message.expectsReply) {
|
|
994
|
+
this.askEdges.delete(entry.message.id);
|
|
995
|
+
}
|
|
996
|
+
this.messageReceiptRoutes.delete(entry.message.id);
|
|
997
|
+
this.mailboxMessages.splice(index, 1);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
private queueMailboxMessage(from: SessionInfo, target: SessionInfo, message: Message, brokerReceivedAt: number): void {
|
|
1003
|
+
this.pruneMailboxMessages(brokerReceivedAt);
|
|
1004
|
+
while (this.mailboxMessages.length >= MAX_MAILBOX_MESSAGES) {
|
|
1005
|
+
const evicted = this.mailboxMessages.shift();
|
|
1006
|
+
if (!evicted) break;
|
|
1007
|
+
if (evicted.message.expectsReply) {
|
|
1008
|
+
this.askEdges.delete(evicted.message.id);
|
|
1009
|
+
}
|
|
1010
|
+
this.messageReceiptRoutes.delete(evicted.message.id);
|
|
1011
|
+
}
|
|
1012
|
+
this.mailboxMessages.push({
|
|
1013
|
+
from: { ...from },
|
|
1014
|
+
target: { ...target },
|
|
1015
|
+
message: { ...message, brokerReceivedAt },
|
|
1016
|
+
queuedAt: brokerReceivedAt,
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
private flushMailboxForSession(session: ConnectedSession, now = Date.now()): void {
|
|
1021
|
+
this.pruneMailboxMessages(now);
|
|
1022
|
+
const sessionName = session.info.name?.toLowerCase();
|
|
1023
|
+
const uniqueMailboxIdentity = this.findLiveSessionsSharingMailboxIdentity(session.info).length === 1;
|
|
1024
|
+
|
|
1025
|
+
for (let index = 0; index < this.mailboxMessages.length;) {
|
|
1026
|
+
const entry = this.mailboxMessages[index]!;
|
|
1027
|
+
const matchesId = entry.target.id === session.info.id;
|
|
1028
|
+
const matchesUniqueName = Boolean(
|
|
1029
|
+
uniqueMailboxIdentity
|
|
1030
|
+
&& sessionName
|
|
1031
|
+
&& entry.target.name?.toLowerCase() === sessionName
|
|
1032
|
+
&& sameCwd(entry.target.cwd, session.info.cwd),
|
|
1033
|
+
);
|
|
1034
|
+
if (!matchesId && !matchesUniqueName) {
|
|
1035
|
+
index += 1;
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
this.mailboxMessages.splice(index, 1);
|
|
1040
|
+
const edge = this.askEdges.get(entry.message.id);
|
|
1041
|
+
if (edge?.to === entry.target.id) {
|
|
1042
|
+
edge.to = session.info.id;
|
|
1043
|
+
}
|
|
1044
|
+
const deliveredMessage: Message = {
|
|
1045
|
+
...entry.message,
|
|
1046
|
+
brokerDeliveredAt: Date.now(),
|
|
1047
|
+
};
|
|
1048
|
+
writeMessage(session.socket, {
|
|
1049
|
+
type: "message",
|
|
1050
|
+
from: entry.from,
|
|
1051
|
+
message: deliveredMessage,
|
|
1052
|
+
});
|
|
1053
|
+
this.messageReceiptRoutes.set(entry.message.id, {
|
|
1054
|
+
from: entry.from.id,
|
|
1055
|
+
to: session.info.id,
|
|
1056
|
+
createdAt: entry.message.brokerReceivedAt ?? entry.queuedAt,
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
private pruneAskEdges(now = Date.now()): void {
|
|
1062
|
+
for (const [messageId, edge] of this.askEdges) {
|
|
1063
|
+
if (now - edge.createdAt > this.askTimeoutMs) {
|
|
1064
|
+
this.askEdges.delete(messageId);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
private clearAskEdgesForSession(sessionId: string): void {
|
|
1070
|
+
for (const [messageId, edge] of this.askEdges) {
|
|
1071
|
+
if (edge.from === sessionId || edge.to === sessionId) {
|
|
1072
|
+
this.askEdges.delete(messageId);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
private pruneMessageReceiptRoutes(now = Date.now()): void {
|
|
1078
|
+
for (const [messageId, route] of this.messageReceiptRoutes) {
|
|
1079
|
+
if (now - route.createdAt > MESSAGE_RECEIPT_ROUTE_RETENTION_MS) {
|
|
1080
|
+
this.messageReceiptRoutes.delete(messageId);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
private clearMessageReceiptRoutesForSession(sessionId: string): void {
|
|
1086
|
+
for (const [messageId, route] of this.messageReceiptRoutes) {
|
|
1087
|
+
if (route.from === sessionId || route.to === sessionId) {
|
|
1088
|
+
this.messageReceiptRoutes.delete(messageId);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
302
1093
|
private findSessions(nameOrId: string): ConnectedSession[] {
|
|
303
1094
|
const byId = this.sessions.get(nameOrId);
|
|
304
1095
|
if (byId) {
|
|
@@ -306,7 +1097,55 @@ class IntercomBroker {
|
|
|
306
1097
|
}
|
|
307
1098
|
|
|
308
1099
|
const lowerName = nameOrId.toLowerCase();
|
|
309
|
-
|
|
1100
|
+
const byName = Array.from(this.sessions.values()).filter(session => session.info.name?.toLowerCase() === lowerName);
|
|
1101
|
+
if (byName.length > 0) {
|
|
1102
|
+
return byName;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
return Array.from(this.sessions.entries())
|
|
1106
|
+
.filter(([id]) => id.startsWith(nameOrId))
|
|
1107
|
+
.map(([, session]) => session);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
private findDisconnectedSessions(nameOrId: string): DisconnectedSession[] {
|
|
1111
|
+
this.pruneDisconnectedSessions();
|
|
1112
|
+
const byId = this.disconnectedSessions.get(nameOrId);
|
|
1113
|
+
if (byId) {
|
|
1114
|
+
return [byId];
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const lowerName = nameOrId.toLowerCase();
|
|
1118
|
+
const byName = Array.from(this.disconnectedSessions.values()).filter(session => session.info.name?.toLowerCase() === lowerName);
|
|
1119
|
+
if (byName.length > 0) {
|
|
1120
|
+
return byName;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
return Array.from(this.disconnectedSessions.entries())
|
|
1124
|
+
.filter(([id]) => id.startsWith(nameOrId))
|
|
1125
|
+
.map(([, session]) => session);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
private findUniqueLiveSessionForDisconnectedSession(info: SessionInfo): ConnectedSession | null {
|
|
1129
|
+
const matches = this.findLiveSessionsSharingMailboxIdentity(info);
|
|
1130
|
+
return matches.length === 1 ? matches[0]! : null;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Mailbox identity is name plus directory, never name alone. A name on its own
|
|
1135
|
+
* is display metadata that unrelated projects reuse, so matching on it would
|
|
1136
|
+
* hand one project's queued mail to a same-named session in another project.
|
|
1137
|
+
* Directories compare through sameCwd so a relaunch that reports the same
|
|
1138
|
+
* directory differently (trailing slash, "."/"..", or a symlink such as macOS
|
|
1139
|
+
* /tmp vs /private/tmp) still matches.
|
|
1140
|
+
*/
|
|
1141
|
+
private findLiveSessionsSharingMailboxIdentity(info: SessionInfo): ConnectedSession[] {
|
|
1142
|
+
const lowerName = info.name?.toLowerCase();
|
|
1143
|
+
if (!lowerName) {
|
|
1144
|
+
return [];
|
|
1145
|
+
}
|
|
1146
|
+
return Array.from(this.sessions.values()).filter(session =>
|
|
1147
|
+
session.info.name?.toLowerCase() === lowerName && sameCwd(session.info.cwd, info.cwd)
|
|
1148
|
+
);
|
|
310
1149
|
}
|
|
311
1150
|
|
|
312
1151
|
private broadcast(msg: BrokerMessage, exclude?: string): void {
|
|
@@ -317,6 +1156,355 @@ class IntercomBroker {
|
|
|
317
1156
|
}
|
|
318
1157
|
}
|
|
319
1158
|
|
|
1159
|
+
private validateExtensionCapability(cap: unknown): cap is ExtensionCapability {
|
|
1160
|
+
if (typeof cap !== "object" || cap === null) {
|
|
1161
|
+
return false;
|
|
1162
|
+
}
|
|
1163
|
+
const c = cap as Record<string, unknown>;
|
|
1164
|
+
if (typeof c.namespace !== "string" || typeof c.ownerEligible !== "boolean") {
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
return this.validateNamespace(c.namespace);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
private validateNamespace(ns: string): boolean {
|
|
1171
|
+
// ^[a-z0-9][a-z0-9._/-]{0,63}$
|
|
1172
|
+
if (ns.length === 0 || ns.length > 64) {
|
|
1173
|
+
return false;
|
|
1174
|
+
}
|
|
1175
|
+
if (!/^[a-z0-9]/.test(ns)) {
|
|
1176
|
+
return false;
|
|
1177
|
+
}
|
|
1178
|
+
if (!/^[a-z0-9][a-z0-9._/-]*$/.test(ns)) {
|
|
1179
|
+
return false;
|
|
1180
|
+
}
|
|
1181
|
+
return true;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
private recomputeNamespaceOwners(): void {
|
|
1185
|
+
const namespaces = new Set(this.namespaceOwners.keys());
|
|
1186
|
+
for (const session of this.sessions.values()) {
|
|
1187
|
+
for (const extension of session.extensions ?? []) {
|
|
1188
|
+
namespaces.add(extension.namespace);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// For each namespace, elect owner by (startedAt, sessionId).
|
|
1193
|
+
for (const namespace of namespaces) {
|
|
1194
|
+
const candidates: Array<{ sessionId: string; session: ConnectedSession }> = [];
|
|
1195
|
+
for (const [sessionId, session] of this.sessions) {
|
|
1196
|
+
if (session.extensions) {
|
|
1197
|
+
const hasNamespace = session.extensions.some(
|
|
1198
|
+
(ext) => ext.namespace === namespace && ext.ownerEligible
|
|
1199
|
+
);
|
|
1200
|
+
if (hasNamespace) {
|
|
1201
|
+
candidates.push({ sessionId, session });
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
if (candidates.length === 0) {
|
|
1207
|
+
if (this.namespaceOwners.delete(namespace)) {
|
|
1208
|
+
for (const session of this.sessions.values()) {
|
|
1209
|
+
const isCapable = session.extensions?.some((extension) => extension.namespace === namespace);
|
|
1210
|
+
if (isCapable) {
|
|
1211
|
+
writeMessage(session.socket, { type: "extension_owner", namespace });
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// Use broker-owned registration order so clients cannot seize authority
|
|
1219
|
+
// by backdating their advertised session start time. Stable-ID socket
|
|
1220
|
+
// replacements preserve the original order.
|
|
1221
|
+
candidates.sort((a, b) => {
|
|
1222
|
+
if (a.session.ownerOrder !== b.session.ownerOrder) {
|
|
1223
|
+
return a.session.ownerOrder - b.session.ownerOrder;
|
|
1224
|
+
}
|
|
1225
|
+
return a.sessionId.localeCompare(b.sessionId);
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
const winner = candidates[0];
|
|
1229
|
+
const existing = this.namespaceOwners.get(namespace);
|
|
1230
|
+
|
|
1231
|
+
// Check if owner changed or socket changed
|
|
1232
|
+
const ownerChanged = !existing || existing.sessionId !== winner.sessionId;
|
|
1233
|
+
const socketChanged = existing && existing.socket !== winner.session.socket;
|
|
1234
|
+
|
|
1235
|
+
if (ownerChanged || socketChanged) {
|
|
1236
|
+
// Generate new epoch
|
|
1237
|
+
const epoch = randomUUID();
|
|
1238
|
+
this.namespaceOwners.set(namespace, {
|
|
1239
|
+
sessionId: winner.sessionId,
|
|
1240
|
+
socket: winner.session.socket,
|
|
1241
|
+
epoch,
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
// Broadcast owner change to all capable sessions
|
|
1245
|
+
for (const session of this.sessions.values()) {
|
|
1246
|
+
if (session.extensions?.length) {
|
|
1247
|
+
const isCapable = session.extensions.some((ext) => ext.namespace === namespace);
|
|
1248
|
+
if (isCapable) {
|
|
1249
|
+
writeMessage(session.socket, {
|
|
1250
|
+
type: "extension_owner",
|
|
1251
|
+
namespace,
|
|
1252
|
+
ownerId: winner.sessionId,
|
|
1253
|
+
ownerEpoch: epoch,
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
private handleExtensionPublish(
|
|
1263
|
+
socket: net.Socket,
|
|
1264
|
+
currentId: string | null,
|
|
1265
|
+
msg: Record<string, unknown>
|
|
1266
|
+
): void {
|
|
1267
|
+
if (!currentId) {
|
|
1268
|
+
throw new Error("Received extension_publish before register");
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const session = this.sessions.get(currentId);
|
|
1272
|
+
if (!session || session.socket !== socket) {
|
|
1273
|
+
writeMessage(socket, { type: "error", error: "Session not found" });
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
if (!session.extensions?.length) {
|
|
1278
|
+
writeMessage(socket, { type: "error", error: "Session has not advertised extension capability" });
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const namespace = msg.namespace;
|
|
1283
|
+
const audience = msg.audience;
|
|
1284
|
+
const ownerOnly = msg.ownerOnly === true;
|
|
1285
|
+
const ownerEpoch = msg.ownerEpoch;
|
|
1286
|
+
const payload = msg.payload;
|
|
1287
|
+
|
|
1288
|
+
if (typeof namespace !== "string" || !this.validateNamespace(namespace)) {
|
|
1289
|
+
writeMessage(socket, { type: "error", error: "Invalid namespace" });
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
if (audience !== "owner" && audience !== "capable") {
|
|
1294
|
+
writeMessage(socket, { type: "error", error: "Invalid audience" });
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
const payloadSize = serializedPayloadSize(payload);
|
|
1299
|
+
if (payloadSize === null || payloadSize > MAX_EXTENSION_MESSAGE_BYTES) {
|
|
1300
|
+
writeMessage(socket, { type: "error", error: "Invalid extension payload or payload exceeds 16 KiB limit" });
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Verify sender has capability for this namespace
|
|
1305
|
+
const hasCapability = session.extensions?.some((ext) => ext.namespace === namespace);
|
|
1306
|
+
if (!hasCapability) {
|
|
1307
|
+
writeMessage(socket, { type: "error", error: "Sender does not have capability for this namespace" });
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
const owner = this.namespaceOwners.get(namespace);
|
|
1312
|
+
if ((audience === "owner" || ownerOnly) && !owner) {
|
|
1313
|
+
writeMessage(socket, { type: "error", error: "No owner for this namespace" });
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// For owner-only messages, validate exact socket and epoch
|
|
1318
|
+
if (ownerOnly && owner) {
|
|
1319
|
+
if (typeof ownerEpoch !== "string") {
|
|
1320
|
+
writeMessage(socket, { type: "error", error: "ownerEpoch required for owner-only messages" });
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
if (currentId !== owner.sessionId || socket !== owner.socket || ownerEpoch !== owner.epoch) {
|
|
1324
|
+
writeMessage(socket, { type: "error", error: "Owner validation failed" });
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Route message to appropriate audience
|
|
1330
|
+
for (const [recipientId, recipientSession] of this.sessions) {
|
|
1331
|
+
if (!recipientSession.extensions?.length) {
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const isCapable = recipientSession.extensions.some((ext) => ext.namespace === namespace);
|
|
1336
|
+
if (!isCapable) {
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const shouldReceive =
|
|
1341
|
+
audience === "capable" ||
|
|
1342
|
+
(audience === "owner" && owner !== undefined &&
|
|
1343
|
+
recipientId === owner.sessionId &&
|
|
1344
|
+
recipientSession.socket === owner.socket);
|
|
1345
|
+
|
|
1346
|
+
if (shouldReceive) {
|
|
1347
|
+
writeMessage(recipientSession.socket, {
|
|
1348
|
+
type: "extension_message",
|
|
1349
|
+
namespace,
|
|
1350
|
+
fromSessionId: currentId,
|
|
1351
|
+
...(owner ? { ownerId: owner.sessionId, ownerEpoch: owner.epoch } : {}),
|
|
1352
|
+
payload,
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
private handleExtensionStateCommit(
|
|
1359
|
+
socket: net.Socket,
|
|
1360
|
+
currentId: string | null,
|
|
1361
|
+
msg: Record<string, unknown>
|
|
1362
|
+
): void {
|
|
1363
|
+
if (!currentId) {
|
|
1364
|
+
throw new Error("Received extension_state_commit before register");
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const session = this.sessions.get(currentId);
|
|
1368
|
+
if (!session || session.socket !== socket) {
|
|
1369
|
+
writeMessage(socket, {
|
|
1370
|
+
type: "extension_state_result",
|
|
1371
|
+
namespace: String(msg.namespace || ""),
|
|
1372
|
+
committed: false,
|
|
1373
|
+
revision: 0,
|
|
1374
|
+
reason: "Session not found",
|
|
1375
|
+
});
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
if (!session.extensions?.length) {
|
|
1380
|
+
writeMessage(socket, {
|
|
1381
|
+
type: "extension_state_result",
|
|
1382
|
+
namespace: String(msg.namespace || ""),
|
|
1383
|
+
committed: false,
|
|
1384
|
+
revision: 0,
|
|
1385
|
+
reason: "Session has not advertised extension capability",
|
|
1386
|
+
});
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
const namespace = msg.namespace;
|
|
1391
|
+
const ownerEpoch = msg.ownerEpoch;
|
|
1392
|
+
const expectedRevision = msg.expectedRevision;
|
|
1393
|
+
const payload = msg.payload;
|
|
1394
|
+
|
|
1395
|
+
if (typeof namespace !== "string" || !this.validateNamespace(namespace)) {
|
|
1396
|
+
writeMessage(socket, {
|
|
1397
|
+
type: "extension_state_result",
|
|
1398
|
+
namespace: String(namespace),
|
|
1399
|
+
committed: false,
|
|
1400
|
+
revision: 0,
|
|
1401
|
+
reason: "Invalid namespace",
|
|
1402
|
+
});
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
if (typeof ownerEpoch !== "string") {
|
|
1407
|
+
writeMessage(socket, {
|
|
1408
|
+
type: "extension_state_result",
|
|
1409
|
+
namespace,
|
|
1410
|
+
committed: false,
|
|
1411
|
+
revision: this.extensionStateManager.getCurrentRevision(namespace),
|
|
1412
|
+
reason: "Invalid ownerEpoch",
|
|
1413
|
+
});
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
if (typeof expectedRevision !== "number" || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
|
1418
|
+
writeMessage(socket, {
|
|
1419
|
+
type: "extension_state_result",
|
|
1420
|
+
namespace,
|
|
1421
|
+
committed: false,
|
|
1422
|
+
revision: this.extensionStateManager.getCurrentRevision(namespace),
|
|
1423
|
+
reason: "Invalid expectedRevision",
|
|
1424
|
+
});
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const payloadSize = serializedPayloadSize(payload);
|
|
1429
|
+
if (payloadSize === null || payloadSize > MAX_EXTENSION_STATE_BYTES) {
|
|
1430
|
+
writeMessage(socket, {
|
|
1431
|
+
type: "extension_state_result",
|
|
1432
|
+
namespace,
|
|
1433
|
+
committed: false,
|
|
1434
|
+
revision: this.extensionStateManager.getCurrentRevision(namespace),
|
|
1435
|
+
reason: "Invalid extension state or payload exceeds 64 KiB limit",
|
|
1436
|
+
});
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// Verify sender has capability for this namespace
|
|
1441
|
+
const hasCapability = session.extensions?.some((ext) => ext.namespace === namespace);
|
|
1442
|
+
if (!hasCapability) {
|
|
1443
|
+
writeMessage(socket, {
|
|
1444
|
+
type: "extension_state_result",
|
|
1445
|
+
namespace,
|
|
1446
|
+
committed: false,
|
|
1447
|
+
revision: this.extensionStateManager.getCurrentRevision(namespace),
|
|
1448
|
+
reason: "Sender does not have capability for this namespace",
|
|
1449
|
+
});
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
const owner = this.namespaceOwners.get(namespace);
|
|
1454
|
+
if (!owner) {
|
|
1455
|
+
writeMessage(socket, {
|
|
1456
|
+
type: "extension_state_result",
|
|
1457
|
+
namespace,
|
|
1458
|
+
committed: false,
|
|
1459
|
+
revision: this.extensionStateManager.getCurrentRevision(namespace),
|
|
1460
|
+
reason: "No owner for this namespace",
|
|
1461
|
+
});
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// Validate owner, socket, and epoch
|
|
1466
|
+
if (currentId !== owner.sessionId || socket !== owner.socket || ownerEpoch !== owner.epoch) {
|
|
1467
|
+
writeMessage(socket, {
|
|
1468
|
+
type: "extension_state_result",
|
|
1469
|
+
namespace,
|
|
1470
|
+
committed: false,
|
|
1471
|
+
revision: this.extensionStateManager.getCurrentRevision(namespace),
|
|
1472
|
+
reason: "Owner validation failed",
|
|
1473
|
+
});
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const result = this.extensionStateManager.commitState(namespace, expectedRevision, payload);
|
|
1478
|
+
|
|
1479
|
+
// Send result to committer
|
|
1480
|
+
writeMessage(socket, {
|
|
1481
|
+
type: "extension_state_result",
|
|
1482
|
+
namespace,
|
|
1483
|
+
committed: result.committed,
|
|
1484
|
+
revision: result.revision,
|
|
1485
|
+
reason: result.reason,
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
// If committed, broadcast new state to all capable sessions
|
|
1489
|
+
if (result.committed) {
|
|
1490
|
+
for (const recipientSession of this.sessions.values()) {
|
|
1491
|
+
if (!recipientSession.extensions?.length) {
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const isCapable = recipientSession.extensions.some((ext) => ext.namespace === namespace);
|
|
1496
|
+
if (isCapable) {
|
|
1497
|
+
writeMessage(recipientSession.socket, {
|
|
1498
|
+
type: "extension_state",
|
|
1499
|
+
namespace,
|
|
1500
|
+
revision: result.revision,
|
|
1501
|
+
payload,
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
320
1508
|
private shutdown(): void {
|
|
321
1509
|
console.log("Broker shutting down");
|
|
322
1510
|
|
|
@@ -324,13 +1512,22 @@ class IntercomBroker {
|
|
|
324
1512
|
session.socket.end();
|
|
325
1513
|
}
|
|
326
1514
|
this.sessions.clear();
|
|
327
|
-
|
|
1515
|
+
this.askEdges.clear();
|
|
1516
|
+
this.messageReceiptRoutes.clear();
|
|
1517
|
+
this.disconnectedSessions.clear();
|
|
1518
|
+
this.mailboxMessages.length = 0;
|
|
1519
|
+
if (typeof LISTEN_TARGET === "string" && process.platform !== "win32") {
|
|
328
1520
|
try {
|
|
329
|
-
unlinkSync(
|
|
1521
|
+
unlinkSync(LISTEN_TARGET);
|
|
330
1522
|
} catch {
|
|
331
1523
|
// The socket may already be gone if shutdown started after a disconnect.
|
|
332
1524
|
}
|
|
333
1525
|
}
|
|
1526
|
+
try {
|
|
1527
|
+
unlinkSync(PORT_PATH);
|
|
1528
|
+
} catch {
|
|
1529
|
+
// The TCP endpoint file only exists when opt-in TCP transport is active.
|
|
1530
|
+
}
|
|
334
1531
|
try {
|
|
335
1532
|
unlinkSync(PID_PATH);
|
|
336
1533
|
} catch {
|