@cjhyy/code-shell-core 0.5.0-rc.1 → 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 (278) hide show
  1. package/dist/agent/agent-definition-registry.d.ts +18 -3
  2. package/dist/agent/agent-definition-registry.js +47 -18
  3. package/dist/agent/agent-definition.d.ts +17 -0
  4. package/dist/agent/agent-definition.js +22 -1
  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 +114 -19
  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 +208 -14
  68. package/dist/engine/engine.js +754 -169
  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 +31 -6
  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 +4 -0
  171. package/dist/prompt/composer.js +28 -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 +33 -0
  220. package/dist/settings/manager.js +94 -1
  221. package/dist/settings/personalization.d.ts +19 -0
  222. package/dist/settings/personalization.js +7 -0
  223. package/dist/settings/schema.d.ts +1142 -126
  224. package/dist/settings/schema.js +119 -10
  225. package/dist/tool-system/builtin/add-marketplace.d.ts +12 -0
  226. package/dist/tool-system/builtin/add-marketplace.js +76 -0
  227. package/dist/tool-system/builtin/agent-registry.d.ts +11 -0
  228. package/dist/tool-system/builtin/agent-registry.js +8 -4
  229. package/dist/tool-system/builtin/agent.d.ts +29 -10
  230. package/dist/tool-system/builtin/agent.js +108 -52
  231. package/dist/tool-system/builtin/apply-patch/index.js +22 -3
  232. package/dist/tool-system/builtin/arena.js +6 -7
  233. package/dist/tool-system/builtin/complete-goal.d.ts +25 -0
  234. package/dist/tool-system/builtin/complete-goal.js +45 -0
  235. package/dist/tool-system/builtin/cron.js +53 -9
  236. package/dist/tool-system/builtin/edit.d.ts +2 -1
  237. package/dist/tool-system/builtin/edit.js +6 -1
  238. package/dist/tool-system/builtin/generate-image.d.ts +25 -0
  239. package/dist/tool-system/builtin/generate-image.js +146 -0
  240. package/dist/tool-system/builtin/glob.js +7 -0
  241. package/dist/tool-system/builtin/grep.js +6 -0
  242. package/dist/tool-system/builtin/index.d.ts +18 -1
  243. package/dist/tool-system/builtin/index.js +62 -1
  244. package/dist/tool-system/builtin/notebook-edit.d.ts +2 -1
  245. package/dist/tool-system/builtin/notebook-edit.js +8 -1
  246. package/dist/tool-system/builtin/read.d.ts +2 -1
  247. package/dist/tool-system/builtin/read.js +9 -1
  248. package/dist/tool-system/builtin/sleep.js +9 -3
  249. package/dist/tool-system/builtin/task.js +4 -1
  250. package/dist/tool-system/builtin/update-automation-memory.d.ts +24 -0
  251. package/dist/tool-system/builtin/update-automation-memory.js +60 -0
  252. package/dist/tool-system/builtin/view-image.d.ts +18 -0
  253. package/dist/tool-system/builtin/view-image.js +96 -0
  254. package/dist/tool-system/builtin/web-search.d.ts +4 -1
  255. package/dist/tool-system/builtin/web-search.js +36 -3
  256. package/dist/tool-system/builtin/write.d.ts +2 -1
  257. package/dist/tool-system/builtin/write.js +7 -1
  258. package/dist/tool-system/context.d.ts +12 -0
  259. package/dist/tool-system/executor.d.ts +0 -1
  260. package/dist/tool-system/executor.js +34 -40
  261. package/dist/tool-system/investigation-guard.d.ts +4 -1
  262. package/dist/tool-system/investigation-guard.js +19 -3
  263. package/dist/tool-system/mcp-manager.d.ts +36 -12
  264. package/dist/tool-system/mcp-manager.js +173 -10
  265. package/dist/tool-system/path-policy.d.ts +94 -0
  266. package/dist/tool-system/path-policy.js +279 -0
  267. package/dist/tool-system/permission.js +43 -50
  268. package/dist/tool-system/plan-mode-allowlist.d.ts +21 -0
  269. package/dist/tool-system/plan-mode-allowlist.js +43 -0
  270. package/dist/tool-system/registry.js +12 -1
  271. package/dist/tool-system/validation.d.ts +10 -0
  272. package/dist/tool-system/validation.js +16 -3
  273. package/dist/types.d.ts +90 -15
  274. package/dist/utils/format.js +4 -2
  275. package/dist/utils/lockfile.js +9 -2
  276. package/dist/utils/theme.d.ts +6 -0
  277. package/dist/utils/theme.js +4 -2
  278. package/package.json +2 -1
