@cjhyy/code-shell-core 0.5.0-rc.2 → 0.6.0-rc.10
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 +3 -0
- package/dist/agent/agent-definition-registry.js +3 -1
- package/dist/agent/agent-definition.d.ts +15 -0
- package/dist/agent/agent-definition.js +27 -3
- package/dist/arena/context/context-tools.js +47 -4
- package/dist/arena/ledger.js +9 -1
- package/dist/arena/strategies/utils.d.ts +1 -4
- package/dist/arena/strategies/utils.js +7 -84
- package/dist/automation/cron-expr.d.ts +10 -0
- package/dist/automation/cron-expr.js +23 -5
- package/dist/automation/runner.d.ts +21 -4
- package/dist/automation/runner.js +48 -12
- package/dist/automation/scheduler.d.ts +38 -0
- package/dist/automation/scheduler.js +89 -12
- package/dist/automation/store.js +5 -2
- package/dist/capability-control/disabled-lists.d.ts +25 -0
- package/dist/capability-control/disabled-lists.js +57 -0
- package/dist/capability-control/overlay.d.ts +15 -0
- package/dist/capability-control/overlay.js +27 -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 +15 -1
- package/dist/cli/agent-server-stdio.js +115 -17
- package/dist/cli/agent-server-tcp.js +40 -26
- package/dist/context/compaction.d.ts +86 -0
- package/dist/context/compaction.js +279 -0
- package/dist/context/manager.d.ts +18 -0
- package/dist/context/manager.js +181 -48
- package/dist/context/token-counter.js +13 -0
- 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/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/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 +280 -141
- package/dist/engine/engine.js +1193 -245
- package/dist/engine/friendly-error.d.ts +18 -0
- package/dist/engine/friendly-error.js +63 -0
- package/dist/engine/goal.d.ts +145 -0
- package/dist/engine/goal.js +144 -0
- package/dist/engine/image-policy.d.ts +13 -0
- package/dist/engine/image-policy.js +24 -0
- package/dist/engine/model-connections-pool.d.ts +17 -0
- package/dist/engine/model-connections-pool.js +67 -0
- package/dist/engine/model-facade.d.ts +10 -0
- package/dist/engine/model-facade.js +15 -0
- package/dist/engine/patch-orphaned-tools.js +3 -0
- package/dist/engine/query.js +2 -0
- package/dist/engine/resolve-llm-config.d.ts +16 -0
- package/dist/engine/resolve-llm-config.js +44 -0
- package/dist/engine/runtime.d.ts +2 -0
- package/dist/engine/runtime.js +27 -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-usage.d.ts +31 -0
- package/dist/engine/session-usage.js +81 -0
- package/dist/engine/steer-queue.d.ts +33 -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 -4
- package/dist/engine/turn-loop.js +432 -54
- 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/utils.d.ts +12 -0
- package/dist/git/utils.js +38 -6
- package/dist/git/worktree.d.ts +48 -0
- package/dist/git/worktree.js +86 -10
- package/dist/hooks/goal-stop-hook.d.ts +28 -0
- package/dist/hooks/goal-stop-hook.js +186 -9
- package/dist/hooks/registry.d.ts +8 -0
- package/dist/hooks/registry.js +19 -0
- package/dist/hooks/shell-runner.js +10 -21
- package/dist/index.d.ts +51 -7
- package/dist/index.js +62 -5
- package/dist/llm/capabilities/param-specs.d.ts +14 -0
- package/dist/llm/capabilities/param-specs.js +62 -0
- package/dist/llm/capabilities/rules.js +5 -1
- package/dist/llm/capabilities/types.d.ts +10 -0
- package/dist/llm/client-base.d.ts +28 -1
- package/dist/llm/client-base.js +119 -13
- package/dist/llm/model-cache.js +4 -2
- package/dist/llm/model-pool.d.ts +21 -0
- package/dist/llm/model-pool.js +21 -1
- package/dist/llm/provider-auth.d.ts +41 -0
- package/dist/llm/provider-auth.js +76 -0
- package/dist/llm/provider-catalog.d.ts +4 -0
- package/dist/llm/providers/anthropic.js +64 -14
- package/dist/llm/providers/openai.d.ts +36 -0
- package/dist/llm/providers/openai.js +257 -51
- package/dist/llm/reasoning-setting.d.ts +3 -3
- package/dist/llm/reasoning-setting.js +9 -1
- package/dist/llm/stream-watchdog.js +5 -1
- package/dist/llm/token-counter.js +9 -2
- package/dist/llm/types.d.ts +4 -0
- package/dist/logging/sanitize-messages.js +22 -0
- package/dist/lsp/manager.d.ts +1 -1
- package/dist/lsp/manager.js +40 -10
- package/dist/model-catalog/builtin.d.ts +12 -0
- package/dist/model-catalog/builtin.js +412 -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 +61 -326
- package/dist/plugins/gitOps.d.ts +19 -0
- package/dist/plugins/gitOps.js +73 -4
- package/dist/plugins/installer/checkUpdate.d.ts +16 -0
- package/dist/plugins/installer/checkUpdate.js +32 -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 +5 -2
- package/dist/plugins/installer/codex/convertMcp.js +45 -5
- package/dist/plugins/installer/install.js +25 -1
- package/dist/plugins/installer/installFromArchive.d.ts +43 -0
- package/dist/plugins/installer/installFromArchive.js +134 -0
- package/dist/plugins/installer/installFromSource.js +8 -2
- package/dist/plugins/installer/loadPluginAgents.js +6 -2
- package/dist/plugins/installer/loadPluginMcp.d.ts +9 -2
- package/dist/plugins/installer/loadPluginMcp.js +35 -2
- package/dist/plugins/installer/pruneDisabled.d.ts +24 -0
- package/dist/plugins/installer/pruneDisabled.js +73 -0
- package/dist/plugins/installer/types.d.ts +5 -2
- package/dist/plugins/installer/types.js +1 -0
- package/dist/plugins/installer/uninstall.js +4 -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 +14 -0
- package/dist/plugins/installer/update.js +53 -21
- package/dist/plugins/loadPluginHooks.d.ts +47 -1
- package/dist/plugins/loadPluginHooks.js +73 -1
- package/dist/plugins/marketplaceManager.d.ts +7 -0
- package/dist/plugins/marketplaceManager.js +20 -0
- package/dist/plugins/pluginCommandHook.js +4 -18
- package/dist/plugins/pluginContent.d.ts +30 -0
- package/dist/plugins/pluginContent.js +83 -0
- package/dist/plugins/pluginInstaller.d.ts +13 -0
- package/dist/plugins/pluginInstaller.js +42 -8
- package/dist/plugins/schemas.js +1 -0
- package/dist/plugins/types.d.ts +6 -0
- package/dist/preset/index.d.ts +11 -1
- package/dist/preset/index.js +115 -6
- package/dist/product/types.d.ts +1 -1
- package/dist/prompt/composer.d.ts +32 -0
- package/dist/prompt/composer.js +75 -21
- package/dist/prompt/instruction-scanner.js +5 -3
- package/dist/prompt/section-loader.js +1 -0
- package/dist/prompt/sections/base.md +2 -0
- package/dist/prompt/sections/browser.md +10 -0
- package/dist/prompt/sections/coding.md +4 -0
- package/dist/protocol/chat-session-manager.d.ts +9 -2
- package/dist/protocol/chat-session-manager.js +38 -0
- package/dist/protocol/chat-session.d.ts +69 -1
- package/dist/protocol/chat-session.js +102 -4
- package/dist/protocol/client.d.ts +31 -0
- package/dist/protocol/client.js +44 -0
- package/dist/protocol/server.d.ts +120 -7
- package/dist/protocol/server.js +753 -51
- package/dist/protocol/transport.js +3 -2
- package/dist/protocol/types.d.ts +60 -0
- package/dist/protocol/types.js +16 -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/review/review-prompt.d.ts +28 -0
- package/dist/review/review-prompt.js +81 -0
- package/dist/run/FileRunStore.js +8 -3
- package/dist/run/RunApprovalBackend.js +25 -5
- package/dist/run/RunManager.d.ts +12 -0
- package/dist/run/RunManager.js +35 -0
- package/dist/run/factory.d.ts +1 -1
- package/dist/runtime/background-shell.d.ts +139 -0
- package/dist/runtime/background-shell.js +509 -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 +98 -39
- package/dist/runtime/spawn-common.d.ts +159 -0
- package/dist/runtime/spawn-common.js +404 -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/diagnostics.d.ts +1 -2
- package/dist/services/diagnostics.js +12 -7
- 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 -50
- package/dist/services/session-memory.js +24 -12
- package/dist/session/file-history.d.ts +124 -1
- package/dist/session/file-history.js +222 -6
- package/dist/session/memory.d.ts +116 -2
- package/dist/session/memory.js +250 -28
- package/dist/session/session-manager.d.ts +40 -0
- package/dist/session/session-manager.js +119 -3
- package/dist/session/simple-diff.d.ts +23 -0
- package/dist/session/simple-diff.js +84 -0
- package/dist/session/transcript.d.ts +29 -1
- package/dist/session/transcript.js +56 -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 +9 -2
- package/dist/settings/disk-defaults.js +11 -2
- package/dist/settings/feature-flags.d.ts +64 -0
- package/dist/settings/feature-flags.js +61 -0
- package/dist/settings/manager.d.ts +68 -1
- package/dist/settings/manager.js +266 -29
- package/dist/settings/migrate-config.d.ts +45 -0
- package/dist/settings/migrate-config.js +125 -0
- package/dist/settings/schema-export.d.ts +25 -0
- package/dist/settings/schema-export.js +38 -0
- package/dist/settings/schema.d.ts +1186 -771
- package/dist/settings/schema.js +268 -97
- 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/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 +12 -0
- package/dist/tool-system/builtin/agent-registry.js +8 -0
- package/dist/tool-system/builtin/agent.d.ts +21 -1
- package/dist/tool-system/builtin/agent.js +489 -42
- 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 +0 -15
- 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 +101 -64
- 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/config.js +7 -0
- package/dist/tool-system/builtin/cron.d.ts +7 -0
- package/dist/tool-system/builtin/cron.js +64 -4
- 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 +104 -0
- package/dist/tool-system/builtin/edit.js +20 -16
- 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 +35 -0
- package/dist/tool-system/builtin/generate-image.js +278 -50
- package/dist/tool-system/builtin/generate-video.d.ts +55 -0
- package/dist/tool-system/builtin/generate-video.js +364 -0
- package/dist/tool-system/builtin/glob.js +0 -7
- package/dist/tool-system/builtin/grep.d.ts +9 -0
- package/dist/tool-system/builtin/grep.js +106 -11
- 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 +10 -2
- package/dist/tool-system/builtin/index.js +251 -27
- 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.js +0 -7
- package/dist/tool-system/builtin/powershell.js +8 -2
- package/dist/tool-system/builtin/read.js +10 -10
- 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 +8 -2
- package/dist/tool-system/builtin/tool-search.js +25 -7
- 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/web-fetch.js +12 -2
- package/dist/tool-system/builtin/web-search.js +21 -7
- 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.js +0 -6
- package/dist/tool-system/context.d.ts +164 -6
- package/dist/tool-system/executor.d.ts +3 -1
- package/dist/tool-system/executor.js +197 -90
- package/dist/tool-system/investigation-guard.js +1 -1
- package/dist/tool-system/mcp-manager.d.ts +42 -4
- package/dist/tool-system/mcp-manager.js +203 -20
- package/dist/tool-system/mcp-stdio-diagnostics.d.ts +9 -0
- package/dist/tool-system/mcp-stdio-diagnostics.js +93 -0
- package/dist/tool-system/path-policy.d.ts +5 -0
- package/dist/tool-system/path-policy.js +307 -8
- package/dist/tool-system/permission.d.ts +39 -2
- package/dist/tool-system/permission.js +283 -70
- package/dist/tool-system/plan-mode-allowlist.d.ts +13 -2
- package/dist/tool-system/plan-mode-allowlist.js +24 -2
- package/dist/tool-system/registry.d.ts +1 -0
- package/dist/tool-system/registry.js +16 -2
- package/dist/tool-system/sandbox/index.d.ts +8 -0
- package/dist/tool-system/sandbox/index.js +7 -2
- package/dist/tool-system/sandbox/off.js +7 -1
- package/dist/tool-system/validate-tool-metadata.d.ts +36 -0
- package/dist/tool-system/validate-tool-metadata.js +63 -0
- package/dist/types.d.ts +252 -7
- 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 +48 -0
- package/dist/utils/exec.js +154 -0
- package/dist/utils/json.d.ts +12 -0
- package/dist/utils/json.js +92 -0
- package/dist/utils/theme.d.ts +1 -1
- package/dist/utils/theme.js +1 -1
- package/dist/utils/toolDisplay.js +0 -1
- package/package.json +13 -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,21 +7,29 @@ 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 {
|
|
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 { addCumulativeUsage, cumulativeCacheHitRate, foldRunUsage, normalizeCumulativeUsageCounters, } 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";
|
|
11
18
|
import { BUILTIN_TOOL_GUARDS } from "../tool-system/builtin/index.js";
|
|
12
19
|
import { asyncAgentRegistry } from "../tool-system/builtin/agent-registry.js";
|
|
20
|
+
import { backgroundShellManager } from "../runtime/background-shell.js";
|
|
13
21
|
import { notificationQueue, buildNotificationMessage, } from "../tool-system/builtin/agent-notifications.js";
|
|
14
22
|
import { PermissionClassifier, HeadlessApprovalBackend, AutoApprovalBackend, InteractiveApprovalBackend, getInteractiveApprovalBackend, } from "../tool-system/permission.js";
|
|
15
23
|
import { HookRegistry } from "../hooks/registry.js";
|
|
16
24
|
import { wrapHookMessages } from "../hooks/inject.js";
|
|
17
25
|
import { createGoalStopHook } from "../hooks/goal-stop-hook.js";
|
|
18
|
-
import { normalizeGoal } from "./goal.js";
|
|
26
|
+
import { normalizeGoal, resolveGoalSetAt, resolveMaxTurns, resolveMaxStopBlocks, } from "./goal.js";
|
|
19
27
|
import { loadPluginHooks } from "../plugins/loadPluginHooks.js";
|
|
20
28
|
import { pluginAgentDirs } from "../plugins/installer/loadPluginAgents.js";
|
|
21
29
|
import { patchOrphanedToolUses } from "./patch-orphaned-tools.js";
|
|
22
30
|
import { runShellHook, shellHookMatches } from "../hooks/shell-runner.js";
|
|
23
31
|
import { ContextManager } from "../context/manager.js";
|
|
24
|
-
import { estimateTokens } from "../context/compaction.js";
|
|
32
|
+
import { estimateTokens, clampContextRatios as clampContextRatiosImpl, } from "../context/compaction.js";
|
|
25
33
|
import { PLAN_MODE_ALLOWED_TOOLS } from "../tool-system/plan-mode-allowlist.js";
|
|
26
34
|
import { PromptComposer } from "../prompt/composer.js";
|
|
27
35
|
import { SessionManager } from "../session/session-manager.js";
|
|
@@ -32,26 +40,28 @@ import { sanitizeContent, sanitizeTaskString } from "../logging/sanitize-message
|
|
|
32
40
|
import { TurnLoop } from "./turn-loop.js";
|
|
33
41
|
import { MCPManager } from "../tool-system/mcp-manager.js";
|
|
34
42
|
import { SettingsManager, userHome } from "../settings/manager.js";
|
|
35
|
-
import {
|
|
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";
|
|
36
47
|
import { FileHistory } from "../session/file-history.js";
|
|
37
|
-
import {
|
|
48
|
+
import { patchBackupTargets } from "../tool-system/builtin/apply-patch/backup-targets.js";
|
|
49
|
+
import { resolveSandboxBackend, } from "../tool-system/sandbox/index.js";
|
|
38
50
|
import { resolveAgentPreset, resolveBuiltinToolNames, } from "../preset/index.js";
|
|
39
51
|
import { ModelPool } from "../llm/model-pool.js";
|
|
40
52
|
import { AgentDefinitionRegistry } from "../agent/agent-definition-registry.js";
|
|
41
|
-
import { ProviderCatalog } from "../llm/provider-catalog.js";
|
|
42
53
|
import { defaultCacheDir } from "../llm/model-cache.js";
|
|
43
|
-
import { detectProviderFromApiKey, buildModelPool
|
|
54
|
+
import { detectProviderFromApiKey, buildModelPool } from "../onboarding.js";
|
|
44
55
|
import { detectPastedNoise } from "../utils/task-sanitizer.js";
|
|
45
|
-
import { parseTaskWithImages
|
|
46
|
-
import { enforceImagePolicy, byteLengthFromBase64, dropOversizedImages, } from "./image-policy.js";
|
|
56
|
+
import { parseTaskWithImages } from "./parse-task.js";
|
|
57
|
+
import { enforceImagePolicy, byteLengthFromBase64, dropOversizedImages, collectAttachedImagePaths, } from "./image-policy.js";
|
|
47
58
|
import { tryCompressImages } from "./image-compression.js";
|
|
48
59
|
import { buildSessionTitle } from "./session-title.js";
|
|
49
60
|
import { capabilitiesFor } from "../llm/capabilities/index.js";
|
|
50
61
|
import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
|
|
51
62
|
import { runDreamConsolidation } from "../services/dream-consolidation.js";
|
|
52
|
-
import { join } from "node:path";
|
|
53
|
-
import {
|
|
54
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
|
|
63
|
+
import { join, isAbsolute } from "node:path";
|
|
64
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
55
65
|
/**
|
|
56
66
|
* Build ScanOptions.compatFileNames from the user's instruction compat toggles.
|
|
57
67
|
* Primary file name stays hard-wired to CODESHELL.md (not exposed). Turning a
|
|
@@ -118,8 +128,25 @@ export function resolveChildLlm(modelKey, pool, parentLlm) {
|
|
|
118
128
|
* 2. user-level ~/.code-shell/agents/*.md (user wins on name)
|
|
119
129
|
* Names in `disabledAgents` are filtered out so the LLM never sees them.
|
|
120
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
|
+
}
|
|
121
145
|
export function loadAgentDefinitionsForCwd(cwd, disabledAgents = [], disabledPlugins = []) {
|
|
122
|
-
|
|
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();
|
|
123
150
|
// Increasing priority; loadFromDirs is last-dir-wins. ORDER ENCODES POLICY:
|
|
124
151
|
// user (cross-project personal default, lowest) → plugins (reusable baseline)
|
|
125
152
|
// → project (highest). A repo's in-tree agent therefore overrides a same-named
|
|
@@ -135,7 +162,7 @@ export function loadAgentDefinitionsForCwd(cwd, disabledAgents = [], disabledPlu
|
|
|
135
162
|
...(cwd ? [{ dir: `${cwd}/.code-shell/agents`, source: "project" }] : []),
|
|
136
163
|
], disabledAgents);
|
|
137
164
|
}
|
|
138
|
-
const NESTED_AGENT_TOOLS = ["Agent", "AgentStatus", "AgentCancel"];
|
|
165
|
+
const NESTED_AGENT_TOOLS = ["Agent", "AgentStatus", "AgentCancel", "AgentSendInput"];
|
|
139
166
|
/**
|
|
140
167
|
* #7: apply a project's per-turn builtin capability override to a tool list.
|
|
141
168
|
* A builtin marked `off` for the current cwd is HIDDEN from the turn's tool
|
|
@@ -238,7 +265,39 @@ export class Engine {
|
|
|
238
265
|
* LLM response arrives.
|
|
239
266
|
*/
|
|
240
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();
|
|
241
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;
|
|
242
301
|
/** Public accessor so UI/clients can read the resolved per-model window. */
|
|
243
302
|
get maxContextTokens() {
|
|
244
303
|
return this.resolveMaxContextTokens();
|
|
@@ -247,6 +306,27 @@ export class Engine {
|
|
|
247
306
|
const modelEntry = this.modelPool.get();
|
|
248
307
|
return modelEntry?.maxContextTokens ?? this.config.maxContextTokens ?? 200_000;
|
|
249
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
|
+
}
|
|
250
330
|
/**
|
|
251
331
|
* Emit a lifecycle hook with isSubAgent auto-merged into data so handlers
|
|
252
332
|
* can skip noisy injections for spawned children. All Engine-side hook
|
|
@@ -278,6 +358,10 @@ export class Engine {
|
|
|
278
358
|
}
|
|
279
359
|
const entries = settings.hooks ?? [];
|
|
280
360
|
for (const entry of entries) {
|
|
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;
|
|
281
365
|
const event = entry.event;
|
|
282
366
|
const handler = async (ctx) => {
|
|
283
367
|
if (!shellHookMatches(entry, ctx))
|
|
@@ -310,6 +394,19 @@ export class Engine {
|
|
|
310
394
|
this.hooks.unregister(event, handler);
|
|
311
395
|
}
|
|
312
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
|
+
}
|
|
313
410
|
// Force the next get() to re-read disk so reloaded hooks reflect the
|
|
314
411
|
// newest settings.json, not a stale merged cache.
|
|
315
412
|
try {
|
|
@@ -343,13 +440,15 @@ export class Engine {
|
|
|
343
440
|
// per-turn path can only hide, not add, so a freshly-`on`'d builtin not in
|
|
344
441
|
// the set needs a session restart to appear.
|
|
345
442
|
const builtinLists = effectiveBuiltinLists(config.enabledBuiltinTools ?? [], config.disabledBuiltinTools ?? [], this.readBuiltinOverride(config.cwd));
|
|
346
|
-
this.toolRegistry =
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
443
|
+
this.toolRegistry =
|
|
444
|
+
config.runtime?.toolRegistry ??
|
|
445
|
+
new ToolRegistry({
|
|
446
|
+
builtinTools: resolveBuiltinToolNames({
|
|
447
|
+
preset: this.preset.name,
|
|
448
|
+
enabledBuiltinTools: builtinLists.enabledBuiltinTools,
|
|
449
|
+
disabledBuiltinTools: builtinLists.disabledBuiltinTools,
|
|
450
|
+
}),
|
|
451
|
+
});
|
|
353
452
|
this.hooks = new HookRegistry();
|
|
354
453
|
// Installed-plugin hooks — declared in each plugin's hooks/hooks.json.
|
|
355
454
|
// Registered first (priority 80) so user-authored hooks at lower
|
|
@@ -362,7 +461,12 @@ export class Engine {
|
|
|
362
461
|
// disabledPlugins suppresses a plugin's hooks too (not just its
|
|
363
462
|
// Skill-tool entries) — see loadPluginHooks. readDisabledLists reads
|
|
364
463
|
// the same settings the prompt composer / tool context use.
|
|
365
|
-
|
|
464
|
+
// disabledPluginHooks is the per-hook overlay
|
|
465
|
+
// (capabilityOverrides.pluginHooks); applied at construction, so a
|
|
466
|
+
// toggle takes effect for NEW sessions (same semantics as
|
|
467
|
+
// disabledPlugins itself).
|
|
468
|
+
const { disabledPlugins, disabledPluginHooks } = this.readDisabledLists();
|
|
469
|
+
loadPluginHooks(this.hooks, disabledPlugins, disabledPluginHooks);
|
|
366
470
|
}
|
|
367
471
|
// settings.hooks → shell-command wrappers. Chain order:
|
|
368
472
|
// plugin (80) → shell (50) → code (default 0).
|
|
@@ -378,7 +482,7 @@ export class Engine {
|
|
|
378
482
|
}
|
|
379
483
|
}
|
|
380
484
|
/**
|
|
381
|
-
* Load
|
|
485
|
+
* Load modelConnections[] from settings into the active ModelPool and
|
|
382
486
|
* resync this.config.llm with the matching entry. Called from the ctor and
|
|
383
487
|
* from reloadModelPool() (e.g. after onboarding writes new entries to disk).
|
|
384
488
|
*/
|
|
@@ -387,54 +491,45 @@ export class Engine {
|
|
|
387
491
|
const sm = this.getSettingsManager();
|
|
388
492
|
sm.invalidate();
|
|
389
493
|
const settings = sm.get();
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
providerKey: m.providerKey,
|
|
402
|
-
});
|
|
403
|
-
}
|
|
404
|
-
// Build catalog from settings.providers[] and attach to the pool
|
|
405
|
-
// so model entries can resolve baseUrl/apiKey from their provider.
|
|
406
|
-
if (settings.providers?.length) {
|
|
407
|
-
this.modelPool.setProviderCatalog(new ProviderCatalog(settings.providers));
|
|
494
|
+
// Unified model catalog (统一模型接入方案 §6): register text
|
|
495
|
+
// connections from settings.modelConnections[] into the pool — the
|
|
496
|
+
// catalog-driven instance store is the sole source of model selection.
|
|
497
|
+
// A connection's instance id becomes its pool key. See
|
|
498
|
+
// docs/superpowers/specs/2026-06-15-unified-model-catalog-design.md.
|
|
499
|
+
const connections = settings.modelConnections;
|
|
500
|
+
if (Array.isArray(connections) && connections.length) {
|
|
501
|
+
const catalog = getMergedCatalog();
|
|
502
|
+
const credentials = settings.credentials;
|
|
503
|
+
for (const entry of modelEntriesFromConnections(connections, (Array.isArray(credentials) ? credentials : []), catalog)) {
|
|
504
|
+
this.modelPool.register(entry);
|
|
408
505
|
}
|
|
506
|
+
}
|
|
507
|
+
const hasConnections = Array.isArray(connections) && connections.length > 0;
|
|
508
|
+
if (hasConnections) {
|
|
409
509
|
this.modelPool.setCacheDir(defaultCacheDir());
|
|
410
510
|
this.modelPool.reloadCachedContextWindows();
|
|
411
|
-
// Resolve active entry.
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
// *user's* current UI model selection and must not clobber a child's
|
|
421
|
-
// routed model — without this guard a role's `model: flash` is silently
|
|
422
|
-
// overridden back to whatever the user has active in the foreground.
|
|
511
|
+
// Resolve the active entry from settings.defaults.text, then switch the
|
|
512
|
+
// pool and write the resolved entry's credentials into config.llm, so the
|
|
513
|
+
// first run() uses the right endpoint instead of whatever env-derived
|
|
514
|
+
// fallback repl.ts seeded earlier.
|
|
515
|
+
// Sub-agents skip this resync: their llm is chosen by the parent's
|
|
516
|
+
// resolveChildLlm (per-role model routing). defaults.text is the *user's*
|
|
517
|
+
// current UI model selection and must not clobber a child's routed model —
|
|
518
|
+
// without this guard a role's `model: flash` is silently overridden back
|
|
519
|
+
// to whatever the user has active in the foreground.
|
|
423
520
|
if (this.config.isSubAgent !== true) {
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
521
|
+
const defaultText = settings.defaults?.text;
|
|
522
|
+
// 统一 catalog only:defaults.text 命中则用;否则回退首个已注册连接,
|
|
523
|
+
// 避免选未配置模型时静默沿用空种子(旧 bug:抛误导性 OPENAI_API_KEY missing)。
|
|
524
|
+
let matchKey;
|
|
525
|
+
if (defaultText && this.modelPool.list().some((e) => e.key === defaultText)) {
|
|
526
|
+
matchKey = defaultText;
|
|
428
527
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
// OpenRouter stores entries as "provider/model-name"; the top-level
|
|
432
|
-
// settings.model.name is just "model-name". Match either form.
|
|
433
|
-
match = settings.models.find((m) => m.model === currentModel ||
|
|
434
|
-
(currentModel && m.model?.endsWith(`/${currentModel}`)));
|
|
528
|
+
else {
|
|
529
|
+
matchKey = this.modelPool.list()[0]?.key;
|
|
435
530
|
}
|
|
436
|
-
if (
|
|
437
|
-
const entry = this.modelPool.switch(
|
|
531
|
+
if (matchKey) {
|
|
532
|
+
const entry = this.modelPool.switch(matchKey);
|
|
438
533
|
this.config = {
|
|
439
534
|
...this.config,
|
|
440
535
|
llm: this.modelPool.toLLMConfig(entry),
|
|
@@ -443,9 +538,10 @@ export class Engine {
|
|
|
443
538
|
}
|
|
444
539
|
}
|
|
445
540
|
else if (this.config.llm.apiKey) {
|
|
446
|
-
// Auto-populate pool from the configured API key when
|
|
447
|
-
// This lets users who only
|
|
448
|
-
// use /model to switch between the provider's
|
|
541
|
+
// Auto-populate pool from the configured API key when no
|
|
542
|
+
// modelConnections[] are configured. This lets users who only have an
|
|
543
|
+
// env/seed API key still use /model to switch between the provider's
|
|
544
|
+
// available models.
|
|
449
545
|
this.autoPopulatePool(this.config.llm.apiKey, this.config.llm.baseUrl);
|
|
450
546
|
}
|
|
451
547
|
// Carry image-attachment settings + sampling temperature into
|
|
@@ -457,8 +553,10 @@ export class Engine {
|
|
|
457
553
|
const modelBlock = settings.model;
|
|
458
554
|
const nextDefaults = { ...(this.config.clientDefaults ?? {}) };
|
|
459
555
|
let defaultsChanged = false;
|
|
460
|
-
|
|
461
|
-
|
|
556
|
+
// Migrate legacy "original" → "high" (raw settings may bypass schema).
|
|
557
|
+
const detail = imageSettings?.detail === "original" ? "high" : imageSettings?.detail;
|
|
558
|
+
if (detail && nextDefaults.imageDetail !== detail) {
|
|
559
|
+
nextDefaults.imageDetail = detail;
|
|
462
560
|
defaultsChanged = true;
|
|
463
561
|
}
|
|
464
562
|
if (typeof modelBlock?.temperature === "number" &&
|
|
@@ -476,7 +574,7 @@ export class Engine {
|
|
|
476
574
|
}
|
|
477
575
|
/**
|
|
478
576
|
* Re-read settings and refresh the model pool. Used after onboarding /login
|
|
479
|
-
* writes new
|
|
577
|
+
* writes new modelConnections[] to disk so the running engine picks them
|
|
480
578
|
* up without a process restart. Existing pool entries are kept (re-registering
|
|
481
579
|
* the same key overwrites them), so callers don't need to clear first.
|
|
482
580
|
*/
|
|
@@ -490,7 +588,7 @@ export class Engine {
|
|
|
490
588
|
}
|
|
491
589
|
}
|
|
492
590
|
/**
|
|
493
|
-
* Auto-populate the model pool when
|
|
591
|
+
* Auto-populate the model pool when no modelConnections[] are configured but
|
|
494
592
|
* the user has configured an API key. Detects the provider from the
|
|
495
593
|
* key prefix / baseUrl and registers all its known models.
|
|
496
594
|
*/
|
|
@@ -528,6 +626,94 @@ export class Engine {
|
|
|
528
626
|
setAskUser(fn) {
|
|
529
627
|
this.config.askUser = fn;
|
|
530
628
|
}
|
|
629
|
+
/**
|
|
630
|
+
* Inject the browser automation bridge after construction (same chicken-and-egg
|
|
631
|
+
* as setAskUser: the desktop host builds the bridge — which drives a webview —
|
|
632
|
+
* after the Engine exists). Undefined → the browser_* tools degrade with a
|
|
633
|
+
* clear "no browser panel" error.
|
|
634
|
+
*/
|
|
635
|
+
setBrowserBridge(bridge) {
|
|
636
|
+
this.config.browserBridge = bridge;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Queue a user message to be spliced into the in-flight run for `sessionId`
|
|
640
|
+
* at the next turn-loop step boundary — the 不打断 steering path (vs cancel +
|
|
641
|
+
* resend). General-purpose: any host path (UI 引导, future agent coordination,
|
|
642
|
+
* external triggers) can call it. If no run is active for this session, reject
|
|
643
|
+
* without queueing so the host can downgrade to a normal run immediately.
|
|
644
|
+
* No-op on blank text.
|
|
645
|
+
*
|
|
646
|
+
* `id` is the host's stable queue-entry id. It rides through to the
|
|
647
|
+
* `steer_injected` event (so the host can match the injected bubble back to
|
|
648
|
+
* the queued draft) and is the handle `unsteer` uses to revoke a still-pending
|
|
649
|
+
* entry. A blank id is tolerated but means the entry can't be revoked.
|
|
650
|
+
*/
|
|
651
|
+
enqueueSteer(sessionId, text, id = "", clientMessageId) {
|
|
652
|
+
const q = this.steerQueueBySid.get(sessionId) ?? [];
|
|
653
|
+
const entryId = id || `steer-${q.length}`;
|
|
654
|
+
if (!sessionId)
|
|
655
|
+
return { accepted: false, id: entryId };
|
|
656
|
+
const activeRunSessionId = this.activeRunSession?.state.sessionId;
|
|
657
|
+
const active = this.activeTurnLoop !== null && activeRunSessionId === sessionId;
|
|
658
|
+
if (!active) {
|
|
659
|
+
logger.info("steer.enqueue.idle_rejected", {
|
|
660
|
+
sessionId,
|
|
661
|
+
id: entryId,
|
|
662
|
+
clientMessageId,
|
|
663
|
+
activeRunSessionId: activeRunSessionId ?? null,
|
|
664
|
+
queueLength: q.length,
|
|
665
|
+
});
|
|
666
|
+
return { accepted: false, id: entryId };
|
|
667
|
+
}
|
|
668
|
+
const next = enqueueSteerItem(q, entryId, text, clientMessageId);
|
|
669
|
+
if (next === q)
|
|
670
|
+
return { accepted: false, id: entryId }; // blank text dropped
|
|
671
|
+
this.steerQueueBySid.set(sessionId, next);
|
|
672
|
+
logger.info("steer.enqueue.accepted", {
|
|
673
|
+
sessionId,
|
|
674
|
+
id: entryId,
|
|
675
|
+
clientMessageId,
|
|
676
|
+
activeRunSessionId,
|
|
677
|
+
queueLength: next.length,
|
|
678
|
+
});
|
|
679
|
+
return { accepted: true, id: entryId };
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Revoke a still-pending steer entry (the 撤回 path). Returns true if it was
|
|
683
|
+
* removed, false if it was already consumed by the turn loop (can't take it
|
|
684
|
+
* back — it has been spliced into the run).
|
|
685
|
+
*/
|
|
686
|
+
unsteer(sessionId, id) {
|
|
687
|
+
const q = this.steerQueueBySid.get(sessionId);
|
|
688
|
+
if (!q || q.length === 0)
|
|
689
|
+
return false;
|
|
690
|
+
const { list, removed } = removeSteerItem(q, id);
|
|
691
|
+
if (removed)
|
|
692
|
+
this.steerQueueBySid.set(sessionId, list);
|
|
693
|
+
return removed;
|
|
694
|
+
}
|
|
695
|
+
/** Drain + clear the steer queue for a session (turn loop consumes per step). */
|
|
696
|
+
consumeSteer(sessionId, source = "normal_step") {
|
|
697
|
+
const q = this.steerQueueBySid.get(sessionId);
|
|
698
|
+
if (!q || q.length === 0)
|
|
699
|
+
return [];
|
|
700
|
+
const { drained, rest } = consumeSteerItems(q);
|
|
701
|
+
this.steerQueueBySid.set(sessionId, rest);
|
|
702
|
+
logger.info("steer.consume.drained", {
|
|
703
|
+
sessionId,
|
|
704
|
+
source,
|
|
705
|
+
count: drained.length,
|
|
706
|
+
ids: drained.map((item) => item.id),
|
|
707
|
+
clientMessageIds: drained.flatMap((item) => item.clientMessageId ? [item.clientMessageId] : []),
|
|
708
|
+
queueLength: rest.length,
|
|
709
|
+
});
|
|
710
|
+
return drained;
|
|
711
|
+
}
|
|
712
|
+
/** Wire the cookie→browser injection callback (InjectCredential tool). Same
|
|
713
|
+
* post-construction injection model as setBrowserBridge. */
|
|
714
|
+
setInjectCredential(fn) {
|
|
715
|
+
this.config.injectCredentialToBrowser = fn;
|
|
716
|
+
}
|
|
531
717
|
/**
|
|
532
718
|
* Whether this engine runs unattended (no interactive human). Used by the
|
|
533
719
|
* in-process AgentServer to decide whether to wire an interactive askUser.
|
|
@@ -535,11 +721,34 @@ export class Engine {
|
|
|
535
721
|
isHeadless() {
|
|
536
722
|
return this.config.headless === true;
|
|
537
723
|
}
|
|
724
|
+
/**
|
|
725
|
+
* Probe whether a session already exists on disk (its state/transcript dir is
|
|
726
|
+
* present). Used by the protocol server to distinguish "resume an existing
|
|
727
|
+
* session" from "silently create a fresh empty one" — e.g. a cron resume job
|
|
728
|
+
* whose target session the user deleted must fail loudly, not run its prompt
|
|
729
|
+
* against a blank session. A stat probe, not a load.
|
|
730
|
+
*/
|
|
731
|
+
sessionExistsOnDisk(sessionId) {
|
|
732
|
+
return this.sessionManager.exists(sessionId);
|
|
733
|
+
}
|
|
538
734
|
/**
|
|
539
735
|
* Run a task from start to finish.
|
|
540
736
|
*/
|
|
541
737
|
async run(task, options) {
|
|
542
|
-
|
|
738
|
+
// When the caller omits cwd but is resuming an existing session, recover
|
|
739
|
+
// that session's bound cwd from disk so a project-bound session keeps
|
|
740
|
+
// loading its own agents/settings/memory even if the host's UI repo
|
|
741
|
+
// selection has drifted to null. Only probe on omission — an explicit cwd
|
|
742
|
+
// always wins, and a fresh session has nothing to recover.
|
|
743
|
+
const sessionCwd = options?.cwd === undefined && options?.sessionId
|
|
744
|
+
? this.sessionManager.readCwd(options.sessionId)
|
|
745
|
+
: undefined;
|
|
746
|
+
const cwd = resolveRunCwd({
|
|
747
|
+
optionCwd: options?.cwd,
|
|
748
|
+
sessionCwd,
|
|
749
|
+
configCwd: this.config.cwd,
|
|
750
|
+
processCwd: process.cwd(),
|
|
751
|
+
});
|
|
543
752
|
// Wrap the caller's onStream so we can intercept `task_update`
|
|
544
753
|
// events emitted by TodoWrite and keep an in-engine snapshot.
|
|
545
754
|
// TaskGuard reads this snapshot at turn end to decide whether to
|
|
@@ -701,6 +910,22 @@ export class Engine {
|
|
|
701
910
|
permissionMode: this.config.permissionMode ?? "acceptEdits",
|
|
702
911
|
}),
|
|
703
912
|
spawn: async (req) => {
|
|
913
|
+
// Anchor this sub-agent in the PARENT transcript at spawn time — before
|
|
914
|
+
// it runs, so it's recorded whether it later completes, is interrupted,
|
|
915
|
+
// or still runs. Replay reads these anchors to rebuild sub-agent cards
|
|
916
|
+
// from sessions/<agentId>/ (agentId === childSid); without it a
|
|
917
|
+
// backgrounded sub-agent leaves no parent-transcript trace and vanishes
|
|
918
|
+
// on reopen. Only on a fresh spawn (not a resume/continuation, which
|
|
919
|
+
// already has its anchor). Guarded so a transcript hiccup never breaks
|
|
920
|
+
// the spawn.
|
|
921
|
+
if (!req.resumeSessionId) {
|
|
922
|
+
try {
|
|
923
|
+
session.transcript.appendSubagent(req.agentId, undefined, req.description);
|
|
924
|
+
}
|
|
925
|
+
catch {
|
|
926
|
+
/* anchor is best-effort; never block the spawn */
|
|
927
|
+
}
|
|
928
|
+
}
|
|
704
929
|
// No nested agents. Strip Agent / AgentStatus / AgentCancel from the
|
|
705
930
|
// child's tool pool so the LLM can't spawn grandchildren — matches
|
|
706
931
|
// Claude Code's ALL_AGENT_DISALLOWED_TOOLS approach. Without this
|
|
@@ -722,9 +947,8 @@ export class Engine {
|
|
|
722
947
|
enabledBuiltinTools: childEnabled,
|
|
723
948
|
disabledBuiltinTools: childDisabled,
|
|
724
949
|
customSystemPrompt: this.config.customSystemPrompt,
|
|
725
|
-
appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt]
|
|
726
|
-
|
|
727
|
-
.join("\n\n") || undefined,
|
|
950
|
+
appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt].filter(Boolean).join("\n\n") ||
|
|
951
|
+
undefined,
|
|
728
952
|
responseLanguage: this.config.responseLanguage,
|
|
729
953
|
userProfile: this.config.userProfile,
|
|
730
954
|
instructions: this.config.instructions,
|
|
@@ -733,6 +957,7 @@ export class Engine {
|
|
|
733
957
|
sessionStorageDir: this.config.sessionStorageDir,
|
|
734
958
|
headless: this.config.headless,
|
|
735
959
|
readOnlySession: req.readOnlySession,
|
|
960
|
+
skillAllowlist: req.skillAllowlist,
|
|
736
961
|
sandbox: this.config.sandbox,
|
|
737
962
|
// Subagents inherit the parent's scope: a child runs in the same
|
|
738
963
|
// cwd/session, so it should see the same config layers the parent did.
|
|
@@ -770,12 +995,41 @@ export class Engine {
|
|
|
770
995
|
// child.run() establishes its own runWithSid scope internally, so
|
|
771
996
|
// child log lines route to the child's sid and parent's ALS
|
|
772
997
|
// binding is unaffected when control returns here.
|
|
773
|
-
|
|
774
|
-
|
|
998
|
+
//
|
|
999
|
+
// agent_id === childSid: cold-start the child UNDER its agentId as the
|
|
1000
|
+
// session id (run() shape (2): a fresh sid the host wants materialized),
|
|
1001
|
+
// so the session persists at sessions/<agentId>/ and AgentSendInput can
|
|
1002
|
+
// later resume it by agentId with no extra id→sid mapping. When
|
|
1003
|
+
// resumeSessionId is set we resume that existing session instead —
|
|
1004
|
+
// run() detects the on-disk session and replays its full transcript
|
|
1005
|
+
// (the CC continuation model; see AgentSendInput).
|
|
1006
|
+
const childSessionId = req.resumeSessionId ?? req.agentId;
|
|
1007
|
+
const result = await child.run(req.prompt, {
|
|
1008
|
+
signal: req.signal,
|
|
1009
|
+
onStream: childStream,
|
|
1010
|
+
sessionId: childSessionId,
|
|
1011
|
+
});
|
|
1012
|
+
return { text: result.text, sessionId: result.sessionId };
|
|
775
1013
|
},
|
|
1014
|
+
sessionExists: (sessionId) => this.sessionManager.exists(sessionId),
|
|
776
1015
|
};
|
|
777
|
-
|
|
778
|
-
|
|
1016
|
+
// Priority: config.sandbox → project settings.sandbox → global → per-run
|
|
1017
|
+
// default. Read UNMERGED per-scope (getForScope) so a project that wrote no
|
|
1018
|
+
// sandbox genuinely follows global, rather than inheriting global's mode and
|
|
1019
|
+
// looking like it set one. Fixes "项目级配了不生效" + the scope model.
|
|
1020
|
+
let projectSandbox;
|
|
1021
|
+
let globalSandbox;
|
|
1022
|
+
try {
|
|
1023
|
+
const sm = this.getSettingsManager();
|
|
1024
|
+
if (this.config.isSubAgent !== true) {
|
|
1025
|
+
projectSandbox = sm.getForScope("project", cwd).sandbox;
|
|
1026
|
+
}
|
|
1027
|
+
globalSandbox = sm.getForScope("user").sandbox;
|
|
1028
|
+
}
|
|
1029
|
+
catch {
|
|
1030
|
+
// settings unavailable → fall through to per-run default
|
|
1031
|
+
}
|
|
1032
|
+
const sandboxConfig = resolveSandboxConfig(this.config.sandbox, projectSandbox, globalSandbox, this.config.headless === true);
|
|
779
1033
|
// A2: explicit sandbox modes (seatbelt, bwrap) must fail closed
|
|
780
1034
|
// per standard §S4. resolveSandboxBackend throws when an explicit
|
|
781
1035
|
// mode is unavailable on this host; we let it propagate. The
|
|
@@ -789,6 +1043,17 @@ export class Engine {
|
|
|
789
1043
|
const sandboxBackend = this.runtime
|
|
790
1044
|
? await this.runtime.resolveSandbox(sandboxConfig, cwd)
|
|
791
1045
|
: await this.resolveSandboxWithoutRuntime(sandboxConfig, cwd);
|
|
1046
|
+
// Observability: surface what sandbox actually applied this run — the
|
|
1047
|
+
// configured mode vs the resolved backend (auto may downgrade to off when
|
|
1048
|
+
// no OS backend is available) + the network policy. Without this you can't
|
|
1049
|
+
// tell whether shell commands were isolated /网络放没放. One line per run.
|
|
1050
|
+
logger.info("sandbox.resolved", {
|
|
1051
|
+
mode: sandboxConfig.mode,
|
|
1052
|
+
backend: sandboxBackend.name,
|
|
1053
|
+
isolated: sandboxBackend.name !== "off",
|
|
1054
|
+
network: sandboxConfig.network,
|
|
1055
|
+
cwd,
|
|
1056
|
+
});
|
|
792
1057
|
// sessionId is filled in after the session bundle is resolved below
|
|
793
1058
|
// (the session may be cold-started or resumed). Until then this is
|
|
794
1059
|
// intentionally shaped as a mutable local; we treat it as immutable
|
|
@@ -797,7 +1062,13 @@ export class Engine {
|
|
|
797
1062
|
...this.buildToolContext(),
|
|
798
1063
|
subAgentSpawner,
|
|
799
1064
|
agentDefinitions: this.getAgentDefinitions(cwd),
|
|
800
|
-
|
|
1065
|
+
// Stamp the resolved network policy onto the backend the tools see so
|
|
1066
|
+
// Bash can surface "网络 deny" on its result. Shallow-copy (don't mutate
|
|
1067
|
+
// the cached backend) — `wrap`/`hintForBlockedOutput` are plain function
|
|
1068
|
+
// properties and survive the spread. Off keeps network undefined.
|
|
1069
|
+
sandbox: sandboxBackend.name === "off"
|
|
1070
|
+
? sandboxBackend
|
|
1071
|
+
: { ...sandboxBackend, network: sandboxConfig.network },
|
|
801
1072
|
cwd,
|
|
802
1073
|
// TodoWrite reads this to push task_update events independently
|
|
803
1074
|
// of its return value, so the UI's pinned task panel refreshes
|
|
@@ -818,10 +1089,24 @@ export class Engine {
|
|
|
818
1089
|
// text block (when prose is present) followed by one image block per
|
|
819
1090
|
// attachment — the provider-specific clients translate this to OpenAI
|
|
820
1091
|
// `image_url` or Anthropic `{type:image, source:base64}` downstream.
|
|
1092
|
+
// When an attached image came from a workspace FILE (the desktop composer's
|
|
1093
|
+
// path-attach flow sets ParsedImage.name = the absolute path), surface that
|
|
1094
|
+
// path to the model as text. The image bytes still ride along for vision,
|
|
1095
|
+
// but tools that operate on files — GenerateImage(referenceImages),
|
|
1096
|
+
// Read, etc. — need the on-disk path, not just the pixels. Without this the
|
|
1097
|
+
// path the composer already knew was silently dropped, and the model would
|
|
1098
|
+
// answer "图片没落到项目文件夹,找不到路径" (the seedance 图生图 dead-end).
|
|
1099
|
+
// Only names that resolve to an existing file qualify; a pasted screenshot
|
|
1100
|
+
// whose name is just "screenshot.png" is not a path and is left out.
|
|
1101
|
+
const attachedPaths = collectAttachedImagePaths(parsedTask.images, (name) => (isAbsolute(name) ? name : join(cwd, name)), existsSync);
|
|
1102
|
+
const pathHint = attachedPaths.length > 0
|
|
1103
|
+
? `\n\n<attached-image-paths>\n${attachedPaths.join("\n")}\n</attached-image-paths>\n` +
|
|
1104
|
+
`(上面附带的图片在工作区的真实路径,如需把它们作为工具输入(例如 GenerateImage 的 referenceImages、图生图参考图),直接使用这些路径。)`
|
|
1105
|
+
: "";
|
|
821
1106
|
const userMessageContent = parsedTask.hasImages
|
|
822
1107
|
? [
|
|
823
|
-
...(parsedTask.text
|
|
824
|
-
? [{ type: "text", text: parsedTask.text }]
|
|
1108
|
+
...(parsedTask.text || pathHint
|
|
1109
|
+
? [{ type: "text", text: `${parsedTask.text}${pathHint}` }]
|
|
825
1110
|
: []),
|
|
826
1111
|
...parsedTask.images.map((img) => ({
|
|
827
1112
|
type: "image",
|
|
@@ -848,6 +1133,23 @@ export class Engine {
|
|
|
848
1133
|
// call) instead of a try/catch on resume.
|
|
849
1134
|
let session;
|
|
850
1135
|
let messages;
|
|
1136
|
+
let freshImageMessage;
|
|
1137
|
+
const claimedClientMessageIds = new Set();
|
|
1138
|
+
const claimClientMessageId = (bundle, clientMessageId, source) => {
|
|
1139
|
+
if (!clientMessageId)
|
|
1140
|
+
return true;
|
|
1141
|
+
if (claimedClientMessageIds.has(clientMessageId) ||
|
|
1142
|
+
bundle.transcript.hasClientMessageId(clientMessageId)) {
|
|
1143
|
+
logger.info("engine.client_message.duplicate_ignored", {
|
|
1144
|
+
sessionId: bundle.state.sessionId,
|
|
1145
|
+
clientMessageId,
|
|
1146
|
+
source,
|
|
1147
|
+
});
|
|
1148
|
+
return false;
|
|
1149
|
+
}
|
|
1150
|
+
claimedClientMessageIds.add(clientMessageId);
|
|
1151
|
+
return true;
|
|
1152
|
+
};
|
|
851
1153
|
if (options?.sessionId && this.sessionManager.exists(options.sessionId)) {
|
|
852
1154
|
session = this.sessionManager.resume(options.sessionId);
|
|
853
1155
|
const cachedCompacted = this.compactedMessagesBySession.get(options.sessionId);
|
|
@@ -871,8 +1173,31 @@ export class Engine {
|
|
|
871
1173
|
}
|
|
872
1174
|
// Append new user message
|
|
873
1175
|
const userMsg = { role: "user", content: userMessageContent };
|
|
1176
|
+
if (!claimClientMessageId(session, options?.clientMessageId, "submit")) {
|
|
1177
|
+
const usage = session.state.tokenUsage ?? {
|
|
1178
|
+
promptTokens: 0,
|
|
1179
|
+
completionTokens: 0,
|
|
1180
|
+
totalTokens: 0,
|
|
1181
|
+
};
|
|
1182
|
+
return {
|
|
1183
|
+
text: "",
|
|
1184
|
+
reason: "completed",
|
|
1185
|
+
sessionId: session.state.sessionId,
|
|
1186
|
+
turnCount: session.state.turnCount ?? 0,
|
|
1187
|
+
usage: {
|
|
1188
|
+
promptTokens: usage.promptTokens ?? 0,
|
|
1189
|
+
completionTokens: usage.completionTokens ?? 0,
|
|
1190
|
+
totalTokens: usage.totalTokens ?? 0,
|
|
1191
|
+
},
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
if (parsedTask.hasImages)
|
|
1195
|
+
freshImageMessage = userMsg;
|
|
874
1196
|
messages.push(userMsg);
|
|
875
|
-
session.transcript.appendMessage("user", userMessageContent
|
|
1197
|
+
session.transcript.appendMessage("user", userMessageContent, {
|
|
1198
|
+
injected: options?.injected === true,
|
|
1199
|
+
clientMessageId: options?.clientMessageId,
|
|
1200
|
+
});
|
|
876
1201
|
// Flush "active" status to disk immediately. resume() set it in memory
|
|
877
1202
|
// (session-manager.ts), but without this write the on-disk state.json
|
|
878
1203
|
// still shows the previous run's terminal reason — so any external
|
|
@@ -884,17 +1209,30 @@ export class Engine {
|
|
|
884
1209
|
// Cold start: shape (2) reuses the host-supplied sid; shape (3)
|
|
885
1210
|
// lets sessionManager generate one with nanoid.
|
|
886
1211
|
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);
|
|
887
|
-
|
|
888
|
-
session
|
|
1212
|
+
const userMsg = { role: "user", content: userMessageContent };
|
|
1213
|
+
claimClientMessageId(session, options?.clientMessageId, "submit");
|
|
1214
|
+
if (parsedTask.hasImages)
|
|
1215
|
+
freshImageMessage = userMsg;
|
|
1216
|
+
messages = [userMsg];
|
|
1217
|
+
session.transcript.appendMessage("user", userMessageContent, {
|
|
1218
|
+
clientMessageId: options?.clientMessageId,
|
|
1219
|
+
});
|
|
889
1220
|
// Save first user message as session summary — text only. The summary
|
|
890
1221
|
// shows up in the session list; "[image]" is more informative than a
|
|
891
1222
|
// truncated `[object Object]` when the prompt was purely visual.
|
|
892
1223
|
const summarySrc = parsedTask.hasImages
|
|
893
|
-
? parsedTask.text ||
|
|
1224
|
+
? parsedTask.text ||
|
|
1225
|
+
`[image${parsedTask.images.length > 1 ? `s × ${parsedTask.images.length}` : ""}]`
|
|
894
1226
|
: taskText;
|
|
895
1227
|
session.state.summary = summarySrc.slice(0, 80).replace(/\n/g, " ");
|
|
896
1228
|
this.sessionManager.saveState(session.state);
|
|
897
1229
|
}
|
|
1230
|
+
// Bump the conversation-turn counter: this user message starts a new turn.
|
|
1231
|
+
// One user message = one turn, regardless of how many turn-loop iterations
|
|
1232
|
+
// or tool calls it spans. File-history snapshots taken below are tagged
|
|
1233
|
+
// with this value so `/undo` reverts exactly this turn's file changes.
|
|
1234
|
+
// (Both resume and cold-start paths converge here.)
|
|
1235
|
+
session.state.turnSeq = (session.state.turnSeq ?? 0) + 1;
|
|
898
1236
|
// Stamp the resolved session id for downstream logging.
|
|
899
1237
|
//
|
|
900
1238
|
// `setCurrentSid` updates the module-level fallback so any code path
|
|
@@ -1043,6 +1381,9 @@ export class Engine {
|
|
|
1043
1381
|
toolExecutor.setContext(toolCtx);
|
|
1044
1382
|
const contextManager = new ContextManager({
|
|
1045
1383
|
maxTokens: this.resolveMaxContextTokens(),
|
|
1384
|
+
// Drop undefined fields so they don't clobber ContextManager defaults
|
|
1385
|
+
// (spread of `{x: undefined}` would override the default with undefined).
|
|
1386
|
+
...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
|
|
1046
1387
|
});
|
|
1047
1388
|
this.lastContextManager = contextManager;
|
|
1048
1389
|
const { disabledSkills, disabledPlugins } = this.readDisabledLists();
|
|
@@ -1057,6 +1398,14 @@ export class Engine {
|
|
|
1057
1398
|
instructionOptions: { compatFileNames: compatFileNamesFrom(this.config.instructions) },
|
|
1058
1399
|
disabledSkills,
|
|
1059
1400
|
disabledPlugins,
|
|
1401
|
+
skillAllowlist: this.config.skillAllowlist,
|
|
1402
|
+
memoriesMaxAgeDays: this.readMemoriesConfig()?.maxAge,
|
|
1403
|
+
goalToolState: {
|
|
1404
|
+
hasGoal: this.config.isSubAgent !== true &&
|
|
1405
|
+
(normalizeGoal(options?.goal) !== undefined ||
|
|
1406
|
+
session.state.activeGoal !== undefined ||
|
|
1407
|
+
normalizeGoal(this.config.goal) !== undefined),
|
|
1408
|
+
},
|
|
1060
1409
|
});
|
|
1061
1410
|
// Connect MCP servers (if configured and not already connected).
|
|
1062
1411
|
// B1: prefer the Runtime-owned MCPManager so all sessions in a
|
|
@@ -1071,7 +1420,7 @@ export class Engine {
|
|
|
1071
1420
|
else {
|
|
1072
1421
|
this.mcpManager = new MCPManager(this.toolRegistry);
|
|
1073
1422
|
}
|
|
1074
|
-
await this.mcpManager.connectAll(mcpServers);
|
|
1423
|
+
await this.mcpManager.connectAll(mcpServers, this);
|
|
1075
1424
|
}
|
|
1076
1425
|
// Parallelize slow initialization:
|
|
1077
1426
|
// 1. createLLMClient — network handshake (started earlier)
|
|
@@ -1092,6 +1441,14 @@ export class Engine {
|
|
|
1092
1441
|
// cwd. Recomputed every message, so configuring a key takes effect on the
|
|
1093
1442
|
// NEXT message without a restart. Tools with no guard entry are always kept.
|
|
1094
1443
|
const guardCwd = toolCtx.cwd;
|
|
1444
|
+
const toolVisibility = {
|
|
1445
|
+
cwd: guardCwd,
|
|
1446
|
+
hasGoal: this.config.isSubAgent !== true &&
|
|
1447
|
+
(normalizeGoal(options?.goal) !== undefined ||
|
|
1448
|
+
session.state.activeGoal !== undefined ||
|
|
1449
|
+
normalizeGoal(this.config.goal) !== undefined),
|
|
1450
|
+
};
|
|
1451
|
+
toolCtx.toolVisibility = toolVisibility;
|
|
1095
1452
|
// #7: per-turn project builtin override. The toolRegistry's builtin tool
|
|
1096
1453
|
// SET is ctor-frozen (and may be shared via runtime), so a mid-session
|
|
1097
1454
|
// project override of a builtin can't rebuild the registry. But the tool
|
|
@@ -1114,14 +1471,45 @@ export class Engine {
|
|
|
1114
1471
|
const disabledBuiltins = new Set(Object.keys(builtinOverride).filter((name) => builtinOverride[name] === "off" && registryNames.has(name)));
|
|
1115
1472
|
toolCtx.disabledBuiltins = disabledBuiltins;
|
|
1116
1473
|
}
|
|
1474
|
+
// MCP tool exposure is per-SESSION even though the pool/registry are
|
|
1475
|
+
// worker-shared (B1): a server connected by another project's session
|
|
1476
|
+
// registers its tools into the SHARED registry, and without this filter
|
|
1477
|
+
// they leaked into every session (e.g. chrome-devtools tools showing up
|
|
1478
|
+
// in a project that never enabled the plugin). Keep an MCP tool only when
|
|
1479
|
+
// its server is in THIS session's merged config.mcpServers — which
|
|
1480
|
+
// already folds the project's capabilityOverrides. Gated on the config
|
|
1481
|
+
// being present: engines without one (sub-agents, bare tests) have no
|
|
1482
|
+
// MCP tools in their private registries anyway.
|
|
1483
|
+
const allowedMcpServers = new Set(Object.entries(this.config.mcpServers ?? {})
|
|
1484
|
+
.filter(([, c]) => c.enabled !== false)
|
|
1485
|
+
.map(([n]) => n));
|
|
1486
|
+
toolCtx.allowedMcpServers = allowedMcpServers;
|
|
1487
|
+
const mcpVisible = (toolName) => {
|
|
1488
|
+
const reg = this.toolRegistry.getTool(toolName);
|
|
1489
|
+
return reg?.source !== "mcp" || allowedMcpServers.has(reg?.serverName ?? "");
|
|
1490
|
+
};
|
|
1491
|
+
// Feature-flag visibility: a builtin mapped in TOOL_FEATURE_FLAGS is
|
|
1492
|
+
// hidden when its flag resolves to false (default-on flags only hide when
|
|
1493
|
+
// explicitly disabled, so zero regression out of the box). Read once per
|
|
1494
|
+
// turn so flipping a flag in settings takes effect on the NEXT message,
|
|
1495
|
+
// like the other capability kinds.
|
|
1496
|
+
const featureFlags = this.readFeatureFlags();
|
|
1117
1497
|
const allToolDefs = applyBuiltinOverrideVisibility(this.toolRegistry.getToolDefinitions(), builtinOverride)
|
|
1498
|
+
.filter((t) => mcpVisible(t.name))
|
|
1118
1499
|
.filter((t) => {
|
|
1119
1500
|
const guard = BUILTIN_TOOL_GUARDS.get(t.name);
|
|
1120
|
-
return guard ? guard(
|
|
1501
|
+
return guard ? guard(toolVisibility) : true;
|
|
1502
|
+
})
|
|
1503
|
+
.filter((t) => {
|
|
1504
|
+
const flag = TOOL_FEATURE_FLAGS.get(t.name);
|
|
1505
|
+
return flag ? isFeatureEnabled(featureFlags, flag) : true;
|
|
1121
1506
|
})
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1507
|
+
// Dynamic per-engine bits the static defs can't carry: the Agent tool's
|
|
1508
|
+
// agent_type enum + listing, and the image/video provider names. See
|
|
1509
|
+
// applyDynamicToolDef — forwarding only the Agent description (dropping
|
|
1510
|
+
// its rebuilt inputSchema) used to strip the agent_type enum, so the
|
|
1511
|
+
// model omitted agent_type and configured roles never applied.
|
|
1512
|
+
.map((t) => applyDynamicToolDef(t, toolCtx.agentDefinitions, guardCwd));
|
|
1125
1513
|
// In plan mode, only expose read-only/planning tools so the model won't
|
|
1126
1514
|
// attempt writes. Shared with executor.ts's execution gate via
|
|
1127
1515
|
// PLAN_MODE_ALLOWED_TOOLS so what the model SEES and what the executor
|
|
@@ -1130,12 +1518,13 @@ export class Engine {
|
|
|
1130
1518
|
const toolDefs = this.planMode
|
|
1131
1519
|
? allToolDefs.filter((t) => PLAN_MODE_ALLOWED_TOOLS.has(t.name))
|
|
1132
1520
|
: allToolDefs;
|
|
1133
|
-
const [llmClient,
|
|
1521
|
+
const [llmClient, fullSystemPrompt, dynamicContextMsg] = await Promise.all([
|
|
1134
1522
|
llmClientPromise,
|
|
1523
|
+
// System prompt is now the STABLE prefix only — skills + git status moved
|
|
1524
|
+
// out to a trailing per-turn message so they no longer bust the cache.
|
|
1135
1525
|
promptComposer.buildSystemPrompt(toolDefs),
|
|
1136
|
-
promptComposer.
|
|
1526
|
+
promptComposer.buildDynamicContextMessage(),
|
|
1137
1527
|
]);
|
|
1138
|
-
const fullSystemPrompt = [systemPrompt, systemContext].filter(Boolean).join("\n\n");
|
|
1139
1528
|
// Prepend userContext (CLAUDE.md) as first message (sync, fast)
|
|
1140
1529
|
const userContextMsg = promptComposer.buildUserContextMessage();
|
|
1141
1530
|
if (userContextMsg) {
|
|
@@ -1154,6 +1543,12 @@ export class Engine {
|
|
|
1154
1543
|
// reminder → user request.
|
|
1155
1544
|
messages.splice(messages.length - 1, 0, lifecycleReminder);
|
|
1156
1545
|
}
|
|
1546
|
+
// Volatile context (skills + git status) goes at the very END — after the
|
|
1547
|
+
// user task — so it sits past the conversation's cache breakpoint. A change
|
|
1548
|
+
// here (new skill, edited file) never invalidates the cached history prefix.
|
|
1549
|
+
if (dynamicContextMsg) {
|
|
1550
|
+
messages.push(dynamicContextMsg);
|
|
1551
|
+
}
|
|
1157
1552
|
this.lastSessionId = session.state.sessionId;
|
|
1158
1553
|
this.lastMessages = messages;
|
|
1159
1554
|
// Wire up LLM summarization for context compaction
|
|
@@ -1165,28 +1560,34 @@ export class Engine {
|
|
|
1165
1560
|
// run would be evaluated fresh and might get a different replacement
|
|
1166
1561
|
// string than the one already in the message, breaking idempotency.
|
|
1167
1562
|
contextManager.initReplacementStateFromMessages(messages);
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1563
|
+
// Two summarizers with DIFFERENT quality needs:
|
|
1564
|
+
//
|
|
1565
|
+
// 1. Context-compaction summary (setSummarizeFn) → PRIMARY model. This
|
|
1566
|
+
// condenses many rounds into the running summary that REPLACES the real
|
|
1567
|
+
// history; a dropped decision makes the conversation "forget" and poisons
|
|
1568
|
+
// every subsequent turn. It fires only near the compact ratio (~0.85), so
|
|
1569
|
+
// it's infrequent — quality far outweighs the occasional extra cost of a
|
|
1570
|
+
// primary-model call. (Manual /compact uses the primary for the same
|
|
1571
|
+
// reason; see forceCompact.)
|
|
1572
|
+
//
|
|
1573
|
+
// 2. Tool-use one-liner summaries (modelFacade.summarize below) → AUX model.
|
|
1574
|
+
// These are tiny throwaway outputs ("Wrote design doc") fired every turn;
|
|
1575
|
+
// that high-frequency, low-stakes chore is exactly what aux is for.
|
|
1174
1576
|
const auxSummaryClient = await this.resolveAuxClient(llmClient);
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
// OpenAI-compatible provider the field is ignored.
|
|
1184
|
-
reasoning: { mode: "off" },
|
|
1185
|
-
});
|
|
1186
|
-
return summaryResponse.text;
|
|
1187
|
-
});
|
|
1188
|
-
// Create components (requires resolved llmClient)
|
|
1577
|
+
Object.assign(session.state, normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage));
|
|
1578
|
+
const recordCumulativeUsage = (usage) => {
|
|
1579
|
+
const next = addCumulativeUsage(session.state, usage);
|
|
1580
|
+
Object.assign(session.state, next);
|
|
1581
|
+
return next;
|
|
1582
|
+
};
|
|
1583
|
+
contextManager.setSummarizeFn(this.buildSummarizeFn(llmClient, recordCumulativeUsage));
|
|
1584
|
+
// Create components (requires resolved llmClient).
|
|
1189
1585
|
const modelFacade = new ModelFacade(llmClient, session.transcript);
|
|
1586
|
+
// Session-cumulative usage baseline: the LLM client is recreated per run
|
|
1587
|
+
// (its getUsage() counts only THIS run), so to accumulate across runs we
|
|
1588
|
+
// capture the persisted total at run start and fold this run's usage onto
|
|
1589
|
+
// it (see foldRunUsage). Snapshot now, before any turn boundary fires.
|
|
1590
|
+
const usageBaseline = { ...session.state.tokenUsage };
|
|
1190
1591
|
// Wire getOutputTokens for token budget tracking
|
|
1191
1592
|
modelFacade.getOutputTokens = () => {
|
|
1192
1593
|
const usage = llmClient.getUsage();
|
|
@@ -1221,14 +1622,39 @@ export class Engine {
|
|
|
1221
1622
|
// File history: auto-backup before Write/Edit
|
|
1222
1623
|
const sessionDir = join(this.config.sessionStorageDir ?? join(userHome(), ".code-shell", "sessions"), session.state.sessionId);
|
|
1223
1624
|
const fileHistory = FileHistory.loadFromDir(sessionDir);
|
|
1224
|
-
|
|
1625
|
+
// Keep a reference so we can unregister in the finally below. Registering an
|
|
1626
|
+
// anonymous handler every run() leaks: unregister matches by handler
|
|
1627
|
+
// identity, so without a stored reference each run stacks another identical
|
|
1628
|
+
// on_tool_start handler that fires (and re-snapshots) on every tool forever.
|
|
1629
|
+
const fileHistoryHandler = async (context) => {
|
|
1225
1630
|
const toolName = context.data?.toolName;
|
|
1226
1631
|
const args = context.data?.args;
|
|
1632
|
+
// Tag snapshots with the current turn (stamped above before any tool
|
|
1633
|
+
// runs) so turn-level /undo can revert just this user message's edits.
|
|
1634
|
+
const turnSeq = session.state.turnSeq;
|
|
1227
1635
|
if ((toolName === "Write" || toolName === "Edit") && args?.file_path) {
|
|
1228
|
-
|
|
1636
|
+
const path = args.file_path;
|
|
1637
|
+
// saveSnapshot returns null when the file does not exist yet — this
|
|
1638
|
+
// hook runs BEFORE the tool, so a null here means the turn is CREATING
|
|
1639
|
+
// the file. Record it (idempotent per turn) so /undo can delete it and
|
|
1640
|
+
// /redo can recreate it.
|
|
1641
|
+
if (fileHistory.saveSnapshot(path, turnSeq) === null && turnSeq !== undefined) {
|
|
1642
|
+
fileHistory.recordCreated(path, turnSeq);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
else if (toolName === "ApplyPatch" && typeof args?.patch === "string") {
|
|
1646
|
+
// ApplyPatch mutates files too, so /undo must see them. Snapshot every
|
|
1647
|
+
// existing file the patch updates or deletes (adds have no prior
|
|
1648
|
+
// content). Resolve relative patch paths against the engine cwd, the
|
|
1649
|
+
// same base ApplyPatch itself uses.
|
|
1650
|
+
const cwd = this.config.cwd ?? process.cwd();
|
|
1651
|
+
for (const target of patchBackupTargets(args.patch, cwd)) {
|
|
1652
|
+
fileHistory.saveSnapshot(target, turnSeq);
|
|
1653
|
+
}
|
|
1229
1654
|
}
|
|
1230
1655
|
return {};
|
|
1231
|
-
}
|
|
1656
|
+
};
|
|
1657
|
+
this.hooks.register("on_tool_start", fileHistoryHandler, 100, "file_history_backup");
|
|
1232
1658
|
// Hook: agent start
|
|
1233
1659
|
await this.emitHook("on_agent_start", {
|
|
1234
1660
|
sessionId: session.state.sessionId,
|
|
@@ -1238,20 +1664,70 @@ export class Engine {
|
|
|
1238
1664
|
// Goal mode: register a GoalStopHook for the lifetime of THIS run so the
|
|
1239
1665
|
// turn loop keeps going until the session model judges the goal met.
|
|
1240
1666
|
// Registered per-run (and cleared in `finally`) so a later goal-less
|
|
1241
|
-
// send doesn't inherit a stale goal. The judge
|
|
1242
|
-
// same model
|
|
1667
|
+
// send doesn't inherit a stale goal. The judge runs on `auxSummaryClient`
|
|
1668
|
+
// — the same cheap aux model used for summarize/compaction — not the
|
|
1669
|
+
// (potentially expensive) session model: "is this goal met?" is a classic
|
|
1670
|
+
// aux-tier task, and a goal run can invoke the judge up to maxStopBlocks
|
|
1671
|
+
// times.
|
|
1243
1672
|
// Normalize the raw goal (string | GoalConfig) once at the run boundary;
|
|
1244
1673
|
// everything inward uses the GoalConfig. normalizeGoal() returns undefined
|
|
1245
1674
|
// when there's effectively no goal (empty objective).
|
|
1246
|
-
|
|
1675
|
+
//
|
|
1676
|
+
// PERSISTENT GOAL (CC /goal style): a goal set on one send survives across
|
|
1677
|
+
// later sends and manual interrupts until met or cleared. Resolution:
|
|
1678
|
+
// 1. options.goal — this send explicitly sets/replaces the goal.
|
|
1679
|
+
// 2. session.state.activeGoal — a goal set on an earlier send.
|
|
1680
|
+
// 3. config.goal — engine-level default (rare; e.g. headless).
|
|
1681
|
+
// When (1) supplies a goal that differs from the stored one we REPLACE the
|
|
1682
|
+
// persisted active goal (one active goal per session) and announce it. A
|
|
1683
|
+
// bare send with no options.goal inherits the stored active goal so the
|
|
1684
|
+
// model keeps working toward it — that's what makes it persistent.
|
|
1685
|
+
const explicitGoal = normalizeGoal(options?.goal);
|
|
1686
|
+
const storedGoal = this.config.isSubAgent !== true ? session.state.activeGoal : undefined;
|
|
1687
|
+
if (explicitGoal && this.config.isSubAgent !== true) {
|
|
1688
|
+
const replaced = !!storedGoal && storedGoal.objective !== explicitGoal.objective;
|
|
1689
|
+
// Stamp WHEN this goal was set so the judge can anchor relative deadlines
|
|
1690
|
+
// ("做到3点") to the set time, not "now" — else once the clock passes the
|
|
1691
|
+
// deadline the judge could read "3点" as tomorrow's and never stop. A new
|
|
1692
|
+
// or changed objective gets a fresh stamp; re-sending the SAME objective
|
|
1693
|
+
// keeps the original anchor (the goal continues, the user didn't restate a
|
|
1694
|
+
// new deadline). User input never carries setAtMs, so we set it here.
|
|
1695
|
+
explicitGoal.setAtMs = resolveGoalSetAt(explicitGoal.objective, storedGoal, Date.now());
|
|
1696
|
+
session.state.activeGoal = explicitGoal;
|
|
1697
|
+
this.sessionManager.saveState(session.state);
|
|
1698
|
+
options?.onStream?.({
|
|
1699
|
+
type: "goal_set",
|
|
1700
|
+
objective: explicitGoal.objective,
|
|
1701
|
+
replaced,
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
const normalizedGoal = explicitGoal ?? storedGoal ?? normalizeGoal(this.config.goal);
|
|
1247
1705
|
let goalHookHandler = null;
|
|
1248
1706
|
if (normalizedGoal && this.config.isSubAgent !== true) {
|
|
1249
1707
|
goalHookHandler = createGoalStopHook({
|
|
1250
1708
|
goal: normalizedGoal,
|
|
1251
|
-
llm:
|
|
1709
|
+
llm: auxSummaryClient,
|
|
1252
1710
|
log: logger,
|
|
1711
|
+
// Clear the persisted active goal the moment the judge says it's met,
|
|
1712
|
+
// so a later bare send doesn't re-inherit a satisfied goal. The hook
|
|
1713
|
+
// calls this from inside its met branch (single source of truth for
|
|
1714
|
+
// "goal achieved"); engine owns the persistence side-effect.
|
|
1715
|
+
onMet: () => {
|
|
1716
|
+
if (session.state.activeGoal) {
|
|
1717
|
+
session.state.activeGoal = undefined;
|
|
1718
|
+
this.sessionManager.saveState(session.state);
|
|
1719
|
+
}
|
|
1720
|
+
},
|
|
1721
|
+
// Re-read the persisted goal each turn so a mid-run 清除 (clearGoal
|
|
1722
|
+
// wrote state.json but this hook's frozen goal copy + the closure's
|
|
1723
|
+
// in-RAM session are untouched) actually stops the judge. Reads disk
|
|
1724
|
+
// via readActiveGoal — authoritative and independent of which session
|
|
1725
|
+
// instance the run closure holds.
|
|
1726
|
+
isGoalActive: (sid) => this.sessionManager.readActiveGoal(sid) !== undefined,
|
|
1253
1727
|
});
|
|
1254
1728
|
this.hooks.register("on_stop", goalHookHandler, 0, "goal-stop");
|
|
1729
|
+
// Expose for clearGoal() mid-run. Already guarded by isSubAgent above.
|
|
1730
|
+
this.activeGoalHook = goalHookHandler;
|
|
1255
1731
|
}
|
|
1256
1732
|
// Surface compaction events to the UI so the user knows when context was trimmed.
|
|
1257
1733
|
// Buffer the most recent event so TurnLoop can drain it and emit the
|
|
@@ -1278,6 +1754,24 @@ export class Engine {
|
|
|
1278
1754
|
pendingCompactInfo = null;
|
|
1279
1755
|
return info;
|
|
1280
1756
|
},
|
|
1757
|
+
consumeSteer: (source) => this.consumeSteer(sid, source),
|
|
1758
|
+
claimClientMessageId: (clientMessageId, source) => claimClientMessageId(session, clientMessageId, source),
|
|
1759
|
+
recordCumulativeUsage,
|
|
1760
|
+
// Clear the persisted goal for a self-reported completion / confirmed
|
|
1761
|
+
// cancel. Clears the in-RAM session's activeGoal (so THIS run's later
|
|
1762
|
+
// turns don't re-arm) AND persists it, and drops the in-flight stop
|
|
1763
|
+
// hook so nothing re-blocks the stop we're about to return.
|
|
1764
|
+
clearPersistedGoal: () => {
|
|
1765
|
+
if (session.state.activeGoal !== undefined) {
|
|
1766
|
+
session.state.activeGoal = undefined;
|
|
1767
|
+
this.sessionManager.saveState(session.state);
|
|
1768
|
+
}
|
|
1769
|
+
if (goalHookHandler) {
|
|
1770
|
+
this.hooks.unregister("on_stop", goalHookHandler);
|
|
1771
|
+
if (this.activeGoalHook === goalHookHandler)
|
|
1772
|
+
this.activeGoalHook = null;
|
|
1773
|
+
}
|
|
1774
|
+
},
|
|
1281
1775
|
ctxOverheadStore: {
|
|
1282
1776
|
get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
|
|
1283
1777
|
set: (s, n) => {
|
|
@@ -1285,10 +1779,23 @@ export class Engine {
|
|
|
1285
1779
|
},
|
|
1286
1780
|
},
|
|
1287
1781
|
}, {
|
|
1288
|
-
|
|
1289
|
-
|
|
1782
|
+
// Goal mode raises the turn ceiling: an unattended goal run keeps
|
|
1783
|
+
// getting re-blocked by the stop-hook until it's done, and the 100
|
|
1784
|
+
// interactive default would silently truncate a long objective. The
|
|
1785
|
+
// real backstops are the goal token/time budgets + maxStopBlocks.
|
|
1786
|
+
maxTurns: resolveMaxTurns(this.config.maxTurns, normalizedGoal),
|
|
1787
|
+
// Consecutive stop-block cap: config override > goal.maxStopBlocks >
|
|
1788
|
+
// GOAL_DEFAULT_MAX_STOP_BLOCKS(25). The old hardcoded 8 was too tight
|
|
1789
|
+
// for complex goals that legitimately get re-blocked while advancing.
|
|
1790
|
+
maxStopBlocks: resolveMaxStopBlocks(this.config.maxStopBlocks, normalizedGoal),
|
|
1791
|
+
// 25 (was 10): modern models routinely batch >10 parallel tool calls
|
|
1792
|
+
// (e.g. reading a dozen files at once). At 10 the excess was silently
|
|
1793
|
+
// dropped; the turn loop now also warns the model when it caps, but a
|
|
1794
|
+
// higher ceiling avoids the round-trip in the common case. (B-3)
|
|
1795
|
+
maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 25,
|
|
1290
1796
|
onStream: options?.onStream,
|
|
1291
1797
|
signal: options?.signal,
|
|
1798
|
+
freshImageMessages: freshImageMessage ? [freshImageMessage] : undefined,
|
|
1292
1799
|
// Goal mode: the active goal is surfaced to the on_stop handler via
|
|
1293
1800
|
// ctx.data.goal; the GoalStopHook (registered above) judges it.
|
|
1294
1801
|
goal: normalizedGoal,
|
|
@@ -1298,62 +1805,106 @@ export class Engine {
|
|
|
1298
1805
|
// completed run.
|
|
1299
1806
|
onTurnBoundary: (turnCount) => {
|
|
1300
1807
|
session.state.turnCount = turnCount;
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1808
|
+
// baseline + this run's running total (idempotent per boundary,
|
|
1809
|
+
// accumulates across runs; carries cacheRead/cacheCreation too).
|
|
1810
|
+
session.state.tokenUsage = foldRunUsage(usageBaseline, modelFacade.getUsage());
|
|
1811
|
+
// Surface the whole-session monotonic cache counts to the UI.
|
|
1812
|
+
// Separate from turn-loop's authoritative per-response emit (which
|
|
1813
|
+
// drives the live context reading and single-turn metric).
|
|
1814
|
+
const cumulative = normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage);
|
|
1815
|
+
const cumulativeHitRate = cumulativeCacheHitRate(cumulative);
|
|
1816
|
+
options?.onStream?.({
|
|
1817
|
+
type: "usage_update",
|
|
1818
|
+
promptTokens: cumulative.cumulativePromptTokens,
|
|
1819
|
+
cumulativePromptTokens: cumulative.cumulativePromptTokens,
|
|
1820
|
+
cumulativeCacheReadTokens: cumulative.cumulativeCacheReadTokens,
|
|
1821
|
+
cumulativeCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
|
|
1822
|
+
...(cumulativeHitRate !== undefined
|
|
1823
|
+
? { cumulativeCacheHitRate: cumulativeHitRate }
|
|
1824
|
+
: {}),
|
|
1825
|
+
sessionPromptTokens: cumulative.cumulativePromptTokens,
|
|
1826
|
+
sessionCacheReadTokens: cumulative.cumulativeCacheReadTokens,
|
|
1827
|
+
sessionCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
|
|
1828
|
+
});
|
|
1307
1829
|
if (this.config.costStore) {
|
|
1308
1830
|
session.state.costState = this.config.costStore.serialize();
|
|
1309
1831
|
}
|
|
1310
1832
|
this.sessionManager.saveState(session.state);
|
|
1311
1833
|
},
|
|
1312
1834
|
});
|
|
1835
|
+
// Expose this run's loop for mid-run extension (TODO 3.1). Top-level only —
|
|
1836
|
+
// a sub-agent's loop is its own concern and isn't user-extendable.
|
|
1837
|
+
if (this.config.isSubAgent !== true)
|
|
1838
|
+
this.activeTurnLoop = turnLoop;
|
|
1839
|
+
// Expose this run's session bundle so a mid-run clearGoal() wipes the goal
|
|
1840
|
+
// on the very instance this loop keeps saving (see field doc). Top-level
|
|
1841
|
+
// only — sub-agents don't carry user-clearable persistent goals.
|
|
1842
|
+
if (this.config.isSubAgent !== true)
|
|
1843
|
+
this.activeRunSession = session;
|
|
1313
1844
|
let result;
|
|
1314
1845
|
try {
|
|
1315
1846
|
result = await turnLoop.run(messages);
|
|
1316
|
-
// ──
|
|
1317
|
-
//
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1321
|
-
//
|
|
1322
|
-
// session's background agents are running, then drain ALL their results
|
|
1323
|
-
// and feed them back as one more turn so the agent summarizes.
|
|
1847
|
+
// ── Headless: drain background sub-agents before resolving ───────
|
|
1848
|
+
// Unified background-work model (2026-06-17): the engine NO LONGER parks
|
|
1849
|
+
// every run waiting on background work. Background work (sub-agents,
|
|
1850
|
+
// video polls, shells) ends the turn, yields, and is picked up later by
|
|
1851
|
+
// the server's notification-wakeup path (maybeWakeIdleSession). The
|
|
1852
|
+
// INTERACTIVE path relies on that wakeup + a run-boundary re-check.
|
|
1324
1853
|
//
|
|
1325
|
-
//
|
|
1326
|
-
//
|
|
1854
|
+
// HEADLESS is the exception: a one-shot `engine.run` whose caller takes
|
|
1855
|
+
// `result.text` as THE answer (automation / SDK) has no later turn to
|
|
1856
|
+
// pick up a wakeup — so it must wait, before resolving, until its own
|
|
1857
|
+
// background SUB-AGENTS finish and summarize. Only sub-agents (their
|
|
1858
|
+
// summary IS part of this run's result), NOT shells (a dev server never
|
|
1859
|
+
// exits → would hang headless forever) and NOT video (a long render the
|
|
1860
|
+
// one-shot run shouldn't block on). This replaces the old for(;;) park
|
|
1861
|
+
// (s-mpvf4rsj-bb6e4639 invariant) for the headless case only.
|
|
1327
1862
|
const sid = session.state.sessionId;
|
|
1328
1863
|
const isTopLevel = this.config.isSubAgent !== true;
|
|
1329
|
-
if (isTopLevel) {
|
|
1864
|
+
if (isTopLevel && this.isHeadless()) {
|
|
1330
1865
|
let aborted = options?.signal?.aborted === true;
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1866
|
+
// Loop: a summarize turn can spawn a NEW background sub-agent; keep
|
|
1867
|
+
// draining + summarizing until none remain. turnCount accumulates, so
|
|
1868
|
+
// the turn-loop's maxTurns still bounds runaway re-summarization.
|
|
1869
|
+
for (;;) {
|
|
1870
|
+
while (!aborted && asyncAgentRegistry.hasRunningForSession(sid)) {
|
|
1871
|
+
aborted = await this.waitForBackgroundAgentChange(sid, options?.signal);
|
|
1872
|
+
}
|
|
1873
|
+
let pending = notificationQueue.drainAll(sid);
|
|
1874
|
+
if (aborted && pending.length === 0) {
|
|
1875
|
+
// Abort race: an agent calls markCompleted (registry notify) and only
|
|
1876
|
+
// THEN enqueue (queue notify) as two separate statements. If the abort
|
|
1877
|
+
// fired before that agent's completion `.then` ran, the while above
|
|
1878
|
+
// exited on `aborted`, this drainAll caught nothing, and a naive
|
|
1879
|
+
// `break` here would drop the agent's output. Give still-settling
|
|
1880
|
+
// agents a bounded window to finish enqueuing, then drain once more.
|
|
1881
|
+
// Each wait is timeout-bounded so a genuinely stuck (never-completing)
|
|
1882
|
+
// agent can't hang abort cleanup forever — we'd rather lose nothing in
|
|
1883
|
+
// the common case and not hang in the pathological one.
|
|
1884
|
+
for (let i = 0; i < 20 && asyncAgentRegistry.hasRunningForSession(sid); i++) {
|
|
1885
|
+
const changed = await this.waitForBackgroundAgentChangeOrTimeout(sid, 25);
|
|
1886
|
+
if (!changed)
|
|
1887
|
+
break; // timed out with no state change → stop waiting
|
|
1888
|
+
}
|
|
1889
|
+
pending = notificationQueue.drainAll(sid);
|
|
1890
|
+
if (pending.length === 0)
|
|
1891
|
+
break;
|
|
1892
|
+
}
|
|
1893
|
+
else if (pending.length === 0) {
|
|
1894
|
+
break;
|
|
1895
|
+
}
|
|
1339
1896
|
const injected = {
|
|
1340
1897
|
role: "user",
|
|
1341
1898
|
content: `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`,
|
|
1342
1899
|
};
|
|
1343
1900
|
if (aborted) {
|
|
1344
|
-
//
|
|
1345
|
-
//
|
|
1346
|
-
|
|
1347
|
-
// user message in this session will see these results in history.
|
|
1348
|
-
session.transcript.appendMessage(injected.role, injected.content);
|
|
1901
|
+
// Mark injected: a synthetic notification, not the user's own input —
|
|
1902
|
+
// the disk reader drops it on replay so no phantom user bubble.
|
|
1903
|
+
session.transcript.appendMessage(injected.role, injected.content, { injected: true });
|
|
1349
1904
|
result = { ...result, messages: [...result.messages, injected] };
|
|
1905
|
+
break;
|
|
1350
1906
|
}
|
|
1351
|
-
|
|
1352
|
-
// All background agents finished: one more turn so the agent reads
|
|
1353
|
-
// every result and summarizes. turnCount keeps accumulating, so
|
|
1354
|
-
// maxTurns still bounds runaway re-summarization.
|
|
1355
|
-
result = await turnLoop.run([...result.messages, injected]);
|
|
1356
|
-
}
|
|
1907
|
+
result = await turnLoop.run([...result.messages, injected]);
|
|
1357
1908
|
}
|
|
1358
1909
|
}
|
|
1359
1910
|
}
|
|
@@ -1362,6 +1913,15 @@ export class Engine {
|
|
|
1362
1913
|
// long-lived engine doesn't keep blocking stops.
|
|
1363
1914
|
if (goalHookHandler)
|
|
1364
1915
|
this.hooks.unregister("on_stop", goalHookHandler);
|
|
1916
|
+
if (this.activeGoalHook === goalHookHandler)
|
|
1917
|
+
this.activeGoalHook = null;
|
|
1918
|
+
if (this.activeTurnLoop === turnLoop)
|
|
1919
|
+
this.activeTurnLoop = null;
|
|
1920
|
+
if (this.activeRunSession === session)
|
|
1921
|
+
this.activeRunSession = null;
|
|
1922
|
+
// Run-scoped too: this handler is re-registered every run(), so it must be
|
|
1923
|
+
// dropped here or it stacks duplicates that re-snapshot on every tool.
|
|
1924
|
+
this.hooks.unregister("on_tool_start", fileHistoryHandler);
|
|
1365
1925
|
}
|
|
1366
1926
|
this.lastMessages = result.messages;
|
|
1367
1927
|
this.compactedMessagesBySession.set(session.state.sessionId, this.stripUserContextMessage(result.messages, userContextMsg));
|
|
@@ -1404,6 +1964,12 @@ export class Engine {
|
|
|
1404
1964
|
void buildSessionTitle(auxSummaryClient, firstUserText, result.text)
|
|
1405
1965
|
.then((title) => {
|
|
1406
1966
|
if (title) {
|
|
1967
|
+
// Persist the title so it survives a localStorage wipe / disk
|
|
1968
|
+
// rebuild — it used to live only in the renderer's localStorage
|
|
1969
|
+
// index. This .then resolves AFTER the saveState below (:1892), so
|
|
1970
|
+
// it must save again itself rather than rely on that write.
|
|
1971
|
+
session.state.title = title;
|
|
1972
|
+
this.sessionManager.saveState(session.state);
|
|
1407
1973
|
onStream({
|
|
1408
1974
|
type: "session_title",
|
|
1409
1975
|
sessionId: session.state.sessionId,
|
|
@@ -1421,12 +1987,9 @@ export class Engine {
|
|
|
1421
1987
|
// distinction and misled anyone reading state.json.
|
|
1422
1988
|
session.state.turnCount = turnLoop.currentTurn;
|
|
1423
1989
|
session.state.status = result.reason;
|
|
1990
|
+
// Session-cumulative (baseline + this run) for persistence...
|
|
1424
1991
|
const usage = modelFacade.getUsage();
|
|
1425
|
-
session.state.tokenUsage =
|
|
1426
|
-
promptTokens: usage.totalPromptTokens,
|
|
1427
|
-
completionTokens: usage.totalCompletionTokens,
|
|
1428
|
-
totalTokens: usage.totalTokens,
|
|
1429
|
-
};
|
|
1992
|
+
session.state.tokenUsage = foldRunUsage(usageBaseline, usage);
|
|
1430
1993
|
if (this.config.costStore) {
|
|
1431
1994
|
session.state.costState = this.config.costStore.serialize();
|
|
1432
1995
|
}
|
|
@@ -1459,12 +2022,34 @@ export class Engine {
|
|
|
1459
2022
|
*/
|
|
1460
2023
|
/**
|
|
1461
2024
|
* Resolve the LLM client for background/auxiliary work (memory extraction,
|
|
1462
|
-
* auto-dream). When settings.
|
|
1463
|
-
* cache) a dedicated client for it so per-turn book-keeping runs on a cheap
|
|
2025
|
+
* auto-dream). When settings.defaults.auxText names a valid pool model, build
|
|
2026
|
+
* (and cache) a dedicated client for it so per-turn book-keeping runs on a cheap
|
|
1464
2027
|
* fast model instead of the expensive primary. Falls back to `fallback` (the
|
|
1465
2028
|
* active run's client) when unset, unknown, or on any build failure — aux
|
|
1466
2029
|
* work is best-effort and must never break a run.
|
|
1467
2030
|
*/
|
|
2031
|
+
/**
|
|
2032
|
+
* Build the SummarizeFn used for context compaction. Extracted so both the
|
|
2033
|
+
* run path and forceCompact share one definition of the summarization call.
|
|
2034
|
+
*/
|
|
2035
|
+
buildSummarizeFn(auxSummaryClient, recordCumulativeUsage) {
|
|
2036
|
+
return async (prompt) => {
|
|
2037
|
+
const summaryResponse = await auxSummaryClient.createMessage({
|
|
2038
|
+
systemPrompt: "You are a conversation summarizer. Be concise and factual.",
|
|
2039
|
+
messages: [{ role: "user", content: prompt }],
|
|
2040
|
+
tools: [],
|
|
2041
|
+
maxTokens: 1024,
|
|
2042
|
+
// Auxiliary call — no need to burn reasoning tokens. On DeepSeek V4
|
|
2043
|
+
// this flips thinking off (~3x faster, fewer tokens); on every other
|
|
2044
|
+
// OpenAI-compatible provider the field is ignored.
|
|
2045
|
+
reasoning: { mode: "off" },
|
|
2046
|
+
});
|
|
2047
|
+
if (summaryResponse.usage) {
|
|
2048
|
+
recordCumulativeUsage?.(summaryResponse.usage);
|
|
2049
|
+
}
|
|
2050
|
+
return summaryResponse.text;
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
1468
2053
|
async resolveAuxClient(fallback) {
|
|
1469
2054
|
let auxKey;
|
|
1470
2055
|
try {
|
|
@@ -1473,7 +2058,9 @@ export class Engine {
|
|
|
1473
2058
|
// once per run on the post-run background path, so the cost is fine.
|
|
1474
2059
|
const sm = this.getSettingsManager();
|
|
1475
2060
|
sm.invalidate();
|
|
1476
|
-
|
|
2061
|
+
// Unified store's defaults.auxText (a connection id = pool key) selects
|
|
2062
|
+
// the aux model; resolveAuxKey returns it (or undefined).
|
|
2063
|
+
auxKey = resolveAuxKey(sm.get());
|
|
1477
2064
|
}
|
|
1478
2065
|
catch {
|
|
1479
2066
|
return fallback;
|
|
@@ -1519,14 +2106,18 @@ export class Engine {
|
|
|
1519
2106
|
try {
|
|
1520
2107
|
// Background calls run on the auxiliary model when configured, so memory
|
|
1521
2108
|
// book-keeping doesn't burn the expensive primary model every turn.
|
|
1522
|
-
|
|
2109
|
+
// settings.memories.extractionModel (if set + valid) overrides the aux
|
|
2110
|
+
// model specifically for memory extraction (TODO 8.1).
|
|
2111
|
+
const llmClient = await this.resolveExtractionClient(primaryClient);
|
|
1523
2112
|
// Only run memory extraction for substantive sessions. The previous
|
|
1524
2113
|
// threshold of 4 user+assistant messages was low enough that two-line
|
|
1525
2114
|
// exchanges ("what's the time?" / "noon") triggered a full LLM
|
|
1526
2115
|
// extraction, which then padded the memory store with low-signal
|
|
1527
2116
|
// entries. 8 messages is roughly "more than a single back-and-forth"
|
|
1528
2117
|
// — substantive enough to be worth a durable note.
|
|
1529
|
-
const messages = transcript
|
|
2118
|
+
const messages = transcript
|
|
2119
|
+
.toMessages()
|
|
2120
|
+
.filter((m) => m.role === "user" || m.role === "assistant");
|
|
1530
2121
|
if (messages.length < 8)
|
|
1531
2122
|
return;
|
|
1532
2123
|
// Memory orchestrator + dream-loop calls are auxiliary LLM calls
|
|
@@ -1558,6 +2149,10 @@ export class Engine {
|
|
|
1558
2149
|
},
|
|
1559
2150
|
runDream: async ({ systemPrompt, userPrompt, projectDir }) => this.runDreamLoop({ systemPrompt, userPrompt, projectDir, llmClient, sessionId }),
|
|
1560
2151
|
projectDir: cwd,
|
|
2152
|
+
// settings.memories.maxCount caps memories accepted per extraction;
|
|
2153
|
+
// autoExtract=false turns the extractor off (summaries/dream stay).
|
|
2154
|
+
maxCount: this.readMemoriesConfig()?.maxCount,
|
|
2155
|
+
autoExtract: this.readMemoriesConfig()?.autoExtract,
|
|
1561
2156
|
});
|
|
1562
2157
|
await orchestrator.run(plainMessages, sessionId);
|
|
1563
2158
|
}
|
|
@@ -1608,10 +2203,10 @@ export class Engine {
|
|
|
1608
2203
|
* Switch the active model by pool key. Takes effect on the next run() call.
|
|
1609
2204
|
* Returns the new model entry.
|
|
1610
2205
|
*
|
|
1611
|
-
* Persists settings.
|
|
2206
|
+
* Persists settings.defaults.text (= the connection id / pool key) so the
|
|
1612
2207
|
* next process startup defaults to the same model — without this, switches
|
|
1613
2208
|
* only live in memory and every restart reverts to the previously persisted
|
|
1614
|
-
*
|
|
2209
|
+
* defaults.text.
|
|
1615
2210
|
*/
|
|
1616
2211
|
switchModel(key) {
|
|
1617
2212
|
const entry = this.modelPool.switch(key);
|
|
@@ -1620,19 +2215,42 @@ export class Engine {
|
|
|
1620
2215
|
// this.config.clientDefaults and survive the switch untouched.
|
|
1621
2216
|
const nextLlm = this.modelPool.toLLMConfig(entry);
|
|
1622
2217
|
this.config = { ...this.config, llm: nextLlm };
|
|
1623
|
-
this.persistActiveModel(entry
|
|
2218
|
+
this.persistActiveModel(entry);
|
|
1624
2219
|
return entry;
|
|
1625
2220
|
}
|
|
2221
|
+
/**
|
|
2222
|
+
* Zero the legacy/model-scoped token/cache usage window on disk. The
|
|
2223
|
+
* whole-session cumulative counters are intentionally left alone.
|
|
2224
|
+
*/
|
|
2225
|
+
resetSessionUsage(sessionId) {
|
|
2226
|
+
const zero = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
2227
|
+
// If the session is mid-run right now, update its live state so the next
|
|
2228
|
+
// turn-boundary write doesn't re-fold a stale baseline.
|
|
2229
|
+
if (this.activeRunSession?.state.sessionId === sessionId) {
|
|
2230
|
+
this.activeRunSession.state.tokenUsage = { ...zero };
|
|
2231
|
+
}
|
|
2232
|
+
// Persist to disk so a reload / next run picks up the reset.
|
|
2233
|
+
if (this.sessionManager.exists(sessionId)) {
|
|
2234
|
+
try {
|
|
2235
|
+
const bundle = this.sessionManager.resume(sessionId);
|
|
2236
|
+
bundle.state.tokenUsage = { ...zero };
|
|
2237
|
+
this.sessionManager.saveState(bundle.state);
|
|
2238
|
+
}
|
|
2239
|
+
catch {
|
|
2240
|
+
// Session not resumable (never persisted yet) — the in-memory reset
|
|
2241
|
+
// above covers the live case; nothing else to do.
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
1626
2245
|
/**
|
|
1627
2246
|
* Write the active model selection to ~/.code-shell/settings.json.
|
|
1628
2247
|
*
|
|
1629
|
-
*
|
|
1630
|
-
*
|
|
1631
|
-
*
|
|
1632
|
-
*
|
|
1633
|
-
* undefined when the entry resolves via providerCatalog.
|
|
2248
|
+
* Persists settings.defaults.text = entry.key (the connection id == pool
|
|
2249
|
+
* key). That is the single field the boot path reads to restore the active
|
|
2250
|
+
* text model on the next startup (see ctor: settings.defaults.text → pool
|
|
2251
|
+
* switch). No legacy activeKey/model.* mirror is written.
|
|
1634
2252
|
*/
|
|
1635
|
-
persistActiveModel(entry
|
|
2253
|
+
persistActiveModel(entry) {
|
|
1636
2254
|
try {
|
|
1637
2255
|
// userHome() (not raw homedir()) so a test that sets process.env.HOME to
|
|
1638
2256
|
// a tmpdir gets its writes isolated too — the SettingsManager reader
|
|
@@ -1651,28 +2269,31 @@ export class Engine {
|
|
|
1651
2269
|
return;
|
|
1652
2270
|
}
|
|
1653
2271
|
}
|
|
1654
|
-
const
|
|
1655
|
-
? existing.
|
|
2272
|
+
const prevDefaults = typeof existing.defaults === "object" && existing.defaults
|
|
2273
|
+
? existing.defaults
|
|
1656
2274
|
: {};
|
|
1657
2275
|
const updated = {
|
|
1658
2276
|
...existing,
|
|
1659
|
-
|
|
1660
|
-
model: {
|
|
1661
|
-
...prevModel,
|
|
1662
|
-
provider: llm.provider,
|
|
1663
|
-
name: entry.model,
|
|
1664
|
-
apiKey: llm.apiKey,
|
|
1665
|
-
baseUrl: llm.baseUrl,
|
|
1666
|
-
},
|
|
2277
|
+
defaults: { ...prevDefaults, text: entry.key },
|
|
1667
2278
|
};
|
|
2279
|
+
// mode 0o600: this writes model.apiKey (plaintext) into settings.json, so
|
|
2280
|
+
// it must be owner-only — same R-1 hardening as SettingsManager/onboarding.
|
|
2281
|
+
// (Third settings.json writer; the R-1 sweep initially missed this one.)
|
|
1668
2282
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
1669
2283
|
const payload = JSON.stringify(updated, null, 2) + "\n";
|
|
1670
|
-
writeFileSync(tmp, payload, "utf-8");
|
|
2284
|
+
writeFileSync(tmp, payload, { encoding: "utf-8", mode: 0o600 });
|
|
1671
2285
|
try {
|
|
1672
2286
|
renameSync(tmp, file);
|
|
1673
2287
|
}
|
|
1674
2288
|
catch {
|
|
1675
|
-
writeFileSync(file, payload, "utf-8");
|
|
2289
|
+
writeFileSync(file, payload, { encoding: "utf-8", mode: 0o600 });
|
|
2290
|
+
}
|
|
2291
|
+
// mode arg only applies on create; tighten an already-existing file too.
|
|
2292
|
+
try {
|
|
2293
|
+
chmodSync(file, 0o600);
|
|
2294
|
+
}
|
|
2295
|
+
catch {
|
|
2296
|
+
/* best-effort */
|
|
1676
2297
|
}
|
|
1677
2298
|
}
|
|
1678
2299
|
catch (err) {
|
|
@@ -1708,12 +2329,10 @@ export class Engine {
|
|
|
1708
2329
|
* payloads are dropped so out-of-order reload deliveries can't let an older
|
|
1709
2330
|
* config clobber a newer one (Q5).
|
|
1710
2331
|
*
|
|
1711
|
-
* MCP:
|
|
1712
|
-
*
|
|
1713
|
-
*
|
|
1714
|
-
*
|
|
1715
|
-
* new servers will be connected on the next run via the existing per-run
|
|
1716
|
-
* connectAll path — so we skip the connect here.
|
|
2332
|
+
* MCP: reconciles the shared MCP pool against the new disk-default server
|
|
2333
|
+
* set. Added servers connect idempotently; removed/disabled servers are
|
|
2334
|
+
* disconnected and their registered MCP tools are unregistered so plugin
|
|
2335
|
+
* disable takes effect without an Electron restart.
|
|
1717
2336
|
*
|
|
1718
2337
|
* Preset (#2): a preset hot-reload re-resolves `this.preset` so the next-turn
|
|
1719
2338
|
* PromptComposer picks up the new preset's system prompt / behavior — that's
|
|
@@ -1747,8 +2366,14 @@ export class Engine {
|
|
|
1747
2366
|
// The builtin tool SET is ctor-frozen and may be shared via runtime — we
|
|
1748
2367
|
// do NOT rebuild it here. If the new preset implies a different builtin
|
|
1749
2368
|
// tool set, that part of the change only lands on session restart.
|
|
1750
|
-
const prevTools = resolveBuiltinToolNames({ preset: prevPresetName })
|
|
1751
|
-
|
|
2369
|
+
const prevTools = resolveBuiltinToolNames({ preset: prevPresetName })
|
|
2370
|
+
.slice()
|
|
2371
|
+
.sort()
|
|
2372
|
+
.join(",");
|
|
2373
|
+
const nextTools = resolveBuiltinToolNames({ preset: nextPreset.name })
|
|
2374
|
+
.slice()
|
|
2375
|
+
.sort()
|
|
2376
|
+
.join(",");
|
|
1752
2377
|
if (prevTools !== nextTools) {
|
|
1753
2378
|
logger.warn("engine.preset_reload.tool_set_change_needs_restart", {
|
|
1754
2379
|
from: prevPresetName,
|
|
@@ -1760,16 +2385,17 @@ export class Engine {
|
|
|
1760
2385
|
}
|
|
1761
2386
|
this.reloadHooks();
|
|
1762
2387
|
if (patch.mcpServers && this.mcpManager) {
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
2388
|
+
// Fire-and-forget reconcile (connect added / disconnect removed servers).
|
|
2389
|
+
// It must NOT surface as an unhandled rejection: a single flaky server
|
|
2390
|
+
// that fails to connect/disconnect during hot-reload would otherwise
|
|
2391
|
+
// crash the host process (or be silently swallowed). Catch + log so the
|
|
2392
|
+
// reconcile is best-effort and the next reload can retry.
|
|
2393
|
+
void this.mcpManager.reconcile(patch.mcpServers, this).catch((err) => {
|
|
2394
|
+
logger.error("engine.mcp_reconcile_failed", {
|
|
2395
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2396
|
+
version,
|
|
2397
|
+
});
|
|
2398
|
+
});
|
|
1773
2399
|
}
|
|
1774
2400
|
this.lastAppliedConfigVersion = version;
|
|
1775
2401
|
}
|
|
@@ -1782,6 +2408,55 @@ export class Engine {
|
|
|
1782
2408
|
* engine.run() call for this session picks up the injected content
|
|
1783
2409
|
* instead of a stale snapshot from the previous run.
|
|
1784
2410
|
*/
|
|
2411
|
+
/**
|
|
2412
|
+
* Read a session's persisted active goal WITHOUT resuming it (cheap — reads
|
|
2413
|
+
* only state.json via SessionManager.readActiveGoal). The desktop host calls
|
|
2414
|
+
* this on session load to re-surface the goal block + its Cancel button: a
|
|
2415
|
+
* persistent goal lives only in state.activeGoal and is never replayed from
|
|
2416
|
+
* the transcript, so after a reload of an aborted goal run the UI would
|
|
2417
|
+
* otherwise show nothing (the "goal 还在但页面不显示、取消不了" bug). Returns
|
|
2418
|
+
* undefined when the session is unknown or has no active goal.
|
|
2419
|
+
*/
|
|
2420
|
+
getGoal(sessionId) {
|
|
2421
|
+
return this.sessionManager.readActiveGoal(sessionId);
|
|
2422
|
+
}
|
|
2423
|
+
/**
|
|
2424
|
+
* Clear a session's persisted active goal (CC `/goal clear`). Works whether
|
|
2425
|
+
* the session is idle or its goal run is in flight: it wipes
|
|
2426
|
+
* `state.activeGoal` (so the next bare send won't re-inherit it) and, if a
|
|
2427
|
+
* goal hook is currently registered for this engine, unregisters it so an
|
|
2428
|
+
* in-flight run can stop instead of being re-blocked by the now-cleared goal.
|
|
2429
|
+
* Returns true if a goal was actually cleared. Idempotent — clearing a
|
|
2430
|
+
* session with no active goal is a no-op returning false.
|
|
2431
|
+
*/
|
|
2432
|
+
clearGoal(sessionId) {
|
|
2433
|
+
if (!this.sessionManager.exists(sessionId))
|
|
2434
|
+
return false;
|
|
2435
|
+
// Prefer the LIVE run's bundle when it's this session: clearing its
|
|
2436
|
+
// in-RAM state.activeGoal is what stops the run loop from writing the goal
|
|
2437
|
+
// back on its next saveState. A fresh resume() copy would be cleared and
|
|
2438
|
+
// persisted, but the running loop's own detached bundle still holds the
|
|
2439
|
+
// goal and resurrects it — the stale-write-back race. Falls back to a
|
|
2440
|
+
// resumed copy when no run of this session is currently in flight.
|
|
2441
|
+
const live = this.activeRunSession && this.activeRunSession.state.sessionId === sessionId
|
|
2442
|
+
? this.activeRunSession
|
|
2443
|
+
: null;
|
|
2444
|
+
const session = live ?? this.sessionManager.resume(sessionId);
|
|
2445
|
+
const had = session.state.activeGoal !== undefined;
|
|
2446
|
+
if (had) {
|
|
2447
|
+
session.state.activeGoal = undefined;
|
|
2448
|
+
this.sessionManager.saveState(session.state);
|
|
2449
|
+
}
|
|
2450
|
+
// If THIS session's goal run is in flight, drop its stop hook so the
|
|
2451
|
+
// current run can terminate (the closure-held goal would otherwise keep
|
|
2452
|
+
// re-blocking). The run's own `finally` also unregisters; double-unregister
|
|
2453
|
+
// is safe (set delete is idempotent).
|
|
2454
|
+
if (this.activeGoalHook && this.lastSessionId === sessionId) {
|
|
2455
|
+
this.hooks.unregister("on_stop", this.activeGoalHook);
|
|
2456
|
+
this.activeGoalHook = null;
|
|
2457
|
+
}
|
|
2458
|
+
return had;
|
|
2459
|
+
}
|
|
1785
2460
|
injectContext(sessionId, content) {
|
|
1786
2461
|
const session = this.sessionManager.resume(sessionId);
|
|
1787
2462
|
session.transcript.appendMessage("assistant", content);
|
|
@@ -1794,25 +2469,74 @@ export class Engine {
|
|
|
1794
2469
|
}
|
|
1795
2470
|
}
|
|
1796
2471
|
/**
|
|
1797
|
-
* Force context compaction on
|
|
2472
|
+
* Force context compaction on a session.
|
|
1798
2473
|
* Returns token stats before/after.
|
|
1799
2474
|
*/
|
|
1800
|
-
forceCompact() {
|
|
1801
|
-
const
|
|
1802
|
-
if (!
|
|
2475
|
+
async forceCompact(sessionId) {
|
|
2476
|
+
const effectiveSessionId = sessionId ?? this.lastSessionId;
|
|
2477
|
+
if (!effectiveSessionId) {
|
|
1803
2478
|
return { before: 0, after: 0, strategy: "none (no active session)" };
|
|
1804
2479
|
}
|
|
1805
|
-
const
|
|
1806
|
-
|
|
2480
|
+
const session = this.sessionManager.resume(effectiveSessionId);
|
|
2481
|
+
const sourceMessages = this.compactedMessagesBySession.get(effectiveSessionId) ?? session.transcript.toMessages();
|
|
1807
2482
|
const before = estimateTokens(sourceMessages);
|
|
1808
|
-
|
|
2483
|
+
let contextManager = this.lastContextManager;
|
|
2484
|
+
if (!contextManager || this.lastSessionId !== effectiveSessionId) {
|
|
2485
|
+
contextManager = new ContextManager({
|
|
2486
|
+
maxTokens: this.resolveMaxContextTokens(),
|
|
2487
|
+
...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
|
|
2488
|
+
});
|
|
2489
|
+
contextManager.setTranscriptPath(session.transcript.getFilePath());
|
|
2490
|
+
contextManager.initReplacementStateFromMessages(sourceMessages);
|
|
2491
|
+
this.lastContextManager = contextManager;
|
|
2492
|
+
}
|
|
2493
|
+
// Manual /compact emits its UI boundary at the protocol layer from the
|
|
2494
|
+
// final before/after result. Capture the tier here, but avoid reusing a
|
|
2495
|
+
// stale run callback retained on lastContextManager, which could otherwise
|
|
2496
|
+
// double-emit.
|
|
2497
|
+
let compactStrategy;
|
|
2498
|
+
contextManager.setOnCompact((info) => {
|
|
2499
|
+
if (info.after < info.before)
|
|
2500
|
+
compactStrategy = info.strategy;
|
|
2501
|
+
});
|
|
2502
|
+
// Manual /compact = maximum compaction NOW. The automatic ladder waits for
|
|
2503
|
+
// compactAtRatio (0.85 * window), so on a 1M-window model an 800k text-only
|
|
2504
|
+
// conversation sits under the gate and manage() only runs a no-op micro.
|
|
2505
|
+
// Wire a summarizeFn (the run path does this per-run; a cold forceCompact on
|
|
2506
|
+
// a resumed-but-never-run session has none) and call forceSummarize, which
|
|
2507
|
+
// ignores the ratio gate and always summarizes (falling back to snip/window).
|
|
2508
|
+
//
|
|
2509
|
+
// Use the PRIMARY model, not the aux model. Automatic background compaction
|
|
2510
|
+
// routes to aux to keep the high-frequency path cheap, but summarization is
|
|
2511
|
+
// a high-fidelity task (drop a decision and the conversation "forgets"), and
|
|
2512
|
+
// a manual /compact is a low-frequency, user-initiated request for quality.
|
|
2513
|
+
// The aux model is sized for tiny outputs (titles, memory extraction), so
|
|
2514
|
+
// downgrading the one compaction the user explicitly asked for is backwards.
|
|
2515
|
+
try {
|
|
2516
|
+
const primaryClient = await createLLMClient(this.config.llm, this.config.clientDefaults);
|
|
2517
|
+
Object.assign(session.state, normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage));
|
|
2518
|
+
const recordCompactUsage = (usage) => {
|
|
2519
|
+
const next = addCumulativeUsage(session.state, usage);
|
|
2520
|
+
Object.assign(session.state, next);
|
|
2521
|
+
this.sessionManager.saveState(session.state);
|
|
2522
|
+
return next;
|
|
2523
|
+
};
|
|
2524
|
+
contextManager.setSummarizeFn(this.buildSummarizeFn(primaryClient, recordCompactUsage));
|
|
2525
|
+
}
|
|
2526
|
+
catch (err) {
|
|
2527
|
+
logger.warn("engine.force_compact_client_failed", {
|
|
2528
|
+
error: err.message,
|
|
2529
|
+
});
|
|
2530
|
+
}
|
|
2531
|
+
const compacted = await contextManager.forceSummarize(sourceMessages);
|
|
1809
2532
|
const after = estimateTokens(compacted);
|
|
1810
|
-
this.compactedMessagesBySession.set(
|
|
2533
|
+
this.compactedMessagesBySession.set(effectiveSessionId, compacted);
|
|
2534
|
+
this.lastSessionId = effectiveSessionId;
|
|
1811
2535
|
this.lastMessages = compacted;
|
|
1812
2536
|
return {
|
|
1813
2537
|
before,
|
|
1814
2538
|
after,
|
|
1815
|
-
strategy:
|
|
2539
|
+
strategy: after >= before ? "no compaction needed" : (compactStrategy ?? "compacted"),
|
|
1816
2540
|
};
|
|
1817
2541
|
}
|
|
1818
2542
|
stripUserContextMessage(messages, userContextMsg) {
|
|
@@ -1823,7 +2547,7 @@ export class Engine {
|
|
|
1823
2547
|
}
|
|
1824
2548
|
getSettingsManager() {
|
|
1825
2549
|
if (!this.settingsManager) {
|
|
1826
|
-
this.settingsManager = new SettingsManager(this.config.cwd, this.config.settingsScope ?? "project");
|
|
2550
|
+
this.settingsManager = new SettingsManager(this.config.cwd, this.config.settingsScope ?? "project", this.config.projectTrusted !== false);
|
|
1827
2551
|
}
|
|
1828
2552
|
return this.settingsManager;
|
|
1829
2553
|
}
|
|
@@ -1875,7 +2599,7 @@ export class Engine {
|
|
|
1875
2599
|
rules.push({ tool: "Bash", decision: "allow" });
|
|
1876
2600
|
}
|
|
1877
2601
|
try {
|
|
1878
|
-
const settingsManager = new SettingsManager(cwd, this.config.settingsScope ?? "project");
|
|
2602
|
+
const settingsManager = new SettingsManager(cwd, this.config.settingsScope ?? "project", this.config.projectTrusted !== false);
|
|
1879
2603
|
const settings = settingsManager.get();
|
|
1880
2604
|
if (settings.permissions?.rules?.length) {
|
|
1881
2605
|
rules.unshift(...settings.permissions.rules);
|
|
@@ -1906,7 +2630,11 @@ export class Engine {
|
|
|
1906
2630
|
backend = interactive;
|
|
1907
2631
|
}
|
|
1908
2632
|
else {
|
|
1909
|
-
backend = new HeadlessApprovalBackend(mode === "bypassPermissions"
|
|
2633
|
+
backend = new HeadlessApprovalBackend(mode === "bypassPermissions"
|
|
2634
|
+
? "approve-all"
|
|
2635
|
+
: mode === "dontAsk"
|
|
2636
|
+
? "deny-all"
|
|
2637
|
+
: "deny-all");
|
|
1910
2638
|
}
|
|
1911
2639
|
}
|
|
1912
2640
|
return { rules, backend };
|
|
@@ -1930,6 +2658,27 @@ export class Engine {
|
|
|
1930
2658
|
getPermissionMode() {
|
|
1931
2659
|
return this.config.permissionMode ?? "acceptEdits";
|
|
1932
2660
|
}
|
|
2661
|
+
/**
|
|
2662
|
+
* Extend the in-flight run's turn ceiling and/or goal budgets (TODO 3.1 —
|
|
2663
|
+
* 运行中续轮/加预算). No-op (returns null) when no run is active. Lets a user
|
|
2664
|
+
* keep an unattended goal going past its original cap instead of restarting.
|
|
2665
|
+
*/
|
|
2666
|
+
extendGoalRun(opts) {
|
|
2667
|
+
if (!this.activeTurnLoop)
|
|
2668
|
+
return null;
|
|
2669
|
+
return this.activeTurnLoop.extend(opts);
|
|
2670
|
+
}
|
|
2671
|
+
/**
|
|
2672
|
+
* The effective permission rules for the current mode + cwd (TODO 5.1) —
|
|
2673
|
+
* preset defaults + mode-derived + settings.permissions.rules, in the same
|
|
2674
|
+
* order the classifier evaluates them. Exposed read-only so `/permissions`
|
|
2675
|
+
* (and any UI) can list what's actually in force. Pure read; builds the same
|
|
2676
|
+
* rule set buildPermissionConfig does, without constructing a backend.
|
|
2677
|
+
*/
|
|
2678
|
+
getPermissionRules() {
|
|
2679
|
+
return this.buildPermissionConfig(this.getPermissionMode(), this.config.cwd ?? process.cwd())
|
|
2680
|
+
.rules;
|
|
2681
|
+
}
|
|
1933
2682
|
/**
|
|
1934
2683
|
* Toggle plan mode directly. Called by the Plan tool (Task 7) via ToolContext.engine.
|
|
1935
2684
|
* Also syncs permissionMode to keep both fields consistent.
|
|
@@ -1983,6 +2732,30 @@ export class Engine {
|
|
|
1983
2732
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1984
2733
|
});
|
|
1985
2734
|
}
|
|
2735
|
+
/**
|
|
2736
|
+
* Like waitForBackgroundAgentChange but with no abort signal and a hard
|
|
2737
|
+
* timeout. Resolves `true` on a registry/queue change, `false` if `timeoutMs`
|
|
2738
|
+
* elapses first. Used only by the headless abort-drain cleanup, where we want
|
|
2739
|
+
* to catch a completing agent's just-about-to-enqueue notification without
|
|
2740
|
+
* risking a permanent hang on an agent that never completes.
|
|
2741
|
+
*/
|
|
2742
|
+
waitForBackgroundAgentChangeOrTimeout(_sessionId, timeoutMs) {
|
|
2743
|
+
return new Promise((resolve) => {
|
|
2744
|
+
let settled = false;
|
|
2745
|
+
const finish = (changed) => {
|
|
2746
|
+
if (settled)
|
|
2747
|
+
return;
|
|
2748
|
+
settled = true;
|
|
2749
|
+
clearTimeout(timer);
|
|
2750
|
+
unsubRegistry();
|
|
2751
|
+
unsubQueue();
|
|
2752
|
+
resolve(changed);
|
|
2753
|
+
};
|
|
2754
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
2755
|
+
const unsubRegistry = asyncAgentRegistry.subscribe(() => finish(true));
|
|
2756
|
+
const unsubQueue = notificationQueue.subscribe(() => finish(true));
|
|
2757
|
+
});
|
|
2758
|
+
}
|
|
1986
2759
|
/**
|
|
1987
2760
|
* Sub-agent role registry for the given cwd, memoized per-cwd so the
|
|
1988
2761
|
* directory is read once rather than every turn. A new cwd (e.g. via
|
|
@@ -1991,12 +2764,8 @@ export class Engine {
|
|
|
1991
2764
|
getAgentDefinitions(cwd) {
|
|
1992
2765
|
const disabledAgents = this.readDisabledAgents(cwd);
|
|
1993
2766
|
const disabledPlugins = this.readDisabledLists().disabledPlugins;
|
|
1994
|
-
const disabledKey = [...disabledAgents, "::", ...disabledPlugins]
|
|
1995
|
-
|
|
1996
|
-
.sort()
|
|
1997
|
-
.join(" ");
|
|
1998
|
-
if (this.agentDefsCache?.cwd !== cwd ||
|
|
1999
|
-
this.agentDefsCache.disabledKey !== disabledKey) {
|
|
2767
|
+
const disabledKey = [...disabledAgents, "::", ...disabledPlugins].slice().sort().join(" ");
|
|
2768
|
+
if (this.agentDefsCache?.cwd !== cwd || this.agentDefsCache.disabledKey !== disabledKey) {
|
|
2000
2769
|
this.agentDefsCache = {
|
|
2001
2770
|
cwd,
|
|
2002
2771
|
disabledKey,
|
|
@@ -2053,7 +2822,7 @@ export class Engine {
|
|
|
2053
2822
|
* by tests that want a ToolContext without a full run() cycle.
|
|
2054
2823
|
*/
|
|
2055
2824
|
resolveSandboxWithoutRuntime(config, cwd) {
|
|
2056
|
-
const key =
|
|
2825
|
+
const key = sandboxCacheKey(config, cwd);
|
|
2057
2826
|
let cached = this.sandboxCache.get(key);
|
|
2058
2827
|
if (!cached) {
|
|
2059
2828
|
cached = resolveSandboxBackend(config, cwd);
|
|
@@ -2068,20 +2837,129 @@ export class Engine {
|
|
|
2068
2837
|
}
|
|
2069
2838
|
return cached;
|
|
2070
2839
|
}
|
|
2840
|
+
/**
|
|
2841
|
+
* Build the shell env layered onto the Bash tool / background shells (see
|
|
2842
|
+
* mergeShellEnv). Three user-configured sources, merged lowest → highest:
|
|
2843
|
+
*
|
|
2844
|
+
* 1. project `localEnvironment.env` — the per-project "local environment"
|
|
2845
|
+
* panel (DATABASE_URL etc.); the floor, so a project's own panel values
|
|
2846
|
+
* can be overridden by an explicit top-level `env`.
|
|
2847
|
+
* 2. global top-level `env` — ~/.code-shell/settings.json; the
|
|
2848
|
+
* canonical home for API keys (OPENAI_API_KEY) a skill script reads —
|
|
2849
|
+
* configure once, every project's skills get it.
|
|
2850
|
+
* 3. project top-level `env` — .code-shell/settings.json; a project
|
|
2851
|
+
* that wants to override a global key wins.
|
|
2852
|
+
*
|
|
2853
|
+
* Each scope is read UNMERGED so the layering here is the single source of
|
|
2854
|
+
* precedence (getForScope merges nothing). Returns undefined when no layer
|
|
2855
|
+
* contributes a key, so the caller passes it through unchanged for projects
|
|
2856
|
+
* that configure none.
|
|
2857
|
+
*
|
|
2858
|
+
* Sub-agents: a sub-agent is the user's OWN agent doing the user's work
|
|
2859
|
+
* (mirrors Claude Code, where sub-agents inherit the parent environment), so
|
|
2860
|
+
* it now reads the SAME env as the parent. The sub-agent branch is kept as an
|
|
2861
|
+
* explicit seam (`filterSubagentEnv`) rather than removed — a future policy
|
|
2862
|
+
* could narrow what a sub-agent sees (e.g. drop credential secrets) by
|
|
2863
|
+
* changing that one hook; today it passes everything through unchanged.
|
|
2864
|
+
* A no-cwd context still gets nothing (there is genuinely no project to read).
|
|
2865
|
+
*
|
|
2866
|
+
* None of these is filtered through the deny regex (mergeShellEnv): the user
|
|
2867
|
+
* put them there deliberately. The allowlist/deny machinery only guards the
|
|
2868
|
+
* host's process.env from a tainted model exfiltrating it via `env | curl`.
|
|
2869
|
+
*/
|
|
2870
|
+
readShellEnv(cwd) {
|
|
2871
|
+
if (!cwd)
|
|
2872
|
+
return undefined;
|
|
2873
|
+
const merged = {};
|
|
2874
|
+
const layer = (env) => {
|
|
2875
|
+
if (!env)
|
|
2876
|
+
return;
|
|
2877
|
+
for (const [k, v] of Object.entries(env)) {
|
|
2878
|
+
if (typeof v === "string")
|
|
2879
|
+
merged[k] = v;
|
|
2880
|
+
}
|
|
2881
|
+
};
|
|
2882
|
+
try {
|
|
2883
|
+
// The fully-merged settings already apply the scope guard (a 'project'
|
|
2884
|
+
// scope never reads the host ~/.code-shell) and the user < project <
|
|
2885
|
+
// local precedence — so the top-level `env` map read from here is global
|
|
2886
|
+
// values overridden by project values, exactly as specified. We layer
|
|
2887
|
+
// localEnvironment.env *under* it as the floor.
|
|
2888
|
+
const settings = this.getSettingsManager().get();
|
|
2889
|
+
layer(settings.localEnvironment?.env); // floor
|
|
2890
|
+
// Credentials flagged "expose as env var" (Credential.exposeAsEnv). This
|
|
2891
|
+
// is the wiring that was missing — the UI/store recorded the flag but no
|
|
2892
|
+
// code ever injected the secret, so `$FIGMA_TOKEN` was always empty.
|
|
2893
|
+
// Scope mirrors settingsScope so a project-scoped engine never surfaces
|
|
2894
|
+
// the host user's credentials (same isolation contract as top-level env).
|
|
2895
|
+
// Placed below settings.env so an explicit `env` entry can still override.
|
|
2896
|
+
const credScope = (this.config.settingsScope ?? "project") === "full" ? "full" : "project";
|
|
2897
|
+
layer(new CredentialStore(cwd).envExposures(credScope));
|
|
2898
|
+
layer(settings.env); // top-level env (global ⊕ project) wins
|
|
2899
|
+
}
|
|
2900
|
+
catch {
|
|
2901
|
+
return undefined;
|
|
2902
|
+
}
|
|
2903
|
+
const result = this.config.isSubAgent === true ? this.filterSubagentEnv(merged) : merged;
|
|
2904
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
2905
|
+
}
|
|
2906
|
+
/**
|
|
2907
|
+
* Policy seam for what a sub-agent's shell sees. A sub-agent inherits the
|
|
2908
|
+
* parent environment by default (mirrors Claude Code), so this is the
|
|
2909
|
+
* identity function today. It exists so a future policy can narrow the set
|
|
2910
|
+
* (e.g. strip credential `exposeAsEnv` secrets, or allowlist by name) in ONE
|
|
2911
|
+
* place instead of scattering `isSubAgent` checks through readShellEnv.
|
|
2912
|
+
*/
|
|
2913
|
+
filterSubagentEnv(env) {
|
|
2914
|
+
return env;
|
|
2915
|
+
}
|
|
2916
|
+
/**
|
|
2917
|
+
* Read the project's `localEnvironment.setupScripts` for this cwd (the raw
|
|
2918
|
+
* per-platform map). Used by EnterWorktree to run setup once in a freshly
|
|
2919
|
+
* created worktree. Returns undefined for sub-agents / no cwd (same minimal
|
|
2920
|
+
* surface as readShellEnv). The platform selection + run live in
|
|
2921
|
+
* git/worktree.ts; this only fetches the configured scripts.
|
|
2922
|
+
*/
|
|
2923
|
+
readWorktreeSetupScripts(cwd) {
|
|
2924
|
+
if (this.config.isSubAgent === true || !cwd)
|
|
2925
|
+
return undefined;
|
|
2926
|
+
try {
|
|
2927
|
+
const scoped = this.getSettingsManager().getForScope("project", cwd);
|
|
2928
|
+
return scoped.localEnvironment?.setupScripts;
|
|
2929
|
+
}
|
|
2930
|
+
catch {
|
|
2931
|
+
return undefined;
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2071
2934
|
buildToolContext() {
|
|
2072
2935
|
const { disabledSkills, disabledPlugins } = this.readDisabledLists();
|
|
2073
2936
|
return {
|
|
2937
|
+
shellEnv: this.readShellEnv(this.config.cwd),
|
|
2074
2938
|
cwd: this.config.cwd ?? process.cwd(),
|
|
2075
2939
|
llmConfig: this.config.llm,
|
|
2076
2940
|
modelPool: this.modelPool,
|
|
2077
2941
|
toolRegistry: this.toolRegistry,
|
|
2078
2942
|
askUser: this.config.askUser,
|
|
2943
|
+
browser: this.config.browserBridge,
|
|
2944
|
+
injectCredentialToBrowser: this.config.injectCredentialToBrowser,
|
|
2079
2945
|
isSubAgent: this.config.isSubAgent === true,
|
|
2946
|
+
// Credential tools narrow their disk reads to this scope: a project/
|
|
2947
|
+
// isolated engine (SDK-embedded) must not surface the host user's
|
|
2948
|
+
// ~/.code-shell credentials or credentialUse.autoApprove. "full" (the
|
|
2949
|
+
// host-application default) merges user + project as before.
|
|
2950
|
+
settingsScope: this.config.settingsScope ?? "project",
|
|
2080
2951
|
hooks: this.hooks,
|
|
2081
2952
|
planMode: this.planMode,
|
|
2953
|
+
permissionMode: this.permissionMode,
|
|
2082
2954
|
engine: this,
|
|
2083
2955
|
disabledSkills,
|
|
2084
2956
|
disabledPlugins,
|
|
2957
|
+
skillAllowlist: this.config.skillAllowlist,
|
|
2958
|
+
backgroundShells: backgroundShellManager,
|
|
2959
|
+
// Sub-agents never start background shells (they're short-lived and
|
|
2960
|
+
// their lifecycle ends with the parent turn); unattended automation
|
|
2961
|
+
// opts out via config. Otherwise allowed.
|
|
2962
|
+
allowBackgroundShells: this.config.isSubAgent === true ? false : this.config.allowBackgroundShells !== false,
|
|
2085
2963
|
};
|
|
2086
2964
|
}
|
|
2087
2965
|
/**
|
|
@@ -2097,27 +2975,97 @@ export class Engine {
|
|
|
2097
2975
|
*/
|
|
2098
2976
|
readDisabledLists() {
|
|
2099
2977
|
if (this.config.isSubAgent === true) {
|
|
2100
|
-
return { disabledSkills: [], disabledPlugins: [] };
|
|
2978
|
+
return { disabledSkills: [], disabledPlugins: [], disabledPluginHooks: [] };
|
|
2979
|
+
}
|
|
2980
|
+
// Shared folding (capability-control/disabled-lists.ts): project
|
|
2981
|
+
// capabilityOverrides over the global baseline + the no-repo whitelist
|
|
2982
|
+
// inversion. Extracted so the MCP merge consumers (engineFactory /
|
|
2983
|
+
// diskDefaultsFrom) fold identically — see that module's doc.
|
|
2984
|
+
return computeEffectiveDisabledLists(this.getSettingsManager(), this.config.cwd);
|
|
2985
|
+
}
|
|
2986
|
+
/**
|
|
2987
|
+
* Public view of the folded disabled lists, for hosts that need the
|
|
2988
|
+
* EFFECTIVE state (e.g. the protocol server's settings hot-reload rebuilds
|
|
2989
|
+
* the plugin-MCP merge per session — a project-level "on" must override the
|
|
2990
|
+
* global disabledPlugins there too).
|
|
2991
|
+
*/
|
|
2992
|
+
getEffectiveDisabledLists() {
|
|
2993
|
+
return this.readDisabledLists();
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* Public: resolve every known feature flag to its effective boolean (the
|
|
2997
|
+
* settings overlay merged over the compiled-in defaults). Used by the
|
|
2998
|
+
* `config` protocol query so the `/features` command can list flag state.
|
|
2999
|
+
*/
|
|
3000
|
+
getFeatureFlags() {
|
|
3001
|
+
return resolveFeatureFlags(this.readFeatureFlags());
|
|
3002
|
+
}
|
|
3003
|
+
/**
|
|
3004
|
+
* Read the merged `settings.featureFlags` overlay for this cwd. Project
|
|
3005
|
+
* settings override user settings via the normal SettingsManager merge.
|
|
3006
|
+
* Returns undefined (→ all defaults) on any read error or for sub-agents,
|
|
3007
|
+
* so a flag check never throws and a child runs with default behavior.
|
|
3008
|
+
*/
|
|
3009
|
+
readFeatureFlags() {
|
|
3010
|
+
if (this.config.isSubAgent === true)
|
|
3011
|
+
return undefined;
|
|
3012
|
+
try {
|
|
3013
|
+
const settings = this.getSettingsManager().get();
|
|
3014
|
+
return settings.featureFlags;
|
|
3015
|
+
}
|
|
3016
|
+
catch {
|
|
3017
|
+
return undefined;
|
|
2101
3018
|
}
|
|
3019
|
+
}
|
|
3020
|
+
/**
|
|
3021
|
+
* Read settings.memories ({ maxCount, maxAge, extractionModel, autoExtract }).
|
|
3022
|
+
* Returns undefined on any error or when absent, so the memory pipeline
|
|
3023
|
+
* falls back to its built-in defaults.
|
|
3024
|
+
*/
|
|
3025
|
+
readMemoriesConfig() {
|
|
2102
3026
|
try {
|
|
2103
|
-
const
|
|
2104
|
-
|
|
2105
|
-
// Fold the project capabilityOverrides over the global baseline so a
|
|
2106
|
-
// project can force-enable a globally-disabled skill/plugin or vice
|
|
2107
|
-
// versa. Read the project overlay UNMERGED (getForScope), not the merged
|
|
2108
|
-
// get(), so tri-state inheritance survives. No cwd / no overlay → the
|
|
2109
|
-
// baseline is returned unchanged (zero regression).
|
|
2110
|
-
const cwd = this.config.cwd;
|
|
2111
|
-
const overrides = cwd
|
|
2112
|
-
? sm.getForScope("project", cwd).capabilityOverrides
|
|
2113
|
-
: undefined;
|
|
2114
|
-
return {
|
|
2115
|
-
disabledSkills: effectiveDisabledList(settings.disabledSkills ?? [], overrides?.skills),
|
|
2116
|
-
disabledPlugins: effectiveDisabledList(settings.disabledPlugins ?? [], overrides?.plugins),
|
|
2117
|
-
};
|
|
3027
|
+
const settings = this.getSettingsManager().get();
|
|
3028
|
+
return settings.memories;
|
|
2118
3029
|
}
|
|
2119
3030
|
catch {
|
|
2120
|
-
return
|
|
3031
|
+
return undefined;
|
|
2121
3032
|
}
|
|
2122
3033
|
}
|
|
3034
|
+
/**
|
|
3035
|
+
* LLM client for memory extraction (TODO 8.1). Prefers
|
|
3036
|
+
* settings.memories.extractionModel when it names a valid pool model;
|
|
3037
|
+
* otherwise falls back to the aux client (which itself falls back to the
|
|
3038
|
+
* passed primary). Build failures fall back too — extraction is best-effort.
|
|
3039
|
+
*/
|
|
3040
|
+
async resolveExtractionClient(primaryClient) {
|
|
3041
|
+
const key = this.readMemoriesConfig()?.extractionModel;
|
|
3042
|
+
if (key) {
|
|
3043
|
+
const entry = this.modelPool.get(key);
|
|
3044
|
+
if (entry) {
|
|
3045
|
+
try {
|
|
3046
|
+
return await createLLMClient(this.modelPool.toLLMConfig(entry), this.config.clientDefaults);
|
|
3047
|
+
}
|
|
3048
|
+
catch (err) {
|
|
3049
|
+
logger.warn("engine.extraction_model_build_failed", {
|
|
3050
|
+
extractionModel: key,
|
|
3051
|
+
error: err.message,
|
|
3052
|
+
});
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
else {
|
|
3056
|
+
logger.warn("engine.extraction_model_missing", { extractionModel: key });
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
return this.resolveAuxClient(primaryClient);
|
|
3060
|
+
}
|
|
2123
3061
|
}
|
|
3062
|
+
/**
|
|
3063
|
+
* Builtin tool name → the feature flag that gates its visibility. A tool here
|
|
3064
|
+
* is hidden from the LLM when its flag resolves to false. Tools not listed are
|
|
3065
|
+
* unaffected. Kept beside the engine (not in builtin/index) because the flag
|
|
3066
|
+
* read needs the engine's scoped SettingsManager.
|
|
3067
|
+
*/
|
|
3068
|
+
const TOOL_FEATURE_FLAGS = new Map([
|
|
3069
|
+
["WebSearch", "web_search"],
|
|
3070
|
+
["Bash", "shell_tool"],
|
|
3071
|
+
]);
|