@cjhyy/code-shell-core 0.5.0-rc.1 → 0.6.0-rc.1

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 (528) hide show
  1. package/README.md +13 -10
  2. package/dist/agent/agent-definition-registry.d.ts +21 -3
  3. package/dist/agent/agent-definition-registry.js +49 -18
  4. package/dist/agent/agent-definition.d.ts +32 -0
  5. package/dist/agent/agent-definition.js +49 -4
  6. package/dist/arena/arena.js +11 -12
  7. package/dist/arena/context/context-tools.js +47 -6
  8. package/dist/arena/context/within-root.d.ts +7 -0
  9. package/dist/arena/context/within-root.js +15 -0
  10. package/dist/arena/iterate/phases/argue.js +8 -2
  11. package/dist/arena/iterate/phases/revise.js +1 -1
  12. package/dist/arena/iterate/phases/tournament.js +3 -3
  13. package/dist/arena/ledger.js +9 -1
  14. package/dist/arena/phases/adjudication.js +1 -4
  15. package/dist/arena/phases/build-consensus.js +1 -4
  16. package/dist/arena/phases/cross-review.js +2 -8
  17. package/dist/arena/phases/debate-rounds.js +1 -4
  18. package/dist/arena/phases/participant-research.js +1 -4
  19. package/dist/arena/phases/planning-detail-expansion.js +8 -10
  20. package/dist/arena/planner.js +0 -1
  21. package/dist/arena/providers/docs.d.ts +1 -0
  22. package/dist/arena/providers/docs.js +5 -2
  23. package/dist/arena/providers/repo.d.ts +1 -0
  24. package/dist/arena/providers/repo.js +5 -2
  25. package/dist/arena/strategies/utils.d.ts +1 -4
  26. package/dist/arena/strategies/utils.js +7 -51
  27. package/dist/arena/transitions.d.ts +7 -2
  28. package/dist/arena/transitions.js +9 -4
  29. package/dist/arena/types.d.ts +8 -2
  30. package/dist/automation/cron-expr.d.ts +41 -0
  31. package/dist/automation/cron-expr.js +169 -0
  32. package/dist/automation/index.d.ts +41 -0
  33. package/dist/automation/index.js +39 -0
  34. package/dist/automation/runner.d.ts +84 -0
  35. package/dist/automation/runner.js +92 -0
  36. package/dist/automation/scheduler.d.ts +194 -0
  37. package/dist/automation/scheduler.js +623 -0
  38. package/dist/automation/store.d.ts +39 -0
  39. package/dist/automation/store.js +122 -0
  40. package/dist/automation/write-policy.d.ts +34 -0
  41. package/dist/automation/write-policy.js +113 -0
  42. package/dist/automation/write-run.d.ts +51 -0
  43. package/dist/automation/write-run.js +38 -0
  44. package/dist/capability-control/disabled-lists.d.ts +25 -0
  45. package/dist/capability-control/disabled-lists.js +57 -0
  46. package/dist/capability-control/index.d.ts +5 -0
  47. package/dist/capability-control/index.js +3 -0
  48. package/dist/capability-control/overlay.d.ts +64 -0
  49. package/dist/capability-control/overlay.js +116 -0
  50. package/dist/capability-control/project.d.ts +65 -0
  51. package/dist/capability-control/project.js +147 -0
  52. package/dist/capability-control/service.d.ts +70 -0
  53. package/dist/capability-control/service.js +147 -0
  54. package/dist/capability-control/types.d.ts +62 -0
  55. package/dist/capability-control/types.js +17 -0
  56. package/dist/cc-orchestrator/agent-adapter.d.ts +50 -0
  57. package/dist/cc-orchestrator/agent-adapter.js +133 -0
  58. package/dist/cc-orchestrator/cc-capability.d.ts +19 -0
  59. package/dist/cc-orchestrator/cc-capability.js +53 -0
  60. package/dist/cc-orchestrator/codex-session-discovery.d.ts +24 -0
  61. package/dist/cc-orchestrator/codex-session-discovery.js +191 -0
  62. package/dist/cc-orchestrator/codex-session-history.d.ts +25 -0
  63. package/dist/cc-orchestrator/codex-session-history.js +187 -0
  64. package/dist/cc-orchestrator/external-agent-changes.d.ts +19 -0
  65. package/dist/cc-orchestrator/external-agent-changes.js +214 -0
  66. package/dist/cc-orchestrator/external-agent-driver.d.ts +18 -0
  67. package/dist/cc-orchestrator/external-agent-driver.js +69 -0
  68. package/dist/cc-orchestrator/index.d.ts +8 -0
  69. package/dist/cc-orchestrator/index.js +8 -0
  70. package/dist/cc-orchestrator/relevance-judge.d.ts +15 -0
  71. package/dist/cc-orchestrator/relevance-judge.js +29 -0
  72. package/dist/cc-orchestrator/session-discovery.d.ts +46 -0
  73. package/dist/cc-orchestrator/session-discovery.js +125 -0
  74. package/dist/cc-orchestrator/session-history.d.ts +19 -0
  75. package/dist/cc-orchestrator/session-history.js +67 -0
  76. package/dist/cli/agent-server-stdio.d.ts +26 -1
  77. package/dist/cli/agent-server-stdio.js +221 -28
  78. package/dist/cli/agent-server-tcp.d.ts +20 -0
  79. package/dist/cli/agent-server-tcp.js +134 -0
  80. package/dist/cli/graceful-shutdown.d.ts +27 -0
  81. package/dist/cli/graceful-shutdown.js +30 -0
  82. package/dist/context/compaction.d.ts +56 -0
  83. package/dist/context/compaction.js +209 -15
  84. package/dist/context/manager.d.ts +0 -20
  85. package/dist/context/manager.js +25 -41
  86. package/dist/cost-tracker.js +5 -61
  87. package/dist/credentials/cipher.d.ts +49 -0
  88. package/dist/credentials/cipher.js +45 -0
  89. package/dist/credentials/cookie-jar.d.ts +24 -0
  90. package/dist/credentials/cookie-jar.js +40 -0
  91. package/dist/credentials/index.d.ts +7 -0
  92. package/dist/credentials/index.js +5 -0
  93. package/dist/credentials/inject-credential-tool.d.ts +20 -0
  94. package/dist/credentials/inject-credential-tool.js +130 -0
  95. package/dist/credentials/store.d.ts +72 -0
  96. package/dist/credentials/store.js +184 -0
  97. package/dist/credentials/types.d.ts +56 -0
  98. package/dist/credentials/use-credential-tool.d.ts +29 -0
  99. package/dist/credentials/use-credential-tool.js +205 -0
  100. package/dist/credentials/use-gate.d.ts +56 -0
  101. package/dist/credentials/use-gate.js +52 -0
  102. package/dist/cron/cron-runtime.d.ts +2 -0
  103. package/dist/cron/cron-runtime.js +2 -0
  104. package/dist/cron/cron-store.d.ts +2 -0
  105. package/dist/cron/cron-store.js +2 -0
  106. package/dist/cron/scheduler.d.ts +5 -32
  107. package/dist/cron/scheduler.js +5 -113
  108. package/dist/data/model-metadata.d.ts +77 -0
  109. package/dist/data/model-metadata.js +56 -0
  110. package/dist/data/model-metadata.json +216 -0
  111. package/dist/data/openrouter-models.d.ts +18 -7
  112. package/dist/data/openrouter-models.js +35 -8
  113. package/dist/data/openrouter-sync.js +1 -1
  114. package/dist/engine/aux-key.d.ts +10 -0
  115. package/dist/engine/aux-key.js +11 -0
  116. package/dist/engine/dynamic-tool-defs.d.ts +19 -0
  117. package/dist/engine/dynamic-tool-defs.js +36 -0
  118. package/dist/engine/engine.d.ts +431 -108
  119. package/dist/engine/engine.js +1630 -279
  120. package/dist/engine/friendly-error.d.ts +18 -0
  121. package/dist/engine/friendly-error.js +63 -0
  122. package/dist/engine/goal.d.ts +187 -0
  123. package/dist/engine/goal.js +196 -0
  124. package/dist/engine/image-compression.d.ts +81 -0
  125. package/dist/engine/image-compression.js +186 -0
  126. package/dist/engine/image-policy.d.ts +165 -0
  127. package/dist/engine/image-policy.js +189 -0
  128. package/dist/engine/model-connections-pool.d.ts +17 -0
  129. package/dist/engine/model-connections-pool.js +66 -0
  130. package/dist/engine/model-facade.d.ts +10 -0
  131. package/dist/engine/model-facade.js +25 -0
  132. package/dist/engine/patch-orphaned-tools.js +3 -0
  133. package/dist/engine/reactive-threshold.d.ts +13 -0
  134. package/dist/engine/reactive-threshold.js +17 -0
  135. package/dist/engine/resolve-llm-config.d.ts +16 -0
  136. package/dist/engine/resolve-llm-config.js +44 -0
  137. package/dist/engine/runtime.js +9 -1
  138. package/dist/engine/sandbox-cache-key.d.ts +10 -0
  139. package/dist/engine/sandbox-cache-key.js +9 -0
  140. package/dist/engine/sandbox-config.d.ts +31 -0
  141. package/dist/engine/sandbox-config.js +38 -0
  142. package/dist/engine/session-title.d.ts +13 -0
  143. package/dist/engine/session-title.js +34 -0
  144. package/dist/engine/session-usage.d.ts +19 -0
  145. package/dist/engine/session-usage.js +25 -0
  146. package/dist/engine/steer-queue.d.ts +32 -0
  147. package/dist/engine/steer-queue.js +26 -0
  148. package/dist/engine/streaming-tool-queue.d.ts +12 -0
  149. package/dist/engine/streaming-tool-queue.js +50 -10
  150. package/dist/engine/turn-loop.d.ts +94 -9
  151. package/dist/engine/turn-loop.js +775 -342
  152. package/dist/engine/types.d.ts +175 -0
  153. package/dist/engine/types.js +13 -0
  154. package/dist/external-agents/config.d.ts +2 -0
  155. package/dist/external-agents/config.js +15 -0
  156. package/dist/external-agents/types.d.ts +31 -0
  157. package/dist/external-agents/types.js +1 -0
  158. package/dist/git/parse-log.d.ts +13 -0
  159. package/dist/git/parse-log.js +21 -0
  160. package/dist/git/utils.d.ts +8 -6
  161. package/dist/git/utils.js +62 -26
  162. package/dist/git/worktree.d.ts +48 -0
  163. package/dist/git/worktree.js +111 -20
  164. package/dist/hooks/events.d.ts +23 -1
  165. package/dist/hooks/goal-stop-hook.d.ts +58 -0
  166. package/dist/hooks/goal-stop-hook.js +279 -0
  167. package/dist/hooks/hook-output.d.ts +33 -0
  168. package/dist/hooks/hook-output.js +79 -0
  169. package/dist/hooks/registry.d.ts +15 -0
  170. package/dist/hooks/registry.js +57 -1
  171. package/dist/hooks/shell-runner.d.ts +2 -0
  172. package/dist/hooks/shell-runner.js +48 -18
  173. package/dist/index.d.ts +79 -11
  174. package/dist/index.js +89 -6
  175. package/dist/llm/capabilities/param-specs.d.ts +14 -0
  176. package/dist/llm/capabilities/param-specs.js +62 -0
  177. package/dist/llm/capabilities/reasoning-control.d.ts +24 -0
  178. package/dist/llm/capabilities/reasoning-control.js +38 -0
  179. package/dist/llm/capabilities/rules.js +37 -3
  180. package/dist/llm/capabilities/types.d.ts +32 -2
  181. package/dist/llm/clamp-max-tokens.d.ts +11 -0
  182. package/dist/llm/clamp-max-tokens.js +17 -0
  183. package/dist/llm/client-base.d.ts +69 -5
  184. package/dist/llm/client-base.js +185 -25
  185. package/dist/llm/client-factory.d.ts +3 -3
  186. package/dist/llm/client-factory.js +2 -2
  187. package/dist/llm/model-cache.js +4 -2
  188. package/dist/llm/model-pool.d.ts +29 -11
  189. package/dist/llm/model-pool.js +36 -20
  190. package/dist/llm/provider-auth.d.ts +41 -0
  191. package/dist/llm/provider-auth.js +76 -0
  192. package/dist/llm/provider-catalog.d.ts +6 -2
  193. package/dist/llm/providers/anthropic.d.ts +32 -2
  194. package/dist/llm/providers/anthropic.js +207 -18
  195. package/dist/llm/providers/openai.d.ts +47 -2
  196. package/dist/llm/providers/openai.js +478 -71
  197. package/dist/llm/reasoning-setting.d.ts +51 -0
  198. package/dist/llm/reasoning-setting.js +40 -0
  199. package/dist/llm/stop-reason.d.ts +13 -0
  200. package/dist/llm/stop-reason.js +21 -0
  201. package/dist/llm/stream-watchdog.js +5 -1
  202. package/dist/llm/strip-vision.d.ts +32 -0
  203. package/dist/llm/strip-vision.js +94 -0
  204. package/dist/llm/token-counter.js +9 -2
  205. package/dist/llm/types.d.ts +9 -6
  206. package/dist/logging/logger.js +18 -4
  207. package/dist/logging/sanitize-messages.d.ts +10 -0
  208. package/dist/logging/sanitize-messages.js +112 -0
  209. package/dist/lsp/client.js +28 -8
  210. package/dist/lsp/manager.d.ts +1 -1
  211. package/dist/lsp/manager.js +42 -11
  212. package/dist/lsp/root-path.d.ts +9 -0
  213. package/dist/lsp/root-path.js +12 -0
  214. package/dist/model-catalog/builtin.d.ts +12 -0
  215. package/dist/model-catalog/builtin.js +407 -0
  216. package/dist/model-catalog/gen-connections.d.ts +20 -0
  217. package/dist/model-catalog/gen-connections.js +28 -0
  218. package/dist/model-catalog/index.d.ts +41 -0
  219. package/dist/model-catalog/index.js +90 -0
  220. package/dist/model-catalog/params.d.ts +20 -0
  221. package/dist/model-catalog/params.js +45 -0
  222. package/dist/model-catalog/resolve.d.ts +48 -0
  223. package/dist/model-catalog/resolve.js +33 -0
  224. package/dist/model-catalog/save-entry.d.ts +32 -0
  225. package/dist/model-catalog/save-entry.js +104 -0
  226. package/dist/model-catalog/types.d.ts +561 -0
  227. package/dist/model-catalog/types.js +93 -0
  228. package/dist/model-catalog/upsert.d.ts +9 -0
  229. package/dist/model-catalog/upsert.js +8 -0
  230. package/dist/onboarding.d.ts +12 -82
  231. package/dist/onboarding.js +75 -333
  232. package/dist/plugins/gitOps.d.ts +53 -0
  233. package/dist/plugins/gitOps.js +130 -5
  234. package/dist/plugins/installer/checkUpdate.d.ts +16 -0
  235. package/dist/plugins/installer/checkUpdate.js +32 -0
  236. package/dist/plugins/installer/codex/convertAgents.d.ts +7 -0
  237. package/dist/plugins/installer/codex/convertAgents.js +55 -0
  238. package/dist/plugins/installer/codex/convertCommands.d.ts +19 -0
  239. package/dist/plugins/installer/codex/convertCommands.js +46 -0
  240. package/dist/plugins/installer/codex/convertMcp.d.ts +11 -0
  241. package/dist/plugins/installer/codex/convertMcp.js +85 -0
  242. package/dist/plugins/installer/codex/convertSkills.d.ts +12 -0
  243. package/dist/plugins/installer/codex/convertSkills.js +33 -0
  244. package/dist/plugins/installer/detectFormat.d.ts +2 -0
  245. package/dist/plugins/installer/detectFormat.js +6 -0
  246. package/dist/plugins/installer/install.d.ts +7 -0
  247. package/dist/plugins/installer/install.js +123 -0
  248. package/dist/plugins/installer/installFromArchive.d.ts +43 -0
  249. package/dist/plugins/installer/installFromArchive.js +134 -0
  250. package/dist/plugins/installer/installFromSource.d.ts +13 -0
  251. package/dist/plugins/installer/installFromSource.js +51 -0
  252. package/dist/plugins/installer/list.d.ts +9 -0
  253. package/dist/plugins/installer/list.js +30 -0
  254. package/dist/plugins/installer/loadPluginAgents.d.ts +3 -0
  255. package/dist/plugins/installer/loadPluginAgents.js +27 -0
  256. package/dist/plugins/installer/loadPluginMcp.d.ts +14 -0
  257. package/dist/plugins/installer/loadPluginMcp.js +107 -0
  258. package/dist/plugins/installer/parseSource.d.ts +20 -0
  259. package/dist/plugins/installer/parseSource.js +74 -0
  260. package/dist/plugins/installer/paths.d.ts +5 -0
  261. package/dist/plugins/installer/paths.js +25 -0
  262. package/dist/plugins/installer/pruneDisabled.d.ts +24 -0
  263. package/dist/plugins/installer/pruneDisabled.js +73 -0
  264. package/dist/plugins/installer/types.d.ts +52 -0
  265. package/dist/plugins/installer/types.js +28 -0
  266. package/dist/plugins/installer/uninstall.d.ts +2 -0
  267. package/dist/plugins/installer/uninstall.js +18 -0
  268. package/dist/plugins/installer/unzip.d.ts +14 -0
  269. package/dist/plugins/installer/unzip.js +82 -0
  270. package/dist/plugins/installer/update.d.ts +28 -0
  271. package/dist/plugins/installer/update.js +97 -0
  272. package/dist/plugins/loadPluginHooks.d.ts +54 -1
  273. package/dist/plugins/loadPluginHooks.js +83 -1
  274. package/dist/plugins/marketplaceManager.d.ts +15 -1
  275. package/dist/plugins/marketplaceManager.js +58 -4
  276. package/dist/plugins/pluginCommandHook.js +34 -14
  277. package/dist/plugins/pluginContent.d.ts +30 -0
  278. package/dist/plugins/pluginContent.js +83 -0
  279. package/dist/plugins/pluginInstaller.d.ts +34 -0
  280. package/dist/plugins/pluginInstaller.js +168 -35
  281. package/dist/plugins/schemas.js +41 -6
  282. package/dist/plugins/types.d.ts +10 -0
  283. package/dist/preset/index.d.ts +7 -1
  284. package/dist/preset/index.js +112 -6
  285. package/dist/product/types.d.ts +1 -1
  286. package/dist/prompt/composer.d.ts +31 -0
  287. package/dist/prompt/composer.js +95 -25
  288. package/dist/prompt/instruction-scanner.js +5 -3
  289. package/dist/prompt/section-loader.js +1 -0
  290. package/dist/prompt/sections/base.md +1 -0
  291. package/dist/prompt/sections/browser.md +10 -0
  292. package/dist/prompt/sections/coding.md +4 -0
  293. package/dist/prompt/sections/orchestration.md +8 -0
  294. package/dist/protocol/chat-session-manager.d.ts +15 -2
  295. package/dist/protocol/chat-session-manager.js +46 -0
  296. package/dist/protocol/chat-session.d.ts +82 -0
  297. package/dist/protocol/chat-session.js +121 -1
  298. package/dist/protocol/client.d.ts +27 -0
  299. package/dist/protocol/client.js +38 -0
  300. package/dist/protocol/redact.d.ts +50 -0
  301. package/dist/protocol/redact.js +71 -0
  302. package/dist/protocol/server.d.ts +153 -2
  303. package/dist/protocol/server.js +807 -39
  304. package/dist/protocol/tcp-transport.d.ts +45 -0
  305. package/dist/protocol/tcp-transport.js +74 -0
  306. package/dist/protocol/transport.js +3 -2
  307. package/dist/protocol/types.d.ts +69 -0
  308. package/dist/protocol/types.js +22 -0
  309. package/dist/quota/credentials.d.ts +3 -0
  310. package/dist/quota/credentials.js +80 -0
  311. package/dist/quota/index.d.ts +36 -0
  312. package/dist/quota/index.js +155 -0
  313. package/dist/quota/types.d.ts +48 -0
  314. package/dist/quota/types.js +13 -0
  315. package/dist/remote/bridge.d.ts +11 -2
  316. package/dist/remote/bridge.js +61 -33
  317. package/dist/review/review-prompt.d.ts +28 -0
  318. package/dist/review/review-prompt.js +81 -0
  319. package/dist/run/ArtifactTracker.js +5 -4
  320. package/dist/run/EngineRunner.d.ts +27 -1
  321. package/dist/run/EngineRunner.js +46 -8
  322. package/dist/run/FileRunStore.js +40 -11
  323. package/dist/run/RunApprovalBackend.js +25 -5
  324. package/dist/run/RunLock.d.ts +28 -2
  325. package/dist/run/RunLock.js +49 -7
  326. package/dist/run/RunManager.d.ts +12 -0
  327. package/dist/run/RunManager.js +86 -3
  328. package/dist/run/RunQueue.d.ts +1 -0
  329. package/dist/run/RunQueue.js +8 -2
  330. package/dist/run/factory.d.ts +8 -1
  331. package/dist/run/factory.js +1 -0
  332. package/dist/run/index.d.ts +2 -2
  333. package/dist/run/index.js +1 -1
  334. package/dist/run/redirect-target.d.ts +7 -0
  335. package/dist/run/redirect-target.js +13 -0
  336. package/dist/run/types.js +1 -1
  337. package/dist/runtime/background-shell.d.ts +139 -0
  338. package/dist/runtime/background-shell.js +495 -0
  339. package/dist/runtime/output-clean.d.ts +24 -0
  340. package/dist/runtime/output-clean.js +41 -0
  341. package/dist/runtime/ring-file.d.ts +64 -0
  342. package/dist/runtime/ring-file.js +174 -0
  343. package/dist/runtime/safe-spawn.js +15 -34
  344. package/dist/runtime/spawn-common.d.ts +148 -0
  345. package/dist/runtime/spawn-common.js +303 -0
  346. package/dist/runtime/truncate-output.d.ts +22 -0
  347. package/dist/runtime/truncate-output.js +49 -0
  348. package/dist/runtime/utf8-cut.d.ts +11 -0
  349. package/dist/runtime/utf8-cut.js +23 -0
  350. package/dist/services/auto-dream.d.ts +4 -0
  351. package/dist/services/auto-dream.js +26 -26
  352. package/dist/services/browser-open.d.ts +13 -0
  353. package/dist/services/browser-open.js +16 -0
  354. package/dist/services/diagnostics.d.ts +1 -2
  355. package/dist/services/diagnostics.js +12 -7
  356. package/dist/services/dream-consolidation.d.ts +57 -0
  357. package/dist/services/dream-consolidation.js +151 -0
  358. package/dist/services/extract-memories.d.ts +14 -1
  359. package/dist/services/extract-memories.js +45 -6
  360. package/dist/services/memory-orchestrator.d.ts +21 -0
  361. package/dist/services/memory-orchestrator.js +125 -32
  362. package/dist/services/notifier.d.ts +8 -0
  363. package/dist/services/notifier.js +39 -25
  364. package/dist/services/oauth.js +5 -7
  365. package/dist/services/session-memory-sort.d.ts +8 -0
  366. package/dist/services/session-memory-sort.js +9 -0
  367. package/dist/services/session-memory.js +30 -19
  368. package/dist/session/file-history.d.ts +124 -1
  369. package/dist/session/file-history.js +232 -9
  370. package/dist/session/memory.d.ts +116 -2
  371. package/dist/session/memory.js +250 -28
  372. package/dist/session/session-manager.d.ts +63 -1
  373. package/dist/session/session-manager.js +186 -5
  374. package/dist/session/simple-diff.d.ts +23 -0
  375. package/dist/session/simple-diff.js +84 -0
  376. package/dist/session/transcript.d.ts +25 -1
  377. package/dist/session/transcript.js +35 -2
  378. package/dist/session/undo-target.d.ts +67 -0
  379. package/dist/session/undo-target.js +144 -0
  380. package/dist/settings/disk-defaults.d.ts +42 -0
  381. package/dist/settings/disk-defaults.js +33 -0
  382. package/dist/settings/feature-flags.d.ts +64 -0
  383. package/dist/settings/feature-flags.js +61 -0
  384. package/dist/settings/manager.d.ts +101 -1
  385. package/dist/settings/manager.js +345 -15
  386. package/dist/settings/migrate-config.d.ts +45 -0
  387. package/dist/settings/migrate-config.js +125 -0
  388. package/dist/settings/personalization.d.ts +19 -0
  389. package/dist/settings/personalization.js +7 -0
  390. package/dist/settings/schema-export.d.ts +25 -0
  391. package/dist/settings/schema-export.js +38 -0
  392. package/dist/settings/schema.d.ts +1827 -396
  393. package/dist/settings/schema.js +361 -81
  394. package/dist/skills/scanner.d.ts +9 -0
  395. package/dist/skills/scanner.js +30 -2
  396. package/dist/stt/resolve-transcribe.d.ts +31 -0
  397. package/dist/stt/resolve-transcribe.js +108 -0
  398. package/dist/stt/transcribe.d.ts +51 -0
  399. package/dist/stt/transcribe.js +65 -0
  400. package/dist/tool-system/browser-bridge.d.ts +226 -0
  401. package/dist/tool-system/browser-bridge.js +163 -0
  402. package/dist/tool-system/builtin/add-marketplace.d.ts +12 -0
  403. package/dist/tool-system/builtin/add-marketplace.js +76 -0
  404. package/dist/tool-system/builtin/agent-heartbeat.d.ts +49 -0
  405. package/dist/tool-system/builtin/agent-heartbeat.js +89 -0
  406. package/dist/tool-system/builtin/agent-notifications.d.ts +12 -3
  407. package/dist/tool-system/builtin/agent-notifications.js +9 -3
  408. package/dist/tool-system/builtin/agent-output-file.d.ts +38 -0
  409. package/dist/tool-system/builtin/agent-output-file.js +72 -0
  410. package/dist/tool-system/builtin/agent-registry.d.ts +23 -0
  411. package/dist/tool-system/builtin/agent-registry.js +16 -4
  412. package/dist/tool-system/builtin/agent.d.ts +50 -11
  413. package/dist/tool-system/builtin/agent.js +576 -73
  414. package/dist/tool-system/builtin/apply-patch/applier.js +66 -8
  415. package/dist/tool-system/builtin/apply-patch/backup-targets.d.ts +10 -0
  416. package/dist/tool-system/builtin/apply-patch/backup-targets.js +30 -0
  417. package/dist/tool-system/builtin/apply-patch/index.js +7 -3
  418. package/dist/tool-system/builtin/arena.js +6 -7
  419. package/dist/tool-system/builtin/background-jobs.d.ts +76 -0
  420. package/dist/tool-system/builtin/background-jobs.js +124 -0
  421. package/dist/tool-system/builtin/background-shell-tools.d.ts +20 -0
  422. package/dist/tool-system/builtin/background-shell-tools.js +108 -0
  423. package/dist/tool-system/builtin/background-work.d.ts +67 -0
  424. package/dist/tool-system/builtin/background-work.js +86 -0
  425. package/dist/tool-system/builtin/bash-output-style.d.ts +32 -0
  426. package/dist/tool-system/builtin/bash-output-style.js +40 -0
  427. package/dist/tool-system/builtin/bash.d.ts +5 -2
  428. package/dist/tool-system/builtin/bash.js +98 -62
  429. package/dist/tool-system/builtin/browser-tools.d.ts +33 -0
  430. package/dist/tool-system/builtin/browser-tools.js +312 -0
  431. package/dist/tool-system/builtin/cancel-goal.d.ts +31 -0
  432. package/dist/tool-system/builtin/cancel-goal.js +64 -0
  433. package/dist/tool-system/builtin/check-quota.d.ts +15 -0
  434. package/dist/tool-system/builtin/check-quota.js +34 -0
  435. package/dist/tool-system/builtin/complete-goal.d.ts +25 -0
  436. package/dist/tool-system/builtin/complete-goal.js +45 -0
  437. package/dist/tool-system/builtin/config.js +7 -0
  438. package/dist/tool-system/builtin/cron.d.ts +7 -0
  439. package/dist/tool-system/builtin/cron.js +105 -9
  440. package/dist/tool-system/builtin/drive-claude-code.d.ts +30 -0
  441. package/dist/tool-system/builtin/drive-claude-code.js +157 -0
  442. package/dist/tool-system/builtin/edit-model-catalog.d.ts +3 -0
  443. package/dist/tool-system/builtin/edit-model-catalog.js +94 -0
  444. package/dist/tool-system/builtin/edit.d.ts +2 -1
  445. package/dist/tool-system/builtin/edit.js +21 -12
  446. package/dist/tool-system/builtin/eol.d.ts +29 -0
  447. package/dist/tool-system/builtin/eol.js +37 -0
  448. package/dist/tool-system/builtin/file-cache.d.ts +6 -0
  449. package/dist/tool-system/builtin/file-cache.js +8 -0
  450. package/dist/tool-system/builtin/generate-image.d.ts +60 -0
  451. package/dist/tool-system/builtin/generate-image.js +374 -0
  452. package/dist/tool-system/builtin/generate-video.d.ts +55 -0
  453. package/dist/tool-system/builtin/generate-video.js +361 -0
  454. package/dist/tool-system/builtin/grep.js +6 -2
  455. package/dist/tool-system/builtin/image-providers.d.ts +86 -0
  456. package/dist/tool-system/builtin/image-providers.js +190 -0
  457. package/dist/tool-system/builtin/image-uploader.d.ts +33 -0
  458. package/dist/tool-system/builtin/image-uploader.js +74 -0
  459. package/dist/tool-system/builtin/index.d.ts +24 -1
  460. package/dist/tool-system/builtin/index.js +310 -25
  461. package/dist/tool-system/builtin/mcp-tools.js +23 -3
  462. package/dist/tool-system/builtin/memory.js +45 -7
  463. package/dist/tool-system/builtin/notebook-edit.d.ts +2 -1
  464. package/dist/tool-system/builtin/notebook-edit.js +1 -1
  465. package/dist/tool-system/builtin/powershell.js +4 -1
  466. package/dist/tool-system/builtin/read.d.ts +2 -1
  467. package/dist/tool-system/builtin/read.js +11 -3
  468. package/dist/tool-system/builtin/repl.js +4 -1
  469. package/dist/tool-system/builtin/skill.js +9 -0
  470. package/dist/tool-system/builtin/sleep.js +12 -5
  471. package/dist/tool-system/builtin/task.js +4 -1
  472. package/dist/tool-system/builtin/tool-search.js +25 -7
  473. package/dist/tool-system/builtin/update-automation-memory.d.ts +24 -0
  474. package/dist/tool-system/builtin/update-automation-memory.js +60 -0
  475. package/dist/tool-system/builtin/video-providers.d.ts +154 -0
  476. package/dist/tool-system/builtin/video-providers.js +235 -0
  477. package/dist/tool-system/builtin/view-image.d.ts +18 -0
  478. package/dist/tool-system/builtin/view-image.js +96 -0
  479. package/dist/tool-system/builtin/web-fetch.js +12 -2
  480. package/dist/tool-system/builtin/web-search.d.ts +4 -1
  481. package/dist/tool-system/builtin/web-search.js +57 -10
  482. package/dist/tool-system/builtin/worktree.d.ts +2 -1
  483. package/dist/tool-system/builtin/worktree.js +31 -4
  484. package/dist/tool-system/builtin/write.d.ts +2 -1
  485. package/dist/tool-system/builtin/write.js +1 -1
  486. package/dist/tool-system/context.d.ts +166 -6
  487. package/dist/tool-system/executor.d.ts +3 -2
  488. package/dist/tool-system/executor.js +197 -119
  489. package/dist/tool-system/investigation-guard.d.ts +4 -1
  490. package/dist/tool-system/investigation-guard.js +20 -4
  491. package/dist/tool-system/mcp-manager.d.ts +78 -16
  492. package/dist/tool-system/mcp-manager.js +357 -28
  493. package/dist/tool-system/path-policy.d.ts +99 -0
  494. package/dist/tool-system/path-policy.js +578 -0
  495. package/dist/tool-system/permission.d.ts +36 -1
  496. package/dist/tool-system/permission.js +322 -117
  497. package/dist/tool-system/plan-mode-allowlist.d.ts +32 -0
  498. package/dist/tool-system/plan-mode-allowlist.js +65 -0
  499. package/dist/tool-system/registry.d.ts +1 -0
  500. package/dist/tool-system/registry.js +26 -1
  501. package/dist/tool-system/sandbox/index.d.ts +8 -0
  502. package/dist/tool-system/sandbox/index.js +7 -2
  503. package/dist/tool-system/validate-tool-metadata.d.ts +36 -0
  504. package/dist/tool-system/validate-tool-metadata.js +63 -0
  505. package/dist/tool-system/validation.d.ts +10 -0
  506. package/dist/tool-system/validation.js +16 -3
  507. package/dist/types.d.ts +301 -15
  508. package/dist/updater.js +20 -9
  509. package/dist/utils/envUtils.d.ts +0 -9
  510. package/dist/utils/envUtils.js +3 -28
  511. package/dist/utils/exec.d.ts +40 -0
  512. package/dist/utils/exec.js +144 -0
  513. package/dist/utils/format.js +4 -2
  514. package/dist/utils/json.d.ts +12 -0
  515. package/dist/utils/json.js +92 -0
  516. package/dist/utils/lockfile.js +9 -2
  517. package/dist/utils/theme.d.ts +7 -1
  518. package/dist/utils/theme.js +5 -3
  519. package/dist/utils/toolDisplay.js +0 -1
  520. package/package.json +14 -7
  521. package/dist/agent/coordinator.d.ts +0 -49
  522. package/dist/agent/coordinator.js +0 -77
  523. package/dist/settings/manager.test.js +0 -73
  524. package/dist/tool-system/builtin/remote-trigger.d.ts +0 -6
  525. package/dist/tool-system/builtin/remote-trigger.js +0 -54
  526. package/dist/tool-system/builtin/send-message.d.ts +0 -6
  527. package/dist/tool-system/builtin/send-message.js +0 -47
  528. /package/dist/{settings/manager.test.d.ts → credentials/types.js} +0 -0
