@cjhyy/code-shell-core 0.5.0-rc.0 → 0.5.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent-definition-registry.d.ts +29 -0
- package/dist/agent/agent-definition-registry.js +74 -0
- package/dist/agent/agent-definition.d.ts +37 -0
- package/dist/agent/agent-definition.js +59 -0
- package/dist/arena/arena.js +11 -12
- package/dist/arena/context/context-tools.js +2 -4
- package/dist/arena/context/within-root.d.ts +7 -0
- package/dist/arena/context/within-root.js +15 -0
- package/dist/arena/iterate/phases/argue.js +8 -2
- package/dist/arena/iterate/phases/revise.js +1 -1
- package/dist/arena/iterate/phases/tournament.js +3 -3
- package/dist/arena/phases/adjudication.js +1 -4
- package/dist/arena/phases/build-consensus.js +1 -4
- package/dist/arena/phases/cross-review.js +2 -8
- package/dist/arena/phases/debate-rounds.js +1 -4
- package/dist/arena/phases/participant-research.js +1 -4
- package/dist/arena/phases/planning-detail-expansion.js +8 -10
- package/dist/arena/planner.js +0 -1
- package/dist/arena/providers/docs.d.ts +1 -0
- package/dist/arena/providers/docs.js +5 -2
- package/dist/arena/providers/repo.d.ts +1 -0
- package/dist/arena/providers/repo.js +5 -2
- package/dist/arena/strategies/utils.js +36 -3
- package/dist/arena/transitions.d.ts +7 -2
- package/dist/arena/transitions.js +9 -4
- package/dist/arena/types.d.ts +8 -2
- package/dist/automation/cron-expr.d.ts +31 -0
- package/dist/automation/cron-expr.js +151 -0
- package/dist/automation/index.d.ts +41 -0
- package/dist/automation/index.js +39 -0
- package/dist/automation/runner.d.ts +67 -0
- package/dist/automation/runner.js +56 -0
- package/dist/automation/scheduler.d.ts +156 -0
- package/dist/automation/scheduler.js +546 -0
- package/dist/automation/store.d.ts +39 -0
- package/dist/automation/store.js +119 -0
- package/dist/automation/write-policy.d.ts +34 -0
- package/dist/automation/write-policy.js +113 -0
- package/dist/automation/write-run.d.ts +51 -0
- package/dist/automation/write-run.js +38 -0
- package/dist/capability-control/index.d.ts +5 -0
- package/dist/capability-control/index.js +3 -0
- package/dist/capability-control/overlay.d.ts +49 -0
- package/dist/capability-control/overlay.js +89 -0
- package/dist/capability-control/project.d.ts +65 -0
- package/dist/capability-control/project.js +147 -0
- package/dist/capability-control/service.d.ts +70 -0
- package/dist/capability-control/service.js +147 -0
- package/dist/capability-control/types.d.ts +62 -0
- package/dist/capability-control/types.js +17 -0
- package/dist/cli/agent-server-stdio.d.ts +12 -1
- package/dist/cli/agent-server-stdio.js +118 -18
- package/dist/cli/agent-server-tcp.d.ts +20 -0
- package/dist/cli/agent-server-tcp.js +120 -0
- package/dist/cli/graceful-shutdown.d.ts +27 -0
- package/dist/cli/graceful-shutdown.js +30 -0
- package/dist/context/compaction.js +23 -15
- package/dist/context/manager.d.ts +0 -20
- package/dist/context/manager.js +0 -37
- package/dist/cron/cron-runtime.d.ts +2 -0
- package/dist/cron/cron-runtime.js +2 -0
- package/dist/cron/cron-store.d.ts +2 -0
- package/dist/cron/cron-store.js +2 -0
- package/dist/cron/scheduler.d.ts +5 -32
- package/dist/cron/scheduler.js +5 -113
- package/dist/data/openrouter-sync.js +1 -1
- package/dist/engine/engine.d.ts +256 -16
- package/dist/engine/engine.js +856 -200
- package/dist/engine/goal.d.ts +42 -0
- package/dist/engine/goal.js +52 -0
- package/dist/engine/image-compression.d.ts +81 -0
- package/dist/engine/image-compression.js +186 -0
- package/dist/engine/image-policy.d.ts +152 -0
- package/dist/engine/image-policy.js +165 -0
- package/dist/engine/reactive-threshold.d.ts +13 -0
- package/dist/engine/reactive-threshold.js +17 -0
- package/dist/engine/runtime.js +7 -0
- package/dist/engine/session-title.d.ts +13 -0
- package/dist/engine/session-title.js +34 -0
- package/dist/engine/turn-loop.d.ts +29 -8
- package/dist/engine/turn-loop.js +498 -316
- package/dist/git/parse-log.d.ts +13 -0
- package/dist/git/parse-log.js +21 -0
- package/dist/git/utils.d.ts +8 -6
- package/dist/git/utils.js +57 -26
- package/dist/git/worktree.js +33 -18
- package/dist/hooks/events.d.ts +23 -1
- package/dist/hooks/goal-stop-hook.d.ts +30 -0
- package/dist/hooks/goal-stop-hook.js +102 -0
- package/dist/hooks/hook-output.d.ts +33 -0
- package/dist/hooks/hook-output.js +79 -0
- package/dist/hooks/registry.d.ts +7 -0
- package/dist/hooks/registry.js +38 -1
- package/dist/hooks/shell-runner.d.ts +2 -0
- package/dist/hooks/shell-runner.js +43 -2
- package/dist/index.d.ts +32 -7
- package/dist/index.js +30 -3
- package/dist/llm/capabilities/reasoning-control.d.ts +24 -0
- package/dist/llm/capabilities/reasoning-control.js +38 -0
- package/dist/llm/capabilities/rules.js +33 -3
- package/dist/llm/capabilities/types.d.ts +22 -2
- package/dist/llm/clamp-max-tokens.d.ts +11 -0
- package/dist/llm/clamp-max-tokens.js +17 -0
- package/dist/llm/client-base.d.ts +41 -4
- package/dist/llm/client-base.js +67 -13
- package/dist/llm/client-factory.d.ts +3 -3
- package/dist/llm/client-factory.js +2 -2
- package/dist/llm/model-pool.d.ts +15 -11
- package/dist/llm/model-pool.js +23 -20
- package/dist/llm/provider-catalog.d.ts +2 -2
- package/dist/llm/providers/anthropic.d.ts +32 -2
- package/dist/llm/providers/anthropic.js +147 -8
- package/dist/llm/providers/openai.d.ts +11 -2
- package/dist/llm/providers/openai.js +253 -52
- package/dist/llm/reasoning-setting.d.ts +51 -0
- package/dist/llm/reasoning-setting.js +32 -0
- package/dist/llm/stop-reason.d.ts +13 -0
- package/dist/llm/stop-reason.js +21 -0
- package/dist/llm/strip-vision.d.ts +32 -0
- package/dist/llm/strip-vision.js +94 -0
- package/dist/llm/types.d.ts +5 -6
- package/dist/logging/logger.js +18 -4
- package/dist/logging/sanitize-messages.d.ts +10 -0
- package/dist/logging/sanitize-messages.js +90 -0
- package/dist/lsp/client.js +28 -8
- package/dist/lsp/manager.js +2 -1
- package/dist/lsp/root-path.d.ts +9 -0
- package/dist/lsp/root-path.js +12 -0
- package/dist/onboarding.js +18 -11
- package/dist/plugins/gitOps.d.ts +34 -0
- package/dist/plugins/gitOps.js +59 -3
- package/dist/plugins/installer/codex/convertAgents.d.ts +7 -0
- package/dist/plugins/installer/codex/convertAgents.js +55 -0
- package/dist/plugins/installer/codex/convertMcp.d.ts +8 -0
- package/dist/plugins/installer/codex/convertMcp.js +45 -0
- package/dist/plugins/installer/codex/convertSkills.d.ts +12 -0
- package/dist/plugins/installer/codex/convertSkills.js +33 -0
- package/dist/plugins/installer/detectFormat.d.ts +2 -0
- package/dist/plugins/installer/detectFormat.js +6 -0
- package/dist/plugins/installer/install.d.ts +7 -0
- package/dist/plugins/installer/install.js +99 -0
- package/dist/plugins/installer/installFromSource.d.ts +13 -0
- package/dist/plugins/installer/installFromSource.js +45 -0
- package/dist/plugins/installer/list.d.ts +9 -0
- package/dist/plugins/installer/list.js +30 -0
- package/dist/plugins/installer/loadPluginAgents.d.ts +3 -0
- package/dist/plugins/installer/loadPluginAgents.js +23 -0
- package/dist/plugins/installer/loadPluginMcp.d.ts +7 -0
- package/dist/plugins/installer/loadPluginMcp.js +74 -0
- package/dist/plugins/installer/parseSource.d.ts +20 -0
- package/dist/plugins/installer/parseSource.js +74 -0
- package/dist/plugins/installer/paths.d.ts +5 -0
- package/dist/plugins/installer/paths.js +25 -0
- package/dist/plugins/installer/types.d.ts +49 -0
- package/dist/plugins/installer/types.js +27 -0
- package/dist/plugins/installer/uninstall.d.ts +2 -0
- package/dist/plugins/installer/uninstall.js +14 -0
- package/dist/plugins/installer/update.d.ts +14 -0
- package/dist/plugins/installer/update.js +65 -0
- package/dist/plugins/loadPluginHooks.d.ts +8 -1
- package/dist/plugins/loadPluginHooks.js +11 -1
- package/dist/plugins/marketplaceManager.d.ts +8 -1
- package/dist/plugins/marketplaceManager.js +38 -4
- package/dist/plugins/pluginCommandHook.js +34 -0
- package/dist/plugins/pluginInstaller.d.ts +21 -0
- package/dist/plugins/pluginInstaller.js +128 -29
- package/dist/plugins/schemas.js +40 -6
- package/dist/plugins/types.d.ts +4 -0
- package/dist/preset/index.js +16 -0
- package/dist/prompt/composer.d.ts +10 -0
- package/dist/prompt/composer.js +29 -4
- package/dist/prompt/sections/orchestration.md +8 -0
- package/dist/protocol/chat-session-manager.d.ts +8 -1
- package/dist/protocol/chat-session-manager.js +10 -0
- package/dist/protocol/chat-session.d.ts +17 -0
- package/dist/protocol/chat-session.js +28 -0
- package/dist/protocol/redact.d.ts +50 -0
- package/dist/protocol/redact.js +71 -0
- package/dist/protocol/server.d.ts +41 -2
- package/dist/protocol/server.js +231 -33
- package/dist/protocol/tcp-transport.d.ts +45 -0
- package/dist/protocol/tcp-transport.js +74 -0
- package/dist/protocol/types.d.ts +23 -0
- package/dist/protocol/types.js +6 -0
- package/dist/remote/bridge.d.ts +11 -2
- package/dist/remote/bridge.js +61 -33
- package/dist/run/ArtifactTracker.js +5 -4
- package/dist/run/EngineRunner.d.ts +27 -1
- package/dist/run/EngineRunner.js +46 -8
- package/dist/run/FileRunStore.js +32 -8
- package/dist/run/RunLock.d.ts +28 -2
- package/dist/run/RunLock.js +49 -7
- package/dist/run/RunManager.js +51 -3
- package/dist/run/RunQueue.d.ts +1 -0
- package/dist/run/RunQueue.js +8 -2
- package/dist/run/factory.d.ts +7 -0
- package/dist/run/factory.js +1 -0
- package/dist/run/index.d.ts +2 -2
- package/dist/run/index.js +1 -1
- package/dist/run/redirect-target.d.ts +7 -0
- package/dist/run/redirect-target.js +13 -0
- package/dist/run/types.js +1 -1
- package/dist/services/browser-open.d.ts +13 -0
- package/dist/services/browser-open.js +16 -0
- package/dist/services/dream-consolidation.d.ts +57 -0
- package/dist/services/dream-consolidation.js +151 -0
- package/dist/services/memory-orchestrator.js +18 -0
- package/dist/services/notifier.d.ts +8 -0
- package/dist/services/notifier.js +39 -25
- package/dist/services/oauth.js +5 -7
- package/dist/services/session-memory-sort.d.ts +8 -0
- package/dist/services/session-memory-sort.js +9 -0
- package/dist/services/session-memory.js +8 -9
- package/dist/session/file-history.js +10 -3
- package/dist/session/session-manager.d.ts +23 -1
- package/dist/session/session-manager.js +73 -2
- package/dist/settings/disk-defaults.d.ts +35 -0
- package/dist/settings/disk-defaults.js +24 -0
- package/dist/settings/manager.d.ts +45 -1
- package/dist/settings/manager.js +121 -17
- package/dist/settings/manager.test.d.ts +1 -0
- package/dist/settings/manager.test.js +73 -0
- package/dist/settings/personalization.d.ts +19 -0
- package/dist/settings/personalization.js +7 -0
- package/dist/settings/schema.d.ts +1163 -126
- package/dist/settings/schema.js +126 -10
- package/dist/skills/scanner.d.ts +13 -6
- package/dist/skills/scanner.js +23 -4
- package/dist/tool-system/builtin/add-marketplace.d.ts +12 -0
- package/dist/tool-system/builtin/add-marketplace.js +76 -0
- package/dist/tool-system/builtin/agent-registry.d.ts +15 -0
- package/dist/tool-system/builtin/agent-registry.js +14 -4
- package/dist/tool-system/builtin/agent.d.ts +55 -0
- package/dist/tool-system/builtin/agent.js +165 -17
- package/dist/tool-system/builtin/apply-patch/index.js +22 -3
- package/dist/tool-system/builtin/arena.js +6 -7
- package/dist/tool-system/builtin/complete-goal.d.ts +25 -0
- package/dist/tool-system/builtin/complete-goal.js +45 -0
- package/dist/tool-system/builtin/cron.js +53 -9
- package/dist/tool-system/builtin/edit.d.ts +2 -1
- package/dist/tool-system/builtin/edit.js +6 -1
- package/dist/tool-system/builtin/generate-image.d.ts +25 -0
- package/dist/tool-system/builtin/generate-image.js +146 -0
- package/dist/tool-system/builtin/glob.js +7 -0
- package/dist/tool-system/builtin/grep.js +6 -0
- package/dist/tool-system/builtin/index.d.ts +18 -1
- package/dist/tool-system/builtin/index.js +62 -1
- package/dist/tool-system/builtin/notebook-edit.d.ts +2 -1
- package/dist/tool-system/builtin/notebook-edit.js +8 -1
- package/dist/tool-system/builtin/read.d.ts +2 -1
- package/dist/tool-system/builtin/read.js +9 -1
- package/dist/tool-system/builtin/skill.js +23 -5
- package/dist/tool-system/builtin/sleep.js +9 -3
- package/dist/tool-system/builtin/task.js +4 -1
- package/dist/tool-system/builtin/update-automation-memory.d.ts +24 -0
- package/dist/tool-system/builtin/update-automation-memory.js +60 -0
- package/dist/tool-system/builtin/view-image.d.ts +18 -0
- package/dist/tool-system/builtin/view-image.js +96 -0
- package/dist/tool-system/builtin/web-search.d.ts +4 -1
- package/dist/tool-system/builtin/web-search.js +36 -3
- package/dist/tool-system/builtin/write.d.ts +2 -1
- package/dist/tool-system/builtin/write.js +7 -1
- package/dist/tool-system/context.d.ts +43 -0
- package/dist/tool-system/executor.d.ts +0 -1
- package/dist/tool-system/executor.js +34 -40
- package/dist/tool-system/investigation-guard.d.ts +4 -1
- package/dist/tool-system/investigation-guard.js +19 -3
- package/dist/tool-system/mcp-manager.d.ts +36 -12
- package/dist/tool-system/mcp-manager.js +173 -10
- package/dist/tool-system/path-policy.d.ts +94 -0
- package/dist/tool-system/path-policy.js +279 -0
- package/dist/tool-system/permission.js +43 -50
- package/dist/tool-system/plan-mode-allowlist.d.ts +21 -0
- package/dist/tool-system/plan-mode-allowlist.js +43 -0
- package/dist/tool-system/registry.js +12 -1
- package/dist/tool-system/validation.d.ts +10 -0
- package/dist/tool-system/validation.js +16 -3
- package/dist/types.d.ts +90 -15
- package/dist/utils/format.js +4 -2
- package/dist/utils/lockfile.js +9 -2
- package/dist/utils/theme.d.ts +6 -0
- package/dist/utils/theme.js +4 -2
- package/package.json +2 -1
package/dist/engine/engine.js
CHANGED
|
@@ -7,46 +7,200 @@ import { ToolExecutor } from "../tool-system/executor.js";
|
|
|
7
7
|
import { InvestigationGuard } from "../tool-system/investigation-guard.js";
|
|
8
8
|
import { TaskGuard } from "../tool-system/task-guard.js";
|
|
9
9
|
import { readLastTodoSnapshot } from "../tool-system/builtin/task.js";
|
|
10
|
+
import { agentToolDefWithTypes } from "../tool-system/builtin/agent.js";
|
|
11
|
+
import { BUILTIN_TOOL_GUARDS } from "../tool-system/builtin/index.js";
|
|
12
|
+
import { asyncAgentRegistry } from "../tool-system/builtin/agent-registry.js";
|
|
13
|
+
import { notificationQueue, buildNotificationMessage, } from "../tool-system/builtin/agent-notifications.js";
|
|
10
14
|
import { PermissionClassifier, HeadlessApprovalBackend, AutoApprovalBackend, InteractiveApprovalBackend, getInteractiveApprovalBackend, } from "../tool-system/permission.js";
|
|
11
15
|
import { HookRegistry } from "../hooks/registry.js";
|
|
12
16
|
import { wrapHookMessages } from "../hooks/inject.js";
|
|
17
|
+
import { createGoalStopHook } from "../hooks/goal-stop-hook.js";
|
|
18
|
+
import { normalizeGoal } from "./goal.js";
|
|
13
19
|
import { loadPluginHooks } from "../plugins/loadPluginHooks.js";
|
|
20
|
+
import { pluginAgentDirs } from "../plugins/installer/loadPluginAgents.js";
|
|
14
21
|
import { patchOrphanedToolUses } from "./patch-orphaned-tools.js";
|
|
15
22
|
import { runShellHook, shellHookMatches } from "../hooks/shell-runner.js";
|
|
16
23
|
import { ContextManager } from "../context/manager.js";
|
|
24
|
+
import { estimateTokens } from "../context/compaction.js";
|
|
25
|
+
import { PLAN_MODE_ALLOWED_TOOLS } from "../tool-system/plan-mode-allowlist.js";
|
|
17
26
|
import { PromptComposer } from "../prompt/composer.js";
|
|
18
27
|
import { SessionManager } from "../session/session-manager.js";
|
|
19
28
|
import { ModelFacade } from "./model-facade.js";
|
|
20
|
-
import { logger, setCurrentSid, runWithSid } from "../logging/logger.js";
|
|
29
|
+
import { logger, setCurrentSid, runWithSid, getCurrentSid } from "../logging/logger.js";
|
|
21
30
|
import { recordSessionStart, recordSessionEnd } from "../logging/session-recorder.js";
|
|
22
31
|
import { sanitizeContent, sanitizeTaskString } from "../logging/sanitize-messages.js";
|
|
23
32
|
import { TurnLoop } from "./turn-loop.js";
|
|
24
33
|
import { MCPManager } from "../tool-system/mcp-manager.js";
|
|
25
|
-
import { SettingsManager } from "../settings/manager.js";
|
|
34
|
+
import { SettingsManager, userHome } from "../settings/manager.js";
|
|
35
|
+
import { effectiveDisabledList, effectiveBuiltinLists } from "../capability-control/overlay.js";
|
|
26
36
|
import { FileHistory } from "../session/file-history.js";
|
|
27
37
|
import { defaultSandboxConfig, resolveSandboxBackend, } from "../tool-system/sandbox/index.js";
|
|
28
38
|
import { resolveAgentPreset, resolveBuiltinToolNames, } from "../preset/index.js";
|
|
29
39
|
import { ModelPool } from "../llm/model-pool.js";
|
|
40
|
+
import { AgentDefinitionRegistry } from "../agent/agent-definition-registry.js";
|
|
30
41
|
import { ProviderCatalog } from "../llm/provider-catalog.js";
|
|
31
42
|
import { defaultCacheDir } from "../llm/model-cache.js";
|
|
32
43
|
import { detectProviderFromApiKey, buildModelPool, } from "../onboarding.js";
|
|
33
44
|
import { detectPastedNoise } from "../utils/task-sanitizer.js";
|
|
34
45
|
import { parseTaskWithImages, } from "./parse-task.js";
|
|
46
|
+
import { enforceImagePolicy, byteLengthFromBase64, dropOversizedImages, } from "./image-policy.js";
|
|
47
|
+
import { tryCompressImages } from "./image-compression.js";
|
|
48
|
+
import { buildSessionTitle } from "./session-title.js";
|
|
35
49
|
import { capabilitiesFor } from "../llm/capabilities/index.js";
|
|
36
50
|
import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
|
|
51
|
+
import { runDreamConsolidation } from "../services/dream-consolidation.js";
|
|
37
52
|
import { join } from "node:path";
|
|
38
53
|
import { homedir } from "node:os";
|
|
39
54
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
|
|
55
|
+
/**
|
|
56
|
+
* Build ScanOptions.compatFileNames from the user's instruction compat toggles.
|
|
57
|
+
* Primary file name stays hard-wired to CODESHELL.md (not exposed). Turning a
|
|
58
|
+
* compat flag off only drops the same-named .md (CLAUDE.md / AGENTS.md); the
|
|
59
|
+
* .claude/ subdir, *.local.md and rules/ are intentionally NOT linked.
|
|
60
|
+
* undefined (instructions omitted) means both stay on — backward compatible.
|
|
61
|
+
*/
|
|
62
|
+
export function compatFileNamesFrom(instructions) {
|
|
63
|
+
const names = [];
|
|
64
|
+
if (instructions?.compatClaude !== false)
|
|
65
|
+
names.push("CLAUDE.md");
|
|
66
|
+
if (instructions?.compatCodex !== false)
|
|
67
|
+
names.push("AGENTS.md");
|
|
68
|
+
return names;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* True when two LLMConfigs name the SAME client identity — i.e. building a
|
|
72
|
+
* client from either would talk to the same model on the same endpoint with the
|
|
73
|
+
* same shaping. Used by resolveAuxClient to de-dup the aux client against the
|
|
74
|
+
* active model WITHOUT collapsing two distinct pool keys that merely share a
|
|
75
|
+
* `model` NAME but differ in reasoning/maxTokens/baseUrl/provider. Compares the
|
|
76
|
+
* fields that actually change request behavior; apiKey is intentionally NOT
|
|
77
|
+
* compared (two keys with the same endpoint+model but different credentials
|
|
78
|
+
* still produce equivalent aux work and don't warrant a second client). The
|
|
79
|
+
* reasoning object is compared by normalized JSON since it's a small
|
|
80
|
+
* discriminated union.
|
|
81
|
+
*/
|
|
82
|
+
function sameLlmIdentity(a, b) {
|
|
83
|
+
return (a.model === b.model &&
|
|
84
|
+
(a.baseUrl ?? undefined) === (b.baseUrl ?? undefined) &&
|
|
85
|
+
(a.provider ?? undefined) === (b.provider ?? undefined) &&
|
|
86
|
+
(a.providerKind ?? undefined) === (b.providerKind ?? undefined) &&
|
|
87
|
+
(a.maxTokens ?? undefined) === (b.maxTokens ?? undefined) &&
|
|
88
|
+
JSON.stringify(a.reasoning ?? null) === JSON.stringify(b.reasoning ?? null));
|
|
89
|
+
}
|
|
90
|
+
// Re-export the config hot-reload patch builder from here so the protocol
|
|
91
|
+
// server (and tests) can import it alongside Engine without reaching into the
|
|
92
|
+
// settings/ subtree directly. The implementation lives in settings/ to keep
|
|
93
|
+
// engine.ts from growing and to sit next to personalizationFrom it composes.
|
|
94
|
+
export { diskDefaultsFrom } from "../settings/disk-defaults.js";
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the LLM config for a spawned child Engine.
|
|
97
|
+
* - `modelKey` set + present in pool → that model's config (pure entry-derived
|
|
98
|
+
* identity; the parent's llm is NOT consulted).
|
|
99
|
+
* - otherwise (no key, no pool, or key miss) → the parent's llm unchanged.
|
|
100
|
+
* Key miss is a soft fallback, NOT an error: a stale agent definition must not
|
|
101
|
+
* crash the spawn.
|
|
102
|
+
*
|
|
103
|
+
* ClientDefaults (temperature/timeout/etc.) are inherited from the parent
|
|
104
|
+
* Engine directly via EngineConfig.clientDefaults — they do not flow through
|
|
105
|
+
* this helper because they're not part of LLMConfig anymore.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveChildLlm(modelKey, pool, parentLlm) {
|
|
108
|
+
if (modelKey && pool?.has(modelKey)) {
|
|
109
|
+
const resolved = pool.resolveLLMConfig(modelKey);
|
|
110
|
+
if (resolved)
|
|
111
|
+
return resolved;
|
|
112
|
+
}
|
|
113
|
+
return parentLlm;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Load reusable sub-agent role definitions, merging:
|
|
117
|
+
* 1. project-level <cwd>/.code-shell/agents/*.md (ships built-ins)
|
|
118
|
+
* 2. user-level ~/.code-shell/agents/*.md (user wins on name)
|
|
119
|
+
* Names in `disabledAgents` are filtered out so the LLM never sees them.
|
|
120
|
+
*/
|
|
121
|
+
export function loadAgentDefinitionsForCwd(cwd, disabledAgents = [], disabledPlugins = []) {
|
|
122
|
+
const home = homedir();
|
|
123
|
+
// Increasing priority; loadFromDirs is last-dir-wins. ORDER ENCODES POLICY:
|
|
124
|
+
// user (cross-project personal default, lowest) → plugins (reusable baseline)
|
|
125
|
+
// → project (highest). A repo's in-tree agent therefore overrides a same-named
|
|
126
|
+
// user agent. This REVERSES the previous user>project behavior (spec §7.2);
|
|
127
|
+
// the descriptor's shadowedSources surfaces the override so the UI can warn.
|
|
128
|
+
return AgentDefinitionRegistry.loadFromDirs([
|
|
129
|
+
{ dir: `${home}/.code-shell/agents`, source: "user" },
|
|
130
|
+
...pluginAgentDirs(disabledPlugins),
|
|
131
|
+
// No project context (no-project bucket): cwd is "". Skip the project
|
|
132
|
+
// source rather than synthesizing "/.code-shell/agents" at the FS root,
|
|
133
|
+
// which silently resolves to nothing and drops every project-level
|
|
134
|
+
// (built-in) agent from the list.
|
|
135
|
+
...(cwd ? [{ dir: `${cwd}/.code-shell/agents`, source: "project" }] : []),
|
|
136
|
+
], disabledAgents);
|
|
137
|
+
}
|
|
138
|
+
const NESTED_AGENT_TOOLS = ["Agent", "AgentStatus", "AgentCancel"];
|
|
139
|
+
/**
|
|
140
|
+
* #7: apply a project's per-turn builtin capability override to a tool list.
|
|
141
|
+
* A builtin marked `off` for the current cwd is HIDDEN from the turn's tool
|
|
142
|
+
* list (matching how skills/plugins/agents `off` apply mid-session). `on` /
|
|
143
|
+
* `inherit` / absent keep the tool — we can't re-add a tool the ctor-frozen
|
|
144
|
+
* registry omitted, but `on` for a tool already present is a no-op. Pure +
|
|
145
|
+
* exported so it's unit-testable without a full run() turn.
|
|
146
|
+
*/
|
|
147
|
+
export function applyBuiltinOverrideVisibility(tools, override) {
|
|
148
|
+
if (!override)
|
|
149
|
+
return tools;
|
|
150
|
+
return tools.filter((t) => override[t.name] !== "off");
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Compute a child Engine's tool scope.
|
|
154
|
+
* - `allowlist` set → child enabled = allowlist minus nested-agent tools
|
|
155
|
+
* (a per-role tool whitelist, e.g. a read-only researcher).
|
|
156
|
+
* - `allowlist` undefined → inherit parent enabled/disabled, always with the
|
|
157
|
+
* nested-agent tools forced into `disabled` (no grandchildren).
|
|
158
|
+
*/
|
|
159
|
+
export function resolveChildToolScope(allowlist, parentDisabled, parentEnabled) {
|
|
160
|
+
if (allowlist) {
|
|
161
|
+
return {
|
|
162
|
+
enabled: allowlist.filter((t) => !NESTED_AGENT_TOOLS.includes(t)),
|
|
163
|
+
disabled: [...NESTED_AGENT_TOOLS],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const disabled = Array.from(new Set([...(parentDisabled ?? []), ...NESTED_AGENT_TOOLS]));
|
|
167
|
+
const enabled = parentEnabled?.filter((t) => !NESTED_AGENT_TOOLS.includes(t));
|
|
168
|
+
return { enabled, disabled };
|
|
169
|
+
}
|
|
40
170
|
export class Engine {
|
|
41
171
|
config;
|
|
172
|
+
// Resolved per-session preset. Set in the ctor; re-resolved by
|
|
173
|
+
// refreshRuntimeConfig on a preset hot-reload so the next-turn PromptComposer
|
|
174
|
+
// picks up the new preset's system prompt / behavior (#2). NOT readonly for
|
|
175
|
+
// that reason. NOTE: the toolRegistry's builtin tool SET is still ctor-frozen
|
|
176
|
+
// and is NOT rebuilt on reload — a preset change that alters the builtin tool
|
|
177
|
+
// set only takes effect on session restart (logged in refreshRuntimeConfig).
|
|
42
178
|
preset;
|
|
43
179
|
toolRegistry;
|
|
44
180
|
hooks;
|
|
45
181
|
sessionManager;
|
|
46
182
|
mcpManager;
|
|
47
183
|
modelPool;
|
|
184
|
+
/**
|
|
185
|
+
* Handles for the settings-sourced hook handlers registered by
|
|
186
|
+
* registerSettingsHooks(), so reloadHooks() can unregister exactly those
|
|
187
|
+
* (and nothing else — plugin hooks, goal/builtin hooks are untouched) before
|
|
188
|
+
* re-registering from fresh settings. Without this, a reload would
|
|
189
|
+
* accumulate duplicate settings-hook handlers that all fire per event.
|
|
190
|
+
*/
|
|
191
|
+
settingsHookHandles = [];
|
|
192
|
+
/**
|
|
193
|
+
* Highest config-reload version applied so far. refreshRuntimeConfig drops
|
|
194
|
+
* any payload whose version is <= this, so out-of-order reload deliveries
|
|
195
|
+
* (multiple quick settings saves) can't let an older config clobber a newer
|
|
196
|
+
* one.
|
|
197
|
+
*/
|
|
198
|
+
lastAppliedConfigVersion = 0;
|
|
199
|
+
/** Memoized sub-agent role registry, keyed by the cwd it was loaded from. */
|
|
200
|
+
agentDefsCache;
|
|
48
201
|
/** Shared resources supplied at construction (adapter pattern — null when self-constructed). */
|
|
49
202
|
runtime;
|
|
203
|
+
sandboxCache = new Map();
|
|
50
204
|
/** Active permission mode for this Engine instance. */
|
|
51
205
|
permissionMode;
|
|
52
206
|
/** True when permissionMode === "plan". */
|
|
@@ -55,6 +209,13 @@ export class Engine {
|
|
|
55
209
|
// don't re-read 6+ JSON files on every /model, /login, etc. The manager
|
|
56
210
|
// handles its own cache invalidation in saveUserSetting().
|
|
57
211
|
settingsManager;
|
|
212
|
+
/**
|
|
213
|
+
* Cached auxiliary-task LLM client, keyed by the models[].key it was built
|
|
214
|
+
* from. Background calls (memory extraction, auto-dream) reuse it across
|
|
215
|
+
* runs so we don't redo the provider handshake every session. Invalidated
|
|
216
|
+
* implicitly: a changed auxModelKey produces a different cache key.
|
|
217
|
+
*/
|
|
218
|
+
auxClientCache;
|
|
58
219
|
// Live state from the current/most-recent run, retained for /compact and
|
|
59
220
|
// for live-mutating PermissionClassifier on permission-mode switch.
|
|
60
221
|
lastContextManager;
|
|
@@ -117,13 +278,48 @@ export class Engine {
|
|
|
117
278
|
}
|
|
118
279
|
const entries = settings.hooks ?? [];
|
|
119
280
|
for (const entry of entries) {
|
|
120
|
-
|
|
281
|
+
const event = entry.event;
|
|
282
|
+
const handler = async (ctx) => {
|
|
121
283
|
if (!shellHookMatches(entry, ctx))
|
|
122
284
|
return {};
|
|
123
285
|
return runShellHook(entry, ctx);
|
|
124
|
-
}
|
|
286
|
+
};
|
|
287
|
+
this.hooks.register(event, handler, 50, `shell:${entry.event}:${entry.command.slice(0, 32)}`);
|
|
288
|
+
// Track the (event, handler) so reloadHooks() can unregister exactly
|
|
289
|
+
// these settings-sourced handlers without touching plugin/goal/code hooks.
|
|
290
|
+
this.settingsHookHandles.push({ event, handler });
|
|
125
291
|
}
|
|
126
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* Re-apply settings.hooks onto the live HookRegistry after a settings
|
|
295
|
+
* change (config hot-reload layer 2). Surgical: removes ONLY the
|
|
296
|
+
* settings-sourced handlers this Engine previously registered (tracked in
|
|
297
|
+
* settingsHookHandles by identity) and re-runs registerSettingsHooks() from
|
|
298
|
+
* fresh disk settings. Plugin hooks (registered once at construction at
|
|
299
|
+
* priority 80) and goal/builtin/SDK-config hooks are never touched.
|
|
300
|
+
*
|
|
301
|
+
* The SettingsManager cache is invalidated first so the re-read reflects the
|
|
302
|
+
* latest settings.json on disk (mirrors freshSettings()'s load() semantics).
|
|
303
|
+
* Sub-agents never register settings hooks, so this is a no-op for them.
|
|
304
|
+
*/
|
|
305
|
+
reloadHooks() {
|
|
306
|
+
if (this.config.isSubAgent === true)
|
|
307
|
+
return;
|
|
308
|
+
// Drop the previously-registered settings handlers by identity.
|
|
309
|
+
for (const { event, handler } of this.settingsHookHandles) {
|
|
310
|
+
this.hooks.unregister(event, handler);
|
|
311
|
+
}
|
|
312
|
+
this.settingsHookHandles = [];
|
|
313
|
+
// Force the next get() to re-read disk so reloaded hooks reflect the
|
|
314
|
+
// newest settings.json, not a stale merged cache.
|
|
315
|
+
try {
|
|
316
|
+
this.getSettingsManager().invalidate();
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
// best-effort; registerSettingsHooks below tolerates read failures
|
|
320
|
+
}
|
|
321
|
+
this.registerSettingsHooks();
|
|
322
|
+
}
|
|
127
323
|
constructor(config) {
|
|
128
324
|
this.config = config;
|
|
129
325
|
// Wire shared runtime (adapter pattern — null when self-constructing).
|
|
@@ -132,11 +328,26 @@ export class Engine {
|
|
|
132
328
|
this.permissionMode = config.permissionMode ?? "acceptEdits";
|
|
133
329
|
this.planMode = this.permissionMode === "plan";
|
|
134
330
|
this.preset = resolveAgentPreset(config.preset);
|
|
331
|
+
// Fold the project's capabilityOverrides.builtin overlay over the global
|
|
332
|
+
// enabled/disabled builtin lists so a project can force-enable a
|
|
333
|
+
// globally-disabled builtin tool or force-disable a globally-enabled one
|
|
334
|
+
// (tri-state). Mirrors readDisabledLists for skills/plugins/agents; no cwd
|
|
335
|
+
// / no overlay → the config lists pass through unchanged (zero regression).
|
|
336
|
+
//
|
|
337
|
+
// #7: this builds the ctor-FROZEN builtin tool SET in the registry — a
|
|
338
|
+
// mid-session project override can't rebuild it. To make a builtin `off`
|
|
339
|
+
// toggle apply mid-session, run()'s per-turn tool-list assembly re-reads
|
|
340
|
+
// readBuiltinOverride(cwd) and HIDES `off` builtins from the turn's tool
|
|
341
|
+
// list (see the allToolDefs filter). `on` here can force-enable a
|
|
342
|
+
// globally-disabled builtin INTO the frozen set at construction; the
|
|
343
|
+
// per-turn path can only hide, not add, so a freshly-`on`'d builtin not in
|
|
344
|
+
// the set needs a session restart to appear.
|
|
345
|
+
const builtinLists = effectiveBuiltinLists(config.enabledBuiltinTools ?? [], config.disabledBuiltinTools ?? [], this.readBuiltinOverride(config.cwd));
|
|
135
346
|
this.toolRegistry = config.runtime?.toolRegistry ?? new ToolRegistry({
|
|
136
347
|
builtinTools: resolveBuiltinToolNames({
|
|
137
348
|
preset: this.preset.name,
|
|
138
|
-
enabledBuiltinTools:
|
|
139
|
-
disabledBuiltinTools:
|
|
349
|
+
enabledBuiltinTools: builtinLists.enabledBuiltinTools,
|
|
350
|
+
disabledBuiltinTools: builtinLists.disabledBuiltinTools,
|
|
140
351
|
}),
|
|
141
352
|
});
|
|
142
353
|
this.hooks = new HookRegistry();
|
|
@@ -148,7 +359,10 @@ export class Engine {
|
|
|
148
359
|
// overhead multiplied across sub-agents outweighs the value, and
|
|
149
360
|
// dispatched tasks should run with minimal surface area.
|
|
150
361
|
if (config.isSubAgent !== true) {
|
|
151
|
-
|
|
362
|
+
// disabledPlugins suppresses a plugin's hooks too (not just its
|
|
363
|
+
// Skill-tool entries) — see loadPluginHooks. readDisabledLists reads
|
|
364
|
+
// the same settings the prompt composer / tool context use.
|
|
365
|
+
loadPluginHooks(this.hooks, this.readDisabledLists().disabledPlugins);
|
|
152
366
|
}
|
|
153
367
|
// settings.hooks → shell-command wrappers. Chain order:
|
|
154
368
|
// plugin (80) → shell (50) → code (default 0).
|
|
@@ -201,24 +415,31 @@ export class Engine {
|
|
|
201
415
|
// We then switch the pool and write the resolved entry's credentials
|
|
202
416
|
// into config.llm, so the first run() uses the right endpoint instead
|
|
203
417
|
// of whatever env-derived fallback repl.ts seeded earlier.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
418
|
+
// Sub-agents skip the activeKey resync: their llm is chosen by the
|
|
419
|
+
// parent's resolveChildLlm (per-role model routing). activeKey is the
|
|
420
|
+
// *user's* current UI model selection and must not clobber a child's
|
|
421
|
+
// routed model — without this guard a role's `model: flash` is silently
|
|
422
|
+
// overridden back to whatever the user has active in the foreground.
|
|
423
|
+
if (this.config.isSubAgent !== true) {
|
|
424
|
+
const activeKey = settings.activeKey;
|
|
425
|
+
let match;
|
|
426
|
+
if (activeKey) {
|
|
427
|
+
match = settings.models.find((m) => m.key === activeKey);
|
|
428
|
+
}
|
|
429
|
+
if (!match) {
|
|
430
|
+
const currentModel = this.config.llm.model;
|
|
431
|
+
// OpenRouter stores entries as "provider/model-name"; the top-level
|
|
432
|
+
// settings.model.name is just "model-name". Match either form.
|
|
433
|
+
match = settings.models.find((m) => m.model === currentModel ||
|
|
434
|
+
(currentModel && m.model?.endsWith(`/${currentModel}`)));
|
|
435
|
+
}
|
|
436
|
+
if (match) {
|
|
437
|
+
const entry = this.modelPool.switch(match.key);
|
|
438
|
+
this.config = {
|
|
439
|
+
...this.config,
|
|
440
|
+
llm: this.modelPool.toLLMConfig(entry),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
222
443
|
}
|
|
223
444
|
}
|
|
224
445
|
else if (this.config.llm.apiKey) {
|
|
@@ -227,6 +448,27 @@ export class Engine {
|
|
|
227
448
|
// use /model to switch between the provider's available models.
|
|
228
449
|
this.autoPopulatePool(this.config.llm.apiKey, this.config.llm.baseUrl);
|
|
229
450
|
}
|
|
451
|
+
// Carry image-attachment settings + sampling temperature into
|
|
452
|
+
// clientDefaults. Both are cross-model knobs — they apply to whatever
|
|
453
|
+
// model is currently active and survive hot-switches. (Pre-cleanup
|
|
454
|
+
// these were merged into llm.imageDetail / llm.temperature; that path
|
|
455
|
+
// is gone because hot-switching now rotates llm wholesale.)
|
|
456
|
+
const imageSettings = settings.images;
|
|
457
|
+
const modelBlock = settings.model;
|
|
458
|
+
const nextDefaults = { ...(this.config.clientDefaults ?? {}) };
|
|
459
|
+
let defaultsChanged = false;
|
|
460
|
+
if (imageSettings?.detail && nextDefaults.imageDetail !== imageSettings.detail) {
|
|
461
|
+
nextDefaults.imageDetail = imageSettings.detail;
|
|
462
|
+
defaultsChanged = true;
|
|
463
|
+
}
|
|
464
|
+
if (typeof modelBlock?.temperature === "number" &&
|
|
465
|
+
nextDefaults.temperature !== modelBlock.temperature) {
|
|
466
|
+
nextDefaults.temperature = modelBlock.temperature;
|
|
467
|
+
defaultsChanged = true;
|
|
468
|
+
}
|
|
469
|
+
if (defaultsChanged) {
|
|
470
|
+
this.config = { ...this.config, clientDefaults: nextDefaults };
|
|
471
|
+
}
|
|
230
472
|
}
|
|
231
473
|
catch {
|
|
232
474
|
// Settings not available — pool stays empty
|
|
@@ -267,7 +509,7 @@ export class Engine {
|
|
|
267
509
|
const entry = this.modelPool.switch(defaultEntry.key);
|
|
268
510
|
this.config = {
|
|
269
511
|
...this.config,
|
|
270
|
-
llm: this.modelPool.toLLMConfig(entry
|
|
512
|
+
llm: this.modelPool.toLLMConfig(entry),
|
|
271
513
|
};
|
|
272
514
|
}
|
|
273
515
|
}
|
|
@@ -286,6 +528,13 @@ export class Engine {
|
|
|
286
528
|
setAskUser(fn) {
|
|
287
529
|
this.config.askUser = fn;
|
|
288
530
|
}
|
|
531
|
+
/**
|
|
532
|
+
* Whether this engine runs unattended (no interactive human). Used by the
|
|
533
|
+
* in-process AgentServer to decide whether to wire an interactive askUser.
|
|
534
|
+
*/
|
|
535
|
+
isHeadless() {
|
|
536
|
+
return this.config.headless === true;
|
|
537
|
+
}
|
|
289
538
|
/**
|
|
290
539
|
* Run a task from start to finish.
|
|
291
540
|
*/
|
|
@@ -304,6 +553,16 @@ export class Engine {
|
|
|
304
553
|
if (event.type === "task_update") {
|
|
305
554
|
latestTodos = event.tasks;
|
|
306
555
|
}
|
|
556
|
+
// Persist goal progress so replay/history shows how many rounds the
|
|
557
|
+
// goal ran. Display-only — toMessages() ignores this type, so it never
|
|
558
|
+
// re-enters the LLM context.
|
|
559
|
+
if (event.type === "goal_progress") {
|
|
560
|
+
session.transcript.append("goal_progress", {
|
|
561
|
+
status: event.status,
|
|
562
|
+
round: event.round,
|
|
563
|
+
...(event.gaps ? { gaps: event.gaps } : {}),
|
|
564
|
+
});
|
|
565
|
+
}
|
|
307
566
|
userOnStream?.(event);
|
|
308
567
|
};
|
|
309
568
|
if (options)
|
|
@@ -350,6 +609,67 @@ export class Engine {
|
|
|
350
609
|
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
351
610
|
};
|
|
352
611
|
}
|
|
612
|
+
// Size gate. Hosts (desktop renderer, TUI) are expected to
|
|
613
|
+
// pre-compress to IMAGE_TARGETS — if they didn't, we fail the turn
|
|
614
|
+
// fast with a clear message instead of letting the OpenAI client
|
|
615
|
+
// grind through three 16-second "Connection error" retries on a
|
|
616
|
+
// 4 MB body. See `image-policy.ts` for the rationale and limits.
|
|
617
|
+
let verdict = enforceImagePolicy(parsedTask.images);
|
|
618
|
+
if (!verdict.ok && verdict.code === "image_too_large") {
|
|
619
|
+
// One image blew the per-image cap. Try the engine-side
|
|
620
|
+
// compressor (jimp-backed when installed; no-op otherwise) so
|
|
621
|
+
// TUI / MCP paths that lack a host-side resize don't fail
|
|
622
|
+
// outright on a screenshot they could have rescaled. The
|
|
623
|
+
// re-check below is what decides whether we proceed.
|
|
624
|
+
const compressed = await tryCompressImages(parsedTask.images);
|
|
625
|
+
if (compressed.anyCompressed) {
|
|
626
|
+
parsedTask.images = compressed.images;
|
|
627
|
+
logger.info("engine.run.image_compressed", {
|
|
628
|
+
before: verdict.offender?.bytes,
|
|
629
|
+
after: compressed.images.reduce((s, i) => s + byteLengthFromBase64(i.base64), 0),
|
|
630
|
+
});
|
|
631
|
+
verdict = enforceImagePolicy(parsedTask.images);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
// After compression, anything still over the per-image cap is
|
|
635
|
+
// dropped with a textual placeholder instead of failing the
|
|
636
|
+
// turn (TODO-week.md #9e). The "5MB brick session" failure
|
|
637
|
+
// mode from Claude Code (research doc §A) was the case where a
|
|
638
|
+
// poisoned image entered history and every subsequent request
|
|
639
|
+
// re-sent it; placeholders keep history clean while letting
|
|
640
|
+
// the rest of the turn run.
|
|
641
|
+
if (!verdict.ok && verdict.code === "image_too_large") {
|
|
642
|
+
const drop = dropOversizedImages(parsedTask.images);
|
|
643
|
+
if (drop.droppedCount > 0) {
|
|
644
|
+
parsedTask.images = drop.kept;
|
|
645
|
+
parsedTask.hasImages = drop.kept.length > 0;
|
|
646
|
+
parsedTask.text = drop.placeholder + "\n\n" + parsedTask.text;
|
|
647
|
+
logger.warn("engine.run.image_dropped", {
|
|
648
|
+
droppedCount: drop.droppedCount,
|
|
649
|
+
keptCount: drop.kept.length,
|
|
650
|
+
});
|
|
651
|
+
verdict = enforceImagePolicy(parsedTask.images);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (!verdict.ok) {
|
|
655
|
+
// Cumulative / count caps can't be rescued by per-image
|
|
656
|
+
// dropping (well — too_many_images could trim by FIFO, but
|
|
657
|
+
// that's a bigger UX call than we want to make silently).
|
|
658
|
+
// Refuse the turn with the policy message.
|
|
659
|
+
logger.warn("engine.run.image_policy_failed", {
|
|
660
|
+
code: verdict.code,
|
|
661
|
+
imageCount: verdict.totals.imageCount,
|
|
662
|
+
totalBytes: verdict.totals.totalBytes,
|
|
663
|
+
offender: verdict.offender,
|
|
664
|
+
});
|
|
665
|
+
return {
|
|
666
|
+
text: `ERROR: ${verdict.message}`,
|
|
667
|
+
reason: "image_error",
|
|
668
|
+
sessionId: options?.sessionId ?? `image-policy-${verdict.code}`,
|
|
669
|
+
turnCount: 0,
|
|
670
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
671
|
+
};
|
|
672
|
+
}
|
|
353
673
|
}
|
|
354
674
|
// For downstream noise-detection + transcript persistence we want the
|
|
355
675
|
// *text* portion only — base64 bytes count as "noise" by the heuristic
|
|
@@ -388,29 +708,35 @@ export class Engine {
|
|
|
388
708
|
// background process explosion), and the sid / approval / dock
|
|
389
709
|
// model assumes a flat parent→children hierarchy. Layered with a
|
|
390
710
|
// runtime check in agent.ts as defense-in-depth.
|
|
391
|
-
const
|
|
392
|
-
const
|
|
393
|
-
...(this.config.disabledBuiltinTools ?? []),
|
|
394
|
-
...NESTED_AGENT_TOOLS,
|
|
395
|
-
]));
|
|
396
|
-
// If enabledBuiltinTools is set (explicit allow-list mode), strip
|
|
397
|
-
// the nested-agent tools from it too so the disable above isn't
|
|
398
|
-
// contradicted by an explicit allow.
|
|
399
|
-
const childEnabled = this.config.enabledBuiltinTools?.filter((t) => !NESTED_AGENT_TOOLS.includes(t));
|
|
711
|
+
const { enabled: childEnabled, disabled: childDisabled } = resolveChildToolScope(req.toolAllowlist, this.config.disabledBuiltinTools, this.config.enabledBuiltinTools);
|
|
712
|
+
const childLlm = resolveChildLlm(req.model, this.modelPool, this.config.llm);
|
|
400
713
|
const child = new Engine({
|
|
401
|
-
llm:
|
|
714
|
+
llm: childLlm,
|
|
715
|
+
// Inherit parent's runtime knobs (temperature, image detail, timeouts)
|
|
716
|
+
// but cap sub-agent retries at 2 — they're short-lived and we'd
|
|
717
|
+
// rather surface failures than burn a 9 s exponential backoff loop.
|
|
718
|
+
clientDefaults: { ...(this.config.clientDefaults ?? {}), retryMaxAttempts: 2 },
|
|
402
719
|
cwd,
|
|
403
720
|
permissionMode: this.config.permissionMode,
|
|
404
721
|
preset: this.preset.name,
|
|
405
722
|
enabledBuiltinTools: childEnabled,
|
|
406
723
|
disabledBuiltinTools: childDisabled,
|
|
407
724
|
customSystemPrompt: this.config.customSystemPrompt,
|
|
408
|
-
appendSystemPrompt: this.config.appendSystemPrompt,
|
|
725
|
+
appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt]
|
|
726
|
+
.filter(Boolean)
|
|
727
|
+
.join("\n\n") || undefined,
|
|
728
|
+
responseLanguage: this.config.responseLanguage,
|
|
729
|
+
userProfile: this.config.userProfile,
|
|
730
|
+
instructions: this.config.instructions,
|
|
409
731
|
maxTurns: req.maxTurns,
|
|
410
732
|
maxContextTokens: this.config.maxContextTokens ?? 200_000,
|
|
411
733
|
sessionStorageDir: this.config.sessionStorageDir,
|
|
412
734
|
headless: this.config.headless,
|
|
735
|
+
readOnlySession: req.readOnlySession,
|
|
413
736
|
sandbox: this.config.sandbox,
|
|
737
|
+
// Subagents inherit the parent's scope: a child runs in the same
|
|
738
|
+
// cwd/session, so it should see the same config layers the parent did.
|
|
739
|
+
settingsScope: this.config.settingsScope ?? "project",
|
|
414
740
|
isSubAgent: true,
|
|
415
741
|
});
|
|
416
742
|
// Where the spawned child Engine's stream events go. AgentTool's
|
|
@@ -458,11 +784,11 @@ export class Engine {
|
|
|
458
784
|
// `auto` mode handles its own downgrade with a one-time warning
|
|
459
785
|
// inside resolveSandboxBackend; explicit modes do not.
|
|
460
786
|
//
|
|
461
|
-
// Backend is cached
|
|
462
|
-
//
|
|
787
|
+
// Backend is cached per runtime/engine so the capability probe runs once
|
|
788
|
+
// per (mode, cwd) instead of every turn.
|
|
463
789
|
const sandboxBackend = this.runtime
|
|
464
790
|
? await this.runtime.resolveSandbox(sandboxConfig, cwd)
|
|
465
|
-
: await
|
|
791
|
+
: await this.resolveSandboxWithoutRuntime(sandboxConfig, cwd);
|
|
466
792
|
// sessionId is filled in after the session bundle is resolved below
|
|
467
793
|
// (the session may be cold-started or resumed). Until then this is
|
|
468
794
|
// intentionally shaped as a mutable local; we treat it as immutable
|
|
@@ -470,6 +796,7 @@ export class Engine {
|
|
|
470
796
|
const toolCtx = {
|
|
471
797
|
...this.buildToolContext(),
|
|
472
798
|
subAgentSpawner,
|
|
799
|
+
agentDefinitions: this.getAgentDefinitions(cwd),
|
|
473
800
|
sandbox: sandboxBackend,
|
|
474
801
|
cwd,
|
|
475
802
|
// TodoWrite reads this to push task_update events independently
|
|
@@ -523,9 +850,8 @@ export class Engine {
|
|
|
523
850
|
let messages;
|
|
524
851
|
if (options?.sessionId && this.sessionManager.exists(options.sessionId)) {
|
|
525
852
|
session = this.sessionManager.resume(options.sessionId);
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
: session.transcript.toMessages();
|
|
853
|
+
const cachedCompacted = this.compactedMessagesBySession.get(options.sessionId);
|
|
854
|
+
messages = cachedCompacted ? [...cachedCompacted] : session.transcript.toMessages();
|
|
529
855
|
// If the previous run was Ctrl+C'd or crashed between an assistant
|
|
530
856
|
// tool_use and the matching tool_result being persisted, the
|
|
531
857
|
// loaded sequence is invalid for OpenAI (which 400s on dangling
|
|
@@ -557,7 +883,7 @@ export class Engine {
|
|
|
557
883
|
else {
|
|
558
884
|
// Cold start: shape (2) reuses the host-supplied sid; shape (3)
|
|
559
885
|
// lets sessionManager generate one with nanoid.
|
|
560
|
-
session = this.sessionManager.create(cwd, this.config.llm.model, this.config.llm.provider, options?.sessionId);
|
|
886
|
+
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);
|
|
561
887
|
messages = [{ role: "user", content: userMessageContent }];
|
|
562
888
|
session.transcript.appendMessage("user", userMessageContent);
|
|
563
889
|
// Save first user message as session summary — text only. The summary
|
|
@@ -683,7 +1009,7 @@ export class Engine {
|
|
|
683
1009
|
}
|
|
684
1010
|
}
|
|
685
1011
|
// Kick off LLM client creation early (network handshake)
|
|
686
|
-
const llmClientPromise = createLLMClient(this.config.llm);
|
|
1012
|
+
const llmClientPromise = createLLMClient(this.config.llm, this.config.clientDefaults);
|
|
687
1013
|
const mode = this.config.permissionMode ?? "acceptEdits";
|
|
688
1014
|
const { rules: defaultRules, backend: approvalBackend } = this.buildPermissionConfig(mode, cwd);
|
|
689
1015
|
const permission = new PermissionClassifier(defaultRules, mode, approvalBackend);
|
|
@@ -704,8 +1030,12 @@ export class Engine {
|
|
|
704
1030
|
}
|
|
705
1031
|
const toolExecutor = new ToolExecutor(this.toolRegistry, permission, this.hooks);
|
|
706
1032
|
const investigationGuard = new InvestigationGuard();
|
|
707
|
-
if (this.config.
|
|
1033
|
+
if (this.config.readOnlySession) {
|
|
1034
|
+
investigationGuard.setPolicy("read-only-review");
|
|
1035
|
+
}
|
|
1036
|
+
else if (this.config.headless) {
|
|
708
1037
|
investigationGuard.setSoftMode(true);
|
|
1038
|
+
}
|
|
709
1039
|
toolExecutor.setInvestigationGuard(investigationGuard);
|
|
710
1040
|
toolExecutor.setTaskGuard(new TaskGuard(() => latestTodos));
|
|
711
1041
|
// Wire abort signal for cascading cancellation + per-Engine ToolContext
|
|
@@ -715,13 +1045,18 @@ export class Engine {
|
|
|
715
1045
|
maxTokens: this.resolveMaxContextTokens(),
|
|
716
1046
|
});
|
|
717
1047
|
this.lastContextManager = contextManager;
|
|
1048
|
+
const { disabledSkills, disabledPlugins } = this.readDisabledLists();
|
|
718
1049
|
const promptComposer = new PromptComposer({
|
|
719
1050
|
cwd,
|
|
720
1051
|
model: this.config.llm.model,
|
|
721
1052
|
preset: this.preset,
|
|
722
1053
|
customSystemPrompt: this.config.customSystemPrompt,
|
|
723
1054
|
appendSystemPrompt: this.config.appendSystemPrompt,
|
|
724
|
-
|
|
1055
|
+
responseLanguage: this.config.responseLanguage,
|
|
1056
|
+
userProfile: this.config.userProfile,
|
|
1057
|
+
instructionOptions: { compatFileNames: compatFileNamesFrom(this.config.instructions) },
|
|
1058
|
+
disabledSkills,
|
|
1059
|
+
disabledPlugins,
|
|
725
1060
|
});
|
|
726
1061
|
// Connect MCP servers (if configured and not already connected).
|
|
727
1062
|
// B1: prefer the Runtime-owned MCPManager so all sessions in a
|
|
@@ -742,27 +1077,58 @@ export class Engine {
|
|
|
742
1077
|
// 1. createLLMClient — network handshake (started earlier)
|
|
743
1078
|
// 2. buildSystemPrompt — includes git status (3 execSync calls)
|
|
744
1079
|
// 3. buildSystemContext — reads environment context
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1080
|
+
// Inject the live available-agent-types listing into the Agent tool's
|
|
1081
|
+
// description. The registry is per-engine (loaded from .code-shell/agents
|
|
1082
|
+
// for this cwd), so it can't live in the static tool def — without this
|
|
1083
|
+
// the model never learns the reusable roles exist and spawns nameless
|
|
1084
|
+
// ad-hoc agents instead (the Core A/B/C incident).
|
|
1085
|
+
// The Agent tool is always available: with configured roles, an omitted
|
|
1086
|
+
// agent_type falls back to one of them (see resolveAgentTypeOverrides); with
|
|
1087
|
+
// no roles configured it runs a true ephemeral agent, so workflows that need
|
|
1088
|
+
// sub-agents (e.g. superpowers) work in any project.
|
|
1089
|
+
// Availability guard (tool-visibility): a gated builtin (WebSearch needs a
|
|
1090
|
+
// search provider, GenerateImage needs an OpenAI provider) is hidden from
|
|
1091
|
+
// the toolDefs the model sees when its credential isn't configured for this
|
|
1092
|
+
// cwd. Recomputed every message, so configuring a key takes effect on the
|
|
1093
|
+
// NEXT message without a restart. Tools with no guard entry are always kept.
|
|
1094
|
+
const guardCwd = toolCtx.cwd;
|
|
1095
|
+
// #7: per-turn project builtin override. The toolRegistry's builtin tool
|
|
1096
|
+
// SET is ctor-frozen (and may be shared via runtime), so a mid-session
|
|
1097
|
+
// project override of a builtin can't rebuild the registry. But the tool
|
|
1098
|
+
// LIST handed to the LLM is assembled fresh every turn, so we apply the
|
|
1099
|
+
// override here: a builtin marked `off` for this cwd is HIDDEN from the
|
|
1100
|
+
// turn's tool list (matching how skills/plugins/agents `off` apply
|
|
1101
|
+
// mid-session via readDisabledLists). `on`/`inherit` keep whatever the
|
|
1102
|
+
// registry already has — we can't re-add a tool the frozen registry omits,
|
|
1103
|
+
// but `on` for a tool already present is a no-op (it stays). This makes a
|
|
1104
|
+
// builtin toggle take effect on the NEXT message, like other capability
|
|
1105
|
+
// kinds, without touching the registry.
|
|
1106
|
+
const builtinOverride = this.readBuiltinOverride(guardCwd);
|
|
1107
|
+
// Turn `off` from a prompt-visibility filter into a real execution gate:
|
|
1108
|
+
// collect the builtin tool names the override marks `off` and hand them to
|
|
1109
|
+
// the executor (via the shared toolCtx the executor already holds a
|
|
1110
|
+
// reference to, set at setContext above) so it rejects a call to a hidden
|
|
1111
|
+
// builtin instead of running it from the still-populated registry.
|
|
1112
|
+
if (builtinOverride) {
|
|
1113
|
+
const registryNames = new Set(this.toolRegistry.getToolDefinitions().map((t) => t.name));
|
|
1114
|
+
const disabledBuiltins = new Set(Object.keys(builtinOverride).filter((name) => builtinOverride[name] === "off" && registryNames.has(name)));
|
|
1115
|
+
toolCtx.disabledBuiltins = disabledBuiltins;
|
|
1116
|
+
}
|
|
1117
|
+
const allToolDefs = applyBuiltinOverrideVisibility(this.toolRegistry.getToolDefinitions(), builtinOverride)
|
|
1118
|
+
.filter((t) => {
|
|
1119
|
+
const guard = BUILTIN_TOOL_GUARDS.get(t.name);
|
|
1120
|
+
return guard ? guard(guardCwd) : true;
|
|
1121
|
+
})
|
|
1122
|
+
.map((t) => t.name === "Agent"
|
|
1123
|
+
? { ...t, description: agentToolDefWithTypes(toolCtx.agentDefinitions).description }
|
|
1124
|
+
: t);
|
|
1125
|
+
// In plan mode, only expose read-only/planning tools so the model won't
|
|
1126
|
+
// attempt writes. Shared with executor.ts's execution gate via
|
|
1127
|
+
// PLAN_MODE_ALLOWED_TOOLS so what the model SEES and what the executor
|
|
1128
|
+
// RUNS can't drift apart. (Bash is in the set; the executor additionally
|
|
1129
|
+
// gates Bash to read-only commands at call time.)
|
|
764
1130
|
const toolDefs = this.planMode
|
|
765
|
-
? allToolDefs.filter((t) =>
|
|
1131
|
+
? allToolDefs.filter((t) => PLAN_MODE_ALLOWED_TOOLS.has(t.name))
|
|
766
1132
|
: allToolDefs;
|
|
767
1133
|
const [llmClient, systemPrompt, systemContext] = await Promise.all([
|
|
768
1134
|
llmClientPromise,
|
|
@@ -799,8 +1165,15 @@ export class Engine {
|
|
|
799
1165
|
// run would be evaluated fresh and might get a different replacement
|
|
800
1166
|
// string than the one already in the message, breaking idempotency.
|
|
801
1167
|
contextManager.initReplacementStateFromMessages(messages);
|
|
1168
|
+
// Summarization (context-compaction + tool-result summaries) are auxiliary
|
|
1169
|
+
// calls — route them to the configured aux model so they don't burn the
|
|
1170
|
+
// expensive primary model every turn (same rationale as runMemoryPipeline).
|
|
1171
|
+
// Resolved once here (not per-call) so the magnetic-disk settings re-read
|
|
1172
|
+
// in resolveAuxClient stays off the compaction hot path. Falls back to the
|
|
1173
|
+
// primary client when no aux model is configured.
|
|
1174
|
+
const auxSummaryClient = await this.resolveAuxClient(llmClient);
|
|
802
1175
|
contextManager.setSummarizeFn(async (prompt) => {
|
|
803
|
-
const summaryResponse = await
|
|
1176
|
+
const summaryResponse = await auxSummaryClient.createMessage({
|
|
804
1177
|
systemPrompt: "You are a conversation summarizer. Be concise and factual.",
|
|
805
1178
|
messages: [{ role: "user", content: prompt }],
|
|
806
1179
|
tools: [],
|
|
@@ -808,7 +1181,7 @@ export class Engine {
|
|
|
808
1181
|
// Auxiliary call — no need to burn reasoning tokens. On DeepSeek V4
|
|
809
1182
|
// this flips thinking off (~3x faster, fewer tokens); on every other
|
|
810
1183
|
// OpenAI-compatible provider the field is ignored.
|
|
811
|
-
|
|
1184
|
+
reasoning: { mode: "off" },
|
|
812
1185
|
});
|
|
813
1186
|
return summaryResponse.text;
|
|
814
1187
|
});
|
|
@@ -824,14 +1197,14 @@ export class Engine {
|
|
|
824
1197
|
// tracker so session_end.cost reflects only the user-facing turns and
|
|
825
1198
|
// turns/requestCount stay aligned.
|
|
826
1199
|
modelFacade.summarize = async (sysPrompt, userMsg) => {
|
|
827
|
-
const resp = await
|
|
1200
|
+
const resp = await auxSummaryClient.createMessage({
|
|
828
1201
|
systemPrompt: sysPrompt,
|
|
829
1202
|
messages: [{ role: "user", content: userMsg }],
|
|
830
1203
|
tools: [],
|
|
831
1204
|
maxTokens: 256,
|
|
832
1205
|
recordUsage: false,
|
|
833
1206
|
// Auxiliary call — see contextManager.setSummarizeFn above.
|
|
834
|
-
|
|
1207
|
+
reasoning: { mode: "off" },
|
|
835
1208
|
});
|
|
836
1209
|
logger.debug("summarize.call", {
|
|
837
1210
|
sysPromptLen: sysPrompt.length,
|
|
@@ -846,7 +1219,7 @@ export class Engine {
|
|
|
846
1219
|
return resp.text;
|
|
847
1220
|
};
|
|
848
1221
|
// File history: auto-backup before Write/Edit
|
|
849
|
-
const sessionDir = join(this.config.sessionStorageDir ?? join(
|
|
1222
|
+
const sessionDir = join(this.config.sessionStorageDir ?? join(userHome(), ".code-shell", "sessions"), session.state.sessionId);
|
|
850
1223
|
const fileHistory = FileHistory.loadFromDir(sessionDir);
|
|
851
1224
|
this.hooks.register("on_tool_start", async (context) => {
|
|
852
1225
|
const toolName = context.data?.toolName;
|
|
@@ -862,6 +1235,24 @@ export class Engine {
|
|
|
862
1235
|
task,
|
|
863
1236
|
model: this.config.llm.model,
|
|
864
1237
|
});
|
|
1238
|
+
// Goal mode: register a GoalStopHook for the lifetime of THIS run so the
|
|
1239
|
+
// turn loop keeps going until the session model judges the goal met.
|
|
1240
|
+
// Registered per-run (and cleared in `finally`) so a later goal-less
|
|
1241
|
+
// send doesn't inherit a stale goal. The judge reuses `llmClient` — the
|
|
1242
|
+
// same model this session is talking to (per design).
|
|
1243
|
+
// Normalize the raw goal (string | GoalConfig) once at the run boundary;
|
|
1244
|
+
// everything inward uses the GoalConfig. normalizeGoal() returns undefined
|
|
1245
|
+
// when there's effectively no goal (empty objective).
|
|
1246
|
+
const normalizedGoal = normalizeGoal(options?.goal ?? this.config.goal);
|
|
1247
|
+
let goalHookHandler = null;
|
|
1248
|
+
if (normalizedGoal && this.config.isSubAgent !== true) {
|
|
1249
|
+
goalHookHandler = createGoalStopHook({
|
|
1250
|
+
goal: normalizedGoal,
|
|
1251
|
+
llm: llmClient,
|
|
1252
|
+
log: logger,
|
|
1253
|
+
});
|
|
1254
|
+
this.hooks.register("on_stop", goalHookHandler, 0, "goal-stop");
|
|
1255
|
+
}
|
|
865
1256
|
// Surface compaction events to the UI so the user knows when context was trimmed.
|
|
866
1257
|
// Buffer the most recent event so TurnLoop can drain it and emit the
|
|
867
1258
|
// post_compact hook on the next turn (ContextManager itself doesn't
|
|
@@ -898,6 +1289,9 @@ export class Engine {
|
|
|
898
1289
|
maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 10,
|
|
899
1290
|
onStream: options?.onStream,
|
|
900
1291
|
signal: options?.signal,
|
|
1292
|
+
// Goal mode: the active goal is surfaced to the on_stop handler via
|
|
1293
|
+
// ctx.data.goal; the GoalStopHook (registered above) judges it.
|
|
1294
|
+
goal: normalizedGoal,
|
|
901
1295
|
// Heartbeat: flush turnCount + tokens to state.json after every turn
|
|
902
1296
|
// so external observers (other CLI processes, /sid, the session list)
|
|
903
1297
|
// see live progress instead of a stale snapshot from the last
|
|
@@ -916,7 +1310,59 @@ export class Engine {
|
|
|
916
1310
|
this.sessionManager.saveState(session.state);
|
|
917
1311
|
},
|
|
918
1312
|
});
|
|
919
|
-
|
|
1313
|
+
let result;
|
|
1314
|
+
try {
|
|
1315
|
+
result = await turnLoop.run(messages);
|
|
1316
|
+
// ── Wait for background sub-agents, then summarize ───────────────
|
|
1317
|
+
// run_in_background sub-agents outlive the turn that spawned them. The
|
|
1318
|
+
// main agent must not resolve while ITS OWN background agents are still
|
|
1319
|
+
// working — otherwise their results land in the notification queue with
|
|
1320
|
+
// nobody to drain them and the run looks "done" while work is in flight
|
|
1321
|
+
// (the s-mpvf4rsj-bb6e4639 bug). We block here until none of this
|
|
1322
|
+
// session's background agents are running, then drain ALL their results
|
|
1323
|
+
// and feed them back as one more turn so the agent summarizes.
|
|
1324
|
+
//
|
|
1325
|
+
// Top-level only: a sub-agent must never wait on grandchildren (and
|
|
1326
|
+
// nested agents are disabled anyway). `signal` aborts the wait.
|
|
1327
|
+
const sid = session.state.sessionId;
|
|
1328
|
+
const isTopLevel = this.config.isSubAgent !== true;
|
|
1329
|
+
if (isTopLevel) {
|
|
1330
|
+
let aborted = options?.signal?.aborted === true;
|
|
1331
|
+
while (!aborted && asyncAgentRegistry.hasRunningForSession(sid)) {
|
|
1332
|
+
aborted = await this.waitForBackgroundAgentChange(sid, options?.signal);
|
|
1333
|
+
}
|
|
1334
|
+
// Drain everything that came back — including partial results when the
|
|
1335
|
+
// user aborted with one agent still stuck. Nothing already returned is
|
|
1336
|
+
// lost: it's injected into the transcript either way.
|
|
1337
|
+
const pending = notificationQueue.drainAll(sid);
|
|
1338
|
+
if (pending.length > 0) {
|
|
1339
|
+
const injected = {
|
|
1340
|
+
role: "user",
|
|
1341
|
+
content: `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`,
|
|
1342
|
+
};
|
|
1343
|
+
if (aborted) {
|
|
1344
|
+
// Aborted: preserve the results in context (transcript + messages)
|
|
1345
|
+
// but do NOT spin up another LLM turn — the user cancelled, and a
|
|
1346
|
+
// fresh turn would just be killed by the same signal. The next
|
|
1347
|
+
// user message in this session will see these results in history.
|
|
1348
|
+
session.transcript.appendMessage(injected.role, injected.content);
|
|
1349
|
+
result = { ...result, messages: [...result.messages, injected] };
|
|
1350
|
+
}
|
|
1351
|
+
else {
|
|
1352
|
+
// All background agents finished: one more turn so the agent reads
|
|
1353
|
+
// every result and summarizes. turnCount keeps accumulating, so
|
|
1354
|
+
// maxTurns still bounds runaway re-summarization.
|
|
1355
|
+
result = await turnLoop.run([...result.messages, injected]);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
finally {
|
|
1361
|
+
// Run-scoped: drop the GoalStopHook so a later goal-less send on this
|
|
1362
|
+
// long-lived engine doesn't keep blocking stops.
|
|
1363
|
+
if (goalHookHandler)
|
|
1364
|
+
this.hooks.unregister("on_stop", goalHookHandler);
|
|
1365
|
+
}
|
|
920
1366
|
this.lastMessages = result.messages;
|
|
921
1367
|
this.compactedMessagesBySession.set(session.state.sessionId, this.stripUserContextMessage(result.messages, userContextMsg));
|
|
922
1368
|
logger.info("engine.done", {
|
|
@@ -943,6 +1389,31 @@ export class Engine {
|
|
|
943
1389
|
// transcript, save a session summary, and conditionally trigger
|
|
944
1390
|
// auto-dream consolidation. Doesn't block the Engine result.
|
|
945
1391
|
void this.runMemoryPipeline(session.transcript, session.state.sessionId, cwd, llmClient);
|
|
1392
|
+
// Fire-and-forget session title generation — only after the FIRST turn.
|
|
1393
|
+
// Reuses the already-resolved auxSummaryClient (aux model, cheap). Best-
|
|
1394
|
+
// effort: failures never touch the run result. The renderer writes the
|
|
1395
|
+
// title into the sidebar on receipt of the session_title stream event.
|
|
1396
|
+
{
|
|
1397
|
+
const messageEvents = session.transcript.getEvents("message");
|
|
1398
|
+
const userMsgEvents = messageEvents.filter((e) => e.data.role === "user");
|
|
1399
|
+
const userMsgCount = userMsgEvents.length;
|
|
1400
|
+
const onStream = options?.onStream;
|
|
1401
|
+
if (userMsgCount === 1 && onStream && result.text) {
|
|
1402
|
+
const rawContent = userMsgEvents[0]?.data?.content;
|
|
1403
|
+
const firstUserText = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent ?? "");
|
|
1404
|
+
void buildSessionTitle(auxSummaryClient, firstUserText, result.text)
|
|
1405
|
+
.then((title) => {
|
|
1406
|
+
if (title) {
|
|
1407
|
+
onStream({
|
|
1408
|
+
type: "session_title",
|
|
1409
|
+
sessionId: session.state.sessionId,
|
|
1410
|
+
title,
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
})
|
|
1414
|
+
.catch(() => { });
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
946
1417
|
// Update session state. Persist the raw terminal reason as the status so
|
|
947
1418
|
// callers can distinguish user-cancelled (aborted_streaming) from real
|
|
948
1419
|
// failures (model_error, prompt_too_long, ...) — previously every
|
|
@@ -986,8 +1457,69 @@ export class Engine {
|
|
|
986
1457
|
* task. Extracts durable memories from the transcript, saves a session
|
|
987
1458
|
* summary, and conditionally triggers auto-dream consolidation.
|
|
988
1459
|
*/
|
|
989
|
-
|
|
1460
|
+
/**
|
|
1461
|
+
* Resolve the LLM client for background/auxiliary work (memory extraction,
|
|
1462
|
+
* auto-dream). When settings.auxModelKey names a valid pool model, build (and
|
|
1463
|
+
* cache) a dedicated client for it so per-turn book-keeping runs on a cheap
|
|
1464
|
+
* fast model instead of the expensive primary. Falls back to `fallback` (the
|
|
1465
|
+
* active run's client) when unset, unknown, or on any build failure — aux
|
|
1466
|
+
* work is best-effort and must never break a run.
|
|
1467
|
+
*/
|
|
1468
|
+
async resolveAuxClient(fallback) {
|
|
1469
|
+
let auxKey;
|
|
1470
|
+
try {
|
|
1471
|
+
// Re-read from disk: settings may have been changed by the desktop
|
|
1472
|
+
// (a separate process) since this worker last cached them. This runs
|
|
1473
|
+
// once per run on the post-run background path, so the cost is fine.
|
|
1474
|
+
const sm = this.getSettingsManager();
|
|
1475
|
+
sm.invalidate();
|
|
1476
|
+
auxKey = sm.get().auxModelKey;
|
|
1477
|
+
}
|
|
1478
|
+
catch {
|
|
1479
|
+
return fallback;
|
|
1480
|
+
}
|
|
1481
|
+
if (!auxKey)
|
|
1482
|
+
return fallback;
|
|
1483
|
+
// Don't spin up a second client when the aux key resolves to the SAME
|
|
1484
|
+
// client config as this engine's active model. Compare FULL LLM IDENTITY
|
|
1485
|
+
// (model + reasoning + maxTokens + baseUrl + provider/providerKind) against
|
|
1486
|
+
// this engine's own per-session config.llm — NOT a separately-tracked active
|
|
1487
|
+
// key, and NOT just the model NAME. Two distinct pool keys can share the same
|
|
1488
|
+
// `model` string yet differ in reasoning/maxOutputTokens/baseUrl/apiKey/
|
|
1489
|
+
// providerKey; de-duping on the name alone would wrongly route the user's
|
|
1490
|
+
// chosen aux entry onto the primary's config. config.llm is isolated per
|
|
1491
|
+
// session and always set for a real run, so this is correct even for desktop
|
|
1492
|
+
// worker sessions built with a shared runtime (which never explicitly
|
|
1493
|
+
// switchModel, so the old activeModelKey field was undefined and defeated the
|
|
1494
|
+
// de-dup), AND immune to another session mutating the shared pool's activeKey.
|
|
1495
|
+
const entry = this.modelPool.get(auxKey);
|
|
1496
|
+
if (entry && sameLlmIdentity(this.modelPool.toLLMConfig(entry), this.config.llm)) {
|
|
1497
|
+
return fallback;
|
|
1498
|
+
}
|
|
1499
|
+
if (this.auxClientCache?.key === auxKey)
|
|
1500
|
+
return this.auxClientCache.client;
|
|
1501
|
+
if (!entry) {
|
|
1502
|
+
logger.warn("engine.aux_model_missing", { auxModelKey: auxKey });
|
|
1503
|
+
return fallback;
|
|
1504
|
+
}
|
|
1505
|
+
try {
|
|
1506
|
+
const client = await createLLMClient(this.modelPool.toLLMConfig(entry), this.config.clientDefaults);
|
|
1507
|
+
this.auxClientCache = { key: auxKey, client };
|
|
1508
|
+
return client;
|
|
1509
|
+
}
|
|
1510
|
+
catch (err) {
|
|
1511
|
+
logger.warn("engine.aux_model_build_failed", {
|
|
1512
|
+
auxModelKey: auxKey,
|
|
1513
|
+
error: err.message,
|
|
1514
|
+
});
|
|
1515
|
+
return fallback;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
async runMemoryPipeline(transcript, sessionId, cwd, primaryClient) {
|
|
990
1519
|
try {
|
|
1520
|
+
// Background calls run on the auxiliary model when configured, so memory
|
|
1521
|
+
// book-keeping doesn't burn the expensive primary model every turn.
|
|
1522
|
+
const llmClient = await this.resolveAuxClient(primaryClient);
|
|
991
1523
|
// Only run memory extraction for substantive sessions. The previous
|
|
992
1524
|
// threshold of 4 user+assistant messages was low enough that two-line
|
|
993
1525
|
// exchanges ("what's the time?" / "noon") triggered a full LLM
|
|
@@ -1020,7 +1552,7 @@ export class Engine {
|
|
|
1020
1552
|
tools: [],
|
|
1021
1553
|
maxTokens: 1024,
|
|
1022
1554
|
recordUsage: false,
|
|
1023
|
-
|
|
1555
|
+
reasoning: { mode: "off" },
|
|
1024
1556
|
});
|
|
1025
1557
|
return resp.text;
|
|
1026
1558
|
},
|
|
@@ -1054,117 +1586,20 @@ export class Engine {
|
|
|
1054
1586
|
* bailed before the first LLM call (e.g. registry missing the tools).
|
|
1055
1587
|
*/
|
|
1056
1588
|
async runDreamLoop(opts) {
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
return false;
|
|
1069
|
-
}
|
|
1070
|
-
// Strip RegisteredTool down to the shape createMessage expects.
|
|
1071
|
-
const toolDefs = memoryTools.map((t) => ({
|
|
1072
|
-
name: t.name,
|
|
1073
|
-
description: t.description,
|
|
1074
|
-
inputSchema: t.inputSchema,
|
|
1075
|
-
}));
|
|
1076
|
-
const toolCtx = {
|
|
1077
|
-
...this.buildToolContext(),
|
|
1078
|
-
cwd: opts.projectDir ?? process.cwd(),
|
|
1079
|
-
};
|
|
1080
|
-
const messages = [{ role: "user", content: opts.userPrompt }];
|
|
1081
|
-
let writeBudget = MAX_WRITES;
|
|
1082
|
-
for (let turn = 0; turn < MAX_TURNS; turn++) {
|
|
1083
|
-
const resp = await opts.llmClient.createMessage({
|
|
1084
|
-
systemPrompt: opts.systemPrompt,
|
|
1085
|
-
messages,
|
|
1086
|
-
tools: toolDefs,
|
|
1087
|
-
maxTokens: 2048,
|
|
1088
|
-
recordUsage: false,
|
|
1089
|
-
thinking: "disabled",
|
|
1090
|
-
});
|
|
1091
|
-
if (resp.toolCalls.length === 0) {
|
|
1092
|
-
logger.info("memory.auto_dream_finished", {
|
|
1093
|
-
sessionId: opts.sessionId,
|
|
1094
|
-
turn,
|
|
1095
|
-
finalText: resp.text.slice(0, 500),
|
|
1096
|
-
});
|
|
1097
|
-
return true;
|
|
1098
|
-
}
|
|
1099
|
-
// Echo the assistant turn back into the conversation so subsequent
|
|
1100
|
-
// turns see the tool_use ids they need to reference.
|
|
1101
|
-
const assistantContent = [];
|
|
1102
|
-
if (resp.text)
|
|
1103
|
-
assistantContent.push({ type: "text", text: resp.text });
|
|
1104
|
-
for (const tc of resp.toolCalls) {
|
|
1105
|
-
assistantContent.push({
|
|
1106
|
-
type: "tool_use",
|
|
1107
|
-
id: tc.id,
|
|
1108
|
-
name: tc.toolName,
|
|
1109
|
-
input: tc.args,
|
|
1110
|
-
});
|
|
1111
|
-
}
|
|
1112
|
-
messages.push({ role: "assistant", content: assistantContent });
|
|
1113
|
-
// Dispatch every tool call requested in this turn.
|
|
1114
|
-
const toolResults = [];
|
|
1115
|
-
for (const tc of resp.toolCalls) {
|
|
1116
|
-
const result = await this.dispatchDreamTool(tc, toolCtx, () => {
|
|
1117
|
-
if (writeBudget <= 0)
|
|
1118
|
-
return false;
|
|
1119
|
-
writeBudget--;
|
|
1120
|
-
return true;
|
|
1121
|
-
});
|
|
1122
|
-
toolResults.push({
|
|
1123
|
-
type: "tool_result",
|
|
1124
|
-
tool_use_id: tc.id,
|
|
1125
|
-
content: result,
|
|
1126
|
-
});
|
|
1127
|
-
}
|
|
1128
|
-
messages.push({ role: "user", content: toolResults });
|
|
1129
|
-
}
|
|
1130
|
-
logger.warn("memory.auto_dream_hit_turn_cap", {
|
|
1589
|
+
// The loop body now lives in services/dream-consolidation.ts so it can
|
|
1590
|
+
// also be driven from the desktop host's manual "整理 / Dream" trigger.
|
|
1591
|
+
// The orchestrator built systemPrompt/userPrompt from this engine's
|
|
1592
|
+
// MemoryManager already; runDreamConsolidation rebuilds them from the same
|
|
1593
|
+
// projectDir, so passing them here would be redundant — we just hand it the
|
|
1594
|
+
// tool registry + a memory-scoped tool context.
|
|
1595
|
+
const { ran } = await runDreamConsolidation({
|
|
1596
|
+
llmClient: opts.llmClient,
|
|
1597
|
+
toolRegistry: this.toolRegistry,
|
|
1598
|
+
toolContext: this.buildToolContext(),
|
|
1599
|
+
projectDir: opts.projectDir,
|
|
1131
1600
|
sessionId: opts.sessionId,
|
|
1132
|
-
maxTurns: MAX_TURNS,
|
|
1133
1601
|
});
|
|
1134
|
-
return
|
|
1135
|
-
}
|
|
1136
|
-
/**
|
|
1137
|
-
* Execute one memory tool call inside the dream loop. Enforces the two
|
|
1138
|
-
* dream-loop invariants the prompt also states:
|
|
1139
|
-
* - Only the 4 memory tools are dispatchable.
|
|
1140
|
-
* - Save/Delete in "user" scope is refused (returned as a tool error)
|
|
1141
|
-
* because dream runs without an interactive permission backend.
|
|
1142
|
-
*/
|
|
1143
|
-
async dispatchDreamTool(tc, ctx, consumeWriteBudget) {
|
|
1144
|
-
const allowed = new Set(["MemoryList", "MemoryRead", "MemorySave", "MemoryDelete"]);
|
|
1145
|
-
if (!allowed.has(tc.toolName)) {
|
|
1146
|
-
return `Error: tool "${tc.toolName}" is not available in the dream loop`;
|
|
1147
|
-
}
|
|
1148
|
-
const isWrite = tc.toolName === "MemorySave" || tc.toolName === "MemoryDelete";
|
|
1149
|
-
if (isWrite) {
|
|
1150
|
-
const scope = tc.args?.scope;
|
|
1151
|
-
if (scope !== "dream") {
|
|
1152
|
-
return (`Error: dream loop may only write to scope "dream", got "${scope}". ` +
|
|
1153
|
-
`User-scope changes require interactive permission, which is not available here.`);
|
|
1154
|
-
}
|
|
1155
|
-
if (!consumeWriteBudget()) {
|
|
1156
|
-
return "Error: dream write budget exhausted — stop calling write tools and summarize instead.";
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
try {
|
|
1160
|
-
const result = await this.toolRegistry.executeTool(tc.toolName, tc.args, { ctx });
|
|
1161
|
-
if (result.isError)
|
|
1162
|
-
return result.error ?? `Error executing ${tc.toolName}`;
|
|
1163
|
-
return result.result ?? "";
|
|
1164
|
-
}
|
|
1165
|
-
catch (err) {
|
|
1166
|
-
return `Error executing ${tc.toolName}: ${err.message}`;
|
|
1167
|
-
}
|
|
1602
|
+
return ran;
|
|
1168
1603
|
}
|
|
1169
1604
|
getToolRegistry() {
|
|
1170
1605
|
return this.toolRegistry;
|
|
@@ -1180,7 +1615,10 @@ export class Engine {
|
|
|
1180
1615
|
*/
|
|
1181
1616
|
switchModel(key) {
|
|
1182
1617
|
const entry = this.modelPool.switch(key);
|
|
1183
|
-
|
|
1618
|
+
// LLMConfig is pure model identity now — rotate it wholesale. Cross-model
|
|
1619
|
+
// runtime knobs (temperature/timeout/retryMaxAttempts/imageDetail) live on
|
|
1620
|
+
// this.config.clientDefaults and survive the switch untouched.
|
|
1621
|
+
const nextLlm = this.modelPool.toLLMConfig(entry);
|
|
1184
1622
|
this.config = { ...this.config, llm: nextLlm };
|
|
1185
1623
|
this.persistActiveModel(entry, nextLlm);
|
|
1186
1624
|
return entry;
|
|
@@ -1196,7 +1634,11 @@ export class Engine {
|
|
|
1196
1634
|
*/
|
|
1197
1635
|
persistActiveModel(entry, llm) {
|
|
1198
1636
|
try {
|
|
1199
|
-
|
|
1637
|
+
// userHome() (not raw homedir()) so a test that sets process.env.HOME to
|
|
1638
|
+
// a tmpdir gets its writes isolated too — the SettingsManager reader
|
|
1639
|
+
// already honors HOME; the writer must match or tests pollute the real
|
|
1640
|
+
// ~/.code-shell/settings.json (this happened: an A-key/model-a leak).
|
|
1641
|
+
const dir = join(userHome(), ".code-shell");
|
|
1200
1642
|
const file = join(dir, "settings.json");
|
|
1201
1643
|
mkdirSync(dir, { recursive: true });
|
|
1202
1644
|
let existing = {};
|
|
@@ -1254,6 +1696,83 @@ export class Engine {
|
|
|
1254
1696
|
getConfig() {
|
|
1255
1697
|
return this.config;
|
|
1256
1698
|
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Config hot-reload "layer 2": merge a disk-default config patch into this
|
|
1701
|
+
* ALREADY-RUNNING session's `this.config`, reload settings hooks, and
|
|
1702
|
+
* incrementally connect any newly-added MCP servers. Applied at the next
|
|
1703
|
+
* turn boundary — an in-flight turn is NOT interrupted: it keeps using the
|
|
1704
|
+
* PromptComposer it was built with, and the next turn rebuilds the composer
|
|
1705
|
+
* from the freshly-merged config (composer is rebuilt per-turn).
|
|
1706
|
+
*
|
|
1707
|
+
* `version` is a monotonic counter from the server: stale (<=last applied)
|
|
1708
|
+
* payloads are dropped so out-of-order reload deliveries can't let an older
|
|
1709
|
+
* config clobber a newer one (Q5).
|
|
1710
|
+
*
|
|
1711
|
+
* MCP: only connects (idempotent — already-connected servers are skipped);
|
|
1712
|
+
* never disconnects, so an in-flight tool call on an existing server is
|
|
1713
|
+
* never severed (Q3). Removed servers are deferred to the next session
|
|
1714
|
+
* rebuild. If mcpManager isn't built yet (no MCP run has happened), the
|
|
1715
|
+
* new servers will be connected on the next run via the existing per-run
|
|
1716
|
+
* connectAll path — so we skip the connect here.
|
|
1717
|
+
*
|
|
1718
|
+
* Preset (#2): a preset hot-reload re-resolves `this.preset` so the next-turn
|
|
1719
|
+
* PromptComposer picks up the new preset's system prompt / behavior — that's
|
|
1720
|
+
* the main user-visible preset effect and it IS hot. The toolRegistry's
|
|
1721
|
+
* builtin tool SET, however, is ctor-frozen (and may be shared via runtime):
|
|
1722
|
+
* it is NOT rebuilt here. So a preset change that alters the builtin tool set
|
|
1723
|
+
* (e.g. general → terminal-coding adds LSP/Brief) only takes effect on the
|
|
1724
|
+
* next session restart; we log a warning when that case is detected.
|
|
1725
|
+
*
|
|
1726
|
+
* disk-default-vs-slice caveat (#8): the patch carries pure DISK-default
|
|
1727
|
+
* values (preset/customSystemPrompt/appendSystemPrompt/responseLanguage/
|
|
1728
|
+
* userProfile — see diskDefaultsFrom). Spreading them here OVERRIDES any
|
|
1729
|
+
* per-request slice override of the same field. This is correct for the
|
|
1730
|
+
* desktop host today (its per-request slice only carries permissionMode+cwd,
|
|
1731
|
+
* which are excluded from the disk patch). A future host that sets
|
|
1732
|
+
* slice.preset (or the other prompt fields) per-request MUST exclude those
|
|
1733
|
+
* from the reload patch — or track per-request overrides separately — or this
|
|
1734
|
+
* reload will clobber them back to disk values.
|
|
1735
|
+
*/
|
|
1736
|
+
refreshRuntimeConfig(patch, version) {
|
|
1737
|
+
if (version <= this.lastAppliedConfigVersion)
|
|
1738
|
+
return;
|
|
1739
|
+
const prevServers = this.config.mcpServers ?? {};
|
|
1740
|
+
const prevPresetName = this.preset.name;
|
|
1741
|
+
this.config = { ...this.config, ...patch };
|
|
1742
|
+
// #2: re-resolve the prompt-affecting preset so the next-turn PromptComposer
|
|
1743
|
+
// (rebuilt per turn from this.preset) reflects the new preset's system
|
|
1744
|
+
// prompt / behavior. Only when the preset actually changed.
|
|
1745
|
+
if (patch.preset !== undefined && patch.preset !== prevPresetName) {
|
|
1746
|
+
const nextPreset = resolveAgentPreset(this.config.preset);
|
|
1747
|
+
// The builtin tool SET is ctor-frozen and may be shared via runtime — we
|
|
1748
|
+
// do NOT rebuild it here. If the new preset implies a different builtin
|
|
1749
|
+
// tool set, that part of the change only lands on session restart.
|
|
1750
|
+
const prevTools = resolveBuiltinToolNames({ preset: prevPresetName }).slice().sort().join(",");
|
|
1751
|
+
const nextTools = resolveBuiltinToolNames({ preset: nextPreset.name }).slice().sort().join(",");
|
|
1752
|
+
if (prevTools !== nextTools) {
|
|
1753
|
+
logger.warn("engine.preset_reload.tool_set_change_needs_restart", {
|
|
1754
|
+
from: prevPresetName,
|
|
1755
|
+
to: nextPreset.name,
|
|
1756
|
+
note: "preset system prompt hot-reloaded; builtin tool-set change takes effect on session restart",
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
this.preset = nextPreset;
|
|
1760
|
+
}
|
|
1761
|
+
this.reloadHooks();
|
|
1762
|
+
if (patch.mcpServers && this.mcpManager) {
|
|
1763
|
+
const added = {};
|
|
1764
|
+
for (const [name, cfg] of Object.entries(patch.mcpServers)) {
|
|
1765
|
+
if (!(name in prevServers))
|
|
1766
|
+
added[name] = cfg;
|
|
1767
|
+
}
|
|
1768
|
+
if (Object.keys(added).length > 0) {
|
|
1769
|
+
// connectAll is idempotent (skips already-connected); fire-and-forget
|
|
1770
|
+
// so a slow server handshake never blocks the reload call.
|
|
1771
|
+
void this.mcpManager.connectAll(added);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
this.lastAppliedConfigVersion = version;
|
|
1775
|
+
}
|
|
1257
1776
|
/**
|
|
1258
1777
|
* Inject context into a session's transcript without triggering a LLM turn.
|
|
1259
1778
|
* The injected content appears as an assistant message so the LLM can see it
|
|
@@ -1283,7 +1802,6 @@ export class Engine {
|
|
|
1283
1802
|
if (!this.lastContextManager || !sessionId) {
|
|
1284
1803
|
return { before: 0, after: 0, strategy: "none (no active session)" };
|
|
1285
1804
|
}
|
|
1286
|
-
const { estimateTokens } = require("../context/compaction.js");
|
|
1287
1805
|
const sourceMessages = this.compactedMessagesBySession.get(sessionId) ??
|
|
1288
1806
|
this.sessionManager.resume(sessionId).transcript.toMessages();
|
|
1289
1807
|
const before = estimateTokens(sourceMessages);
|
|
@@ -1305,7 +1823,7 @@ export class Engine {
|
|
|
1305
1823
|
}
|
|
1306
1824
|
getSettingsManager() {
|
|
1307
1825
|
if (!this.settingsManager) {
|
|
1308
|
-
this.settingsManager = new SettingsManager(this.config.cwd);
|
|
1826
|
+
this.settingsManager = new SettingsManager(this.config.cwd, this.config.settingsScope ?? "project");
|
|
1309
1827
|
}
|
|
1310
1828
|
return this.settingsManager;
|
|
1311
1829
|
}
|
|
@@ -1357,7 +1875,7 @@ export class Engine {
|
|
|
1357
1875
|
rules.push({ tool: "Bash", decision: "allow" });
|
|
1358
1876
|
}
|
|
1359
1877
|
try {
|
|
1360
|
-
const settingsManager = new SettingsManager(cwd);
|
|
1878
|
+
const settingsManager = new SettingsManager(cwd, this.config.settingsScope ?? "project");
|
|
1361
1879
|
const settings = settingsManager.get();
|
|
1362
1880
|
if (settings.permissions?.rules?.length) {
|
|
1363
1881
|
rules.unshift(...settings.permissions.rules);
|
|
@@ -1428,12 +1946,130 @@ export class Engine {
|
|
|
1428
1946
|
this.planMode = value;
|
|
1429
1947
|
}
|
|
1430
1948
|
}
|
|
1949
|
+
/**
|
|
1950
|
+
* Block until a background agent's state changes (finishes / its result is
|
|
1951
|
+
* enqueued) or `signal` aborts. Resolves `true` if aborted, `false` on a
|
|
1952
|
+
* change. The caller re-checks `hasRunningForSession` after each wake, so a
|
|
1953
|
+
* spurious wake (another session's agent) just loops again.
|
|
1954
|
+
*
|
|
1955
|
+
* Subscribes to BOTH the registry AND the notification queue — and that's
|
|
1956
|
+
* load-bearing, not belt-and-suspenders. A completing agent calls
|
|
1957
|
+
* `markCompleted` (registry notify) and only THEN `enqueue` (queue notify),
|
|
1958
|
+
* as two separate statements. If we woke on the registry notify alone, the
|
|
1959
|
+
* loop could re-check, see no running agents, and `drainAll` BEFORE the
|
|
1960
|
+
* result was enqueued — silently losing the last agent's output. Waking on
|
|
1961
|
+
* the queue notify guarantees the item is already in the bucket. But a
|
|
1962
|
+
* *cancelled* agent marks-but-never-enqueues (by design), so we also need
|
|
1963
|
+
* the registry notify or a final cancel would hang the wait forever. Hence
|
|
1964
|
+
* both. Subscribe-before-await closes the check/wait race either way.
|
|
1965
|
+
*/
|
|
1966
|
+
waitForBackgroundAgentChange(_sessionId, signal) {
|
|
1967
|
+
if (signal?.aborted)
|
|
1968
|
+
return Promise.resolve(true);
|
|
1969
|
+
return new Promise((resolve) => {
|
|
1970
|
+
let settled = false;
|
|
1971
|
+
const finish = (aborted) => {
|
|
1972
|
+
if (settled)
|
|
1973
|
+
return;
|
|
1974
|
+
settled = true;
|
|
1975
|
+
unsubRegistry();
|
|
1976
|
+
unsubQueue();
|
|
1977
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1978
|
+
resolve(aborted);
|
|
1979
|
+
};
|
|
1980
|
+
const onAbort = () => finish(true);
|
|
1981
|
+
const unsubRegistry = asyncAgentRegistry.subscribe(() => finish(false));
|
|
1982
|
+
const unsubQueue = notificationQueue.subscribe(() => finish(false));
|
|
1983
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1984
|
+
});
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Sub-agent role registry for the given cwd, memoized per-cwd so the
|
|
1988
|
+
* directory is read once rather than every turn. A new cwd (e.g. via
|
|
1989
|
+
* run({ cwd })) reloads.
|
|
1990
|
+
*/
|
|
1991
|
+
getAgentDefinitions(cwd) {
|
|
1992
|
+
const disabledAgents = this.readDisabledAgents(cwd);
|
|
1993
|
+
const disabledPlugins = this.readDisabledLists().disabledPlugins;
|
|
1994
|
+
const disabledKey = [...disabledAgents, "::", ...disabledPlugins]
|
|
1995
|
+
.slice()
|
|
1996
|
+
.sort()
|
|
1997
|
+
.join(" ");
|
|
1998
|
+
if (this.agentDefsCache?.cwd !== cwd ||
|
|
1999
|
+
this.agentDefsCache.disabledKey !== disabledKey) {
|
|
2000
|
+
this.agentDefsCache = {
|
|
2001
|
+
cwd,
|
|
2002
|
+
disabledKey,
|
|
2003
|
+
reg: loadAgentDefinitionsForCwd(cwd, disabledAgents, disabledPlugins),
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
return this.agentDefsCache.reg;
|
|
2007
|
+
}
|
|
2008
|
+
/**
|
|
2009
|
+
* Read settings.disabledAgents, folded with the project's
|
|
2010
|
+
* capabilityOverrides.agents overlay for `cwd`. Unlike disabledSkills,
|
|
2011
|
+
* sub-agents do NOT skip this — a disabled role must stay invisible
|
|
2012
|
+
* everywhere. The overlay lets a project force-enable a globally-disabled
|
|
2013
|
+
* role or force-disable a globally-enabled one (tri-state); read UNMERGED
|
|
2014
|
+
* (getForScope) so inherit survives. No cwd / no overlay → baseline
|
|
2015
|
+
* unchanged. Mirrors readDisabledLists (skills/plugins).
|
|
2016
|
+
*/
|
|
2017
|
+
readDisabledAgents(cwd) {
|
|
2018
|
+
try {
|
|
2019
|
+
const sm = this.getSettingsManager();
|
|
2020
|
+
const settings = sm.get();
|
|
2021
|
+
const baseline = Array.isArray(settings.disabledAgents) ? settings.disabledAgents : [];
|
|
2022
|
+
const overrides = cwd
|
|
2023
|
+
? sm.getForScope("project", cwd).capabilityOverrides
|
|
2024
|
+
: undefined;
|
|
2025
|
+
return effectiveDisabledList(baseline, overrides?.agents);
|
|
2026
|
+
}
|
|
2027
|
+
catch {
|
|
2028
|
+
return [];
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
/**
|
|
2032
|
+
* Read the project's capabilityOverrides.builtin bucket for `cwd`, read
|
|
2033
|
+
* UNMERGED (getForScope) so tri-state inherit survives. Sub-agents skip the
|
|
2034
|
+
* overlay (minimal surface, same as readDisabledLists) — their builtin lists
|
|
2035
|
+
* are already narrowed by resolveChildToolScope. No cwd / error → undefined,
|
|
2036
|
+
* so the caller's baseline builtin lists pass through unchanged.
|
|
2037
|
+
*/
|
|
2038
|
+
readBuiltinOverride(cwd) {
|
|
2039
|
+
if (this.config.isSubAgent === true || !cwd)
|
|
2040
|
+
return undefined;
|
|
2041
|
+
try {
|
|
2042
|
+
const overrides = this.getSettingsManager().getForScope("project", cwd)
|
|
2043
|
+
.capabilityOverrides;
|
|
2044
|
+
return overrides?.builtin;
|
|
2045
|
+
}
|
|
2046
|
+
catch {
|
|
2047
|
+
return undefined;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
1431
2050
|
/**
|
|
1432
2051
|
* Build a base ToolContext for this Engine. Used by run() (which then
|
|
1433
2052
|
* overlays turn-specific fields like sandbox and subAgentSpawner) and
|
|
1434
2053
|
* by tests that want a ToolContext without a full run() cycle.
|
|
1435
2054
|
*/
|
|
2055
|
+
resolveSandboxWithoutRuntime(config, cwd) {
|
|
2056
|
+
const key = `${config.mode}:${cwd}`;
|
|
2057
|
+
let cached = this.sandboxCache.get(key);
|
|
2058
|
+
if (!cached) {
|
|
2059
|
+
cached = resolveSandboxBackend(config, cwd);
|
|
2060
|
+
// Mirror EngineRuntime.resolveSandbox: don't cache a rejection, or an
|
|
2061
|
+
// explicit-mode probe that throws stays sticky until process restart even
|
|
2062
|
+
// after the user fixes the config.
|
|
2063
|
+
cached.catch(() => {
|
|
2064
|
+
if (this.sandboxCache.get(key) === cached)
|
|
2065
|
+
this.sandboxCache.delete(key);
|
|
2066
|
+
});
|
|
2067
|
+
this.sandboxCache.set(key, cached);
|
|
2068
|
+
}
|
|
2069
|
+
return cached;
|
|
2070
|
+
}
|
|
1436
2071
|
buildToolContext() {
|
|
2072
|
+
const { disabledSkills, disabledPlugins } = this.readDisabledLists();
|
|
1437
2073
|
return {
|
|
1438
2074
|
cwd: this.config.cwd ?? process.cwd(),
|
|
1439
2075
|
llmConfig: this.config.llm,
|
|
@@ -1444,24 +2080,44 @@ export class Engine {
|
|
|
1444
2080
|
hooks: this.hooks,
|
|
1445
2081
|
planMode: this.planMode,
|
|
1446
2082
|
engine: this,
|
|
1447
|
-
disabledSkills
|
|
2083
|
+
disabledSkills,
|
|
2084
|
+
disabledPlugins,
|
|
1448
2085
|
};
|
|
1449
2086
|
}
|
|
1450
2087
|
/**
|
|
1451
|
-
* Read settings.disabledSkills
|
|
1452
|
-
*
|
|
1453
|
-
* at ~line 237):
|
|
1454
|
-
*
|
|
2088
|
+
* Read settings.disabledSkills + settings.disabledPlugins in a single
|
|
2089
|
+
* pass. Sub-agents skip both for the same reason they skip
|
|
2090
|
+
* settings.hooks / plugin hooks (registerSettingsHooks at ~line 237):
|
|
2091
|
+
* they run with a minimal surface area. Defaults to [] for both
|
|
2092
|
+
* fields so callers don't have to null-check.
|
|
2093
|
+
*
|
|
2094
|
+
* Combined read avoids drift if settings change between two separate
|
|
2095
|
+
* reads — the prompt composer and the tool context will always see
|
|
2096
|
+
* the same snapshot.
|
|
1455
2097
|
*/
|
|
1456
|
-
|
|
1457
|
-
if (this.config.isSubAgent === true)
|
|
1458
|
-
return [];
|
|
2098
|
+
readDisabledLists() {
|
|
2099
|
+
if (this.config.isSubAgent === true) {
|
|
2100
|
+
return { disabledSkills: [], disabledPlugins: [] };
|
|
2101
|
+
}
|
|
1459
2102
|
try {
|
|
1460
|
-
const
|
|
1461
|
-
|
|
2103
|
+
const sm = this.getSettingsManager();
|
|
2104
|
+
const settings = sm.get();
|
|
2105
|
+
// Fold the project capabilityOverrides over the global baseline so a
|
|
2106
|
+
// project can force-enable a globally-disabled skill/plugin or vice
|
|
2107
|
+
// versa. Read the project overlay UNMERGED (getForScope), not the merged
|
|
2108
|
+
// get(), so tri-state inheritance survives. No cwd / no overlay → the
|
|
2109
|
+
// baseline is returned unchanged (zero regression).
|
|
2110
|
+
const cwd = this.config.cwd;
|
|
2111
|
+
const overrides = cwd
|
|
2112
|
+
? sm.getForScope("project", cwd).capabilityOverrides
|
|
2113
|
+
: undefined;
|
|
2114
|
+
return {
|
|
2115
|
+
disabledSkills: effectiveDisabledList(settings.disabledSkills ?? [], overrides?.skills),
|
|
2116
|
+
disabledPlugins: effectiveDisabledList(settings.disabledPlugins ?? [], overrides?.plugins),
|
|
2117
|
+
};
|
|
1462
2118
|
}
|
|
1463
2119
|
catch {
|
|
1464
|
-
return [];
|
|
2120
|
+
return { disabledSkills: [], disabledPlugins: [] };
|
|
1465
2121
|
}
|
|
1466
2122
|
}
|
|
1467
2123
|
}
|