@cjhyy/code-shell-core 0.7.1 → 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 +7 -3
- package/dist/context/compaction.d.ts +39 -3
- package/dist/context/compaction.js +151 -39
- package/dist/context/manager.d.ts +17 -1
- package/dist/context/manager.js +36 -1
- package/dist/credentials/access.d.ts +3 -1
- package/dist/credentials/access.js +24 -3
- package/dist/credentials/index.d.ts +1 -1
- package/dist/credentials/index.js +1 -1
- package/dist/credentials/oauth.d.ts +4 -1
- package/dist/credentials/oauth.js +14 -0
- package/dist/credentials/store.d.ts +15 -1
- package/dist/credentials/store.js +17 -4
- package/dist/credentials/types.d.ts +24 -0
- 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 +254 -195
- package/dist/engine/engine.js +1812 -2041
- 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/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 +49 -10
- package/dist/engine/turn-loop.js +370 -104
- package/dist/engine/types.d.ts +30 -3
- package/dist/engine/types.js +1 -1
- package/dist/{engine/goal.d.ts → goal/lifecycle.d.ts} +76 -4
- package/dist/goal/lifecycle.js +482 -0
- package/dist/hooks/events.d.ts +24 -10
- package/dist/hooks/goal-stop-hook.d.ts +17 -28
- package/dist/hooks/goal-stop-hook.js +110 -465
- package/dist/hooks/registry.js +1 -1
- package/dist/index.d.ts +48 -108
- 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 +34 -123
- 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 +60 -0
- 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/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 +6 -0
- package/dist/plugins/pluginCommandHook.js +25 -8
- 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 +64 -1
- package/dist/protocol/chat-session-manager.js +104 -0
- package/dist/protocol/chat-session.d.ts +61 -10
- package/dist/protocol/chat-session.js +135 -25
- package/dist/protocol/client.d.ts +33 -3
- package/dist/protocol/client.js +96 -5
- 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 +127 -3
- package/dist/protocol/server.js +1309 -182
- package/dist/protocol/types.d.ts +152 -34
- package/dist/protocol/types.js +16 -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.js +10 -1
- package/dist/services/memory-orchestrator.js +6 -2
- package/dist/services/oauth.js +4 -1
- 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 +182 -28
- package/dist/session/session-manager.js +910 -130
- package/dist/session/session-message.d.ts +22 -0
- package/dist/session/session-message.js +1 -0
- package/dist/session/transcript.d.ts +46 -5
- package/dist/session/transcript.js +159 -3
- 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 +274 -33
- package/dist/tool-system/builtin/background-jobs.d.ts +55 -2
- package/dist/tool-system/builtin/background-jobs.js +96 -4
- package/dist/tool-system/builtin/background-work.d.ts +6 -0
- package/dist/tool-system/builtin/background-work.js +8 -1
- package/dist/tool-system/builtin/bash.js +5 -2
- package/dist/tool-system/builtin/browser-tools.d.ts +6 -3
- 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.js +2 -6
- 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.js +26 -7
- package/dist/tool-system/builtin/grep.js +2 -2
- package/dist/tool-system/builtin/image-uploader.js +7 -1
- package/dist/tool-system/builtin/index.d.ts +32 -7
- package/dist/tool-system/builtin/index.js +237 -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/web-fetch.js +17 -13
- package/dist/tool-system/builtin/web-search.js +1 -3
- 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 +77 -19
- package/dist/tool-system/executor.js +75 -50
- 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 +11 -1
- package/dist/tool-system/mcp-manager.js +19 -4
- 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/permission.d.ts +26 -0
- package/dist/tool-system/permission.js +46 -0
- package/dist/tool-system/registry.d.ts +12 -1
- package/dist/tool-system/registry.js +31 -7
- 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 +186 -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 -45
- package/dist/arena/arena.js +0 -362
- 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 -25
- package/dist/arena/phases/adjudication.js +0 -142
- package/dist/arena/phases/build-consensus.d.ts +0 -30
- package/dist/arena/phases/build-consensus.js +0 -85
- 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 -47
- package/dist/arena/phases/cross-review.js +0 -224
- package/dist/arena/phases/debate-rounds.d.ts +0 -28
- package/dist/arena/phases/debate-rounds.js +0 -161
- package/dist/arena/phases/participant-research.d.ts +0 -39
- package/dist/arena/phases/participant-research.js +0 -316
- package/dist/arena/phases/planning-detail-expansion.d.ts +0 -39
- package/dist/arena/phases/planning-detail-expansion.js +0 -120
- package/dist/arena/planner.d.ts +0 -28
- package/dist/arena/planner.js +0 -312
- 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 -518
- 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 -72
- package/dist/git/worktree/crud.js +0 -235
- 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 -87
- 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 -43
- package/dist/git/worktree/query.js +0 -128
- 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 -416
- 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 -363
- /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,67 +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 { addTokenUsage, 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 { buildSessionTitle } from "./session-title.js";
|
|
58
|
-
import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
|
|
59
|
-
import { runDreamConsolidation } from "../services/dream-consolidation.js";
|
|
44
|
+
import { PromptCacheDiagnosticRecorder, promptCacheDropHint, } from "./prompt-cache-diagnostics.js";
|
|
60
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";
|
|
61
57
|
import { join } from "node:path";
|
|
62
58
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
63
|
-
const CACHE_READ_DROP_MIN_PREVIOUS_TOKENS = 100;
|
|
64
|
-
const CACHE_READ_DROP_MAX_CURRENT_TOKENS = 64;
|
|
65
|
-
const CACHE_READ_DROP_RATIO = 0.1;
|
|
66
|
-
const CACHE_READ_DIAGNOSTIC_MAX_SESSIONS = 256;
|
|
67
59
|
/**
|
|
68
60
|
* Build ScanOptions.compatFileNames from the user's instruction compat toggles.
|
|
69
61
|
* Primary file name stays hard-wired to CODESHELL.md (not exposed). Turning a
|
|
@@ -79,31 +71,11 @@ export function compatFileNamesFrom(instructions) {
|
|
|
79
71
|
names.push("AGENTS.md");
|
|
80
72
|
return names;
|
|
81
73
|
}
|
|
82
|
-
/**
|
|
83
|
-
* True when two LLMConfigs name the SAME client identity — i.e. building a
|
|
84
|
-
* client from either would talk to the same model on the same endpoint with the
|
|
85
|
-
* same shaping. Used by resolveAuxClient to de-dup the aux client against the
|
|
86
|
-
* active model WITHOUT collapsing two distinct pool keys that merely share a
|
|
87
|
-
* `model` NAME but differ in reasoning/maxTokens/baseUrl/provider. Compares the
|
|
88
|
-
* fields that actually change request behavior; apiKey is intentionally NOT
|
|
89
|
-
* compared (two keys with the same endpoint+model but different credentials
|
|
90
|
-
* still produce equivalent aux work and don't warrant a second client). The
|
|
91
|
-
* reasoning object is compared by normalized JSON since it's a small
|
|
92
|
-
* discriminated union.
|
|
93
|
-
*/
|
|
94
|
-
function sameLlmIdentity(a, b) {
|
|
95
|
-
return (a.model === b.model &&
|
|
96
|
-
(a.baseUrl ?? undefined) === (b.baseUrl ?? undefined) &&
|
|
97
|
-
(a.provider ?? undefined) === (b.provider ?? undefined) &&
|
|
98
|
-
(a.providerKind ?? undefined) === (b.providerKind ?? undefined) &&
|
|
99
|
-
(a.maxTokens ?? undefined) === (b.maxTokens ?? undefined) &&
|
|
100
|
-
JSON.stringify(a.reasoning ?? null) === JSON.stringify(b.reasoning ?? null));
|
|
101
|
-
}
|
|
102
74
|
// Re-export the config hot-reload patch builder from here so the protocol
|
|
103
75
|
// server (and tests) can import it alongside Engine without reaching into the
|
|
104
76
|
// settings/ subtree directly. The implementation lives in settings/ to keep
|
|
105
77
|
// engine.ts from growing and to sit next to personalizationFrom it composes.
|
|
106
|
-
export { diskDefaultsFrom } from "
|
|
78
|
+
export { diskDefaultsFrom } from "./disk-defaults.js";
|
|
107
79
|
/**
|
|
108
80
|
* Resolve the LLM config for a spawned child Engine.
|
|
109
81
|
* - `modelKey` set + present in pool → that model's config (pure entry-derived
|
|
@@ -116,34 +88,16 @@ export { diskDefaultsFrom } from "../settings/disk-defaults.js";
|
|
|
116
88
|
* Engine directly via EngineConfig.clientDefaults — they do not flow through
|
|
117
89
|
* this helper because they're not part of LLMConfig anymore.
|
|
118
90
|
*/
|
|
119
|
-
export
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return resolved;
|
|
124
|
-
}
|
|
125
|
-
return parentLlm;
|
|
126
|
-
}
|
|
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";
|
|
127
95
|
/**
|
|
128
96
|
* Load reusable sub-agent role definitions, merging:
|
|
129
97
|
* 1. project-level <cwd>/.code-shell/agents/*.md (ships built-ins)
|
|
130
98
|
* 2. user-level ~/.code-shell/agents/*.md (user wins on name)
|
|
131
99
|
* Names in `disabledAgents` are filtered out so the LLM never sees them.
|
|
132
100
|
*/
|
|
133
|
-
/**
|
|
134
|
-
* Resolve the working directory for a run. Precedence for legacy sessions:
|
|
135
|
-
* options.cwd > resumed session's state.cwd > config.cwd > process.cwd()
|
|
136
|
-
*
|
|
137
|
-
* The session-cwd tier is what stops a project-bound session from being
|
|
138
|
-
* resumed against the wrong directory: when a host omits options.cwd (e.g. its
|
|
139
|
-
* sidebar repo selection drifted to null), the session's own recorded cwd is
|
|
140
|
-
* recovered so the engine still loads THAT project's agents/settings/memory,
|
|
141
|
-
* not whatever process.cwd() happens to be. Pure so the precedence is testable
|
|
142
|
-
* without standing up an Engine.
|
|
143
|
-
*/
|
|
144
|
-
export function resolveRunCwd(args) {
|
|
145
|
-
return args.optionCwd ?? args.sessionCwd ?? args.configCwd ?? args.processCwd;
|
|
146
|
-
}
|
|
147
101
|
export function loadAgentDefinitionsForCwd(cwd, disabledAgents = [], disabledPlugins = []) {
|
|
148
102
|
// userHome() (not raw homedir(), which bun caches at process start and never
|
|
149
103
|
// re-reads) so the user-agents dir honors a test's process.env.HOME override
|
|
@@ -164,7 +118,6 @@ export function loadAgentDefinitionsForCwd(cwd, disabledAgents = [], disabledPlu
|
|
|
164
118
|
...(cwd ? [{ dir: `${cwd}/.code-shell/agents`, source: "project" }] : []),
|
|
165
119
|
], disabledAgents);
|
|
166
120
|
}
|
|
167
|
-
const NESTED_AGENT_TOOLS = ["Agent", "AgentStatus", "AgentCancel", "AgentSendInput"];
|
|
168
121
|
/**
|
|
169
122
|
* #7: apply a project's per-turn builtin capability override to a tool list.
|
|
170
123
|
* A builtin marked `off` for the current cwd is HIDDEN from the turn's tool
|
|
@@ -185,17 +138,6 @@ export function applyBuiltinOverrideVisibility(tools, override) {
|
|
|
185
138
|
* - `allowlist` undefined → inherit parent enabled/disabled, always with the
|
|
186
139
|
* nested-agent tools forced into `disabled` (no grandchildren).
|
|
187
140
|
*/
|
|
188
|
-
export function resolveChildToolScope(allowlist, parentDisabled, parentEnabled) {
|
|
189
|
-
if (allowlist) {
|
|
190
|
-
return {
|
|
191
|
-
enabled: allowlist.filter((t) => !NESTED_AGENT_TOOLS.includes(t)),
|
|
192
|
-
disabled: [...NESTED_AGENT_TOOLS],
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
const disabled = Array.from(new Set([...(parentDisabled ?? []), ...NESTED_AGENT_TOOLS]));
|
|
196
|
-
const enabled = parentEnabled?.filter((t) => !NESTED_AGENT_TOOLS.includes(t));
|
|
197
|
-
return { enabled, disabled };
|
|
198
|
-
}
|
|
199
141
|
export class Engine {
|
|
200
142
|
config;
|
|
201
143
|
// Resolved per-session preset. Set in the ctor; re-resolved by
|
|
@@ -205,9 +147,22 @@ export class Engine {
|
|
|
205
147
|
// and is NOT rebuilt on reload — a preset change that alters the builtin tool
|
|
206
148
|
// set only takes effect on session restart (logged in refreshRuntimeConfig).
|
|
207
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. */
|
|
208
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;
|
|
209
163
|
hooks;
|
|
210
164
|
sessionManager;
|
|
165
|
+
sessionMessageRouter;
|
|
211
166
|
mcpManager;
|
|
212
167
|
modelPool;
|
|
213
168
|
/**
|
|
@@ -229,24 +184,13 @@ export class Engine {
|
|
|
229
184
|
agentDefsCache;
|
|
230
185
|
/** Shared resources supplied at construction (adapter pattern — null when self-constructed). */
|
|
231
186
|
runtime;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
/** Active permission mode for this Engine instance. */
|
|
236
|
-
permissionMode;
|
|
237
|
-
/** True when permissionMode === "plan". */
|
|
238
|
-
planMode;
|
|
187
|
+
runEnvironmentResolver;
|
|
188
|
+
auxiliaryPipeline;
|
|
189
|
+
permissionController;
|
|
239
190
|
// Lazy SettingsManager — reused across updateConfig/readSetting so we
|
|
240
191
|
// don't re-read 6+ JSON files on every /model, /login, etc. The manager
|
|
241
192
|
// handles its own cache invalidation in saveUserSetting().
|
|
242
193
|
settingsManager;
|
|
243
|
-
/**
|
|
244
|
-
* Cached auxiliary-task LLM client, keyed by the models[].key it was built
|
|
245
|
-
* from. Background calls (memory extraction, auto-dream) reuse it across
|
|
246
|
-
* runs so we don't redo the provider handshake every session. Invalidated
|
|
247
|
-
* implicitly: a changed auxModelKey produces a different cache key.
|
|
248
|
-
*/
|
|
249
|
-
auxClientCache;
|
|
250
194
|
// Live state from the current/most-recent run, retained for /compact and
|
|
251
195
|
// run-boundary PermissionClassifier replacement/reconfiguration.
|
|
252
196
|
lastContextManager;
|
|
@@ -269,7 +213,7 @@ export class Engine {
|
|
|
269
213
|
* LLM response arrives.
|
|
270
214
|
*/
|
|
271
215
|
ctxOverheadBySid = new Map();
|
|
272
|
-
|
|
216
|
+
promptCacheDiagnostics = new PromptCacheDiagnosticRecorder({ maxSessions: 256 });
|
|
273
217
|
/**
|
|
274
218
|
* Step-gap steering queue (per sessionId, in-memory). Host pushes user
|
|
275
219
|
* messages here via enqueueSteer while a run is in flight; the turn loop
|
|
@@ -279,7 +223,6 @@ export class Engine {
|
|
|
279
223
|
* multiple Engines don't interfere and it stays cleanly extractable.
|
|
280
224
|
*/
|
|
281
225
|
steerQueueBySid = new Map();
|
|
282
|
-
activePermission;
|
|
283
226
|
/**
|
|
284
227
|
* The TurnLoop of the in-flight run(), exposed so extendGoalRun() can bump a
|
|
285
228
|
* running goal's turn/budget ceilings mid-run (TODO 3.1). Null when idle.
|
|
@@ -291,12 +234,18 @@ export class Engine {
|
|
|
291
234
|
* otherwise keep re-blocking the stop). Null when no goal run is active.
|
|
292
235
|
*/
|
|
293
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;
|
|
294
243
|
/**
|
|
295
244
|
* The in-flight run's session bundle, held so clearGoal() can wipe the goal
|
|
296
245
|
* on the SAME instance the run loop is persisting each turn — not a fresh
|
|
297
246
|
* detached copy from resume(). Without this, a mid-run 清除 clears disk, but
|
|
298
247
|
* the still-running loop's next saveState(bundle.state) resurrects the goal
|
|
299
|
-
* (bundle.state.
|
|
248
|
+
* (bundle.state.goalLifecycle was never rebased). A never-completing goal run
|
|
300
249
|
* (judge keeps returning not_met → continueSession) stays live for a long
|
|
301
250
|
* time, so this write-back race is the norm, not an edge case, for such runs.
|
|
302
251
|
* Single-valued like activeTurnLoop — one top-level run per engine at a time.
|
|
@@ -306,23 +255,25 @@ export class Engine {
|
|
|
306
255
|
/**
|
|
307
256
|
* Same-instance run guard. Engine owns single-valued live controls and one
|
|
308
257
|
* HookRegistry, so a second run must not enter until the first has completed
|
|
309
|
-
* all state persistence and end hooks.
|
|
310
|
-
*
|
|
311
|
-
* coordinate different Engine instances sharing a sessionId, Workers, or
|
|
312
|
-
* processes; session-level locking/CAS for those cases is a separate finding.
|
|
258
|
+
* all state persistence and end hooks. Cross-instance/process whole-state
|
|
259
|
+
* writers are additionally fenced by SessionManager's persisted revision CAS.
|
|
313
260
|
*/
|
|
314
261
|
runInProgress = false;
|
|
262
|
+
agentControlStateListener;
|
|
263
|
+
agentDirectionsDeliveredListener;
|
|
315
264
|
/** Permission update requested while runInProgress. Applied in run() finally. */
|
|
316
|
-
pendingPermissionMode = null;
|
|
317
|
-
/** Plan update paired with pendingPermissionMode for one atomic boundary apply. */
|
|
318
|
-
pendingPlanMode = null;
|
|
319
265
|
/** Public accessor so UI/clients can read the resolved per-model window. */
|
|
320
266
|
get maxContextTokens() {
|
|
321
267
|
return this.resolveMaxContextTokens();
|
|
322
268
|
}
|
|
323
269
|
resolveMaxContextTokens() {
|
|
324
|
-
const modelEntry = this.modelPool
|
|
325
|
-
|
|
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);
|
|
326
277
|
}
|
|
327
278
|
/**
|
|
328
279
|
* Compaction thresholds from settings.context, clamped so they keep the
|
|
@@ -440,10 +391,63 @@ export class Engine {
|
|
|
440
391
|
this.config = config;
|
|
441
392
|
// Wire shared runtime (adapter pattern — null when self-constructing).
|
|
442
393
|
this.runtime = config.runtime ?? null;
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
+
});
|
|
447
451
|
// Fold the project's capabilityOverrides.builtin overlay over the global
|
|
448
452
|
// enabled/disabled builtin lists so a project can force-enable a
|
|
449
453
|
// globally-disabled builtin tool or force-disable a globally-enabled one
|
|
@@ -459,16 +463,25 @@ export class Engine {
|
|
|
459
463
|
// per-turn path can only hide, not add, so a freshly-`on`'d builtin not in
|
|
460
464
|
// the set needs a session restart to appear.
|
|
461
465
|
const builtinLists = effectiveBuiltinLists(config.enabledBuiltinTools ?? [], config.disabledBuiltinTools ?? [], this.readBuiltinOverride(config.cwd));
|
|
462
|
-
this.
|
|
466
|
+
this.runtimeToolRegistry =
|
|
463
467
|
config.runtime?.toolRegistry ??
|
|
464
468
|
new ToolRegistry({
|
|
465
469
|
builtinTools: resolveBuiltinToolNames({
|
|
466
470
|
preset: this.preset.name,
|
|
467
471
|
host: config.builtinToolHost,
|
|
468
|
-
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
|
+
],
|
|
469
478
|
disabledBuiltinTools: builtinLists.disabledBuiltinTools,
|
|
479
|
+
capabilities: this.capabilities,
|
|
470
480
|
}),
|
|
481
|
+
toolCatalog: this.toolCatalog,
|
|
471
482
|
});
|
|
483
|
+
this.toolRegistry = this.runtimeToolRegistry.fork();
|
|
484
|
+
registerExtensionModules(this.toolRegistry, config.extensionModules ?? []);
|
|
472
485
|
this.hooks = new HookRegistry();
|
|
473
486
|
// Installed-plugin hooks — declared in each plugin's hooks/hooks.json.
|
|
474
487
|
// Registered first (priority 80) so user-authored hooks at lower
|
|
@@ -489,14 +502,26 @@ export class Engine {
|
|
|
489
502
|
loadPluginHooks(this.hooks, disabledPlugins, disabledPluginHooks);
|
|
490
503
|
}
|
|
491
504
|
// settings.hooks → shell-command wrappers. Chain order:
|
|
492
|
-
// plugin (80) → shell (50) → code (default 0).
|
|
505
|
+
// plugin (80) → shell (50) → capability (20) → SDK code (default 0).
|
|
493
506
|
this.registerSettingsHooks();
|
|
507
|
+
for (const hook of composeCapabilityEngineHooks(this.capabilities)) {
|
|
508
|
+
this.hooks.register(hook.event, hook.handler, hook.priority, hook.name);
|
|
509
|
+
}
|
|
494
510
|
for (const hook of config.hooks ?? []) {
|
|
495
511
|
this.hooks.register(hook.event, hook.handler, hook.priority, hook.name);
|
|
496
512
|
}
|
|
497
|
-
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));
|
|
498
516
|
// Initialize model pool — prefer runtime's shared pool, fall back to self-constructed.
|
|
499
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
|
+
});
|
|
500
525
|
if (!config.runtime) {
|
|
501
526
|
this.populateModelPoolFromSettings();
|
|
502
527
|
}
|
|
@@ -638,6 +663,10 @@ export class Engine {
|
|
|
638
663
|
registerCustomTool(definition, executor) {
|
|
639
664
|
this.toolRegistry.registerTool(definition, executor);
|
|
640
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
|
+
}
|
|
641
670
|
/**
|
|
642
671
|
* Inject the askUser handler after construction. Used by AgentServer
|
|
643
672
|
* to wire its protocol-backed askUser into an Engine that was created
|
|
@@ -646,6 +675,13 @@ export class Engine {
|
|
|
646
675
|
setAskUser(fn) {
|
|
647
676
|
this.config.askUser = fn;
|
|
648
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
|
+
}
|
|
649
685
|
/**
|
|
650
686
|
* Inject the browser automation bridge after construction (same chicken-and-egg
|
|
651
687
|
* as setAskUser: the desktop host builds the bridge — which drives a webview —
|
|
@@ -659,6 +695,14 @@ export class Engine {
|
|
|
659
695
|
setWorkspaceBridge(bridge) {
|
|
660
696
|
this.config.workspaceBridge = bridge;
|
|
661
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
|
+
}
|
|
662
706
|
/**
|
|
663
707
|
* Queue a user message to be spliced into the in-flight run for `sessionId`
|
|
664
708
|
* at the next turn-loop step boundary — the 不打断 steering path (vs cancel +
|
|
@@ -754,6 +798,12 @@ export class Engine {
|
|
|
754
798
|
isHeadless() {
|
|
755
799
|
return this.config.headless === true;
|
|
756
800
|
}
|
|
801
|
+
get permissionMode() {
|
|
802
|
+
return this.permissionController.permissionMode;
|
|
803
|
+
}
|
|
804
|
+
get planMode() {
|
|
805
|
+
return this.permissionController.planMode;
|
|
806
|
+
}
|
|
757
807
|
/**
|
|
758
808
|
* Probe whether a session already exists on disk (its state/transcript dir is
|
|
759
809
|
* present). Used by the protocol server to distinguish "resume an existing
|
|
@@ -767,6 +817,121 @@ export class Engine {
|
|
|
767
817
|
forkSession(sourceSessionId, options) {
|
|
768
818
|
return this.sessionManager.fork(sourceSessionId, options);
|
|
769
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
|
+
}
|
|
770
935
|
/**
|
|
771
936
|
* Run a task from start to finish. Rejects immediately when this Engine
|
|
772
937
|
* instance already has a run in progress; hosts that want queueing own that
|
|
@@ -782,64 +947,51 @@ export class Engine {
|
|
|
782
947
|
}
|
|
783
948
|
finally {
|
|
784
949
|
try {
|
|
785
|
-
this.
|
|
950
|
+
this.permissionController.applyPending();
|
|
786
951
|
}
|
|
787
952
|
finally {
|
|
788
953
|
this.runInProgress = false;
|
|
789
954
|
}
|
|
790
955
|
}
|
|
791
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
|
+
}
|
|
792
977
|
async runExclusive(task, options) {
|
|
793
978
|
// Freeze permission context once, before the first await. Per-turn protocol
|
|
794
979
|
// overrides live only for this run; persistent setPermissionMode/setPlanMode
|
|
795
980
|
// calls made while busy are staged separately and cannot mutate this pair.
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
reason: "completed",
|
|
811
|
-
sessionId: options.sessionId,
|
|
812
|
-
turnCount: 0,
|
|
813
|
-
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
814
|
-
};
|
|
815
|
-
}
|
|
816
|
-
if (workspaceResume?.ok &&
|
|
817
|
-
workspaceResume.reason === "worktree_missing_branch_gone" &&
|
|
818
|
-
workspaceResume.message) {
|
|
819
|
-
return {
|
|
820
|
-
text: workspaceResume.message,
|
|
821
|
-
reason: "completed",
|
|
822
|
-
sessionId: options.sessionId,
|
|
823
|
-
turnCount: 0,
|
|
824
|
-
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
825
|
-
};
|
|
826
|
-
}
|
|
827
|
-
// Existing P1 sessions resolve cwd from SessionWorkspace, even if the host
|
|
828
|
-
// passes a stale cwd. Legacy sessions without workspace keep the historical
|
|
829
|
-
// explicit-cwd precedence for backward compatibility.
|
|
830
|
-
const workspaceCwd = workspaceResume?.ok && workspaceResume.reason !== "legacy" ? workspaceResume.cwd : undefined;
|
|
831
|
-
const sessionCwd = workspaceCwd === undefined && options?.cwd === undefined && options?.sessionId
|
|
832
|
-
? workspaceResume?.ok
|
|
833
|
-
? workspaceResume.cwd
|
|
834
|
-
: this.sessionManager.readCwd(options.sessionId)
|
|
835
|
-
: undefined;
|
|
836
|
-
const cwd = workspaceCwd ??
|
|
837
|
-
resolveRunCwd({
|
|
838
|
-
optionCwd: options?.cwd,
|
|
839
|
-
sessionCwd,
|
|
840
|
-
configCwd: this.config.cwd,
|
|
841
|
-
processCwd: process.cwd(),
|
|
842
|
-
});
|
|
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;
|
|
843
995
|
// Wrap the caller's onStream so we can intercept `task_update`
|
|
844
996
|
// events emitted by TodoWrite and keep an in-engine snapshot.
|
|
845
997
|
// TaskGuard reads this snapshot at turn end to decide whether to
|
|
@@ -848,23 +1000,13 @@ export class Engine {
|
|
|
848
1000
|
// store is the transcript, but TaskGuard runs in-loop and can't
|
|
849
1001
|
// afford a transcript scan per turn.
|
|
850
1002
|
let latestTodos = [];
|
|
851
|
-
const
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
// re-enters the LLM context.
|
|
859
|
-
if (event.type === "goal_progress") {
|
|
860
|
-
session.transcript.append("goal_progress", {
|
|
861
|
-
status: event.status,
|
|
862
|
-
round: event.round,
|
|
863
|
-
...(event.gaps ? { gaps: event.gaps } : {}),
|
|
864
|
-
});
|
|
865
|
-
}
|
|
866
|
-
userOnStream?.(event);
|
|
867
|
-
};
|
|
1003
|
+
const wrappedOnStream = this.buildWrappedOnStream({
|
|
1004
|
+
userOnStream: options?.onStream,
|
|
1005
|
+
getSession: () => session,
|
|
1006
|
+
setLatestTodos: (todos) => {
|
|
1007
|
+
latestTodos = todos;
|
|
1008
|
+
},
|
|
1009
|
+
});
|
|
868
1010
|
if (options)
|
|
869
1011
|
options.onStream = wrappedOnStream;
|
|
870
1012
|
const imageInput = await prepareRunImageInput({
|
|
@@ -891,176 +1033,20 @@ export class Engine {
|
|
|
891
1033
|
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
892
1034
|
};
|
|
893
1035
|
}
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
// setArenaLLMConfig, setSubAgentConfig, setToolSearchRegistry).
|
|
897
|
-
const subAgentSpawner = {
|
|
898
|
-
parentStream: options?.onStream,
|
|
899
|
-
describe: () => ({
|
|
900
|
-
cwd,
|
|
901
|
-
preset: this.preset.name,
|
|
902
|
-
permissionMode: runPermissionMode,
|
|
903
|
-
}),
|
|
904
|
-
spawn: async (req) => {
|
|
905
|
-
// Anchor this sub-agent in the PARENT transcript at spawn time — before
|
|
906
|
-
// it runs, so it's recorded whether it later completes, is interrupted,
|
|
907
|
-
// or still runs. Replay reads these anchors to rebuild sub-agent cards
|
|
908
|
-
// from sessions/<agentId>/ (agentId === childSid); without it a
|
|
909
|
-
// backgrounded sub-agent leaves no parent-transcript trace and vanishes
|
|
910
|
-
// on reopen. Only on a fresh spawn (not a resume/continuation, which
|
|
911
|
-
// already has its anchor). Guarded so a transcript hiccup never breaks
|
|
912
|
-
// the spawn.
|
|
913
|
-
if (!req.resumeSessionId) {
|
|
914
|
-
try {
|
|
915
|
-
session.transcript.appendSubagent(req.agentId, undefined, req.description);
|
|
916
|
-
}
|
|
917
|
-
catch {
|
|
918
|
-
/* anchor is best-effort; never block the spawn */
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
// No nested agents. Strip Agent / AgentStatus / AgentCancel from the
|
|
922
|
-
// child's tool pool so the LLM can't spawn grandchildren — matches
|
|
923
|
-
// Claude Code's ALL_AGENT_DISALLOWED_TOOLS approach. Without this
|
|
924
|
-
// guard a runaway model could fork-bomb sub-agents (token cost +
|
|
925
|
-
// background process explosion), and the sid / approval / dock
|
|
926
|
-
// model assumes a flat parent→children hierarchy. Layered with a
|
|
927
|
-
// runtime check in agent.ts as defense-in-depth.
|
|
928
|
-
const { enabled: childEnabled, disabled: childDisabled } = resolveChildToolScope(req.toolAllowlist, this.config.disabledBuiltinTools, this.config.enabledBuiltinTools);
|
|
929
|
-
const childLlm = resolveChildLlm(req.model, this.modelPool, this.config.llm);
|
|
930
|
-
const child = new Engine({
|
|
931
|
-
llm: childLlm,
|
|
932
|
-
// Inherit parent's runtime knobs (temperature, image detail, timeouts)
|
|
933
|
-
// but cap sub-agent retries at 2 — they're short-lived and we'd
|
|
934
|
-
// rather surface failures than burn a 9 s exponential backoff loop.
|
|
935
|
-
clientDefaults: { ...(this.config.clientDefaults ?? {}), retryMaxAttempts: 2 },
|
|
936
|
-
cwd,
|
|
937
|
-
permissionMode: runPermissionMode,
|
|
938
|
-
preset: this.preset.name,
|
|
939
|
-
enabledBuiltinTools: childEnabled,
|
|
940
|
-
disabledBuiltinTools: childDisabled,
|
|
941
|
-
builtinToolHost: this.config.builtinToolHost,
|
|
942
|
-
customSystemPrompt: this.config.customSystemPrompt,
|
|
943
|
-
appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt].filter(Boolean).join("\n\n") ||
|
|
944
|
-
undefined,
|
|
945
|
-
responseLanguage: this.config.responseLanguage,
|
|
946
|
-
userProfile: this.config.userProfile,
|
|
947
|
-
instructions: this.config.instructions,
|
|
948
|
-
maxTurns: req.maxTurns,
|
|
949
|
-
maxContextTokens: this.config.maxContextTokens ?? 200_000,
|
|
950
|
-
sessionStorageDir: this.config.sessionStorageDir,
|
|
951
|
-
headless: this.config.headless,
|
|
952
|
-
readOnlySession: req.readOnlySession,
|
|
953
|
-
skillAllowlist: req.skillAllowlist,
|
|
954
|
-
sandbox: this.config.sandbox,
|
|
955
|
-
// Subagents inherit the parent's scope: a child runs in the same
|
|
956
|
-
// cwd/session, so it should see the same config layers the parent did.
|
|
957
|
-
settingsScope: this.config.settingsScope ?? "project",
|
|
958
|
-
isSubAgent: true,
|
|
959
|
-
});
|
|
960
|
-
// Where the spawned child Engine's stream events go. AgentTool's
|
|
961
|
-
// background path passes a `streamOverride` (transcriptSink) so the
|
|
962
|
-
// per-event detail is captured into the agent's transcript instead
|
|
963
|
-
// of flooding the main feed. Sync calls leave streamOverride unset
|
|
964
|
-
// and we fall back to the parent UI's onStream so synchronous
|
|
965
|
-
// sub-agents still render inline.
|
|
966
|
-
const destStream = req.streamOverride ?? options?.onStream;
|
|
967
|
-
const childStream = destStream
|
|
968
|
-
? (event) => {
|
|
969
|
-
// Filter ctx-bar signals: the bar tracks the main conversation's
|
|
970
|
-
// prompt size, and a sub-agent's own session emits would clobber
|
|
971
|
-
// it (its sid is new, its messages are tiny, the rough char/4
|
|
972
|
-
// seed lands the bar at <1% mid-turn). Sub-agent token accounting
|
|
973
|
-
// lives in CostTracker (recordUsage), not the ctx bar.
|
|
974
|
-
//
|
|
975
|
-
// - session_started: would seed main ctx with sub-agent's prompt
|
|
976
|
-
// - usage_update: would overwrite main ctx with sub-agent's prompt
|
|
977
|
-
// - context_compact: would reset main ctx to sub-agent's post-compact
|
|
978
|
-
// value AND print a misleading "context compacted" boundary in
|
|
979
|
-
// the main chat (the main session didn't compact).
|
|
980
|
-
if (event.type === "usage_update" ||
|
|
981
|
-
event.type === "session_started" ||
|
|
982
|
-
event.type === "context_compact") {
|
|
983
|
-
return;
|
|
984
|
-
}
|
|
985
|
-
destStream({ ...event, agentId: req.agentId });
|
|
986
|
-
}
|
|
987
|
-
: undefined;
|
|
988
|
-
// child.run() establishes its own runWithSid scope internally, so
|
|
989
|
-
// child log lines route to the child's sid and parent's ALS
|
|
990
|
-
// binding is unaffected when control returns here.
|
|
991
|
-
//
|
|
992
|
-
// agent_id === childSid: cold-start the child UNDER its agentId as the
|
|
993
|
-
// session id (run() shape (2): a fresh sid the host wants materialized),
|
|
994
|
-
// so the session persists at sessions/<agentId>/ and AgentSendInput can
|
|
995
|
-
// later resume it by agentId with no extra id→sid mapping. When
|
|
996
|
-
// resumeSessionId is set we resume that existing session instead —
|
|
997
|
-
// run() detects the on-disk session and replays its full transcript
|
|
998
|
-
// (the CC continuation model; see AgentSendInput).
|
|
999
|
-
const childSessionId = req.resumeSessionId ?? req.agentId;
|
|
1000
|
-
const result = await child.run(req.prompt, {
|
|
1001
|
-
signal: req.signal,
|
|
1002
|
-
onStream: childStream,
|
|
1003
|
-
sessionId: childSessionId,
|
|
1004
|
-
});
|
|
1005
|
-
return { text: result.text, sessionId: result.sessionId, usage: result.usage };
|
|
1006
|
-
},
|
|
1007
|
-
sessionExists: (sessionId) => this.sessionManager.exists(sessionId),
|
|
1008
|
-
};
|
|
1009
|
-
const sandboxConfig = this.resolveSandboxConfigForCwd(cwd);
|
|
1010
|
-
// A2: explicit sandbox modes (seatbelt, bwrap) must fail closed
|
|
1011
|
-
// per standard §S4. resolveSandboxBackend throws when an explicit
|
|
1012
|
-
// mode is unavailable on this host; we let it propagate. The
|
|
1013
|
-
// previous behavior — catching the throw inside the hot turn and
|
|
1014
|
-
// silently downgrading to "off" — was the leak A2 closes. The
|
|
1015
|
-
// `auto` mode handles its own downgrade with a one-time warning
|
|
1016
|
-
// inside resolveSandboxBackend; explicit modes do not.
|
|
1017
|
-
//
|
|
1018
|
-
// Backend is cached per runtime/engine so the capability probe runs once
|
|
1019
|
-
// per (mode, cwd) instead of every turn.
|
|
1020
|
-
const sandboxBackend = this.runtime
|
|
1021
|
-
? await this.runtime.resolveSandbox(sandboxConfig, cwd)
|
|
1022
|
-
: await this.resolveSandboxWithoutRuntime(sandboxConfig, cwd);
|
|
1023
|
-
// Observability: surface what sandbox actually applied this run — the
|
|
1024
|
-
// configured mode vs the resolved backend (auto may downgrade to off when
|
|
1025
|
-
// no OS backend is available) + the network policy. Without this you can't
|
|
1026
|
-
// tell whether shell commands were isolated /网络放没放. One line per run.
|
|
1027
|
-
logger.info("sandbox.resolved", {
|
|
1028
|
-
mode: sandboxConfig.mode,
|
|
1029
|
-
backend: sandboxBackend.name,
|
|
1030
|
-
isolated: sandboxBackend.name !== "off",
|
|
1031
|
-
network: sandboxConfig.network,
|
|
1032
|
-
cwd,
|
|
1033
|
-
});
|
|
1034
|
-
// sessionId is filled in after the session bundle is resolved below
|
|
1035
|
-
// (the session may be cold-started or resumed). Until then this is
|
|
1036
|
-
// intentionally shaped as a mutable local; we treat it as immutable
|
|
1037
|
-
// after the assignment.
|
|
1038
|
-
const toolCtx = {
|
|
1039
|
-
...this.buildToolContext(),
|
|
1040
|
-
approvalRouter: options?.approvalRouter ?? this.config.approvalRouter,
|
|
1041
|
-
permissionMode: runPermissionMode,
|
|
1042
|
-
planMode: runPlanMode,
|
|
1043
|
-
subAgentSpawner,
|
|
1044
|
-
agentDefinitions: this.getAgentDefinitions(cwd),
|
|
1045
|
-
// Stamp the resolved network policy onto the backend the tools see so
|
|
1046
|
-
// Bash can surface "网络 deny" on its result. Shallow-copy (don't mutate
|
|
1047
|
-
// the cached backend) — `wrap`/`hintForBlockedOutput` are plain function
|
|
1048
|
-
// properties and survive the spread. Off keeps network undefined.
|
|
1049
|
-
sandbox: sandboxBackend.name === "off"
|
|
1050
|
-
? sandboxBackend
|
|
1051
|
-
: { ...sandboxBackend, network: sandboxConfig.network },
|
|
1036
|
+
const toolCtx = await this.wireRunSandboxToolContext({
|
|
1037
|
+
options,
|
|
1052
1038
|
cwd,
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1039
|
+
runPermissionMode,
|
|
1040
|
+
runPlanMode,
|
|
1041
|
+
profile,
|
|
1042
|
+
profileParams,
|
|
1043
|
+
sessionProfileOverrides,
|
|
1044
|
+
profileMemoryDir,
|
|
1045
|
+
getSession: () => session,
|
|
1046
|
+
reportResult: (key, value) => {
|
|
1047
|
+
(profileReportedResults ??= {})[key] = value;
|
|
1062
1048
|
},
|
|
1063
|
-
};
|
|
1049
|
+
});
|
|
1064
1050
|
logger.info("engine.run", {
|
|
1065
1051
|
task: taskText.slice(0, 200),
|
|
1066
1052
|
cwd,
|
|
@@ -1082,465 +1068,83 @@ export class Engine {
|
|
|
1082
1068
|
// surfacing as `[-32603] Session not found: <sid>` on the very first
|
|
1083
1069
|
// TUI turn. Detection now uses `sessionManager.exists()` (one stat
|
|
1084
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.
|
|
1085
1074
|
let session;
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
// tool_use and the matching tool_result being persisted, the
|
|
1112
|
-
// loaded sequence is invalid for OpenAI (which 400s on dangling
|
|
1113
|
-
// tool_calls). Patch synthetic tool_results so the next API call
|
|
1114
|
-
// doesn't fail before the turn even starts.
|
|
1115
|
-
const patched = patchOrphanedToolUses(messages);
|
|
1116
|
-
if (patched.gapsPatched > 0) {
|
|
1117
|
-
logger.warn("engine.resume.patched_orphaned_tool_uses", {
|
|
1118
|
-
sessionId: options.sessionId,
|
|
1119
|
-
gaps: patched.gapsPatched,
|
|
1120
|
-
toolResults: patched.toolResultsInjected,
|
|
1121
|
-
});
|
|
1122
|
-
}
|
|
1123
|
-
// Restore cost state from previous session, if the caller injected a store
|
|
1124
|
-
if (session.state.costState && this.config.costStore) {
|
|
1125
|
-
this.config.costStore.restore(session.state.costState);
|
|
1126
|
-
}
|
|
1127
|
-
// Append new user message
|
|
1128
|
-
const userMsg = { role: "user", content: userMessageContent };
|
|
1129
|
-
if (!claimClientMessageId(session, options?.clientMessageId, "submit")) {
|
|
1130
|
-
const usage = session.state.tokenUsage ?? {
|
|
1131
|
-
promptTokens: 0,
|
|
1132
|
-
completionTokens: 0,
|
|
1133
|
-
totalTokens: 0,
|
|
1134
|
-
};
|
|
1135
|
-
return {
|
|
1136
|
-
text: "",
|
|
1137
|
-
reason: "completed",
|
|
1138
|
-
sessionId: session.state.sessionId,
|
|
1139
|
-
turnCount: session.state.turnCount ?? 0,
|
|
1140
|
-
usage: {
|
|
1141
|
-
promptTokens: usage.promptTokens ?? 0,
|
|
1142
|
-
completionTokens: usage.completionTokens ?? 0,
|
|
1143
|
-
totalTokens: usage.totalTokens ?? 0,
|
|
1144
|
-
},
|
|
1145
|
-
};
|
|
1146
|
-
}
|
|
1147
|
-
if (parsedTask.hasImages)
|
|
1148
|
-
freshImageMessage = userMsg;
|
|
1149
|
-
messages.push(userMsg);
|
|
1150
|
-
session.transcript.appendMessage("user", userMessageContent, {
|
|
1151
|
-
injected: options?.injected === true,
|
|
1152
|
-
clientMessageId: options?.clientMessageId,
|
|
1153
|
-
});
|
|
1154
|
-
// Flush "active" status to disk immediately. resume() set it in memory
|
|
1155
|
-
// (session-manager.ts), but without this write the on-disk state.json
|
|
1156
|
-
// still shows the previous run's terminal reason — so any external
|
|
1157
|
-
// observer (another CLI process, /sid, the session list) would think
|
|
1158
|
-
// the session is still errored/aborted while we're actually running.
|
|
1159
|
-
this.sessionManager.saveState(session.state);
|
|
1160
|
-
}
|
|
1161
|
-
else {
|
|
1162
|
-
// Cold start: shape (2) reuses the host-supplied sid; shape (3)
|
|
1163
|
-
// lets sessionManager generate one with nanoid.
|
|
1164
|
-
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);
|
|
1165
|
-
const userMsg = { role: "user", content: userMessageContent };
|
|
1166
|
-
claimClientMessageId(session, options?.clientMessageId, "submit");
|
|
1167
|
-
if (parsedTask.hasImages)
|
|
1168
|
-
freshImageMessage = userMsg;
|
|
1169
|
-
messages = [userMsg];
|
|
1170
|
-
session.transcript.appendMessage("user", userMessageContent, {
|
|
1171
|
-
clientMessageId: options?.clientMessageId,
|
|
1172
|
-
});
|
|
1173
|
-
// Save first user message as session summary — text only. The summary
|
|
1174
|
-
// shows up in the session list; "[image]" is more informative than a
|
|
1175
|
-
// truncated `[object Object]` when the prompt was purely visual.
|
|
1176
|
-
const summarySrc = parsedTask.hasImages
|
|
1177
|
-
? parsedTask.text ||
|
|
1178
|
-
`[image${parsedTask.images.length > 1 ? `s × ${parsedTask.images.length}` : ""}]`
|
|
1179
|
-
: taskText;
|
|
1180
|
-
session.state.summary = summarySrc.slice(0, 80).replace(/\n/g, " ");
|
|
1181
|
-
this.sessionManager.saveState(session.state);
|
|
1182
|
-
}
|
|
1183
|
-
// Bump the conversation-turn counter: this user message starts a new turn.
|
|
1184
|
-
// One user message = one turn, regardless of how many turn-loop iterations
|
|
1185
|
-
// or tool calls it spans. File-history snapshots taken below are tagged
|
|
1186
|
-
// with this value so `/undo` reverts exactly this turn's file changes.
|
|
1187
|
-
// (Both resume and cold-start paths converge here.)
|
|
1188
|
-
session.state.turnSeq = (session.state.turnSeq ?? 0) + 1;
|
|
1189
|
-
// B2 / Gate 1: stamp the resolved sid onto the tool context so
|
|
1190
|
-
// session-scoped side effects (background-agent completion
|
|
1191
|
-
// notifications) attribute to the right session. toolCtx is created
|
|
1192
|
-
// before the session bundle is resolved (see ~line 635), so this is
|
|
1193
|
-
// the first point we can set it. After this assignment treat the
|
|
1194
|
-
// field follows the latest successfully injected user intent for the rest
|
|
1195
|
-
// of the run, so tools launched after a steer attribute their side effects
|
|
1196
|
-
// to that steer rather than this original submit.
|
|
1197
|
-
toolCtx.sessionId = session.state.sessionId;
|
|
1198
|
-
toolCtx.originClientMessageId = options?.clientMessageId;
|
|
1199
|
-
toolCtx.recordExternalFileChanges = (record) => {
|
|
1200
|
-
session.transcript.append("external_file_changes", { ...record });
|
|
1201
|
-
};
|
|
1202
|
-
toolCtx.setSessionWorkspace = (workspace) => {
|
|
1203
|
-
session.state.workspace = workspace;
|
|
1204
|
-
};
|
|
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);
|
|
1205
1100
|
const sessionRun = runWithSid(session.state.sessionId, async () => {
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
// not the bytes. Transcript persistence keeps the full payload.
|
|
1210
|
-
task: sanitizeTaskString(task),
|
|
1211
|
-
cwd,
|
|
1212
|
-
model: this.config.llm.model,
|
|
1213
|
-
provider: this.config.llm.provider,
|
|
1214
|
-
permissionMode: runPermissionMode,
|
|
1215
|
-
resumed: resumedFromDisk,
|
|
1216
|
-
});
|
|
1217
|
-
// Session-level hook: fired once per Engine.run() entry, regardless of
|
|
1218
|
-
// cold-start vs resume. Handlers can return `messages` to inject a
|
|
1219
|
-
// <system-reminder> at the head of the conversation (between
|
|
1220
|
-
// userContext and the new user prompt). Used by the built-in
|
|
1221
|
-
// superpowers injector to surface the `using-superpowers` ruleset.
|
|
1222
|
-
const sessionStartHook = await this.emitHook("on_session_start", {
|
|
1223
|
-
sessionId: session.state.sessionId,
|
|
1101
|
+
const hookMessages = await this.runSessionStartHooks({
|
|
1102
|
+
session,
|
|
1103
|
+
task,
|
|
1224
1104
|
cwd,
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
// reminders that should accompany each user turn (e.g. "skills
|
|
1231
|
-
// available — check before acting").
|
|
1232
|
-
const promptSubmitHook = await this.emitHook("user_prompt_submit", {
|
|
1233
|
-
sessionId: session.state.sessionId,
|
|
1234
|
-
// Pass the text-only portion. Handlers reading the prompt for keyword
|
|
1235
|
-
// detection / classification (e.g. superpowers' "did the user ask
|
|
1236
|
-
// about X?") don't gain anything from megabytes of base64 inlined here,
|
|
1237
|
-
// and silently leaking attachment bytes through hooks is the kind of
|
|
1238
|
-
// exfiltration risk a curious user-installed shell hook shouldn't carry.
|
|
1239
|
-
prompt: taskText,
|
|
1240
|
-
resumed: resumedFromDisk,
|
|
1241
|
-
}, options?.signal);
|
|
1242
|
-
// updatedPrompt: handler rewrote the user's prompt text. Replace the
|
|
1243
|
-
// last user message we just pushed (cold-start: line ~511; resume:
|
|
1244
|
-
// line ~500). Original prompt is in the transcript already — we log
|
|
1245
|
-
// the rewrite so audit chains know a hook touched user input.
|
|
1246
|
-
if (typeof promptSubmitHook.updatedPrompt === "string") {
|
|
1247
|
-
const lastIdx = messages.length - 1;
|
|
1248
|
-
const last = messages[lastIdx];
|
|
1249
|
-
if (last && last.role === "user" && typeof last.content === "string") {
|
|
1250
|
-
logger.info("hook.updated_prompt", {
|
|
1251
|
-
sessionId: session.state.sessionId,
|
|
1252
|
-
originalChars: last.content.length,
|
|
1253
|
-
updatedChars: promptSubmitHook.updatedPrompt.length,
|
|
1254
|
-
});
|
|
1255
|
-
messages[lastIdx] = { role: "user", content: promptSubmitHook.updatedPrompt };
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
const contextManager = new ContextManager({
|
|
1259
|
-
maxTokens: this.resolveMaxContextTokens(),
|
|
1260
|
-
// Drop undefined fields so they don't clobber ContextManager defaults
|
|
1261
|
-
// (spread of `{x: undefined}` would override the default with undefined).
|
|
1262
|
-
...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
|
|
1105
|
+
runPermissionMode,
|
|
1106
|
+
resumedFromDisk,
|
|
1107
|
+
options,
|
|
1108
|
+
taskText,
|
|
1109
|
+
messages,
|
|
1263
1110
|
});
|
|
1264
|
-
this.lastContextManager = contextManager;
|
|
1265
|
-
const persistedContextAnchor = session.state.contextUsageAnchor;
|
|
1266
|
-
const contextAnchorCompatible = persistedContextAnchor !== undefined &&
|
|
1267
|
-
(persistedContextAnchor.provider === undefined ||
|
|
1268
|
-
persistedContextAnchor.provider === this.config.llm.provider) &&
|
|
1269
|
-
(persistedContextAnchor.model === undefined ||
|
|
1270
|
-
persistedContextAnchor.model === this.config.llm.model) &&
|
|
1271
|
-
(persistedContextAnchor.messageCount <= messages.length ||
|
|
1272
|
-
persistedContextAnchor.estimateAtAnchor !== undefined);
|
|
1273
|
-
if (contextAnchorCompatible) {
|
|
1274
|
-
contextManager.seedActualUsage(persistedContextAnchor);
|
|
1275
|
-
}
|
|
1276
|
-
// Best-effort token estimate of the full prompt so the UI's ctx bar isn't
|
|
1277
|
-
// 0% before the first real usage_update arrives. The authoritative count
|
|
1278
|
-
// comes from `usage.promptTokens` after the first LLM response — this is
|
|
1279
|
-
// just a display-friendly approximation for the first frame, annotated
|
|
1280
|
-
// with source/confidence so consumers don't treat heuristics as truth.
|
|
1281
|
-
//
|
|
1282
|
-
// Only seed once per (process, sid). On subsequent turns the UI already
|
|
1283
|
-
// shows the previous turn's accurate ctx; overwriting it with a fresh
|
|
1284
|
-
// best-effort estimate would make the bar visibly drop on every submit.
|
|
1285
1111
|
const sid = session.state.sessionId;
|
|
1286
|
-
const
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
return {
|
|
1291
|
-
tokens: checked.tokens,
|
|
1292
|
-
source: checked.promptTokensSource,
|
|
1293
|
-
confidence: checked.promptTokensConfidence,
|
|
1294
|
-
};
|
|
1295
|
-
})()
|
|
1296
|
-
: {
|
|
1297
|
-
tokens: 0,
|
|
1298
|
-
source: "heuristic_estimate",
|
|
1299
|
-
confidence: "low",
|
|
1300
|
-
};
|
|
1301
|
-
if (needsCtxSeed)
|
|
1302
|
-
this.ctxSeedSent.add(sid);
|
|
1303
|
-
// Tell the client the sid *now* instead of waiting for run() to resolve.
|
|
1304
|
-
// The user wants `/sid` to work mid-turn; without this, the client only
|
|
1305
|
-
// learns the sid when the run completes.
|
|
1306
|
-
options?.onStream?.({
|
|
1307
|
-
type: "session_started",
|
|
1308
|
-
sessionId: sid,
|
|
1309
|
-
promptTokens: ctxSeed.tokens,
|
|
1310
|
-
promptTokensSource: ctxSeed.source,
|
|
1311
|
-
promptTokensConfidence: ctxSeed.confidence,
|
|
1312
|
-
});
|
|
1313
|
-
// Replay the last TodoWrite snapshot on resume so the UI's pinned
|
|
1314
|
-
// task panel re-hydrates without the LLM needing to call TodoWrite
|
|
1315
|
-
// again. Scans the resumed transcript newest-first (and tolerates
|
|
1316
|
-
// legacy TaskCreate/Update events for sessions recorded against
|
|
1317
|
-
// the pre-2026-05-24 API). New sessions have no transcript yet so
|
|
1318
|
-
// readLastTodoSnapshot returns null and nothing is emitted.
|
|
1319
|
-
if (options?.sessionId) {
|
|
1320
|
-
const snap = readLastTodoSnapshot(session.transcript.getEvents());
|
|
1321
|
-
if (snap && snap.length > 0) {
|
|
1322
|
-
latestTodos = snap;
|
|
1323
|
-
options?.onStream?.({ type: "task_update", tasks: snap });
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
// Kick off LLM client creation early (network handshake)
|
|
1327
|
-
const llmClientPromise = createLLMClient(this.config.llm, this.config.clientDefaults);
|
|
1328
|
-
// MCP connection below may keep us from awaiting this promise for a while.
|
|
1329
|
-
// Observe rejection immediately so a fast client-init failure cannot become
|
|
1330
|
-
// an unhandledRejection during that gap; Promise.all still receives the
|
|
1331
|
-
// original promise and routes the same error through the lifecycle catch.
|
|
1332
|
-
void llmClientPromise.catch(() => { });
|
|
1333
|
-
const mode = runPermissionMode;
|
|
1334
|
-
this.activeApprovalRouter = toolCtx.approvalRouter;
|
|
1335
|
-
const { rules: defaultRules, backend: approvalBackend } = this.buildPermissionConfig(mode, cwd, toolCtx.approvalRouter);
|
|
1336
|
-
const permission = new PermissionClassifier(defaultRules, mode, approvalBackend);
|
|
1337
|
-
this.activePermission = permission;
|
|
1338
|
-
// If the backend is the interactive one, wire it for project-scope
|
|
1339
|
-
// persistence: it needs cwd to find settings.local.json, and a callback
|
|
1340
|
-
// to apply newly-saved rules to the live classifier so subsequent calls
|
|
1341
|
-
// in this same session don't re-prompt. Headless/auto backends skip
|
|
1342
|
-
// this — they don't prompt, so there are no project rules to persist.
|
|
1343
|
-
if (approvalBackend instanceof InteractiveApprovalBackend) {
|
|
1344
|
-
approvalBackend.setSessionContext(session.state.sessionId, {
|
|
1345
|
-
cwd,
|
|
1346
|
-
onProjectRules: (rules) => {
|
|
1347
|
-
// Prepend the *full* accumulated list of session-saved project rules
|
|
1348
|
-
// so user approvals win over defaults and earlier approvals aren't
|
|
1349
|
-
// dropped when later ones come in.
|
|
1350
|
-
permission.reconfigure(mode, approvalBackend, [...rules, ...defaultRules]);
|
|
1351
|
-
},
|
|
1352
|
-
});
|
|
1353
|
-
}
|
|
1354
|
-
const toolExecutor = new ToolExecutor(this.toolRegistry, permission, this.hooks);
|
|
1355
|
-
const investigationGuard = new InvestigationGuard();
|
|
1356
|
-
if (this.config.readOnlySession) {
|
|
1357
|
-
investigationGuard.setPolicy("read-only-review");
|
|
1358
|
-
}
|
|
1359
|
-
else if (this.config.headless) {
|
|
1360
|
-
investigationGuard.setSoftMode(true);
|
|
1361
|
-
}
|
|
1362
|
-
toolExecutor.setInvestigationGuard(investigationGuard);
|
|
1363
|
-
toolExecutor.setTaskGuard(new TaskGuard(() => latestTodos));
|
|
1364
|
-
// Wire abort signal for cascading cancellation + per-Engine ToolContext
|
|
1365
|
-
toolExecutor.setSignal(options?.signal);
|
|
1366
|
-
toolExecutor.setContext(toolCtx);
|
|
1367
|
-
const { disabledSkills, disabledPlugins } = this.readDisabledLists();
|
|
1368
|
-
const promptComposer = new PromptComposer({
|
|
1112
|
+
const { contextManager, llmClientPromise, toolExecutor } = this.wireRunContextAndPermission({
|
|
1113
|
+
session,
|
|
1114
|
+
sid,
|
|
1115
|
+
options,
|
|
1369
1116
|
cwd,
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
instructionOptions: { compatFileNames: compatFileNamesFrom(this.config.instructions) },
|
|
1377
|
-
disabledSkills,
|
|
1378
|
-
disabledPlugins,
|
|
1379
|
-
skillAllowlist: this.config.skillAllowlist,
|
|
1380
|
-
memoriesMaxAgeDays: this.readMemoriesConfig()?.maxAge,
|
|
1381
|
-
goalToolState: {
|
|
1382
|
-
hasGoal: this.config.isSubAgent !== true &&
|
|
1383
|
-
(normalizeGoal(options?.goal) !== undefined ||
|
|
1384
|
-
session.state.activeGoal !== undefined ||
|
|
1385
|
-
normalizeGoal(this.config.goal) !== undefined),
|
|
1117
|
+
toolCtx,
|
|
1118
|
+
runPermissionMode,
|
|
1119
|
+
messages,
|
|
1120
|
+
getLatestTodos: () => latestTodos,
|
|
1121
|
+
setLatestTodos: (todos) => {
|
|
1122
|
+
latestTodos = todos;
|
|
1386
1123
|
},
|
|
1387
1124
|
});
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
// 3. buildSystemContext — reads environment context
|
|
1407
|
-
// Inject the live available-agent-types listing into the Agent tool's
|
|
1408
|
-
// description. The registry is per-engine (loaded from .code-shell/agents
|
|
1409
|
-
// for this cwd), so it can't live in the static tool def — without this
|
|
1410
|
-
// the model never learns the reusable roles exist and spawns nameless
|
|
1411
|
-
// ad-hoc agents instead (the Core A/B/C incident).
|
|
1412
|
-
// The Agent tool is always available: with configured roles, an omitted
|
|
1413
|
-
// agent_type falls back to one of them (see resolveAgentTypeOverrides); with
|
|
1414
|
-
// no roles configured it runs a true ephemeral agent, so workflows that need
|
|
1415
|
-
// sub-agents (e.g. superpowers) work in any project.
|
|
1416
|
-
// Availability guard (tool-visibility): a gated builtin (WebSearch needs a
|
|
1417
|
-
// search provider, GenerateImage needs an OpenAI provider) is hidden from
|
|
1418
|
-
// the toolDefs the model sees when its credential isn't configured for this
|
|
1419
|
-
// cwd. Recomputed every message, so configuring a key takes effect on the
|
|
1420
|
-
// NEXT message without a restart. Tools with no guard entry are always kept.
|
|
1421
|
-
const guardCwd = toolCtx.cwd;
|
|
1422
|
-
const toolVisibility = {
|
|
1423
|
-
cwd: guardCwd,
|
|
1424
|
-
hasGoal: this.config.isSubAgent !== true &&
|
|
1425
|
-
(normalizeGoal(options?.goal) !== undefined ||
|
|
1426
|
-
session.state.activeGoal !== undefined ||
|
|
1427
|
-
normalizeGoal(this.config.goal) !== undefined),
|
|
1428
|
-
settingsScope: this.config.settingsScope ?? "project",
|
|
1429
|
-
};
|
|
1430
|
-
toolCtx.toolVisibility = toolVisibility;
|
|
1431
|
-
// #7: per-turn project builtin override. The toolRegistry's builtin tool
|
|
1432
|
-
// SET is ctor-frozen (and may be shared via runtime), so a mid-session
|
|
1433
|
-
// project override of a builtin can't rebuild the registry. But the tool
|
|
1434
|
-
// LIST handed to the LLM is assembled fresh every turn, so we apply the
|
|
1435
|
-
// override here: a builtin marked `off` for this cwd is HIDDEN from the
|
|
1436
|
-
// turn's tool list (matching how skills/plugins/agents `off` apply
|
|
1437
|
-
// mid-session via readDisabledLists). `on`/`inherit` keep whatever the
|
|
1438
|
-
// registry already has — we can't re-add a tool the frozen registry omits,
|
|
1439
|
-
// but `on` for a tool already present is a no-op (it stays). This makes a
|
|
1440
|
-
// builtin toggle take effect on the NEXT message, like other capability
|
|
1441
|
-
// kinds, without touching the registry.
|
|
1442
|
-
const builtinOverride = this.readBuiltinOverride(guardCwd);
|
|
1443
|
-
// Turn `off` from a prompt-visibility filter into a real execution gate:
|
|
1444
|
-
// collect the builtin tool names the override marks `off` and hand them to
|
|
1445
|
-
// the executor (via the shared toolCtx the executor already holds a
|
|
1446
|
-
// reference to, set at setContext above) so it rejects a call to a hidden
|
|
1447
|
-
// builtin instead of running it from the still-populated registry.
|
|
1448
|
-
if (builtinOverride) {
|
|
1449
|
-
const registryNames = new Set(this.toolRegistry.getToolDefinitions().map((t) => t.name));
|
|
1450
|
-
const disabledBuiltins = new Set(Object.keys(builtinOverride).filter((name) => builtinOverride[name] === "off" && registryNames.has(name)));
|
|
1451
|
-
toolCtx.disabledBuiltins = disabledBuiltins;
|
|
1452
|
-
}
|
|
1453
|
-
// MCP tool exposure is per-SESSION even though the pool/registry are
|
|
1454
|
-
// worker-shared (B1): a server connected by another project's session
|
|
1455
|
-
// registers its tools into the SHARED registry, and without this filter
|
|
1456
|
-
// they leaked into every session (e.g. chrome-devtools tools showing up
|
|
1457
|
-
// in a project that never enabled the plugin). Keep an MCP tool only when
|
|
1458
|
-
// its server is in THIS session's merged config.mcpServers — which
|
|
1459
|
-
// already folds the project's capabilityOverrides. Gated on the config
|
|
1460
|
-
// being present: engines without one (sub-agents, bare tests) have no
|
|
1461
|
-
// MCP tools in their private registries anyway.
|
|
1462
|
-
const allowedMcpServers = new Set(Object.entries(this.config.mcpServers ?? {})
|
|
1463
|
-
.filter(([, c]) => c.enabled !== false)
|
|
1464
|
-
.map(([n]) => n));
|
|
1465
|
-
toolCtx.allowedMcpServers = allowedMcpServers;
|
|
1466
|
-
const mcpVisible = (toolName) => {
|
|
1467
|
-
const reg = this.toolRegistry.getTool(toolName);
|
|
1468
|
-
return reg?.source !== "mcp" || allowedMcpServers.has(reg?.serverName ?? "");
|
|
1469
|
-
};
|
|
1470
|
-
// Feature-flag visibility: a builtin mapped in TOOL_FEATURE_FLAGS is
|
|
1471
|
-
// hidden when its flag resolves to false (default-on flags only hide when
|
|
1472
|
-
// explicitly disabled, so zero regression out of the box). Read once per
|
|
1473
|
-
// turn so flipping a flag in settings takes effect on the NEXT message,
|
|
1474
|
-
// like the other capability kinds.
|
|
1475
|
-
const featureFlags = this.readFeatureFlags();
|
|
1476
|
-
const allToolDefs = applyBuiltinOverrideVisibility(this.toolRegistry.getToolDefinitions(), builtinOverride)
|
|
1477
|
-
.filter((t) => mcpVisible(t.name))
|
|
1478
|
-
.filter((t) => {
|
|
1479
|
-
const guard = BUILTIN_TOOL_GUARDS.get(t.name);
|
|
1480
|
-
return guard ? guard(toolVisibility) : true;
|
|
1481
|
-
})
|
|
1482
|
-
.filter((t) => {
|
|
1483
|
-
const flag = TOOL_FEATURE_FLAGS.get(t.name);
|
|
1484
|
-
return flag ? isFeatureEnabled(featureFlags, flag) : true;
|
|
1485
|
-
})
|
|
1486
|
-
// Dynamic per-engine bits the static defs can't carry: the Agent tool's
|
|
1487
|
-
// agent_type enum + listing, and the image/video provider names. See
|
|
1488
|
-
// applyDynamicToolDef — forwarding only the Agent description (dropping
|
|
1489
|
-
// its rebuilt inputSchema) used to strip the agent_type enum, so the
|
|
1490
|
-
// model omitted agent_type and configured roles never applied.
|
|
1491
|
-
.map((t) => applyDynamicToolDef(t, toolCtx.agentDefinitions, guardCwd));
|
|
1492
|
-
// In plan mode, only expose read-only/planning tools so the model won't
|
|
1493
|
-
// attempt writes. Shared with executor.ts's execution gate via
|
|
1494
|
-
// PLAN_MODE_ALLOWED_TOOLS so what the model SEES and what the executor
|
|
1495
|
-
// RUNS can't drift apart. (Bash is in the set; the executor additionally
|
|
1496
|
-
// gates Bash to read-only commands at call time.)
|
|
1497
|
-
const toolDefs = runPlanMode
|
|
1498
|
-
? allToolDefs.filter((t) => PLAN_MODE_ALLOWED_TOOLS.has(t.name))
|
|
1499
|
-
: allToolDefs;
|
|
1500
|
-
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,
|
|
1501
1143
|
llmClientPromise,
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
]);
|
|
1507
|
-
// Prepend userContext (CLAUDE.md) as first message (sync, fast)
|
|
1508
|
-
const userContextMsg = promptComposer.buildUserContextMessage();
|
|
1509
|
-
if (userContextMsg) {
|
|
1510
|
-
messages.unshift(userContextMsg);
|
|
1511
|
-
}
|
|
1512
|
-
// Inject hook-supplied reminders just before the most recent user task.
|
|
1513
|
-
// Combined into one <system-reminder> block so a noisy handler chain
|
|
1514
|
-
// doesn't turn into 3+ separate user turns in the API request.
|
|
1515
|
-
const lifecycleReminder = wrapHookMessages([
|
|
1516
|
-
...(sessionStartHook.messages ?? []),
|
|
1517
|
-
...(promptSubmitHook.messages ?? []),
|
|
1518
|
-
]);
|
|
1519
|
-
if (lifecycleReminder) {
|
|
1520
|
-
// messages[length - 1] is the user task we just pushed above. Insert
|
|
1521
|
-
// the reminder immediately before it so the model reads: CLAUDE.md →
|
|
1522
|
-
// reminder → user request.
|
|
1523
|
-
messages.splice(messages.length - 1, 0, lifecycleReminder);
|
|
1524
|
-
}
|
|
1525
|
-
// Volatile context (skills + git status) goes at the very END — after the
|
|
1526
|
-
// user task — so it sits past the conversation's cache breakpoint. A change
|
|
1527
|
-
// here (new skill, edited file) never invalidates the cached history prefix.
|
|
1528
|
-
if (dynamicContextMsg) {
|
|
1529
|
-
messages.push(dynamicContextMsg);
|
|
1530
|
-
}
|
|
1531
|
-
this.lastSessionId = session.state.sessionId;
|
|
1532
|
-
this.lastMessages = messages;
|
|
1533
|
-
// Wire up LLM summarization for context compaction
|
|
1534
|
-
// Uses a lightweight call without tools
|
|
1535
|
-
contextManager.setTranscriptPath(session.transcript.getFilePath());
|
|
1536
|
-
// Re-derive frozen persistence decisions from the messages we just
|
|
1537
|
-
// loaded. Skipped on cold start (messages == [userContextMsg] only).
|
|
1538
|
-
// Critical for resume — otherwise a result that was persisted last
|
|
1539
|
-
// run would be evaluated fresh and might get a different replacement
|
|
1540
|
-
// string than the one already in the message, breaking idempotency.
|
|
1541
|
-
contextManager.initReplacementStateFromMessages(messages);
|
|
1542
|
-
// Two summarizers with DIFFERENT quality needs:
|
|
1543
|
-
//
|
|
1144
|
+
contextManager,
|
|
1145
|
+
profile,
|
|
1146
|
+
profileParams,
|
|
1147
|
+
});
|
|
1544
1148
|
// 1. Context-compaction summary (setSummarizeFn) → PRIMARY model. This
|
|
1545
1149
|
// condenses many rounds into the running summary that REPLACES the real
|
|
1546
1150
|
// history; a dropped decision makes the conversation "forget" and poisons
|
|
@@ -1556,869 +1160,1061 @@ export class Engine {
|
|
|
1556
1160
|
// Auto-compaction runs inside TurnLoop.manageAsync(), after the loop has
|
|
1557
1161
|
// initialized its run-scoped Goal tracker. The closure is wired before
|
|
1558
1162
|
// construction but cannot execute until turnLoop.run() starts.
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
totalTokens: 0,
|
|
1565
|
-
cacheReadTokens: 0,
|
|
1566
|
-
cacheCreationTokens: 0,
|
|
1567
|
-
};
|
|
1568
|
-
let runAccountingFinalized = false;
|
|
1569
|
-
Object.assign(session.state, normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage));
|
|
1570
|
-
const recordCumulativeUsage = (usage) => {
|
|
1571
|
-
const next = addCumulativeUsage(session.state, usage);
|
|
1572
|
-
Object.assign(session.state, next);
|
|
1573
|
-
return next;
|
|
1574
|
-
};
|
|
1575
|
-
const recordExternalBilledUsage = (usage) => {
|
|
1576
|
-
externalRunUsage = addTokenUsage(externalRunUsage, usage);
|
|
1577
|
-
const cumulative = recordCumulativeUsage(usage);
|
|
1578
|
-
autoCompactionGoalTermination = turnLoop.recordGoalJudgeUsage(usage);
|
|
1579
|
-
if (runAccountingFinalized) {
|
|
1580
|
-
try {
|
|
1581
|
-
const latest = this.sessionManager.resume(sid).state;
|
|
1582
|
-
const lateCumulative = addCumulativeUsage(latest, usage);
|
|
1583
|
-
this.sessionManager.updateSessionState(sid, {
|
|
1584
|
-
tokenUsage: addTokenUsage(latest.tokenUsage, usage),
|
|
1585
|
-
...lateCumulative,
|
|
1586
|
-
...(this.config.costStore
|
|
1587
|
-
? {
|
|
1588
|
-
costState: this.config.costStore.serialize(),
|
|
1589
|
-
}
|
|
1590
|
-
: {}),
|
|
1591
|
-
});
|
|
1592
|
-
}
|
|
1593
|
-
catch (err) {
|
|
1594
|
-
logger.warn("engine.late_usage_persist_failed", {
|
|
1595
|
-
sessionId: sid,
|
|
1596
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1597
|
-
});
|
|
1598
|
-
}
|
|
1599
|
-
}
|
|
1600
|
-
return cumulative;
|
|
1601
|
-
};
|
|
1602
|
-
contextManager.setSummarizeFn(this.buildSummarizeFn(llmClient, recordExternalBilledUsage));
|
|
1603
|
-
// Create components (requires resolved llmClient).
|
|
1604
|
-
const modelFacade = new ModelFacade(llmClient, session.transcript);
|
|
1605
|
-
const getRunUsage = () => {
|
|
1606
|
-
const visible = modelFacade.getUsage();
|
|
1607
|
-
return {
|
|
1608
|
-
...visible,
|
|
1609
|
-
totalPromptTokens: visible.totalPromptTokens + externalRunUsage.promptTokens,
|
|
1610
|
-
totalCompletionTokens: visible.totalCompletionTokens + externalRunUsage.completionTokens,
|
|
1611
|
-
totalTokens: visible.totalTokens + externalRunUsage.totalTokens,
|
|
1612
|
-
totalCacheReadTokens: visible.totalCacheReadTokens + (externalRunUsage.cacheReadTokens ?? 0),
|
|
1613
|
-
totalCacheCreationTokens: visible.totalCacheCreationTokens + (externalRunUsage.cacheCreationTokens ?? 0),
|
|
1614
|
-
};
|
|
1615
|
-
};
|
|
1616
|
-
const callPrimaryModel = modelFacade.call.bind(modelFacade);
|
|
1617
|
-
modelFacade.call = async (...args) => {
|
|
1618
|
-
// A primary-model summary may itself exhaust the Goal budget. Do not
|
|
1619
|
-
// issue the main turn request after that billed sub-call; return control
|
|
1620
|
-
// to TurnLoop, whose existing post-response guard emits and persists the
|
|
1621
|
-
// canonical goal_budget_exhausted termination.
|
|
1622
|
-
if (autoCompactionGoalTermination) {
|
|
1623
|
-
return {
|
|
1624
|
-
text: "",
|
|
1625
|
-
toolCalls: [],
|
|
1626
|
-
stopReason: "stop",
|
|
1627
|
-
};
|
|
1628
|
-
}
|
|
1629
|
-
return callPrimaryModel(...args);
|
|
1630
|
-
};
|
|
1631
|
-
// Session-cumulative usage baseline: the LLM client is recreated per run
|
|
1632
|
-
// (its getUsage() counts only THIS run), so to accumulate across runs we
|
|
1633
|
-
// capture the persisted total at run start and fold this run's usage onto
|
|
1634
|
-
// it (see foldRunUsage). Snapshot now, before any turn boundary fires.
|
|
1635
|
-
const usageBaseline = { ...session.state.tokenUsage };
|
|
1636
|
-
// Wire getOutputTokens for token budget tracking
|
|
1637
|
-
modelFacade.getOutputTokens = () => {
|
|
1638
|
-
const usage = getRunUsage();
|
|
1639
|
-
return usage.totalCompletionTokens;
|
|
1640
|
-
};
|
|
1641
|
-
// Wire summarize for tool use summaries (uses lightweight call). Keep the
|
|
1642
|
-
// request out of the foreground tracker while billing and reporting it to
|
|
1643
|
-
// the owning session/Goal budget.
|
|
1644
|
-
modelFacade.summarize = async (sysPrompt, userMsg) => {
|
|
1645
|
-
const resp = await auxSummaryClient.createMessage({
|
|
1646
|
-
systemPrompt: sysPrompt,
|
|
1647
|
-
messages: [{ role: "user", content: userMsg }],
|
|
1648
|
-
tools: [],
|
|
1649
|
-
maxTokens: 256,
|
|
1650
|
-
billingEnabled: true,
|
|
1651
|
-
requestVisible: false,
|
|
1652
|
-
// Auxiliary call — see contextManager.setSummarizeFn above.
|
|
1653
|
-
reasoning: { mode: "off" },
|
|
1654
|
-
});
|
|
1655
|
-
if (resp.usage)
|
|
1656
|
-
recordExternalBilledUsage(resp.usage);
|
|
1657
|
-
logger.debug("summarize.call", {
|
|
1658
|
-
sysPromptLen: sysPrompt.length,
|
|
1659
|
-
userMsgLen: userMsg.length,
|
|
1660
|
-
userMsgPreview: userMsg.slice(0, 300),
|
|
1661
|
-
completionLen: resp.text.length,
|
|
1662
|
-
completionPreview: resp.text.slice(0, 300),
|
|
1663
|
-
stopReason: resp.stopReason,
|
|
1664
|
-
promptTokens: resp.usage?.promptTokens,
|
|
1665
|
-
completionTokens: resp.usage?.completionTokens,
|
|
1666
|
-
});
|
|
1667
|
-
return resp.text;
|
|
1668
|
-
};
|
|
1669
|
-
// File history: auto-backup before Write/Edit
|
|
1670
|
-
const sessionDir = join(this.config.sessionStorageDir ?? join(userHome(), ".code-shell", "sessions"), session.state.sessionId);
|
|
1671
|
-
const fileHistory = FileHistory.loadFromDir(sessionDir);
|
|
1672
|
-
// Keep a reference so we can unregister in the finally below. Registering an
|
|
1673
|
-
// anonymous handler every run() leaks: unregister matches by handler
|
|
1674
|
-
// identity, so without a stored reference each run stacks another identical
|
|
1675
|
-
// on_tool_start handler that fires (and re-snapshots) on every tool forever.
|
|
1676
|
-
const fileHistoryHandler = async (context) => {
|
|
1677
|
-
const toolName = context.data?.toolName;
|
|
1678
|
-
const args = context.data?.args;
|
|
1679
|
-
// Tag snapshots with the current turn (stamped above before any tool
|
|
1680
|
-
// runs) so turn-level /undo can revert just this user message's edits.
|
|
1681
|
-
const turnSeq = session.state.turnSeq;
|
|
1682
|
-
if ((toolName === "Write" || toolName === "Edit") && args?.file_path) {
|
|
1683
|
-
const path = args.file_path;
|
|
1684
|
-
// saveSnapshot returns null when the file does not exist yet — this
|
|
1685
|
-
// hook runs BEFORE the tool, so a null here means the turn is CREATING
|
|
1686
|
-
// the file. Record it (idempotent per turn) so /undo can delete it and
|
|
1687
|
-
// /redo can recreate it.
|
|
1688
|
-
if (fileHistory.saveSnapshot(path, turnSeq) === null && turnSeq !== undefined) {
|
|
1689
|
-
fileHistory.recordCreated(path, turnSeq);
|
|
1690
|
-
}
|
|
1691
|
-
}
|
|
1692
|
-
else if (toolName === "ApplyPatch" && typeof args?.patch === "string") {
|
|
1693
|
-
// ApplyPatch mutates files too, so /undo must see them. Snapshot every
|
|
1694
|
-
// existing file the patch updates or deletes (adds have no prior
|
|
1695
|
-
// content). Resolve relative patch paths against the engine cwd, the
|
|
1696
|
-
// same base ApplyPatch itself uses.
|
|
1697
|
-
const cwd = this.config.cwd ?? process.cwd();
|
|
1698
|
-
for (const target of patchBackupTargets(args.patch, cwd)) {
|
|
1699
|
-
fileHistory.saveSnapshot(target, turnSeq);
|
|
1700
|
-
}
|
|
1701
|
-
}
|
|
1702
|
-
return {};
|
|
1703
|
-
};
|
|
1704
|
-
this.hooks.register("on_tool_start", fileHistoryHandler, 100, "file_history_backup");
|
|
1705
|
-
// Hook: agent start
|
|
1706
|
-
await this.emitHook("on_agent_start", {
|
|
1707
|
-
sessionId: session.state.sessionId,
|
|
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,
|
|
1708
1168
|
task,
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
// turn loop keeps going until the session model judges the goal met.
|
|
1713
|
-
// Registered per-run (and cleared in `finally`) so a later goal-less
|
|
1714
|
-
// send doesn't inherit a stale goal. The judge runs on the primary
|
|
1715
|
-
// session client; auxSummaryClient remains dedicated to low-consequence
|
|
1716
|
-
// summaries/titles and retains defaults.auxText routing/fallback behavior.
|
|
1717
|
-
// Normalize the raw goal (string | GoalConfig) once at the run boundary;
|
|
1718
|
-
// everything inward uses the GoalConfig. normalizeGoal() returns undefined
|
|
1719
|
-
// when there's effectively no goal (empty objective).
|
|
1720
|
-
//
|
|
1721
|
-
// PERSISTENT GOAL (CC /goal style): a goal set on one send survives across
|
|
1722
|
-
// later sends and manual interrupts until met or cleared. Goal completion
|
|
1723
|
-
// is a high-consequence decision, so V1 routes it to the primary session
|
|
1724
|
-
// client, which is the model expected to interpret the supplied execution
|
|
1725
|
-
// evidence. defaults.auxText remains in force for summaries, titles and
|
|
1726
|
-
// other auxiliary work through auxSummaryClient.
|
|
1727
|
-
// Resolution:
|
|
1728
|
-
// 1. options.goal — this send explicitly sets/replaces the goal.
|
|
1729
|
-
// 2. session.state.activeGoal — a goal set on an earlier send.
|
|
1730
|
-
// 3. config.goal — engine-level default (rare; e.g. headless).
|
|
1731
|
-
// When (1) supplies a goal that differs from the stored one we REPLACE the
|
|
1732
|
-
// persisted active goal (one active goal per session) and announce it. A
|
|
1733
|
-
// bare send with no options.goal inherits the stored active goal so the
|
|
1734
|
-
// model keeps working toward it — that's what makes it persistent.
|
|
1735
|
-
const explicitGoal = normalizeGoal(options?.goal);
|
|
1736
|
-
let storedGoal = this.config.isSubAgent !== true ? session.state.activeGoal : undefined;
|
|
1737
|
-
// Defense in depth: a stale whole-state writer may have restored the
|
|
1738
|
-
// activeGoal field after this exact goal instance was force-terminated.
|
|
1739
|
-
// Refuse to arm it and converge the live bundle before hook registration.
|
|
1740
|
-
if (storedGoal && isSameGoalInstance(storedGoal, session.state.goalTerminal)) {
|
|
1741
|
-
session.state.activeGoal = undefined;
|
|
1742
|
-
storedGoal = undefined;
|
|
1743
|
-
this.sessionManager.saveState(session.state);
|
|
1744
|
-
}
|
|
1745
|
-
if (explicitGoal && this.config.isSubAgent !== true) {
|
|
1746
|
-
const replaced = !!storedGoal && storedGoal.objective !== explicitGoal.objective;
|
|
1747
|
-
// Stamp WHEN this goal was set so the judge can anchor relative deadlines
|
|
1748
|
-
// ("做到3点") to the set time, not "now" — else once the clock passes the
|
|
1749
|
-
// deadline the judge could read "3点" as tomorrow's and never stop. A new
|
|
1750
|
-
// or changed objective gets a fresh stamp; re-sending the SAME objective
|
|
1751
|
-
// keeps the original anchor (the goal continues, the user didn't restate a
|
|
1752
|
-
// new deadline). User input never carries setAtMs, so we set it here.
|
|
1753
|
-
const resolvedSetAt = resolveGoalSetAt(explicitGoal.objective, storedGoal, Date.now());
|
|
1754
|
-
// A user explicitly re-starting the same objective creates a new goal
|
|
1755
|
-
// instance. Avoid a same-millisecond collision with its old tombstone.
|
|
1756
|
-
explicitGoal.setAtMs =
|
|
1757
|
-
session.state.goalTerminal?.objective === explicitGoal.objective &&
|
|
1758
|
-
session.state.goalTerminal.setAtMs === resolvedSetAt
|
|
1759
|
-
? resolvedSetAt + 1
|
|
1760
|
-
: resolvedSetAt;
|
|
1761
|
-
session.state.activeGoal = explicitGoal;
|
|
1762
|
-
this.sessionManager.saveState(session.state);
|
|
1763
|
-
options?.onStream?.({
|
|
1764
|
-
type: "goal_set",
|
|
1765
|
-
objective: explicitGoal.objective,
|
|
1766
|
-
replaced,
|
|
1767
|
-
});
|
|
1768
|
-
}
|
|
1769
|
-
const normalizedGoal = explicitGoal ?? storedGoal ?? normalizeGoal(this.config.goal);
|
|
1770
|
-
// Snapshot the persisted goal identity owned by THIS run. Terminal
|
|
1771
|
-
// cleanup compares against this immutable copy so an old run cannot
|
|
1772
|
-
// delete a replacement goal installed while it was finishing.
|
|
1773
|
-
const persistedRunGoal = normalizedGoal && isSameGoalInstance(session.state.activeGoal, normalizedGoal)
|
|
1774
|
-
? { ...normalizedGoal }
|
|
1775
|
-
: undefined;
|
|
1776
|
-
let goalHookHandler = null;
|
|
1777
|
-
let goalJudgeContext;
|
|
1778
|
-
if (normalizedGoal && this.config.isSubAgent !== true) {
|
|
1779
|
-
goalHookHandler = createGoalStopHook({
|
|
1780
|
-
goal: normalizedGoal,
|
|
1781
|
-
llm: llmClient,
|
|
1782
|
-
log: logger,
|
|
1783
|
-
getJudgeContext: () => goalJudgeContext,
|
|
1784
|
-
onJudgeUsage: (usage) => {
|
|
1785
|
-
// The provider records this request into llmClient.getUsage() and the
|
|
1786
|
-
// process-wide CostTracker. This separate callback feeds the session
|
|
1787
|
-
// cumulative cache counters and the live Goal hard-budget tracker.
|
|
1788
|
-
if (usage)
|
|
1789
|
-
recordCumulativeUsage(usage);
|
|
1790
|
-
return turnLoop.recordGoalJudgeUsage(usage);
|
|
1791
|
-
},
|
|
1792
|
-
// Clear the persisted active goal the moment the judge says it's met,
|
|
1793
|
-
// so a later bare send doesn't re-inherit a satisfied goal. The hook
|
|
1794
|
-
// calls this from inside its met branch (single source of truth for
|
|
1795
|
-
// "goal achieved"); engine owns the persistence side-effect.
|
|
1796
|
-
onMet: () => {
|
|
1797
|
-
if (persistedRunGoal &&
|
|
1798
|
-
isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
|
|
1799
|
-
session.state.activeGoal = undefined;
|
|
1800
|
-
this.sessionManager.saveState(session.state);
|
|
1801
|
-
}
|
|
1802
|
-
},
|
|
1803
|
-
// Re-read the persisted goal each turn so a mid-run 清除 (clearGoal
|
|
1804
|
-
// wrote state.json but this hook's frozen goal copy + the closure's
|
|
1805
|
-
// in-RAM session are untouched) actually stops the judge. Reads disk
|
|
1806
|
-
// via readActiveGoal — authoritative and independent of which session
|
|
1807
|
-
// instance the run closure holds.
|
|
1808
|
-
isGoalActive: (sid) => isSameGoalInstance(this.sessionManager.readActiveGoal(sid), normalizedGoal),
|
|
1809
|
-
});
|
|
1810
|
-
this.hooks.register("on_stop", goalHookHandler, 0, "goal-stop");
|
|
1811
|
-
// Expose for clearGoal() mid-run. Already guarded by isSubAgent above.
|
|
1812
|
-
this.activeGoalHook = goalHookHandler;
|
|
1813
|
-
}
|
|
1814
|
-
// Surface compaction events to the UI so the user knows when context was trimmed.
|
|
1815
|
-
// Buffer the most recent event so TurnLoop can drain it and emit the
|
|
1816
|
-
// post_compact hook on the next turn (ContextManager itself doesn't
|
|
1817
|
-
// know about HookRegistry — the buffer is the seam).
|
|
1818
|
-
let pendingCompactInfo = null;
|
|
1819
|
-
contextManager.setOnCompact((info) => {
|
|
1820
|
-
pendingCompactInfo = info;
|
|
1821
|
-
options?.onStream?.({ type: "context_compact", ...info });
|
|
1822
|
-
});
|
|
1823
|
-
// Run turn loop
|
|
1824
|
-
turnLoop = new TurnLoop({
|
|
1825
|
-
model: modelFacade,
|
|
1169
|
+
cwd,
|
|
1170
|
+
options,
|
|
1171
|
+
toolCtx,
|
|
1826
1172
|
toolExecutor,
|
|
1827
1173
|
contextManager,
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
pendingCompactInfo = null;
|
|
1837
|
-
return info;
|
|
1838
|
-
},
|
|
1839
|
-
consumeSteer: (source) => this.consumeSteer(sid, source),
|
|
1840
|
-
restoreSteer: (items) => this.restoreSteer(sid, items),
|
|
1841
|
-
buildSteerUserMessageContent: async (item) => {
|
|
1842
|
-
const steerImageInput = await prepareRunImageInput({
|
|
1843
|
-
task: item.text,
|
|
1844
|
-
cwd,
|
|
1845
|
-
llm: this.config.llm,
|
|
1846
|
-
sessionId: sid,
|
|
1847
|
-
attachments: item.attachments,
|
|
1848
|
-
});
|
|
1849
|
-
if (!steerImageInput.ok) {
|
|
1850
|
-
throw new Error(steerImageInput.result.text);
|
|
1851
|
-
}
|
|
1852
|
-
return buildRunUserMessageContent(steerImageInput.parsedTask, cwd, steerImageInput.taskText);
|
|
1853
|
-
},
|
|
1854
|
-
claimClientMessageId: (clientMessageId, source) => claimClientMessageId(session, clientMessageId, source),
|
|
1855
|
-
releaseClientMessageId: (clientMessageId) => {
|
|
1856
|
-
claimedClientMessageIds.delete(clientMessageId);
|
|
1857
|
-
},
|
|
1858
|
-
setOriginClientMessageId: (clientMessageId) => {
|
|
1859
|
-
toolCtx.originClientMessageId = clientMessageId;
|
|
1860
|
-
},
|
|
1861
|
-
recordCumulativeUsage,
|
|
1862
|
-
recordCacheReadDiagnostics: (usage) => {
|
|
1863
|
-
this.recordCacheReadDiagnostics(sid, usage);
|
|
1864
|
-
},
|
|
1865
|
-
recordContextUsageAnchor: (anchor) => {
|
|
1866
|
-
session.state.contextUsageAnchor = {
|
|
1867
|
-
...anchor,
|
|
1868
|
-
provider: this.config.llm.provider,
|
|
1869
|
-
model: this.config.llm.model,
|
|
1870
|
-
};
|
|
1871
|
-
},
|
|
1872
|
-
// Clear the persisted goal for a self-reported completion / confirmed
|
|
1873
|
-
// cancel. Clears the in-RAM session's activeGoal (so THIS run's later
|
|
1874
|
-
// turns don't re-arm) AND persists it, and drops the in-flight stop
|
|
1875
|
-
// hook so nothing re-blocks the stop we're about to return.
|
|
1876
|
-
clearPersistedGoal: () => {
|
|
1877
|
-
if (persistedRunGoal &&
|
|
1878
|
-
isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
|
|
1879
|
-
session.state.activeGoal = undefined;
|
|
1880
|
-
this.sessionManager.saveState(session.state);
|
|
1881
|
-
}
|
|
1882
|
-
if (goalHookHandler) {
|
|
1883
|
-
this.hooks.unregister("on_stop", goalHookHandler);
|
|
1884
|
-
if (this.activeGoalHook === goalHookHandler)
|
|
1885
|
-
this.activeGoalHook = null;
|
|
1886
|
-
}
|
|
1887
|
-
},
|
|
1888
|
-
updateGoalJudgeContext: (context) => {
|
|
1889
|
-
goalJudgeContext = context;
|
|
1890
|
-
},
|
|
1891
|
-
ctxOverheadStore: {
|
|
1892
|
-
get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
|
|
1893
|
-
set: (s, n) => {
|
|
1894
|
-
this.ctxOverheadBySid.set(s, n);
|
|
1895
|
-
},
|
|
1896
|
-
},
|
|
1897
|
-
}, {
|
|
1898
|
-
// Goal mode raises the turn ceiling: an unattended goal run keeps
|
|
1899
|
-
// getting re-blocked by the stop-hook until it's done, and the 100
|
|
1900
|
-
// interactive default would silently truncate a long objective. The
|
|
1901
|
-
// real backstops are the goal token/time budgets + maxStopBlocks.
|
|
1902
|
-
maxTurns: resolveMaxTurns(this.config.maxTurns, normalizedGoal),
|
|
1903
|
-
// Consecutive stop-block cap: config override > goal.maxStopBlocks >
|
|
1904
|
-
// GOAL_DEFAULT_MAX_STOP_BLOCKS(25). The old hardcoded 8 was too tight
|
|
1905
|
-
// for complex goals that legitimately get re-blocked while advancing.
|
|
1906
|
-
maxStopBlocks: resolveMaxStopBlocks(this.config.maxStopBlocks, normalizedGoal),
|
|
1907
|
-
// 25 (was 10): modern models routinely batch >10 parallel tool calls
|
|
1908
|
-
// (e.g. reading a dozen files at once). At 10 the excess was silently
|
|
1909
|
-
// dropped; the turn loop now also warns the model when it caps, but a
|
|
1910
|
-
// higher ceiling avoids the round-trip in the common case. (B-3)
|
|
1911
|
-
maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 25,
|
|
1912
|
-
onStream: options?.onStream,
|
|
1913
|
-
signal: options?.signal,
|
|
1914
|
-
freshImageMessages: freshImageMessage ? [freshImageMessage] : undefined,
|
|
1915
|
-
volatileContextMessages: dynamicContextMsg ? [dynamicContextMsg] : undefined,
|
|
1916
|
-
// Goal mode: the active goal is surfaced to the on_stop handler via
|
|
1917
|
-
// ctx.data.goal; the GoalStopHook (registered above) judges it.
|
|
1918
|
-
goal: normalizedGoal,
|
|
1919
|
-
// Heartbeat: flush turnCount + tokens to state.json after every turn
|
|
1920
|
-
// so external observers (other CLI processes, /sid, the session list)
|
|
1921
|
-
// see live progress instead of a stale snapshot from the last
|
|
1922
|
-
// completed run.
|
|
1923
|
-
onTurnBoundary: (turnCount) => {
|
|
1924
|
-
session.state.turnCount = turnCount;
|
|
1925
|
-
// baseline + this run's running total (idempotent per boundary,
|
|
1926
|
-
// accumulates across runs; carries cacheRead/cacheCreation too).
|
|
1927
|
-
session.state.tokenUsage = foldRunUsage(usageBaseline, getRunUsage());
|
|
1928
|
-
// Surface the whole-session monotonic cache counts to the UI.
|
|
1929
|
-
// Separate from turn-loop's authoritative per-response emit (which
|
|
1930
|
-
// drives the live context reading and single-turn metric).
|
|
1931
|
-
const cumulative = normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage);
|
|
1932
|
-
const cumulativeHitRate = cumulativeCacheHitRate(cumulative);
|
|
1933
|
-
options?.onStream?.({
|
|
1934
|
-
type: "usage_update",
|
|
1935
|
-
promptTokens: cumulative.cumulativePromptTokens,
|
|
1936
|
-
promptTokensSource: "session_cumulative",
|
|
1937
|
-
promptTokensConfidence: "high",
|
|
1938
|
-
cumulativePromptTokens: cumulative.cumulativePromptTokens,
|
|
1939
|
-
cumulativeCacheReadTokens: cumulative.cumulativeCacheReadTokens,
|
|
1940
|
-
cumulativeCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
|
|
1941
|
-
...(cumulativeHitRate !== undefined
|
|
1942
|
-
? { cumulativeCacheHitRate: cumulativeHitRate }
|
|
1943
|
-
: {}),
|
|
1944
|
-
sessionPromptTokens: cumulative.cumulativePromptTokens,
|
|
1945
|
-
sessionCacheReadTokens: cumulative.cumulativeCacheReadTokens,
|
|
1946
|
-
sessionCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
|
|
1947
|
-
});
|
|
1948
|
-
if (this.config.costStore) {
|
|
1949
|
-
session.state.costState = this.config.costStore.serialize();
|
|
1950
|
-
}
|
|
1951
|
-
this.sessionManager.saveState(session.state);
|
|
1952
|
-
},
|
|
1174
|
+
llmClient,
|
|
1175
|
+
auxSummaryClient,
|
|
1176
|
+
fullSystemPrompt,
|
|
1177
|
+
toolDefs,
|
|
1178
|
+
claimClientMessageId,
|
|
1179
|
+
releaseClientMessageId,
|
|
1180
|
+
freshImageMessage,
|
|
1181
|
+
dynamicContextMsg,
|
|
1953
1182
|
});
|
|
1954
|
-
toolCtx.recordBilledUsage = recordExternalBilledUsage;
|
|
1955
|
-
// Expose this run's loop for mid-run extension (TODO 3.1). Top-level only —
|
|
1956
|
-
// a sub-agent's loop is its own concern and isn't user-extendable.
|
|
1957
|
-
if (this.config.isSubAgent !== true)
|
|
1958
|
-
this.activeTurnLoop = turnLoop;
|
|
1959
|
-
// Expose this run's session bundle so a mid-run clearGoal() wipes the goal
|
|
1960
|
-
// on the very instance this loop keeps saving (see field doc). Top-level
|
|
1961
|
-
// only — sub-agents don't carry user-clearable persistent goals.
|
|
1962
|
-
if (this.config.isSubAgent !== true)
|
|
1963
|
-
this.activeRunSession = session;
|
|
1964
|
-
const applyGoalTermination = (termination) => {
|
|
1965
|
-
if (!termination || !persistedRunGoal)
|
|
1966
|
-
return;
|
|
1967
|
-
// Judge prompt overflow ends only this run. The objective is unfinished
|
|
1968
|
-
// and may be resumed after the user reduces fixed judge context, so it
|
|
1969
|
-
// must not get a terminal tombstone or be cleared from activeGoal.
|
|
1970
|
-
if (termination === "judge_prompt_too_large")
|
|
1971
|
-
return;
|
|
1972
|
-
// Record the terminal identity even when a newer goal has already
|
|
1973
|
-
// replaced it. Only clear activeGoal when it is still the run's goal.
|
|
1974
|
-
session.state.goalTerminal = {
|
|
1975
|
-
objective: persistedRunGoal.objective,
|
|
1976
|
-
setAtMs: persistedRunGoal.setAtMs,
|
|
1977
|
-
reason: termination,
|
|
1978
|
-
terminatedAtMs: Date.now(),
|
|
1979
|
-
};
|
|
1980
|
-
if (isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
|
|
1981
|
-
session.state.activeGoal = undefined;
|
|
1982
|
-
}
|
|
1983
|
-
this.sessionManager.saveState(session.state);
|
|
1984
|
-
if (goalHookHandler) {
|
|
1985
|
-
this.hooks.unregister("on_stop", goalHookHandler);
|
|
1986
|
-
if (this.activeGoalHook === goalHookHandler)
|
|
1987
|
-
this.activeGoalHook = null;
|
|
1988
|
-
}
|
|
1989
|
-
};
|
|
1990
1183
|
let result;
|
|
1991
1184
|
let firstGoalTermination;
|
|
1992
1185
|
try {
|
|
1993
|
-
result = await
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
// the server's notification-wakeup path (maybeWakeIdleSession). The
|
|
2001
|
-
// INTERACTIVE path relies on that wakeup + a run-boundary re-check.
|
|
2002
|
-
//
|
|
2003
|
-
// HEADLESS is the exception: a one-shot `engine.run` whose caller takes
|
|
2004
|
-
// `result.text` as THE answer (automation / SDK) has no later turn to
|
|
2005
|
-
// pick up a wakeup — so it must wait, before resolving, until its own
|
|
2006
|
-
// background SUB-AGENTS finish and summarize. Only sub-agents (their
|
|
2007
|
-
// summary IS part of this run's result), NOT shells (a dev server never
|
|
2008
|
-
// exits → would hang headless forever) and NOT video (a long render the
|
|
2009
|
-
// one-shot run shouldn't block on). This replaces the old for(;;) park
|
|
2010
|
-
// (s-mpvf4rsj-bb6e4639 invariant) for the headless case only.
|
|
2011
|
-
const sid = session.state.sessionId;
|
|
2012
|
-
const isTopLevel = this.config.isSubAgent !== true;
|
|
2013
|
-
if (isTopLevel && this.isHeadless()) {
|
|
2014
|
-
let aborted = options?.signal?.aborted === true;
|
|
2015
|
-
// Loop: a summarize turn can spawn a NEW background sub-agent; keep
|
|
2016
|
-
// draining + summarizing until none remain. turnCount accumulates, so
|
|
2017
|
-
// the turn-loop's maxTurns still bounds runaway re-summarization.
|
|
2018
|
-
for (;;) {
|
|
2019
|
-
while (!aborted && asyncAgentRegistry.hasRunningForSession(sid)) {
|
|
2020
|
-
aborted = await this.waitForBackgroundAgentChange(sid, options?.signal);
|
|
2021
|
-
}
|
|
2022
|
-
let pending = notificationQueue.drainAll(sid);
|
|
2023
|
-
if (aborted && pending.length === 0) {
|
|
2024
|
-
// Abort race: an agent calls markCompleted (registry notify) and only
|
|
2025
|
-
// THEN enqueue (queue notify) as two separate statements. If the abort
|
|
2026
|
-
// fired before that agent's completion `.then` ran, the while above
|
|
2027
|
-
// exited on `aborted`, this drainAll caught nothing, and a naive
|
|
2028
|
-
// `break` here would drop the agent's output. Give still-settling
|
|
2029
|
-
// agents a bounded window to finish enqueuing, then drain once more.
|
|
2030
|
-
// Each wait is timeout-bounded so a genuinely stuck (never-completing)
|
|
2031
|
-
// agent can't hang abort cleanup forever — we'd rather lose nothing in
|
|
2032
|
-
// the common case and not hang in the pathological one.
|
|
2033
|
-
for (let i = 0; i < 20 && asyncAgentRegistry.hasRunningForSession(sid); i++) {
|
|
2034
|
-
const changed = await this.waitForBackgroundAgentChangeOrTimeout(sid, 25);
|
|
2035
|
-
if (!changed)
|
|
2036
|
-
break; // timed out with no state change → stop waiting
|
|
2037
|
-
}
|
|
2038
|
-
pending = notificationQueue.drainAll(sid);
|
|
2039
|
-
if (pending.length === 0)
|
|
2040
|
-
break;
|
|
2041
|
-
}
|
|
2042
|
-
else if (pending.length === 0) {
|
|
2043
|
-
break;
|
|
2044
|
-
}
|
|
2045
|
-
const injected = {
|
|
2046
|
-
role: "user",
|
|
2047
|
-
content: `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`,
|
|
2048
|
-
};
|
|
2049
|
-
if (aborted || firstGoalTermination) {
|
|
2050
|
-
// Mark injected: a synthetic notification, not the user's own input —
|
|
2051
|
-
// the disk reader drops it on replay so no phantom user bubble.
|
|
2052
|
-
// A goal termination is also a hard boundary: retain the notification
|
|
2053
|
-
// for recovery, but never re-enter TurnLoop (which would reset its
|
|
2054
|
-
// run-scoped goal budget tracker and could overwrite the first reason).
|
|
2055
|
-
session.transcript.appendMessage(injected.role, injected.content, { injected: true });
|
|
2056
|
-
result = { ...result, messages: [...result.messages, injected] };
|
|
2057
|
-
break;
|
|
2058
|
-
}
|
|
2059
|
-
result = await turnLoop.run([...result.messages, injected]);
|
|
2060
|
-
firstGoalTermination ??= result.goalTermination;
|
|
2061
|
-
applyGoalTermination(result.goalTermination);
|
|
2062
|
-
}
|
|
2063
|
-
}
|
|
1186
|
+
({ result, firstGoalTermination } = await this.runTurnLoopWithHeadlessDrain({
|
|
1187
|
+
turnLoop,
|
|
1188
|
+
messages,
|
|
1189
|
+
applyGoalTermination,
|
|
1190
|
+
session,
|
|
1191
|
+
options,
|
|
1192
|
+
}));
|
|
2064
1193
|
}
|
|
2065
1194
|
finally {
|
|
2066
1195
|
// Run-scoped: drop the GoalStopHook so a later goal-less send on this
|
|
2067
1196
|
// long-lived engine doesn't keep blocking stops.
|
|
2068
1197
|
if (goalHookHandler)
|
|
2069
1198
|
this.hooks.unregister("on_stop", goalHookHandler);
|
|
2070
|
-
if (this.activeGoalHook === goalHookHandler)
|
|
1199
|
+
if (this.activeGoalHook === goalHookHandler) {
|
|
2071
1200
|
this.activeGoalHook = null;
|
|
1201
|
+
this.activeGoalHookAttached = false;
|
|
1202
|
+
this.activeRuntimeGoal = null;
|
|
1203
|
+
this.activePersistedRunGoal = null;
|
|
1204
|
+
}
|
|
2072
1205
|
if (this.activeTurnLoop === turnLoop)
|
|
2073
1206
|
this.activeTurnLoop = null;
|
|
2074
1207
|
if (this.activeRunSession === session)
|
|
2075
1208
|
this.activeRunSession = null;
|
|
2076
1209
|
// Run-scoped too: this handler is re-registered every run(), so it must be
|
|
2077
1210
|
// dropped here or it stacks duplicates that re-snapshot on every tool.
|
|
2078
|
-
|
|
1211
|
+
fileHistoryHook.dispose();
|
|
2079
1212
|
}
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
sessionId: session.state.sessionId,
|
|
2085
|
-
reason: result.reason,
|
|
2086
|
-
turns: turnLoop.currentTurn,
|
|
2087
|
-
tokens: getRunUsage().totalTokens,
|
|
2088
|
-
});
|
|
2089
|
-
recordSessionEnd(session.state.sessionId, {
|
|
2090
|
-
reason: result.reason,
|
|
2091
|
-
turns: turnLoop.currentTurn,
|
|
2092
|
-
cost: getRunUsage(),
|
|
2093
|
-
});
|
|
2094
|
-
// Session-level hook: fired symmetrically with on_session_start once
|
|
2095
|
-
// the turn loop has resolved (completion, error, or abort). Handlers
|
|
2096
|
-
// are notify-only — any returned messages are dropped because the run
|
|
2097
|
-
// is already over and there's no next turn to inject into.
|
|
2098
|
-
await this.emitHook("on_session_end", {
|
|
2099
|
-
sessionId: session.state.sessionId,
|
|
2100
|
-
reason: result.reason,
|
|
1213
|
+
const finalized = await this.finalizeRun({
|
|
1214
|
+
session,
|
|
1215
|
+
result,
|
|
1216
|
+
firstGoalTermination,
|
|
2101
1217
|
turnCount: turnLoop.currentTurn,
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
const sessionId = session.state.sessionId;
|
|
2118
|
-
const rawContent = userMsgEvents[0]?.data?.content;
|
|
2119
|
-
const firstUserText = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent ?? "");
|
|
2120
|
-
void buildSessionTitle(auxSummaryClient, firstUserText, result.text, recordExternalBilledUsage)
|
|
2121
|
-
.then((title) => {
|
|
2122
|
-
if (title) {
|
|
2123
|
-
// Persist the title so it survives a localStorage wipe / disk
|
|
2124
|
-
// rebuild — it used to live only in the renderer's localStorage
|
|
2125
|
-
// index. Read the latest persisted state at callback time and
|
|
2126
|
-
// merge only title; the completed run's session.state snapshot
|
|
2127
|
-
// may already be stale after later serial session updates.
|
|
2128
|
-
this.sessionManager.updateSessionState(sessionId, { title });
|
|
2129
|
-
onStream({
|
|
2130
|
-
type: "session_title",
|
|
2131
|
-
sessionId,
|
|
2132
|
-
title,
|
|
2133
|
-
});
|
|
2134
|
-
}
|
|
2135
|
-
})
|
|
2136
|
-
.catch(() => { });
|
|
2137
|
-
}
|
|
2138
|
-
}
|
|
2139
|
-
// Update session state. Persist the raw terminal reason as the status so
|
|
2140
|
-
// callers can distinguish user-cancelled (aborted_streaming) from real
|
|
2141
|
-
// failures (model_error, prompt_too_long, ...) — previously every
|
|
2142
|
-
// non-completed outcome collapsed to "errored", which threw away the
|
|
2143
|
-
// distinction and misled anyone reading state.json.
|
|
2144
|
-
if (session.transcript.flushFailed()) {
|
|
2145
|
-
const failure = session.transcript.getFlushFailure();
|
|
2146
|
-
logger.error("engine.transcript_persistence_failed", {
|
|
2147
|
-
sessionId: session.state.sessionId,
|
|
2148
|
-
terminalReason: result.reason,
|
|
2149
|
-
degraded: true,
|
|
2150
|
-
...failure,
|
|
2151
|
-
});
|
|
2152
|
-
}
|
|
2153
|
-
session.state.turnCount = turnLoop.currentTurn;
|
|
2154
|
-
session.state.status = result.reason;
|
|
2155
|
-
// Session-cumulative (baseline + this run) for persistence...
|
|
2156
|
-
const usage = getRunUsage();
|
|
2157
|
-
session.state.tokenUsage = foldRunUsage(usageBaseline, usage);
|
|
2158
|
-
if (this.config.costStore) {
|
|
2159
|
-
session.state.costState = this.config.costStore.serialize();
|
|
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);
|
|
2160
1233
|
}
|
|
2161
|
-
|
|
2162
|
-
// instance. A different Engine using the same sessionId can still race
|
|
2163
|
-
// this saveState (including an old run's abort cleanup vs a replacement
|
|
2164
|
-
// Engine); cross-instance/process session serialization is a separate finding.
|
|
2165
|
-
this.sessionManager.saveState(session.state);
|
|
2166
|
-
runAccountingFinalized = true;
|
|
2167
|
-
// Hook: agent end
|
|
2168
|
-
await this.emitHook("on_agent_end", {
|
|
2169
|
-
sessionId: session.state.sessionId,
|
|
2170
|
-
reason: result.reason,
|
|
2171
|
-
turnCount: turnLoop.currentTurn,
|
|
2172
|
-
}, options?.signal);
|
|
2173
|
-
// Emit completion
|
|
2174
|
-
options?.onStream?.({ type: "turn_complete", reason: result.reason });
|
|
2175
|
-
return {
|
|
2176
|
-
text: result.text,
|
|
2177
|
-
reason: result.reason,
|
|
2178
|
-
goalTermination: firstGoalTermination,
|
|
2179
|
-
sessionId: session.state.sessionId,
|
|
2180
|
-
turnCount: turnLoop.currentTurn,
|
|
2181
|
-
usage: {
|
|
2182
|
-
promptTokens: usage.totalPromptTokens,
|
|
2183
|
-
completionTokens: usage.totalCompletionTokens,
|
|
2184
|
-
totalTokens: usage.totalTokens,
|
|
2185
|
-
cacheReadTokens: usage.totalCacheReadTokens,
|
|
2186
|
-
cacheCreationTokens: usage.totalCacheCreationTokens,
|
|
2187
|
-
},
|
|
2188
|
-
};
|
|
1234
|
+
return finalized;
|
|
2189
1235
|
});
|
|
2190
1236
|
return Promise.resolve(sessionRun).catch((err) => {
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
this.sessionManager.saveState(session.state);
|
|
2197
|
-
session.transcript.appendError(error, { phase: "initialization" });
|
|
2198
|
-
logger.error("engine.run_lifecycle_failed", {
|
|
2199
|
-
sessionId: session.state.sessionId,
|
|
2200
|
-
error: err instanceof Error ? err.message : String(err),
|
|
2201
|
-
});
|
|
2202
|
-
recordSessionEnd(session.state.sessionId, {
|
|
2203
|
-
reason: "model_error",
|
|
2204
|
-
turns: session.state.turnCount,
|
|
1237
|
+
const failed = buildRunFailureResult({
|
|
1238
|
+
err,
|
|
1239
|
+
session,
|
|
1240
|
+
options,
|
|
1241
|
+
persistFinalRunState: (state) => this.persistFinalRunState(state),
|
|
2205
1242
|
});
|
|
2206
|
-
options?.
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
return
|
|
2210
|
-
text: `ERROR: ${error}`,
|
|
2211
|
-
reason: "model_error",
|
|
2212
|
-
sessionId: session.state.sessionId,
|
|
2213
|
-
turnCount: session.state.turnCount,
|
|
2214
|
-
usage: {
|
|
2215
|
-
promptTokens: usage.promptTokens ?? 0,
|
|
2216
|
-
completionTokens: usage.completionTokens ?? 0,
|
|
2217
|
-
totalTokens: usage.totalTokens ?? 0,
|
|
2218
|
-
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
2219
|
-
cacheCreationTokens: usage.cacheCreationTokens ?? 0,
|
|
2220
|
-
},
|
|
2221
|
-
};
|
|
1243
|
+
if (options?.clientMessageId) {
|
|
1244
|
+
this.appendClientRunReceipt(session, options.clientMessageId, failed);
|
|
1245
|
+
}
|
|
1246
|
+
return failed;
|
|
2222
1247
|
});
|
|
2223
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", {
|
|
1257
|
+
sessionId: session.state.sessionId,
|
|
1258
|
+
clientMessageId,
|
|
1259
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
2224
1263
|
/**
|
|
2225
|
-
*
|
|
2226
|
-
*
|
|
2227
|
-
*
|
|
2228
|
-
|
|
2229
|
-
/**
|
|
2230
|
-
* Resolve the LLM client for background/auxiliary work (memory extraction,
|
|
2231
|
-
* auto-dream). When settings.defaults.auxText names a valid pool model, build
|
|
2232
|
-
* (and cache) a dedicated client for it so per-turn book-keeping runs on a cheap
|
|
2233
|
-
* fast model instead of the expensive primary. Falls back to `fallback` (the
|
|
2234
|
-
* active run's client) when unset, unknown, or on any build failure — aux
|
|
2235
|
-
* 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.
|
|
2236
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
|
+
}
|
|
2237
1301
|
/**
|
|
2238
|
-
* Build the
|
|
2239
|
-
*
|
|
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.
|
|
2240
1307
|
*/
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
recordCumulativeUsage?.(summaryResponse.usage);
|
|
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
|
+
});
|
|
2258
1324
|
}
|
|
2259
|
-
|
|
1325
|
+
userOnStream?.(event);
|
|
2260
1326
|
};
|
|
2261
1327
|
}
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
//
|
|
2280
|
-
//
|
|
2281
|
-
//
|
|
2282
|
-
//
|
|
2283
|
-
//
|
|
2284
|
-
//
|
|
2285
|
-
//
|
|
2286
|
-
//
|
|
2287
|
-
//
|
|
2288
|
-
//
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
}
|
|
2306
|
-
catch (err) {
|
|
2307
|
-
logger.warn("engine.aux_model_build_failed", {
|
|
2308
|
-
auxModelKey: auxKey,
|
|
2309
|
-
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
|
+
},
|
|
2310
1371
|
});
|
|
2311
|
-
return fallback;
|
|
2312
1372
|
}
|
|
1373
|
+
return { result, firstGoalTermination };
|
|
2313
1374
|
}
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
//
|
|
2335
|
-
//
|
|
2336
|
-
//
|
|
2337
|
-
//
|
|
2338
|
-
const
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
callLLM: async (sysPrompt, userMsg) => {
|
|
2347
|
-
// Use a lightweight auxiliary call (no tools, no streaming, no
|
|
2348
|
-
// reasoning tokens).
|
|
2349
|
-
const resp = await llmClient.createMessage({
|
|
2350
|
-
systemPrompt: sysPrompt,
|
|
2351
|
-
messages: [{ role: "user", content: userMsg }],
|
|
2352
|
-
tools: [],
|
|
2353
|
-
maxTokens: 1024,
|
|
2354
|
-
billingEnabled: true,
|
|
2355
|
-
requestVisible: false,
|
|
2356
|
-
reasoning: { mode: "off" },
|
|
2357
|
-
});
|
|
2358
|
-
if (resp.usage)
|
|
2359
|
-
recordBilledUsage?.(resp.usage);
|
|
2360
|
-
return resp.text;
|
|
2361
|
-
},
|
|
2362
|
-
runDream: async ({ systemPrompt, userPrompt, projectDir }) => this.runDreamLoop({
|
|
2363
|
-
systemPrompt,
|
|
2364
|
-
userPrompt,
|
|
2365
|
-
projectDir,
|
|
2366
|
-
llmClient,
|
|
2367
|
-
sessionId,
|
|
2368
|
-
recordBilledUsage,
|
|
2369
|
-
}),
|
|
2370
|
-
projectDir: cwd,
|
|
2371
|
-
// settings.memories.maxCount caps memories accepted per extraction;
|
|
2372
|
-
// autoExtract=false turns the extractor off (summaries/dream stay).
|
|
2373
|
-
maxCount: this.readMemoriesConfig()?.maxCount,
|
|
2374
|
-
autoExtract: this.readMemoriesConfig()?.autoExtract,
|
|
2375
|
-
});
|
|
2376
|
-
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);
|
|
2377
1407
|
}
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
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({
|
|
2381
1445
|
sessionId,
|
|
2382
|
-
|
|
1446
|
+
title,
|
|
1447
|
+
workspaceRoot: sourceRoot,
|
|
1448
|
+
...(workspaceProfile ? { workspaceProfile } : {}),
|
|
2383
1449
|
});
|
|
2384
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
|
+
};
|
|
2385
1471
|
}
|
|
2386
1472
|
/**
|
|
2387
|
-
*
|
|
2388
|
-
*
|
|
2389
|
-
*
|
|
2390
|
-
*
|
|
2391
|
-
*
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
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,
|
|
2411
1574
|
toolRegistry: this.toolRegistry,
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
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({
|
|
1995
|
+
toolRegistry: this.toolRegistry,
|
|
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
|
+
},
|
|
2416
2199
|
});
|
|
2417
|
-
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);
|
|
2418
2210
|
}
|
|
2419
2211
|
getToolRegistry() {
|
|
2420
2212
|
return this.toolRegistry;
|
|
2421
2213
|
}
|
|
2214
|
+
/** Registry suitable for constructing EngineRuntime; excludes local capabilities. */
|
|
2215
|
+
getRuntimeToolRegistry() {
|
|
2216
|
+
return this.runtimeToolRegistry;
|
|
2217
|
+
}
|
|
2422
2218
|
/**
|
|
2423
2219
|
* Switch the active model by pool key. Takes effect on the next run() call.
|
|
2424
2220
|
* Returns the new model entry.
|
|
@@ -2428,14 +2224,18 @@ export class Engine {
|
|
|
2428
2224
|
* only live in memory and every restart reverts to the previously persisted
|
|
2429
2225
|
* defaults.text.
|
|
2430
2226
|
*/
|
|
2431
|
-
switchModel(key) {
|
|
2227
|
+
switchModel(key, opts) {
|
|
2432
2228
|
const entry = this.modelPool.switch(key);
|
|
2433
2229
|
// LLMConfig is pure model identity now — rotate it wholesale. Cross-model
|
|
2434
2230
|
// runtime knobs (temperature/timeout/retryMaxAttempts/imageDetail) live on
|
|
2435
2231
|
// this.config.clientDefaults and survive the switch untouched.
|
|
2436
2232
|
const nextLlm = this.modelPool.toLLMConfig(entry);
|
|
2437
2233
|
this.config = { ...this.config, llm: nextLlm };
|
|
2438
|
-
|
|
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);
|
|
2439
2239
|
return entry;
|
|
2440
2240
|
}
|
|
2441
2241
|
/**
|
|
@@ -2452,9 +2252,14 @@ export class Engine {
|
|
|
2452
2252
|
// Persist to disk so a reload / next run picks up the reset.
|
|
2453
2253
|
if (this.sessionManager.exists(sessionId)) {
|
|
2454
2254
|
try {
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
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
|
+
}
|
|
2458
2263
|
}
|
|
2459
2264
|
catch {
|
|
2460
2265
|
// Session not resumable (never persisted yet) — the in-memory reset
|
|
@@ -2534,6 +2339,94 @@ export class Engine {
|
|
|
2534
2339
|
getSessionManager() {
|
|
2535
2340
|
return this.sessionManager;
|
|
2536
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
|
+
}
|
|
2537
2430
|
getConfig() {
|
|
2538
2431
|
return this.config;
|
|
2539
2432
|
}
|
|
@@ -2559,7 +2452,7 @@ export class Engine {
|
|
|
2559
2452
|
* the main user-visible preset effect and it IS hot. The toolRegistry's
|
|
2560
2453
|
* builtin tool SET, however, is ctor-frozen (and may be shared via runtime):
|
|
2561
2454
|
* it is NOT rebuilt here. So a preset change that alters the builtin tool set
|
|
2562
|
-
* (e.g.
|
|
2455
|
+
* (e.g. switching to a capability-contributed preset adds tools) only takes effect on the
|
|
2563
2456
|
* next session restart; we log a warning when that case is detected.
|
|
2564
2457
|
*
|
|
2565
2458
|
* disk-default-vs-slice caveat (#8): the patch carries pure DISK-default
|
|
@@ -2581,13 +2474,14 @@ export class Engine {
|
|
|
2581
2474
|
// (rebuilt per turn from this.preset) reflects the new preset's system
|
|
2582
2475
|
// prompt / behavior. Only when the preset actually changed.
|
|
2583
2476
|
if (patch.preset !== undefined && patch.preset !== prevPresetName) {
|
|
2584
|
-
const nextPreset = resolveAgentPreset(this.config.preset);
|
|
2477
|
+
const nextPreset = resolveAgentPreset(this.config.preset, this.capabilities);
|
|
2585
2478
|
// The builtin tool SET is ctor-frozen and may be shared via runtime — we
|
|
2586
2479
|
// do NOT rebuild it here. If the new preset implies a different builtin
|
|
2587
2480
|
// tool set, that part of the change only lands on session restart.
|
|
2588
2481
|
const prevTools = resolveBuiltinToolNames({
|
|
2589
2482
|
preset: prevPresetName,
|
|
2590
2483
|
host: this.config.builtinToolHost,
|
|
2484
|
+
capabilities: this.capabilities,
|
|
2591
2485
|
})
|
|
2592
2486
|
.slice()
|
|
2593
2487
|
.sort()
|
|
@@ -2595,6 +2489,7 @@ export class Engine {
|
|
|
2595
2489
|
const nextTools = resolveBuiltinToolNames({
|
|
2596
2490
|
preset: nextPreset.name,
|
|
2597
2491
|
host: this.config.builtinToolHost,
|
|
2492
|
+
capabilities: this.capabilities,
|
|
2598
2493
|
})
|
|
2599
2494
|
.slice()
|
|
2600
2495
|
.sort()
|
|
@@ -2637,7 +2532,7 @@ export class Engine {
|
|
|
2637
2532
|
* Read a session's persisted active goal WITHOUT resuming it (cheap — reads
|
|
2638
2533
|
* only state.json via SessionManager.readActiveGoal). The desktop host calls
|
|
2639
2534
|
* this on session load to re-surface the goal block + its Cancel button: a
|
|
2640
|
-
* persistent goal lives only in state.
|
|
2535
|
+
* persistent goal lives only in state.goalLifecycle and is never replayed from
|
|
2641
2536
|
* the transcript, so after a reload of an aborted goal run the UI would
|
|
2642
2537
|
* otherwise show nothing (the "goal 还在但页面不显示、取消不了" bug). Returns
|
|
2643
2538
|
* undefined when the session is unknown or has no active goal.
|
|
@@ -2645,43 +2540,185 @@ export class Engine {
|
|
|
2645
2540
|
getGoal(sessionId) {
|
|
2646
2541
|
return this.sessionManager.readActiveGoal(sessionId);
|
|
2647
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
|
+
}
|
|
2648
2637
|
/**
|
|
2649
2638
|
* Clear a session's persisted active goal (CC `/goal clear`). Works whether
|
|
2650
2639
|
* the session is idle or its goal run is in flight: it wipes
|
|
2651
|
-
* `state.
|
|
2640
|
+
* `state.goalLifecycle` (so the next bare send won't re-inherit it) and, if a
|
|
2652
2641
|
* goal hook is currently registered for this engine, unregisters it so an
|
|
2653
2642
|
* in-flight run can stop instead of being re-blocked by the now-cleared goal.
|
|
2654
2643
|
* Returns true if a goal was actually cleared. Idempotent — clearing a
|
|
2655
2644
|
* session with no active goal is a no-op returning false.
|
|
2656
2645
|
*/
|
|
2657
|
-
clearGoal(sessionId) {
|
|
2646
|
+
clearGoal(sessionId, expected) {
|
|
2658
2647
|
if (!this.sessionManager.exists(sessionId))
|
|
2659
2648
|
return false;
|
|
2660
|
-
// Prefer the LIVE run's bundle when it's this session
|
|
2661
|
-
//
|
|
2662
|
-
// back on its next saveState. A fresh resume() copy would be cleared and
|
|
2663
|
-
// persisted, but the running loop's own detached bundle still holds the
|
|
2664
|
-
// goal and resurrects it — the stale-write-back race. Falls back to a
|
|
2665
|
-
// 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.
|
|
2666
2651
|
const live = this.activeRunSession && this.activeRunSession.state.sessionId === sessionId
|
|
2667
2652
|
? this.activeRunSession
|
|
2668
2653
|
: null;
|
|
2669
2654
|
const session = live ?? this.sessionManager.resume(sessionId);
|
|
2670
|
-
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
|
+
}
|
|
2671
2666
|
if (had) {
|
|
2672
|
-
|
|
2673
|
-
|
|
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);
|
|
2674
2685
|
}
|
|
2675
2686
|
// If THIS session's goal run is in flight, drop its stop hook so the
|
|
2676
2687
|
// current run can terminate (the closure-held goal would otherwise keep
|
|
2677
2688
|
// re-blocking). The run's own `finally` also unregisters; double-unregister
|
|
2678
2689
|
// is safe (set delete is idempotent).
|
|
2679
|
-
if (
|
|
2680
|
-
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);
|
|
2681
2696
|
this.activeGoalHook = null;
|
|
2697
|
+
this.activeGoalHookAttached = false;
|
|
2698
|
+
this.activeRuntimeGoal = null;
|
|
2699
|
+
this.activePersistedRunGoal = null;
|
|
2682
2700
|
}
|
|
2683
2701
|
return had;
|
|
2684
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
|
+
}
|
|
2685
2722
|
/**
|
|
2686
2723
|
* Reset a session's workspace pointer back to its main root. If the session is
|
|
2687
2724
|
* actively running, mutate that live SessionBundle first so the run's next
|
|
@@ -2690,32 +2727,13 @@ export class Engine {
|
|
|
2690
2727
|
releaseSessionWorkspace(sessionId) {
|
|
2691
2728
|
if (!sessionId || !this.sessionManager.exists(sessionId))
|
|
2692
2729
|
return null;
|
|
2693
|
-
const mainRoot = this.sessionManager.
|
|
2730
|
+
const mainRoot = this.sessionManager.readSessionMainRoot(sessionId) ??
|
|
2694
2731
|
(this.activeRunSession?.state.sessionId === sessionId
|
|
2695
2732
|
? this.activeRunSession.state.cwd
|
|
2696
2733
|
: undefined);
|
|
2697
2734
|
if (!mainRoot)
|
|
2698
2735
|
return null;
|
|
2699
|
-
|
|
2700
|
-
if (this.activeRunSession?.state.sessionId === sessionId) {
|
|
2701
|
-
this.activeRunSession.state.workspace = workspace;
|
|
2702
|
-
}
|
|
2703
|
-
try {
|
|
2704
|
-
const bundle = this.activeRunSession?.state.sessionId === sessionId
|
|
2705
|
-
? this.activeRunSession
|
|
2706
|
-
: this.sessionManager.resume(sessionId);
|
|
2707
|
-
bundle.state.workspace = workspace;
|
|
2708
|
-
this.sessionManager.saveState(bundle.state);
|
|
2709
|
-
}
|
|
2710
|
-
catch {
|
|
2711
|
-
try {
|
|
2712
|
-
this.sessionManager.setSessionWorkspace(sessionId, workspace);
|
|
2713
|
-
}
|
|
2714
|
-
catch {
|
|
2715
|
-
return null;
|
|
2716
|
-
}
|
|
2717
|
-
}
|
|
2718
|
-
return workspace;
|
|
2736
|
+
return this.setSessionWorkspace(sessionId, { root: mainRoot, kind: "main" });
|
|
2719
2737
|
}
|
|
2720
2738
|
injectContext(sessionId, content) {
|
|
2721
2739
|
const session = this.sessionManager.resume(sessionId);
|
|
@@ -2740,6 +2758,42 @@ export class Engine {
|
|
|
2740
2758
|
const session = this.sessionManager.resume(effectiveSessionId);
|
|
2741
2759
|
const sourceMessages = this.compactedMessagesBySession.get(effectiveSessionId) ?? session.transcript.toMessages();
|
|
2742
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) {
|
|
2743
2797
|
let contextManager = this.lastContextManager;
|
|
2744
2798
|
if (!contextManager || this.lastSessionId !== effectiveSessionId) {
|
|
2745
2799
|
contextManager = new ContextManager({
|
|
@@ -2750,88 +2804,86 @@ export class Engine {
|
|
|
2750
2804
|
contextManager.initReplacementStateFromMessages(sourceMessages);
|
|
2751
2805
|
this.lastContextManager = contextManager;
|
|
2752
2806
|
}
|
|
2753
|
-
// Manual /compact emits its UI boundary at the protocol layer from the
|
|
2754
|
-
// final before/after result. Capture the tier here, but avoid reusing a
|
|
2755
|
-
// stale run callback retained on lastContextManager, which could otherwise
|
|
2756
|
-
// double-emit.
|
|
2757
|
-
let compactStrategy;
|
|
2758
|
-
contextManager.setOnCompact((info) => {
|
|
2759
|
-
if (info.after < info.before)
|
|
2760
|
-
compactStrategy = info.strategy;
|
|
2761
|
-
});
|
|
2762
|
-
// Manual /compact = maximum compaction NOW. The automatic ladder waits for
|
|
2763
|
-
// compactAtRatio (0.85 * window), so on a 1M-window model an 800k text-only
|
|
2764
|
-
// conversation sits under the gate and manage() only runs a no-op micro.
|
|
2765
|
-
// Wire a summarizeFn (the run path does this per-run; a cold forceCompact on
|
|
2766
|
-
// a resumed-but-never-run session has none) and call forceSummarize, which
|
|
2767
|
-
// ignores the ratio gate and always summarizes (falling back to snip/window).
|
|
2768
|
-
//
|
|
2769
|
-
// Use the PRIMARY model, not the aux model. Automatic background compaction
|
|
2770
|
-
// routes to aux to keep the high-frequency path cheap, but summarization is
|
|
2771
|
-
// a high-fidelity task (drop a decision and the conversation "forgets"), and
|
|
2772
|
-
// a manual /compact is a low-frequency, user-initiated request for quality.
|
|
2773
|
-
// The aux model is sized for tiny outputs (titles, memory extraction), so
|
|
2774
|
-
// downgrading the one compaction the user explicitly asked for is backwards.
|
|
2775
2807
|
try {
|
|
2776
2808
|
const primaryClient = await createLLMClient(this.config.llm, this.config.clientDefaults);
|
|
2777
2809
|
Object.assign(session.state, normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage));
|
|
2778
2810
|
const recordCompactUsage = (usage) => {
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
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
|
+
});
|
|
2782
2819
|
return next;
|
|
2783
2820
|
};
|
|
2784
2821
|
contextManager.setSummarizeFn(this.buildSummarizeFn(primaryClient, recordCompactUsage));
|
|
2785
2822
|
}
|
|
2786
2823
|
catch (err) {
|
|
2787
|
-
logger.warn(
|
|
2824
|
+
logger.warn(`engine.${op}_client_failed`, {
|
|
2788
2825
|
error: err.message,
|
|
2789
2826
|
});
|
|
2790
2827
|
}
|
|
2791
|
-
|
|
2792
|
-
const after = estimateTokens(compacted);
|
|
2793
|
-
this.compactedMessagesBySession.set(effectiveSessionId, compacted);
|
|
2794
|
-
this.lastSessionId = effectiveSessionId;
|
|
2795
|
-
this.lastMessages = compacted;
|
|
2796
|
-
return {
|
|
2797
|
-
before,
|
|
2798
|
-
after,
|
|
2799
|
-
strategy: after >= before ? "no compaction needed" : (compactStrategy ?? "compacted"),
|
|
2800
|
-
};
|
|
2828
|
+
return contextManager;
|
|
2801
2829
|
}
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
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 };
|
|
2810
2857
|
}
|
|
2811
|
-
recordCacheReadDiagnostics(sessionId,
|
|
2812
|
-
const
|
|
2813
|
-
if (
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
if (this.lastCacheReadBySid.size > CACHE_READ_DIAGNOSTIC_MAX_SESSIONS) {
|
|
2819
|
-
const oldestSessionId = this.lastCacheReadBySid.keys().next().value;
|
|
2820
|
-
if (oldestSessionId !== undefined)
|
|
2821
|
-
this.lastCacheReadBySid.delete(oldestSessionId);
|
|
2822
|
-
}
|
|
2823
|
-
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
|
+
});
|
|
2824
2865
|
return;
|
|
2825
|
-
|
|
2826
|
-
if (
|
|
2827
|
-
logger.
|
|
2866
|
+
}
|
|
2867
|
+
if (result.kind === "schema_changed") {
|
|
2868
|
+
logger.info("engine.cache_diagnostic_schema_changed", {
|
|
2828
2869
|
sessionId,
|
|
2829
|
-
|
|
2830
|
-
currentCacheReadTokens: current,
|
|
2831
|
-
dropRatio,
|
|
2832
|
-
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,
|
|
2833
2871
|
});
|
|
2872
|
+
return;
|
|
2834
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
|
+
});
|
|
2835
2887
|
}
|
|
2836
2888
|
getSettingsManager() {
|
|
2837
2889
|
if (!this.settingsManager) {
|
|
@@ -2846,7 +2898,7 @@ export class Engine {
|
|
|
2846
2898
|
this.getSettingsManager().saveUserSetting(key, value);
|
|
2847
2899
|
}
|
|
2848
2900
|
/**
|
|
2849
|
-
* Read a settings value by dotted key (e.g. "
|
|
2901
|
+
* Read a settings value by dotted key (e.g. "capabilities.foo.enabled").
|
|
2850
2902
|
* Returns undefined if any segment is missing.
|
|
2851
2903
|
*/
|
|
2852
2904
|
readSetting(key) {
|
|
@@ -2860,82 +2912,6 @@ export class Engine {
|
|
|
2860
2912
|
}
|
|
2861
2913
|
return target;
|
|
2862
2914
|
}
|
|
2863
|
-
buildPermissionConfig(mode, cwd, approvalRouter) {
|
|
2864
|
-
const rules = [...this.preset.defaultPermissionRules];
|
|
2865
|
-
// Memory tools: dream scope is the LLM's own workspace, so save/delete
|
|
2866
|
-
// there go through without prompting. user-scope save/delete have no
|
|
2867
|
-
// explicit allow rule here, so default-mode classifier fallback asks the
|
|
2868
|
-
// user to confirm modifications. RegisteredTool.permissionDefault is only
|
|
2869
|
-
// UI/metadata and is not read by the classifier.
|
|
2870
|
-
rules.push({
|
|
2871
|
-
tool: "MemorySave",
|
|
2872
|
-
argsPattern: { scope: "^dream$" },
|
|
2873
|
-
decision: "allow",
|
|
2874
|
-
reason: "Dream scope is the LLM's auto-consolidation workspace",
|
|
2875
|
-
});
|
|
2876
|
-
rules.push({
|
|
2877
|
-
tool: "MemoryDelete",
|
|
2878
|
-
argsPattern: { scope: "^dream$" },
|
|
2879
|
-
decision: "allow",
|
|
2880
|
-
reason: "Dream scope is the LLM's auto-consolidation workspace",
|
|
2881
|
-
});
|
|
2882
|
-
if (mode === "acceptEdits" || mode === "bypassPermissions") {
|
|
2883
|
-
rules.push({ tool: "Write", decision: "allow" });
|
|
2884
|
-
rules.push({ tool: "Edit", decision: "allow" });
|
|
2885
|
-
}
|
|
2886
|
-
if (mode === "bypassPermissions") {
|
|
2887
|
-
rules.push({ tool: "Bash", decision: "allow" });
|
|
2888
|
-
}
|
|
2889
|
-
try {
|
|
2890
|
-
const settingsManager = new SettingsManager(cwd, this.config.settingsScope ?? "project", this.config.projectTrusted !== false);
|
|
2891
|
-
const settings = settingsManager.get();
|
|
2892
|
-
if (settings.permissions?.rules?.length) {
|
|
2893
|
-
rules.unshift(...settings.permissions.rules);
|
|
2894
|
-
}
|
|
2895
|
-
}
|
|
2896
|
-
catch {
|
|
2897
|
-
// Settings not available — defaults only
|
|
2898
|
-
}
|
|
2899
|
-
let backend;
|
|
2900
|
-
if (this.config.approvalBackend) {
|
|
2901
|
-
backend =
|
|
2902
|
-
mode === "auto"
|
|
2903
|
-
? new AutoApprovalBackend(this.config.approvalBackend)
|
|
2904
|
-
: this.config.approvalBackend;
|
|
2905
|
-
}
|
|
2906
|
-
else if (mode === "auto") {
|
|
2907
|
-
backend = new AutoApprovalBackend();
|
|
2908
|
-
}
|
|
2909
|
-
else {
|
|
2910
|
-
// If a host installed an InteractiveApprovalBackend prompt fn
|
|
2911
|
-
// (agent-server-stdio does this on boot via setInteractiveApprovalFn),
|
|
2912
|
-
// use it so the UI gets a chance to approve/deny. Without this,
|
|
2913
|
-
// every `ask` permission silently fell through to deny-all and
|
|
2914
|
-
// the user saw "Permission denied by user" with NO modal — exactly
|
|
2915
|
-
// the bug that motivated this fix.
|
|
2916
|
-
let interactive;
|
|
2917
|
-
if (approvalRouter) {
|
|
2918
|
-
interactive =
|
|
2919
|
-
this.interactiveBackends.get(approvalRouter) ??
|
|
2920
|
-
new InteractiveApprovalBackend(approvalRouter);
|
|
2921
|
-
this.interactiveBackends.set(approvalRouter, interactive);
|
|
2922
|
-
}
|
|
2923
|
-
else {
|
|
2924
|
-
interactive = getInteractiveApprovalBackend();
|
|
2925
|
-
}
|
|
2926
|
-
if (interactive.hasPromptFn()) {
|
|
2927
|
-
backend = interactive;
|
|
2928
|
-
}
|
|
2929
|
-
else {
|
|
2930
|
-
backend = new HeadlessApprovalBackend(mode === "bypassPermissions"
|
|
2931
|
-
? "approve-all"
|
|
2932
|
-
: mode === "dontAsk"
|
|
2933
|
-
? "deny-all"
|
|
2934
|
-
: "deny-all");
|
|
2935
|
-
}
|
|
2936
|
-
}
|
|
2937
|
-
return { rules, backend };
|
|
2938
|
-
}
|
|
2939
2915
|
/**
|
|
2940
2916
|
* Switch permission mode at runtime. Idle updates apply immediately; busy
|
|
2941
2917
|
* updates are committed atomically when the current run settles, so its
|
|
@@ -2943,34 +2919,10 @@ export class Engine {
|
|
|
2943
2919
|
* Session-only — does not persist to settings.
|
|
2944
2920
|
*/
|
|
2945
2921
|
setPermissionMode(mode) {
|
|
2946
|
-
|
|
2947
|
-
this.pendingPermissionMode = mode;
|
|
2948
|
-
this.pendingPlanMode = mode === "plan";
|
|
2949
|
-
return;
|
|
2950
|
-
}
|
|
2951
|
-
this.applyPermissionState(mode, mode === "plan");
|
|
2952
|
-
}
|
|
2953
|
-
applyPermissionState(mode, planMode) {
|
|
2954
|
-
this.config = { ...this.config, permissionMode: mode };
|
|
2955
|
-
this.permissionMode = mode;
|
|
2956
|
-
this.planMode = planMode;
|
|
2957
|
-
if (this.activePermission) {
|
|
2958
|
-
const cwd = this.config.cwd ?? process.cwd();
|
|
2959
|
-
const { rules, backend } = this.buildPermissionConfig(mode, cwd, this.activeApprovalRouter);
|
|
2960
|
-
this.activePermission.reconfigure(mode, backend, rules);
|
|
2961
|
-
}
|
|
2962
|
-
}
|
|
2963
|
-
applyPendingPermissionState() {
|
|
2964
|
-
if (this.pendingPermissionMode === null)
|
|
2965
|
-
return;
|
|
2966
|
-
const mode = this.pendingPermissionMode;
|
|
2967
|
-
const planMode = this.pendingPlanMode ?? mode === "plan";
|
|
2968
|
-
this.pendingPermissionMode = null;
|
|
2969
|
-
this.pendingPlanMode = null;
|
|
2970
|
-
this.applyPermissionState(mode, planMode);
|
|
2922
|
+
this.permissionController.setPermissionMode(mode);
|
|
2971
2923
|
}
|
|
2972
2924
|
getPermissionMode() {
|
|
2973
|
-
return this.
|
|
2925
|
+
return this.permissionController.getPermissionMode();
|
|
2974
2926
|
}
|
|
2975
2927
|
/**
|
|
2976
2928
|
* Extend the in-flight run's turn ceiling and/or goal budgets (TODO 3.1 —
|
|
@@ -2990,20 +2942,14 @@ export class Engine {
|
|
|
2990
2942
|
* rule set buildPermissionConfig does, without constructing a backend.
|
|
2991
2943
|
*/
|
|
2992
2944
|
getPermissionRules() {
|
|
2993
|
-
return this.
|
|
2945
|
+
return this.permissionController.getPermissionRules();
|
|
2994
2946
|
}
|
|
2995
2947
|
/**
|
|
2996
2948
|
* Toggle plan mode directly. Called by the Plan tool (Task 7) via ToolContext.engine.
|
|
2997
2949
|
* Also syncs permissionMode to keep both fields consistent.
|
|
2998
2950
|
*/
|
|
2999
2951
|
setPlanMode(value) {
|
|
3000
|
-
|
|
3001
|
-
this.setPermissionMode("plan");
|
|
3002
|
-
}
|
|
3003
|
-
else if ((this.pendingPermissionMode ?? this.permissionMode) === "plan") {
|
|
3004
|
-
// Leaving plan mode: drop back to the default.
|
|
3005
|
-
this.setPermissionMode("acceptEdits");
|
|
3006
|
-
}
|
|
2952
|
+
this.permissionController.setPlanMode(value);
|
|
3007
2953
|
}
|
|
3008
2954
|
/**
|
|
3009
2955
|
* Block until a background agent's state changes (finishes / its result is
|
|
@@ -3071,9 +3017,9 @@ export class Engine {
|
|
|
3071
3017
|
* directory is read once rather than every turn. A new cwd (e.g. via
|
|
3072
3018
|
* run({ cwd })) reloads.
|
|
3073
3019
|
*/
|
|
3074
|
-
getAgentDefinitions(cwd) {
|
|
3075
|
-
const disabledAgents = this.readDisabledAgents(cwd);
|
|
3076
|
-
const disabledPlugins = this.readDisabledLists().disabledPlugins;
|
|
3020
|
+
getAgentDefinitions(cwd, explicitProfileOverrides) {
|
|
3021
|
+
const disabledAgents = this.readDisabledAgents(cwd, explicitProfileOverrides);
|
|
3022
|
+
const disabledPlugins = this.readDisabledLists(cwd, explicitProfileOverrides).disabledPlugins;
|
|
3077
3023
|
const disabledKey = [...disabledAgents, "::", ...disabledPlugins].slice().sort().join(" ");
|
|
3078
3024
|
if (this.agentDefsCache?.cwd !== cwd || this.agentDefsCache.disabledKey !== disabledKey) {
|
|
3079
3025
|
this.agentDefsCache = {
|
|
@@ -3093,14 +3039,12 @@ export class Engine {
|
|
|
3093
3039
|
* (getForScope) so inherit survives. No cwd / no overlay → baseline
|
|
3094
3040
|
* unchanged. Mirrors readDisabledLists (skills/plugins).
|
|
3095
3041
|
*/
|
|
3096
|
-
readDisabledAgents(cwd) {
|
|
3042
|
+
readDisabledAgents(cwd, explicitProfileOverrides) {
|
|
3097
3043
|
try {
|
|
3098
3044
|
const sm = this.getSettingsManager();
|
|
3099
3045
|
const settings = sm.get();
|
|
3100
3046
|
const baseline = Array.isArray(settings.disabledAgents) ? settings.disabledAgents : [];
|
|
3101
|
-
const overrides = cwd
|
|
3102
|
-
? sm.getForScope("project", cwd).capabilityOverrides
|
|
3103
|
-
: undefined;
|
|
3047
|
+
const overrides = effectiveProjectOverrides(sm, cwd, explicitProfileOverrides);
|
|
3104
3048
|
return effectiveDisabledList(baseline, overrides?.agents);
|
|
3105
3049
|
}
|
|
3106
3050
|
catch {
|
|
@@ -3114,12 +3058,11 @@ export class Engine {
|
|
|
3114
3058
|
* are already narrowed by resolveChildToolScope. No cwd / error → undefined,
|
|
3115
3059
|
* so the caller's baseline builtin lists pass through unchanged.
|
|
3116
3060
|
*/
|
|
3117
|
-
readBuiltinOverride(cwd) {
|
|
3061
|
+
readBuiltinOverride(cwd, explicitProfileOverrides) {
|
|
3118
3062
|
if (this.config.isSubAgent === true || !cwd)
|
|
3119
3063
|
return undefined;
|
|
3120
3064
|
try {
|
|
3121
|
-
const overrides = this.getSettingsManager()
|
|
3122
|
-
.capabilityOverrides;
|
|
3065
|
+
const overrides = effectiveProjectOverrides(this.getSettingsManager(), cwd, explicitProfileOverrides);
|
|
3123
3066
|
return overrides?.builtin;
|
|
3124
3067
|
}
|
|
3125
3068
|
catch {
|
|
@@ -3131,171 +3074,32 @@ export class Engine {
|
|
|
3131
3074
|
* overlays turn-specific fields like sandbox and subAgentSpawner) and
|
|
3132
3075
|
* by tests that want a ToolContext without a full run() cycle.
|
|
3133
3076
|
*/
|
|
3134
|
-
|
|
3135
|
-
const
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
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,
|
|
3145
3088
|
});
|
|
3146
|
-
|
|
3147
|
-
}
|
|
3148
|
-
return cached;
|
|
3149
|
-
}
|
|
3150
|
-
resolveSandboxConfigForCwd(cwd) {
|
|
3151
|
-
// Priority: config.sandbox → project settings.sandbox → global → per-run
|
|
3152
|
-
// default. Read UNMERGED per-scope (getForScope) so a project that wrote no
|
|
3153
|
-
// sandbox genuinely follows global, rather than inheriting global's mode and
|
|
3154
|
-
// looking like it set one. Fixes "项目级配了不生效" + the scope model.
|
|
3155
|
-
let projectSandbox;
|
|
3156
|
-
let globalSandbox;
|
|
3157
|
-
try {
|
|
3158
|
-
const sm = this.getSettingsManager();
|
|
3159
|
-
if (this.config.isSubAgent !== true) {
|
|
3160
|
-
projectSandbox = sm.getForScope("project", cwd).sandbox;
|
|
3161
|
-
}
|
|
3162
|
-
globalSandbox = sm.getForScope("user").sandbox;
|
|
3163
|
-
}
|
|
3164
|
-
catch {
|
|
3165
|
-
// settings unavailable → fall through to per-run default
|
|
3166
|
-
}
|
|
3167
|
-
return resolveSandboxConfig(this.config.sandbox, projectSandbox, globalSandbox, this.config.headless === true);
|
|
3168
|
-
}
|
|
3169
|
-
/**
|
|
3170
|
-
* Build the shell env layered onto the Bash tool / background shells (see
|
|
3171
|
-
* mergeShellEnv). Three user-configured sources, merged lowest → highest:
|
|
3172
|
-
*
|
|
3173
|
-
* 1. project `localEnvironment.env` — the per-project "local environment"
|
|
3174
|
-
* panel (DATABASE_URL etc.); the floor, so a project's own panel values
|
|
3175
|
-
* can be overridden by an explicit top-level `env`.
|
|
3176
|
-
* 2. global top-level `env` — ~/.code-shell/settings.json; the
|
|
3177
|
-
* canonical home for API keys (OPENAI_API_KEY) a skill script reads —
|
|
3178
|
-
* configure once, every project's skills get it.
|
|
3179
|
-
* 3. project top-level `env` — .code-shell/settings.json; a project
|
|
3180
|
-
* that wants to override a global key wins.
|
|
3181
|
-
*
|
|
3182
|
-
* Each scope is read UNMERGED so the layering here is the single source of
|
|
3183
|
-
* precedence (getForScope merges nothing). Returns undefined when no layer
|
|
3184
|
-
* contributes a key, so the caller passes it through unchanged for projects
|
|
3185
|
-
* that configure none.
|
|
3186
|
-
*
|
|
3187
|
-
* Sub-agents: a sub-agent is the user's OWN agent doing the user's work
|
|
3188
|
-
* (mirrors Claude Code, where sub-agents inherit the parent environment), so
|
|
3189
|
-
* it now reads the SAME env as the parent. The sub-agent branch is kept as an
|
|
3190
|
-
* explicit seam (`filterSubagentEnv`) rather than removed — a future policy
|
|
3191
|
-
* could narrow what a sub-agent sees (e.g. drop credential secrets) by
|
|
3192
|
-
* changing that one hook; today it passes everything through unchanged.
|
|
3193
|
-
* A no-cwd context still gets nothing (there is genuinely no project to read).
|
|
3194
|
-
*
|
|
3195
|
-
* None of these is filtered through the deny regex (mergeShellEnv): the user
|
|
3196
|
-
* put them there deliberately. The allowlist/deny machinery only guards the
|
|
3197
|
-
* host's process.env from a tainted model exfiltrating it via `env | curl`.
|
|
3198
|
-
*/
|
|
3199
|
-
readShellEnv(cwd) {
|
|
3200
|
-
if (!cwd)
|
|
3201
|
-
return undefined;
|
|
3202
|
-
const merged = {};
|
|
3203
|
-
const layer = (env) => {
|
|
3204
|
-
if (!env)
|
|
3205
|
-
return;
|
|
3206
|
-
for (const [k, v] of Object.entries(env)) {
|
|
3207
|
-
if (typeof v === "string")
|
|
3208
|
-
merged[k] = v;
|
|
3209
|
-
}
|
|
3210
|
-
};
|
|
3211
|
-
try {
|
|
3212
|
-
// The fully-merged settings already apply the scope guard (a 'project'
|
|
3213
|
-
// scope never reads the host ~/.code-shell) and the user < project <
|
|
3214
|
-
// local precedence — so the top-level `env` map read from here is global
|
|
3215
|
-
// values overridden by project values, exactly as specified. We layer
|
|
3216
|
-
// localEnvironment.env *under* it as the floor.
|
|
3217
|
-
const settings = this.getSettingsManager().get();
|
|
3218
|
-
layer(settings.localEnvironment?.env); // floor
|
|
3219
|
-
// Credentials flagged "expose as env var" (Credential.exposeAsEnv). This
|
|
3220
|
-
// is the wiring that was missing — the UI/store recorded the flag but no
|
|
3221
|
-
// code ever injected the secret, so `$FIGMA_TOKEN` was always empty.
|
|
3222
|
-
// Scope mirrors settingsScope so a project-scoped engine never surfaces
|
|
3223
|
-
// the host user's credentials (same isolation contract as top-level env).
|
|
3224
|
-
// Placed below settings.env so an explicit `env` entry can still override.
|
|
3225
|
-
const credScope = (this.config.settingsScope ?? "project") === "full" ? "full" : "project";
|
|
3226
|
-
layer(getCredentialAccess().envExposures(cwd, credScope));
|
|
3227
|
-
layer(settings.env); // top-level env (global ⊕ project) wins
|
|
3228
|
-
}
|
|
3229
|
-
catch {
|
|
3230
|
-
return undefined;
|
|
3231
|
-
}
|
|
3232
|
-
const result = this.config.isSubAgent === true ? this.filterSubagentEnv(merged) : merged;
|
|
3233
|
-
return Object.keys(result).length > 0 ? result : undefined;
|
|
3234
|
-
}
|
|
3235
|
-
/**
|
|
3236
|
-
* Policy seam for what a sub-agent's shell sees. A sub-agent inherits the
|
|
3237
|
-
* parent environment by default (mirrors Claude Code), so this is the
|
|
3238
|
-
* identity function today. It exists so a future policy can narrow the set
|
|
3239
|
-
* (e.g. strip credential `exposeAsEnv` secrets, or allowlist by name) in ONE
|
|
3240
|
-
* place instead of scattering `isSubAgent` checks through readShellEnv.
|
|
3241
|
-
*/
|
|
3242
|
-
filterSubagentEnv(env) {
|
|
3243
|
-
return env;
|
|
3244
|
-
}
|
|
3245
|
-
/**
|
|
3246
|
-
* Read the project's `localEnvironment.setupScripts` for this cwd (the raw
|
|
3247
|
-
* per-platform map). Used by EnterWorktree to run setup once in a freshly
|
|
3248
|
-
* created worktree. Returns undefined for sub-agents / no cwd (same minimal
|
|
3249
|
-
* surface as readShellEnv). The platform selection + run live in
|
|
3250
|
-
* git/worktree.ts; this only fetches the configured scripts.
|
|
3251
|
-
*/
|
|
3252
|
-
readWorktreeSetupScripts(cwd) {
|
|
3253
|
-
if (this.config.isSubAgent === true || !cwd)
|
|
3254
|
-
return undefined;
|
|
3255
|
-
try {
|
|
3256
|
-
const scoped = this.getSettingsManager().getForScope("project", cwd);
|
|
3257
|
-
return scoped.localEnvironment?.setupScripts;
|
|
3258
|
-
}
|
|
3259
|
-
catch {
|
|
3260
|
-
return undefined;
|
|
3261
|
-
}
|
|
3262
|
-
}
|
|
3263
|
-
readWorktreeBranchPrefix(cwd) {
|
|
3264
|
-
if (this.config.isSubAgent === true || !cwd)
|
|
3265
|
-
return undefined;
|
|
3266
|
-
try {
|
|
3267
|
-
const settings = this.getSettingsManager().get();
|
|
3268
|
-
return settings.worktree?.branchPrefix;
|
|
3269
|
-
}
|
|
3270
|
-
catch {
|
|
3271
|
-
return undefined;
|
|
3272
|
-
}
|
|
3273
|
-
}
|
|
3274
|
-
async resolveWorktreeSetupSandbox(cwd) {
|
|
3275
|
-
if (!cwd)
|
|
3276
|
-
return undefined;
|
|
3277
|
-
const sandboxConfig = this.resolveSandboxConfigForCwd(cwd);
|
|
3278
|
-
const sandboxBackend = this.runtime
|
|
3279
|
-
? await this.runtime.resolveSandbox(sandboxConfig, cwd)
|
|
3280
|
-
: await this.resolveSandboxWithoutRuntime(sandboxConfig, cwd);
|
|
3281
|
-
return sandboxBackend.name === "off"
|
|
3282
|
-
? sandboxBackend
|
|
3283
|
-
: { ...sandboxBackend, network: sandboxConfig.network };
|
|
3284
|
-
}
|
|
3285
|
-
readWorktreeSetupShellEnv(cwd) {
|
|
3286
|
-
return this.readShellEnv(cwd);
|
|
3287
|
-
}
|
|
3288
|
-
buildToolContext() {
|
|
3289
|
-
const { disabledSkills, disabledPlugins } = this.readDisabledLists();
|
|
3089
|
+
return [[capability.id, service]];
|
|
3090
|
+
}));
|
|
3290
3091
|
const ctx = {
|
|
3291
|
-
shellEnv: this.readShellEnv(
|
|
3292
|
-
cwd
|
|
3092
|
+
shellEnv: this.runEnvironmentResolver.readShellEnv(cwd),
|
|
3093
|
+
cwd,
|
|
3094
|
+
profileMemoryDir,
|
|
3293
3095
|
llmConfig: this.config.llm,
|
|
3294
3096
|
modelPool: this.modelPool,
|
|
3295
3097
|
toolRegistry: this.toolRegistry,
|
|
3098
|
+
capabilityServices,
|
|
3296
3099
|
askUser: this.config.askUser,
|
|
3297
3100
|
browser: this.config.browserBridge,
|
|
3298
3101
|
workspace: this.config.workspaceBridge,
|
|
3102
|
+
panels: this.config.panelBridge,
|
|
3299
3103
|
injectCredentialToBrowser: this.config.injectCredentialToBrowser,
|
|
3300
3104
|
isSubAgent: this.config.isSubAgent === true,
|
|
3301
3105
|
// Credential tools narrow their disk reads to this scope: a project/
|
|
@@ -3332,7 +3136,7 @@ export class Engine {
|
|
|
3332
3136
|
* reads — the prompt composer and the tool context will always see
|
|
3333
3137
|
* the same snapshot.
|
|
3334
3138
|
*/
|
|
3335
|
-
readDisabledLists() {
|
|
3139
|
+
readDisabledLists(cwd = this.config.cwd, explicitProfileOverrides) {
|
|
3336
3140
|
if (this.config.isSubAgent === true) {
|
|
3337
3141
|
return { disabledSkills: [], disabledPlugins: [], disabledPluginHooks: [] };
|
|
3338
3142
|
}
|
|
@@ -3340,7 +3144,7 @@ export class Engine {
|
|
|
3340
3144
|
// capabilityOverrides over the global baseline + the no-repo whitelist
|
|
3341
3145
|
// inversion. Extracted so the MCP merge consumers (engineFactory /
|
|
3342
3146
|
// diskDefaultsFrom) fold identically — see that module's doc.
|
|
3343
|
-
return computeEffectiveDisabledLists(this.getSettingsManager(),
|
|
3147
|
+
return computeEffectiveDisabledLists(this.getSettingsManager(), cwd, explicitProfileOverrides);
|
|
3344
3148
|
}
|
|
3345
3149
|
/**
|
|
3346
3150
|
* Public view of the folded disabled lists, for hosts that need the
|
|
@@ -3382,40 +3186,7 @@ export class Engine {
|
|
|
3382
3186
|
* falls back to its built-in defaults.
|
|
3383
3187
|
*/
|
|
3384
3188
|
readMemoriesConfig() {
|
|
3385
|
-
|
|
3386
|
-
const settings = this.getSettingsManager().get();
|
|
3387
|
-
return settings.memories;
|
|
3388
|
-
}
|
|
3389
|
-
catch {
|
|
3390
|
-
return undefined;
|
|
3391
|
-
}
|
|
3392
|
-
}
|
|
3393
|
-
/**
|
|
3394
|
-
* LLM client for memory extraction (TODO 8.1). Prefers
|
|
3395
|
-
* settings.memories.extractionModel when it names a valid pool model;
|
|
3396
|
-
* otherwise falls back to the aux client (which itself falls back to the
|
|
3397
|
-
* passed primary). Build failures fall back too — extraction is best-effort.
|
|
3398
|
-
*/
|
|
3399
|
-
async resolveExtractionClient(primaryClient) {
|
|
3400
|
-
const key = this.readMemoriesConfig()?.extractionModel;
|
|
3401
|
-
if (key) {
|
|
3402
|
-
const entry = this.modelPool.get(key);
|
|
3403
|
-
if (entry) {
|
|
3404
|
-
try {
|
|
3405
|
-
return await createLLMClient(this.modelPool.toLLMConfig(entry), this.config.clientDefaults);
|
|
3406
|
-
}
|
|
3407
|
-
catch (err) {
|
|
3408
|
-
logger.warn("engine.extraction_model_build_failed", {
|
|
3409
|
-
extractionModel: key,
|
|
3410
|
-
error: err.message,
|
|
3411
|
-
});
|
|
3412
|
-
}
|
|
3413
|
-
}
|
|
3414
|
-
else {
|
|
3415
|
-
logger.warn("engine.extraction_model_missing", { extractionModel: key });
|
|
3416
|
-
}
|
|
3417
|
-
}
|
|
3418
|
-
return this.resolveAuxClient(primaryClient);
|
|
3189
|
+
return this.auxiliaryPipeline.readMemoriesConfig();
|
|
3419
3190
|
}
|
|
3420
3191
|
}
|
|
3421
3192
|
/**
|