@@ -7,57 +7,175 @@ 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 { applyDynamicToolDef } from "./dynamic-tool-defs.js";
11
+ import { getMergedCatalog } from "../model-catalog/index.js";
12
+ import { modelEntriesFromConnections } from "./model-connections-pool.js";
13
+ import { resolveAuxKey } from "./aux-key.js";
14
+ import { foldRunUsage } from "./session-usage.js";
15
+ import { enqueueSteerItem, consumeSteerItems, removeSteerItem, } from "./steer-queue.js";
16
+ import { resolveSandboxConfig } from "./sandbox-config.js";
17
+ import { sandboxCacheKey } from "./sandbox-cache-key.js";
18
+ import { BUILTIN_TOOL_GUARDS } from "../tool-system/builtin/index.js";
19
+ import { asyncAgentRegistry } from "../tool-system/builtin/agent-registry.js";
20
+ import { backgroundShellManager } from "../runtime/background-shell.js";
21
+ import { notificationQueue, buildNotificationMessage, } from "../tool-system/builtin/agent-notifications.js";
10
22
  import { PermissionClassifier, HeadlessApprovalBackend, AutoApprovalBackend, InteractiveApprovalBackend, getInteractiveApprovalBackend, } from "../tool-system/permission.js";
11
23
  import { HookRegistry } from "../hooks/registry.js";
12
24
  import { wrapHookMessages } from "../hooks/inject.js";
25
+ import { createGoalStopHook } from "../hooks/goal-stop-hook.js";
26
+ import { normalizeGoal, resolveGoalSetAt, resolveMaxTurns, resolveMaxStopBlocks, } from "./goal.js";
13
27
  import { loadPluginHooks } from "../plugins/loadPluginHooks.js";
28
+ import { pluginAgentDirs } from "../plugins/installer/loadPluginAgents.js";
14
29
  import { patchOrphanedToolUses } from "./patch-orphaned-tools.js";
15
30
  import { runShellHook, shellHookMatches } from "../hooks/shell-runner.js";
16
31
  import { ContextManager } from "../context/manager.js";
32
+ import { estimateTokens, clampContextRatios as clampContextRatiosImpl, } from "../context/compaction.js";
33
+ import { PLAN_MODE_ALLOWED_TOOLS } from "../tool-system/plan-mode-allowlist.js";
17
34
  import { PromptComposer } from "../prompt/composer.js";
18
35
  import { SessionManager } from "../session/session-manager.js";
19
36
  import { ModelFacade } from "./model-facade.js";
20
- import { logger, setCurrentSid, runWithSid } from "../logging/logger.js";
37
+ import { logger, setCurrentSid, runWithSid, getCurrentSid } from "../logging/logger.js";
21
38
  import { recordSessionStart, recordSessionEnd } from "../logging/session-recorder.js";
22
39
  import { sanitizeContent, sanitizeTaskString } from "../logging/sanitize-messages.js";
23
40
  import { TurnLoop } from "./turn-loop.js";
24
41
  import { MCPManager } from "../tool-system/mcp-manager.js";
25
- import { SettingsManager } from "../settings/manager.js";
42
+ import { SettingsManager, userHome } from "../settings/manager.js";
43
+ import { CredentialStore } from "../credentials/store.js";
44
+ import { isFeatureEnabled, resolveFeatureFlags, } from "../settings/feature-flags.js";
45
+ import { effectiveDisabledList, effectiveBuiltinLists, } from "../capability-control/overlay.js";
46
+ import { computeEffectiveDisabledLists } from "../capability-control/disabled-lists.js";
26
47
  import { FileHistory } from "../session/file-history.js";
27
- import { defaultSandboxConfig, resolveSandboxBackend, } from "../tool-system/sandbox/index.js";
48
+ import { patchBackupTargets } from "../tool-system/builtin/apply-patch/backup-targets.js";
49
+ import { resolveSandboxBackend, } from "../tool-system/sandbox/index.js";
28
50
  import { resolveAgentPreset, resolveBuiltinToolNames, } from "../preset/index.js";
29
51
  import { ModelPool } from "../llm/model-pool.js";
30
52
  import { AgentDefinitionRegistry } from "../agent/agent-definition-registry.js";
31
- import { ProviderCatalog } from "../llm/provider-catalog.js";
32
53
  import { defaultCacheDir } from "../llm/model-cache.js";
33
54
  import { detectProviderFromApiKey, buildModelPool, } from "../onboarding.js";
34
55
  import { detectPastedNoise } from "../utils/task-sanitizer.js";
35
56
  import { parseTaskWithImages, } from "./parse-task.js";
57
+ import { enforceImagePolicy, byteLengthFromBase64, dropOversizedImages, collectAttachedImagePaths, } from "./image-policy.js";
58
+ import { tryCompressImages } from "./image-compression.js";
59
+ import { buildSessionTitle } from "./session-title.js";
36
60
  import { capabilitiesFor } from "../llm/capabilities/index.js";
37
61
  import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
38
- import { join } from "node:path";
39
- import { homedir } from "node:os";
40
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
62
+ import { runDreamConsolidation } from "../services/dream-consolidation.js";
63
+ import { join, isAbsolute } from "node:path";
64
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
65
+ /**
66
+ * Build ScanOptions.compatFileNames from the user's instruction compat toggles.
67
+ * Primary file name stays hard-wired to CODESHELL.md (not exposed). Turning a
68
+ * compat flag off only drops the same-named .md (CLAUDE.md / AGENTS.md); the
69
+ * .claude/ subdir, *.local.md and rules/ are intentionally NOT linked.
70
+ * undefined (instructions omitted) means both stay on — backward compatible.
71
+ */
72
+ export function compatFileNamesFrom(instructions) {
73
+ const names = [];
74
+ if (instructions?.compatClaude !== false)
75
+ names.push("CLAUDE.md");
76
+ if (instructions?.compatCodex !== false)
77
+ names.push("AGENTS.md");
78
+ return names;
79
+ }
80
+ /**
81
+ * True when two LLMConfigs name the SAME client identity — i.e. building a
82
+ * client from either would talk to the same model on the same endpoint with the
83
+ * same shaping. Used by resolveAuxClient to de-dup the aux client against the
84
+ * active model WITHOUT collapsing two distinct pool keys that merely share a
85
+ * `model` NAME but differ in reasoning/maxTokens/baseUrl/provider. Compares the
86
+ * fields that actually change request behavior; apiKey is intentionally NOT
87
+ * compared (two keys with the same endpoint+model but different credentials
88
+ * still produce equivalent aux work and don't warrant a second client). The
89
+ * reasoning object is compared by normalized JSON since it's a small
90
+ * discriminated union.
91
+ */
92
+ function sameLlmIdentity(a, b) {
93
+ return (a.model === b.model &&
94
+ (a.baseUrl ?? undefined) === (b.baseUrl ?? undefined) &&
95
+ (a.provider ?? undefined) === (b.provider ?? undefined) &&
96
+ (a.providerKind ?? undefined) === (b.providerKind ?? undefined) &&
97
+ (a.maxTokens ?? undefined) === (b.maxTokens ?? undefined) &&
98
+ JSON.stringify(a.reasoning ?? null) === JSON.stringify(b.reasoning ?? null));
99
+ }
100
+ // Re-export the config hot-reload patch builder from here so the protocol
101
+ // server (and tests) can import it alongside Engine without reaching into the
102
+ // settings/ subtree directly. The implementation lives in settings/ to keep
103
+ // engine.ts from growing and to sit next to personalizationFrom it composes.
104
+ export { diskDefaultsFrom } from "../settings/disk-defaults.js";
41
105
  /**
42
106
  * Resolve the LLM config for a spawned child Engine.
43
- * - `modelKey` set + present in pool → that model's config (over parent base).
107
+ * - `modelKey` set + present in pool → that model's config (pure entry-derived
108
+ * identity; the parent's llm is NOT consulted).
44
109
  * - otherwise (no key, no pool, or key miss) → the parent's llm unchanged.
45
110
  * Key miss is a soft fallback, NOT an error: a stale agent definition must not
46
111
  * crash the spawn.
112
+ *
113
+ * ClientDefaults (temperature/timeout/etc.) are inherited from the parent
114
+ * Engine directly via EngineConfig.clientDefaults — they do not flow through
115
+ * this helper because they're not part of LLMConfig anymore.
47
116
  */
