@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.
Files changed (283) hide show
  1. package/dist/agent/agent-definition-registry.d.ts +29 -0
  2. package/dist/agent/agent-definition-registry.js +74 -0
  3. package/dist/agent/agent-definition.d.ts +37 -0
  4. package/dist/agent/agent-definition.js +59 -0
  5. package/dist/arena/arena.js +11 -12
  6. package/dist/arena/context/context-tools.js +2 -4
  7. package/dist/arena/context/within-root.d.ts +7 -0
  8. package/dist/arena/context/within-root.js +15 -0
  9. package/dist/arena/iterate/phases/argue.js +8 -2
  10. package/dist/arena/iterate/phases/revise.js +1 -1
  11. package/dist/arena/iterate/phases/tournament.js +3 -3
  12. package/dist/arena/phases/adjudication.js +1 -4
  13. package/dist/arena/phases/build-consensus.js +1 -4
  14. package/dist/arena/phases/cross-review.js +2 -8
  15. package/dist/arena/phases/debate-rounds.js +1 -4
  16. package/dist/arena/phases/participant-research.js +1 -4
  17. package/dist/arena/phases/planning-detail-expansion.js +8 -10
  18. package/dist/arena/planner.js +0 -1
  19. package/dist/arena/providers/docs.d.ts +1 -0
  20. package/dist/arena/providers/docs.js +5 -2
  21. package/dist/arena/providers/repo.d.ts +1 -0
  22. package/dist/arena/providers/repo.js +5 -2
  23. package/dist/arena/strategies/utils.js +36 -3
  24. package/dist/arena/transitions.d.ts +7 -2
  25. package/dist/arena/transitions.js +9 -4
  26. package/dist/arena/types.d.ts +8 -2
  27. package/dist/automation/cron-expr.d.ts +31 -0
  28. package/dist/automation/cron-expr.js +151 -0
  29. package/dist/automation/index.d.ts +41 -0
  30. package/dist/automation/index.js +39 -0
  31. package/dist/automation/runner.d.ts +67 -0
  32. package/dist/automation/runner.js +56 -0
  33. package/dist/automation/scheduler.d.ts +156 -0
  34. package/dist/automation/scheduler.js +546 -0
  35. package/dist/automation/store.d.ts +39 -0
  36. package/dist/automation/store.js +119 -0
  37. package/dist/automation/write-policy.d.ts +34 -0
  38. package/dist/automation/write-policy.js +113 -0
  39. package/dist/automation/write-run.d.ts +51 -0
  40. package/dist/automation/write-run.js +38 -0
  41. package/dist/capability-control/index.d.ts +5 -0
  42. package/dist/capability-control/index.js +3 -0
  43. package/dist/capability-control/overlay.d.ts +49 -0
  44. package/dist/capability-control/overlay.js +89 -0
  45. package/dist/capability-control/project.d.ts +65 -0
  46. package/dist/capability-control/project.js +147 -0
  47. package/dist/capability-control/service.d.ts +70 -0
  48. package/dist/capability-control/service.js +147 -0
  49. package/dist/capability-control/types.d.ts +62 -0
  50. package/dist/capability-control/types.js +17 -0
  51. package/dist/cli/agent-server-stdio.d.ts +12 -1
  52. package/dist/cli/agent-server-stdio.js +118 -18
  53. package/dist/cli/agent-server-tcp.d.ts +20 -0
  54. package/dist/cli/agent-server-tcp.js +120 -0
  55. package/dist/cli/graceful-shutdown.d.ts +27 -0
  56. package/dist/cli/graceful-shutdown.js +30 -0
  57. package/dist/context/compaction.js +23 -15
  58. package/dist/context/manager.d.ts +0 -20
  59. package/dist/context/manager.js +0 -37
  60. package/dist/cron/cron-runtime.d.ts +2 -0
  61. package/dist/cron/cron-runtime.js +2 -0
  62. package/dist/cron/cron-store.d.ts +2 -0
  63. package/dist/cron/cron-store.js +2 -0
  64. package/dist/cron/scheduler.d.ts +5 -32
  65. package/dist/cron/scheduler.js +5 -113
  66. package/dist/data/openrouter-sync.js +1 -1
  67. package/dist/engine/engine.d.ts +256 -16
  68. package/dist/engine/engine.js +856 -200
  69. package/dist/engine/goal.d.ts +42 -0
  70. package/dist/engine/goal.js +52 -0
  71. package/dist/engine/image-compression.d.ts +81 -0
  72. package/dist/engine/image-compression.js +186 -0
  73. package/dist/engine/image-policy.d.ts +152 -0
  74. package/dist/engine/image-policy.js +165 -0
  75. package/dist/engine/reactive-threshold.d.ts +13 -0
  76. package/dist/engine/reactive-threshold.js +17 -0
  77. package/dist/engine/runtime.js +7 -0
  78. package/dist/engine/session-title.d.ts +13 -0
  79. package/dist/engine/session-title.js +34 -0
  80. package/dist/engine/turn-loop.d.ts +29 -8
  81. package/dist/engine/turn-loop.js +498 -316
  82. package/dist/git/parse-log.d.ts +13 -0
  83. package/dist/git/parse-log.js +21 -0
  84. package/dist/git/utils.d.ts +8 -6
  85. package/dist/git/utils.js +57 -26
  86. package/dist/git/worktree.js +33 -18
  87. package/dist/hooks/events.d.ts +23 -1
  88. package/dist/hooks/goal-stop-hook.d.ts +30 -0
  89. package/dist/hooks/goal-stop-hook.js +102 -0
  90. package/dist/hooks/hook-output.d.ts +33 -0
  91. package/dist/hooks/hook-output.js +79 -0
  92. package/dist/hooks/registry.d.ts +7 -0
  93. package/dist/hooks/registry.js +38 -1
  94. package/dist/hooks/shell-runner.d.ts +2 -0
  95. package/dist/hooks/shell-runner.js +43 -2
  96. package/dist/index.d.ts +32 -7
  97. package/dist/index.js +30 -3
  98. package/dist/llm/capabilities/reasoning-control.d.ts +24 -0
  99. package/dist/llm/capabilities/reasoning-control.js +38 -0
  100. package/dist/llm/capabilities/rules.js +33 -3
  101. package/dist/llm/capabilities/types.d.ts +22 -2
  102. package/dist/llm/clamp-max-tokens.d.ts +11 -0
  103. package/dist/llm/clamp-max-tokens.js +17 -0
  104. package/dist/llm/client-base.d.ts +41 -4
  105. package/dist/llm/client-base.js +67 -13
  106. package/dist/llm/client-factory.d.ts +3 -3
  107. package/dist/llm/client-factory.js +2 -2
  108. package/dist/llm/model-pool.d.ts +15 -11
  109. package/dist/llm/model-pool.js +23 -20
  110. package/dist/llm/provider-catalog.d.ts +2 -2
  111. package/dist/llm/providers/anthropic.d.ts +32 -2
  112. package/dist/llm/providers/anthropic.js +147 -8
  113. package/dist/llm/providers/openai.d.ts +11 -2
  114. package/dist/llm/providers/openai.js +253 -52
  115. package/dist/llm/reasoning-setting.d.ts +51 -0
  116. package/dist/llm/reasoning-setting.js +32 -0
  117. package/dist/llm/stop-reason.d.ts +13 -0
  118. package/dist/llm/stop-reason.js +21 -0
  119. package/dist/llm/strip-vision.d.ts +32 -0
  120. package/dist/llm/strip-vision.js +94 -0
  121. package/dist/llm/types.d.ts +5 -6
  122. package/dist/logging/logger.js +18 -4
  123. package/dist/logging/sanitize-messages.d.ts +10 -0
  124. package/dist/logging/sanitize-messages.js +90 -0
  125. package/dist/lsp/client.js +28 -8
  126. package/dist/lsp/manager.js +2 -1
  127. package/dist/lsp/root-path.d.ts +9 -0
  128. package/dist/lsp/root-path.js +12 -0
  129. package/dist/onboarding.js +18 -11
  130. package/dist/plugins/gitOps.d.ts +34 -0
  131. package/dist/plugins/gitOps.js +59 -3
  132. package/dist/plugins/installer/codex/convertAgents.d.ts +7 -0
  133. package/dist/plugins/installer/codex/convertAgents.js +55 -0
  134. package/dist/plugins/installer/codex/convertMcp.d.ts +8 -0
  135. package/dist/plugins/installer/codex/convertMcp.js +45 -0
  136. package/dist/plugins/installer/codex/convertSkills.d.ts +12 -0
  137. package/dist/plugins/installer/codex/convertSkills.js +33 -0
  138. package/dist/plugins/installer/detectFormat.d.ts +2 -0
  139. package/dist/plugins/installer/detectFormat.js +6 -0
  140. package/dist/plugins/installer/install.d.ts +7 -0
  141. package/dist/plugins/installer/install.js +99 -0
  142. package/dist/plugins/installer/installFromSource.d.ts +13 -0
  143. package/dist/plugins/installer/installFromSource.js +45 -0
  144. package/dist/plugins/installer/list.d.ts +9 -0
  145. package/dist/plugins/installer/list.js +30 -0
  146. package/dist/plugins/installer/loadPluginAgents.d.ts +3 -0
  147. package/dist/plugins/installer/loadPluginAgents.js +23 -0
  148. package/dist/plugins/installer/loadPluginMcp.d.ts +7 -0
  149. package/dist/plugins/installer/loadPluginMcp.js +74 -0
  150. package/dist/plugins/installer/parseSource.d.ts +20 -0
  151. package/dist/plugins/installer/parseSource.js +74 -0
  152. package/dist/plugins/installer/paths.d.ts +5 -0
  153. package/dist/plugins/installer/paths.js +25 -0
  154. package/dist/plugins/installer/types.d.ts +49 -0
  155. package/dist/plugins/installer/types.js +27 -0
  156. package/dist/plugins/installer/uninstall.d.ts +2 -0
  157. package/dist/plugins/installer/uninstall.js +14 -0
  158. package/dist/plugins/installer/update.d.ts +14 -0
  159. package/dist/plugins/installer/update.js +65 -0
  160. package/dist/plugins/loadPluginHooks.d.ts +8 -1
  161. package/dist/plugins/loadPluginHooks.js +11 -1
  162. package/dist/plugins/marketplaceManager.d.ts +8 -1
  163. package/dist/plugins/marketplaceManager.js +38 -4
  164. package/dist/plugins/pluginCommandHook.js +34 -0
  165. package/dist/plugins/pluginInstaller.d.ts +21 -0
  166. package/dist/plugins/pluginInstaller.js +128 -29
  167. package/dist/plugins/schemas.js +40 -6
  168. package/dist/plugins/types.d.ts +4 -0
  169. package/dist/preset/index.js +16 -0
  170. package/dist/prompt/composer.d.ts +10 -0
  171. package/dist/prompt/composer.js +29 -4
  172. package/dist/prompt/sections/orchestration.md +8 -0
  173. package/dist/protocol/chat-session-manager.d.ts +8 -1
  174. package/dist/protocol/chat-session-manager.js +10 -0
  175. package/dist/protocol/chat-session.d.ts +17 -0
  176. package/dist/protocol/chat-session.js +28 -0
  177. package/dist/protocol/redact.d.ts +50 -0
  178. package/dist/protocol/redact.js +71 -0
  179. package/dist/protocol/server.d.ts +41 -2
  180. package/dist/protocol/server.js +231 -33
  181. package/dist/protocol/tcp-transport.d.ts +45 -0
  182. package/dist/protocol/tcp-transport.js +74 -0
  183. package/dist/protocol/types.d.ts +23 -0
  184. package/dist/protocol/types.js +6 -0
  185. package/dist/remote/bridge.d.ts +11 -2
  186. package/dist/remote/bridge.js +61 -33
  187. package/dist/run/ArtifactTracker.js +5 -4
  188. package/dist/run/EngineRunner.d.ts +27 -1
  189. package/dist/run/EngineRunner.js +46 -8
  190. package/dist/run/FileRunStore.js +32 -8
  191. package/dist/run/RunLock.d.ts +28 -2
  192. package/dist/run/RunLock.js +49 -7
  193. package/dist/run/RunManager.js +51 -3
  194. package/dist/run/RunQueue.d.ts +1 -0
  195. package/dist/run/RunQueue.js +8 -2
  196. package/dist/run/factory.d.ts +7 -0
  197. package/dist/run/factory.js +1 -0
  198. package/dist/run/index.d.ts +2 -2
  199. package/dist/run/index.js +1 -1
  200. package/dist/run/redirect-target.d.ts +7 -0
  201. package/dist/run/redirect-target.js +13 -0
  202. package/dist/run/types.js +1 -1
  203. package/dist/services/browser-open.d.ts +13 -0
  204. package/dist/services/browser-open.js +16 -0
  205. package/dist/services/dream-consolidation.d.ts +57 -0
  206. package/dist/services/dream-consolidation.js +151 -0
  207. package/dist/services/memory-orchestrator.js +18 -0
  208. package/dist/services/notifier.d.ts +8 -0
  209. package/dist/services/notifier.js +39 -25
  210. package/dist/services/oauth.js +5 -7
  211. package/dist/services/session-memory-sort.d.ts +8 -0
  212. package/dist/services/session-memory-sort.js +9 -0
  213. package/dist/services/session-memory.js +8 -9
  214. package/dist/session/file-history.js +10 -3
  215. package/dist/session/session-manager.d.ts +23 -1
  216. package/dist/session/session-manager.js +73 -2
  217. package/dist/settings/disk-defaults.d.ts +35 -0
  218. package/dist/settings/disk-defaults.js +24 -0
  219. package/dist/settings/manager.d.ts +45 -1
  220. package/dist/settings/manager.js +121 -17
  221. package/dist/settings/manager.test.d.ts +1 -0
  222. package/dist/settings/manager.test.js +73 -0
  223. package/dist/settings/personalization.d.ts +19 -0
  224. package/dist/settings/personalization.js +7 -0
  225. package/dist/settings/schema.d.ts +1163 -126
  226. package/dist/settings/schema.js +126 -10
  227. package/dist/skills/scanner.d.ts +13 -6
  228. package/dist/skills/scanner.js +23 -4
  229. package/dist/tool-system/builtin/add-marketplace.d.ts +12 -0
  230. package/dist/tool-system/builtin/add-marketplace.js +76 -0
  231. package/dist/tool-system/builtin/agent-registry.d.ts +15 -0
  232. package/dist/tool-system/builtin/agent-registry.js +14 -4
  233. package/dist/tool-system/builtin/agent.d.ts +55 -0
  234. package/dist/tool-system/builtin/agent.js +165 -17
  235. package/dist/tool-system/builtin/apply-patch/index.js +22 -3
  236. package/dist/tool-system/builtin/arena.js +6 -7
  237. package/dist/tool-system/builtin/complete-goal.d.ts +25 -0
  238. package/dist/tool-system/builtin/complete-goal.js +45 -0
  239. package/dist/tool-system/builtin/cron.js +53 -9
  240. package/dist/tool-system/builtin/edit.d.ts +2 -1
  241. package/dist/tool-system/builtin/edit.js +6 -1
  242. package/dist/tool-system/builtin/generate-image.d.ts +25 -0
  243. package/dist/tool-system/builtin/generate-image.js +146 -0
  244. package/dist/tool-system/builtin/glob.js +7 -0
  245. package/dist/tool-system/builtin/grep.js +6 -0
  246. package/dist/tool-system/builtin/index.d.ts +18 -1
  247. package/dist/tool-system/builtin/index.js +62 -1
  248. package/dist/tool-system/builtin/notebook-edit.d.ts +2 -1
  249. package/dist/tool-system/builtin/notebook-edit.js +8 -1
  250. package/dist/tool-system/builtin/read.d.ts +2 -1
  251. package/dist/tool-system/builtin/read.js +9 -1
  252. package/dist/tool-system/builtin/skill.js +23 -5
  253. package/dist/tool-system/builtin/sleep.js +9 -3
  254. package/dist/tool-system/builtin/task.js +4 -1
  255. package/dist/tool-system/builtin/update-automation-memory.d.ts +24 -0
  256. package/dist/tool-system/builtin/update-automation-memory.js +60 -0
  257. package/dist/tool-system/builtin/view-image.d.ts +18 -0
  258. package/dist/tool-system/builtin/view-image.js +96 -0
  259. package/dist/tool-system/builtin/web-search.d.ts +4 -1
  260. package/dist/tool-system/builtin/web-search.js +36 -3
  261. package/dist/tool-system/builtin/write.d.ts +2 -1
  262. package/dist/tool-system/builtin/write.js +7 -1
  263. package/dist/tool-system/context.d.ts +43 -0
  264. package/dist/tool-system/executor.d.ts +0 -1
  265. package/dist/tool-system/executor.js +34 -40
  266. package/dist/tool-system/investigation-guard.d.ts +4 -1
  267. package/dist/tool-system/investigation-guard.js +19 -3
  268. package/dist/tool-system/mcp-manager.d.ts +36 -12
  269. package/dist/tool-system/mcp-manager.js +173 -10
  270. package/dist/tool-system/path-policy.d.ts +94 -0
  271. package/dist/tool-system/path-policy.js +279 -0
  272. package/dist/tool-system/permission.js +43 -50
  273. package/dist/tool-system/plan-mode-allowlist.d.ts +21 -0
  274. package/dist/tool-system/plan-mode-allowlist.js +43 -0
  275. package/dist/tool-system/registry.js +12 -1
  276. package/dist/tool-system/validation.d.ts +10 -0
  277. package/dist/tool-system/validation.js +16 -3
  278. package/dist/types.d.ts +90 -15
  279. package/dist/utils/format.js +4 -2
  280. package/dist/utils/lockfile.js +9 -2
  281. package/dist/utils/theme.d.ts +6 -0
  282. package/dist/utils/theme.js +4 -2
  283. package/package.json +2 -1
