@cjhyy/code-shell-core 0.7.0 → 0.8.0
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 +36 -23
- package/dist/agent/agent-definition-registry.d.ts +2 -2
- package/dist/agent/agent-definition-registry.js +47 -10
- package/dist/agent/agent-definition.d.ts +5 -0
- package/dist/agent/agent-definition.js +14 -0
- package/dist/automation/index.d.ts +7 -4
- package/dist/automation/index.js +4 -2
- package/dist/automation/runner.js +3 -1
- package/dist/automation/scheduler.d.ts +57 -1
- package/dist/automation/scheduler.js +123 -23
- package/dist/automation/store.d.ts +6 -2
- package/dist/automation/store.js +7 -3
- package/dist/capabilities/index.d.ts +109 -0
- package/dist/capabilities/index.js +80 -0
- package/dist/capability-control/disabled-lists.d.ts +2 -1
- package/dist/capability-control/disabled-lists.js +3 -5
- package/dist/capability-control/overlay.d.ts +14 -1
- package/dist/capability-control/overlay.js +40 -0
- package/dist/capability-control/service.js +6 -4
- package/dist/cli/agent-server-stdio.d.ts +1 -1
- package/dist/cli/agent-server-stdio.js +66 -6
- package/dist/cli/agent-server-tcp.js +20 -5
- package/dist/context/compaction.d.ts +39 -3
- package/dist/context/compaction.js +151 -39
- package/dist/context/manager.d.ts +20 -4
- package/dist/context/manager.js +42 -7
- package/dist/context/token-counter.js +5 -3
- package/dist/context/tool-result-storage.d.ts +6 -0
- package/dist/context/tool-result-storage.js +25 -4
- package/dist/credentials/access.d.ts +13 -2
- package/dist/credentials/access.js +99 -4
- package/dist/credentials/index.d.ts +3 -2
- package/dist/credentials/index.js +2 -1
- package/dist/credentials/oauth.d.ts +9 -1
- package/dist/credentials/oauth.js +80 -1
- package/dist/credentials/store.d.ts +16 -2
- package/dist/credentials/store.js +33 -11
- package/dist/credentials/types.d.ts +62 -1
- package/dist/credentials/types.js +16 -1
- package/dist/credentials/use-credential-tool.js +15 -1
- package/dist/engine/auxiliary-pipeline.d.ts +40 -0
- package/dist/engine/auxiliary-pipeline.js +181 -0
- package/dist/{settings → engine}/disk-defaults.d.ts +2 -2
- package/dist/{settings → engine}/disk-defaults.js +5 -2
- package/dist/engine/dynamic-tool-defs.d.ts +4 -0
- package/dist/engine/dynamic-tool-defs.js +4 -0
- package/dist/engine/engine.d.ts +272 -184
- package/dist/engine/engine.js +1869 -1864
- package/dist/engine/file-history-hook.d.ts +11 -0
- package/dist/engine/file-history-hook.js +31 -0
- package/dist/engine/goal-judge-context.d.ts +9 -0
- package/dist/engine/goal-judge-context.js +234 -0
- package/dist/engine/injected-context-cache.d.ts +6 -0
- package/dist/engine/injected-context-cache.js +13 -0
- package/dist/engine/input-attachments.d.ts +1 -1
- package/dist/engine/model-facade.d.ts +3 -3
- package/dist/engine/model-facade.js +15 -14
- package/dist/engine/permission-controller.d.ts +55 -0
- package/dist/engine/permission-controller.js +148 -0
- package/dist/engine/prompt-cache-diagnostics.d.ts +58 -0
- package/dist/engine/prompt-cache-diagnostics.js +178 -0
- package/dist/engine/run-accounting.d.ts +41 -0
- package/dist/engine/run-accounting.js +126 -0
- package/dist/engine/run-context.d.ts +44 -0
- package/dist/engine/run-context.js +88 -0
- package/dist/engine/run-environment.d.ts +30 -0
- package/dist/engine/run-environment.js +77 -0
- package/dist/engine/run-finalize.d.ts +58 -0
- package/dist/engine/run-finalize.js +245 -0
- package/dist/engine/run-goal.d.ts +58 -0
- package/dist/engine/run-goal.js +188 -0
- package/dist/engine/run-image-input.d.ts +1 -1
- package/dist/engine/run-image-input.js +5 -3
- package/dist/engine/run-session-open.d.ts +41 -0
- package/dist/engine/run-session-open.js +186 -0
- package/dist/engine/run-setup.d.ts +42 -0
- package/dist/engine/run-setup.js +58 -0
- package/dist/engine/run-tooling.d.ts +125 -0
- package/dist/engine/run-tooling.js +283 -0
- package/dist/engine/run-types.d.ts +139 -0
- package/dist/engine/run-types.js +12 -0
- package/dist/engine/run-workspace.d.ts +50 -0
- package/dist/engine/run-workspace.js +114 -0
- package/dist/engine/session-title.d.ts +2 -1
- package/dist/engine/session-title.js +4 -1
- package/dist/engine/steer-queue.d.ts +1 -1
- package/dist/engine/subagent-spawner.d.ts +32 -0
- package/dist/engine/subagent-spawner.js +287 -0
- package/dist/engine/tool-summary.d.ts +2 -2
- package/dist/engine/tool-summary.js +2 -2
- package/dist/engine/turn-loop.d.ts +67 -10
- package/dist/engine/turn-loop.js +538 -97
- package/dist/engine/types.d.ts +35 -4
- package/dist/engine/types.js +1 -1
- package/dist/{engine/goal.d.ts → goal/lifecycle.d.ts} +80 -6
- package/dist/goal/lifecycle.js +482 -0
- package/dist/hooks/events.d.ts +26 -9
- package/dist/hooks/events.js +0 -3
- package/dist/hooks/goal-stop-hook.d.ts +28 -29
- package/dist/hooks/goal-stop-hook.js +344 -144
- package/dist/hooks/registry.js +4 -1
- package/dist/hooks/shell-runner.d.ts +12 -1
- package/dist/hooks/shell-runner.js +160 -9
- package/dist/index.d.ts +51 -110
- package/dist/index.extension.d.ts +51 -0
- package/dist/index.extension.js +39 -0
- package/dist/index.internal.d.ts +86 -0
- package/dist/index.internal.js +92 -0
- package/dist/index.js +37 -125
- package/dist/links/cli.d.ts +48 -0
- package/dist/links/cli.js +617 -0
- package/dist/links/http.d.ts +40 -0
- package/dist/links/http.js +166 -0
- package/dist/links/index.d.ts +4 -0
- package/dist/links/index.js +3 -0
- package/dist/links/link-action-tool.d.ts +5 -0
- package/dist/links/link-action-tool.js +241 -0
- package/dist/links/providers.d.ts +8 -0
- package/dist/links/providers.js +665 -0
- package/dist/links/types.d.ts +44 -0
- package/dist/llm/client-base.d.ts +7 -0
- package/dist/llm/client-base.js +72 -10
- package/dist/llm/model-pool.d.ts +1 -1
- package/dist/llm/model-pool.js +1 -1
- package/dist/llm/providers/anthropic.d.ts +1 -0
- package/dist/llm/providers/anthropic.js +9 -0
- package/dist/llm/providers/openai.d.ts +1 -0
- package/dist/llm/providers/openai.js +17 -0
- package/dist/llm/types.d.ts +12 -5
- package/dist/model-catalog/index.js +15 -2
- package/dist/model-catalog/resolve.d.ts +13 -1
- package/dist/model-catalog/resolve.js +37 -2
- package/dist/model-catalog/types.d.ts +74 -48
- package/dist/model-catalog/types.js +16 -5
- package/dist/model-catalog/upsert.d.ts +3 -1
- package/dist/model-catalog/upsert.js +9 -0
- package/dist/onboarding.d.ts +0 -1
- package/dist/onboarding.js +1 -22
- package/dist/panel-apps/bindings.d.ts +24 -0
- package/dist/panel-apps/bindings.js +69 -0
- package/dist/panel-apps/github-archive.d.ts +21 -0
- package/dist/panel-apps/github-archive.js +98 -0
- package/dist/panel-apps/index.d.ts +4 -0
- package/dist/panel-apps/index.js +4 -0
- package/dist/panel-apps/installer.d.ts +68 -0
- package/dist/panel-apps/installer.js +622 -0
- package/dist/panel-apps/manifest.d.ts +364 -0
- package/dist/panel-apps/manifest.js +189 -0
- package/dist/panel-apps/paths.d.ts +13 -0
- package/dist/panel-apps/paths.js +38 -0
- package/dist/panel-apps/registry.d.ts +51 -0
- package/dist/panel-apps/registry.js +99 -0
- package/dist/panel-apps/runtime.d.ts +80 -0
- package/dist/panel-apps/runtime.js +154 -0
- package/dist/plugins/gitOps.js +8 -1
- package/dist/plugins/installedPlugins.d.ts +2 -1
- package/dist/plugins/installedPlugins.js +146 -8
- package/dist/plugins/installer/codex/convertAgents.d.ts +4 -1
- package/dist/plugins/installer/codex/convertAgents.js +62 -8
- package/dist/plugins/installer/codex/convertCommands.d.ts +5 -5
- package/dist/plugins/installer/codex/convertCommands.js +35 -12
- package/dist/plugins/installer/codex/convertHooks.d.ts +10 -0
- package/dist/plugins/installer/codex/convertHooks.js +82 -0
- package/dist/plugins/installer/codex/convertSkills.js +41 -6
- package/dist/plugins/installer/install.d.ts +7 -1
- package/dist/plugins/installer/install.js +21 -87
- package/dist/plugins/installer/installFromArchive.d.ts +26 -4
- package/dist/plugins/installer/installFromArchive.js +44 -27
- package/dist/plugins/installer/installFromNpm.d.ts +31 -0
- package/dist/plugins/installer/installFromNpm.js +273 -0
- package/dist/plugins/installer/loadPluginAgents.js +17 -4
- package/dist/plugins/installer/loadPluginMcp.d.ts +14 -0
- package/dist/plugins/installer/loadPluginMcp.js +245 -35
- package/dist/plugins/installer/normalizeManifest.d.ts +13 -0
- package/dist/plugins/installer/normalizeManifest.js +306 -0
- package/dist/plugins/installer/npmTar.d.ts +13 -0
- package/dist/plugins/installer/npmTar.js +321 -0
- package/dist/plugins/installer/parseSource.d.ts +11 -0
- package/dist/plugins/installer/parseSource.js +48 -0
- package/dist/plugins/installer/preview.d.ts +101 -0
- package/dist/plugins/installer/preview.js +336 -0
- package/dist/plugins/installer/projectPluginSource.d.ts +14 -0
- package/dist/plugins/installer/projectPluginSource.js +140 -0
- package/dist/plugins/installer/types.d.ts +775 -0
- package/dist/plugins/installer/types.js +100 -0
- package/dist/plugins/installer/unzip.d.ts +11 -0
- package/dist/plugins/installer/unzip.js +123 -9
- package/dist/plugins/installer/update.d.ts +2 -2
- package/dist/plugins/installer/update.js +43 -4
- package/dist/plugins/loadPluginHooks.d.ts +19 -2
- package/dist/plugins/loadPluginHooks.js +97 -86
- package/dist/plugins/pluginAutomationTemplates.d.ts +18 -0
- package/dist/plugins/pluginAutomationTemplates.js +50 -0
- package/dist/plugins/pluginCatalog.d.ts +45 -0
- package/dist/plugins/pluginCatalog.js +87 -0
- package/dist/plugins/pluginCommandHook.d.ts +10 -4
- package/dist/plugins/pluginCommandHook.js +133 -18
- package/dist/plugins/pluginCommandsLoader.d.ts +19 -0
- package/dist/plugins/pluginCommandsLoader.js +181 -7
- package/dist/plugins/pluginContent.d.ts +10 -1
- package/dist/plugins/pluginContent.js +87 -30
- package/dist/plugins/pluginHookApproval.d.ts +30 -0
- package/dist/plugins/pluginHookApproval.js +160 -0
- package/dist/plugins/pluginHookIntegrity.d.ts +69 -0
- package/dist/plugins/pluginHookIntegrity.js +361 -0
- package/dist/plugins/pluginInstaller.js +71 -12
- package/dist/plugins/pluginMcpApproval.d.ts +19 -0
- package/dist/plugins/pluginMcpApproval.js +110 -0
- package/dist/plugins/pluginMcpIntegrity.d.ts +21 -0
- package/dist/plugins/pluginMcpIntegrity.js +74 -0
- package/dist/plugins/types.d.ts +30 -0
- package/dist/plugins/varRewrite.d.ts +9 -5
- package/dist/plugins/varRewrite.js +26 -17
- package/dist/preset/index.d.ts +11 -8
- package/dist/preset/index.js +62 -203
- package/dist/product/define.js +9 -3
- package/dist/product/types.d.ts +0 -2
- package/dist/profile/activation.d.ts +36 -0
- package/dist/profile/activation.js +57 -0
- package/dist/profile/catalog-store.d.ts +77 -0
- package/dist/profile/catalog-store.js +346 -0
- package/dist/profile/catalog.d.ts +54 -0
- package/dist/profile/catalog.js +152 -0
- package/dist/profile/index.d.ts +7 -0
- package/dist/profile/index.js +7 -0
- package/dist/profile/requirements.d.ts +76 -0
- package/dist/profile/requirements.js +117 -0
- package/dist/profile/resolve.d.ts +11 -0
- package/dist/profile/resolve.js +41 -0
- package/dist/profile/store.d.ts +17 -0
- package/dist/profile/store.js +167 -0
- package/dist/profile/types.d.ts +321 -0
- package/dist/profile/types.js +104 -0
- package/dist/prompt/composer.d.ts +46 -7
- package/dist/prompt/composer.js +78 -48
- package/dist/prompt/instruction-scanner.d.ts +6 -5
- package/dist/prompt/instruction-scanner.js +8 -23
- package/dist/prompt/section-loader.d.ts +2 -2
- package/dist/prompt/section-loader.js +7 -4
- package/dist/prompt/sections/browser.md +10 -9
- package/dist/prompt/sections/harness-base.md +9 -0
- package/dist/protocol/chat-session-manager.d.ts +77 -3
- package/dist/protocol/chat-session-manager.js +194 -18
- package/dist/protocol/chat-session.d.ts +72 -9
- package/dist/protocol/chat-session.js +151 -16
- package/dist/protocol/client.d.ts +37 -4
- package/dist/protocol/client.js +118 -6
- package/dist/protocol/index.d.ts +2 -2
- package/dist/protocol/index.js +2 -2
- package/dist/protocol/mobile-remote-types.d.ts +406 -0
- package/dist/protocol/mobile-remote-types.js +1 -0
- package/dist/protocol/server.d.ts +152 -14
- package/dist/protocol/server.js +1574 -229
- package/dist/protocol/types.d.ts +182 -32
- package/dist/protocol/types.js +18 -0
- package/dist/run/ArtifactTracker.d.ts +6 -1
- package/dist/run/ArtifactTracker.js +29 -21
- package/dist/run/EngineRunner.d.ts +2 -0
- package/dist/run/EngineRunner.js +1 -0
- package/dist/run/RunManager.d.ts +26 -0
- package/dist/run/RunManager.js +82 -11
- package/dist/run/factory.d.ts +3 -0
- package/dist/run/factory.js +2 -0
- package/dist/runtime/safe-spawn.d.ts +6 -0
- package/dist/runtime/safe-spawn.js +8 -13
- package/dist/services/auto-dream.d.ts +13 -1
- package/dist/services/auto-dream.js +56 -8
- package/dist/services/dream-consolidation.d.ts +3 -0
- package/dist/services/dream-consolidation.js +14 -2
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/memory-orchestrator.js +6 -2
- package/dist/services/oauth.d.ts +34 -10
- package/dist/services/oauth.js +236 -98
- package/dist/services/session-memory.d.ts +4 -4
- package/dist/services/session-memory.js +13 -10
- package/dist/session/memory.d.ts +26 -7
- package/dist/session/memory.js +79 -21
- package/dist/session/session-manager.d.ts +208 -25
- package/dist/session/session-manager.js +1232 -116
- package/dist/session/session-message.d.ts +22 -0
- package/dist/session/session-message.js +1 -0
- package/dist/session/transcript.d.ts +76 -6
- package/dist/session/transcript.js +278 -7
- package/dist/settings/feature-flags.d.ts +40 -0
- package/dist/settings/feature-flags.js +40 -0
- package/dist/settings/manager.d.ts +39 -1
- package/dist/settings/manager.js +102 -41
- package/dist/settings/migrate-config.js +4 -2
- package/dist/settings/schema.d.ts +511 -177
- package/dist/settings/schema.js +98 -25
- package/dist/skills/scanner.d.ts +6 -2
- package/dist/skills/scanner.js +160 -9
- package/dist/sources/adapter.d.ts +14 -0
- package/dist/sources/adapter.js +7 -0
- package/dist/sources/adapters/local-files.d.ts +10 -0
- package/dist/sources/adapters/local-files.js +127 -0
- package/dist/sources/adapters/mcp-resource.d.ts +17 -0
- package/dist/sources/adapters/mcp-resource.js +41 -0
- package/dist/sources/adapters/mock.d.ts +3 -0
- package/dist/sources/adapters/mock.js +29 -0
- package/dist/sources/binding.d.ts +6 -0
- package/dist/sources/binding.js +24 -0
- package/dist/sources/catalog.d.ts +6 -0
- package/dist/sources/catalog.js +64 -0
- package/dist/sources/context-summary.d.ts +8 -0
- package/dist/sources/context-summary.js +13 -0
- package/dist/sources/credential-status.d.ts +2 -0
- package/dist/sources/credential-status.js +13 -0
- package/dist/sources/index.d.ts +10 -0
- package/dist/sources/index.js +10 -0
- package/dist/sources/resolve.d.ts +28 -0
- package/dist/sources/resolve.js +47 -0
- package/dist/sources/truncate-utf8.d.ts +7 -0
- package/dist/sources/truncate-utf8.js +18 -0
- package/dist/sources/types.d.ts +71 -0
- package/dist/sources/types.js +26 -0
- package/dist/testing/fetch-stub.d.ts +32 -0
- package/dist/testing/fetch-stub.js +24 -0
- package/dist/themes/image.d.ts +18 -0
- package/dist/themes/image.js +117 -0
- package/dist/themes/index.d.ts +4 -0
- package/dist/themes/index.js +4 -0
- package/dist/themes/installer.d.ts +50 -0
- package/dist/themes/installer.js +290 -0
- package/dist/themes/manifest.d.ts +102 -0
- package/dist/themes/manifest.js +86 -0
- package/dist/themes/paths.d.ts +14 -0
- package/dist/themes/paths.js +33 -0
- package/dist/tool-system/browser-bridge.d.ts +53 -11
- package/dist/tool-system/browser-bridge.js +8 -9
- package/dist/tool-system/builtin/agent-heartbeat.d.ts +2 -2
- package/dist/tool-system/builtin/agent-heartbeat.js +20 -6
- package/dist/tool-system/builtin/agent-notifications.d.ts +127 -72
- package/dist/tool-system/builtin/agent-notifications.js +291 -101
- package/dist/tool-system/builtin/agent-output-file.js +1 -3
- package/dist/tool-system/builtin/agent-progress.d.ts +6 -0
- package/dist/tool-system/builtin/agent-progress.js +83 -0
- package/dist/tool-system/builtin/agent-registry.d.ts +38 -2
- package/dist/tool-system/builtin/agent-registry.js +178 -9
- package/dist/tool-system/builtin/agent-transcript-translator.js +1 -1
- package/dist/tool-system/builtin/agent.d.ts +2 -0
- package/dist/tool-system/builtin/agent.js +279 -34
- package/dist/tool-system/builtin/background-jobs.d.ts +63 -7
- package/dist/tool-system/builtin/background-jobs.js +111 -15
- package/dist/tool-system/builtin/background-work.d.ts +12 -1
- package/dist/tool-system/builtin/background-work.js +12 -1
- package/dist/tool-system/builtin/bash.d.ts +3 -5
- package/dist/tool-system/builtin/bash.js +14 -6
- package/dist/tool-system/builtin/browser-tools.d.ts +8 -5
- package/dist/tool-system/builtin/browser-tools.js +89 -23
- package/dist/tool-system/builtin/edit-model-catalog.d.ts +6 -0
- package/dist/tool-system/builtin/edit-model-catalog.js +172 -12
- package/dist/tool-system/builtin/edit.d.ts +2 -1
- package/dist/tool-system/builtin/edit.js +14 -10
- package/dist/tool-system/builtin/file-cache.d.ts +2 -0
- package/dist/tool-system/builtin/file-cache.js +4 -0
- package/dist/tool-system/builtin/generate-image.js +17 -10
- package/dist/tool-system/builtin/generate-video.d.ts +4 -0
- package/dist/tool-system/builtin/generate-video.js +164 -28
- package/dist/tool-system/builtin/glob.d.ts +2 -1
- package/dist/tool-system/builtin/glob.js +28 -3
- package/dist/tool-system/builtin/grep.d.ts +1 -0
- package/dist/tool-system/builtin/grep.js +82 -17
- package/dist/tool-system/builtin/image-uploader.js +7 -1
- package/dist/tool-system/builtin/index.d.ts +57 -18
- package/dist/tool-system/builtin/index.js +280 -167
- package/dist/tool-system/builtin/memory.d.ts +6 -0
- package/dist/tool-system/builtin/memory.js +36 -15
- package/dist/tool-system/builtin/panel.d.ts +9 -0
- package/dist/tool-system/builtin/panel.js +314 -0
- package/dist/tool-system/builtin/read.js +5 -1
- package/dist/tool-system/builtin/send-message-to-session.d.ts +6 -0
- package/dist/tool-system/builtin/send-message-to-session.js +67 -0
- package/dist/tool-system/builtin/sources.d.ts +8 -0
- package/dist/tool-system/builtin/sources.js +137 -0
- package/dist/tool-system/builtin/tool-search.js +7 -2
- package/dist/tool-system/builtin/update-automation-memory.js +2 -0
- package/dist/tool-system/builtin/video-providers.d.ts +12 -15
- package/dist/tool-system/builtin/video-providers.js +1 -0
- package/dist/tool-system/builtin/view-image.d.ts +2 -2
- package/dist/tool-system/builtin/web-fetch.js +60 -15
- package/dist/tool-system/builtin/web-search.js +1 -3
- package/dist/tool-system/builtin/write.d.ts +2 -1
- package/dist/tool-system/builtin/write.js +14 -4
- package/dist/tool-system/capability-module.d.ts +93 -0
- package/dist/tool-system/capability-module.js +53 -0
- package/dist/tool-system/context.d.ts +94 -24
- package/dist/tool-system/executor.js +99 -58
- package/dist/tool-system/external-tool-exposure.d.ts +60 -0
- package/dist/tool-system/external-tool-exposure.js +304 -0
- package/dist/tool-system/mcp-manager.d.ts +17 -1
- package/dist/tool-system/mcp-manager.js +72 -7
- package/dist/tool-system/mcp-tool-policy.d.ts +9 -0
- package/dist/tool-system/mcp-tool-policy.js +34 -0
- package/dist/tool-system/panel-bridge.d.ts +53 -0
- package/dist/tool-system/panel-bridge.js +1 -0
- package/dist/tool-system/path-policy.d.ts +19 -0
- package/dist/tool-system/path-policy.js +62 -1
- package/dist/tool-system/permission.d.ts +69 -3
- package/dist/tool-system/permission.js +429 -30
- package/dist/tool-system/registry.d.ts +15 -3
- package/dist/tool-system/registry.js +82 -40
- package/dist/tool-system/sandbox/index.js +3 -8
- package/dist/tool-system/sandbox/seatbelt.js +2 -6
- package/dist/tool-system/session-tool-host.d.ts +139 -0
- package/dist/tool-system/session-tool-host.js +167 -0
- package/dist/tool-system/testing/tool-registry-harness.d.ts +31 -0
- package/dist/tool-system/testing/tool-registry-harness.js +51 -0
- package/dist/tool-system/validation.d.ts +14 -7
- package/dist/tool-system/validation.js +701 -7
- package/dist/types.d.ts +201 -20
- package/dist/{cc-orchestrator → utils}/cwd-normalize.d.ts +1 -0
- package/dist/{cc-orchestrator → utils}/cwd-normalize.js +1 -0
- package/dist/utils/file-mutex.d.ts +65 -0
- package/dist/utils/file-mutex.js +145 -0
- package/dist/utils/json.d.ts +2 -4
- package/dist/utils/json.js +2 -4
- package/dist/utils/lockfile.d.ts +30 -1
- package/dist/utils/lockfile.js +2 -2
- package/dist/utils/secret-scrubber.d.ts +8 -0
- package/dist/utils/secret-scrubber.js +227 -0
- package/dist/utils/toolDisplay.js +1 -1
- package/package.json +20 -5
- package/THIRD_PARTY_NOTICES.md +0 -206
- package/dist/arena/arena.d.ts +0 -43
- package/dist/arena/arena.js +0 -333
- package/dist/arena/context/context-tools.d.ts +0 -16
- package/dist/arena/context/context-tools.js +0 -272
- package/dist/arena/context/within-root.d.ts +0 -7
- package/dist/arena/context/within-root.js +0 -15
- package/dist/arena/detect-mode.d.ts +0 -20
- package/dist/arena/detect-mode.js +0 -78
- package/dist/arena/digest-builder.d.ts +0 -25
- package/dist/arena/digest-builder.js +0 -120
- package/dist/arena/index.d.ts +0 -29
- package/dist/arena/index.js +0 -29
- package/dist/arena/iterate/convergence.d.ts +0 -25
- package/dist/arena/iterate/convergence.js +0 -103
- package/dist/arena/iterate/formats/index.d.ts +0 -22
- package/dist/arena/iterate/formats/index.js +0 -283
- package/dist/arena/iterate/index.d.ts +0 -11
- package/dist/arena/iterate/index.js +0 -9
- package/dist/arena/iterate/iterative-arena.d.ts +0 -23
- package/dist/arena/iterate/iterative-arena.js +0 -237
- package/dist/arena/iterate/parse.d.ts +0 -42
- package/dist/arena/iterate/parse.js +0 -123
- package/dist/arena/iterate/phases/argue.d.ts +0 -22
- package/dist/arena/iterate/phases/argue.js +0 -165
- package/dist/arena/iterate/phases/revise.d.ts +0 -16
- package/dist/arena/iterate/phases/revise.js +0 -62
- package/dist/arena/iterate/phases/tournament.d.ts +0 -34
- package/dist/arena/iterate/phases/tournament.js +0 -113
- package/dist/arena/iterate/tools/web-tools.d.ts +0 -13
- package/dist/arena/iterate/tools/web-tools.js +0 -54
- package/dist/arena/iterate/types.d.ts +0 -152
- package/dist/arena/iterate/types.js +0 -8
- package/dist/arena/ledger.d.ts +0 -47
- package/dist/arena/ledger.js +0 -159
- package/dist/arena/lenses/architecture.d.ts +0 -5
- package/dist/arena/lenses/architecture.js +0 -22
- package/dist/arena/lenses/engineering.d.ts +0 -5
- package/dist/arena/lenses/engineering.js +0 -22
- package/dist/arena/lenses/general.d.ts +0 -5
- package/dist/arena/lenses/general.js +0 -20
- package/dist/arena/lenses/index.d.ts +0 -16
- package/dist/arena/lenses/index.js +0 -47
- package/dist/arena/lenses/product.d.ts +0 -5
- package/dist/arena/lenses/product.js +0 -22
- package/dist/arena/model-presets.d.ts +0 -23
- package/dist/arena/model-presets.js +0 -44
- package/dist/arena/phases/adjudication.d.ts +0 -24
- package/dist/arena/phases/adjudication.js +0 -141
- package/dist/arena/phases/build-consensus.d.ts +0 -29
- package/dist/arena/phases/build-consensus.js +0 -83
- package/dist/arena/phases/claim-registry.d.ts +0 -26
- package/dist/arena/phases/claim-registry.js +0 -60
- package/dist/arena/phases/cross-review.d.ts +0 -45
- package/dist/arena/phases/cross-review.js +0 -218
- package/dist/arena/phases/debate-rounds.d.ts +0 -27
- package/dist/arena/phases/debate-rounds.js +0 -159
- package/dist/arena/phases/participant-research.d.ts +0 -38
- package/dist/arena/phases/participant-research.js +0 -314
- package/dist/arena/phases/planning-detail-expansion.d.ts +0 -38
- package/dist/arena/phases/planning-detail-expansion.js +0 -119
- package/dist/arena/planner.d.ts +0 -27
- package/dist/arena/planner.js +0 -311
- package/dist/arena/providers/docs.d.ts +0 -7
- package/dist/arena/providers/docs.js +0 -111
- package/dist/arena/providers/git.d.ts +0 -8
- package/dist/arena/providers/git.js +0 -174
- package/dist/arena/providers/index.d.ts +0 -32
- package/dist/arena/providers/index.js +0 -132
- package/dist/arena/providers/none.d.ts +0 -7
- package/dist/arena/providers/none.js +0 -11
- package/dist/arena/providers/repo.d.ts +0 -7
- package/dist/arena/providers/repo.js +0 -258
- package/dist/arena/render/session.d.ts +0 -17
- package/dist/arena/render/session.js +0 -190
- package/dist/arena/render/terminal.d.ts +0 -34
- package/dist/arena/render/terminal.js +0 -286
- package/dist/arena/strategies/discussion.d.ts +0 -25
- package/dist/arena/strategies/discussion.js +0 -143
- package/dist/arena/strategies/index.d.ts +0 -15
- package/dist/arena/strategies/index.js +0 -28
- package/dist/arena/strategies/language-wrapper.d.ts +0 -17
- package/dist/arena/strategies/language-wrapper.js +0 -102
- package/dist/arena/strategies/lens-wrapper.d.ts +0 -16
- package/dist/arena/strategies/lens-wrapper.js +0 -236
- package/dist/arena/strategies/planning.d.ts +0 -30
- package/dist/arena/strategies/planning.js +0 -225
- package/dist/arena/strategies/review.d.ts +0 -26
- package/dist/arena/strategies/review.js +0 -168
- package/dist/arena/strategies/utils.d.ts +0 -36
- package/dist/arena/strategies/utils.js +0 -603
- package/dist/arena/tools/selector.d.ts +0 -17
- package/dist/arena/tools/selector.js +0 -61
- package/dist/arena/transitions.d.ts +0 -53
- package/dist/arena/transitions.js +0 -97
- package/dist/arena/types.d.ts +0 -514
- package/dist/arena/types.js +0 -27
- package/dist/cc-orchestrator/agent-adapter.d.ts +0 -54
- package/dist/cc-orchestrator/agent-adapter.js +0 -143
- package/dist/cc-orchestrator/cc-capability.d.ts +0 -19
- package/dist/cc-orchestrator/cc-capability.js +0 -53
- package/dist/cc-orchestrator/codex-session-discovery.d.ts +0 -24
- package/dist/cc-orchestrator/codex-session-discovery.js +0 -191
- package/dist/cc-orchestrator/codex-session-history.d.ts +0 -38
- package/dist/cc-orchestrator/codex-session-history.js +0 -247
- package/dist/cc-orchestrator/external-agent-bindings.d.ts +0 -27
- package/dist/cc-orchestrator/external-agent-bindings.js +0 -150
- package/dist/cc-orchestrator/external-agent-changes.d.ts +0 -19
- package/dist/cc-orchestrator/external-agent-changes.js +0 -231
- package/dist/cc-orchestrator/external-agent-driver.d.ts +0 -19
- package/dist/cc-orchestrator/external-agent-driver.js +0 -284
- package/dist/cc-orchestrator/external-agent-session-store.d.ts +0 -23
- package/dist/cc-orchestrator/external-agent-session-store.js +0 -146
- package/dist/cc-orchestrator/index.d.ts +0 -8
- package/dist/cc-orchestrator/index.js +0 -8
- package/dist/cc-orchestrator/relevance-judge.d.ts +0 -15
- package/dist/cc-orchestrator/relevance-judge.js +0 -29
- package/dist/cc-orchestrator/session-discovery.d.ts +0 -46
- package/dist/cc-orchestrator/session-discovery.js +0 -125
- package/dist/cc-orchestrator/session-history.d.ts +0 -54
- package/dist/cc-orchestrator/session-history.js +0 -150
- package/dist/cron/cron-runtime.d.ts +0 -2
- package/dist/cron/cron-runtime.js +0 -2
- package/dist/cron/cron-store.d.ts +0 -2
- package/dist/cron/cron-store.js +0 -2
- package/dist/cron/scheduler.d.ts +0 -7
- package/dist/cron/scheduler.js +0 -7
- package/dist/engine/goal.js +0 -206
- package/dist/external-agents/config.d.ts +0 -2
- package/dist/external-agents/config.js +0 -15
- package/dist/external-agents/types.d.ts +0 -31
- package/dist/git/parse-log.d.ts +0 -13
- package/dist/git/parse-log.js +0 -21
- package/dist/git/utils.d.ts +0 -49
- package/dist/git/utils.js +0 -161
- package/dist/git/worktree/crud.d.ts +0 -69
- package/dist/git/worktree/crud.js +0 -206
- package/dist/git/worktree/diff.d.ts +0 -14
- package/dist/git/worktree/diff.js +0 -82
- package/dist/git/worktree/git-exec.d.ts +0 -7
- package/dist/git/worktree/git-exec.js +0 -51
- package/dist/git/worktree/index.d.ts +0 -5
- package/dist/git/worktree/index.js +0 -5
- package/dist/git/worktree/query.d.ts +0 -42
- package/dist/git/worktree/query.js +0 -121
- package/dist/git/worktree/slug.d.ts +0 -11
- package/dist/git/worktree/slug.js +0 -58
- package/dist/git/worktree.d.ts +0 -1
- package/dist/git/worktree.js +0 -5
- package/dist/lsp/client.d.ts +0 -41
- package/dist/lsp/client.js +0 -186
- package/dist/lsp/manager.d.ts +0 -40
- package/dist/lsp/manager.js +0 -155
- package/dist/lsp/root-path.d.ts +0 -9
- package/dist/lsp/root-path.js +0 -12
- package/dist/lsp/servers.d.ts +0 -16
- package/dist/lsp/servers.js +0 -60
- package/dist/prompt/sections/coding.md +0 -35
- package/dist/quota/credentials.d.ts +0 -3
- package/dist/quota/credentials.js +0 -80
- package/dist/quota/index.d.ts +0 -36
- package/dist/quota/index.js +0 -155
- package/dist/quota/types.d.ts +0 -48
- package/dist/quota/types.js +0 -13
- package/dist/review/review-prompt.d.ts +0 -28
- package/dist/review/review-prompt.js +0 -81
- package/dist/state.d.ts +0 -160
- package/dist/state.js +0 -267
- package/dist/tool-system/builtin/apply-patch/applier.d.ts +0 -26
- package/dist/tool-system/builtin/apply-patch/applier.js +0 -308
- package/dist/tool-system/builtin/apply-patch/backup-targets.d.ts +0 -10
- package/dist/tool-system/builtin/apply-patch/backup-targets.js +0 -30
- package/dist/tool-system/builtin/apply-patch/index.d.ts +0 -20
- package/dist/tool-system/builtin/apply-patch/index.js +0 -105
- package/dist/tool-system/builtin/apply-patch/parser.d.ts +0 -17
- package/dist/tool-system/builtin/apply-patch/parser.js +0 -212
- package/dist/tool-system/builtin/apply-patch/seek-sequence.d.ts +0 -18
- package/dist/tool-system/builtin/apply-patch/seek-sequence.js +0 -123
- package/dist/tool-system/builtin/apply-patch/types.d.ts +0 -49
- package/dist/tool-system/builtin/apply-patch/types.js +0 -13
- package/dist/tool-system/builtin/arena.d.ts +0 -31
- package/dist/tool-system/builtin/arena.js +0 -415
- package/dist/tool-system/builtin/brief.d.ts +0 -6
- package/dist/tool-system/builtin/brief.js +0 -41
- package/dist/tool-system/builtin/check-quota.d.ts +0 -15
- package/dist/tool-system/builtin/check-quota.js +0 -34
- package/dist/tool-system/builtin/drive-claude-code.d.ts +0 -51
- package/dist/tool-system/builtin/drive-claude-code.js +0 -698
- package/dist/tool-system/builtin/lsp.d.ts +0 -7
- package/dist/tool-system/builtin/lsp.js +0 -144
- package/dist/tool-system/builtin/notebook-edit.d.ts +0 -7
- package/dist/tool-system/builtin/notebook-edit.js +0 -127
- package/dist/tool-system/builtin/worktree.d.ts +0 -11
- package/dist/tool-system/builtin/worktree.js +0 -345
- /package/dist/{external-agents → links}/types.js +0 -0
- /package/dist/{engine/session-usage.d.ts → session/usage.d.ts} +0 -0
- /package/dist/{engine/session-usage.js → session/usage.js} +0 -0
package/dist/engine/engine.js
CHANGED
|
@@ -3,66 +3,59 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { createLLMClient } from "../llm/client-factory.js";
|
|
5
5
|
import { ToolRegistry } from "../tool-system/registry.js";
|
|
6
|
-
import {
|
|
7
|
-
import { InvestigationGuard } from "../tool-system/investigation-guard.js";
|
|
8
|
-
import { TaskGuard } from "../tool-system/task-guard.js";
|
|
6
|
+
import { queryExtensionModules, registerExtensionModules, } from "../tool-system/capability-module.js";
|
|
9
7
|
import { readLastTodoSnapshot } from "../tool-system/builtin/task.js";
|
|
10
|
-
import { applyDynamicToolDef } from "./dynamic-tool-defs.js";
|
|
11
8
|
import { getMergedCatalog } from "../model-catalog/index.js";
|
|
12
9
|
import { modelEntriesFromConnections } from "./model-connections-pool.js";
|
|
13
|
-
import {
|
|
14
|
-
import { addCumulativeUsage, cumulativeCacheHitRate, foldRunUsage, normalizeCumulativeUsageCounters, } from "./session-usage.js";
|
|
10
|
+
import { cumulativeCacheHitRate, foldRunUsage, normalizeCumulativeUsageCounters, } from "../session/usage.js";
|
|
15
11
|
import { enqueueSteerItem, consumeSteerItems, removeSteerItem, } from "./steer-queue.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import { BUILTIN_TOOL_GUARDS } from "../tool-system/builtin/index.js";
|
|
12
|
+
import { RunEnvironmentResolver } from "./run-environment.js";
|
|
13
|
+
import { BUILTIN_TOOLS, } from "../tool-system/builtin/index.js";
|
|
19
14
|
import { asyncAgentRegistry } from "../tool-system/builtin/agent-registry.js";
|
|
20
15
|
import { backgroundShellManager } from "../runtime/background-shell.js";
|
|
21
|
-
import { notificationQueue
|
|
22
|
-
import { PermissionClassifier, HeadlessApprovalBackend, AutoApprovalBackend, InteractiveApprovalBackend, getInteractiveApprovalBackend, } from "../tool-system/permission.js";
|
|
16
|
+
import { notificationQueue } from "../tool-system/builtin/agent-notifications.js";
|
|
23
17
|
import { HookRegistry } from "../hooks/registry.js";
|
|
24
|
-
import {
|
|
25
|
-
import { createGoalStopHook } from "../hooks/goal-stop-hook.js";
|
|
26
|
-
import { normalizeGoal, resolveGoalSetAt, resolveMaxTurns, resolveMaxStopBlocks, isSameGoalInstance, } from "./goal.js";
|
|
18
|
+
import { normalizeGoal, resolveMaxTurns, resolveMaxStopBlocks, goalConfigFromLifecycle, isGoalLifecycleCurrent, isSameGoalVersion, } from "../goal/lifecycle.js";
|
|
27
19
|
import { loadPluginHooks } from "../plugins/loadPluginHooks.js";
|
|
28
20
|
import { pluginAgentDirs } from "../plugins/installer/loadPluginAgents.js";
|
|
29
|
-
import { patchOrphanedToolUses } from "./patch-orphaned-tools.js";
|
|
30
21
|
import { runShellHook, shellHookMatches } from "../hooks/shell-runner.js";
|
|
31
22
|
import { ContextManager } from "../context/manager.js";
|
|
32
|
-
import { estimateTokens, clampContextRatios as clampContextRatiosImpl, } from "../context/compaction.js";
|
|
33
|
-
import { PLAN_MODE_ALLOWED_TOOLS } from "../tool-system/plan-mode-allowlist.js";
|
|
23
|
+
import { CONTEXT_PACKAGE_MAX_OUTPUT_TOKENS, buildContextPackagePromptFromSerialized, estimateTokens, groupMessagesByApiRound, serializeContextPackageMessages, clampContextRatios as clampContextRatiosImpl, } from "../context/compaction.js";
|
|
34
24
|
import { PromptComposer } from "../prompt/composer.js";
|
|
35
|
-
import { SessionManager } from "../session/session-manager.js";
|
|
36
|
-
import {
|
|
37
|
-
import { logger, runWithSid
|
|
38
|
-
import { recordSessionStart
|
|
39
|
-
import {
|
|
25
|
+
import { SessionManager, assertSafeSessionId, isEphemeralSessionState, sessionsRoot, } from "../session/session-manager.js";
|
|
26
|
+
import { createRunUsageAccounting, wireRunModelFacade } from "./run-accounting.js";
|
|
27
|
+
import { logger, runWithSid } from "../logging/logger.js";
|
|
28
|
+
import { recordSessionStart } from "../logging/session-recorder.js";
|
|
29
|
+
import { sanitizeTaskString } from "../logging/sanitize-messages.js";
|
|
40
30
|
import { TurnLoop } from "./turn-loop.js";
|
|
41
|
-
import { MCPManager } from "../tool-system/mcp-manager.js";
|
|
42
31
|
import { SettingsManager, userHome } from "../settings/manager.js";
|
|
43
32
|
import { getCredentialAccess } from "../credentials/access.js";
|
|
44
|
-
import {
|
|
45
|
-
import { effectiveDisabledList,
|
|
33
|
+
import { resolveFeatureFlags, } from "../settings/feature-flags.js";
|
|
34
|
+
import { effectiveBuiltinLists, effectiveDisabledList, effectiveProjectOverrides, } from "../capability-control/overlay.js";
|
|
46
35
|
import { computeEffectiveDisabledLists } from "../capability-control/disabled-lists.js";
|
|
47
|
-
import {
|
|
48
|
-
import { patchBackupTargets } from "../tool-system/builtin/apply-patch/backup-targets.js";
|
|
49
|
-
import { resolveSandboxBackend, } from "../tool-system/sandbox/index.js";
|
|
36
|
+
import { registerFileHistoryHook } from "./file-history-hook.js";
|
|
50
37
|
import { resolveAgentPreset, resolveBuiltinToolNames } from "../preset/index.js";
|
|
38
|
+
import { composeDynamicContextProviders, composeCapabilityEngineHooks, composePromptSections, composeToolCatalog, resolveCapabilities, resolveInstructionBoundary, } from "../capabilities/index.js";
|
|
51
39
|
import { ModelPool } from "../llm/model-pool.js";
|
|
52
40
|
import { AgentDefinitionRegistry } from "../agent/agent-definition-registry.js";
|
|
53
41
|
import { defaultCacheDir } from "../llm/model-cache.js";
|
|
54
42
|
import { detectProviderFromApiKey, buildModelPool } from "../onboarding.js";
|
|
55
43
|
import { detectPastedNoise } from "../utils/task-sanitizer.js";
|
|
56
|
-
import {
|
|
57
|
-
import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
|
|
58
|
-
import { runDreamConsolidation } from "../services/dream-consolidation.js";
|
|
44
|
+
import { PromptCacheDiagnosticRecorder, promptCacheDropHint, } from "./prompt-cache-diagnostics.js";
|
|
59
45
|
import { buildRunUserMessageContent, prepareRunImageInput } from "./run-image-input.js";
|
|
46
|
+
import { QUICK_CHAT_RESTRICTED_PROFILE, } from "./run-types.js";
|
|
47
|
+
import { createSubAgentSpawner } from "./subagent-spawner.js";
|
|
48
|
+
import { AuxiliaryPipeline, sameLlmIdentity } from "./auxiliary-pipeline.js";
|
|
49
|
+
import { PermissionController } from "./permission-controller.js";
|
|
50
|
+
import { buildPromptComposerConfig } from "./run-setup.js";
|
|
51
|
+
import { resolveRunWorkspace } from "./run-workspace.js";
|
|
52
|
+
import { openRunSession } from "./run-session-open.js";
|
|
53
|
+
import { buildRunToolContext, buildRunPermissionPipeline, connectRunMcp, assembleRunToolDefs, } from "./run-tooling.js";
|
|
54
|
+
import { createRunContextManager, composeRunSystemPrompt, assembleRunMessages, } from "./run-context.js";
|
|
55
|
+
import { resolveRunGoal, armRunGoalHook, createGoalTerminationApplier, } from "./run-goal.js";
|
|
56
|
+
import { drainHeadlessBackgroundAgents, finalizeRunSuccess, buildRunFailureResult, } from "./run-finalize.js";
|
|
60
57
|
import { join } from "node:path";
|
|
61
58
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
62
|
-
const CACHE_READ_DROP_MIN_PREVIOUS_TOKENS = 100;
|
|
63
|
-
const CACHE_READ_DROP_MAX_CURRENT_TOKENS = 64;
|
|
64
|
-
const CACHE_READ_DROP_RATIO = 0.1;
|
|
65
|
-
const CACHE_READ_DIAGNOSTIC_MAX_SESSIONS = 256;
|
|
66
59
|
/**
|
|
67
60
|
* Build ScanOptions.compatFileNames from the user's instruction compat toggles.
|
|
68
61
|
* Primary file name stays hard-wired to CODESHELL.md (not exposed). Turning a
|
|
@@ -78,31 +71,11 @@ export function compatFileNamesFrom(instructions) {
|
|
|
78
71
|
names.push("AGENTS.md");
|
|
79
72
|
return names;
|
|
80
73
|
}
|
|
81
|
-
/**
|
|
82
|
-
* True when two LLMConfigs name the SAME client identity — i.e. building a
|
|
83
|
-
* client from either would talk to the same model on the same endpoint with the
|
|
84
|
-
* same shaping. Used by resolveAuxClient to de-dup the aux client against the
|
|
85
|
-
* active model WITHOUT collapsing two distinct pool keys that merely share a
|
|
86
|
-
* `model` NAME but differ in reasoning/maxTokens/baseUrl/provider. Compares the
|
|
87
|
-
* fields that actually change request behavior; apiKey is intentionally NOT
|
|
88
|
-
* compared (two keys with the same endpoint+model but different credentials
|
|
89
|
-
* still produce equivalent aux work and don't warrant a second client). The
|
|
90
|
-
* reasoning object is compared by normalized JSON since it's a small
|
|
91
|
-
* discriminated union.
|
|
92
|
-
*/
|
|
93
|
-
function sameLlmIdentity(a, b) {
|
|
94
|
-
return (a.model === b.model &&
|
|
95
|
-
(a.baseUrl ?? undefined) === (b.baseUrl ?? undefined) &&
|
|
96
|
-
(a.provider ?? undefined) === (b.provider ?? undefined) &&
|
|
97
|
-
(a.providerKind ?? undefined) === (b.providerKind ?? undefined) &&
|
|
98
|
-
(a.maxTokens ?? undefined) === (b.maxTokens ?? undefined) &&
|
|
99
|
-
JSON.stringify(a.reasoning ?? null) === JSON.stringify(b.reasoning ?? null));
|
|
100
|
-
}
|
|
101
74
|
// Re-export the config hot-reload patch builder from here so the protocol
|
|
102
75
|
// server (and tests) can import it alongside Engine without reaching into the
|
|
103
76
|
// settings/ subtree directly. The implementation lives in settings/ to keep
|
|
104
77
|
// engine.ts from growing and to sit next to personalizationFrom it composes.
|
|
105
|
-
export { diskDefaultsFrom } from "
|
|
78
|
+
export { diskDefaultsFrom } from "./disk-defaults.js";
|
|
106
79
|
/**
|
|
107
80
|
* Resolve the LLM config for a spawned child Engine.
|
|
108
81
|
* - `modelKey` set + present in pool → that model's config (pure entry-derived
|
|
@@ -115,34 +88,16 @@ export { diskDefaultsFrom } from "../settings/disk-defaults.js";
|
|
|
115
88
|
* Engine directly via EngineConfig.clientDefaults — they do not flow through
|
|
116
89
|
* this helper because they're not part of LLMConfig anymore.
|
|
117
90
|
*/
|
|
118
|
-
export
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return resolved;
|
|
123
|
-
}
|
|
124
|
-
return parentLlm;
|
|
125
|
-
}
|
|
91
|
+
export { resolveChildLlm, resolveChildToolScope } from "./subagent-spawner.js";
|
|
92
|
+
// resolveRunCwd moved to run-workspace.ts; re-exported here so
|
|
93
|
+
// engine.resolve-cwd.test.ts keeps resolving it from engine.js unchanged.
|
|
94
|
+
export { resolveRunCwd } from "./run-workspace.js";
|
|
126
95
|
/**
|
|
127
96
|
* Load reusable sub-agent role definitions, merging:
|
|
128
97
|
* 1. project-level <cwd>/.code-shell/agents/*.md (ships built-ins)
|
|
129
98
|
* 2. user-level ~/.code-shell/agents/*.md (user wins on name)
|
|
130
99
|
* Names in `disabledAgents` are filtered out so the LLM never sees them.
|
|
131
100
|
*/
|
|
132
|
-
/**
|
|
133
|
-
* Resolve the working directory for a run. Precedence for legacy sessions:
|
|
134
|
-
* options.cwd > resumed session's state.cwd > config.cwd > process.cwd()
|
|
135
|
-
*
|
|
136
|
-
* The session-cwd tier is what stops a project-bound session from being
|
|
137
|
-
* resumed against the wrong directory: when a host omits options.cwd (e.g. its
|
|
138
|
-
* sidebar repo selection drifted to null), the session's own recorded cwd is
|
|
139
|
-
* recovered so the engine still loads THAT project's agents/settings/memory,
|
|
140
|
-
* not whatever process.cwd() happens to be. Pure so the precedence is testable
|
|
141
|
-
* without standing up an Engine.
|
|
142
|
-
*/
|
|
143
|
-
export function resolveRunCwd(args) {
|
|
144
|
-
return args.optionCwd ?? args.sessionCwd ?? args.configCwd ?? args.processCwd;
|
|
145
|
-
}
|
|
146
101
|
export function loadAgentDefinitionsForCwd(cwd, disabledAgents = [], disabledPlugins = []) {
|
|
147
102
|
// userHome() (not raw homedir(), which bun caches at process start and never
|
|
148
103
|
// re-reads) so the user-agents dir honors a test's process.env.HOME override
|
|
@@ -163,7 +118,6 @@ export function loadAgentDefinitionsForCwd(cwd, disabledAgents = [], disabledPlu
|
|
|
163
118
|
...(cwd ? [{ dir: `${cwd}/.code-shell/agents`, source: "project" }] : []),
|
|
164
119
|
], disabledAgents);
|
|
165
120
|
}
|
|
166
|
-
const NESTED_AGENT_TOOLS = ["Agent", "AgentStatus", "AgentCancel", "AgentSendInput"];
|
|
167
121
|
/**
|
|
168
122
|
* #7: apply a project's per-turn builtin capability override to a tool list.
|
|
169
123
|
* A builtin marked `off` for the current cwd is HIDDEN from the turn's tool
|
|
@@ -184,17 +138,6 @@ export function applyBuiltinOverrideVisibility(tools, override) {
|
|
|
184
138
|
* - `allowlist` undefined → inherit parent enabled/disabled, always with the
|
|
185
139
|
* nested-agent tools forced into `disabled` (no grandchildren).
|
|
186
140
|
*/
|
|
187
|
-
export function resolveChildToolScope(allowlist, parentDisabled, parentEnabled) {
|
|
188
|
-
if (allowlist) {
|
|
189
|
-
return {
|
|
190
|
-
enabled: allowlist.filter((t) => !NESTED_AGENT_TOOLS.includes(t)),
|
|
191
|
-
disabled: [...NESTED_AGENT_TOOLS],
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
const disabled = Array.from(new Set([...(parentDisabled ?? []), ...NESTED_AGENT_TOOLS]));
|
|
195
|
-
const enabled = parentEnabled?.filter((t) => !NESTED_AGENT_TOOLS.includes(t));
|
|
196
|
-
return { enabled, disabled };
|
|
197
|
-
}
|
|
198
141
|
export class Engine {
|
|
199
142
|
config;
|
|
200
143
|
// Resolved per-session preset. Set in the ctor; re-resolved by
|
|
@@ -204,9 +147,22 @@ export class Engine {
|
|
|
204
147
|
// and is NOT rebuilt on reload — a preset change that alters the builtin tool
|
|
205
148
|
// set only takes effect on session restart (logged in refreshRuntimeConfig).
|
|
206
149
|
preset;
|
|
150
|
+
/** Capability-free seed owned by the runtime/host; never mutated by an Engine. */
|
|
151
|
+
runtimeToolRegistry;
|
|
152
|
+
/** Engine-local view containing only this Engine's capability modules. */
|
|
207
153
|
toolRegistry;
|
|
154
|
+
capabilities;
|
|
155
|
+
toolCatalog;
|
|
156
|
+
toolGuards;
|
|
157
|
+
/** Per-turn dynamic definition rewriters contributed by builtin exposures. */
|
|
158
|
+
toolRewriters;
|
|
159
|
+
/** Named per-run behavior profiles (core defaults + config + extensions). */
|
|
160
|
+
behaviorProfiles;
|
|
161
|
+
capabilityPromptSections;
|
|
162
|
+
capabilityDynamicContextProviders;
|
|
208
163
|
hooks;
|
|
209
164
|
sessionManager;
|
|
165
|
+
sessionMessageRouter;
|
|
210
166
|
mcpManager;
|
|
211
167
|
modelPool;
|
|
212
168
|
/**
|
|
@@ -228,24 +184,15 @@ export class Engine {
|
|
|
228
184
|
agentDefsCache;
|
|
229
185
|
/** Shared resources supplied at construction (adapter pattern — null when self-constructed). */
|
|
230
186
|
runtime;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
/** True when permissionMode === "plan". */
|
|
235
|
-
planMode;
|
|
187
|
+
runEnvironmentResolver;
|
|
188
|
+
auxiliaryPipeline;
|
|
189
|
+
permissionController;
|
|
236
190
|
// Lazy SettingsManager — reused across updateConfig/readSetting so we
|
|
237
191
|
// don't re-read 6+ JSON files on every /model, /login, etc. The manager
|
|
238
192
|
// handles its own cache invalidation in saveUserSetting().
|
|
239
193
|
settingsManager;
|
|
240
|
-
/**
|
|
241
|
-
* Cached auxiliary-task LLM client, keyed by the models[].key it was built
|
|
242
|
-
* from. Background calls (memory extraction, auto-dream) reuse it across
|
|
243
|
-
* runs so we don't redo the provider handshake every session. Invalidated
|
|
244
|
-
* implicitly: a changed auxModelKey produces a different cache key.
|
|
245
|
-
*/
|
|
246
|
-
auxClientCache;
|
|
247
194
|
// Live state from the current/most-recent run, retained for /compact and
|
|
248
|
-
//
|
|
195
|
+
// run-boundary PermissionClassifier replacement/reconfiguration.
|
|
249
196
|
lastContextManager;
|
|
250
197
|
lastMessages;
|
|
251
198
|
lastSessionId;
|
|
@@ -266,7 +213,7 @@ export class Engine {
|
|
|
266
213
|
* LLM response arrives.
|
|
267
214
|
*/
|
|
268
215
|
ctxOverheadBySid = new Map();
|
|
269
|
-
|
|
216
|
+
promptCacheDiagnostics = new PromptCacheDiagnosticRecorder({ maxSessions: 256 });
|
|
270
217
|
/**
|
|
271
218
|
* Step-gap steering queue (per sessionId, in-memory). Host pushes user
|
|
272
219
|
* messages here via enqueueSteer while a run is in flight; the turn loop
|
|
@@ -276,7 +223,6 @@ export class Engine {
|
|
|
276
223
|
* multiple Engines don't interfere and it stays cleanly extractable.
|
|
277
224
|
*/
|
|
278
225
|
steerQueueBySid = new Map();
|
|
279
|
-
activePermission;
|
|
280
226
|
/**
|
|
281
227
|
* The TurnLoop of the in-flight run(), exposed so extendGoalRun() can bump a
|
|
282
228
|
* running goal's turn/budget ceilings mid-run (TODO 3.1). Null when idle.
|
|
@@ -288,25 +234,46 @@ export class Engine {
|
|
|
288
234
|
* otherwise keep re-blocking the stop). Null when no goal run is active.
|
|
289
235
|
*/
|
|
290
236
|
activeGoalHook = null;
|
|
237
|
+
/** Whether activeGoalHook is currently registered (pause detaches it). */
|
|
238
|
+
activeGoalHookAttached = false;
|
|
239
|
+
/** Mutable goal view consumed by the running judge after edits/resume. */
|
|
240
|
+
activeRuntimeGoal = null;
|
|
241
|
+
/** Mutable terminal snapshot kept in sync with a mid-run objective edit. */
|
|
242
|
+
activePersistedRunGoal = null;
|
|
291
243
|
/**
|
|
292
244
|
* The in-flight run's session bundle, held so clearGoal() can wipe the goal
|
|
293
245
|
* on the SAME instance the run loop is persisting each turn — not a fresh
|
|
294
246
|
* detached copy from resume(). Without this, a mid-run 清除 clears disk, but
|
|
295
247
|
* the still-running loop's next saveState(bundle.state) resurrects the goal
|
|
296
|
-
* (bundle.state.
|
|
248
|
+
* (bundle.state.goalLifecycle was never rebased). A never-completing goal run
|
|
297
249
|
* (judge keeps returning not_met → continueSession) stays live for a long
|
|
298
250
|
* time, so this write-back race is the norm, not an edge case, for such runs.
|
|
299
251
|
* Single-valued like activeTurnLoop — one top-level run per engine at a time.
|
|
300
252
|
* Null when idle; set at run start, cleared in run's finally.
|
|
301
253
|
*/
|
|
302
254
|
activeRunSession = null;
|
|
255
|
+
/**
|
|
256
|
+
* Same-instance run guard. Engine owns single-valued live controls and one
|
|
257
|
+
* HookRegistry, so a second run must not enter until the first has completed
|
|
258
|
+
* all state persistence and end hooks. Cross-instance/process whole-state
|
|
259
|
+
* writers are additionally fenced by SessionManager's persisted revision CAS.
|
|
260
|
+
*/
|
|
261
|
+
runInProgress = false;
|
|
262
|
+
agentControlStateListener;
|
|
263
|
+
agentDirectionsDeliveredListener;
|
|
264
|
+
/** Permission update requested while runInProgress. Applied in run() finally. */
|
|
303
265
|
/** Public accessor so UI/clients can read the resolved per-model window. */
|
|
304
266
|
get maxContextTokens() {
|
|
305
267
|
return this.resolveMaxContextTokens();
|
|
306
268
|
}
|
|
307
269
|
resolveMaxContextTokens() {
|
|
308
|
-
const modelEntry = this.modelPool
|
|
309
|
-
|
|
270
|
+
const modelEntry = this.modelPool
|
|
271
|
+
.list()
|
|
272
|
+
.find((entry) => sameLlmIdentity(this.modelPool.toLLMConfig(entry), this.config.llm));
|
|
273
|
+
return (this.config.llm.maxContextTokens ??
|
|
274
|
+
modelEntry?.maxContextTokens ??
|
|
275
|
+
this.config.maxContextTokens ??
|
|
276
|
+
200_000);
|
|
310
277
|
}
|
|
311
278
|
/**
|
|
312
279
|
* Compaction thresholds from settings.context, clamped so they keep the
|
|
@@ -335,10 +302,11 @@ export class Engine {
|
|
|
335
302
|
* emits should go through this wrapper to keep the context envelope
|
|
336
303
|
* uniform with TurnLoop.emitHook.
|
|
337
304
|
*/
|
|
338
|
-
async emitHook(event, data = {}) {
|
|
305
|
+
async emitHook(event, data = {}, signal) {
|
|
339
306
|
return this.hooks.emit(event, {
|
|
340
307
|
...data,
|
|
341
308
|
isSubAgent: this.config.isSubAgent === true,
|
|
309
|
+
signal,
|
|
342
310
|
});
|
|
343
311
|
}
|
|
344
312
|
/**
|
|
@@ -423,10 +391,63 @@ export class Engine {
|
|
|
423
391
|
this.config = config;
|
|
424
392
|
// Wire shared runtime (adapter pattern — null when self-constructing).
|
|
425
393
|
this.runtime = config.runtime ?? null;
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
394
|
+
this.runEnvironmentResolver = new RunEnvironmentResolver({
|
|
395
|
+
config: () => this.config,
|
|
396
|
+
settings: () => this.getSettingsManager(),
|
|
397
|
+
credentialAccess: {
|
|
398
|
+
envExposures: (cwd, scope) => getCredentialAccess().envExposures(cwd, scope),
|
|
399
|
+
},
|
|
400
|
+
...(this.runtime ? { runtime: this.runtime } : {}),
|
|
401
|
+
});
|
|
402
|
+
this.capabilities = resolveCapabilities(config.capabilities);
|
|
403
|
+
this.config = { ...config, capabilities: this.capabilities };
|
|
404
|
+
this.toolCatalog = composeToolCatalog(BUILTIN_TOOLS, this.capabilities, config.extensionModules ?? []);
|
|
405
|
+
this.toolGuards = new Map(this.toolCatalog.flatMap((tool) => tool.exposure.availability
|
|
406
|
+
? [[tool.definition.name, tool.exposure.availability]]
|
|
407
|
+
: []));
|
|
408
|
+
this.toolRewriters = new Map(this.toolCatalog.flatMap((tool) => tool.exposure.rewriteDefinition
|
|
409
|
+
? [[tool.definition.name, tool.exposure.rewriteDefinition]]
|
|
410
|
+
: []));
|
|
411
|
+
// Behavior profile registry: core defaults first, then host config, then
|
|
412
|
+
// extension modules — later registrations override earlier ones by id.
|
|
413
|
+
this.behaviorProfiles = new Map([
|
|
414
|
+
QUICK_CHAT_RESTRICTED_PROFILE,
|
|
415
|
+
...(config.behaviorProfiles ?? []),
|
|
416
|
+
...(config.extensionModules ?? []).flatMap((module) => module.behaviorProfiles ?? []),
|
|
417
|
+
].map((profile) => [profile.id, profile]));
|
|
418
|
+
this.capabilityPromptSections = composePromptSections(this.capabilities);
|
|
419
|
+
this.capabilityDynamicContextProviders = composeDynamicContextProviders(this.capabilities);
|
|
420
|
+
this.preset = resolveAgentPreset(config.preset, this.capabilities);
|
|
421
|
+
// Extension catalogTools join the active preset regardless of its name:
|
|
422
|
+
// presets snapshot their tool lists from the catalogs known at module
|
|
423
|
+
// load, which can never include extension packages. Visibility stays
|
|
424
|
+
// gated by each tool's exposure.availability guard.
|
|
425
|
+
const extensionCatalogTools = (config.extensionModules ?? []).flatMap((module) => [
|
|
426
|
+
...(module.catalogTools ?? []),
|
|
427
|
+
]);
|
|
428
|
+
if (extensionCatalogTools.length > 0) {
|
|
429
|
+
this.preset = {
|
|
430
|
+
...this.preset,
|
|
431
|
+
builtinTools: [
|
|
432
|
+
...this.preset.builtinTools,
|
|
433
|
+
...extensionCatalogTools.map((tool) => tool.definition.name),
|
|
434
|
+
],
|
|
435
|
+
defaultPermissionRules: [
|
|
436
|
+
...this.preset.defaultPermissionRules,
|
|
437
|
+
...extensionCatalogTools.flatMap((tool) => [
|
|
438
|
+
...(tool.exposure.defaultPermissionRules ?? []),
|
|
439
|
+
]),
|
|
440
|
+
],
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
this.permissionController = new PermissionController({
|
|
444
|
+
config: () => this.config,
|
|
445
|
+
updateConfig: (next) => {
|
|
446
|
+
this.config = next;
|
|
447
|
+
},
|
|
448
|
+
presetRules: () => [...this.preset.defaultPermissionRules],
|
|
449
|
+
runInProgress: () => this.runInProgress,
|
|
450
|
+
});
|
|
430
451
|
// Fold the project's capabilityOverrides.builtin overlay over the global
|
|
431
452
|
// enabled/disabled builtin lists so a project can force-enable a
|
|
432
453
|
// globally-disabled builtin tool or force-disable a globally-enabled one
|
|
@@ -442,16 +463,25 @@ export class Engine {
|
|
|
442
463
|
// per-turn path can only hide, not add, so a freshly-`on`'d builtin not in
|
|
443
464
|
// the set needs a session restart to appear.
|
|
444
465
|
const builtinLists = effectiveBuiltinLists(config.enabledBuiltinTools ?? [], config.disabledBuiltinTools ?? [], this.readBuiltinOverride(config.cwd));
|
|
445
|
-
this.
|
|
466
|
+
this.runtimeToolRegistry =
|
|
446
467
|
config.runtime?.toolRegistry ??
|
|
447
468
|
new ToolRegistry({
|
|
448
469
|
builtinTools: resolveBuiltinToolNames({
|
|
449
470
|
preset: this.preset.name,
|
|
450
471
|
host: config.builtinToolHost,
|
|
451
|
-
enabledBuiltinTools:
|
|
472
|
+
enabledBuiltinTools: [
|
|
473
|
+
...builtinLists.enabledBuiltinTools,
|
|
474
|
+
// Extension catalogTools are preset-agnostic (see preset merge
|
|
475
|
+
// above); their availability guards gate actual visibility.
|
|
476
|
+
...extensionCatalogTools.map((tool) => tool.definition.name),
|
|
477
|
+
],
|
|
452
478
|
disabledBuiltinTools: builtinLists.disabledBuiltinTools,
|
|
479
|
+
capabilities: this.capabilities,
|
|
453
480
|
}),
|
|
481
|
+
toolCatalog: this.toolCatalog,
|
|
454
482
|
});
|
|
483
|
+
this.toolRegistry = this.runtimeToolRegistry.fork();
|
|
484
|
+
registerExtensionModules(this.toolRegistry, config.extensionModules ?? []);
|
|
455
485
|
this.hooks = new HookRegistry();
|
|
456
486
|
// Installed-plugin hooks — declared in each plugin's hooks/hooks.json.
|
|
457
487
|
// Registered first (priority 80) so user-authored hooks at lower
|
|
@@ -472,14 +502,26 @@ export class Engine {
|
|
|
472
502
|
loadPluginHooks(this.hooks, disabledPlugins, disabledPluginHooks);
|
|
473
503
|
}
|
|
474
504
|
// settings.hooks → shell-command wrappers. Chain order:
|
|
475
|
-
// plugin (80) → shell (50) → code (default 0).
|
|
505
|
+
// plugin (80) → shell (50) → capability (20) → SDK code (default 0).
|
|
476
506
|
this.registerSettingsHooks();
|
|
507
|
+
for (const hook of composeCapabilityEngineHooks(this.capabilities)) {
|
|
508
|
+
this.hooks.register(hook.event, hook.handler, hook.priority, hook.name);
|
|
509
|
+
}
|
|
477
510
|
for (const hook of config.hooks ?? []) {
|
|
478
511
|
this.hooks.register(hook.event, hook.handler, hook.priority, hook.name);
|
|
479
512
|
}
|
|
480
|
-
this.sessionManager = new SessionManager(config.sessionStorageDir
|
|
513
|
+
this.sessionManager = new SessionManager(config.sessionStorageDir, this.capabilities
|
|
514
|
+
.map((capability) => capability.sessionWorkspace)
|
|
515
|
+
.find((candidate) => candidate !== undefined));
|
|
481
516
|
// Initialize model pool — prefer runtime's shared pool, fall back to self-constructed.
|
|
482
517
|
this.modelPool = config.runtime?.modelPool ?? new ModelPool();
|
|
518
|
+
this.auxiliaryPipeline = new AuxiliaryPipeline({
|
|
519
|
+
config: () => this.config,
|
|
520
|
+
settings: () => this.getSettingsManager(),
|
|
521
|
+
modelPool: () => this.modelPool,
|
|
522
|
+
toolRegistry: () => this.toolRegistry,
|
|
523
|
+
toolContext: () => this.buildToolContext(),
|
|
524
|
+
});
|
|
483
525
|
if (!config.runtime) {
|
|
484
526
|
this.populateModelPoolFromSettings();
|
|
485
527
|
}
|
|
@@ -621,6 +663,10 @@ export class Engine {
|
|
|
621
663
|
registerCustomTool(definition, executor) {
|
|
622
664
|
this.toolRegistry.registerTool(definition, executor);
|
|
623
665
|
}
|
|
666
|
+
/** Dispatch a host-installed capability query without teaching core its name. */
|
|
667
|
+
queryCapability(type, params = {}) {
|
|
668
|
+
return queryExtensionModules(this.config.extensionModules ?? [], type, params);
|
|
669
|
+
}
|
|
624
670
|
/**
|
|
625
671
|
* Inject the askUser handler after construction. Used by AgentServer
|
|
626
672
|
* to wire its protocol-backed askUser into an Engine that was created
|
|
@@ -629,6 +675,13 @@ export class Engine {
|
|
|
629
675
|
setAskUser(fn) {
|
|
630
676
|
this.config.askUser = fn;
|
|
631
677
|
}
|
|
678
|
+
/** Internal child-runtime seam used by the single-writer supervisor. */
|
|
679
|
+
setAgentControlStateListener(listener) {
|
|
680
|
+
this.agentControlStateListener = listener;
|
|
681
|
+
}
|
|
682
|
+
setAgentDirectionsDeliveredListener(listener) {
|
|
683
|
+
this.agentDirectionsDeliveredListener = listener;
|
|
684
|
+
}
|
|
632
685
|
/**
|
|
633
686
|
* Inject the browser automation bridge after construction (same chicken-and-egg
|
|
634
687
|
* as setAskUser: the desktop host builds the bridge — which drives a webview —
|
|
@@ -642,6 +695,14 @@ export class Engine {
|
|
|
642
695
|
setWorkspaceBridge(bridge) {
|
|
643
696
|
this.config.workspaceBridge = bridge;
|
|
644
697
|
}
|
|
698
|
+
/** Inject the host-backed panel discovery/focus bridge after construction. */
|
|
699
|
+
setPanelBridge(bridge) {
|
|
700
|
+
this.config.panelBridge = bridge;
|
|
701
|
+
}
|
|
702
|
+
/** Inject the host router used by SendMessageToSession. */
|
|
703
|
+
setSessionMessageRouter(router) {
|
|
704
|
+
this.sessionMessageRouter = router;
|
|
705
|
+
}
|
|
645
706
|
/**
|
|
646
707
|
* Queue a user message to be spliced into the in-flight run for `sessionId`
|
|
647
708
|
* at the next turn-loop step boundary — the 不打断 steering path (vs cancel +
|
|
@@ -737,6 +798,12 @@ export class Engine {
|
|
|
737
798
|
isHeadless() {
|
|
738
799
|
return this.config.headless === true;
|
|
739
800
|
}
|
|
801
|
+
get permissionMode() {
|
|
802
|
+
return this.permissionController.permissionMode;
|
|
803
|
+
}
|
|
804
|
+
get planMode() {
|
|
805
|
+
return this.permissionController.planMode;
|
|
806
|
+
}
|
|
740
807
|
/**
|
|
741
808
|
* Probe whether a session already exists on disk (its state/transcript dir is
|
|
742
809
|
* present). Used by the protocol server to distinguish "resume an existing
|
|
@@ -747,49 +814,184 @@ export class Engine {
|
|
|
747
814
|
sessionExistsOnDisk(sessionId) {
|
|
748
815
|
return this.sessionManager.exists(sessionId);
|
|
749
816
|
}
|
|
817
|
+
forkSession(sourceSessionId, options) {
|
|
818
|
+
return this.sessionManager.fork(sourceSessionId, options);
|
|
819
|
+
}
|
|
820
|
+
selectContextPackage(sourceSessionId, range) {
|
|
821
|
+
return this.sessionManager.selectContextPackage(sourceSessionId, range);
|
|
822
|
+
}
|
|
823
|
+
createSummaryFork(sourceSessionId, options) {
|
|
824
|
+
return this.sessionManager.createSummaryFork(sourceSessionId, options);
|
|
825
|
+
}
|
|
826
|
+
/** Summarize a selected transcript package using the configured aux tier. */
|
|
827
|
+
async summarizeContextPackage(messages, signal, sourceSessionId) {
|
|
828
|
+
if (messages.length === 0)
|
|
829
|
+
throw new Error("Cannot summarize an empty context package");
|
|
830
|
+
const serializedSelection = serializeContextPackageMessages(messages);
|
|
831
|
+
if (!serializedSelection.hasSummarizableContent) {
|
|
832
|
+
throw new Error("Cannot summarize an image-only context package without textual or tool facts");
|
|
833
|
+
}
|
|
834
|
+
if (sourceSessionId && this.config.costStore) {
|
|
835
|
+
const persistedCost = this.sessionManager.resume(sourceSessionId).state.costState;
|
|
836
|
+
if (persistedCost)
|
|
837
|
+
this.config.costStore.restore(persistedCost);
|
|
838
|
+
}
|
|
839
|
+
const primaryClient = await createLLMClient(this.config.llm, this.config.clientDefaults);
|
|
840
|
+
const resolvedAux = await this.auxiliaryPipeline.resolveAuxClientWithMetadata(primaryClient, this.resolveMaxContextTokens());
|
|
841
|
+
const client = resolvedAux.client;
|
|
842
|
+
const systemPrompt = "You package selected conversation context. Be concise, factual, and complete.";
|
|
843
|
+
const fitsAuxWindow = (conversation, priorSummary) => {
|
|
844
|
+
const prompt = buildContextPackagePromptFromSerialized(conversation, priorSummary);
|
|
845
|
+
const requestTokens = estimateTokens([
|
|
846
|
+
{ role: "system", content: systemPrompt },
|
|
847
|
+
{ role: "user", content: prompt },
|
|
848
|
+
]);
|
|
849
|
+
return requestTokens + CONTEXT_PACKAGE_MAX_OUTPUT_TOKENS <= resolvedAux.maxContextTokens;
|
|
850
|
+
};
|
|
851
|
+
if (!fitsAuxWindow("x")) {
|
|
852
|
+
throw new Error(`Auxiliary model context window (${resolvedAux.maxContextTokens}) is too small for the context package template and output reserve`);
|
|
853
|
+
}
|
|
854
|
+
// Preserve complete API rounds whenever they fit. If one round alone is
|
|
855
|
+
// larger than the aux window, split its lossless serialized form and feed
|
|
856
|
+
// every fragment through the same rolling nine-section merge.
|
|
857
|
+
const pending = groupMessagesByApiRound(messages).map((group) => serializeContextPackageMessages(group).text);
|
|
858
|
+
let summary;
|
|
859
|
+
while (pending.length > 0) {
|
|
860
|
+
let conversation = "";
|
|
861
|
+
while (pending.length > 0) {
|
|
862
|
+
const next = pending[0];
|
|
863
|
+
const candidate = conversation ? `${conversation}\n${next}` : next;
|
|
864
|
+
if (fitsAuxWindow(candidate, summary)) {
|
|
865
|
+
conversation = candidate;
|
|
866
|
+
pending.shift();
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
if (conversation)
|
|
870
|
+
break;
|
|
871
|
+
let low = 1;
|
|
872
|
+
let high = next.length;
|
|
873
|
+
let fitLength = 0;
|
|
874
|
+
while (low <= high) {
|
|
875
|
+
const middle = Math.floor((low + high) / 2);
|
|
876
|
+
if (fitsAuxWindow(next.slice(0, middle), summary)) {
|
|
877
|
+
fitLength = middle;
|
|
878
|
+
low = middle + 1;
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
high = middle - 1;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if (fitLength === 0) {
|
|
885
|
+
throw new Error(`Auxiliary model context window (${resolvedAux.maxContextTokens}) cannot fit the rolling context package prompt`);
|
|
886
|
+
}
|
|
887
|
+
conversation = next.slice(0, fitLength);
|
|
888
|
+
const remainder = next.slice(fitLength);
|
|
889
|
+
if (remainder)
|
|
890
|
+
pending[0] = remainder;
|
|
891
|
+
else
|
|
892
|
+
pending.shift();
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
const response = await client.createMessage({
|
|
896
|
+
systemPrompt,
|
|
897
|
+
messages: [
|
|
898
|
+
{
|
|
899
|
+
role: "user",
|
|
900
|
+
content: buildContextPackagePromptFromSerialized(conversation, summary),
|
|
901
|
+
},
|
|
902
|
+
],
|
|
903
|
+
tools: [],
|
|
904
|
+
maxTokens: CONTEXT_PACKAGE_MAX_OUTPUT_TOKENS,
|
|
905
|
+
billingEnabled: true,
|
|
906
|
+
requestVisible: false,
|
|
907
|
+
reasoning: { mode: "off" },
|
|
908
|
+
signal,
|
|
909
|
+
});
|
|
910
|
+
if (response.usage && sourceSessionId) {
|
|
911
|
+
this.sessionManager.recordAuxiliaryUsage(sourceSessionId, response.usage, this.config.costStore?.serialize());
|
|
912
|
+
}
|
|
913
|
+
summary = response.text.trim();
|
|
914
|
+
if (!summary)
|
|
915
|
+
throw new Error("Context package summary was empty");
|
|
916
|
+
}
|
|
917
|
+
return {
|
|
918
|
+
summary: summary,
|
|
919
|
+
estimatedTokens: estimateTokens([{ role: "user", content: summary }]),
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
/** Restore a cold Engine's configured model from persisted source state without resetting usage. */
|
|
923
|
+
restoreSessionModel(sessionId) {
|
|
924
|
+
const state = this.sessionManager.resume(sessionId).state;
|
|
925
|
+
if (this.config.llm.model === state.model && this.config.llm.provider === state.provider)
|
|
926
|
+
return;
|
|
927
|
+
const entry = this.modelPool
|
|
928
|
+
.list()
|
|
929
|
+
.find((candidate) => candidate.model === state.model && candidate.provider === state.provider) ?? this.modelPool.list().find((candidate) => candidate.model === state.model);
|
|
930
|
+
if (!entry) {
|
|
931
|
+
throw new Error(`Persisted source model is no longer configured: ${state.model}`);
|
|
932
|
+
}
|
|
933
|
+
this.config = { ...this.config, llm: this.modelPool.toLLMConfig(entry) };
|
|
934
|
+
}
|
|
750
935
|
/**
|
|
751
|
-
* Run a task from start to finish.
|
|
936
|
+
* Run a task from start to finish. Rejects immediately when this Engine
|
|
937
|
+
* instance already has a run in progress; hosts that want queueing own that
|
|
938
|
+
* policy (for example ChatSession's FIFO queue).
|
|
752
939
|
*/
|
|
753
940
|
async run(task, options) {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
: undefined;
|
|
757
|
-
if (workspaceResume && !workspaceResume.ok) {
|
|
758
|
-
return {
|
|
759
|
-
text: `ERROR: ${workspaceResume.message}`,
|
|
760
|
-
reason: "completed",
|
|
761
|
-
sessionId: options.sessionId,
|
|
762
|
-
turnCount: 0,
|
|
763
|
-
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
764
|
-
};
|
|
941
|
+
if (this.runInProgress) {
|
|
942
|
+
throw new Error("Engine.run() cannot start while another run is in progress");
|
|
765
943
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
return {
|
|
770
|
-
text: workspaceResume.message,
|
|
771
|
-
reason: "completed",
|
|
772
|
-
sessionId: options.sessionId,
|
|
773
|
-
turnCount: 0,
|
|
774
|
-
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
775
|
-
};
|
|
944
|
+
this.runInProgress = true;
|
|
945
|
+
try {
|
|
946
|
+
return await this.runExclusive(task, options);
|
|
776
947
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
948
|
+
finally {
|
|
949
|
+
try {
|
|
950
|
+
this.permissionController.applyPending();
|
|
951
|
+
}
|
|
952
|
+
finally {
|
|
953
|
+
this.runInProgress = false;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Resolve the run's active behavior profile. A profile bound to the
|
|
959
|
+
* persisted session kind wins (so e.g. a resumed pet session keeps the safe
|
|
960
|
+
* profile even when the host omits behaviorMode); otherwise the explicit
|
|
961
|
+
* behaviorMode names a registered profile directly. Explicit unknown modes
|
|
962
|
+
* and non-work session kinds without an owning profile fail closed: silently
|
|
963
|
+
* falling back to an unrestricted run would turn a missing extension into a
|
|
964
|
+
* permission-boundary bypass.
|
|
965
|
+
*/
|
|
966
|
+
resolveBehaviorProfile(sessionKind, behaviorMode) {
|
|
967
|
+
const explicitProfile = behaviorMode !== undefined ? this.behaviorProfiles.get(behaviorMode) : undefined;
|
|
968
|
+
if (behaviorMode !== undefined && !explicitProfile) {
|
|
969
|
+
throw new Error(`unknown behavior profile: ${behaviorMode}`);
|
|
970
|
+
}
|
|
971
|
+
const sessionProfile = [...this.behaviorProfiles.values()].find((profile) => profile.activateForSessionKinds?.includes(sessionKind));
|
|
972
|
+
if (sessionKind !== "work" && !sessionProfile) {
|
|
973
|
+
throw new Error(`session kind has no registered behavior profile: ${sessionKind}`);
|
|
974
|
+
}
|
|
975
|
+
return sessionProfile ?? explicitProfile;
|
|
976
|
+
}
|
|
977
|
+
async runExclusive(task, options) {
|
|
978
|
+
// Freeze permission context once, before the first await. Per-turn protocol
|
|
979
|
+
// overrides live only for this run; persistent setPermissionMode/setPlanMode
|
|
980
|
+
// calls made while busy are staged separately and cannot mutate this pair.
|
|
981
|
+
const workspaceResolved = await resolveRunWorkspace({
|
|
982
|
+
options,
|
|
983
|
+
sessionManager: this.sessionManager,
|
|
984
|
+
resolveBehaviorProfile: (kind, mode) => this.resolveBehaviorProfile(kind, mode),
|
|
985
|
+
configPermissionMode: this.config.permissionMode,
|
|
986
|
+
configCwd: this.config.cwd,
|
|
987
|
+
settings: this.getSettingsManager(),
|
|
988
|
+
processCwd: process.cwd(),
|
|
989
|
+
});
|
|
990
|
+
if (!workspaceResolved.ok)
|
|
991
|
+
return workspaceResolved.result;
|
|
992
|
+
const { sessionKind, sessionWorkspaceProfile, profile, profileParams, runPermissionMode, runPlanMode, cwd, profileState: { workspaceProfile: runWorkspaceProfile, sessionProfileOverrides, profileMemoryDir, }, } = workspaceResolved.resolution;
|
|
993
|
+
/** Structured results the profile's run services report; keyed per profile contract. */
|
|
994
|
+
let profileReportedResults;
|
|
793
995
|
// Wrap the caller's onStream so we can intercept `task_update`
|
|
794
996
|
// events emitted by TodoWrite and keep an in-engine snapshot.
|
|
795
997
|
// TaskGuard reads this snapshot at turn end to decide whether to
|
|
@@ -798,23 +1000,13 @@ export class Engine {
|
|
|
798
1000
|
// store is the transcript, but TaskGuard runs in-loop and can't
|
|
799
1001
|
// afford a transcript scan per turn.
|
|
800
1002
|
let latestTodos = [];
|
|
801
|
-
const
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
// re-enters the LLM context.
|
|
809
|
-
if (event.type === "goal_progress") {
|
|
810
|
-
session.transcript.append("goal_progress", {
|
|
811
|
-
status: event.status,
|
|
812
|
-
round: event.round,
|
|
813
|
-
...(event.gaps ? { gaps: event.gaps } : {}),
|
|
814
|
-
});
|
|
815
|
-
}
|
|
816
|
-
userOnStream?.(event);
|
|
817
|
-
};
|
|
1003
|
+
const wrappedOnStream = this.buildWrappedOnStream({
|
|
1004
|
+
userOnStream: options?.onStream,
|
|
1005
|
+
getSession: () => session,
|
|
1006
|
+
setLatestTodos: (todos) => {
|
|
1007
|
+
latestTodos = todos;
|
|
1008
|
+
},
|
|
1009
|
+
});
|
|
818
1010
|
if (options)
|
|
819
1011
|
options.onStream = wrappedOnStream;
|
|
820
1012
|
const imageInput = await prepareRunImageInput({
|
|
@@ -841,173 +1033,20 @@ export class Engine {
|
|
|
841
1033
|
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
842
1034
|
};
|
|
843
1035
|
}
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
// setArenaLLMConfig, setSubAgentConfig, setToolSearchRegistry).
|
|
847
|
-
const subAgentSpawner = {
|
|
848
|
-
parentStream: options?.onStream,
|
|
849
|
-
describe: () => ({
|
|
850
|
-
cwd,
|
|
851
|
-
preset: this.preset.name,
|
|
852
|
-
permissionMode: this.config.permissionMode ?? "acceptEdits",
|
|
853
|
-
}),
|
|
854
|
-
spawn: async (req) => {
|
|
855
|
-
// Anchor this sub-agent in the PARENT transcript at spawn time — before
|
|
856
|
-
// it runs, so it's recorded whether it later completes, is interrupted,
|
|
857
|
-
// or still runs. Replay reads these anchors to rebuild sub-agent cards
|
|
858
|
-
// from sessions/<agentId>/ (agentId === childSid); without it a
|
|
859
|
-
// backgrounded sub-agent leaves no parent-transcript trace and vanishes
|
|
860
|
-
// on reopen. Only on a fresh spawn (not a resume/continuation, which
|
|
861
|
-
// already has its anchor). Guarded so a transcript hiccup never breaks
|
|
862
|
-
// the spawn.
|
|
863
|
-
if (!req.resumeSessionId) {
|
|
864
|
-
try {
|
|
865
|
-
session.transcript.appendSubagent(req.agentId, undefined, req.description);
|
|
866
|
-
}
|
|
867
|
-
catch {
|
|
868
|
-
/* anchor is best-effort; never block the spawn */
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
// No nested agents. Strip Agent / AgentStatus / AgentCancel from the
|
|
872
|
-
// child's tool pool so the LLM can't spawn grandchildren — matches
|
|
873
|
-
// Claude Code's ALL_AGENT_DISALLOWED_TOOLS approach. Without this
|
|
874
|
-
// guard a runaway model could fork-bomb sub-agents (token cost +
|
|
875
|
-
// background process explosion), and the sid / approval / dock
|
|
876
|
-
// model assumes a flat parent→children hierarchy. Layered with a
|
|
877
|
-
// runtime check in agent.ts as defense-in-depth.
|
|
878
|
-
const { enabled: childEnabled, disabled: childDisabled } = resolveChildToolScope(req.toolAllowlist, this.config.disabledBuiltinTools, this.config.enabledBuiltinTools);
|
|
879
|
-
const childLlm = resolveChildLlm(req.model, this.modelPool, this.config.llm);
|
|
880
|
-
const child = new Engine({
|
|
881
|
-
llm: childLlm,
|
|
882
|
-
// Inherit parent's runtime knobs (temperature, image detail, timeouts)
|
|
883
|
-
// but cap sub-agent retries at 2 — they're short-lived and we'd
|
|
884
|
-
// rather surface failures than burn a 9 s exponential backoff loop.
|
|
885
|
-
clientDefaults: { ...(this.config.clientDefaults ?? {}), retryMaxAttempts: 2 },
|
|
886
|
-
cwd,
|
|
887
|
-
permissionMode: this.config.permissionMode,
|
|
888
|
-
preset: this.preset.name,
|
|
889
|
-
enabledBuiltinTools: childEnabled,
|
|
890
|
-
disabledBuiltinTools: childDisabled,
|
|
891
|
-
builtinToolHost: this.config.builtinToolHost,
|
|
892
|
-
customSystemPrompt: this.config.customSystemPrompt,
|
|
893
|
-
appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt].filter(Boolean).join("\n\n") ||
|
|
894
|
-
undefined,
|
|
895
|
-
responseLanguage: this.config.responseLanguage,
|
|
896
|
-
userProfile: this.config.userProfile,
|
|
897
|
-
instructions: this.config.instructions,
|
|
898
|
-
maxTurns: req.maxTurns,
|
|
899
|
-
maxContextTokens: this.config.maxContextTokens ?? 200_000,
|
|
900
|
-
sessionStorageDir: this.config.sessionStorageDir,
|
|
901
|
-
headless: this.config.headless,
|
|
902
|
-
readOnlySession: req.readOnlySession,
|
|
903
|
-
skillAllowlist: req.skillAllowlist,
|
|
904
|
-
sandbox: this.config.sandbox,
|
|
905
|
-
// Subagents inherit the parent's scope: a child runs in the same
|
|
906
|
-
// cwd/session, so it should see the same config layers the parent did.
|
|
907
|
-
settingsScope: this.config.settingsScope ?? "project",
|
|
908
|
-
isSubAgent: true,
|
|
909
|
-
});
|
|
910
|
-
// Where the spawned child Engine's stream events go. AgentTool's
|
|
911
|
-
// background path passes a `streamOverride` (transcriptSink) so the
|
|
912
|
-
// per-event detail is captured into the agent's transcript instead
|
|
913
|
-
// of flooding the main feed. Sync calls leave streamOverride unset
|
|
914
|
-
// and we fall back to the parent UI's onStream so synchronous
|
|
915
|
-
// sub-agents still render inline.
|
|
916
|
-
const destStream = req.streamOverride ?? options?.onStream;
|
|
917
|
-
const childStream = destStream
|
|
918
|
-
? (event) => {
|
|
919
|
-
// Filter ctx-bar signals: the bar tracks the main conversation's
|
|
920
|
-
// prompt size, and a sub-agent's own session emits would clobber
|
|
921
|
-
// it (its sid is new, its messages are tiny, the rough char/4
|
|
922
|
-
// seed lands the bar at <1% mid-turn). Sub-agent token accounting
|
|
923
|
-
// lives in CostTracker (recordUsage), not the ctx bar.
|
|
924
|
-
//
|
|
925
|
-
// - session_started: would seed main ctx with sub-agent's prompt
|
|
926
|
-
// - usage_update: would overwrite main ctx with sub-agent's prompt
|
|
927
|
-
// - context_compact: would reset main ctx to sub-agent's post-compact
|
|
928
|
-
// value AND print a misleading "context compacted" boundary in
|
|
929
|
-
// the main chat (the main session didn't compact).
|
|
930
|
-
if (event.type === "usage_update" ||
|
|
931
|
-
event.type === "session_started" ||
|
|
932
|
-
event.type === "context_compact") {
|
|
933
|
-
return;
|
|
934
|
-
}
|
|
935
|
-
destStream({ ...event, agentId: req.agentId });
|
|
936
|
-
}
|
|
937
|
-
: undefined;
|
|
938
|
-
// child.run() establishes its own runWithSid scope internally, so
|
|
939
|
-
// child log lines route to the child's sid and parent's ALS
|
|
940
|
-
// binding is unaffected when control returns here.
|
|
941
|
-
//
|
|
942
|
-
// agent_id === childSid: cold-start the child UNDER its agentId as the
|
|
943
|
-
// session id (run() shape (2): a fresh sid the host wants materialized),
|
|
944
|
-
// so the session persists at sessions/<agentId>/ and AgentSendInput can
|
|
945
|
-
// later resume it by agentId with no extra id→sid mapping. When
|
|
946
|
-
// resumeSessionId is set we resume that existing session instead —
|
|
947
|
-
// run() detects the on-disk session and replays its full transcript
|
|
948
|
-
// (the CC continuation model; see AgentSendInput).
|
|
949
|
-
const childSessionId = req.resumeSessionId ?? req.agentId;
|
|
950
|
-
const result = await child.run(req.prompt, {
|
|
951
|
-
signal: req.signal,
|
|
952
|
-
onStream: childStream,
|
|
953
|
-
sessionId: childSessionId,
|
|
954
|
-
});
|
|
955
|
-
return { text: result.text, sessionId: result.sessionId };
|
|
956
|
-
},
|
|
957
|
-
sessionExists: (sessionId) => this.sessionManager.exists(sessionId),
|
|
958
|
-
};
|
|
959
|
-
const sandboxConfig = this.resolveSandboxConfigForCwd(cwd);
|
|
960
|
-
// A2: explicit sandbox modes (seatbelt, bwrap) must fail closed
|
|
961
|
-
// per standard §S4. resolveSandboxBackend throws when an explicit
|
|
962
|
-
// mode is unavailable on this host; we let it propagate. The
|
|
963
|
-
// previous behavior — catching the throw inside the hot turn and
|
|
964
|
-
// silently downgrading to "off" — was the leak A2 closes. The
|
|
965
|
-
// `auto` mode handles its own downgrade with a one-time warning
|
|
966
|
-
// inside resolveSandboxBackend; explicit modes do not.
|
|
967
|
-
//
|
|
968
|
-
// Backend is cached per runtime/engine so the capability probe runs once
|
|
969
|
-
// per (mode, cwd) instead of every turn.
|
|
970
|
-
const sandboxBackend = this.runtime
|
|
971
|
-
? await this.runtime.resolveSandbox(sandboxConfig, cwd)
|
|
972
|
-
: await this.resolveSandboxWithoutRuntime(sandboxConfig, cwd);
|
|
973
|
-
// Observability: surface what sandbox actually applied this run — the
|
|
974
|
-
// configured mode vs the resolved backend (auto may downgrade to off when
|
|
975
|
-
// no OS backend is available) + the network policy. Without this you can't
|
|
976
|
-
// tell whether shell commands were isolated /网络放没放. One line per run.
|
|
977
|
-
logger.info("sandbox.resolved", {
|
|
978
|
-
mode: sandboxConfig.mode,
|
|
979
|
-
backend: sandboxBackend.name,
|
|
980
|
-
isolated: sandboxBackend.name !== "off",
|
|
981
|
-
network: sandboxConfig.network,
|
|
982
|
-
cwd,
|
|
983
|
-
});
|
|
984
|
-
// sessionId is filled in after the session bundle is resolved below
|
|
985
|
-
// (the session may be cold-started or resumed). Until then this is
|
|
986
|
-
// intentionally shaped as a mutable local; we treat it as immutable
|
|
987
|
-
// after the assignment.
|
|
988
|
-
const toolCtx = {
|
|
989
|
-
...this.buildToolContext(),
|
|
990
|
-
subAgentSpawner,
|
|
991
|
-
agentDefinitions: this.getAgentDefinitions(cwd),
|
|
992
|
-
// Stamp the resolved network policy onto the backend the tools see so
|
|
993
|
-
// Bash can surface "网络 deny" on its result. Shallow-copy (don't mutate
|
|
994
|
-
// the cached backend) — `wrap`/`hintForBlockedOutput` are plain function
|
|
995
|
-
// properties and survive the spread. Off keeps network undefined.
|
|
996
|
-
sandbox: sandboxBackend.name === "off"
|
|
997
|
-
? sandboxBackend
|
|
998
|
-
: { ...sandboxBackend, network: sandboxConfig.network },
|
|
1036
|
+
const toolCtx = await this.wireRunSandboxToolContext({
|
|
1037
|
+
options,
|
|
999
1038
|
cwd,
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1039
|
+
runPermissionMode,
|
|
1040
|
+
runPlanMode,
|
|
1041
|
+
profile,
|
|
1042
|
+
profileParams,
|
|
1043
|
+
sessionProfileOverrides,
|
|
1044
|
+
profileMemoryDir,
|
|
1045
|
+
getSession: () => session,
|
|
1046
|
+
reportResult: (key, value) => {
|
|
1047
|
+
(profileReportedResults ??= {})[key] = value;
|
|
1009
1048
|
},
|
|
1010
|
-
};
|
|
1049
|
+
});
|
|
1011
1050
|
logger.info("engine.run", {
|
|
1012
1051
|
task: taskText.slice(0, 200),
|
|
1013
1052
|
cwd,
|
|
@@ -1029,459 +1068,83 @@ export class Engine {
|
|
|
1029
1068
|
// surfacing as `[-32603] Session not found: <sid>` on the very first
|
|
1030
1069
|
// TUI turn. Detection now uses `sessionManager.exists()` (one stat
|
|
1031
1070
|
// call) instead of a try/catch on resume.
|
|
1071
|
+
// wrappedOnStream (defined before the session opens, executed only after)
|
|
1072
|
+
// closes over `session`, so keep the declaration here and assign from the
|
|
1073
|
+
// opener's result.
|
|
1032
1074
|
let session;
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
const patched = patchOrphanedToolUses(messages);
|
|
1063
|
-
if (patched.gapsPatched > 0) {
|
|
1064
|
-
logger.warn("engine.resume.patched_orphaned_tool_uses", {
|
|
1065
|
-
sessionId: options.sessionId,
|
|
1066
|
-
gaps: patched.gapsPatched,
|
|
1067
|
-
toolResults: patched.toolResultsInjected,
|
|
1068
|
-
});
|
|
1069
|
-
}
|
|
1070
|
-
// Restore cost state from previous session, if the caller injected a store
|
|
1071
|
-
if (session.state.costState && this.config.costStore) {
|
|
1072
|
-
this.config.costStore.restore(session.state.costState);
|
|
1073
|
-
}
|
|
1074
|
-
// Append new user message
|
|
1075
|
-
const userMsg = { role: "user", content: userMessageContent };
|
|
1076
|
-
if (!claimClientMessageId(session, options?.clientMessageId, "submit")) {
|
|
1077
|
-
const usage = session.state.tokenUsage ?? {
|
|
1078
|
-
promptTokens: 0,
|
|
1079
|
-
completionTokens: 0,
|
|
1080
|
-
totalTokens: 0,
|
|
1081
|
-
};
|
|
1082
|
-
return {
|
|
1083
|
-
text: "",
|
|
1084
|
-
reason: "completed",
|
|
1085
|
-
sessionId: session.state.sessionId,
|
|
1086
|
-
turnCount: session.state.turnCount ?? 0,
|
|
1087
|
-
usage: {
|
|
1088
|
-
promptTokens: usage.promptTokens ?? 0,
|
|
1089
|
-
completionTokens: usage.completionTokens ?? 0,
|
|
1090
|
-
totalTokens: usage.totalTokens ?? 0,
|
|
1091
|
-
},
|
|
1092
|
-
};
|
|
1093
|
-
}
|
|
1094
|
-
if (parsedTask.hasImages)
|
|
1095
|
-
freshImageMessage = userMsg;
|
|
1096
|
-
messages.push(userMsg);
|
|
1097
|
-
session.transcript.appendMessage("user", userMessageContent, {
|
|
1098
|
-
injected: options?.injected === true,
|
|
1099
|
-
clientMessageId: options?.clientMessageId,
|
|
1100
|
-
});
|
|
1101
|
-
// Flush "active" status to disk immediately. resume() set it in memory
|
|
1102
|
-
// (session-manager.ts), but without this write the on-disk state.json
|
|
1103
|
-
// still shows the previous run's terminal reason — so any external
|
|
1104
|
-
// observer (another CLI process, /sid, the session list) would think
|
|
1105
|
-
// the session is still errored/aborted while we're actually running.
|
|
1106
|
-
this.sessionManager.saveState(session.state);
|
|
1107
|
-
}
|
|
1108
|
-
else {
|
|
1109
|
-
// Cold start: shape (2) reuses the host-supplied sid; shape (3)
|
|
1110
|
-
// lets sessionManager generate one with nanoid.
|
|
1111
|
-
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);
|
|
1112
|
-
const userMsg = { role: "user", content: userMessageContent };
|
|
1113
|
-
claimClientMessageId(session, options?.clientMessageId, "submit");
|
|
1114
|
-
if (parsedTask.hasImages)
|
|
1115
|
-
freshImageMessage = userMsg;
|
|
1116
|
-
messages = [userMsg];
|
|
1117
|
-
session.transcript.appendMessage("user", userMessageContent, {
|
|
1118
|
-
clientMessageId: options?.clientMessageId,
|
|
1119
|
-
});
|
|
1120
|
-
// Save first user message as session summary — text only. The summary
|
|
1121
|
-
// shows up in the session list; "[image]" is more informative than a
|
|
1122
|
-
// truncated `[object Object]` when the prompt was purely visual.
|
|
1123
|
-
const summarySrc = parsedTask.hasImages
|
|
1124
|
-
? parsedTask.text ||
|
|
1125
|
-
`[image${parsedTask.images.length > 1 ? `s × ${parsedTask.images.length}` : ""}]`
|
|
1126
|
-
: taskText;
|
|
1127
|
-
session.state.summary = summarySrc.slice(0, 80).replace(/\n/g, " ");
|
|
1128
|
-
this.sessionManager.saveState(session.state);
|
|
1129
|
-
}
|
|
1130
|
-
// Bump the conversation-turn counter: this user message starts a new turn.
|
|
1131
|
-
// One user message = one turn, regardless of how many turn-loop iterations
|
|
1132
|
-
// or tool calls it spans. File-history snapshots taken below are tagged
|
|
1133
|
-
// with this value so `/undo` reverts exactly this turn's file changes.
|
|
1134
|
-
// (Both resume and cold-start paths converge here.)
|
|
1135
|
-
session.state.turnSeq = (session.state.turnSeq ?? 0) + 1;
|
|
1136
|
-
// B2 / Gate 1: stamp the resolved sid onto the tool context so
|
|
1137
|
-
// session-scoped side effects (background-agent completion
|
|
1138
|
-
// notifications) attribute to the right session. toolCtx is created
|
|
1139
|
-
// before the session bundle is resolved (see ~line 635), so this is
|
|
1140
|
-
// the first point we can set it. After this assignment treat the
|
|
1141
|
-
// field follows the latest successfully injected user intent for the rest
|
|
1142
|
-
// of the run, so tools launched after a steer attribute their side effects
|
|
1143
|
-
// to that steer rather than this original submit.
|
|
1144
|
-
toolCtx.sessionId = session.state.sessionId;
|
|
1145
|
-
toolCtx.originClientMessageId = options?.clientMessageId;
|
|
1146
|
-
toolCtx.recordExternalFileChanges = (record) => {
|
|
1147
|
-
session.transcript.append("external_file_changes", { ...record });
|
|
1148
|
-
};
|
|
1149
|
-
toolCtx.setSessionWorkspace = (workspace) => {
|
|
1150
|
-
session.state.workspace = workspace;
|
|
1151
|
-
};
|
|
1152
|
-
return runWithSid(session.state.sessionId, async () => {
|
|
1153
|
-
recordSessionStart(session.state.sessionId, {
|
|
1154
|
-
// Strip <codeshell-image> base64 payloads before they reach
|
|
1155
|
-
// <repo>/log/. Reader still sees the marker + byte count, just
|
|
1156
|
-
// not the bytes. Transcript persistence keeps the full payload.
|
|
1157
|
-
task: sanitizeTaskString(task),
|
|
1158
|
-
cwd,
|
|
1159
|
-
model: this.config.llm.model,
|
|
1160
|
-
provider: this.config.llm.provider,
|
|
1161
|
-
permissionMode: this.config.permissionMode ?? "acceptEdits",
|
|
1162
|
-
resumed: resumedFromDisk,
|
|
1163
|
-
});
|
|
1164
|
-
// Session-level hook: fired once per Engine.run() entry, regardless of
|
|
1165
|
-
// cold-start vs resume. Handlers can return `messages` to inject a
|
|
1166
|
-
// <system-reminder> at the head of the conversation (between
|
|
1167
|
-
// userContext and the new user prompt). Used by the built-in
|
|
1168
|
-
// superpowers injector to surface the `using-superpowers` ruleset.
|
|
1169
|
-
const sessionStartHook = await this.emitHook("on_session_start", {
|
|
1170
|
-
sessionId: session.state.sessionId,
|
|
1075
|
+
const openedResult = openRunSession({
|
|
1076
|
+
sessionManager: this.sessionManager,
|
|
1077
|
+
options,
|
|
1078
|
+
parsedTask,
|
|
1079
|
+
taskText,
|
|
1080
|
+
userMessageContent,
|
|
1081
|
+
cwd,
|
|
1082
|
+
sessionKind,
|
|
1083
|
+
sessionWorkspaceProfile,
|
|
1084
|
+
...(options?.sessionBrief ? { sessionBrief: options.sessionBrief } : {}),
|
|
1085
|
+
llmModel: this.config.llm.model,
|
|
1086
|
+
llmProvider: this.config.llm.provider,
|
|
1087
|
+
isSubAgent: this.config.isSubAgent === true,
|
|
1088
|
+
origin: this.config.origin,
|
|
1089
|
+
costStore: this.config.costStore,
|
|
1090
|
+
onAgentDirectionsDelivered: (ids) => this.agentDirectionsDeliveredListener?.(ids),
|
|
1091
|
+
cachedCompactedMessages: options?.sessionId
|
|
1092
|
+
? this.compactedMessagesBySession.get(options.sessionId)
|
|
1093
|
+
: undefined,
|
|
1094
|
+
});
|
|
1095
|
+
if (!openedResult.ok)
|
|
1096
|
+
return openedResult.result;
|
|
1097
|
+
const { messages, freshImageMessage, resumedFromDisk, claimClientMessageId, releaseClientMessageId, } = openedResult.opened;
|
|
1098
|
+
session = openedResult.opened.session;
|
|
1099
|
+
this.stampRunToolContext(toolCtx, session, options);
|
|
1100
|
+
const sessionRun = runWithSid(session.state.sessionId, async () => {
|
|
1101
|
+
const hookMessages = await this.runSessionStartHooks({
|
|
1102
|
+
session,
|
|
1103
|
+
task,
|
|
1171
1104
|
cwd,
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
// reminders that should accompany each user turn (e.g. "skills
|
|
1178
|
-
// available — check before acting").
|
|
1179
|
-
const promptSubmitHook = await this.emitHook("user_prompt_submit", {
|
|
1180
|
-
sessionId: session.state.sessionId,
|
|
1181
|
-
// Pass the text-only portion. Handlers reading the prompt for keyword
|
|
1182
|
-
// detection / classification (e.g. superpowers' "did the user ask
|
|
1183
|
-
// about X?") don't gain anything from megabytes of base64 inlined here,
|
|
1184
|
-
// and silently leaking attachment bytes through hooks is the kind of
|
|
1185
|
-
// exfiltration risk a curious user-installed shell hook shouldn't carry.
|
|
1186
|
-
prompt: taskText,
|
|
1187
|
-
resumed: resumedFromDisk,
|
|
1188
|
-
});
|
|
1189
|
-
// updatedPrompt: handler rewrote the user's prompt text. Replace the
|
|
1190
|
-
// last user message we just pushed (cold-start: line ~511; resume:
|
|
1191
|
-
// line ~500). Original prompt is in the transcript already — we log
|
|
1192
|
-
// the rewrite so audit chains know a hook touched user input.
|
|
1193
|
-
if (typeof promptSubmitHook.updatedPrompt === "string") {
|
|
1194
|
-
const lastIdx = messages.length - 1;
|
|
1195
|
-
const last = messages[lastIdx];
|
|
1196
|
-
if (last && last.role === "user" && typeof last.content === "string") {
|
|
1197
|
-
logger.info("hook.updated_prompt", {
|
|
1198
|
-
sessionId: session.state.sessionId,
|
|
1199
|
-
originalChars: last.content.length,
|
|
1200
|
-
updatedChars: promptSubmitHook.updatedPrompt.length,
|
|
1201
|
-
});
|
|
1202
|
-
messages[lastIdx] = { role: "user", content: promptSubmitHook.updatedPrompt };
|
|
1203
|
-
}
|
|
1204
|
-
}
|
|
1205
|
-
const contextManager = new ContextManager({
|
|
1206
|
-
maxTokens: this.resolveMaxContextTokens(),
|
|
1207
|
-
// Drop undefined fields so they don't clobber ContextManager defaults
|
|
1208
|
-
// (spread of `{x: undefined}` would override the default with undefined).
|
|
1209
|
-
...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
|
|
1105
|
+
runPermissionMode,
|
|
1106
|
+
resumedFromDisk,
|
|
1107
|
+
options,
|
|
1108
|
+
taskText,
|
|
1109
|
+
messages,
|
|
1210
1110
|
});
|
|
1211
|
-
this.lastContextManager = contextManager;
|
|
1212
|
-
const persistedContextAnchor = session.state.contextUsageAnchor;
|
|
1213
|
-
const contextAnchorCompatible = persistedContextAnchor !== undefined &&
|
|
1214
|
-
(persistedContextAnchor.provider === undefined ||
|
|
1215
|
-
persistedContextAnchor.provider === this.config.llm.provider) &&
|
|
1216
|
-
(persistedContextAnchor.model === undefined ||
|
|
1217
|
-
persistedContextAnchor.model === this.config.llm.model) &&
|
|
1218
|
-
(persistedContextAnchor.messageCount <= messages.length ||
|
|
1219
|
-
persistedContextAnchor.estimateAtAnchor !== undefined);
|
|
1220
|
-
if (contextAnchorCompatible) {
|
|
1221
|
-
contextManager.seedActualUsage(persistedContextAnchor);
|
|
1222
|
-
}
|
|
1223
|
-
// Best-effort token estimate of the full prompt so the UI's ctx bar isn't
|
|
1224
|
-
// 0% before the first real usage_update arrives. The authoritative count
|
|
1225
|
-
// comes from `usage.promptTokens` after the first LLM response — this is
|
|
1226
|
-
// just a display-friendly approximation for the first frame, annotated
|
|
1227
|
-
// with source/confidence so consumers don't treat heuristics as truth.
|
|
1228
|
-
//
|
|
1229
|
-
// Only seed once per (process, sid). On subsequent turns the UI already
|
|
1230
|
-
// shows the previous turn's accurate ctx; overwriting it with a fresh
|
|
1231
|
-
// best-effort estimate would make the bar visibly drop on every submit.
|
|
1232
1111
|
const sid = session.state.sessionId;
|
|
1233
|
-
const
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
return {
|
|
1238
|
-
tokens: checked.tokens,
|
|
1239
|
-
source: checked.promptTokensSource,
|
|
1240
|
-
confidence: checked.promptTokensConfidence,
|
|
1241
|
-
};
|
|
1242
|
-
})()
|
|
1243
|
-
: {
|
|
1244
|
-
tokens: 0,
|
|
1245
|
-
source: "heuristic_estimate",
|
|
1246
|
-
confidence: "low",
|
|
1247
|
-
};
|
|
1248
|
-
if (needsCtxSeed)
|
|
1249
|
-
this.ctxSeedSent.add(sid);
|
|
1250
|
-
// Tell the client the sid *now* instead of waiting for run() to resolve.
|
|
1251
|
-
// The user wants `/sid` to work mid-turn; without this, the client only
|
|
1252
|
-
// learns the sid when the run completes.
|
|
1253
|
-
options?.onStream?.({
|
|
1254
|
-
type: "session_started",
|
|
1255
|
-
sessionId: sid,
|
|
1256
|
-
promptTokens: ctxSeed.tokens,
|
|
1257
|
-
promptTokensSource: ctxSeed.source,
|
|
1258
|
-
promptTokensConfidence: ctxSeed.confidence,
|
|
1259
|
-
});
|
|
1260
|
-
// Replay the last TodoWrite snapshot on resume so the UI's pinned
|
|
1261
|
-
// task panel re-hydrates without the LLM needing to call TodoWrite
|
|
1262
|
-
// again. Scans the resumed transcript newest-first (and tolerates
|
|
1263
|
-
// legacy TaskCreate/Update events for sessions recorded against
|
|
1264
|
-
// the pre-2026-05-24 API). New sessions have no transcript yet so
|
|
1265
|
-
// readLastTodoSnapshot returns null and nothing is emitted.
|
|
1266
|
-
if (options?.sessionId) {
|
|
1267
|
-
const snap = readLastTodoSnapshot(session.transcript.getEvents());
|
|
1268
|
-
if (snap && snap.length > 0) {
|
|
1269
|
-
latestTodos = snap;
|
|
1270
|
-
options?.onStream?.({ type: "task_update", tasks: snap });
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1273
|
-
// Kick off LLM client creation early (network handshake)
|
|
1274
|
-
const llmClientPromise = createLLMClient(this.config.llm, this.config.clientDefaults);
|
|
1275
|
-
const mode = this.config.permissionMode ?? "acceptEdits";
|
|
1276
|
-
const { rules: defaultRules, backend: approvalBackend } = this.buildPermissionConfig(mode, cwd);
|
|
1277
|
-
const permission = new PermissionClassifier(defaultRules, mode, approvalBackend);
|
|
1278
|
-
this.activePermission = permission;
|
|
1279
|
-
// If the backend is the interactive one, wire it for project-scope
|
|
1280
|
-
// persistence: it needs cwd to find settings.local.json, and a callback
|
|
1281
|
-
// to apply newly-saved rules to the live classifier so subsequent calls
|
|
1282
|
-
// in this same session don't re-prompt. Headless/auto backends skip
|
|
1283
|
-
// this — they don't prompt, so there are no project rules to persist.
|
|
1284
|
-
if (approvalBackend instanceof InteractiveApprovalBackend) {
|
|
1285
|
-
approvalBackend.setSessionContext(session.state.sessionId, {
|
|
1286
|
-
cwd,
|
|
1287
|
-
onProjectRules: (rules) => {
|
|
1288
|
-
// Prepend the *full* accumulated list of session-saved project rules
|
|
1289
|
-
// so user approvals win over defaults and earlier approvals aren't
|
|
1290
|
-
// dropped when later ones come in.
|
|
1291
|
-
permission.reconfigure(mode, approvalBackend, [...rules, ...defaultRules]);
|
|
1292
|
-
},
|
|
1293
|
-
});
|
|
1294
|
-
}
|
|
1295
|
-
const toolExecutor = new ToolExecutor(this.toolRegistry, permission, this.hooks);
|
|
1296
|
-
const investigationGuard = new InvestigationGuard();
|
|
1297
|
-
if (this.config.readOnlySession) {
|
|
1298
|
-
investigationGuard.setPolicy("read-only-review");
|
|
1299
|
-
}
|
|
1300
|
-
else if (this.config.headless) {
|
|
1301
|
-
investigationGuard.setSoftMode(true);
|
|
1302
|
-
}
|
|
1303
|
-
toolExecutor.setInvestigationGuard(investigationGuard);
|
|
1304
|
-
toolExecutor.setTaskGuard(new TaskGuard(() => latestTodos));
|
|
1305
|
-
// Wire abort signal for cascading cancellation + per-Engine ToolContext
|
|
1306
|
-
toolExecutor.setSignal(options?.signal);
|
|
1307
|
-
toolExecutor.setContext(toolCtx);
|
|
1308
|
-
const { disabledSkills, disabledPlugins } = this.readDisabledLists();
|
|
1309
|
-
const promptComposer = new PromptComposer({
|
|
1112
|
+
const { contextManager, llmClientPromise, toolExecutor } = this.wireRunContextAndPermission({
|
|
1113
|
+
session,
|
|
1114
|
+
sid,
|
|
1115
|
+
options,
|
|
1310
1116
|
cwd,
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
instructionOptions: { compatFileNames: compatFileNamesFrom(this.config.instructions) },
|
|
1318
|
-
disabledSkills,
|
|
1319
|
-
disabledPlugins,
|
|
1320
|
-
skillAllowlist: this.config.skillAllowlist,
|
|
1321
|
-
memoriesMaxAgeDays: this.readMemoriesConfig()?.maxAge,
|
|
1322
|
-
goalToolState: {
|
|
1323
|
-
hasGoal: this.config.isSubAgent !== true &&
|
|
1324
|
-
(normalizeGoal(options?.goal) !== undefined ||
|
|
1325
|
-
session.state.activeGoal !== undefined ||
|
|
1326
|
-
normalizeGoal(this.config.goal) !== undefined),
|
|
1117
|
+
toolCtx,
|
|
1118
|
+
runPermissionMode,
|
|
1119
|
+
messages,
|
|
1120
|
+
getLatestTodos: () => latestTodos,
|
|
1121
|
+
setLatestTodos: (todos) => {
|
|
1122
|
+
latestTodos = todos;
|
|
1327
1123
|
},
|
|
1328
1124
|
});
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
// 3. buildSystemContext — reads environment context
|
|
1348
|
-
// Inject the live available-agent-types listing into the Agent tool's
|
|
1349
|
-
// description. The registry is per-engine (loaded from .code-shell/agents
|
|
1350
|
-
// for this cwd), so it can't live in the static tool def — without this
|
|
1351
|
-
// the model never learns the reusable roles exist and spawns nameless
|
|
1352
|
-
// ad-hoc agents instead (the Core A/B/C incident).
|
|
1353
|
-
// The Agent tool is always available: with configured roles, an omitted
|
|
1354
|
-
// agent_type falls back to one of them (see resolveAgentTypeOverrides); with
|
|
1355
|
-
// no roles configured it runs a true ephemeral agent, so workflows that need
|
|
1356
|
-
// sub-agents (e.g. superpowers) work in any project.
|
|
1357
|
-
// Availability guard (tool-visibility): a gated builtin (WebSearch needs a
|
|
1358
|
-
// search provider, GenerateImage needs an OpenAI provider) is hidden from
|
|
1359
|
-
// the toolDefs the model sees when its credential isn't configured for this
|
|
1360
|
-
// cwd. Recomputed every message, so configuring a key takes effect on the
|
|
1361
|
-
// NEXT message without a restart. Tools with no guard entry are always kept.
|
|
1362
|
-
const guardCwd = toolCtx.cwd;
|
|
1363
|
-
const toolVisibility = {
|
|
1364
|
-
cwd: guardCwd,
|
|
1365
|
-
hasGoal: this.config.isSubAgent !== true &&
|
|
1366
|
-
(normalizeGoal(options?.goal) !== undefined ||
|
|
1367
|
-
session.state.activeGoal !== undefined ||
|
|
1368
|
-
normalizeGoal(this.config.goal) !== undefined),
|
|
1369
|
-
settingsScope: this.config.settingsScope ?? "project",
|
|
1370
|
-
};
|
|
1371
|
-
toolCtx.toolVisibility = toolVisibility;
|
|
1372
|
-
// #7: per-turn project builtin override. The toolRegistry's builtin tool
|
|
1373
|
-
// SET is ctor-frozen (and may be shared via runtime), so a mid-session
|
|
1374
|
-
// project override of a builtin can't rebuild the registry. But the tool
|
|
1375
|
-
// LIST handed to the LLM is assembled fresh every turn, so we apply the
|
|
1376
|
-
// override here: a builtin marked `off` for this cwd is HIDDEN from the
|
|
1377
|
-
// turn's tool list (matching how skills/plugins/agents `off` apply
|
|
1378
|
-
// mid-session via readDisabledLists). `on`/`inherit` keep whatever the
|
|
1379
|
-
// registry already has — we can't re-add a tool the frozen registry omits,
|
|
1380
|
-
// but `on` for a tool already present is a no-op (it stays). This makes a
|
|
1381
|
-
// builtin toggle take effect on the NEXT message, like other capability
|
|
1382
|
-
// kinds, without touching the registry.
|
|
1383
|
-
const builtinOverride = this.readBuiltinOverride(guardCwd);
|
|
1384
|
-
// Turn `off` from a prompt-visibility filter into a real execution gate:
|
|
1385
|
-
// collect the builtin tool names the override marks `off` and hand them to
|
|
1386
|
-
// the executor (via the shared toolCtx the executor already holds a
|
|
1387
|
-
// reference to, set at setContext above) so it rejects a call to a hidden
|
|
1388
|
-
// builtin instead of running it from the still-populated registry.
|
|
1389
|
-
if (builtinOverride) {
|
|
1390
|
-
const registryNames = new Set(this.toolRegistry.getToolDefinitions().map((t) => t.name));
|
|
1391
|
-
const disabledBuiltins = new Set(Object.keys(builtinOverride).filter((name) => builtinOverride[name] === "off" && registryNames.has(name)));
|
|
1392
|
-
toolCtx.disabledBuiltins = disabledBuiltins;
|
|
1393
|
-
}
|
|
1394
|
-
// MCP tool exposure is per-SESSION even though the pool/registry are
|
|
1395
|
-
// worker-shared (B1): a server connected by another project's session
|
|
1396
|
-
// registers its tools into the SHARED registry, and without this filter
|
|
1397
|
-
// they leaked into every session (e.g. chrome-devtools tools showing up
|
|
1398
|
-
// in a project that never enabled the plugin). Keep an MCP tool only when
|
|
1399
|
-
// its server is in THIS session's merged config.mcpServers — which
|
|
1400
|
-
// already folds the project's capabilityOverrides. Gated on the config
|
|
1401
|
-
// being present: engines without one (sub-agents, bare tests) have no
|
|
1402
|
-
// MCP tools in their private registries anyway.
|
|
1403
|
-
const allowedMcpServers = new Set(Object.entries(this.config.mcpServers ?? {})
|
|
1404
|
-
.filter(([, c]) => c.enabled !== false)
|
|
1405
|
-
.map(([n]) => n));
|
|
1406
|
-
toolCtx.allowedMcpServers = allowedMcpServers;
|
|
1407
|
-
const mcpVisible = (toolName) => {
|
|
1408
|
-
const reg = this.toolRegistry.getTool(toolName);
|
|
1409
|
-
return reg?.source !== "mcp" || allowedMcpServers.has(reg?.serverName ?? "");
|
|
1410
|
-
};
|
|
1411
|
-
// Feature-flag visibility: a builtin mapped in TOOL_FEATURE_FLAGS is
|
|
1412
|
-
// hidden when its flag resolves to false (default-on flags only hide when
|
|
1413
|
-
// explicitly disabled, so zero regression out of the box). Read once per
|
|
1414
|
-
// turn so flipping a flag in settings takes effect on the NEXT message,
|
|
1415
|
-
// like the other capability kinds.
|
|
1416
|
-
const featureFlags = this.readFeatureFlags();
|
|
1417
|
-
const allToolDefs = applyBuiltinOverrideVisibility(this.toolRegistry.getToolDefinitions(), builtinOverride)
|
|
1418
|
-
.filter((t) => mcpVisible(t.name))
|
|
1419
|
-
.filter((t) => {
|
|
1420
|
-
const guard = BUILTIN_TOOL_GUARDS.get(t.name);
|
|
1421
|
-
return guard ? guard(toolVisibility) : true;
|
|
1422
|
-
})
|
|
1423
|
-
.filter((t) => {
|
|
1424
|
-
const flag = TOOL_FEATURE_FLAGS.get(t.name);
|
|
1425
|
-
return flag ? isFeatureEnabled(featureFlags, flag) : true;
|
|
1426
|
-
})
|
|
1427
|
-
// Dynamic per-engine bits the static defs can't carry: the Agent tool's
|
|
1428
|
-
// agent_type enum + listing, and the image/video provider names. See
|
|
1429
|
-
// applyDynamicToolDef — forwarding only the Agent description (dropping
|
|
1430
|
-
// its rebuilt inputSchema) used to strip the agent_type enum, so the
|
|
1431
|
-
// model omitted agent_type and configured roles never applied.
|
|
1432
|
-
.map((t) => applyDynamicToolDef(t, toolCtx.agentDefinitions, guardCwd));
|
|
1433
|
-
// In plan mode, only expose read-only/planning tools so the model won't
|
|
1434
|
-
// attempt writes. Shared with executor.ts's execution gate via
|
|
1435
|
-
// PLAN_MODE_ALLOWED_TOOLS so what the model SEES and what the executor
|
|
1436
|
-
// RUNS can't drift apart. (Bash is in the set; the executor additionally
|
|
1437
|
-
// gates Bash to read-only commands at call time.)
|
|
1438
|
-
const toolDefs = this.planMode
|
|
1439
|
-
? allToolDefs.filter((t) => PLAN_MODE_ALLOWED_TOOLS.has(t.name))
|
|
1440
|
-
: allToolDefs;
|
|
1441
|
-
const [llmClient, fullSystemPrompt, dynamicContextMsg] = await Promise.all([
|
|
1125
|
+
const { promptComposer, toolDefs } = await this.wireRunTooling({
|
|
1126
|
+
options,
|
|
1127
|
+
session,
|
|
1128
|
+
cwd,
|
|
1129
|
+
toolCtx,
|
|
1130
|
+
profile,
|
|
1131
|
+
profileParams,
|
|
1132
|
+
runWorkspaceProfile,
|
|
1133
|
+
profileMemoryDir,
|
|
1134
|
+
sessionProfileOverrides,
|
|
1135
|
+
runPlanMode,
|
|
1136
|
+
});
|
|
1137
|
+
const { llmClient, fullSystemPrompt, dynamicContextMsg, userContextMsg } = await this.assembleRunPrompts({
|
|
1138
|
+
session,
|
|
1139
|
+
messages,
|
|
1140
|
+
hookMessages,
|
|
1141
|
+
promptComposer,
|
|
1142
|
+
toolDefs,
|
|
1442
1143
|
llmClientPromise,
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
]);
|
|
1448
|
-
// Prepend userContext (CLAUDE.md) as first message (sync, fast)
|
|
1449
|
-
const userContextMsg = promptComposer.buildUserContextMessage();
|
|
1450
|
-
if (userContextMsg) {
|
|
1451
|
-
messages.unshift(userContextMsg);
|
|
1452
|
-
}
|
|
1453
|
-
// Inject hook-supplied reminders just before the most recent user task.
|
|
1454
|
-
// Combined into one <system-reminder> block so a noisy handler chain
|
|
1455
|
-
// doesn't turn into 3+ separate user turns in the API request.
|
|
1456
|
-
const lifecycleReminder = wrapHookMessages([
|
|
1457
|
-
...(sessionStartHook.messages ?? []),
|
|
1458
|
-
...(promptSubmitHook.messages ?? []),
|
|
1459
|
-
]);
|
|
1460
|
-
if (lifecycleReminder) {
|
|
1461
|
-
// messages[length - 1] is the user task we just pushed above. Insert
|
|
1462
|
-
// the reminder immediately before it so the model reads: CLAUDE.md →
|
|
1463
|
-
// reminder → user request.
|
|
1464
|
-
messages.splice(messages.length - 1, 0, lifecycleReminder);
|
|
1465
|
-
}
|
|
1466
|
-
// Volatile context (skills + git status) goes at the very END — after the
|
|
1467
|
-
// user task — so it sits past the conversation's cache breakpoint. A change
|
|
1468
|
-
// here (new skill, edited file) never invalidates the cached history prefix.
|
|
1469
|
-
if (dynamicContextMsg) {
|
|
1470
|
-
messages.push(dynamicContextMsg);
|
|
1471
|
-
}
|
|
1472
|
-
this.lastSessionId = session.state.sessionId;
|
|
1473
|
-
this.lastMessages = messages;
|
|
1474
|
-
// Wire up LLM summarization for context compaction
|
|
1475
|
-
// Uses a lightweight call without tools
|
|
1476
|
-
contextManager.setTranscriptPath(session.transcript.getFilePath());
|
|
1477
|
-
// Re-derive frozen persistence decisions from the messages we just
|
|
1478
|
-
// loaded. Skipped on cold start (messages == [userContextMsg] only).
|
|
1479
|
-
// Critical for resume — otherwise a result that was persisted last
|
|
1480
|
-
// run would be evaluated fresh and might get a different replacement
|
|
1481
|
-
// string than the one already in the message, breaking idempotency.
|
|
1482
|
-
contextManager.initReplacementStateFromMessages(messages);
|
|
1483
|
-
// Two summarizers with DIFFERENT quality needs:
|
|
1484
|
-
//
|
|
1144
|
+
contextManager,
|
|
1145
|
+
profile,
|
|
1146
|
+
profileParams,
|
|
1147
|
+
});
|
|
1485
1148
|
// 1. Context-compaction summary (setSummarizeFn) → PRIMARY model. This
|
|
1486
1149
|
// condenses many rounds into the running summary that REPLACES the real
|
|
1487
1150
|
// history; a dropped decision makes the conversation "forget" and poisons
|
|
@@ -1494,719 +1157,1064 @@ export class Engine {
|
|
|
1494
1157
|
// These are tiny throwaway outputs ("Wrote design doc") fired every turn;
|
|
1495
1158
|
// that high-frequency, low-stakes chore is exactly what aux is for.
|
|
1496
1159
|
const auxSummaryClient = await this.resolveAuxClient(llmClient);
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
const modelFacade = new ModelFacade(llmClient, session.transcript);
|
|
1506
|
-
// Session-cumulative usage baseline: the LLM client is recreated per run
|
|
1507
|
-
// (its getUsage() counts only THIS run), so to accumulate across runs we
|
|
1508
|
-
// capture the persisted total at run start and fold this run's usage onto
|
|
1509
|
-
// it (see foldRunUsage). Snapshot now, before any turn boundary fires.
|
|
1510
|
-
const usageBaseline = { ...session.state.tokenUsage };
|
|
1511
|
-
// Wire getOutputTokens for token budget tracking
|
|
1512
|
-
modelFacade.getOutputTokens = () => {
|
|
1513
|
-
const usage = llmClient.getUsage();
|
|
1514
|
-
return usage.totalCompletionTokens;
|
|
1515
|
-
};
|
|
1516
|
-
// Wire summarize for tool use summaries (uses lightweight call).
|
|
1517
|
-
// recordUsage=false keeps these auxiliary sub-calls out of the main usage
|
|
1518
|
-
// tracker so session_end.cost reflects only the user-facing turns and
|
|
1519
|
-
// turns/requestCount stay aligned.
|
|
1520
|
-
modelFacade.summarize = async (sysPrompt, userMsg) => {
|
|
1521
|
-
const resp = await auxSummaryClient.createMessage({
|
|
1522
|
-
systemPrompt: sysPrompt,
|
|
1523
|
-
messages: [{ role: "user", content: userMsg }],
|
|
1524
|
-
tools: [],
|
|
1525
|
-
maxTokens: 256,
|
|
1526
|
-
recordUsage: false,
|
|
1527
|
-
// Auxiliary call — see contextManager.setSummarizeFn above.
|
|
1528
|
-
reasoning: { mode: "off" },
|
|
1529
|
-
});
|
|
1530
|
-
logger.debug("summarize.call", {
|
|
1531
|
-
sysPromptLen: sysPrompt.length,
|
|
1532
|
-
userMsgLen: userMsg.length,
|
|
1533
|
-
userMsgPreview: userMsg.slice(0, 300),
|
|
1534
|
-
completionLen: resp.text.length,
|
|
1535
|
-
completionPreview: resp.text.slice(0, 300),
|
|
1536
|
-
stopReason: resp.stopReason,
|
|
1537
|
-
promptTokens: resp.usage?.promptTokens,
|
|
1538
|
-
completionTokens: resp.usage?.completionTokens,
|
|
1539
|
-
});
|
|
1540
|
-
return resp.text;
|
|
1541
|
-
};
|
|
1542
|
-
// File history: auto-backup before Write/Edit
|
|
1543
|
-
const sessionDir = join(this.config.sessionStorageDir ?? join(userHome(), ".code-shell", "sessions"), session.state.sessionId);
|
|
1544
|
-
const fileHistory = FileHistory.loadFromDir(sessionDir);
|
|
1545
|
-
// Keep a reference so we can unregister in the finally below. Registering an
|
|
1546
|
-
// anonymous handler every run() leaks: unregister matches by handler
|
|
1547
|
-
// identity, so without a stored reference each run stacks another identical
|
|
1548
|
-
// on_tool_start handler that fires (and re-snapshots) on every tool forever.
|
|
1549
|
-
const fileHistoryHandler = async (context) => {
|
|
1550
|
-
const toolName = context.data?.toolName;
|
|
1551
|
-
const args = context.data?.args;
|
|
1552
|
-
// Tag snapshots with the current turn (stamped above before any tool
|
|
1553
|
-
// runs) so turn-level /undo can revert just this user message's edits.
|
|
1554
|
-
const turnSeq = session.state.turnSeq;
|
|
1555
|
-
if ((toolName === "Write" || toolName === "Edit") && args?.file_path) {
|
|
1556
|
-
const path = args.file_path;
|
|
1557
|
-
// saveSnapshot returns null when the file does not exist yet — this
|
|
1558
|
-
// hook runs BEFORE the tool, so a null here means the turn is CREATING
|
|
1559
|
-
// the file. Record it (idempotent per turn) so /undo can delete it and
|
|
1560
|
-
// /redo can recreate it.
|
|
1561
|
-
if (fileHistory.saveSnapshot(path, turnSeq) === null && turnSeq !== undefined) {
|
|
1562
|
-
fileHistory.recordCreated(path, turnSeq);
|
|
1563
|
-
}
|
|
1564
|
-
}
|
|
1565
|
-
else if (toolName === "ApplyPatch" && typeof args?.patch === "string") {
|
|
1566
|
-
// ApplyPatch mutates files too, so /undo must see them. Snapshot every
|
|
1567
|
-
// existing file the patch updates or deletes (adds have no prior
|
|
1568
|
-
// content). Resolve relative patch paths against the engine cwd, the
|
|
1569
|
-
// same base ApplyPatch itself uses.
|
|
1570
|
-
const cwd = this.config.cwd ?? process.cwd();
|
|
1571
|
-
for (const target of patchBackupTargets(args.patch, cwd)) {
|
|
1572
|
-
fileHistory.saveSnapshot(target, turnSeq);
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
return {};
|
|
1576
|
-
};
|
|
1577
|
-
this.hooks.register("on_tool_start", fileHistoryHandler, 100, "file_history_backup");
|
|
1578
|
-
// Hook: agent start
|
|
1579
|
-
await this.emitHook("on_agent_start", {
|
|
1580
|
-
sessionId: session.state.sessionId,
|
|
1160
|
+
// Auto-compaction runs inside TurnLoop.manageAsync(), after the loop has
|
|
1161
|
+
// initialized its run-scoped Goal tracker. The closure is wired before
|
|
1162
|
+
// construction but cannot execute until turnLoop.run() starts.
|
|
1163
|
+
// Assigned after the callbacks that close over it are constructed; they
|
|
1164
|
+
// cannot run until turnLoop.run(), so definite assignment is intentional.
|
|
1165
|
+
const { turnLoop, applyGoalTermination, goalHookHandler, fileHistoryHook, getRunUsage, recordExternalBilledUsage, accounting, usageBaseline, } = await this.wireRunLoop({
|
|
1166
|
+
session,
|
|
1167
|
+
sid,
|
|
1581
1168
|
task,
|
|
1582
|
-
|
|
1169
|
+
cwd,
|
|
1170
|
+
options,
|
|
1171
|
+
toolCtx,
|
|
1172
|
+
toolExecutor,
|
|
1173
|
+
contextManager,
|
|
1174
|
+
llmClient,
|
|
1175
|
+
auxSummaryClient,
|
|
1176
|
+
fullSystemPrompt,
|
|
1177
|
+
toolDefs,
|
|
1178
|
+
claimClientMessageId,
|
|
1179
|
+
releaseClientMessageId,
|
|
1180
|
+
freshImageMessage,
|
|
1181
|
+
dynamicContextMsg,
|
|
1583
1182
|
});
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
// PERSISTENT GOAL (CC /goal style): a goal set on one send survives across
|
|
1595
|
-
// later sends and manual interrupts until met or cleared. Goal completion
|
|
1596
|
-
// is a high-consequence decision, so V1 routes it to the primary session
|
|
1597
|
-
// client, which is the model expected to interpret the supplied execution
|
|
1598
|
-
// evidence. defaults.auxText remains in force for summaries, titles and
|
|
1599
|
-
// other auxiliary work through auxSummaryClient.
|
|
1600
|
-
// Resolution:
|
|
1601
|
-
// 1. options.goal — this send explicitly sets/replaces the goal.
|
|
1602
|
-
// 2. session.state.activeGoal — a goal set on an earlier send.
|
|
1603
|
-
// 3. config.goal — engine-level default (rare; e.g. headless).
|
|
1604
|
-
// When (1) supplies a goal that differs from the stored one we REPLACE the
|
|
1605
|
-
// persisted active goal (one active goal per session) and announce it. A
|
|
1606
|
-
// bare send with no options.goal inherits the stored active goal so the
|
|
1607
|
-
// model keeps working toward it — that's what makes it persistent.
|
|
1608
|
-
const explicitGoal = normalizeGoal(options?.goal);
|
|
1609
|
-
let storedGoal = this.config.isSubAgent !== true ? session.state.activeGoal : undefined;
|
|
1610
|
-
// Defense in depth: a stale whole-state writer may have restored the
|
|
1611
|
-
// activeGoal field after this exact goal instance was force-terminated.
|
|
1612
|
-
// Refuse to arm it and converge the live bundle before hook registration.
|
|
1613
|
-
if (storedGoal && isSameGoalInstance(storedGoal, session.state.goalTerminal)) {
|
|
1614
|
-
session.state.activeGoal = undefined;
|
|
1615
|
-
storedGoal = undefined;
|
|
1616
|
-
this.sessionManager.saveState(session.state);
|
|
1183
|
+
let result;
|
|
1184
|
+
let firstGoalTermination;
|
|
1185
|
+
try {
|
|
1186
|
+
({ result, firstGoalTermination } = await this.runTurnLoopWithHeadlessDrain({
|
|
1187
|
+
turnLoop,
|
|
1188
|
+
messages,
|
|
1189
|
+
applyGoalTermination,
|
|
1190
|
+
session,
|
|
1191
|
+
options,
|
|
1192
|
+
}));
|
|
1617
1193
|
}
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
//
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
options?.onStream?.({
|
|
1637
|
-
type: "goal_set",
|
|
1638
|
-
objective: explicitGoal.objective,
|
|
1639
|
-
replaced,
|
|
1640
|
-
});
|
|
1194
|
+
finally {
|
|
1195
|
+
// Run-scoped: drop the GoalStopHook so a later goal-less send on this
|
|
1196
|
+
// long-lived engine doesn't keep blocking stops.
|
|
1197
|
+
if (goalHookHandler)
|
|
1198
|
+
this.hooks.unregister("on_stop", goalHookHandler);
|
|
1199
|
+
if (this.activeGoalHook === goalHookHandler) {
|
|
1200
|
+
this.activeGoalHook = null;
|
|
1201
|
+
this.activeGoalHookAttached = false;
|
|
1202
|
+
this.activeRuntimeGoal = null;
|
|
1203
|
+
this.activePersistedRunGoal = null;
|
|
1204
|
+
}
|
|
1205
|
+
if (this.activeTurnLoop === turnLoop)
|
|
1206
|
+
this.activeTurnLoop = null;
|
|
1207
|
+
if (this.activeRunSession === session)
|
|
1208
|
+
this.activeRunSession = null;
|
|
1209
|
+
// Run-scoped too: this handler is re-registered every run(), so it must be
|
|
1210
|
+
// dropped here or it stacks duplicates that re-snapshot on every tool.
|
|
1211
|
+
fileHistoryHook.dispose();
|
|
1641
1212
|
}
|
|
1642
|
-
const
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
if (persistedRunGoal &&
|
|
1663
|
-
isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
|
|
1664
|
-
session.state.activeGoal = undefined;
|
|
1665
|
-
this.sessionManager.saveState(session.state);
|
|
1666
|
-
}
|
|
1667
|
-
},
|
|
1668
|
-
// Re-read the persisted goal each turn so a mid-run 清除 (clearGoal
|
|
1669
|
-
// wrote state.json but this hook's frozen goal copy + the closure's
|
|
1670
|
-
// in-RAM session are untouched) actually stops the judge. Reads disk
|
|
1671
|
-
// via readActiveGoal — authoritative and independent of which session
|
|
1672
|
-
// instance the run closure holds.
|
|
1673
|
-
isGoalActive: (sid) => isSameGoalInstance(this.sessionManager.readActiveGoal(sid), normalizedGoal),
|
|
1674
|
-
});
|
|
1675
|
-
this.hooks.register("on_stop", goalHookHandler, 0, "goal-stop");
|
|
1676
|
-
// Expose for clearGoal() mid-run. Already guarded by isSubAgent above.
|
|
1677
|
-
this.activeGoalHook = goalHookHandler;
|
|
1213
|
+
const finalized = await this.finalizeRun({
|
|
1214
|
+
session,
|
|
1215
|
+
result,
|
|
1216
|
+
firstGoalTermination,
|
|
1217
|
+
turnCount: turnLoop.currentTurn,
|
|
1218
|
+
getRunUsage,
|
|
1219
|
+
usageBaseline,
|
|
1220
|
+
userContextMsg,
|
|
1221
|
+
dynamicContextMsg,
|
|
1222
|
+
options,
|
|
1223
|
+
cwd,
|
|
1224
|
+
llmClient,
|
|
1225
|
+
auxSummaryClient,
|
|
1226
|
+
recordExternalBilledUsage,
|
|
1227
|
+
accounting,
|
|
1228
|
+
profile,
|
|
1229
|
+
getProfileReportedResults: () => profileReportedResults,
|
|
1230
|
+
});
|
|
1231
|
+
if (options?.clientMessageId) {
|
|
1232
|
+
this.appendClientRunReceipt(session, options.clientMessageId, finalized);
|
|
1678
1233
|
}
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1234
|
+
return finalized;
|
|
1235
|
+
});
|
|
1236
|
+
return Promise.resolve(sessionRun).catch((err) => {
|
|
1237
|
+
const failed = buildRunFailureResult({
|
|
1238
|
+
err,
|
|
1239
|
+
session,
|
|
1240
|
+
options,
|
|
1241
|
+
persistFinalRunState: (state) => this.persistFinalRunState(state),
|
|
1687
1242
|
});
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
model: modelFacade,
|
|
1691
|
-
toolExecutor,
|
|
1692
|
-
contextManager,
|
|
1693
|
-
hooks: this.hooks,
|
|
1694
|
-
transcript: session.transcript,
|
|
1695
|
-
systemPrompt: fullSystemPrompt,
|
|
1696
|
-
tools: toolDefs,
|
|
1697
|
-
sessionId: sid,
|
|
1698
|
-
isSubAgent: this.config.isSubAgent === true,
|
|
1699
|
-
consumePendingCompactInfo: () => {
|
|
1700
|
-
const info = pendingCompactInfo;
|
|
1701
|
-
pendingCompactInfo = null;
|
|
1702
|
-
return info;
|
|
1703
|
-
},
|
|
1704
|
-
consumeSteer: (source) => this.consumeSteer(sid, source),
|
|
1705
|
-
restoreSteer: (items) => this.restoreSteer(sid, items),
|
|
1706
|
-
buildSteerUserMessageContent: async (item) => {
|
|
1707
|
-
const steerImageInput = await prepareRunImageInput({
|
|
1708
|
-
task: item.text,
|
|
1709
|
-
cwd,
|
|
1710
|
-
llm: this.config.llm,
|
|
1711
|
-
sessionId: sid,
|
|
1712
|
-
attachments: item.attachments,
|
|
1713
|
-
});
|
|
1714
|
-
if (!steerImageInput.ok) {
|
|
1715
|
-
throw new Error(steerImageInput.result.text);
|
|
1716
|
-
}
|
|
1717
|
-
return buildRunUserMessageContent(steerImageInput.parsedTask, cwd, steerImageInput.taskText);
|
|
1718
|
-
},
|
|
1719
|
-
claimClientMessageId: (clientMessageId, source) => claimClientMessageId(session, clientMessageId, source),
|
|
1720
|
-
releaseClientMessageId: (clientMessageId) => {
|
|
1721
|
-
claimedClientMessageIds.delete(clientMessageId);
|
|
1722
|
-
},
|
|
1723
|
-
setOriginClientMessageId: (clientMessageId) => {
|
|
1724
|
-
toolCtx.originClientMessageId = clientMessageId;
|
|
1725
|
-
},
|
|
1726
|
-
recordCumulativeUsage,
|
|
1727
|
-
recordCacheReadDiagnostics: (usage) => {
|
|
1728
|
-
this.recordCacheReadDiagnostics(sid, usage);
|
|
1729
|
-
},
|
|
1730
|
-
recordContextUsageAnchor: (anchor) => {
|
|
1731
|
-
session.state.contextUsageAnchor = {
|
|
1732
|
-
...anchor,
|
|
1733
|
-
provider: this.config.llm.provider,
|
|
1734
|
-
model: this.config.llm.model,
|
|
1735
|
-
};
|
|
1736
|
-
},
|
|
1737
|
-
// Clear the persisted goal for a self-reported completion / confirmed
|
|
1738
|
-
// cancel. Clears the in-RAM session's activeGoal (so THIS run's later
|
|
1739
|
-
// turns don't re-arm) AND persists it, and drops the in-flight stop
|
|
1740
|
-
// hook so nothing re-blocks the stop we're about to return.
|
|
1741
|
-
clearPersistedGoal: () => {
|
|
1742
|
-
if (persistedRunGoal &&
|
|
1743
|
-
isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
|
|
1744
|
-
session.state.activeGoal = undefined;
|
|
1745
|
-
this.sessionManager.saveState(session.state);
|
|
1746
|
-
}
|
|
1747
|
-
if (goalHookHandler) {
|
|
1748
|
-
this.hooks.unregister("on_stop", goalHookHandler);
|
|
1749
|
-
if (this.activeGoalHook === goalHookHandler)
|
|
1750
|
-
this.activeGoalHook = null;
|
|
1751
|
-
}
|
|
1752
|
-
},
|
|
1753
|
-
updateGoalJudgeContext: (context) => {
|
|
1754
|
-
goalJudgeContext = context;
|
|
1755
|
-
},
|
|
1756
|
-
ctxOverheadStore: {
|
|
1757
|
-
get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
|
|
1758
|
-
set: (s, n) => {
|
|
1759
|
-
this.ctxOverheadBySid.set(s, n);
|
|
1760
|
-
},
|
|
1761
|
-
},
|
|
1762
|
-
}, {
|
|
1763
|
-
// Goal mode raises the turn ceiling: an unattended goal run keeps
|
|
1764
|
-
// getting re-blocked by the stop-hook until it's done, and the 100
|
|
1765
|
-
// interactive default would silently truncate a long objective. The
|
|
1766
|
-
// real backstops are the goal token/time budgets + maxStopBlocks.
|
|
1767
|
-
maxTurns: resolveMaxTurns(this.config.maxTurns, normalizedGoal),
|
|
1768
|
-
// Consecutive stop-block cap: config override > goal.maxStopBlocks >
|
|
1769
|
-
// GOAL_DEFAULT_MAX_STOP_BLOCKS(25). The old hardcoded 8 was too tight
|
|
1770
|
-
// for complex goals that legitimately get re-blocked while advancing.
|
|
1771
|
-
maxStopBlocks: resolveMaxStopBlocks(this.config.maxStopBlocks, normalizedGoal),
|
|
1772
|
-
// 25 (was 10): modern models routinely batch >10 parallel tool calls
|
|
1773
|
-
// (e.g. reading a dozen files at once). At 10 the excess was silently
|
|
1774
|
-
// dropped; the turn loop now also warns the model when it caps, but a
|
|
1775
|
-
// higher ceiling avoids the round-trip in the common case. (B-3)
|
|
1776
|
-
maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 25,
|
|
1777
|
-
onStream: options?.onStream,
|
|
1778
|
-
signal: options?.signal,
|
|
1779
|
-
freshImageMessages: freshImageMessage ? [freshImageMessage] : undefined,
|
|
1780
|
-
volatileContextMessages: dynamicContextMsg ? [dynamicContextMsg] : undefined,
|
|
1781
|
-
// Goal mode: the active goal is surfaced to the on_stop handler via
|
|
1782
|
-
// ctx.data.goal; the GoalStopHook (registered above) judges it.
|
|
1783
|
-
goal: normalizedGoal,
|
|
1784
|
-
// Heartbeat: flush turnCount + tokens to state.json after every turn
|
|
1785
|
-
// so external observers (other CLI processes, /sid, the session list)
|
|
1786
|
-
// see live progress instead of a stale snapshot from the last
|
|
1787
|
-
// completed run.
|
|
1788
|
-
onTurnBoundary: (turnCount) => {
|
|
1789
|
-
session.state.turnCount = turnCount;
|
|
1790
|
-
// baseline + this run's running total (idempotent per boundary,
|
|
1791
|
-
// accumulates across runs; carries cacheRead/cacheCreation too).
|
|
1792
|
-
session.state.tokenUsage = foldRunUsage(usageBaseline, modelFacade.getUsage());
|
|
1793
|
-
// Surface the whole-session monotonic cache counts to the UI.
|
|
1794
|
-
// Separate from turn-loop's authoritative per-response emit (which
|
|
1795
|
-
// drives the live context reading and single-turn metric).
|
|
1796
|
-
const cumulative = normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage);
|
|
1797
|
-
const cumulativeHitRate = cumulativeCacheHitRate(cumulative);
|
|
1798
|
-
options?.onStream?.({
|
|
1799
|
-
type: "usage_update",
|
|
1800
|
-
promptTokens: cumulative.cumulativePromptTokens,
|
|
1801
|
-
promptTokensSource: "session_cumulative",
|
|
1802
|
-
promptTokensConfidence: "high",
|
|
1803
|
-
cumulativePromptTokens: cumulative.cumulativePromptTokens,
|
|
1804
|
-
cumulativeCacheReadTokens: cumulative.cumulativeCacheReadTokens,
|
|
1805
|
-
cumulativeCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
|
|
1806
|
-
...(cumulativeHitRate !== undefined
|
|
1807
|
-
? { cumulativeCacheHitRate: cumulativeHitRate }
|
|
1808
|
-
: {}),
|
|
1809
|
-
sessionPromptTokens: cumulative.cumulativePromptTokens,
|
|
1810
|
-
sessionCacheReadTokens: cumulative.cumulativeCacheReadTokens,
|
|
1811
|
-
sessionCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
|
|
1812
|
-
});
|
|
1813
|
-
if (this.config.costStore) {
|
|
1814
|
-
session.state.costState = this.config.costStore.serialize();
|
|
1815
|
-
}
|
|
1816
|
-
this.sessionManager.saveState(session.state);
|
|
1817
|
-
},
|
|
1818
|
-
});
|
|
1819
|
-
// Expose this run's loop for mid-run extension (TODO 3.1). Top-level only —
|
|
1820
|
-
// a sub-agent's loop is its own concern and isn't user-extendable.
|
|
1821
|
-
if (this.config.isSubAgent !== true)
|
|
1822
|
-
this.activeTurnLoop = turnLoop;
|
|
1823
|
-
// Expose this run's session bundle so a mid-run clearGoal() wipes the goal
|
|
1824
|
-
// on the very instance this loop keeps saving (see field doc). Top-level
|
|
1825
|
-
// only — sub-agents don't carry user-clearable persistent goals.
|
|
1826
|
-
if (this.config.isSubAgent !== true)
|
|
1827
|
-
this.activeRunSession = session;
|
|
1828
|
-
const applyGoalTermination = (termination) => {
|
|
1829
|
-
if (!termination || !persistedRunGoal)
|
|
1830
|
-
return;
|
|
1831
|
-
// Record the terminal identity even when a newer goal has already
|
|
1832
|
-
// replaced it. Only clear activeGoal when it is still the run's goal.
|
|
1833
|
-
session.state.goalTerminal = {
|
|
1834
|
-
objective: persistedRunGoal.objective,
|
|
1835
|
-
setAtMs: persistedRunGoal.setAtMs,
|
|
1836
|
-
reason: termination,
|
|
1837
|
-
terminatedAtMs: Date.now(),
|
|
1838
|
-
};
|
|
1839
|
-
if (isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
|
|
1840
|
-
session.state.activeGoal = undefined;
|
|
1841
|
-
}
|
|
1842
|
-
this.sessionManager.saveState(session.state);
|
|
1843
|
-
if (goalHookHandler) {
|
|
1844
|
-
this.hooks.unregister("on_stop", goalHookHandler);
|
|
1845
|
-
if (this.activeGoalHook === goalHookHandler)
|
|
1846
|
-
this.activeGoalHook = null;
|
|
1847
|
-
}
|
|
1848
|
-
};
|
|
1849
|
-
let result;
|
|
1850
|
-
try {
|
|
1851
|
-
result = await turnLoop.run(messages);
|
|
1852
|
-
applyGoalTermination(result.goalTermination);
|
|
1853
|
-
// ── Headless: drain background sub-agents before resolving ───────
|
|
1854
|
-
// Unified background-work model (2026-06-17): the engine NO LONGER parks
|
|
1855
|
-
// every run waiting on background work. Background work (sub-agents,
|
|
1856
|
-
// video polls, shells) ends the turn, yields, and is picked up later by
|
|
1857
|
-
// the server's notification-wakeup path (maybeWakeIdleSession). The
|
|
1858
|
-
// INTERACTIVE path relies on that wakeup + a run-boundary re-check.
|
|
1859
|
-
//
|
|
1860
|
-
// HEADLESS is the exception: a one-shot `engine.run` whose caller takes
|
|
1861
|
-
// `result.text` as THE answer (automation / SDK) has no later turn to
|
|
1862
|
-
// pick up a wakeup — so it must wait, before resolving, until its own
|
|
1863
|
-
// background SUB-AGENTS finish and summarize. Only sub-agents (their
|
|
1864
|
-
// summary IS part of this run's result), NOT shells (a dev server never
|
|
1865
|
-
// exits → would hang headless forever) and NOT video (a long render the
|
|
1866
|
-
// one-shot run shouldn't block on). This replaces the old for(;;) park
|
|
1867
|
-
// (s-mpvf4rsj-bb6e4639 invariant) for the headless case only.
|
|
1868
|
-
const sid = session.state.sessionId;
|
|
1869
|
-
const isTopLevel = this.config.isSubAgent !== true;
|
|
1870
|
-
if (isTopLevel && this.isHeadless()) {
|
|
1871
|
-
let aborted = options?.signal?.aborted === true;
|
|
1872
|
-
// Loop: a summarize turn can spawn a NEW background sub-agent; keep
|
|
1873
|
-
// draining + summarizing until none remain. turnCount accumulates, so
|
|
1874
|
-
// the turn-loop's maxTurns still bounds runaway re-summarization.
|
|
1875
|
-
for (;;) {
|
|
1876
|
-
while (!aborted && asyncAgentRegistry.hasRunningForSession(sid)) {
|
|
1877
|
-
aborted = await this.waitForBackgroundAgentChange(sid, options?.signal);
|
|
1878
|
-
}
|
|
1879
|
-
let pending = notificationQueue.drainAll(sid);
|
|
1880
|
-
if (aborted && pending.length === 0) {
|
|
1881
|
-
// Abort race: an agent calls markCompleted (registry notify) and only
|
|
1882
|
-
// THEN enqueue (queue notify) as two separate statements. If the abort
|
|
1883
|
-
// fired before that agent's completion `.then` ran, the while above
|
|
1884
|
-
// exited on `aborted`, this drainAll caught nothing, and a naive
|
|
1885
|
-
// `break` here would drop the agent's output. Give still-settling
|
|
1886
|
-
// agents a bounded window to finish enqueuing, then drain once more.
|
|
1887
|
-
// Each wait is timeout-bounded so a genuinely stuck (never-completing)
|
|
1888
|
-
// agent can't hang abort cleanup forever — we'd rather lose nothing in
|
|
1889
|
-
// the common case and not hang in the pathological one.
|
|
1890
|
-
for (let i = 0; i < 20 && asyncAgentRegistry.hasRunningForSession(sid); i++) {
|
|
1891
|
-
const changed = await this.waitForBackgroundAgentChangeOrTimeout(sid, 25);
|
|
1892
|
-
if (!changed)
|
|
1893
|
-
break; // timed out with no state change → stop waiting
|
|
1894
|
-
}
|
|
1895
|
-
pending = notificationQueue.drainAll(sid);
|
|
1896
|
-
if (pending.length === 0)
|
|
1897
|
-
break;
|
|
1898
|
-
}
|
|
1899
|
-
else if (pending.length === 0) {
|
|
1900
|
-
break;
|
|
1901
|
-
}
|
|
1902
|
-
const injected = {
|
|
1903
|
-
role: "user",
|
|
1904
|
-
content: `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`,
|
|
1905
|
-
};
|
|
1906
|
-
if (aborted) {
|
|
1907
|
-
// Mark injected: a synthetic notification, not the user's own input —
|
|
1908
|
-
// the disk reader drops it on replay so no phantom user bubble.
|
|
1909
|
-
session.transcript.appendMessage(injected.role, injected.content, { injected: true });
|
|
1910
|
-
result = { ...result, messages: [...result.messages, injected] };
|
|
1911
|
-
break;
|
|
1912
|
-
}
|
|
1913
|
-
result = await turnLoop.run([...result.messages, injected]);
|
|
1914
|
-
applyGoalTermination(result.goalTermination);
|
|
1915
|
-
}
|
|
1916
|
-
}
|
|
1917
|
-
}
|
|
1918
|
-
finally {
|
|
1919
|
-
// Run-scoped: drop the GoalStopHook so a later goal-less send on this
|
|
1920
|
-
// long-lived engine doesn't keep blocking stops.
|
|
1921
|
-
if (goalHookHandler)
|
|
1922
|
-
this.hooks.unregister("on_stop", goalHookHandler);
|
|
1923
|
-
if (this.activeGoalHook === goalHookHandler)
|
|
1924
|
-
this.activeGoalHook = null;
|
|
1925
|
-
if (this.activeTurnLoop === turnLoop)
|
|
1926
|
-
this.activeTurnLoop = null;
|
|
1927
|
-
if (this.activeRunSession === session)
|
|
1928
|
-
this.activeRunSession = null;
|
|
1929
|
-
// Run-scoped too: this handler is re-registered every run(), so it must be
|
|
1930
|
-
// dropped here or it stacks duplicates that re-snapshot on every tool.
|
|
1931
|
-
this.hooks.unregister("on_tool_start", fileHistoryHandler);
|
|
1932
|
-
}
|
|
1933
|
-
this.lastMessages = result.messages;
|
|
1934
|
-
const cachedMessages = this.stripInjectedContextMessages(result.messages, userContextMsg, dynamicContextMsg);
|
|
1935
|
-
this.compactedMessagesBySession.set(session.state.sessionId, cachedMessages);
|
|
1936
|
-
logger.info("engine.done", {
|
|
1937
|
-
sessionId: session.state.sessionId,
|
|
1938
|
-
reason: result.reason,
|
|
1939
|
-
turns: turnLoop.currentTurn,
|
|
1940
|
-
tokens: modelFacade.getUsage().totalTokens,
|
|
1941
|
-
});
|
|
1942
|
-
recordSessionEnd(session.state.sessionId, {
|
|
1943
|
-
reason: result.reason,
|
|
1944
|
-
turns: turnLoop.currentTurn,
|
|
1945
|
-
cost: modelFacade.getUsage(),
|
|
1946
|
-
});
|
|
1947
|
-
// Session-level hook: fired symmetrically with on_session_start once
|
|
1948
|
-
// the turn loop has resolved (completion, error, or abort). Handlers
|
|
1949
|
-
// are notify-only — any returned messages are dropped because the run
|
|
1950
|
-
// is already over and there's no next turn to inject into.
|
|
1951
|
-
await this.emitHook("on_session_end", {
|
|
1952
|
-
sessionId: session.state.sessionId,
|
|
1953
|
-
reason: result.reason,
|
|
1954
|
-
turnCount: turnLoop.currentTurn,
|
|
1955
|
-
});
|
|
1956
|
-
// Fire-and-forget memory pipeline: extract durable memories from the
|
|
1957
|
-
// transcript, save a session summary, and conditionally trigger
|
|
1958
|
-
// auto-dream consolidation. Doesn't block the Engine result.
|
|
1959
|
-
void this.runMemoryPipeline(session.transcript, session.state.sessionId, cwd, llmClient);
|
|
1960
|
-
// Fire-and-forget session title generation — only after the FIRST turn.
|
|
1961
|
-
// Reuses the already-resolved auxSummaryClient (aux model, cheap). Best-
|
|
1962
|
-
// effort: failures never touch the run result. The renderer writes the
|
|
1963
|
-
// title into the sidebar on receipt of the session_title stream event.
|
|
1964
|
-
{
|
|
1965
|
-
const messageEvents = session.transcript.getEvents("message");
|
|
1966
|
-
const userMsgEvents = messageEvents.filter((e) => e.data.role === "user");
|
|
1967
|
-
const userMsgCount = userMsgEvents.length;
|
|
1968
|
-
const onStream = options?.onStream;
|
|
1969
|
-
if (userMsgCount === 1 && onStream && result.text) {
|
|
1970
|
-
const rawContent = userMsgEvents[0]?.data?.content;
|
|
1971
|
-
const firstUserText = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent ?? "");
|
|
1972
|
-
void buildSessionTitle(auxSummaryClient, firstUserText, result.text)
|
|
1973
|
-
.then((title) => {
|
|
1974
|
-
if (title) {
|
|
1975
|
-
// Persist the title so it survives a localStorage wipe / disk
|
|
1976
|
-
// rebuild — it used to live only in the renderer's localStorage
|
|
1977
|
-
// index. This .then resolves AFTER the saveState below (:1892), so
|
|
1978
|
-
// it must save again itself rather than rely on that write.
|
|
1979
|
-
session.state.title = title;
|
|
1980
|
-
this.sessionManager.saveState(session.state);
|
|
1981
|
-
onStream({
|
|
1982
|
-
type: "session_title",
|
|
1983
|
-
sessionId: session.state.sessionId,
|
|
1984
|
-
title,
|
|
1985
|
-
});
|
|
1986
|
-
}
|
|
1987
|
-
})
|
|
1988
|
-
.catch(() => { });
|
|
1989
|
-
}
|
|
1990
|
-
}
|
|
1991
|
-
// Update session state. Persist the raw terminal reason as the status so
|
|
1992
|
-
// callers can distinguish user-cancelled (aborted_streaming) from real
|
|
1993
|
-
// failures (model_error, prompt_too_long, ...) — previously every
|
|
1994
|
-
// non-completed outcome collapsed to "errored", which threw away the
|
|
1995
|
-
// distinction and misled anyone reading state.json.
|
|
1996
|
-
session.state.turnCount = turnLoop.currentTurn;
|
|
1997
|
-
session.state.status = result.reason;
|
|
1998
|
-
// Session-cumulative (baseline + this run) for persistence...
|
|
1999
|
-
const usage = modelFacade.getUsage();
|
|
2000
|
-
session.state.tokenUsage = foldRunUsage(usageBaseline, usage);
|
|
2001
|
-
if (this.config.costStore) {
|
|
2002
|
-
session.state.costState = this.config.costStore.serialize();
|
|
1243
|
+
if (options?.clientMessageId) {
|
|
1244
|
+
this.appendClientRunReceipt(session, options.clientMessageId, failed);
|
|
2003
1245
|
}
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
1246
|
+
return failed;
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
/** A receipt write failure must not turn an already finalized model result
|
|
1250
|
+
* into a second synthetic failure (or reject the Engine.run contract). */
|
|
1251
|
+
appendClientRunReceipt(session, clientMessageId, result) {
|
|
1252
|
+
try {
|
|
1253
|
+
session.transcript.appendRunResult(clientMessageId, result);
|
|
1254
|
+
}
|
|
1255
|
+
catch (error) {
|
|
1256
|
+
logger.warn("engine.client_message.receipt_persist_failed", {
|
|
2007
1257
|
sessionId: session.state.sessionId,
|
|
2008
|
-
|
|
2009
|
-
|
|
1258
|
+
clientMessageId,
|
|
1259
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2010
1260
|
});
|
|
2011
|
-
|
|
2012
|
-
options?.onStream?.({ type: "turn_complete", reason: result.reason });
|
|
2013
|
-
return {
|
|
2014
|
-
text: result.text,
|
|
2015
|
-
reason: result.reason,
|
|
2016
|
-
sessionId: session.state.sessionId,
|
|
2017
|
-
turnCount: turnLoop.currentTurn,
|
|
2018
|
-
usage: {
|
|
2019
|
-
promptTokens: usage.totalPromptTokens,
|
|
2020
|
-
completionTokens: usage.totalCompletionTokens,
|
|
2021
|
-
totalTokens: usage.totalTokens,
|
|
2022
|
-
},
|
|
2023
|
-
};
|
|
2024
|
-
});
|
|
1261
|
+
}
|
|
2025
1262
|
}
|
|
2026
1263
|
/**
|
|
2027
|
-
*
|
|
2028
|
-
*
|
|
2029
|
-
*
|
|
2030
|
-
|
|
2031
|
-
/**
|
|
2032
|
-
* Resolve the LLM client for background/auxiliary work (memory extraction,
|
|
2033
|
-
* auto-dream). When settings.defaults.auxText names a valid pool model, build
|
|
2034
|
-
* (and cache) a dedicated client for it so per-turn book-keeping runs on a cheap
|
|
2035
|
-
* fast model instead of the expensive primary. Falls back to `fallback` (the
|
|
2036
|
-
* active run's client) when unset, unknown, or on any build failure — aux
|
|
2037
|
-
* work is best-effort and must never break a run.
|
|
1264
|
+
* Terminal success path: forward to {@link finalizeRunSuccess} with the
|
|
1265
|
+
* engine-bound persistence / memory / hook closures filled in. Extracted from
|
|
1266
|
+
* the {@link runExclusive} skeleton so the terminal-state assembly reads as a
|
|
1267
|
+
* single call; behavior is unchanged.
|
|
2038
1268
|
*/
|
|
1269
|
+
finalizeRun(args) {
|
|
1270
|
+
const { session, result, firstGoalTermination, turnCount, getRunUsage, usageBaseline, userContextMsg, dynamicContextMsg, options, cwd, llmClient, auxSummaryClient, recordExternalBilledUsage, accounting, profile, getProfileReportedResults, } = args;
|
|
1271
|
+
return finalizeRunSuccess({
|
|
1272
|
+
session,
|
|
1273
|
+
result,
|
|
1274
|
+
firstGoalTermination,
|
|
1275
|
+
turnCount,
|
|
1276
|
+
getRunUsage,
|
|
1277
|
+
usageBaseline,
|
|
1278
|
+
userContextMsg,
|
|
1279
|
+
dynamicContextMsg,
|
|
1280
|
+
setCompactedMessages: (s, msgs) => this.compactedMessagesBySession.set(s, msgs),
|
|
1281
|
+
setLastMessages: (msgs) => {
|
|
1282
|
+
this.lastMessages = msgs;
|
|
1283
|
+
},
|
|
1284
|
+
options,
|
|
1285
|
+
emitHook: (event, payload, signal) => this.emitHook(event, payload, signal),
|
|
1286
|
+
cwd,
|
|
1287
|
+
llmClient,
|
|
1288
|
+
auxSummaryClient,
|
|
1289
|
+
recordExternalBilledUsage,
|
|
1290
|
+
runMemoryPipeline: (transcript, sessionId, runCwd, client, record) => this.runMemoryPipeline(transcript, sessionId, runCwd, client, record),
|
|
1291
|
+
updatePersistedSessionState: (s, patch) => this.updatePersistedSessionState(s, patch),
|
|
1292
|
+
persistFinalRunState: (state) => this.persistFinalRunState(state),
|
|
1293
|
+
markRunAccountingFinalized: () => accounting.markRunAccountingFinalized(),
|
|
1294
|
+
costStoreSerialize: this.config.costStore
|
|
1295
|
+
? () => this.config.costStore.serialize()
|
|
1296
|
+
: undefined,
|
|
1297
|
+
profile,
|
|
1298
|
+
getProfileReportedResults,
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
2039
1301
|
/**
|
|
2040
|
-
* Build the
|
|
2041
|
-
*
|
|
1302
|
+
* Build the stream callback that wraps the caller's `onStream`: it snapshots
|
|
1303
|
+
* TodoWrite `task_update` events for TaskGuard and persists `goal_progress`
|
|
1304
|
+
* events to the transcript before delegating. Extracted verbatim from the
|
|
1305
|
+
* {@link runExclusive} skeleton; the todo buffer and (not-yet-open) session
|
|
1306
|
+
* are reached through the `setLatestTodos` / `getSession` accessors.
|
|
2042
1307
|
*/
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
1308
|
+
buildWrappedOnStream(args) {
|
|
1309
|
+
const { userOnStream, getSession, setLatestTodos } = args;
|
|
1310
|
+
return (event) => {
|
|
1311
|
+
if (event.type === "task_update") {
|
|
1312
|
+
setLatestTodos(event.tasks);
|
|
1313
|
+
}
|
|
1314
|
+
// Persist goal progress so replay/history shows how many rounds the
|
|
1315
|
+
// goal ran. Display-only — toMessages() ignores this type, so it never
|
|
1316
|
+
// re-enters the LLM context.
|
|
1317
|
+
if (event.type === "goal_progress") {
|
|
1318
|
+
getSession().transcript.append("goal_progress", {
|
|
1319
|
+
...(event.goalId ? { goalId: event.goalId } : {}),
|
|
1320
|
+
status: event.status,
|
|
1321
|
+
round: event.round,
|
|
1322
|
+
...(event.gaps ? { gaps: event.gaps } : {}),
|
|
1323
|
+
});
|
|
2057
1324
|
}
|
|
2058
|
-
|
|
1325
|
+
userOnStream?.(event);
|
|
2059
1326
|
};
|
|
2060
1327
|
}
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
//
|
|
2079
|
-
//
|
|
2080
|
-
//
|
|
2081
|
-
//
|
|
2082
|
-
//
|
|
2083
|
-
//
|
|
2084
|
-
//
|
|
2085
|
-
//
|
|
2086
|
-
//
|
|
2087
|
-
//
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
}
|
|
2105
|
-
catch (err) {
|
|
2106
|
-
logger.warn("engine.aux_model_build_failed", {
|
|
2107
|
-
auxModelKey: auxKey,
|
|
2108
|
-
error: err.message,
|
|
1328
|
+
/**
|
|
1329
|
+
* Run the turn loop once, apply its goal termination, and — for a top-level
|
|
1330
|
+
* headless run only — drain background sub-agents before resolving. Extracted
|
|
1331
|
+
* verbatim from the body of the {@link runExclusive} try block; the cleanup
|
|
1332
|
+
* `finally` stays in the skeleton so the run-scoped hook/loop teardown is
|
|
1333
|
+
* guaranteed regardless of how this method returns or throws.
|
|
1334
|
+
*/
|
|
1335
|
+
async runTurnLoopWithHeadlessDrain(args) {
|
|
1336
|
+
const { turnLoop, messages, applyGoalTermination, session, options } = args;
|
|
1337
|
+
let result = await turnLoop.run(messages);
|
|
1338
|
+
let firstGoalTermination = result.goalTermination;
|
|
1339
|
+
applyGoalTermination(result.goalTermination, result.goalTerminationRound);
|
|
1340
|
+
// ── Headless: drain background sub-agents before resolving ───────
|
|
1341
|
+
// Unified background-work model (2026-06-17): the engine NO LONGER parks
|
|
1342
|
+
// every run waiting on background work. Background work (sub-agents,
|
|
1343
|
+
// video polls, shells) ends the turn, yields, and is picked up later by
|
|
1344
|
+
// the server's notification-wakeup path (maybeWakeIdleSession). The
|
|
1345
|
+
// INTERACTIVE path relies on that wakeup + a run-boundary re-check.
|
|
1346
|
+
//
|
|
1347
|
+
// HEADLESS is the exception: a one-shot `engine.run` whose caller takes
|
|
1348
|
+
// `result.text` as THE answer (automation / SDK) has no later turn to
|
|
1349
|
+
// pick up a wakeup — so it must wait, before resolving, until its own
|
|
1350
|
+
// background SUB-AGENTS finish and summarize. Only sub-agents (their
|
|
1351
|
+
// summary IS part of this run's result), NOT shells (a dev server never
|
|
1352
|
+
// exits → would hang headless forever) and NOT video (a long render the
|
|
1353
|
+
// one-shot run shouldn't block on). This replaces the old for(;;) park
|
|
1354
|
+
// (s-mpvf4rsj-bb6e4639 invariant) for the headless case only.
|
|
1355
|
+
const sid = session.state.sessionId;
|
|
1356
|
+
const isTopLevel = this.config.isSubAgent !== true;
|
|
1357
|
+
if (isTopLevel && this.isHeadless()) {
|
|
1358
|
+
result = await drainHeadlessBackgroundAgents({
|
|
1359
|
+
sid,
|
|
1360
|
+
session,
|
|
1361
|
+
signal: options?.signal,
|
|
1362
|
+
initialResult: result,
|
|
1363
|
+
runTurnLoop: (msgs) => turnLoop.run(msgs),
|
|
1364
|
+
applyGoalTermination,
|
|
1365
|
+
waitForBackgroundAgentChange: (s, sig) => this.waitForBackgroundAgentChange(s, sig),
|
|
1366
|
+
waitForBackgroundAgentChangeOrTimeout: (s, ms) => this.waitForBackgroundAgentChangeOrTimeout(s, ms),
|
|
1367
|
+
getFirstGoalTermination: () => firstGoalTermination,
|
|
1368
|
+
setFirstGoalTermination: (t) => {
|
|
1369
|
+
firstGoalTermination = t;
|
|
1370
|
+
},
|
|
2109
1371
|
});
|
|
2110
|
-
return fallback;
|
|
2111
1372
|
}
|
|
1373
|
+
return { result, firstGoalTermination };
|
|
2112
1374
|
}
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
//
|
|
2134
|
-
//
|
|
2135
|
-
//
|
|
2136
|
-
//
|
|
2137
|
-
const
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
callLLM: async (sysPrompt, userMsg) => {
|
|
2146
|
-
// Use a lightweight auxiliary call (no tools, no streaming, no
|
|
2147
|
-
// reasoning tokens).
|
|
2148
|
-
const resp = await llmClient.createMessage({
|
|
2149
|
-
systemPrompt: sysPrompt,
|
|
2150
|
-
messages: [{ role: "user", content: userMsg }],
|
|
2151
|
-
tools: [],
|
|
2152
|
-
maxTokens: 1024,
|
|
2153
|
-
recordUsage: false,
|
|
2154
|
-
reasoning: { mode: "off" },
|
|
2155
|
-
});
|
|
2156
|
-
return resp.text;
|
|
2157
|
-
},
|
|
2158
|
-
runDream: async ({ systemPrompt, userPrompt, projectDir }) => this.runDreamLoop({ systemPrompt, userPrompt, projectDir, llmClient, sessionId }),
|
|
2159
|
-
projectDir: cwd,
|
|
2160
|
-
// settings.memories.maxCount caps memories accepted per extraction;
|
|
2161
|
-
// autoExtract=false turns the extractor off (summaries/dream stay).
|
|
2162
|
-
maxCount: this.readMemoriesConfig()?.maxCount,
|
|
2163
|
-
autoExtract: this.readMemoriesConfig()?.autoExtract,
|
|
2164
|
-
});
|
|
2165
|
-
await orchestrator.run(plainMessages, sessionId);
|
|
1375
|
+
/**
|
|
1376
|
+
* Stamp the resolved session identity and session-scoped side-effect sinks
|
|
1377
|
+
* onto the run's {@link ToolContext}, now that the session bundle is open.
|
|
1378
|
+
* Extracted verbatim from the {@link runExclusive} skeleton.
|
|
1379
|
+
*/
|
|
1380
|
+
stampRunToolContext(toolCtx, session, options) {
|
|
1381
|
+
// B2 / Gate 1: stamp the resolved sid onto the tool context so
|
|
1382
|
+
// session-scoped side effects (background-agent completion
|
|
1383
|
+
// notifications) attribute to the right session. toolCtx is created
|
|
1384
|
+
// before the session bundle is resolved (see ~line 635), so this is
|
|
1385
|
+
// the first point we can set it. After this assignment treat the
|
|
1386
|
+
// field follows the latest successfully injected user intent for the rest
|
|
1387
|
+
// of the run, so tools launched after a steer attribute their side effects
|
|
1388
|
+
// to that steer rather than this original submit.
|
|
1389
|
+
toolCtx.sessionId = session.state.sessionId;
|
|
1390
|
+
toolCtx.originClientMessageId = options?.clientMessageId;
|
|
1391
|
+
toolCtx.recordExternalFileChanges = (record) => {
|
|
1392
|
+
session.transcript.append("external_file_changes", { ...record });
|
|
1393
|
+
};
|
|
1394
|
+
toolCtx.setSessionWorkspace = (workspace, persistedRevision) => {
|
|
1395
|
+
// Enter/ExitWorktree passes the revision returned by its in-process
|
|
1396
|
+
// field update. The desktop bridge persists in another process before
|
|
1397
|
+
// returning, so repeat the idempotent workspace update here to obtain a
|
|
1398
|
+
// revision owned by this live bundle.
|
|
1399
|
+
const stateRevision = persistedRevision ??
|
|
1400
|
+
this.sessionManager.setSessionWorkspace(session.state.sessionId, workspace);
|
|
1401
|
+
Object.assign(session.state, { workspace, stateRevision });
|
|
1402
|
+
};
|
|
1403
|
+
if (this.config.isSubAgent !== true &&
|
|
1404
|
+
session.state.kind === "work" &&
|
|
1405
|
+
!isEphemeralSessionState(session.state)) {
|
|
1406
|
+
this.attachSessionMessageService(toolCtx, session, options);
|
|
2166
1407
|
}
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
1408
|
+
}
|
|
1409
|
+
/** Attach a closed-set, host-routed Session message sender to this run. */
|
|
1410
|
+
attachSessionMessageService(toolCtx, session, options) {
|
|
1411
|
+
const sourceSessionId = session.state.sessionId;
|
|
1412
|
+
const sourceRoot = this.sessionManager.readSessionMainRoot(sourceSessionId);
|
|
1413
|
+
if (!sourceRoot)
|
|
1414
|
+
return;
|
|
1415
|
+
if (!this.sessionMessageRouter)
|
|
1416
|
+
return;
|
|
1417
|
+
const catalog = [];
|
|
1418
|
+
const seen = new Set();
|
|
1419
|
+
const rawTargets = Array.isArray(options?.sessionMessageTargets)
|
|
1420
|
+
? [...options.sessionMessageTargets]
|
|
1421
|
+
: [];
|
|
1422
|
+
for (const raw of rawTargets.slice(0, 100)) {
|
|
1423
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
1424
|
+
continue;
|
|
1425
|
+
const candidate = raw;
|
|
1426
|
+
const sessionId = typeof candidate.sessionId === "string" ? candidate.sessionId : "";
|
|
1427
|
+
try {
|
|
1428
|
+
assertSafeSessionId(sessionId);
|
|
1429
|
+
}
|
|
1430
|
+
catch {
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
if (seen.has(sessionId))
|
|
1434
|
+
continue;
|
|
1435
|
+
if (candidate.workspaceRoot !== sourceRoot)
|
|
1436
|
+
continue;
|
|
1437
|
+
const title = typeof candidate.title === "string" ? candidate.title.trim() : "";
|
|
1438
|
+
if (!title || title.length > 512)
|
|
1439
|
+
continue;
|
|
1440
|
+
const workspaceProfile = typeof candidate.workspaceProfile === "string"
|
|
1441
|
+
? candidate.workspaceProfile.trim().slice(0, 256)
|
|
1442
|
+
: "";
|
|
1443
|
+
seen.add(sessionId);
|
|
1444
|
+
catalog.push({
|
|
2170
1445
|
sessionId,
|
|
2171
|
-
|
|
1446
|
+
title,
|
|
1447
|
+
workspaceRoot: sourceRoot,
|
|
1448
|
+
...(workspaceProfile ? { workspaceProfile } : {}),
|
|
2172
1449
|
});
|
|
2173
1450
|
}
|
|
1451
|
+
const targets = catalog.filter((target) => target.sessionId !== sourceSessionId);
|
|
1452
|
+
toolCtx.sessionMessages = {
|
|
1453
|
+
targets,
|
|
1454
|
+
send: async ({ targetSessionId, message }) => {
|
|
1455
|
+
const target = targets.find((candidate) => candidate.sessionId === targetSessionId);
|
|
1456
|
+
if (!target)
|
|
1457
|
+
throw new Error("target Session is not in the host-authorized project list");
|
|
1458
|
+
if (!message.trim())
|
|
1459
|
+
throw new Error("message is required");
|
|
1460
|
+
if (message.length > 48_000)
|
|
1461
|
+
throw new Error("message exceeds 48000 characters");
|
|
1462
|
+
await this.sessionMessageRouter({
|
|
1463
|
+
sourceSessionId,
|
|
1464
|
+
target,
|
|
1465
|
+
message,
|
|
1466
|
+
catalog,
|
|
1467
|
+
});
|
|
1468
|
+
return target;
|
|
1469
|
+
},
|
|
1470
|
+
};
|
|
2174
1471
|
}
|
|
2175
1472
|
/**
|
|
2176
|
-
*
|
|
2177
|
-
*
|
|
2178
|
-
*
|
|
2179
|
-
*
|
|
2180
|
-
*
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
1473
|
+
* Await the parallel prompt/context assembly (LLM client handshake, system
|
|
1474
|
+
* prompt, dynamic + user context messages), splice the hook-injected and
|
|
1475
|
+
* context messages into `messages`, publish this run's last-session snapshot,
|
|
1476
|
+
* and prime the {@link ContextManager}'s transcript path + replacement state.
|
|
1477
|
+
* Extracted verbatim from the {@link runExclusive} skeleton.
|
|
1478
|
+
*/
|
|
1479
|
+
async assembleRunPrompts(args) {
|
|
1480
|
+
const { session, messages, hookMessages, promptComposer, toolDefs, llmClientPromise, contextManager, profile, profileParams, } = args;
|
|
1481
|
+
const [llmClient, baseSystemPrompt, dynamicContextMsg] = await Promise.all([
|
|
1482
|
+
llmClientPromise,
|
|
1483
|
+
// System prompt is now the STABLE prefix only — skills + git status moved
|
|
1484
|
+
// out to a trailing per-turn message so they no longer bust the cache.
|
|
1485
|
+
promptComposer.buildSystemPrompt(toolDefs),
|
|
1486
|
+
promptComposer.buildDynamicContextMessage(),
|
|
1487
|
+
]);
|
|
1488
|
+
const fullSystemPrompt = composeRunSystemPrompt({
|
|
1489
|
+
baseSystemPrompt,
|
|
1490
|
+
profile,
|
|
1491
|
+
profileParams,
|
|
1492
|
+
});
|
|
1493
|
+
const userContextMsg = promptComposer.buildUserContextMessage();
|
|
1494
|
+
assembleRunMessages({
|
|
1495
|
+
messages,
|
|
1496
|
+
userContextMsg,
|
|
1497
|
+
hookMessages,
|
|
1498
|
+
dynamicContextMsg,
|
|
1499
|
+
});
|
|
1500
|
+
this.lastSessionId = session.state.sessionId;
|
|
1501
|
+
this.lastMessages = messages;
|
|
1502
|
+
// Wire up LLM summarization for context compaction
|
|
1503
|
+
// Uses a lightweight call without tools
|
|
1504
|
+
contextManager.setTranscriptPath(session.transcript.getFilePath());
|
|
1505
|
+
// Re-derive frozen persistence decisions from the messages we just
|
|
1506
|
+
// loaded. Skipped on cold start (messages == [userContextMsg] only).
|
|
1507
|
+
// Critical for resume — otherwise a result that was persisted last
|
|
1508
|
+
// run would be evaluated fresh and might get a different replacement
|
|
1509
|
+
// string than the one already in the message, breaking idempotency.
|
|
1510
|
+
contextManager.initReplacementStateFromMessages(messages);
|
|
1511
|
+
// Two summarizers with DIFFERENT quality needs (see the run loop wiring for
|
|
1512
|
+
// the aux-model summarizer set up alongside the ModelFacade).
|
|
1513
|
+
return { llmClient, fullSystemPrompt, dynamicContextMsg, userContextMsg };
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Seed the run's {@link ContextManager}, emit the early `session_started`
|
|
1517
|
+
* event, replay the last TodoWrite snapshot on resume, kick off the LLM client
|
|
1518
|
+
* handshake, and build the permission-gated {@link ToolExecutor}. Extracted
|
|
1519
|
+
* verbatim from the {@link runExclusive} skeleton; the todo snapshot is read
|
|
1520
|
+
* and written through the `getLatestTodos` / `setLatestTodos` accessors so the
|
|
1521
|
+
* outer wrapped-onStream and TaskGuard keep observing the same buffer.
|
|
1522
|
+
*/
|
|
1523
|
+
wireRunContextAndPermission(args) {
|
|
1524
|
+
const { session, sid, options, cwd, toolCtx, runPermissionMode, messages, getLatestTodos, setLatestTodos, } = args;
|
|
1525
|
+
const { contextManager, ctxSeed } = createRunContextManager({
|
|
1526
|
+
maxTokens: this.resolveMaxContextTokens(),
|
|
1527
|
+
ratios: this.resolveContextRatios(),
|
|
1528
|
+
persistedAnchor: session.state.contextUsageAnchor,
|
|
1529
|
+
llmProvider: this.config.llm.provider,
|
|
1530
|
+
llmModel: this.config.llm.model,
|
|
1531
|
+
messages,
|
|
1532
|
+
needsCtxSeed: !this.ctxSeedSent.has(sid),
|
|
1533
|
+
});
|
|
1534
|
+
this.lastContextManager = contextManager;
|
|
1535
|
+
if (!this.ctxSeedSent.has(sid))
|
|
1536
|
+
this.ctxSeedSent.add(sid);
|
|
1537
|
+
// Tell the client the sid *now* instead of waiting for run() to resolve.
|
|
1538
|
+
// The user wants `/sid` to work mid-turn; without this, the client only
|
|
1539
|
+
// learns the sid when the run completes.
|
|
1540
|
+
options?.onStream?.({
|
|
1541
|
+
type: "session_started",
|
|
1542
|
+
sessionId: sid,
|
|
1543
|
+
promptTokens: ctxSeed.tokens,
|
|
1544
|
+
promptTokensSource: ctxSeed.source,
|
|
1545
|
+
promptTokensConfidence: ctxSeed.confidence,
|
|
1546
|
+
});
|
|
1547
|
+
// Replay the last TodoWrite snapshot on resume so the UI's pinned
|
|
1548
|
+
// task panel re-hydrates without the LLM needing to call TodoWrite
|
|
1549
|
+
// again. Scans the resumed transcript newest-first (and tolerates
|
|
1550
|
+
// legacy TaskCreate/Update events for sessions recorded against
|
|
1551
|
+
// the pre-2026-05-24 API). New sessions have no transcript yet so
|
|
1552
|
+
// readLastTodoSnapshot returns null and nothing is emitted.
|
|
1553
|
+
if (options?.sessionId) {
|
|
1554
|
+
const snap = readLastTodoSnapshot(session.transcript.getEvents());
|
|
1555
|
+
if (snap && snap.length > 0) {
|
|
1556
|
+
setLatestTodos(snap);
|
|
1557
|
+
options?.onStream?.({ type: "task_update", tasks: snap });
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
// Kick off LLM client creation early (network handshake)
|
|
1561
|
+
const llmClientPromise = createLLMClient(this.config.llm, this.config.clientDefaults);
|
|
1562
|
+
// MCP connection below may keep us from awaiting this promise for a while.
|
|
1563
|
+
// Observe rejection immediately so a fast client-init failure cannot become
|
|
1564
|
+
// an unhandledRejection during that gap; Promise.all still receives the
|
|
1565
|
+
// original promise and routes the same error through the lifecycle catch.
|
|
1566
|
+
void llmClientPromise.catch(() => { });
|
|
1567
|
+
const mode = runPermissionMode;
|
|
1568
|
+
const { toolExecutor } = buildRunPermissionPipeline({
|
|
1569
|
+
permissionController: this.permissionController,
|
|
1570
|
+
mode,
|
|
1571
|
+
cwd,
|
|
1572
|
+
approvalRouter: toolCtx.approvalRouter,
|
|
1573
|
+
sessionId: session.state.sessionId,
|
|
1574
|
+
toolRegistry: this.toolRegistry,
|
|
1575
|
+
hooks: this.hooks,
|
|
1576
|
+
toolCtx,
|
|
1577
|
+
signal: options?.signal,
|
|
1578
|
+
readOnlySession: this.config.readOnlySession === true,
|
|
1579
|
+
headless: this.config.headless === true,
|
|
1580
|
+
getLatestTodos: () => getLatestTodos(),
|
|
1581
|
+
onApprovalPhase: (waiting, toolName) => {
|
|
1582
|
+
options?.onAgentProgress?.({
|
|
1583
|
+
type: "phase",
|
|
1584
|
+
phase: waiting ? "waiting-permission" : "tool",
|
|
1585
|
+
toolName,
|
|
1586
|
+
});
|
|
1587
|
+
},
|
|
1588
|
+
emitNotificationHook: (payload) => {
|
|
1589
|
+
void this.emitHook("notification", payload);
|
|
1590
|
+
},
|
|
1591
|
+
});
|
|
1592
|
+
return { contextManager, llmClientPromise, toolExecutor };
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* Resolve the run sandbox, construct the child sub-agent spawner (the sole
|
|
1596
|
+
* `new Engine(...)` call path, kept in engine.ts so the protocol bypass guard
|
|
1597
|
+
* stays satisfied), and assemble the per-run {@link ToolContext}. Extracted
|
|
1598
|
+
* verbatim from the {@link runExclusive} skeleton; the session bundle is read
|
|
1599
|
+
* lazily via `getSession` because it is opened after this wiring runs.
|
|
1600
|
+
*/
|
|
1601
|
+
async wireRunSandboxToolContext(args) {
|
|
1602
|
+
const { options, cwd, runPermissionMode, runPlanMode, profile, profileParams, sessionProfileOverrides, profileMemoryDir, getSession, reportResult, } = args;
|
|
1603
|
+
// Resolve before constructing the child spawner: a parent sandbox may come
|
|
1604
|
+
// solely from project/user settings rather than config.sandbox, while a
|
|
1605
|
+
// child intentionally skips project settings. Passing the complete
|
|
1606
|
+
// effective config is what makes undefined role sandbox mean inherit.
|
|
1607
|
+
const sandboxConfig = this.runEnvironmentResolver.resolveSandboxConfig(cwd);
|
|
1608
|
+
// Build the per-Engine ToolContext that will be threaded through every
|
|
1609
|
+
// tool call. Replaces the old module-level singleton setters used by
|
|
1610
|
+
// built-ins and product capabilities.
|
|
1611
|
+
const subAgentSpawner = createSubAgentSpawner({
|
|
1612
|
+
parentConfig: this.config,
|
|
1613
|
+
parentSandbox: sandboxConfig,
|
|
1614
|
+
presetName: this.preset.name,
|
|
1615
|
+
cwd,
|
|
1616
|
+
permissionMode: runPermissionMode,
|
|
1617
|
+
modelPool: this.modelPool,
|
|
1618
|
+
parentStream: options?.onStream,
|
|
1619
|
+
appendParentSubagent: (agentId, description) => {
|
|
1620
|
+
getSession().transcript.appendSubagent(agentId, undefined, description);
|
|
1621
|
+
},
|
|
1622
|
+
sessionExists: (sessionId) => this.sessionManager.exists(sessionId),
|
|
1623
|
+
getSessionParentId: (sessionId) => this.sessionManager.readParentSessionId(sessionId),
|
|
1624
|
+
childRunner: {
|
|
1625
|
+
createChild: (config) => new Engine(config),
|
|
1626
|
+
runChild: async (config, childTask, childOptions) => {
|
|
1627
|
+
const child = new Engine(config);
|
|
1628
|
+
return child.run(childTask, childOptions);
|
|
1629
|
+
},
|
|
1630
|
+
},
|
|
1631
|
+
});
|
|
1632
|
+
// A2: explicit sandbox modes (seatbelt, bwrap) must fail closed
|
|
1633
|
+
// per standard §S4. resolveSandboxBackend throws when an explicit
|
|
1634
|
+
// mode is unavailable on this host; we let it propagate. The
|
|
1635
|
+
// previous behavior — catching the throw inside the hot turn and
|
|
1636
|
+
// silently downgrading to "off" — was the leak A2 closes. The
|
|
1637
|
+
// `auto` mode handles its own downgrade with a one-time warning
|
|
1638
|
+
// inside resolveSandboxBackend; explicit modes do not.
|
|
1639
|
+
//
|
|
1640
|
+
// Backend is cached per runtime/engine so the capability probe runs once
|
|
1641
|
+
// per (mode, cwd) instead of every turn.
|
|
1642
|
+
const sandboxBackend = await this.runEnvironmentResolver.resolveSandbox(cwd);
|
|
1643
|
+
// Observability: surface what sandbox actually applied this run — the
|
|
1644
|
+
// configured mode vs the resolved backend (auto may downgrade to off when
|
|
1645
|
+
// no OS backend is available) + the network policy. Without this you can't
|
|
1646
|
+
// tell whether shell commands were isolated /网络放没放. One line per run.
|
|
1647
|
+
logger.info("sandbox.resolved", {
|
|
1648
|
+
mode: sandboxConfig.mode,
|
|
1649
|
+
backend: sandboxBackend.name,
|
|
1650
|
+
isolated: sandboxBackend.name !== "off",
|
|
1651
|
+
network: sandboxConfig.network,
|
|
1652
|
+
cwd,
|
|
1653
|
+
});
|
|
1654
|
+
const toolCtx = buildRunToolContext({
|
|
1655
|
+
base: this.buildToolContext(cwd, sessionProfileOverrides, profileMemoryDir),
|
|
1656
|
+
options,
|
|
1657
|
+
configApprovalRouter: this.config.approvalRouter,
|
|
1658
|
+
runPermissionMode,
|
|
1659
|
+
runPlanMode,
|
|
1660
|
+
subAgentSpawner,
|
|
1661
|
+
agentDefinitions: this.getAgentDefinitions(cwd, sessionProfileOverrides),
|
|
1662
|
+
sandbox: sandboxBackend.name === "off"
|
|
1663
|
+
? sandboxBackend
|
|
1664
|
+
: { ...sandboxBackend, network: sandboxConfig.network },
|
|
1665
|
+
cwd,
|
|
1666
|
+
shellEnv: this.runEnvironmentResolver.readShellEnv(cwd),
|
|
1667
|
+
profile,
|
|
1668
|
+
profileParams,
|
|
1669
|
+
reportResult,
|
|
1670
|
+
});
|
|
1671
|
+
return toolCtx;
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Wire every run-scoped dependency the turn loop needs: usage accounting +
|
|
1675
|
+
* summarizer, the {@link ModelFacade}, the file-history hook, the
|
|
1676
|
+
* `on_agent_start` hook, goal resolution / arming, the {@link TurnLoop} itself,
|
|
1677
|
+
* and the goal-termination applier. Extracted verbatim from the
|
|
1678
|
+
* {@link runExclusive} skeleton; the goal slots, judge-context buffer and usage
|
|
1679
|
+
* baseline are fully local here — only the values the try/finally + finalize
|
|
1680
|
+
* phases consume cross back out.
|
|
1681
|
+
*/
|
|
1682
|
+
async wireRunLoop(args) {
|
|
1683
|
+
const { session, sid, task, cwd, options, toolCtx, toolExecutor, contextManager, llmClient, auxSummaryClient, fullSystemPrompt, toolDefs, claimClientMessageId, releaseClientMessageId, freshImageMessage, dynamicContextMsg, } = args;
|
|
1684
|
+
// eslint-disable-next-line prefer-const
|
|
1685
|
+
let turnLoop;
|
|
1686
|
+
const accounting = createRunUsageAccounting({
|
|
1687
|
+
session,
|
|
1688
|
+
sid,
|
|
1689
|
+
resumeState: (s) => this.sessionManager.resume(s).state,
|
|
1690
|
+
updatePersistedSessionState: (s, patch) => this.updatePersistedSessionState(s, patch),
|
|
1691
|
+
costStore: this.config.costStore,
|
|
1692
|
+
recordGoalJudgeUsage: (usage) => turnLoop.recordGoalJudgeUsage(usage),
|
|
1693
|
+
});
|
|
1694
|
+
const { recordCumulativeUsage, recordExternalBilledUsage } = accounting;
|
|
1695
|
+
contextManager.setSummarizeFn(this.buildSummarizeFn(llmClient, recordExternalBilledUsage));
|
|
1696
|
+
const { modelFacade, getRunUsage } = wireRunModelFacade({
|
|
1697
|
+
llmClient,
|
|
1698
|
+
auxSummaryClient,
|
|
1699
|
+
transcript: session.transcript,
|
|
1700
|
+
accounting,
|
|
1701
|
+
});
|
|
1702
|
+
// Session-cumulative usage baseline: the LLM client is recreated per run
|
|
1703
|
+
// (its getUsage() counts only THIS run), so to accumulate across runs we
|
|
1704
|
+
// capture the persisted total at run start and fold this run's usage onto
|
|
1705
|
+
// it (see foldRunUsage). Snapshot now, before any turn boundary fires.
|
|
1706
|
+
const usageBaseline = { ...session.state.tokenUsage };
|
|
1707
|
+
const sessionDir = join(this.config.sessionStorageDir ?? sessionsRoot(), session.state.sessionId);
|
|
1708
|
+
const fileHistoryHook = registerFileHistoryHook({
|
|
1709
|
+
hooks: this.hooks,
|
|
1710
|
+
sessionDir,
|
|
1711
|
+
cwd,
|
|
1712
|
+
getTurnSeq: () => session.state.turnSeq,
|
|
1713
|
+
contributions: this.capabilities.flatMap((capability) => [...(capability.fileHistory ?? [])]),
|
|
1714
|
+
});
|
|
1715
|
+
// Hook: agent start
|
|
1716
|
+
await this.emitHook("on_agent_start", {
|
|
1717
|
+
sessionId: session.state.sessionId,
|
|
1718
|
+
task,
|
|
1719
|
+
model: this.config.llm.model,
|
|
1720
|
+
}, options?.signal);
|
|
1721
|
+
// Goal mode: register a GoalStopHook for the lifetime of THIS run so the
|
|
1722
|
+
// turn loop keeps going until the session model judges the goal met.
|
|
1723
|
+
// Registered per-run (and cleared in `finally`) so a later goal-less
|
|
1724
|
+
// send doesn't inherit a stale goal. The judge runs on the primary
|
|
1725
|
+
// session client; auxSummaryClient remains dedicated to low-consequence
|
|
1726
|
+
// summaries/titles and retains defaults.auxText routing/fallback behavior.
|
|
1727
|
+
// Normalize the raw goal (string | GoalConfig) once at the run boundary;
|
|
1728
|
+
// everything inward uses the GoalConfig. normalizeGoal() returns undefined
|
|
1729
|
+
// when there's effectively no goal (empty objective).
|
|
1730
|
+
//
|
|
1731
|
+
// PERSISTENT GOAL (CC /goal style): a goal set on one send survives across
|
|
1732
|
+
// later sends and manual interrupts until met or cleared. Goal completion
|
|
1733
|
+
// is a high-consequence decision, so V1 routes it to the primary session
|
|
1734
|
+
// client, which is the model expected to interpret the supplied execution
|
|
1735
|
+
// evidence. defaults.auxText remains in force for summaries, titles and
|
|
1736
|
+
// other auxiliary work through auxSummaryClient.
|
|
1737
|
+
// Resolution:
|
|
1738
|
+
// 1. options.goal — this send explicitly sets/replaces the goal.
|
|
1739
|
+
// 2. session.state.goalLifecycle — a goal set on an earlier send.
|
|
1740
|
+
// 3. config.goal — engine-level default (rare; e.g. headless).
|
|
1741
|
+
// When (1) supplies a goal that differs from the stored one we REPLACE the
|
|
1742
|
+
// persisted active goal (one active goal per session) and announce it. A
|
|
1743
|
+
// bare send with no options.goal inherits the stored active goal so the
|
|
1744
|
+
// model keeps working toward it — that's what makes it persistent.
|
|
1745
|
+
const goalSlots = {
|
|
1746
|
+
getActiveRuntimeGoal: () => this.activeRuntimeGoal,
|
|
1747
|
+
setActiveRuntimeGoal: (g) => {
|
|
1748
|
+
this.activeRuntimeGoal = g;
|
|
1749
|
+
},
|
|
1750
|
+
getActivePersistedRunGoal: () => this.activePersistedRunGoal,
|
|
1751
|
+
setActivePersistedRunGoal: (g) => {
|
|
1752
|
+
this.activePersistedRunGoal = g;
|
|
1753
|
+
},
|
|
1754
|
+
getActiveGoalHook: () => this.activeGoalHook,
|
|
1755
|
+
setActiveGoalHook: (h) => {
|
|
1756
|
+
this.activeGoalHook = h;
|
|
1757
|
+
},
|
|
1758
|
+
setActiveGoalHookAttached: (a) => {
|
|
1759
|
+
this.activeGoalHookAttached = a;
|
|
1760
|
+
},
|
|
1761
|
+
};
|
|
1762
|
+
const { normalizedGoal, persistedRunGoal } = resolveRunGoal({
|
|
1763
|
+
options,
|
|
1764
|
+
session,
|
|
1765
|
+
sessionManager: this.sessionManager,
|
|
1766
|
+
configGoal: this.config.goal,
|
|
1767
|
+
isSubAgent: this.config.isSubAgent === true,
|
|
1768
|
+
sid,
|
|
1769
|
+
onStream: options?.onStream,
|
|
1770
|
+
});
|
|
1771
|
+
let latestGoalJudgeContext;
|
|
1772
|
+
const goalHookHandler = armRunGoalHook({
|
|
1773
|
+
slots: goalSlots,
|
|
1774
|
+
hooks: this.hooks,
|
|
1775
|
+
llmClient,
|
|
1776
|
+
isSubAgent: this.config.isSubAgent === true,
|
|
1777
|
+
normalizedGoal,
|
|
1778
|
+
persistedRunGoal,
|
|
1779
|
+
session,
|
|
1780
|
+
sessionManager: this.sessionManager,
|
|
1781
|
+
persistGoalTerminal: (state, goal, reason) => this.persistGoalTerminal(state, goal, reason),
|
|
1782
|
+
getJudgeContext: () => latestGoalJudgeContext,
|
|
1783
|
+
recordCumulativeUsage,
|
|
1784
|
+
recordGoalJudgeUsage: (usage) => turnLoop.recordGoalJudgeUsage(usage),
|
|
1785
|
+
});
|
|
1786
|
+
turnLoop = this.buildTurnLoop({
|
|
1787
|
+
modelFacade,
|
|
1788
|
+
toolExecutor,
|
|
1789
|
+
contextManager,
|
|
1790
|
+
session,
|
|
1791
|
+
fullSystemPrompt,
|
|
1792
|
+
toolDefs,
|
|
1793
|
+
sid,
|
|
1794
|
+
options,
|
|
1795
|
+
cwd,
|
|
1796
|
+
claimClientMessageId,
|
|
1797
|
+
releaseClientMessageId,
|
|
1798
|
+
toolCtx,
|
|
1799
|
+
persistedRunGoal,
|
|
1800
|
+
goalHookHandler,
|
|
1801
|
+
normalizedGoal,
|
|
1802
|
+
freshImageMessage,
|
|
1803
|
+
dynamicContextMsg,
|
|
1804
|
+
usageBaseline,
|
|
1805
|
+
getRunUsage,
|
|
1806
|
+
recordCumulativeUsage,
|
|
1807
|
+
publishGoalJudgeContext: (context) => {
|
|
1808
|
+
latestGoalJudgeContext = context;
|
|
1809
|
+
},
|
|
1810
|
+
});
|
|
1811
|
+
toolCtx.recordBilledUsage = recordExternalBilledUsage;
|
|
1812
|
+
// Expose this run's loop for mid-run extension (TODO 3.1). Top-level only —
|
|
1813
|
+
// a sub-agent's loop is its own concern and isn't user-extendable.
|
|
1814
|
+
if (this.config.isSubAgent !== true)
|
|
1815
|
+
this.activeTurnLoop = turnLoop;
|
|
1816
|
+
// Expose this run's session bundle so a mid-run clearGoal() wipes the goal
|
|
1817
|
+
// on the very instance this loop keeps saving (see field doc). Top-level
|
|
1818
|
+
// only — sub-agents don't carry user-clearable persistent goals.
|
|
1819
|
+
if (this.config.isSubAgent !== true)
|
|
1820
|
+
this.activeRunSession = session;
|
|
1821
|
+
const applyGoalTermination = createGoalTerminationApplier({
|
|
1822
|
+
slots: goalSlots,
|
|
1823
|
+
hooks: this.hooks,
|
|
1824
|
+
session,
|
|
1825
|
+
persistedRunGoal,
|
|
1826
|
+
goalHookHandler,
|
|
1827
|
+
persistGoalTerminalOutcome: (state, goal, t) => this.persistGoalTerminalOutcome(state, goal, t),
|
|
1828
|
+
readActiveGoal: (s) => this.sessionManager.readActiveGoal(s),
|
|
1829
|
+
onStream: options?.onStream,
|
|
1830
|
+
});
|
|
1831
|
+
return {
|
|
1832
|
+
turnLoop,
|
|
1833
|
+
applyGoalTermination,
|
|
1834
|
+
goalHookHandler,
|
|
1835
|
+
fileHistoryHook,
|
|
1836
|
+
getRunUsage,
|
|
1837
|
+
recordExternalBilledUsage,
|
|
1838
|
+
accounting,
|
|
1839
|
+
usageBaseline,
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
/**
|
|
1843
|
+
* Record the session start and fire the once-per-run `on_session_start` and
|
|
1844
|
+
* per-turn `user_prompt_submit` / `agent_direction_submit` hooks, applying any
|
|
1845
|
+
* `updatedPrompt` rewrite in place on `messages`. Extracted verbatim from the
|
|
1846
|
+
* {@link runExclusive} skeleton; returns the combined hook-injected messages
|
|
1847
|
+
* for {@link assembleRunMessages}.
|
|
1848
|
+
*/
|
|
1849
|
+
async runSessionStartHooks(args) {
|
|
1850
|
+
const { session, task, cwd, runPermissionMode, resumedFromDisk, options, taskText, messages } = args;
|
|
1851
|
+
recordSessionStart(session.state.sessionId, {
|
|
1852
|
+
// Strip <codeshell-image> base64 payloads before they reach
|
|
1853
|
+
// <repo>/log/. Reader still sees the marker + byte count, just
|
|
1854
|
+
// not the bytes. Transcript persistence keeps the full payload.
|
|
1855
|
+
task: sanitizeTaskString(task),
|
|
1856
|
+
cwd,
|
|
1857
|
+
model: this.config.llm.model,
|
|
1858
|
+
provider: this.config.llm.provider,
|
|
1859
|
+
permissionMode: runPermissionMode,
|
|
1860
|
+
resumed: resumedFromDisk,
|
|
1861
|
+
});
|
|
1862
|
+
// Session-level hook: fired once per Engine.run() entry, regardless of
|
|
1863
|
+
// cold-start vs resume. Handlers can return `messages` to inject a
|
|
1864
|
+
// <system-reminder> at the head of the conversation (between
|
|
1865
|
+
// userContext and the new user prompt). Used by the built-in
|
|
1866
|
+
// superpowers injector to surface the `using-superpowers` ruleset.
|
|
1867
|
+
const sessionStartHook = await this.emitHook("on_session_start", {
|
|
1868
|
+
sessionId: session.state.sessionId,
|
|
1869
|
+
cwd,
|
|
1870
|
+
resumed: resumedFromDisk,
|
|
1871
|
+
source: resumedFromDisk ? "resume" : "startup",
|
|
1872
|
+
}, options?.signal);
|
|
1873
|
+
// Per-turn hook: fired every time a new user prompt enters the loop.
|
|
1874
|
+
// Equivalent to CC's UserPromptSubmit. Handlers can inject lightweight
|
|
1875
|
+
// reminders that should accompany each user turn (e.g. "skills
|
|
1876
|
+
// available — check before acting").
|
|
1877
|
+
const promptSubmitHook = await this.emitHook(options?.agentDirection ? "agent_direction_submit" : "user_prompt_submit", {
|
|
1878
|
+
sessionId: session.state.sessionId,
|
|
1879
|
+
// Pass the text-only portion. Handlers reading the prompt for keyword
|
|
1880
|
+
// detection / classification (e.g. superpowers' "did the user ask
|
|
1881
|
+
// about X?") don't gain anything from megabytes of base64 inlined here,
|
|
1882
|
+
// and silently leaking attachment bytes through hooks is the kind of
|
|
1883
|
+
// exfiltration risk a curious user-installed shell hook shouldn't carry.
|
|
1884
|
+
prompt: taskText,
|
|
1885
|
+
resumed: resumedFromDisk,
|
|
1886
|
+
...(options?.agentDirection
|
|
1887
|
+
? {
|
|
1888
|
+
source: "agent-direction",
|
|
1889
|
+
authority: "agent",
|
|
1890
|
+
envelopeIds: options.agentDirection.envelopeIds,
|
|
1891
|
+
correlationIds: options.agentDirection.correlationIds,
|
|
1892
|
+
}
|
|
1893
|
+
: {}),
|
|
1894
|
+
}, options?.signal);
|
|
1895
|
+
// updatedPrompt: handler rewrote the user's prompt text. Replace the
|
|
1896
|
+
// last user message we just pushed (cold-start: line ~511; resume:
|
|
1897
|
+
// line ~500). Original prompt is in the transcript already — we log
|
|
1898
|
+
// the rewrite so audit chains know a hook touched user input.
|
|
1899
|
+
if (typeof promptSubmitHook.updatedPrompt === "string") {
|
|
1900
|
+
const lastIdx = messages.length - 1;
|
|
1901
|
+
const last = messages[lastIdx];
|
|
1902
|
+
if (last && last.role === "user" && typeof last.content === "string") {
|
|
1903
|
+
logger.info("hook.updated_prompt", {
|
|
1904
|
+
sessionId: session.state.sessionId,
|
|
1905
|
+
originalChars: last.content.length,
|
|
1906
|
+
updatedChars: promptSubmitHook.updatedPrompt.length,
|
|
1907
|
+
});
|
|
1908
|
+
messages[lastIdx] = { role: "user", content: promptSubmitHook.updatedPrompt };
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
return [...(sessionStartHook.messages ?? []), ...(promptSubmitHook.messages ?? [])];
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Resolve goal visibility, disabled skill/plugin lists, build the
|
|
1915
|
+
* {@link PromptComposer}, connect MCP, and assemble the visibility-filtered
|
|
1916
|
+
* tool defs for this run. Extracted verbatim from the {@link runExclusive}
|
|
1917
|
+
* skeleton; the intermediate visibility/disabled/mcp values are fully local to
|
|
1918
|
+
* this method — only the composer and tool defs cross back out.
|
|
1919
|
+
*/
|
|
1920
|
+
async wireRunTooling(args) {
|
|
1921
|
+
const { options, session, cwd, toolCtx, profile, profileParams, runWorkspaceProfile, profileMemoryDir, sessionProfileOverrides, runPlanMode, } = args;
|
|
1922
|
+
const visibilityExplicitGoal = normalizeGoal(options?.goal);
|
|
1923
|
+
const visibilityLifecycle = session.state.goalLifecycle;
|
|
1924
|
+
const visibilityStoredGoal = visibilityLifecycle && isGoalLifecycleCurrent(visibilityLifecycle)
|
|
1925
|
+
? goalConfigFromLifecycle(visibilityLifecycle)
|
|
1926
|
+
: undefined;
|
|
1927
|
+
const visibilityDefaultGoal = normalizeGoal(this.config.goal);
|
|
1928
|
+
const hasRunnableGoal = this.config.isSubAgent !== true &&
|
|
1929
|
+
((visibilityExplicitGoal !== undefined && visibilityExplicitGoal.paused !== true) ||
|
|
1930
|
+
(visibilityStoredGoal !== undefined && visibilityStoredGoal.paused !== true) ||
|
|
1931
|
+
(visibilityDefaultGoal !== undefined && visibilityDefaultGoal.paused !== true));
|
|
1932
|
+
const { disabledSkills, disabledPlugins } = this.readDisabledLists(cwd, sessionProfileOverrides);
|
|
1933
|
+
const promptComposer = new PromptComposer(buildPromptComposerConfig({
|
|
1934
|
+
cwd,
|
|
1935
|
+
model: this.config.llm.model,
|
|
1936
|
+
preset: this.preset,
|
|
1937
|
+
customSystemPrompt: this.config.customSystemPrompt,
|
|
1938
|
+
appendSystemPrompt: [this.config.appendSystemPrompt, profile?.systemPromptAppend]
|
|
1939
|
+
.filter(Boolean)
|
|
1940
|
+
.join("\n\n") || undefined,
|
|
1941
|
+
responseLanguage: this.config.responseLanguage,
|
|
1942
|
+
userProfile: this.config.userProfile,
|
|
1943
|
+
workspaceProfile: runWorkspaceProfile,
|
|
1944
|
+
// Read from the Session's own persisted state, so every turn — not just
|
|
1945
|
+
// the first — carries the standing brief.
|
|
1946
|
+
sessionBrief: session.state.sessionBrief,
|
|
1947
|
+
profileMemoryDir,
|
|
1948
|
+
instructionCompatFileNames: compatFileNamesFrom(this.config.instructions),
|
|
1949
|
+
instructionBoundaryFinder: (scanCwd) => resolveInstructionBoundary(scanCwd, this.capabilities),
|
|
1950
|
+
disabledSkills,
|
|
1951
|
+
disabledPlugins,
|
|
1952
|
+
skillAllowlist: this.config.skillAllowlist,
|
|
1953
|
+
memoriesMaxAgeDays: this.readMemoriesConfig()?.maxAge,
|
|
1954
|
+
goalToolState: { hasGoal: hasRunnableGoal },
|
|
1955
|
+
capabilityPromptSections: this.capabilityPromptSections,
|
|
1956
|
+
dynamicContextProviders: this.capabilityDynamicContextProviders,
|
|
1957
|
+
getSettingsManager: () => this.getSettingsManager(),
|
|
1958
|
+
toolCatalog: this.toolCatalog,
|
|
1959
|
+
}));
|
|
1960
|
+
const mcpServers = this.config.mcpServers ?? {};
|
|
1961
|
+
const mcpDisabled = profile?.disableMcp === true;
|
|
1962
|
+
await connectRunMcp({
|
|
1963
|
+
mcpServers,
|
|
1964
|
+
mcpDisabled,
|
|
1965
|
+
getManager: () => this.mcpManager,
|
|
1966
|
+
setManager: (m) => {
|
|
1967
|
+
this.mcpManager = m;
|
|
1968
|
+
},
|
|
1969
|
+
runtimePool: this.runtime?.mcpPool,
|
|
1970
|
+
toolRegistry: this.toolRegistry,
|
|
1971
|
+
engineForConnect: this,
|
|
1972
|
+
emitNotificationHook: (payload) => {
|
|
1973
|
+
void this.emitHook("notification", payload);
|
|
1974
|
+
},
|
|
1975
|
+
});
|
|
1976
|
+
// Parallelize slow initialization:
|
|
1977
|
+
// 1. createLLMClient — network handshake (started earlier)
|
|
1978
|
+
// 2. buildSystemPrompt — cacheable prompt assembled from generic sections
|
|
1979
|
+
// 3. buildSystemContext — reads environment context
|
|
1980
|
+
// Inject the live available-agent-types listing into the Agent tool's
|
|
1981
|
+
// description. The registry is per-engine (loaded from .code-shell/agents
|
|
1982
|
+
// for this cwd), so it can't live in the static tool def — without this
|
|
1983
|
+
// the model never learns the reusable roles exist and spawns nameless
|
|
1984
|
+
// ad-hoc agents instead (the Core A/B/C incident).
|
|
1985
|
+
// The Agent tool is always available: with configured roles, an omitted
|
|
1986
|
+
// agent_type falls back to one of them (see resolveAgentTypeOverrides); with
|
|
1987
|
+
// no roles configured it runs a true ephemeral agent, so workflows that need
|
|
1988
|
+
// sub-agents (e.g. superpowers) work in any project.
|
|
1989
|
+
// Availability guard (tool-visibility): a gated builtin (WebSearch needs a
|
|
1990
|
+
// search provider, GenerateImage needs an OpenAI provider) is hidden from
|
|
1991
|
+
// the toolDefs the model sees when its credential isn't configured for this
|
|
1992
|
+
// cwd. Recomputed every message, so configuring a key takes effect on the
|
|
1993
|
+
// NEXT message without a restart. Tools with no guard entry are always kept.
|
|
1994
|
+
const toolDefs = assembleRunToolDefs({
|
|
2200
1995
|
toolRegistry: this.toolRegistry,
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
1996
|
+
toolCtx,
|
|
1997
|
+
guardCwd: toolCtx.cwd,
|
|
1998
|
+
hasRunnableGoal,
|
|
1999
|
+
settingsScope: this.config.settingsScope ?? "project",
|
|
2000
|
+
builtinToolHost: this.config.builtinToolHost,
|
|
2001
|
+
isSubAgent: this.config.isSubAgent === true,
|
|
2002
|
+
behaviorProfileId: profile?.id ?? options?.behaviorMode,
|
|
2003
|
+
profileMeta: profile?.buildVisibilityMeta?.(profileParams),
|
|
2004
|
+
builtinOverride: this.readBuiltinOverride(toolCtx.cwd, sessionProfileOverrides),
|
|
2005
|
+
mcpServers: this.config.mcpServers ?? {},
|
|
2006
|
+
mcpDisabled,
|
|
2007
|
+
featureFlags: this.readFeatureFlags(),
|
|
2008
|
+
toolGuards: this.toolGuards,
|
|
2009
|
+
toolRewriters: this.toolRewriters,
|
|
2010
|
+
toolFeatureFlags: TOOL_FEATURE_FLAGS,
|
|
2011
|
+
applyBuiltinOverrideVisibility,
|
|
2012
|
+
profileAllowedToolNames: profile?.allowedToolNames,
|
|
2013
|
+
runPlanMode,
|
|
2014
|
+
});
|
|
2015
|
+
return { promptComposer, toolDefs };
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* Assemble the run-scoped {@link TurnLoop} (its dependency object + option
|
|
2019
|
+
* object) from the per-run locals gathered in {@link runExclusive}. Extracted
|
|
2020
|
+
* verbatim from the orchestration skeleton so `runExclusive` stays a readable
|
|
2021
|
+
* sequence of phase calls; the buffered-compaction `let` lives entirely inside
|
|
2022
|
+
* here now, and the only value crossing back out is the goal-judge context,
|
|
2023
|
+
* published through the `publishGoalJudgeContext` callback.
|
|
2024
|
+
*/
|
|
2025
|
+
buildTurnLoop(args) {
|
|
2026
|
+
const { modelFacade, toolExecutor, contextManager, session, fullSystemPrompt, toolDefs, sid, options, cwd, claimClientMessageId, releaseClientMessageId, toolCtx, persistedRunGoal, goalHookHandler, normalizedGoal, freshImageMessage, dynamicContextMsg, usageBaseline, getRunUsage, recordCumulativeUsage, publishGoalJudgeContext, } = args;
|
|
2027
|
+
// Surface compaction events to the UI so the user knows when context was trimmed.
|
|
2028
|
+
// Buffer the most recent event so TurnLoop can drain it and emit the
|
|
2029
|
+
// post_compact hook on the next turn (ContextManager itself doesn't
|
|
2030
|
+
// know about HookRegistry — the buffer is the seam).
|
|
2031
|
+
let pendingCompactInfo = null;
|
|
2032
|
+
contextManager.setOnCompact((info) => {
|
|
2033
|
+
pendingCompactInfo = info;
|
|
2034
|
+
options?.onStream?.({ type: "context_compact", ...info });
|
|
2035
|
+
});
|
|
2036
|
+
// Run turn loop
|
|
2037
|
+
const turnLoop = new TurnLoop({
|
|
2038
|
+
model: modelFacade,
|
|
2039
|
+
toolExecutor,
|
|
2040
|
+
contextManager,
|
|
2041
|
+
hooks: this.hooks,
|
|
2042
|
+
transcript: session.transcript,
|
|
2043
|
+
systemPrompt: fullSystemPrompt,
|
|
2044
|
+
tools: toolDefs,
|
|
2045
|
+
sessionId: sid,
|
|
2046
|
+
isSubAgent: this.config.isSubAgent === true,
|
|
2047
|
+
consumePendingCompactInfo: () => {
|
|
2048
|
+
const info = pendingCompactInfo;
|
|
2049
|
+
pendingCompactInfo = null;
|
|
2050
|
+
return info;
|
|
2051
|
+
},
|
|
2052
|
+
consumeSteer: (source) => this.consumeSteer(sid, source),
|
|
2053
|
+
consumeAgentDirections: this.config.isSubAgent === true
|
|
2054
|
+
? () => notificationQueue.drain(sid, (envelope) => envelope.kind === "direction" &&
|
|
2055
|
+
envelope.runtimeGeneration === options?.runtimeGeneration)
|
|
2056
|
+
: undefined,
|
|
2057
|
+
onAgentControlState: this.config.isSubAgent === true
|
|
2058
|
+
? (state) => {
|
|
2059
|
+
this.agentControlStateListener?.(state);
|
|
2060
|
+
if (state === "model") {
|
|
2061
|
+
options?.onAgentProgress?.({ type: "phase", phase: "model" });
|
|
2062
|
+
}
|
|
2063
|
+
else if (state === "tool-batch") {
|
|
2064
|
+
options?.onAgentProgress?.({ type: "phase", phase: "tool" });
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
: undefined,
|
|
2068
|
+
onAgentDirectionsDelivered: this.config.isSubAgent === true
|
|
2069
|
+
? (envelopeIds) => this.agentDirectionsDeliveredListener?.(envelopeIds)
|
|
2070
|
+
: undefined,
|
|
2071
|
+
restoreSteer: (items) => this.restoreSteer(sid, items),
|
|
2072
|
+
buildSteerUserMessageContent: async (item) => {
|
|
2073
|
+
const steerImageInput = await prepareRunImageInput({
|
|
2074
|
+
task: item.text,
|
|
2075
|
+
cwd,
|
|
2076
|
+
llm: this.config.llm,
|
|
2077
|
+
sessionId: sid,
|
|
2078
|
+
attachments: item.attachments,
|
|
2079
|
+
});
|
|
2080
|
+
if (!steerImageInput.ok) {
|
|
2081
|
+
throw new Error(steerImageInput.result.text);
|
|
2082
|
+
}
|
|
2083
|
+
return buildRunUserMessageContent(steerImageInput.parsedTask, cwd, steerImageInput.taskText);
|
|
2084
|
+
},
|
|
2085
|
+
claimClientMessageId: (clientMessageId, source) => claimClientMessageId(session, clientMessageId, source),
|
|
2086
|
+
releaseClientMessageId,
|
|
2087
|
+
setOriginClientMessageId: (clientMessageId) => {
|
|
2088
|
+
toolCtx.originClientMessageId = clientMessageId;
|
|
2089
|
+
},
|
|
2090
|
+
recordCumulativeUsage,
|
|
2091
|
+
onAgentUsage: (usage) => options?.onAgentProgress?.({ type: "usage", usage }),
|
|
2092
|
+
recordCacheReadDiagnostics: (sample) => {
|
|
2093
|
+
this.recordCacheReadDiagnostics(sid, sample);
|
|
2094
|
+
},
|
|
2095
|
+
recordContextUsageAnchor: (anchor) => {
|
|
2096
|
+
session.state.contextUsageAnchor = {
|
|
2097
|
+
...anchor,
|
|
2098
|
+
provider: this.config.llm.provider,
|
|
2099
|
+
model: this.config.llm.model,
|
|
2100
|
+
};
|
|
2101
|
+
},
|
|
2102
|
+
// Clear the persisted goal for a self-reported completion / confirmed
|
|
2103
|
+
// cancel. Clears the in-RAM session's activeGoal (so THIS run's later
|
|
2104
|
+
// turns don't re-arm) AND persists it, and drops the in-flight stop
|
|
2105
|
+
// hook so nothing re-blocks the stop we're about to return.
|
|
2106
|
+
clearPersistedGoal: (reason) => {
|
|
2107
|
+
const runGoal = this.activePersistedRunGoal ?? persistedRunGoal;
|
|
2108
|
+
if (runGoal && !this.persistGoalTerminal(session.state, runGoal, reason)) {
|
|
2109
|
+
return false;
|
|
2110
|
+
}
|
|
2111
|
+
if (goalHookHandler) {
|
|
2112
|
+
this.hooks.unregister("on_stop", goalHookHandler);
|
|
2113
|
+
if (this.activeGoalHook === goalHookHandler) {
|
|
2114
|
+
this.activeGoalHook = null;
|
|
2115
|
+
this.activeGoalHookAttached = false;
|
|
2116
|
+
this.activeRuntimeGoal = null;
|
|
2117
|
+
this.activePersistedRunGoal = null;
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
return true;
|
|
2121
|
+
},
|
|
2122
|
+
publishGoalJudgeContext: (context) => {
|
|
2123
|
+
publishGoalJudgeContext(context);
|
|
2124
|
+
},
|
|
2125
|
+
// A background_notification yield parks the run until the Session is
|
|
2126
|
+
// woken by the completion notification. Only a top-level interactive
|
|
2127
|
+
// session can be woken (server refuses headless; sub-agent sessions
|
|
2128
|
+
// are not in chatManager) — everywhere else honouring the yield would
|
|
2129
|
+
// end the run early and orphan the background result, so the loop
|
|
2130
|
+
// never sees the request and the model keeps its full turn.
|
|
2131
|
+
...(this.isHeadless() || this.config.isSubAgent === true
|
|
2132
|
+
? {}
|
|
2133
|
+
: {
|
|
2134
|
+
peekToolRunYield: () => toolCtx.runYield?.peek?.(),
|
|
2135
|
+
consumeToolRunYield: () => toolCtx.runYield?.consume(),
|
|
2136
|
+
}),
|
|
2137
|
+
ctxOverheadStore: {
|
|
2138
|
+
get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
|
|
2139
|
+
set: (s, n) => {
|
|
2140
|
+
this.ctxOverheadBySid.set(s, n);
|
|
2141
|
+
},
|
|
2142
|
+
},
|
|
2143
|
+
}, {
|
|
2144
|
+
// Goal mode raises the turn ceiling: an unattended goal run keeps
|
|
2145
|
+
// getting re-blocked by the stop-hook until it's done, and the 100
|
|
2146
|
+
// interactive default would silently truncate a long objective. The
|
|
2147
|
+
// real backstops are the goal token/time budgets + maxStopBlocks.
|
|
2148
|
+
maxTurns: resolveMaxTurns(this.config.maxTurns, normalizedGoal),
|
|
2149
|
+
// Consecutive stop-block cap: config override > goal.maxStopBlocks >
|
|
2150
|
+
// GOAL_DEFAULT_MAX_STOP_BLOCKS(25). The old hardcoded 8 was too tight
|
|
2151
|
+
// for complex goals that legitimately get re-blocked while advancing.
|
|
2152
|
+
maxStopBlocks: resolveMaxStopBlocks(this.config.maxStopBlocks, normalizedGoal),
|
|
2153
|
+
// 25 (was 10): modern models routinely batch >10 parallel tool calls
|
|
2154
|
+
// (e.g. reading a dozen files at once). At 10 the excess was silently
|
|
2155
|
+
// dropped; the turn loop now also warns the model when it caps, but a
|
|
2156
|
+
// higher ceiling avoids the round-trip in the common case. (B-3)
|
|
2157
|
+
maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 25,
|
|
2158
|
+
onStream: options?.onStream,
|
|
2159
|
+
signal: options?.signal,
|
|
2160
|
+
freshImageMessages: freshImageMessage ? [freshImageMessage] : undefined,
|
|
2161
|
+
volatileContextMessages: dynamicContextMsg ? [dynamicContextMsg] : undefined,
|
|
2162
|
+
// Goal mode: the active goal is surfaced to the on_stop handler via
|
|
2163
|
+
// ctx.data.goal; the GoalStopHook (registered above) judges it.
|
|
2164
|
+
goal: normalizedGoal,
|
|
2165
|
+
// Heartbeat: flush turnCount + tokens to state.json after every turn
|
|
2166
|
+
// so external observers (other CLI processes, /sid, the session list)
|
|
2167
|
+
// see live progress instead of a stale snapshot from the last
|
|
2168
|
+
// completed run.
|
|
2169
|
+
onTurnBoundary: (turnCount) => {
|
|
2170
|
+
session.state.turnCount = turnCount;
|
|
2171
|
+
// baseline + this run's running total (idempotent per boundary,
|
|
2172
|
+
// accumulates across runs; carries cacheRead/cacheCreation too).
|
|
2173
|
+
session.state.tokenUsage = foldRunUsage(usageBaseline, getRunUsage());
|
|
2174
|
+
// Surface the whole-session monotonic cache counts to the UI.
|
|
2175
|
+
// Separate from turn-loop's authoritative per-response emit (which
|
|
2176
|
+
// drives the live context reading and single-turn metric).
|
|
2177
|
+
const cumulative = normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage);
|
|
2178
|
+
const cumulativeHitRate = cumulativeCacheHitRate(cumulative);
|
|
2179
|
+
options?.onStream?.({
|
|
2180
|
+
type: "usage_update",
|
|
2181
|
+
promptTokens: cumulative.cumulativePromptTokens,
|
|
2182
|
+
promptTokensSource: "session_cumulative",
|
|
2183
|
+
promptTokensConfidence: "high",
|
|
2184
|
+
cumulativePromptTokens: cumulative.cumulativePromptTokens,
|
|
2185
|
+
cumulativeCacheReadTokens: cumulative.cumulativeCacheReadTokens,
|
|
2186
|
+
cumulativeCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
|
|
2187
|
+
...(cumulativeHitRate !== undefined
|
|
2188
|
+
? { cumulativeCacheHitRate: cumulativeHitRate }
|
|
2189
|
+
: {}),
|
|
2190
|
+
sessionPromptTokens: cumulative.cumulativePromptTokens,
|
|
2191
|
+
sessionCacheReadTokens: cumulative.cumulativeCacheReadTokens,
|
|
2192
|
+
sessionCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
|
|
2193
|
+
});
|
|
2194
|
+
if (this.config.costStore) {
|
|
2195
|
+
session.state.costState = this.config.costStore.serialize();
|
|
2196
|
+
}
|
|
2197
|
+
this.persistRunProgress(session.state);
|
|
2198
|
+
},
|
|
2204
2199
|
});
|
|
2205
|
-
return
|
|
2200
|
+
return turnLoop;
|
|
2201
|
+
}
|
|
2202
|
+
buildSummarizeFn(auxSummaryClient, recordCumulativeUsage) {
|
|
2203
|
+
return this.auxiliaryPipeline.buildSummarizeFn(auxSummaryClient, recordCumulativeUsage);
|
|
2204
|
+
}
|
|
2205
|
+
async resolveAuxClient(fallback) {
|
|
2206
|
+
return this.auxiliaryPipeline.resolveAuxClient(fallback);
|
|
2207
|
+
}
|
|
2208
|
+
async runMemoryPipeline(transcript, sessionId, cwd, primaryClient, recordBilledUsage) {
|
|
2209
|
+
return this.auxiliaryPipeline.runMemoryPipeline(transcript, sessionId, cwd, primaryClient, recordBilledUsage);
|
|
2206
2210
|
}
|
|
2207
2211
|
getToolRegistry() {
|
|
2208
2212
|
return this.toolRegistry;
|
|
2209
2213
|
}
|
|
2214
|
+
/** Registry suitable for constructing EngineRuntime; excludes local capabilities. */
|
|
2215
|
+
getRuntimeToolRegistry() {
|
|
2216
|
+
return this.runtimeToolRegistry;
|
|
2217
|
+
}
|
|
2210
2218
|
/**
|
|
2211
2219
|
* Switch the active model by pool key. Takes effect on the next run() call.
|
|
2212
2220
|
* Returns the new model entry.
|
|
@@ -2216,14 +2224,18 @@ export class Engine {
|
|
|
2216
2224
|
* only live in memory and every restart reverts to the previously persisted
|
|
2217
2225
|
* defaults.text.
|
|
2218
2226
|
*/
|
|
2219
|
-
switchModel(key) {
|
|
2227
|
+
switchModel(key, opts) {
|
|
2220
2228
|
const entry = this.modelPool.switch(key);
|
|
2221
2229
|
// LLMConfig is pure model identity now — rotate it wholesale. Cross-model
|
|
2222
2230
|
// runtime knobs (temperature/timeout/retryMaxAttempts/imageDetail) live on
|
|
2223
2231
|
// this.config.clientDefaults and survive the switch untouched.
|
|
2224
2232
|
const nextLlm = this.modelPool.toLLMConfig(entry);
|
|
2225
2233
|
this.config = { ...this.config, llm: nextLlm };
|
|
2226
|
-
|
|
2234
|
+
// persist: false is the per-session path (ChatSession) — switching one
|
|
2235
|
+
// session's model must not rewrite settings.defaults.text, the boot
|
|
2236
|
+
// default every future session inherits.
|
|
2237
|
+
if (opts?.persist !== false)
|
|
2238
|
+
this.persistActiveModel(entry);
|
|
2227
2239
|
return entry;
|
|
2228
2240
|
}
|
|
2229
2241
|
/**
|
|
@@ -2240,9 +2252,14 @@ export class Engine {
|
|
|
2240
2252
|
// Persist to disk so a reload / next run picks up the reset.
|
|
2241
2253
|
if (this.sessionManager.exists(sessionId)) {
|
|
2242
2254
|
try {
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2255
|
+
if (this.activeRunSession?.state.sessionId === sessionId) {
|
|
2256
|
+
this.sessionManager.saveStateOrUpdateFields(this.activeRunSession.state, {
|
|
2257
|
+
tokenUsage: { ...zero },
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
else {
|
|
2261
|
+
this.sessionManager.updateSessionState(sessionId, { tokenUsage: { ...zero } });
|
|
2262
|
+
}
|
|
2246
2263
|
}
|
|
2247
2264
|
catch {
|
|
2248
2265
|
// Session not resumable (never persisted yet) — the in-memory reset
|
|
@@ -2322,6 +2339,94 @@ export class Engine {
|
|
|
2322
2339
|
getSessionManager() {
|
|
2323
2340
|
return this.sessionManager;
|
|
2324
2341
|
}
|
|
2342
|
+
/**
|
|
2343
|
+
* Apply a field-level disk update and rebase this Engine's matching live
|
|
2344
|
+
* bundle onto the returned revision so its next whole-state CAS can proceed.
|
|
2345
|
+
*/
|
|
2346
|
+
updatePersistedSessionState(sessionId, partial) {
|
|
2347
|
+
const stateRevision = this.sessionManager.updateSessionState(sessionId, partial);
|
|
2348
|
+
if (this.activeRunSession?.state.sessionId !== sessionId)
|
|
2349
|
+
return;
|
|
2350
|
+
Object.assign(this.activeRunSession.state, partial, { stateRevision });
|
|
2351
|
+
}
|
|
2352
|
+
/**
|
|
2353
|
+
* Adopt the exact persisted snapshot that won a Goal-control CAS without
|
|
2354
|
+
* discarding counters which the current run has advanced since its previous
|
|
2355
|
+
* heartbeat. Merely copying the new stateRevision is unsafe: the next
|
|
2356
|
+
* whole-state save would then be allowed to publish stale title/workspace
|
|
2357
|
+
* metadata over a concurrent field-level writer.
|
|
2358
|
+
*/
|
|
2359
|
+
rebaseActiveRunAfterGoalUpdate(live, persisted) {
|
|
2360
|
+
const runOwned = {
|
|
2361
|
+
status: live.status,
|
|
2362
|
+
summary: live.summary,
|
|
2363
|
+
tokenUsage: live.tokenUsage,
|
|
2364
|
+
contextUsageAnchor: live.contextUsageAnchor,
|
|
2365
|
+
cumulativePromptTokens: live.cumulativePromptTokens,
|
|
2366
|
+
cumulativeCacheReadTokens: live.cumulativeCacheReadTokens,
|
|
2367
|
+
cumulativeCacheCreationTokens: live.cumulativeCacheCreationTokens,
|
|
2368
|
+
turnCount: live.turnCount,
|
|
2369
|
+
turnSeq: live.turnSeq,
|
|
2370
|
+
completedThroughEventId: live.completedThroughEventId,
|
|
2371
|
+
completedSnapshotVersion: live.completedSnapshotVersion,
|
|
2372
|
+
invokedSkills: live.invokedSkills,
|
|
2373
|
+
costState: live.costState,
|
|
2374
|
+
};
|
|
2375
|
+
// Optional fields deleted by another writer must disappear locally too;
|
|
2376
|
+
// clear before assigning rather than leaving stale own-properties behind.
|
|
2377
|
+
for (const key of Object.keys(live)) {
|
|
2378
|
+
delete live[key];
|
|
2379
|
+
}
|
|
2380
|
+
Object.assign(live, persisted, runOwned);
|
|
2381
|
+
}
|
|
2382
|
+
persistGoalTerminal(state, goal, reason) {
|
|
2383
|
+
return this.persistGoalTerminalOutcome(state, goal, reason) !== "failed";
|
|
2384
|
+
}
|
|
2385
|
+
persistGoalTerminalOutcome(state, goal, reason) {
|
|
2386
|
+
const outcome = this.sessionManager.saveGoalTerminalOutcome(state, goal, reason);
|
|
2387
|
+
if (outcome === "failed") {
|
|
2388
|
+
logger.warn("session.goal_terminal_persist_failed", {
|
|
2389
|
+
sessionId: state.sessionId,
|
|
2390
|
+
goalId: goal.goalId,
|
|
2391
|
+
reason,
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2394
|
+
return outcome;
|
|
2395
|
+
}
|
|
2396
|
+
persistFinalRunState(state) {
|
|
2397
|
+
const finalFields = {
|
|
2398
|
+
status: state.status,
|
|
2399
|
+
turnCount: state.turnCount,
|
|
2400
|
+
turnSeq: state.turnSeq,
|
|
2401
|
+
tokenUsage: state.tokenUsage,
|
|
2402
|
+
cumulativePromptTokens: state.cumulativePromptTokens,
|
|
2403
|
+
cumulativeCacheReadTokens: state.cumulativeCacheReadTokens,
|
|
2404
|
+
cumulativeCacheCreationTokens: state.cumulativeCacheCreationTokens,
|
|
2405
|
+
contextUsageAnchor: state.contextUsageAnchor,
|
|
2406
|
+
costState: state.costState,
|
|
2407
|
+
completedSnapshotVersion: state.completedSnapshotVersion,
|
|
2408
|
+
completedThroughEventId: state.completedThroughEventId,
|
|
2409
|
+
};
|
|
2410
|
+
if (!this.sessionManager.saveStateOrUpdateFields(state, finalFields)) {
|
|
2411
|
+
logger.warn("session.final_state_persist_failed", { sessionId: state.sessionId });
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
persistRunProgress(state) {
|
|
2415
|
+
const progressFields = {
|
|
2416
|
+
status: state.status,
|
|
2417
|
+
turnCount: state.turnCount,
|
|
2418
|
+
turnSeq: state.turnSeq,
|
|
2419
|
+
tokenUsage: state.tokenUsage,
|
|
2420
|
+
cumulativePromptTokens: state.cumulativePromptTokens,
|
|
2421
|
+
cumulativeCacheReadTokens: state.cumulativeCacheReadTokens,
|
|
2422
|
+
cumulativeCacheCreationTokens: state.cumulativeCacheCreationTokens,
|
|
2423
|
+
contextUsageAnchor: state.contextUsageAnchor,
|
|
2424
|
+
costState: state.costState,
|
|
2425
|
+
};
|
|
2426
|
+
if (!this.sessionManager.saveStateOrUpdateFields(state, progressFields)) {
|
|
2427
|
+
logger.warn("session.run_progress_persist_failed", { sessionId: state.sessionId });
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2325
2430
|
getConfig() {
|
|
2326
2431
|
return this.config;
|
|
2327
2432
|
}
|
|
@@ -2347,7 +2452,7 @@ export class Engine {
|
|
|
2347
2452
|
* the main user-visible preset effect and it IS hot. The toolRegistry's
|
|
2348
2453
|
* builtin tool SET, however, is ctor-frozen (and may be shared via runtime):
|
|
2349
2454
|
* it is NOT rebuilt here. So a preset change that alters the builtin tool set
|
|
2350
|
-
* (e.g.
|
|
2455
|
+
* (e.g. switching to a capability-contributed preset adds tools) only takes effect on the
|
|
2351
2456
|
* next session restart; we log a warning when that case is detected.
|
|
2352
2457
|
*
|
|
2353
2458
|
* disk-default-vs-slice caveat (#8): the patch carries pure DISK-default
|
|
@@ -2369,13 +2474,14 @@ export class Engine {
|
|
|
2369
2474
|
// (rebuilt per turn from this.preset) reflects the new preset's system
|
|
2370
2475
|
// prompt / behavior. Only when the preset actually changed.
|
|
2371
2476
|
if (patch.preset !== undefined && patch.preset !== prevPresetName) {
|
|
2372
|
-
const nextPreset = resolveAgentPreset(this.config.preset);
|
|
2477
|
+
const nextPreset = resolveAgentPreset(this.config.preset, this.capabilities);
|
|
2373
2478
|
// The builtin tool SET is ctor-frozen and may be shared via runtime — we
|
|
2374
2479
|
// do NOT rebuild it here. If the new preset implies a different builtin
|
|
2375
2480
|
// tool set, that part of the change only lands on session restart.
|
|
2376
2481
|
const prevTools = resolveBuiltinToolNames({
|
|
2377
2482
|
preset: prevPresetName,
|
|
2378
2483
|
host: this.config.builtinToolHost,
|
|
2484
|
+
capabilities: this.capabilities,
|
|
2379
2485
|
})
|
|
2380
2486
|
.slice()
|
|
2381
2487
|
.sort()
|
|
@@ -2383,6 +2489,7 @@ export class Engine {
|
|
|
2383
2489
|
const nextTools = resolveBuiltinToolNames({
|
|
2384
2490
|
preset: nextPreset.name,
|
|
2385
2491
|
host: this.config.builtinToolHost,
|
|
2492
|
+
capabilities: this.capabilities,
|
|
2386
2493
|
})
|
|
2387
2494
|
.slice()
|
|
2388
2495
|
.sort()
|
|
@@ -2425,7 +2532,7 @@ export class Engine {
|
|
|
2425
2532
|
* Read a session's persisted active goal WITHOUT resuming it (cheap — reads
|
|
2426
2533
|
* only state.json via SessionManager.readActiveGoal). The desktop host calls
|
|
2427
2534
|
* this on session load to re-surface the goal block + its Cancel button: a
|
|
2428
|
-
* persistent goal lives only in state.
|
|
2535
|
+
* persistent goal lives only in state.goalLifecycle and is never replayed from
|
|
2429
2536
|
* the transcript, so after a reload of an aborted goal run the UI would
|
|
2430
2537
|
* otherwise show nothing (the "goal 还在但页面不显示、取消不了" bug). Returns
|
|
2431
2538
|
* undefined when the session is unknown or has no active goal.
|
|
@@ -2433,43 +2540,185 @@ export class Engine {
|
|
|
2433
2540
|
getGoal(sessionId) {
|
|
2434
2541
|
return this.sessionManager.readActiveGoal(sessionId);
|
|
2435
2542
|
}
|
|
2543
|
+
/** True when the current run was built with Goal prompt/tools and can resume in place. */
|
|
2544
|
+
canResumeGoalInPlace(sessionId) {
|
|
2545
|
+
return (this.activeTurnLoop !== null &&
|
|
2546
|
+
this.activeRunSession?.state.sessionId === sessionId &&
|
|
2547
|
+
this.activeRuntimeGoal !== null &&
|
|
2548
|
+
this.activeGoalHook !== null);
|
|
2549
|
+
}
|
|
2550
|
+
/**
|
|
2551
|
+
* Edit or pause/resume a persisted goal. Mid-run edits use the same step-gap
|
|
2552
|
+
* delivery seam as Steer: the current model/tool call is not aborted, and the
|
|
2553
|
+
* updated objective is injected before the next model step. Pausing detaches
|
|
2554
|
+
* the goal judge and stops this run before another model request is started.
|
|
2555
|
+
*/
|
|
2556
|
+
updateGoal(sessionId, patch) {
|
|
2557
|
+
if (!sessionId || !this.sessionManager.exists(sessionId))
|
|
2558
|
+
return undefined;
|
|
2559
|
+
const live = this.activeRunSession?.state.sessionId === sessionId ? this.activeRunSession : null;
|
|
2560
|
+
const liveLifecycle = live?.state.goalLifecycle;
|
|
2561
|
+
const before = liveLifecycle && isGoalLifecycleCurrent(liveLifecycle)
|
|
2562
|
+
? goalConfigFromLifecycle(liveLifecycle)
|
|
2563
|
+
: this.sessionManager.readActiveGoal(sessionId);
|
|
2564
|
+
if (!before)
|
|
2565
|
+
return undefined;
|
|
2566
|
+
if (patch.expectedGoalId !== undefined && before.goalId !== patch.expectedGoalId) {
|
|
2567
|
+
return undefined;
|
|
2568
|
+
}
|
|
2569
|
+
if (patch.expectedRevision !== undefined && (before.revision ?? 1) !== patch.expectedRevision) {
|
|
2570
|
+
return undefined;
|
|
2571
|
+
}
|
|
2572
|
+
const updated = this.sessionManager.updateActiveGoal(sessionId, {
|
|
2573
|
+
...patch,
|
|
2574
|
+
expectedGoalId: patch.expectedGoalId ?? before.goalId,
|
|
2575
|
+
expectedRevision: patch.expectedRevision ?? before.revision ?? 1,
|
|
2576
|
+
});
|
|
2577
|
+
if (!updated)
|
|
2578
|
+
return undefined;
|
|
2579
|
+
const next = updated.goal;
|
|
2580
|
+
if (live) {
|
|
2581
|
+
this.rebaseActiveRunAfterGoalUpdate(live.state, updated.state);
|
|
2582
|
+
}
|
|
2583
|
+
const resumesDormantGoal = this.activeTurnLoop !== null &&
|
|
2584
|
+
live !== null &&
|
|
2585
|
+
before.paused === true &&
|
|
2586
|
+
next.paused !== true &&
|
|
2587
|
+
this.activeRuntimeGoal === null &&
|
|
2588
|
+
this.activeGoalHook !== null;
|
|
2589
|
+
if (resumesDormantGoal) {
|
|
2590
|
+
// This ordinary run was constructed while the persisted Goal was paused:
|
|
2591
|
+
// its system prompt, visible tools and ToolContext are goal-less and
|
|
2592
|
+
// cannot be safely hot-swapped. Stop it at the next step boundary; the
|
|
2593
|
+
// protocol's conditional resume turn will rebuild a fully Goal-capable
|
|
2594
|
+
// run from the now-unpaused persisted state.
|
|
2595
|
+
this.activeTurnLoop.updateGoal(undefined);
|
|
2596
|
+
}
|
|
2597
|
+
const controlsThisRun = this.activeTurnLoop !== null &&
|
|
2598
|
+
live !== null &&
|
|
2599
|
+
isSameGoalVersion(before, this.activeRuntimeGoal ?? undefined);
|
|
2600
|
+
if (controlsThisRun) {
|
|
2601
|
+
if (this.activeRuntimeGoal) {
|
|
2602
|
+
Object.assign(this.activeRuntimeGoal, next);
|
|
2603
|
+
if (next.paused !== true)
|
|
2604
|
+
delete this.activeRuntimeGoal.paused;
|
|
2605
|
+
}
|
|
2606
|
+
if (this.activePersistedRunGoal) {
|
|
2607
|
+
Object.assign(this.activePersistedRunGoal, next);
|
|
2608
|
+
if (next.paused !== true)
|
|
2609
|
+
delete this.activePersistedRunGoal.paused;
|
|
2610
|
+
}
|
|
2611
|
+
if (next.paused === true) {
|
|
2612
|
+
if (this.activeGoalHook && this.activeGoalHookAttached) {
|
|
2613
|
+
this.hooks.unregister("on_stop", this.activeGoalHook);
|
|
2614
|
+
this.activeGoalHookAttached = false;
|
|
2615
|
+
}
|
|
2616
|
+
this.activeTurnLoop.updateGoal(undefined);
|
|
2617
|
+
}
|
|
2618
|
+
else {
|
|
2619
|
+
const objectiveChanged = before.objective !== next.objective;
|
|
2620
|
+
const resumed = before.paused === true;
|
|
2621
|
+
this.activeTurnLoop.updateGoal(next, objectiveChanged
|
|
2622
|
+
? `目标已编辑。新的目标:${next.objective}`
|
|
2623
|
+
: resumed
|
|
2624
|
+
? `目标已恢复:${next.objective}`
|
|
2625
|
+
: undefined, {
|
|
2626
|
+
maxTurns: resolveMaxTurns(this.config.maxTurns, next),
|
|
2627
|
+
maxStopBlocks: resolveMaxStopBlocks(this.config.maxStopBlocks, next),
|
|
2628
|
+
});
|
|
2629
|
+
if (this.activeGoalHook && !this.activeGoalHookAttached) {
|
|
2630
|
+
this.hooks.register("on_stop", this.activeGoalHook, 0, "goal-stop");
|
|
2631
|
+
this.activeGoalHookAttached = true;
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
return next;
|
|
2636
|
+
}
|
|
2436
2637
|
/**
|
|
2437
2638
|
* Clear a session's persisted active goal (CC `/goal clear`). Works whether
|
|
2438
2639
|
* the session is idle or its goal run is in flight: it wipes
|
|
2439
|
-
* `state.
|
|
2640
|
+
* `state.goalLifecycle` (so the next bare send won't re-inherit it) and, if a
|
|
2440
2641
|
* goal hook is currently registered for this engine, unregisters it so an
|
|
2441
2642
|
* in-flight run can stop instead of being re-blocked by the now-cleared goal.
|
|
2442
2643
|
* Returns true if a goal was actually cleared. Idempotent — clearing a
|
|
2443
2644
|
* session with no active goal is a no-op returning false.
|
|
2444
2645
|
*/
|
|
2445
|
-
clearGoal(sessionId) {
|
|
2646
|
+
clearGoal(sessionId, expected) {
|
|
2446
2647
|
if (!this.sessionManager.exists(sessionId))
|
|
2447
2648
|
return false;
|
|
2448
|
-
// Prefer the LIVE run's bundle when it's this session
|
|
2449
|
-
//
|
|
2450
|
-
// back on its next saveState. A fresh resume() copy would be cleared and
|
|
2451
|
-
// persisted, but the running loop's own detached bundle still holds the
|
|
2452
|
-
// goal and resurrects it — the stale-write-back race. Falls back to a
|
|
2453
|
-
// resumed copy when no run of this session is currently in flight.
|
|
2649
|
+
// Prefer the LIVE run's bundle when it's this session so the domain update
|
|
2650
|
+
// rebases the exact state object used by subsequent progress writes.
|
|
2454
2651
|
const live = this.activeRunSession && this.activeRunSession.state.sessionId === sessionId
|
|
2455
2652
|
? this.activeRunSession
|
|
2456
2653
|
: null;
|
|
2457
2654
|
const session = live ?? this.sessionManager.resume(sessionId);
|
|
2458
|
-
const
|
|
2655
|
+
const lifecycle = session.state.goalLifecycle;
|
|
2656
|
+
const currentGoal = lifecycle && isGoalLifecycleCurrent(lifecycle)
|
|
2657
|
+
? goalConfigFromLifecycle(lifecycle)
|
|
2658
|
+
: undefined;
|
|
2659
|
+
const had = currentGoal !== undefined;
|
|
2660
|
+
let controlsThisRun = false;
|
|
2661
|
+
if (had &&
|
|
2662
|
+
((expected?.goalId !== undefined && currentGoal?.goalId !== expected.goalId) ||
|
|
2663
|
+
(expected?.revision !== undefined && (currentGoal?.revision ?? 1) !== expected.revision))) {
|
|
2664
|
+
return false;
|
|
2665
|
+
}
|
|
2459
2666
|
if (had) {
|
|
2460
|
-
|
|
2461
|
-
|
|
2667
|
+
const clearedGoal = currentGoal;
|
|
2668
|
+
controlsThisRun =
|
|
2669
|
+
this.activeTurnLoop !== null &&
|
|
2670
|
+
live !== null &&
|
|
2671
|
+
isSameGoalVersion(clearedGoal, this.activeRuntimeGoal ?? undefined);
|
|
2672
|
+
if (!this.persistGoalTerminal(session.state, clearedGoal, "user_cleared"))
|
|
2673
|
+
return false;
|
|
2674
|
+
// A cross-writer edit may have won between the expected-version check
|
|
2675
|
+
// above and saveGoalTerminal's conflict merge. In that case the old
|
|
2676
|
+
// revision's tombstone is durable but the newer active revision remains;
|
|
2677
|
+
// report a stale delete, and never stop/detach the run that owns it.
|
|
2678
|
+
if (session.state.goalLifecycle && isGoalLifecycleCurrent(session.state.goalLifecycle)) {
|
|
2679
|
+
return false;
|
|
2680
|
+
}
|
|
2681
|
+
// A paused Goal inherited by an ordinary run is only dormant persisted
|
|
2682
|
+
// state; deleting it must not cancel that unrelated conversation.
|
|
2683
|
+
if (controlsThisRun)
|
|
2684
|
+
this.activeTurnLoop.updateGoal(undefined);
|
|
2462
2685
|
}
|
|
2463
2686
|
// If THIS session's goal run is in flight, drop its stop hook so the
|
|
2464
2687
|
// current run can terminate (the closure-held goal would otherwise keep
|
|
2465
2688
|
// re-blocking). The run's own `finally` also unregisters; double-unregister
|
|
2466
2689
|
// is safe (set delete is idempotent).
|
|
2467
|
-
if (
|
|
2468
|
-
this.
|
|
2690
|
+
if (had &&
|
|
2691
|
+
this.activeGoalHook &&
|
|
2692
|
+
this.lastSessionId === sessionId &&
|
|
2693
|
+
(controlsThisRun || this.activeRuntimeGoal === null)) {
|
|
2694
|
+
if (this.activeGoalHookAttached)
|
|
2695
|
+
this.hooks.unregister("on_stop", this.activeGoalHook);
|
|
2469
2696
|
this.activeGoalHook = null;
|
|
2697
|
+
this.activeGoalHookAttached = false;
|
|
2698
|
+
this.activeRuntimeGoal = null;
|
|
2699
|
+
this.activePersistedRunGoal = null;
|
|
2470
2700
|
}
|
|
2471
2701
|
return had;
|
|
2472
2702
|
}
|
|
2703
|
+
/**
|
|
2704
|
+
* Persist a workspace pointer through the Engine that owns the live bundle.
|
|
2705
|
+
* Host-side workspace actions use this RPC-facing seam so advancing the disk
|
|
2706
|
+
* revision also rebases the active run before its next progress write.
|
|
2707
|
+
*/
|
|
2708
|
+
setSessionWorkspace(sessionId, workspace) {
|
|
2709
|
+
if (!sessionId || !this.sessionManager.exists(sessionId))
|
|
2710
|
+
return null;
|
|
2711
|
+
try {
|
|
2712
|
+
const stateRevision = this.sessionManager.setSessionWorkspace(sessionId, workspace);
|
|
2713
|
+
if (this.activeRunSession?.state.sessionId === sessionId) {
|
|
2714
|
+
Object.assign(this.activeRunSession.state, { workspace, stateRevision });
|
|
2715
|
+
}
|
|
2716
|
+
return workspace;
|
|
2717
|
+
}
|
|
2718
|
+
catch {
|
|
2719
|
+
return null;
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2473
2722
|
/**
|
|
2474
2723
|
* Reset a session's workspace pointer back to its main root. If the session is
|
|
2475
2724
|
* actively running, mutate that live SessionBundle first so the run's next
|
|
@@ -2478,32 +2727,13 @@ export class Engine {
|
|
|
2478
2727
|
releaseSessionWorkspace(sessionId) {
|
|
2479
2728
|
if (!sessionId || !this.sessionManager.exists(sessionId))
|
|
2480
2729
|
return null;
|
|
2481
|
-
const mainRoot = this.sessionManager.
|
|
2730
|
+
const mainRoot = this.sessionManager.readSessionMainRoot(sessionId) ??
|
|
2482
2731
|
(this.activeRunSession?.state.sessionId === sessionId
|
|
2483
2732
|
? this.activeRunSession.state.cwd
|
|
2484
2733
|
: undefined);
|
|
2485
2734
|
if (!mainRoot)
|
|
2486
2735
|
return null;
|
|
2487
|
-
|
|
2488
|
-
if (this.activeRunSession?.state.sessionId === sessionId) {
|
|
2489
|
-
this.activeRunSession.state.workspace = workspace;
|
|
2490
|
-
}
|
|
2491
|
-
try {
|
|
2492
|
-
const bundle = this.activeRunSession?.state.sessionId === sessionId
|
|
2493
|
-
? this.activeRunSession
|
|
2494
|
-
: this.sessionManager.resume(sessionId);
|
|
2495
|
-
bundle.state.workspace = workspace;
|
|
2496
|
-
this.sessionManager.saveState(bundle.state);
|
|
2497
|
-
}
|
|
2498
|
-
catch {
|
|
2499
|
-
try {
|
|
2500
|
-
this.sessionManager.setSessionWorkspace(sessionId, workspace);
|
|
2501
|
-
}
|
|
2502
|
-
catch {
|
|
2503
|
-
return null;
|
|
2504
|
-
}
|
|
2505
|
-
}
|
|
2506
|
-
return workspace;
|
|
2736
|
+
return this.setSessionWorkspace(sessionId, { root: mainRoot, kind: "main" });
|
|
2507
2737
|
}
|
|
2508
2738
|
injectContext(sessionId, content) {
|
|
2509
2739
|
const session = this.sessionManager.resume(sessionId);
|
|
@@ -2528,6 +2758,42 @@ export class Engine {
|
|
|
2528
2758
|
const session = this.sessionManager.resume(effectiveSessionId);
|
|
2529
2759
|
const sourceMessages = this.compactedMessagesBySession.get(effectiveSessionId) ?? session.transcript.toMessages();
|
|
2530
2760
|
const before = estimateTokens(sourceMessages);
|
|
2761
|
+
const contextManager = await this.prepareContextManagerForSession(effectiveSessionId, session, sourceMessages, "force_compact");
|
|
2762
|
+
// Manual /compact emits its UI boundary at the protocol layer from the
|
|
2763
|
+
// final before/after result. Capture the tier here, but avoid reusing a
|
|
2764
|
+
// stale run callback retained on lastContextManager, which could otherwise
|
|
2765
|
+
// double-emit.
|
|
2766
|
+
let compactStrategy;
|
|
2767
|
+
contextManager.setOnCompact((info) => {
|
|
2768
|
+
if (info.after < info.before)
|
|
2769
|
+
compactStrategy = info.strategy;
|
|
2770
|
+
});
|
|
2771
|
+
const compacted = await contextManager.forceSummarize(sourceMessages);
|
|
2772
|
+
const after = estimateTokens(compacted);
|
|
2773
|
+
this.compactedMessagesBySession.set(effectiveSessionId, compacted);
|
|
2774
|
+
this.lastSessionId = effectiveSessionId;
|
|
2775
|
+
this.lastMessages = compacted;
|
|
2776
|
+
return {
|
|
2777
|
+
before,
|
|
2778
|
+
after,
|
|
2779
|
+
strategy: after >= before ? "no compaction needed" : (compactStrategy ?? "compacted"),
|
|
2780
|
+
};
|
|
2781
|
+
}
|
|
2782
|
+
/**
|
|
2783
|
+
* Lazily build (or reuse) the ContextManager for a resumed session and wire a
|
|
2784
|
+
* PRIMARY-model summarizeFn onto it. Shared by forceCompact and
|
|
2785
|
+
* archiveTurnRange: both need a summarizer on a session that may have been
|
|
2786
|
+
* resumed-but-never-run (so no run wired one), and both bill compaction usage
|
|
2787
|
+
* against the session.
|
|
2788
|
+
*
|
|
2789
|
+
* The PRIMARY model is used deliberately — automatic background compaction
|
|
2790
|
+
* routes to the cheap aux model, but a user-/host-initiated compaction or
|
|
2791
|
+
* range archival is low-frequency and demands fidelity (a dropped decision =
|
|
2792
|
+
* the conversation "forgets"). Client-construction failure is logged and
|
|
2793
|
+
* swallowed so the caller degrades gracefully (forceSummarize falls back to
|
|
2794
|
+
* snip/window; summarizeRange returns the input untouched).
|
|
2795
|
+
*/
|
|
2796
|
+
async prepareContextManagerForSession(effectiveSessionId, session, sourceMessages, op) {
|
|
2531
2797
|
let contextManager = this.lastContextManager;
|
|
2532
2798
|
if (!contextManager || this.lastSessionId !== effectiveSessionId) {
|
|
2533
2799
|
contextManager = new ContextManager({
|
|
@@ -2538,88 +2804,86 @@ export class Engine {
|
|
|
2538
2804
|
contextManager.initReplacementStateFromMessages(sourceMessages);
|
|
2539
2805
|
this.lastContextManager = contextManager;
|
|
2540
2806
|
}
|
|
2541
|
-
// Manual /compact emits its UI boundary at the protocol layer from the
|
|
2542
|
-
// final before/after result. Capture the tier here, but avoid reusing a
|
|
2543
|
-
// stale run callback retained on lastContextManager, which could otherwise
|
|
2544
|
-
// double-emit.
|
|
2545
|
-
let compactStrategy;
|
|
2546
|
-
contextManager.setOnCompact((info) => {
|
|
2547
|
-
if (info.after < info.before)
|
|
2548
|
-
compactStrategy = info.strategy;
|
|
2549
|
-
});
|
|
2550
|
-
// Manual /compact = maximum compaction NOW. The automatic ladder waits for
|
|
2551
|
-
// compactAtRatio (0.85 * window), so on a 1M-window model an 800k text-only
|
|
2552
|
-
// conversation sits under the gate and manage() only runs a no-op micro.
|
|
2553
|
-
// Wire a summarizeFn (the run path does this per-run; a cold forceCompact on
|
|
2554
|
-
// a resumed-but-never-run session has none) and call forceSummarize, which
|
|
2555
|
-
// ignores the ratio gate and always summarizes (falling back to snip/window).
|
|
2556
|
-
//
|
|
2557
|
-
// Use the PRIMARY model, not the aux model. Automatic background compaction
|
|
2558
|
-
// routes to aux to keep the high-frequency path cheap, but summarization is
|
|
2559
|
-
// a high-fidelity task (drop a decision and the conversation "forgets"), and
|
|
2560
|
-
// a manual /compact is a low-frequency, user-initiated request for quality.
|
|
2561
|
-
// The aux model is sized for tiny outputs (titles, memory extraction), so
|
|
2562
|
-
// downgrading the one compaction the user explicitly asked for is backwards.
|
|
2563
2807
|
try {
|
|
2564
2808
|
const primaryClient = await createLLMClient(this.config.llm, this.config.clientDefaults);
|
|
2565
2809
|
Object.assign(session.state, normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage));
|
|
2566
2810
|
const recordCompactUsage = (usage) => {
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2811
|
+
this.sessionManager.recordAuxiliaryUsage(effectiveSessionId, usage, this.config.costStore?.serialize());
|
|
2812
|
+
const latest = this.sessionManager.resume(effectiveSessionId).state;
|
|
2813
|
+
const next = normalizeCumulativeUsageCounters(latest, latest.tokenUsage);
|
|
2814
|
+
Object.assign(session.state, next, {
|
|
2815
|
+
tokenUsage: latest.tokenUsage,
|
|
2816
|
+
costState: latest.costState,
|
|
2817
|
+
stateRevision: latest.stateRevision,
|
|
2818
|
+
});
|
|
2570
2819
|
return next;
|
|
2571
2820
|
};
|
|
2572
2821
|
contextManager.setSummarizeFn(this.buildSummarizeFn(primaryClient, recordCompactUsage));
|
|
2573
2822
|
}
|
|
2574
2823
|
catch (err) {
|
|
2575
|
-
logger.warn(
|
|
2824
|
+
logger.warn(`engine.${op}_client_failed`, {
|
|
2576
2825
|
error: err.message,
|
|
2577
2826
|
});
|
|
2578
2827
|
}
|
|
2579
|
-
|
|
2580
|
-
const after = estimateTokens(compacted);
|
|
2581
|
-
this.compactedMessagesBySession.set(effectiveSessionId, compacted);
|
|
2582
|
-
this.lastSessionId = effectiveSessionId;
|
|
2583
|
-
this.lastMessages = compacted;
|
|
2584
|
-
return {
|
|
2585
|
-
before,
|
|
2586
|
-
after,
|
|
2587
|
-
strategy: after >= before ? "no compaction needed" : (compactStrategy ?? "compacted"),
|
|
2588
|
-
};
|
|
2828
|
+
return contextManager;
|
|
2589
2829
|
}
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2830
|
+
/**
|
|
2831
|
+
* Archive a caller-chosen contiguous message-index window `[range.start,
|
|
2832
|
+
* range.end)` of a session into a single anchored summary, leaving everything
|
|
2833
|
+
* outside the window untouched, and cache the result so a later
|
|
2834
|
+
* forceCompact/resume reads the archived history. This is a generic
|
|
2835
|
+
* range-archival facade over ContextManager.summarizeRange — the caller
|
|
2836
|
+
* decides which span to collapse (the range is a half-open message-index
|
|
2837
|
+
* window, matching summarizeRange). Returns token stats before/after; equal
|
|
2838
|
+
* before/after means the window was empty or the summary was rejected.
|
|
2839
|
+
*/
|
|
2840
|
+
async archiveTurnRange(sessionId, range) {
|
|
2841
|
+
const effectiveSessionId = sessionId || this.lastSessionId;
|
|
2842
|
+
if (!effectiveSessionId)
|
|
2843
|
+
return { before: 0, after: 0 };
|
|
2844
|
+
const session = this.sessionManager.resume(effectiveSessionId);
|
|
2845
|
+
const sourceMessages = this.compactedMessagesBySession.get(effectiveSessionId) ?? session.transcript.toMessages();
|
|
2846
|
+
const before = estimateTokens(sourceMessages);
|
|
2847
|
+
const contextManager = await this.prepareContextManagerForSession(effectiveSessionId, session, sourceMessages, "archive_range");
|
|
2848
|
+
// Range archival is initiated deliberately, not by a pressure heuristic;
|
|
2849
|
+
// don't let a stale run callback retained on lastContextManager double-emit.
|
|
2850
|
+
contextManager.setOnCompact(() => { });
|
|
2851
|
+
const archived = await contextManager.summarizeRange(sourceMessages, range);
|
|
2852
|
+
const after = estimateTokens(archived);
|
|
2853
|
+
this.compactedMessagesBySession.set(effectiveSessionId, archived);
|
|
2854
|
+
this.lastSessionId = effectiveSessionId;
|
|
2855
|
+
this.lastMessages = archived;
|
|
2856
|
+
return { before, after };
|
|
2598
2857
|
}
|
|
2599
|
-
recordCacheReadDiagnostics(sessionId,
|
|
2600
|
-
const
|
|
2601
|
-
if (
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
if (this.lastCacheReadBySid.size > CACHE_READ_DIAGNOSTIC_MAX_SESSIONS) {
|
|
2607
|
-
const oldestSessionId = this.lastCacheReadBySid.keys().next().value;
|
|
2608
|
-
if (oldestSessionId !== undefined)
|
|
2609
|
-
this.lastCacheReadBySid.delete(oldestSessionId);
|
|
2610
|
-
}
|
|
2611
|
-
if (previous === undefined || previous < CACHE_READ_DROP_MIN_PREVIOUS_TOKENS)
|
|
2858
|
+
recordCacheReadDiagnostics(sessionId, sample) {
|
|
2859
|
+
const result = this.promptCacheDiagnostics.record(sessionId, sample);
|
|
2860
|
+
if (result.kind === "scope_changed") {
|
|
2861
|
+
logger.info("engine.cache_scope_changed", {
|
|
2862
|
+
sessionId,
|
|
2863
|
+
cacheScopeHash: sample.fingerprint.cacheScopeHash,
|
|
2864
|
+
});
|
|
2612
2865
|
return;
|
|
2613
|
-
|
|
2614
|
-
if (
|
|
2615
|
-
logger.
|
|
2866
|
+
}
|
|
2867
|
+
if (result.kind === "schema_changed") {
|
|
2868
|
+
logger.info("engine.cache_diagnostic_schema_changed", {
|
|
2616
2869
|
sessionId,
|
|
2617
|
-
|
|
2618
|
-
currentCacheReadTokens: current,
|
|
2619
|
-
dropRatio,
|
|
2620
|
-
hint: "Prompt cache read tokens dropped sharply. Check for changed cacheable prefix, stale dynamic context in history, tool/schema changes, or provider cache eviction.",
|
|
2870
|
+
version: sample.fingerprint.version,
|
|
2621
2871
|
});
|
|
2872
|
+
return;
|
|
2622
2873
|
}
|
|
2874
|
+
if (result.kind !== "drop")
|
|
2875
|
+
return;
|
|
2876
|
+
logger.warn("engine.cache_read_drop", {
|
|
2877
|
+
sessionId,
|
|
2878
|
+
previousCacheReadTokens: result.previous.cacheReadTokens,
|
|
2879
|
+
currentCacheReadTokens: result.current.cacheReadTokens,
|
|
2880
|
+
dropRatio: result.dropRatio,
|
|
2881
|
+
cause: result.attribution.cause,
|
|
2882
|
+
changedPrefixes: result.attribution.changedPrefixes,
|
|
2883
|
+
previousPrefix: result.previous.fingerprint,
|
|
2884
|
+
currentPrefix: result.current.fingerprint,
|
|
2885
|
+
hint: promptCacheDropHint(result.attribution),
|
|
2886
|
+
});
|
|
2623
2887
|
}
|
|
2624
2888
|
getSettingsManager() {
|
|
2625
2889
|
if (!this.settingsManager) {
|
|
@@ -2634,7 +2898,7 @@ export class Engine {
|
|
|
2634
2898
|
this.getSettingsManager().saveUserSetting(key, value);
|
|
2635
2899
|
}
|
|
2636
2900
|
/**
|
|
2637
|
-
* Read a settings value by dotted key (e.g. "
|
|
2901
|
+
* Read a settings value by dotted key (e.g. "capabilities.foo.enabled").
|
|
2638
2902
|
* Returns undefined if any segment is missing.
|
|
2639
2903
|
*/
|
|
2640
2904
|
readSetting(key) {
|
|
@@ -2648,91 +2912,17 @@ export class Engine {
|
|
|
2648
2912
|
}
|
|
2649
2913
|
return target;
|
|
2650
2914
|
}
|
|
2651
|
-
buildPermissionConfig(mode, cwd) {
|
|
2652
|
-
const rules = [...this.preset.defaultPermissionRules];
|
|
2653
|
-
// Memory tools: dream scope is the LLM's own workspace, so save/delete
|
|
2654
|
-
// there go through without prompting. user-scope save/delete have no
|
|
2655
|
-
// explicit allow rule here, so default-mode classifier fallback asks the
|
|
2656
|
-
// user to confirm modifications. RegisteredTool.permissionDefault is only
|
|
2657
|
-
// UI/metadata and is not read by the classifier.
|
|
2658
|
-
rules.push({
|
|
2659
|
-
tool: "MemorySave",
|
|
2660
|
-
argsPattern: { scope: "^dream$" },
|
|
2661
|
-
decision: "allow",
|
|
2662
|
-
reason: "Dream scope is the LLM's auto-consolidation workspace",
|
|
2663
|
-
});
|
|
2664
|
-
rules.push({
|
|
2665
|
-
tool: "MemoryDelete",
|
|
2666
|
-
argsPattern: { scope: "^dream$" },
|
|
2667
|
-
decision: "allow",
|
|
2668
|
-
reason: "Dream scope is the LLM's auto-consolidation workspace",
|
|
2669
|
-
});
|
|
2670
|
-
if (mode === "acceptEdits" || mode === "bypassPermissions") {
|
|
2671
|
-
rules.push({ tool: "Write", decision: "allow" });
|
|
2672
|
-
rules.push({ tool: "Edit", decision: "allow" });
|
|
2673
|
-
}
|
|
2674
|
-
if (mode === "bypassPermissions") {
|
|
2675
|
-
rules.push({ tool: "Bash", decision: "allow" });
|
|
2676
|
-
}
|
|
2677
|
-
try {
|
|
2678
|
-
const settingsManager = new SettingsManager(cwd, this.config.settingsScope ?? "project", this.config.projectTrusted !== false);
|
|
2679
|
-
const settings = settingsManager.get();
|
|
2680
|
-
if (settings.permissions?.rules?.length) {
|
|
2681
|
-
rules.unshift(...settings.permissions.rules);
|
|
2682
|
-
}
|
|
2683
|
-
}
|
|
2684
|
-
catch {
|
|
2685
|
-
// Settings not available — defaults only
|
|
2686
|
-
}
|
|
2687
|
-
let backend;
|
|
2688
|
-
if (this.config.approvalBackend) {
|
|
2689
|
-
backend =
|
|
2690
|
-
mode === "auto"
|
|
2691
|
-
? new AutoApprovalBackend(this.config.approvalBackend)
|
|
2692
|
-
: this.config.approvalBackend;
|
|
2693
|
-
}
|
|
2694
|
-
else if (mode === "auto") {
|
|
2695
|
-
backend = new AutoApprovalBackend();
|
|
2696
|
-
}
|
|
2697
|
-
else {
|
|
2698
|
-
// If a host installed an InteractiveApprovalBackend prompt fn
|
|
2699
|
-
// (agent-server-stdio does this on boot via setInteractiveApprovalFn),
|
|
2700
|
-
// use it so the UI gets a chance to approve/deny. Without this,
|
|
2701
|
-
// every `ask` permission silently fell through to deny-all and
|
|
2702
|
-
// the user saw "Permission denied by user" with NO modal — exactly
|
|
2703
|
-
// the bug that motivated this fix.
|
|
2704
|
-
const interactive = getInteractiveApprovalBackend();
|
|
2705
|
-
if (interactive.hasPromptFn()) {
|
|
2706
|
-
backend = interactive;
|
|
2707
|
-
}
|
|
2708
|
-
else {
|
|
2709
|
-
backend = new HeadlessApprovalBackend(mode === "bypassPermissions"
|
|
2710
|
-
? "approve-all"
|
|
2711
|
-
: mode === "dontAsk"
|
|
2712
|
-
? "deny-all"
|
|
2713
|
-
: "deny-all");
|
|
2714
|
-
}
|
|
2715
|
-
}
|
|
2716
|
-
return { rules, backend };
|
|
2717
|
-
}
|
|
2718
2915
|
/**
|
|
2719
|
-
* Switch permission mode at runtime.
|
|
2720
|
-
*
|
|
2721
|
-
* and
|
|
2916
|
+
* Switch permission mode at runtime. Idle updates apply immediately; busy
|
|
2917
|
+
* updates are committed atomically when the current run settles, so its
|
|
2918
|
+
* classifier and ToolContext retain the immutable start-of-run snapshot.
|
|
2722
2919
|
* Session-only — does not persist to settings.
|
|
2723
2920
|
*/
|
|
2724
2921
|
setPermissionMode(mode) {
|
|
2725
|
-
this.
|
|
2726
|
-
this.permissionMode = mode;
|
|
2727
|
-
this.planMode = mode === "plan";
|
|
2728
|
-
if (this.activePermission) {
|
|
2729
|
-
const cwd = this.config.cwd ?? process.cwd();
|
|
2730
|
-
const { rules, backend } = this.buildPermissionConfig(mode, cwd);
|
|
2731
|
-
this.activePermission.reconfigure(mode, backend, rules);
|
|
2732
|
-
}
|
|
2922
|
+
this.permissionController.setPermissionMode(mode);
|
|
2733
2923
|
}
|
|
2734
2924
|
getPermissionMode() {
|
|
2735
|
-
return this.
|
|
2925
|
+
return this.permissionController.getPermissionMode();
|
|
2736
2926
|
}
|
|
2737
2927
|
/**
|
|
2738
2928
|
* Extend the in-flight run's turn ceiling and/or goal budgets (TODO 3.1 —
|
|
@@ -2752,24 +2942,14 @@ export class Engine {
|
|
|
2752
2942
|
* rule set buildPermissionConfig does, without constructing a backend.
|
|
2753
2943
|
*/
|
|
2754
2944
|
getPermissionRules() {
|
|
2755
|
-
return this.
|
|
2756
|
-
.rules;
|
|
2945
|
+
return this.permissionController.getPermissionRules();
|
|
2757
2946
|
}
|
|
2758
2947
|
/**
|
|
2759
2948
|
* Toggle plan mode directly. Called by the Plan tool (Task 7) via ToolContext.engine.
|
|
2760
2949
|
* Also syncs permissionMode to keep both fields consistent.
|
|
2761
2950
|
*/
|
|
2762
2951
|
setPlanMode(value) {
|
|
2763
|
-
|
|
2764
|
-
this.setPermissionMode("plan");
|
|
2765
|
-
}
|
|
2766
|
-
else if (this.permissionMode === "plan") {
|
|
2767
|
-
// Leaving plan mode: drop back to the default.
|
|
2768
|
-
this.setPermissionMode("acceptEdits");
|
|
2769
|
-
}
|
|
2770
|
-
else {
|
|
2771
|
-
this.planMode = value;
|
|
2772
|
-
}
|
|
2952
|
+
this.permissionController.setPlanMode(value);
|
|
2773
2953
|
}
|
|
2774
2954
|
/**
|
|
2775
2955
|
* Block until a background agent's state changes (finishes / its result is
|
|
@@ -2837,9 +3017,9 @@ export class Engine {
|
|
|
2837
3017
|
* directory is read once rather than every turn. A new cwd (e.g. via
|
|
2838
3018
|
* run({ cwd })) reloads.
|
|
2839
3019
|
*/
|
|
2840
|
-
getAgentDefinitions(cwd) {
|
|
2841
|
-
const disabledAgents = this.readDisabledAgents(cwd);
|
|
2842
|
-
const disabledPlugins = this.readDisabledLists().disabledPlugins;
|
|
3020
|
+
getAgentDefinitions(cwd, explicitProfileOverrides) {
|
|
3021
|
+
const disabledAgents = this.readDisabledAgents(cwd, explicitProfileOverrides);
|
|
3022
|
+
const disabledPlugins = this.readDisabledLists(cwd, explicitProfileOverrides).disabledPlugins;
|
|
2843
3023
|
const disabledKey = [...disabledAgents, "::", ...disabledPlugins].slice().sort().join(" ");
|
|
2844
3024
|
if (this.agentDefsCache?.cwd !== cwd || this.agentDefsCache.disabledKey !== disabledKey) {
|
|
2845
3025
|
this.agentDefsCache = {
|
|
@@ -2859,14 +3039,12 @@ export class Engine {
|
|
|
2859
3039
|
* (getForScope) so inherit survives. No cwd / no overlay → baseline
|
|
2860
3040
|
* unchanged. Mirrors readDisabledLists (skills/plugins).
|
|
2861
3041
|
*/
|
|
2862
|
-
readDisabledAgents(cwd) {
|
|
3042
|
+
readDisabledAgents(cwd, explicitProfileOverrides) {
|
|
2863
3043
|
try {
|
|
2864
3044
|
const sm = this.getSettingsManager();
|
|
2865
3045
|
const settings = sm.get();
|
|
2866
3046
|
const baseline = Array.isArray(settings.disabledAgents) ? settings.disabledAgents : [];
|
|
2867
|
-
const overrides = cwd
|
|
2868
|
-
? sm.getForScope("project", cwd).capabilityOverrides
|
|
2869
|
-
: undefined;
|
|
3047
|
+
const overrides = effectiveProjectOverrides(sm, cwd, explicitProfileOverrides);
|
|
2870
3048
|
return effectiveDisabledList(baseline, overrides?.agents);
|
|
2871
3049
|
}
|
|
2872
3050
|
catch {
|
|
@@ -2880,12 +3058,11 @@ export class Engine {
|
|
|
2880
3058
|
* are already narrowed by resolveChildToolScope. No cwd / error → undefined,
|
|
2881
3059
|
* so the caller's baseline builtin lists pass through unchanged.
|
|
2882
3060
|
*/
|
|
2883
|
-
readBuiltinOverride(cwd) {
|
|
3061
|
+
readBuiltinOverride(cwd, explicitProfileOverrides) {
|
|
2884
3062
|
if (this.config.isSubAgent === true || !cwd)
|
|
2885
3063
|
return undefined;
|
|
2886
3064
|
try {
|
|
2887
|
-
const overrides = this.getSettingsManager()
|
|
2888
|
-
.capabilityOverrides;
|
|
3065
|
+
const overrides = effectiveProjectOverrides(this.getSettingsManager(), cwd, explicitProfileOverrides);
|
|
2889
3066
|
return overrides?.builtin;
|
|
2890
3067
|
}
|
|
2891
3068
|
catch {
|
|
@@ -2897,171 +3074,32 @@ export class Engine {
|
|
|
2897
3074
|
* overlays turn-specific fields like sandbox and subAgentSpawner) and
|
|
2898
3075
|
* by tests that want a ToolContext without a full run() cycle.
|
|
2899
3076
|
*/
|
|
2900
|
-
|
|
2901
|
-
const
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
3077
|
+
buildToolContext(cwd = this.config.cwd ?? process.cwd(), explicitProfileOverrides, profileMemoryDir) {
|
|
3078
|
+
const { disabledSkills, disabledPlugins } = this.readDisabledLists(cwd, explicitProfileOverrides);
|
|
3079
|
+
const capabilityServices = Object.fromEntries(this.capabilities.flatMap((capability) => {
|
|
3080
|
+
if (!capability.createToolService)
|
|
3081
|
+
return [];
|
|
3082
|
+
const service = capability.createToolService({
|
|
3083
|
+
isSubAgent: this.config.isSubAgent === true,
|
|
3084
|
+
settings: this.getSettingsManager(),
|
|
3085
|
+
resolveSandbox: (cwd) => this.runEnvironmentResolver.resolveSandbox(cwd),
|
|
3086
|
+
readShellEnv: (cwd) => this.runEnvironmentResolver.readShellEnv(cwd),
|
|
3087
|
+
getSessionManager: () => this.sessionManager,
|
|
2911
3088
|
});
|
|
2912
|
-
|
|
2913
|
-
}
|
|
2914
|
-
return cached;
|
|
2915
|
-
}
|
|
2916
|
-
resolveSandboxConfigForCwd(cwd) {
|
|
2917
|
-
// Priority: config.sandbox → project settings.sandbox → global → per-run
|
|
2918
|
-
// default. Read UNMERGED per-scope (getForScope) so a project that wrote no
|
|
2919
|
-
// sandbox genuinely follows global, rather than inheriting global's mode and
|
|
2920
|
-
// looking like it set one. Fixes "项目级配了不生效" + the scope model.
|
|
2921
|
-
let projectSandbox;
|
|
2922
|
-
let globalSandbox;
|
|
2923
|
-
try {
|
|
2924
|
-
const sm = this.getSettingsManager();
|
|
2925
|
-
if (this.config.isSubAgent !== true) {
|
|
2926
|
-
projectSandbox = sm.getForScope("project", cwd).sandbox;
|
|
2927
|
-
}
|
|
2928
|
-
globalSandbox = sm.getForScope("user").sandbox;
|
|
2929
|
-
}
|
|
2930
|
-
catch {
|
|
2931
|
-
// settings unavailable → fall through to per-run default
|
|
2932
|
-
}
|
|
2933
|
-
return resolveSandboxConfig(this.config.sandbox, projectSandbox, globalSandbox, this.config.headless === true);
|
|
2934
|
-
}
|
|
2935
|
-
/**
|
|
2936
|
-
* Build the shell env layered onto the Bash tool / background shells (see
|
|
2937
|
-
* mergeShellEnv). Three user-configured sources, merged lowest → highest:
|
|
2938
|
-
*
|
|
2939
|
-
* 1. project `localEnvironment.env` — the per-project "local environment"
|
|
2940
|
-
* panel (DATABASE_URL etc.); the floor, so a project's own panel values
|
|
2941
|
-
* can be overridden by an explicit top-level `env`.
|
|
2942
|
-
* 2. global top-level `env` — ~/.code-shell/settings.json; the
|
|
2943
|
-
* canonical home for API keys (OPENAI_API_KEY) a skill script reads —
|
|
2944
|
-
* configure once, every project's skills get it.
|
|
2945
|
-
* 3. project top-level `env` — .code-shell/settings.json; a project
|
|
2946
|
-
* that wants to override a global key wins.
|
|
2947
|
-
*
|
|
2948
|
-
* Each scope is read UNMERGED so the layering here is the single source of
|
|
2949
|
-
* precedence (getForScope merges nothing). Returns undefined when no layer
|
|
2950
|
-
* contributes a key, so the caller passes it through unchanged for projects
|
|
2951
|
-
* that configure none.
|
|
2952
|
-
*
|
|
2953
|
-
* Sub-agents: a sub-agent is the user's OWN agent doing the user's work
|
|
2954
|
-
* (mirrors Claude Code, where sub-agents inherit the parent environment), so
|
|
2955
|
-
* it now reads the SAME env as the parent. The sub-agent branch is kept as an
|
|
2956
|
-
* explicit seam (`filterSubagentEnv`) rather than removed — a future policy
|
|
2957
|
-
* could narrow what a sub-agent sees (e.g. drop credential secrets) by
|
|
2958
|
-
* changing that one hook; today it passes everything through unchanged.
|
|
2959
|
-
* A no-cwd context still gets nothing (there is genuinely no project to read).
|
|
2960
|
-
*
|
|
2961
|
-
* None of these is filtered through the deny regex (mergeShellEnv): the user
|
|
2962
|
-
* put them there deliberately. The allowlist/deny machinery only guards the
|
|
2963
|
-
* host's process.env from a tainted model exfiltrating it via `env | curl`.
|
|
2964
|
-
*/
|
|
2965
|
-
readShellEnv(cwd) {
|
|
2966
|
-
if (!cwd)
|
|
2967
|
-
return undefined;
|
|
2968
|
-
const merged = {};
|
|
2969
|
-
const layer = (env) => {
|
|
2970
|
-
if (!env)
|
|
2971
|
-
return;
|
|
2972
|
-
for (const [k, v] of Object.entries(env)) {
|
|
2973
|
-
if (typeof v === "string")
|
|
2974
|
-
merged[k] = v;
|
|
2975
|
-
}
|
|
2976
|
-
};
|
|
2977
|
-
try {
|
|
2978
|
-
// The fully-merged settings already apply the scope guard (a 'project'
|
|
2979
|
-
// scope never reads the host ~/.code-shell) and the user < project <
|
|
2980
|
-
// local precedence — so the top-level `env` map read from here is global
|
|
2981
|
-
// values overridden by project values, exactly as specified. We layer
|
|
2982
|
-
// localEnvironment.env *under* it as the floor.
|
|
2983
|
-
const settings = this.getSettingsManager().get();
|
|
2984
|
-
layer(settings.localEnvironment?.env); // floor
|
|
2985
|
-
// Credentials flagged "expose as env var" (Credential.exposeAsEnv). This
|
|
2986
|
-
// is the wiring that was missing — the UI/store recorded the flag but no
|
|
2987
|
-
// code ever injected the secret, so `$FIGMA_TOKEN` was always empty.
|
|
2988
|
-
// Scope mirrors settingsScope so a project-scoped engine never surfaces
|
|
2989
|
-
// the host user's credentials (same isolation contract as top-level env).
|
|
2990
|
-
// Placed below settings.env so an explicit `env` entry can still override.
|
|
2991
|
-
const credScope = (this.config.settingsScope ?? "project") === "full" ? "full" : "project";
|
|
2992
|
-
layer(getCredentialAccess().envExposures(cwd, credScope));
|
|
2993
|
-
layer(settings.env); // top-level env (global ⊕ project) wins
|
|
2994
|
-
}
|
|
2995
|
-
catch {
|
|
2996
|
-
return undefined;
|
|
2997
|
-
}
|
|
2998
|
-
const result = this.config.isSubAgent === true ? this.filterSubagentEnv(merged) : merged;
|
|
2999
|
-
return Object.keys(result).length > 0 ? result : undefined;
|
|
3000
|
-
}
|
|
3001
|
-
/**
|
|
3002
|
-
* Policy seam for what a sub-agent's shell sees. A sub-agent inherits the
|
|
3003
|
-
* parent environment by default (mirrors Claude Code), so this is the
|
|
3004
|
-
* identity function today. It exists so a future policy can narrow the set
|
|
3005
|
-
* (e.g. strip credential `exposeAsEnv` secrets, or allowlist by name) in ONE
|
|
3006
|
-
* place instead of scattering `isSubAgent` checks through readShellEnv.
|
|
3007
|
-
*/
|
|
3008
|
-
filterSubagentEnv(env) {
|
|
3009
|
-
return env;
|
|
3010
|
-
}
|
|
3011
|
-
/**
|
|
3012
|
-
* Read the project's `localEnvironment.setupScripts` for this cwd (the raw
|
|
3013
|
-
* per-platform map). Used by EnterWorktree to run setup once in a freshly
|
|
3014
|
-
* created worktree. Returns undefined for sub-agents / no cwd (same minimal
|
|
3015
|
-
* surface as readShellEnv). The platform selection + run live in
|
|
3016
|
-
* git/worktree.ts; this only fetches the configured scripts.
|
|
3017
|
-
*/
|
|
3018
|
-
readWorktreeSetupScripts(cwd) {
|
|
3019
|
-
if (this.config.isSubAgent === true || !cwd)
|
|
3020
|
-
return undefined;
|
|
3021
|
-
try {
|
|
3022
|
-
const scoped = this.getSettingsManager().getForScope("project", cwd);
|
|
3023
|
-
return scoped.localEnvironment?.setupScripts;
|
|
3024
|
-
}
|
|
3025
|
-
catch {
|
|
3026
|
-
return undefined;
|
|
3027
|
-
}
|
|
3028
|
-
}
|
|
3029
|
-
readWorktreeBranchPrefix(cwd) {
|
|
3030
|
-
if (this.config.isSubAgent === true || !cwd)
|
|
3031
|
-
return undefined;
|
|
3032
|
-
try {
|
|
3033
|
-
const settings = this.getSettingsManager().get();
|
|
3034
|
-
return settings.worktree?.branchPrefix;
|
|
3035
|
-
}
|
|
3036
|
-
catch {
|
|
3037
|
-
return undefined;
|
|
3038
|
-
}
|
|
3039
|
-
}
|
|
3040
|
-
async resolveWorktreeSetupSandbox(cwd) {
|
|
3041
|
-
if (!cwd)
|
|
3042
|
-
return undefined;
|
|
3043
|
-
const sandboxConfig = this.resolveSandboxConfigForCwd(cwd);
|
|
3044
|
-
const sandboxBackend = this.runtime
|
|
3045
|
-
? await this.runtime.resolveSandbox(sandboxConfig, cwd)
|
|
3046
|
-
: await this.resolveSandboxWithoutRuntime(sandboxConfig, cwd);
|
|
3047
|
-
return sandboxBackend.name === "off"
|
|
3048
|
-
? sandboxBackend
|
|
3049
|
-
: { ...sandboxBackend, network: sandboxConfig.network };
|
|
3050
|
-
}
|
|
3051
|
-
readWorktreeSetupShellEnv(cwd) {
|
|
3052
|
-
return this.readShellEnv(cwd);
|
|
3053
|
-
}
|
|
3054
|
-
buildToolContext() {
|
|
3055
|
-
const { disabledSkills, disabledPlugins } = this.readDisabledLists();
|
|
3089
|
+
return [[capability.id, service]];
|
|
3090
|
+
}));
|
|
3056
3091
|
const ctx = {
|
|
3057
|
-
shellEnv: this.readShellEnv(
|
|
3058
|
-
cwd
|
|
3092
|
+
shellEnv: this.runEnvironmentResolver.readShellEnv(cwd),
|
|
3093
|
+
cwd,
|
|
3094
|
+
profileMemoryDir,
|
|
3059
3095
|
llmConfig: this.config.llm,
|
|
3060
3096
|
modelPool: this.modelPool,
|
|
3061
3097
|
toolRegistry: this.toolRegistry,
|
|
3098
|
+
capabilityServices,
|
|
3062
3099
|
askUser: this.config.askUser,
|
|
3063
3100
|
browser: this.config.browserBridge,
|
|
3064
3101
|
workspace: this.config.workspaceBridge,
|
|
3102
|
+
panels: this.config.panelBridge,
|
|
3065
3103
|
injectCredentialToBrowser: this.config.injectCredentialToBrowser,
|
|
3066
3104
|
isSubAgent: this.config.isSubAgent === true,
|
|
3067
3105
|
// Credential tools narrow their disk reads to this scope: a project/
|
|
@@ -3098,7 +3136,7 @@ export class Engine {
|
|
|
3098
3136
|
* reads — the prompt composer and the tool context will always see
|
|
3099
3137
|
* the same snapshot.
|
|
3100
3138
|
*/
|
|
3101
|
-
readDisabledLists() {
|
|
3139
|
+
readDisabledLists(cwd = this.config.cwd, explicitProfileOverrides) {
|
|
3102
3140
|
if (this.config.isSubAgent === true) {
|
|
3103
3141
|
return { disabledSkills: [], disabledPlugins: [], disabledPluginHooks: [] };
|
|
3104
3142
|
}
|
|
@@ -3106,7 +3144,7 @@ export class Engine {
|
|
|
3106
3144
|
// capabilityOverrides over the global baseline + the no-repo whitelist
|
|
3107
3145
|
// inversion. Extracted so the MCP merge consumers (engineFactory /
|
|
3108
3146
|
// diskDefaultsFrom) fold identically — see that module's doc.
|
|
3109
|
-
return computeEffectiveDisabledLists(this.getSettingsManager(),
|
|
3147
|
+
return computeEffectiveDisabledLists(this.getSettingsManager(), cwd, explicitProfileOverrides);
|
|
3110
3148
|
}
|
|
3111
3149
|
/**
|
|
3112
3150
|
* Public view of the folded disabled lists, for hosts that need the
|
|
@@ -3148,40 +3186,7 @@ export class Engine {
|
|
|
3148
3186
|
* falls back to its built-in defaults.
|
|
3149
3187
|
*/
|
|
3150
3188
|
readMemoriesConfig() {
|
|
3151
|
-
|
|
3152
|
-
const settings = this.getSettingsManager().get();
|
|
3153
|
-
return settings.memories;
|
|
3154
|
-
}
|
|
3155
|
-
catch {
|
|
3156
|
-
return undefined;
|
|
3157
|
-
}
|
|
3158
|
-
}
|
|
3159
|
-
/**
|
|
3160
|
-
* LLM client for memory extraction (TODO 8.1). Prefers
|
|
3161
|
-
* settings.memories.extractionModel when it names a valid pool model;
|
|
3162
|
-
* otherwise falls back to the aux client (which itself falls back to the
|
|
3163
|
-
* passed primary). Build failures fall back too — extraction is best-effort.
|
|
3164
|
-
*/
|
|
3165
|
-
async resolveExtractionClient(primaryClient) {
|
|
3166
|
-
const key = this.readMemoriesConfig()?.extractionModel;
|
|
3167
|
-
if (key) {
|
|
3168
|
-
const entry = this.modelPool.get(key);
|
|
3169
|
-
if (entry) {
|
|
3170
|
-
try {
|
|
3171
|
-
return await createLLMClient(this.modelPool.toLLMConfig(entry), this.config.clientDefaults);
|
|
3172
|
-
}
|
|
3173
|
-
catch (err) {
|
|
3174
|
-
logger.warn("engine.extraction_model_build_failed", {
|
|
3175
|
-
extractionModel: key,
|
|
3176
|
-
error: err.message,
|
|
3177
|
-
});
|
|
3178
|
-
}
|
|
3179
|
-
}
|
|
3180
|
-
else {
|
|
3181
|
-
logger.warn("engine.extraction_model_missing", { extractionModel: key });
|
|
3182
|
-
}
|
|
3183
|
-
}
|
|
3184
|
-
return this.resolveAuxClient(primaryClient);
|
|
3189
|
+
return this.auxiliaryPipeline.readMemoriesConfig();
|
|
3185
3190
|
}
|
|
3186
3191
|
}
|
|
3187
3192
|
/**
|