@cjhyy/code-shell-core 0.5.0-rc.1 → 0.6.0-rc.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 +13 -10
- package/dist/agent/agent-definition-registry.d.ts +21 -3
- package/dist/agent/agent-definition-registry.js +49 -18
- package/dist/agent/agent-definition.d.ts +32 -0
- package/dist/agent/agent-definition.js +49 -4
- package/dist/arena/arena.js +11 -12
- package/dist/arena/context/context-tools.js +47 -6
- package/dist/arena/context/within-root.d.ts +7 -0
- package/dist/arena/context/within-root.js +15 -0
- package/dist/arena/iterate/phases/argue.js +8 -2
- package/dist/arena/iterate/phases/revise.js +1 -1
- package/dist/arena/iterate/phases/tournament.js +3 -3
- package/dist/arena/ledger.js +9 -1
- package/dist/arena/phases/adjudication.js +1 -4
- package/dist/arena/phases/build-consensus.js +1 -4
- package/dist/arena/phases/cross-review.js +2 -8
- package/dist/arena/phases/debate-rounds.js +1 -4
- package/dist/arena/phases/participant-research.js +1 -4
- package/dist/arena/phases/planning-detail-expansion.js +8 -10
- package/dist/arena/planner.js +0 -1
- package/dist/arena/providers/docs.d.ts +1 -0
- package/dist/arena/providers/docs.js +5 -2
- package/dist/arena/providers/repo.d.ts +1 -0
- package/dist/arena/providers/repo.js +5 -2
- package/dist/arena/strategies/utils.d.ts +1 -4
- package/dist/arena/strategies/utils.js +7 -51
- package/dist/arena/transitions.d.ts +7 -2
- package/dist/arena/transitions.js +9 -4
- package/dist/arena/types.d.ts +8 -2
- package/dist/automation/cron-expr.d.ts +41 -0
- package/dist/automation/cron-expr.js +169 -0
- package/dist/automation/index.d.ts +41 -0
- package/dist/automation/index.js +39 -0
- package/dist/automation/runner.d.ts +84 -0
- package/dist/automation/runner.js +92 -0
- package/dist/automation/scheduler.d.ts +194 -0
- package/dist/automation/scheduler.js +623 -0
- package/dist/automation/store.d.ts +39 -0
- package/dist/automation/store.js +122 -0
- package/dist/automation/write-policy.d.ts +34 -0
- package/dist/automation/write-policy.js +113 -0
- package/dist/automation/write-run.d.ts +51 -0
- package/dist/automation/write-run.js +38 -0
- package/dist/capability-control/disabled-lists.d.ts +25 -0
- package/dist/capability-control/disabled-lists.js +57 -0
- package/dist/capability-control/index.d.ts +5 -0
- package/dist/capability-control/index.js +3 -0
- package/dist/capability-control/overlay.d.ts +64 -0
- package/dist/capability-control/overlay.js +116 -0
- package/dist/capability-control/project.d.ts +65 -0
- package/dist/capability-control/project.js +147 -0
- package/dist/capability-control/service.d.ts +70 -0
- package/dist/capability-control/service.js +147 -0
- package/dist/capability-control/types.d.ts +62 -0
- package/dist/capability-control/types.js +17 -0
- package/dist/cc-orchestrator/agent-adapter.d.ts +50 -0
- package/dist/cc-orchestrator/agent-adapter.js +133 -0
- package/dist/cc-orchestrator/cc-capability.d.ts +19 -0
- package/dist/cc-orchestrator/cc-capability.js +53 -0
- package/dist/cc-orchestrator/codex-session-discovery.d.ts +24 -0
- package/dist/cc-orchestrator/codex-session-discovery.js +191 -0
- package/dist/cc-orchestrator/codex-session-history.d.ts +25 -0
- package/dist/cc-orchestrator/codex-session-history.js +187 -0
- package/dist/cc-orchestrator/external-agent-changes.d.ts +19 -0
- package/dist/cc-orchestrator/external-agent-changes.js +214 -0
- package/dist/cc-orchestrator/external-agent-driver.d.ts +18 -0
- package/dist/cc-orchestrator/external-agent-driver.js +69 -0
- package/dist/cc-orchestrator/index.d.ts +8 -0
- package/dist/cc-orchestrator/index.js +8 -0
- package/dist/cc-orchestrator/relevance-judge.d.ts +15 -0
- package/dist/cc-orchestrator/relevance-judge.js +29 -0
- package/dist/cc-orchestrator/session-discovery.d.ts +46 -0
- package/dist/cc-orchestrator/session-discovery.js +125 -0
- package/dist/cc-orchestrator/session-history.d.ts +19 -0
- package/dist/cc-orchestrator/session-history.js +67 -0
- package/dist/cli/agent-server-stdio.d.ts +26 -1
- package/dist/cli/agent-server-stdio.js +221 -28
- package/dist/cli/agent-server-tcp.d.ts +20 -0
- package/dist/cli/agent-server-tcp.js +134 -0
- package/dist/cli/graceful-shutdown.d.ts +27 -0
- package/dist/cli/graceful-shutdown.js +30 -0
- package/dist/context/compaction.d.ts +56 -0
- package/dist/context/compaction.js +209 -15
- package/dist/context/manager.d.ts +0 -20
- package/dist/context/manager.js +25 -41
- package/dist/cost-tracker.js +5 -61
- package/dist/credentials/cipher.d.ts +49 -0
- package/dist/credentials/cipher.js +45 -0
- package/dist/credentials/cookie-jar.d.ts +24 -0
- package/dist/credentials/cookie-jar.js +40 -0
- package/dist/credentials/index.d.ts +7 -0
- package/dist/credentials/index.js +5 -0
- package/dist/credentials/inject-credential-tool.d.ts +20 -0
- package/dist/credentials/inject-credential-tool.js +130 -0
- package/dist/credentials/store.d.ts +72 -0
- package/dist/credentials/store.js +184 -0
- package/dist/credentials/types.d.ts +56 -0
- package/dist/credentials/use-credential-tool.d.ts +29 -0
- package/dist/credentials/use-credential-tool.js +205 -0
- package/dist/credentials/use-gate.d.ts +56 -0
- package/dist/credentials/use-gate.js +52 -0
- package/dist/cron/cron-runtime.d.ts +2 -0
- package/dist/cron/cron-runtime.js +2 -0
- package/dist/cron/cron-store.d.ts +2 -0
- package/dist/cron/cron-store.js +2 -0
- package/dist/cron/scheduler.d.ts +5 -32
- package/dist/cron/scheduler.js +5 -113
- package/dist/data/model-metadata.d.ts +77 -0
- package/dist/data/model-metadata.js +56 -0
- package/dist/data/model-metadata.json +216 -0
- package/dist/data/openrouter-models.d.ts +18 -7
- package/dist/data/openrouter-models.js +35 -8
- package/dist/data/openrouter-sync.js +1 -1
- package/dist/engine/aux-key.d.ts +10 -0
- package/dist/engine/aux-key.js +11 -0
- package/dist/engine/dynamic-tool-defs.d.ts +19 -0
- package/dist/engine/dynamic-tool-defs.js +36 -0
- package/dist/engine/engine.d.ts +431 -108
- package/dist/engine/engine.js +1630 -279
- package/dist/engine/friendly-error.d.ts +18 -0
- package/dist/engine/friendly-error.js +63 -0
- package/dist/engine/goal.d.ts +187 -0
- package/dist/engine/goal.js +196 -0
- package/dist/engine/image-compression.d.ts +81 -0
- package/dist/engine/image-compression.js +186 -0
- package/dist/engine/image-policy.d.ts +165 -0
- package/dist/engine/image-policy.js +189 -0
- package/dist/engine/model-connections-pool.d.ts +17 -0
- package/dist/engine/model-connections-pool.js +66 -0
- package/dist/engine/model-facade.d.ts +10 -0
- package/dist/engine/model-facade.js +25 -0
- package/dist/engine/patch-orphaned-tools.js +3 -0
- package/dist/engine/reactive-threshold.d.ts +13 -0
- package/dist/engine/reactive-threshold.js +17 -0
- package/dist/engine/resolve-llm-config.d.ts +16 -0
- package/dist/engine/resolve-llm-config.js +44 -0
- package/dist/engine/runtime.js +9 -1
- package/dist/engine/sandbox-cache-key.d.ts +10 -0
- package/dist/engine/sandbox-cache-key.js +9 -0
- package/dist/engine/sandbox-config.d.ts +31 -0
- package/dist/engine/sandbox-config.js +38 -0
- package/dist/engine/session-title.d.ts +13 -0
- package/dist/engine/session-title.js +34 -0
- package/dist/engine/session-usage.d.ts +19 -0
- package/dist/engine/session-usage.js +25 -0
- package/dist/engine/steer-queue.d.ts +32 -0
- package/dist/engine/steer-queue.js +26 -0
- package/dist/engine/streaming-tool-queue.d.ts +12 -0
- package/dist/engine/streaming-tool-queue.js +50 -10
- package/dist/engine/turn-loop.d.ts +94 -9
- package/dist/engine/turn-loop.js +775 -342
- package/dist/engine/types.d.ts +175 -0
- package/dist/engine/types.js +13 -0
- package/dist/external-agents/config.d.ts +2 -0
- package/dist/external-agents/config.js +15 -0
- package/dist/external-agents/types.d.ts +31 -0
- package/dist/external-agents/types.js +1 -0
- package/dist/git/parse-log.d.ts +13 -0
- package/dist/git/parse-log.js +21 -0
- package/dist/git/utils.d.ts +8 -6
- package/dist/git/utils.js +62 -26
- package/dist/git/worktree.d.ts +48 -0
- package/dist/git/worktree.js +111 -20
- package/dist/hooks/events.d.ts +23 -1
- package/dist/hooks/goal-stop-hook.d.ts +58 -0
- package/dist/hooks/goal-stop-hook.js +279 -0
- package/dist/hooks/hook-output.d.ts +33 -0
- package/dist/hooks/hook-output.js +79 -0
- package/dist/hooks/registry.d.ts +15 -0
- package/dist/hooks/registry.js +57 -1
- package/dist/hooks/shell-runner.d.ts +2 -0
- package/dist/hooks/shell-runner.js +48 -18
- package/dist/index.d.ts +79 -11
- package/dist/index.js +89 -6
- package/dist/llm/capabilities/param-specs.d.ts +14 -0
- package/dist/llm/capabilities/param-specs.js +62 -0
- package/dist/llm/capabilities/reasoning-control.d.ts +24 -0
- package/dist/llm/capabilities/reasoning-control.js +38 -0
- package/dist/llm/capabilities/rules.js +37 -3
- package/dist/llm/capabilities/types.d.ts +32 -2
- package/dist/llm/clamp-max-tokens.d.ts +11 -0
- package/dist/llm/clamp-max-tokens.js +17 -0
- package/dist/llm/client-base.d.ts +69 -5
- package/dist/llm/client-base.js +185 -25
- package/dist/llm/client-factory.d.ts +3 -3
- package/dist/llm/client-factory.js +2 -2
- package/dist/llm/model-cache.js +4 -2
- package/dist/llm/model-pool.d.ts +29 -11
- package/dist/llm/model-pool.js +36 -20
- package/dist/llm/provider-auth.d.ts +41 -0
- package/dist/llm/provider-auth.js +76 -0
- package/dist/llm/provider-catalog.d.ts +6 -2
- package/dist/llm/providers/anthropic.d.ts +32 -2
- package/dist/llm/providers/anthropic.js +207 -18
- package/dist/llm/providers/openai.d.ts +47 -2
- package/dist/llm/providers/openai.js +478 -71
- package/dist/llm/reasoning-setting.d.ts +51 -0
- package/dist/llm/reasoning-setting.js +40 -0
- package/dist/llm/stop-reason.d.ts +13 -0
- package/dist/llm/stop-reason.js +21 -0
- package/dist/llm/stream-watchdog.js +5 -1
- package/dist/llm/strip-vision.d.ts +32 -0
- package/dist/llm/strip-vision.js +94 -0
- package/dist/llm/token-counter.js +9 -2
- package/dist/llm/types.d.ts +9 -6
- package/dist/logging/logger.js +18 -4
- package/dist/logging/sanitize-messages.d.ts +10 -0
- package/dist/logging/sanitize-messages.js +112 -0
- package/dist/lsp/client.js +28 -8
- package/dist/lsp/manager.d.ts +1 -1
- package/dist/lsp/manager.js +42 -11
- package/dist/lsp/root-path.d.ts +9 -0
- package/dist/lsp/root-path.js +12 -0
- package/dist/model-catalog/builtin.d.ts +12 -0
- package/dist/model-catalog/builtin.js +407 -0
- package/dist/model-catalog/gen-connections.d.ts +20 -0
- package/dist/model-catalog/gen-connections.js +28 -0
- package/dist/model-catalog/index.d.ts +41 -0
- package/dist/model-catalog/index.js +90 -0
- package/dist/model-catalog/params.d.ts +20 -0
- package/dist/model-catalog/params.js +45 -0
- package/dist/model-catalog/resolve.d.ts +48 -0
- package/dist/model-catalog/resolve.js +33 -0
- package/dist/model-catalog/save-entry.d.ts +32 -0
- package/dist/model-catalog/save-entry.js +104 -0
- package/dist/model-catalog/types.d.ts +561 -0
- package/dist/model-catalog/types.js +93 -0
- package/dist/model-catalog/upsert.d.ts +9 -0
- package/dist/model-catalog/upsert.js +8 -0
- package/dist/onboarding.d.ts +12 -82
- package/dist/onboarding.js +75 -333
- package/dist/plugins/gitOps.d.ts +53 -0
- package/dist/plugins/gitOps.js +130 -5
- package/dist/plugins/installer/checkUpdate.d.ts +16 -0
- package/dist/plugins/installer/checkUpdate.js +32 -0
- package/dist/plugins/installer/codex/convertAgents.d.ts +7 -0
- package/dist/plugins/installer/codex/convertAgents.js +55 -0
- package/dist/plugins/installer/codex/convertCommands.d.ts +19 -0
- package/dist/plugins/installer/codex/convertCommands.js +46 -0
- package/dist/plugins/installer/codex/convertMcp.d.ts +11 -0
- package/dist/plugins/installer/codex/convertMcp.js +85 -0
- package/dist/plugins/installer/codex/convertSkills.d.ts +12 -0
- package/dist/plugins/installer/codex/convertSkills.js +33 -0
- package/dist/plugins/installer/detectFormat.d.ts +2 -0
- package/dist/plugins/installer/detectFormat.js +6 -0
- package/dist/plugins/installer/install.d.ts +7 -0
- package/dist/plugins/installer/install.js +123 -0
- package/dist/plugins/installer/installFromArchive.d.ts +43 -0
- package/dist/plugins/installer/installFromArchive.js +134 -0
- package/dist/plugins/installer/installFromSource.d.ts +13 -0
- package/dist/plugins/installer/installFromSource.js +51 -0
- package/dist/plugins/installer/list.d.ts +9 -0
- package/dist/plugins/installer/list.js +30 -0
- package/dist/plugins/installer/loadPluginAgents.d.ts +3 -0
- package/dist/plugins/installer/loadPluginAgents.js +27 -0
- package/dist/plugins/installer/loadPluginMcp.d.ts +14 -0
- package/dist/plugins/installer/loadPluginMcp.js +107 -0
- package/dist/plugins/installer/parseSource.d.ts +20 -0
- package/dist/plugins/installer/parseSource.js +74 -0
- package/dist/plugins/installer/paths.d.ts +5 -0
- package/dist/plugins/installer/paths.js +25 -0
- package/dist/plugins/installer/pruneDisabled.d.ts +24 -0
- package/dist/plugins/installer/pruneDisabled.js +73 -0
- package/dist/plugins/installer/types.d.ts +52 -0
- package/dist/plugins/installer/types.js +28 -0
- package/dist/plugins/installer/uninstall.d.ts +2 -0
- package/dist/plugins/installer/uninstall.js +18 -0
- package/dist/plugins/installer/unzip.d.ts +14 -0
- package/dist/plugins/installer/unzip.js +82 -0
- package/dist/plugins/installer/update.d.ts +28 -0
- package/dist/plugins/installer/update.js +97 -0
- package/dist/plugins/loadPluginHooks.d.ts +54 -1
- package/dist/plugins/loadPluginHooks.js +83 -1
- package/dist/plugins/marketplaceManager.d.ts +15 -1
- package/dist/plugins/marketplaceManager.js +58 -4
- package/dist/plugins/pluginCommandHook.js +34 -14
- package/dist/plugins/pluginContent.d.ts +30 -0
- package/dist/plugins/pluginContent.js +83 -0
- package/dist/plugins/pluginInstaller.d.ts +34 -0
- package/dist/plugins/pluginInstaller.js +168 -35
- package/dist/plugins/schemas.js +41 -6
- package/dist/plugins/types.d.ts +10 -0
- package/dist/preset/index.d.ts +7 -1
- package/dist/preset/index.js +112 -6
- package/dist/product/types.d.ts +1 -1
- package/dist/prompt/composer.d.ts +31 -0
- package/dist/prompt/composer.js +95 -25
- package/dist/prompt/instruction-scanner.js +5 -3
- package/dist/prompt/section-loader.js +1 -0
- package/dist/prompt/sections/base.md +1 -0
- package/dist/prompt/sections/browser.md +10 -0
- package/dist/prompt/sections/coding.md +4 -0
- package/dist/prompt/sections/orchestration.md +8 -0
- package/dist/protocol/chat-session-manager.d.ts +15 -2
- package/dist/protocol/chat-session-manager.js +46 -0
- package/dist/protocol/chat-session.d.ts +82 -0
- package/dist/protocol/chat-session.js +121 -1
- package/dist/protocol/client.d.ts +27 -0
- package/dist/protocol/client.js +38 -0
- package/dist/protocol/redact.d.ts +50 -0
- package/dist/protocol/redact.js +71 -0
- package/dist/protocol/server.d.ts +153 -2
- package/dist/protocol/server.js +807 -39
- package/dist/protocol/tcp-transport.d.ts +45 -0
- package/dist/protocol/tcp-transport.js +74 -0
- package/dist/protocol/transport.js +3 -2
- package/dist/protocol/types.d.ts +69 -0
- package/dist/protocol/types.js +22 -0
- package/dist/quota/credentials.d.ts +3 -0
- package/dist/quota/credentials.js +80 -0
- package/dist/quota/index.d.ts +36 -0
- package/dist/quota/index.js +155 -0
- package/dist/quota/types.d.ts +48 -0
- package/dist/quota/types.js +13 -0
- package/dist/remote/bridge.d.ts +11 -2
- package/dist/remote/bridge.js +61 -33
- package/dist/review/review-prompt.d.ts +28 -0
- package/dist/review/review-prompt.js +81 -0
- package/dist/run/ArtifactTracker.js +5 -4
- package/dist/run/EngineRunner.d.ts +27 -1
- package/dist/run/EngineRunner.js +46 -8
- package/dist/run/FileRunStore.js +40 -11
- package/dist/run/RunApprovalBackend.js +25 -5
- package/dist/run/RunLock.d.ts +28 -2
- package/dist/run/RunLock.js +49 -7
- package/dist/run/RunManager.d.ts +12 -0
- package/dist/run/RunManager.js +86 -3
- package/dist/run/RunQueue.d.ts +1 -0
- package/dist/run/RunQueue.js +8 -2
- package/dist/run/factory.d.ts +8 -1
- package/dist/run/factory.js +1 -0
- package/dist/run/index.d.ts +2 -2
- package/dist/run/index.js +1 -1
- package/dist/run/redirect-target.d.ts +7 -0
- package/dist/run/redirect-target.js +13 -0
- package/dist/run/types.js +1 -1
- package/dist/runtime/background-shell.d.ts +139 -0
- package/dist/runtime/background-shell.js +495 -0
- package/dist/runtime/output-clean.d.ts +24 -0
- package/dist/runtime/output-clean.js +41 -0
- package/dist/runtime/ring-file.d.ts +64 -0
- package/dist/runtime/ring-file.js +174 -0
- package/dist/runtime/safe-spawn.js +15 -34
- package/dist/runtime/spawn-common.d.ts +148 -0
- package/dist/runtime/spawn-common.js +303 -0
- package/dist/runtime/truncate-output.d.ts +22 -0
- package/dist/runtime/truncate-output.js +49 -0
- package/dist/runtime/utf8-cut.d.ts +11 -0
- package/dist/runtime/utf8-cut.js +23 -0
- package/dist/services/auto-dream.d.ts +4 -0
- package/dist/services/auto-dream.js +26 -26
- package/dist/services/browser-open.d.ts +13 -0
- package/dist/services/browser-open.js +16 -0
- package/dist/services/diagnostics.d.ts +1 -2
- package/dist/services/diagnostics.js +12 -7
- package/dist/services/dream-consolidation.d.ts +57 -0
- package/dist/services/dream-consolidation.js +151 -0
- package/dist/services/extract-memories.d.ts +14 -1
- package/dist/services/extract-memories.js +45 -6
- package/dist/services/memory-orchestrator.d.ts +21 -0
- package/dist/services/memory-orchestrator.js +125 -32
- package/dist/services/notifier.d.ts +8 -0
- package/dist/services/notifier.js +39 -25
- package/dist/services/oauth.js +5 -7
- package/dist/services/session-memory-sort.d.ts +8 -0
- package/dist/services/session-memory-sort.js +9 -0
- package/dist/services/session-memory.js +30 -19
- package/dist/session/file-history.d.ts +124 -1
- package/dist/session/file-history.js +232 -9
- package/dist/session/memory.d.ts +116 -2
- package/dist/session/memory.js +250 -28
- package/dist/session/session-manager.d.ts +63 -1
- package/dist/session/session-manager.js +186 -5
- package/dist/session/simple-diff.d.ts +23 -0
- package/dist/session/simple-diff.js +84 -0
- package/dist/session/transcript.d.ts +25 -1
- package/dist/session/transcript.js +35 -2
- package/dist/session/undo-target.d.ts +67 -0
- package/dist/session/undo-target.js +144 -0
- package/dist/settings/disk-defaults.d.ts +42 -0
- package/dist/settings/disk-defaults.js +33 -0
- package/dist/settings/feature-flags.d.ts +64 -0
- package/dist/settings/feature-flags.js +61 -0
- package/dist/settings/manager.d.ts +101 -1
- package/dist/settings/manager.js +345 -15
- package/dist/settings/migrate-config.d.ts +45 -0
- package/dist/settings/migrate-config.js +125 -0
- package/dist/settings/personalization.d.ts +19 -0
- package/dist/settings/personalization.js +7 -0
- package/dist/settings/schema-export.d.ts +25 -0
- package/dist/settings/schema-export.js +38 -0
- package/dist/settings/schema.d.ts +1827 -396
- package/dist/settings/schema.js +361 -81
- package/dist/skills/scanner.d.ts +9 -0
- package/dist/skills/scanner.js +30 -2
- package/dist/stt/resolve-transcribe.d.ts +31 -0
- package/dist/stt/resolve-transcribe.js +108 -0
- package/dist/stt/transcribe.d.ts +51 -0
- package/dist/stt/transcribe.js +65 -0
- package/dist/tool-system/browser-bridge.d.ts +226 -0
- package/dist/tool-system/browser-bridge.js +163 -0
- package/dist/tool-system/builtin/add-marketplace.d.ts +12 -0
- package/dist/tool-system/builtin/add-marketplace.js +76 -0
- package/dist/tool-system/builtin/agent-heartbeat.d.ts +49 -0
- package/dist/tool-system/builtin/agent-heartbeat.js +89 -0
- package/dist/tool-system/builtin/agent-notifications.d.ts +12 -3
- package/dist/tool-system/builtin/agent-notifications.js +9 -3
- package/dist/tool-system/builtin/agent-output-file.d.ts +38 -0
- package/dist/tool-system/builtin/agent-output-file.js +72 -0
- package/dist/tool-system/builtin/agent-registry.d.ts +23 -0
- package/dist/tool-system/builtin/agent-registry.js +16 -4
- package/dist/tool-system/builtin/agent.d.ts +50 -11
- package/dist/tool-system/builtin/agent.js +576 -73
- package/dist/tool-system/builtin/apply-patch/applier.js +66 -8
- package/dist/tool-system/builtin/apply-patch/backup-targets.d.ts +10 -0
- package/dist/tool-system/builtin/apply-patch/backup-targets.js +30 -0
- package/dist/tool-system/builtin/apply-patch/index.js +7 -3
- package/dist/tool-system/builtin/arena.js +6 -7
- package/dist/tool-system/builtin/background-jobs.d.ts +76 -0
- package/dist/tool-system/builtin/background-jobs.js +124 -0
- package/dist/tool-system/builtin/background-shell-tools.d.ts +20 -0
- package/dist/tool-system/builtin/background-shell-tools.js +108 -0
- package/dist/tool-system/builtin/background-work.d.ts +67 -0
- package/dist/tool-system/builtin/background-work.js +86 -0
- package/dist/tool-system/builtin/bash-output-style.d.ts +32 -0
- package/dist/tool-system/builtin/bash-output-style.js +40 -0
- package/dist/tool-system/builtin/bash.d.ts +5 -2
- package/dist/tool-system/builtin/bash.js +98 -62
- package/dist/tool-system/builtin/browser-tools.d.ts +33 -0
- package/dist/tool-system/builtin/browser-tools.js +312 -0
- package/dist/tool-system/builtin/cancel-goal.d.ts +31 -0
- package/dist/tool-system/builtin/cancel-goal.js +64 -0
- package/dist/tool-system/builtin/check-quota.d.ts +15 -0
- package/dist/tool-system/builtin/check-quota.js +34 -0
- package/dist/tool-system/builtin/complete-goal.d.ts +25 -0
- package/dist/tool-system/builtin/complete-goal.js +45 -0
- package/dist/tool-system/builtin/config.js +7 -0
- package/dist/tool-system/builtin/cron.d.ts +7 -0
- package/dist/tool-system/builtin/cron.js +105 -9
- package/dist/tool-system/builtin/drive-claude-code.d.ts +30 -0
- package/dist/tool-system/builtin/drive-claude-code.js +157 -0
- package/dist/tool-system/builtin/edit-model-catalog.d.ts +3 -0
- package/dist/tool-system/builtin/edit-model-catalog.js +94 -0
- package/dist/tool-system/builtin/edit.d.ts +2 -1
- package/dist/tool-system/builtin/edit.js +21 -12
- package/dist/tool-system/builtin/eol.d.ts +29 -0
- package/dist/tool-system/builtin/eol.js +37 -0
- package/dist/tool-system/builtin/file-cache.d.ts +6 -0
- package/dist/tool-system/builtin/file-cache.js +8 -0
- package/dist/tool-system/builtin/generate-image.d.ts +60 -0
- package/dist/tool-system/builtin/generate-image.js +374 -0
- package/dist/tool-system/builtin/generate-video.d.ts +55 -0
- package/dist/tool-system/builtin/generate-video.js +361 -0
- package/dist/tool-system/builtin/grep.js +6 -2
- package/dist/tool-system/builtin/image-providers.d.ts +86 -0
- package/dist/tool-system/builtin/image-providers.js +190 -0
- package/dist/tool-system/builtin/image-uploader.d.ts +33 -0
- package/dist/tool-system/builtin/image-uploader.js +74 -0
- package/dist/tool-system/builtin/index.d.ts +24 -1
- package/dist/tool-system/builtin/index.js +310 -25
- package/dist/tool-system/builtin/mcp-tools.js +23 -3
- package/dist/tool-system/builtin/memory.js +45 -7
- package/dist/tool-system/builtin/notebook-edit.d.ts +2 -1
- package/dist/tool-system/builtin/notebook-edit.js +1 -1
- package/dist/tool-system/builtin/powershell.js +4 -1
- package/dist/tool-system/builtin/read.d.ts +2 -1
- package/dist/tool-system/builtin/read.js +11 -3
- package/dist/tool-system/builtin/repl.js +4 -1
- package/dist/tool-system/builtin/skill.js +9 -0
- package/dist/tool-system/builtin/sleep.js +12 -5
- package/dist/tool-system/builtin/task.js +4 -1
- package/dist/tool-system/builtin/tool-search.js +25 -7
- package/dist/tool-system/builtin/update-automation-memory.d.ts +24 -0
- package/dist/tool-system/builtin/update-automation-memory.js +60 -0
- package/dist/tool-system/builtin/video-providers.d.ts +154 -0
- package/dist/tool-system/builtin/video-providers.js +235 -0
- package/dist/tool-system/builtin/view-image.d.ts +18 -0
- package/dist/tool-system/builtin/view-image.js +96 -0
- package/dist/tool-system/builtin/web-fetch.js +12 -2
- package/dist/tool-system/builtin/web-search.d.ts +4 -1
- package/dist/tool-system/builtin/web-search.js +57 -10
- package/dist/tool-system/builtin/worktree.d.ts +2 -1
- package/dist/tool-system/builtin/worktree.js +31 -4
- package/dist/tool-system/builtin/write.d.ts +2 -1
- package/dist/tool-system/builtin/write.js +1 -1
- package/dist/tool-system/context.d.ts +166 -6
- package/dist/tool-system/executor.d.ts +3 -2
- package/dist/tool-system/executor.js +197 -119
- package/dist/tool-system/investigation-guard.d.ts +4 -1
- package/dist/tool-system/investigation-guard.js +20 -4
- package/dist/tool-system/mcp-manager.d.ts +78 -16
- package/dist/tool-system/mcp-manager.js +357 -28
- package/dist/tool-system/path-policy.d.ts +99 -0
- package/dist/tool-system/path-policy.js +578 -0
- package/dist/tool-system/permission.d.ts +36 -1
- package/dist/tool-system/permission.js +322 -117
- package/dist/tool-system/plan-mode-allowlist.d.ts +32 -0
- package/dist/tool-system/plan-mode-allowlist.js +65 -0
- package/dist/tool-system/registry.d.ts +1 -0
- package/dist/tool-system/registry.js +26 -1
- package/dist/tool-system/sandbox/index.d.ts +8 -0
- package/dist/tool-system/sandbox/index.js +7 -2
- package/dist/tool-system/validate-tool-metadata.d.ts +36 -0
- package/dist/tool-system/validate-tool-metadata.js +63 -0
- package/dist/tool-system/validation.d.ts +10 -0
- package/dist/tool-system/validation.js +16 -3
- package/dist/types.d.ts +301 -15
- package/dist/updater.js +20 -9
- package/dist/utils/envUtils.d.ts +0 -9
- package/dist/utils/envUtils.js +3 -28
- package/dist/utils/exec.d.ts +40 -0
- package/dist/utils/exec.js +144 -0
- package/dist/utils/format.js +4 -2
- package/dist/utils/json.d.ts +12 -0
- package/dist/utils/json.js +92 -0
- package/dist/utils/lockfile.js +9 -2
- package/dist/utils/theme.d.ts +7 -1
- package/dist/utils/theme.js +5 -3
- package/dist/utils/toolDisplay.js +0 -1
- package/package.json +14 -7
- package/dist/agent/coordinator.d.ts +0 -49
- package/dist/agent/coordinator.js +0 -77
- package/dist/settings/manager.test.js +0 -73
- package/dist/tool-system/builtin/remote-trigger.d.ts +0 -6
- package/dist/tool-system/builtin/remote-trigger.js +0 -54
- package/dist/tool-system/builtin/send-message.d.ts +0 -6
- package/dist/tool-system/builtin/send-message.js +0 -47
- /package/dist/{settings/manager.test.d.ts → credentials/types.js} +0 -0
package/dist/engine/engine.js
CHANGED
|
@@ -7,57 +7,175 @@ import { ToolExecutor } from "../tool-system/executor.js";
|
|
|
7
7
|
import { InvestigationGuard } from "../tool-system/investigation-guard.js";
|
|
8
8
|
import { TaskGuard } from "../tool-system/task-guard.js";
|
|
9
9
|
import { readLastTodoSnapshot } from "../tool-system/builtin/task.js";
|
|
10
|
+
import { applyDynamicToolDef } from "./dynamic-tool-defs.js";
|
|
11
|
+
import { getMergedCatalog } from "../model-catalog/index.js";
|
|
12
|
+
import { modelEntriesFromConnections } from "./model-connections-pool.js";
|
|
13
|
+
import { resolveAuxKey } from "./aux-key.js";
|
|
14
|
+
import { foldRunUsage } from "./session-usage.js";
|
|
15
|
+
import { enqueueSteerItem, consumeSteerItems, removeSteerItem, } from "./steer-queue.js";
|
|
16
|
+
import { resolveSandboxConfig } from "./sandbox-config.js";
|
|
17
|
+
import { sandboxCacheKey } from "./sandbox-cache-key.js";
|
|
18
|
+
import { BUILTIN_TOOL_GUARDS } from "../tool-system/builtin/index.js";
|
|
19
|
+
import { asyncAgentRegistry } from "../tool-system/builtin/agent-registry.js";
|
|
20
|
+
import { backgroundShellManager } from "../runtime/background-shell.js";
|
|
21
|
+
import { notificationQueue, buildNotificationMessage, } from "../tool-system/builtin/agent-notifications.js";
|
|
10
22
|
import { PermissionClassifier, HeadlessApprovalBackend, AutoApprovalBackend, InteractiveApprovalBackend, getInteractiveApprovalBackend, } from "../tool-system/permission.js";
|
|
11
23
|
import { HookRegistry } from "../hooks/registry.js";
|
|
12
24
|
import { wrapHookMessages } from "../hooks/inject.js";
|
|
25
|
+
import { createGoalStopHook } from "../hooks/goal-stop-hook.js";
|
|
26
|
+
import { normalizeGoal, resolveGoalSetAt, resolveMaxTurns, resolveMaxStopBlocks, } from "./goal.js";
|
|
13
27
|
import { loadPluginHooks } from "../plugins/loadPluginHooks.js";
|
|
28
|
+
import { pluginAgentDirs } from "../plugins/installer/loadPluginAgents.js";
|
|
14
29
|
import { patchOrphanedToolUses } from "./patch-orphaned-tools.js";
|
|
15
30
|
import { runShellHook, shellHookMatches } from "../hooks/shell-runner.js";
|
|
16
31
|
import { ContextManager } from "../context/manager.js";
|
|
32
|
+
import { estimateTokens, clampContextRatios as clampContextRatiosImpl, } from "../context/compaction.js";
|
|
33
|
+
import { PLAN_MODE_ALLOWED_TOOLS } from "../tool-system/plan-mode-allowlist.js";
|
|
17
34
|
import { PromptComposer } from "../prompt/composer.js";
|
|
18
35
|
import { SessionManager } from "../session/session-manager.js";
|
|
19
36
|
import { ModelFacade } from "./model-facade.js";
|
|
20
|
-
import { logger, setCurrentSid, runWithSid } from "../logging/logger.js";
|
|
37
|
+
import { logger, setCurrentSid, runWithSid, getCurrentSid } from "../logging/logger.js";
|
|
21
38
|
import { recordSessionStart, recordSessionEnd } from "../logging/session-recorder.js";
|
|
22
39
|
import { sanitizeContent, sanitizeTaskString } from "../logging/sanitize-messages.js";
|
|
23
40
|
import { TurnLoop } from "./turn-loop.js";
|
|
24
41
|
import { MCPManager } from "../tool-system/mcp-manager.js";
|
|
25
|
-
import { SettingsManager } from "../settings/manager.js";
|
|
42
|
+
import { SettingsManager, userHome } from "../settings/manager.js";
|
|
43
|
+
import { CredentialStore } from "../credentials/store.js";
|
|
44
|
+
import { isFeatureEnabled, resolveFeatureFlags, } from "../settings/feature-flags.js";
|
|
45
|
+
import { effectiveDisabledList, effectiveBuiltinLists, } from "../capability-control/overlay.js";
|
|
46
|
+
import { computeEffectiveDisabledLists } from "../capability-control/disabled-lists.js";
|
|
26
47
|
import { FileHistory } from "../session/file-history.js";
|
|
27
|
-
import {
|
|
48
|
+
import { patchBackupTargets } from "../tool-system/builtin/apply-patch/backup-targets.js";
|
|
49
|
+
import { resolveSandboxBackend, } from "../tool-system/sandbox/index.js";
|
|
28
50
|
import { resolveAgentPreset, resolveBuiltinToolNames, } from "../preset/index.js";
|
|
29
51
|
import { ModelPool } from "../llm/model-pool.js";
|
|
30
52
|
import { AgentDefinitionRegistry } from "../agent/agent-definition-registry.js";
|
|
31
|
-
import { ProviderCatalog } from "../llm/provider-catalog.js";
|
|
32
53
|
import { defaultCacheDir } from "../llm/model-cache.js";
|
|
33
54
|
import { detectProviderFromApiKey, buildModelPool, } from "../onboarding.js";
|
|
34
55
|
import { detectPastedNoise } from "../utils/task-sanitizer.js";
|
|
35
56
|
import { parseTaskWithImages, } from "./parse-task.js";
|
|
57
|
+
import { enforceImagePolicy, byteLengthFromBase64, dropOversizedImages, collectAttachedImagePaths, } from "./image-policy.js";
|
|
58
|
+
import { tryCompressImages } from "./image-compression.js";
|
|
59
|
+
import { buildSessionTitle } from "./session-title.js";
|
|
36
60
|
import { capabilitiesFor } from "../llm/capabilities/index.js";
|
|
37
61
|
import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
|
|
62
|
+
import { runDreamConsolidation } from "../services/dream-consolidation.js";
|
|
63
|
+
import { join, isAbsolute } from "node:path";
|
|
64
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
|
|
65
|
+
/**
|
|
66
|
+
* Build ScanOptions.compatFileNames from the user's instruction compat toggles.
|
|
67
|
+
* Primary file name stays hard-wired to CODESHELL.md (not exposed). Turning a
|
|
68
|
+
* compat flag off only drops the same-named .md (CLAUDE.md / AGENTS.md); the
|
|
69
|
+
* .claude/ subdir, *.local.md and rules/ are intentionally NOT linked.
|
|
70
|
+
* undefined (instructions omitted) means both stay on — backward compatible.
|
|
71
|
+
*/
|
|
72
|
+
export function compatFileNamesFrom(instructions) {
|
|
73
|
+
const names = [];
|
|
74
|
+
if (instructions?.compatClaude !== false)
|
|
75
|
+
names.push("CLAUDE.md");
|
|
76
|
+
if (instructions?.compatCodex !== false)
|
|
77
|
+
names.push("AGENTS.md");
|
|
78
|
+
return names;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* True when two LLMConfigs name the SAME client identity — i.e. building a
|
|
82
|
+
* client from either would talk to the same model on the same endpoint with the
|
|
83
|
+
* same shaping. Used by resolveAuxClient to de-dup the aux client against the
|
|
84
|
+
* active model WITHOUT collapsing two distinct pool keys that merely share a
|
|
85
|
+
* `model` NAME but differ in reasoning/maxTokens/baseUrl/provider. Compares the
|
|
86
|
+
* fields that actually change request behavior; apiKey is intentionally NOT
|
|
87
|
+
* compared (two keys with the same endpoint+model but different credentials
|
|
88
|
+
* still produce equivalent aux work and don't warrant a second client). The
|
|
89
|
+
* reasoning object is compared by normalized JSON since it's a small
|
|
90
|
+
* discriminated union.
|
|
91
|
+
*/
|
|
92
|
+
function sameLlmIdentity(a, b) {
|
|
93
|
+
return (a.model === b.model &&
|
|
94
|
+
(a.baseUrl ?? undefined) === (b.baseUrl ?? undefined) &&
|
|
95
|
+
(a.provider ?? undefined) === (b.provider ?? undefined) &&
|
|
96
|
+
(a.providerKind ?? undefined) === (b.providerKind ?? undefined) &&
|
|
97
|
+
(a.maxTokens ?? undefined) === (b.maxTokens ?? undefined) &&
|
|
98
|
+
JSON.stringify(a.reasoning ?? null) === JSON.stringify(b.reasoning ?? null));
|
|
99
|
+
}
|
|
100
|
+
// Re-export the config hot-reload patch builder from here so the protocol
|
|
101
|
+
// server (and tests) can import it alongside Engine without reaching into the
|
|
102
|
+
// settings/ subtree directly. The implementation lives in settings/ to keep
|
|
103
|
+
// engine.ts from growing and to sit next to personalizationFrom it composes.
|
|
104
|
+
export { diskDefaultsFrom } from "../settings/disk-defaults.js";
|
|
41
105
|
/**
|
|
42
106
|
* Resolve the LLM config for a spawned child Engine.
|
|
43
|
-
* - `modelKey` set + present in pool → that model's config (
|
|
107
|
+
* - `modelKey` set + present in pool → that model's config (pure entry-derived
|
|
108
|
+
* identity; the parent's llm is NOT consulted).
|
|
44
109
|
* - otherwise (no key, no pool, or key miss) → the parent's llm unchanged.
|
|
45
110
|
* Key miss is a soft fallback, NOT an error: a stale agent definition must not
|
|
46
111
|
* crash the spawn.
|
|
112
|
+
*
|
|
113
|
+
* ClientDefaults (temperature/timeout/etc.) are inherited from the parent
|
|
114
|
+
* Engine directly via EngineConfig.clientDefaults — they do not flow through
|
|
115
|
+
* this helper because they're not part of LLMConfig anymore.
|
|
47
116
|
*/
|
|
48
117
|
export function resolveChildLlm(modelKey, pool, parentLlm) {
|
|
49
118
|
if (modelKey && pool?.has(modelKey)) {
|
|
50
|
-
const resolved = pool.resolveLLMConfig(modelKey
|
|
119
|
+
const resolved = pool.resolveLLMConfig(modelKey);
|
|
51
120
|
if (resolved)
|
|
52
121
|
return resolved;
|
|
53
122
|
}
|
|
54
123
|
return parentLlm;
|
|
55
124
|
}
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Load reusable sub-agent role definitions, merging:
|
|
127
|
+
* 1. project-level <cwd>/.code-shell/agents/*.md (ships built-ins)
|
|
128
|
+
* 2. user-level ~/.code-shell/agents/*.md (user wins on name)
|
|
129
|
+
* Names in `disabledAgents` are filtered out so the LLM never sees them.
|
|
130
|
+
*/
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the working directory for a run. Precedence:
|
|
133
|
+
* options.cwd > resumed session's state.cwd > config.cwd > process.cwd()
|
|
134
|
+
*
|
|
135
|
+
* The session-cwd tier is what stops a project-bound session from being
|
|
136
|
+
* resumed against the wrong directory: when a host omits options.cwd (e.g. its
|
|
137
|
+
* sidebar repo selection drifted to null), the session's own recorded cwd is
|
|
138
|
+
* recovered so the engine still loads THAT project's agents/settings/memory,
|
|
139
|
+
* not whatever process.cwd() happens to be. Pure so the precedence is testable
|
|
140
|
+
* without standing up an Engine.
|
|
141
|
+
*/
|
|
142
|
+
export function resolveRunCwd(args) {
|
|
143
|
+
return args.optionCwd ?? args.sessionCwd ?? args.configCwd ?? args.processCwd;
|
|
144
|
+
}
|
|
145
|
+
export function loadAgentDefinitionsForCwd(cwd, disabledAgents = [], disabledPlugins = []) {
|
|
146
|
+
// userHome() (not raw homedir(), which bun caches at process start and never
|
|
147
|
+
// re-reads) so the user-agents dir honors a test's process.env.HOME override
|
|
148
|
+
// and stays consistent with the rest of the codebase's home resolution.
|
|
149
|
+
const home = userHome();
|
|
150
|
+
// Increasing priority; loadFromDirs is last-dir-wins. ORDER ENCODES POLICY:
|
|
151
|
+
// user (cross-project personal default, lowest) → plugins (reusable baseline)
|
|
152
|
+
// → project (highest). A repo's in-tree agent therefore overrides a same-named
|
|
153
|
+
// user agent. This REVERSES the previous user>project behavior (spec §7.2);
|
|
154
|
+
// the descriptor's shadowedSources surfaces the override so the UI can warn.
|
|
155
|
+
return AgentDefinitionRegistry.loadFromDirs([
|
|
156
|
+
{ dir: `${home}/.code-shell/agents`, source: "user" },
|
|
157
|
+
...pluginAgentDirs(disabledPlugins),
|
|
158
|
+
// No project context (no-project bucket): cwd is "". Skip the project
|
|
159
|
+
// source rather than synthesizing "/.code-shell/agents" at the FS root,
|
|
160
|
+
// which silently resolves to nothing and drops every project-level
|
|
161
|
+
// (built-in) agent from the list.
|
|
162
|
+
...(cwd ? [{ dir: `${cwd}/.code-shell/agents`, source: "project" }] : []),
|
|
163
|
+
], disabledAgents);
|
|
164
|
+
}
|
|
165
|
+
const NESTED_AGENT_TOOLS = ["Agent", "AgentStatus", "AgentCancel", "AgentSendInput"];
|
|
166
|
+
/**
|
|
167
|
+
* #7: apply a project's per-turn builtin capability override to a tool list.
|
|
168
|
+
* A builtin marked `off` for the current cwd is HIDDEN from the turn's tool
|
|
169
|
+
* list (matching how skills/plugins/agents `off` apply mid-session). `on` /
|
|
170
|
+
* `inherit` / absent keep the tool — we can't re-add a tool the ctor-frozen
|
|
171
|
+
* registry omitted, but `on` for a tool already present is a no-op. Pure +
|
|
172
|
+
* exported so it's unit-testable without a full run() turn.
|
|
173
|
+
*/
|
|
174
|
+
export function applyBuiltinOverrideVisibility(tools, override) {
|
|
175
|
+
if (!override)
|
|
176
|
+
return tools;
|
|
177
|
+
return tools.filter((t) => override[t.name] !== "off");
|
|
59
178
|
}
|
|
60
|
-
const NESTED_AGENT_TOOLS = ["Agent", "AgentStatus", "AgentCancel"];
|
|
61
179
|
/**
|
|
62
180
|
* Compute a child Engine's tool scope.
|
|
63
181
|
* - `allowlist` set → child enabled = allowlist minus nested-agent tools
|
|
@@ -78,16 +196,38 @@ export function resolveChildToolScope(allowlist, parentDisabled, parentEnabled)
|
|
|
78
196
|
}
|
|
79
197
|
export class Engine {
|
|
80
198
|
config;
|
|
199
|
+
// Resolved per-session preset. Set in the ctor; re-resolved by
|
|
200
|
+
// refreshRuntimeConfig on a preset hot-reload so the next-turn PromptComposer
|
|
201
|
+
// picks up the new preset's system prompt / behavior (#2). NOT readonly for
|
|
202
|
+
// that reason. NOTE: the toolRegistry's builtin tool SET is still ctor-frozen
|
|
203
|
+
// and is NOT rebuilt on reload — a preset change that alters the builtin tool
|
|
204
|
+
// set only takes effect on session restart (logged in refreshRuntimeConfig).
|
|
81
205
|
preset;
|
|
82
206
|
toolRegistry;
|
|
83
207
|
hooks;
|
|
84
208
|
sessionManager;
|
|
85
209
|
mcpManager;
|
|
86
210
|
modelPool;
|
|
211
|
+
/**
|
|
212
|
+
* Handles for the settings-sourced hook handlers registered by
|
|
213
|
+
* registerSettingsHooks(), so reloadHooks() can unregister exactly those
|
|
214
|
+
* (and nothing else — plugin hooks, goal/builtin hooks are untouched) before
|
|
215
|
+
* re-registering from fresh settings. Without this, a reload would
|
|
216
|
+
* accumulate duplicate settings-hook handlers that all fire per event.
|
|
217
|
+
*/
|
|
218
|
+
settingsHookHandles = [];
|
|
219
|
+
/**
|
|
220
|
+
* Highest config-reload version applied so far. refreshRuntimeConfig drops
|
|
221
|
+
* any payload whose version is <= this, so out-of-order reload deliveries
|
|
222
|
+
* (multiple quick settings saves) can't let an older config clobber a newer
|
|
223
|
+
* one.
|
|
224
|
+
*/
|
|
225
|
+
lastAppliedConfigVersion = 0;
|
|
87
226
|
/** Memoized sub-agent role registry, keyed by the cwd it was loaded from. */
|
|
88
227
|
agentDefsCache;
|
|
89
228
|
/** Shared resources supplied at construction (adapter pattern — null when self-constructed). */
|
|
90
229
|
runtime;
|
|
230
|
+
sandboxCache = new Map();
|
|
91
231
|
/** Active permission mode for this Engine instance. */
|
|
92
232
|
permissionMode;
|
|
93
233
|
/** True when permissionMode === "plan". */
|
|
@@ -96,6 +236,13 @@ export class Engine {
|
|
|
96
236
|
// don't re-read 6+ JSON files on every /model, /login, etc. The manager
|
|
97
237
|
// handles its own cache invalidation in saveUserSetting().
|
|
98
238
|
settingsManager;
|
|
239
|
+
/**
|
|
240
|
+
* Cached auxiliary-task LLM client, keyed by the models[].key it was built
|
|
241
|
+
* from. Background calls (memory extraction, auto-dream) reuse it across
|
|
242
|
+
* runs so we don't redo the provider handshake every session. Invalidated
|
|
243
|
+
* implicitly: a changed auxModelKey produces a different cache key.
|
|
244
|
+
*/
|
|
245
|
+
auxClientCache;
|
|
99
246
|
// Live state from the current/most-recent run, retained for /compact and
|
|
100
247
|
// for live-mutating PermissionClassifier on permission-mode switch.
|
|
101
248
|
lastContextManager;
|
|
@@ -118,7 +265,39 @@ export class Engine {
|
|
|
118
265
|
* LLM response arrives.
|
|
119
266
|
*/
|
|
120
267
|
ctxOverheadBySid = new Map();
|
|
268
|
+
/**
|
|
269
|
+
* Step-gap steering queue (per sessionId, in-memory). Host pushes user
|
|
270
|
+
* messages here via enqueueSteer while a run is in flight; the turn loop
|
|
271
|
+
* drains it at each step boundary and splices them into the next LLM request
|
|
272
|
+
* WITHOUT aborting (the 不打断 path, vs cancel+resend). Pure memory, forgotten
|
|
273
|
+
* on process exit — same model as the credential session-allow set, so
|
|
274
|
+
* multiple Engines don't interfere and it stays cleanly extractable.
|
|
275
|
+
*/
|
|
276
|
+
steerQueueBySid = new Map();
|
|
121
277
|
activePermission;
|
|
278
|
+
/**
|
|
279
|
+
* The TurnLoop of the in-flight run(), exposed so extendGoalRun() can bump a
|
|
280
|
+
* running goal's turn/budget ceilings mid-run (TODO 3.1). Null when idle.
|
|
281
|
+
*/
|
|
282
|
+
activeTurnLoop = null;
|
|
283
|
+
/**
|
|
284
|
+
* The goal-stop hook of the in-flight goal run, exposed so clearGoal() can
|
|
285
|
+
* unregister it mid-run (the closure holds the now-cleared goal and would
|
|
286
|
+
* otherwise keep re-blocking the stop). Null when no goal run is active.
|
|
287
|
+
*/
|
|
288
|
+
activeGoalHook = null;
|
|
289
|
+
/**
|
|
290
|
+
* The in-flight run's session bundle, held so clearGoal() can wipe the goal
|
|
291
|
+
* on the SAME instance the run loop is persisting each turn — not a fresh
|
|
292
|
+
* detached copy from resume(). Without this, a mid-run 清除 clears disk, but
|
|
293
|
+
* the still-running loop's next saveState(bundle.state) resurrects the goal
|
|
294
|
+
* (bundle.state.activeGoal was never dropped). A never-completing goal run
|
|
295
|
+
* (judge keeps returning not_met → continueSession) stays live for a long
|
|
296
|
+
* time, so this write-back race is the norm, not an edge case, for such runs.
|
|
297
|
+
* Single-valued like activeTurnLoop — one top-level run per engine at a time.
|
|
298
|
+
* Null when idle; set at run start, cleared in run's finally.
|
|
299
|
+
*/
|
|
300
|
+
activeRunSession = null;
|
|
122
301
|
/** Public accessor so UI/clients can read the resolved per-model window. */
|
|
123
302
|
get maxContextTokens() {
|
|
124
303
|
return this.resolveMaxContextTokens();
|
|
@@ -127,6 +306,27 @@ export class Engine {
|
|
|
127
306
|
const modelEntry = this.modelPool.get();
|
|
128
307
|
return modelEntry?.maxContextTokens ?? this.config.maxContextTokens ?? 200_000;
|
|
129
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Compaction thresholds from settings.context, clamped so they keep the
|
|
311
|
+
* required ordering floor < compact < summarize even if the user configures
|
|
312
|
+
* conflicting values (e.g. summarize below compact). Falls back to the
|
|
313
|
+
* ContextManager defaults when a field is absent.
|
|
314
|
+
*/
|
|
315
|
+
resolveContextRatios() {
|
|
316
|
+
let ctx;
|
|
317
|
+
try {
|
|
318
|
+
// Read from SettingsManager (shared across all hosts) rather than
|
|
319
|
+
// EngineConfig, mirroring readMemoriesConfig — avoids per-host wiring
|
|
320
|
+
// drift (see memory: personalization host wiring).
|
|
321
|
+
ctx = this.getSettingsManager().get().context;
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return {};
|
|
325
|
+
}
|
|
326
|
+
if (!ctx)
|
|
327
|
+
return {};
|
|
328
|
+
return clampContextRatiosImpl(ctx);
|
|
329
|
+
}
|
|
130
330
|
/**
|
|
131
331
|
* Emit a lifecycle hook with isSubAgent auto-merged into data so handlers
|
|
132
332
|
* can skip noisy injections for spawned children. All Engine-side hook
|
|
@@ -158,13 +358,65 @@ export class Engine {
|
|
|
158
358
|
}
|
|
159
359
|
const entries = settings.hooks ?? [];
|
|
160
360
|
for (const entry of entries) {
|
|
161
|
-
|
|
361
|
+
// Soft off-switch (settings hooks UI): the entry stays in the file but
|
|
362
|
+
// doesn't register. reloadHooks() re-runs this, so toggling is hot.
|
|
363
|
+
if (entry.disabled === true)
|
|
364
|
+
continue;
|
|
365
|
+
const event = entry.event;
|
|
366
|
+
const handler = async (ctx) => {
|
|
162
367
|
if (!shellHookMatches(entry, ctx))
|
|
163
368
|
return {};
|
|
164
369
|
return runShellHook(entry, ctx);
|
|
165
|
-
}
|
|
370
|
+
};
|
|
371
|
+
this.hooks.register(event, handler, 50, `shell:${entry.event}:${entry.command.slice(0, 32)}`);
|
|
372
|
+
// Track the (event, handler) so reloadHooks() can unregister exactly
|
|
373
|
+
// these settings-sourced handlers without touching plugin/goal/code hooks.
|
|
374
|
+
this.settingsHookHandles.push({ event, handler });
|
|
166
375
|
}
|
|
167
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* Re-apply settings.hooks onto the live HookRegistry after a settings
|
|
379
|
+
* change (config hot-reload layer 2). Surgical: removes ONLY the
|
|
380
|
+
* settings-sourced handlers this Engine previously registered (tracked in
|
|
381
|
+
* settingsHookHandles by identity) and re-runs registerSettingsHooks() from
|
|
382
|
+
* fresh disk settings. Plugin hooks (registered once at construction at
|
|
383
|
+
* priority 80) and goal/builtin/SDK-config hooks are never touched.
|
|
384
|
+
*
|
|
385
|
+
* The SettingsManager cache is invalidated first so the re-read reflects the
|
|
386
|
+
* latest settings.json on disk (mirrors freshSettings()'s load() semantics).
|
|
387
|
+
* Sub-agents never register settings hooks, so this is a no-op for them.
|
|
388
|
+
*/
|
|
389
|
+
reloadHooks() {
|
|
390
|
+
if (this.config.isSubAgent === true)
|
|
391
|
+
return;
|
|
392
|
+
// Drop the previously-registered settings handlers by identity.
|
|
393
|
+
for (const { event, handler } of this.settingsHookHandles) {
|
|
394
|
+
this.hooks.unregister(event, handler);
|
|
395
|
+
}
|
|
396
|
+
this.settingsHookHandles = [];
|
|
397
|
+
// Also drop & re-load plugin hooks. Plugin hooks are registered under
|
|
398
|
+
// `plugin:<name>:<event>` names; without this, disabling a plugin
|
|
399
|
+
// mid-session left its hooks firing until the next new session (asymmetric
|
|
400
|
+
// with settings-hook hot-reload). Re-reading readDisabledLists means a
|
|
401
|
+
// now-disabled plugin's hooks are simply not re-registered.
|
|
402
|
+
this.hooks.removeByNamePrefix("plugin:");
|
|
403
|
+
try {
|
|
404
|
+
const { disabledPlugins, disabledPluginHooks } = this.readDisabledLists();
|
|
405
|
+
loadPluginHooks(this.hooks, disabledPlugins, disabledPluginHooks);
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
// best-effort — a plugin-load failure must not break settings reload below
|
|
409
|
+
}
|
|
410
|
+
// Force the next get() to re-read disk so reloaded hooks reflect the
|
|
411
|
+
// newest settings.json, not a stale merged cache.
|
|
412
|
+
try {
|
|
413
|
+
this.getSettingsManager().invalidate();
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
// best-effort; registerSettingsHooks below tolerates read failures
|
|
417
|
+
}
|
|
418
|
+
this.registerSettingsHooks();
|
|
419
|
+
}
|
|
168
420
|
constructor(config) {
|
|
169
421
|
this.config = config;
|
|
170
422
|
// Wire shared runtime (adapter pattern — null when self-constructing).
|
|
@@ -173,11 +425,26 @@ export class Engine {
|
|
|
173
425
|
this.permissionMode = config.permissionMode ?? "acceptEdits";
|
|
174
426
|
this.planMode = this.permissionMode === "plan";
|
|
175
427
|
this.preset = resolveAgentPreset(config.preset);
|
|
428
|
+
// Fold the project's capabilityOverrides.builtin overlay over the global
|
|
429
|
+
// enabled/disabled builtin lists so a project can force-enable a
|
|
430
|
+
// globally-disabled builtin tool or force-disable a globally-enabled one
|
|
431
|
+
// (tri-state). Mirrors readDisabledLists for skills/plugins/agents; no cwd
|
|
432
|
+
// / no overlay → the config lists pass through unchanged (zero regression).
|
|
433
|
+
//
|
|
434
|
+
// #7: this builds the ctor-FROZEN builtin tool SET in the registry — a
|
|
435
|
+
// mid-session project override can't rebuild it. To make a builtin `off`
|
|
436
|
+
// toggle apply mid-session, run()'s per-turn tool-list assembly re-reads
|
|
437
|
+
// readBuiltinOverride(cwd) and HIDES `off` builtins from the turn's tool
|
|
438
|
+
// list (see the allToolDefs filter). `on` here can force-enable a
|
|
439
|
+
// globally-disabled builtin INTO the frozen set at construction; the
|
|
440
|
+
// per-turn path can only hide, not add, so a freshly-`on`'d builtin not in
|
|
441
|
+
// the set needs a session restart to appear.
|
|
442
|
+
const builtinLists = effectiveBuiltinLists(config.enabledBuiltinTools ?? [], config.disabledBuiltinTools ?? [], this.readBuiltinOverride(config.cwd));
|
|
176
443
|
this.toolRegistry = config.runtime?.toolRegistry ?? new ToolRegistry({
|
|
177
444
|
builtinTools: resolveBuiltinToolNames({
|
|
178
445
|
preset: this.preset.name,
|
|
179
|
-
enabledBuiltinTools:
|
|
180
|
-
disabledBuiltinTools:
|
|
446
|
+
enabledBuiltinTools: builtinLists.enabledBuiltinTools,
|
|
447
|
+
disabledBuiltinTools: builtinLists.disabledBuiltinTools,
|
|
181
448
|
}),
|
|
182
449
|
});
|
|
183
450
|
this.hooks = new HookRegistry();
|
|
@@ -189,7 +456,15 @@ export class Engine {
|
|
|
189
456
|
// overhead multiplied across sub-agents outweighs the value, and
|
|
190
457
|
// dispatched tasks should run with minimal surface area.
|
|
191
458
|
if (config.isSubAgent !== true) {
|
|
192
|
-
|
|
459
|
+
// disabledPlugins suppresses a plugin's hooks too (not just its
|
|
460
|
+
// Skill-tool entries) — see loadPluginHooks. readDisabledLists reads
|
|
461
|
+
// the same settings the prompt composer / tool context use.
|
|
462
|
+
// disabledPluginHooks is the per-hook overlay
|
|
463
|
+
// (capabilityOverrides.pluginHooks); applied at construction, so a
|
|
464
|
+
// toggle takes effect for NEW sessions (same semantics as
|
|
465
|
+
// disabledPlugins itself).
|
|
466
|
+
const { disabledPlugins, disabledPluginHooks } = this.readDisabledLists();
|
|
467
|
+
loadPluginHooks(this.hooks, disabledPlugins, disabledPluginHooks);
|
|
193
468
|
}
|
|
194
469
|
// settings.hooks → shell-command wrappers. Chain order:
|
|
195
470
|
// plugin (80) → shell (50) → code (default 0).
|
|
@@ -205,7 +480,7 @@ export class Engine {
|
|
|
205
480
|
}
|
|
206
481
|
}
|
|
207
482
|
/**
|
|
208
|
-
* Load
|
|
483
|
+
* Load modelConnections[] from settings into the active ModelPool and
|
|
209
484
|
* resync this.config.llm with the matching entry. Called from the ctor and
|
|
210
485
|
* from reloadModelPool() (e.g. after onboarding writes new entries to disk).
|
|
211
486
|
*/
|
|
@@ -214,67 +489,82 @@ export class Engine {
|
|
|
214
489
|
const sm = this.getSettingsManager();
|
|
215
490
|
sm.invalidate();
|
|
216
491
|
const settings = sm.get();
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
providerKey: m.providerKey,
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
// Build catalog from settings.providers[] and attach to the pool
|
|
232
|
-
// so model entries can resolve baseUrl/apiKey from their provider.
|
|
233
|
-
if (settings.providers?.length) {
|
|
234
|
-
this.modelPool.setProviderCatalog(new ProviderCatalog(settings.providers));
|
|
492
|
+
// Unified model catalog (统一模型接入方案 §6): register text
|
|
493
|
+
// connections from settings.modelConnections[] into the pool — the
|
|
494
|
+
// catalog-driven instance store is the sole source of model selection.
|
|
495
|
+
// A connection's instance id becomes its pool key. See
|
|
496
|
+
// docs/superpowers/specs/2026-06-15-unified-model-catalog-design.md.
|
|
497
|
+
const connections = settings.modelConnections;
|
|
498
|
+
if (Array.isArray(connections) && connections.length) {
|
|
499
|
+
const catalog = getMergedCatalog();
|
|
500
|
+
const credentials = settings.credentials;
|
|
501
|
+
for (const entry of modelEntriesFromConnections(connections, (Array.isArray(credentials) ? credentials : []), catalog)) {
|
|
502
|
+
this.modelPool.register(entry);
|
|
235
503
|
}
|
|
504
|
+
}
|
|
505
|
+
const hasConnections = Array.isArray(connections) && connections.length > 0;
|
|
506
|
+
if (hasConnections) {
|
|
236
507
|
this.modelPool.setCacheDir(defaultCacheDir());
|
|
237
508
|
this.modelPool.reloadCachedContextWindows();
|
|
238
|
-
// Resolve active entry.
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
// *user's* current UI model selection and must not clobber a child's
|
|
248
|
-
// routed model — without this guard a role's `model: flash` is silently
|
|
249
|
-
// overridden back to whatever the user has active in the foreground.
|
|
509
|
+
// Resolve the active entry from settings.defaults.text, then switch the
|
|
510
|
+
// pool and write the resolved entry's credentials into config.llm, so the
|
|
511
|
+
// first run() uses the right endpoint instead of whatever env-derived
|
|
512
|
+
// fallback repl.ts seeded earlier.
|
|
513
|
+
// Sub-agents skip this resync: their llm is chosen by the parent's
|
|
514
|
+
// resolveChildLlm (per-role model routing). defaults.text is the *user's*
|
|
515
|
+
// current UI model selection and must not clobber a child's routed model —
|
|
516
|
+
// without this guard a role's `model: flash` is silently overridden back
|
|
517
|
+
// to whatever the user has active in the foreground.
|
|
250
518
|
if (this.config.isSubAgent !== true) {
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
519
|
+
const defaultText = settings.defaults?.text;
|
|
520
|
+
// 统一 catalog only:defaults.text 命中则用;否则回退首个已注册连接,
|
|
521
|
+
// 避免选未配置模型时静默沿用空种子(旧 bug:抛误导性 OPENAI_API_KEY missing)。
|
|
522
|
+
let matchKey;
|
|
523
|
+
if (defaultText && this.modelPool.list().some((e) => e.key === defaultText)) {
|
|
524
|
+
matchKey = defaultText;
|
|
255
525
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
// OpenRouter stores entries as "provider/model-name"; the top-level
|
|
259
|
-
// settings.model.name is just "model-name". Match either form.
|
|
260
|
-
match = settings.models.find((m) => m.model === currentModel ||
|
|
261
|
-
(currentModel && m.model?.endsWith(`/${currentModel}`)));
|
|
526
|
+
else {
|
|
527
|
+
matchKey = this.modelPool.list()[0]?.key;
|
|
262
528
|
}
|
|
263
|
-
if (
|
|
264
|
-
const entry = this.modelPool.switch(
|
|
529
|
+
if (matchKey) {
|
|
530
|
+
const entry = this.modelPool.switch(matchKey);
|
|
265
531
|
this.config = {
|
|
266
532
|
...this.config,
|
|
267
|
-
llm: this.modelPool.toLLMConfig(entry
|
|
533
|
+
llm: this.modelPool.toLLMConfig(entry),
|
|
268
534
|
};
|
|
269
535
|
}
|
|
270
536
|
}
|
|
271
537
|
}
|
|
272
538
|
else if (this.config.llm.apiKey) {
|
|
273
|
-
// Auto-populate pool from the configured API key when
|
|
274
|
-
// This lets users who only
|
|
275
|
-
// use /model to switch between the provider's
|
|
539
|
+
// Auto-populate pool from the configured API key when no
|
|
540
|
+
// modelConnections[] are configured. This lets users who only have an
|
|
541
|
+
// env/seed API key still use /model to switch between the provider's
|
|
542
|
+
// available models.
|
|
276
543
|
this.autoPopulatePool(this.config.llm.apiKey, this.config.llm.baseUrl);
|
|
277
544
|
}
|
|
545
|
+
// Carry image-attachment settings + sampling temperature into
|
|
546
|
+
// clientDefaults. Both are cross-model knobs — they apply to whatever
|
|
547
|
+
// model is currently active and survive hot-switches. (Pre-cleanup
|
|
548
|
+
// these were merged into llm.imageDetail / llm.temperature; that path
|
|
549
|
+
// is gone because hot-switching now rotates llm wholesale.)
|
|
550
|
+
const imageSettings = settings.images;
|
|
551
|
+
const modelBlock = settings.model;
|
|
552
|
+
const nextDefaults = { ...(this.config.clientDefaults ?? {}) };
|
|
553
|
+
let defaultsChanged = false;
|
|
554
|
+
// Migrate legacy "original" → "high" (raw settings may bypass schema).
|
|
555
|
+
const detail = imageSettings?.detail === "original" ? "high" : imageSettings?.detail;
|
|
556
|
+
if (detail && nextDefaults.imageDetail !== detail) {
|
|
557
|
+
nextDefaults.imageDetail = detail;
|
|
558
|
+
defaultsChanged = true;
|
|
559
|
+
}
|
|
560
|
+
if (typeof modelBlock?.temperature === "number" &&
|
|
561
|
+
nextDefaults.temperature !== modelBlock.temperature) {
|
|
562
|
+
nextDefaults.temperature = modelBlock.temperature;
|
|
563
|
+
defaultsChanged = true;
|
|
564
|
+
}
|
|
565
|
+
if (defaultsChanged) {
|
|
566
|
+
this.config = { ...this.config, clientDefaults: nextDefaults };
|
|
567
|
+
}
|
|
278
568
|
}
|
|
279
569
|
catch {
|
|
280
570
|
// Settings not available — pool stays empty
|
|
@@ -282,7 +572,7 @@ export class Engine {
|
|
|
282
572
|
}
|
|
283
573
|
/**
|
|
284
574
|
* Re-read settings and refresh the model pool. Used after onboarding /login
|
|
285
|
-
* writes new
|
|
575
|
+
* writes new modelConnections[] to disk so the running engine picks them
|
|
286
576
|
* up without a process restart. Existing pool entries are kept (re-registering
|
|
287
577
|
* the same key overwrites them), so callers don't need to clear first.
|
|
288
578
|
*/
|
|
@@ -296,7 +586,7 @@ export class Engine {
|
|
|
296
586
|
}
|
|
297
587
|
}
|
|
298
588
|
/**
|
|
299
|
-
* Auto-populate the model pool when
|
|
589
|
+
* Auto-populate the model pool when no modelConnections[] are configured but
|
|
300
590
|
* the user has configured an API key. Detects the provider from the
|
|
301
591
|
* key prefix / baseUrl and registers all its known models.
|
|
302
592
|
*/
|
|
@@ -315,7 +605,7 @@ export class Engine {
|
|
|
315
605
|
const entry = this.modelPool.switch(defaultEntry.key);
|
|
316
606
|
this.config = {
|
|
317
607
|
...this.config,
|
|
318
|
-
llm: this.modelPool.toLLMConfig(entry
|
|
608
|
+
llm: this.modelPool.toLLMConfig(entry),
|
|
319
609
|
};
|
|
320
610
|
}
|
|
321
611
|
}
|
|
@@ -334,11 +624,100 @@ export class Engine {
|
|
|
334
624
|
setAskUser(fn) {
|
|
335
625
|
this.config.askUser = fn;
|
|
336
626
|
}
|
|
627
|
+
/**
|
|
628
|
+
* Inject the browser automation bridge after construction (same chicken-and-egg
|
|
629
|
+
* as setAskUser: the desktop host builds the bridge — which drives a webview —
|
|
630
|
+
* after the Engine exists). Undefined → the browser_* tools degrade with a
|
|
631
|
+
* clear "no browser panel" error.
|
|
632
|
+
*/
|
|
633
|
+
setBrowserBridge(bridge) {
|
|
634
|
+
this.config.browserBridge = bridge;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Queue a user message to be spliced into the in-flight run for `sessionId`
|
|
638
|
+
* at the next turn-loop step boundary — the 不打断 steering path (vs cancel +
|
|
639
|
+
* resend). General-purpose: any host path (UI 引导, future agent coordination,
|
|
640
|
+
* external triggers) can call it. If no run is active for the session the
|
|
641
|
+
* message simply waits in the queue and is consumed when that session next
|
|
642
|
+
* runs (rare race; host normally only steers while busy). No-op on blank text.
|
|
643
|
+
*
|
|
644
|
+
* `id` is the host's stable queue-entry id. It rides through to the
|
|
645
|
+
* `steer_injected` event (so the host can match the injected bubble back to
|
|
646
|
+
* the queued draft) and is the handle `unsteer` uses to revoke a still-pending
|
|
647
|
+
* entry. A blank id is tolerated but means the entry can't be revoked.
|
|
648
|
+
*/
|
|
649
|
+
enqueueSteer(sessionId, text, id = "") {
|
|
650
|
+
if (!sessionId)
|
|
651
|
+
return;
|
|
652
|
+
const q = this.steerQueueBySid.get(sessionId) ?? [];
|
|
653
|
+
const next = enqueueSteerItem(q, id || `steer-${q.length}`, text);
|
|
654
|
+
if (next === q)
|
|
655
|
+
return; // blank text dropped
|
|
656
|
+
this.steerQueueBySid.set(sessionId, next);
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Revoke a still-pending steer entry (the 撤回 path). Returns true if it was
|
|
660
|
+
* removed, false if it was already consumed by the turn loop (can't take it
|
|
661
|
+
* back — it has been spliced into the run).
|
|
662
|
+
*/
|
|
663
|
+
unsteer(sessionId, id) {
|
|
664
|
+
const q = this.steerQueueBySid.get(sessionId);
|
|
665
|
+
if (!q || q.length === 0)
|
|
666
|
+
return false;
|
|
667
|
+
const { list, removed } = removeSteerItem(q, id);
|
|
668
|
+
if (removed)
|
|
669
|
+
this.steerQueueBySid.set(sessionId, list);
|
|
670
|
+
return removed;
|
|
671
|
+
}
|
|
672
|
+
/** Drain + clear the steer queue for a session (turn loop consumes per step). */
|
|
673
|
+
consumeSteer(sessionId) {
|
|
674
|
+
const q = this.steerQueueBySid.get(sessionId);
|
|
675
|
+
if (!q || q.length === 0)
|
|
676
|
+
return [];
|
|
677
|
+
const { drained, rest } = consumeSteerItems(q);
|
|
678
|
+
this.steerQueueBySid.set(sessionId, rest);
|
|
679
|
+
return drained;
|
|
680
|
+
}
|
|
681
|
+
/** Wire the cookie→browser injection callback (InjectCredential tool). Same
|
|
682
|
+
* post-construction injection model as setBrowserBridge. */
|
|
683
|
+
setInjectCredential(fn) {
|
|
684
|
+
this.config.injectCredentialToBrowser = fn;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Whether this engine runs unattended (no interactive human). Used by the
|
|
688
|
+
* in-process AgentServer to decide whether to wire an interactive askUser.
|
|
689
|
+
*/
|
|
690
|
+
isHeadless() {
|
|
691
|
+
return this.config.headless === true;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Probe whether a session already exists on disk (its state/transcript dir is
|
|
695
|
+
* present). Used by the protocol server to distinguish "resume an existing
|
|
696
|
+
* session" from "silently create a fresh empty one" — e.g. a cron resume job
|
|
697
|
+
* whose target session the user deleted must fail loudly, not run its prompt
|
|
698
|
+
* against a blank session. A stat probe, not a load.
|
|
699
|
+
*/
|
|
700
|
+
sessionExistsOnDisk(sessionId) {
|
|
701
|
+
return this.sessionManager.exists(sessionId);
|
|
702
|
+
}
|
|
337
703
|
/**
|
|
338
704
|
* Run a task from start to finish.
|
|
339
705
|
*/
|
|
340
706
|
async run(task, options) {
|
|
341
|
-
|
|
707
|
+
// When the caller omits cwd but is resuming an existing session, recover
|
|
708
|
+
// that session's bound cwd from disk so a project-bound session keeps
|
|
709
|
+
// loading its own agents/settings/memory even if the host's UI repo
|
|
710
|
+
// selection has drifted to null. Only probe on omission — an explicit cwd
|
|
711
|
+
// always wins, and a fresh session has nothing to recover.
|
|
712
|
+
const sessionCwd = options?.cwd === undefined && options?.sessionId
|
|
713
|
+
? this.sessionManager.readCwd(options.sessionId)
|
|
714
|
+
: undefined;
|
|
715
|
+
const cwd = resolveRunCwd({
|
|
716
|
+
optionCwd: options?.cwd,
|
|
717
|
+
sessionCwd,
|
|
718
|
+
configCwd: this.config.cwd,
|
|
719
|
+
processCwd: process.cwd(),
|
|
720
|
+
});
|
|
342
721
|
// Wrap the caller's onStream so we can intercept `task_update`
|
|
343
722
|
// events emitted by TodoWrite and keep an in-engine snapshot.
|
|
344
723
|
// TaskGuard reads this snapshot at turn end to decide whether to
|
|
@@ -352,6 +731,16 @@ export class Engine {
|
|
|
352
731
|
if (event.type === "task_update") {
|
|
353
732
|
latestTodos = event.tasks;
|
|
354
733
|
}
|
|
734
|
+
// Persist goal progress so replay/history shows how many rounds the
|
|
735
|
+
// goal ran. Display-only — toMessages() ignores this type, so it never
|
|
736
|
+
// re-enters the LLM context.
|
|
737
|
+
if (event.type === "goal_progress") {
|
|
738
|
+
session.transcript.append("goal_progress", {
|
|
739
|
+
status: event.status,
|
|
740
|
+
round: event.round,
|
|
741
|
+
...(event.gaps ? { gaps: event.gaps } : {}),
|
|
742
|
+
});
|
|
743
|
+
}
|
|
355
744
|
userOnStream?.(event);
|
|
356
745
|
};
|
|
357
746
|
if (options)
|
|
@@ -398,6 +787,67 @@ export class Engine {
|
|
|
398
787
|
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
399
788
|
};
|
|
400
789
|
}
|
|
790
|
+
// Size gate. Hosts (desktop renderer, TUI) are expected to
|
|
791
|
+
// pre-compress to IMAGE_TARGETS — if they didn't, we fail the turn
|
|
792
|
+
// fast with a clear message instead of letting the OpenAI client
|
|
793
|
+
// grind through three 16-second "Connection error" retries on a
|
|
794
|
+
// 4 MB body. See `image-policy.ts` for the rationale and limits.
|
|
795
|
+
let verdict = enforceImagePolicy(parsedTask.images);
|
|
796
|
+
if (!verdict.ok && verdict.code === "image_too_large") {
|
|
797
|
+
// One image blew the per-image cap. Try the engine-side
|
|
798
|
+
// compressor (jimp-backed when installed; no-op otherwise) so
|
|
799
|
+
// TUI / MCP paths that lack a host-side resize don't fail
|
|
800
|
+
// outright on a screenshot they could have rescaled. The
|
|
801
|
+
// re-check below is what decides whether we proceed.
|
|
802
|
+
const compressed = await tryCompressImages(parsedTask.images);
|
|
803
|
+
if (compressed.anyCompressed) {
|
|
804
|
+
parsedTask.images = compressed.images;
|
|
805
|
+
logger.info("engine.run.image_compressed", {
|
|
806
|
+
before: verdict.offender?.bytes,
|
|
807
|
+
after: compressed.images.reduce((s, i) => s + byteLengthFromBase64(i.base64), 0),
|
|
808
|
+
});
|
|
809
|
+
verdict = enforceImagePolicy(parsedTask.images);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
// After compression, anything still over the per-image cap is
|
|
813
|
+
// dropped with a textual placeholder instead of failing the
|
|
814
|
+
// turn (TODO-week.md #9e). The "5MB brick session" failure
|
|
815
|
+
// mode from Claude Code (research doc §A) was the case where a
|
|
816
|
+
// poisoned image entered history and every subsequent request
|
|
817
|
+
// re-sent it; placeholders keep history clean while letting
|
|
818
|
+
// the rest of the turn run.
|
|
819
|
+
if (!verdict.ok && verdict.code === "image_too_large") {
|
|
820
|
+
const drop = dropOversizedImages(parsedTask.images);
|
|
821
|
+
if (drop.droppedCount > 0) {
|
|
822
|
+
parsedTask.images = drop.kept;
|
|
823
|
+
parsedTask.hasImages = drop.kept.length > 0;
|
|
824
|
+
parsedTask.text = drop.placeholder + "\n\n" + parsedTask.text;
|
|
825
|
+
logger.warn("engine.run.image_dropped", {
|
|
826
|
+
droppedCount: drop.droppedCount,
|
|
827
|
+
keptCount: drop.kept.length,
|
|
828
|
+
});
|
|
829
|
+
verdict = enforceImagePolicy(parsedTask.images);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
if (!verdict.ok) {
|
|
833
|
+
// Cumulative / count caps can't be rescued by per-image
|
|
834
|
+
// dropping (well — too_many_images could trim by FIFO, but
|
|
835
|
+
// that's a bigger UX call than we want to make silently).
|
|
836
|
+
// Refuse the turn with the policy message.
|
|
837
|
+
logger.warn("engine.run.image_policy_failed", {
|
|
838
|
+
code: verdict.code,
|
|
839
|
+
imageCount: verdict.totals.imageCount,
|
|
840
|
+
totalBytes: verdict.totals.totalBytes,
|
|
841
|
+
offender: verdict.offender,
|
|
842
|
+
});
|
|
843
|
+
return {
|
|
844
|
+
text: `ERROR: ${verdict.message}`,
|
|
845
|
+
reason: "image_error",
|
|
846
|
+
sessionId: options?.sessionId ?? `image-policy-${verdict.code}`,
|
|
847
|
+
turnCount: 0,
|
|
848
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
849
|
+
};
|
|
850
|
+
}
|
|
401
851
|
}
|
|
402
852
|
// For downstream noise-detection + transcript persistence we want the
|
|
403
853
|
// *text* portion only — base64 bytes count as "noise" by the heuristic
|
|
@@ -429,6 +879,22 @@ export class Engine {
|
|
|
429
879
|
permissionMode: this.config.permissionMode ?? "acceptEdits",
|
|
430
880
|
}),
|
|
431
881
|
spawn: async (req) => {
|
|
882
|
+
// Anchor this sub-agent in the PARENT transcript at spawn time — before
|
|
883
|
+
// it runs, so it's recorded whether it later completes, is interrupted,
|
|
884
|
+
// or still runs. Replay reads these anchors to rebuild sub-agent cards
|
|
885
|
+
// from sessions/<agentId>/ (agentId === childSid); without it a
|
|
886
|
+
// backgrounded sub-agent leaves no parent-transcript trace and vanishes
|
|
887
|
+
// on reopen. Only on a fresh spawn (not a resume/continuation, which
|
|
888
|
+
// already has its anchor). Guarded so a transcript hiccup never breaks
|
|
889
|
+
// the spawn.
|
|
890
|
+
if (!req.resumeSessionId) {
|
|
891
|
+
try {
|
|
892
|
+
session.transcript.appendSubagent(req.agentId, undefined, req.description);
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
/* anchor is best-effort; never block the spawn */
|
|
896
|
+
}
|
|
897
|
+
}
|
|
432
898
|
// No nested agents. Strip Agent / AgentStatus / AgentCancel from the
|
|
433
899
|
// child's tool pool so the LLM can't spawn grandchildren — matches
|
|
434
900
|
// Claude Code's ALL_AGENT_DISALLOWED_TOOLS approach. Without this
|
|
@@ -439,7 +905,11 @@ export class Engine {
|
|
|
439
905
|
const { enabled: childEnabled, disabled: childDisabled } = resolveChildToolScope(req.toolAllowlist, this.config.disabledBuiltinTools, this.config.enabledBuiltinTools);
|
|
440
906
|
const childLlm = resolveChildLlm(req.model, this.modelPool, this.config.llm);
|
|
441
907
|
const child = new Engine({
|
|
442
|
-
llm:
|
|
908
|
+
llm: childLlm,
|
|
909
|
+
// Inherit parent's runtime knobs (temperature, image detail, timeouts)
|
|
910
|
+
// but cap sub-agent retries at 2 — they're short-lived and we'd
|
|
911
|
+
// rather surface failures than burn a 9 s exponential backoff loop.
|
|
912
|
+
clientDefaults: { ...(this.config.clientDefaults ?? {}), retryMaxAttempts: 2 },
|
|
443
913
|
cwd,
|
|
444
914
|
permissionMode: this.config.permissionMode,
|
|
445
915
|
preset: this.preset.name,
|
|
@@ -449,10 +919,15 @@ export class Engine {
|
|
|
449
919
|
appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt]
|
|
450
920
|
.filter(Boolean)
|
|
451
921
|
.join("\n\n") || undefined,
|
|
922
|
+
responseLanguage: this.config.responseLanguage,
|
|
923
|
+
userProfile: this.config.userProfile,
|
|
924
|
+
instructions: this.config.instructions,
|
|
452
925
|
maxTurns: req.maxTurns,
|
|
453
926
|
maxContextTokens: this.config.maxContextTokens ?? 200_000,
|
|
454
927
|
sessionStorageDir: this.config.sessionStorageDir,
|
|
455
928
|
headless: this.config.headless,
|
|
929
|
+
readOnlySession: req.readOnlySession,
|
|
930
|
+
skillAllowlist: req.skillAllowlist,
|
|
456
931
|
sandbox: this.config.sandbox,
|
|
457
932
|
// Subagents inherit the parent's scope: a child runs in the same
|
|
458
933
|
// cwd/session, so it should see the same config layers the parent did.
|
|
@@ -490,12 +965,41 @@ export class Engine {
|
|
|
490
965
|
// child.run() establishes its own runWithSid scope internally, so
|
|
491
966
|
// child log lines route to the child's sid and parent's ALS
|
|
492
967
|
// binding is unaffected when control returns here.
|
|
493
|
-
|
|
494
|
-
|
|
968
|
+
//
|
|
969
|
+
// agent_id === childSid: cold-start the child UNDER its agentId as the
|
|
970
|
+
// session id (run() shape (2): a fresh sid the host wants materialized),
|
|
971
|
+
// so the session persists at sessions/<agentId>/ and AgentSendInput can
|
|
972
|
+
// later resume it by agentId with no extra id→sid mapping. When
|
|
973
|
+
// resumeSessionId is set we resume that existing session instead —
|
|
974
|
+
// run() detects the on-disk session and replays its full transcript
|
|
975
|
+
// (the CC continuation model; see AgentSendInput).
|
|
976
|
+
const childSessionId = req.resumeSessionId ?? req.agentId;
|
|
977
|
+
const result = await child.run(req.prompt, {
|
|
978
|
+
signal: req.signal,
|
|
979
|
+
onStream: childStream,
|
|
980
|
+
sessionId: childSessionId,
|
|
981
|
+
});
|
|
982
|
+
return { text: result.text, sessionId: result.sessionId };
|
|
495
983
|
},
|
|
984
|
+
sessionExists: (sessionId) => this.sessionManager.exists(sessionId),
|
|
496
985
|
};
|
|
497
|
-
|
|
498
|
-
|
|
986
|
+
// Priority: config.sandbox → project settings.sandbox → global → per-run
|
|
987
|
+
// default. Read UNMERGED per-scope (getForScope) so a project that wrote no
|
|
988
|
+
// sandbox genuinely follows global, rather than inheriting global's mode and
|
|
989
|
+
// looking like it set one. Fixes "项目级配了不生效" + the scope model.
|
|
990
|
+
let projectSandbox;
|
|
991
|
+
let globalSandbox;
|
|
992
|
+
try {
|
|
993
|
+
const sm = this.getSettingsManager();
|
|
994
|
+
if (this.config.isSubAgent !== true) {
|
|
995
|
+
projectSandbox = sm.getForScope("project", cwd).sandbox;
|
|
996
|
+
}
|
|
997
|
+
globalSandbox = sm.getForScope("user").sandbox;
|
|
998
|
+
}
|
|
999
|
+
catch {
|
|
1000
|
+
// settings unavailable → fall through to per-run default
|
|
1001
|
+
}
|
|
1002
|
+
const sandboxConfig = resolveSandboxConfig(this.config.sandbox, projectSandbox, globalSandbox, this.config.headless === true);
|
|
499
1003
|
// A2: explicit sandbox modes (seatbelt, bwrap) must fail closed
|
|
500
1004
|
// per standard §S4. resolveSandboxBackend throws when an explicit
|
|
501
1005
|
// mode is unavailable on this host; we let it propagate. The
|
|
@@ -504,11 +1008,22 @@ export class Engine {
|
|
|
504
1008
|
// `auto` mode handles its own downgrade with a one-time warning
|
|
505
1009
|
// inside resolveSandboxBackend; explicit modes do not.
|
|
506
1010
|
//
|
|
507
|
-
// Backend is cached
|
|
508
|
-
//
|
|
1011
|
+
// Backend is cached per runtime/engine so the capability probe runs once
|
|
1012
|
+
// per (mode, cwd) instead of every turn.
|
|
509
1013
|
const sandboxBackend = this.runtime
|
|
510
1014
|
? await this.runtime.resolveSandbox(sandboxConfig, cwd)
|
|
511
|
-
: await
|
|
1015
|
+
: await this.resolveSandboxWithoutRuntime(sandboxConfig, cwd);
|
|
1016
|
+
// Observability: surface what sandbox actually applied this run — the
|
|
1017
|
+
// configured mode vs the resolved backend (auto may downgrade to off when
|
|
1018
|
+
// no OS backend is available) + the network policy. Without this you can't
|
|
1019
|
+
// tell whether shell commands were isolated /网络放没放. One line per run.
|
|
1020
|
+
logger.info("sandbox.resolved", {
|
|
1021
|
+
mode: sandboxConfig.mode,
|
|
1022
|
+
backend: sandboxBackend.name,
|
|
1023
|
+
isolated: sandboxBackend.name !== "off",
|
|
1024
|
+
network: sandboxConfig.network,
|
|
1025
|
+
cwd,
|
|
1026
|
+
});
|
|
512
1027
|
// sessionId is filled in after the session bundle is resolved below
|
|
513
1028
|
// (the session may be cold-started or resumed). Until then this is
|
|
514
1029
|
// intentionally shaped as a mutable local; we treat it as immutable
|
|
@@ -517,7 +1032,13 @@ export class Engine {
|
|
|
517
1032
|
...this.buildToolContext(),
|
|
518
1033
|
subAgentSpawner,
|
|
519
1034
|
agentDefinitions: this.getAgentDefinitions(cwd),
|
|
520
|
-
|
|
1035
|
+
// Stamp the resolved network policy onto the backend the tools see so
|
|
1036
|
+
// Bash can surface "网络 deny" on its result. Shallow-copy (don't mutate
|
|
1037
|
+
// the cached backend) — `wrap`/`hintForBlockedOutput` are plain function
|
|
1038
|
+
// properties and survive the spread. Off keeps network undefined.
|
|
1039
|
+
sandbox: sandboxBackend.name === "off"
|
|
1040
|
+
? sandboxBackend
|
|
1041
|
+
: { ...sandboxBackend, network: sandboxConfig.network },
|
|
521
1042
|
cwd,
|
|
522
1043
|
// TodoWrite reads this to push task_update events independently
|
|
523
1044
|
// of its return value, so the UI's pinned task panel refreshes
|
|
@@ -538,10 +1059,24 @@ export class Engine {
|
|
|
538
1059
|
// text block (when prose is present) followed by one image block per
|
|
539
1060
|
// attachment — the provider-specific clients translate this to OpenAI
|
|
540
1061
|
// `image_url` or Anthropic `{type:image, source:base64}` downstream.
|
|
1062
|
+
// When an attached image came from a workspace FILE (the desktop composer's
|
|
1063
|
+
// path-attach flow sets ParsedImage.name = the absolute path), surface that
|
|
1064
|
+
// path to the model as text. The image bytes still ride along for vision,
|
|
1065
|
+
// but tools that operate on files — GenerateImage(referenceImages),
|
|
1066
|
+
// Read, etc. — need the on-disk path, not just the pixels. Without this the
|
|
1067
|
+
// path the composer already knew was silently dropped, and the model would
|
|
1068
|
+
// answer "图片没落到项目文件夹,找不到路径" (the seedance 图生图 dead-end).
|
|
1069
|
+
// Only names that resolve to an existing file qualify; a pasted screenshot
|
|
1070
|
+
// whose name is just "screenshot.png" is not a path and is left out.
|
|
1071
|
+
const attachedPaths = collectAttachedImagePaths(parsedTask.images, (name) => (isAbsolute(name) ? name : join(cwd, name)), existsSync);
|
|
1072
|
+
const pathHint = attachedPaths.length > 0
|
|
1073
|
+
? `\n\n<attached-image-paths>\n${attachedPaths.join("\n")}\n</attached-image-paths>\n` +
|
|
1074
|
+
`(上面附带的图片在工作区的真实路径,如需把它们作为工具输入(例如 GenerateImage 的 referenceImages、图生图参考图),直接使用这些路径。)`
|
|
1075
|
+
: "";
|
|
541
1076
|
const userMessageContent = parsedTask.hasImages
|
|
542
1077
|
? [
|
|
543
|
-
...(parsedTask.text
|
|
544
|
-
? [{ type: "text", text: parsedTask.text }]
|
|
1078
|
+
...(parsedTask.text || pathHint
|
|
1079
|
+
? [{ type: "text", text: `${parsedTask.text}${pathHint}` }]
|
|
545
1080
|
: []),
|
|
546
1081
|
...parsedTask.images.map((img) => ({
|
|
547
1082
|
type: "image",
|
|
@@ -570,9 +1105,8 @@ export class Engine {
|
|
|
570
1105
|
let messages;
|
|
571
1106
|
if (options?.sessionId && this.sessionManager.exists(options.sessionId)) {
|
|
572
1107
|
session = this.sessionManager.resume(options.sessionId);
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
: session.transcript.toMessages();
|
|
1108
|
+
const cachedCompacted = this.compactedMessagesBySession.get(options.sessionId);
|
|
1109
|
+
messages = cachedCompacted ? [...cachedCompacted] : session.transcript.toMessages();
|
|
576
1110
|
// If the previous run was Ctrl+C'd or crashed between an assistant
|
|
577
1111
|
// tool_use and the matching tool_result being persisted, the
|
|
578
1112
|
// loaded sequence is invalid for OpenAI (which 400s on dangling
|
|
@@ -593,7 +1127,7 @@ export class Engine {
|
|
|
593
1127
|
// Append new user message
|
|
594
1128
|
const userMsg = { role: "user", content: userMessageContent };
|
|
595
1129
|
messages.push(userMsg);
|
|
596
|
-
session.transcript.appendMessage("user", userMessageContent);
|
|
1130
|
+
session.transcript.appendMessage("user", userMessageContent, { injected: options?.injected === true });
|
|
597
1131
|
// Flush "active" status to disk immediately. resume() set it in memory
|
|
598
1132
|
// (session-manager.ts), but without this write the on-disk state.json
|
|
599
1133
|
// still shows the previous run's terminal reason — so any external
|
|
@@ -604,7 +1138,7 @@ export class Engine {
|
|
|
604
1138
|
else {
|
|
605
1139
|
// Cold start: shape (2) reuses the host-supplied sid; shape (3)
|
|
606
1140
|
// lets sessionManager generate one with nanoid.
|
|
607
|
-
session = this.sessionManager.create(cwd, this.config.llm.model, this.config.llm.provider, options?.sessionId);
|
|
1141
|
+
session = this.sessionManager.create(cwd, this.config.llm.model, this.config.llm.provider, options?.sessionId, this.config.isSubAgent === true ? getCurrentSid() : undefined, this.config.isSubAgent === true ? "subagent" : this.config.origin);
|
|
608
1142
|
messages = [{ role: "user", content: userMessageContent }];
|
|
609
1143
|
session.transcript.appendMessage("user", userMessageContent);
|
|
610
1144
|
// Save first user message as session summary — text only. The summary
|
|
@@ -616,6 +1150,12 @@ export class Engine {
|
|
|
616
1150
|
session.state.summary = summarySrc.slice(0, 80).replace(/\n/g, " ");
|
|
617
1151
|
this.sessionManager.saveState(session.state);
|
|
618
1152
|
}
|
|
1153
|
+
// Bump the conversation-turn counter: this user message starts a new turn.
|
|
1154
|
+
// One user message = one turn, regardless of how many turn-loop iterations
|
|
1155
|
+
// or tool calls it spans. File-history snapshots taken below are tagged
|
|
1156
|
+
// with this value so `/undo` reverts exactly this turn's file changes.
|
|
1157
|
+
// (Both resume and cold-start paths converge here.)
|
|
1158
|
+
session.state.turnSeq = (session.state.turnSeq ?? 0) + 1;
|
|
619
1159
|
// Stamp the resolved session id for downstream logging.
|
|
620
1160
|
//
|
|
621
1161
|
// `setCurrentSid` updates the module-level fallback so any code path
|
|
@@ -730,7 +1270,7 @@ export class Engine {
|
|
|
730
1270
|
}
|
|
731
1271
|
}
|
|
732
1272
|
// Kick off LLM client creation early (network handshake)
|
|
733
|
-
const llmClientPromise = createLLMClient(this.config.llm);
|
|
1273
|
+
const llmClientPromise = createLLMClient(this.config.llm, this.config.clientDefaults);
|
|
734
1274
|
const mode = this.config.permissionMode ?? "acceptEdits";
|
|
735
1275
|
const { rules: defaultRules, backend: approvalBackend } = this.buildPermissionConfig(mode, cwd);
|
|
736
1276
|
const permission = new PermissionClassifier(defaultRules, mode, approvalBackend);
|
|
@@ -751,8 +1291,12 @@ export class Engine {
|
|
|
751
1291
|
}
|
|
752
1292
|
const toolExecutor = new ToolExecutor(this.toolRegistry, permission, this.hooks);
|
|
753
1293
|
const investigationGuard = new InvestigationGuard();
|
|
754
|
-
if (this.config.
|
|
1294
|
+
if (this.config.readOnlySession) {
|
|
1295
|
+
investigationGuard.setPolicy("read-only-review");
|
|
1296
|
+
}
|
|
1297
|
+
else if (this.config.headless) {
|
|
755
1298
|
investigationGuard.setSoftMode(true);
|
|
1299
|
+
}
|
|
756
1300
|
toolExecutor.setInvestigationGuard(investigationGuard);
|
|
757
1301
|
toolExecutor.setTaskGuard(new TaskGuard(() => latestTodos));
|
|
758
1302
|
// Wire abort signal for cascading cancellation + per-Engine ToolContext
|
|
@@ -760,6 +1304,9 @@ export class Engine {
|
|
|
760
1304
|
toolExecutor.setContext(toolCtx);
|
|
761
1305
|
const contextManager = new ContextManager({
|
|
762
1306
|
maxTokens: this.resolveMaxContextTokens(),
|
|
1307
|
+
// Drop undefined fields so they don't clobber ContextManager defaults
|
|
1308
|
+
// (spread of `{x: undefined}` would override the default with undefined).
|
|
1309
|
+
...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
|
|
763
1310
|
});
|
|
764
1311
|
this.lastContextManager = contextManager;
|
|
765
1312
|
const { disabledSkills, disabledPlugins } = this.readDisabledLists();
|
|
@@ -769,8 +1316,13 @@ export class Engine {
|
|
|
769
1316
|
preset: this.preset,
|
|
770
1317
|
customSystemPrompt: this.config.customSystemPrompt,
|
|
771
1318
|
appendSystemPrompt: this.config.appendSystemPrompt,
|
|
1319
|
+
responseLanguage: this.config.responseLanguage,
|
|
1320
|
+
userProfile: this.config.userProfile,
|
|
1321
|
+
instructionOptions: { compatFileNames: compatFileNamesFrom(this.config.instructions) },
|
|
772
1322
|
disabledSkills,
|
|
773
1323
|
disabledPlugins,
|
|
1324
|
+
skillAllowlist: this.config.skillAllowlist,
|
|
1325
|
+
memoriesMaxAgeDays: this.readMemoriesConfig()?.maxAge,
|
|
774
1326
|
});
|
|
775
1327
|
// Connect MCP servers (if configured and not already connected).
|
|
776
1328
|
// B1: prefer the Runtime-owned MCPManager so all sessions in a
|
|
@@ -785,40 +1337,103 @@ export class Engine {
|
|
|
785
1337
|
else {
|
|
786
1338
|
this.mcpManager = new MCPManager(this.toolRegistry);
|
|
787
1339
|
}
|
|
788
|
-
await this.mcpManager.connectAll(mcpServers);
|
|
1340
|
+
await this.mcpManager.connectAll(mcpServers, this);
|
|
789
1341
|
}
|
|
790
1342
|
// Parallelize slow initialization:
|
|
791
1343
|
// 1. createLLMClient — network handshake (started earlier)
|
|
792
1344
|
// 2. buildSystemPrompt — includes git status (3 execSync calls)
|
|
793
1345
|
// 3. buildSystemContext — reads environment context
|
|
794
|
-
|
|
795
|
-
//
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
1346
|
+
// Inject the live available-agent-types listing into the Agent tool's
|
|
1347
|
+
// description. The registry is per-engine (loaded from .code-shell/agents
|
|
1348
|
+
// for this cwd), so it can't live in the static tool def — without this
|
|
1349
|
+
// the model never learns the reusable roles exist and spawns nameless
|
|
1350
|
+
// ad-hoc agents instead (the Core A/B/C incident).
|
|
1351
|
+
// The Agent tool is always available: with configured roles, an omitted
|
|
1352
|
+
// agent_type falls back to one of them (see resolveAgentTypeOverrides); with
|
|
1353
|
+
// no roles configured it runs a true ephemeral agent, so workflows that need
|
|
1354
|
+
// sub-agents (e.g. superpowers) work in any project.
|
|
1355
|
+
// Availability guard (tool-visibility): a gated builtin (WebSearch needs a
|
|
1356
|
+
// search provider, GenerateImage needs an OpenAI provider) is hidden from
|
|
1357
|
+
// the toolDefs the model sees when its credential isn't configured for this
|
|
1358
|
+
// cwd. Recomputed every message, so configuring a key takes effect on the
|
|
1359
|
+
// NEXT message without a restart. Tools with no guard entry are always kept.
|
|
1360
|
+
const guardCwd = toolCtx.cwd;
|
|
1361
|
+
// #7: per-turn project builtin override. The toolRegistry's builtin tool
|
|
1362
|
+
// SET is ctor-frozen (and may be shared via runtime), so a mid-session
|
|
1363
|
+
// project override of a builtin can't rebuild the registry. But the tool
|
|
1364
|
+
// LIST handed to the LLM is assembled fresh every turn, so we apply the
|
|
1365
|
+
// override here: a builtin marked `off` for this cwd is HIDDEN from the
|
|
1366
|
+
// turn's tool list (matching how skills/plugins/agents `off` apply
|
|
1367
|
+
// mid-session via readDisabledLists). `on`/`inherit` keep whatever the
|
|
1368
|
+
// registry already has — we can't re-add a tool the frozen registry omits,
|
|
1369
|
+
// but `on` for a tool already present is a no-op (it stays). This makes a
|
|
1370
|
+
// builtin toggle take effect on the NEXT message, like other capability
|
|
1371
|
+
// kinds, without touching the registry.
|
|
1372
|
+
const builtinOverride = this.readBuiltinOverride(guardCwd);
|
|
1373
|
+
// Turn `off` from a prompt-visibility filter into a real execution gate:
|
|
1374
|
+
// collect the builtin tool names the override marks `off` and hand them to
|
|
1375
|
+
// the executor (via the shared toolCtx the executor already holds a
|
|
1376
|
+
// reference to, set at setContext above) so it rejects a call to a hidden
|
|
1377
|
+
// builtin instead of running it from the still-populated registry.
|
|
1378
|
+
if (builtinOverride) {
|
|
1379
|
+
const registryNames = new Set(this.toolRegistry.getToolDefinitions().map((t) => t.name));
|
|
1380
|
+
const disabledBuiltins = new Set(Object.keys(builtinOverride).filter((name) => builtinOverride[name] === "off" && registryNames.has(name)));
|
|
1381
|
+
toolCtx.disabledBuiltins = disabledBuiltins;
|
|
1382
|
+
}
|
|
1383
|
+
// MCP tool exposure is per-SESSION even though the pool/registry are
|
|
1384
|
+
// worker-shared (B1): a server connected by another project's session
|
|
1385
|
+
// registers its tools into the SHARED registry, and without this filter
|
|
1386
|
+
// they leaked into every session (e.g. chrome-devtools tools showing up
|
|
1387
|
+
// in a project that never enabled the plugin). Keep an MCP tool only when
|
|
1388
|
+
// its server is in THIS session's merged config.mcpServers — which
|
|
1389
|
+
// already folds the project's capabilityOverrides. Gated on the config
|
|
1390
|
+
// being present: engines without one (sub-agents, bare tests) have no
|
|
1391
|
+
// MCP tools in their private registries anyway.
|
|
1392
|
+
const allowedMcpServers = new Set(Object.entries(this.config.mcpServers ?? {})
|
|
1393
|
+
.filter(([, c]) => c.enabled !== false)
|
|
1394
|
+
.map(([n]) => n));
|
|
1395
|
+
toolCtx.allowedMcpServers = allowedMcpServers;
|
|
1396
|
+
const mcpVisible = (toolName) => {
|
|
1397
|
+
const reg = this.toolRegistry.getTool(toolName);
|
|
1398
|
+
return reg?.source !== "mcp" || allowedMcpServers.has(reg?.serverName ?? "");
|
|
1399
|
+
};
|
|
1400
|
+
// Feature-flag visibility: a builtin mapped in TOOL_FEATURE_FLAGS is
|
|
1401
|
+
// hidden when its flag resolves to false (default-on flags only hide when
|
|
1402
|
+
// explicitly disabled, so zero regression out of the box). Read once per
|
|
1403
|
+
// turn so flipping a flag in settings takes effect on the NEXT message,
|
|
1404
|
+
// like the other capability kinds.
|
|
1405
|
+
const featureFlags = this.readFeatureFlags();
|
|
1406
|
+
const allToolDefs = applyBuiltinOverrideVisibility(this.toolRegistry.getToolDefinitions(), builtinOverride)
|
|
1407
|
+
.filter((t) => mcpVisible(t.name))
|
|
1408
|
+
.filter((t) => {
|
|
1409
|
+
const guard = BUILTIN_TOOL_GUARDS.get(t.name);
|
|
1410
|
+
return guard ? guard(guardCwd) : true;
|
|
1411
|
+
})
|
|
1412
|
+
.filter((t) => {
|
|
1413
|
+
const flag = TOOL_FEATURE_FLAGS.get(t.name);
|
|
1414
|
+
return flag ? isFeatureEnabled(featureFlags, flag) : true;
|
|
1415
|
+
})
|
|
1416
|
+
// Dynamic per-engine bits the static defs can't carry: the Agent tool's
|
|
1417
|
+
// agent_type enum + listing, and the image/video provider names. See
|
|
1418
|
+
// applyDynamicToolDef — forwarding only the Agent description (dropping
|
|
1419
|
+
// its rebuilt inputSchema) used to strip the agent_type enum, so the
|
|
1420
|
+
// model omitted agent_type and configured roles never applied.
|
|
1421
|
+
.map((t) => applyDynamicToolDef(t, toolCtx.agentDefinitions, guardCwd));
|
|
1422
|
+
// In plan mode, only expose read-only/planning tools so the model won't
|
|
1423
|
+
// attempt writes. Shared with executor.ts's execution gate via
|
|
1424
|
+
// PLAN_MODE_ALLOWED_TOOLS so what the model SEES and what the executor
|
|
1425
|
+
// RUNS can't drift apart. (Bash is in the set; the executor additionally
|
|
1426
|
+
// gates Bash to read-only commands at call time.)
|
|
813
1427
|
const toolDefs = this.planMode
|
|
814
|
-
? allToolDefs.filter((t) =>
|
|
1428
|
+
? allToolDefs.filter((t) => PLAN_MODE_ALLOWED_TOOLS.has(t.name))
|
|
815
1429
|
: allToolDefs;
|
|
816
|
-
const [llmClient,
|
|
1430
|
+
const [llmClient, fullSystemPrompt, dynamicContextMsg] = await Promise.all([
|
|
817
1431
|
llmClientPromise,
|
|
1432
|
+
// System prompt is now the STABLE prefix only — skills + git status moved
|
|
1433
|
+
// out to a trailing per-turn message so they no longer bust the cache.
|
|
818
1434
|
promptComposer.buildSystemPrompt(toolDefs),
|
|
819
|
-
promptComposer.
|
|
1435
|
+
promptComposer.buildDynamicContextMessage(),
|
|
820
1436
|
]);
|
|
821
|
-
const fullSystemPrompt = [systemPrompt, systemContext].filter(Boolean).join("\n\n");
|
|
822
1437
|
// Prepend userContext (CLAUDE.md) as first message (sync, fast)
|
|
823
1438
|
const userContextMsg = promptComposer.buildUserContextMessage();
|
|
824
1439
|
if (userContextMsg) {
|
|
@@ -837,6 +1452,12 @@ export class Engine {
|
|
|
837
1452
|
// reminder → user request.
|
|
838
1453
|
messages.splice(messages.length - 1, 0, lifecycleReminder);
|
|
839
1454
|
}
|
|
1455
|
+
// Volatile context (skills + git status) goes at the very END — after the
|
|
1456
|
+
// user task — so it sits past the conversation's cache breakpoint. A change
|
|
1457
|
+
// here (new skill, edited file) never invalidates the cached history prefix.
|
|
1458
|
+
if (dynamicContextMsg) {
|
|
1459
|
+
messages.push(dynamicContextMsg);
|
|
1460
|
+
}
|
|
840
1461
|
this.lastSessionId = session.state.sessionId;
|
|
841
1462
|
this.lastMessages = messages;
|
|
842
1463
|
// Wire up LLM summarization for context compaction
|
|
@@ -848,8 +1469,15 @@ export class Engine {
|
|
|
848
1469
|
// run would be evaluated fresh and might get a different replacement
|
|
849
1470
|
// string than the one already in the message, breaking idempotency.
|
|
850
1471
|
contextManager.initReplacementStateFromMessages(messages);
|
|
1472
|
+
// Summarization (context-compaction + tool-result summaries) are auxiliary
|
|
1473
|
+
// calls — route them to the configured aux model so they don't burn the
|
|
1474
|
+
// expensive primary model every turn (same rationale as runMemoryPipeline).
|
|
1475
|
+
// Resolved once here (not per-call) so the magnetic-disk settings re-read
|
|
1476
|
+
// in resolveAuxClient stays off the compaction hot path. Falls back to the
|
|
1477
|
+
// primary client when no aux model is configured.
|
|
1478
|
+
const auxSummaryClient = await this.resolveAuxClient(llmClient);
|
|
851
1479
|
contextManager.setSummarizeFn(async (prompt) => {
|
|
852
|
-
const summaryResponse = await
|
|
1480
|
+
const summaryResponse = await auxSummaryClient.createMessage({
|
|
853
1481
|
systemPrompt: "You are a conversation summarizer. Be concise and factual.",
|
|
854
1482
|
messages: [{ role: "user", content: prompt }],
|
|
855
1483
|
tools: [],
|
|
@@ -857,12 +1485,17 @@ export class Engine {
|
|
|
857
1485
|
// Auxiliary call — no need to burn reasoning tokens. On DeepSeek V4
|
|
858
1486
|
// this flips thinking off (~3x faster, fewer tokens); on every other
|
|
859
1487
|
// OpenAI-compatible provider the field is ignored.
|
|
860
|
-
|
|
1488
|
+
reasoning: { mode: "off" },
|
|
861
1489
|
});
|
|
862
1490
|
return summaryResponse.text;
|
|
863
1491
|
});
|
|
864
|
-
// Create components (requires resolved llmClient)
|
|
1492
|
+
// Create components (requires resolved llmClient).
|
|
865
1493
|
const modelFacade = new ModelFacade(llmClient, session.transcript);
|
|
1494
|
+
// Session-cumulative usage baseline: the LLM client is recreated per run
|
|
1495
|
+
// (its getUsage() counts only THIS run), so to accumulate across runs we
|
|
1496
|
+
// capture the persisted total at run start and fold this run's usage onto
|
|
1497
|
+
// it (see foldRunUsage). Snapshot now, before any turn boundary fires.
|
|
1498
|
+
const usageBaseline = { ...session.state.tokenUsage };
|
|
866
1499
|
// Wire getOutputTokens for token budget tracking
|
|
867
1500
|
modelFacade.getOutputTokens = () => {
|
|
868
1501
|
const usage = llmClient.getUsage();
|
|
@@ -873,14 +1506,14 @@ export class Engine {
|
|
|
873
1506
|
// tracker so session_end.cost reflects only the user-facing turns and
|
|
874
1507
|
// turns/requestCount stay aligned.
|
|
875
1508
|
modelFacade.summarize = async (sysPrompt, userMsg) => {
|
|
876
|
-
const resp = await
|
|
1509
|
+
const resp = await auxSummaryClient.createMessage({
|
|
877
1510
|
systemPrompt: sysPrompt,
|
|
878
1511
|
messages: [{ role: "user", content: userMsg }],
|
|
879
1512
|
tools: [],
|
|
880
1513
|
maxTokens: 256,
|
|
881
1514
|
recordUsage: false,
|
|
882
1515
|
// Auxiliary call — see contextManager.setSummarizeFn above.
|
|
883
|
-
|
|
1516
|
+
reasoning: { mode: "off" },
|
|
884
1517
|
});
|
|
885
1518
|
logger.debug("summarize.call", {
|
|
886
1519
|
sysPromptLen: sysPrompt.length,
|
|
@@ -895,22 +1528,115 @@ export class Engine {
|
|
|
895
1528
|
return resp.text;
|
|
896
1529
|
};
|
|
897
1530
|
// File history: auto-backup before Write/Edit
|
|
898
|
-
const sessionDir = join(this.config.sessionStorageDir ?? join(
|
|
1531
|
+
const sessionDir = join(this.config.sessionStorageDir ?? join(userHome(), ".code-shell", "sessions"), session.state.sessionId);
|
|
899
1532
|
const fileHistory = FileHistory.loadFromDir(sessionDir);
|
|
900
|
-
|
|
1533
|
+
// Keep a reference so we can unregister in the finally below. Registering an
|
|
1534
|
+
// anonymous handler every run() leaks: unregister matches by handler
|
|
1535
|
+
// identity, so without a stored reference each run stacks another identical
|
|
1536
|
+
// on_tool_start handler that fires (and re-snapshots) on every tool forever.
|
|
1537
|
+
const fileHistoryHandler = async (context) => {
|
|
901
1538
|
const toolName = context.data?.toolName;
|
|
902
1539
|
const args = context.data?.args;
|
|
1540
|
+
// Tag snapshots with the current turn (stamped above before any tool
|
|
1541
|
+
// runs) so turn-level /undo can revert just this user message's edits.
|
|
1542
|
+
const turnSeq = session.state.turnSeq;
|
|
903
1543
|
if ((toolName === "Write" || toolName === "Edit") && args?.file_path) {
|
|
904
|
-
|
|
1544
|
+
const path = args.file_path;
|
|
1545
|
+
// saveSnapshot returns null when the file does not exist yet — this
|
|
1546
|
+
// hook runs BEFORE the tool, so a null here means the turn is CREATING
|
|
1547
|
+
// the file. Record it (idempotent per turn) so /undo can delete it and
|
|
1548
|
+
// /redo can recreate it.
|
|
1549
|
+
if (fileHistory.saveSnapshot(path, turnSeq) === null && turnSeq !== undefined) {
|
|
1550
|
+
fileHistory.recordCreated(path, turnSeq);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
else if (toolName === "ApplyPatch" && typeof args?.patch === "string") {
|
|
1554
|
+
// ApplyPatch mutates files too, so /undo must see them. Snapshot every
|
|
1555
|
+
// existing file the patch updates or deletes (adds have no prior
|
|
1556
|
+
// content). Resolve relative patch paths against the engine cwd, the
|
|
1557
|
+
// same base ApplyPatch itself uses.
|
|
1558
|
+
const cwd = this.config.cwd ?? process.cwd();
|
|
1559
|
+
for (const target of patchBackupTargets(args.patch, cwd)) {
|
|
1560
|
+
fileHistory.saveSnapshot(target, turnSeq);
|
|
1561
|
+
}
|
|
905
1562
|
}
|
|
906
1563
|
return {};
|
|
907
|
-
}
|
|
1564
|
+
};
|
|
1565
|
+
this.hooks.register("on_tool_start", fileHistoryHandler, 100, "file_history_backup");
|
|
908
1566
|
// Hook: agent start
|
|
909
1567
|
await this.emitHook("on_agent_start", {
|
|
910
1568
|
sessionId: session.state.sessionId,
|
|
911
1569
|
task,
|
|
912
1570
|
model: this.config.llm.model,
|
|
913
1571
|
});
|
|
1572
|
+
// Goal mode: register a GoalStopHook for the lifetime of THIS run so the
|
|
1573
|
+
// turn loop keeps going until the session model judges the goal met.
|
|
1574
|
+
// Registered per-run (and cleared in `finally`) so a later goal-less
|
|
1575
|
+
// send doesn't inherit a stale goal. The judge runs on `auxSummaryClient`
|
|
1576
|
+
// — the same cheap aux model used for summarize/compaction — not the
|
|
1577
|
+
// (potentially expensive) session model: "is this goal met?" is a classic
|
|
1578
|
+
// aux-tier task, and a goal run can invoke the judge up to maxStopBlocks
|
|
1579
|
+
// times.
|
|
1580
|
+
// Normalize the raw goal (string | GoalConfig) once at the run boundary;
|
|
1581
|
+
// everything inward uses the GoalConfig. normalizeGoal() returns undefined
|
|
1582
|
+
// when there's effectively no goal (empty objective).
|
|
1583
|
+
//
|
|
1584
|
+
// PERSISTENT GOAL (CC /goal style): a goal set on one send survives across
|
|
1585
|
+
// later sends and manual interrupts until met or cleared. Resolution:
|
|
1586
|
+
// 1. options.goal — this send explicitly sets/replaces the goal.
|
|
1587
|
+
// 2. session.state.activeGoal — a goal set on an earlier send.
|
|
1588
|
+
// 3. config.goal — engine-level default (rare; e.g. headless).
|
|
1589
|
+
// When (1) supplies a goal that differs from the stored one we REPLACE the
|
|
1590
|
+
// persisted active goal (one active goal per session) and announce it. A
|
|
1591
|
+
// bare send with no options.goal inherits the stored active goal so the
|
|
1592
|
+
// model keeps working toward it — that's what makes it persistent.
|
|
1593
|
+
const explicitGoal = normalizeGoal(options?.goal);
|
|
1594
|
+
const storedGoal = this.config.isSubAgent !== true ? session.state.activeGoal : undefined;
|
|
1595
|
+
if (explicitGoal && this.config.isSubAgent !== true) {
|
|
1596
|
+
const replaced = !!storedGoal && storedGoal.objective !== explicitGoal.objective;
|
|
1597
|
+
// Stamp WHEN this goal was set so the judge can anchor relative deadlines
|
|
1598
|
+
// ("做到3点") to the set time, not "now" — else once the clock passes the
|
|
1599
|
+
// deadline the judge could read "3点" as tomorrow's and never stop. A new
|
|
1600
|
+
// or changed objective gets a fresh stamp; re-sending the SAME objective
|
|
1601
|
+
// keeps the original anchor (the goal continues, the user didn't restate a
|
|
1602
|
+
// new deadline). User input never carries setAtMs, so we set it here.
|
|
1603
|
+
explicitGoal.setAtMs = resolveGoalSetAt(explicitGoal.objective, storedGoal, Date.now());
|
|
1604
|
+
session.state.activeGoal = explicitGoal;
|
|
1605
|
+
this.sessionManager.saveState(session.state);
|
|
1606
|
+
options?.onStream?.({
|
|
1607
|
+
type: "goal_set",
|
|
1608
|
+
objective: explicitGoal.objective,
|
|
1609
|
+
replaced,
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
const normalizedGoal = explicitGoal ?? storedGoal ?? normalizeGoal(this.config.goal);
|
|
1613
|
+
let goalHookHandler = null;
|
|
1614
|
+
if (normalizedGoal && this.config.isSubAgent !== true) {
|
|
1615
|
+
goalHookHandler = createGoalStopHook({
|
|
1616
|
+
goal: normalizedGoal,
|
|
1617
|
+
llm: auxSummaryClient,
|
|
1618
|
+
log: logger,
|
|
1619
|
+
// Clear the persisted active goal the moment the judge says it's met,
|
|
1620
|
+
// so a later bare send doesn't re-inherit a satisfied goal. The hook
|
|
1621
|
+
// calls this from inside its met branch (single source of truth for
|
|
1622
|
+
// "goal achieved"); engine owns the persistence side-effect.
|
|
1623
|
+
onMet: () => {
|
|
1624
|
+
if (session.state.activeGoal) {
|
|
1625
|
+
session.state.activeGoal = undefined;
|
|
1626
|
+
this.sessionManager.saveState(session.state);
|
|
1627
|
+
}
|
|
1628
|
+
},
|
|
1629
|
+
// Re-read the persisted goal each turn so a mid-run 清除 (clearGoal
|
|
1630
|
+
// wrote state.json but this hook's frozen goal copy + the closure's
|
|
1631
|
+
// in-RAM session are untouched) actually stops the judge. Reads disk
|
|
1632
|
+
// via readActiveGoal — authoritative and independent of which session
|
|
1633
|
+
// instance the run closure holds.
|
|
1634
|
+
isGoalActive: (sid) => this.sessionManager.readActiveGoal(sid) !== undefined,
|
|
1635
|
+
});
|
|
1636
|
+
this.hooks.register("on_stop", goalHookHandler, 0, "goal-stop");
|
|
1637
|
+
// Expose for clearGoal() mid-run. Already guarded by isSubAgent above.
|
|
1638
|
+
this.activeGoalHook = goalHookHandler;
|
|
1639
|
+
}
|
|
914
1640
|
// Surface compaction events to the UI so the user knows when context was trimmed.
|
|
915
1641
|
// Buffer the most recent event so TurnLoop can drain it and emit the
|
|
916
1642
|
// post_compact hook on the next turn (ContextManager itself doesn't
|
|
@@ -936,6 +1662,22 @@ export class Engine {
|
|
|
936
1662
|
pendingCompactInfo = null;
|
|
937
1663
|
return info;
|
|
938
1664
|
},
|
|
1665
|
+
consumeSteer: () => this.consumeSteer(sid),
|
|
1666
|
+
// Clear the persisted goal for a self-reported completion / confirmed
|
|
1667
|
+
// cancel. Clears the in-RAM session's activeGoal (so THIS run's later
|
|
1668
|
+
// turns don't re-arm) AND persists it, and drops the in-flight stop
|
|
1669
|
+
// hook so nothing re-blocks the stop we're about to return.
|
|
1670
|
+
clearPersistedGoal: () => {
|
|
1671
|
+
if (session.state.activeGoal !== undefined) {
|
|
1672
|
+
session.state.activeGoal = undefined;
|
|
1673
|
+
this.sessionManager.saveState(session.state);
|
|
1674
|
+
}
|
|
1675
|
+
if (goalHookHandler) {
|
|
1676
|
+
this.hooks.unregister("on_stop", goalHookHandler);
|
|
1677
|
+
if (this.activeGoalHook === goalHookHandler)
|
|
1678
|
+
this.activeGoalHook = null;
|
|
1679
|
+
}
|
|
1680
|
+
},
|
|
939
1681
|
ctxOverheadStore: {
|
|
940
1682
|
get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
|
|
941
1683
|
set: (s, n) => {
|
|
@@ -943,29 +1685,142 @@ export class Engine {
|
|
|
943
1685
|
},
|
|
944
1686
|
},
|
|
945
1687
|
}, {
|
|
946
|
-
|
|
947
|
-
|
|
1688
|
+
// Goal mode raises the turn ceiling: an unattended goal run keeps
|
|
1689
|
+
// getting re-blocked by the stop-hook until it's done, and the 100
|
|
1690
|
+
// interactive default would silently truncate a long objective. The
|
|
1691
|
+
// real backstops are the goal token/time budgets + maxStopBlocks.
|
|
1692
|
+
maxTurns: resolveMaxTurns(this.config.maxTurns, normalizedGoal),
|
|
1693
|
+
// Consecutive stop-block cap: config override > goal.maxStopBlocks >
|
|
1694
|
+
// GOAL_DEFAULT_MAX_STOP_BLOCKS(25). The old hardcoded 8 was too tight
|
|
1695
|
+
// for complex goals that legitimately get re-blocked while advancing.
|
|
1696
|
+
maxStopBlocks: resolveMaxStopBlocks(this.config.maxStopBlocks, normalizedGoal),
|
|
1697
|
+
// 25 (was 10): modern models routinely batch >10 parallel tool calls
|
|
1698
|
+
// (e.g. reading a dozen files at once). At 10 the excess was silently
|
|
1699
|
+
// dropped; the turn loop now also warns the model when it caps, but a
|
|
1700
|
+
// higher ceiling avoids the round-trip in the common case. (B-3)
|
|
1701
|
+
maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 25,
|
|
948
1702
|
onStream: options?.onStream,
|
|
949
1703
|
signal: options?.signal,
|
|
1704
|
+
// Goal mode: the active goal is surfaced to the on_stop handler via
|
|
1705
|
+
// ctx.data.goal; the GoalStopHook (registered above) judges it.
|
|
1706
|
+
goal: normalizedGoal,
|
|
950
1707
|
// Heartbeat: flush turnCount + tokens to state.json after every turn
|
|
951
1708
|
// so external observers (other CLI processes, /sid, the session list)
|
|
952
1709
|
// see live progress instead of a stale snapshot from the last
|
|
953
1710
|
// completed run.
|
|
954
1711
|
onTurnBoundary: (turnCount) => {
|
|
955
1712
|
session.state.turnCount = turnCount;
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1713
|
+
// baseline + this run's running total (idempotent per boundary,
|
|
1714
|
+
// accumulates across runs; carries cacheRead/cacheCreation too).
|
|
1715
|
+
session.state.tokenUsage = foldRunUsage(usageBaseline, modelFacade.getUsage());
|
|
1716
|
+
// Surface the session-cumulative cache counts to the UI (the "本会话
|
|
1717
|
+
// 累计命中率" tooltip). Separate from turn-loop's per-response
|
|
1718
|
+
// usage_update (which drives the live context reading).
|
|
1719
|
+
const cum = session.state.tokenUsage;
|
|
1720
|
+
options?.onStream?.({
|
|
1721
|
+
type: "usage_update",
|
|
1722
|
+
promptTokens: cum.promptTokens,
|
|
1723
|
+
sessionPromptTokens: cum.promptTokens,
|
|
1724
|
+
sessionCacheReadTokens: cum.cacheReadTokens ?? 0,
|
|
1725
|
+
sessionCacheCreationTokens: cum.cacheCreationTokens ?? 0,
|
|
1726
|
+
});
|
|
962
1727
|
if (this.config.costStore) {
|
|
963
1728
|
session.state.costState = this.config.costStore.serialize();
|
|
964
1729
|
}
|
|
965
1730
|
this.sessionManager.saveState(session.state);
|
|
966
1731
|
},
|
|
967
1732
|
});
|
|
968
|
-
|
|
1733
|
+
// Expose this run's loop for mid-run extension (TODO 3.1). Top-level only —
|
|
1734
|
+
// a sub-agent's loop is its own concern and isn't user-extendable.
|
|
1735
|
+
if (this.config.isSubAgent !== true)
|
|
1736
|
+
this.activeTurnLoop = turnLoop;
|
|
1737
|
+
// Expose this run's session bundle so a mid-run clearGoal() wipes the goal
|
|
1738
|
+
// on the very instance this loop keeps saving (see field doc). Top-level
|
|
1739
|
+
// only — sub-agents don't carry user-clearable persistent goals.
|
|
1740
|
+
if (this.config.isSubAgent !== true)
|
|
1741
|
+
this.activeRunSession = session;
|
|
1742
|
+
let result;
|
|
1743
|
+
try {
|
|
1744
|
+
result = await turnLoop.run(messages);
|
|
1745
|
+
// ── Headless: drain background sub-agents before resolving ───────
|
|
1746
|
+
// Unified background-work model (2026-06-17): the engine NO LONGER parks
|
|
1747
|
+
// every run waiting on background work. Background work (sub-agents,
|
|
1748
|
+
// video polls, shells) ends the turn, yields, and is picked up later by
|
|
1749
|
+
// the server's notification-wakeup path (maybeWakeIdleSession). The
|
|
1750
|
+
// INTERACTIVE path relies on that wakeup + a run-boundary re-check.
|
|
1751
|
+
//
|
|
1752
|
+
// HEADLESS is the exception: a one-shot `engine.run` whose caller takes
|
|
1753
|
+
// `result.text` as THE answer (automation / SDK) has no later turn to
|
|
1754
|
+
// pick up a wakeup — so it must wait, before resolving, until its own
|
|
1755
|
+
// background SUB-AGENTS finish and summarize. Only sub-agents (their
|
|
1756
|
+
// summary IS part of this run's result), NOT shells (a dev server never
|
|
1757
|
+
// exits → would hang headless forever) and NOT video (a long render the
|
|
1758
|
+
// one-shot run shouldn't block on). This replaces the old for(;;) park
|
|
1759
|
+
// (s-mpvf4rsj-bb6e4639 invariant) for the headless case only.
|
|
1760
|
+
const sid = session.state.sessionId;
|
|
1761
|
+
const isTopLevel = this.config.isSubAgent !== true;
|
|
1762
|
+
if (isTopLevel && this.isHeadless()) {
|
|
1763
|
+
let aborted = options?.signal?.aborted === true;
|
|
1764
|
+
// Loop: a summarize turn can spawn a NEW background sub-agent; keep
|
|
1765
|
+
// draining + summarizing until none remain. turnCount accumulates, so
|
|
1766
|
+
// the turn-loop's maxTurns still bounds runaway re-summarization.
|
|
1767
|
+
for (;;) {
|
|
1768
|
+
while (!aborted && asyncAgentRegistry.hasRunningForSession(sid)) {
|
|
1769
|
+
aborted = await this.waitForBackgroundAgentChange(sid, options?.signal);
|
|
1770
|
+
}
|
|
1771
|
+
let pending = notificationQueue.drainAll(sid);
|
|
1772
|
+
if (aborted && pending.length === 0) {
|
|
1773
|
+
// Abort race: an agent calls markCompleted (registry notify) and only
|
|
1774
|
+
// THEN enqueue (queue notify) as two separate statements. If the abort
|
|
1775
|
+
// fired before that agent's completion `.then` ran, the while above
|
|
1776
|
+
// exited on `aborted`, this drainAll caught nothing, and a naive
|
|
1777
|
+
// `break` here would drop the agent's output. Give still-settling
|
|
1778
|
+
// agents a bounded window to finish enqueuing, then drain once more.
|
|
1779
|
+
// Each wait is timeout-bounded so a genuinely stuck (never-completing)
|
|
1780
|
+
// agent can't hang abort cleanup forever — we'd rather lose nothing in
|
|
1781
|
+
// the common case and not hang in the pathological one.
|
|
1782
|
+
for (let i = 0; i < 20 && asyncAgentRegistry.hasRunningForSession(sid); i++) {
|
|
1783
|
+
const changed = await this.waitForBackgroundAgentChangeOrTimeout(sid, 25);
|
|
1784
|
+
if (!changed)
|
|
1785
|
+
break; // timed out with no state change → stop waiting
|
|
1786
|
+
}
|
|
1787
|
+
pending = notificationQueue.drainAll(sid);
|
|
1788
|
+
if (pending.length === 0)
|
|
1789
|
+
break;
|
|
1790
|
+
}
|
|
1791
|
+
else if (pending.length === 0) {
|
|
1792
|
+
break;
|
|
1793
|
+
}
|
|
1794
|
+
const injected = {
|
|
1795
|
+
role: "user",
|
|
1796
|
+
content: `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`,
|
|
1797
|
+
};
|
|
1798
|
+
if (aborted) {
|
|
1799
|
+
// Mark injected: a synthetic notification, not the user's own input —
|
|
1800
|
+
// the disk reader drops it on replay so no phantom user bubble.
|
|
1801
|
+
session.transcript.appendMessage(injected.role, injected.content, { injected: true });
|
|
1802
|
+
result = { ...result, messages: [...result.messages, injected] };
|
|
1803
|
+
break;
|
|
1804
|
+
}
|
|
1805
|
+
result = await turnLoop.run([...result.messages, injected]);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
finally {
|
|
1810
|
+
// Run-scoped: drop the GoalStopHook so a later goal-less send on this
|
|
1811
|
+
// long-lived engine doesn't keep blocking stops.
|
|
1812
|
+
if (goalHookHandler)
|
|
1813
|
+
this.hooks.unregister("on_stop", goalHookHandler);
|
|
1814
|
+
if (this.activeGoalHook === goalHookHandler)
|
|
1815
|
+
this.activeGoalHook = null;
|
|
1816
|
+
if (this.activeTurnLoop === turnLoop)
|
|
1817
|
+
this.activeTurnLoop = null;
|
|
1818
|
+
if (this.activeRunSession === session)
|
|
1819
|
+
this.activeRunSession = null;
|
|
1820
|
+
// Run-scoped too: this handler is re-registered every run(), so it must be
|
|
1821
|
+
// dropped here or it stacks duplicates that re-snapshot on every tool.
|
|
1822
|
+
this.hooks.unregister("on_tool_start", fileHistoryHandler);
|
|
1823
|
+
}
|
|
969
1824
|
this.lastMessages = result.messages;
|
|
970
1825
|
this.compactedMessagesBySession.set(session.state.sessionId, this.stripUserContextMessage(result.messages, userContextMsg));
|
|
971
1826
|
logger.info("engine.done", {
|
|
@@ -992,6 +1847,37 @@ export class Engine {
|
|
|
992
1847
|
// transcript, save a session summary, and conditionally trigger
|
|
993
1848
|
// auto-dream consolidation. Doesn't block the Engine result.
|
|
994
1849
|
void this.runMemoryPipeline(session.transcript, session.state.sessionId, cwd, llmClient);
|
|
1850
|
+
// Fire-and-forget session title generation — only after the FIRST turn.
|
|
1851
|
+
// Reuses the already-resolved auxSummaryClient (aux model, cheap). Best-
|
|
1852
|
+
// effort: failures never touch the run result. The renderer writes the
|
|
1853
|
+
// title into the sidebar on receipt of the session_title stream event.
|
|
1854
|
+
{
|
|
1855
|
+
const messageEvents = session.transcript.getEvents("message");
|
|
1856
|
+
const userMsgEvents = messageEvents.filter((e) => e.data.role === "user");
|
|
1857
|
+
const userMsgCount = userMsgEvents.length;
|
|
1858
|
+
const onStream = options?.onStream;
|
|
1859
|
+
if (userMsgCount === 1 && onStream && result.text) {
|
|
1860
|
+
const rawContent = userMsgEvents[0]?.data?.content;
|
|
1861
|
+
const firstUserText = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent ?? "");
|
|
1862
|
+
void buildSessionTitle(auxSummaryClient, firstUserText, result.text)
|
|
1863
|
+
.then((title) => {
|
|
1864
|
+
if (title) {
|
|
1865
|
+
// Persist the title so it survives a localStorage wipe / disk
|
|
1866
|
+
// rebuild — it used to live only in the renderer's localStorage
|
|
1867
|
+
// index. This .then resolves AFTER the saveState below (:1892), so
|
|
1868
|
+
// it must save again itself rather than rely on that write.
|
|
1869
|
+
session.state.title = title;
|
|
1870
|
+
this.sessionManager.saveState(session.state);
|
|
1871
|
+
onStream({
|
|
1872
|
+
type: "session_title",
|
|
1873
|
+
sessionId: session.state.sessionId,
|
|
1874
|
+
title,
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
})
|
|
1878
|
+
.catch(() => { });
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
995
1881
|
// Update session state. Persist the raw terminal reason as the status so
|
|
996
1882
|
// callers can distinguish user-cancelled (aborted_streaming) from real
|
|
997
1883
|
// failures (model_error, prompt_too_long, ...) — previously every
|
|
@@ -999,12 +1885,9 @@ export class Engine {
|
|
|
999
1885
|
// distinction and misled anyone reading state.json.
|
|
1000
1886
|
session.state.turnCount = turnLoop.currentTurn;
|
|
1001
1887
|
session.state.status = result.reason;
|
|
1888
|
+
// Session-cumulative (baseline + this run) for persistence...
|
|
1002
1889
|
const usage = modelFacade.getUsage();
|
|
1003
|
-
session.state.tokenUsage =
|
|
1004
|
-
promptTokens: usage.totalPromptTokens,
|
|
1005
|
-
completionTokens: usage.totalCompletionTokens,
|
|
1006
|
-
totalTokens: usage.totalTokens,
|
|
1007
|
-
};
|
|
1890
|
+
session.state.tokenUsage = foldRunUsage(usageBaseline, usage);
|
|
1008
1891
|
if (this.config.costStore) {
|
|
1009
1892
|
session.state.costState = this.config.costStore.serialize();
|
|
1010
1893
|
}
|
|
@@ -1035,8 +1918,73 @@ export class Engine {
|
|
|
1035
1918
|
* task. Extracts durable memories from the transcript, saves a session
|
|
1036
1919
|
* summary, and conditionally triggers auto-dream consolidation.
|
|
1037
1920
|
*/
|
|
1038
|
-
|
|
1921
|
+
/**
|
|
1922
|
+
* Resolve the LLM client for background/auxiliary work (memory extraction,
|
|
1923
|
+
* auto-dream). When settings.defaults.auxText names a valid pool model, build
|
|
1924
|
+
* (and cache) a dedicated client for it so per-turn book-keeping runs on a cheap
|
|
1925
|
+
* fast model instead of the expensive primary. Falls back to `fallback` (the
|
|
1926
|
+
* active run's client) when unset, unknown, or on any build failure — aux
|
|
1927
|
+
* work is best-effort and must never break a run.
|
|
1928
|
+
*/
|
|
1929
|
+
async resolveAuxClient(fallback) {
|
|
1930
|
+
let auxKey;
|
|
1931
|
+
try {
|
|
1932
|
+
// Re-read from disk: settings may have been changed by the desktop
|
|
1933
|
+
// (a separate process) since this worker last cached them. This runs
|
|
1934
|
+
// once per run on the post-run background path, so the cost is fine.
|
|
1935
|
+
const sm = this.getSettingsManager();
|
|
1936
|
+
sm.invalidate();
|
|
1937
|
+
// Unified store's defaults.auxText (a connection id = pool key) selects
|
|
1938
|
+
// the aux model; resolveAuxKey returns it (or undefined).
|
|
1939
|
+
auxKey = resolveAuxKey(sm.get());
|
|
1940
|
+
}
|
|
1941
|
+
catch {
|
|
1942
|
+
return fallback;
|
|
1943
|
+
}
|
|
1944
|
+
if (!auxKey)
|
|
1945
|
+
return fallback;
|
|
1946
|
+
// Don't spin up a second client when the aux key resolves to the SAME
|
|
1947
|
+
// client config as this engine's active model. Compare FULL LLM IDENTITY
|
|
1948
|
+
// (model + reasoning + maxTokens + baseUrl + provider/providerKind) against
|
|
1949
|
+
// this engine's own per-session config.llm — NOT a separately-tracked active
|
|
1950
|
+
// key, and NOT just the model NAME. Two distinct pool keys can share the same
|
|
1951
|
+
// `model` string yet differ in reasoning/maxOutputTokens/baseUrl/apiKey/
|
|
1952
|
+
// providerKey; de-duping on the name alone would wrongly route the user's
|
|
1953
|
+
// chosen aux entry onto the primary's config. config.llm is isolated per
|
|
1954
|
+
// session and always set for a real run, so this is correct even for desktop
|
|
1955
|
+
// worker sessions built with a shared runtime (which never explicitly
|
|
1956
|
+
// switchModel, so the old activeModelKey field was undefined and defeated the
|
|
1957
|
+
// de-dup), AND immune to another session mutating the shared pool's activeKey.
|
|
1958
|
+
const entry = this.modelPool.get(auxKey);
|
|
1959
|
+
if (entry && sameLlmIdentity(this.modelPool.toLLMConfig(entry), this.config.llm)) {
|
|
1960
|
+
return fallback;
|
|
1961
|
+
}
|
|
1962
|
+
if (this.auxClientCache?.key === auxKey)
|
|
1963
|
+
return this.auxClientCache.client;
|
|
1964
|
+
if (!entry) {
|
|
1965
|
+
logger.warn("engine.aux_model_missing", { auxModelKey: auxKey });
|
|
1966
|
+
return fallback;
|
|
1967
|
+
}
|
|
1968
|
+
try {
|
|
1969
|
+
const client = await createLLMClient(this.modelPool.toLLMConfig(entry), this.config.clientDefaults);
|
|
1970
|
+
this.auxClientCache = { key: auxKey, client };
|
|
1971
|
+
return client;
|
|
1972
|
+
}
|
|
1973
|
+
catch (err) {
|
|
1974
|
+
logger.warn("engine.aux_model_build_failed", {
|
|
1975
|
+
auxModelKey: auxKey,
|
|
1976
|
+
error: err.message,
|
|
1977
|
+
});
|
|
1978
|
+
return fallback;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
async runMemoryPipeline(transcript, sessionId, cwd, primaryClient) {
|
|
1039
1982
|
try {
|
|
1983
|
+
// Background calls run on the auxiliary model when configured, so memory
|
|
1984
|
+
// book-keeping doesn't burn the expensive primary model every turn.
|
|
1985
|
+
// settings.memories.extractionModel (if set + valid) overrides the aux
|
|
1986
|
+
// model specifically for memory extraction (TODO 8.1).
|
|
1987
|
+
const llmClient = await this.resolveExtractionClient(primaryClient);
|
|
1040
1988
|
// Only run memory extraction for substantive sessions. The previous
|
|
1041
1989
|
// threshold of 4 user+assistant messages was low enough that two-line
|
|
1042
1990
|
// exchanges ("what's the time?" / "noon") triggered a full LLM
|
|
@@ -1069,12 +2017,16 @@ export class Engine {
|
|
|
1069
2017
|
tools: [],
|
|
1070
2018
|
maxTokens: 1024,
|
|
1071
2019
|
recordUsage: false,
|
|
1072
|
-
|
|
2020
|
+
reasoning: { mode: "off" },
|
|
1073
2021
|
});
|
|
1074
2022
|
return resp.text;
|
|
1075
2023
|
},
|
|
1076
2024
|
runDream: async ({ systemPrompt, userPrompt, projectDir }) => this.runDreamLoop({ systemPrompt, userPrompt, projectDir, llmClient, sessionId }),
|
|
1077
2025
|
projectDir: cwd,
|
|
2026
|
+
// settings.memories.maxCount caps memories accepted per extraction;
|
|
2027
|
+
// autoExtract=false turns the extractor off (summaries/dream stay).
|
|
2028
|
+
maxCount: this.readMemoriesConfig()?.maxCount,
|
|
2029
|
+
autoExtract: this.readMemoriesConfig()?.autoExtract,
|
|
1078
2030
|
});
|
|
1079
2031
|
await orchestrator.run(plainMessages, sessionId);
|
|
1080
2032
|
}
|
|
@@ -1103,117 +2055,20 @@ export class Engine {
|
|
|
1103
2055
|
* bailed before the first LLM call (e.g. registry missing the tools).
|
|
1104
2056
|
*/
|
|
1105
2057
|
async runDreamLoop(opts) {
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
return false;
|
|
1118
|
-
}
|
|
1119
|
-
// Strip RegisteredTool down to the shape createMessage expects.
|
|
1120
|
-
const toolDefs = memoryTools.map((t) => ({
|
|
1121
|
-
name: t.name,
|
|
1122
|
-
description: t.description,
|
|
1123
|
-
inputSchema: t.inputSchema,
|
|
1124
|
-
}));
|
|
1125
|
-
const toolCtx = {
|
|
1126
|
-
...this.buildToolContext(),
|
|
1127
|
-
cwd: opts.projectDir ?? process.cwd(),
|
|
1128
|
-
};
|
|
1129
|
-
const messages = [{ role: "user", content: opts.userPrompt }];
|
|
1130
|
-
let writeBudget = MAX_WRITES;
|
|
1131
|
-
for (let turn = 0; turn < MAX_TURNS; turn++) {
|
|
1132
|
-
const resp = await opts.llmClient.createMessage({
|
|
1133
|
-
systemPrompt: opts.systemPrompt,
|
|
1134
|
-
messages,
|
|
1135
|
-
tools: toolDefs,
|
|
1136
|
-
maxTokens: 2048,
|
|
1137
|
-
recordUsage: false,
|
|
1138
|
-
thinking: "disabled",
|
|
1139
|
-
});
|
|
1140
|
-
if (resp.toolCalls.length === 0) {
|
|
1141
|
-
logger.info("memory.auto_dream_finished", {
|
|
1142
|
-
sessionId: opts.sessionId,
|
|
1143
|
-
turn,
|
|
1144
|
-
finalText: resp.text.slice(0, 500),
|
|
1145
|
-
});
|
|
1146
|
-
return true;
|
|
1147
|
-
}
|
|
1148
|
-
// Echo the assistant turn back into the conversation so subsequent
|
|
1149
|
-
// turns see the tool_use ids they need to reference.
|
|
1150
|
-
const assistantContent = [];
|
|
1151
|
-
if (resp.text)
|
|
1152
|
-
assistantContent.push({ type: "text", text: resp.text });
|
|
1153
|
-
for (const tc of resp.toolCalls) {
|
|
1154
|
-
assistantContent.push({
|
|
1155
|
-
type: "tool_use",
|
|
1156
|
-
id: tc.id,
|
|
1157
|
-
name: tc.toolName,
|
|
1158
|
-
input: tc.args,
|
|
1159
|
-
});
|
|
1160
|
-
}
|
|
1161
|
-
messages.push({ role: "assistant", content: assistantContent });
|
|
1162
|
-
// Dispatch every tool call requested in this turn.
|
|
1163
|
-
const toolResults = [];
|
|
1164
|
-
for (const tc of resp.toolCalls) {
|
|
1165
|
-
const result = await this.dispatchDreamTool(tc, toolCtx, () => {
|
|
1166
|
-
if (writeBudget <= 0)
|
|
1167
|
-
return false;
|
|
1168
|
-
writeBudget--;
|
|
1169
|
-
return true;
|
|
1170
|
-
});
|
|
1171
|
-
toolResults.push({
|
|
1172
|
-
type: "tool_result",
|
|
1173
|
-
tool_use_id: tc.id,
|
|
1174
|
-
content: result,
|
|
1175
|
-
});
|
|
1176
|
-
}
|
|
1177
|
-
messages.push({ role: "user", content: toolResults });
|
|
1178
|
-
}
|
|
1179
|
-
logger.warn("memory.auto_dream_hit_turn_cap", {
|
|
2058
|
+
// The loop body now lives in services/dream-consolidation.ts so it can
|
|
2059
|
+
// also be driven from the desktop host's manual "整理 / Dream" trigger.
|
|
2060
|
+
// The orchestrator built systemPrompt/userPrompt from this engine's
|
|
2061
|
+
// MemoryManager already; runDreamConsolidation rebuilds them from the same
|
|
2062
|
+
// projectDir, so passing them here would be redundant — we just hand it the
|
|
2063
|
+
// tool registry + a memory-scoped tool context.
|
|
2064
|
+
const { ran } = await runDreamConsolidation({
|
|
2065
|
+
llmClient: opts.llmClient,
|
|
2066
|
+
toolRegistry: this.toolRegistry,
|
|
2067
|
+
toolContext: this.buildToolContext(),
|
|
2068
|
+
projectDir: opts.projectDir,
|
|
1180
2069
|
sessionId: opts.sessionId,
|
|
1181
|
-
maxTurns: MAX_TURNS,
|
|
1182
2070
|
});
|
|
1183
|
-
return
|
|
1184
|
-
}
|
|
1185
|
-
/**
|
|
1186
|
-
* Execute one memory tool call inside the dream loop. Enforces the two
|
|
1187
|
-
* dream-loop invariants the prompt also states:
|
|
1188
|
-
* - Only the 4 memory tools are dispatchable.
|
|
1189
|
-
* - Save/Delete in "user" scope is refused (returned as a tool error)
|
|
1190
|
-
* because dream runs without an interactive permission backend.
|
|
1191
|
-
*/
|
|
1192
|
-
async dispatchDreamTool(tc, ctx, consumeWriteBudget) {
|
|
1193
|
-
const allowed = new Set(["MemoryList", "MemoryRead", "MemorySave", "MemoryDelete"]);
|
|
1194
|
-
if (!allowed.has(tc.toolName)) {
|
|
1195
|
-
return `Error: tool "${tc.toolName}" is not available in the dream loop`;
|
|
1196
|
-
}
|
|
1197
|
-
const isWrite = tc.toolName === "MemorySave" || tc.toolName === "MemoryDelete";
|
|
1198
|
-
if (isWrite) {
|
|
1199
|
-
const scope = tc.args?.scope;
|
|
1200
|
-
if (scope !== "dream") {
|
|
1201
|
-
return (`Error: dream loop may only write to scope "dream", got "${scope}". ` +
|
|
1202
|
-
`User-scope changes require interactive permission, which is not available here.`);
|
|
1203
|
-
}
|
|
1204
|
-
if (!consumeWriteBudget()) {
|
|
1205
|
-
return "Error: dream write budget exhausted — stop calling write tools and summarize instead.";
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
try {
|
|
1209
|
-
const result = await this.toolRegistry.executeTool(tc.toolName, tc.args, { ctx });
|
|
1210
|
-
if (result.isError)
|
|
1211
|
-
return result.error ?? `Error executing ${tc.toolName}`;
|
|
1212
|
-
return result.result ?? "";
|
|
1213
|
-
}
|
|
1214
|
-
catch (err) {
|
|
1215
|
-
return `Error executing ${tc.toolName}: ${err.message}`;
|
|
1216
|
-
}
|
|
2071
|
+
return ran;
|
|
1217
2072
|
}
|
|
1218
2073
|
getToolRegistry() {
|
|
1219
2074
|
return this.toolRegistry;
|
|
@@ -1222,30 +2077,62 @@ export class Engine {
|
|
|
1222
2077
|
* Switch the active model by pool key. Takes effect on the next run() call.
|
|
1223
2078
|
* Returns the new model entry.
|
|
1224
2079
|
*
|
|
1225
|
-
* Persists settings.
|
|
2080
|
+
* Persists settings.defaults.text (= the connection id / pool key) so the
|
|
1226
2081
|
* next process startup defaults to the same model — without this, switches
|
|
1227
2082
|
* only live in memory and every restart reverts to the previously persisted
|
|
1228
|
-
*
|
|
2083
|
+
* defaults.text.
|
|
1229
2084
|
*/
|
|
1230
2085
|
switchModel(key) {
|
|
1231
2086
|
const entry = this.modelPool.switch(key);
|
|
1232
|
-
|
|
2087
|
+
// LLMConfig is pure model identity now — rotate it wholesale. Cross-model
|
|
2088
|
+
// runtime knobs (temperature/timeout/retryMaxAttempts/imageDetail) live on
|
|
2089
|
+
// this.config.clientDefaults and survive the switch untouched.
|
|
2090
|
+
const nextLlm = this.modelPool.toLLMConfig(entry);
|
|
1233
2091
|
this.config = { ...this.config, llm: nextLlm };
|
|
1234
|
-
this.persistActiveModel(entry
|
|
2092
|
+
this.persistActiveModel(entry);
|
|
1235
2093
|
return entry;
|
|
1236
2094
|
}
|
|
2095
|
+
/**
|
|
2096
|
+
* Zero a session's cumulative token/cache usage on disk. Called on a model
|
|
2097
|
+
* switch: a different model has its own prompt cache, so the accumulated
|
|
2098
|
+
* cache-hit stats from the prior model are no longer meaningful. The next
|
|
2099
|
+
* run's baseline (snapshotted from state.tokenUsage) then starts from zero.
|
|
2100
|
+
*/
|
|
2101
|
+
resetSessionUsage(sessionId) {
|
|
2102
|
+
const zero = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
2103
|
+
// If the session is mid-run right now, update its live state so the next
|
|
2104
|
+
// turn-boundary write doesn't re-fold a stale baseline.
|
|
2105
|
+
if (this.activeRunSession?.state.sessionId === sessionId) {
|
|
2106
|
+
this.activeRunSession.state.tokenUsage = { ...zero };
|
|
2107
|
+
}
|
|
2108
|
+
// Persist to disk so a reload / next run picks up the reset.
|
|
2109
|
+
if (this.sessionManager.exists(sessionId)) {
|
|
2110
|
+
try {
|
|
2111
|
+
const bundle = this.sessionManager.resume(sessionId);
|
|
2112
|
+
bundle.state.tokenUsage = { ...zero };
|
|
2113
|
+
this.sessionManager.saveState(bundle.state);
|
|
2114
|
+
}
|
|
2115
|
+
catch {
|
|
2116
|
+
// Session not resumable (never persisted yet) — the in-memory reset
|
|
2117
|
+
// above covers the live case; nothing else to do.
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
1237
2121
|
/**
|
|
1238
2122
|
* Write the active model selection to ~/.code-shell/settings.json.
|
|
1239
2123
|
*
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1243
|
-
*
|
|
1244
|
-
* undefined when the entry resolves via providerCatalog.
|
|
2124
|
+
* Persists settings.defaults.text = entry.key (the connection id == pool
|
|
2125
|
+
* key). That is the single field the boot path reads to restore the active
|
|
2126
|
+
* text model on the next startup (see ctor: settings.defaults.text → pool
|
|
2127
|
+
* switch). No legacy activeKey/model.* mirror is written.
|
|
1245
2128
|
*/
|
|
1246
|
-
persistActiveModel(entry
|
|
2129
|
+
persistActiveModel(entry) {
|
|
1247
2130
|
try {
|
|
1248
|
-
|
|
2131
|
+
// userHome() (not raw homedir()) so a test that sets process.env.HOME to
|
|
2132
|
+
// a tmpdir gets its writes isolated too — the SettingsManager reader
|
|
2133
|
+
// already honors HOME; the writer must match or tests pollute the real
|
|
2134
|
+
// ~/.code-shell/settings.json (this happened: an A-key/model-a leak).
|
|
2135
|
+
const dir = join(userHome(), ".code-shell");
|
|
1249
2136
|
const file = join(dir, "settings.json");
|
|
1250
2137
|
mkdirSync(dir, { recursive: true });
|
|
1251
2138
|
let existing = {};
|
|
@@ -1258,29 +2145,30 @@ export class Engine {
|
|
|
1258
2145
|
return;
|
|
1259
2146
|
}
|
|
1260
2147
|
}
|
|
1261
|
-
const
|
|
1262
|
-
? existing.
|
|
2148
|
+
const prevDefaults = typeof existing.defaults === "object" && existing.defaults
|
|
2149
|
+
? existing.defaults
|
|
1263
2150
|
: {};
|
|
1264
2151
|
const updated = {
|
|
1265
2152
|
...existing,
|
|
1266
|
-
|
|
1267
|
-
model: {
|
|
1268
|
-
...prevModel,
|
|
1269
|
-
provider: llm.provider,
|
|
1270
|
-
name: entry.model,
|
|
1271
|
-
apiKey: llm.apiKey,
|
|
1272
|
-
baseUrl: llm.baseUrl,
|
|
1273
|
-
},
|
|
2153
|
+
defaults: { ...prevDefaults, text: entry.key },
|
|
1274
2154
|
};
|
|
2155
|
+
// mode 0o600: this writes model.apiKey (plaintext) into settings.json, so
|
|
2156
|
+
// it must be owner-only — same R-1 hardening as SettingsManager/onboarding.
|
|
2157
|
+
// (Third settings.json writer; the R-1 sweep initially missed this one.)
|
|
1275
2158
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
1276
2159
|
const payload = JSON.stringify(updated, null, 2) + "\n";
|
|
1277
|
-
writeFileSync(tmp, payload, "utf-8");
|
|
2160
|
+
writeFileSync(tmp, payload, { encoding: "utf-8", mode: 0o600 });
|
|
1278
2161
|
try {
|
|
1279
2162
|
renameSync(tmp, file);
|
|
1280
2163
|
}
|
|
1281
2164
|
catch {
|
|
1282
|
-
writeFileSync(file, payload, "utf-8");
|
|
2165
|
+
writeFileSync(file, payload, { encoding: "utf-8", mode: 0o600 });
|
|
2166
|
+
}
|
|
2167
|
+
// mode arg only applies on create; tighten an already-existing file too.
|
|
2168
|
+
try {
|
|
2169
|
+
chmodSync(file, 0o600);
|
|
1283
2170
|
}
|
|
2171
|
+
catch { /* best-effort */ }
|
|
1284
2172
|
}
|
|
1285
2173
|
catch (err) {
|
|
1286
2174
|
logger.warn(`persistActiveModel failed: ${err.message}`);
|
|
@@ -1303,6 +2191,82 @@ export class Engine {
|
|
|
1303
2191
|
getConfig() {
|
|
1304
2192
|
return this.config;
|
|
1305
2193
|
}
|
|
2194
|
+
/**
|
|
2195
|
+
* Config hot-reload "layer 2": merge a disk-default config patch into this
|
|
2196
|
+
* ALREADY-RUNNING session's `this.config`, reload settings hooks, and
|
|
2197
|
+
* incrementally connect any newly-added MCP servers. Applied at the next
|
|
2198
|
+
* turn boundary — an in-flight turn is NOT interrupted: it keeps using the
|
|
2199
|
+
* PromptComposer it was built with, and the next turn rebuilds the composer
|
|
2200
|
+
* from the freshly-merged config (composer is rebuilt per-turn).
|
|
2201
|
+
*
|
|
2202
|
+
* `version` is a monotonic counter from the server: stale (<=last applied)
|
|
2203
|
+
* payloads are dropped so out-of-order reload deliveries can't let an older
|
|
2204
|
+
* config clobber a newer one (Q5).
|
|
2205
|
+
*
|
|
2206
|
+
* MCP: reconciles the shared MCP pool against the new disk-default server
|
|
2207
|
+
* set. Added servers connect idempotently; removed/disabled servers are
|
|
2208
|
+
* disconnected and their registered MCP tools are unregistered so plugin
|
|
2209
|
+
* disable takes effect without an Electron restart.
|
|
2210
|
+
*
|
|
2211
|
+
* Preset (#2): a preset hot-reload re-resolves `this.preset` so the next-turn
|
|
2212
|
+
* PromptComposer picks up the new preset's system prompt / behavior — that's
|
|
2213
|
+
* the main user-visible preset effect and it IS hot. The toolRegistry's
|
|
2214
|
+
* builtin tool SET, however, is ctor-frozen (and may be shared via runtime):
|
|
2215
|
+
* it is NOT rebuilt here. So a preset change that alters the builtin tool set
|
|
2216
|
+
* (e.g. general → terminal-coding adds LSP/Brief) only takes effect on the
|
|
2217
|
+
* next session restart; we log a warning when that case is detected.
|
|
2218
|
+
*
|
|
2219
|
+
* disk-default-vs-slice caveat (#8): the patch carries pure DISK-default
|
|
2220
|
+
* values (preset/customSystemPrompt/appendSystemPrompt/responseLanguage/
|
|
2221
|
+
* userProfile — see diskDefaultsFrom). Spreading them here OVERRIDES any
|
|
2222
|
+
* per-request slice override of the same field. This is correct for the
|
|
2223
|
+
* desktop host today (its per-request slice only carries permissionMode+cwd,
|
|
2224
|
+
* which are excluded from the disk patch). A future host that sets
|
|
2225
|
+
* slice.preset (or the other prompt fields) per-request MUST exclude those
|
|
2226
|
+
* from the reload patch — or track per-request overrides separately — or this
|
|
2227
|
+
* reload will clobber them back to disk values.
|
|
2228
|
+
*/
|
|
2229
|
+
refreshRuntimeConfig(patch, version) {
|
|
2230
|
+
if (version <= this.lastAppliedConfigVersion)
|
|
2231
|
+
return;
|
|
2232
|
+
const prevServers = this.config.mcpServers ?? {};
|
|
2233
|
+
const prevPresetName = this.preset.name;
|
|
2234
|
+
this.config = { ...this.config, ...patch };
|
|
2235
|
+
// #2: re-resolve the prompt-affecting preset so the next-turn PromptComposer
|
|
2236
|
+
// (rebuilt per turn from this.preset) reflects the new preset's system
|
|
2237
|
+
// prompt / behavior. Only when the preset actually changed.
|
|
2238
|
+
if (patch.preset !== undefined && patch.preset !== prevPresetName) {
|
|
2239
|
+
const nextPreset = resolveAgentPreset(this.config.preset);
|
|
2240
|
+
// The builtin tool SET is ctor-frozen and may be shared via runtime — we
|
|
2241
|
+
// do NOT rebuild it here. If the new preset implies a different builtin
|
|
2242
|
+
// tool set, that part of the change only lands on session restart.
|
|
2243
|
+
const prevTools = resolveBuiltinToolNames({ preset: prevPresetName }).slice().sort().join(",");
|
|
2244
|
+
const nextTools = resolveBuiltinToolNames({ preset: nextPreset.name }).slice().sort().join(",");
|
|
2245
|
+
if (prevTools !== nextTools) {
|
|
2246
|
+
logger.warn("engine.preset_reload.tool_set_change_needs_restart", {
|
|
2247
|
+
from: prevPresetName,
|
|
2248
|
+
to: nextPreset.name,
|
|
2249
|
+
note: "preset system prompt hot-reloaded; builtin tool-set change takes effect on session restart",
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
this.preset = nextPreset;
|
|
2253
|
+
}
|
|
2254
|
+
this.reloadHooks();
|
|
2255
|
+
if (patch.mcpServers && this.mcpManager) {
|
|
2256
|
+
// Fire-and-forget reconcile (connect added / disconnect removed servers).
|
|
2257
|
+
// It must NOT surface as an unhandled rejection: a single flaky server
|
|
2258
|
+
// that fails to connect/disconnect during hot-reload would otherwise
|
|
2259
|
+
// crash the host process (or be silently swallowed). Catch + log so the
|
|
2260
|
+
// reconcile is best-effort and the next reload can retry.
|
|
2261
|
+
void this.mcpManager.reconcile(patch.mcpServers, this).catch((err) => {
|
|
2262
|
+
logger.error("engine.mcp_reconcile_failed", {
|
|
2263
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2264
|
+
version,
|
|
2265
|
+
});
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
this.lastAppliedConfigVersion = version;
|
|
2269
|
+
}
|
|
1306
2270
|
/**
|
|
1307
2271
|
* Inject context into a session's transcript without triggering a LLM turn.
|
|
1308
2272
|
* The injected content appears as an assistant message so the LLM can see it
|
|
@@ -1312,6 +2276,55 @@ export class Engine {
|
|
|
1312
2276
|
* engine.run() call for this session picks up the injected content
|
|
1313
2277
|
* instead of a stale snapshot from the previous run.
|
|
1314
2278
|
*/
|
|
2279
|
+
/**
|
|
2280
|
+
* Read a session's persisted active goal WITHOUT resuming it (cheap — reads
|
|
2281
|
+
* only state.json via SessionManager.readActiveGoal). The desktop host calls
|
|
2282
|
+
* this on session load to re-surface the goal block + its Cancel button: a
|
|
2283
|
+
* persistent goal lives only in state.activeGoal and is never replayed from
|
|
2284
|
+
* the transcript, so after a reload of an aborted goal run the UI would
|
|
2285
|
+
* otherwise show nothing (the "goal 还在但页面不显示、取消不了" bug). Returns
|
|
2286
|
+
* undefined when the session is unknown or has no active goal.
|
|
2287
|
+
*/
|
|
2288
|
+
getGoal(sessionId) {
|
|
2289
|
+
return this.sessionManager.readActiveGoal(sessionId);
|
|
2290
|
+
}
|
|
2291
|
+
/**
|
|
2292
|
+
* Clear a session's persisted active goal (CC `/goal clear`). Works whether
|
|
2293
|
+
* the session is idle or its goal run is in flight: it wipes
|
|
2294
|
+
* `state.activeGoal` (so the next bare send won't re-inherit it) and, if a
|
|
2295
|
+
* goal hook is currently registered for this engine, unregisters it so an
|
|
2296
|
+
* in-flight run can stop instead of being re-blocked by the now-cleared goal.
|
|
2297
|
+
* Returns true if a goal was actually cleared. Idempotent — clearing a
|
|
2298
|
+
* session with no active goal is a no-op returning false.
|
|
2299
|
+
*/
|
|
2300
|
+
clearGoal(sessionId) {
|
|
2301
|
+
if (!this.sessionManager.exists(sessionId))
|
|
2302
|
+
return false;
|
|
2303
|
+
// Prefer the LIVE run's bundle when it's this session: clearing its
|
|
2304
|
+
// in-RAM state.activeGoal is what stops the run loop from writing the goal
|
|
2305
|
+
// back on its next saveState. A fresh resume() copy would be cleared and
|
|
2306
|
+
// persisted, but the running loop's own detached bundle still holds the
|
|
2307
|
+
// goal and resurrects it — the stale-write-back race. Falls back to a
|
|
2308
|
+
// resumed copy when no run of this session is currently in flight.
|
|
2309
|
+
const live = this.activeRunSession && this.activeRunSession.state.sessionId === sessionId
|
|
2310
|
+
? this.activeRunSession
|
|
2311
|
+
: null;
|
|
2312
|
+
const session = live ?? this.sessionManager.resume(sessionId);
|
|
2313
|
+
const had = session.state.activeGoal !== undefined;
|
|
2314
|
+
if (had) {
|
|
2315
|
+
session.state.activeGoal = undefined;
|
|
2316
|
+
this.sessionManager.saveState(session.state);
|
|
2317
|
+
}
|
|
2318
|
+
// If THIS session's goal run is in flight, drop its stop hook so the
|
|
2319
|
+
// current run can terminate (the closure-held goal would otherwise keep
|
|
2320
|
+
// re-blocking). The run's own `finally` also unregisters; double-unregister
|
|
2321
|
+
// is safe (set delete is idempotent).
|
|
2322
|
+
if (this.activeGoalHook && this.lastSessionId === sessionId) {
|
|
2323
|
+
this.hooks.unregister("on_stop", this.activeGoalHook);
|
|
2324
|
+
this.activeGoalHook = null;
|
|
2325
|
+
}
|
|
2326
|
+
return had;
|
|
2327
|
+
}
|
|
1315
2328
|
injectContext(sessionId, content) {
|
|
1316
2329
|
const session = this.sessionManager.resume(sessionId);
|
|
1317
2330
|
session.transcript.appendMessage("assistant", content);
|
|
@@ -1332,7 +2345,6 @@ export class Engine {
|
|
|
1332
2345
|
if (!this.lastContextManager || !sessionId) {
|
|
1333
2346
|
return { before: 0, after: 0, strategy: "none (no active session)" };
|
|
1334
2347
|
}
|
|
1335
|
-
const { estimateTokens } = require("../context/compaction.js");
|
|
1336
2348
|
const sourceMessages = this.compactedMessagesBySession.get(sessionId) ??
|
|
1337
2349
|
this.sessionManager.resume(sessionId).transcript.toMessages();
|
|
1338
2350
|
const before = estimateTokens(sourceMessages);
|
|
@@ -1354,7 +2366,7 @@ export class Engine {
|
|
|
1354
2366
|
}
|
|
1355
2367
|
getSettingsManager() {
|
|
1356
2368
|
if (!this.settingsManager) {
|
|
1357
|
-
this.settingsManager = new SettingsManager(this.config.cwd, this.config.settingsScope ?? "project");
|
|
2369
|
+
this.settingsManager = new SettingsManager(this.config.cwd, this.config.settingsScope ?? "project", this.config.projectTrusted !== false);
|
|
1358
2370
|
}
|
|
1359
2371
|
return this.settingsManager;
|
|
1360
2372
|
}
|
|
@@ -1406,7 +2418,7 @@ export class Engine {
|
|
|
1406
2418
|
rules.push({ tool: "Bash", decision: "allow" });
|
|
1407
2419
|
}
|
|
1408
2420
|
try {
|
|
1409
|
-
const settingsManager = new SettingsManager(cwd, this.config.settingsScope ?? "project");
|
|
2421
|
+
const settingsManager = new SettingsManager(cwd, this.config.settingsScope ?? "project", this.config.projectTrusted !== false);
|
|
1410
2422
|
const settings = settingsManager.get();
|
|
1411
2423
|
if (settings.permissions?.rules?.length) {
|
|
1412
2424
|
rules.unshift(...settings.permissions.rules);
|
|
@@ -1461,6 +2473,26 @@ export class Engine {
|
|
|
1461
2473
|
getPermissionMode() {
|
|
1462
2474
|
return this.config.permissionMode ?? "acceptEdits";
|
|
1463
2475
|
}
|
|
2476
|
+
/**
|
|
2477
|
+
* Extend the in-flight run's turn ceiling and/or goal budgets (TODO 3.1 —
|
|
2478
|
+
* 运行中续轮/加预算). No-op (returns null) when no run is active. Lets a user
|
|
2479
|
+
* keep an unattended goal going past its original cap instead of restarting.
|
|
2480
|
+
*/
|
|
2481
|
+
extendGoalRun(opts) {
|
|
2482
|
+
if (!this.activeTurnLoop)
|
|
2483
|
+
return null;
|
|
2484
|
+
return this.activeTurnLoop.extend(opts);
|
|
2485
|
+
}
|
|
2486
|
+
/**
|
|
2487
|
+
* The effective permission rules for the current mode + cwd (TODO 5.1) —
|
|
2488
|
+
* preset defaults + mode-derived + settings.permissions.rules, in the same
|
|
2489
|
+
* order the classifier evaluates them. Exposed read-only so `/permissions`
|
|
2490
|
+
* (and any UI) can list what's actually in force. Pure read; builds the same
|
|
2491
|
+
* rule set buildPermissionConfig does, without constructing a backend.
|
|
2492
|
+
*/
|
|
2493
|
+
getPermissionRules() {
|
|
2494
|
+
return this.buildPermissionConfig(this.getPermissionMode(), this.config.cwd ?? process.cwd()).rules;
|
|
2495
|
+
}
|
|
1464
2496
|
/**
|
|
1465
2497
|
* Toggle plan mode directly. Called by the Plan tool (Task 7) via ToolContext.engine.
|
|
1466
2498
|
* Also syncs permissionMode to keep both fields consistent.
|
|
@@ -1477,36 +2509,275 @@ export class Engine {
|
|
|
1477
2509
|
this.planMode = value;
|
|
1478
2510
|
}
|
|
1479
2511
|
}
|
|
2512
|
+
/**
|
|
2513
|
+
* Block until a background agent's state changes (finishes / its result is
|
|
2514
|
+
* enqueued) or `signal` aborts. Resolves `true` if aborted, `false` on a
|
|
2515
|
+
* change. The caller re-checks `hasRunningForSession` after each wake, so a
|
|
2516
|
+
* spurious wake (another session's agent) just loops again.
|
|
2517
|
+
*
|
|
2518
|
+
* Subscribes to BOTH the registry AND the notification queue — and that's
|
|
2519
|
+
* load-bearing, not belt-and-suspenders. A completing agent calls
|
|
2520
|
+
* `markCompleted` (registry notify) and only THEN `enqueue` (queue notify),
|
|
2521
|
+
* as two separate statements. If we woke on the registry notify alone, the
|
|
2522
|
+
* loop could re-check, see no running agents, and `drainAll` BEFORE the
|
|
2523
|
+
* result was enqueued — silently losing the last agent's output. Waking on
|
|
2524
|
+
* the queue notify guarantees the item is already in the bucket. But a
|
|
2525
|
+
* *cancelled* agent marks-but-never-enqueues (by design), so we also need
|
|
2526
|
+
* the registry notify or a final cancel would hang the wait forever. Hence
|
|
2527
|
+
* both. Subscribe-before-await closes the check/wait race either way.
|
|
2528
|
+
*/
|
|
2529
|
+
waitForBackgroundAgentChange(_sessionId, signal) {
|
|
2530
|
+
if (signal?.aborted)
|
|
2531
|
+
return Promise.resolve(true);
|
|
2532
|
+
return new Promise((resolve) => {
|
|
2533
|
+
let settled = false;
|
|
2534
|
+
const finish = (aborted) => {
|
|
2535
|
+
if (settled)
|
|
2536
|
+
return;
|
|
2537
|
+
settled = true;
|
|
2538
|
+
unsubRegistry();
|
|
2539
|
+
unsubQueue();
|
|
2540
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2541
|
+
resolve(aborted);
|
|
2542
|
+
};
|
|
2543
|
+
const onAbort = () => finish(true);
|
|
2544
|
+
const unsubRegistry = asyncAgentRegistry.subscribe(() => finish(false));
|
|
2545
|
+
const unsubQueue = notificationQueue.subscribe(() => finish(false));
|
|
2546
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2547
|
+
});
|
|
2548
|
+
}
|
|
2549
|
+
/**
|
|
2550
|
+
* Like waitForBackgroundAgentChange but with no abort signal and a hard
|
|
2551
|
+
* timeout. Resolves `true` on a registry/queue change, `false` if `timeoutMs`
|
|
2552
|
+
* elapses first. Used only by the headless abort-drain cleanup, where we want
|
|
2553
|
+
* to catch a completing agent's just-about-to-enqueue notification without
|
|
2554
|
+
* risking a permanent hang on an agent that never completes.
|
|
2555
|
+
*/
|
|
2556
|
+
waitForBackgroundAgentChangeOrTimeout(_sessionId, timeoutMs) {
|
|
2557
|
+
return new Promise((resolve) => {
|
|
2558
|
+
let settled = false;
|
|
2559
|
+
const finish = (changed) => {
|
|
2560
|
+
if (settled)
|
|
2561
|
+
return;
|
|
2562
|
+
settled = true;
|
|
2563
|
+
clearTimeout(timer);
|
|
2564
|
+
unsubRegistry();
|
|
2565
|
+
unsubQueue();
|
|
2566
|
+
resolve(changed);
|
|
2567
|
+
};
|
|
2568
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
2569
|
+
const unsubRegistry = asyncAgentRegistry.subscribe(() => finish(true));
|
|
2570
|
+
const unsubQueue = notificationQueue.subscribe(() => finish(true));
|
|
2571
|
+
});
|
|
2572
|
+
}
|
|
1480
2573
|
/**
|
|
1481
2574
|
* Sub-agent role registry for the given cwd, memoized per-cwd so the
|
|
1482
2575
|
* directory is read once rather than every turn. A new cwd (e.g. via
|
|
1483
2576
|
* run({ cwd })) reloads.
|
|
1484
2577
|
*/
|
|
1485
2578
|
getAgentDefinitions(cwd) {
|
|
1486
|
-
|
|
1487
|
-
|
|
2579
|
+
const disabledAgents = this.readDisabledAgents(cwd);
|
|
2580
|
+
const disabledPlugins = this.readDisabledLists().disabledPlugins;
|
|
2581
|
+
const disabledKey = [...disabledAgents, "::", ...disabledPlugins]
|
|
2582
|
+
.slice()
|
|
2583
|
+
.sort()
|
|
2584
|
+
.join(" ");
|
|
2585
|
+
if (this.agentDefsCache?.cwd !== cwd ||
|
|
2586
|
+
this.agentDefsCache.disabledKey !== disabledKey) {
|
|
2587
|
+
this.agentDefsCache = {
|
|
2588
|
+
cwd,
|
|
2589
|
+
disabledKey,
|
|
2590
|
+
reg: loadAgentDefinitionsForCwd(cwd, disabledAgents, disabledPlugins),
|
|
2591
|
+
};
|
|
1488
2592
|
}
|
|
1489
2593
|
return this.agentDefsCache.reg;
|
|
1490
2594
|
}
|
|
2595
|
+
/**
|
|
2596
|
+
* Read settings.disabledAgents, folded with the project's
|
|
2597
|
+
* capabilityOverrides.agents overlay for `cwd`. Unlike disabledSkills,
|
|
2598
|
+
* sub-agents do NOT skip this — a disabled role must stay invisible
|
|
2599
|
+
* everywhere. The overlay lets a project force-enable a globally-disabled
|
|
2600
|
+
* role or force-disable a globally-enabled one (tri-state); read UNMERGED
|
|
2601
|
+
* (getForScope) so inherit survives. No cwd / no overlay → baseline
|
|
2602
|
+
* unchanged. Mirrors readDisabledLists (skills/plugins).
|
|
2603
|
+
*/
|
|
2604
|
+
readDisabledAgents(cwd) {
|
|
2605
|
+
try {
|
|
2606
|
+
const sm = this.getSettingsManager();
|
|
2607
|
+
const settings = sm.get();
|
|
2608
|
+
const baseline = Array.isArray(settings.disabledAgents) ? settings.disabledAgents : [];
|
|
2609
|
+
const overrides = cwd
|
|
2610
|
+
? sm.getForScope("project", cwd).capabilityOverrides
|
|
2611
|
+
: undefined;
|
|
2612
|
+
return effectiveDisabledList(baseline, overrides?.agents);
|
|
2613
|
+
}
|
|
2614
|
+
catch {
|
|
2615
|
+
return [];
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
/**
|
|
2619
|
+
* Read the project's capabilityOverrides.builtin bucket for `cwd`, read
|
|
2620
|
+
* UNMERGED (getForScope) so tri-state inherit survives. Sub-agents skip the
|
|
2621
|
+
* overlay (minimal surface, same as readDisabledLists) — their builtin lists
|
|
2622
|
+
* are already narrowed by resolveChildToolScope. No cwd / error → undefined,
|
|
2623
|
+
* so the caller's baseline builtin lists pass through unchanged.
|
|
2624
|
+
*/
|
|
2625
|
+
readBuiltinOverride(cwd) {
|
|
2626
|
+
if (this.config.isSubAgent === true || !cwd)
|
|
2627
|
+
return undefined;
|
|
2628
|
+
try {
|
|
2629
|
+
const overrides = this.getSettingsManager().getForScope("project", cwd)
|
|
2630
|
+
.capabilityOverrides;
|
|
2631
|
+
return overrides?.builtin;
|
|
2632
|
+
}
|
|
2633
|
+
catch {
|
|
2634
|
+
return undefined;
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
1491
2637
|
/**
|
|
1492
2638
|
* Build a base ToolContext for this Engine. Used by run() (which then
|
|
1493
2639
|
* overlays turn-specific fields like sandbox and subAgentSpawner) and
|
|
1494
2640
|
* by tests that want a ToolContext without a full run() cycle.
|
|
1495
2641
|
*/
|
|
2642
|
+
resolveSandboxWithoutRuntime(config, cwd) {
|
|
2643
|
+
const key = sandboxCacheKey(config, cwd);
|
|
2644
|
+
let cached = this.sandboxCache.get(key);
|
|
2645
|
+
if (!cached) {
|
|
2646
|
+
cached = resolveSandboxBackend(config, cwd);
|
|
2647
|
+
// Mirror EngineRuntime.resolveSandbox: don't cache a rejection, or an
|
|
2648
|
+
// explicit-mode probe that throws stays sticky until process restart even
|
|
2649
|
+
// after the user fixes the config.
|
|
2650
|
+
cached.catch(() => {
|
|
2651
|
+
if (this.sandboxCache.get(key) === cached)
|
|
2652
|
+
this.sandboxCache.delete(key);
|
|
2653
|
+
});
|
|
2654
|
+
this.sandboxCache.set(key, cached);
|
|
2655
|
+
}
|
|
2656
|
+
return cached;
|
|
2657
|
+
}
|
|
2658
|
+
/**
|
|
2659
|
+
* Build the shell env layered onto the Bash tool / background shells (see
|
|
2660
|
+
* mergeShellEnv). Three user-configured sources, merged lowest → highest:
|
|
2661
|
+
*
|
|
2662
|
+
* 1. project `localEnvironment.env` — the per-project "local environment"
|
|
2663
|
+
* panel (DATABASE_URL etc.); the floor, so a project's own panel values
|
|
2664
|
+
* can be overridden by an explicit top-level `env`.
|
|
2665
|
+
* 2. global top-level `env` — ~/.code-shell/settings.json; the
|
|
2666
|
+
* canonical home for API keys (OPENAI_API_KEY) a skill script reads —
|
|
2667
|
+
* configure once, every project's skills get it.
|
|
2668
|
+
* 3. project top-level `env` — .code-shell/settings.json; a project
|
|
2669
|
+
* that wants to override a global key wins.
|
|
2670
|
+
*
|
|
2671
|
+
* Each scope is read UNMERGED so the layering here is the single source of
|
|
2672
|
+
* precedence (getForScope merges nothing). Returns undefined when no layer
|
|
2673
|
+
* contributes a key, so the caller passes it through unchanged for projects
|
|
2674
|
+
* that configure none.
|
|
2675
|
+
*
|
|
2676
|
+
* Sub-agents: a sub-agent is the user's OWN agent doing the user's work
|
|
2677
|
+
* (mirrors Claude Code, where sub-agents inherit the parent environment), so
|
|
2678
|
+
* it now reads the SAME env as the parent. The sub-agent branch is kept as an
|
|
2679
|
+
* explicit seam (`filterSubagentEnv`) rather than removed — a future policy
|
|
2680
|
+
* could narrow what a sub-agent sees (e.g. drop credential secrets) by
|
|
2681
|
+
* changing that one hook; today it passes everything through unchanged.
|
|
2682
|
+
* A no-cwd context still gets nothing (there is genuinely no project to read).
|
|
2683
|
+
*
|
|
2684
|
+
* None of these is filtered through the deny regex (mergeShellEnv): the user
|
|
2685
|
+
* put them there deliberately. The allowlist/deny machinery only guards the
|
|
2686
|
+
* host's process.env from a tainted model exfiltrating it via `env | curl`.
|
|
2687
|
+
*/
|
|
2688
|
+
readShellEnv(cwd) {
|
|
2689
|
+
if (!cwd)
|
|
2690
|
+
return undefined;
|
|
2691
|
+
const merged = {};
|
|
2692
|
+
const layer = (env) => {
|
|
2693
|
+
if (!env)
|
|
2694
|
+
return;
|
|
2695
|
+
for (const [k, v] of Object.entries(env)) {
|
|
2696
|
+
if (typeof v === "string")
|
|
2697
|
+
merged[k] = v;
|
|
2698
|
+
}
|
|
2699
|
+
};
|
|
2700
|
+
try {
|
|
2701
|
+
// The fully-merged settings already apply the scope guard (a 'project'
|
|
2702
|
+
// scope never reads the host ~/.code-shell) and the user < project <
|
|
2703
|
+
// local precedence — so the top-level `env` map read from here is global
|
|
2704
|
+
// values overridden by project values, exactly as specified. We layer
|
|
2705
|
+
// localEnvironment.env *under* it as the floor.
|
|
2706
|
+
const settings = this.getSettingsManager().get();
|
|
2707
|
+
layer(settings.localEnvironment?.env); // floor
|
|
2708
|
+
// Credentials flagged "expose as env var" (Credential.exposeAsEnv). This
|
|
2709
|
+
// is the wiring that was missing — the UI/store recorded the flag but no
|
|
2710
|
+
// code ever injected the secret, so `$FIGMA_TOKEN` was always empty.
|
|
2711
|
+
// Scope mirrors settingsScope so a project-scoped engine never surfaces
|
|
2712
|
+
// the host user's credentials (same isolation contract as top-level env).
|
|
2713
|
+
// Placed below settings.env so an explicit `env` entry can still override.
|
|
2714
|
+
const credScope = (this.config.settingsScope ?? "project") === "full" ? "full" : "project";
|
|
2715
|
+
layer(new CredentialStore(cwd).envExposures(credScope));
|
|
2716
|
+
layer(settings.env); // top-level env (global ⊕ project) wins
|
|
2717
|
+
}
|
|
2718
|
+
catch {
|
|
2719
|
+
return undefined;
|
|
2720
|
+
}
|
|
2721
|
+
const result = this.config.isSubAgent === true ? this.filterSubagentEnv(merged) : merged;
|
|
2722
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
2723
|
+
}
|
|
2724
|
+
/**
|
|
2725
|
+
* Policy seam for what a sub-agent's shell sees. A sub-agent inherits the
|
|
2726
|
+
* parent environment by default (mirrors Claude Code), so this is the
|
|
2727
|
+
* identity function today. It exists so a future policy can narrow the set
|
|
2728
|
+
* (e.g. strip credential `exposeAsEnv` secrets, or allowlist by name) in ONE
|
|
2729
|
+
* place instead of scattering `isSubAgent` checks through readShellEnv.
|
|
2730
|
+
*/
|
|
2731
|
+
filterSubagentEnv(env) {
|
|
2732
|
+
return env;
|
|
2733
|
+
}
|
|
2734
|
+
/**
|
|
2735
|
+
* Read the project's `localEnvironment.setupScripts` for this cwd (the raw
|
|
2736
|
+
* per-platform map). Used by EnterWorktree to run setup once in a freshly
|
|
2737
|
+
* created worktree. Returns undefined for sub-agents / no cwd (same minimal
|
|
2738
|
+
* surface as readShellEnv). The platform selection + run live in
|
|
2739
|
+
* git/worktree.ts; this only fetches the configured scripts.
|
|
2740
|
+
*/
|
|
2741
|
+
readWorktreeSetupScripts(cwd) {
|
|
2742
|
+
if (this.config.isSubAgent === true || !cwd)
|
|
2743
|
+
return undefined;
|
|
2744
|
+
try {
|
|
2745
|
+
const scoped = this.getSettingsManager().getForScope("project", cwd);
|
|
2746
|
+
return scoped.localEnvironment?.setupScripts;
|
|
2747
|
+
}
|
|
2748
|
+
catch {
|
|
2749
|
+
return undefined;
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
1496
2752
|
buildToolContext() {
|
|
1497
2753
|
const { disabledSkills, disabledPlugins } = this.readDisabledLists();
|
|
1498
2754
|
return {
|
|
2755
|
+
shellEnv: this.readShellEnv(this.config.cwd),
|
|
1499
2756
|
cwd: this.config.cwd ?? process.cwd(),
|
|
1500
2757
|
llmConfig: this.config.llm,
|
|
1501
2758
|
modelPool: this.modelPool,
|
|
1502
2759
|
toolRegistry: this.toolRegistry,
|
|
1503
2760
|
askUser: this.config.askUser,
|
|
2761
|
+
browser: this.config.browserBridge,
|
|
2762
|
+
injectCredentialToBrowser: this.config.injectCredentialToBrowser,
|
|
1504
2763
|
isSubAgent: this.config.isSubAgent === true,
|
|
2764
|
+
// Credential tools narrow their disk reads to this scope: a project/
|
|
2765
|
+
// isolated engine (SDK-embedded) must not surface the host user's
|
|
2766
|
+
// ~/.code-shell credentials or credentialUse.autoApprove. "full" (the
|
|
2767
|
+
// host-application default) merges user + project as before.
|
|
2768
|
+
settingsScope: this.config.settingsScope ?? "project",
|
|
1505
2769
|
hooks: this.hooks,
|
|
1506
2770
|
planMode: this.planMode,
|
|
2771
|
+
permissionMode: this.permissionMode,
|
|
1507
2772
|
engine: this,
|
|
1508
2773
|
disabledSkills,
|
|
1509
2774
|
disabledPlugins,
|
|
2775
|
+
skillAllowlist: this.config.skillAllowlist,
|
|
2776
|
+
backgroundShells: backgroundShellManager,
|
|
2777
|
+
// Sub-agents never start background shells (they're short-lived and
|
|
2778
|
+
// their lifecycle ends with the parent turn); unattended automation
|
|
2779
|
+
// opts out via config. Otherwise allowed.
|
|
2780
|
+
allowBackgroundShells: this.config.isSubAgent === true ? false : this.config.allowBackgroundShells !== false,
|
|
1510
2781
|
};
|
|
1511
2782
|
}
|
|
1512
2783
|
/**
|
|
@@ -1522,17 +2793,97 @@ export class Engine {
|
|
|
1522
2793
|
*/
|
|
1523
2794
|
readDisabledLists() {
|
|
1524
2795
|
if (this.config.isSubAgent === true) {
|
|
1525
|
-
return { disabledSkills: [], disabledPlugins: [] };
|
|
2796
|
+
return { disabledSkills: [], disabledPlugins: [], disabledPluginHooks: [] };
|
|
1526
2797
|
}
|
|
2798
|
+
// Shared folding (capability-control/disabled-lists.ts): project
|
|
2799
|
+
// capabilityOverrides over the global baseline + the no-repo whitelist
|
|
2800
|
+
// inversion. Extracted so the MCP merge consumers (engineFactory /
|
|
2801
|
+
// diskDefaultsFrom) fold identically — see that module's doc.
|
|
2802
|
+
return computeEffectiveDisabledLists(this.getSettingsManager(), this.config.cwd);
|
|
2803
|
+
}
|
|
2804
|
+
/**
|
|
2805
|
+
* Public view of the folded disabled lists, for hosts that need the
|
|
2806
|
+
* EFFECTIVE state (e.g. the protocol server's settings hot-reload rebuilds
|
|
2807
|
+
* the plugin-MCP merge per session — a project-level "on" must override the
|
|
2808
|
+
* global disabledPlugins there too).
|
|
2809
|
+
*/
|
|
2810
|
+
getEffectiveDisabledLists() {
|
|
2811
|
+
return this.readDisabledLists();
|
|
2812
|
+
}
|
|
2813
|
+
/**
|
|
2814
|
+
* Public: resolve every known feature flag to its effective boolean (the
|
|
2815
|
+
* settings overlay merged over the compiled-in defaults). Used by the
|
|
2816
|
+
* `config` protocol query so the `/features` command can list flag state.
|
|
2817
|
+
*/
|
|
2818
|
+
getFeatureFlags() {
|
|
2819
|
+
return resolveFeatureFlags(this.readFeatureFlags());
|
|
2820
|
+
}
|
|
2821
|
+
/**
|
|
2822
|
+
* Read the merged `settings.featureFlags` overlay for this cwd. Project
|
|
2823
|
+
* settings override user settings via the normal SettingsManager merge.
|
|
2824
|
+
* Returns undefined (→ all defaults) on any read error or for sub-agents,
|
|
2825
|
+
* so a flag check never throws and a child runs with default behavior.
|
|
2826
|
+
*/
|
|
2827
|
+
readFeatureFlags() {
|
|
2828
|
+
if (this.config.isSubAgent === true)
|
|
2829
|
+
return undefined;
|
|
1527
2830
|
try {
|
|
1528
2831
|
const settings = this.getSettingsManager().get();
|
|
1529
|
-
return
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
2832
|
+
return settings.featureFlags;
|
|
2833
|
+
}
|
|
2834
|
+
catch {
|
|
2835
|
+
return undefined;
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
/**
|
|
2839
|
+
* Read settings.memories ({ maxCount, maxAge, extractionModel, autoExtract }).
|
|
2840
|
+
* Returns undefined on any error or when absent, so the memory pipeline
|
|
2841
|
+
* falls back to its built-in defaults.
|
|
2842
|
+
*/
|
|
2843
|
+
readMemoriesConfig() {
|
|
2844
|
+
try {
|
|
2845
|
+
const settings = this.getSettingsManager().get();
|
|
2846
|
+
return settings.memories;
|
|
1533
2847
|
}
|
|
1534
2848
|
catch {
|
|
1535
|
-
return
|
|
2849
|
+
return undefined;
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
/**
|
|
2853
|
+
* LLM client for memory extraction (TODO 8.1). Prefers
|
|
2854
|
+
* settings.memories.extractionModel when it names a valid pool model;
|
|
2855
|
+
* otherwise falls back to the aux client (which itself falls back to the
|
|
2856
|
+
* passed primary). Build failures fall back too — extraction is best-effort.
|
|
2857
|
+
*/
|
|
2858
|
+
async resolveExtractionClient(primaryClient) {
|
|
2859
|
+
const key = this.readMemoriesConfig()?.extractionModel;
|
|
2860
|
+
if (key) {
|
|
2861
|
+
const entry = this.modelPool.get(key);
|
|
2862
|
+
if (entry) {
|
|
2863
|
+
try {
|
|
2864
|
+
return await createLLMClient(this.modelPool.toLLMConfig(entry), this.config.clientDefaults);
|
|
2865
|
+
}
|
|
2866
|
+
catch (err) {
|
|
2867
|
+
logger.warn("engine.extraction_model_build_failed", {
|
|
2868
|
+
extractionModel: key,
|
|
2869
|
+
error: err.message,
|
|
2870
|
+
});
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
else {
|
|
2874
|
+
logger.warn("engine.extraction_model_missing", { extractionModel: key });
|
|
2875
|
+
}
|
|
1536
2876
|
}
|
|
2877
|
+
return this.resolveAuxClient(primaryClient);
|
|
1537
2878
|
}
|
|
1538
2879
|
}
|
|
2880
|
+
/**
|
|
2881
|
+
* Builtin tool name → the feature flag that gates its visibility. A tool here
|
|
2882
|
+
* is hidden from the LLM when its flag resolves to false. Tools not listed are
|
|
2883
|
+
* unaffected. Kept beside the engine (not in builtin/index) because the flag
|
|
2884
|
+
* read needs the engine's scoped SettingsManager.
|
|
2885
|
+
*/
|
|
2886
|
+
const TOOL_FEATURE_FLAGS = new Map([
|
|
2887
|
+
["WebSearch", "web_search"],
|
|
2888
|
+
["Bash", "shell_tool"],
|
|
2889
|
+
]);
|