mixdog 1.1.2 → 1.2.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/README.md +8 -5
- package/package.json +2 -1
- package/scripts/lib/run-node-tests.mjs +9 -0
- package/scripts/prune-desktop-runtime.mjs +3 -0
- package/scripts/test-direct.mjs +4 -2
- package/src/defaults/shipped-history.json +10 -0
- package/src/defaults/skills/pptx/SKILL.md +6 -6
- package/src/defaults/skills/pptx/references/review.md +6 -4
- package/src/defaults/skills/pptx/scripts/source-extract.mjs +1 -1
- package/src/defaults/skills/setup/references/actions.md +18 -5
- package/src/defaults/skills/visualize/SKILL.md +160 -0
- package/src/defaults/skills/visualize/references/craft.md +88 -0
- package/src/headless-command.mjs +56 -3
- package/src/headless-exec.mjs +17 -1
- package/src/help.mjs +2 -0
- package/src/rules/shared/60-verification.md +3 -1
- package/src/rules/shared/70-delivery.md +4 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/preset.mjs +1 -0
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +1 -0
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -0
- package/src/runtime/agent/orchestrator/cache-mtime.mjs +26 -5
- package/src/runtime/agent/orchestrator/config-presets.mjs +2 -0
- package/src/runtime/agent/orchestrator/config-providers-merge.mjs +17 -11
- package/src/runtime/agent/orchestrator/config-storage.mjs +1 -0
- package/src/runtime/agent/orchestrator/config.mjs +19 -5
- package/src/runtime/agent/orchestrator/context/collect.mjs +2 -1
- package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +11 -5
- package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +74 -21
- package/src/runtime/agent/orchestrator/mcp/client-config.mjs +133 -15
- package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +55 -8
- package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +86 -8
- package/src/runtime/agent/orchestrator/mcp/client.mjs +474 -164
- package/src/runtime/agent/orchestrator/mcp/lazy-output-validator.mjs +25 -0
- package/src/runtime/agent/orchestrator/mcp/plugin-placeholders.mjs +54 -0
- package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +4 -0
- package/src/runtime/agent/orchestrator/providers/account-pool.mjs +108 -17
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +32 -12
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth-tokens.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/auto-effort.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/command-code-oauth-login.mjs +337 -0
- package/src/runtime/agent/orchestrator/providers/command-code-oauth-tokens.mjs +159 -0
- package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +444 -0
- package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +7 -4
- package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +21 -6
- package/src/runtime/agent/orchestrator/providers/effort-rejection.mjs +244 -0
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +41 -28
- package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +7 -1
- package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +8 -40
- package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +51 -12
- package/src/runtime/agent/orchestrator/providers/model-cache.mjs +37 -2
- package/src/runtime/agent/orchestrator/providers/model-proxy-bridge.mjs +1080 -0
- package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +82 -0
- package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +556 -0
- package/src/runtime/agent/orchestrator/providers/model-proxy-provider.mjs +251 -0
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +69 -102
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +15 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +6 -5
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +6 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +15 -4
- package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +5 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +4 -4
- package/src/runtime/agent/orchestrator/providers/registry.mjs +47 -7
- package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +10 -0
- package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +36 -3
- package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +15 -11
- package/src/runtime/agent/orchestrator/runtime-core/extension-scopes.mjs +15 -55
- package/src/runtime/agent/orchestrator/runtime-core/goal-control.mjs +15 -2
- package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +16 -1
- package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +278 -84
- package/src/runtime/agent/orchestrator/runtime-core/skill-tool-loading.mjs +4 -1
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-schema.mjs +21 -0
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +54 -3
- package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +10 -3
- package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +10 -0
- package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +9 -5
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +18 -8
- package/src/runtime/agent/orchestrator/session/catalog-db/index.mjs +284 -0
- package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/catalog-db/sqlite-gate.mjs +27 -0
- package/src/runtime/agent/orchestrator/session/catalog-db/worker.mjs +628 -0
- package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +10 -3
- package/src/runtime/agent/orchestrator/session/compact/messages.mjs +24 -13
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +13 -5
- package/src/runtime/agent/orchestrator/session/compaction-archive-capture.mjs +362 -0
- package/src/runtime/agent/orchestrator/session/compaction-archive.mjs +253 -0
- package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +184 -0
- package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +136 -0
- package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +128 -0
- package/src/runtime/agent/orchestrator/session/conversation-import/imported-session.mjs +127 -0
- package/src/runtime/agent/orchestrator/session/conversation-import/turns.mjs +101 -0
- package/src/runtime/agent/orchestrator/session/explicit-skills.mjs +83 -5
- package/src/runtime/agent/orchestrator/session/imported-message.mjs +11 -0
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/continuation.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/diagnostic.mjs +1 -7
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/refusal.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +5 -3
- package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +7 -4
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +21 -10
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/loop/usage.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +12 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +15 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +22 -12
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +9 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +9 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +5 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -16
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +17 -4
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +90 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -12
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +32 -3
- package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +27 -0
- package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +16 -1
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +0 -1
- package/src/runtime/agent/orchestrator/session/message-ids.mjs +123 -0
- package/src/runtime/agent/orchestrator/session/message-ids.test-support.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +33 -22
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +6 -2
- package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/store/sweep/sweep-finish.mjs +10 -3
- package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +11 -0
- package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +817 -0
- package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +36 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +23 -12
- package/src/runtime/agent/orchestrator/session/store-summary-reader-catalog-fixture.mjs +158 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +202 -374
- package/src/runtime/agent/orchestrator/session/store-transcript-projection.mjs +20 -3
- package/src/runtime/agent/orchestrator/session/synthetic-user-envelope.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/tool-batch/pre-dispatch.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +48 -9
- package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +161 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/spawn-env.mjs +4 -0
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +52 -2
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -22
- package/src/runtime/agent/orchestrator/tools/builtin/git-repo-rw-lock.mjs +10 -3
- package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +6 -6
- package/src/runtime/agent/orchestrator/tools/builtin/read-cache-policy.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +203 -33
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-fast-paths.mjs +32 -35
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +8 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-render.mjs +11 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-stream.mjs +9 -6
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +83 -68
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +8 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +20 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +16 -7
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-publication.mjs +137 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-snapshot.mjs +11 -10
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +21 -11
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +13 -10
- package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +17 -12
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +56 -13
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +58 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/root-resolution.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +25 -1
- package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/patch/apply-patch/route.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/read-redirects.mjs +34 -30
- package/src/runtime/agent/orchestrator/tools/patch/sequence/units.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/patch/sequence.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +78 -5
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +2 -0
- package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -0
- package/src/runtime/attachments/store.d.mts +3 -0
- package/src/runtime/attachments/store.mjs +38 -0
- package/src/runtime/channels/lib/owned-runtime/owned-start.mjs +1 -1
- package/src/runtime/channels/lib/webhook/session-dispatch.mjs +16 -4
- package/src/runtime/channels/lib/worker-main.mjs +17 -14
- package/src/runtime/computer-bridge/linux-readiness.mjs +0 -2
- package/src/runtime/effort-judge/judge-client.mjs +56 -18
- package/src/runtime/memory/lib/memory.mjs +1 -1
- package/src/runtime/memory/lib/session-ingest.mjs +2 -2
- package/src/runtime/shared/atomic-file.mjs +33 -6
- package/src/runtime/shared/best-effort-stderr.mjs +8 -0
- package/src/runtime/shared/code-graph-state.mjs +12 -0
- package/src/runtime/shared/defaults-separation.mjs +2 -0
- package/src/runtime/shared/extension-scopes.mjs +5 -3
- package/src/runtime/shared/file-lock.mjs +37 -11
- package/src/runtime/shared/git-runner-worker.mjs +3 -2
- package/src/runtime/shared/git-runner.mjs +4 -3
- package/src/runtime/shared/llm/anthropic-effort.mjs +14 -0
- package/src/runtime/shared/llm/default-context-window.mjs +6 -0
- package/src/runtime/shared/llm/model-catalog-projection.mjs +5 -4
- package/src/runtime/shared/llm/model-catalog.mjs +240 -130
- package/src/runtime/shared/llm/provider-catalog-cache.mjs +36 -28
- package/src/runtime/shared/llm/provider-catalog-registry.mjs +162 -0
- package/src/runtime/shared/llm/provider-seed-rosters.mjs +130 -0
- package/src/runtime/shared/model-proxy-mode.d.mts +3 -0
- package/src/runtime/shared/model-proxy-mode.mjs +19 -0
- package/src/runtime/shared/plugin-manifest.mjs +18 -9
- package/src/runtime/shared/pristine-execution-contract.json +9 -2
- package/src/runtime/shared/pristine-execution.mjs +28 -4
- package/src/runtime/shared/provider-accounts.mjs +125 -21
- package/src/runtime/shared/provider-api-key.mjs +33 -1
- package/src/runtime/shared/schedule-model-ref.mjs +11 -2
- package/src/runtime/shared/session-usage-totals.mjs +19 -0
- package/src/runtime/shared/sharp-electron-linux.mjs +25 -0
- package/src/runtime/shared/shell-display.mjs +9 -3
- package/src/runtime/shared/singleton-owner.mjs +36 -6
- package/src/runtime/shared/skill-mentions.d.mts +10 -0
- package/src/runtime/shared/skill-mentions.mjs +133 -0
- package/src/runtime/shared/skill-script-runtime.mjs +264 -0
- package/src/runtime/shared/statusline/statusline-segments.mjs +68 -14
- package/src/runtime/shared/tool-primitives.mjs +35 -2
- package/src/runtime/shared/tool-surface.mjs +7 -0
- package/src/runtime/shared/tool-work-units.mjs +14 -0
- package/src/runtime/shared/transcript-metadata.mjs +17 -1
- package/src/runtime/shared/turn-changes.mjs +791 -0
- package/src/runtime/shared/turn-snapshot-store.mjs +346 -12
- package/src/runtime/shared/turn-snapshot.mjs +690 -81
- package/src/runtime/shared/turn-worktree-snapshot.mjs +185 -15
- package/src/runtime/shared/worker-requests.mjs +15 -2
- package/src/runtime/shared/worktrees/cache-cleanups.mjs +52 -0
- package/src/runtime/shared/worktrees/contract.mjs +551 -0
- package/src/runtime/shared/worktrees/disk.mjs +57 -0
- package/src/runtime/shared/worktrees/git.mjs +247 -0
- package/src/runtime/shared/worktrees/include.mjs +480 -0
- package/src/runtime/shared/worktrees/lock.mjs +46 -0
- package/src/runtime/shared/worktrees/maintenance.mjs +96 -0
- package/src/runtime/shared/worktrees/manager.mjs +1215 -0
- package/src/runtime/shared/worktrees/merge.mjs +182 -0
- package/src/runtime/shared/worktrees/owner.mjs +46 -0
- package/src/runtime/shared/worktrees/paths.mjs +62 -0
- package/src/runtime/shared/worktrees/registry.mjs +110 -0
- package/src/runtime/shared/worktrees/settings.mjs +84 -0
- package/src/runtime/shared/worktrees/slug.mjs +62 -0
- package/src/runtime/shared/worktrees/test-support.mjs +96 -0
- package/src/runtime/visualize-bridge/client.mjs +181 -0
- package/src/runtime/visualize-bridge/tool-defs.mjs +97 -0
- package/src/session-runtime/automation-agents.mjs +8 -2
- package/src/session-runtime/boot/apis.mjs +90 -0
- package/src/session-runtime/boot/config.mjs +7 -2
- package/src/session-runtime/boot/facade.mjs +21 -0
- package/src/session-runtime/boot/infrastructure.mjs +30 -18
- package/src/session-runtime/boot/session.mjs +1 -0
- package/src/session-runtime/boot/tools.mjs +4 -0
- package/src/session-runtime/config-lifecycle.mjs +10 -1
- package/src/session-runtime/conversation-import/discovery.mjs +698 -0
- package/src/session-runtime/conversation-import/service.mjs +369 -0
- package/src/session-runtime/cwd-plugins/cwd-apply.mjs +21 -6
- package/src/session-runtime/cwd-plugins/plugin-status.mjs +47 -42
- package/src/session-runtime/global-extensions.mjs +2 -2
- package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +27 -2
- package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +32 -1
- package/src/session-runtime/internal-tool-executor.mjs +9 -1
- package/src/session-runtime/lifecycle/session-catalog.mjs +8 -1
- package/src/session-runtime/lifecycle/session-switch/context-switch.mjs +5 -0
- package/src/session-runtime/lifecycle/session-switch/resume.mjs +21 -3
- package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +16 -0
- package/src/session-runtime/mcp-glue/connect.mjs +68 -16
- package/src/session-runtime/mcp-glue/plugin-servers.mjs +499 -0
- package/src/session-runtime/mcp-glue/server-catalog.mjs +122 -33
- package/src/session-runtime/mcp-glue/server-input.mjs +29 -0
- package/src/session-runtime/mcp-glue.mjs +22 -2
- package/src/session-runtime/model-route/route-persist.mjs +3 -0
- package/src/session-runtime/model-route/set-route.mjs +15 -2
- package/src/session-runtime/new-session-config.mjs +3 -2
- package/src/session-runtime/notification-bus.mjs +18 -3
- package/src/session-runtime/provider-auth/accounts.mjs +33 -15
- package/src/session-runtime/provider-models/row-hydration.mjs +2 -0
- package/src/session-runtime/provider-models/web-search-catalog.mjs +5 -1
- package/src/session-runtime/provider-readiness.mjs +16 -10
- package/src/session-runtime/resource-extension-sync.mjs +45 -7
- package/src/session-runtime/resource-mcp-api.mjs +121 -84
- package/src/session-runtime/resource-plugins-api.mjs +29 -41
- package/src/session-runtime/resource-scope-decorate.mjs +6 -8
- package/src/session-runtime/resource-skills-api.mjs +23 -2
- package/src/session-runtime/route-resolution.mjs +10 -1
- package/src/session-runtime/route-state.mjs +16 -2
- package/src/session-runtime/runtime-core.mjs +5 -0
- package/src/session-runtime/runtime-facade.mjs +8 -0
- package/src/session-runtime/runtime-review-api.mjs +9 -0
- package/src/session-runtime/schedule-session-run.mjs +1 -1
- package/src/session-runtime/services/agent-tool/helpers.mjs +5 -0
- package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +3 -0
- package/src/session-runtime/services/agent-tool/job-views/job-lookup.mjs +1 -0
- package/src/session-runtime/services/agent-tool/job-views/spawn-meta.mjs +1 -0
- package/src/session-runtime/services/agent-tool/lead-worker-index.mjs +15 -0
- package/src/session-runtime/services/agent-tool/send-flow.mjs +1 -0
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +10 -2
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +19 -7
- package/src/session-runtime/services/agent-tool/spawn-preset.mjs +3 -0
- package/src/session-runtime/services/agent-tool/tag-registry.mjs +2 -1
- package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +5 -0
- package/src/session-runtime/services/agent-tool/worker-rows.mjs +1 -0
- package/src/session-runtime/services/agent-tool.mjs +1 -0
- package/src/session-runtime/services/channel-session-router.mjs +4 -1
- package/src/session-runtime/services/daemon-candidate.mjs +3 -2
- package/src/session-runtime/services/daemon-crash-capture/spawn-capture.mjs +55 -0
- package/src/session-runtime/services/hook-bus/command-handler.mjs +4 -1
- package/src/session-runtime/services/hook-bus/handlers.mjs +7 -3
- package/src/session-runtime/services/provider-admin.mjs +64 -18
- package/src/session-runtime/services/usage-dashboard-model.mjs +3 -0
- package/src/session-runtime/session-create/session-options.mjs +10 -1
- package/src/session-runtime/session-turn-api.mjs +1 -0
- package/src/session-runtime/settings-api.mjs +4 -2
- package/src/session-runtime/settings-builtin-tools-api.mjs +9 -2
- package/src/session-runtime/settings-worktree-api.mjs +17 -0
- package/src/session-runtime/setup-tool/executor.mjs +23 -5
- package/src/session-runtime/setup-tool/extended-actions.mjs +1 -0
- package/src/session-runtime/setup-tool/settings-contract.mjs +4 -1
- package/src/session-runtime/setup-tool/tool-defs.mjs +38 -4
- package/src/session-runtime/skills-api.mjs +7 -3
- package/src/session-runtime/turn/agent-ops.mjs +12 -1
- package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +14 -3
- package/src/session-runtime/turn/turn-run/snapshot-tracker.mjs +20 -15
- package/src/session-runtime/turn/turn-run/turn-open.mjs +4 -5
- package/src/session-runtime/turn/turn-run/turn-prepare.mjs +29 -1
- package/src/session-runtime/turn/turn-run/turn-settle.mjs +6 -1
- package/src/session-runtime/turn/turn-run.mjs +6 -7
- package/src/session-runtime/webhook-session-run.mjs +16 -4
- package/src/session-runtime/workflow-agents-api/agent-route.mjs +15 -3
- package/src/session-runtime/workspace/admission-gate.mjs +197 -0
- package/src/session-runtime/workspace/project-branches.mjs +136 -0
- package/src/session-runtime/workspace/transition-steps.mjs +364 -0
- package/src/session-runtime/workspace/transitions.mjs +651 -0
- package/src/session-runtime/workspace/workspace-state.mjs +112 -0
- package/src/skill-script-entry.mjs +36 -0
- package/src/standalone/channel-transport/http-routes.mjs +17 -0
- package/src/standalone/channel-transport.mjs +13 -1
- package/src/standalone/daemon-desktop-runtime.mjs +1 -0
- package/src/standalone/daemon-owner.mjs +372 -0
- package/src/standalone/daemon-stored-session-views.mjs +15 -4
- package/src/standalone/daemon.mjs +179 -8
- package/src/standalone/desktop-service-registry.mjs +20 -0
- package/src/standalone/local-session-runtime.mjs +11 -0
- package/src/standalone/session-client/attachment-calls.mjs +3 -1
- package/src/standalone/session-client/daemon-unavailable.mjs +62 -0
- package/src/standalone/session-client/lease-heartbeat.mjs +47 -0
- package/src/standalone/session-client.mjs +104 -13
- package/src/standalone/session-protocol.mjs +21 -0
- package/src/standalone/session-proxy.mjs +3 -0
- package/src/standalone/session-service/agent-tree/agent-child.mjs +4 -0
- package/src/standalone/session-service/agent-tree/agent-rehydrate.mjs +1 -0
- package/src/standalone/session-service/entries/lifecycle.mjs +6 -0
- package/src/standalone/session-service/projection/revisions.mjs +2 -0
- package/src/standalone/session-service/projection/transcript-window.mjs +149 -19
- package/src/standalone/session-service/projection/workspace.mjs +14 -0
- package/src/standalone/session-service/session-calls/session-action.mjs +17 -4
- package/src/standalone/session-transport/call-route.mjs +13 -0
- package/src/standalone/session-transport/client-registry/client-grace.mjs +45 -3
- package/src/standalone/session-transport/client-registry/registration-replays.mjs +1 -0
- package/src/standalone/session-transport/client-registry.mjs +20 -3
- package/src/standalone/session-transport/routes.mjs +79 -2
- package/src/standalone/session-transport/stack.mjs +2 -0
- package/src/standalone/session-transport.mjs +24 -1
- package/src/tui/App.jsx +9 -1
- package/src/tui/app/app-view/floating-panel.jsx +9 -1
- package/src/tui/app/app-view.jsx +6 -0
- package/src/tui/app/extension-pickers/plugins-pickers.mjs +35 -16
- package/src/tui/app/message-selector.mjs +2 -1
- package/src/tui/app/model-options.mjs +37 -5
- package/src/tui/app/model-picker/provider-models-list.mjs +2 -2
- package/src/tui/app/model-picker.mjs +4 -2
- package/src/tui/app/skill-mention-palette.mjs +88 -0
- package/src/tui/app/slash-palette.mjs +23 -7
- package/src/tui/app/transcript-row-estimate.mjs +11 -3
- package/src/tui/app/use-prompt-draft-flow.mjs +7 -2
- package/src/tui/app/use-prompt-state.mjs +5 -0
- package/src/tui/components/Message.jsx +22 -1
- package/src/tui/components/PromptInput.jsx +21 -3
- package/src/tui/components/SlashCommandPalette.jsx +12 -3
- package/src/tui/components/TranscriptItem.jsx +9 -1
- package/src/tui/components/prompt-input/key-handler.mjs +21 -8
- package/src/tui/components/user-message-skills.mjs +38 -0
- package/src/tui/dist/index.mjs +839 -207
- package/src/tui/index.jsx +10 -2
- package/src/tui/session/compaction-view.mjs +147 -0
- package/src/tui/session/context-state.mjs +6 -0
- package/src/tui/session/initial-state.mjs +3 -0
- package/src/tui/session/queue-helpers.mjs +7 -2
- package/src/tui/session/quota-resume.mjs +2 -1
- package/src/tui/session/session-api/commands.mjs +10 -2
- package/src/tui/session/session-api/controls.mjs +4 -0
- package/src/tui/session/session-api/extensions.mjs +4 -2
- package/src/tui/session/session-api/intake/rewind.mjs +2 -1
- package/src/tui/session/session-api/integrations.mjs +17 -0
- package/src/tui/session/session-api/lifecycle/dispose.mjs +6 -1
- package/src/tui/session/session-api/lifecycle/inheritance.mjs +2 -0
- package/src/tui/session/session-api/lifecycle/resume.mjs +3 -0
- package/src/tui/session/session-api/routes.mjs +10 -1
- package/src/tui/session/session-api/settings.mjs +9 -0
- package/src/tui/session/session-api/worktree.mjs +60 -0
- package/src/tui/session/session-api.mjs +2 -0
- package/src/tui/session/session-flow/auto-clear.mjs +31 -12
- package/src/tui/session/session-flow/drain.mjs +27 -3
- package/src/tui/session/session-flow/session-reset.mjs +4 -3
- package/src/tui/session/session-flow.mjs +2 -0
- package/src/tui/session/transcript-intake.mjs +14 -5
- package/src/tui/session/tui-steering-persist.mjs +25 -9
- package/src/tui/session/turn-stream.mjs +32 -4
- package/src/tui/session/turn-tool-cards.mjs +10 -2
- package/src/tui/session/turn.mjs +56 -10
- package/src/runtime/effort-judge/previous-human-request.mjs +0 -162
- package/src/runtime/effort-judge/verified-context-input.mjs +0 -10
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
// Model proxy bridge: remote-mode model calls for processes the daemon does
|
|
2
|
+
// not host.
|
|
3
|
+
//
|
|
4
|
+
// With MIXDOG_MODEL_PROXY=client the daemon holds no model credentials; its
|
|
5
|
+
// ModelProxyProviders talk through the in-process channel the desktop client
|
|
6
|
+
// attaches to (model-proxy-channel.mjs). A skill script started by the model's
|
|
7
|
+
// shell (headless exec, one fresh runtime per run) is a separate process and
|
|
8
|
+
// cannot reach that channel, so the daemon relays for it over loopback:
|
|
9
|
+
//
|
|
10
|
+
// child -> daemon {"type":"hello","v":1,"token":…} (once, first)
|
|
11
|
+
// daemon -> child {"type":"state","connected","generation"} (initial + on change)
|
|
12
|
+
// child -> daemon {"type":"reserve","id","bytes"} (request frame size)
|
|
13
|
+
// daemon -> child {"type":"grant","id"} (FIFO, within the byte cap)
|
|
14
|
+
// child -> daemon {"type":"req","id","callbacks":[…],"call":<wire>} (only once granted)
|
|
15
|
+
// daemon -> child {"type":"event","id","seq","name","args":<wire>}
|
|
16
|
+
// child -> daemon {"type":"result","id","seq","ok","value"|"error"}
|
|
17
|
+
// daemon -> child {"type":"done","id","ok","value"|"error"}
|
|
18
|
+
// child -> daemon {"type":"cancel","id"}
|
|
19
|
+
//
|
|
20
|
+
// NDJSON frames; every frame is decoded with the channel's wire codec, so
|
|
21
|
+
// bytes and errors (status, code, partial usage…) survive exactly as on the
|
|
22
|
+
// PC wire. The endpoint and its per-lifetime token are published only in the
|
|
23
|
+
// owner-only daemon.json. The bridge itself never holds a credential: it
|
|
24
|
+
// forwards to the same channel the daemon's own providers use.
|
|
25
|
+
//
|
|
26
|
+
// Admission: a request's bytes are reserved before they are sent, so the
|
|
27
|
+
// daemon never has to stop reading a socket for budget. Control frames
|
|
28
|
+
// (result, cancel, reserve) are always read and handled at once, bounded by
|
|
29
|
+
// their own frame limit; only reservations wait, first come first served.
|
|
30
|
+
import net from 'node:net';
|
|
31
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
32
|
+
import { readFileSync } from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { createLoopbackListener } from '../../../shared/loopback-listener.mjs';
|
|
35
|
+
import { isPidAlive } from '../../../shared/pid-liveness.mjs';
|
|
36
|
+
import {
|
|
37
|
+
MODEL_PROXY_METHODS,
|
|
38
|
+
ModelProxyUnavailableError,
|
|
39
|
+
decodeModelProxyWire,
|
|
40
|
+
encodeModelProxyWire,
|
|
41
|
+
modelProxyErrorFromWire,
|
|
42
|
+
modelProxyErrorToWire,
|
|
43
|
+
modelProxyWireValue,
|
|
44
|
+
} from './model-proxy-channel.mjs';
|
|
45
|
+
|
|
46
|
+
export const MODEL_PROXY_BRIDGE_PROTOCOL = 1;
|
|
47
|
+
|
|
48
|
+
const MiB = 1024 * 1024;
|
|
49
|
+
/**
|
|
50
|
+
* Byte limits are 1.5× the PC's character limits (model-proxy-host.ts: 128 MiB
|
|
51
|
+
* per call, 256 MiB in flight): multibyte transcript text the PC accepts is
|
|
52
|
+
* never refused here, and an ASCII payload (base64 attachments) meets the PC's
|
|
53
|
+
* own limit first.
|
|
54
|
+
*/
|
|
55
|
+
export const MODEL_PROXY_BRIDGE_LIMITS = Object.freeze({
|
|
56
|
+
helloTimeoutMs: 5_000,
|
|
57
|
+
helloMaxBytes: 1024,
|
|
58
|
+
/** Every frame but `req`: results, cancels, reservations (callback answers are tiny). */
|
|
59
|
+
controlFrameMaxBytes: MiB,
|
|
60
|
+
requestFrameMaxBytes: 192 * MiB,
|
|
61
|
+
/** Granted request frames (being received or running), across all connections. */
|
|
62
|
+
aggregateBytes: 384 * MiB,
|
|
63
|
+
/** Unwritten bytes queued to one client before it is dropped as not reading. */
|
|
64
|
+
outgoingQueueBytes: 64 * MiB,
|
|
65
|
+
/** A relayed callback the client has not answered by then aborts its call. */
|
|
66
|
+
callbackAckMs: 30_000,
|
|
67
|
+
/** A partial frame, or a grant whose own request frame, receives no bytes for this long: its client is dropped. */
|
|
68
|
+
frameStallMs: 60_000,
|
|
69
|
+
/** A granted request frame must have arrived in full by then, however steadily it trickles. */
|
|
70
|
+
grantMaxMs: 300_000,
|
|
71
|
+
maxConnections: 16,
|
|
72
|
+
maxRequestsPerConnection: 16,
|
|
73
|
+
maxCallbacks: 32,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
77
|
+
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
78
|
+
// Same rule as splitModelProxySendOptions (/^on[A-Z]/), bounded.
|
|
79
|
+
const CALLBACK_NAME_RE = /^on[A-Z][A-Za-z0-9_]{0,62}$/;
|
|
80
|
+
const MAX_FIELD_CHARS = 256;
|
|
81
|
+
const NEWLINE = 0x0a;
|
|
82
|
+
const REQ_PREFIX = Buffer.from('{"type":"req",');
|
|
83
|
+
// The bridge client writes `{"type":"req","id":"<id>",…`: enough head bytes name the grant.
|
|
84
|
+
const REQ_ID_RE = /^\{"type":"req","id":"([A-Za-z0-9_-]{1,64})"/;
|
|
85
|
+
const REQ_HEAD_BYTES = 96;
|
|
86
|
+
const RECONNECT_DELAY_MS = 250;
|
|
87
|
+
|
|
88
|
+
function bridgeError(code, message) {
|
|
89
|
+
const error = new Error(`model proxy bridge: ${message}`);
|
|
90
|
+
error.name = 'ModelProxyBridgeError';
|
|
91
|
+
error.code = code;
|
|
92
|
+
return error;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function abortError(message) {
|
|
96
|
+
const error = new Error(message);
|
|
97
|
+
error.name = 'AbortError';
|
|
98
|
+
return error;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function abortReason(signal) {
|
|
102
|
+
if (signal?.reason instanceof Error) return signal.reason;
|
|
103
|
+
return abortError(String(signal?.reason || 'model call aborted'));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function unavailable(detail, reason) {
|
|
107
|
+
const error = new ModelProxyUnavailableError(detail);
|
|
108
|
+
if (reason) error.reason = reason;
|
|
109
|
+
return error;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isPlainObject(value) {
|
|
113
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Log-safe token: no frame content, only a bounded identifier. */
|
|
117
|
+
function logField(value) {
|
|
118
|
+
const text = String(value ?? '');
|
|
119
|
+
return text ? text.slice(0, 80).replace(/[^\w.:/@+-]/g, '_') : '-';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Framing ─────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/** Newline framing over chunk lists: a large frame is joined once, and its
|
|
125
|
+
* size is checked against its limit before it is ever parsed. */
|
|
126
|
+
class LineBuffer {
|
|
127
|
+
#chunks = [];
|
|
128
|
+
#headCache = null;
|
|
129
|
+
size = 0;
|
|
130
|
+
|
|
131
|
+
/** The frame's first bytes (stable once long enough: the frame start never changes). */
|
|
132
|
+
#head(piece, length) {
|
|
133
|
+
if (this.#headCache) return this.#headCache;
|
|
134
|
+
const parts = [];
|
|
135
|
+
let have = 0;
|
|
136
|
+
for (const part of [...this.#chunks, piece]) {
|
|
137
|
+
if (have >= length) break;
|
|
138
|
+
parts.push(part);
|
|
139
|
+
have += part.length;
|
|
140
|
+
}
|
|
141
|
+
const head = Buffer.concat(parts).subarray(0, length);
|
|
142
|
+
if (head.length >= length) this.#headCache = head;
|
|
143
|
+
return head;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The first `length` bytes of the buffered partial frame. */
|
|
147
|
+
peek(length) {
|
|
148
|
+
const parts = [];
|
|
149
|
+
let have = 0;
|
|
150
|
+
for (const part of this.#chunks) {
|
|
151
|
+
if (have >= length) break;
|
|
152
|
+
parts.push(part);
|
|
153
|
+
have += part.length;
|
|
154
|
+
}
|
|
155
|
+
return Buffer.concat(parts).subarray(0, length);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Feed `chunk`; `onLine(line)` runs for each complete frame as it is found
|
|
160
|
+
* (so a frame can change the limits of the next) and returns false to stop.
|
|
161
|
+
* `limit.min()` is the size below which no frame is refused and
|
|
162
|
+
* `limit.forHead(head)` the limit of a frame starting with `head`. Returns
|
|
163
|
+
* false once a frame exceeds its limit.
|
|
164
|
+
*/
|
|
165
|
+
push(chunk, limit, onLine) {
|
|
166
|
+
let start = 0;
|
|
167
|
+
for (;;) {
|
|
168
|
+
const index = chunk.indexOf(NEWLINE, start);
|
|
169
|
+
const piece = chunk.subarray(start, index < 0 ? chunk.length : index);
|
|
170
|
+
const size = this.size + piece.length;
|
|
171
|
+
if (size > limit.min() && size > limit.forHead(this.#head(piece, REQ_PREFIX.length))) return false;
|
|
172
|
+
if (index < 0) {
|
|
173
|
+
if (piece.length) {
|
|
174
|
+
this.#chunks.push(piece);
|
|
175
|
+
this.size = size;
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
const line = this.#chunks.length ? Buffer.concat([...this.#chunks, piece]) : piece;
|
|
180
|
+
this.#chunks = [];
|
|
181
|
+
this.#headCache = null;
|
|
182
|
+
this.size = 0;
|
|
183
|
+
if (onLine(line) === false) return true;
|
|
184
|
+
start = index + 1;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const startsWithReq = (head) => head.length >= REQ_PREFIX.length && head.equals(REQ_PREFIX);
|
|
190
|
+
|
|
191
|
+
// ── Daemon side ─────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The daemon's loopback relay to `channel`. `admit()` gates every new
|
|
195
|
+
* connection and request (owner fencing, shutdown); `trackWork(active)` hears
|
|
196
|
+
* every change of the running-request count; `log` gets one line per request
|
|
197
|
+
* (id, method, provider, model, duration, error code) and never a token or a
|
|
198
|
+
* frame.
|
|
199
|
+
*/
|
|
200
|
+
export function createModelProxyBridgeServer({
|
|
201
|
+
channel,
|
|
202
|
+
admit = () => true,
|
|
203
|
+
trackWork = () => {},
|
|
204
|
+
log = () => {},
|
|
205
|
+
limits: limitOverrides = {},
|
|
206
|
+
} = {}) {
|
|
207
|
+
const limits = { ...MODEL_PROXY_BRIDGE_LIMITS, ...limitOverrides };
|
|
208
|
+
const tokenBytes = randomBytes(32);
|
|
209
|
+
const token = tokenBytes.toString('hex');
|
|
210
|
+
const connections = new Set();
|
|
211
|
+
/** Reservations waiting for byte budget, oldest first. */
|
|
212
|
+
const waiting = [];
|
|
213
|
+
let draining = false;
|
|
214
|
+
let redrain = false;
|
|
215
|
+
const settling = new Set();
|
|
216
|
+
/** Bytes of granted reservations and of running requests' frames. */
|
|
217
|
+
let budgetUsed = 0;
|
|
218
|
+
let active = 0;
|
|
219
|
+
let closing = false;
|
|
220
|
+
let closed = null;
|
|
221
|
+
|
|
222
|
+
const server = net.createServer({ allowHalfOpen: false }, onConnection);
|
|
223
|
+
const listener = createLoopbackListener({
|
|
224
|
+
server,
|
|
225
|
+
onListening: () => {},
|
|
226
|
+
onError: (error) => log(`model-proxy bridge listener error: ${logField(error?.code || error?.name)}`),
|
|
227
|
+
});
|
|
228
|
+
const unsubscribe = channel.onStateChange(() => {
|
|
229
|
+
for (const conn of connections) if (conn.authenticated) sendState(conn);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
function setActive(delta) {
|
|
233
|
+
active += delta;
|
|
234
|
+
trackWork(active);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function onConnection(socket) {
|
|
238
|
+
if (closing || connections.size >= limits.maxConnections || !admit()) {
|
|
239
|
+
socket.destroy();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
socket.setNoDelay(true);
|
|
243
|
+
const conn = {
|
|
244
|
+
socket,
|
|
245
|
+
authenticated: false,
|
|
246
|
+
closed: false,
|
|
247
|
+
lines: new LineBuffer(),
|
|
248
|
+
limit: null,
|
|
249
|
+
reservations: new Map(),
|
|
250
|
+
/** The grant the buffered partial frame belongs to, and its size when last seen. */
|
|
251
|
+
partialGrant: null,
|
|
252
|
+
partialSize: 0,
|
|
253
|
+
requests: new Map(),
|
|
254
|
+
stallTimer: null,
|
|
255
|
+
helloTimer: setTimeout(() => {
|
|
256
|
+
if (!conn.authenticated) closeConnection(conn);
|
|
257
|
+
}, limits.helloTimeoutMs),
|
|
258
|
+
};
|
|
259
|
+
conn.helloTimer.unref?.();
|
|
260
|
+
conn.limit = frameLimit(conn);
|
|
261
|
+
connections.add(conn);
|
|
262
|
+
socket.on('data', (chunk) => onData(conn, chunk));
|
|
263
|
+
socket.on('error', () => {});
|
|
264
|
+
socket.on('close', () => closeConnection(conn));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function frameLimit(conn) {
|
|
268
|
+
return {
|
|
269
|
+
min: () => (conn.authenticated ? limits.controlFrameMaxBytes : limits.helloMaxBytes),
|
|
270
|
+
forHead: (head) => {
|
|
271
|
+
if (!conn.authenticated) return limits.helloMaxBytes;
|
|
272
|
+
// A request frame may only be as large as the largest grant it could be for.
|
|
273
|
+
return startsWithReq(head) ? largestGrant(conn) : limits.controlFrameMaxBytes;
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function largestGrant(conn) {
|
|
279
|
+
let largest = 0;
|
|
280
|
+
for (const reservation of conn.reservations.values()) {
|
|
281
|
+
if (reservation.granted && reservation.bytes > largest) largest = reservation.bytes;
|
|
282
|
+
}
|
|
283
|
+
return largest;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Never paused for budget: request bytes were reserved before they were sent. */
|
|
287
|
+
function onData(conn, chunk) {
|
|
288
|
+
if (conn.closed) return;
|
|
289
|
+
const withinLimits = conn.lines.push(chunk, conn.limit, (line) => {
|
|
290
|
+
handleLine(conn, line);
|
|
291
|
+
return !conn.closed;
|
|
292
|
+
});
|
|
293
|
+
if (conn.closed) return;
|
|
294
|
+
if (!withinLimits) {
|
|
295
|
+
fail(conn, 'MODEL_PROXY_BRIDGE_FRAME_TOO_LARGE');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
trackGrantProgress(conn);
|
|
299
|
+
armStall(conn);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A grant's stall timer is refreshed only by its own `req` frame growing:
|
|
304
|
+
* control frames and frames for other ids never keep a silent grant alive.
|
|
305
|
+
* (A frame that completes settles its grant in handleRequest.)
|
|
306
|
+
*/
|
|
307
|
+
function trackGrantProgress(conn) {
|
|
308
|
+
const size = conn.lines.size;
|
|
309
|
+
if (size === 0) {
|
|
310
|
+
conn.partialGrant = null;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const id = REQ_ID_RE.exec(conn.lines.peek(REQ_HEAD_BYTES).toString('latin1'))?.[1];
|
|
314
|
+
const reservation = id === undefined ? undefined : conn.reservations.get(id);
|
|
315
|
+
const owner = reservation?.granted ? reservation : null;
|
|
316
|
+
if (owner && (owner !== conn.partialGrant || size > conn.partialSize)) owner.timer?.refresh();
|
|
317
|
+
conn.partialGrant = owner;
|
|
318
|
+
conn.partialSize = size;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function armStall(conn) {
|
|
322
|
+
if (conn.lines.size === 0) {
|
|
323
|
+
clearTimeout(conn.stallTimer);
|
|
324
|
+
conn.stallTimer = null;
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (conn.stallTimer) {
|
|
328
|
+
conn.stallTimer.refresh();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
conn.stallTimer = setTimeout(() => fail(conn, 'MODEL_PROXY_BRIDGE_FRAME_STALLED'), limits.frameStallMs);
|
|
332
|
+
conn.stallTimer.unref?.();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Grants waiting reservations strictly in FIFO order. Non-reentrant: a grant
|
|
337
|
+
* can close its connection (slow client), which calls back in here; a nested
|
|
338
|
+
* call only requests another pass, so the loop always acts on the current
|
|
339
|
+
* head of the queue. A reservation leaves the queue only once granted or
|
|
340
|
+
* withdrawn (cancel, disconnect, fencing); budget held by grants and running
|
|
341
|
+
* requests is always released by their completion or by a bounded timeout,
|
|
342
|
+
* so a waiting reservation is eventually granted.
|
|
343
|
+
*/
|
|
344
|
+
function drain() {
|
|
345
|
+
if (draining) {
|
|
346
|
+
redrain = true;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
draining = true;
|
|
350
|
+
try {
|
|
351
|
+
do {
|
|
352
|
+
redrain = false;
|
|
353
|
+
while (waiting.length) {
|
|
354
|
+
const reservation = waiting[0];
|
|
355
|
+
if (reservation.conn.closed || reservation.conn.reservations.get(reservation.id) !== reservation) {
|
|
356
|
+
waiting.shift();
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (budgetUsed + reservation.bytes > limits.aggregateBytes) break;
|
|
360
|
+
waiting.shift();
|
|
361
|
+
grant(reservation);
|
|
362
|
+
}
|
|
363
|
+
} while (redrain);
|
|
364
|
+
} finally {
|
|
365
|
+
draining = false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function grant(reservation) {
|
|
370
|
+
const { conn } = reservation;
|
|
371
|
+
budgetUsed += reservation.bytes;
|
|
372
|
+
reservation.granted = true;
|
|
373
|
+
// The granted frame must keep arriving, and arrive in full by the ceiling,
|
|
374
|
+
// or its budget is reclaimed.
|
|
375
|
+
reservation.timer = setTimeout(() => fail(conn, 'MODEL_PROXY_BRIDGE_FRAME_STALLED'), limits.frameStallMs);
|
|
376
|
+
reservation.timer.unref?.();
|
|
377
|
+
reservation.deadline = setTimeout(() => fail(conn, 'MODEL_PROXY_BRIDGE_FRAME_STALLED'), limits.grantMaxMs);
|
|
378
|
+
reservation.deadline.unref?.();
|
|
379
|
+
send(conn, { type: 'grant', id: reservation.id });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Withdraws a reservation; a granted one gives its budget back (the caller drains). */
|
|
383
|
+
function dropReservation(conn, reservation) {
|
|
384
|
+
conn.reservations.delete(reservation.id);
|
|
385
|
+
clearTimeout(reservation.timer);
|
|
386
|
+
clearTimeout(reservation.deadline);
|
|
387
|
+
if (reservation.granted) budgetUsed -= reservation.bytes;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function handleLine(conn, line) {
|
|
391
|
+
if (!conn.authenticated) {
|
|
392
|
+
handleHello(conn, line);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
let frame;
|
|
396
|
+
try {
|
|
397
|
+
frame = decodeModelProxyWire(line.toString('utf8'));
|
|
398
|
+
} catch {
|
|
399
|
+
fail(conn, 'MODEL_PROXY_BRIDGE_BAD_FRAME');
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (!isPlainObject(frame) || (line.length > limits.controlFrameMaxBytes && frame.type !== 'req')) {
|
|
403
|
+
fail(conn, 'MODEL_PROXY_BRIDGE_BAD_FRAME');
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (frame.type === 'req') handleRequest(conn, frame, line.length + 1);
|
|
407
|
+
else if (frame.type === 'reserve') handleReserve(conn, frame);
|
|
408
|
+
else if (frame.type === 'result') handleResult(conn, frame);
|
|
409
|
+
else if (frame.type === 'cancel') handleCancel(conn, frame);
|
|
410
|
+
else fail(conn, 'MODEL_PROXY_BRIDGE_BAD_FRAME');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function handleHello(conn, line) {
|
|
414
|
+
let hello = null;
|
|
415
|
+
try {
|
|
416
|
+
hello = JSON.parse(line.toString('utf8'));
|
|
417
|
+
} catch {
|
|
418
|
+
/* rejected below */
|
|
419
|
+
}
|
|
420
|
+
const presented = typeof hello?.token === 'string' && TOKEN_RE.test(hello.token) ? hello.token : null;
|
|
421
|
+
const valid =
|
|
422
|
+
hello?.type === 'hello' &&
|
|
423
|
+
hello.v === MODEL_PROXY_BRIDGE_PROTOCOL &&
|
|
424
|
+
presented !== null &&
|
|
425
|
+
timingSafeEqual(Buffer.from(presented, 'hex'), tokenBytes);
|
|
426
|
+
if (!valid) {
|
|
427
|
+
fail(conn, 'MODEL_PROXY_BRIDGE_UNAUTHORIZED');
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (!admit()) {
|
|
431
|
+
fail(conn, 'MODEL_PROXY_UNAVAILABLE');
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
clearTimeout(conn.helloTimer);
|
|
435
|
+
conn.authenticated = true;
|
|
436
|
+
sendState(conn);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function refuse(conn, id, error) {
|
|
440
|
+
send(conn, { type: 'done', id, ok: false, error: modelProxyErrorToWire(error) });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function validCallbackNames(names) {
|
|
444
|
+
return (
|
|
445
|
+
Array.isArray(names) &&
|
|
446
|
+
names.length <= limits.maxCallbacks &&
|
|
447
|
+
names.every((name) => typeof name === 'string' && CALLBACK_NAME_RE.test(name)) &&
|
|
448
|
+
new Set(names).size === names.length
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** A request announces its frame size first; it is granted when the byte budget allows. */
|
|
453
|
+
function handleReserve(conn, frame) {
|
|
454
|
+
const { id, bytes } = frame;
|
|
455
|
+
if (typeof id !== 'string' || !ID_RE.test(id) || !Number.isSafeInteger(bytes) || bytes < 1) {
|
|
456
|
+
fail(conn, 'MODEL_PROXY_BRIDGE_BAD_FRAME');
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (conn.requests.has(id) || conn.reservations.has(id)) {
|
|
460
|
+
fail(conn, 'MODEL_PROXY_BRIDGE_DUPLICATE_ID');
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (!admit()) {
|
|
464
|
+
refuse(conn, id, unavailable('the daemon is not accepting new model calls'));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (conn.requests.size + conn.reservations.size >= limits.maxRequestsPerConnection) {
|
|
468
|
+
refuse(conn, id, bridgeError('MODEL_PROXY_BRIDGE_LIMIT', 'too many concurrent calls on one connection'));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (bytes > limits.requestFrameMaxBytes || bytes > limits.aggregateBytes) {
|
|
472
|
+
refuse(conn, id, bridgeError('MODEL_PROXY_REQUEST_TOO_LARGE', 'the model call exceeds the bridge limit'));
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const reservation = { conn, id, bytes, granted: false, timer: null, deadline: null };
|
|
476
|
+
conn.reservations.set(id, reservation);
|
|
477
|
+
waiting.push(reservation);
|
|
478
|
+
drain();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function requestRefusal(call, names) {
|
|
482
|
+
if (!admit()) return unavailable('the daemon is not accepting new model calls');
|
|
483
|
+
if (!isPlainObject(call) || !MODEL_PROXY_METHODS.includes(call.method)) {
|
|
484
|
+
return bridgeError('MODEL_PROXY_BRIDGE_METHOD', 'unsupported model proxy method');
|
|
485
|
+
}
|
|
486
|
+
const provider = call.provider ?? '';
|
|
487
|
+
const model = call.model ?? null;
|
|
488
|
+
if (
|
|
489
|
+
typeof provider !== 'string' ||
|
|
490
|
+
provider.length > MAX_FIELD_CHARS ||
|
|
491
|
+
(model !== null && (typeof model !== 'string' || model.length > MAX_FIELD_CHARS)) ||
|
|
492
|
+
!validCallbackNames(names)
|
|
493
|
+
) {
|
|
494
|
+
return bridgeError('MODEL_PROXY_BRIDGE_BAD_REQUEST', 'malformed model proxy request');
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function handleRequest(conn, frame, bytes) {
|
|
500
|
+
const { id, call, callbacks: names } = frame;
|
|
501
|
+
const reservation = typeof id === 'string' ? conn.reservations.get(id) : undefined;
|
|
502
|
+
// Only a granted reservation admits a request frame, and only up to its size.
|
|
503
|
+
if (!reservation?.granted || bytes > reservation.bytes) {
|
|
504
|
+
fail(conn, 'MODEL_PROXY_BRIDGE_BAD_FRAME');
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
conn.reservations.delete(id);
|
|
508
|
+
clearTimeout(reservation.timer);
|
|
509
|
+
clearTimeout(reservation.deadline);
|
|
510
|
+
const refusal = requestRefusal(call, names);
|
|
511
|
+
if (refusal) {
|
|
512
|
+
budgetUsed -= reservation.bytes;
|
|
513
|
+
refuse(conn, id, refusal);
|
|
514
|
+
drain();
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const entry = {
|
|
518
|
+
id,
|
|
519
|
+
method: call.method,
|
|
520
|
+
provider: call.provider ?? '',
|
|
521
|
+
model: call.model ?? null,
|
|
522
|
+
// The granted bytes stay reserved until the call settles.
|
|
523
|
+
bytes: reservation.bytes,
|
|
524
|
+
controller: new AbortController(),
|
|
525
|
+
seq: 0,
|
|
526
|
+
waiters: new Map(),
|
|
527
|
+
queue: Promise.resolve(),
|
|
528
|
+
startedAt: Date.now(),
|
|
529
|
+
};
|
|
530
|
+
conn.requests.set(id, entry);
|
|
531
|
+
setActive(1);
|
|
532
|
+
const callbacks = Object.fromEntries(names.map((name) => [name, (...args) => relay(conn, entry, name, args)]));
|
|
533
|
+
const { callbacks: _names, ...upstream } = call;
|
|
534
|
+
const settled = channel
|
|
535
|
+
.request(upstream, { signal: entry.controller.signal, callbacks })
|
|
536
|
+
.then(
|
|
537
|
+
(value) => finish(conn, entry, true, value),
|
|
538
|
+
(error) => finish(conn, entry, false, error)
|
|
539
|
+
)
|
|
540
|
+
.finally(() => settling.delete(settled));
|
|
541
|
+
settling.add(settled);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** One callback of one request, strictly after the previous one answered. */
|
|
545
|
+
function relay(conn, entry, name, args) {
|
|
546
|
+
const run = () =>
|
|
547
|
+
new Promise((resolve, reject) => {
|
|
548
|
+
if (conn.closed || entry.controller.signal.aborted) {
|
|
549
|
+
reject(entry.controller.signal.reason ?? abortError('model call aborted'));
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
entry.seq += 1;
|
|
553
|
+
const seq = entry.seq;
|
|
554
|
+
const timer = setTimeout(() => {
|
|
555
|
+
entry.waiters.delete(seq);
|
|
556
|
+
const error = unavailable('the bridge client did not acknowledge a model callback');
|
|
557
|
+
reject(error);
|
|
558
|
+
abortEntry(entry, error);
|
|
559
|
+
}, limits.callbackAckMs);
|
|
560
|
+
timer.unref?.();
|
|
561
|
+
entry.waiters.set(seq, { resolve, reject, timer });
|
|
562
|
+
send(conn, { type: 'event', id: entry.id, seq, name, args: modelProxyWireValue(args) ?? [] });
|
|
563
|
+
});
|
|
564
|
+
const next = entry.queue.then(run, run);
|
|
565
|
+
entry.queue = next.then(
|
|
566
|
+
() => {},
|
|
567
|
+
() => {}
|
|
568
|
+
);
|
|
569
|
+
return next;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function rejectWaiters(entry, reason) {
|
|
573
|
+
const waiters = [...entry.waiters.values()];
|
|
574
|
+
entry.waiters.clear();
|
|
575
|
+
for (const waiter of waiters) {
|
|
576
|
+
clearTimeout(waiter.timer);
|
|
577
|
+
waiter.reject(reason);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function abortEntry(entry, reason) {
|
|
582
|
+
if (!entry.controller.signal.aborted) entry.controller.abort(reason);
|
|
583
|
+
rejectWaiters(entry, reason);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function finish(conn, entry, ok, value) {
|
|
587
|
+
// Every relayed callback is answered (or failed) before `done`.
|
|
588
|
+
await entry.queue;
|
|
589
|
+
rejectWaiters(entry, abortError('model call settled'));
|
|
590
|
+
conn.requests.delete(entry.id);
|
|
591
|
+
budgetUsed -= entry.bytes;
|
|
592
|
+
setActive(-1);
|
|
593
|
+
const code = ok ? '' : ` error=${logField(value?.code || value?.name || 'Error')}`;
|
|
594
|
+
log(
|
|
595
|
+
`model-proxy bridge ${logField(entry.method)} id=${logField(entry.id)} provider=${logField(entry.provider)}` +
|
|
596
|
+
` model=${logField(entry.model)} ${Date.now() - entry.startedAt}ms${code}`
|
|
597
|
+
);
|
|
598
|
+
if (ok) send(conn, { type: 'done', id: entry.id, ok: true, value: modelProxyWireValue(value) ?? null });
|
|
599
|
+
else send(conn, { type: 'done', id: entry.id, ok: false, error: modelProxyErrorToWire(value) });
|
|
600
|
+
drain();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function handleResult(conn, frame) {
|
|
604
|
+
const entry = conn.requests.get(frame.id);
|
|
605
|
+
const waiter = entry?.waiters.get(frame.seq);
|
|
606
|
+
if (!waiter) return;
|
|
607
|
+
entry.waiters.delete(frame.seq);
|
|
608
|
+
clearTimeout(waiter.timer);
|
|
609
|
+
if (frame.ok === true) waiter.resolve(frame.value ?? null);
|
|
610
|
+
else waiter.reject(modelProxyErrorFromWire(frame.error));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function handleCancel(conn, frame) {
|
|
614
|
+
const reservation = conn.reservations.get(frame.id);
|
|
615
|
+
if (reservation) {
|
|
616
|
+
dropReservation(conn, reservation);
|
|
617
|
+
drain();
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const entry = conn.requests.get(frame.id);
|
|
621
|
+
if (entry) abortEntry(entry, abortError('the bridge client cancelled the model call'));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function sendState(conn) {
|
|
625
|
+
send(conn, { type: 'state', connected: channel.connected, generation: channel.generation });
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function send(conn, frame) {
|
|
629
|
+
if (conn.closed) return;
|
|
630
|
+
if (conn.socket.writableLength > limits.outgoingQueueBytes) {
|
|
631
|
+
fail(conn, 'MODEL_PROXY_BRIDGE_SLOW_CLIENT');
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
conn.socket.write(`${JSON.stringify(frame)}\n`);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function fail(conn, code) {
|
|
638
|
+
if (conn.closed) return;
|
|
639
|
+
conn.socket.write(`${JSON.stringify({ type: 'error', code })}\n`);
|
|
640
|
+
closeConnection(conn, true);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Never replays: every running call of the connection is aborted upstream. */
|
|
644
|
+
function closeConnection(conn, flush = false) {
|
|
645
|
+
if (conn.closed) return;
|
|
646
|
+
conn.closed = true;
|
|
647
|
+
connections.delete(conn);
|
|
648
|
+
clearTimeout(conn.helloTimer);
|
|
649
|
+
clearTimeout(conn.stallTimer);
|
|
650
|
+
const reason = unavailable('the bridge client disconnected');
|
|
651
|
+
for (const entry of conn.requests.values()) abortEntry(entry, reason);
|
|
652
|
+
for (const reservation of [...conn.reservations.values()]) dropReservation(conn, reservation);
|
|
653
|
+
if (flush) {
|
|
654
|
+
conn.socket.end();
|
|
655
|
+
setTimeout(() => conn.socket.destroy(), 1_000).unref?.();
|
|
656
|
+
} else {
|
|
657
|
+
conn.socket.destroy();
|
|
658
|
+
}
|
|
659
|
+
drain();
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return {
|
|
663
|
+
/** Listen on 127.0.0.1 (an OS-chosen port); resolves the endpoint to publish. */
|
|
664
|
+
async start() {
|
|
665
|
+
const port = await listener.start();
|
|
666
|
+
return { port, token };
|
|
667
|
+
},
|
|
668
|
+
get activeCount() {
|
|
669
|
+
return active;
|
|
670
|
+
},
|
|
671
|
+
get connectionCount() {
|
|
672
|
+
return connections.size;
|
|
673
|
+
},
|
|
674
|
+
/** Abort every running call (fenced owner, shutdown); new ones are gated by admit(). */
|
|
675
|
+
abortAll(reason = 'the daemon is stopping') {
|
|
676
|
+
const error = unavailable(String(reason));
|
|
677
|
+
for (const conn of connections) {
|
|
678
|
+
for (const entry of conn.requests.values()) abortEntry(entry, error);
|
|
679
|
+
// Reserved calls never start: refused now, not when their frame arrives.
|
|
680
|
+
for (const reservation of [...conn.reservations.values()]) {
|
|
681
|
+
dropReservation(conn, reservation);
|
|
682
|
+
refuse(conn, reservation.id, error);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
drain();
|
|
686
|
+
},
|
|
687
|
+
/** Stop listening, abort every call, close every socket and wait until each call settled. */
|
|
688
|
+
close(reason = 'the daemon is stopping') {
|
|
689
|
+
if (closed) return closed;
|
|
690
|
+
closing = true;
|
|
691
|
+
unsubscribe();
|
|
692
|
+
this.abortAll(reason);
|
|
693
|
+
for (const conn of [...connections]) closeConnection(conn);
|
|
694
|
+
closed = Promise.all([listener.stop(), Promise.allSettled([...settling])]).then(() => {});
|
|
695
|
+
return closed;
|
|
696
|
+
},
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ── Child side ──────────────────────────────────────────────────────────────
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* The daemon's bridge endpoint from `<runtimeRoot>/daemon.json`. Fails closed
|
|
704
|
+
* with MODEL_PROXY_UNAVAILABLE (and a `reason`) when the file is missing, its
|
|
705
|
+
* daemon is gone, or it publishes no bridge. Never echoes file contents.
|
|
706
|
+
*/
|
|
707
|
+
export function readModelProxyBridgeDiscovery(runtimeRoot) {
|
|
708
|
+
const root = String(runtimeRoot || '').trim();
|
|
709
|
+
if (!root) throw unavailable('MIXDOG_RUNTIME_ROOT does not name the daemon runtime root', 'runtime-root-missing');
|
|
710
|
+
let record;
|
|
711
|
+
try {
|
|
712
|
+
record = JSON.parse(readFileSync(join(root, 'daemon.json'), 'utf8'));
|
|
713
|
+
} catch {
|
|
714
|
+
throw unavailable('no readable daemon discovery file in the runtime root', 'discovery-missing');
|
|
715
|
+
}
|
|
716
|
+
if (!isPidAlive(record?.pid)) {
|
|
717
|
+
throw unavailable('the daemon named by the discovery file is not running', 'daemon-not-running');
|
|
718
|
+
}
|
|
719
|
+
const endpoint = record?.endpoints?.modelProxy;
|
|
720
|
+
const port = Number(endpoint?.port);
|
|
721
|
+
if (
|
|
722
|
+
!Number.isInteger(port) ||
|
|
723
|
+
port < 1 ||
|
|
724
|
+
port > 65_535 ||
|
|
725
|
+
typeof endpoint?.token !== 'string' ||
|
|
726
|
+
!TOKEN_RE.test(endpoint.token)
|
|
727
|
+
) {
|
|
728
|
+
throw unavailable('the daemon publishes no model proxy bridge endpoint', 'endpoint-missing');
|
|
729
|
+
}
|
|
730
|
+
return { port, token: endpoint.token, pid: Number(record.pid) };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* The child half: exactly the channel surface ModelProxyProvider and
|
|
735
|
+
* loadModelProxyProviderConfig use. Connects lazily; `connected` means the
|
|
736
|
+
* bridge is authenticated AND the daemon has a usable PC client. The
|
|
737
|
+
* generation advances on every (re)connect, disconnect and upstream change.
|
|
738
|
+
*/
|
|
739
|
+
export class ModelProxyBridgeChannel {
|
|
740
|
+
#host;
|
|
741
|
+
#port;
|
|
742
|
+
#token;
|
|
743
|
+
#connectTimeoutMs;
|
|
744
|
+
#maxFrameBytes;
|
|
745
|
+
#socket = null;
|
|
746
|
+
#lines = null;
|
|
747
|
+
#connecting = null;
|
|
748
|
+
#authenticated = false;
|
|
749
|
+
#upstream = false;
|
|
750
|
+
#upstreamGeneration = null;
|
|
751
|
+
#generation = 0;
|
|
752
|
+
#requests = new Map();
|
|
753
|
+
#nextId = 0;
|
|
754
|
+
#changeWaiters = new Set();
|
|
755
|
+
#disposed = false;
|
|
756
|
+
|
|
757
|
+
constructor({
|
|
758
|
+
port,
|
|
759
|
+
token,
|
|
760
|
+
host = '127.0.0.1',
|
|
761
|
+
connectTimeoutMs = 10_000,
|
|
762
|
+
maxFrameBytes = MODEL_PROXY_BRIDGE_LIMITS.requestFrameMaxBytes,
|
|
763
|
+
} = {}) {
|
|
764
|
+
this.#host = host;
|
|
765
|
+
this.#port = port;
|
|
766
|
+
this.#token = token;
|
|
767
|
+
this.#connectTimeoutMs = connectTimeoutMs;
|
|
768
|
+
this.#maxFrameBytes = maxFrameBytes;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
get connected() {
|
|
772
|
+
return this.#authenticated && this.#upstream;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
get generation() {
|
|
776
|
+
return this.#generation;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Connects if needed; true once the bridge and a PC client are usable, false after `timeoutMs`. */
|
|
780
|
+
async whenConnected(timeoutMs) {
|
|
781
|
+
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
|
|
782
|
+
for (;;) {
|
|
783
|
+
if (this.connected) return true;
|
|
784
|
+
const remaining = deadline - Date.now();
|
|
785
|
+
if (this.#disposed || remaining <= 0) return false;
|
|
786
|
+
if (this.#authenticated) {
|
|
787
|
+
await this.#waitForChange(remaining);
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
const failed = await this.#withTimeout(this.#connect(), remaining).then(
|
|
791
|
+
() => false,
|
|
792
|
+
() => true
|
|
793
|
+
);
|
|
794
|
+
if (failed && !this.#authenticated) {
|
|
795
|
+
await this.#waitForChange(Math.min(RECONNECT_DELAY_MS, Math.max(0, deadline - Date.now())));
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
request(call, { signal = null, callbacks = {} } = {}) {
|
|
801
|
+
if (signal?.aborted) return Promise.reject(abortReason(signal));
|
|
802
|
+
if (this.#disposed) return Promise.reject(unavailable('the model proxy bridge was closed'));
|
|
803
|
+
return new Promise((resolve, reject) => {
|
|
804
|
+
this.#nextId += 1;
|
|
805
|
+
const entry = {
|
|
806
|
+
id: `r${this.#nextId}`,
|
|
807
|
+
callbacks,
|
|
808
|
+
resolve,
|
|
809
|
+
reject,
|
|
810
|
+
signal,
|
|
811
|
+
onAbort: null,
|
|
812
|
+
settled: false,
|
|
813
|
+
sent: false,
|
|
814
|
+
queue: Promise.resolve(),
|
|
815
|
+
};
|
|
816
|
+
if (signal) {
|
|
817
|
+
entry.onAbort = () => {
|
|
818
|
+
const wasSent = entry.sent && !entry.settled;
|
|
819
|
+
this.#settle(entry, false, abortReason(signal));
|
|
820
|
+
if (wasSent) this.#write({ type: 'cancel', id: entry.id });
|
|
821
|
+
};
|
|
822
|
+
signal.addEventListener('abort', entry.onAbort, { once: true });
|
|
823
|
+
}
|
|
824
|
+
this.#requests.set(entry.id, entry);
|
|
825
|
+
this.#updateRef();
|
|
826
|
+
this.#connect().then(
|
|
827
|
+
() => this.#send(entry, call),
|
|
828
|
+
(error) => this.#settle(entry, false, error)
|
|
829
|
+
);
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Close for good: outstanding calls fail, later ones fail fast. */
|
|
834
|
+
dispose(reason = 'the model proxy bridge was closed') {
|
|
835
|
+
if (this.#disposed) return;
|
|
836
|
+
this.#disposed = true;
|
|
837
|
+
const error = unavailable(reason);
|
|
838
|
+
if (this.#socket) this.#drop(this.#socket, error);
|
|
839
|
+
for (const entry of [...this.#requests.values()]) this.#settle(entry, false, error);
|
|
840
|
+
this.#notifyChange();
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
#send(entry, call) {
|
|
844
|
+
if (entry.settled) return;
|
|
845
|
+
if (!this.#upstream) {
|
|
846
|
+
this.#settle(entry, false, unavailable('no desktop client is connected to run model calls'));
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
let line;
|
|
850
|
+
try {
|
|
851
|
+
// Built in this order so a large frame starts with the `req` prefix the
|
|
852
|
+
// daemon checks before buffering it.
|
|
853
|
+
line = `{"type":"req","id":${JSON.stringify(entry.id)},"callbacks":${JSON.stringify(
|
|
854
|
+
Object.keys(entry.callbacks)
|
|
855
|
+
)},"call":${encodeModelProxyWire(call)}}`;
|
|
856
|
+
} catch (error) {
|
|
857
|
+
this.#settle(entry, false, error);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
const bytes = Buffer.byteLength(line) + 1;
|
|
861
|
+
if (bytes > this.#maxFrameBytes) {
|
|
862
|
+
this.#settle(
|
|
863
|
+
entry,
|
|
864
|
+
false,
|
|
865
|
+
bridgeError('MODEL_PROXY_REQUEST_TOO_LARGE', 'the model call exceeds the bridge limit')
|
|
866
|
+
);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
// The frame follows its grant; a cancel from here on also withdraws the reservation.
|
|
870
|
+
entry.line = line;
|
|
871
|
+
entry.sent = true;
|
|
872
|
+
this.#socket.write(`${JSON.stringify({ type: 'reserve', id: entry.id, bytes })}\n`);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
#handleGrant(socket, frame) {
|
|
876
|
+
const entry = this.#requests.get(String(frame.id));
|
|
877
|
+
if (!entry?.line) return;
|
|
878
|
+
const line = entry.line;
|
|
879
|
+
entry.line = null;
|
|
880
|
+
socket.write(`${line}\n`);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
#connect() {
|
|
884
|
+
if (this.#disposed) return Promise.reject(unavailable('the model proxy bridge was closed'));
|
|
885
|
+
if (this.#authenticated) return Promise.resolve();
|
|
886
|
+
if (this.#connecting) return this.#connecting.promise;
|
|
887
|
+
const attempt = Promise.withResolvers();
|
|
888
|
+
attempt.promise.catch(() => {});
|
|
889
|
+
this.#connecting = attempt;
|
|
890
|
+
const socket = net.connect({ host: this.#host, port: this.#port });
|
|
891
|
+
this.#socket = socket;
|
|
892
|
+
this.#lines = new LineBuffer();
|
|
893
|
+
socket.setNoDelay(true);
|
|
894
|
+
attempt.timer = setTimeout(
|
|
895
|
+
() => this.#drop(socket, unavailable('the model proxy bridge did not answer')),
|
|
896
|
+
this.#connectTimeoutMs
|
|
897
|
+
);
|
|
898
|
+
socket.on('connect', () => {
|
|
899
|
+
socket.write(`${JSON.stringify({ type: 'hello', v: MODEL_PROXY_BRIDGE_PROTOCOL, token: this.#token })}\n`);
|
|
900
|
+
});
|
|
901
|
+
socket.on('data', (chunk) => this.#onData(socket, chunk));
|
|
902
|
+
socket.on('error', () => {});
|
|
903
|
+
socket.on('close', () => this.#drop(socket, unavailable('the model proxy bridge disconnected')));
|
|
904
|
+
this.#updateRef();
|
|
905
|
+
return attempt.promise;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
#onData(socket, chunk) {
|
|
909
|
+
if (socket !== this.#socket) return;
|
|
910
|
+
const max = () => this.#maxFrameBytes;
|
|
911
|
+
const withinLimits = this.#lines.push(chunk, { min: max, forHead: max }, (line) => {
|
|
912
|
+
let frame;
|
|
913
|
+
try {
|
|
914
|
+
frame = decodeModelProxyWire(line.toString('utf8'));
|
|
915
|
+
} catch {
|
|
916
|
+
this.#drop(socket, unavailable('the model proxy bridge sent a malformed frame'));
|
|
917
|
+
return false;
|
|
918
|
+
}
|
|
919
|
+
if (isPlainObject(frame)) this.#handleFrame(socket, frame);
|
|
920
|
+
return socket === this.#socket;
|
|
921
|
+
});
|
|
922
|
+
if (!withinLimits && socket === this.#socket) {
|
|
923
|
+
this.#drop(socket, unavailable('the model proxy bridge sent an oversized frame'));
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
#handleFrame(socket, frame) {
|
|
928
|
+
switch (frame.type) {
|
|
929
|
+
case 'state':
|
|
930
|
+
this.#handleState(frame);
|
|
931
|
+
return;
|
|
932
|
+
case 'grant':
|
|
933
|
+
this.#handleGrant(socket, frame);
|
|
934
|
+
return;
|
|
935
|
+
case 'event':
|
|
936
|
+
this.#handleEvent(frame);
|
|
937
|
+
return;
|
|
938
|
+
case 'done': {
|
|
939
|
+
const entry = this.#requests.get(String(frame.id));
|
|
940
|
+
if (!entry) return;
|
|
941
|
+
const ok = frame.ok === true;
|
|
942
|
+
const value = ok ? (frame.value ?? null) : modelProxyErrorFromWire(frame.error);
|
|
943
|
+
entry.queue.then(() => this.#settle(entry, ok, value));
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
case 'error':
|
|
947
|
+
this.#drop(socket, unavailable(`the model proxy bridge refused the connection (${logField(frame.code)})`));
|
|
948
|
+
return;
|
|
949
|
+
default:
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
#handleState(frame) {
|
|
954
|
+
const connected = frame.connected === true;
|
|
955
|
+
const generation = frame.generation;
|
|
956
|
+
if (!this.#authenticated) {
|
|
957
|
+
this.#authenticated = true;
|
|
958
|
+
this.#upstream = connected;
|
|
959
|
+
this.#upstreamGeneration = generation;
|
|
960
|
+
this.#generation += 1;
|
|
961
|
+
const attempt = this.#connecting;
|
|
962
|
+
this.#connecting = null;
|
|
963
|
+
clearTimeout(attempt?.timer);
|
|
964
|
+
attempt?.resolve();
|
|
965
|
+
this.#updateRef();
|
|
966
|
+
this.#notifyChange();
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (connected === this.#upstream && generation === this.#upstreamGeneration) return;
|
|
970
|
+
this.#upstream = connected;
|
|
971
|
+
this.#upstreamGeneration = generation;
|
|
972
|
+
this.#generation += 1;
|
|
973
|
+
this.#notifyChange();
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
#handleEvent(frame) {
|
|
977
|
+
const entry = this.#requests.get(String(frame.id));
|
|
978
|
+
const seq = frame.seq;
|
|
979
|
+
if (!entry) {
|
|
980
|
+
// Given up here: answer at once so the daemon never waits out the ack.
|
|
981
|
+
this.#write({
|
|
982
|
+
type: 'result',
|
|
983
|
+
id: frame.id,
|
|
984
|
+
seq,
|
|
985
|
+
ok: false,
|
|
986
|
+
error: modelProxyErrorToWire(abortError('model call aborted')),
|
|
987
|
+
});
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
const run = async () => {
|
|
991
|
+
const callback = entry.callbacks[String(frame.name || '')];
|
|
992
|
+
let reply;
|
|
993
|
+
try {
|
|
994
|
+
const args = Array.isArray(frame.args) ? frame.args : [];
|
|
995
|
+
const value = typeof callback === 'function' ? await callback(...args) : null;
|
|
996
|
+
reply = { type: 'result', id: entry.id, seq, ok: true, value: modelProxyWireValue(value) ?? null };
|
|
997
|
+
} catch (error) {
|
|
998
|
+
reply = { type: 'result', id: entry.id, seq, ok: false, error: modelProxyErrorToWire(error) };
|
|
999
|
+
}
|
|
1000
|
+
if (Buffer.byteLength(JSON.stringify(reply)) + 1 > MODEL_PROXY_BRIDGE_LIMITS.controlFrameMaxBytes) {
|
|
1001
|
+
const error = bridgeError('MODEL_PROXY_BRIDGE_RESULT_TOO_LARGE', 'a callback result exceeds the bridge limit');
|
|
1002
|
+
reply = { type: 'result', id: entry.id, seq, ok: false, error: modelProxyErrorToWire(error) };
|
|
1003
|
+
}
|
|
1004
|
+
this.#write(reply);
|
|
1005
|
+
};
|
|
1006
|
+
entry.queue = entry.queue.then(run, run);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
#write(frame) {
|
|
1010
|
+
if (!this.#socket || !this.#authenticated) return;
|
|
1011
|
+
this.#socket.write(`${JSON.stringify(frame)}\n`);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
#settle(entry, ok, value) {
|
|
1015
|
+
if (entry.settled) return;
|
|
1016
|
+
entry.settled = true;
|
|
1017
|
+
entry.line = null;
|
|
1018
|
+
this.#requests.delete(entry.id);
|
|
1019
|
+
if (entry.onAbort) entry.signal.removeEventListener('abort', entry.onAbort);
|
|
1020
|
+
entry.callbacks = {};
|
|
1021
|
+
this.#updateRef();
|
|
1022
|
+
if (ok) entry.resolve(value);
|
|
1023
|
+
else entry.reject(value);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
#drop(socket, error) {
|
|
1027
|
+
if (socket !== this.#socket) return;
|
|
1028
|
+
this.#socket = null;
|
|
1029
|
+
this.#lines = null;
|
|
1030
|
+
socket.destroy();
|
|
1031
|
+
const wasAuthenticated = this.#authenticated;
|
|
1032
|
+
this.#authenticated = false;
|
|
1033
|
+
this.#upstream = false;
|
|
1034
|
+
this.#upstreamGeneration = null;
|
|
1035
|
+
if (wasAuthenticated) this.#generation += 1;
|
|
1036
|
+
const attempt = this.#connecting;
|
|
1037
|
+
this.#connecting = null;
|
|
1038
|
+
if (attempt) {
|
|
1039
|
+
clearTimeout(attempt.timer);
|
|
1040
|
+
attempt.reject(error);
|
|
1041
|
+
}
|
|
1042
|
+
for (const entry of [...this.#requests.values()]) this.#settle(entry, false, error);
|
|
1043
|
+
this.#notifyChange();
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** The socket keeps the process alive only while something waits on it. */
|
|
1047
|
+
#updateRef() {
|
|
1048
|
+
if (!this.#socket) return;
|
|
1049
|
+
if (this.#connecting || this.#requests.size || this.#changeWaiters.size) this.#socket.ref();
|
|
1050
|
+
else this.#socket.unref();
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
#waitForChange(ms) {
|
|
1054
|
+
return new Promise((resolve) => {
|
|
1055
|
+
const waiter = () => {
|
|
1056
|
+
clearTimeout(timer);
|
|
1057
|
+
this.#changeWaiters.delete(waiter);
|
|
1058
|
+
this.#updateRef();
|
|
1059
|
+
resolve();
|
|
1060
|
+
};
|
|
1061
|
+
const timer = setTimeout(waiter, ms);
|
|
1062
|
+
this.#changeWaiters.add(waiter);
|
|
1063
|
+
this.#updateRef();
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
#notifyChange() {
|
|
1068
|
+
for (const waiter of [...this.#changeWaiters]) waiter();
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
#withTimeout(promise, ms) {
|
|
1072
|
+
let timer;
|
|
1073
|
+
return Promise.race([
|
|
1074
|
+
promise,
|
|
1075
|
+
new Promise((_, reject) => {
|
|
1076
|
+
timer = setTimeout(() => reject(unavailable('the model proxy bridge did not answer in time')), ms);
|
|
1077
|
+
}),
|
|
1078
|
+
]).finally(() => clearTimeout(timer));
|
|
1079
|
+
}
|
|
1080
|
+
}
|