@@ -7,22 +7,32 @@ 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";
@@ -33,31 +43,112 @@ import { defaultCacheDir } from "../llm/model-cache.js";
33
43
  import { detectProviderFromApiKey, buildModelPool, } from "../onboarding.js";
34
44
  import { detectPastedNoise } from "../utils/task-sanitizer.js";
35
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";
36
49
  import { capabilitiesFor } from "../llm/capabilities/index.js";
37
50
  import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
51
+ import { runDreamConsolidation } from "../services/dream-consolidation.js";
38
52
  import { join } from "node:path";
39
53
  import { homedir } from "node:os";
40
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";
41
95
  /**
42
96
  * Resolve the LLM config for a spawned child Engine.
43
- * - `modelKey` set + present in pool → that model's config (over parent base).
97
+ * - `modelKey` set + present in pool → that model's config (pure entry-derived
98
+ * identity; the parent's llm is NOT consulted).
44
99
  * - otherwise (no key, no pool, or key miss) → the parent's llm unchanged.
45
100
  * Key miss is a soft fallback, NOT an error: a stale agent definition must not
46
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.
47
106
  */
48
107
  export function resolveChildLlm(modelKey, pool, parentLlm) {
49
108
  if (modelKey && pool?.has(modelKey)) {
50
- const resolved = pool.resolveLLMConfig(modelKey, parentLlm);
109
+ const resolved = pool.resolveLLMConfig(modelKey);
51
110
  if (resolved)
52
111
  return resolved;
53
112
  }
54
113
  return parentLlm;
55
114
  }
56
- /** Load reusable sub-agent role definitions from <cwd>/.code-shell/agents. */
57
- export function loadAgentDefinitionsForCwd(cwd) {
58
- return AgentDefinitionRegistry.loadFromDir(`${cwd}/.code-shell/agents`);
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);
59
137
  }
60
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
+ }
61
152
  /**
62
153
  * Compute a child Engine's tool scope.
63
154
  * - `allowlist` set → child enabled = allowlist minus nested-agent tools
@@ -78,16 +169,38 @@ export function resolveChildToolScope(allowlist, parentDisabled, parentEnabled)
78
169
  }
79
170
  export class Engine {
80
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).
81
178
  preset;
82
179
  toolRegistry;
83
180
  hooks;
84
181
  sessionManager;
85
182
  mcpManager;
86
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;
87
199
  /** Memoized sub-agent role registry, keyed by the cwd it was loaded from. */
88
200
  agentDefsCache;
89
201
  /** Shared resources supplied at construction (adapter pattern — null when self-constructed). */
90
202
  runtime;
203
+ sandboxCache = new Map();
91
204
  /** Active permission mode for this Engine instance. */
92
205
  permissionMode;
93
206
  /** True when permissionMode === "plan". */
@@ -96,6 +209,13 @@ export class Engine {
96
209
  // don't re-read 6+ JSON files on every /model, /login, etc. The manager
97
210
  // handles its own cache invalidation in saveUserSetting().
98
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;
99
219
  // Live state from the current/most-recent run, retained for /compact and
100
220
  // for live-mutating PermissionClassifier on permission-mode switch.
101
221
  lastContextManager;
@@ -158,12 +278,47 @@ export class Engine {
158
278
  }
159
279
  const entries = settings.hooks ?? [];
160
280
  for (const entry of entries) {
161
- this.hooks.register(entry.event, async (ctx) => {
281
+ const event = entry.event;
282
+ const handler = async (ctx) => {
162
283
  if (!shellHookMatches(entry, ctx))
163
284
  return {};
164
285
  return runShellHook(entry, ctx);
165
- }, 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 });
291
+ }
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);
166
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();
167
322
  }