@@ -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
- this.hooks.register(entry.event, async (ctx) => {
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
- }, 50, `shell:${entry.event}:${entry.command.slice(0, 32)}`);
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: config.enabledBuiltinTools,
139
- disabledBuiltinTools: config.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
- loadPluginHooks(this.hooks);
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
- const activeKey = settings.activeKey;
205
- let match;
206
- if (activeKey) {
207
- match = settings.models.find((m) => m.key === activeKey);
208
- }
209
- if (!match) {
210
- const currentModel = this.config.llm.model;
211
- // OpenRouter stores entries as "provider/model-name"; the top-level
212
- // settings.model.name is just "model-name". Match either form.
213
- match = settings.models.find((m) => m.model === currentModel ||
214
- (currentModel && m.model?.endsWith(`/${currentModel}`)));
215
- }
216
- if (match) {
217
- const entry = this.modelPool.switch(match.key);
218
- this.config = {
219
- ...this.config,
220
- llm: this.modelPool.toLLMConfig(entry, this.config.llm),
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, this.config.llm),
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 NESTED_AGENT_TOOLS = ["Agent", "AgentStatus", "AgentCancel"];
392
- const childDisabled = Array.from(new Set([
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: { ...this.config.llm, retryMaxAttempts: 2 },
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 on EngineRuntime (when available) so the
462
- // capability probe runs once per (mode, cwd) instead of every turn.
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 resolveSandboxBackend(sandboxConfig, cwd);
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
- messages = this.compactedMessagesBySession.get(options.sessionId)
527
- ? [...this.compactedMessagesBySession.get(options.sessionId)]
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.headless)
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
- disabledSkills: this.readDisabledSkills(),
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
- const allToolDefs = this.toolRegistry.getToolDefinitions();
746
- // In plan mode, only expose read-only tools so the model won't attempt writes
747
- const planModeAllowed = new Set([
748
- "EnterPlanMode",
749
- "ExitPlanMode",
750
- "Read",
751
- "Glob",
752
- "Grep",
753
- "WebSearch",
754
- "WebFetch",
755
- "AskUserQuestion",
756
- "Agent",
757
- "ToolSearch",
758
- "TaskCreate",
759
- "TaskUpdate",
760
- "TaskList",
761
- "TaskGet",
762
- "Bash", // Bash is included but executor filters non-read-only commands
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) => planModeAllowed.has(t.name))
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 llmClient.createMessage({
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
- thinking: "disabled",
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 llmClient.createMessage({
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
- thinking: "disabled",
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(homedir(), ".code-shell", "sessions"), session.state.sessionId);
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
- const result = await turnLoop.run(messages);
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
- async runMemoryPipeline(transcript, sessionId, cwd, llmClient) {
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
- thinking: "disabled",
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
- const MAX_TURNS = 8;
1058
- const MAX_WRITES = 10;
1059
- const MEMORY_TOOL_NAMES = ["MemoryList", "MemoryRead", "MemorySave", "MemoryDelete"];
1060
- const memoryTools = MEMORY_TOOL_NAMES
1061
- .map((n) => this.toolRegistry.getTool(n))
1062
- .filter((t) => t != null);
1063
- if (memoryTools.length < MEMORY_TOOL_NAMES.length) {
1064
- logger.warn("memory.auto_dream_missing_tools", {
1065
- sessionId: opts.sessionId,
1066
- found: memoryTools.map((t) => t.name),
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 true;
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
- const nextLlm = this.modelPool.toLLMConfig(entry, this.config.llm);
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
- const dir = join(homedir(), ".code-shell");
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: this.readDisabledSkills(),
2083
+ disabledSkills,
2084
+ disabledPlugins,
1448
2085
  };
1449
2086
  }
1450
2087
  /**
1451
- * Read settings.disabledSkills. Sub-agents skip this for the same
1452
- * reason they skip settings.hooks / plugin hooks (registerSettingsHooks
1453
- * at ~line 237): they run with a minimal surface area. Defaults to []
1454
- * so callers don't have to null-check.
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
- readDisabledSkills() {
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 settings = this.getSettingsManager().get();
1461
- return settings.disabledSkills ?? [];
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
  }