mixdog 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/package.json +1 -1
- package/scripts/gate-local.mjs +19 -3
- package/scripts/model-catalog-audit.mjs +1 -15
- package/src/agents/reviewer/AGENT.md +3 -3
- package/src/agents/reviewer/agent.json +1 -1
- package/src/defaults/shipped-history.json +5 -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 +13 -3
- package/src/defaults/skills/visualize/SKILL.md +12 -9
- package/src/repl/slash-commands.mjs +16 -1
- 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-progress-watchdog.mjs +29 -4
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +10 -0
- package/src/runtime/agent/orchestrator/cache-mtime.mjs +26 -5
- package/src/runtime/agent/orchestrator/config.mjs +10 -4
- 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 +122 -15
- package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +54 -11
- package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +104 -9
- package/src/runtime/agent/orchestrator/mcp/client.mjs +358 -140
- package/src/runtime/agent/orchestrator/mcp/lazy-output-validator.mjs +25 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +103 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-credentials.mjs +150 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +111 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-probe.mjs +87 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +95 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-request.mjs +122 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +9 -0
- package/src/runtime/agent/orchestrator/mcp/oauth.mjs +229 -0
- package/src/runtime/agent/orchestrator/mcp/plugin-placeholders.mjs +54 -0
- package/src/runtime/agent/orchestrator/providers/account-pool.mjs +294 -44
- package/src/runtime/agent/orchestrator/providers/anthropic-api-recovery.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +94 -31
- package/src/runtime/agent/orchestrator/providers/anthropic-error-contract.mjs +96 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +39 -18
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens-recovery.mjs +55 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +25 -21
- package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-midstream-recovery.mjs +18 -4
- package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +13 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-midstream.mjs +10 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-recovery.mjs +23 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +46 -20
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +14 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +73 -35
- package/src/runtime/agent/orchestrator/providers/anthropic-overload-fallback.mjs +69 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +4 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +53 -18
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +30 -9
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +30 -14
- package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/antigravity-retry-policy.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +82 -9
- 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 +447 -0
- package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +38 -5
- package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/cursor-wire-guards.mjs +97 -2
- package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-bridge.mjs +102 -23
- package/src/runtime/agent/orchestrator/providers/cursor-wire-transport.mjs +103 -33
- package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +29 -6
- package/src/runtime/agent/orchestrator/providers/cursor.mjs +18 -1
- package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +7 -4
- package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +86 -19
- package/src/runtime/agent/orchestrator/providers/gemini-cache-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +29 -8
- package/src/runtime/agent/orchestrator/providers/gemini-error-contract.mjs +191 -0
- package/src/runtime/agent/orchestrator/providers/gemini-family.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/gemini-history-signatures.mjs +9 -4
- package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +180 -0
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +21 -82
- package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -12
- package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +21 -20
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +97 -51
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +68 -19
- package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +7 -1
- package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/model-proxy-bridge.mjs +1080 -0
- package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +15 -0
- package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +35 -0
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +4 -9
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +6 -5
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-result.mjs +22 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-error-contract.mjs +50 -0
- 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-response-normalization.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +16 -8
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses-state.mjs +3 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +94 -13
- package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +193 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream-common.mjs +66 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +6 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +6 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -14
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +26 -6
- package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +42 -25
- package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +274 -0
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-request.mjs +72 -32
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/terminal-frames.mjs +18 -11
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-outcome.mjs +1 -2
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-stamps.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-state.mjs +2 -3
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-watchdogs.mjs +1 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-dispatch.mjs +34 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +5 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +22 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +5 -9
- package/src/runtime/agent/orchestrator/providers/openai-retry-policy.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-handshake.mjs +16 -8
- package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/attempt-context.mjs +1 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/policy.mjs +25 -14
- package/src/runtime/agent/orchestrator/providers/openai-ws-terminal.mjs +18 -11
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +35 -24
- package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +581 -0
- package/src/runtime/agent/orchestrator/providers/registry.mjs +35 -9
- package/src/runtime/agent/orchestrator/providers/retry-backoff.mjs +46 -7
- package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +118 -219
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +148 -45
- package/src/runtime/agent/orchestrator/providers/retry-midstream.mjs +81 -16
- package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +3 -0
- package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +3 -0
- 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/goal-reminder.mjs +0 -18
- package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +57 -28
- package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +19 -16
- package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +268 -181
- 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/session/catalog-db/index.mjs +284 -0
- package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +270 -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/messages.mjs +6 -2
- 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.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +10 -4
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +102 -18
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +42 -5
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +14 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +20 -12
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +50 -23
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +22 -14
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +18 -7
- package/src/runtime/agent/orchestrator/session/manager.mjs +0 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +155 -29
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +6 -2
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +16 -0
- package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +22 -10
- package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +819 -0
- package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +22 -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 +183 -374
- 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 +41 -3
- package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +38 -17
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/deadline-plan.mjs +12 -15
- 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/builtin-tools.mjs +5 -5
- 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-range-index.mjs +173 -22
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +8 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +5 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +15 -2
- 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/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/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/shell-snapshot.mjs +4 -0
- package/src/runtime/attachments/store.d.mts +3 -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/pg/process.mjs +34 -24
- package/src/runtime/memory/lib/pg/supervisor.mjs +16 -13
- package/src/runtime/memory/lib/session-ingest.mjs +2 -2
- package/src/runtime/office/README.md +1 -1
- package/src/runtime/office/shared/values.mjs +0 -5
- package/src/runtime/shared/atomic-file.mjs +25 -4
- package/src/runtime/shared/background-tasks.mjs +92 -14
- package/src/runtime/shared/err-text.mjs +25 -0
- package/src/runtime/shared/error-presentation.mjs +159 -19
- package/src/runtime/shared/extension-scopes.mjs +5 -3
- package/src/runtime/shared/file-lock.mjs +37 -11
- package/src/runtime/shared/llm/anthropic-betas.mjs +4 -3
- package/src/runtime/shared/llm/anthropic-effort.mjs +134 -131
- package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +50 -14
- package/src/runtime/shared/llm/model-catalog.mjs +53 -52
- package/src/runtime/shared/llm/openai-daybreak.mjs +4 -16
- package/src/runtime/shared/llm/provider-catalog-registry.mjs +17 -0
- package/src/runtime/shared/llm/provider-model-identities.mjs +13 -4
- package/src/runtime/shared/llm/usage-rollup.mjs +0 -1
- package/src/runtime/shared/model-display.mjs +13 -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 +124 -20
- package/src/runtime/shared/provider-api-key.mjs +12 -0
- package/src/runtime/shared/quota-resume-store.mjs +11 -0
- package/src/runtime/shared/session-baseline-store.mjs +960 -0
- package/src/runtime/shared/shell-display.mjs +9 -3
- 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-format.mjs +4 -4
- package/src/runtime/shared/task-notification-envelope.mjs +2 -2
- package/src/runtime/shared/tool-primitives.mjs +35 -2
- package/src/runtime/shared/turn-changes.mjs +447 -219
- package/src/runtime/shared/turn-snapshot-store.mjs +182 -1
- package/src/runtime/shared/turn-snapshot.mjs +170 -48
- package/src/runtime/shared/turn-worktree-snapshot.mjs +160 -51
- package/src/runtime/shared/worker-requests.mjs +15 -2
- package/src/runtime/shared/worktrees/contract.mjs +0 -24
- package/src/runtime/shared/worktrees/test-support.mjs +19 -4
- package/src/session-runtime/automation-agents.mjs +8 -2
- package/src/session-runtime/boot/apis.mjs +61 -0
- package/src/session-runtime/boot/config.mjs +5 -2
- package/src/session-runtime/boot/facade.mjs +1 -0
- package/src/session-runtime/boot/infrastructure.mjs +35 -18
- 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 +10 -5
- package/src/session-runtime/cwd-plugins/plugin-status.mjs +47 -42
- package/src/session-runtime/lifecycle/session-catalog.mjs +2 -0
- package/src/session-runtime/lifecycle/teardown.mjs +1 -1
- package/src/session-runtime/mcp-glue/connect.mjs +83 -16
- package/src/session-runtime/mcp-glue/plugin-servers.mjs +499 -0
- package/src/session-runtime/mcp-glue/server-catalog.mjs +135 -33
- package/src/session-runtime/mcp-glue.mjs +25 -2
- package/src/session-runtime/mcp-oauth-login.mjs +174 -0
- package/src/session-runtime/model-route/model-switch-target.mjs +34 -0
- package/src/session-runtime/model-route/set-route.mjs +83 -15
- package/src/session-runtime/model-route/web-search-route.mjs +1 -1
- package/src/session-runtime/native-web-search/routes.mjs +22 -3
- package/src/session-runtime/new-session-config.mjs +3 -2
- package/src/session-runtime/notification-bus.mjs +9 -2
- package/src/session-runtime/provider-auth/accounts.mjs +33 -15
- package/src/session-runtime/provider-models/model-meta.mjs +12 -3
- 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/quick-model-rows/web-search-rows.mjs +13 -8
- package/src/session-runtime/quick-web-search-models.mjs +13 -38
- package/src/session-runtime/resource-extension-sync.mjs +24 -5
- package/src/session-runtime/resource-mcp-api.mjs +198 -78
- 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/runtime-review-api.mjs +13 -0
- package/src/session-runtime/schedule-session-run.mjs +1 -1
- package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +13 -1
- package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +7 -2
- package/src/session-runtime/services/agent-tool/send-flow.mjs +22 -4
- package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +2 -0
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +22 -5
- package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -1
- package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +2 -2
- package/src/session-runtime/services/agent-tool/worker-rows.mjs +17 -0
- package/src/session-runtime/services/channel-session-router.mjs +4 -1
- 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/settings-compaction-api.mjs +1 -0
- package/src/session-runtime/setup-tool/executor.mjs +12 -1
- package/src/session-runtime/setup-tool/extended-actions.mjs +1 -0
- package/src/session-runtime/setup-tool/settings-contract.mjs +1 -0
- package/src/session-runtime/setup-tool/tool-defs.mjs +7 -1
- package/src/session-runtime/skills-api.mjs +7 -3
- package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +1 -0
- package/src/session-runtime/webhook-session-run.mjs +16 -4
- package/src/skill-script-entry.mjs +36 -0
- package/src/standalone/daemon-drain-policy.mjs +8 -0
- package/src/standalone/daemon-stored-session-views.mjs +11 -3
- package/src/standalone/daemon-telemetry.mjs +55 -0
- package/src/standalone/daemon.mjs +44 -11
- package/src/standalone/session-protocol.mjs +12 -0
- package/src/standalone/session-service/agent-tree/agent-cancel.mjs +33 -2
- package/src/standalone/session-service/agent-tree/agent-turns.mjs +26 -10
- package/src/standalone/session-service/agent-tree.mjs +12 -0
- package/src/standalone/session-service/quota-resume.mjs +8 -0
- package/src/standalone/session-service/retention/busy.mjs +15 -1
- package/src/standalone/session-service/session-calls/session-action.mjs +6 -1
- package/src/standalone/session-service-api.mjs +5 -0
- package/src/standalone/session-service.mjs +1 -0
- 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 +7 -0
- package/src/tui/app/create-app-pickers.mjs +4 -0
- package/src/tui/app/extension-pickers/mcp-login.mjs +107 -0
- package/src/tui/app/extension-pickers/mcp-servers-picker.mjs +2 -1
- package/src/tui/app/extension-pickers/plugins-pickers.mjs +35 -16
- package/src/tui/app/extension-pickers.mjs +2 -1
- package/src/tui/app/model-options.mjs +13 -16
- package/src/tui/app/skill-mention-palette.mjs +88 -0
- package/src/tui/app/slash-commands.mjs +7 -2
- package/src/tui/app/slash-dispatch/panel-commands.mjs +16 -1
- 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/StatusLine.jsx +2 -0
- package/src/tui/components/TranscriptItem.jsx +9 -1
- package/src/tui/components/prompt-input/key-handler.mjs +21 -8
- package/src/tui/components/status-line/local-line.mjs +5 -2
- package/src/tui/components/status-line/use-statusline-text.mjs +7 -1
- package/src/tui/components/user-message-skills.mjs +38 -0
- package/src/tui/dist/index.mjs +1323 -214
- package/src/tui/session/queue-helpers.mjs +7 -2
- package/src/tui/session/quota-resume.mjs +46 -14
- package/src/tui/session/session-api/commands.mjs +33 -24
- package/src/tui/session/session-api/extensions.mjs +26 -2
- package/src/tui/session/session-api/integrations.mjs +7 -0
- package/src/tui/session/session-api/lifecycle.mjs +5 -1
- package/src/tui/session/session-api/routes.mjs +71 -7
- package/src/tui/session/session-api/settings.mjs +12 -14
- package/src/tui/session/session-flow/drain.mjs +21 -5
- package/src/tui/session/session-flow/failed-turn-rewind.mjs +10 -4
- package/src/tui/session/tui-steering-persist.mjs +25 -9
- package/src/tui/session/turn.mjs +55 -15
- package/src/tui/statusline-ansi-bridge.mjs +1 -1
- package/src/ui/statusline-agents.mjs +15 -4
- package/src/ui/statusline.mjs +7 -3
- package/src/ui/streaming-markdown-heal.mjs +34 -3
- package/src/vendor/statusline/src/gateway/claude-current.mjs +14 -4
- package/src/runtime/effort-judge/previous-human-request.mjs +0 -162
- package/src/runtime/effort-judge/verified-context-input.mjs +0 -10
- package/src/runtime/memory/lib/memory-session-merge.mjs +0 -32
- package/src/runtime/shared/llm/usage-session-history.mjs +0 -266
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
// Session change baselines: the FIRST observed state of every file a session's
|
|
2
|
+
// tools wrote, per review owner. The session-wide review (getSessionReviewDiff)
|
|
3
|
+
// compares these with the disk for every file a Git baseline tree does not
|
|
4
|
+
// cover — a non-Git root, or a file outside the Git root — so interleaved Lead
|
|
5
|
+
// and worker edits, external edits and record eviction never change what the
|
|
6
|
+
// session started from.
|
|
7
|
+
//
|
|
8
|
+
// Layout under the turn-snapshot store root:
|
|
9
|
+
// baselines/<hash(session)>.json index: one entry per file key
|
|
10
|
+
// baselines/<hash(session)>/<sha256> captured before-bytes (immutable)
|
|
11
|
+
// review-owners/<hash(child)>.json worker → Lead review attribution
|
|
12
|
+
//
|
|
13
|
+
// An index entry is `{ key, path, root, state, status, size, sha256?, blob?, at }`:
|
|
14
|
+
// `state` is `present` or `absent` (the file did not exist when first seen),
|
|
15
|
+
// `status` is `captured` (bytes kept in `blob`), `omitted-binary` /
|
|
16
|
+
// `omitted-oversized` (bytes never kept; `sha256` when they were seen) or
|
|
17
|
+
// `evicted` (bytes dropped for the storage bound; `sha256` kept). An entry is
|
|
18
|
+
// never replaced by a later observation. Which path holds a renamed file (and
|
|
19
|
+
// the path whose baseline it came from) follows from the rename `events` —
|
|
20
|
+
// `[at, from, to]` moves and `[at, key]` in-place creates/deletes — replayed
|
|
21
|
+
// in issue order onto `renames`, the map folded through `foldedAt`.
|
|
22
|
+
// `clearedAt` is the epoch of the last Delete history: a capture issued
|
|
23
|
+
// before it (in any process) never lands after it; one issued in its very
|
|
24
|
+
// millisecond by another process is kept, but marks the index incomplete. `at` values are issue
|
|
25
|
+
// times: wall-clock milliseconds, strictly increasing within a process.
|
|
26
|
+
//
|
|
27
|
+
// Several processes share one index: every write runs under the index's file
|
|
28
|
+
// lock, re-reads the index and replays this process's unwritten captures on
|
|
29
|
+
// top of it, so no process drops another's entries (first seen wins) and a
|
|
30
|
+
// capture published late still lands where it was observed.
|
|
31
|
+
import { createHash } from 'node:crypto';
|
|
32
|
+
import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { withFileLock, writeFileAtomicAsync } from './atomic-file.mjs';
|
|
35
|
+
import { cleanString as clean } from './clean.mjs';
|
|
36
|
+
import { NET_TEXT_MAX_BYTES, changePathKey } from './turn-changes.mjs';
|
|
37
|
+
import { sweepDirectory, turnSnapshotStoreRoot } from './turn-snapshot-store.mjs';
|
|
38
|
+
|
|
39
|
+
const BASELINE_VERSION = 1;
|
|
40
|
+
/** Largest before-state kept as bytes: the review reads current files up to
|
|
41
|
+
* the same bound, so every captured baseline can be compared exactly. */
|
|
42
|
+
export const BASELINE_FILE_MAX_BYTES = NET_TEXT_MAX_BYTES;
|
|
43
|
+
// Captured bytes per session; past it the oldest captures are evicted.
|
|
44
|
+
const MAX_BLOB_BYTES_PER_SESSION = 64 * 1024 * 1024;
|
|
45
|
+
// Files (and renamed paths) per session; past it the index is marked incomplete.
|
|
46
|
+
const MAX_FILES_PER_SESSION = 4096;
|
|
47
|
+
// Rename events kept unfolded; a capture older than the folded ones cannot be
|
|
48
|
+
// placed among them and marks the index incomplete.
|
|
49
|
+
const MAX_RENAME_EVENTS = MAX_FILES_PER_SESSION;
|
|
50
|
+
const MAX_BASELINE_SESSIONS = 512;
|
|
51
|
+
const MAX_OWNER_RECORDS = 4096;
|
|
52
|
+
const BASELINE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
53
|
+
// Age and count sweeps of the whole store run at most this often per process.
|
|
54
|
+
const SWEEP_INTERVAL_MS = 10 * 60 * 1000;
|
|
55
|
+
// A blob directory without an index is only pruned once this old: its first
|
|
56
|
+
// index write may still be on the way.
|
|
57
|
+
const ORPHAN_BLOB_GRACE_MS = 60 * 60 * 1000;
|
|
58
|
+
const STATE_CACHE_SESSIONS = 16;
|
|
59
|
+
const LOCK_TIMEOUT_MS = 10_000;
|
|
60
|
+
// Measured review rows kept between polls, across every session.
|
|
61
|
+
const ROW_CACHE_MAX_BYTES = 16 * 1024 * 1024;
|
|
62
|
+
const STATUSES = new Set(['captured', 'omitted-binary', 'omitted-oversized', 'evicted']);
|
|
63
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
64
|
+
|
|
65
|
+
const hashName = (value) => createHash('sha256').update(value).digest('hex').slice(0, 24);
|
|
66
|
+
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
67
|
+
|
|
68
|
+
const baselinesRoot = () => join(turnSnapshotStoreRoot(), 'baselines');
|
|
69
|
+
const ownersRoot = () => join(turnSnapshotStoreRoot(), 'review-owners');
|
|
70
|
+
const indexPath = (sessionId) => join(baselinesRoot(), `${hashName(sessionId)}.json`);
|
|
71
|
+
const blobDir = (sessionId) => join(baselinesRoot(), hashName(sessionId));
|
|
72
|
+
const ownerPath = (sessionId) => join(ownersRoot(), `${hashName(sessionId)}.json`);
|
|
73
|
+
// Memory is keyed by store root too: a data-dir retarget never reads stale state.
|
|
74
|
+
const memoryKey = (sessionId) => `${turnSnapshotStoreRoot()}\0${sessionId}`;
|
|
75
|
+
|
|
76
|
+
// Issue times: a capture issued after a clear in this process is always
|
|
77
|
+
// later than it, even within one millisecond.
|
|
78
|
+
let lastIssue = 0;
|
|
79
|
+
function issueTime() {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
lastIssue = now > lastIssue ? now : lastIssue + 1 / 64;
|
|
82
|
+
return lastIssue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Call order across sessions: captures, loads and clears pass through one
|
|
86
|
+
// queue that only resolves review owners and hands each task to its session's
|
|
87
|
+
// chain, so a worker's capture reaches its Lead's chain before a later Lead
|
|
88
|
+
// capture, a load follows every capture issued before it and a clear follows
|
|
89
|
+
// (and wins over) them. Tasks run on the session chains, not on this queue.
|
|
90
|
+
let ordering = Promise.resolve();
|
|
91
|
+
function inOrder(task) {
|
|
92
|
+
const run = ordering.then(task);
|
|
93
|
+
ordering = run.then(
|
|
94
|
+
() => undefined,
|
|
95
|
+
() => undefined
|
|
96
|
+
);
|
|
97
|
+
return run;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Per-session serial chain: captures apply in the order `inOrder` hands them
|
|
101
|
+
// over, a load reads after every capture queued before it, and a clear lands
|
|
102
|
+
// between them.
|
|
103
|
+
const chains = new Map();
|
|
104
|
+
function chain(key, task) {
|
|
105
|
+
const previous = chains.get(key) || Promise.resolve();
|
|
106
|
+
const next = previous.then(task, task);
|
|
107
|
+
const settled = next.then(
|
|
108
|
+
() => undefined,
|
|
109
|
+
() => undefined
|
|
110
|
+
);
|
|
111
|
+
chains.set(key, settled);
|
|
112
|
+
settled.then(() => {
|
|
113
|
+
if (chains.get(key) === settled) chains.delete(key);
|
|
114
|
+
});
|
|
115
|
+
return next;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Index state ─────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
const states = new Map();
|
|
121
|
+
|
|
122
|
+
function emptyState(sessionId) {
|
|
123
|
+
return {
|
|
124
|
+
sessionId,
|
|
125
|
+
incomplete: false,
|
|
126
|
+
// Issue time of the latest capture that made it incomplete (0 = unknown or
|
|
127
|
+
// not a capture): a clear compares it with its own issue time, since only
|
|
128
|
+
// a capture issued after the clear can owe it incompleteness.
|
|
129
|
+
incompleteAt: 0,
|
|
130
|
+
createdAt: 0,
|
|
131
|
+
clearedAt: 0,
|
|
132
|
+
files: new Map(),
|
|
133
|
+
// Paths edited after the clear whose first state is lost: key → { key,
|
|
134
|
+
// path, root, at } (their review lists them as missing; `at` is their
|
|
135
|
+
// latest edit, which tells a later clear whether it predates the entry).
|
|
136
|
+
missing: new Map(),
|
|
137
|
+
// Renames folded through `foldedAt`, the events after it (issue order)
|
|
138
|
+
// and the map both give: renamed path → the path it came from.
|
|
139
|
+
baseRenames: new Map(),
|
|
140
|
+
foldedAt: 0,
|
|
141
|
+
events: [],
|
|
142
|
+
renames: new Map(),
|
|
143
|
+
// `mtime:size` of the index this state was read from ('' = none).
|
|
144
|
+
signature: '',
|
|
145
|
+
// Captures not yet written ({ at, changes }) and the blobs they need.
|
|
146
|
+
pending: [],
|
|
147
|
+
writes: new Map(),
|
|
148
|
+
// Path → the `pending` capture holding its latest edit that recorded
|
|
149
|
+
// nothing (see applyCapture).
|
|
150
|
+
heldRedundant: new Map(),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const hasBaselines = (state) => state.files.size > 0 || state.incomplete;
|
|
155
|
+
|
|
156
|
+
/** The index can no longer vouch for everything, because of the capture issued
|
|
157
|
+
* at `at` (0 when the cause is not a capture). */
|
|
158
|
+
function markIncomplete(state, at = 0) {
|
|
159
|
+
state.incomplete = true;
|
|
160
|
+
if (at > state.incompleteAt) state.incompleteAt = at;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parsedEntry(value) {
|
|
164
|
+
const entry = value && typeof value === 'object' ? value : {};
|
|
165
|
+
const key = clean(entry.key);
|
|
166
|
+
const path = clean(entry.path);
|
|
167
|
+
if (!key || !path || !STATUSES.has(entry.status) || !['present', 'absent'].includes(entry.state)) return null;
|
|
168
|
+
return {
|
|
169
|
+
key,
|
|
170
|
+
path,
|
|
171
|
+
root: clean(entry.root),
|
|
172
|
+
state: entry.state,
|
|
173
|
+
status: entry.status,
|
|
174
|
+
size: Number(entry.size) || 0,
|
|
175
|
+
...(SHA256.test(entry.sha256 || '') ? { sha256: entry.sha256 } : {}),
|
|
176
|
+
...(entry.status === 'captured' && SHA256.test(entry.blob || '') ? { blob: entry.blob } : {}),
|
|
177
|
+
at: Number(entry.at) || 0,
|
|
178
|
+
...(Number(entry.touched) > 0 ? { touched: Number(entry.touched) } : {}),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function indexSignature(sessionId) {
|
|
183
|
+
try {
|
|
184
|
+
const info = await stat(indexPath(sessionId));
|
|
185
|
+
return `${info.mtimeMs}:${info.size}`;
|
|
186
|
+
} catch {
|
|
187
|
+
return '';
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The index on disk now, as a state carrying its signature. */
|
|
192
|
+
async function readState(sessionId) {
|
|
193
|
+
const state = emptyState(sessionId);
|
|
194
|
+
// Stat first: a write landing between the two only costs a later re-read.
|
|
195
|
+
state.signature = await indexSignature(sessionId);
|
|
196
|
+
if (!state.signature) return state;
|
|
197
|
+
let text;
|
|
198
|
+
try {
|
|
199
|
+
text = await readFile(indexPath(sessionId), 'utf8');
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error?.code === 'ENOENT') return { ...state, signature: '' };
|
|
202
|
+
// Unreadable: what it held is unknown, so nothing may be vouched for.
|
|
203
|
+
return { ...state, incomplete: true };
|
|
204
|
+
}
|
|
205
|
+
let parsed = null;
|
|
206
|
+
try {
|
|
207
|
+
parsed = JSON.parse(text);
|
|
208
|
+
} catch {
|
|
209
|
+
parsed = null;
|
|
210
|
+
}
|
|
211
|
+
if (parsed?.version !== BASELINE_VERSION || parsed.sessionId !== sessionId) return { ...state, incomplete: true };
|
|
212
|
+
state.incomplete = parsed.incomplete === true;
|
|
213
|
+
state.createdAt = Number(parsed.createdAt) || 0;
|
|
214
|
+
state.incompleteAt = Number(parsed.incompleteAt) || 0;
|
|
215
|
+
state.clearedAt = Number(parsed.clearedAt) || 0;
|
|
216
|
+
for (const value of Array.isArray(parsed.files) ? parsed.files : []) {
|
|
217
|
+
const entry = parsedEntry(value);
|
|
218
|
+
if (entry) state.files.set(entry.key, entry);
|
|
219
|
+
else state.incomplete = true;
|
|
220
|
+
}
|
|
221
|
+
for (const value of Array.isArray(parsed.missing) ? parsed.missing : []) {
|
|
222
|
+
const key = clean(value?.key);
|
|
223
|
+
const path = clean(value?.path);
|
|
224
|
+
// `at`: when the path was last edited (absent in older indexes).
|
|
225
|
+
const at = Number(value?.at);
|
|
226
|
+
if (key && path) state.missing.set(key, { key, path, root: clean(value.root), ...(at > 0 ? { at } : {}) });
|
|
227
|
+
else state.incomplete = true;
|
|
228
|
+
}
|
|
229
|
+
for (const pair of Array.isArray(parsed.renames) ? parsed.renames : []) {
|
|
230
|
+
if (Array.isArray(pair) && typeof pair[0] === 'string' && typeof pair[1] === 'string') {
|
|
231
|
+
state.baseRenames.set(pair[0], pair[1]);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
state.foldedAt = Number(parsed.foldedAt) || 0;
|
|
235
|
+
for (const event of Array.isArray(parsed.events) ? parsed.events : []) {
|
|
236
|
+
if (isRenameEvent(event)) state.events.push(event);
|
|
237
|
+
else state.incomplete = true;
|
|
238
|
+
}
|
|
239
|
+
state.events.sort((left, right) => left[0] - right[0]);
|
|
240
|
+
state.renames = refold(state);
|
|
241
|
+
compactEvents(state);
|
|
242
|
+
return state;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** `fresh` (the index on disk) with this process's unwritten captures of
|
|
246
|
+
* `prior` replayed on top (a capture issued before a clear is dropped). */
|
|
247
|
+
function rebased(fresh, prior) {
|
|
248
|
+
for (const op of prior?.pending || []) holdCapture(fresh, applyCapture(fresh, op, prior.writes));
|
|
249
|
+
return fresh;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function remember(sessionId, state) {
|
|
253
|
+
const key = memoryKey(sessionId);
|
|
254
|
+
states.delete(key);
|
|
255
|
+
states.set(key, state);
|
|
256
|
+
// Never forget unwritten captures: only settled states leave the cache.
|
|
257
|
+
for (const [cachedKey, cached] of states) {
|
|
258
|
+
if (states.size <= STATE_CACHE_SESSIONS) break;
|
|
259
|
+
if (cachedKey !== key && cached.pending.length === 0) states.delete(cachedKey);
|
|
260
|
+
}
|
|
261
|
+
return state;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The session's index state: memory while the index on disk is the one it
|
|
265
|
+
* was read from, else re-read (another process wrote it). Runs inside its
|
|
266
|
+
* chain. */
|
|
267
|
+
async function stateFor(sessionId) {
|
|
268
|
+
const cached = states.get(memoryKey(sessionId));
|
|
269
|
+
if (cached && cached.signature === (await indexSignature(sessionId))) return remember(sessionId, cached);
|
|
270
|
+
return remember(sessionId, rebased(await readState(sessionId), cached));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Capture ─────────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
const isOmitted = (value) =>
|
|
276
|
+
Boolean(value) && typeof value === 'object' && !(value instanceof Uint8Array) && value.omitted === true;
|
|
277
|
+
|
|
278
|
+
function contentBytes(value) {
|
|
279
|
+
if (Buffer.isBuffer(value)) return value;
|
|
280
|
+
if (value instanceof Uint8Array) return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
281
|
+
return Buffer.from(String(value), 'utf8');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The first-seen entry of one path, plus the bytes to store for it. */
|
|
285
|
+
function baselineEntry({ key, path, root }, before, at) {
|
|
286
|
+
const base = { key, path, root, at };
|
|
287
|
+
if (before == null) return { entry: { ...base, state: 'absent', status: 'captured', size: 0 } };
|
|
288
|
+
if (isOmitted(before)) {
|
|
289
|
+
const status = before.reason === 'binary' ? 'omitted-binary' : 'omitted-oversized';
|
|
290
|
+
return { entry: { ...base, state: 'present', status, size: Number(before.size) || 0 } };
|
|
291
|
+
}
|
|
292
|
+
const bytes = contentBytes(before);
|
|
293
|
+
const sha256 = digest(bytes);
|
|
294
|
+
if (bytes.length > BASELINE_FILE_MAX_BYTES) {
|
|
295
|
+
return { entry: { ...base, state: 'present', status: 'omitted-oversized', size: bytes.length, sha256 } };
|
|
296
|
+
}
|
|
297
|
+
return { entry: { ...base, state: 'present', status: 'captured', size: bytes.length, sha256, blob: sha256 }, bytes };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Evict the oldest captured bytes until the session fits its budget. The
|
|
301
|
+
* entry keeps its hash, so an unchanged file still nets away. */
|
|
302
|
+
function boundCapturedBytes(state) {
|
|
303
|
+
const captured = [...state.files.values()].filter((entry) => entry.blob).sort((left, right) => left.at - right.at);
|
|
304
|
+
let total = captured.reduce((sum, entry) => sum + entry.size, 0);
|
|
305
|
+
for (const entry of captured) {
|
|
306
|
+
if (total <= MAX_BLOB_BYTES_PER_SESSION) break;
|
|
307
|
+
total -= entry.size;
|
|
308
|
+
const { blob: _blob, ...rest } = entry;
|
|
309
|
+
state.files.set(entry.key, { ...rest, status: 'evicted' });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Keep the session within its file and byte bounds (renames are bounded as
|
|
314
|
+
* they fold); whatever is dropped marks the index incomplete. Runs on every
|
|
315
|
+
* write, so entries merged in from another process are bounded too. */
|
|
316
|
+
function enforceBounds(state) {
|
|
317
|
+
if (state.files.size > MAX_FILES_PER_SESSION) {
|
|
318
|
+
const newest = [...state.files.values()].sort((left, right) => left.at - right.at).slice(MAX_FILES_PER_SESSION);
|
|
319
|
+
for (const entry of newest) state.files.delete(entry.key);
|
|
320
|
+
markIncomplete(state, Math.max(...newest.map((entry) => entry.touched ?? entry.at)));
|
|
321
|
+
}
|
|
322
|
+
boundCapturedBytes(state);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const isRenameEvent = (event) =>
|
|
326
|
+
Array.isArray(event) &&
|
|
327
|
+
(event.length === 2 || event.length === 3) &&
|
|
328
|
+
Number.isFinite(event[0]) &&
|
|
329
|
+
event.slice(1).every((key) => typeof key === 'string' && key.length > 0);
|
|
330
|
+
|
|
331
|
+
/** Apply one rename event to `map` (renamed path → the path it came from):
|
|
332
|
+
* a move carries the source's origin to its destination, an in-place
|
|
333
|
+
* create/delete ends the path's rename. Past the bound, incomplete. */
|
|
334
|
+
function foldEvent(map, [at, key, to], state) {
|
|
335
|
+
if (to === undefined) {
|
|
336
|
+
map.delete(key);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const origin = map.get(key) ?? key;
|
|
340
|
+
map.delete(key);
|
|
341
|
+
if (origin === to) map.delete(to);
|
|
342
|
+
else if (map.has(to) || map.size < MAX_FILES_PER_SESSION) map.set(to, origin);
|
|
343
|
+
else markIncomplete(state, at);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function refold(state) {
|
|
347
|
+
const map = new Map(state.baseRenames);
|
|
348
|
+
for (const event of state.events) foldEvent(map, event, state);
|
|
349
|
+
return map;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Fold the oldest events into the base map past the event bound. */
|
|
353
|
+
function compactEvents(state) {
|
|
354
|
+
const excess = state.events.length - MAX_RENAME_EVENTS;
|
|
355
|
+
if (excess <= 0) return;
|
|
356
|
+
for (const event of state.events.slice(0, excess)) {
|
|
357
|
+
foldEvent(state.baseRenames, event, state);
|
|
358
|
+
state.foldedAt = event[0];
|
|
359
|
+
}
|
|
360
|
+
state.events = state.events.slice(excess);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const eventId = (event) => event.join('\0');
|
|
364
|
+
|
|
365
|
+
/** Place one capture's rename events where it was observed (its issue time),
|
|
366
|
+
* so a capture another process published late still chains in order, and
|
|
367
|
+
* update the rename map. Returns whether anything was placed. */
|
|
368
|
+
function placeRenameEvents(state, at, events) {
|
|
369
|
+
if (events.length === 0) return false;
|
|
370
|
+
if (at <= state.foldedAt) {
|
|
371
|
+
// Older than history already folded away: its place in it is unknown.
|
|
372
|
+
markIncomplete(state, at);
|
|
373
|
+
for (const event of events) foldEvent(state.baseRenames, event, state);
|
|
374
|
+
state.renames = refold(state);
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
let index = state.events.length;
|
|
378
|
+
while (index > 0 && state.events[index - 1][0] > at) index -= 1;
|
|
379
|
+
// A capture replayed onto an index that already holds it is not placed twice.
|
|
380
|
+
const placed = new Set();
|
|
381
|
+
for (let other = index - 1; other >= 0 && state.events[other][0] === at; other -= 1) {
|
|
382
|
+
placed.add(eventId(state.events[other]));
|
|
383
|
+
}
|
|
384
|
+
const fresh = placed.size > 0 ? events.filter((event) => !placed.has(eventId(event))) : events;
|
|
385
|
+
if (fresh.length === 0) return false;
|
|
386
|
+
const appended = index === state.events.length;
|
|
387
|
+
state.events = [...state.events.slice(0, index), ...fresh, ...state.events.slice(index)];
|
|
388
|
+
if (appended) for (const event of fresh) foldEvent(state.renames, event, state);
|
|
389
|
+
else state.renames = refold(state);
|
|
390
|
+
compactEvents(state);
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Fold one capture batch (`{ at, changes }`) into the state. A change carries
|
|
396
|
+
* its `before` (a new capture) or, replayed, the `entry` it recorded (bytes
|
|
397
|
+
* from `blobs`) or `redundant` (it recorded none). Returns null when nothing
|
|
398
|
+
* changed, else `{ writes, op }`: the blobs to write and the capture reduced
|
|
399
|
+
* to what can still become a baseline — first-seen entries and the rename /
|
|
400
|
+
* existence events, never a before-buffer a baseline already supersedes.
|
|
401
|
+
*/
|
|
402
|
+
function applyCapture(state, { at, changes }, blobs = null) {
|
|
403
|
+
// Issued before the session's history was last deleted (in any process):
|
|
404
|
+
// it never lands after the clear.
|
|
405
|
+
if (at < state.clearedAt) return null;
|
|
406
|
+
const writes = new Map();
|
|
407
|
+
let dirty = false;
|
|
408
|
+
if (at === state.clearedAt) {
|
|
409
|
+
// A tie with a clear can only come from another process (issue times are
|
|
410
|
+
// strict within one): its order is unknown, so it is kept, unvouched.
|
|
411
|
+
dirty = true;
|
|
412
|
+
markIncomplete(state, at);
|
|
413
|
+
}
|
|
414
|
+
// A later observation of a known path keeps its first state but is noted on
|
|
415
|
+
// the entry (`touched`), so a clear that lands after this capture was issued
|
|
416
|
+
// knows the path was edited after it and its first state is lost.
|
|
417
|
+
// A touch alone is not `dirty`: it is held like a redundant capture, so
|
|
418
|
+
// repeated edits of one path keep only the latest (see below).
|
|
419
|
+
let touched = false;
|
|
420
|
+
const touch = (side) => {
|
|
421
|
+
const existing = state.files.get(side.key);
|
|
422
|
+
if (existing && at > (existing.touched ?? existing.at)) {
|
|
423
|
+
state.files.set(side.key, { ...existing, touched: at });
|
|
424
|
+
touched = true;
|
|
425
|
+
}
|
|
426
|
+
const lost = state.missing.get(side.key);
|
|
427
|
+
if (lost && at > (lost.at ?? 0)) {
|
|
428
|
+
state.missing.set(side.key, { ...lost, at });
|
|
429
|
+
touched = true;
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
const add = (side, make) => {
|
|
433
|
+
const existing = state.files.get(side.key);
|
|
434
|
+
// First seen wins: only an earlier observation (another process's,
|
|
435
|
+
// replayed onto this index) replaces an entry.
|
|
436
|
+
if (existing && !(at < existing.at)) {
|
|
437
|
+
touch(side);
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
if (!existing && state.files.size >= MAX_FILES_PER_SESSION) {
|
|
441
|
+
// A new cause (a later capture than any recorded) is worth a write.
|
|
442
|
+
dirty ||= !state.incomplete || at > state.incompleteAt;
|
|
443
|
+
markIncomplete(state, at);
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
const made = make();
|
|
447
|
+
const { bytes } = made;
|
|
448
|
+
// The entry it replaces was an observation too.
|
|
449
|
+
const latest = Math.max(existing?.touched ?? 0, existing?.at ?? 0);
|
|
450
|
+
const entry = latest > made.entry.at ? { ...made.entry, touched: latest } : made.entry;
|
|
451
|
+
state.files.set(side.key, entry);
|
|
452
|
+
if (bytes) writes.set(entry.blob, bytes);
|
|
453
|
+
dirty = true;
|
|
454
|
+
return entry;
|
|
455
|
+
};
|
|
456
|
+
const recorded = (change) => {
|
|
457
|
+
if (!change.entry) return baselineEntry(change.source, change.before, at);
|
|
458
|
+
const bytes = change.entry.blob ? blobs?.get(change.entry.blob) : undefined;
|
|
459
|
+
if (!change.entry.blob || bytes) return { entry: change.entry, bytes };
|
|
460
|
+
// Its unwritten bytes went to the byte budget.
|
|
461
|
+
markIncomplete(state, at);
|
|
462
|
+
const { blob: _blob, ...rest } = change.entry;
|
|
463
|
+
return { entry: { ...rest, status: 'evicted' } };
|
|
464
|
+
};
|
|
465
|
+
const events = [];
|
|
466
|
+
const kept = [];
|
|
467
|
+
for (const change of changes) {
|
|
468
|
+
if (change.redundant) touch(change.source);
|
|
469
|
+
const entry = change.redundant ? null : add(change.source, () => recorded(change));
|
|
470
|
+
kept.push({
|
|
471
|
+
source: change.source,
|
|
472
|
+
...(change.destination ? { destination: change.destination } : {}),
|
|
473
|
+
replaced: change.replaced,
|
|
474
|
+
...(entry ? { entry } : { redundant: true }),
|
|
475
|
+
});
|
|
476
|
+
if (change.destination) {
|
|
477
|
+
// The destination did not exist before the move; its file descends from
|
|
478
|
+
// whatever the source descended from.
|
|
479
|
+
add(change.destination, () => baselineEntry(change.destination, null, at));
|
|
480
|
+
events.push([at, change.source.key, change.destination.key]);
|
|
481
|
+
} else if (change.replaced) {
|
|
482
|
+
// Created or deleted in place: no longer a renamed file.
|
|
483
|
+
events.push([at, change.source.key]);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
for (const change of changes) {
|
|
487
|
+
// Replayed, it recorded nothing because an earlier capture had — one a
|
|
488
|
+
// clear has since removed: the path's first state is lost, never dropped.
|
|
489
|
+
if (!change.redundant || state.files.has(change.source.key) || state.missing.has(change.source.key)) continue;
|
|
490
|
+
const { key, path, root } = change.source;
|
|
491
|
+
if (state.missing.size < MAX_FILES_PER_SESSION) state.missing.set(key, { key, path, root, at });
|
|
492
|
+
markIncomplete(state, at);
|
|
493
|
+
dirty = true;
|
|
494
|
+
}
|
|
495
|
+
if (placeRenameEvents(state, at, events)) dirty = true;
|
|
496
|
+
if (!dirty) {
|
|
497
|
+
// Recorded nothing new, yet the capture it relies on may still be cleared
|
|
498
|
+
// away before it is written: held (without bytes) until the unwritten
|
|
499
|
+
// captures are. Only each path's latest such edit is kept, since a clear
|
|
500
|
+
// landing between edits must still see the one after it.
|
|
501
|
+
if ((state.pending.length === 0 && !touched) || kept.length === 0) return null;
|
|
502
|
+
const op = { at, changes: kept };
|
|
503
|
+
for (const change of kept) {
|
|
504
|
+
const prior = state.heldRedundant.get(change.source.key);
|
|
505
|
+
if (prior) {
|
|
506
|
+
prior.changes = prior.changes.filter((held) => held.source.key !== change.source.key);
|
|
507
|
+
const index = prior.changes.length === 0 ? state.pending.indexOf(prior) : -1;
|
|
508
|
+
if (index >= 0) state.pending.splice(index, 1);
|
|
509
|
+
}
|
|
510
|
+
state.heldRedundant.set(change.source.key, op);
|
|
511
|
+
}
|
|
512
|
+
return { writes, op };
|
|
513
|
+
}
|
|
514
|
+
if (!state.createdAt) state.createdAt = at;
|
|
515
|
+
return { writes, op: { at, changes: kept } };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Hold an applied capture until it is written: its reduced form and its
|
|
519
|
+
* bytes, within the session byte budget (bytes past it are dropped and the
|
|
520
|
+
* baselines marked incomplete). */
|
|
521
|
+
function holdCapture(state, applied) {
|
|
522
|
+
if (!applied) return;
|
|
523
|
+
state.pending.push(applied.op);
|
|
524
|
+
for (const [blob, bytes] of applied.writes) state.writes.set(blob, bytes);
|
|
525
|
+
enforceBounds(state);
|
|
526
|
+
const referenced = new Set([...state.files.values()].map((entry) => entry.blob).filter(Boolean));
|
|
527
|
+
for (const blob of state.writes.keys()) {
|
|
528
|
+
if (referenced.has(blob)) continue;
|
|
529
|
+
state.writes.delete(blob);
|
|
530
|
+
markIncomplete(state, applied.op.at);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
let lastSweep = { root: '', at: 0 };
|
|
535
|
+
|
|
536
|
+
async function pruneOrphanBlobDirs() {
|
|
537
|
+
const root = baselinesRoot();
|
|
538
|
+
const names = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
539
|
+
const indexes = new Set(names.filter((entry) => entry.isFile()).map((entry) => entry.name));
|
|
540
|
+
for (const entry of names) {
|
|
541
|
+
if (!entry.isDirectory() || indexes.has(`${entry.name}.json`)) continue;
|
|
542
|
+
const info = await stat(join(root, entry.name)).catch(() => null);
|
|
543
|
+
if (info && Date.now() - info.mtimeMs > ORPHAN_BLOB_GRACE_MS) {
|
|
544
|
+
await rm(join(root, entry.name), { recursive: true, force: true }).catch(() => {});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Age and count bounds of the whole store, at most once per interval. */
|
|
550
|
+
async function sweepStore() {
|
|
551
|
+
const root = turnSnapshotStoreRoot();
|
|
552
|
+
if (lastSweep.root === root && Date.now() - lastSweep.at < SWEEP_INTERVAL_MS) return;
|
|
553
|
+
lastSweep = { root, at: Date.now() };
|
|
554
|
+
await sweepDirectory(baselinesRoot(), BASELINE_TTL_MS, MAX_BASELINE_SESSIONS);
|
|
555
|
+
await sweepDirectory(ownersRoot(), BASELINE_TTL_MS, MAX_OWNER_RECORDS);
|
|
556
|
+
await pruneOrphanBlobDirs();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const withIndexLock = (sessionId, task) =>
|
|
560
|
+
withFileLock(`${indexPath(sessionId)}.lock`, task, { timeoutMs: LOCK_TIMEOUT_MS });
|
|
561
|
+
|
|
562
|
+
async function writeIndex(state) {
|
|
563
|
+
await writeFileAtomicAsync(
|
|
564
|
+
indexPath(state.sessionId),
|
|
565
|
+
JSON.stringify({
|
|
566
|
+
version: BASELINE_VERSION,
|
|
567
|
+
sessionId: state.sessionId,
|
|
568
|
+
createdAt: state.createdAt,
|
|
569
|
+
updatedAt: Date.now(),
|
|
570
|
+
...(state.clearedAt ? { clearedAt: state.clearedAt } : {}),
|
|
571
|
+
...(state.incomplete ? { incomplete: true } : {}),
|
|
572
|
+
...(state.incompleteAt ? { incompleteAt: state.incompleteAt } : {}),
|
|
573
|
+
files: [...state.files.values()],
|
|
574
|
+
...(state.missing.size > 0 ? { missing: [...state.missing.values()] } : {}),
|
|
575
|
+
renames: [...state.baseRenames],
|
|
576
|
+
...(state.foldedAt ? { foldedAt: state.foldedAt } : {}),
|
|
577
|
+
events: state.events,
|
|
578
|
+
})
|
|
579
|
+
);
|
|
580
|
+
state.signature = await indexSignature(state.sessionId);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Write the state's unwritten captures: under the index lock, onto the index
|
|
584
|
+
* as it is on disk now (another process may have written it). Returns the
|
|
585
|
+
* state now cached. */
|
|
586
|
+
async function persistState(sessionId, state) {
|
|
587
|
+
return await withIndexLock(sessionId, async () => {
|
|
588
|
+
let current = state;
|
|
589
|
+
if (current.signature !== (await indexSignature(sessionId))) {
|
|
590
|
+
current = remember(sessionId, rebased(await readState(sessionId), state));
|
|
591
|
+
}
|
|
592
|
+
if (current.pending.length === 0) return current;
|
|
593
|
+
enforceBounds(current);
|
|
594
|
+
const directory = blobDir(sessionId);
|
|
595
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
596
|
+
const kept = new Set([...current.files.values()].map((entry) => entry.blob).filter(Boolean));
|
|
597
|
+
// Content-addressed and immutable: an existing blob already holds these
|
|
598
|
+
// bytes. A torn blob fails its hash check on read, so it needs no fsync.
|
|
599
|
+
for (const [blob, bytes] of current.writes) {
|
|
600
|
+
if (kept.has(blob)) await writeFileAtomicAsync(join(directory, blob), bytes, { createOnly: true, fsync: false });
|
|
601
|
+
}
|
|
602
|
+
await writeIndex(current);
|
|
603
|
+
current.pending = [];
|
|
604
|
+
current.writes = new Map();
|
|
605
|
+
current.heldRedundant = new Map();
|
|
606
|
+
// Under the lock no other writer has a blob on the way.
|
|
607
|
+
for (const name of await readdir(directory).catch(() => [])) {
|
|
608
|
+
if (SHA256.test(name) && !kept.has(name)) await rm(join(directory, name), { force: true }).catch(() => {});
|
|
609
|
+
}
|
|
610
|
+
return current;
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Write the state's unwritten captures. A refusal keeps them in memory for
|
|
615
|
+
* the next capture or load to retry; returns the state now cached. */
|
|
616
|
+
async function flush(sessionId, state) {
|
|
617
|
+
if (state.pending.length === 0) return state;
|
|
618
|
+
let current = state;
|
|
619
|
+
try {
|
|
620
|
+
current = await persistState(sessionId, state);
|
|
621
|
+
} catch {
|
|
622
|
+
current = states.get(memoryKey(sessionId)) || state;
|
|
623
|
+
}
|
|
624
|
+
void sweepStore().catch(() => {});
|
|
625
|
+
return current;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function captureInto(sessionId, op) {
|
|
629
|
+
const state = await stateFor(sessionId);
|
|
630
|
+
holdCapture(state, applyCapture(state, op));
|
|
631
|
+
// Captures the disk refused before are retried with this one, even when it
|
|
632
|
+
// records nothing new.
|
|
633
|
+
await flush(sessionId, state);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ── Review owners ───────────────────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
// child session → its Lead ('' = none), durable in review-owners/.
|
|
639
|
+
const owners = new Map();
|
|
640
|
+
const ownerLookups = new Map();
|
|
641
|
+
|
|
642
|
+
/** Attribute a worker session's edits to the Lead that launched it. Durable,
|
|
643
|
+
* and independent of the turn's (trimmed) agent list. */
|
|
644
|
+
export function recordReviewOwner(childSessionId, ownerSessionId) {
|
|
645
|
+
const child = clean(childSessionId);
|
|
646
|
+
const owner = clean(ownerSessionId);
|
|
647
|
+
if (!child || !owner || child === owner) return;
|
|
648
|
+
const key = memoryKey(child);
|
|
649
|
+
if (owners.get(key) === owner) return;
|
|
650
|
+
owners.set(key, owner);
|
|
651
|
+
const path = ownerPath(child);
|
|
652
|
+
void chain(`owner:${key}`, async () => {
|
|
653
|
+
await mkdir(ownersRoot(), { recursive: true, mode: 0o700 });
|
|
654
|
+
await writeFileAtomicAsync(
|
|
655
|
+
path,
|
|
656
|
+
JSON.stringify({ version: BASELINE_VERSION, sessionId: child, ownerSessionId: owner, at: Date.now() })
|
|
657
|
+
);
|
|
658
|
+
}).catch(() => {});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function readOwner(sessionId) {
|
|
662
|
+
try {
|
|
663
|
+
const parsed = JSON.parse(await readFile(ownerPath(sessionId), 'utf8'));
|
|
664
|
+
return parsed?.version === BASELINE_VERSION && parsed.sessionId === sessionId ? clean(parsed.ownerSessionId) : '';
|
|
665
|
+
} catch {
|
|
666
|
+
return '';
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Load a session's durable attribution into memory (once per process). */
|
|
671
|
+
export function primeReviewOwner(sessionId) {
|
|
672
|
+
const id = clean(sessionId);
|
|
673
|
+
if (!id) return Promise.resolve();
|
|
674
|
+
const key = memoryKey(id);
|
|
675
|
+
if (owners.has(key)) return Promise.resolve();
|
|
676
|
+
let pending = ownerLookups.get(key);
|
|
677
|
+
if (!pending) {
|
|
678
|
+
pending = readOwner(id)
|
|
679
|
+
.then((owner) => {
|
|
680
|
+
if (!owners.has(key)) owners.set(key, owner);
|
|
681
|
+
})
|
|
682
|
+
.finally(() => ownerLookups.delete(key));
|
|
683
|
+
ownerLookups.set(key, pending);
|
|
684
|
+
}
|
|
685
|
+
return pending;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** The session itself, then each Lead it is attributed to (durable
|
|
689
|
+
* attributions are loaded on the way). */
|
|
690
|
+
async function reviewOwnerChain(sessionId, hint) {
|
|
691
|
+
const ids = [sessionId];
|
|
692
|
+
let next = clean(hint);
|
|
693
|
+
if (!next) {
|
|
694
|
+
await primeReviewOwner(sessionId);
|
|
695
|
+
next = owners.get(memoryKey(sessionId)) || '';
|
|
696
|
+
}
|
|
697
|
+
while (next && !ids.includes(next)) {
|
|
698
|
+
ids.push(next);
|
|
699
|
+
await primeReviewOwner(next);
|
|
700
|
+
next = owners.get(memoryKey(next)) || '';
|
|
701
|
+
}
|
|
702
|
+
return ids;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Record the first-seen state of each changed path in the baselines of the
|
|
707
|
+
* session and of every Lead it is attributed to (`owner`: the Lead a bound
|
|
708
|
+
* worker tracker names). A change is `{ path, root, before, newPath?,
|
|
709
|
+
* replaced? }`: absolute paths, the directory the tool ran in, the bytes
|
|
710
|
+
* before the change (null = absent, or an omitted marker), the rename
|
|
711
|
+
* destination, and whether the file was created or deleted in place.
|
|
712
|
+
*/
|
|
713
|
+
export function captureSessionBaselines(sessionId, changes, { owner = '' } = {}) {
|
|
714
|
+
const id = clean(sessionId);
|
|
715
|
+
const prepared = [];
|
|
716
|
+
for (const change of Array.isArray(changes) ? changes : []) {
|
|
717
|
+
const path = clean(change?.path);
|
|
718
|
+
if (!path) continue;
|
|
719
|
+
const root = clean(change.root);
|
|
720
|
+
const newPath = clean(change.newPath);
|
|
721
|
+
prepared.push({
|
|
722
|
+
source: { key: changePathKey(path), path, root },
|
|
723
|
+
...(newPath ? { destination: { key: changePathKey(newPath), path: newPath, root } } : {}),
|
|
724
|
+
before: change.before,
|
|
725
|
+
replaced: change.replaced === true,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
if (!id || prepared.length === 0) return;
|
|
729
|
+
// Queued now, in call order: an owner lookup never lets a later capture
|
|
730
|
+
// (or a clear) overtake this one.
|
|
731
|
+
const op = { at: issueTime(), changes: prepared };
|
|
732
|
+
void inOrder(async () => {
|
|
733
|
+
const targets = await reviewOwnerChain(id, owner).catch(() => [id]);
|
|
734
|
+
for (const target of targets) void chain(memoryKey(target), () => captureInto(target, op)).catch(() => {});
|
|
735
|
+
}).catch(() => {});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ── Review ──────────────────────────────────────────────────────────────────
|
|
739
|
+
|
|
740
|
+
// Measured review rows by session and unit, reused while the disk is
|
|
741
|
+
// unchanged; least recently used first out past the shared byte budget.
|
|
742
|
+
const rowCache = new Map();
|
|
743
|
+
let rowCacheBytes = 0;
|
|
744
|
+
|
|
745
|
+
function dropRow(key) {
|
|
746
|
+
const hit = rowCache.get(key);
|
|
747
|
+
if (!hit) return;
|
|
748
|
+
rowCache.delete(key);
|
|
749
|
+
rowCacheBytes -= hit.bytes;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function dropRows(sessionKey) {
|
|
753
|
+
const prefix = `${sessionKey}\0`;
|
|
754
|
+
for (const key of [...rowCache.keys()]) if (key.startsWith(prefix)) dropRow(key);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const rowBytes = (value) =>
|
|
758
|
+
256 +
|
|
759
|
+
Buffer.byteLength(String(value?.sig || '')) +
|
|
760
|
+
Buffer.byteLength(value?.change?.text || '') +
|
|
761
|
+
Buffer.byteLength(String(value?.change?.reason || ''));
|
|
762
|
+
|
|
763
|
+
/** The row cache view of one session: `get(unit)` / `set(unit, value)`. */
|
|
764
|
+
function rowCacheFor(sessionKey) {
|
|
765
|
+
const prefix = `${sessionKey}\0`;
|
|
766
|
+
return {
|
|
767
|
+
get(unit) {
|
|
768
|
+
const key = prefix + unit;
|
|
769
|
+
const hit = rowCache.get(key);
|
|
770
|
+
if (!hit) return undefined;
|
|
771
|
+
rowCache.delete(key);
|
|
772
|
+
rowCache.set(key, hit);
|
|
773
|
+
return hit.value;
|
|
774
|
+
},
|
|
775
|
+
set(unit, value) {
|
|
776
|
+
const key = prefix + unit;
|
|
777
|
+
dropRow(key);
|
|
778
|
+
const bytes = rowBytes(value);
|
|
779
|
+
if (bytes > ROW_CACHE_MAX_BYTES) return;
|
|
780
|
+
rowCache.set(key, { value, bytes });
|
|
781
|
+
rowCacheBytes += bytes;
|
|
782
|
+
while (rowCacheBytes > ROW_CACHE_MAX_BYTES) dropRow(rowCache.keys().next().value);
|
|
783
|
+
},
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
export function _rowCacheBytesForTest() {
|
|
788
|
+
return rowCacheBytes;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Unwritten capture batches this process holds for the session (tests). */
|
|
792
|
+
export function _pendingCapturesForTest(sessionId) {
|
|
793
|
+
return states.get(memoryKey(clean(sessionId)))?.pending.length ?? 0;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Bytes this process holds for the session's unwritten captures (tests). */
|
|
797
|
+
export function _unsavedBytesForTest(sessionId) {
|
|
798
|
+
const state = states.get(memoryKey(clean(sessionId)));
|
|
799
|
+
let bytes = 0;
|
|
800
|
+
for (const value of state?.writes.values() || []) bytes += value.length;
|
|
801
|
+
for (const op of state?.pending || []) {
|
|
802
|
+
for (const change of op.changes) {
|
|
803
|
+
if (change.before != null && !isOmitted(change.before)) bytes += contentBytes(change.before).length;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return bytes;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function readBlob(sessionId, entry) {
|
|
810
|
+
if (!entry?.blob) return undefined;
|
|
811
|
+
try {
|
|
812
|
+
const bytes = await readFile(join(blobDir(sessionId), entry.blob));
|
|
813
|
+
return digest(bytes) === entry.blob ? bytes : undefined;
|
|
814
|
+
} catch {
|
|
815
|
+
return undefined;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* The session's baselines after every capture queued so far, or null when it
|
|
821
|
+
* never recorded one: `{ createdAt, incomplete, files, missing, renames,
|
|
822
|
+
* cache, readBlob, unsaved? }`. `files` are immutable entries; `missing`
|
|
823
|
+
* lists `{ key, path, root }` of paths whose first state was lost;
|
|
824
|
+
* `readBlob(entry)`
|
|
825
|
+
* resolves to the captured bytes or undefined. Captures the disk refused are
|
|
826
|
+
* retried first; still refused, `unsaved` reports them (only this process
|
|
827
|
+
* holds them).
|
|
828
|
+
*/
|
|
829
|
+
export async function loadSessionBaselines(sessionId) {
|
|
830
|
+
const id = clean(sessionId);
|
|
831
|
+
if (!id) return null;
|
|
832
|
+
const key = memoryKey(id);
|
|
833
|
+
const queued = await inOrder(() => ({
|
|
834
|
+
result: chain(key, async () => {
|
|
835
|
+
const state = await flush(id, await stateFor(id));
|
|
836
|
+
if (!hasBaselines(state)) return null;
|
|
837
|
+
// Bytes of captures the disk refused are still measured from memory.
|
|
838
|
+
const unwritten = new Map(state.writes);
|
|
839
|
+
return {
|
|
840
|
+
createdAt: state.createdAt,
|
|
841
|
+
incomplete: state.incomplete,
|
|
842
|
+
files: [...state.files.values()],
|
|
843
|
+
missing: [...state.missing.values()].filter((entry) => !state.files.has(entry.key)),
|
|
844
|
+
renames: new Map(state.renames),
|
|
845
|
+
cache: rowCacheFor(key),
|
|
846
|
+
readBlob: async (entry) => unwritten.get(entry?.blob) ?? (await readBlob(id, entry)),
|
|
847
|
+
...(state.pending.length > 0 ? { unsaved: true } : {}),
|
|
848
|
+
};
|
|
849
|
+
}),
|
|
850
|
+
}));
|
|
851
|
+
return await queued.result;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Carry what the index on disk holds from `state.clearedAt` on into the
|
|
855
|
+
* freshly cleared `state`: another process may have published a capture issued
|
|
856
|
+
* after this clear while it was waiting for the lock. */
|
|
857
|
+
function keepSinceClear(state, onDisk) {
|
|
858
|
+
const { clearedAt } = state;
|
|
859
|
+
// The latest capture issued from the clear on that the index cannot vouch
|
|
860
|
+
// for (0 = none). Capture issue times only: the index's write time is a
|
|
861
|
+
// different clock reading than the clear's issue time.
|
|
862
|
+
let cause = 0;
|
|
863
|
+
for (const entry of onDisk.files.values()) {
|
|
864
|
+
const latest = entry.touched ?? entry.at;
|
|
865
|
+
if (latest < clearedAt) continue;
|
|
866
|
+
if (entry.at < clearedAt) {
|
|
867
|
+
// First seen before the clear, edited again after it: the state it had
|
|
868
|
+
// when the clear landed is lost, so it is listed as missing.
|
|
869
|
+
const { key, path, root } = entry;
|
|
870
|
+
state.missing.set(key, { key, path, root, at: latest });
|
|
871
|
+
cause = Math.max(cause, latest);
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
state.files.set(entry.key, entry);
|
|
875
|
+
// Issued in the clear's very millisecond: order unknown (see applyCapture).
|
|
876
|
+
if (entry.at === clearedAt) cause = Math.max(cause, clearedAt);
|
|
877
|
+
}
|
|
878
|
+
// A path already listed as missing stays so only when edited from this
|
|
879
|
+
// clear on; one without an edit time (an older index) predates it.
|
|
880
|
+
for (const lost of onDisk.missing.values()) {
|
|
881
|
+
if (lost.at === undefined || lost.at < clearedAt) continue;
|
|
882
|
+
state.missing.set(lost.key, lost);
|
|
883
|
+
cause = Math.max(cause, lost.at);
|
|
884
|
+
}
|
|
885
|
+
// Incompleteness owed to a capture from the clear on stays (an entry the
|
|
886
|
+
// bound rejected leaves nothing else to show); what cannot be placed on
|
|
887
|
+
// either side of the clear is not vouched for.
|
|
888
|
+
if (onDisk.incomplete && onDisk.incompleteAt >= clearedAt) cause = Math.max(cause, onDisk.incompleteAt);
|
|
889
|
+
if (onDisk.foldedAt >= clearedAt) cause = Math.max(cause, onDisk.foldedAt);
|
|
890
|
+
if (state.files.size === 0 && state.missing.size === 0 && cause === 0) return;
|
|
891
|
+
state.createdAt = onDisk.createdAt;
|
|
892
|
+
state.events = onDisk.events.filter((event) => event[0] >= clearedAt);
|
|
893
|
+
state.renames = refold(state);
|
|
894
|
+
if (cause > 0) markIncomplete(state, cause);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** Delete the session's baselines (Delete history): later edits start fresh.
|
|
898
|
+
* Every capture issued before it — in this process or, by its `clearedAt`
|
|
899
|
+
* epoch, in another — is dropped; one issued after it is kept. */
|
|
900
|
+
export async function clearSessionBaselines(sessionId) {
|
|
901
|
+
const id = clean(sessionId);
|
|
902
|
+
if (!id) return;
|
|
903
|
+
const key = memoryKey(id);
|
|
904
|
+
// Taken at the call: a capture issued after it is never older than it.
|
|
905
|
+
const clearedAt = issueTime();
|
|
906
|
+
const queued = await inOrder(() => ({
|
|
907
|
+
result: chain(key, async () => {
|
|
908
|
+
const cleared = emptyState(id);
|
|
909
|
+
cleared.clearedAt = clearedAt;
|
|
910
|
+
// Memory first: even when the disk refuses the clear, this process
|
|
911
|
+
// never reviews the old baselines again, and its next write replaces
|
|
912
|
+
// the old index.
|
|
913
|
+
cleared.signature = await indexSignature(id);
|
|
914
|
+
remember(id, cleared);
|
|
915
|
+
dropRows(key);
|
|
916
|
+
await withIndexLock(id, async () => {
|
|
917
|
+
await mkdir(baselinesRoot(), { recursive: true, mode: 0o700 });
|
|
918
|
+
const onDisk = await readState(id);
|
|
919
|
+
// A later clear already landed: nothing here predates it.
|
|
920
|
+
if (onDisk.clearedAt >= clearedAt) {
|
|
921
|
+
remember(id, onDisk);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
keepSinceClear(cleared, onDisk);
|
|
925
|
+
// A tombstone index, not a deletion: its epoch tells other processes
|
|
926
|
+
// which of their unwritten captures predate the clear.
|
|
927
|
+
await writeIndex(cleared);
|
|
928
|
+
const kept = new Set([...cleared.files.values()].map((entry) => entry.blob).filter(Boolean));
|
|
929
|
+
for (const name of await readdir(blobDir(id)).catch(() => [])) {
|
|
930
|
+
if (!kept.has(name)) await rm(join(blobDir(id), name), { recursive: true, force: true });
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
}),
|
|
934
|
+
}));
|
|
935
|
+
await queued.result;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** The session was deleted: its baselines and its attribution go with it. */
|
|
939
|
+
export async function forgetSessionBaselines(sessionId) {
|
|
940
|
+
const id = clean(sessionId);
|
|
941
|
+
if (!id) return;
|
|
942
|
+
await clearSessionBaselines(id).catch(() => {});
|
|
943
|
+
owners.delete(memoryKey(id));
|
|
944
|
+
await chain(`owner:${memoryKey(id)}`, () => rm(ownerPath(id), { force: true })).catch(() => {});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
export function _resetSessionBaselinesForTest() {
|
|
948
|
+
states.clear();
|
|
949
|
+
owners.clear();
|
|
950
|
+
ownerLookups.clear();
|
|
951
|
+
rowCache.clear();
|
|
952
|
+
rowCacheBytes = 0;
|
|
953
|
+
lastSweep = { root: '', at: 0 };
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** Wait for every capture, load and clear issued so far (tests). */
|
|
957
|
+
export async function _settleSessionBaselinesForTest() {
|
|
958
|
+
await inOrder(() => undefined);
|
|
959
|
+
await Promise.all([...chains.values()]);
|
|
960
|
+
}
|