168
323
  constructor(config) {
169
324
  this.config = config;
@@ -173,11 +328,26 @@ export class Engine {
173
328
  this.permissionMode = config.permissionMode ?? "acceptEdits";
174
329
  this.planMode = this.permissionMode === "plan";
175
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));
176
346
  this.toolRegistry = config.runtime?.toolRegistry ?? new ToolRegistry({
177
347
  builtinTools: resolveBuiltinToolNames({
178
348
  preset: this.preset.name,
179
- enabledBuiltinTools: config.enabledBuiltinTools,
180
- disabledBuiltinTools: config.disabledBuiltinTools,
349
+ enabledBuiltinTools: builtinLists.enabledBuiltinTools,
350
+ disabledBuiltinTools: builtinLists.disabledBuiltinTools,
181
351
  }),
182
352
  });
183
353
  this.hooks = new HookRegistry();
@@ -189,7 +359,10 @@ export class Engine {
189
359
  // overhead multiplied across sub-agents outweighs the value, and
190
360
  // dispatched tasks should run with minimal surface area.
191
361
  if (config.isSubAgent !== true) {
192
- 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);
193
366
  }
194
367
  // settings.hooks → shell-command wrappers. Chain order:
195
368
  // plugin (80) → shell (50) → code (default 0).
@@ -264,7 +437,7 @@ export class Engine {
264
437
  const entry = this.modelPool.switch(match.key);
265
438
  this.config = {
266
439
  ...this.config,
267
- llm: this.modelPool.toLLMConfig(entry, this.config.llm),
440
+ llm: this.modelPool.toLLMConfig(entry),
268
441
  };
269
442
  }
270
443
  }
@@ -275,6 +448,27 @@ export class Engine {
275
448
  // use /model to switch between the provider's available models.
276
449
  this.autoPopulatePool(this.config.llm.apiKey, this.config.llm.baseUrl);
277
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
+ }
278
472
  }
279
473
  catch {
280
474
  // Settings not available — pool stays empty
@@ -315,7 +509,7 @@ export class Engine {
315
509
  const entry = this.modelPool.switch(defaultEntry.key);
316
510
  this.config = {
317
511
  ...this.config,
318
- llm: this.modelPool.toLLMConfig(entry, this.config.llm),
512
+ llm: this.modelPool.toLLMConfig(entry),
319
513
  };
320
514
  }
321
515
  }
@@ -334,6 +528,13 @@ export class Engine {
334
528
  setAskUser(fn) {
335
529
  this.config.askUser = fn;
336
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
+ }
337
538
  /**
338
539
  * Run a task from start to finish.
339
540
  */
@@ -352,6 +553,16 @@ export class Engine {
352
553
  if (event.type === "task_update") {
353
554
  latestTodos = event.tasks;
354
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
+ }
355
566
  userOnStream?.(event);
356
567
  };
357
568
  if (options)
@@ -398,6 +609,67 @@ export class Engine {
398
609
  usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
399
610
  };
400
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
+ }
401
673
  }
402
674
  // For downstream noise-detection + transcript persistence we want the
403
675
  // *text* portion only — base64 bytes count as "noise" by the heuristic