48
117
  export function resolveChildLlm(modelKey, pool, parentLlm) {
49
118
  if (modelKey && pool?.has(modelKey)) {
50
- const resolved = pool.resolveLLMConfig(modelKey, parentLlm);
119
+ const resolved = pool.resolveLLMConfig(modelKey);
51
120
  if (resolved)
52
121
  return resolved;
53
122
  }
54
123
  return parentLlm;
55
124
  }
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`);
125
+ /**
126
+ * Load reusable sub-agent role definitions, merging:
127
+ * 1. project-level <cwd>/.code-shell/agents/*.md (ships built-ins)
128
+ * 2. user-level ~/.code-shell/agents/*.md (user wins on name)
129
+ * Names in `disabledAgents` are filtered out so the LLM never sees them.
130
+ */
131
+ /**
132
+ * Resolve the working directory for a run. Precedence:
133
+ * options.cwd > resumed session's state.cwd > config.cwd > process.cwd()
134
+ *
135
+ * The session-cwd tier is what stops a project-bound session from being
136
+ * resumed against the wrong directory: when a host omits options.cwd (e.g. its
137
+ * sidebar repo selection drifted to null), the session's own recorded cwd is
138
+ * recovered so the engine still loads THAT project's agents/settings/memory,
139
+ * not whatever process.cwd() happens to be. Pure so the precedence is testable
140
+ * without standing up an Engine.
141
+ */
142
+ export function resolveRunCwd(args) {
143
+ return args.optionCwd ?? args.sessionCwd ?? args.configCwd ?? args.processCwd;
144
+ }
145
+ export function loadAgentDefinitionsForCwd(cwd, disabledAgents = [], disabledPlugins = []) {
146
+ // userHome() (not raw homedir(), which bun caches at process start and never
147
+ // re-reads) so the user-agents dir honors a test's process.env.HOME override
148
+ // and stays consistent with the rest of the codebase's home resolution.
149
+ const home = userHome();
150
+ // Increasing priority; loadFromDirs is last-dir-wins. ORDER ENCODES POLICY:
151
+ // user (cross-project personal default, lowest) → plugins (reusable baseline)
152
+ // → project (highest). A repo's in-tree agent therefore overrides a same-named
153
+ // user agent. This REVERSES the previous user>project behavior (spec §7.2);
154
+ // the descriptor's shadowedSources surfaces the override so the UI can warn.
155
+ return AgentDefinitionRegistry.loadFromDirs([
156
+ { dir: `${home}/.code-shell/agents`, source: "user" },
157
+ ...pluginAgentDirs(disabledPlugins),
158
+ // No project context (no-project bucket): cwd is "". Skip the project
159
+ // source rather than synthesizing "/.code-shell/agents" at the FS root,
160
+ // which silently resolves to nothing and drops every project-level
161
+ // (built-in) agent from the list.
162
+ ...(cwd ? [{ dir: `${cwd}/.code-shell/agents`, source: "project" }] : []),
163
+ ], disabledAgents);
164
+ }
165
+ const NESTED_AGENT_TOOLS = ["Agent", "AgentStatus", "AgentCancel", "AgentSendInput"];
166
+ /**
167
+ * #7: apply a project's per-turn builtin capability override to a tool list.
168
+ * A builtin marked `off` for the current cwd is HIDDEN from the turn's tool
169
+ * list (matching how skills/plugins/agents `off` apply mid-session). `on` /
170
+ * `inherit` / absent keep the tool — we can't re-add a tool the ctor-frozen
171
+ * registry omitted, but `on` for a tool already present is a no-op. Pure +
172
+ * exported so it's unit-testable without a full run() turn.
173
+ */
174
+ export function applyBuiltinOverrideVisibility(tools, override) {
175
+ if (!override)
176
+ return tools;
177
+ return tools.filter((t) => override[t.name] !== "off");
59
178
  }
60
- const NESTED_AGENT_TOOLS = ["Agent", "AgentStatus", "AgentCancel"];
61
179
  /**
62
180
  * Compute a child Engine's tool scope.
63
181
  * - `allowlist` set → child enabled = allowlist minus nested-agent tools
@@ -78,16 +196,38 @@ export function resolveChildToolScope(allowlist, parentDisabled, parentEnabled)
78
196
  }
79
197
  export class Engine {
80
198
  config;
199
+ // Resolved per-session preset. Set in the ctor; re-resolved by
200
+ // refreshRuntimeConfig on a preset hot-reload so the next-turn PromptComposer
201
+ // picks up the new preset's system prompt / behavior (#2). NOT readonly for
202
+ // that reason. NOTE: the toolRegistry's builtin tool SET is still ctor-frozen
203
+ // and is NOT rebuilt on reload — a preset change that alters the builtin tool
204
+ // set only takes effect on session restart (logged in refreshRuntimeConfig).
81
205
  preset;
82
206
  toolRegistry;
83
207
  hooks;
84
208
  sessionManager;
85
209
  mcpManager;
86
210
  modelPool;
211
+ /**
212
+ * Handles for the settings-sourced hook handlers registered by
213
+ * registerSettingsHooks(), so reloadHooks() can unregister exactly those
214
+ * (and nothing else — plugin hooks, goal/builtin hooks are untouched) before
215
+ * re-registering from fresh settings. Without this, a reload would
216
+ * accumulate duplicate settings-hook handlers that all fire per event.
217
+ */
218
+ settingsHookHandles = [];
219
+ /**
220
+ * Highest config-reload version applied so far. refreshRuntimeConfig drops
221
+ * any payload whose version is <= this, so out-of-order reload deliveries
222
+ * (multiple quick settings saves) can't let an older config clobber a newer
223
+ * one.
224
+ */
225
+ lastAppliedConfigVersion = 0;
87
226
  /** Memoized sub-agent role registry, keyed by the cwd it was loaded from. */
88
227
  agentDefsCache;
89
228
  /** Shared resources supplied at construction (adapter pattern — null when self-constructed). */
90
229
  runtime;
230
+ sandboxCache = new Map();
91
231
  /** Active permission mode for this Engine instance. */
92
232
  permissionMode;
93
233
  /** True when permissionMode === "plan". */
@@ -96,6 +236,13 @@ export class Engine {
96
236
  // don't re-read 6+ JSON files on every /model, /login, etc. The manager
97
237
  // handles its own cache invalidation in saveUserSetting().
98
238
  settingsManager;
239
+ /**
240
+ * Cached auxiliary-task LLM client, keyed by the models[].key it was built
241
+ * from. Background calls (memory extraction, auto-dream) reuse it across
242
+ * runs so we don't redo the provider handshake every session. Invalidated
243
+ * implicitly: a changed auxModelKey produces a different cache key.
244
+ */
245
+ auxClientCache;
99
246
  // Live state from the current/most-recent run, retained for /compact and
100
247
  // for live-mutating PermissionClassifier on permission-mode switch.
101
248
  lastContextManager;
@@ -118,7 +265,39 @@ export class Engine {
118
265
  * LLM response arrives.
119
266
  */
120
267
  ctxOverheadBySid = new Map();
268
+ /**
269
+ * Step-gap steering queue (per sessionId, in-memory). Host pushes user
270
+ * messages here via enqueueSteer while a run is in flight; the turn loop
271
+ * drains it at each step boundary and splices them into the next LLM request
272
+ * WITHOUT aborting (the 不打断 path, vs cancel+resend). Pure memory, forgotten
273
+ * on process exit — same model as the credential session-allow set, so
274
+ * multiple Engines don't interfere and it stays cleanly extractable.
275
+ */
276
+ steerQueueBySid = new Map();
121
277
  activePermission;
278
+ /**
279
+ * The TurnLoop of the in-flight run(), exposed so extendGoalRun() can bump a
280
+ * running goal's turn/budget ceilings mid-run (TODO 3.1). Null when idle.
281
+ */
282
+ activeTurnLoop = null;
283
+ /**
284
+ * The goal-stop hook of the in-flight goal run, exposed so clearGoal() can
285
+ * unregister it mid-run (the closure holds the now-cleared goal and would
286
+ * otherwise keep re-blocking the stop). Null when no goal run is active.
287
+ */
288
+ activeGoalHook = null;
289
+ /**
290
+ * The in-flight run's session bundle, held so clearGoal() can wipe the goal
291
+ * on the SAME instance the run loop is persisting each turn — not a fresh
292
+ * detached copy from resume(). Without this, a mid-run 清除 clears disk, but
293
+ * the still-running loop's next saveState(bundle.state) resurrects the goal
294
+ * (bundle.state.activeGoal was never dropped). A never-completing goal run
295
+ * (judge keeps returning not_met → continueSession) stays live for a long
296
+ * time, so this write-back race is the norm, not an edge case, for such runs.
297
+ * Single-valued like activeTurnLoop — one top-level run per engine at a time.
298
+ * Null when idle; set at run start, cleared in run's finally.
299
+ */
300
+ activeRunSession = null;
122
301
  /** Public accessor so UI/clients can read the resolved per-model window. */
123
302
  get maxContextTokens() {
124
303
  return this.resolveMaxContextTokens();
@@ -127,6 +306,27 @@ export class Engine {
127
306
  const modelEntry = this.modelPool.get();
128
307
  return modelEntry?.maxContextTokens ?? this.config.maxContextTokens ?? 200_000;
129
308
  }
309
+ /**
310
+ * Compaction thresholds from settings.context, clamped so they keep the
311
+ * required ordering floor < compact < summarize even if the user configures
312
+ * conflicting values (e.g. summarize below compact). Falls back to the
313
+ * ContextManager defaults when a field is absent.
314
+ */
315
+ resolveContextRatios() {
316
+ let ctx;
317
+ try {
318
+ // Read from SettingsManager (shared across all hosts) rather than
319
+ // EngineConfig, mirroring readMemoriesConfig — avoids per-host wiring
320
+ // drift (see memory: personalization host wiring).
321
+ ctx = this.getSettingsManager().get().context;
322
+ }
323
+ catch {
324
+ return {};
325
+ }
326
+ if (!ctx)
327
+ return {};
328
+ return clampContextRatiosImpl(ctx);
329
+ }
130
330
  /**
131
331
  * Emit a lifecycle hook with isSubAgent auto-merged into data so handlers
132
332
  * can skip noisy injections for spawned children. All Engine-side hook
@@ -158,13 +358,65 @@ export class Engine {
158
358
  }
159
359
  const entries = settings.hooks ?? [];
160
360
  for (const entry of entries) {
161
- this.hooks.register(entry.event, async (ctx) => {
361
+ // Soft off-switch (settings hooks UI): the entry stays in the file but
362
+ // doesn't register. reloadHooks() re-runs this, so toggling is hot.
363
+ if (entry.disabled === true)
364
+ continue;
365
+ const event = entry.event;
366
+ const handler = async (ctx) => {
162
367
  if (!shellHookMatches(entry, ctx))
163
368
  return {};
164
369
  return runShellHook(entry, ctx);
165
- }, 50, `shell:${entry.event}:${entry.command.slice(0, 32)}`);
370
+ };
371
+ this.hooks.register(event, handler, 50, `shell:${entry.event}:${entry.command.slice(0, 32)}`);
372
+ // Track the (event, handler) so reloadHooks() can unregister exactly
373
+ // these settings-sourced handlers without touching plugin/goal/code hooks.
374
+ this.settingsHookHandles.push({ event, handler });
166
375
  }
167
376
  }
377
+ /**
378
+ * Re-apply settings.hooks onto the live HookRegistry after a settings
379
+ * change (config hot-reload layer 2). Surgical: removes ONLY the
380
+ * settings-sourced handlers this Engine previously registered (tracked in
381
+ * settingsHookHandles by identity) and re-runs registerSettingsHooks() from
382
+ * fresh disk settings. Plugin hooks (registered once at construction at
383
+ * priority 80) and goal/builtin/SDK-config hooks are never touched.
384
+ *
385
+ * The SettingsManager cache is invalidated first so the re-read reflects the
386
+ * latest settings.json on disk (mirrors freshSettings()'s load() semantics).
387
+ * Sub-agents never register settings hooks, so this is a no-op for them.
388
+ */
389
+ reloadHooks() {
390
+ if (this.config.isSubAgent === true)
391
+ return;
392
+ // Drop the previously-registered settings handlers by identity.
393
+ for (const { event, handler } of this.settingsHookHandles) {
394
+ this.hooks.unregister(event, handler);
395
+ }
396
+ this.settingsHookHandles = [];
397
+ // Also drop & re-load plugin hooks. Plugin hooks are registered under
398
+ // `plugin:<name>:<event>` names; without this, disabling a plugin
399
+ // mid-session left its hooks firing until the next new session (asymmetric
400
+ // with settings-hook hot-reload). Re-reading readDisabledLists means a
401
+ // now-disabled plugin's hooks are simply not re-registered.
402
+ this.hooks.removeByNamePrefix("plugin:");
403
+ try {
404
+ const { disabledPlugins, disabledPluginHooks } = this.readDisabledLists();
405
+ loadPluginHooks(this.hooks, disabledPlugins, disabledPluginHooks);
406
+ }
407
+ catch {
408
+ // best-effort — a plugin-load failure must not break settings reload below
409
+ }
410
+ // Force the next get() to re-read disk so reloaded hooks reflect the
411
+ // newest settings.json, not a stale merged cache.
412
+ try {
413
+ this.getSettingsManager().invalidate();
414
+ }
415
+ catch {
416
+ // best-effort; registerSettingsHooks below tolerates read failures
417
+ }
418
+ this.registerSettingsHooks();
419
+ }
168
420
  constructor(config) {
169
421
  this.config = config;
170
422
  // Wire shared runtime (adapter pattern — null when self-constructing).
@@ -173,11 +425,26 @@ export class Engine {
173
425
  this.permissionMode = config.permissionMode ?? "acceptEdits";
174
426
  this.planMode = this.permissionMode === "plan";
175
427
  this.preset = resolveAgentPreset(config.preset);
428
+ // Fold the project's capabilityOverrides.builtin overlay over the global
429
+ // enabled/disabled builtin lists so a project can force-enable a
430
+ // globally-disabled builtin tool or force-disable a globally-enabled one
431
+ // (tri-state). Mirrors readDisabledLists for skills/plugins/agents; no cwd
432
+ // / no overlay → the config lists pass through unchanged (zero regression).
433
+ //
434
+ // #7: this builds the ctor-FROZEN builtin tool SET in the registry — a
435
+ // mid-session project override can't rebuild it. To make a builtin `off`
436
+ // toggle apply mid-session, run()'s per-turn tool-list assembly re-reads
437
+ // readBuiltinOverride(cwd) and HIDES `off` builtins from the turn's tool
438
+ // list (see the allToolDefs filter). `on` here can force-enable a
439
+ // globally-disabled builtin INTO the frozen set at construction; the
440
+ // per-turn path can only hide, not add, so a freshly-`on`'d builtin not in
441
+ // the set needs a session restart to appear.
442
+ const builtinLists = effectiveBuiltinLists(config.enabledBuiltinTools ?? [], config.disabledBuiltinTools ?? [], this.readBuiltinOverride(config.cwd));
176
443
  this.toolRegistry = config.runtime?.toolRegistry ?? new ToolRegistry({
177
444
  builtinTools: resolveBuiltinToolNames({
178
445
  preset: this.preset.name,
179
- enabledBuiltinTools: config.enabledBuiltinTools,
180
- disabledBuiltinTools: config.disabledBuiltinTools,
446
+ enabledBuiltinTools: builtinLists.enabledBuiltinTools,
447
+ disabledBuiltinTools: builtinLists.disabledBuiltinTools,
181
448
  }),
182
449
  });
183
450
  this.hooks = new HookRegistry();
@@ -189,7 +456,15 @@ export class Engine {
189
456
  // overhead multiplied across sub-agents outweighs the value, and
190
457
  // dispatched tasks should run with minimal surface area.
191
458
  if (config.isSubAgent !== true) {
192
- loadPluginHooks(this.hooks);
459
+ // disabledPlugins suppresses a plugin's hooks too (not just its
460
+ // Skill-tool entries) — see loadPluginHooks. readDisabledLists reads
461
+ // the same settings the prompt composer / tool context use.
462
+ // disabledPluginHooks is the per-hook overlay
463
+ // (capabilityOverrides.pluginHooks); applied at construction, so a
464
+ // toggle takes effect for NEW sessions (same semantics as
465
+ // disabledPlugins itself).
466
+ const { disabledPlugins, disabledPluginHooks } = this.readDisabledLists();
467
+ loadPluginHooks(this.hooks, disabledPlugins, disabledPluginHooks);
193
468
  }
194
469
  // settings.hooks → shell-command wrappers. Chain order:
195
470
  // plugin (80) → shell (50) → code (default 0).
@@ -205,7 +480,7 @@ export class Engine {
205
480
  }
206
481
  }
207
482
  /**
208
- * Load models[] / providers[] from settings into the active ModelPool and
483
+ * Load modelConnections[] from settings into the active ModelPool and
209
484
  * resync this.config.llm with the matching entry. Called from the ctor and
210
485
  * from reloadModelPool() (e.g. after onboarding writes new entries to disk).
211
486
  */
@@ -214,67 +489,82 @@ export class Engine {
214
489
  const sm = this.getSettingsManager();
215
490
  sm.invalidate();
216
491
  const settings = sm.get();
217
- if (settings.models?.length) {
218
- for (const m of settings.models) {
219
- this.modelPool.register({
220
- key: m.key,
221
- label: m.label,
222
- provider: m.provider ?? "",
223
- model: m.model,
224
- baseUrl: m.baseUrl,
225
- apiKey: m.apiKey,
226
- maxOutputTokens: m.maxOutputTokens,
227
- maxContextTokens: m.maxContextTokens,
228
- providerKey: m.providerKey,
229
- });
230
- }
231
- // Build catalog from settings.providers[] and attach to the pool
232
- // so model entries can resolve baseUrl/apiKey from their provider.
233
- if (settings.providers?.length) {
234
- this.modelPool.setProviderCatalog(new ProviderCatalog(settings.providers));
492
+ // Unified model catalog (统一模型接入方案 §6): register text
493
+ // connections from settings.modelConnections[] into the pool — the
494
+ // catalog-driven instance store is the sole source of model selection.
495
+ // A connection's instance id becomes its pool key. See
496
+ // docs/superpowers/specs/2026-06-15-unified-model-catalog-design.md.
497
+ const connections = settings.modelConnections;
498
+ if (Array.isArray(connections) && connections.length) {
499
+ const catalog = getMergedCatalog();
500
+ const credentials = settings.credentials;
501
+ for (const entry of modelEntriesFromConnections(connections, (Array.isArray(credentials) ? credentials : []), catalog)) {
502
+ this.modelPool.register(entry);
235
503
  }
504
+ }
505
+ const hasConnections = Array.isArray(connections) && connections.length > 0;
506
+ if (hasConnections) {
236
507
  this.modelPool.setCacheDir(defaultCacheDir());
237
508
  this.modelPool.reloadCachedContextWindows();
238
- // Resolve active entry. Priority:
239
- // 1. settings.activeKey primary source of truth (new shape).
240
- // 2. Match settings.model.name against models[].model legacy
241
- // pre-activeKey configs and the migration path.
242
- // We then switch the pool and write the resolved entry's credentials
243
- // into config.llm, so the first run() uses the right endpoint instead
244
- // of whatever env-derived fallback repl.ts seeded earlier.
245
- // Sub-agents skip the activeKey resync: their llm is chosen by the
246
- // parent's resolveChildLlm (per-role model routing). activeKey is the
247
- // *user's* current UI model selection and must not clobber a child's
248
- // routed model — without this guard a role's `model: flash` is silently
249
- // overridden back to whatever the user has active in the foreground.
509
+ // Resolve the active entry from settings.defaults.text, then switch the
510
+ // pool and write the resolved entry's credentials into config.llm, so the
511
+ // first run() uses the right endpoint instead of whatever env-derived
512
+ // fallback repl.ts seeded earlier.
513
+ // Sub-agents skip this resync: their llm is chosen by the parent's
514
+ // resolveChildLlm (per-role model routing). defaults.text is the *user's*
515
+ // current UI model selection and must not clobber a child's routed model —
516
+ // without this guard a role's `model: flash` is silently overridden back
517
+ // to whatever the user has active in the foreground.
250
518
  if (this.config.isSubAgent !== true) {
251
- const activeKey = settings.activeKey;
252
- let match;
253
- if (activeKey) {
254
- match = settings.models.find((m) => m.key === activeKey);
519
+ const defaultText = settings.defaults?.text;
520
+ // 统一 catalog only:defaults.text 命中则用;否则回退首个已注册连接,
521
+ // 避免选未配置模型时静默沿用空种子(旧 bug:抛误导性 OPENAI_API_KEY missing)
522
+ let matchKey;
523
+ if (defaultText && this.modelPool.list().some((e) => e.key === defaultText)) {
524
+ matchKey = defaultText;
255
525
  }
256
- if (!match) {
257
- const currentModel = this.config.llm.model;
258
- // OpenRouter stores entries as "provider/model-name"; the top-level
259
- // settings.model.name is just "model-name". Match either form.
260
- match = settings.models.find((m) => m.model === currentModel ||
261
- (currentModel && m.model?.endsWith(`/${currentModel}`)));
526
+ else {
527
+ matchKey = this.modelPool.list()[0]?.key;
262
528
  }
263
- if (match) {
264
- const entry = this.modelPool.switch(match.key);
529
+ if (matchKey) {
530
+ const entry = this.modelPool.switch(matchKey);
265
531
  this.config = {
266
532
  ...this.config,
267
- llm: this.modelPool.toLLMConfig(entry, this.config.llm),
533
+ llm: this.modelPool.toLLMConfig(entry),
268
534
  };
269
535
  }
270
536
  }
271
537
  }
272
538
  else if (this.config.llm.apiKey) {
273
- // Auto-populate pool from the configured API key when models[] is empty.
274
- // This lets users who only set model.apiKey (without models[]) still
275
- // use /model to switch between the provider's available models.
539
+ // Auto-populate pool from the configured API key when no
540
+ // modelConnections[] are configured. This lets users who only have an
541
+ // env/seed API key still use /model to switch between the provider's
542
+ // available models.
276
543
  this.autoPopulatePool(this.config.llm.apiKey, this.config.llm.baseUrl);
277
544
  }
545
+ // Carry image-attachment settings + sampling temperature into
546
+ // clientDefaults. Both are cross-model knobs — they apply to whatever
547
+ // model is currently active and survive hot-switches. (Pre-cleanup
548
+ // these were merged into llm.imageDetail / llm.temperature; that path
549
+ // is gone because hot-switching now rotates llm wholesale.)
550
+ const imageSettings = settings.images;
551
+ const modelBlock = settings.model;
552
+ const nextDefaults = { ...(this.config.clientDefaults ?? {}) };
553
+ let defaultsChanged = false;
554
+ // Migrate legacy "original" → "high" (raw settings may bypass schema).
555
+ const detail = imageSettings?.detail === "original" ? "high" : imageSettings?.detail;
556
+ if (detail && nextDefaults.imageDetail !== detail) {
557
+ nextDefaults.imageDetail = detail;
558
+ defaultsChanged = true;
559
+ }
560
+ if (typeof modelBlock?.temperature === "number" &&
561
+ nextDefaults.temperature !== modelBlock.temperature) {
562
+ nextDefaults.temperature = modelBlock.temperature;
563
+ defaultsChanged = true;
564
+ }
565
+ if (defaultsChanged) {
566
+ this.config = { ...this.config, clientDefaults: nextDefaults };
567
+ }
278
568
  }
279
569
  catch {
280
570
  // Settings not available — pool stays empty
@@ -282,7 +572,7 @@ export class Engine {
282
572
  }
283
573
  /**
284
574
  * Re-read settings and refresh the model pool. Used after onboarding /login
285
- * writes new providers[] / models[] to disk so the running engine picks them
575
+ * writes new modelConnections[] to disk so the running engine picks them
286
576
  * up without a process restart. Existing pool entries are kept (re-registering
287
577
  * the same key overwrites them), so callers don't need to clear first.
288
578
  */
@@ -296,7 +586,7 @@ export class Engine {
296
586
  }
297
587
  }
298
588
  /**
299
- * Auto-populate the model pool when settings.models[] is empty but
589
+ * Auto-populate the model pool when no modelConnections[] are configured but
300
590
  * the user has configured an API key. Detects the provider from the
301
591
  * key prefix / baseUrl and registers all its known models.
302
592
  */
@@ -315,7 +605,7 @@ export class Engine {
315
605
  const entry = this.modelPool.switch(defaultEntry.key);
316
606
  this.config = {
317
607
  ...this.config,
318
- llm: this.modelPool.toLLMConfig(entry, this.config.llm),
608
+ llm: this.modelPool.toLLMConfig(entry),
319
609
  };
320
610
  }
321
611
  }
@@ -334,11 +624,100 @@ export class Engine {
334
624
  setAskUser(fn) {
335
625
  this.config.askUser = fn;
336
626
  }
627
+ /**
628
+ * Inject the browser automation bridge after construction (same chicken-and-egg
629
+ * as setAskUser: the desktop host builds the bridge — which drives a webview —
630
+ * after the Engine exists). Undefined → the browser_* tools degrade with a
631
+ * clear "no browser panel" error.
632
+ */
633
+ setBrowserBridge(bridge) {
634
+ this.config.browserBridge = bridge;
635
+ }
636
+ /**
637
+ * Queue a user message to be spliced into the in-flight run for `sessionId`
638
+ * at the next turn-loop step boundary — the 不打断 steering path (vs cancel +
639
+ * resend). General-purpose: any host path (UI 引导, future agent coordination,
640
+ * external triggers) can call it. If no run is active for the session the
641
+ * message simply waits in the queue and is consumed when that session next
642
+ * runs (rare race; host normally only steers while busy). No-op on blank text.
643
+ *
644
+ * `id` is the host's stable queue-entry id. It rides through to the
645
+ * `steer_injected` event (so the host can match the injected bubble back to
646
+ * the queued draft) and is the handle `unsteer` uses to revoke a still-pending
647
+ * entry. A blank id is tolerated but means the entry can't be revoked.
648
+ */
649
+ enqueueSteer(sessionId, text, id = "") {
650
+ if (!sessionId)
651
+ return;
652
+ const q = this.steerQueueBySid.get(sessionId) ?? [];
653
+ const next = enqueueSteerItem(q, id || `steer-${q.length}`, text);
654
+ if (next === q)
655
+ return; // blank text dropped
656
+ this.steerQueueBySid.set(sessionId, next);
657
+ }
658
+ /**
659
+ * Revoke a still-pending steer entry (the 撤回 path). Returns true if it was
660
+ * removed, false if it was already consumed by the turn loop (can't take it
661
+ * back — it has been spliced into the run).
662
+ */
663
+ unsteer(sessionId, id) {
664
+ const q = this.steerQueueBySid.get(sessionId);
665
+ if (!q || q.length === 0)
666
+ return false;
667
+ const { list, removed } = removeSteerItem(q, id);
668
+ if (removed)
669
+ this.steerQueueBySid.set(sessionId, list);
670
+ return removed;
671
+ }
672
+ /** Drain + clear the steer queue for a session (turn loop consumes per step). */
673
+ consumeSteer(sessionId) {
674
+ const q = this.steerQueueBySid.get(sessionId);
675
+ if (!q || q.length === 0)
676
+ return [];
677
+ const { drained, rest } = consumeSteerItems(q);
678
+ this.steerQueueBySid.set(sessionId, rest);
679
+ return drained;
680
+ }
681
+ /** Wire the cookie→browser injection callback (InjectCredential tool). Same
682
+ * post-construction injection model as setBrowserBridge. */
683
+ setInjectCredential(fn) {
684
+ this.config.injectCredentialToBrowser = fn;
685
+ }
686
+ /**
687
+ * Whether this engine runs unattended (no interactive human). Used by the
688
+ * in-process AgentServer to decide whether to wire an interactive askUser.
689
+ */
690
+ isHeadless() {
691
+ return this.config.headless === true;
692
+ }
693
+ /**
694
+ * Probe whether a session already exists on disk (its state/transcript dir is
695
+ * present). Used by the protocol server to distinguish "resume an existing
696
+ * session" from "silently create a fresh empty one" — e.g. a cron resume job
697
+ * whose target session the user deleted must fail loudly, not run its prompt
698
+ * against a blank session. A stat probe, not a load.
699
+ */
700
+ sessionExistsOnDisk(sessionId) {
701
+ return this.sessionManager.exists(sessionId);
702
+ }
337
703
  /**
338
704
  * Run a task from start to finish.
339
705
  */
340
706
  async run(task, options) {
341
- const cwd = options?.cwd ?? this.config.cwd ?? process.cwd();
707
+ // When the caller omits cwd but is resuming an existing session, recover
708
+ // that session's bound cwd from disk so a project-bound session keeps
709
+ // loading its own agents/settings/memory even if the host's UI repo
710
+ // selection has drifted to null. Only probe on omission — an explicit cwd
711
+ // always wins, and a fresh session has nothing to recover.
712
+ const sessionCwd = options?.cwd === undefined && options?.sessionId
713
+ ? this.sessionManager.readCwd(options.sessionId)
714
+ : undefined;
715
+ const cwd = resolveRunCwd({
716
+ optionCwd: options?.cwd,
717
+ sessionCwd,
718
+ configCwd: this.config.cwd,
719
+ processCwd: process.cwd(),
720
+ });
342
721
  // Wrap the caller's onStream so we can intercept `task_update`
343
722
  // events emitted by TodoWrite and keep an in-engine snapshot.
344
723
  // TaskGuard reads this snapshot at turn end to decide whether to
@@ -352,6 +731,16 @@ export class Engine {
352
731
  if (event.type === "task_update") {
353
732
  latestTodos = event.tasks;
354
733
  }
734
+ // Persist goal progress so replay/history shows how many rounds the
735
+ // goal ran. Display-only — toMessages() ignores this type, so it never
736
+ // re-enters the LLM context.
737
+ if (event.type === "goal_progress") {
738
+ session.transcript.append("goal_progress", {
739
+ status: event.status,
740
+ round: event.round,
741
+ ...(event.gaps ? { gaps: event.gaps } : {}),
742
+ });
743
+ }
355
744
  userOnStream?.(event);
356
745
  };
357
746
  if (options)
@@ -398,6 +787,67 @@ export class Engine {
398
787
  usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
399
788
  };
400
789
  }
790
+ // Size gate. Hosts (desktop renderer, TUI) are expected to
791
+ // pre-compress to IMAGE_TARGETS — if they didn't, we fail the turn
792
+ // fast with a clear message instead of letting the OpenAI client
793
+ // grind through three 16-second "Connection error" retries on a
794
+ // 4 MB body. See `image-policy.ts` for the rationale and limits.
795
+ let verdict = enforceImagePolicy(parsedTask.images);
796
+ if (!verdict.ok && verdict.code === "image_too_large") {
797
+ // One image blew the per-image cap. Try the engine-side
798
+ // compressor (jimp-backed when installed; no-op otherwise) so
799
+ // TUI / MCP paths that lack a host-side resize don't fail
800
+ // outright on a screenshot they could have rescaled. The
801
+ // re-check below is what decides whether we proceed.
802
+ const compressed = await tryCompressImages(parsedTask.images);
803
+ if (compressed.anyCompressed) {
804
+ parsedTask.images = compressed.images;
805
+ logger.info("engine.run.image_compressed", {
806
+ before: verdict.offender?.bytes,
807
+ after: compressed.images.reduce((s, i) => s + byteLengthFromBase64(i.base64), 0),
808
+ });
809
+ verdict = enforceImagePolicy(parsedTask.images);
810
+ }
811
+ }
812
+ // After compression, anything still over the per-image cap is
813
+ // dropped with a textual placeholder instead of failing the
814
+ // turn (TODO-week.md #9e). The "5MB brick session" failure
815
+ // mode from Claude Code (research doc §A) was the case where a
816
+ // poisoned image entered history and every subsequent request
817
+ // re-sent it; placeholders keep history clean while letting
818
+ // the rest of the turn run.
819
+ if (!verdict.ok && verdict.code === "image_too_large") {
820
+ const drop = dropOversizedImages(parsedTask.images);
821
+ if (drop.droppedCount > 0) {
822
+ parsedTask.images = drop.kept;
823
+ parsedTask.hasImages = drop.kept.length > 0;
824
+ parsedTask.text = drop.placeholder + "\n\n" + parsedTask.text;
825
+ logger.warn("engine.run.image_dropped", {
826
+ droppedCount: drop.droppedCount,
827
+ keptCount: drop.kept.length,
828
+ });
829
+ verdict = enforceImagePolicy(parsedTask.images);
830
+ }
831
+ }
832
+ if (!verdict.ok) {
833
+ // Cumulative / count caps can't be rescued by per-image
834
+ // dropping (well — too_many_images could trim by FIFO, but
835
+ // that's a bigger UX call than we want to make silently).
836
+ // Refuse the turn with the policy message.
837
+ logger.warn("engine.run.image_policy_failed", {
838
+ code: verdict.code,
839
+ imageCount: verdict.totals.imageCount,
840
+ totalBytes: verdict.totals.totalBytes,
841
+ offender: verdict.offender,
842
+ });
843
+ return {
844
+ text: `ERROR: ${verdict.message}`,
845
+ reason: "image_error",
846
+ sessionId: options?.sessionId ?? `image-policy-${verdict.code}`,
847
+ turnCount: 0,
848
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
849
+ };
850
+ }
401
851
  }
402
852
  // For downstream noise-detection + transcript persistence we want the
403
853
  // *text* portion only — base64 bytes count as "noise" by the heuristic
@@ -429,6 +879,22 @@ export class Engine {
429
879
  permissionMode: this.config.permissionMode ?? "acceptEdits",
430
880
  }),
431
881
  spawn: async (req) => {
882
+ // Anchor this sub-agent in the PARENT transcript at spawn time — before
883
+ // it runs, so it's recorded whether it later completes, is interrupted,
884
+ // or still runs. Replay reads these anchors to rebuild sub-agent cards
885
+ // from sessions/<agentId>/ (agentId === childSid); without it a
886
+ // backgrounded sub-agent leaves no parent-transcript trace and vanishes
887
+ // on reopen. Only on a fresh spawn (not a resume/continuation, which
888
+ // already has its anchor). Guarded so a transcript hiccup never breaks
889
+ // the spawn.
890
+ if (!req.resumeSessionId) {
891
+ try {
892
+ session.transcript.appendSubagent(req.agentId, undefined, req.description);
893
+ }
894
+ catch {
895
+ /* anchor is best-effort; never block the spawn */
896
+ }
897
+ }
432
898
  // No nested agents. Strip Agent / AgentStatus / AgentCancel from the
433
899
  // child's tool pool so the LLM can't spawn grandchildren — matches
434
900
  // Claude Code's ALL_AGENT_DISALLOWED_TOOLS approach. Without this
@@ -439,7 +905,11 @@ export class Engine {
439
905
  const { enabled: childEnabled, disabled: childDisabled } = resolveChildToolScope(req.toolAllowlist, this.config.disabledBuiltinTools, this.config.enabledBuiltinTools);
440
906
  const childLlm = resolveChildLlm(req.model, this.modelPool, this.config.llm);
441
907
  const child = new Engine({
442
- llm: { ...childLlm, retryMaxAttempts: 2 },
908
+ llm: childLlm,
909
+ // Inherit parent's runtime knobs (temperature, image detail, timeouts)
910
+ // but cap sub-agent retries at 2 — they're short-lived and we'd
911
+ // rather surface failures than burn a 9 s exponential backoff loop.
912
+ clientDefaults: { ...(this.config.clientDefaults ?? {}), retryMaxAttempts: 2 },
443
913
  cwd,
444
914
  permissionMode: this.config.permissionMode,
445
915
  preset: this.preset.name,
@@ -449,10 +919,15 @@ export class Engine {
449
919
  appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt]
450
920
  .filter(Boolean)
451
921
  .join("\n\n") || undefined,
922
+ responseLanguage: this.config.responseLanguage,
923
+ userProfile: this.config.userProfile,
924
+ instructions: this.config.instructions,
452
925
  maxTurns: req.maxTurns,
453
926
  maxContextTokens: this.config.maxContextTokens ?? 200_000,
454
927
  sessionStorageDir: this.config.sessionStorageDir,
455
928
  headless: this.config.headless,
929
+ readOnlySession: req.readOnlySession,
930
+ skillAllowlist: req.skillAllowlist,
456
931
  sandbox: this.config.sandbox,
457
932
  // Subagents inherit the parent's scope: a child runs in the same
458
933
  // cwd/session, so it should see the same config layers the parent did.
@@ -490,12 +965,41 @@ export class Engine {
490
965
  // child.run() establishes its own runWithSid scope internally, so
491
966
  // child log lines route to the child's sid and parent's ALS
492
967
  // binding is unaffected when control returns here.
493
- const result = await child.run(req.prompt, { signal: req.signal, onStream: childStream });
494
- return result.text;
968
+ //
969
+ // agent_id === childSid: cold-start the child UNDER its agentId as the
970
+ // session id (run() shape (2): a fresh sid the host wants materialized),
971
+ // so the session persists at sessions/<agentId>/ and AgentSendInput can
972
+ // later resume it by agentId with no extra id→sid mapping. When
973
+ // resumeSessionId is set we resume that existing session instead —
974
+ // run() detects the on-disk session and replays its full transcript
975
+ // (the CC continuation model; see AgentSendInput).
976
+ const childSessionId = req.resumeSessionId ?? req.agentId;
977
+ const result = await child.run(req.prompt, {
978
+ signal: req.signal,
979
+ onStream: childStream,
980
+ sessionId: childSessionId,
981
+ });
982
+ return { text: result.text, sessionId: result.sessionId };
495
983
  },
984
+ sessionExists: (sessionId) => this.sessionManager.exists(sessionId),
496
985
  };
497
- const sandboxConfig = this.config.sandbox ??
498
- defaultSandboxConfig(this.config.headless ? "auto" : "off");
986
+ // Priority: config.sandbox → project settings.sandbox → global → per-run
987
+ // default. Read UNMERGED per-scope (getForScope) so a project that wrote no
988
+ // sandbox genuinely follows global, rather than inheriting global's mode and
989
+ // looking like it set one. Fixes "项目级配了不生效" + the scope model.
990
+ let projectSandbox;
991
+ let globalSandbox;
992
+ try {
993
+ const sm = this.getSettingsManager();
994
+ if (this.config.isSubAgent !== true) {
995
+ projectSandbox = sm.getForScope("project", cwd).sandbox;
996
+ }
997
+ globalSandbox = sm.getForScope("user").sandbox;
998
+ }
999
+ catch {
1000
+ // settings unavailable → fall through to per-run default
1001
+ }
1002
+ const sandboxConfig = resolveSandboxConfig(this.config.sandbox, projectSandbox, globalSandbox, this.config.headless === true);
499
1003
  // A2: explicit sandbox modes (seatbelt, bwrap) must fail closed
500
1004
  // per standard §S4. resolveSandboxBackend throws when an explicit
501
1005
  // mode is unavailable on this host; we let it propagate. The
@@ -504,11 +1008,22 @@ export class Engine {
504
1008
  // `auto` mode handles its own downgrade with a one-time warning
505
1009
  // inside resolveSandboxBackend; explicit modes do not.
506
1010
  //
507
- // Backend is cached on EngineRuntime (when available) so the
508
- // capability probe runs once per (mode, cwd) instead of every turn.
1011
+ // Backend is cached per runtime/engine so the capability probe runs once
1012
+ // per (mode, cwd) instead of every turn.
509
1013
  const sandboxBackend = this.runtime
510
1014
  ? await this.runtime.resolveSandbox(sandboxConfig, cwd)
511
- : await resolveSandboxBackend(sandboxConfig, cwd);
1015
+ : await this.resolveSandboxWithoutRuntime(sandboxConfig, cwd);
1016
+ // Observability: surface what sandbox actually applied this run — the
1017
+ // configured mode vs the resolved backend (auto may downgrade to off when
1018
+ // no OS backend is available) + the network policy. Without this you can't
1019
+ // tell whether shell commands were isolated /网络放没放. One line per run.
1020
+ logger.info("sandbox.resolved", {
1021
+ mode: sandboxConfig.mode,
1022
+ backend: sandboxBackend.name,
1023
+ isolated: sandboxBackend.name !== "off",
1024
+ network: sandboxConfig.network,
1025
+ cwd,
1026
+ });
512
1027
  // sessionId is filled in after the session bundle is resolved below
513
1028
  // (the session may be cold-started or resumed). Until then this is
514
1029
  // intentionally shaped as a mutable local; we treat it as immutable
@@ -517,7 +1032,13 @@ export class Engine {
517
1032
  ...this.buildToolContext(),
518
1033
  subAgentSpawner,
519
1034
  agentDefinitions: this.getAgentDefinitions(cwd),
520
- sandbox: sandboxBackend,
1035
+ // Stamp the resolved network policy onto the backend the tools see so
1036
+ // Bash can surface "网络 deny" on its result. Shallow-copy (don't mutate
1037
+ // the cached backend) — `wrap`/`hintForBlockedOutput` are plain function
1038
+ // properties and survive the spread. Off keeps network undefined.
1039
+ sandbox: sandboxBackend.name === "off"
1040
+ ? sandboxBackend
1041
+ : { ...sandboxBackend, network: sandboxConfig.network },
521
1042
  cwd,
522
1043
  // TodoWrite reads this to push task_update events independently
523
1044
  // of its return value, so the UI's pinned task panel refreshes
@@ -538,10 +1059,24 @@ export class Engine {
538
1059
  // text block (when prose is present) followed by one image block per
539
1060
  // attachment — the provider-specific clients translate this to OpenAI
540
1061
  // `image_url` or Anthropic `{type:image, source:base64}` downstream.
1062
+ // When an attached image came from a workspace FILE (the desktop composer's
1063
+ // path-attach flow sets ParsedImage.name = the absolute path), surface that
1064
+ // path to the model as text. The image bytes still ride along for vision,
1065
+ // but tools that operate on files — GenerateImage(referenceImages),
1066
+ // Read, etc. — need the on-disk path, not just the pixels. Without this the
1067
+ // path the composer already knew was silently dropped, and the model would
1068
+ // answer "图片没落到项目文件夹,找不到路径" (the seedance 图生图 dead-end).
1069
+ // Only names that resolve to an existing file qualify; a pasted screenshot
1070
+ // whose name is just "screenshot.png" is not a path and is left out.
1071
+ const attachedPaths = collectAttachedImagePaths(parsedTask.images, (name) => (isAbsolute(name) ? name : join(cwd, name)), existsSync);
1072
+ const pathHint = attachedPaths.length > 0
1073
+ ? `\n\n<attached-image-paths>\n${attachedPaths.join("\n")}\n</attached-image-paths>\n` +
1074
+ `(上面附带的图片在工作区的真实路径,如需把它们作为工具输入(例如 GenerateImage 的 referenceImages、图生图参考图),直接使用这些路径。)`
1075
+ : "";
541
1076
  const userMessageContent = parsedTask.hasImages
542
1077
  ? [
543
- ...(parsedTask.text
544
- ? [{ type: "text", text: parsedTask.text }]
1078
+ ...(parsedTask.text || pathHint
1079
+ ? [{ type: "text", text: `${parsedTask.text}${pathHint}` }]
545
1080
  : []),
546
1081
  ...parsedTask.images.map((img) => ({
547
1082
  type: "image",
@@ -570,9 +1105,8 @@ export class Engine {
570
1105
  let messages;
571
1106
  if (options?.sessionId && this.sessionManager.exists(options.sessionId)) {
572
1107
  session = this.sessionManager.resume(options.sessionId);
573
- messages = this.compactedMessagesBySession.get(options.sessionId)
574
- ? [...this.compactedMessagesBySession.get(options.sessionId)]
575
- : session.transcript.toMessages();
1108
+ const cachedCompacted = this.compactedMessagesBySession.get(options.sessionId);
1109
+ messages = cachedCompacted ? [...cachedCompacted] : session.transcript.toMessages();
576
1110
  // If the previous run was Ctrl+C'd or crashed between an assistant
577
1111
  // tool_use and the matching tool_result being persisted, the
578
1112
  // loaded sequence is invalid for OpenAI (which 400s on dangling
@@ -593,7 +1127,7 @@ export class Engine {
593
1127
  // Append new user message
594
1128
  const userMsg = { role: "user", content: userMessageContent };
595
1129
  messages.push(userMsg);
596
- session.transcript.appendMessage("user", userMessageContent);
1130
+ session.transcript.appendMessage("user", userMessageContent, { injected: options?.injected === true });
597
1131
  // Flush "active" status to disk immediately. resume() set it in memory
598
1132
  // (session-manager.ts), but without this write the on-disk state.json
599
1133
  // still shows the previous run's terminal reason — so any external
@@ -604,7 +1138,7 @@ export class Engine {
604
1138
  else {
605
1139
  // Cold start: shape (2) reuses the host-supplied sid; shape (3)
606
1140
  // lets sessionManager generate one with nanoid.
607
- session = this.sessionManager.create(cwd, this.config.llm.model, this.config.llm.provider, options?.sessionId);
1141
+ 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
1142
  messages = [{ role: "user", content: userMessageContent }];
609
1143
  session.transcript.appendMessage("user", userMessageContent);
610
1144
  // Save first user message as session summary — text only. The summary
@@ -616,6 +1150,12 @@ export class Engine {
616
1150
  session.state.summary = summarySrc.slice(0, 80).replace(/\n/g, " ");
617
1151
  this.sessionManager.saveState(session.state);
618
1152
  }
1153
+ // Bump the conversation-turn counter: this user message starts a new turn.
1154
+ // One user message = one turn, regardless of how many turn-loop iterations
1155
+ // or tool calls it spans. File-history snapshots taken below are tagged
1156
+ // with this value so `/undo` reverts exactly this turn's file changes.
1157
+ // (Both resume and cold-start paths converge here.)
1158
+ session.state.turnSeq = (session.state.turnSeq ?? 0) + 1;
619
1159
  // Stamp the resolved session id for downstream logging.
620
1160
  //
621
1161
  // `setCurrentSid` updates the module-level fallback so any code path
@@ -730,7 +1270,7 @@ export class Engine {
730
1270
  }
731
1271
  }
732
1272
  // Kick off LLM client creation early (network handshake)
733
- const llmClientPromise = createLLMClient(this.config.llm);
1273
+ const llmClientPromise = createLLMClient(this.config.llm, this.config.clientDefaults);
734
1274
  const mode = this.config.permissionMode ?? "acceptEdits";
735
1275
  const { rules: defaultRules, backend: approvalBackend } = this.buildPermissionConfig(mode, cwd);
736
1276
  const permission = new PermissionClassifier(defaultRules, mode, approvalBackend);
@@ -751,8 +1291,12 @@ export class Engine {
751
1291
  }
752
1292
  const toolExecutor = new ToolExecutor(this.toolRegistry, permission, this.hooks);
753
1293
  const investigationGuard = new InvestigationGuard();
754
- if (this.config.headless)
1294
+ if (this.config.readOnlySession) {
1295
+ investigationGuard.setPolicy("read-only-review");
1296
+ }
1297
+ else if (this.config.headless) {
755
1298
  investigationGuard.setSoftMode(true);
1299
+ }
756
1300
  toolExecutor.setInvestigationGuard(investigationGuard);
757
1301
  toolExecutor.setTaskGuard(new TaskGuard(() => latestTodos));
758
1302
  // Wire abort signal for cascading cancellation + per-Engine ToolContext
@@ -760,6 +1304,9 @@ export class Engine {
760
1304
  toolExecutor.setContext(toolCtx);
761
1305
  const contextManager = new ContextManager({
762
1306
  maxTokens: this.resolveMaxContextTokens(),
1307
+ // Drop undefined fields so they don't clobber ContextManager defaults
1308
+ // (spread of `{x: undefined}` would override the default with undefined).
1309
+ ...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
763
1310
  });
764
1311
  this.lastContextManager = contextManager;
765
1312
  const { disabledSkills, disabledPlugins } = this.readDisabledLists();
@@ -769,8 +1316,13 @@ export class Engine {
769
1316
  preset: this.preset,
770
1317
  customSystemPrompt: this.config.customSystemPrompt,
771
1318
  appendSystemPrompt: this.config.appendSystemPrompt,
1319
+ responseLanguage: this.config.responseLanguage,
1320
+ userProfile: this.config.userProfile,
1321
+ instructionOptions: { compatFileNames: compatFileNamesFrom(this.config.instructions) },
772
1322
  disabledSkills,
773
1323
  disabledPlugins,
1324
+ skillAllowlist: this.config.skillAllowlist,
1325
+ memoriesMaxAgeDays: this.readMemoriesConfig()?.maxAge,
774
1326
  });
775
1327
  // Connect MCP servers (if configured and not already connected).
776
1328
  // B1: prefer the Runtime-owned MCPManager so all sessions in a
@@ -785,40 +1337,103 @@ export class Engine {
785
1337
  else {
786
1338
  this.mcpManager = new MCPManager(this.toolRegistry);
787
1339
  }
788
- await this.mcpManager.connectAll(mcpServers);
1340
+ await this.mcpManager.connectAll(mcpServers, this);
789
1341
  }
790
1342
  // Parallelize slow initialization:
791
1343
  // 1. createLLMClient — network handshake (started earlier)
792
1344
  // 2. buildSystemPrompt — includes git status (3 execSync calls)
793
1345
  // 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
- ]);
1346
+ // Inject the live available-agent-types listing into the Agent tool's
1347
+ // description. The registry is per-engine (loaded from .code-shell/agents
1348
+ // for this cwd), so it can't live in the static tool def — without this
1349
+ // the model never learns the reusable roles exist and spawns nameless
1350
+ // ad-hoc agents instead (the Core A/B/C incident).
1351
+ // The Agent tool is always available: with configured roles, an omitted
1352
+ // agent_type falls back to one of them (see resolveAgentTypeOverrides); with
1353
+ // no roles configured it runs a true ephemeral agent, so workflows that need
1354
+ // sub-agents (e.g. superpowers) work in any project.
1355
+ // Availability guard (tool-visibility): a gated builtin (WebSearch needs a
1356
+ // search provider, GenerateImage needs an OpenAI provider) is hidden from
1357
+ // the toolDefs the model sees when its credential isn't configured for this
1358
+ // cwd. Recomputed every message, so configuring a key takes effect on the
1359
+ // NEXT message without a restart. Tools with no guard entry are always kept.
1360
+ const guardCwd = toolCtx.cwd;
1361
+ // #7: per-turn project builtin override. The toolRegistry's builtin tool
1362
+ // SET is ctor-frozen (and may be shared via runtime), so a mid-session
1363
+ // project override of a builtin can't rebuild the registry. But the tool
1364
+ // LIST handed to the LLM is assembled fresh every turn, so we apply the
1365
+ // override here: a builtin marked `off` for this cwd is HIDDEN from the
1366
+ // turn's tool list (matching how skills/plugins/agents `off` apply
1367
+ // mid-session via readDisabledLists). `on`/`inherit` keep whatever the
1368
+ // registry already has — we can't re-add a tool the frozen registry omits,
1369
+ // but `on` for a tool already present is a no-op (it stays). This makes a
1370
+ // builtin toggle take effect on the NEXT message, like other capability
1371
+ // kinds, without touching the registry.
1372
+ const builtinOverride = this.readBuiltinOverride(guardCwd);
1373
+ // Turn `off` from a prompt-visibility filter into a real execution gate:
1374
+ // collect the builtin tool names the override marks `off` and hand them to
1375
+ // the executor (via the shared toolCtx the executor already holds a
1376
+ // reference to, set at setContext above) so it rejects a call to a hidden
1377
+ // builtin instead of running it from the still-populated registry.
1378
+ if (builtinOverride) {
1379
+ const registryNames = new Set(this.toolRegistry.getToolDefinitions().map((t) => t.name));
1380
+ const disabledBuiltins = new Set(Object.keys(builtinOverride).filter((name) => builtinOverride[name] === "off" && registryNames.has(name)));
1381
+ toolCtx.disabledBuiltins = disabledBuiltins;
1382
+ }
1383
+ // MCP tool exposure is per-SESSION even though the pool/registry are
1384
+ // worker-shared (B1): a server connected by another project's session
1385
+ // registers its tools into the SHARED registry, and without this filter
1386
+ // they leaked into every session (e.g. chrome-devtools tools showing up
1387
+ // in a project that never enabled the plugin). Keep an MCP tool only when
1388
+ // its server is in THIS session's merged config.mcpServers — which
1389
+ // already folds the project's capabilityOverrides. Gated on the config
1390
+ // being present: engines without one (sub-agents, bare tests) have no
1391
+ // MCP tools in their private registries anyway.
1392
+ const allowedMcpServers = new Set(Object.entries(this.config.mcpServers ?? {})
1393
+ .filter(([, c]) => c.enabled !== false)
1394
+ .map(([n]) => n));
1395
+ toolCtx.allowedMcpServers = allowedMcpServers;
1396
+ const mcpVisible = (toolName) => {
1397
+ const reg = this.toolRegistry.getTool(toolName);
1398
+ return reg?.source !== "mcp" || allowedMcpServers.has(reg?.serverName ?? "");
1399
+ };
1400
+ // Feature-flag visibility: a builtin mapped in TOOL_FEATURE_FLAGS is
1401
+ // hidden when its flag resolves to false (default-on flags only hide when
1402
+ // explicitly disabled, so zero regression out of the box). Read once per
1403
+ // turn so flipping a flag in settings takes effect on the NEXT message,
1404
+ // like the other capability kinds.
1405
+ const featureFlags = this.readFeatureFlags();
1406
+ const allToolDefs = applyBuiltinOverrideVisibility(this.toolRegistry.getToolDefinitions(), builtinOverride)
1407
+ .filter((t) => mcpVisible(t.name))
1408
+ .filter((t) => {
1409
+ const guard = BUILTIN_TOOL_GUARDS.get(t.name);
1410
+ return guard ? guard(guardCwd) : true;
1411
+ })
1412
+ .filter((t) => {
1413
+ const flag = TOOL_FEATURE_FLAGS.get(t.name);
1414
+ return flag ? isFeatureEnabled(featureFlags, flag) : true;
1415
+ })
1416
+ // Dynamic per-engine bits the static defs can't carry: the Agent tool's
1417
+ // agent_type enum + listing, and the image/video provider names. See
1418
+ // applyDynamicToolDef — forwarding only the Agent description (dropping
1419
+ // its rebuilt inputSchema) used to strip the agent_type enum, so the
1420
+ // model omitted agent_type and configured roles never applied.
1421
+ .map((t) => applyDynamicToolDef(t, toolCtx.agentDefinitions, guardCwd));
1422
+ // In plan mode, only expose read-only/planning tools so the model won't
1423
+ // attempt writes. Shared with executor.ts's execution gate via
1424
+ // PLAN_MODE_ALLOWED_TOOLS so what the model SEES and what the executor
1425
+ // RUNS can't drift apart. (Bash is in the set; the executor additionally
1426
+ // gates Bash to read-only commands at call time.)
813
1427
  const toolDefs = this.planMode
814
- ? allToolDefs.filter((t) => planModeAllowed.has(t.name))
1428
+ ? allToolDefs.filter((t) => PLAN_MODE_ALLOWED_TOOLS.has(t.name))
815
1429
  : allToolDefs;
816
- const [llmClient, systemPrompt, systemContext] = await Promise.all([
1430
+ const [llmClient, fullSystemPrompt, dynamicContextMsg] = await Promise.all([
817
1431
  llmClientPromise,
1432
+ // System prompt is now the STABLE prefix only — skills + git status moved
1433
+ // out to a trailing per-turn message so they no longer bust the cache.
818
1434
  promptComposer.buildSystemPrompt(toolDefs),
819
- promptComposer.buildSystemContext(),
1435
+ promptComposer.buildDynamicContextMessage(),
820
1436
  ]);
821
- const fullSystemPrompt = [systemPrompt, systemContext].filter(Boolean).join("\n\n");
822
1437
  // Prepend userContext (CLAUDE.md) as first message (sync, fast)
823
1438
  const userContextMsg = promptComposer.buildUserContextMessage();
824
1439
  if (userContextMsg) {
@@ -837,6 +1452,12 @@ export class Engine {
837
1452
  // reminder → user request.
838
1453
  messages.splice(messages.length - 1, 0, lifecycleReminder);
839
1454
  }
1455
+ // Volatile context (skills + git status) goes at the very END — after the
1456
+ // user task — so it sits past the conversation's cache breakpoint. A change
1457
+ // here (new skill, edited file) never invalidates the cached history prefix.
1458
+ if (dynamicContextMsg) {
1459
+ messages.push(dynamicContextMsg);
1460
+ }
840
1461
  this.lastSessionId = session.state.sessionId;
841
1462
  this.lastMessages = messages;
842
1463
  // Wire up LLM summarization for context compaction
@@ -848,8 +1469,15 @@ export class Engine {
848
1469
  // run would be evaluated fresh and might get a different replacement
849
1470
  // string than the one already in the message, breaking idempotency.
850
1471
  contextManager.initReplacementStateFromMessages(messages);
1472
+ // Summarization (context-compaction + tool-result summaries) are auxiliary
1473
+ // calls — route them to the configured aux model so they don't burn the
1474
+ // expensive primary model every turn (same rationale as runMemoryPipeline).
1475
+ // Resolved once here (not per-call) so the magnetic-disk settings re-read
1476
+ // in resolveAuxClient stays off the compaction hot path. Falls back to the
1477
+ // primary client when no aux model is configured.
1478
+ const auxSummaryClient = await this.resolveAuxClient(llmClient);
851
1479
  contextManager.setSummarizeFn(async (prompt) => {
852
- const summaryResponse = await llmClient.createMessage({
1480
+ const summaryResponse = await auxSummaryClient.createMessage({
853
1481
  systemPrompt: "You are a conversation summarizer. Be concise and factual.",
854
1482
  messages: [{ role: "user", content: prompt }],
855
1483
  tools: [],
@@ -857,12 +1485,17 @@ export class Engine {
857
1485
  // Auxiliary call — no need to burn reasoning tokens. On DeepSeek V4
858
1486
  // this flips thinking off (~3x faster, fewer tokens); on every other
859
1487
  // OpenAI-compatible provider the field is ignored.
860
- thinking: "disabled",
1488
+ reasoning: { mode: "off" },
861
1489
  });
862
1490
  return summaryResponse.text;
863
1491
  });
864
- // Create components (requires resolved llmClient)
1492
+ // Create components (requires resolved llmClient).
865
1493
  const modelFacade = new ModelFacade(llmClient, session.transcript);
1494
+ // Session-cumulative usage baseline: the LLM client is recreated per run
1495
+ // (its getUsage() counts only THIS run), so to accumulate across runs we
1496
+ // capture the persisted total at run start and fold this run's usage onto
1497
+ // it (see foldRunUsage). Snapshot now, before any turn boundary fires.
1498
+ const usageBaseline = { ...session.state.tokenUsage };
866
1499
  // Wire getOutputTokens for token budget tracking
867
1500
  modelFacade.getOutputTokens = () => {
868
1501
  const usage = llmClient.getUsage();
@@ -873,14 +1506,14 @@ export class Engine {
873
1506
  // tracker so session_end.cost reflects only the user-facing turns and
874
1507
  // turns/requestCount stay aligned.
875
1508
  modelFacade.summarize = async (sysPrompt, userMsg) => {
876
- const resp = await llmClient.createMessage({
1509
+ const resp = await auxSummaryClient.createMessage({
877
1510
  systemPrompt: sysPrompt,
878
1511
  messages: [{ role: "user", content: userMsg }],
879
1512
  tools: [],
880
1513
  maxTokens: 256,
881
1514
  recordUsage: false,
882
1515
  // Auxiliary call — see contextManager.setSummarizeFn above.
883
- thinking: "disabled",
1516
+ reasoning: { mode: "off" },
884
1517
  });
885
1518
  logger.debug("summarize.call", {
886
1519
  sysPromptLen: sysPrompt.length,
@@ -895,22 +1528,115 @@ export class Engine {
895
1528
  return resp.text;
896
1529
  };
897
1530
  // File history: auto-backup before Write/Edit
898
- const sessionDir = join(this.config.sessionStorageDir ?? join(homedir(), ".code-shell", "sessions"), session.state.sessionId);
1531
+ const sessionDir = join(this.config.sessionStorageDir ?? join(userHome(), ".code-shell", "sessions"), session.state.sessionId);
899
1532
  const fileHistory = FileHistory.loadFromDir(sessionDir);
900
- this.hooks.register("on_tool_start", async (context) => {
1533
+ // Keep a reference so we can unregister in the finally below. Registering an
1534
+ // anonymous handler every run() leaks: unregister matches by handler
1535
+ // identity, so without a stored reference each run stacks another identical
1536
+ // on_tool_start handler that fires (and re-snapshots) on every tool forever.
1537
+ const fileHistoryHandler = async (context) => {
901
1538
  const toolName = context.data?.toolName;
902
1539
  const args = context.data?.args;
1540
+ // Tag snapshots with the current turn (stamped above before any tool
1541
+ // runs) so turn-level /undo can revert just this user message's edits.
1542
+ const turnSeq = session.state.turnSeq;
903
1543
  if ((toolName === "Write" || toolName === "Edit") && args?.file_path) {
904
- fileHistory.saveSnapshot(args.file_path);
1544
+ const path = args.file_path;
1545
+ // saveSnapshot returns null when the file does not exist yet — this
1546
+ // hook runs BEFORE the tool, so a null here means the turn is CREATING
1547
+ // the file. Record it (idempotent per turn) so /undo can delete it and
1548
+ // /redo can recreate it.
1549
+ if (fileHistory.saveSnapshot(path, turnSeq) === null && turnSeq !== undefined) {
1550
+ fileHistory.recordCreated(path, turnSeq);
1551
+ }
1552
+ }
1553
+ else if (toolName === "ApplyPatch" && typeof args?.patch === "string") {
1554
+ // ApplyPatch mutates files too, so /undo must see them. Snapshot every
1555
+ // existing file the patch updates or deletes (adds have no prior
1556
+ // content). Resolve relative patch paths against the engine cwd, the
1557
+ // same base ApplyPatch itself uses.
1558
+ const cwd = this.config.cwd ?? process.cwd();
1559
+ for (const target of patchBackupTargets(args.patch, cwd)) {
1560
+ fileHistory.saveSnapshot(target, turnSeq);
1561
+ }
905
1562
  }
906
1563
  return {};
907
- }, 100, "file_history_backup");
1564
+ };
1565
+ this.hooks.register("on_tool_start", fileHistoryHandler, 100, "file_history_backup");
908
1566
  // Hook: agent start
909
1567
  await this.emitHook("on_agent_start", {
910
1568
  sessionId: session.state.sessionId,
911
1569
  task,
912
1570
  model: this.config.llm.model,
913
1571
  });
1572
+ // Goal mode: register a GoalStopHook for the lifetime of THIS run so the
1573
+ // turn loop keeps going until the session model judges the goal met.
1574
+ // Registered per-run (and cleared in `finally`) so a later goal-less
1575
+ // send doesn't inherit a stale goal. The judge runs on `auxSummaryClient`
1576
+ // — the same cheap aux model used for summarize/compaction — not the
1577
+ // (potentially expensive) session model: "is this goal met?" is a classic
1578
+ // aux-tier task, and a goal run can invoke the judge up to maxStopBlocks
1579
+ // times.
1580
+ // Normalize the raw goal (string | GoalConfig) once at the run boundary;
1581
+ // everything inward uses the GoalConfig. normalizeGoal() returns undefined
1582
+ // when there's effectively no goal (empty objective).
1583
+ //
1584
+ // PERSISTENT GOAL (CC /goal style): a goal set on one send survives across
1585
+ // later sends and manual interrupts until met or cleared. Resolution:
1586
+ // 1. options.goal — this send explicitly sets/replaces the goal.
1587
+ // 2. session.state.activeGoal — a goal set on an earlier send.
1588
+ // 3. config.goal — engine-level default (rare; e.g. headless).
1589
+ // When (1) supplies a goal that differs from the stored one we REPLACE the
1590
+ // persisted active goal (one active goal per session) and announce it. A
1591
+ // bare send with no options.goal inherits the stored active goal so the
1592
+ // model keeps working toward it — that's what makes it persistent.
1593
+ const explicitGoal = normalizeGoal(options?.goal);
1594
+ const storedGoal = this.config.isSubAgent !== true ? session.state.activeGoal : undefined;
1595
+ if (explicitGoal && this.config.isSubAgent !== true) {
1596
+ const replaced = !!storedGoal && storedGoal.objective !== explicitGoal.objective;
1597
+ // Stamp WHEN this goal was set so the judge can anchor relative deadlines
1598
+ // ("做到3点") to the set time, not "now" — else once the clock passes the
1599
+ // deadline the judge could read "3点" as tomorrow's and never stop. A new
1600
+ // or changed objective gets a fresh stamp; re-sending the SAME objective
1601
+ // keeps the original anchor (the goal continues, the user didn't restate a
1602
+ // new deadline). User input never carries setAtMs, so we set it here.
1603
+ explicitGoal.setAtMs = resolveGoalSetAt(explicitGoal.objective, storedGoal, Date.now());
1604
+ session.state.activeGoal = explicitGoal;
1605
+ this.sessionManager.saveState(session.state);
1606
+ options?.onStream?.({
1607
+ type: "goal_set",
1608
+ objective: explicitGoal.objective,
1609
+ replaced,
1610
+ });
1611
+ }
1612
+ const normalizedGoal = explicitGoal ?? storedGoal ?? normalizeGoal(this.config.goal);
1613
+ let goalHookHandler = null;
1614
+ if (normalizedGoal && this.config.isSubAgent !== true) {
1615
+ goalHookHandler = createGoalStopHook({
1616
+ goal: normalizedGoal,
1617
+ llm: auxSummaryClient,
1618
+ log: logger,
1619
+ // Clear the persisted active goal the moment the judge says it's met,
1620
+ // so a later bare send doesn't re-inherit a satisfied goal. The hook
1621
+ // calls this from inside its met branch (single source of truth for
1622
+ // "goal achieved"); engine owns the persistence side-effect.
1623
+ onMet: () => {
1624
+ if (session.state.activeGoal) {
1625
+ session.state.activeGoal = undefined;
1626
+ this.sessionManager.saveState(session.state);
1627
+ }
1628
+ },
1629
+ // Re-read the persisted goal each turn so a mid-run 清除 (clearGoal
1630
+ // wrote state.json but this hook's frozen goal copy + the closure's
1631
+ // in-RAM session are untouched) actually stops the judge. Reads disk
1632
+ // via readActiveGoal — authoritative and independent of which session
1633
+ // instance the run closure holds.
1634
+ isGoalActive: (sid) => this.sessionManager.readActiveGoal(sid) !== undefined,
1635
+ });
1636
+ this.hooks.register("on_stop", goalHookHandler, 0, "goal-stop");
1637
+ // Expose for clearGoal() mid-run. Already guarded by isSubAgent above.
1638
+ this.activeGoalHook = goalHookHandler;
1639
+ }
914
1640
  // Surface compaction events to the UI so the user knows when context was trimmed.
915
1641
  // Buffer the most recent event so TurnLoop can drain it and emit the
916
1642
  // post_compact hook on the next turn (ContextManager itself doesn't
@@ -936,6 +1662,22 @@ export class Engine {
936
1662
  pendingCompactInfo = null;
937
1663
  return info;
938
1664
  },
1665
+ consumeSteer: () => this.consumeSteer(sid),
1666
+ // Clear the persisted goal for a self-reported completion / confirmed
1667
+ // cancel. Clears the in-RAM session's activeGoal (so THIS run's later
1668
+ // turns don't re-arm) AND persists it, and drops the in-flight stop
1669
+ // hook so nothing re-blocks the stop we're about to return.
1670
+ clearPersistedGoal: () => {
1671
+ if (session.state.activeGoal !== undefined) {
1672
+ session.state.activeGoal = undefined;
1673
+ this.sessionManager.saveState(session.state);
1674
+ }
1675
+ if (goalHookHandler) {
1676
+ this.hooks.unregister("on_stop", goalHookHandler);
1677
+ if (this.activeGoalHook === goalHookHandler)
1678
+ this.activeGoalHook = null;
1679
+ }
1680
+ },
939
1681
  ctxOverheadStore: {
940
1682
  get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
941
1683
  set: (s, n) => {
@@ -943,29 +1685,142 @@ export class Engine {
943
1685
  },
944
1686
  },
945
1687
  }, {
946
- maxTurns: this.config.maxTurns ?? 100,
947
- maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 10,
1688
+ // Goal mode raises the turn ceiling: an unattended goal run keeps
1689
+ // getting re-blocked by the stop-hook until it's done, and the 100
1690
+ // interactive default would silently truncate a long objective. The
1691
+ // real backstops are the goal token/time budgets + maxStopBlocks.
1692
+ maxTurns: resolveMaxTurns(this.config.maxTurns, normalizedGoal),
1693
+ // Consecutive stop-block cap: config override > goal.maxStopBlocks >
1694
+ // GOAL_DEFAULT_MAX_STOP_BLOCKS(25). The old hardcoded 8 was too tight
1695
+ // for complex goals that legitimately get re-blocked while advancing.
1696
+ maxStopBlocks: resolveMaxStopBlocks(this.config.maxStopBlocks, normalizedGoal),
1697
+ // 25 (was 10): modern models routinely batch >10 parallel tool calls
1698
+ // (e.g. reading a dozen files at once). At 10 the excess was silently
1699
+ // dropped; the turn loop now also warns the model when it caps, but a
1700
+ // higher ceiling avoids the round-trip in the common case. (B-3)
1701
+ maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 25,
948
1702
  onStream: options?.onStream,
949
1703
  signal: options?.signal,
1704
+ // Goal mode: the active goal is surfaced to the on_stop handler via
1705
+ // ctx.data.goal; the GoalStopHook (registered above) judges it.
1706
+ goal: normalizedGoal,
950
1707
  // Heartbeat: flush turnCount + tokens to state.json after every turn
951
1708
  // so external observers (other CLI processes, /sid, the session list)
952
1709
  // see live progress instead of a stale snapshot from the last
953
1710
  // completed run.
954
1711
  onTurnBoundary: (turnCount) => {
955
1712
  session.state.turnCount = turnCount;
956
- const u = modelFacade.getUsage();
957
- session.state.tokenUsage = {
958
- promptTokens: u.totalPromptTokens,
959
- completionTokens: u.totalCompletionTokens,
960
- totalTokens: u.totalTokens,
961
- };
1713
+ // baseline + this run's running total (idempotent per boundary,
1714
+ // accumulates across runs; carries cacheRead/cacheCreation too).
1715
+ session.state.tokenUsage = foldRunUsage(usageBaseline, modelFacade.getUsage());
1716
+ // Surface the session-cumulative cache counts to the UI (the "本会话
1717
+ // 累计命中率" tooltip). Separate from turn-loop's per-response
1718
+ // usage_update (which drives the live context reading).
1719
+ const cum = session.state.tokenUsage;
1720
+ options?.onStream?.({
1721
+ type: "usage_update",
1722
+ promptTokens: cum.promptTokens,
1723
+ sessionPromptTokens: cum.promptTokens,
1724
+ sessionCacheReadTokens: cum.cacheReadTokens ?? 0,
1725
+ sessionCacheCreationTokens: cum.cacheCreationTokens ?? 0,
1726
+ });
962
1727
  if (this.config.costStore) {
963
1728
  session.state.costState = this.config.costStore.serialize();
964
1729
  }
965
1730
  this.sessionManager.saveState(session.state);
966
1731
  },
967
1732
  });
968
- const result = await turnLoop.run(messages);
1733
+ // Expose this run's loop for mid-run extension (TODO 3.1). Top-level only —
1734
+ // a sub-agent's loop is its own concern and isn't user-extendable.
1735
+ if (this.config.isSubAgent !== true)
1736
+ this.activeTurnLoop = turnLoop;
1737
+ // Expose this run's session bundle so a mid-run clearGoal() wipes the goal
1738
+ // on the very instance this loop keeps saving (see field doc). Top-level
1739
+ // only — sub-agents don't carry user-clearable persistent goals.
1740
+ if (this.config.isSubAgent !== true)
1741
+ this.activeRunSession = session;
1742
+ let result;
1743
+ try {
1744
+ result = await turnLoop.run(messages);
1745
+ // ── Headless: drain background sub-agents before resolving ───────
1746
+ // Unified background-work model (2026-06-17): the engine NO LONGER parks
1747
+ // every run waiting on background work. Background work (sub-agents,
1748
+ // video polls, shells) ends the turn, yields, and is picked up later by
1749
+ // the server's notification-wakeup path (maybeWakeIdleSession). The
1750
+ // INTERACTIVE path relies on that wakeup + a run-boundary re-check.
1751
+ //
1752
+ // HEADLESS is the exception: a one-shot `engine.run` whose caller takes
1753
+ // `result.text` as THE answer (automation / SDK) has no later turn to
1754
+ // pick up a wakeup — so it must wait, before resolving, until its own
1755
+ // background SUB-AGENTS finish and summarize. Only sub-agents (their
1756
+ // summary IS part of this run's result), NOT shells (a dev server never
1757
+ // exits → would hang headless forever) and NOT video (a long render the
1758
+ // one-shot run shouldn't block on). This replaces the old for(;;) park
1759
+ // (s-mpvf4rsj-bb6e4639 invariant) for the headless case only.
1760
+ const sid = session.state.sessionId;
1761
+ const isTopLevel = this.config.isSubAgent !== true;
1762
+ if (isTopLevel && this.isHeadless()) {
1763
+ let aborted = options?.signal?.aborted === true;
1764
+ // Loop: a summarize turn can spawn a NEW background sub-agent; keep
1765
+ // draining + summarizing until none remain. turnCount accumulates, so
1766
+ // the turn-loop's maxTurns still bounds runaway re-summarization.
1767
+ for (;;) {
1768
+ while (!aborted && asyncAgentRegistry.hasRunningForSession(sid)) {
1769
+ aborted = await this.waitForBackgroundAgentChange(sid, options?.signal);
1770
+ }
1771
+ let pending = notificationQueue.drainAll(sid);
1772
+ if (aborted && pending.length === 0) {
1773
+ // Abort race: an agent calls markCompleted (registry notify) and only
1774
+ // THEN enqueue (queue notify) as two separate statements. If the abort
1775
+ // fired before that agent's completion `.then` ran, the while above
1776
+ // exited on `aborted`, this drainAll caught nothing, and a naive
1777
+ // `break` here would drop the agent's output. Give still-settling
1778
+ // agents a bounded window to finish enqueuing, then drain once more.
1779
+ // Each wait is timeout-bounded so a genuinely stuck (never-completing)
1780
+ // agent can't hang abort cleanup forever — we'd rather lose nothing in
1781
+ // the common case and not hang in the pathological one.
1782
+ for (let i = 0; i < 20 && asyncAgentRegistry.hasRunningForSession(sid); i++) {
1783
+ const changed = await this.waitForBackgroundAgentChangeOrTimeout(sid, 25);
1784
+ if (!changed)
1785
+ break; // timed out with no state change → stop waiting
1786
+ }
1787
+ pending = notificationQueue.drainAll(sid);
1788
+ if (pending.length === 0)
1789
+ break;
1790
+ }
1791
+ else if (pending.length === 0) {
1792
+ break;
1793
+ }
1794
+ const injected = {
1795
+ role: "user",
1796
+ content: `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`,
1797
+ };
1798
+ if (aborted) {
1799
+ // Mark injected: a synthetic notification, not the user's own input —
1800
+ // the disk reader drops it on replay so no phantom user bubble.
1801
+ session.transcript.appendMessage(injected.role, injected.content, { injected: true });
1802
+ result = { ...result, messages: [...result.messages, injected] };
1803
+ break;
1804
+ }
1805
+ result = await turnLoop.run([...result.messages, injected]);
1806
+ }
1807
+ }
1808
+ }
1809
+ finally {
1810
+ // Run-scoped: drop the GoalStopHook so a later goal-less send on this
1811
+ // long-lived engine doesn't keep blocking stops.
1812
+ if (goalHookHandler)
1813
+ this.hooks.unregister("on_stop", goalHookHandler);
1814
+ if (this.activeGoalHook === goalHookHandler)
1815
+ this.activeGoalHook = null;
1816
+ if (this.activeTurnLoop === turnLoop)
1817
+ this.activeTurnLoop = null;
1818
+ if (this.activeRunSession === session)
1819
+ this.activeRunSession = null;
1820
+ // Run-scoped too: this handler is re-registered every run(), so it must be
1821
+ // dropped here or it stacks duplicates that re-snapshot on every tool.
1822
+ this.hooks.unregister("on_tool_start", fileHistoryHandler);
1823
+ }
969
1824
  this.lastMessages = result.messages;
970
1825
  this.compactedMessagesBySession.set(session.state.sessionId, this.stripUserContextMessage(result.messages, userContextMsg));
971
1826
  logger.info("engine.done", {
@@ -992,6 +1847,37 @@ export class Engine {
992
1847
  // transcript, save a session summary, and conditionally trigger
993
1848
  // auto-dream consolidation. Doesn't block the Engine result.
994
1849
  void this.runMemoryPipeline(session.transcript, session.state.sessionId, cwd, llmClient);
1850
+ // Fire-and-forget session title generation — only after the FIRST turn.
1851
+ // Reuses the already-resolved auxSummaryClient (aux model, cheap). Best-
1852
+ // effort: failures never touch the run result. The renderer writes the
1853
+ // title into the sidebar on receipt of the session_title stream event.
1854
+ {
1855
+ const messageEvents = session.transcript.getEvents("message");
1856
+ const userMsgEvents = messageEvents.filter((e) => e.data.role === "user");
1857
+ const userMsgCount = userMsgEvents.length;
1858
+ const onStream = options?.onStream;
1859
+ if (userMsgCount === 1 && onStream && result.text) {
1860
+ const rawContent = userMsgEvents[0]?.data?.content;
1861
+ const firstUserText = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent ?? "");
1862
+ void buildSessionTitle(auxSummaryClient, firstUserText, result.text)
1863
+ .then((title) => {
1864
+ if (title) {
1865
+ // Persist the title so it survives a localStorage wipe / disk
1866
+ // rebuild — it used to live only in the renderer's localStorage
1867
+ // index. This .then resolves AFTER the saveState below (:1892), so
1868
+ // it must save again itself rather than rely on that write.
1869
+ session.state.title = title;
1870
+ this.sessionManager.saveState(session.state);
1871
+ onStream({
1872
+ type: "session_title",
1873
+ sessionId: session.state.sessionId,
1874
+ title,
1875
+ });
1876
+ }
1877
+ })
1878
+ .catch(() => { });
1879
+ }
1880
+ }
995
1881
  // Update session state. Persist the raw terminal reason as the status so
996
1882
  // callers can distinguish user-cancelled (aborted_streaming) from real
997
1883
  // failures (model_error, prompt_too_long, ...) — previously every
@@ -999,12 +1885,9 @@ export class Engine {
999
1885
  // distinction and misled anyone reading state.json.
1000
1886
  session.state.turnCount = turnLoop.currentTurn;
1001
1887
  session.state.status = result.reason;
1888
+ // Session-cumulative (baseline + this run) for persistence...
1002
1889
  const usage = modelFacade.getUsage();
1003
- session.state.tokenUsage = {
1004
- promptTokens: usage.totalPromptTokens,
1005
- completionTokens: usage.totalCompletionTokens,
1006
- totalTokens: usage.totalTokens,
1007
- };
1890
+ session.state.tokenUsage = foldRunUsage(usageBaseline, usage);
1008
1891
  if (this.config.costStore) {
1009
1892
  session.state.costState = this.config.costStore.serialize();
1010
1893
  }
@@ -1035,8 +1918,73 @@ export class Engine {
1035
1918
  * task. Extracts durable memories from the transcript, saves a session
1036
1919
  * summary, and conditionally triggers auto-dream consolidation.
1037
1920
  */
1038
- async runMemoryPipeline(transcript, sessionId, cwd, llmClient) {
1921
+ /**
1922
+ * Resolve the LLM client for background/auxiliary work (memory extraction,
1923
+ * auto-dream). When settings.defaults.auxText names a valid pool model, build
1924
+ * (and cache) a dedicated client for it so per-turn book-keeping runs on a cheap
1925
+ * fast model instead of the expensive primary. Falls back to `fallback` (the
1926
+ * active run's client) when unset, unknown, or on any build failure — aux
1927
+ * work is best-effort and must never break a run.
1928
+ */
1929
+ async resolveAuxClient(fallback) {
1930
+ let auxKey;
1931
+ try {
1932
+ // Re-read from disk: settings may have been changed by the desktop
1933
+ // (a separate process) since this worker last cached them. This runs
1934
+ // once per run on the post-run background path, so the cost is fine.
1935
+ const sm = this.getSettingsManager();
1936
+ sm.invalidate();
1937
+ // Unified store's defaults.auxText (a connection id = pool key) selects
1938
+ // the aux model; resolveAuxKey returns it (or undefined).
1939
+ auxKey = resolveAuxKey(sm.get());
1940
+ }
1941
+ catch {
1942
+ return fallback;
1943
+ }
1944
+ if (!auxKey)
1945
+ return fallback;
1946
+ // Don't spin up a second client when the aux key resolves to the SAME
1947
+ // client config as this engine's active model. Compare FULL LLM IDENTITY
1948
+ // (model + reasoning + maxTokens + baseUrl + provider/providerKind) against
1949
+ // this engine's own per-session config.llm — NOT a separately-tracked active
1950
+ // key, and NOT just the model NAME. Two distinct pool keys can share the same
1951
+ // `model` string yet differ in reasoning/maxOutputTokens/baseUrl/apiKey/
1952
+ // providerKey; de-duping on the name alone would wrongly route the user's
1953
+ // chosen aux entry onto the primary's config. config.llm is isolated per
1954
+ // session and always set for a real run, so this is correct even for desktop
1955
+ // worker sessions built with a shared runtime (which never explicitly
1956
+ // switchModel, so the old activeModelKey field was undefined and defeated the
1957
+ // de-dup), AND immune to another session mutating the shared pool's activeKey.
1958
+ const entry = this.modelPool.get(auxKey);
1959
+ if (entry && sameLlmIdentity(this.modelPool.toLLMConfig(entry), this.config.llm)) {
1960
+ return fallback;
1961
+ }
1962
+ if (this.auxClientCache?.key === auxKey)
1963
+ return this.auxClientCache.client;
1964
+ if (!entry) {
1965
+ logger.warn("engine.aux_model_missing", { auxModelKey: auxKey });
1966
+ return fallback;
1967
+ }
1968
+ try {
1969
+ const client = await createLLMClient(this.modelPool.toLLMConfig(entry), this.config.clientDefaults);
1970
+ this.auxClientCache = { key: auxKey, client };
1971
+ return client;
1972
+ }
1973
+ catch (err) {
1974
+ logger.warn("engine.aux_model_build_failed", {
1975
+ auxModelKey: auxKey,
1976
+ error: err.message,
1977
+ });
1978
+ return fallback;
1979
+ }
1980
+ }
1981
+ async runMemoryPipeline(transcript, sessionId, cwd, primaryClient) {
1039
1982
  try {
1983
+ // Background calls run on the auxiliary model when configured, so memory
1984
+ // book-keeping doesn't burn the expensive primary model every turn.
1985
+ // settings.memories.extractionModel (if set + valid) overrides the aux
1986
+ // model specifically for memory extraction (TODO 8.1).
1987
+ const llmClient = await this.resolveExtractionClient(primaryClient);
1040
1988
  // Only run memory extraction for substantive sessions. The previous
1041
1989
  // threshold of 4 user+assistant messages was low enough that two-line
1042
1990
  // exchanges ("what's the time?" / "noon") triggered a full LLM
@@ -1069,12 +2017,16 @@ export class Engine {
1069
2017
  tools: [],
1070
2018
  maxTokens: 1024,
1071
2019
  recordUsage: false,
1072
- thinking: "disabled",
2020
+ reasoning: { mode: "off" },
1073
2021
  });
1074
2022
  return resp.text;
1075
2023
  },
1076
2024
  runDream: async ({ systemPrompt, userPrompt, projectDir }) => this.runDreamLoop({ systemPrompt, userPrompt, projectDir, llmClient, sessionId }),
1077
2025
  projectDir: cwd,
2026
+ // settings.memories.maxCount caps memories accepted per extraction;
2027
+ // autoExtract=false turns the extractor off (summaries/dream stay).
2028
+ maxCount: this.readMemoriesConfig()?.maxCount,
2029
+ autoExtract: this.readMemoriesConfig()?.autoExtract,
1078
2030
  });
1079
2031
  await orchestrator.run(plainMessages, sessionId);
1080
2032
  }
@@ -1103,117 +2055,20 @@ export class Engine {
1103
2055
  * bailed before the first LLM call (e.g. registry missing the tools).
1104
2056
  */
1105
2057
  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", {
2058
+ // The loop body now lives in services/dream-consolidation.ts so it can
2059
+ // also be driven from the desktop host's manual "整理 / Dream" trigger.
2060
+ // The orchestrator built systemPrompt/userPrompt from this engine's
2061
+ // MemoryManager already; runDreamConsolidation rebuilds them from the same
2062
+ // projectDir, so passing them here would be redundant — we just hand it the
2063
+ // tool registry + a memory-scoped tool context.
2064
+ const { ran } = await runDreamConsolidation({
2065
+ llmClient: opts.llmClient,
2066
+ toolRegistry: this.toolRegistry,
2067
+ toolContext: this.buildToolContext(),
2068
+ projectDir: opts.projectDir,
1180
2069
  sessionId: opts.sessionId,
1181
- maxTurns: MAX_TURNS,
1182
2070
  });
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
- }
2071
+ return ran;
1217
2072
  }
1218
2073
  getToolRegistry() {
1219
2074
  return this.toolRegistry;
@@ -1222,30 +2077,62 @@ export class Engine {
1222
2077
  * Switch the active model by pool key. Takes effect on the next run() call.
1223
2078
  * Returns the new model entry.
1224
2079
  *
1225
- * Persists settings.activeKey (and a legacy settings.model.* mirror) so the
2080
+ * Persists settings.defaults.text (= the connection id / pool key) so the
1226
2081
  * next process startup defaults to the same model — without this, switches
1227
2082
  * only live in memory and every restart reverts to the previously persisted
1228
- * activeKey.
2083
+ * defaults.text.
1229
2084
  */
1230
2085
  switchModel(key) {
1231
2086
  const entry = this.modelPool.switch(key);
1232
- const nextLlm = this.modelPool.toLLMConfig(entry, this.config.llm);
2087
+ // LLMConfig is pure model identity now — rotate it wholesale. Cross-model
2088
+ // runtime knobs (temperature/timeout/retryMaxAttempts/imageDetail) live on
2089
+ // this.config.clientDefaults and survive the switch untouched.
2090
+ const nextLlm = this.modelPool.toLLMConfig(entry);
1233
2091
  this.config = { ...this.config, llm: nextLlm };
1234
- this.persistActiveModel(entry, nextLlm);
2092
+ this.persistActiveModel(entry);
1235
2093
  return entry;
1236
2094
  }
2095
+ /**
2096
+ * Zero a session's cumulative token/cache usage on disk. Called on a model
2097
+ * switch: a different model has its own prompt cache, so the accumulated
2098
+ * cache-hit stats from the prior model are no longer meaningful. The next
2099
+ * run's baseline (snapshotted from state.tokenUsage) then starts from zero.
2100
+ */
2101
+ resetSessionUsage(sessionId) {
2102
+ const zero = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
2103
+ // If the session is mid-run right now, update its live state so the next
2104
+ // turn-boundary write doesn't re-fold a stale baseline.
2105
+ if (this.activeRunSession?.state.sessionId === sessionId) {
2106
+ this.activeRunSession.state.tokenUsage = { ...zero };
2107
+ }
2108
+ // Persist to disk so a reload / next run picks up the reset.
2109
+ if (this.sessionManager.exists(sessionId)) {
2110
+ try {
2111
+ const bundle = this.sessionManager.resume(sessionId);
2112
+ bundle.state.tokenUsage = { ...zero };
2113
+ this.sessionManager.saveState(bundle.state);
2114
+ }
2115
+ catch {
2116
+ // Session not resumable (never persisted yet) — the in-memory reset
2117
+ // above covers the live case; nothing else to do.
2118
+ }
2119
+ }
2120
+ }
1237
2121
  /**
1238
2122
  * Write the active model selection to ~/.code-shell/settings.json.
1239
2123
  *
1240
- * We mirror into the legacy settings.model.* block (provider/name/apiKey/
1241
- * baseUrl) because boot paths in cli/main.ts, repl.ts, run.ts still read it.
1242
- * The mirror uses resolved llm values (not raw entry.*) so credentials that
1243
- * live on settings.providers[] flow through correctly — entry.apiKey is
1244
- * undefined when the entry resolves via providerCatalog.
2124
+ * Persists settings.defaults.text = entry.key (the connection id == pool
2125
+ * key). That is the single field the boot path reads to restore the active
2126
+ * text model on the next startup (see ctor: settings.defaults.text pool
2127
+ * switch). No legacy activeKey/model.* mirror is written.
1245
2128
  */
1246
- persistActiveModel(entry, llm) {
2129
+ persistActiveModel(entry) {
1247
2130
  try {
1248
- const dir = join(homedir(), ".code-shell");
2131
+ // userHome() (not raw homedir()) so a test that sets process.env.HOME to
2132
+ // a tmpdir gets its writes isolated too — the SettingsManager reader
2133
+ // already honors HOME; the writer must match or tests pollute the real
2134
+ // ~/.code-shell/settings.json (this happened: an A-key/model-a leak).
2135
+ const dir = join(userHome(), ".code-shell");
1249
2136
  const file = join(dir, "settings.json");
1250
2137
  mkdirSync(dir, { recursive: true });
1251
2138
  let existing = {};
@@ -1258,29 +2145,30 @@ export class Engine {
1258
2145
  return;
1259
2146
  }
1260
2147
  }
1261
- const prevModel = typeof existing.model === "object" && existing.model
1262
- ? existing.model
2148
+ const prevDefaults = typeof existing.defaults === "object" && existing.defaults
2149
+ ? existing.defaults
1263
2150
  : {};
1264
2151
  const updated = {
1265
2152
  ...existing,
1266
- activeKey: entry.key,
1267
- model: {
1268
- ...prevModel,
1269
- provider: llm.provider,
1270
- name: entry.model,
1271
- apiKey: llm.apiKey,
1272
- baseUrl: llm.baseUrl,
1273
- },
2153
+ defaults: { ...prevDefaults, text: entry.key },
1274
2154
  };
2155
+ // mode 0o600: this writes model.apiKey (plaintext) into settings.json, so
2156
+ // it must be owner-only — same R-1 hardening as SettingsManager/onboarding.
2157
+ // (Third settings.json writer; the R-1 sweep initially missed this one.)
1275
2158
  const tmp = `${file}.${process.pid}.tmp`;
1276
2159
  const payload = JSON.stringify(updated, null, 2) + "\n";
1277
- writeFileSync(tmp, payload, "utf-8");
2160
+ writeFileSync(tmp, payload, { encoding: "utf-8", mode: 0o600 });
1278
2161
  try {
1279
2162
  renameSync(tmp, file);
1280
2163
  }
1281
2164
  catch {
1282
- writeFileSync(file, payload, "utf-8");
2165
+ writeFileSync(file, payload, { encoding: "utf-8", mode: 0o600 });
2166
+ }
2167
+ // mode arg only applies on create; tighten an already-existing file too.
2168
+ try {
2169
+ chmodSync(file, 0o600);
1283
2170
  }
2171
+ catch { /* best-effort */ }
1284
2172
  }
1285
2173
  catch (err) {
1286
2174
  logger.warn(`persistActiveModel failed: ${err.message}`);
@@ -1303,6 +2191,82 @@ export class Engine {
1303
2191
  getConfig() {
1304
2192
  return this.config;
1305
2193
  }
2194
+ /**
2195
+ * Config hot-reload "layer 2": merge a disk-default config patch into this
2196
+ * ALREADY-RUNNING session's `this.config`, reload settings hooks, and
2197
+ * incrementally connect any newly-added MCP servers. Applied at the next
2198
+ * turn boundary — an in-flight turn is NOT interrupted: it keeps using the
2199
+ * PromptComposer it was built with, and the next turn rebuilds the composer
2200
+ * from the freshly-merged config (composer is rebuilt per-turn).
2201
+ *
2202
+ * `version` is a monotonic counter from the server: stale (<=last applied)
2203
+ * payloads are dropped so out-of-order reload deliveries can't let an older
2204
+ * config clobber a newer one (Q5).
2205
+ *
2206
+ * MCP: reconciles the shared MCP pool against the new disk-default server
2207
+ * set. Added servers connect idempotently; removed/disabled servers are
2208
+ * disconnected and their registered MCP tools are unregistered so plugin
2209
+ * disable takes effect without an Electron restart.
2210
+ *
2211
+ * Preset (#2): a preset hot-reload re-resolves `this.preset` so the next-turn
2212
+ * PromptComposer picks up the new preset's system prompt / behavior — that's
2213
+ * the main user-visible preset effect and it IS hot. The toolRegistry's
2214
+ * builtin tool SET, however, is ctor-frozen (and may be shared via runtime):
2215
+ * it is NOT rebuilt here. So a preset change that alters the builtin tool set
2216
+ * (e.g. general → terminal-coding adds LSP/Brief) only takes effect on the
2217
+ * next session restart; we log a warning when that case is detected.
2218
+ *
2219
+ * disk-default-vs-slice caveat (#8): the patch carries pure DISK-default
2220
+ * values (preset/customSystemPrompt/appendSystemPrompt/responseLanguage/
2221
+ * userProfile — see diskDefaultsFrom). Spreading them here OVERRIDES any
2222
+ * per-request slice override of the same field. This is correct for the
2223
+ * desktop host today (its per-request slice only carries permissionMode+cwd,
2224
+ * which are excluded from the disk patch). A future host that sets
2225
+ * slice.preset (or the other prompt fields) per-request MUST exclude those
2226
+ * from the reload patch — or track per-request overrides separately — or this
2227
+ * reload will clobber them back to disk values.
2228
+ */
2229
+ refreshRuntimeConfig(patch, version) {
2230
+ if (version <= this.lastAppliedConfigVersion)
2231
+ return;
2232
+ const prevServers = this.config.mcpServers ?? {};
2233
+ const prevPresetName = this.preset.name;
2234
+ this.config = { ...this.config, ...patch };
2235
+ // #2: re-resolve the prompt-affecting preset so the next-turn PromptComposer
2236
+ // (rebuilt per turn from this.preset) reflects the new preset's system
2237
+ // prompt / behavior. Only when the preset actually changed.
2238
+ if (patch.preset !== undefined && patch.preset !== prevPresetName) {
2239
+ const nextPreset = resolveAgentPreset(this.config.preset);
2240
+ // The builtin tool SET is ctor-frozen and may be shared via runtime — we
2241
+ // do NOT rebuild it here. If the new preset implies a different builtin
2242
+ // tool set, that part of the change only lands on session restart.
2243
+ const prevTools = resolveBuiltinToolNames({ preset: prevPresetName }).slice().sort().join(",");
2244
+ const nextTools = resolveBuiltinToolNames({ preset: nextPreset.name }).slice().sort().join(",");
2245
+ if (prevTools !== nextTools) {
2246
+ logger.warn("engine.preset_reload.tool_set_change_needs_restart", {
2247
+ from: prevPresetName,
2248
+ to: nextPreset.name,
2249
+ note: "preset system prompt hot-reloaded; builtin tool-set change takes effect on session restart",
2250
+ });
2251
+ }
2252
+ this.preset = nextPreset;
2253
+ }
2254
+ this.reloadHooks();
2255
+ if (patch.mcpServers && this.mcpManager) {
2256
+ // Fire-and-forget reconcile (connect added / disconnect removed servers).
2257
+ // It must NOT surface as an unhandled rejection: a single flaky server
2258
+ // that fails to connect/disconnect during hot-reload would otherwise
2259
+ // crash the host process (or be silently swallowed). Catch + log so the
2260
+ // reconcile is best-effort and the next reload can retry.
2261
+ void this.mcpManager.reconcile(patch.mcpServers, this).catch((err) => {
2262
+ logger.error("engine.mcp_reconcile_failed", {
2263
+ error: err instanceof Error ? err.message : String(err),
2264
+ version,
2265
+ });
2266
+ });
2267
+ }
2268
+ this.lastAppliedConfigVersion = version;
2269
+ }
1306
2270
  /**
1307
2271
  * Inject context into a session's transcript without triggering a LLM turn.
1308
2272
  * The injected content appears as an assistant message so the LLM can see it
@@ -1312,6 +2276,55 @@ export class Engine {
1312
2276
  * engine.run() call for this session picks up the injected content
1313
2277
  * instead of a stale snapshot from the previous run.
1314
2278
  */
2279
+ /**
2280
+ * Read a session's persisted active goal WITHOUT resuming it (cheap — reads
2281
+ * only state.json via SessionManager.readActiveGoal). The desktop host calls
2282
+ * this on session load to re-surface the goal block + its Cancel button: a
2283
+ * persistent goal lives only in state.activeGoal and is never replayed from
2284
+ * the transcript, so after a reload of an aborted goal run the UI would
2285
+ * otherwise show nothing (the "goal 还在但页面不显示、取消不了" bug). Returns
2286
+ * undefined when the session is unknown or has no active goal.
2287
+ */
2288
+ getGoal(sessionId) {
2289
+ return this.sessionManager.readActiveGoal(sessionId);
2290
+ }
2291
+ /**
2292
+ * Clear a session's persisted active goal (CC `/goal clear`). Works whether
2293
+ * the session is idle or its goal run is in flight: it wipes
2294
+ * `state.activeGoal` (so the next bare send won't re-inherit it) and, if a
2295
+ * goal hook is currently registered for this engine, unregisters it so an
2296
+ * in-flight run can stop instead of being re-blocked by the now-cleared goal.
2297
+ * Returns true if a goal was actually cleared. Idempotent — clearing a
2298
+ * session with no active goal is a no-op returning false.
2299
+ */
2300
+ clearGoal(sessionId) {
2301
+ if (!this.sessionManager.exists(sessionId))
2302
+ return false;
2303
+ // Prefer the LIVE run's bundle when it's this session: clearing its
2304
+ // in-RAM state.activeGoal is what stops the run loop from writing the goal
2305
+ // back on its next saveState. A fresh resume() copy would be cleared and
2306
+ // persisted, but the running loop's own detached bundle still holds the
2307
+ // goal and resurrects it — the stale-write-back race. Falls back to a
2308
+ // resumed copy when no run of this session is currently in flight.
2309
+ const live = this.activeRunSession && this.activeRunSession.state.sessionId === sessionId
2310
+ ? this.activeRunSession
2311
+ : null;
2312
+ const session = live ?? this.sessionManager.resume(sessionId);
2313
+ const had = session.state.activeGoal !== undefined;
2314
+ if (had) {
2315
+ session.state.activeGoal = undefined;
2316
+ this.sessionManager.saveState(session.state);
2317
+ }
2318
+ // If THIS session's goal run is in flight, drop its stop hook so the
2319
+ // current run can terminate (the closure-held goal would otherwise keep
2320
+ // re-blocking). The run's own `finally` also unregisters; double-unregister
2321
+ // is safe (set delete is idempotent).
2322
+ if (this.activeGoalHook && this.lastSessionId === sessionId) {
2323
+ this.hooks.unregister("on_stop", this.activeGoalHook);
2324
+ this.activeGoalHook = null;
2325
+ }
2326
+ return had;
2327
+ }
1315
2328
  injectContext(sessionId, content) {
1316
2329
  const session = this.sessionManager.resume(sessionId);
1317
2330
  session.transcript.appendMessage("assistant", content);
@@ -1332,7 +2345,6 @@ export class Engine {
1332
2345
  if (!this.lastContextManager || !sessionId) {
1333
2346
  return { before: 0, after: 0, strategy: "none (no active session)" };
1334
2347
  }
1335
- const { estimateTokens } = require("../context/compaction.js");
1336
2348
  const sourceMessages = this.compactedMessagesBySession.get(sessionId) ??
1337
2349
  this.sessionManager.resume(sessionId).transcript.toMessages();
1338
2350
  const before = estimateTokens(sourceMessages);
@@ -1354,7 +2366,7 @@ export class Engine {
1354
2366
  }
1355
2367
  getSettingsManager() {
1356
2368
  if (!this.settingsManager) {
1357
- this.settingsManager = new SettingsManager(this.config.cwd, this.config.settingsScope ?? "project");
2369
+ this.settingsManager = new SettingsManager(this.config.cwd, this.config.settingsScope ?? "project", this.config.projectTrusted !== false);
1358
2370
  }
1359
2371
  return this.settingsManager;
1360
2372
  }
@@ -1406,7 +2418,7 @@ export class Engine {
1406
2418
  rules.push({ tool: "Bash", decision: "allow" });
1407
2419
  }
1408
2420
  try {
1409
- const settingsManager = new SettingsManager(cwd, this.config.settingsScope ?? "project");
2421
+ const settingsManager = new SettingsManager(cwd, this.config.settingsScope ?? "project", this.config.projectTrusted !== false);
1410
2422
  const settings = settingsManager.get();
1411
2423
  if (settings.permissions?.rules?.length) {
1412
2424
  rules.unshift(...settings.permissions.rules);
@@ -1461,6 +2473,26 @@ export class Engine {
1461
2473
  getPermissionMode() {
1462
2474
  return this.config.permissionMode ?? "acceptEdits";
1463
2475
  }
2476
+ /**
2477
+ * Extend the in-flight run's turn ceiling and/or goal budgets (TODO 3.1 —
2478
+ * 运行中续轮/加预算). No-op (returns null) when no run is active. Lets a user
2479
+ * keep an unattended goal going past its original cap instead of restarting.
2480
+ */
2481
+ extendGoalRun(opts) {
2482
+ if (!this.activeTurnLoop)
2483
+ return null;
2484
+ return this.activeTurnLoop.extend(opts);
2485
+ }
2486
+ /**
2487
+ * The effective permission rules for the current mode + cwd (TODO 5.1) —
2488
+ * preset defaults + mode-derived + settings.permissions.rules, in the same
2489
+ * order the classifier evaluates them. Exposed read-only so `/permissions`
2490
+ * (and any UI) can list what's actually in force. Pure read; builds the same
2491
+ * rule set buildPermissionConfig does, without constructing a backend.
2492
+ */
2493
+ getPermissionRules() {
2494
+ return this.buildPermissionConfig(this.getPermissionMode(), this.config.cwd ?? process.cwd()).rules;
2495
+ }
1464
2496
  /**
1465
2497
  * Toggle plan mode directly. Called by the Plan tool (Task 7) via ToolContext.engine.
1466
2498
  * Also syncs permissionMode to keep both fields consistent.
@@ -1477,36 +2509,275 @@ export class Engine {
1477
2509
  this.planMode = value;
1478
2510
  }
1479
2511
  }
2512
+ /**
2513
+ * Block until a background agent's state changes (finishes / its result is
2514
+ * enqueued) or `signal` aborts. Resolves `true` if aborted, `false` on a
2515
+ * change. The caller re-checks `hasRunningForSession` after each wake, so a
2516
+ * spurious wake (another session's agent) just loops again.
2517
+ *
2518
+ * Subscribes to BOTH the registry AND the notification queue — and that's
2519
+ * load-bearing, not belt-and-suspenders. A completing agent calls
2520
+ * `markCompleted` (registry notify) and only THEN `enqueue` (queue notify),
2521
+ * as two separate statements. If we woke on the registry notify alone, the
2522
+ * loop could re-check, see no running agents, and `drainAll` BEFORE the
2523
+ * result was enqueued — silently losing the last agent's output. Waking on
2524
+ * the queue notify guarantees the item is already in the bucket. But a
2525
+ * *cancelled* agent marks-but-never-enqueues (by design), so we also need
2526
+ * the registry notify or a final cancel would hang the wait forever. Hence
2527
+ * both. Subscribe-before-await closes the check/wait race either way.
2528
+ */
2529
+ waitForBackgroundAgentChange(_sessionId, signal) {
2530
+ if (signal?.aborted)
2531
+ return Promise.resolve(true);
2532
+ return new Promise((resolve) => {
2533
+ let settled = false;
2534
+ const finish = (aborted) => {
2535
+ if (settled)
2536
+ return;
2537
+ settled = true;
2538
+ unsubRegistry();
2539
+ unsubQueue();
2540
+ signal?.removeEventListener("abort", onAbort);
2541
+ resolve(aborted);
2542
+ };
2543
+ const onAbort = () => finish(true);
2544
+ const unsubRegistry = asyncAgentRegistry.subscribe(() => finish(false));
2545
+ const unsubQueue = notificationQueue.subscribe(() => finish(false));
2546
+ signal?.addEventListener("abort", onAbort, { once: true });
2547
+ });
2548
+ }
2549
+ /**
2550
+ * Like waitForBackgroundAgentChange but with no abort signal and a hard
2551
+ * timeout. Resolves `true` on a registry/queue change, `false` if `timeoutMs`
2552
+ * elapses first. Used only by the headless abort-drain cleanup, where we want
2553
+ * to catch a completing agent's just-about-to-enqueue notification without
2554
+ * risking a permanent hang on an agent that never completes.
2555
+ */
2556
+ waitForBackgroundAgentChangeOrTimeout(_sessionId, timeoutMs) {
2557
+ return new Promise((resolve) => {
2558
+ let settled = false;
2559
+ const finish = (changed) => {
2560
+ if (settled)
2561
+ return;
2562
+ settled = true;
2563
+ clearTimeout(timer);
2564
+ unsubRegistry();
2565
+ unsubQueue();
2566
+ resolve(changed);
2567
+ };
2568
+ const timer = setTimeout(() => finish(false), timeoutMs);
2569
+ const unsubRegistry = asyncAgentRegistry.subscribe(() => finish(true));
2570
+ const unsubQueue = notificationQueue.subscribe(() => finish(true));
2571
+ });
2572
+ }
1480
2573
  /**
1481
2574
  * Sub-agent role registry for the given cwd, memoized per-cwd so the
1482
2575
  * directory is read once rather than every turn. A new cwd (e.g. via
1483
2576
  * run({ cwd })) reloads.
1484
2577
  */
1485
2578
  getAgentDefinitions(cwd) {
1486
- if (this.agentDefsCache?.cwd !== cwd) {
1487
- this.agentDefsCache = { cwd, reg: loadAgentDefinitionsForCwd(cwd) };
2579
+ const disabledAgents = this.readDisabledAgents(cwd);
2580
+ const disabledPlugins = this.readDisabledLists().disabledPlugins;
2581
+ const disabledKey = [...disabledAgents, "::", ...disabledPlugins]
2582
+ .slice()
2583
+ .sort()
2584
+ .join(" ");
2585
+ if (this.agentDefsCache?.cwd !== cwd ||
2586
+ this.agentDefsCache.disabledKey !== disabledKey) {
2587
+ this.agentDefsCache = {
2588
+ cwd,
2589
+ disabledKey,
2590
+ reg: loadAgentDefinitionsForCwd(cwd, disabledAgents, disabledPlugins),
2591
+ };
1488
2592
  }
1489
2593
  return this.agentDefsCache.reg;
1490
2594
  }
2595
+ /**
2596
+ * Read settings.disabledAgents, folded with the project's
2597
+ * capabilityOverrides.agents overlay for `cwd`. Unlike disabledSkills,
2598
+ * sub-agents do NOT skip this — a disabled role must stay invisible
2599
+ * everywhere. The overlay lets a project force-enable a globally-disabled
2600
+ * role or force-disable a globally-enabled one (tri-state); read UNMERGED
2601
+ * (getForScope) so inherit survives. No cwd / no overlay → baseline
2602
+ * unchanged. Mirrors readDisabledLists (skills/plugins).
2603
+ */
2604
+ readDisabledAgents(cwd) {
2605
+ try {
2606
+ const sm = this.getSettingsManager();
2607
+ const settings = sm.get();
2608
+ const baseline = Array.isArray(settings.disabledAgents) ? settings.disabledAgents : [];
2609
+ const overrides = cwd
2610
+ ? sm.getForScope("project", cwd).capabilityOverrides
2611
+ : undefined;
2612
+ return effectiveDisabledList(baseline, overrides?.agents);
2613
+ }
2614
+ catch {
2615
+ return [];
2616
+ }
2617
+ }
2618
+ /**
2619
+ * Read the project's capabilityOverrides.builtin bucket for `cwd`, read
2620
+ * UNMERGED (getForScope) so tri-state inherit survives. Sub-agents skip the
2621
+ * overlay (minimal surface, same as readDisabledLists) — their builtin lists
2622
+ * are already narrowed by resolveChildToolScope. No cwd / error → undefined,
2623
+ * so the caller's baseline builtin lists pass through unchanged.
2624
+ */
2625
+ readBuiltinOverride(cwd) {
2626
+ if (this.config.isSubAgent === true || !cwd)
2627
+ return undefined;
2628
+ try {
2629
+ const overrides = this.getSettingsManager().getForScope("project", cwd)
2630
+ .capabilityOverrides;
2631
+ return overrides?.builtin;
2632
+ }
2633
+ catch {
2634
+ return undefined;
2635
+ }
2636
+ }
1491
2637
  /**
1492
2638
  * Build a base ToolContext for this Engine. Used by run() (which then
1493
2639
  * overlays turn-specific fields like sandbox and subAgentSpawner) and
1494
2640
  * by tests that want a ToolContext without a full run() cycle.
1495
2641
  */
2642
+ resolveSandboxWithoutRuntime(config, cwd) {
2643
+ const key = sandboxCacheKey(config, cwd);
2644
+ let cached = this.sandboxCache.get(key);
2645
+ if (!cached) {
2646
+ cached = resolveSandboxBackend(config, cwd);
2647
+ // Mirror EngineRuntime.resolveSandbox: don't cache a rejection, or an
2648
+ // explicit-mode probe that throws stays sticky until process restart even
2649
+ // after the user fixes the config.
2650
+ cached.catch(() => {
2651
+ if (this.sandboxCache.get(key) === cached)
2652
+ this.sandboxCache.delete(key);
2653
+ });
2654
+ this.sandboxCache.set(key, cached);
2655
+ }
2656
+ return cached;
2657
+ }
2658
+ /**
2659
+ * Build the shell env layered onto the Bash tool / background shells (see
2660
+ * mergeShellEnv). Three user-configured sources, merged lowest → highest:
2661
+ *
2662
+ * 1. project `localEnvironment.env` — the per-project "local environment"
2663
+ * panel (DATABASE_URL etc.); the floor, so a project's own panel values
2664
+ * can be overridden by an explicit top-level `env`.
2665
+ * 2. global top-level `env` — ~/.code-shell/settings.json; the
2666
+ * canonical home for API keys (OPENAI_API_KEY) a skill script reads —
2667
+ * configure once, every project's skills get it.
2668
+ * 3. project top-level `env` — .code-shell/settings.json; a project
2669
+ * that wants to override a global key wins.
2670
+ *
2671
+ * Each scope is read UNMERGED so the layering here is the single source of
2672
+ * precedence (getForScope merges nothing). Returns undefined when no layer
2673
+ * contributes a key, so the caller passes it through unchanged for projects
2674
+ * that configure none.
2675
+ *
2676
+ * Sub-agents: a sub-agent is the user's OWN agent doing the user's work
2677
+ * (mirrors Claude Code, where sub-agents inherit the parent environment), so
2678
+ * it now reads the SAME env as the parent. The sub-agent branch is kept as an
2679
+ * explicit seam (`filterSubagentEnv`) rather than removed — a future policy
2680
+ * could narrow what a sub-agent sees (e.g. drop credential secrets) by
2681
+ * changing that one hook; today it passes everything through unchanged.
2682
+ * A no-cwd context still gets nothing (there is genuinely no project to read).
2683
+ *
2684
+ * None of these is filtered through the deny regex (mergeShellEnv): the user
2685
+ * put them there deliberately. The allowlist/deny machinery only guards the
2686
+ * host's process.env from a tainted model exfiltrating it via `env | curl`.
2687
+ */
2688
+ readShellEnv(cwd) {
2689
+ if (!cwd)
2690
+ return undefined;
2691
+ const merged = {};
2692
+ const layer = (env) => {
2693
+ if (!env)
2694
+ return;
2695
+ for (const [k, v] of Object.entries(env)) {
2696
+ if (typeof v === "string")
2697
+ merged[k] = v;
2698
+ }
2699
+ };
2700
+ try {
2701
+ // The fully-merged settings already apply the scope guard (a 'project'
2702
+ // scope never reads the host ~/.code-shell) and the user < project <
2703
+ // local precedence — so the top-level `env` map read from here is global
2704
+ // values overridden by project values, exactly as specified. We layer
2705
+ // localEnvironment.env *under* it as the floor.
2706
+ const settings = this.getSettingsManager().get();
2707
+ layer(settings.localEnvironment?.env); // floor
2708
+ // Credentials flagged "expose as env var" (Credential.exposeAsEnv). This
2709
+ // is the wiring that was missing — the UI/store recorded the flag but no
2710
+ // code ever injected the secret, so `$FIGMA_TOKEN` was always empty.
2711
+ // Scope mirrors settingsScope so a project-scoped engine never surfaces
2712
+ // the host user's credentials (same isolation contract as top-level env).
2713
+ // Placed below settings.env so an explicit `env` entry can still override.
2714
+ const credScope = (this.config.settingsScope ?? "project") === "full" ? "full" : "project";
2715
+ layer(new CredentialStore(cwd).envExposures(credScope));
2716
+ layer(settings.env); // top-level env (global ⊕ project) wins
2717
+ }
2718
+ catch {
2719
+ return undefined;
2720
+ }
2721
+ const result = this.config.isSubAgent === true ? this.filterSubagentEnv(merged) : merged;
2722
+ return Object.keys(result).length > 0 ? result : undefined;
2723
+ }
2724
+ /**
2725
+ * Policy seam for what a sub-agent's shell sees. A sub-agent inherits the
2726
+ * parent environment by default (mirrors Claude Code), so this is the
2727
+ * identity function today. It exists so a future policy can narrow the set
2728
+ * (e.g. strip credential `exposeAsEnv` secrets, or allowlist by name) in ONE
2729
+ * place instead of scattering `isSubAgent` checks through readShellEnv.
2730
+ */
2731
+ filterSubagentEnv(env) {
2732
+ return env;
2733
+ }
2734
+ /**
2735
+ * Read the project's `localEnvironment.setupScripts` for this cwd (the raw
2736
+ * per-platform map). Used by EnterWorktree to run setup once in a freshly
2737
+ * created worktree. Returns undefined for sub-agents / no cwd (same minimal
2738
+ * surface as readShellEnv). The platform selection + run live in
2739
+ * git/worktree.ts; this only fetches the configured scripts.
2740
+ */
2741
+ readWorktreeSetupScripts(cwd) {
2742
+ if (this.config.isSubAgent === true || !cwd)
2743
+ return undefined;
2744
+ try {
2745
+ const scoped = this.getSettingsManager().getForScope("project", cwd);
2746
+ return scoped.localEnvironment?.setupScripts;
2747
+ }
2748
+ catch {
2749
+ return undefined;
2750
+ }
2751
+ }
1496
2752
  buildToolContext() {
1497
2753
  const { disabledSkills, disabledPlugins } = this.readDisabledLists();
1498
2754
  return {
2755
+ shellEnv: this.readShellEnv(this.config.cwd),
1499
2756
  cwd: this.config.cwd ?? process.cwd(),
1500
2757
  llmConfig: this.config.llm,
1501
2758
  modelPool: this.modelPool,
1502
2759
  toolRegistry: this.toolRegistry,
1503
2760
  askUser: this.config.askUser,
2761
+ browser: this.config.browserBridge,
2762
+ injectCredentialToBrowser: this.config.injectCredentialToBrowser,
1504
2763
  isSubAgent: this.config.isSubAgent === true,
2764
+ // Credential tools narrow their disk reads to this scope: a project/
2765
+ // isolated engine (SDK-embedded) must not surface the host user's
2766
+ // ~/.code-shell credentials or credentialUse.autoApprove. "full" (the
2767
+ // host-application default) merges user + project as before.
2768
+ settingsScope: this.config.settingsScope ?? "project",
1505
2769
  hooks: this.hooks,
1506
2770
  planMode: this.planMode,
2771
+ permissionMode: this.permissionMode,
1507
2772
  engine: this,
1508
2773
  disabledSkills,
1509
2774
  disabledPlugins,
2775
+ skillAllowlist: this.config.skillAllowlist,
2776
+ backgroundShells: backgroundShellManager,
2777
+ // Sub-agents never start background shells (they're short-lived and
2778
+ // their lifecycle ends with the parent turn); unattended automation
2779
+ // opts out via config. Otherwise allowed.
2780
+ allowBackgroundShells: this.config.isSubAgent === true ? false : this.config.allowBackgroundShells !== false,
1510
2781
  };
1511
2782
  }
1512
2783
  /**
@@ -1522,17 +2793,97 @@ export class Engine {
1522
2793
  */
1523
2794
  readDisabledLists() {
1524
2795
  if (this.config.isSubAgent === true) {
1525
- return { disabledSkills: [], disabledPlugins: [] };
2796
+ return { disabledSkills: [], disabledPlugins: [], disabledPluginHooks: [] };
1526
2797
  }
2798
+ // Shared folding (capability-control/disabled-lists.ts): project
2799
+ // capabilityOverrides over the global baseline + the no-repo whitelist
2800
+ // inversion. Extracted so the MCP merge consumers (engineFactory /
2801
+ // diskDefaultsFrom) fold identically — see that module's doc.
2802
+ return computeEffectiveDisabledLists(this.getSettingsManager(), this.config.cwd);
2803
+ }
2804
+ /**
2805
+ * Public view of the folded disabled lists, for hosts that need the
2806
+ * EFFECTIVE state (e.g. the protocol server's settings hot-reload rebuilds
2807
+ * the plugin-MCP merge per session — a project-level "on" must override the
2808
+ * global disabledPlugins there too).
2809
+ */
2810
+ getEffectiveDisabledLists() {
2811
+ return this.readDisabledLists();
2812
+ }
2813
+ /**
2814
+ * Public: resolve every known feature flag to its effective boolean (the
2815
+ * settings overlay merged over the compiled-in defaults). Used by the
2816
+ * `config` protocol query so the `/features` command can list flag state.
2817
+ */
2818
+ getFeatureFlags() {
2819
+ return resolveFeatureFlags(this.readFeatureFlags());
2820
+ }
2821
+ /**
2822
+ * Read the merged `settings.featureFlags` overlay for this cwd. Project
2823
+ * settings override user settings via the normal SettingsManager merge.
2824
+ * Returns undefined (→ all defaults) on any read error or for sub-agents,
2825
+ * so a flag check never throws and a child runs with default behavior.
2826
+ */
2827
+ readFeatureFlags() {
2828
+ if (this.config.isSubAgent === true)
2829
+ return undefined;
1527
2830
  try {
1528
2831
  const settings = this.getSettingsManager().get();
1529
- return {
1530
- disabledSkills: settings.disabledSkills ?? [],
1531
- disabledPlugins: settings.disabledPlugins ?? [],
1532
- };
2832
+ return settings.featureFlags;
2833
+ }
2834
+ catch {
2835
+ return undefined;
2836
+ }
2837
+ }
2838
+ /**
2839
+ * Read settings.memories ({ maxCount, maxAge, extractionModel, autoExtract }).
2840
+ * Returns undefined on any error or when absent, so the memory pipeline
2841
+ * falls back to its built-in defaults.
2842
+ */
2843
+ readMemoriesConfig() {
2844
+ try {
2845
+ const settings = this.getSettingsManager().get();
2846
+ return settings.memories;
1533
2847
  }
1534
2848
  catch {
1535
- return { disabledSkills: [], disabledPlugins: [] };
2849
+ return undefined;
2850
+ }
2851
+ }
2852
+ /**
2853
+ * LLM client for memory extraction (TODO 8.1). Prefers
2854
+ * settings.memories.extractionModel when it names a valid pool model;
2855
+ * otherwise falls back to the aux client (which itself falls back to the
2856
+ * passed primary). Build failures fall back too — extraction is best-effort.
2857
+ */
2858
+ async resolveExtractionClient(primaryClient) {
2859
+ const key = this.readMemoriesConfig()?.extractionModel;
2860
+ if (key) {
2861
+ const entry = this.modelPool.get(key);
2862
+ if (entry) {
2863
+ try {
2864
+ return await createLLMClient(this.modelPool.toLLMConfig(entry), this.config.clientDefaults);
2865
+ }
2866
+ catch (err) {
2867
+ logger.warn("engine.extraction_model_build_failed", {
2868
+ extractionModel: key,
2869
+ error: err.message,
2870
+ });
2871
+ }
2872
+ }
2873
+ else {
2874
+ logger.warn("engine.extraction_model_missing", { extractionModel: key });
2875
+ }
1536
2876
  }
2877
+ return this.resolveAuxClient(primaryClient);
1537
2878
  }
1538
2879
  }
2880
+ /**
2881
+ * Builtin tool name → the feature flag that gates its visibility. A tool here
2882
+ * is hidden from the LLM when its flag resolves to false. Tools not listed are
2883
+ * unaffected. Kept beside the engine (not in builtin/index) because the flag
2884
+ * read needs the engine's scoped SettingsManager.
2885
+ */
2886
+ const TOOL_FEATURE_FLAGS = new Map([
2887
+ ["WebSearch", "web_search"],
2888
+ ["Bash", "shell_tool"],
2889
+ ]);