@@ -439,7 +711,11 @@ export class Engine {
439
711
  const { enabled: childEnabled, disabled: childDisabled } = resolveChildToolScope(req.toolAllowlist, this.config.disabledBuiltinTools, this.config.enabledBuiltinTools);
440
712
  const childLlm = resolveChildLlm(req.model, this.modelPool, this.config.llm);
441
713
  const child = new Engine({
442
- llm: { ...childLlm, 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 },
443
719
  cwd,
444
720
  permissionMode: this.config.permissionMode,
445
721
  preset: this.preset.name,
@@ -449,10 +725,14 @@ export class Engine {
449
725
  appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt]
450
726
  .filter(Boolean)
451
727
  .join("\n\n") || undefined,
728
+ responseLanguage: this.config.responseLanguage,
729
+ userProfile: this.config.userProfile,
730
+ instructions: this.config.instructions,
452
731
  maxTurns: req.maxTurns,
453
732
  maxContextTokens: this.config.maxContextTokens ?? 200_000,
454
733
  sessionStorageDir: this.config.sessionStorageDir,
455
734
  headless: this.config.headless,
735
+ readOnlySession: req.readOnlySession,
456
736
  sandbox: this.config.sandbox,
457
737
  // Subagents inherit the parent's scope: a child runs in the same
458
738
  // cwd/session, so it should see the same config layers the parent did.
@@ -504,11 +784,11 @@ export class Engine {
504
784
  // `auto` mode handles its own downgrade with a one-time warning
505
785
  // inside resolveSandboxBackend; explicit modes do not.
506
786
  //
507
- // Backend is cached on EngineRuntime (when available) so the
508
- // 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.
509
789
  const sandboxBackend = this.runtime
510
790
  ? await this.runtime.resolveSandbox(sandboxConfig, cwd)
511
- : await resolveSandboxBackend(sandboxConfig, cwd);
791
+ : await this.resolveSandboxWithoutRuntime(sandboxConfig, cwd);
512
792
  // sessionId is filled in after the session bundle is resolved below
513
793
  // (the session may be cold-started or resumed). Until then this is
514
794
  // intentionally shaped as a mutable local; we treat it as immutable
@@ -570,9 +850,8 @@ export class Engine {
570
850
  let messages;
571
851
  if (options?.sessionId && this.sessionManager.exists(options.sessionId)) {
572
852
  session = this.sessionManager.resume(options.sessionId);
573
- messages = this.compactedMessagesBySession.get(options.sessionId)
574
- ? [...this.compactedMessagesBySession.get(options.sessionId)]
575
- : session.transcript.toMessages();
853
+ const cachedCompacted = this.compactedMessagesBySession.get(options.sessionId);
854
+ messages = cachedCompacted ? [...cachedCompacted] : session.transcript.toMessages();
576
855
  // If the previous run was Ctrl+C'd or crashed between an assistant
577
856
  // tool_use and the matching tool_result being persisted, the
578
857
  // loaded sequence is invalid for OpenAI (which 400s on dangling
@@ -604,7 +883,7 @@ export class Engine {
604
883
  else {
605
884
  // Cold start: shape (2) reuses the host-supplied sid; shape (3)
606
885
  // lets sessionManager generate one with nanoid.
607
- 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);
608
887
  messages = [{ role: "user", content: userMessageContent }];
609
888
  session.transcript.appendMessage("user", userMessageContent);
610
889
  // Save first user message as session summary — text only. The summary
@@ -730,7 +1009,7 @@ export class Engine {
730
1009
  }
731
1010
  }
732
1011
  // Kick off LLM client creation early (network handshake)
733
- const llmClientPromise = createLLMClient(this.config.llm);
1012
+ const llmClientPromise = createLLMClient(this.config.llm, this.config.clientDefaults);
734
1013
  const mode = this.config.permissionMode ?? "acceptEdits";
735
1014
  const { rules: defaultRules, backend: approvalBackend } = this.buildPermissionConfig(mode, cwd);
736
1015
  const permission = new PermissionClassifier(defaultRules, mode, approvalBackend);
@@ -751,8 +1030,12 @@ export class Engine {
751
1030
  }
752
1031
  const toolExecutor = new ToolExecutor(this.toolRegistry, permission, this.hooks);
753
1032
  const investigationGuard = new InvestigationGuard();
754
- if (this.config.headless)
1033
+ if (this.config.readOnlySession) {
1034
+ investigationGuard.setPolicy("read-only-review");
1035
+ }
1036
+ else if (this.config.headless) {
755
1037
  investigationGuard.setSoftMode(true);
1038
+ }
756
1039
  toolExecutor.setInvestigationGuard(investigationGuard);
757
1040
  toolExecutor.setTaskGuard(new TaskGuard(() => latestTodos));
758
1041
  // Wire abort signal for cascading cancellation + per-Engine ToolContext
@@ -769,6 +1052,9 @@ export class Engine {
769
1052
  preset: this.preset,
770
1053
  customSystemPrompt: this.config.customSystemPrompt,
771
1054
  appendSystemPrompt: this.config.appendSystemPrompt,
1055
+ responseLanguage: this.config.responseLanguage,
1056
+ userProfile: this.config.userProfile,
1057
+ instructionOptions: { compatFileNames: compatFileNamesFrom(this.config.instructions) },
772
1058
  disabledSkills,
773
1059
  disabledPlugins,
774
1060
  });
@@ -791,27 +1077,58 @@ export class Engine {
791
1077
  // 1. createLLMClient — network handshake (started earlier)
792
1078
  // 2. buildSystemPrompt — includes git status (3 execSync calls)
793
1079
  // 3. buildSystemContext — reads environment context
794
- const allToolDefs = this.toolRegistry.getToolDefinitions();
795
- // In plan mode, only expose read-only tools so the model won't attempt writes
796
- const planModeAllowed = new Set([
797
- "EnterPlanMode",
798
- "ExitPlanMode",
799
- "Read",
800
- "Glob",
801
- "Grep",
802
- "WebSearch",
803
- "WebFetch",
804
- "AskUserQuestion",
805
- "Agent",
806
- "ToolSearch",
807
- "TaskCreate",
808
- "TaskUpdate",
809
- "TaskList",
810
- "TaskGet",
811
- "Bash", // Bash is included but executor filters non-read-only commands
812
- ]);
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.)
813
1130
  const toolDefs = this.planMode
814
- ? allToolDefs.filter((t) => planModeAllowed.has(t.name))
1131
+ ? allToolDefs.filter((t) => PLAN_MODE_ALLOWED_TOOLS.has(t.name))
815
1132
  : allToolDefs;
816
1133
  const [llmClient, systemPrompt, systemContext] = await Promise.all([
817
1134
  llmClientPromise,
@@ -848,8 +1165,15 @@ export class Engine {
848
1165
  // run would be evaluated fresh and might get a different replacement
849
1166
  // string than the one already in the message, breaking idempotency.
850
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);
851
1175
  contextManager.setSummarizeFn(async (prompt) => {
852
- const summaryResponse = await llmClient.createMessage({
1176
+ const summaryResponse = await auxSummaryClient.createMessage({
853
1177
  systemPrompt: "You are a conversation summarizer. Be concise and factual.",
854
1178
  messages: [{ role: "user", content: prompt }],
855
1179
  tools: [],
@@ -857,7 +1181,7 @@ export class Engine {
857
1181
  // Auxiliary call — no need to burn reasoning tokens. On DeepSeek V4
858
1182
  // this flips thinking off (~3x faster, fewer tokens); on every other
859
1183
  // OpenAI-compatible provider the field is ignored.
860
- thinking: "disabled",
1184
+ reasoning: { mode: "off" },
861
1185
  });
862
1186
  return summaryResponse.text;
863
1187
  });
@@ -873,14 +1197,14 @@ export class Engine {
873
1197
  // tracker so session_end.cost reflects only the user-facing turns and
874
1198
  // turns/requestCount stay aligned.
875
1199
  modelFacade.summarize = async (sysPrompt, userMsg) => {
876
- const resp = await llmClient.createMessage({
1200
+ const resp = await auxSummaryClient.createMessage({
877
1201
  systemPrompt: sysPrompt,
878
1202
  messages: [{ role: "user", content: userMsg }],
879
1203
  tools: [],
880
1204
  maxTokens: 256,
881
1205
  recordUsage: false,
882
1206
  // Auxiliary call — see contextManager.setSummarizeFn above.
883
- thinking: "disabled",
1207
+ reasoning: { mode: "off" },
884
1208
  });
885
1209
  logger.debug("summarize.call", {
886
1210
  sysPromptLen: sysPrompt.length,
@@ -895,7 +1219,7 @@ export class Engine {
895
1219
  return resp.text;
896
1220
  };
897
1221
  // File history: auto-backup before Write/Edit
898
- 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);
899
1223
  const fileHistory = FileHistory.loadFromDir(sessionDir);
900
1224
  this.hooks.register("on_tool_start", async (context) => {
901
1225
  const toolName = context.data?.toolName;
@@ -911,6 +1235,24 @@ export class Engine {
911
1235
  task,
912
1236
  model: this.config.llm.model,
913
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
+ }
914
1256
  // Surface compaction events to the UI so the user knows when context was trimmed.
915
1257
  // Buffer the most recent event so TurnLoop can drain it and emit the
916
1258
  // post_compact hook on the next turn (ContextManager itself doesn't
@@ -947,6 +1289,9 @@ export class Engine {
947
1289
  maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 10,
948
1290
  onStream: options?.onStream,
949
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,
950
1295
  // Heartbeat: flush turnCount + tokens to state.json after every turn
951
1296
  // so external observers (other CLI processes, /sid, the session list)
952
1297
  // see live progress instead of a stale snapshot from the last
@@ -965,7 +1310,59 @@ export class Engine {
965
1310
  this.sessionManager.saveState(session.state);
966
1311
  },
967
1312
  });
968
- 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
+ }
969
1366
  this.lastMessages = result.messages;
970
1367
  this.compactedMessagesBySession.set(session.state.sessionId, this.stripUserContextMessage(result.messages, userContextMsg));
971
1368
  logger.info("engine.done", {
@@ -992,6 +1389,31 @@ export class Engine {
992
1389
  // transcript, save a session summary, and conditionally trigger
993
1390
  // auto-dream consolidation. Doesn't block the Engine result.
994
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
+ }
995
1417
  // Update session state. Persist the raw terminal reason as the status so
996
1418
  // callers can distinguish user-cancelled (aborted_streaming) from real
997
1419
  // failures (model_error, prompt_too_long, ...) — previously every
@@ -1035,8 +1457,69 @@ export class Engine {
1035
1457
  * task. Extracts durable memories from the transcript, saves a session
1036
1458
  * summary, and conditionally triggers auto-dream consolidation.
1037
1459
  */
1038
- 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) {
1039
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);
1040
1523
  // Only run memory extraction for substantive sessions. The previous
1041
1524
  // threshold of 4 user+assistant messages was low enough that two-line
1042
1525
  // exchanges ("what's the time?" / "noon") triggered a full LLM
@@ -1069,7 +1552,7 @@ export class Engine {
1069
1552
  tools: [],
1070
1553
  maxTokens: 1024,
1071
1554
  recordUsage: false,
1072
- thinking: "disabled",
1555
+ reasoning: { mode: "off" },
1073
1556
  });
1074
1557
  return resp.text;
1075
1558
  },
@@ -1103,117 +1586,20 @@ export class Engine {
1103
1586
  * bailed before the first LLM call (e.g. registry missing the tools).
1104
1587
  */
1105
1588
  async runDreamLoop(opts) {
1106
- const MAX_TURNS = 8;
1107
- const MAX_WRITES = 10;
1108
- const MEMORY_TOOL_NAMES = ["MemoryList", "MemoryRead", "MemorySave", "MemoryDelete"];
1109
- const memoryTools = MEMORY_TOOL_NAMES
1110
- .map((n) => this.toolRegistry.getTool(n))
1111
- .filter((t) => t != null);
1112
- if (memoryTools.length < MEMORY_TOOL_NAMES.length) {
1113
- logger.warn("memory.auto_dream_missing_tools", {
1114
- sessionId: opts.sessionId,
1115
- found: memoryTools.map((t) => t.name),
1116
- });
1117
- return false;
1118
- }
1119
- // Strip RegisteredTool down to the shape createMessage expects.
1120
- const toolDefs = memoryTools.map((t) => ({
1121
- name: t.name,
1122
- description: t.description,
1123
- inputSchema: t.inputSchema,
1124
- }));
1125
- const toolCtx = {
1126
- ...this.buildToolContext(),
1127
- cwd: opts.projectDir ?? process.cwd(),
1128
- };
1129
- const messages = [{ role: "user", content: opts.userPrompt }];
1130
- let writeBudget = MAX_WRITES;
1131
- for (let turn = 0; turn < MAX_TURNS; turn++) {
1132
- const resp = await opts.llmClient.createMessage({
1133
- systemPrompt: opts.systemPrompt,
1134
- messages,
1135
- tools: toolDefs,
1136
- maxTokens: 2048,
1137
- recordUsage: false,
1138
- thinking: "disabled",
1139
- });
1140
- if (resp.toolCalls.length === 0) {
1141
- logger.info("memory.auto_dream_finished", {
1142
- sessionId: opts.sessionId,
1143
- turn,
1144
- finalText: resp.text.slice(0, 500),
1145
- });
1146
- return true;
1147
- }
1148
- // Echo the assistant turn back into the conversation so subsequent
1149
- // turns see the tool_use ids they need to reference.
1150
- const assistantContent = [];
1151
- if (resp.text)
1152
- assistantContent.push({ type: "text", text: resp.text });
1153
- for (const tc of resp.toolCalls) {
1154
- assistantContent.push({
1155
- type: "tool_use",
1156
- id: tc.id,
1157
- name: tc.toolName,
1158
- input: tc.args,
1159
- });
1160
- }
1161
- messages.push({ role: "assistant", content: assistantContent });
1162
- // Dispatch every tool call requested in this turn.
1163
- const toolResults = [];
1164
- for (const tc of resp.toolCalls) {
1165
- const result = await this.dispatchDreamTool(tc, toolCtx, () => {
1166
- if (writeBudget <= 0)
1167
- return false;
1168
- writeBudget--;
1169
- return true;
1170
- });
1171
- toolResults.push({
1172
- type: "tool_result",
1173
- tool_use_id: tc.id,
1174
- content: result,
1175
- });
1176
- }
1177
- messages.push({ role: "user", content: toolResults });
1178
- }
1179
- 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,
1180
1600
  sessionId: opts.sessionId,
1181
- maxTurns: MAX_TURNS,
1182
1601
  });
1183
- return true;
1184
- }
1185
- /**
1186
- * Execute one memory tool call inside the dream loop. Enforces the two
1187
- * dream-loop invariants the prompt also states:
1188
- * - Only the 4 memory tools are dispatchable.
1189
- * - Save/Delete in "user" scope is refused (returned as a tool error)
1190
- * because dream runs without an interactive permission backend.
1191
- */
1192
- async dispatchDreamTool(tc, ctx, consumeWriteBudget) {
1193
- const allowed = new Set(["MemoryList", "MemoryRead", "MemorySave", "MemoryDelete"]);
1194
- if (!allowed.has(tc.toolName)) {
1195
- return `Error: tool "${tc.toolName}" is not available in the dream loop`;
1196
- }
1197
- const isWrite = tc.toolName === "MemorySave" || tc.toolName === "MemoryDelete";
1198
- if (isWrite) {
1199
- const scope = tc.args?.scope;
1200
- if (scope !== "dream") {
1201
- return (`Error: dream loop may only write to scope "dream", got "${scope}". ` +
1202
- `User-scope changes require interactive permission, which is not available here.`);
1203
- }
1204
- if (!consumeWriteBudget()) {
1205
- return "Error: dream write budget exhausted — stop calling write tools and summarize instead.";
1206
- }
1207
- }
1208
- try {
1209
- const result = await this.toolRegistry.executeTool(tc.toolName, tc.args, { ctx });
1210
- if (result.isError)
1211
- return result.error ?? `Error executing ${tc.toolName}`;
1212
- return result.result ?? "";
1213
- }
1214
- catch (err) {
1215
- return `Error executing ${tc.toolName}: ${err.message}`;
1216
- }
1602
+ return ran;
1217
1603
  }
1218
1604
  getToolRegistry() {
1219
1605
  return this.toolRegistry;
@@ -1229,7 +1615,10 @@ export class Engine {
1229
1615
  */
1230
1616
  switchModel(key) {
1231
1617
  const entry = this.modelPool.switch(key);
1232
- 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);
1233
1622
  this.config = { ...this.config, llm: nextLlm };
1234
1623
  this.persistActiveModel(entry, nextLlm);
1235
1624
  return entry;
@@ -1245,7 +1634,11 @@ export class Engine {
1245
1634
  */
1246
1635
  persistActiveModel(entry, llm) {
1247
1636
  try {
1248
- 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");
1249
1642
  const file = join(dir, "settings.json");
1250
1643
  mkdirSync(dir, { recursive: true });
1251
1644
  let existing = {};
@@ -1303,6 +1696,83 @@ export class Engine {
1303
1696
  getConfig() {
1304
1697
  return this.config;
1305
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
+ }
1306
1776
  /**
1307
1777
  * Inject context into a session's transcript without triggering a LLM turn.
1308
1778
  * The injected content appears as an assistant message so the LLM can see it
@@ -1332,7 +1802,6 @@ export class Engine {
1332
1802
  if (!this.lastContextManager || !sessionId) {
1333
1803
  return { before: 0, after: 0, strategy: "none (no active session)" };
1334
1804
  }
1335
- const { estimateTokens } = require("../context/compaction.js");
1336
1805
  const sourceMessages = this.compactedMessagesBySession.get(sessionId) ??
1337
1806
  this.sessionManager.resume(sessionId).transcript.toMessages();
1338
1807
  const before = estimateTokens(sourceMessages);
@@ -1477,22 +1946,128 @@ export class Engine {
1477
1946
  this.planMode = value;
1478
1947
  }
1479
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
+ }
1480
1986
  /**
1481
1987
  * Sub-agent role registry for the given cwd, memoized per-cwd so the
1482
1988
  * directory is read once rather than every turn. A new cwd (e.g. via
1483
1989
  * run({ cwd })) reloads.
1484
1990
  */
1485
1991
  getAgentDefinitions(cwd) {
1486
- if (this.agentDefsCache?.cwd !== cwd) {
1487
- this.agentDefsCache = { cwd, reg: loadAgentDefinitionsForCwd(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
+ };
1488
2005
  }
1489
2006
  return this.agentDefsCache.reg;
1490
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
+ }
1491
2050
  /**
1492
2051
  * Build a base ToolContext for this Engine. Used by run() (which then
1493
2052
  * overlays turn-specific fields like sandbox and subAgentSpawner) and
1494
2053
  * by tests that want a ToolContext without a full run() cycle.
1495
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
+ }
1496
2071
  buildToolContext() {
1497
2072
  const { disabledSkills, disabledPlugins } = this.readDisabledLists();
1498
2073
  return {
@@ -1525,10 +2100,20 @@ export class Engine {
1525
2100
  return { disabledSkills: [], disabledPlugins: [] };
1526
2101
  }
1527
2102
  try {
1528
- const settings = this.getSettingsManager().get();
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;
1529
2114
  return {
1530
- disabledSkills: settings.disabledSkills ?? [],
1531
- disabledPlugins: settings.disabledPlugins ?? [],
2115
+ disabledSkills: effectiveDisabledList(settings.disabledSkills ?? [], overrides?.skills),
2116
+ disabledPlugins: effectiveDisabledList(settings.disabledPlugins ?? [], overrides?.plugins),
1532
2117
  };
1533
2118
  }
1534
2119
  catch {